From 1db3779ab37fc5e3e93b1ef4e1cb0e760ce167ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Chalet?= Date: Sun, 31 Jul 2016 12:17:54 +0200 Subject: [PATCH] Allow handling grant_type=password and grant_type=client_credentials requests in user code and introduce OpenIddictMiddleware --- .../Controllers/AuthorizationController.cs | 6 + .../Infrastructure/OpenIddictMiddleware.cs | 180 +++++++++++++++++ .../OpenIddictProvider.Exchange.cs | 186 ++++++------------ src/OpenIddict.Core/OpenIddictExtensions.cs | 24 ++- 4 files changed, 259 insertions(+), 137 deletions(-) create mode 100644 src/OpenIddict.Core/Infrastructure/OpenIddictMiddleware.cs diff --git a/samples/Mvc.Server/Controllers/AuthorizationController.cs b/samples/Mvc.Server/Controllers/AuthorizationController.cs index 7a892c7e..3063437d 100644 --- a/samples/Mvc.Server/Controllers/AuthorizationController.cs +++ b/samples/Mvc.Server/Controllers/AuthorizationController.cs @@ -36,6 +36,9 @@ namespace Mvc.Server { _userManager = userManager; } + // Note: if you don't provide your own authorization action, OpenIddict will + // directly process authorization requests without requiring user consent. + [Authorize, HttpGet, Route("~/connect/authorize")] public async Task Authorize() { // Extract the authorization request from the ASP.NET environment. @@ -97,6 +100,9 @@ namespace Mvc.Server { return Forbid(OpenIdConnectServerDefaults.AuthenticationScheme); } + // Note: if you don't provide your own logout action, OpenIddict will + // directly process logout requests without requiring user confirmation. + [HttpGet("~/connect/logout")] public IActionResult Logout() { // Extract the authorization request from the ASP.NET environment. diff --git a/src/OpenIddict.Core/Infrastructure/OpenIddictMiddleware.cs b/src/OpenIddict.Core/Infrastructure/OpenIddictMiddleware.cs new file mode 100644 index 00000000..aad8aa2d --- /dev/null +++ b/src/OpenIddict.Core/Infrastructure/OpenIddictMiddleware.cs @@ -0,0 +1,180 @@ +using System.Diagnostics; +using System.Linq; +using System.Security.Claims; +using System.Threading.Tasks; +using AspNet.Security.OpenIdConnect.Extensions; +using JetBrains.Annotations; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Authentication; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace OpenIddict.Infrastructure { + public class OpenIddictMiddleware + where TUser : class where TApplication : class + where TAuthorization : class where TScope : class where TToken : class { + private readonly RequestDelegate next; + + public OpenIddictMiddleware([NotNull] RequestDelegate next) { + this.next = next; + } + + public async Task Invoke([NotNull] HttpContext context) { + // Invoke the rest of the pipeline to allow handling + // authorization, logout or token requests in user code. + await next(context); + + // If the request was already handled, skip the default logic. + if (context.Response.HasStarted || context.Response.StatusCode != 404) { + return; + } + + // If the request doesn't correspond to an OpenID Connect request, ignore it. + var request = context.GetOpenIdConnectRequest(); + if (request == null || (!request.IsAuthorizationRequest() && + !request.IsLogoutRequest() && + !request.IsTokenRequest())) { + return; + } + + // If an OpenID Connect response was already prepared, bypass the default logic. + var response = context.GetOpenIdConnectResponse(); + if (response != null) { + return; + } + + // Resolve the OpenIddict services from the scoped container. + var services = context.RequestServices.GetRequiredService>(); + + // Reset the response status code to allow the OpenID Connect server + // middleware to apply a challenge, signin or logout response. + context.Response.StatusCode = 200; + + ClaimsPrincipal principal = null; + + if (request.IsAuthorizationRequest()) { + // If the user is not logged in, return a challenge response. + if (!context.User.Identities.Any(identity => identity.IsAuthenticated)) { + await context.Authentication.ChallengeAsync(); + + return; + } + + // Retrieve the profile of the logged in user. If the user + // cannot be found, return a challenge response. + var user = await services.Users.GetUserAsync(context.User); + if (user == null) { + await context.Authentication.ChallengeAsync(); + + return; + } + + services.Logger.LogInformation("The authorization request was handled without asking for user consent."); + + principal = new ClaimsPrincipal(await services.Users.CreateIdentityAsync(user, request.GetScopes())); + } + + else if (request.IsLogoutRequest()) { + // Ask ASP.NET Core Identity to delete the local and external cookies created + // when the user agent is redirected from the external identity provider + // after a successful authentication flow (e.g Google or Facebook). + await services.SignIn.SignOutAsync(); + + await context.Authentication.SignOutAsync(services.Options.AuthenticationScheme); + + services.Logger.LogInformation("The logout request was handled without asking for user consent."); + + return; + } + + else if (request.IsTokenRequest()) { + Debug.Assert(request.IsClientCredentialsGrantType() || request.IsPasswordGrantType(), + "Only grant_type=client_credentials and grant_type=password requests should be handled here."); + + services.Logger.LogInformation("The token request was automatically handled."); + + if (request.IsClientCredentialsGrantType()) { + // Retrieve the application details corresponding to the requested client_id. + // Note: this call shouldn't return a null instance, but a race condition may occur + // if the application was removed after the initial check made by ValidateTokenRequest. + var application = await services.Applications.FindByClientIdAsync(request.ClientId); + if (application == null) { + services.Logger.LogError("The token request was aborted because the client application " + + "was not found in the database: '{ClientId}'.", request.ClientId); + + await context.Authentication.ForbidAsync(services.Options.AuthenticationScheme); + + return; + } + + var identity = new ClaimsIdentity(services.Options.AuthenticationScheme); + + // Note: the name identifier is always included in both identity and + // access tokens, even if an explicit destination is not specified. + identity.AddClaim(ClaimTypes.NameIdentifier, request.ClientId); + + identity.AddClaim(ClaimTypes.Name, await services.Applications.GetDisplayNameAsync(application), + OpenIdConnectConstants.Destinations.AccessToken, + OpenIdConnectConstants.Destinations.IdentityToken); + + principal = new ClaimsPrincipal(identity); + } + + else if (request.IsPasswordGrantType()) { + // Retrieve the user profile corresponding to the specified username. + var user = await services.Users.FindByNameAsync(request.Username); + if (user == null) { + services.Logger.LogError("The token request was rejected because no user profile corresponding to " + + "the specified username was found: '{Username}'.", request.Username); + + await context.Authentication.ForbidAsync(services.Options.AuthenticationScheme); + + return; + } + + // Ensure the username/password couple is valid. + if (!await services.Users.CheckPasswordAsync(user, request.Password)) { + services.Logger.LogError("The token request was rejected because the password didn't match " + + "the password associated with the account '{Username}'.", request.Username); + + if (services.Users.SupportsUserLockout) { + await services.Users.AccessFailedAsync(user); + + if (await services.Users.IsLockedOutAsync(user)) { + services.Logger.LogError("The token request was rejected because the account '{Username}' " + + "was locked out to prevent brute force attacks.", request.Username); + } + } + + await context.Authentication.ForbidAsync(services.Options.AuthenticationScheme); + + return; + } + + if (services.Users.SupportsUserLockout) { + await services.Users.ResetAccessFailedCountAsync(user); + } + + principal = new ClaimsPrincipal(await services.Users.CreateIdentityAsync(user, request.GetScopes())); + } + } + + // At this stage, don't alter the response + // if a sign-in operation can't be performed. + if (principal != null) { + // Create a new authentication ticket holding the user identity. + var ticket = new AuthenticationTicket( + principal, new AuthenticationProperties(), + services.Options.AuthenticationScheme); + + ticket.SetResources(request.GetResources()); + ticket.SetScopes(request.GetScopes()); + + await context.Authentication.SignInAsync(ticket.AuthenticationScheme, ticket.Principal, ticket.Properties); + } + } + } +} diff --git a/src/OpenIddict.Core/Infrastructure/OpenIddictProvider.Exchange.cs b/src/OpenIddict.Core/Infrastructure/OpenIddictProvider.Exchange.cs index d11a67fc..00aca09e 100644 --- a/src/OpenIddict.Core/Infrastructure/OpenIddictProvider.Exchange.cs +++ b/src/OpenIddict.Core/Infrastructure/OpenIddictProvider.Exchange.cs @@ -338,119 +338,74 @@ namespace OpenIddict.Infrastructure { var user = await services.Users.FindByNameAsync(context.Request.Username); if (user == null) { - services.Logger.LogError("The token request was rejected because no user profile corresponding to " + - "the specified username was found: '{Username}'.", context.Request.Username); - - context.Reject( - error: OpenIdConnectConstants.Errors.InvalidGrant, - description: "Invalid credentials."); - - return; - } - - // Ensure the user is allowed to sign in. - if (!await services.SignIn.CanSignInAsync(user)) { - services.Logger.LogError("The token request was rejected because the user '{Username}' " + - "was not allowed to sign in.", context.Request.Username); - - context.Reject( - error: OpenIdConnectConstants.Errors.InvalidGrant, - description: "The user is not allowed to sign in."); - - return; + services.Logger.LogWarning("The token request was not fully validated because the profile corresponding to the " + + "given username was not found in the database: {Username}.", context.Request.Username); } - // Ensure the user is not already locked out. - if (services.Users.SupportsUserLockout && await services.Users.IsLockedOutAsync(user)) { - services.Logger.LogError("The token request was rejected because the account '{Username}' " + - "was locked out to prevent brute force attacks.", context.Request.Username); - - context.Reject( - error: OpenIdConnectConstants.Errors.InvalidGrant, - description: "Account locked out."); - - return; - } + else { + // Ensure the user is allowed to sign in. + if (!await services.SignIn.CanSignInAsync(user)) { + services.Logger.LogError("The token request was rejected because the user '{Username}' " + + "was not allowed to sign in.", context.Request.Username); - // Ensure the password is valid. - if (!await services.Users.CheckPasswordAsync(user, context.Request.Password)) { - services.Logger.LogError("The token request was rejected because the password didn't match " + - "the password associated with the account '{Username}'.", context.Request.Username); - - context.Reject( - error: OpenIdConnectConstants.Errors.InvalidGrant, - description: "Invalid credentials."); - - if (services.Users.SupportsUserLockout) { - await services.Users.AccessFailedAsync(user); - - // Ensure the user is not locked out. - if (await services.Users.IsLockedOutAsync(user)) { - services.Logger.LogError("The token request was rejected because the account '{Username}' " + - "was locked out to prevent brute force attacks.", context.Request.Username); + context.Reject( + error: OpenIdConnectConstants.Errors.InvalidGrant, + description: "The user is not allowed to sign in."); - context.Reject( - error: OpenIdConnectConstants.Errors.InvalidGrant, - description: "Account locked out."); - } + return; } - return; - } - - if (services.Users.SupportsUserLockout) { - await services.Users.ResetAccessFailedCountAsync(user); - } - - // Reject the token request if two-factor authentication has been enabled by the user. - if (services.Users.SupportsUserTwoFactor && await services.Users.GetTwoFactorEnabledAsync(user)) { - services.Logger.LogError("The token request was rejected because two-factor authentication " + - "was required for the account '{Username}.", context.Request.Username); - - context.Reject( - error: OpenIdConnectConstants.Errors.InvalidGrant, - description: "Two-factor authentication is required for this account."); + // Ensure the user is not already locked out. + if (services.Users.SupportsUserLockout && await services.Users.IsLockedOutAsync(user)) { + services.Logger.LogError("The token request was rejected because the account '{Username}' " + + "was locked out to prevent brute force attacks.", context.Request.Username); - return; - } + context.Reject( + error: OpenIdConnectConstants.Errors.InvalidGrant, + description: "Account locked out."); - // Return an error if the username corresponds to the registered - // email address and if the "email" scope has not been requested. - if (services.Users.SupportsUserEmail && context.Request.HasScope(OpenIdConnectConstants.Scopes.Profile) && - !context.Request.HasScope(OpenIdConnectConstants.Scopes.Email)) { - // Retrieve the username and the email address associated with the user. - var username = await services.Users.GetUserNameAsync(user); - var email = await services.Users.GetEmailAsync(user); + return; + } - if (!string.IsNullOrEmpty(email) && string.Equals(username, email, StringComparison.OrdinalIgnoreCase)) { - services.Logger.LogError("The token request was rejected because the 'email' scope was not requested: " + - "to prevent data leakage, the 'email' scope must be granted when the username " + - "is identical to the email address associated with the user profile."); + // Reject the token request if two-factor authentication has been enabled by the user. + if (services.Users.SupportsUserTwoFactor && await services.Users.GetTwoFactorEnabledAsync(user)) { + services.Logger.LogError("The token request was rejected because two-factor authentication " + + "was required for the account '{Username}.", context.Request.Username); context.Reject( - error: OpenIdConnectConstants.Errors.InvalidRequest, - description: "The 'email' scope is required."); + error: OpenIdConnectConstants.Errors.InvalidGrant, + description: "Two-factor authentication is required for this account."); return; } - } - var identity = await services.Users.CreateIdentityAsync(user, context.Request.GetScopes()); - if (identity == null) { - throw new InvalidOperationException("The token request was aborted because the user manager returned a null " + - $"identity for user '{await services.Users.GetUserNameAsync(user)}'."); - } + // Return an error if the username corresponds to the registered + // email address and if the "email" scope has not been requested. + if (services.Users.SupportsUserEmail && context.Request.HasScope(OpenIdConnectConstants.Scopes.Profile) && + !context.Request.HasScope(OpenIdConnectConstants.Scopes.Email)) { + // Retrieve the username and the email address associated with the user. + var username = await services.Users.GetUserNameAsync(user); + var email = await services.Users.GetEmailAsync(user); - // Create a new authentication ticket holding the user identity. - var ticket = new AuthenticationTicket( - new ClaimsPrincipal(identity), - new AuthenticationProperties(), - context.Options.AuthenticationScheme); + if (!string.IsNullOrEmpty(email) && string.Equals(username, email, StringComparison.OrdinalIgnoreCase)) { + services.Logger.LogError("The token request was rejected because the 'email' scope was not requested: " + + "to prevent data leakage, the 'email' scope must be granted when the username " + + "is identical to the email address associated with the user profile."); - ticket.SetResources(context.Request.GetResources()); - ticket.SetScopes(context.Request.GetScopes()); + context.Reject( + error: OpenIdConnectConstants.Errors.InvalidRequest, + description: "The 'email' scope is required."); - context.Validate(ticket); + return; + } + } + } + + // Call context.SkipToNextMiddleware() to invoke the next middleware in the pipeline. + // This allows handling grant_type=password requests in a custom controller action. + // If the request is not handled in user code, OpenIddictMiddleware will automatically + // create and return a token response using the default authentication logic. + context.SkipToNextMiddleware(); } else if (context.Request.IsClientCredentialsGrantType()) { @@ -459,42 +414,11 @@ namespace OpenIddict.Infrastructure { Debug.Assert(!string.IsNullOrEmpty(context.Request.ClientId) && !string.IsNullOrEmpty(context.Request.ClientSecret), "The client credentials shouldn't be null."); - // Retrieve the application details corresponding to the requested client_id. - // Note: this call shouldn't return a null instance, but a race condition may occur - // if the application was removed after the initial check made by ValidateTokenRequest. - var application = await services.Applications.FindByClientIdAsync(context.Request.ClientId); - if (application == null) { - services.Logger.LogError("The token request was aborted because the client application " + - "was not found in the database: '{ClientId}'.", context.Request.ClientId); - - context.Reject( - error: OpenIdConnectConstants.Errors.InvalidClient, - description: "Application not found in the database: ensure that your client_id is correct."); - - return; - } - - var identity = new ClaimsIdentity(context.Options.AuthenticationScheme); - - // Note: the name identifier is always included in both identity and - // access tokens, even if an explicit destination is not specified. - identity.AddClaim(ClaimTypes.NameIdentifier, context.Request.ClientId); - - identity.AddClaim(ClaimTypes.Name, await services.Applications.GetDisplayNameAsync(application), - OpenIdConnectConstants.Destinations.AccessToken, - OpenIdConnectConstants.Destinations.IdentityToken); - - // Create a new authentication ticket - // holding the application identity. - var ticket = new AuthenticationTicket( - new ClaimsPrincipal(identity), - new AuthenticationProperties(), - context.Options.AuthenticationScheme); - - ticket.SetResources(context.Request.GetResources()); - ticket.SetScopes(context.Request.GetScopes()); - - context.Validate(ticket); + // Call context.SkipToNextMiddleware() to invoke the next middleware in the pipeline. + // This allows handling grant_type=client_credentials requests in a custom controller action. + // If the request is not handled in user code, OpenIddictMiddleware will automatically + // create and return a token response using the default authentication logic. + context.SkipToNextMiddleware(); } } } diff --git a/src/OpenIddict.Core/OpenIddictExtensions.cs b/src/OpenIddict.Core/OpenIddictExtensions.cs index 00b5c240..5a2debfa 100644 --- a/src/OpenIddict.Core/OpenIddictExtensions.cs +++ b/src/OpenIddict.Core/OpenIddictExtensions.cs @@ -7,6 +7,7 @@ using System; using System.Linq; using AspNet.Security.OpenIdConnect.Extensions; +using AspNet.Security.OpenIdConnect.Server; using JetBrains.Annotations; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.DependencyInjection; @@ -63,6 +64,16 @@ namespace Microsoft.AspNetCore.Builder { builder.Configure(options => { // Register the OpenID Connect server provider in the OpenIddict options. options.Provider = new OpenIddictProvider(); + + // Register the OpenID Connect server middleware as a native module. + options.Modules.Add(new OpenIddictModule("OpenID Connect server", 0, app => { + app.UseMiddleware(); + })); + + // Register the OpenIddict middleware as a built-in module. + options.Modules.Add(new OpenIddictModule("OpenIddict", 1, app => { + app.UseMiddleware>(); + })); }); // Register the OpenIddict core services in the DI container. @@ -73,6 +84,12 @@ namespace Microsoft.AspNetCore.Builder { builder.Services.TryAddScoped>(); builder.Services.TryAddScoped>(); + // Override the default options manager for IOptions + // to ensure the OpenID Connect server middleware uses the OpenIddict options. + builder.Services.TryAddScoped>(provider => { + return provider.GetRequiredService>(); + }); + return builder; } @@ -132,13 +149,8 @@ namespace Microsoft.AspNetCore.Builder { "client credentials, password and refresh token flows."); } - // Get the modules registered by the application - // and add the OpenID Connect server middleware. - var modules = options.Modules.ToList(); - modules.Add(new OpenIddictModule("OpenID Connect server", 0, builder => builder.UseOpenIdConnectServer(options))); - // Register the OpenIddict modules in the ASP.NET Core pipeline. - foreach (var module in modules.OrderBy(module => module.Position)) { + foreach (var module in options.Modules.OrderBy(module => module.Position)) { if (module?.Registration == null) { throw new InvalidOperationException("An invalid OpenIddict module was registered."); }