From 2aa4c45794388f7d73f9a0e050d3ce9f423f98d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Chalet?= Date: Mon, 13 Jan 2020 20:38:42 +0100 Subject: [PATCH] Update the server sample to use the authorization manager --- .../Controllers/AuthorizationController.cs | 196 +++++++++++++++++- samples/Mvc.Server/Startup.cs | 4 +- .../OpenIddictConstants.cs | 1 + .../Primitives/OpenIddictExtensions.cs | 19 ++ .../Primitives/OpenIddictExtensionsTests.cs | 36 ++++ 5 files changed, 250 insertions(+), 6 deletions(-) diff --git a/samples/Mvc.Server/Controllers/AuthorizationController.cs b/samples/Mvc.Server/Controllers/AuthorizationController.cs index ff892398..2c7b66c0 100644 --- a/samples/Mvc.Server/Controllers/AuthorizationController.cs +++ b/samples/Mvc.Server/Controllers/AuthorizationController.cs @@ -6,6 +6,8 @@ using System; using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; using System.Security.Claims; using System.Threading.Tasks; using Microsoft.AspNetCore; @@ -14,6 +16,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Primitives; using Mvc.Server.Helpers; using Mvc.Server.Models; using Mvc.Server.ViewModels.Authorization; @@ -28,15 +31,18 @@ namespace Mvc.Server public class AuthorizationController : Controller { private readonly OpenIddictApplicationManager _applicationManager; + private readonly OpenIddictAuthorizationManager _authorizationManager; private readonly SignInManager _signInManager; private readonly UserManager _userManager; public AuthorizationController( OpenIddictApplicationManager applicationManager, + OpenIddictAuthorizationManager authorizationManager, SignInManager signInManager, UserManager userManager) { _applicationManager = applicationManager; + _authorizationManager = authorizationManager; _signInManager = signInManager; _userManager = userManager; } @@ -45,21 +51,160 @@ namespace Mvc.Server // Note: to support interactive flows like the code flow, // you must provide your own authorization endpoint action: - [Authorize, HttpGet("~/connect/authorize")] + [HttpGet("~/connect/authorize")] + [HttpPost("~/connect/authorize")] + [IgnoreAntiforgeryToken] public async Task Authorize() { var request = HttpContext.GetOpenIddictServerRequest() ?? throw new InvalidOperationException("The OpenID Connect request cannot be retrieved."); + // Retrieve the user principal stored in the authentication cookie. + // If it can't be extracted, redirect the user to the login page. + var result = await HttpContext.AuthenticateAsync(); + if (result == null || !result.Succeeded) + { + // If the client application requested promptless authentication, + // return an error indicating that the user is not logged in. + if (request.HasPrompt(Prompts.None)) + { + return Forbid(new AuthenticationProperties(new Dictionary + { + [OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.LoginRequired, + [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "The user is not logged in." + }), OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + } + + return Challenge(new AuthenticationProperties + { + RedirectUri = Request.PathBase + Request.Path + QueryString.Create( + Request.HasFormContentType ? Request.Form.ToList() : Request.Query.ToList()) + }); + } + + // If prompt=login was specified by the client application, + // immediately return the user agent to the login page. + if (request.HasPrompt(Prompts.Login)) + { + // To avoid endless login -> authorization redirects, the prompt=login flag + // is removed from the authorization request payload before redirecting the user. + var prompt = string.Join(" ", request.GetPrompts().Remove(Prompts.Login)); + + var parameters = Request.HasFormContentType ? + Request.Form.Where(parameter => parameter.Key != Parameters.Prompt).ToList() : + Request.Query.Where(parameter => parameter.Key != Parameters.Prompt).ToList(); + + parameters.Add(KeyValuePair.Create(Parameters.Prompt, new StringValues(prompt))); + + return Challenge(new AuthenticationProperties + { + RedirectUri = Request.PathBase + Request.Path + QueryString.Create(parameters) + }); + } + + // If a max_age parameter was provided, ensure that the cookie is not too old. + // If it's too old, automatically redirect the user agent to the login page. + if (request.MaxAge != null && result.Properties.IssuedUtc != null && + DateTimeOffset.UtcNow - result.Properties.IssuedUtc > TimeSpan.FromSeconds(request.MaxAge.Value)) + { + if (request.HasPrompt(Prompts.None)) + { + return Forbid(new AuthenticationProperties(new Dictionary + { + [OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.LoginRequired, + [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "The user is not logged in." + }), OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + } + + return Challenge(new AuthenticationProperties + { + RedirectUri = Request.PathBase + Request.Path + QueryString.Create( + Request.HasFormContentType ? Request.Form.ToList() : Request.Query.ToList()) + }); + } + + // Retrieve the profile of the logged in user. + var user = await _userManager.GetUserAsync(User) ?? + throw new InvalidOperationException("The user details cannot be retrieved."); + // Retrieve the application details from the database. var application = await _applicationManager.FindByClientIdAsync(request.ClientId) ?? throw new InvalidOperationException("Details concerning the calling client application cannot be found."); - return View(new AuthorizeViewModel + // Retrieve the permanent authorizations associated with the user and the calling client application. + var authorizations = await _authorizationManager.FindAsync( + subject: User.FindFirst(Claims.Subject)?.Value, + client : await _applicationManager.GetIdAsync(application), + status : Statuses.Valid, + type : AuthorizationTypes.Permanent, + scopes : ImmutableArray.CreateRange(request.GetScopes())).ToListAsync(); + + switch (await _applicationManager.GetConsentTypeAsync(application)) { - ApplicationName = await _applicationManager.GetDisplayNameAsync(application), - Scope = request.Scope - }); + // If the consent is external (e.g when authorizations are granted by a sysadmin), + // immediately return an error if no authorization can be found in the database. + case ConsentTypes.External when !authorizations.Any(): + return Forbid(new AuthenticationProperties(new Dictionary + { + [OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.ConsentRequired, + [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = + "The logged in user is not allowed to access this client application." + }), OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + + // If the consent is implicit or if an authorization was found, + // return an authorization response without displaying the consent form. + case ConsentTypes.Implicit: + case ConsentTypes.External when authorizations.Any(): + case ConsentTypes.Explicit when authorizations.Any() && !request.HasPrompt(Prompts.Consent): + var principal = await _signInManager.CreateUserPrincipalAsync(user); + + // Note: in this sample, the granted scopes match the requested scope + // but you may want to allow the user to uncheck specific scopes. + // For that, simply restrict the list of scopes before calling SetScopes. + principal.SetScopes(request.GetScopes()); + principal.SetResources("resource_server"); + + // Automatically create a permanent authorization to avoid requiring explicit consent + // for future authorization or token requests containing the same scopes. + var authorization = authorizations.LastOrDefault(); + if (authorization == null) + { + authorization = await _authorizationManager.CreateAsync( + principal: principal, + subject : principal.FindFirst(Claims.Subject)?.Value, + client : await _applicationManager.GetIdAsync(application), + type : AuthorizationTypes.Permanent, + scopes : ImmutableArray.CreateRange(principal.GetScopes())); + } + + principal.SetInternalAuthorizationId(await _authorizationManager.GetIdAsync(authorization)); + + foreach (var claim in principal.Claims) + { + claim.SetDestinations(GetDestinations(claim, principal)); + } + + return SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + + // At this point, no authorization was found in the database and an error must be returned + // if the client application specified prompt=none in the authorization request. + case ConsentTypes.Explicit when request.HasPrompt(Prompts.None): + case ConsentTypes.Systematic when request.HasPrompt(Prompts.None): + return Forbid(new AuthenticationProperties(new Dictionary + { + [OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.ConsentRequired, + [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = + "Interactive user consent is required.", + }), OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + + // In every other case, render the consent form. + default: + return View(new AuthorizeViewModel + { + ApplicationName = await _applicationManager.GetDisplayNameAsync(application), + Scope = request.Scope + }); + } } [Authorize, FormValueRequired("submit.Accept")] @@ -73,6 +218,32 @@ namespace Mvc.Server var user = await _userManager.GetUserAsync(User) ?? throw new InvalidOperationException("The user details cannot be retrieved."); + // Retrieve the application details from the database. + var application = await _applicationManager.FindByClientIdAsync(request.ClientId) ?? + throw new InvalidOperationException("Details concerning the calling client application cannot be found."); + + // Retrieve the permanent authorizations associated with the user and the calling client application. + var authorizations = await _authorizationManager.FindAsync( + subject: User.FindFirst(Claims.Subject)?.Value, + client : await _applicationManager.GetIdAsync(application), + status : Statuses.Valid, + type : AuthorizationTypes.Permanent, + scopes : ImmutableArray.CreateRange(request.GetScopes())).ToListAsync(); + + // Note: the same check is already made in the other action but is repeated + // here to ensure a malicious user can't abuse this POST-only endpoint and + // force it to return a valid response without the external authorization. + switch (await _applicationManager.GetConsentTypeAsync(application)) + { + case ConsentTypes.External when !authorizations.Any(): + return Forbid(new AuthenticationProperties(new Dictionary + { + [OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.ConsentRequired, + [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = + "The logged in user is not allowed to access this client application." + }), OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + } + var principal = await _signInManager.CreateUserPrincipalAsync(user); // Note: in this sample, the granted scopes match the requested scope @@ -81,6 +252,21 @@ namespace Mvc.Server principal.SetScopes(request.GetScopes()); principal.SetResources("resource_server"); + // Automatically create a permanent authorization to avoid requiring explicit consent + // for future authorization or token requests containing the same scopes. + var authorization = authorizations.LastOrDefault(); + if (authorization == null) + { + authorization = await _authorizationManager.CreateAsync( + principal: principal, + subject : principal.FindFirst(Claims.Subject)?.Value, + client : await _applicationManager.GetIdAsync(application), + type : AuthorizationTypes.Permanent, + scopes : ImmutableArray.CreateRange(principal.GetScopes())); + } + + principal.SetInternalAuthorizationId(await _authorizationManager.GetIdAsync(authorization)); + foreach (var claim in principal.Claims) { claim.SetDestinations(GetDestinations(claim, principal)); diff --git a/samples/Mvc.Server/Startup.cs b/samples/Mvc.Server/Startup.cs index c915e075..2214f34f 100644 --- a/samples/Mvc.Server/Startup.cs +++ b/samples/Mvc.Server/Startup.cs @@ -182,6 +182,7 @@ namespace Mvc.Server { ClientId = "mvc", ClientSecret = "901564A5-E7FE-42CB-B10D-61EF6A8F3654", + ConsentType = OpenIddictConstants.ConsentTypes.Explicit, DisplayName = "MVC client application", PostLogoutRedirectUris = { new Uri("http://localhost:53507/signout-callback-oidc") }, RedirectUris = { new Uri("http://localhost:53507/signin-oidc") }, @@ -219,8 +220,9 @@ namespace Mvc.Server var descriptor = new OpenIddictApplicationDescriptor { ClientId = "postman", + ConsentType = OpenIddictConstants.ConsentTypes.Systematic, DisplayName = "Postman", - RedirectUris = { new Uri("https://www.getpostman.com/oauth2/callback") }, + RedirectUris = { new Uri("urn:postman") }, Permissions = { OpenIddictConstants.Permissions.Endpoints.Authorization, diff --git a/src/OpenIddict.Abstractions/OpenIddictConstants.cs b/src/OpenIddict.Abstractions/OpenIddictConstants.cs index 3a2ec4d2..ab4fc57d 100644 --- a/src/OpenIddict.Abstractions/OpenIddictConstants.cs +++ b/src/OpenIddict.Abstractions/OpenIddictConstants.cs @@ -132,6 +132,7 @@ namespace OpenIddict.Abstractions public const string Explicit = "explicit"; public const string External = "external"; public const string Implicit = "implicit"; + public const string Systematic = "systematic"; } public static class Destinations diff --git a/src/OpenIddict.Abstractions/Primitives/OpenIddictExtensions.cs b/src/OpenIddict.Abstractions/Primitives/OpenIddictExtensions.cs index 4832f6a3..9070af2c 100644 --- a/src/OpenIddict.Abstractions/Primitives/OpenIddictExtensions.cs +++ b/src/OpenIddict.Abstractions/Primitives/OpenIddictExtensions.cs @@ -44,6 +44,25 @@ namespace OpenIddict.Abstractions return GetValues(request.AcrValues, Separators.Space).Distinct(StringComparer.Ordinal).ToImmutableArray(); } + /// + /// Extracts the prompt values from an . + /// + /// The instance. + public static ImmutableArray GetPrompts([NotNull] this OpenIddictRequest request) + { + if (request == null) + { + throw new ArgumentNullException(nameof(request)); + } + + if (string.IsNullOrEmpty(request.Prompt)) + { + return ImmutableArray.Create(); + } + + return GetValues(request.Prompt, Separators.Space).Distinct(StringComparer.Ordinal).ToImmutableArray(); + } + /// /// Extracts the response types from an . /// diff --git a/test/OpenIddict.Abstractions.Tests/Primitives/OpenIddictExtensionsTests.cs b/test/OpenIddict.Abstractions.Tests/Primitives/OpenIddictExtensionsTests.cs index acbdcdaa..53958b22 100644 --- a/test/OpenIddict.Abstractions.Tests/Primitives/OpenIddictExtensionsTests.cs +++ b/test/OpenIddict.Abstractions.Tests/Primitives/OpenIddictExtensionsTests.cs @@ -52,6 +52,42 @@ namespace OpenIddict.Abstractions.Tests.Primitives Assert.Equal(values, request.GetAcrValues()); } + [Fact] + public void GetPrompts_ThrowsAnExceptionForNullRequest() + { + // Arrange + var request = (OpenIddictRequest) null; + + // Act + var exception = Assert.Throws(() => request.GetPrompts()); + + // Assert + Assert.Equal("request", exception.ParamName); + } + + [Theory] + [InlineData(null, new string[0])] + [InlineData("login", new[] { "login" })] + [InlineData("login ", new[] { "login" })] + [InlineData(" login ", new[] { "login" })] + [InlineData("login consent", new[] { "login", "consent" })] + [InlineData("login consent", new[] { "login", "consent" })] + [InlineData("login consent ", new[] { "login", "consent" })] + [InlineData(" login consent", new[] { "login", "consent" })] + [InlineData("login login consent", new[] { "login", "consent" })] + [InlineData("login LOGIN consent", new[] { "login", "LOGIN", "consent" })] + public void GetPrompts_ReturnsExpectedPrompts(string value, string[] values) + { + // Arrange + var request = new OpenIddictRequest + { + Prompt = value + }; + + // Act and assert + Assert.Equal(values, request.GetPrompts()); + } + [Fact] public void GetResponseTypes_ThrowsAnExceptionForNullRequest() {