From e832b7f026e05c3053bf13929f3afe4e576a4e0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Chalet?= Date: Fri, 21 Jul 2023 15:45:41 +0200 Subject: [PATCH] [Prototype] Update the OpenID module to use the OpenIddict client --- .../OpenIdClientConfiguration.cs | 115 ++++++++---- .../Controllers/CallbackController.cs | 171 ++++++++++++++++++ ...penIdClientCustomParametersEventHandler.cs | 31 ++++ .../OrchardCore.OpenId.csproj | 1 - .../OrchardCore.OpenId/Startup.cs | 73 +++++++- .../Views/{Access => Shared}/Error.cshtml | 0 6 files changed, 343 insertions(+), 48 deletions(-) create mode 100644 src/OrchardCore.Modules/OrchardCore.OpenId/Controllers/CallbackController.cs create mode 100644 src/OrchardCore.Modules/OrchardCore.OpenId/Handlers/OpenIdClientCustomParametersEventHandler.cs rename src/OrchardCore.Modules/OrchardCore.OpenId/Views/{Access => Shared}/Error.cshtml (100%) diff --git a/src/OrchardCore.Modules/OrchardCore.OpenId/Configuration/OpenIdClientConfiguration.cs b/src/OrchardCore.Modules/OrchardCore.OpenId/Configuration/OpenIdClientConfiguration.cs index 396b1a3172b0..dc61403d6319 100644 --- a/src/OrchardCore.Modules/OrchardCore.OpenId/Configuration/OpenIdClientConfiguration.cs +++ b/src/OrchardCore.Modules/OrchardCore.OpenId/Configuration/OpenIdClientConfiguration.cs @@ -2,12 +2,16 @@ using System.ComponentModel.DataAnnotations; using System.Diagnostics; using System.Linq; +using System.Security.Cryptography; using System.Threading.Tasks; using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Authentication.OpenIdConnect; using Microsoft.AspNetCore.DataProtection; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; +using OpenIddict.Client; +using OpenIddict.Client.AspNetCore; using OrchardCore.Environment.Shell; using OrchardCore.Modules; using OrchardCore.OpenId.Services; @@ -16,23 +20,26 @@ namespace OrchardCore.OpenId.Configuration { [Feature(OpenIdConstants.Features.Client)] - public class OpenIdClientConfiguration : - IConfigureOptions, - IConfigureNamedOptions + public class OpenIdClientConfiguration : IConfigureOptions, + IConfigureOptions, + IConfigureNamedOptions { private readonly IOpenIdClientService _clientService; private readonly IDataProtectionProvider _dataProtectionProvider; + private readonly IServiceProvider _serviceProvider; private readonly ShellSettings _shellSettings; private readonly ILogger _logger; public OpenIdClientConfiguration( IOpenIdClientService clientService, IDataProtectionProvider dataProtectionProvider, + IServiceProvider serviceProvider, ShellSettings shellSettings, ILogger logger) { _clientService = clientService; _dataProtectionProvider = dataProtectionProvider; + _serviceProvider = serviceProvider; _shellSettings = shellSettings; _logger = logger; } @@ -45,42 +52,53 @@ public void Configure(AuthenticationOptions options) return; } - // Register the OpenID Connect client handler in the authentication handlers collection. - options.AddScheme(OpenIdConnectDefaults.AuthenticationScheme, settings.DisplayName); - } + options.AddScheme( + OpenIddictClientAspNetCoreDefaults.AuthenticationScheme, displayName: null); - public void Configure(string name, OpenIdConnectOptions options) - { - // Ignore OpenID Connect client handler instances that don't correspond to the instance managed by the OpenID module. - if (!string.Equals(name, OpenIdConnectDefaults.AuthenticationScheme)) + foreach (var scheme in _serviceProvider.GetRequiredService>() + .CurrentValue.ForwardedAuthenticationSchemes) { - return; + options.AddScheme(scheme.Name, scheme.DisplayName); } + } + public void Configure(OpenIddictClientOptions options) + { var settings = GetClientSettingsAsync().GetAwaiter().GetResult(); if (settings == null) { return; } - options.Authority = settings.Authority.AbsoluteUri; - options.ClientId = settings.ClientId; - options.SignedOutRedirectUri = settings.SignedOutRedirectUri ?? options.SignedOutRedirectUri; - options.SignedOutCallbackPath = settings.SignedOutCallbackPath ?? options.SignedOutCallbackPath; - options.RequireHttpsMetadata = string.Equals(settings.Authority.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase); - options.GetClaimsFromUserInfoEndpoint = true; - options.ResponseMode = settings.ResponseMode; - options.ResponseType = settings.ResponseType; - options.SaveTokens = settings.StoreExternalTokens; + // Note: the provider name, redirect URI and post-logout redirect URI use the same default + // values as the Microsoft ASP.NET Core OpenID Connect handler, for compatibility reasons. + var registration = new OpenIddictClientRegistration + { + Issuer = settings.Authority, + ClientId = settings.ClientId, + RedirectUri = new Uri(settings.CallbackPath ?? "signin-oidc", UriKind.RelativeOrAbsolute), + PostLogoutRedirectUri = new Uri(settings.SignedOutCallbackPath ?? "signout-callback-oidc", UriKind.RelativeOrAbsolute), + ProviderName = "OpenIdConnect", + ProviderDisplayName = settings.DisplayName, + Properties = + { + [nameof(OpenIdClientSettings)] = settings + } + }; - options.CallbackPath = settings.CallbackPath ?? options.CallbackPath; + if (!String.IsNullOrEmpty(settings.ResponseMode)) + { + registration.ResponseModes.Add(settings.ResponseMode); + } + + if (!String.IsNullOrEmpty(settings.ResponseType)) + { + registration.ResponseTypes.Add(settings.ResponseType); + } if (settings.Scopes != null) { - foreach (var scope in settings.Scopes) - { - options.Scope.Add(scope); - } + registration.Scopes.UnionWith(settings.Scopes); } if (!string.IsNullOrEmpty(settings.ClientSecret)) @@ -89,7 +107,7 @@ public void Configure(string name, OpenIdConnectOptions options) try { - options.ClientSecret = protector.Unprotect(settings.ClientSecret); + registration.ClientSecret = protector.Unprotect(settings.ClientSecret); } catch { @@ -97,22 +115,39 @@ public void Configure(string name, OpenIdConnectOptions options) } } - if (settings.Parameters?.Any() == true) - { - var parameters = settings.Parameters; - options.Events.OnRedirectToIdentityProvider = (context) => - { - foreach (var parameter in parameters) - { - context.ProtocolMessage.SetParameter(parameter.Name, parameter.Value); - } + options.Registrations.Add(registration); - return Task.CompletedTask; - }; - } + // Note: claims are mapped by CallbackController, so the built-in mapping feature is unnecessary. + options.DisableWebServicesFederationClaimMapping = true; + + // TODO: use proper encryption/signing credentials, similar to what's used for the server feature. + options.EncryptionCredentials.Add(new EncryptingCredentials(new SymmetricSecurityKey( + RandomNumberGenerator.GetBytes(256 / 8)), SecurityAlgorithms.Aes256KW, SecurityAlgorithms.Aes256CbcHmacSha512)); + + options.SigningCredentials.Add(new SigningCredentials(new SymmetricSecurityKey( + RandomNumberGenerator.GetBytes(256 / 8)), SecurityAlgorithms.HmacSha256)); + } + + public void Configure(string name, OpenIddictClientAspNetCoreOptions options) + { + // Note: the OpenID module handles the redirection requests in its dedicated + // ASP.NET Core MVC controller, which requires enabling the pass-through mode. + options.EnableRedirectionEndpointPassthrough = true; + options.EnablePostLogoutRedirectionEndpointPassthrough = true; + + // Note: error pass-through is enabled to allow the actions of the MVC callback controller + // to handle the errors returned by the interactive endpoints without relying on the generic + // status code pages middleware to rewrite the response later in the request processing. + options.EnableErrorPassthrough = true; + + // Note: in Orchard, transport security is usually configured via the dedicated HTTPS module. + // To make configuration easier and avoid having to configure it in two different features, + // the transport security requirement enforced by OpenIddict by default is always turned off. + options.DisableTransportSecurityRequirement = true; } - public void Configure(OpenIdConnectOptions options) => Debug.Fail("This infrastructure method shouldn't be called."); + public void Configure(OpenIddictClientAspNetCoreOptions options) + => Debug.Fail("This infrastructure method shouldn't be called."); private async Task GetClientSettingsAsync() { diff --git a/src/OrchardCore.Modules/OrchardCore.OpenId/Controllers/CallbackController.cs b/src/OrchardCore.Modules/OrchardCore.OpenId/Controllers/CallbackController.cs new file mode 100644 index 000000000000..fc8162b88a50 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.OpenId/Controllers/CallbackController.cs @@ -0,0 +1,171 @@ +using System; +using System.Collections.Generic; +using System.IdentityModel.Tokens.Jwt; +using System.Linq; +using System.Security.Claims; +using System.Threading.Tasks; +using Microsoft.AspNetCore; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using OpenIddict.Client; +using OpenIddict.Client.AspNetCore; +using OrchardCore.Modules; +using OrchardCore.OpenId.Settings; +using OrchardCore.OpenId.ViewModels; +using static OpenIddict.Abstractions.OpenIddictConstants; +using static OpenIddict.Client.AspNetCore.OpenIddictClientAspNetCoreConstants; + +namespace OrchardCore.OpenId.Controllers; + +[AllowAnonymous, Feature(OpenIdConstants.Features.Client)] +public class CallbackController : Controller +{ + private readonly OpenIddictClientService _service; + + public CallbackController(OpenIddictClientService service) + => _service = service; + + [IgnoreAntiforgeryToken] + public async Task LogInCallback() + { + var response = HttpContext.GetOpenIddictClientResponse(); + if (response != null) + { + return View("Error", new ErrorViewModel + { + Error = response.Error, + ErrorDescription = response.ErrorDescription + }); + } + + var request = HttpContext.GetOpenIddictClientRequest(); + if (request == null) + { + return NotFound(); + } + + // Retrieve the authorization data validated by OpenIddict as part of the callback handling. + var result = await HttpContext.AuthenticateAsync(OpenIddictClientAspNetCoreDefaults.AuthenticationScheme); + + // Important: if the remote server doesn't support OpenID Connect and doesn't expose a userinfo endpoint, + // result.Principal.Identity will represent an unauthenticated identity and won't contain any claim. + // + // Such identities cannot be used as-is to build an authentication cookie in ASP.NET Core, as the + // antiforgery stack requires at least a name claim to bind CSRF cookies to the user's identity. + if (result.Principal.Identity is not ClaimsIdentity { IsAuthenticated: true }) + { + throw new InvalidOperationException("The external authorization data cannot be used for authentication."); + } + + // Build an identity based on the external claims and that will be used to create the authentication cookie. + // + // Note: for compatibility reasons, the claims are mapped to their WS-Federation equivalent + // using the default mapping provided by JwtSecurityTokenHandler.DefaultInboundClaimTypeMap. + var claims = result.Principal.Claims.Select(claim => + JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.TryGetValue(claim.Type, out var type) ? + new Claim(type, claim.Value, claim.ValueType, claim.Issuer, claim.OriginalIssuer, claim.Subject) : claim); + + var identity = new ClaimsIdentity(claims, + authenticationType: CookieAuthenticationDefaults.AuthenticationScheme, + nameType: ClaimTypes.Name, + roleType: ClaimTypes.Role); + + // Build the authentication properties based on the properties that were added when the challenge was triggered. + var properties = new AuthenticationProperties(result.Properties.Items) + { + RedirectUri = result.Properties.RedirectUri ?? "/" + }; + + // If enabled, preserve the received tokens in the authentication cookie. + // + // Note: for compatibility reasons, the tokens are stored using the same + // names as the Microsoft ASP.NET Core OIDC client: when both a frontchannel + // and a backchannel token exist, the backchannel one is always preferred. + var registration = await _service.GetClientRegistrationByIdAsync(result.Principal.FindFirstValue(Claims.Private.RegistrationId)); + if (registration.Properties.TryGetValue(nameof(OpenIdClientSettings), out var settings) && + settings is OpenIdClientSettings { StoreExternalTokens: true }) + { + var tokens = new List(); + + if (!String.IsNullOrEmpty(result.Properties.GetTokenValue(Tokens.BackchannelAccessToken)) || + !String.IsNullOrEmpty(result.Properties.GetTokenValue(Tokens.FrontchannelAccessToken))) + { + tokens.Add(new AuthenticationToken + { + Name = Parameters.AccessToken, + Value = result.Properties.GetTokenValue(Tokens.BackchannelAccessToken) ?? + result.Properties.GetTokenValue(Tokens.FrontchannelAccessToken) + }); + } + + if (!String.IsNullOrEmpty(result.Properties.GetTokenValue(Tokens.BackchannelAccessTokenExpirationDate)) || + !String.IsNullOrEmpty(result.Properties.GetTokenValue(Tokens.FrontchannelAccessTokenExpirationDate))) + { + tokens.Add(new AuthenticationToken + { + Name = "expires_at", + Value = result.Properties.GetTokenValue(Tokens.BackchannelAccessTokenExpirationDate) ?? + result.Properties.GetTokenValue(Tokens.FrontchannelAccessTokenExpirationDate) + }); + } + + if (!String.IsNullOrEmpty(result.Properties.GetTokenValue(Tokens.BackchannelIdentityToken)) || + !String.IsNullOrEmpty(result.Properties.GetTokenValue(Tokens.FrontchannelIdentityToken))) + { + tokens.Add(new AuthenticationToken + { + Name = Parameters.IdToken, + Value = result.Properties.GetTokenValue(Tokens.BackchannelIdentityToken) ?? + result.Properties.GetTokenValue(Tokens.FrontchannelIdentityToken) + }); + } + + if (!String.IsNullOrEmpty(result.Properties.GetTokenValue(Tokens.RefreshToken))) + { + tokens.Add(new AuthenticationToken + { + Name = Parameters.RefreshToken, + Value = result.Properties.GetTokenValue(Tokens.RefreshToken) + }); + } + + properties.StoreTokens(tokens); + } + + else + { + properties.StoreTokens(Enumerable.Empty()); + } + + // Ask the cookie authentication handler to return a new cookie and redirect + // the user agent to the return URL stored in the authentication properties. + return SignIn(new ClaimsPrincipal(identity), properties); + } + + [IgnoreAntiforgeryToken] + public async Task LogOutCallback() + { + var response = HttpContext.GetOpenIddictClientResponse(); + if (response != null) + { + return View("Error", new ErrorViewModel + { + Error = response.Error, + ErrorDescription = response.ErrorDescription + }); + } + + var request = HttpContext.GetOpenIddictClientRequest(); + if (request == null) + { + return NotFound(); + } + + // Retrieve the data stored by OpenIddict in the state token created when the logout was triggered + // and redirect the user agent to the specified return URL (or to the home page if none was set). + var result = await HttpContext.AuthenticateAsync(OpenIddictClientAspNetCoreDefaults.AuthenticationScheme); + return Redirect(result!.Properties!.RedirectUri ?? "/"); + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.OpenId/Handlers/OpenIdClientCustomParametersEventHandler.cs b/src/OrchardCore.Modules/OrchardCore.OpenId/Handlers/OpenIdClientCustomParametersEventHandler.cs new file mode 100644 index 000000000000..91e254aeee1c --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.OpenId/Handlers/OpenIdClientCustomParametersEventHandler.cs @@ -0,0 +1,31 @@ +using System.Threading.Tasks; +using OpenIddict.Client; +using OrchardCore.OpenId.Settings; +using static OpenIddict.Client.OpenIddictClientEvents; + +namespace OrchardCore.OpenId.Services.Handlers; + +public class OpenIdClientCustomParametersEventHandler : IOpenIddictClientHandler +{ + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .UseSingletonHandler() + .SetOrder(OpenIddictClientHandlers.AttachCustomChallengeParameters.Descriptor.Order - 1) + .SetType(OpenIddictClientHandlerType.BuiltIn) + .Build(); + + public ValueTask HandleAsync(ProcessChallengeContext context) + { + // If the client registration is managed by Orchard, attach the custom parameters set by the user. + if (context.Registration.Properties.TryGetValue(nameof(OpenIdClientSettings), out var value) && + value is OpenIdClientSettings settings && settings.Parameters is { Length: > 0 } parameters) + { + foreach (var parameter in parameters) + { + context.Parameters[parameter.Name] = parameter.Value; + } + } + + return default; + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.OpenId/OrchardCore.OpenId.csproj b/src/OrchardCore.Modules/OrchardCore.OpenId/OrchardCore.OpenId.csproj index 5188bc74006a..f35a651825f3 100644 --- a/src/OrchardCore.Modules/OrchardCore.OpenId/OrchardCore.OpenId.csproj +++ b/src/OrchardCore.Modules/OrchardCore.OpenId/OrchardCore.OpenId.csproj @@ -29,7 +29,6 @@ - diff --git a/src/OrchardCore.Modules/OrchardCore.OpenId/Startup.cs b/src/OrchardCore.Modules/OrchardCore.OpenId/Startup.cs index 9154697ed558..83de2334b779 100644 --- a/src/OrchardCore.Modules/OrchardCore.OpenId/Startup.cs +++ b/src/OrchardCore.Modules/OrchardCore.OpenId/Startup.cs @@ -4,12 +4,13 @@ using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Authentication.OpenIdConnect; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Options; +using OpenIddict.Client; +using OpenIddict.Client.AspNetCore; using OpenIddict.Server; using OpenIddict.Server.AspNetCore; using OpenIddict.Server.DataProtection; @@ -69,6 +70,21 @@ public class ClientStartup : StartupBase { public override void ConfigureServices(IServiceCollection services) { + services.AddOpenIddict() + .AddClient(options => + { + options.UseAspNetCore(); + options.UseSystemNetHttp(); + + // TODO: determine what flows we want to enable and whether this + // should be configurable by the user (like the server feature). + options.AllowAuthorizationCodeFlow() + .AllowHybridFlow() + .AllowImplicitFlow(); + + options.AddEventHandler(OpenIdClientCustomParametersEventHandler.Descriptor); + }); + services.TryAddSingleton(); // Note: the following services are registered using TryAddEnumerable to prevent duplicate registrations. @@ -78,17 +94,60 @@ public override void ConfigureServices(IServiceCollection services) ServiceDescriptor.Scoped() }); - // Register the options initializers required by the OpenID Connect client handler. + // Note: the OpenIddict ASP.NET host adds an authentication options initializer that takes care of + // registering the client ASP.NET Core handler. Yet, it MUST NOT be registered at this stage + // as it is lazily registered by OpenIdClientConfiguration only after checking the OpenID client + // settings are valid and can be safely used in this tenant without causing runtime exceptions. + // To prevent that, the initializer is manually removed from the services collection of the tenant. + services.RemoveAll, OpenIddictClientAspNetCoreConfiguration>(); + services.TryAddEnumerable(new[] { - // Orchard-specific initializers: ServiceDescriptor.Singleton, OpenIdClientConfiguration>(), - ServiceDescriptor.Singleton, OpenIdClientConfiguration>(), - - // Built-in initializers: - ServiceDescriptor.Singleton, OpenIdConnectPostConfigureOptions>() + ServiceDescriptor.Singleton, OpenIdClientConfiguration>(), + ServiceDescriptor.Singleton, OpenIdClientConfiguration>() }); } + + public override void Configure(IApplicationBuilder app, IEndpointRouteBuilder routes, IServiceProvider serviceProvider) + { + var settings = GetClientSettingsAsync().GetAwaiter().GetResult(); + if (settings == null) + { + return; + } + + // Note: the redirection and post-logout redirection endpoints use the same default values + // as the Microsoft ASP.NET Core OpenID Connect handler, for compatibility reasons. + routes.MapAreaControllerRoute( + name: "Callback.LogInCallback", + areaName: typeof(Startup).Namespace, + pattern: settings.CallbackPath ?? "signin-oidc", + defaults: new { controller = "Callback", action = "LogInCallback" } + ); + + routes.MapAreaControllerRoute( + name: "Callback.LogOutCallback", + areaName: typeof(Startup).Namespace, + pattern: settings.SignedOutCallbackPath ?? "signout-callback-oidc", + defaults: new { controller = "Callback", action = "LogOutCallback" } + ); + + async Task GetClientSettingsAsync() + { + // Note: the OpenID client service is registered as a singleton service and thus can be + // safely used with the non-scoped/root service provider available at this stage. + var service = serviceProvider.GetRequiredService(); + + var configuration = await service.GetSettingsAsync(); + if ((await service.ValidateSettingsAsync(configuration)).Any(result => result != ValidationResult.Success)) + { + return null; + } + + return configuration; + } + } } [Feature(OpenIdConstants.Features.Management)] diff --git a/src/OrchardCore.Modules/OrchardCore.OpenId/Views/Access/Error.cshtml b/src/OrchardCore.Modules/OrchardCore.OpenId/Views/Shared/Error.cshtml similarity index 100% rename from src/OrchardCore.Modules/OrchardCore.OpenId/Views/Access/Error.cshtml rename to src/OrchardCore.Modules/OrchardCore.OpenId/Views/Shared/Error.cshtml