diff --git a/Directory.Packages.props b/Directory.Packages.props index 185268716..ee3d99be6 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -41,6 +41,7 @@ + diff --git a/GrandNode.slnx b/GrandNode.slnx index 6fd7cef09..5de1bb723 100644 --- a/GrandNode.slnx +++ b/GrandNode.slnx @@ -37,6 +37,7 @@ + diff --git a/src/Plugins/Authentication.OpenIdConnect/Areas/Admin/Controllers/OidcLoginSettingsController.cs b/src/Plugins/Authentication.OpenIdConnect/Areas/Admin/Controllers/OidcLoginSettingsController.cs new file mode 100644 index 000000000..c9ac02be0 --- /dev/null +++ b/src/Plugins/Authentication.OpenIdConnect/Areas/Admin/Controllers/OidcLoginSettingsController.cs @@ -0,0 +1,90 @@ +using Authentication.OpenIdConnect.Models; +using Grand.Business.Core.Interfaces.Common.Configuration; +using Grand.Business.Core.Interfaces.Common.Localization; +using Grand.Business.Core.Interfaces.Common.Security; +using Grand.Domain.Permissions; +using Grand.Infrastructure.Configuration; +using Grand.Web.Common.Controllers; +using Grand.Web.Common.Security.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Authentication.OpenIdConnect.Areas.Admin.Controllers; + +[PermissionAuthorize(PermissionSystemName.ExternalAuthenticationMethods)] +public class OidcLoginSettingsController : BaseAdminPluginController +{ + #region Ctor + + public OidcLoginSettingsController( + OidcLoginSettings oidcLoginSettings, + OidcResourceServerConfig resourceServerConfig, + OidcLoginConfig oidcLoginConfig, + ITranslationService translationService, + IPermissionService permissionService, + ISettingService settingService) + { + _oidcLoginSettings = oidcLoginSettings; + _resourceServerConfig = resourceServerConfig; + _oidcLoginConfig = oidcLoginConfig; + _translationService = translationService; + _permissionService = permissionService; + _settingService = settingService; + } + + #endregion + + #region Fields + + private readonly OidcLoginSettings _oidcLoginSettings; + private readonly OidcResourceServerConfig _resourceServerConfig; + private readonly OidcLoginConfig _oidcLoginConfig; + private readonly ITranslationService _translationService; + private readonly IPermissionService _permissionService; + private readonly ISettingService _settingService; + + #endregion + + #region Methods + + public async Task Configure() + { + if (!await _permissionService.Authorize(StandardPermission.ManageExternalAuthenticationMethods)) + return AccessDeniedView(); + + var model = new ConfigurationModel { + Authority = _resourceServerConfig.Authority, + ClientId = _oidcLoginConfig.ClientId, + //never the secret itself + ClientSecret = _translationService.GetResource(string.IsNullOrWhiteSpace(_oidcLoginConfig.ClientSecret) + ? "Plugins.ExternalAuth.OpenIdConnect.ClientSecret.NotSet" + : "Plugins.ExternalAuth.OpenIdConnect.ClientSecret.Set"), + DisplayOrder = _oidcLoginSettings.DisplayOrder + }; + + return View(model); + } + + [HttpPost] + [AutoValidateAntiforgeryToken] + public async Task Configure(ConfigurationModel model) + { + if (!await _permissionService.Authorize(StandardPermission.ManageExternalAuthenticationMethods)) + return AccessDeniedView(); + + if (!ModelState.IsValid) + return await Configure(); + + _oidcLoginSettings.DisplayOrder = model.DisplayOrder; + + await _settingService.SaveSetting(_oidcLoginSettings); + + //now clear settings cache + await _settingService.ClearCache(); + + Success(_translationService.GetResource("Admin.Plugins.Saved")); + + return await Configure(); + } + + #endregion +} diff --git a/src/Plugins/Authentication.OpenIdConnect/Areas/Admin/Views/OidcLoginSettings/Configure.cshtml b/src/Plugins/Authentication.OpenIdConnect/Areas/Admin/Views/OidcLoginSettings/Configure.cshtml new file mode 100644 index 000000000..1a5ba11e1 --- /dev/null +++ b/src/Plugins/Authentication.OpenIdConnect/Areas/Admin/Views/OidcLoginSettings/Configure.cshtml @@ -0,0 +1,35 @@ +@model Authentication.OpenIdConnect.Models.ConfigurationModel +@{ + Layout = "_AdminLayout"; + ViewBag.Title = "OpenID Connect"; +} + + +@* the identity provider and the client come from appsettings and are shown read-only; the secret is never shown *@ +
+ + + + + +
+ @Html.Raw(Loc["Plugins.ExternalAuth.OpenIdConnect.Description"]) +
+ +
+ + + + + + + + + + +
+
+
+
diff --git a/src/Plugins/Authentication.OpenIdConnect/Areas/Admin/_ViewImports.cshtml b/src/Plugins/Authentication.OpenIdConnect/Areas/Admin/_ViewImports.cshtml new file mode 100644 index 000000000..fb3b84aa9 --- /dev/null +++ b/src/Plugins/Authentication.OpenIdConnect/Areas/Admin/_ViewImports.cshtml @@ -0,0 +1,9 @@ +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers +@addTagHelper *, Grand.Web.Common +@addTagHelper *, Grand.Web + +@using Microsoft.AspNetCore.Mvc.ViewFeatures +@using Grand.Infrastructure.Extensions +@using Grand.Web.Common.Localization + +@inject LocService Loc \ No newline at end of file diff --git a/src/Plugins/Authentication.OpenIdConnect/Authentication.OpenIdConnect.csproj b/src/Plugins/Authentication.OpenIdConnect/Authentication.OpenIdConnect.csproj new file mode 100644 index 000000000..653ee9744 --- /dev/null +++ b/src/Plugins/Authentication.OpenIdConnect/Authentication.OpenIdConnect.csproj @@ -0,0 +1,34 @@ + + + + + true + false + + + ..\..\Web\Grand.Web\Plugins\Authentication.OpenIdConnect + $(OutputPath) + + + + ..\..\Web\Grand.Web\Plugins\Authentication.OpenIdConnect + $(OutputPath) + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Plugins/Authentication.OpenIdConnect/Components/OidcLoginViewComponent.cs b/src/Plugins/Authentication.OpenIdConnect/Components/OidcLoginViewComponent.cs new file mode 100644 index 000000000..4e002c047 --- /dev/null +++ b/src/Plugins/Authentication.OpenIdConnect/Components/OidcLoginViewComponent.cs @@ -0,0 +1,17 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Mvc; + +namespace Authentication.OpenIdConnect.Components; + +[ViewComponent(Name = "OidcLogin")] +public class OidcLoginViewComponent(IAuthenticationSchemeProvider authenticationSchemeProvider) : ViewComponent +{ + public async Task InvokeAsync() + { + //no scheme (not configured, or not restarted since the install): a button that can only fail is not shown + if (await authenticationSchemeProvider.GetSchemeAsync(OidcLoginDefaults.AuthenticationScheme) == null) + return Content(string.Empty); + + return View(); + } +} diff --git a/src/Plugins/Authentication.OpenIdConnect/Controllers/OidcLoginController.cs b/src/Plugins/Authentication.OpenIdConnect/Controllers/OidcLoginController.cs new file mode 100644 index 000000000..bfbcd1ae3 --- /dev/null +++ b/src/Plugins/Authentication.OpenIdConnect/Controllers/OidcLoginController.cs @@ -0,0 +1,243 @@ +using Authentication.OpenIdConnect.Models; +using Grand.Business.Core.Events.Customers; +using Grand.Business.Core.Interfaces.Authentication; +using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Business.Core.Utilities.Authentication; +using Grand.Infrastructure; +using Grand.Mediator; +using Grand.Web.Common.Controllers; +using Grand.Web.Common.Security.ExternalRegistration; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using System.Globalization; + +namespace Authentication.OpenIdConnect.Controllers; + +public class OidcLoginController : BasePluginController +{ + #region Ctor + + public OidcLoginController( + IExternalRegistrationContext externalRegistrationContext, + IOidcCustomerResolver oidcCustomerResolver, + IGrandAuthenticationService authenticationService, + IMediator mediator, + IExternalAuthenticationService externalAuthenticationService, + IAuthenticationSchemeProvider authenticationSchemeProvider, + IGroupService groupService, + IContextAccessor contextAccessor, + ILogger logger) + { + _externalRegistrationContext = externalRegistrationContext; + _oidcCustomerResolver = oidcCustomerResolver; + _authenticationService = authenticationService; + _mediator = mediator; + _externalAuthenticationService = externalAuthenticationService; + _authenticationSchemeProvider = authenticationSchemeProvider; + _groupService = groupService; + _contextAccessor = contextAccessor; + _logger = logger; + } + + #endregion + + #region Fields + + private const string LinkSubjectKey = "Grand.OidcLogin.LinkSubject"; + + private readonly IExternalRegistrationContext _externalRegistrationContext; + private readonly IOidcCustomerResolver _oidcCustomerResolver; + private readonly IGrandAuthenticationService _authenticationService; + private readonly IMediator _mediator; + private readonly IExternalAuthenticationService _externalAuthenticationService; + private readonly IAuthenticationSchemeProvider _authenticationSchemeProvider; + private readonly IGroupService _groupService; + private readonly IContextAccessor _contextAccessor; + private readonly ILogger _logger; + + #endregion + + #region Methods + + public async Task Login(string returnUrl) + { + if (!IsAvailable()) + return RedirectToRoute("OidcLoginFailed"); + + //the scheme is registered at startup, only when both the provider and the client are configured + if (await _authenticationSchemeProvider.GetSchemeAsync(OidcLoginDefaults.AuthenticationScheme) == null) + { + _logger.LogWarning("OpenID Connect login has no sign-in scheme: OidcResourceServer:Authority or " + + "OidcLogin:ClientId is empty, or the application was not restarted after the plugin was installed"); + return RedirectToRoute("OidcLoginFailed"); + } + + var properties = new AuthenticationProperties { + RedirectUri = Url.RouteUrl("OidcLoginCallback", new { returnUrl = LocalOrNull(returnUrl) }) + }; + return Challenge(properties, OidcLoginDefaults.AuthenticationScheme); + } + + public async Task Callback(string returnUrl) + { + var pending = await _externalRegistrationContext.GetPending(); + if (pending == null) + return RedirectToRoute("OidcLoginFailed"); + + //the method may have been switched off, or limited to other stores or groups, since the sign-in started + if (!IsAvailable() || pending.ProviderSystemName != OidcLoginDefaults.ProviderSystemName) + { + await _externalRegistrationContext.Clear(); + return RedirectToRoute("OidcLoginFailed"); + } + + returnUrl = LocalOrNull(returnUrl); + var result = await _oidcCustomerResolver.ResolveForLogin(pending.Subject, pending.Email, pending.EmailVerified, + _contextAccessor.StoreContext.CurrentStore.Id); + + //a store session is never replaced by another account, and a signed-in visitor does not register; + //connecting accounts (LinkRequired) asks for the password of the account itself + if (result.Outcome is OidcLoginOutcome.SignIn or OidcLoginOutcome.RegistrationRequired) + { + var current = _contextAccessor.WorkContext.CurrentCustomer; + if (await _groupService.IsRegistered(current)) + { + await _externalRegistrationContext.Clear(); + return result.Customer?.Id == current.Id + ? RedirectLocal(returnUrl) + : RedirectToRoute("OidcLoginFailed", new { signedIn = true }); + } + } + + switch (result.Outcome) + { + case OidcLoginOutcome.SignIn: + await _externalRegistrationContext.Clear(); + //the identity provider is the sign-in: its multi-factor policy applies, not the store's two-factor code + await _mediator.Publish(new CustomerLoggedInEvent(result.Customer)); + await _authenticationService.SignIn(result.Customer, false); + return RedirectLocal(returnUrl); + case OidcLoginOutcome.RegistrationRequired: + return RedirectToRoute("Register", new { returnUrl }); + case OidcLoginOutcome.LinkRequired: + return RedirectToRoute("OidcLoginLink", new { returnUrl }); + default: + await _externalRegistrationContext.Clear(); + return RedirectToRoute("OidcLoginFailed", new { reason = (int)result.Refusal }); + } + } + + public async Task Link(string returnUrl) + { + var pending = await _externalRegistrationContext.GetPending(); + if (pending == null) + return RedirectToRoute("OidcLoginFailed"); + + if (!IsAvailable()) + { + await _externalRegistrationContext.Clear(); + return RedirectToRoute("OidcLoginFailed"); + } + + //an existing session never connects an account: the password is always asked for again. + //Only the sign-in cookie is ended; IGrandAuthenticationService.SignOut would also drop the + //external cookie that holds the pending identity + if (await _groupService.IsRegistered(_contextAccessor.WorkContext.CurrentCustomer)) + await HttpContext.SignOutAsync(GrandCookieAuthenticationDefaults.AuthenticationScheme); + + //when the link page was shown, in whole seconds like the sign-in ticket's issue time once stored in the cookie + HttpContext.Session.SetString(LinkSubjectKey, + DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture) + ":" + pending.Subject); + + returnUrl = LocalOrNull(returnUrl); + var completeUrl = Url.RouteUrl("OidcLoginLinkComplete", new { returnUrl }); + return View(new LinkModel { + Email = pending.Email, + LoginUrl = Url.RouteUrl("Login", new { returnUrl = completeUrl }) + }); + } + + public async Task LinkComplete(string returnUrl) + { + returnUrl = LocalOrNull(returnUrl); + var customer = _contextAccessor.WorkContext.CurrentCustomer; + if (!await _groupService.IsRegistered(customer)) + return RedirectToRoute("OidcLoginLink", new { returnUrl }); + + var pending = await _externalRegistrationContext.GetPending(); + if (pending == null) + return RedirectToRoute("OidcLoginFailed"); + + //the marker proves the link page was shown for this identity, and the sign-in ticket that the + //password sign-in issued after it + var marker = HttpContext.Session.GetString(LinkSubjectKey); + HttpContext.Session.Remove(LinkSubjectKey); + if (!TryReadMarker(marker, out var shown, out var subject) || subject != pending.Subject || + !await SignedInAfter(shown)) + return RedirectToRoute("OidcLoginLink", new { returnUrl }); + + if (!IsAvailable() || pending.ProviderSystemName != OidcLoginDefaults.ProviderSystemName) + { + await _externalRegistrationContext.Clear(); + return RedirectToRoute("OidcLoginFailed"); + } + + //only the account the provider identity names may be connected to it + if (!string.Equals(customer.Email?.Trim(), pending.Email.Trim(), StringComparison.OrdinalIgnoreCase)) + { + await _externalRegistrationContext.Clear(); + return RedirectToRoute("OidcLoginFailed", new { mismatch = true }); + } + + return await _externalRegistrationContext.Complete(customer, _contextAccessor.StoreContext.CurrentStore.Id) switch { + OidcLinkResult.Linked => RedirectLocal(returnUrl), + //the provider identity, not this account, is the one already connected elsewhere + OidcLinkResult.SubjectLinkedToOtherCustomer => RedirectToRoute("OidcLoginFailed", new { taken = true }), + _ => RedirectToRoute("OidcLoginFailed", new { reason = (int)OidcLoginRefusal.LinkedToOtherIdentity }) + }; + } + + public IActionResult Failed(int? reason, bool mismatch = false, bool taken = false, bool signedIn = false) => + View(new FailedModel { + Reason = (OidcLoginRefusal)(reason ?? 0), Mismatch = mismatch, IdentityTaken = taken, + SignedInAsOtherAccount = signedIn + }); + + #endregion + + #region Utilities + + private bool IsAvailable() => + _externalAuthenticationService.AuthenticationProviderIsAvailable(OidcLoginDefaults.ProviderSystemName); + + private static bool TryReadMarker(string marker, out long shown, out string subject) + { + shown = 0; + subject = null; + var separator = marker?.IndexOf(':') ?? -1; + if (separator <= 0 || + !long.TryParse(marker.AsSpan(0, separator), NumberStyles.None, CultureInfo.InvariantCulture, out shown)) + return false; + subject = marker[(separator + 1)..]; + return true; + } + + //the main sign-in ticket must be issued after the link page was shown; no issue time means not fresh + private async Task SignedInAfter(long shown) + { + var result = await HttpContext.AuthenticateAsync(GrandCookieAuthenticationDefaults.AuthenticationScheme); + var issued = result.Succeeded ? result.Properties?.IssuedUtc : null; + //the same second counts: the link page ended any session it found, so a ticket from that second is a new sign-in + return issued.HasValue && issued.Value.ToUnixTimeSeconds() >= shown; + } + + private string LocalOrNull(string returnUrl) => + !string.IsNullOrEmpty(returnUrl) && Url.IsLocalUrl(returnUrl) ? returnUrl : null; + + private IActionResult RedirectLocal(string returnUrl) => + returnUrl != null ? Redirect(returnUrl) : RedirectToRoute("HomePage"); + + #endregion +} diff --git a/src/Plugins/Authentication.OpenIdConnect/EndpointProvider.cs b/src/Plugins/Authentication.OpenIdConnect/EndpointProvider.cs new file mode 100644 index 000000000..1c889aa00 --- /dev/null +++ b/src/Plugins/Authentication.OpenIdConnect/EndpointProvider.cs @@ -0,0 +1,24 @@ +using Grand.Infrastructure.Endpoints; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; + +namespace Authentication.OpenIdConnect; + +public class EndpointProvider : IEndpointProvider +{ + public void RegisterEndpoint(IEndpointRouteBuilder endpointRouteBuilder) + { + endpointRouteBuilder.MapControllerRoute("OidcLoginLogin", "oidc/login", + new { controller = "OidcLogin", action = "Login" }); + endpointRouteBuilder.MapControllerRoute("OidcLoginCallback", "oidc/callback", + new { controller = "OidcLogin", action = "Callback" }); + endpointRouteBuilder.MapControllerRoute("OidcLoginLink", "oidc/link", + new { controller = "OidcLogin", action = "Link" }); + endpointRouteBuilder.MapControllerRoute("OidcLoginLinkComplete", "oidc/link/complete", + new { controller = "OidcLogin", action = "LinkComplete" }); + endpointRouteBuilder.MapControllerRoute("OidcLoginFailed", "oidc/failed", + new { controller = "OidcLogin", action = "Failed" }); + } + + public int Priority => 10; +} diff --git a/src/Plugins/Authentication.OpenIdConnect/Infrastructure/OidcLoginAuthenticationBuilder.cs b/src/Plugins/Authentication.OpenIdConnect/Infrastructure/OidcLoginAuthenticationBuilder.cs new file mode 100644 index 000000000..dcf5d4dfe --- /dev/null +++ b/src/Plugins/Authentication.OpenIdConnect/Infrastructure/OidcLoginAuthenticationBuilder.cs @@ -0,0 +1,88 @@ +using Grand.Business.Core.Interfaces.Authentication; +using Grand.Business.Core.Utilities.Authentication; +using Grand.Infrastructure.Configuration; +using Grand.Web.Common.Security.ExternalRegistration; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.OpenIdConnect; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.IdentityModel.Protocols.OpenIdConnect; + +namespace Authentication.OpenIdConnect.Infrastructure; + +/// +/// Browser sign-in with the identity provider of the resource server (authorization code + PKCE) +/// +public class OidcLoginAuthenticationBuilder : IAuthenticationBuilder +{ + public void AddAuthentication(AuthenticationBuilder builder, IConfiguration configuration) + { + var resourceServer = new OidcResourceServerConfig(); + configuration.GetSection("OidcResourceServer").Bind(resourceServer); + var login = new OidcLoginConfig(); + configuration.GetSection(OidcLoginDefaults.ConfigSection).Bind(login); + + //not configured: no scheme; the login action answers with the failure page + if (!login.IsConfigured(resourceServer)) + return; + + builder.AddOpenIdConnect(OidcLoginDefaults.AuthenticationScheme, options => + { + options.Authority = resourceServer.Authority; + options.RequireHttpsMetadata = resourceServer.RequireHttpsMetadata; + options.ClientId = login.ClientId; + options.ClientSecret = login.ClientSecret; + options.ResponseType = OpenIdConnectResponseType.Code; + options.ResponseMode = OpenIdConnectResponseMode.Query; + options.UsePkce = true; + options.CallbackPath = OidcLoginDefaults.CallbackPath; + options.SaveTokens = false; + options.MapInboundClaims = false; + options.GetClaimsFromUserInfoEndpoint = true; + options.Scope.Clear(); + foreach (var scope in OidcLoginIdentityMapper.Scopes(login.Scopes)) + options.Scope.Add(scope); + //user info answers are mapped only for the keys listed here + foreach (var claim in new[] { resourceServer.Claims.Subject, resourceServer.Claims.Email, + resourceServer.Claims.EmailVerified, "given_name", "family_name" }) + options.ClaimActions.MapUniqueJsonKey(claim, claim); + + options.Events = new OpenIdConnectEvents { + OnTicketReceived = async ctx => + { + var identity = OidcLoginIdentityMapper.Map(ctx.Principal, resourceServer.Claims); + if (identity == null) + { + ctx.HandleResponse(); + Logger(ctx.HttpContext).LogWarning("OpenID Connect sign-in without a subject or an e-mail"); + await Fail(ctx.HttpContext); + return; + } + + //only what the sign-in needs, kept briefly in the external authentication cookie + ctx.Principal = ExternalRegistrationContext.CreatePrincipal(identity); + OidcLoginIdentityMapper.ApplyPendingLifetime(ctx.Properties!, DateTimeOffset.UtcNow); + }, + OnRemoteFailure = async ctx => + { + ctx.HandleResponse(); + Logger(ctx.HttpContext).LogWarning(ctx.Failure, "OpenID Connect sign-in failed"); + await Fail(ctx.HttpContext); + } + }; + }); + } + + //a pending identity of an earlier attempt must not outlive a failed one: the next registration or link would use it + private static async Task Fail(Microsoft.AspNetCore.Http.HttpContext context) + { + await context.SignOutAsync(GrandCookieAuthenticationDefaults.ExternalAuthenticationScheme); + context.Response.Redirect(context.Request.PathBase + OidcLoginDefaults.FailedPath); + } + + private static ILogger Logger(Microsoft.AspNetCore.Http.HttpContext context) => + context.RequestServices.GetRequiredService().CreateLogger(typeof(OidcLoginAuthenticationBuilder)); + + public int Priority => 503; +} diff --git a/src/Plugins/Authentication.OpenIdConnect/Infrastructure/OidcLoginIdentityMapper.cs b/src/Plugins/Authentication.OpenIdConnect/Infrastructure/OidcLoginIdentityMapper.cs new file mode 100644 index 000000000..f3eee17a4 --- /dev/null +++ b/src/Plugins/Authentication.OpenIdConnect/Infrastructure/OidcLoginIdentityMapper.cs @@ -0,0 +1,48 @@ +using Grand.Infrastructure.Configuration; +using Grand.Web.Common.Security.ExternalRegistration; +using Microsoft.AspNetCore.Authentication; +using Microsoft.IdentityModel.Protocols.OpenIdConnect; +using System.Security.Claims; + +namespace Authentication.OpenIdConnect.Infrastructure; + +public static class OidcLoginIdentityMapper +{ + /// + /// The provider identity as a pending identity, by the claim names of the resource server configuration; + /// null without a subject or an e-mail + /// + public static ExternalRegistrationIdentity Map(ClaimsPrincipal principal, OidcClaimNames claims) + { + var sub = principal?.FindFirst(claims.Subject)?.Value; + var email = principal?.FindFirst(claims.Email)?.Value; + if (string.IsNullOrEmpty(sub) || string.IsNullOrWhiteSpace(email)) + return null; + + return new ExternalRegistrationIdentity(OidcLoginDefaults.ProviderSystemName, sub, email.Trim(), + string.Equals(principal.FindFirst(claims.EmailVerified)?.Value, "true", StringComparison.OrdinalIgnoreCase), + principal.FindFirst("given_name")?.Value, principal.FindFirst("family_name")?.Value); + } + + /// + /// The pending identity lives a fixed time: non-persistent and not renewed by the sliding expiration + /// + public static void ApplyPendingLifetime(AuthenticationProperties properties, DateTimeOffset now) + { + properties.IsPersistent = false; + properties.AllowRefresh = false; + properties.ExpiresUtc = now.Add(OidcLoginDefaults.PendingLifetime); + } + + /// + /// The scopes of the configuration, always with "openid": without it the provider issues no ID token and every + /// sign-in fails + /// + public static IEnumerable Scopes(string configured) + { + var scopes = (configured ?? "").Split(' ', StringSplitOptions.RemoveEmptyEntries).Distinct().ToList(); + if (!scopes.Contains(OpenIdConnectScope.OpenId)) + scopes.Insert(0, OpenIdConnectScope.OpenId); + return scopes; + } +} diff --git a/src/Plugins/Authentication.OpenIdConnect/Infrastructure/StartupApplication.cs b/src/Plugins/Authentication.OpenIdConnect/Infrastructure/StartupApplication.cs new file mode 100644 index 000000000..6b5dbe768 --- /dev/null +++ b/src/Plugins/Authentication.OpenIdConnect/Infrastructure/StartupApplication.cs @@ -0,0 +1,28 @@ +using Grand.Business.Core.Interfaces.Authentication; +using Grand.Infrastructure; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Authentication.OpenIdConnect.Infrastructure; + +public class StartupApplication : IStartupApplication +{ + public void ConfigureServices(IServiceCollection services, IConfiguration configuration) + { + var config = new OidcLoginConfig(); + configuration.GetSection(OidcLoginDefaults.ConfigSection).Bind(config); + services.AddSingleton(config); + + services.AddScoped(); + } + + public int Priority => 10; + + public void Configure(WebApplication application, IWebHostEnvironment webHostEnvironment) + { + } + + public bool BeforeConfigure => false; +} diff --git a/src/Plugins/Authentication.OpenIdConnect/Manifest.cs b/src/Plugins/Authentication.OpenIdConnect/Manifest.cs new file mode 100644 index 000000000..63e792322 --- /dev/null +++ b/src/Plugins/Authentication.OpenIdConnect/Manifest.cs @@ -0,0 +1,10 @@ +using Authentication.OpenIdConnect; +using Grand.Infrastructure.Plugins; + +[assembly: PluginInfo( + FriendlyName = "OpenID Connect", + Group = "Authentication methods", + SystemName = OidcLoginDefaults.ProviderSystemName, + Author = "grandnode team", + Version = "2.5.0" +)] diff --git a/src/Plugins/Authentication.OpenIdConnect/Models/ConfigurationModel.cs b/src/Plugins/Authentication.OpenIdConnect/Models/ConfigurationModel.cs new file mode 100644 index 000000000..be0d88793 --- /dev/null +++ b/src/Plugins/Authentication.OpenIdConnect/Models/ConfigurationModel.cs @@ -0,0 +1,20 @@ +using Grand.Infrastructure.ModelBinding; +using Grand.Infrastructure.Models; + +namespace Authentication.OpenIdConnect.Models; + +public class ConfigurationModel : BaseModel +{ + [GrandResourceDisplayName("Plugins.ExternalAuth.OpenIdConnect.Authority")] + public string Authority { get; set; } + + [GrandResourceDisplayName("Plugins.ExternalAuth.OpenIdConnect.ClientId")] + public string ClientId { get; set; } + + /// Only whether a secret is configured, never the secret itself + [GrandResourceDisplayName("Plugins.ExternalAuth.OpenIdConnect.ClientSecret")] + public string ClientSecret { get; set; } + + [GrandResourceDisplayName("Plugins.ExternalAuth.OpenIdConnect.DisplayOrder")] + public int DisplayOrder { get; set; } +} diff --git a/src/Plugins/Authentication.OpenIdConnect/Models/FailedModel.cs b/src/Plugins/Authentication.OpenIdConnect/Models/FailedModel.cs new file mode 100644 index 000000000..a5273d808 --- /dev/null +++ b/src/Plugins/Authentication.OpenIdConnect/Models/FailedModel.cs @@ -0,0 +1,15 @@ +using Grand.Business.Core.Utilities.Authentication; + +namespace Authentication.OpenIdConnect.Models; + +public class FailedModel +{ + public OidcLoginRefusal Reason { get; set; } + public bool Mismatch { get; set; } + + /// The provider identity is already connected to another account + public bool IdentityTaken { get; set; } + + /// Another account is signed in to the store + public bool SignedInAsOtherAccount { get; set; } +} diff --git a/src/Plugins/Authentication.OpenIdConnect/Models/LinkModel.cs b/src/Plugins/Authentication.OpenIdConnect/Models/LinkModel.cs new file mode 100644 index 000000000..76807cf82 --- /dev/null +++ b/src/Plugins/Authentication.OpenIdConnect/Models/LinkModel.cs @@ -0,0 +1,7 @@ +namespace Authentication.OpenIdConnect.Models; + +public class LinkModel +{ + public string Email { get; set; } + public string LoginUrl { get; set; } +} diff --git a/src/Plugins/Authentication.OpenIdConnect/OidcLoginConfig.cs b/src/Plugins/Authentication.OpenIdConnect/OidcLoginConfig.cs new file mode 100644 index 000000000..7b743af67 --- /dev/null +++ b/src/Plugins/Authentication.OpenIdConnect/OidcLoginConfig.cs @@ -0,0 +1,16 @@ +using Grand.Infrastructure.Configuration; + +namespace Authentication.OpenIdConnect; + +/// +/// appsettings section OidcLogin: the client of this store at the identity provider configured in OidcResourceServer +/// +public class OidcLoginConfig +{ + public string ClientId { get; set; } + public string ClientSecret { get; set; } + public string Scopes { get; set; } = "openid profile email"; + + public bool IsConfigured(OidcResourceServerConfig resourceServer) => + !string.IsNullOrWhiteSpace(resourceServer?.Authority) && !string.IsNullOrWhiteSpace(ClientId); +} diff --git a/src/Plugins/Authentication.OpenIdConnect/OidcLoginDefaults.cs b/src/Plugins/Authentication.OpenIdConnect/OidcLoginDefaults.cs new file mode 100644 index 000000000..c7a2b898a --- /dev/null +++ b/src/Plugins/Authentication.OpenIdConnect/OidcLoginDefaults.cs @@ -0,0 +1,16 @@ +using Grand.Web.Common.Security.Oidc; + +namespace Authentication.OpenIdConnect; + +public static class OidcLoginDefaults +{ + /// Plugin system name; also the ExternalAuthentication link name shared with the resource server + public const string ProviderSystemName = OidcResourceServerDefaults.ProviderSystemName; + + public const string AuthenticationScheme = "OidcLogin"; + public const string CallbackPath = "/signin-oidc"; + public const string ConfigSection = "OidcLogin"; + public const string FailedPath = "/oidc/failed"; + public const string ConfigurationUrl = "../OidcLoginSettings/Configure"; + public static readonly TimeSpan PendingLifetime = TimeSpan.FromMinutes(15); +} diff --git a/src/Plugins/Authentication.OpenIdConnect/OidcLoginPlugin.cs b/src/Plugins/Authentication.OpenIdConnect/OidcLoginPlugin.cs new file mode 100644 index 000000000..7f2e391da --- /dev/null +++ b/src/Plugins/Authentication.OpenIdConnect/OidcLoginPlugin.cs @@ -0,0 +1,83 @@ +using Grand.Business.Core.Interfaces.Common.Configuration; +using Grand.Business.Core.Interfaces.Common.Localization; +using Grand.Infrastructure.Plugins; + +namespace Authentication.OpenIdConnect; + +/// +/// Sign-in with an OpenID Connect identity provider +/// +public class OidcLoginPlugin( + ISettingService settingService, + IPluginTranslateResource pluginTranslateResource) + : BasePlugin, IPlugin +{ + private const string Prefix = "Plugins.ExternalAuth.OpenIdConnect."; + + private static readonly (string Key, string Value)[] Resources = [ + ("Login", "Sign in with your organization account"), + ("Link.Title", "Connect your account"), + ("Link.Text", + "An account with the e-mail {0} already exists in this store. Sign in once with its password to connect it; next time the button is enough."), + ("Link.SignIn", "Sign in with password"), + ("Link.Mismatch", + "You are signed in with a different e-mail address than the one from your identity provider. Nothing was connected."), + ("Failed.Title", "Sign-in failed"), + ("Failed.Generic", + "Signing in with the identity provider did not succeed. Try again or use another sign-in method."), + ("Failed.AccountInactive", + "Your account is not active yet. Use the activation link from the e-mail or wait for approval."), + ("Failed.RegistrationDisabled", "New accounts cannot be registered in this store."), + ("Failed.LinkedToOtherIdentity", "This account is already connected to another identity provider account."), + ("Failed.IdentityLinkedToOtherAccount", + "Your identity provider account is already connected to another account in this store. Nothing was connected."), + ("Failed.SignedInAsOtherAccount", + "You are already signed in with another account. Sign out first, then sign in with your identity provider."), + ("DisplayOrder", "Display order"), + ("ClientId", "Client ID"), + ("ClientSecret", "Client secret"), + ("ClientSecret.Set", "set"), + ("ClientSecret.NotSet", "not set"), + ("Authority", "Identity provider (Authority)"), + ("Description", + "

The identity provider and the client are configured in the application settings file (appsettings), not here: " + + "section OidcResourceServer holds the identity provider (Authority), " + + "section OidcLogin holds the client of this store (ClientId, ClientSecret, Scopes).

" + + "

Register this redirect URI at the identity provider for every URL of the store: " + + "https://<store-url>/signin-oidc.

" + + "

Restart the application after changing the settings. Staff accounts connect once with their password; " + + "use the multi-factor authentication of the identity provider for them.

") + ]; + + /// + /// Gets a configuration page URL + /// + public override string ConfigurationUrl() + { + return OidcLoginDefaults.ConfigurationUrl; + } + + public override async Task Install() + { + //settings + await settingService.SaveSetting(new OidcLoginSettings()); + + //locales + foreach (var (key, value) in Resources) + await pluginTranslateResource.AddOrUpdatePluginTranslateResource(Prefix + key, value); + + await base.Install(); + } + + public override async Task Uninstall() + { + //settings + await settingService.DeleteSetting(); + + //locales + foreach (var (key, _) in Resources) + await pluginTranslateResource.DeletePluginTranslationResource(Prefix + key); + + await base.Uninstall(); + } +} diff --git a/src/Plugins/Authentication.OpenIdConnect/OidcLoginProvider.cs b/src/Plugins/Authentication.OpenIdConnect/OidcLoginProvider.cs new file mode 100644 index 000000000..34c757dd3 --- /dev/null +++ b/src/Plugins/Authentication.OpenIdConnect/OidcLoginProvider.cs @@ -0,0 +1,23 @@ +using Grand.Business.Core.Interfaces.Authentication; + +namespace Authentication.OpenIdConnect; + +public class OidcLoginProvider(OidcLoginSettings settings) : IExternalAuthenticationProvider +{ + public string ConfigurationUrl => OidcLoginDefaults.ConfigurationUrl; + + public string SystemName => OidcLoginDefaults.ProviderSystemName; + + public string FriendlyName => "OpenID Connect"; + + public int Priority => settings.DisplayOrder; + + public IList LimitedToStores => new List(); + + public IList LimitedToGroups => new List(); + + /// + /// Gets a view component for displaying plugin in public store + /// + public Task GetPublicViewComponentName() => Task.FromResult("OidcLogin"); +} diff --git a/src/Plugins/Authentication.OpenIdConnect/OidcLoginSettings.cs b/src/Plugins/Authentication.OpenIdConnect/OidcLoginSettings.cs new file mode 100644 index 000000000..76fba5c93 --- /dev/null +++ b/src/Plugins/Authentication.OpenIdConnect/OidcLoginSettings.cs @@ -0,0 +1,8 @@ +using Grand.Domain.Configuration; + +namespace Authentication.OpenIdConnect; + +public class OidcLoginSettings : ISettings +{ + public int DisplayOrder { get; set; } +} diff --git a/src/Plugins/Authentication.OpenIdConnect/Views/OidcLogin/Failed.cshtml b/src/Plugins/Authentication.OpenIdConnect/Views/OidcLogin/Failed.cshtml new file mode 100644 index 000000000..2e50dd084 --- /dev/null +++ b/src/Plugins/Authentication.OpenIdConnect/Views/OidcLogin/Failed.cshtml @@ -0,0 +1,23 @@ +@using Grand.Business.Core.Utilities.Authentication +@model Authentication.OpenIdConnect.Models.FailedModel +@{ + Layout = "_SingleColumn"; + var key = Model.Mismatch + ? "Plugins.ExternalAuth.OpenIdConnect.Link.Mismatch" + : Model.IdentityTaken + ? "Plugins.ExternalAuth.OpenIdConnect.Failed.IdentityLinkedToOtherAccount" + : Model.SignedInAsOtherAccount + ? "Plugins.ExternalAuth.OpenIdConnect.Failed.SignedInAsOtherAccount" + : Model.Reason switch { + OidcLoginRefusal.AccountInactive => "Plugins.ExternalAuth.OpenIdConnect.Failed.AccountInactive", + OidcLoginRefusal.RegistrationDisabled => "Plugins.ExternalAuth.OpenIdConnect.Failed.RegistrationDisabled", + OidcLoginRefusal.LinkedToOtherIdentity => "Plugins.ExternalAuth.OpenIdConnect.Failed.LinkedToOtherIdentity", + _ => "Plugins.ExternalAuth.OpenIdConnect.Failed.Generic" + }; +} +
+

@Loc["Plugins.ExternalAuth.OpenIdConnect.Failed.Title"]

+
+
@Loc[key]
+
+
diff --git a/src/Plugins/Authentication.OpenIdConnect/Views/OidcLogin/Link.cshtml b/src/Plugins/Authentication.OpenIdConnect/Views/OidcLogin/Link.cshtml new file mode 100644 index 000000000..4bf726480 --- /dev/null +++ b/src/Plugins/Authentication.OpenIdConnect/Views/OidcLogin/Link.cshtml @@ -0,0 +1,11 @@ +@model Authentication.OpenIdConnect.Models.LinkModel +@{ + Layout = "_SingleColumn"; +} + diff --git a/src/Plugins/Authentication.OpenIdConnect/Views/Shared/Components/OidcLogin/Default.cshtml b/src/Plugins/Authentication.OpenIdConnect/Views/Shared/Components/OidcLogin/Default.cshtml new file mode 100644 index 000000000..e56b35025 --- /dev/null +++ b/src/Plugins/Authentication.OpenIdConnect/Views/Shared/Components/OidcLogin/Default.cshtml @@ -0,0 +1,9 @@ +@{ + Layout = ""; +} + diff --git a/src/Plugins/Authentication.OpenIdConnect/Views/_ViewImports.cshtml b/src/Plugins/Authentication.OpenIdConnect/Views/_ViewImports.cshtml new file mode 100644 index 000000000..8a04d32e5 --- /dev/null +++ b/src/Plugins/Authentication.OpenIdConnect/Views/_ViewImports.cshtml @@ -0,0 +1,10 @@ +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers +@addTagHelper *, Grand.Web.Common +@addTagHelper *, Grand.Web + +@using Microsoft.AspNetCore.Mvc.ViewFeatures +@using Grand.Infrastructure.Extensions +@using Grand.Web.Common.Extensions +@using Grand.Web.Common.Localization + +@inject LocService Loc \ No newline at end of file diff --git a/src/Tests/Grand.Modules.Tests/Grand.Modules.Tests.csproj b/src/Tests/Grand.Modules.Tests/Grand.Modules.Tests.csproj index 04c452c40..c19a8903d 100644 --- a/src/Tests/Grand.Modules.Tests/Grand.Modules.Tests.csproj +++ b/src/Tests/Grand.Modules.Tests/Grand.Modules.Tests.csproj @@ -22,6 +22,7 @@ + diff --git a/src/Tests/Grand.Modules.Tests/Plugins/OpenIdConnect/OidcLoginControllerTests.cs b/src/Tests/Grand.Modules.Tests/Plugins/OpenIdConnect/OidcLoginControllerTests.cs new file mode 100644 index 000000000..9a5307fc3 --- /dev/null +++ b/src/Tests/Grand.Modules.Tests/Plugins/OpenIdConnect/OidcLoginControllerTests.cs @@ -0,0 +1,494 @@ +using Authentication.OpenIdConnect; +using Authentication.OpenIdConnect.Controllers; +using Authentication.OpenIdConnect.Models; +using Grand.Business.Core.Events.Customers; +using Grand.Business.Core.Interfaces.Authentication; +using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Business.Core.Utilities.Authentication; +using Grand.Domain.Common; +using Grand.Domain.Customers; +using Grand.Domain.Stores; +using Grand.Infrastructure; +using Grand.Mediator; +using Grand.Web.Common.Security.ExternalRegistration; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using System.Globalization; +using System.Security.Claims; + +namespace Grand.Modules.Tests.Plugins.OpenIdConnect; + +[TestClass] +public class OidcLoginControllerTests +{ + private static readonly ExternalRegistrationIdentity Pending = + new(OidcLoginDefaults.ProviderSystemName, "sub-1", "john@example.com", true, "John", "Doe"); + + private Mock _external; + private Mock _resolver; + private Mock _auth; + private Mock _mediator; + private Mock _externalAuth; + private Mock _schemes; + private Mock _groups; + private Customer _current; + private OidcLoginController _controller; + private Mock _httpAuth; + private Mock> _logger; + private DateTimeOffset? _ticketIssued; + private readonly List _routes = []; + + private const string MarkerKey = "Grand.OidcLogin.LinkSubject"; + + private sealed class TestSession : ISession + { + private readonly Dictionary _data = new(); + public bool IsAvailable => true; + public string Id => "s"; + public IEnumerable Keys => _data.Keys; + public void Clear() => _data.Clear(); + public Task CommitAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; + public Task LoadAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; + public void Remove(string key) => _data.Remove(key); + public void Set(string key, byte[] value) => _data[key] = value; + public bool TryGetValue(string key, out byte[] value) => _data.TryGetValue(key, out value); + } + + [TestInitialize] + public void Init() + { + _external = new Mock(); + _resolver = new Mock(); + _auth = new Mock(); + _mediator = new Mock(); + _externalAuth = new Mock(); + _externalAuth.Setup(x => x.AuthenticationProviderIsAvailable(OidcLoginDefaults.ProviderSystemName)).Returns(true); + _schemes = new Mock(); + _schemes.Setup(x => x.GetSchemeAsync(OidcLoginDefaults.AuthenticationScheme)).ReturnsAsync( + new AuthenticationScheme(OidcLoginDefaults.AuthenticationScheme, null, typeof(IAuthenticationHandler))); + _groups = new Mock(); + _current = new Customer { Id = "c1", Email = "John@Example.com" }; + var work = new Mock(); + work.Setup(x => x.CurrentCustomer).Returns(() => _current); + var store = new Mock(); + store.Setup(x => x.CurrentStore).Returns(new Store { Id = "s1" }); + var ctx = new Mock(); + ctx.Setup(x => x.WorkContext).Returns(work.Object); + ctx.Setup(x => x.StoreContext).Returns(store.Object); + + _httpAuth = new Mock(); + _ticketIssued = DateTimeOffset.UtcNow; + _httpAuth.Setup(x => x.AuthenticateAsync(It.IsAny(), GrandCookieAuthenticationDefaults.AuthenticationScheme)) + .ReturnsAsync(() => AuthenticateResult.Success(new AuthenticationTicket(new ClaimsPrincipal(new ClaimsIdentity("test")), + new AuthenticationProperties { IssuedUtc = _ticketIssued }, GrandCookieAuthenticationDefaults.AuthenticationScheme))); + _logger = new Mock>(); + var http = new DefaultHttpContext { + Session = new TestSession(), + RequestServices = new ServiceCollection().AddSingleton(_httpAuth.Object).BuildServiceProvider() + }; + _controller = new OidcLoginController(_external.Object, _resolver.Object, _auth.Object, _mediator.Object, + _externalAuth.Object, _schemes.Object, _groups.Object, ctx.Object, _logger.Object) { + ControllerContext = new ControllerContext { HttpContext = http } + }; + var url = new Mock(); + url.Setup(x => x.IsLocalUrl(It.IsAny())).Returns(u => !string.IsNullOrEmpty(u) && u.StartsWith('/') && !u.StartsWith("//")); + url.Setup(x => x.RouteUrl(It.IsAny())).Returns(c => { + _routes.Add(c); + return "/" + c.RouteName + (c.Values == null ? "" : "?" + string.Join("&", c.Values.GetType().GetProperties() + .Select(p => p.Name + "=" + p.GetValue(c.Values)))); + }); + _controller.Url = url.Object; + _controller.TempData = new Mock().Object; + } + + [TestMethod] + public async Task Login_ExternalReturnUrl_Dropped() + { + var result = (ChallengeResult)await _controller.Login("https://evil.example/"); + Assert.AreEqual(OidcLoginDefaults.AuthenticationScheme, result.AuthenticationSchemes.Single()); + Assert.IsFalse(result.Properties!.RedirectUri!.Contains("evil")); + Assert.AreEqual("/OidcLoginCallback?returnUrl=", result.Properties.RedirectUri); + } + + [TestMethod] + public async Task Login_NotConfigured_Failed() + { + _schemes.Setup(x => x.GetSchemeAsync(OidcLoginDefaults.AuthenticationScheme)).ReturnsAsync((AuthenticationScheme)null); + Assert.AreEqual("OidcLoginFailed", ((RedirectToRouteResult)await _controller.Login("/")).RouteName); + _logger.Verify(x => x.Log(LogLevel.Warning, It.IsAny(), + It.Is((v, _) => v.ToString()!.Contains( + "or the application was not restarted after the plugin was installed")), + It.IsAny(), It.IsAny>()), Times.Once); + } + + [TestMethod] + public async Task Login_MethodInactive_Failed() + { + _externalAuth.Setup(x => x.AuthenticationProviderIsAvailable(It.IsAny())).Returns(false); + Assert.AreEqual("OidcLoginFailed", ((RedirectToRouteResult)await _controller.Login("/")).RouteName); + } + + [TestMethod] + public async Task Callback_NoPending_Failed() + { + _external.Setup(x => x.GetPending()).ReturnsAsync((ExternalRegistrationIdentity)null); + Assert.AreEqual("OidcLoginFailed", ((RedirectToRouteResult)await _controller.Callback("/")).RouteName); + } + + [TestMethod] + public async Task Callback_SignIn_SignsInClearsRedirects() + { + var customer = new Customer { Id = "c9" }; + _external.Setup(x => x.GetPending()).ReturnsAsync(Pending); + _resolver.Setup(x => x.ResolveForLogin("sub-1", "john@example.com", true, "s1")).ReturnsAsync(OidcLoginResult.SignIn(customer)); + var result = await _controller.Callback("/cart"); + _auth.Verify(x => x.SignIn(customer, false), Times.Once); + _mediator.Verify(x => x.Publish(It.Is(e => e.Customer == customer), It.IsAny()), Times.Once); + _external.Verify(x => x.Clear(), Times.Once); + Assert.AreEqual("/cart", ((RedirectResult)result).Url); + } + + [TestMethod] + public async Task Callback_RegistrationRequired_ToRegister_PendingKept() + { + _external.Setup(x => x.GetPending()).ReturnsAsync(Pending); + _resolver.Setup(x => x.ResolveForLogin(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(OidcLoginResult.RegistrationRequired()); + Assert.AreEqual("Register", ((RedirectToRouteResult)await _controller.Callback("/")).RouteName); + _external.Verify(x => x.Clear(), Times.Never); + } + + [TestMethod] + public async Task Callback_LinkRequired_ToLink() + { + _external.Setup(x => x.GetPending()).ReturnsAsync(Pending); + _resolver.Setup(x => x.ResolveForLogin(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(OidcLoginResult.LinkRequired(new Customer())); + Assert.AreEqual("OidcLoginLink", ((RedirectToRouteResult)await _controller.Callback("/")).RouteName); + } + + [TestMethod] + public async Task Callback_Refused_ClearsAndFailsWithReason() + { + _external.Setup(x => x.GetPending()).ReturnsAsync(Pending); + _resolver.Setup(x => x.ResolveForLogin(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(OidcLoginResult.Refused(OidcLoginRefusal.AccountInactive)); + var result = (RedirectToRouteResult)await _controller.Callback("/"); + Assert.AreEqual("OidcLoginFailed", result.RouteName); + Assert.AreEqual((int)OidcLoginRefusal.AccountInactive, result.RouteValues!["reason"]); + _external.Verify(x => x.Clear(), Times.Once); + _auth.Verify(x => x.SignIn(It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task LinkComplete_NotSignedIn_BackToLink() + { + _groups.Setup(x => x.IsRegistered(_current)).ReturnsAsync(false); + Assert.AreEqual("OidcLoginLink", ((RedirectToRouteResult)await _controller.LinkComplete("/")).RouteName); + } + + [TestMethod] + public async Task LinkComplete_EmailMatches_Links() + { + _groups.Setup(x => x.IsRegistered(_current)).ReturnsAsync(true); + Marker("sub-1"); + _external.Setup(x => x.GetPending()).ReturnsAsync(Pending); + _external.Setup(x => x.Complete(_current, "s1")).ReturnsAsync(OidcLinkResult.Linked); + Assert.AreEqual("/account", ((RedirectResult)await _controller.LinkComplete("/account")).Url); + } + + [TestMethod] + public async Task LinkComplete_OtherEmail_NothingLinked() + { + _current = new Customer { Id = "c2", Email = "someone@else.com" }; + _groups.Setup(x => x.IsRegistered(_current)).ReturnsAsync(true); + Marker("sub-1"); + _external.Setup(x => x.GetPending()).ReturnsAsync(Pending); + var result = (RedirectToRouteResult)await _controller.LinkComplete("/"); + Assert.AreEqual("OidcLoginFailed", result.RouteName); + _external.Verify(x => x.Complete(It.IsAny(), It.IsAny()), Times.Never); + _external.Verify(x => x.Clear(), Times.Once); + } + + [TestMethod] + public async Task LinkComplete_SubjectTaken_FailedWithIdentityTaken() + { + _groups.Setup(x => x.IsRegistered(_current)).ReturnsAsync(true); + Marker("sub-1"); + _external.Setup(x => x.GetPending()).ReturnsAsync(Pending); + _external.Setup(x => x.Complete(_current, "s1")).ReturnsAsync(OidcLinkResult.SubjectLinkedToOtherCustomer); + var result = (RedirectToRouteResult)await _controller.LinkComplete("/"); + Assert.AreEqual("OidcLoginFailed", result.RouteName); + Assert.AreEqual(true, result.RouteValues!["taken"]); + Assert.IsFalse(result.RouteValues.ContainsKey("reason")); + } + + [TestMethod] + public async Task LinkComplete_CustomerLinkedToOtherSubject_FailedWithLinkedToOtherIdentity() + { + _groups.Setup(x => x.IsRegistered(_current)).ReturnsAsync(true); + Marker("sub-1"); + _external.Setup(x => x.GetPending()).ReturnsAsync(Pending); + _external.Setup(x => x.Complete(_current, "s1")).ReturnsAsync(OidcLinkResult.CustomerLinkedToOtherSubject); + var result = (RedirectToRouteResult)await _controller.LinkComplete("/"); + Assert.AreEqual((int)OidcLoginRefusal.LinkedToOtherIdentity, result.RouteValues!["reason"]); + } + + [TestMethod] + public void Failed_IdentityTaken_InModel() + { + var model = (FailedModel)((ViewResult)_controller.Failed(null, taken: true)).Model!; + Assert.IsTrue(model.IdentityTaken); + } + + [TestMethod] + public async Task Callback_MethodInactive_ClearsAndFails_NothingResolved() + { + _external.Setup(x => x.GetPending()).ReturnsAsync(Pending); + _externalAuth.Setup(x => x.AuthenticationProviderIsAvailable(It.IsAny())).Returns(false); + Assert.AreEqual("OidcLoginFailed", ((RedirectToRouteResult)await _controller.Callback("/")).RouteName); + _external.Verify(x => x.Clear(), Times.Once); + _resolver.Verify(x => x.ResolveForLogin(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + [TestMethod] + public async Task Callback_OtherProviderPending_ClearsAndFails_NothingResolved() + { + _external.Setup(x => x.GetPending()).ReturnsAsync(Pending with { ProviderSystemName = "Other" }); + Assert.AreEqual("OidcLoginFailed", ((RedirectToRouteResult)await _controller.Callback("/")).RouteName); + _external.Verify(x => x.Clear(), Times.Once); + _resolver.Verify(x => x.ResolveForLogin(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + [TestMethod] + public async Task Link_MethodInactive_ClearsAndFails_StaysSignedIn() + { + _groups.Setup(x => x.IsRegistered(_current)).ReturnsAsync(true); + _external.Setup(x => x.GetPending()).ReturnsAsync(Pending); + _externalAuth.Setup(x => x.AuthenticationProviderIsAvailable(It.IsAny())).Returns(false); + Assert.AreEqual("OidcLoginFailed", ((RedirectToRouteResult)await _controller.Link("/")).RouteName); + _external.Verify(x => x.Clear(), Times.Once); + _httpAuth.Verify(x => x.SignOutAsync(It.IsAny(), GrandCookieAuthenticationDefaults.AuthenticationScheme, + It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task LinkComplete_MethodInactive_ClearsAndFails_NothingLinked() + { + _groups.Setup(x => x.IsRegistered(_current)).ReturnsAsync(true); + _external.Setup(x => x.GetPending()).ReturnsAsync(Pending); + Marker("sub-1"); + _externalAuth.Setup(x => x.AuthenticationProviderIsAvailable(It.IsAny())).Returns(false); + Assert.AreEqual("OidcLoginFailed", ((RedirectToRouteResult)await _controller.LinkComplete("/")).RouteName); + _external.Verify(x => x.Complete(It.IsAny(), It.IsAny()), Times.Never); + _external.Verify(x => x.Clear(), Times.Once); + } + + [TestMethod] + public async Task Callback_SignIn_TwoFactorCustomer_SignsInWithoutStoreCode() + { + var customer = new Customer { Id = "c9", Email = "john@example.com" }; + customer.UserFields.Add(new UserField { Key = SystemCustomerFieldNames.TwoFactorEnabled, Value = "True", StoreId = "" }); + _external.Setup(x => x.GetPending()).ReturnsAsync(Pending); + _resolver.Setup(x => x.ResolveForLogin(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(OidcLoginResult.SignIn(customer)); + Assert.AreEqual("/cart", ((RedirectResult)await _controller.Callback("/cart")).Url); + _auth.Verify(x => x.SignIn(customer, false), Times.Once); + } + + [TestMethod] + public async Task Callback_SignIn_OtherAccountSignedIn_ClearsAndFails_SessionKept() + { + _groups.Setup(x => x.IsRegistered(_current)).ReturnsAsync(true); + _external.Setup(x => x.GetPending()).ReturnsAsync(Pending); + _resolver.Setup(x => x.ResolveForLogin(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(OidcLoginResult.SignIn(new Customer { Id = "other" })); + var result = (RedirectToRouteResult)await _controller.Callback("/"); + Assert.AreEqual("OidcLoginFailed", result.RouteName); + Assert.AreEqual(true, result.RouteValues!["signedIn"]); + _external.Verify(x => x.Clear(), Times.Once); + _auth.Verify(x => x.SignIn(It.IsAny(), It.IsAny()), Times.Never); + _mediator.Verify(x => x.Publish(It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task Callback_SignIn_SameAccountSignedIn_RedirectsWithoutNewSignIn() + { + _groups.Setup(x => x.IsRegistered(_current)).ReturnsAsync(true); + _external.Setup(x => x.GetPending()).ReturnsAsync(Pending); + _resolver.Setup(x => x.ResolveForLogin(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(OidcLoginResult.SignIn(_current)); + Assert.AreEqual("/cart", ((RedirectResult)await _controller.Callback("/cart")).Url); + _external.Verify(x => x.Clear(), Times.Once); + _auth.Verify(x => x.SignIn(It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task Callback_RegistrationRequired_SignedIn_ClearsAndFails() + { + _groups.Setup(x => x.IsRegistered(_current)).ReturnsAsync(true); + _external.Setup(x => x.GetPending()).ReturnsAsync(Pending); + _resolver.Setup(x => x.ResolveForLogin(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(OidcLoginResult.RegistrationRequired()); + var result = (RedirectToRouteResult)await _controller.Callback("/"); + Assert.AreEqual("OidcLoginFailed", result.RouteName); + Assert.AreEqual(true, result.RouteValues!["signedIn"]); + _external.Verify(x => x.Clear(), Times.Once); + } + + [TestMethod] + public async Task Callback_LinkRequired_SignedIn_StillToLink() + { + _groups.Setup(x => x.IsRegistered(_current)).ReturnsAsync(true); + _external.Setup(x => x.GetPending()).ReturnsAsync(Pending); + _resolver.Setup(x => x.ResolveForLogin(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(OidcLoginResult.LinkRequired(new Customer { Id = "other" })); + Assert.AreEqual("OidcLoginLink", ((RedirectToRouteResult)await _controller.Callback("/")).RouteName); + } + + [TestMethod] + public async Task LinkComplete_TicketIssuedInSameSecondAsLink_Links() + { + _groups.Setup(x => x.IsRegistered(_current)).ReturnsAsync(true); + _external.Setup(x => x.GetPending()).ReturnsAsync(Pending); + _external.Setup(x => x.Complete(_current, "s1")).ReturnsAsync(OidcLinkResult.Linked); + var now = DateTimeOffset.FromUnixTimeSeconds(DateTimeOffset.UtcNow.ToUnixTimeSeconds()); + Marker("sub-1", now); + _ticketIssued = now.AddMilliseconds(800); + Assert.AreEqual("/account", ((RedirectResult)await _controller.LinkComplete("/account")).Url); + } + + [TestMethod] + public void Failed_SignedIn_InModel() + { + var model = (FailedModel)((ViewResult)_controller.Failed(null, signedIn: true)).Model!; + Assert.IsTrue(model.SignedInAsOtherAccount); + } + + //the link page was shown a minute ago unless a test says otherwise + private void Marker(string subject, DateTimeOffset? shown = null) => + _controller.HttpContext.Session.SetString(MarkerKey, + (shown ?? DateTimeOffset.UtcNow.AddMinutes(-1)).ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture) + ":" + subject); + + [TestMethod] + public async Task LinkComplete_TicketIssuedBeforeLink_BackToLink_NothingLinked() + { + _groups.Setup(x => x.IsRegistered(_current)).ReturnsAsync(true); + _external.Setup(x => x.GetPending()).ReturnsAsync(Pending); + _external.Setup(x => x.Complete(_current, "s1")).ReturnsAsync(OidcLinkResult.Linked); + Marker("sub-1", DateTimeOffset.UtcNow); + _ticketIssued = DateTimeOffset.UtcNow.AddMinutes(-5); + Assert.AreEqual("OidcLoginLink", ((RedirectToRouteResult)await _controller.LinkComplete("/")).RouteName); + _external.Verify(x => x.Complete(It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task LinkComplete_TicketWithoutIssueTime_BackToLink_NothingLinked() + { + _groups.Setup(x => x.IsRegistered(_current)).ReturnsAsync(true); + _external.Setup(x => x.GetPending()).ReturnsAsync(Pending); + _external.Setup(x => x.Complete(_current, "s1")).ReturnsAsync(OidcLinkResult.Linked); + Marker("sub-1"); + _ticketIssued = null; + Assert.AreEqual("OidcLoginLink", ((RedirectToRouteResult)await _controller.LinkComplete("/")).RouteName); + _external.Verify(x => x.Complete(It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task LinkComplete_TicketIssuedAfterLink_Links() + { + _groups.Setup(x => x.IsRegistered(_current)).ReturnsAsync(true); + _external.Setup(x => x.GetPending()).ReturnsAsync(Pending); + _external.Setup(x => x.Complete(_current, "s1")).ReturnsAsync(OidcLinkResult.Linked); + Marker("sub-1", DateTimeOffset.UtcNow.AddMinutes(-2)); + _ticketIssued = DateTimeOffset.UtcNow.AddMinutes(-1); + Assert.AreEqual("/account", ((RedirectResult)await _controller.LinkComplete("/account")).Url); + _external.Verify(x => x.Complete(_current, "s1"), Times.Once); + } + + [TestMethod] + public async Task Callback_SignIn_ExternalReturnUrl_GoesHome() + { + var customer = new Customer { Id = "c9" }; + _external.Setup(x => x.GetPending()).ReturnsAsync(Pending); + _resolver.Setup(x => x.ResolveForLogin(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(OidcLoginResult.SignIn(customer)); + Assert.AreEqual("HomePage", ((RedirectToRouteResult)await _controller.Callback("https://evil.example/")).RouteName); + } + + [TestMethod] + public async Task LinkComplete_ExternalReturnUrl_GoesHome() + { + _groups.Setup(x => x.IsRegistered(_current)).ReturnsAsync(true); + _external.Setup(x => x.GetPending()).ReturnsAsync(Pending); + _external.Setup(x => x.Complete(_current, "s1")).ReturnsAsync(OidcLinkResult.Linked); + Marker("sub-1"); + Assert.AreEqual("HomePage", ((RedirectToRouteResult)await _controller.LinkComplete("https://evil.example/")).RouteName); + } + + [TestMethod] + public async Task Link_RegisteredCustomer_SignedOutAndMarkerSet() + { + _groups.Setup(x => x.IsRegistered(_current)).ReturnsAsync(true); + _external.Setup(x => x.GetPending()).ReturnsAsync(Pending); + var result = await _controller.Link("/"); + Assert.IsInstanceOfType(result); + _httpAuth.Verify(x => x.SignOutAsync(It.IsAny(), GrandCookieAuthenticationDefaults.AuthenticationScheme, + It.IsAny()), Times.Once); + _httpAuth.Verify(x => x.SignOutAsync(It.IsAny(), GrandCookieAuthenticationDefaults.ExternalAuthenticationScheme, + It.IsAny()), Times.Never); + _auth.Verify(x => x.SignOut(), Times.Never); + var marker = _controller.HttpContext.Session.GetString(MarkerKey)!.Split(':', 2); + Assert.AreEqual("sub-1", marker[1]); + Assert.IsTrue(Math.Abs(long.Parse(marker[0], CultureInfo.InvariantCulture) - DateTimeOffset.UtcNow.ToUnixTimeSeconds()) <= 5); + } + + [TestMethod] + public async Task LinkComplete_NoMarker_BackToLink_NothingLinked() + { + _groups.Setup(x => x.IsRegistered(_current)).ReturnsAsync(true); + _external.Setup(x => x.GetPending()).ReturnsAsync(Pending); + Assert.AreEqual("OidcLoginLink", ((RedirectToRouteResult)await _controller.LinkComplete("/")).RouteName); + _external.Verify(x => x.Complete(It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task LinkComplete_OtherSubjectMarker_BackToLink_NothingLinked() + { + _groups.Setup(x => x.IsRegistered(_current)).ReturnsAsync(true); + _external.Setup(x => x.GetPending()).ReturnsAsync(Pending); + Marker("sub-other"); + Assert.AreEqual("OidcLoginLink", ((RedirectToRouteResult)await _controller.LinkComplete("/")).RouteName); + _external.Verify(x => x.Complete(It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task LinkComplete_Success_RemovesMarker() + { + _groups.Setup(x => x.IsRegistered(_current)).ReturnsAsync(true); + _external.Setup(x => x.GetPending()).ReturnsAsync(Pending); + _external.Setup(x => x.Complete(_current, "s1")).ReturnsAsync(OidcLinkResult.Linked); + Marker("sub-1"); + await _controller.LinkComplete("/"); + Assert.IsNull(_controller.HttpContext.Session.GetString(MarkerKey)); + } + + [TestMethod] + public async Task LinkComplete_OtherProviderPending_ClearsAndFails() + { + _groups.Setup(x => x.IsRegistered(_current)).ReturnsAsync(true); + _external.Setup(x => x.GetPending()).ReturnsAsync(Pending with { ProviderSystemName = "Other" }); + Marker("sub-1"); + Assert.AreEqual("OidcLoginFailed", ((RedirectToRouteResult)await _controller.LinkComplete("/")).RouteName); + _external.Verify(x => x.Complete(It.IsAny(), It.IsAny()), Times.Never); + _external.Verify(x => x.Clear(), Times.Once); + } +} diff --git a/src/Tests/Grand.Modules.Tests/Plugins/OpenIdConnect/OidcLoginIdentityMapperTests.cs b/src/Tests/Grand.Modules.Tests/Plugins/OpenIdConnect/OidcLoginIdentityMapperTests.cs new file mode 100644 index 000000000..e34fdf51c --- /dev/null +++ b/src/Tests/Grand.Modules.Tests/Plugins/OpenIdConnect/OidcLoginIdentityMapperTests.cs @@ -0,0 +1,81 @@ +using Authentication.OpenIdConnect.Infrastructure; +using Grand.Infrastructure.Configuration; +using Microsoft.AspNetCore.Authentication; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System.Security.Claims; + +namespace Grand.Modules.Tests.Plugins.OpenIdConnect; + +[TestClass] +public class OidcLoginIdentityMapperTests +{ + private static ClaimsPrincipal P(params (string, string)[] claims) => + new(new ClaimsIdentity(claims.Select(c => new Claim(c.Item1, c.Item2)), "oidc")); + + [TestMethod] + public void Maps_AllFields() + { + var id = OidcLoginIdentityMapper.Map(P(("sub", "s1"), ("email", "A@B.c"), ("email_verified", "True"), + ("given_name", "Ann"), ("family_name", "Bell")), new OidcClaimNames()); + Assert.AreEqual("ExternalAuth.OpenIdConnect", id.ProviderSystemName); + Assert.AreEqual("s1", id.Subject); + Assert.AreEqual("A@B.c", id.Email); + Assert.IsTrue(id.EmailVerified); + Assert.AreEqual("Ann", id.FirstName); + Assert.AreEqual("Bell", id.LastName); + } + + [TestMethod] + [DataRow(null)] + [DataRow("false")] + [DataRow("yes")] + public void EmailVerified_OnlyTrue(string value) + { + var claims = new List<(string, string)> { ("sub", "s1"), ("email", "a@b.c") }; + if (value != null) claims.Add(("email_verified", value)); + Assert.IsFalse(OidcLoginIdentityMapper.Map(P(claims.ToArray()), new OidcClaimNames()).EmailVerified); + } + + [TestMethod] + public void CustomClaimNames() + { + var names = new OidcClaimNames { Subject = "oid", Email = "upn", EmailVerified = "ev" }; + var id = OidcLoginIdentityMapper.Map(P(("oid", "o1"), ("upn", "u@b.c"), ("ev", "true")), names); + Assert.AreEqual("o1", id.Subject); + Assert.AreEqual("u@b.c", id.Email); + Assert.IsTrue(id.EmailVerified); + } + + [TestMethod] + [DataRow(null, "a@b.c")] + [DataRow("s1", null)] + public void MissingSubjectOrEmail_Null(string sub, string email) + { + var claims = new List<(string, string)>(); + if (sub != null) claims.Add(("sub", sub)); + if (email != null) claims.Add(("email", email)); + Assert.IsNull(OidcLoginIdentityMapper.Map(P(claims.ToArray()), new OidcClaimNames())); + } + + [TestMethod] + public void PendingLifetime_IsAbsolute() + { + var now = DateTimeOffset.UtcNow; + var props = new AuthenticationProperties { IsPersistent = true }; + OidcLoginIdentityMapper.ApplyPendingLifetime(props, now); + Assert.IsFalse(props.IsPersistent); + Assert.IsFalse(props.AllowRefresh); + //the properties keep whole seconds + Assert.IsTrue((now.AddMinutes(15) - props.ExpiresUtc.Value).Duration() < TimeSpan.FromSeconds(1)); + } + + [TestMethod] + [DataRow("profile email offline_access", "openid profile email offline_access")] + [DataRow("openid profile email openid", "openid profile email")] + [DataRow("", "openid")] + [DataRow(null, "openid")] + public void Scopes_AlwaysOpenId(string configured, string expected) + { + Assert.AreEqual(expected, string.Join(' ', OidcLoginIdentityMapper.Scopes(configured))); + } +} diff --git a/src/Tests/Grand.Modules.Tests/Plugins/OpenIdConnect/OidcLoginProviderTests.cs b/src/Tests/Grand.Modules.Tests/Plugins/OpenIdConnect/OidcLoginProviderTests.cs new file mode 100644 index 000000000..71281eeed --- /dev/null +++ b/src/Tests/Grand.Modules.Tests/Plugins/OpenIdConnect/OidcLoginProviderTests.cs @@ -0,0 +1,26 @@ +using Authentication.OpenIdConnect; +using Grand.Infrastructure.Configuration; +using Grand.Web.Common.Security.Oidc; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Grand.Modules.Tests.Plugins.OpenIdConnect; + +[TestClass] +public class OidcLoginProviderTests +{ + [TestMethod] + public void SystemName_IsTheLinkName() => + Assert.AreEqual(OidcResourceServerDefaults.ProviderSystemName, new OidcLoginProvider(new OidcLoginSettings()).SystemName); + + [TestMethod] + public void Priority_FromSettings() => + Assert.AreEqual(7, new OidcLoginProvider(new OidcLoginSettings { DisplayOrder = 7 }).Priority); + + [TestMethod] + [DataRow("https://idp", "client", true)] + [DataRow("", "client", false)] + [DataRow("https://idp", " ", false)] + public void IsConfigured(string authority, string clientId, bool expected) => + Assert.AreEqual(expected, new OidcLoginConfig { ClientId = clientId } + .IsConfigured(new OidcResourceServerConfig { Authority = authority })); +}