diff --git a/.ai/skills/scheduled-task.md b/.ai/skills/scheduled-task.md index 81d6bdd323..2e66f6d08c 100644 --- a/.ai/skills/scheduled-task.md +++ b/.ai/skills/scheduled-task.md @@ -135,6 +135,7 @@ public class ScheduleTask : BaseEntity | `GenerateSitemapXmlTask` | `"Generate sitemap XML file"` | 10080 min | false | | `EndAuctionsTask` | `"End of the auctions"` | 60 min | false | | `CancelOrderScheduledTask` | `"Cancel unpaid and pending orders"` | 1440 min | false | +| `DeleteOidcSessionsScheduleTask` | `"Delete OpenID Connect sessions"` | 1440 min | true | ## Expected Output Produce one of these results: diff --git a/src/Business/Grand.Business.Authentication/Services/CookieAuthenticationService.cs b/src/Business/Grand.Business.Authentication/Services/CookieAuthenticationService.cs index 508e1e1b6a..bea8d2e451 100644 --- a/src/Business/Grand.Business.Authentication/Services/CookieAuthenticationService.cs +++ b/src/Business/Grand.Business.Authentication/Services/CookieAuthenticationService.cs @@ -7,6 +7,7 @@ using Grand.Infrastructure.Configuration; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; using System.Security.Claims; namespace Grand.Business.Authentication.Services; @@ -26,13 +27,17 @@ public class CookieAuthenticationService : IGrandAuthenticationService /// Group service /// HTTP context accessor /// SecurityConfig + /// Server-side sessions of provider sign-ins + /// Logger public CookieAuthenticationService( CustomerSettings customerSettings, ICustomerService customerService, IGroupService groupService, IHttpContextAccessor httpContextAccessor, ICookieOptionsFactory cookieOptionsFactory, - SecurityConfig securityConfig) + SecurityConfig securityConfig, + IOidcSessionService oidcSessionService, + ILogger logger) { _customerSettings = customerSettings; _customerService = customerService; @@ -40,6 +45,8 @@ public CookieAuthenticationService( _httpContextAccessor = httpContextAccessor; _cookieOptionsFactory = cookieOptionsFactory; _securityConfig = securityConfig; + _oidcSessionService = oidcSessionService; + _logger = logger; } #endregion @@ -63,6 +70,8 @@ public CookieAuthenticationService( private readonly ICookieOptionsFactory _cookieOptionsFactory; private readonly SecurityConfig _securityConfig; + private readonly IOidcSessionService _oidcSessionService; + private readonly ILogger _logger; #endregion @@ -77,6 +86,44 @@ public virtual async Task SignIn(Customer customer, bool isPersistent) { ArgumentNullException.ThrowIfNull(customer); + //a re-sign-in of the same customer (password, e-mail or username change) keeps the server-side + //session of a provider sign-in, so idle/max lifetime, revalidation and back-channel logout still apply + await SignIn(customer, isPersistent, await CurrentOidcSessionIdOf(customer)); + } + + private async Task CurrentOidcSessionIdOf(Customer customer) + { + if (_httpContextAccessor.HttpContext == null || string.IsNullOrEmpty(customer.Id)) + return null; + + var authenticateResult = await _httpContextAccessor.HttpContext.AuthenticateAsync( + GrandCookieAuthenticationDefaults.AuthenticationScheme); + if (authenticateResult?.Succeeded != true) + return null; + + var customerId = authenticateResult.Principal?.FindFirst(claim => + claim.Type == CustomerIdClaimType && + claim.Issuer.Equals(_securityConfig.CookieClaimsIssuer, StringComparison.InvariantCultureIgnoreCase))?.Value; + + return customerId == customer.Id ? OidcSessionId(authenticateResult.Principal) : null; + } + + /// + /// Sign in with a session started at the identity provider + /// + /// Customer + /// Whether the authentication session is persisted across multiple requests + /// Id of the OidcSession; null or empty = an ordinary sign-in + public virtual async Task SignIn(Customer customer, bool isPersistent, string oidcSessionId) + { + ArgumentNullException.ThrowIfNull(customer); + + //the same customer signing in through the provider again: the cookie that referenced the previous session is + //replaced, so that session would never be used or ended again + if (!string.IsNullOrEmpty(oidcSessionId) && + await CurrentOidcSessionIdOf(customer) is { Length: > 0 } previous && previous != oidcSessionId) + await EndOidcSession(previous); + //create claims for customer's username and email var claims = new List(); @@ -109,6 +156,11 @@ public virtual async Task SignIn(Customer customer, bool isPersistent) _securityConfig.CookieClaimsIssuer)); } + //the server-side session of a provider sign-in: ending it ends this cookie + if (!string.IsNullOrEmpty(oidcSessionId)) + claims.Add(new Claim(OidcSessionDefaults.SessionIdClaimType, oidcSessionId, ClaimValueTypes.String, + _securityConfig.CookieClaimsIssuer)); + //create principal for the present scheme of authentication var userIdentity = new ClaimsIdentity(claims, GrandCookieAuthenticationDefaults.AuthenticationScheme); var userPrincipal = new ClaimsPrincipal(userIdentity); @@ -137,6 +189,12 @@ public virtual async Task SignOut() //and then sign out customer from the present scheme of authentication if (_httpContextAccessor.HttpContext != null) { + var authenticateResult = await _httpContextAccessor.HttpContext.AuthenticateAsync( + GrandCookieAuthenticationDefaults.AuthenticationScheme); + var oidcSessionId = authenticateResult?.Succeeded == true ? OidcSessionId(authenticateResult.Principal) : null; + if (!string.IsNullOrEmpty(oidcSessionId)) + await EndOidcSession(oidcSessionId); + await _httpContextAccessor.HttpContext.SignOutAsync(GrandCookieAuthenticationDefaults .AuthenticationScheme); //sign out also from other schema @@ -215,8 +273,32 @@ private async Task IsValidCustomer(Customer customer, ClaimsPrincipal prin if (!customer.Active || customer.Deleted || !await _groupService.IsRegistered(customer)) return false; + //a provider sign-in: the server-side session decides (ended, idle, refused by the provider) + var oidcSessionId = OidcSessionId(principal); + if (!string.IsNullOrEmpty(oidcSessionId) && !await _oidcSessionService.Validate(oidcSessionId, customer)) + return false; + return true; } + + private async Task EndOidcSession(string sessionId) + { + try + { + await _oidcSessionService.End(sessionId, OidcSessionEndReason.SignOut); + } + catch (Exception ex) + { + //the cookie that referenced it is replaced anyway; the record goes with the cleanup task + _logger.LogWarning(ex, "Could not end the OpenID Connect session {SessionId}", sessionId); + } + } + + private string OidcSessionId(ClaimsPrincipal principal) => + principal?.FindFirst(claim => + claim.Type == OidcSessionDefaults.SessionIdClaimType && + claim.Issuer.Equals(_securityConfig.CookieClaimsIssuer, StringComparison.InvariantCultureIgnoreCase))?.Value; + /// /// Get customer cookie /// diff --git a/src/Business/Grand.Business.Authentication/Services/OidcCustomerResolver.cs b/src/Business/Grand.Business.Authentication/Services/OidcCustomerResolver.cs index c013a3fa15..e69024cf76 100644 --- a/src/Business/Grand.Business.Authentication/Services/OidcCustomerResolver.cs +++ b/src/Business/Grand.Business.Authentication/Services/OidcCustomerResolver.cs @@ -1,4 +1,5 @@ using Grand.Business.Core.Events.Customers; +using Grand.Business.Core.Extensions; using Grand.Business.Core.Interfaces.Authentication; using Grand.Business.Core.Interfaces.Common.Configuration; using Grand.Business.Core.Interfaces.Common.Directory; @@ -451,7 +452,6 @@ private async Task HasTwoFactor(Customer customer, string storeId) => private async Task RequiresPassword(Customer customer, string storeId) => await IsBackOfficeAccount(customer) || await HasTwoFactor(customer, storeId); - private async Task IsBackOfficeAccount(Customer customer) => - !string.IsNullOrEmpty(customer.VendorId) || !string.IsNullOrEmpty(customer.StaffStoreId) || - await _permissionService.Authorize(StandardPermission.ManageAccessAdminPanel, customer); + private Task IsBackOfficeAccount(Customer customer) => + _permissionService.IsBackOfficeAccount(customer); } diff --git a/src/Business/Grand.Business.Authentication/Services/OidcSessionService.cs b/src/Business/Grand.Business.Authentication/Services/OidcSessionService.cs new file mode 100644 index 0000000000..0f27e4c48e --- /dev/null +++ b/src/Business/Grand.Business.Authentication/Services/OidcSessionService.cs @@ -0,0 +1,310 @@ +using Grand.Business.Core.Extensions; +using Grand.Business.Core.Interfaces.Authentication; +using Grand.Business.Core.Interfaces.Common.Security; +using Grand.Business.Core.Utilities.Authentication; +using Grand.Data; +using Grand.Domain.Customers; +using Grand.Infrastructure.Caching; +using Grand.Infrastructure.Caching.Constants; +using Grand.Infrastructure.Configuration; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.Extensions.Logging; +using System.Security.Cryptography; + +namespace Grand.Business.Authentication.Services; + +/// +/// Browser sessions started by an identity provider sign-in. Every session can be ended on the server (sign-out, +/// back-channel logout); a back-office session also ends after the idle time or the maximum lifetime, and every +/// session is confirmed at the provider at an interval by exchanging its refresh token - the only way to notice +/// that the provider disabled or deleted the user, since providers send no logout for that +/// +public class OidcSessionService : IOidcSessionService +{ + //how often the activity time of a back-office session is written + private static readonly TimeSpan ActivityWriteInterval = TimeSpan.FromMinutes(1); + + //minutes; ended sessions are removed from the cache, so only the activity time may be this stale + private const int CacheTimeMinutes = 1; + + //one refresh at a time per session on every instance (refresh tokens rotate): a lease stored on the session; + //longer than the refresh request may take, so it lapses only when its holder died + private static readonly TimeSpan RefreshLeaseTime = TimeSpan.FromSeconds(30); + + private readonly IRepository _sessionRepository; + private readonly ICacheBase _cache; + private readonly IPermissionService _permissionService; + private readonly IOidcTokenRevocationService _tokenRevocationService; + private readonly IEnumerable _tokenRefreshers; + private readonly IDataProtector _protector; + private readonly OidcLoginConfig _config; + private readonly SecurityConfig _securityConfig; + private readonly ILogger _logger; + + public OidcSessionService( + IRepository sessionRepository, + ICacheBase cache, + IPermissionService permissionService, + IOidcTokenRevocationService tokenRevocationService, + IEnumerable tokenRefreshers, + IDataProtectionProvider dataProtectionProvider, + OidcLoginConfig config, + SecurityConfig securityConfig, + ILogger logger) + { + _sessionRepository = sessionRepository; + _cache = cache; + _permissionService = permissionService; + _tokenRevocationService = tokenRevocationService; + _tokenRefreshers = tokenRefreshers; + _protector = dataProtectionProvider.CreateProtector(OidcSessionDefaults.RefreshTokenPurpose); + _config = config; + _securityConfig = securityConfig; + _logger = logger; + } + + public virtual async Task Start(Customer customer, string subject, string sid, string refreshToken, string storeId) + { + ArgumentNullException.ThrowIfNull(customer); + ArgumentException.ThrowIfNullOrEmpty(subject); + + if (string.IsNullOrEmpty(refreshToken)) + { + _logger.LogError("Provider sign-in of customer {CustomerId} refused: the provider issued no refresh token, so the " + + "session could not be revalidated. Request offline_access in OidcLogin:Scopes and allow the " + + "Refresh Token grant for the client at the provider", customer.Id); + return null; + } + + var now = DateTime.UtcNow; + var session = new OidcSession { + CustomerId = customer.Id, + StoreId = storeId ?? "", + Subject = subject, + Sid = sid ?? "", + CreatedUtc = now, + LastActivityUtc = now, + LastValidatedUtc = now, + ProtectedRefreshToken = _protector.Protect(refreshToken) + }; + await _sessionRepository.InsertAsync(session); + return session.Id; + } + + public virtual async Task Validate(string sessionId, Customer customer) + { + if (string.IsNullOrEmpty(sessionId) || customer == null) + return false; + + var session = await Get(sessionId); + if (session == null || session.EndedUtc.HasValue || session.CustomerId != customer.Id) + return false; + + var now = DateTime.UtcNow; + //back-office is evaluated now: an account promoted or demoted during the session follows its current role + var backOffice = await _permissionService.IsBackOfficeAccount(customer); + if (backOffice) + { + if (now - session.LastActivityUtc > _config.StaffIdleTimeout) + return await EndInvalid(session, OidcSessionEndReason.Idle); + if (now - session.CreatedUtc > _config.StaffMaxSession) + return await EndInvalid(session, OidcSessionEndReason.MaxLifetime); + } + + if (string.IsNullOrEmpty(session.ProtectedRefreshToken)) + return await EndInvalid(session, OidcSessionEndReason.NoRefreshToken); + + if (now - session.LastValidatedUtc >= _config.RevalidationInterval && !await Revalidate(session, now)) + return false; + + //only the staff idle rule reads the activity time + if (backOffice && now - session.LastActivityUtc >= ActivityWriteInterval) + { + await _sessionRepository.UpdateField(session.Id, x => x.LastActivityUtc, now); + await _cache.RemoveAsync(string.Format(CacheKey.OIDC_SESSION_BY_ID_KEY, session.Id)); + } + + return true; + } + + private async Task EndInvalid(OidcSession session, OidcSessionEndReason reason) + { + await End(session.Id, reason); + return false; + } + + /// + /// Confirms the account at the provider. A refusal ends the session and refuses the subject's older access tokens; + /// no answer keeps the session until two intervals passed since the last confirmation + /// + private async Task Revalidate(OidcSession cached, DateTime now) + { + var grace = _config.RevalidationInterval * 2; + + //atomic on MongoDB: of the requests that find the check due, on any instance, one takes the lease + var leaseId = Guid.NewGuid().ToString("N"); + await _sessionRepository.UpdateOneAsync( + x => x.Id == cached.Id && x.EndedUtc == null && + (x.RefreshLeaseUntilUtc == null || x.RefreshLeaseUntilUtc < now), + UpdateBuilder.Create().Set(x => x.RefreshLeaseId, leaseId) + .Set(x => x.RefreshLeaseUntilUtc, now + RefreshLeaseTime)); + + //the cached record may predate a refresh that just finished: its token is already rotated + var session = await _sessionRepository.GetByIdAsync(cached.Id); + if (session == null || session.EndedUtc.HasValue) + return false; + //another request is refreshing: go on while the last confirmation is recent enough + if (session.RefreshLeaseId != leaseId) + return now - session.LastValidatedUtc < grace; + + try + { + if (now - session.LastValidatedUtc < _config.RevalidationInterval) + return true; + + var refresher = _tokenRefreshers.FirstOrDefault(); + var refreshToken = Unprotect(session.ProtectedRefreshToken); + OidcRefreshResult result; + if (refresher == null) + result = OidcRefreshResult.NotAsked(); + else if (refreshToken == null) + { + _logger.LogWarning("The stored refresh token of session {SessionId} cannot be read (Data Protection keys changed)", + session.Id); + result = OidcRefreshResult.NotAsked(); + } + else + { + try + { + result = await refresher.Refresh(refreshToken); + } + catch (Exception ex) + { + //no cancellation token is passed, so a cancellation is a provider timeout: no answer, like any failure + _logger.LogWarning("Refreshing session {SessionId} failed: {ExceptionType}", session.Id, ex.GetType().Name); + result = OidcRefreshResult.Unavailable(); + } + } + + switch (result.Outcome) + { + case OidcRefreshOutcome.Refreshed: + { + var update = UpdateBuilder.Create().Set(x => x.LastValidatedUtc, now); + if (!string.IsNullOrEmpty(result.RefreshToken)) + update.Set(x => x.ProtectedRefreshToken, _protector.Protect(result.RefreshToken)); + //conditional on the token read: a newer token stored meanwhile by another instance is never overwritten + await _sessionRepository.UpdateOneAsync( + x => x.Id == session.Id && x.EndedUtc == null && x.ProtectedRefreshToken == session.ProtectedRefreshToken, + update); + await _cache.RemoveAsync(string.Format(CacheKey.OIDC_SESSION_BY_ID_KEY, session.Id)); + return true; + } + case OidcRefreshOutcome.Refused: + { + //the token may have been rotated by another instance in the meantime: then the refusal is about the old one + var current = await _sessionRepository.GetByIdAsync(session.Id); + if (current is { EndedUtc: null } && current.ProtectedRefreshToken != session.ProtectedRefreshToken) + { + await _cache.RemoveAsync(string.Format(CacheKey.OIDC_SESSION_BY_ID_KEY, session.Id)); + return true; + } + + _logger.LogWarning("The identity provider refused the session {SessionId} of customer {CustomerId}", + session.Id, session.CustomerId); + await End(session.Id, OidcSessionEndReason.RevalidationRefused); + await _tokenRevocationService.RevokeTokens(session.Subject); + return false; + } + case OidcRefreshOutcome.NotAsked: + //nothing can confirm the session any more; the provider refused nothing, so the subject's access + //tokens stay valid + await End(session.Id, OidcSessionEndReason.RevalidationRefused); + return false; + default: + _logger.LogWarning("The identity provider could not confirm session {SessionId}; last confirmed {LastValidatedUtc}", + session.Id, session.LastValidatedUtc); + if (now - session.LastValidatedUtc < grace) + return true; + await End(session.Id, OidcSessionEndReason.RevalidationUnavailable); + return false; + } + } + finally + { + await _sessionRepository.UpdateOneAsync(x => x.Id == session.Id && x.RefreshLeaseId == leaseId, + UpdateBuilder.Create().Set(x => x.RefreshLeaseId, null) + .Set(x => x.RefreshLeaseUntilUtc, null)); + } + } + + public virtual async Task End(string sessionId, OidcSessionEndReason reason) + { + if (string.IsNullOrEmpty(sessionId)) + return; + //the first end wins: its reason is the one that explains the sign-out; an ended session is never confirmed + //again, so its refresh token is not kept for the retention period + await _sessionRepository.UpdateOneAsync(x => x.Id == sessionId && x.EndedUtc == null, + UpdateBuilder.Create().Set(x => x.EndedUtc, DateTime.UtcNow).Set(x => x.EndReason, reason) + .Set(x => x.ProtectedRefreshToken, null)); + await _cache.RemoveAsync(string.Format(CacheKey.OIDC_SESSION_BY_ID_KEY, sessionId)); + _logger.LogInformation("OpenID Connect session {SessionId} ended: {Reason}", sessionId, reason); + } + + public virtual async Task> EndBySid(string subject, string sid) + { + ArgumentException.ThrowIfNullOrEmpty(sid); + var query = _sessionRepository.Table.Where(x => x.Sid == sid && x.EndedUtc == null); + if (!string.IsNullOrEmpty(subject)) + query = query.Where(x => x.Subject == subject); + return await EndAll(await _sessionRepository.ToListAsync(query)); + } + + public virtual async Task> EndBySubject(string subject) + { + ArgumentException.ThrowIfNullOrEmpty(subject); + return await EndAll(await _sessionRepository.ToListAsync( + _sessionRepository.Table.Where(x => x.Subject == subject && x.EndedUtc == null))); + } + + public virtual async Task DeleteExpired() + { + var now = DateTime.UtcNow; + var ended = now - OidcSessionDefaults.Retention; + await _sessionRepository.DeleteManyAsync(x => x.EndedUtc != null && x.EndedUtc < ended); + + //sessions nobody used again end lazily, at the next request: these will never get one + var created = now - TimeSpan.FromHours(_securityConfig.CookieAuthExpires) - OidcSessionDefaults.Retention; + await _sessionRepository.DeleteManyAsync(x => x.CreatedUtc < created); + + //a session in use is confirmed every RevalidationMinutes: one not confirmed for this long was left (most end with + //the browser, without a sign-out) and must not keep its refresh token any longer + var abandoned = now - OidcSessionDefaults.AbandonedAfter; + await _sessionRepository.DeleteManyAsync(x => x.EndedUtc == null && x.LastValidatedUtc < abandoned); + } + + private async Task> EndAll(IList sessions) + { + foreach (var session in sessions) + await End(session.Id, OidcSessionEndReason.BackChannel); + return sessions; + } + + private Task Get(string sessionId) => + _cache.GetAsync(string.Format(CacheKey.OIDC_SESSION_BY_ID_KEY, sessionId), + () => _sessionRepository.GetByIdAsync(sessionId), CacheTimeMinutes); + + private string Unprotect(string protectedToken) + { + try + { + return _protector.Unprotect(protectedToken); + } + catch (CryptographicException) + { + //key ring lost or rotated away: the token cannot be used any more + return null; + } + } +} diff --git a/src/Business/Grand.Business.Authentication/Services/OidcTokenRevocationService.cs b/src/Business/Grand.Business.Authentication/Services/OidcTokenRevocationService.cs new file mode 100644 index 0000000000..5eaddd766c --- /dev/null +++ b/src/Business/Grand.Business.Authentication/Services/OidcTokenRevocationService.cs @@ -0,0 +1,30 @@ +using Grand.Business.Core.Interfaces.Authentication; +using Grand.Data; +using Grand.Domain.Customers; + +namespace Grand.Business.Authentication.Services; + +/// +/// Keeps the revocation time on the subject's links (one per store with per-store identity). Not cached: the check +/// must hold at once on every instance, and the lookup uses the provider + identifier index +/// +public class OidcTokenRevocationService(IRepository externalAuthenticationRepository) + : IOidcTokenRevocationService +{ + public Task RevokeTokens(string subject) + { + ArgumentException.ThrowIfNullOrEmpty(subject); + return externalAuthenticationRepository.UpdateManyAsync( + x => x.ProviderSystemName == OidcCustomerResolver.ProviderSystemName && x.ExternalIdentifier == subject, + UpdateBuilder.Create().Set(x => x.TokensRevokedBeforeUtc, DateTime.UtcNow)); + } + + public async Task IsRevoked(string subject, DateTime issuedUtc) + { + if (string.IsNullOrEmpty(subject)) + return false; + return await externalAuthenticationRepository.GetOneAsync(x => + x.ProviderSystemName == OidcCustomerResolver.ProviderSystemName && x.ExternalIdentifier == subject && + x.TokensRevokedBeforeUtc != null && x.TokensRevokedBeforeUtc > issuedUtc) != null; + } +} diff --git a/src/Business/Grand.Business.Authentication/Startup/StartupApplication.cs b/src/Business/Grand.Business.Authentication/Startup/StartupApplication.cs index 20bc77417f..e03aee9535 100644 --- a/src/Business/Grand.Business.Authentication/Startup/StartupApplication.cs +++ b/src/Business/Grand.Business.Authentication/Startup/StartupApplication.cs @@ -23,6 +23,8 @@ public void ConfigureServices(IServiceCollection services, IConfiguration config services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); } public void Configure(WebApplication application, IWebHostEnvironment webHostEnvironment) diff --git a/src/Business/Grand.Business.Core/Extensions/BackOfficeAccountExtensions.cs b/src/Business/Grand.Business.Core/Extensions/BackOfficeAccountExtensions.cs new file mode 100644 index 0000000000..62cda25789 --- /dev/null +++ b/src/Business/Grand.Business.Core/Extensions/BackOfficeAccountExtensions.cs @@ -0,0 +1,18 @@ +using Grand.Business.Core.Interfaces.Common.Security; +using Grand.Domain.Customers; +using Grand.Domain.Permissions; + +namespace Grand.Business.Core.Extensions; + +public static class BackOfficeAccountExtensions +{ + /// + /// A back-office account: vendor, store staff, or an account with admin panel access + /// + public static async Task IsBackOfficeAccount(this IPermissionService permissionService, Customer customer) + { + ArgumentNullException.ThrowIfNull(customer); + return !string.IsNullOrEmpty(customer.VendorId) || !string.IsNullOrEmpty(customer.StaffStoreId) || + await permissionService.Authorize(StandardPermission.ManageAccessAdminPanel, customer); + } +} diff --git a/src/Business/Grand.Business.Core/Interfaces/Authentication/IGrandAuthenticationService.cs b/src/Business/Grand.Business.Core/Interfaces/Authentication/IGrandAuthenticationService.cs index 3fe8086a40..71e0c1b4c3 100644 --- a/src/Business/Grand.Business.Core/Interfaces/Authentication/IGrandAuthenticationService.cs +++ b/src/Business/Grand.Business.Core/Interfaces/Authentication/IGrandAuthenticationService.cs @@ -14,6 +14,15 @@ public interface IGrandAuthenticationService /// A value indicating whether to create a persistent cookie Task SignIn(Customer customer, bool createPersistentCookie); + /// + /// Sign in with a session started at the identity provider; the cookie references the session, so it can be ended + /// on the server + /// + /// Customer + /// A value indicating whether to create a persistent cookie + /// Id of the OidcSession; null or empty = an ordinary sign-in + Task SignIn(Customer customer, bool createPersistentCookie, string oidcSessionId); + /// /// Sign out /// diff --git a/src/Business/Grand.Business.Core/Interfaces/Authentication/IOidcSessionService.cs b/src/Business/Grand.Business.Core/Interfaces/Authentication/IOidcSessionService.cs new file mode 100644 index 0000000000..23142d3a59 --- /dev/null +++ b/src/Business/Grand.Business.Core/Interfaces/Authentication/IOidcSessionService.cs @@ -0,0 +1,32 @@ +using Grand.Domain.Customers; + +namespace Grand.Business.Core.Interfaces.Authentication; + +/// +/// Server-side state of browser sessions started by an identity provider sign-in +/// +public interface IOidcSessionService +{ + /// + /// Records a new session and returns its id; the refresh token is kept (protected) only for a back-office account. + /// Null when a back-office account brings no refresh token: such a session could never be revalidated + /// + Task Start(Customer customer, string subject, string sid, string refreshToken, string storeId); + + /// + /// The session may continue: not ended, of this customer, and for a back-office account within the idle and + /// maximum lifetime and confirmed by the provider at the configured interval (ends the session otherwise) + /// + Task Validate(string sessionId, Customer customer); + + Task End(string sessionId, OidcSessionEndReason reason); + + /// Ends the open sessions with the provider session id (and the subject, when given); returns them + Task> EndBySid(string subject, string sid); + + /// Ends every open session of the subject; returns them + Task> EndBySubject(string subject); + + /// Deletes ended sessions and sessions past their lifetime, after the retention period + Task DeleteExpired(); +} diff --git a/src/Business/Grand.Business.Core/Interfaces/Authentication/IOidcTokenRefresher.cs b/src/Business/Grand.Business.Core/Interfaces/Authentication/IOidcTokenRefresher.cs new file mode 100644 index 0000000000..fee3d1ac28 --- /dev/null +++ b/src/Business/Grand.Business.Core/Interfaces/Authentication/IOidcTokenRefresher.cs @@ -0,0 +1,12 @@ +using Grand.Business.Core.Utilities.Authentication; + +namespace Grand.Business.Core.Interfaces.Authentication; + +/// +/// Exchanges a refresh token at the identity provider (implemented by the OpenID Connect login plugin; with no +/// implementation registered a session cannot be confirmed and ends) +/// +public interface IOidcTokenRefresher +{ + Task Refresh(string refreshToken, CancellationToken cancellationToken = default); +} diff --git a/src/Business/Grand.Business.Core/Interfaces/Authentication/IOidcTokenRevocationService.cs b/src/Business/Grand.Business.Core/Interfaces/Authentication/IOidcTokenRevocationService.cs new file mode 100644 index 0000000000..e00e5386ec --- /dev/null +++ b/src/Business/Grand.Business.Core/Interfaces/Authentication/IOidcTokenRevocationService.cs @@ -0,0 +1,14 @@ +namespace Grand.Business.Core.Interfaces.Authentication; + +/// +/// Refuses identity provider access tokens of a subject issued before the provider ended the subject's session +/// or refused the account +/// +public interface IOidcTokenRevocationService +{ + /// Tokens of the subject issued until now are refused + Task RevokeTokens(string subject); + + /// The token of the subject issued at is refused + Task IsRevoked(string subject, DateTime issuedUtc); +} diff --git a/src/Business/Grand.Business.Core/Utilities/Authentication/OidcRefreshResult.cs b/src/Business/Grand.Business.Core/Utilities/Authentication/OidcRefreshResult.cs new file mode 100644 index 0000000000..4426bfdc38 --- /dev/null +++ b/src/Business/Grand.Business.Core/Utilities/Authentication/OidcRefreshResult.cs @@ -0,0 +1,29 @@ +namespace Grand.Business.Core.Utilities.Authentication; + +public enum OidcRefreshOutcome +{ + /// The provider accepted the refresh token: the account is still usable there + Refreshed, + + /// A definitive refusal (invalid_grant, invalid_request, any 400/401 of the token endpoint) + Refused, + + /// No answer: network error, timeout, 5xx + Unavailable, + + /// + /// The provider was not asked: this installation cannot (sign-in not configured, stored token unreadable). The + /// session cannot be confirmed any more, but the provider refused nothing + /// + NotAsked +} + +/// The answer of the provider +/// The rotated refresh token, or null when the provider keeps the old one +public sealed record OidcRefreshResult(OidcRefreshOutcome Outcome, string RefreshToken = null) +{ + public static OidcRefreshResult Refreshed(string refreshToken) => new(OidcRefreshOutcome.Refreshed, refreshToken); + public static OidcRefreshResult Refused() => new(OidcRefreshOutcome.Refused); + public static OidcRefreshResult Unavailable() => new(OidcRefreshOutcome.Unavailable); + public static OidcRefreshResult NotAsked() => new(OidcRefreshOutcome.NotAsked); +} diff --git a/src/Business/Grand.Business.Core/Utilities/Authentication/OidcSessionDefaults.cs b/src/Business/Grand.Business.Core/Utilities/Authentication/OidcSessionDefaults.cs new file mode 100644 index 0000000000..478e63468f --- /dev/null +++ b/src/Business/Grand.Business.Core/Utilities/Authentication/OidcSessionDefaults.cs @@ -0,0 +1,19 @@ +namespace Grand.Business.Core.Utilities.Authentication; + +public static class OidcSessionDefaults +{ + /// Claim of the main authentication cookie holding the OidcSession id + public const string SessionIdClaimType = "grand:oidcSession"; + + /// Data Protection purpose of stored refresh tokens + public const string RefreshTokenPurpose = "Grand.OidcSession.RefreshToken"; + + /// How long an ended or expired session record is kept before the cleanup task deletes it + public static readonly TimeSpan Retention = TimeSpan.FromDays(7); + + /// + /// An open session not confirmed at the provider for this long is deleted by the cleanup task (its browser is gone; + /// providers drop an unused offline session after about as long) + /// + public static readonly TimeSpan AbandonedAfter = TimeSpan.FromDays(30); +} diff --git a/src/Core/Grand.Data/LiteDb/LiteDBRepository.cs b/src/Core/Grand.Data/LiteDb/LiteDBRepository.cs index 9d2403986f..32a1dbbfeb 100644 --- a/src/Core/Grand.Data/LiteDb/LiteDBRepository.cs +++ b/src/Core/Grand.Data/LiteDb/LiteDBRepository.cs @@ -210,7 +210,9 @@ public virtual Task IncField(string id, Expression> expression, U public virtual Task UpdateOneAsync(Expression> filterexpression, UpdateBuilder updateBuilder) { var entity = Collection.FindOne(filterexpression); - Update(entity, updateBuilder); + //like MongoDB: a filter that matches nothing updates nothing + if (entity != null) + Update(entity, updateBuilder); return Task.CompletedTask; } diff --git a/src/Core/Grand.Domain/Customers/ExternalAuthentication.cs b/src/Core/Grand.Domain/Customers/ExternalAuthentication.cs index d657c2c5ce..1fb8169a56 100644 --- a/src/Core/Grand.Domain/Customers/ExternalAuthentication.cs +++ b/src/Core/Grand.Domain/Customers/ExternalAuthentication.cs @@ -44,4 +44,10 @@ public class ExternalAuthentication : BaseEntity /// Gets or sets a value indicating whether the link was made by the e-mail address alone, without the account's password /// public bool LinkedByEmail { get; set; } + + /// + /// Gets or sets the time before which the identity provider's access tokens of this subject are refused (provider + /// sign-out, or the provider refused the account); empty = never + /// + public DateTime? TokensRevokedBeforeUtc { get; set; } } \ No newline at end of file diff --git a/src/Core/Grand.Domain/Customers/OidcSession.cs b/src/Core/Grand.Domain/Customers/OidcSession.cs new file mode 100644 index 0000000000..73d46eb325 --- /dev/null +++ b/src/Core/Grand.Domain/Customers/OidcSession.cs @@ -0,0 +1,48 @@ +namespace Grand.Domain.Customers; + +/// +/// A browser session started by a sign-in at the OpenID Connect identity provider; the main authentication cookie +/// carries its id, so the session can be ended on the server (sign-out, back-channel logout, staff policy) +/// +public class OidcSession : BaseEntity +{ + public string CustomerId { get; set; } + + /// The store of the sign-in + public string StoreId { get; set; } + + /// Provider subject (sub) + public string Subject { get; set; } + + /// Provider session id (sid) from the ID token; empty when the provider sends none + public string Sid { get; set; } + + public DateTime CreatedUtc { get; set; } + public DateTime LastActivityUtc { get; set; } + + /// Last successful check of the account at the provider + public DateTime LastValidatedUtc { get; set; } + + /// Refresh token protected with Data Protection; cleared when the session ends + public string ProtectedRefreshToken { get; set; } + + /// Holder of the refresh in progress (one at a time on every instance); empty when none + public string RefreshLeaseId { get; set; } + + /// The lease lapses at this time, so a holder that died does not block the next refresh + public DateTime? RefreshLeaseUntilUtc { get; set; } + + public DateTime? EndedUtc { get; set; } + public OidcSessionEndReason? EndReason { get; set; } +} + +public enum OidcSessionEndReason +{ + SignOut = 1, + BackChannel = 2, + Idle = 3, + MaxLifetime = 4, + RevalidationRefused = 5, + RevalidationUnavailable = 6, + NoRefreshToken = 7 +} diff --git a/src/Core/Grand.Infrastructure/Caching/Constants/CustomerCacheKey.cs b/src/Core/Grand.Infrastructure/Caching/Constants/CustomerCacheKey.cs index 49f6c642cd..61238fc10e 100644 --- a/src/Core/Grand.Infrastructure/Caching/Constants/CustomerCacheKey.cs +++ b/src/Core/Grand.Infrastructure/Caching/Constants/CustomerCacheKey.cs @@ -113,5 +113,18 @@ public static partial class CacheKey /// public static string SALESEMPLOYEE_PATTERN_KEY => "Grand.salesemployee."; + /// + /// Key for an OpenID Connect session + /// + /// + /// {0} : session ID + /// + public static string OIDC_SESSION_BY_ID_KEY => "Grand.oidcsession.id-{0}"; + + /// + /// Key pattern to clear cache + /// + public static string OIDC_SESSION_PATTERN_KEY => "Grand.oidcsession."; + #endregion } \ No newline at end of file diff --git a/src/Core/Grand.Infrastructure/Configuration/OidcLoginConfig.cs b/src/Core/Grand.Infrastructure/Configuration/OidcLoginConfig.cs new file mode 100644 index 0000000000..352d148778 --- /dev/null +++ b/src/Core/Grand.Infrastructure/Configuration/OidcLoginConfig.cs @@ -0,0 +1,47 @@ +namespace Grand.Infrastructure.Configuration; + +/// +/// appsettings section OidcLogin: the client of this store at the identity provider configured in OidcResourceServer, +/// and the policy for sessions started by a provider sign-in +/// +public class OidcLoginConfig +{ + private const int DefaultStaffIdleTimeoutMinutes = 30; + private const int DefaultStaffMaxSessionHours = 10; + private const int DefaultRevalidationMinutes = 10; + + public string ClientId { get; set; } + public string ClientSecret { get; set; } + public string Scopes { get; set; } = "openid profile email offline_access"; + + /// A back-office session ends after this much inactivity (sliding) + public int StaffIdleTimeoutMinutes { get; set; } = DefaultStaffIdleTimeoutMinutes; + + /// A back-office session ends this long after the sign-in + public int StaffMaxSessionHours { get; set; } = DefaultStaffMaxSessionHours; + + /// + /// Every session started by a provider sign-in confirms the account at the identity provider this often (refresh + /// token exchange): an account disabled or deleted there loses GrandNode within this time + /// + public int RevalidationMinutes { get; set; } = DefaultRevalidationMinutes; + + /// + /// Back-office accounts connected to the identity provider with their password sign in only through the provider; + /// an account without such a connection keeps its password (break-glass) + /// + public bool StaffSsoOnly { get; set; } + + //zero or less is a configuration mistake, never "check on every request" or "end every session at once" + public TimeSpan StaffIdleTimeout => + TimeSpan.FromMinutes(StaffIdleTimeoutMinutes > 0 ? StaffIdleTimeoutMinutes : DefaultStaffIdleTimeoutMinutes); + + public TimeSpan StaffMaxSession => + TimeSpan.FromHours(StaffMaxSessionHours > 0 ? StaffMaxSessionHours : DefaultStaffMaxSessionHours); + + public TimeSpan RevalidationInterval => + TimeSpan.FromMinutes(RevalidationMinutes > 0 ? RevalidationMinutes : DefaultRevalidationMinutes); + + public bool IsConfigured(OidcResourceServerConfig resourceServer) => + !string.IsNullOrWhiteSpace(resourceServer?.Authority) && !string.IsNullOrWhiteSpace(ClientId); +} diff --git a/src/Core/Grand.Infrastructure/StartupBase.cs b/src/Core/Grand.Infrastructure/StartupBase.cs index 777c132313..e2a1af27bb 100644 --- a/src/Core/Grand.Infrastructure/StartupBase.cs +++ b/src/Core/Grand.Infrastructure/StartupBase.cs @@ -222,6 +222,7 @@ private static void RegisterConfigurations(IServiceCollection services, IConfigu services.StartupConfig(configuration.GetSection("BackendAPI")); services.StartupConfig(configuration.GetSection("FrontendAPI")); services.StartupConfig(configuration.GetSection("OidcResourceServer")); + services.StartupConfig(configuration.GetSection("OidcLogin")); services.StartupConfig(configuration.GetSection("Database")); services.StartupConfig(configuration.GetSection("Amazon")); services.StartupConfig(configuration.GetSection("Azure")); diff --git a/src/Modules/Grand.Module.Installer/Services/InstallDataScheduleTasks.cs b/src/Modules/Grand.Module.Installer/Services/InstallDataScheduleTasks.cs index c5ec64e539..5837de4c14 100644 --- a/src/Modules/Grand.Module.Installer/Services/InstallDataScheduleTasks.cs +++ b/src/Modules/Grand.Module.Installer/Services/InstallDataScheduleTasks.cs @@ -51,6 +51,12 @@ protected virtual Task InstallScheduleTasks() Enabled = false, StopOnError = false, TimeInterval = 1440 + }, + new() { + ScheduleTaskName = "Delete OpenID Connect sessions", + Enabled = true, + StopOnError = false, + TimeInterval = 1440 } }; tasks.ForEach(x => _scheduleTaskRepository.Insert(x)); diff --git a/src/Modules/Grand.Module.Installer/Services/InstallationService.cs b/src/Modules/Grand.Module.Installer/Services/InstallationService.cs index 10f5fc5ca2..e61e4557ba 100644 --- a/src/Modules/Grand.Module.Installer/Services/InstallationService.cs +++ b/src/Modules/Grand.Module.Installer/Services/InstallationService.cs @@ -122,6 +122,7 @@ public InstallationService( IRepository permissionRepository, IRepository permissionAction, IRepository externalAuthenticationRepository, + IRepository oidcSessionRepository, IRepository merchandiseReturnReasonRepository, IRepository merchandiseReturnActionRepository, IRepository contactUsRepository, @@ -218,6 +219,7 @@ public InstallationService( _permissionAction = permissionAction; _vendorRepository = vendorRepository; _externalAuthenticationRepository = externalAuthenticationRepository; + _oidcSessionRepository = oidcSessionRepository; _discountusageRepository = discountusageRepository; _merchandiseReturnReasonRepository = merchandiseReturnReasonRepository; _contactUsRepository = contactUsRepository; @@ -384,6 +386,7 @@ public virtual async Task InstallData(string httpscheme, HostString host, string private readonly IRepository _permissionRepository; private readonly IRepository _permissionAction; private readonly IRepository _externalAuthenticationRepository; + private readonly IRepository _oidcSessionRepository; private readonly IRepository _merchandiseReturnReasonRepository; private readonly IRepository _merchandiseReturnActionRepository; private readonly IRepository _contactUsRepository; @@ -918,6 +921,16 @@ await dbContext.CreateIndex(_externalAuthenticationRepository, .Ascending(x => x.ExternalIdentifier).Ascending(x => x.StoreId), "ProviderSystemName_ExternalIdentifier_StoreId", true); + //OpenID Connect sessions: back-channel logout looks up by sid and subject, the cleanup by end time + await dbContext.CreateIndex(_oidcSessionRepository, + OrderBuilder.Create().Ascending(x => x.Sid), "Sid"); + await dbContext.CreateIndex(_oidcSessionRepository, + OrderBuilder.Create().Ascending(x => x.Subject), "Subject"); + await dbContext.CreateIndex(_oidcSessionRepository, + OrderBuilder.Create().Ascending(x => x.CustomerId), "CustomerId"); + await dbContext.CreateIndex(_oidcSessionRepository, + OrderBuilder.Create().Ascending(x => x.EndedUtc), "EndedUtc"); + //merchandise return await dbContext.CreateIndex(_merchandiseReturnRepository, OrderBuilder.Create().Ascending(x => x.ReturnNumber), "ReturnNumber", true); diff --git a/src/Modules/Grand.Module.Migration/Migrations/2.5/MigrationOidcSessions.cs b/src/Modules/Grand.Module.Migration/Migrations/2.5/MigrationOidcSessions.cs new file mode 100644 index 0000000000..a30c7073c1 --- /dev/null +++ b/src/Modules/Grand.Module.Migration/Migrations/2.5/MigrationOidcSessions.cs @@ -0,0 +1,56 @@ +using Grand.Data; +using Grand.Domain.Customers; +using Grand.Domain.Tasks; +using Grand.Infrastructure.Migrations; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Grand.Module.Migration.Migrations._2._5; + +public class MigrationOidcSessions : IMigration +{ + private const string TaskName = "Delete OpenID Connect sessions"; + + public int Priority => 2; + public DbVersion Version => new(2, 5); + public Guid Identity => new("3f6a9e21-7c4b-4d0e-9b8a-5e2c1d7f4a63"); + public string Name => "OpenID Connect sessions: indexes and cleanup task"; + + public bool UpgradeProcess(IServiceProvider serviceProvider) + { + var sessionRepository = serviceProvider.GetRequiredService>(); + var taskRepository = serviceProvider.GetRequiredService>(); + var databaseContext = serviceProvider.GetRequiredService(); + var logService = serviceProvider.GetRequiredService>(); + + try + { + databaseContext.CreateIndex(sessionRepository, + OrderBuilder.Create().Ascending(x => x.Sid), "Sid").GetAwaiter().GetResult(); + databaseContext.CreateIndex(sessionRepository, + OrderBuilder.Create().Ascending(x => x.Subject), "Subject").GetAwaiter().GetResult(); + databaseContext.CreateIndex(sessionRepository, + OrderBuilder.Create().Ascending(x => x.CustomerId), "CustomerId").GetAwaiter().GetResult(); + databaseContext.CreateIndex(sessionRepository, + OrderBuilder.Create().Ascending(x => x.EndedUtc), "EndedUtc").GetAwaiter().GetResult(); + } + catch (Exception ex) + { + logService.LogError(ex, "UpgradeProcess - OidcSessions indexes (2.5)"); + } + + try + { + if (!taskRepository.Table.Any(x => x.ScheduleTaskName == TaskName)) + taskRepository.Insert(new ScheduleTask { + ScheduleTaskName = TaskName, Enabled = true, StopOnError = false, TimeInterval = 1440 + }); + } + catch (Exception ex) + { + logService.LogError(ex, "UpgradeProcess - OidcSessions schedule task (2.5)"); + } + + return true; + } +} diff --git a/src/Modules/Grand.Module.ScheduledTasks/BackgroundServices/DeleteOidcSessionsScheduleTask.cs b/src/Modules/Grand.Module.ScheduledTasks/BackgroundServices/DeleteOidcSessionsScheduleTask.cs new file mode 100644 index 0000000000..c004f06685 --- /dev/null +++ b/src/Modules/Grand.Module.ScheduledTasks/BackgroundServices/DeleteOidcSessionsScheduleTask.cs @@ -0,0 +1,12 @@ +using Grand.Business.Core.Interfaces.Authentication; +using Grand.Business.Core.Interfaces.System.ScheduleTasks; + +namespace Grand.Module.ScheduledTasks.BackgroundServices; + +/// +/// Deletes the records of ended OpenID Connect sessions and of sessions past their lifetime +/// +public class DeleteOidcSessionsScheduleTask(IOidcSessionService oidcSessionService) : IScheduleTask +{ + public Task Execute() => oidcSessionService.DeleteExpired(); +} diff --git a/src/Modules/Grand.Module.ScheduledTasks/Startup/StartupApplication.cs b/src/Modules/Grand.Module.ScheduledTasks/Startup/StartupApplication.cs index 6f620d782f..894d5b4dcc 100644 --- a/src/Modules/Grand.Module.ScheduledTasks/Startup/StartupApplication.cs +++ b/src/Modules/Grand.Module.ScheduledTasks/Startup/StartupApplication.cs @@ -30,6 +30,7 @@ private static void RegisterTask(IServiceCollection serviceCollection) serviceCollection.AddKeyedScoped("Clear cache"); serviceCollection.AddKeyedScoped("Generate sitemap XML file"); serviceCollection.AddKeyedScoped("Delete guests"); + serviceCollection.AddKeyedScoped("Delete OpenID Connect sessions"); serviceCollection.AddKeyedScoped("Update currency exchange rates"); serviceCollection.AddKeyedScoped("End of the auctions"); serviceCollection.AddKeyedScoped("Cancel unpaid and pending orders"); diff --git a/src/Plugins/Authentication.OpenIdConnect/Infrastructure/StartupApplication.cs b/src/Plugins/Authentication.OpenIdConnect/Infrastructure/StartupApplication.cs index 6b5dbe7689..c7bd259abd 100644 --- a/src/Plugins/Authentication.OpenIdConnect/Infrastructure/StartupApplication.cs +++ b/src/Plugins/Authentication.OpenIdConnect/Infrastructure/StartupApplication.cs @@ -11,10 +11,6 @@ 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(); } diff --git a/src/Plugins/Authentication.OpenIdConnect/OidcLoginConfig.cs b/src/Plugins/Authentication.OpenIdConnect/OidcLoginConfig.cs deleted file mode 100644 index 7b743af677..0000000000 --- a/src/Plugins/Authentication.OpenIdConnect/OidcLoginConfig.cs +++ /dev/null @@ -1,16 +0,0 @@ -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/Tests/Grand.Business.Authentication.Tests/Services/BackOfficeAccountExtensionsTests.cs b/src/Tests/Grand.Business.Authentication.Tests/Services/BackOfficeAccountExtensionsTests.cs new file mode 100644 index 0000000000..3af9bbee6a --- /dev/null +++ b/src/Tests/Grand.Business.Authentication.Tests/Services/BackOfficeAccountExtensionsTests.cs @@ -0,0 +1,36 @@ +using Grand.Business.Core.Extensions; +using Grand.Business.Core.Interfaces.Common.Security; +using Grand.Domain.Customers; +using Grand.Domain.Permissions; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Business.Authentication.Tests.Services; + +[TestClass] +public class BackOfficeAccountExtensionsTests +{ + private static Mock Permissions(bool adminPanel) + { + var permissions = new Mock(); + permissions.Setup(x => x.Authorize(StandardPermission.ManageAccessAdminPanel, It.IsAny())) + .ReturnsAsync(adminPanel); + return permissions; + } + + [TestMethod] + public async Task Customer_NotBackOffice() => + Assert.IsFalse(await Permissions(false).Object.IsBackOfficeAccount(new Customer())); + + [TestMethod] + public async Task AdminPanelAccess_BackOffice() => + Assert.IsTrue(await Permissions(true).Object.IsBackOfficeAccount(new Customer())); + + [TestMethod] + public async Task Vendor_BackOffice() => + Assert.IsTrue(await Permissions(false).Object.IsBackOfficeAccount(new Customer { VendorId = "v1" })); + + [TestMethod] + public async Task StoreStaff_BackOffice() => + Assert.IsTrue(await Permissions(false).Object.IsBackOfficeAccount(new Customer { StaffStoreId = "s1" })); +} diff --git a/src/Tests/Grand.Business.Authentication.Tests/Services/CookieAuthenticationServiceTests.cs b/src/Tests/Grand.Business.Authentication.Tests/Services/CookieAuthenticationServiceTests.cs index 2599bcbd9c..09fb202606 100644 --- a/src/Tests/Grand.Business.Authentication.Tests/Services/CookieAuthenticationServiceTests.cs +++ b/src/Tests/Grand.Business.Authentication.Tests/Services/CookieAuthenticationServiceTests.cs @@ -1,11 +1,14 @@ using Grand.Business.Authentication.Services; +using Grand.Business.Core.Interfaces.Authentication; using Grand.Business.Core.Interfaces.Common.Directory; using Grand.Business.Core.Interfaces.Customers; using Grand.Business.Core.Utilities.Authentication; +using Grand.Domain.Common; using Grand.Domain.Customers; using Grand.Infrastructure.Configuration; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging.Abstractions; using Microsoft.VisualStudio.TestTools.UnitTesting; using Moq; using System.Security.Claims; @@ -25,6 +28,7 @@ public class CookieAuthenticationServiceTests private DefaultHttpContext _httpContext; private Mock serviceProviderMock; private CookieOptionsFactory _cookieOptionsFactory; + private Mock _oidcSessionService; [TestInitialize] public void Init() @@ -39,8 +43,10 @@ public void Init() CookiePrefix = ".Grand." }; _cookieOptionsFactory = new CookieOptionsFactory(_config); + _oidcSessionService = new Mock(); _cookieAuthService = new CookieAuthenticationService(_customerSettings, _customerServiceMock.Object, - _groupServiceMock.Object, _httpAccessorMock.Object, _cookieOptionsFactory, _config); + _groupServiceMock.Object, _httpAccessorMock.Object, _cookieOptionsFactory, _config, + _oidcSessionService.Object, NullLogger.Instance); //For mock HttpContext extension methods like SignOutAsync ,SignInAsync etc.. _authServiceMock = new Mock(); serviceProviderMock = new Mock(); @@ -155,4 +161,222 @@ public async Task GetAuthenticatedCustomer_UsernameEnableGuests_ReturnNull() Assert.IsNull(customer); //_customerServiceMock.Verify(c => c.GetCustomerByUsername(It.IsAny()), Times.Once); } -} \ No newline at end of file + + private static AuthenticateResult Ticket(params Claim[] extra) + { + var claims = new List { + new("grand:customerId", "c1", ClaimValueTypes.String, "grandnode"), + new(ClaimTypes.UserData, "token", ClaimValueTypes.String, "grandnode") + }; + claims.AddRange(extra); + return AuthenticateResult.Success(new AuthenticationTicket( + new ClaimsPrincipal(new ClaimsIdentity(claims, GrandCookieAuthenticationDefaults.AuthenticationScheme)), + GrandCookieAuthenticationDefaults.AuthenticationScheme)); + } + + private Customer RegisteredCustomer() + { + var customer = new Customer { Id = "c1", Active = true }; + customer.UserFields.Add(new UserField { Key = SystemCustomerFieldNames.PasswordToken, Value = "token", StoreId = "" }); + _customerServiceMock.Setup(x => x.GetCustomerById("c1")).ReturnsAsync(customer); + _groupServiceMock.Setup(x => x.IsRegistered(customer)).ReturnsAsync(true); + return customer; + } + + [TestMethod] + public async Task SignIn_WithOidcSession_AddsClaim() + { + ClaimsPrincipal signedIn = null; + _authServiceMock.Setup(x => x.SignInAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .Callback((HttpContext _, string _, ClaimsPrincipal p, AuthenticationProperties _) => signedIn = p) + .Returns(Task.CompletedTask); + var customer = new Customer { Id = "c1", Email = "a@b.c" }; + customer.UserFields.Add(new UserField { Key = SystemCustomerFieldNames.PasswordToken, Value = "token", StoreId = "" }); + + await _cookieAuthService.SignIn(customer, false, "session-1"); + + var claim = signedIn.FindFirst(OidcSessionDefaults.SessionIdClaimType); + Assert.AreEqual("session-1", claim.Value); + Assert.AreEqual("grandnode", claim.Issuer); + } + + [TestMethod] + public async Task GetAuthenticatedCustomer_NoSessionClaim_SessionServiceNotAsked() + { + var customer = RegisteredCustomer(); + _authServiceMock.Setup(x => x.AuthenticateAsync(It.IsAny(), It.IsAny())).ReturnsAsync(Ticket()); + Assert.AreSame(customer, await _cookieAuthService.GetAuthenticatedCustomer()); + _oidcSessionService.Verify(x => x.Validate(It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task GetAuthenticatedCustomer_EndedSession_Null() + { + var customer = RegisteredCustomer(); + _authServiceMock.Setup(x => x.AuthenticateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Ticket(new Claim(OidcSessionDefaults.SessionIdClaimType, "session-1", ClaimValueTypes.String, "grandnode"))); + _oidcSessionService.Setup(x => x.Validate("session-1", customer)).ReturnsAsync(false); + Assert.IsNull(await _cookieAuthService.GetAuthenticatedCustomer()); + } + + [TestMethod] + public async Task GetAuthenticatedCustomer_ValidSession_ReturnCustomer() + { + var customer = RegisteredCustomer(); + _authServiceMock.Setup(x => x.AuthenticateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Ticket(new Claim(OidcSessionDefaults.SessionIdClaimType, "session-1", ClaimValueTypes.String, "grandnode"))); + _oidcSessionService.Setup(x => x.Validate("session-1", customer)).ReturnsAsync(true); + Assert.AreSame(customer, await _cookieAuthService.GetAuthenticatedCustomer()); + } + + //a claim not issued by the cookie (another issuer) is not a session reference + [TestMethod] + public async Task GetAuthenticatedCustomer_SessionClaimOfOtherIssuer_Ignored() + { + var customer = RegisteredCustomer(); + _authServiceMock.Setup(x => x.AuthenticateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Ticket(new Claim(OidcSessionDefaults.SessionIdClaimType, "session-1", ClaimValueTypes.String, "other"))); + Assert.AreSame(customer, await _cookieAuthService.GetAuthenticatedCustomer()); + _oidcSessionService.Verify(x => x.Validate(It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task SignOut_EndsOidcSession() + { + _authServiceMock.Setup(x => x.AuthenticateAsync(It.IsAny(), GrandCookieAuthenticationDefaults.AuthenticationScheme)) + .ReturnsAsync(Ticket(new Claim(OidcSessionDefaults.SessionIdClaimType, "session-1", ClaimValueTypes.String, "grandnode"))); + await _cookieAuthService.SignOut(); + _oidcSessionService.Verify(x => x.End("session-1", OidcSessionEndReason.SignOut), Times.Once); + } + + private (Customer customer, Func signedIn) PrepareSignIn(string customerId) + { + ClaimsPrincipal signedIn = null; + _authServiceMock.Setup(x => x.SignInAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .Callback((HttpContext _, string _, ClaimsPrincipal p, AuthenticationProperties _) => signedIn = p) + .Returns(Task.CompletedTask); + var customer = new Customer { Id = customerId, Email = "a@b.c" }; + customer.UserFields.Add(new UserField { Key = SystemCustomerFieldNames.PasswordToken, Value = "token", StoreId = "" }); + return (customer, () => signedIn); + } + + //re-sign-in (password, e-mail or username change) keeps the session of a provider sign-in + [TestMethod] + public async Task SignIn_WithoutSessionId_SameCustomerCookieWithSession_CarriesSessionOver() + { + var (customer, signedIn) = PrepareSignIn("c1"); + _authServiceMock.Setup(x => x.AuthenticateAsync(It.IsAny(), GrandCookieAuthenticationDefaults.AuthenticationScheme)) + .ReturnsAsync(Ticket(new Claim(OidcSessionDefaults.SessionIdClaimType, "session-1", ClaimValueTypes.String, "grandnode"))); + + await _cookieAuthService.SignIn(customer, false); + + var claim = signedIn().FindFirst(OidcSessionDefaults.SessionIdClaimType); + Assert.IsNotNull(claim); + Assert.AreEqual("session-1", claim.Value); + Assert.AreEqual("grandnode", claim.Issuer); + } + + [TestMethod] + public async Task SignIn_WithoutSessionId_OtherCustomerCookieWithSession_NoCarryOver() + { + var (customer, signedIn) = PrepareSignIn("c2"); + _authServiceMock.Setup(x => x.AuthenticateAsync(It.IsAny(), GrandCookieAuthenticationDefaults.AuthenticationScheme)) + .ReturnsAsync(Ticket(new Claim(OidcSessionDefaults.SessionIdClaimType, "session-1", ClaimValueTypes.String, "grandnode"))); + + await _cookieAuthService.SignIn(customer, false); + + Assert.IsNull(signedIn().FindFirst(OidcSessionDefaults.SessionIdClaimType)); + } + + [TestMethod] + public async Task SignIn_WithoutSessionId_SessionClaimOfOtherIssuer_NoCarryOver() + { + var (customer, signedIn) = PrepareSignIn("c1"); + _authServiceMock.Setup(x => x.AuthenticateAsync(It.IsAny(), GrandCookieAuthenticationDefaults.AuthenticationScheme)) + .ReturnsAsync(Ticket(new Claim(OidcSessionDefaults.SessionIdClaimType, "session-1", ClaimValueTypes.String, "other"))); + + await _cookieAuthService.SignIn(customer, false); + + Assert.IsNull(signedIn().FindFirst(OidcSessionDefaults.SessionIdClaimType)); + } + + //signing in through the provider again: the replaced session is ended, not left open with its refresh token + [TestMethod] + public async Task SignIn_NewSession_SameCustomerCookieWithOtherSession_EndsPrevious() + { + var (customer, signedIn) = PrepareSignIn("c1"); + _authServiceMock.Setup(x => x.AuthenticateAsync(It.IsAny(), GrandCookieAuthenticationDefaults.AuthenticationScheme)) + .ReturnsAsync(Ticket(new Claim(OidcSessionDefaults.SessionIdClaimType, "session-1", ClaimValueTypes.String, "grandnode"))); + + await _cookieAuthService.SignIn(customer, false, "session-2"); + + _oidcSessionService.Verify(x => x.End("session-1", OidcSessionEndReason.SignOut), Times.Once); + Assert.AreEqual("session-2", signedIn().FindFirst(OidcSessionDefaults.SessionIdClaimType)!.Value); + } + + [TestMethod] + public async Task SignIn_NewSession_OtherCustomerCookie_PreviousNotEnded() + { + var (customer, _) = PrepareSignIn("c2"); + _authServiceMock.Setup(x => x.AuthenticateAsync(It.IsAny(), GrandCookieAuthenticationDefaults.AuthenticationScheme)) + .ReturnsAsync(Ticket(new Claim(OidcSessionDefaults.SessionIdClaimType, "session-1", ClaimValueTypes.String, "grandnode"))); + + await _cookieAuthService.SignIn(customer, false, "session-2"); + + _oidcSessionService.Verify(x => x.End(It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task SignIn_WithoutSessionId_NoCurrentCookie_NoClaim() + { + var (customer, signedIn) = PrepareSignIn("c1"); + _authServiceMock.Setup(x => x.AuthenticateAsync(It.IsAny(), GrandCookieAuthenticationDefaults.AuthenticationScheme)) + .ReturnsAsync(AuthenticateResult.NoResult()); + + await _cookieAuthService.SignIn(customer, false); + + Assert.IsNull(signedIn().FindFirst(OidcSessionDefaults.SessionIdClaimType)); + } + + [TestMethod] + public async Task SignIn_WithoutSessionId_CurrentCookieWithoutSession_NoClaim() + { + var (customer, signedIn) = PrepareSignIn("c1"); + _authServiceMock.Setup(x => x.AuthenticateAsync(It.IsAny(), GrandCookieAuthenticationDefaults.AuthenticationScheme)) + .ReturnsAsync(Ticket()); + + await _cookieAuthService.SignIn(customer, false); + + Assert.IsNull(signedIn().FindFirst(OidcSessionDefaults.SessionIdClaimType)); + } + + [TestMethod] + public async Task SignOut_NoSessionClaim_EndNeverCalled() + { + _authServiceMock.Setup(x => x.AuthenticateAsync(It.IsAny(), GrandCookieAuthenticationDefaults.AuthenticationScheme)) + .ReturnsAsync(Ticket()); + await _cookieAuthService.SignOut(); + _oidcSessionService.Verify(x => x.End(It.IsAny(), It.IsAny()), Times.Never); + _authServiceMock.Verify(x => x.SignOutAsync(It.IsAny(), GrandCookieAuthenticationDefaults.AuthenticationScheme, + It.IsAny()), Times.Once); + } + + //a failing session store must not keep the customer signed in + [TestMethod] + public async Task SignOut_EndThrows_StillSignsOut() + { + _authServiceMock.Setup(x => x.AuthenticateAsync(It.IsAny(), GrandCookieAuthenticationDefaults.AuthenticationScheme)) + .ReturnsAsync(Ticket(new Claim(OidcSessionDefaults.SessionIdClaimType, "session-1", ClaimValueTypes.String, "grandnode"))); + _oidcSessionService.Setup(x => x.End("session-1", OidcSessionEndReason.SignOut)) + .ThrowsAsync(new InvalidOperationException("database down")); + + await _cookieAuthService.SignOut(); + + _authServiceMock.Verify(x => x.SignOutAsync(It.IsAny(), GrandCookieAuthenticationDefaults.AuthenticationScheme, + It.IsAny()), Times.Once); + _authServiceMock.Verify(x => x.SignOutAsync(It.IsAny(), GrandCookieAuthenticationDefaults.ExternalAuthenticationScheme, + It.IsAny()), Times.Once); + } +} diff --git a/src/Tests/Grand.Business.Authentication.Tests/Services/OidcLoginConfigTests.cs b/src/Tests/Grand.Business.Authentication.Tests/Services/OidcLoginConfigTests.cs new file mode 100644 index 0000000000..bc46d7403e --- /dev/null +++ b/src/Tests/Grand.Business.Authentication.Tests/Services/OidcLoginConfigTests.cs @@ -0,0 +1,41 @@ +using Grand.Infrastructure.Configuration; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Grand.Business.Authentication.Tests.Services; + +[TestClass] +public class OidcLoginConfigTests +{ + [TestMethod] + public void Defaults() + { + var config = new OidcLoginConfig(); + Assert.AreEqual("openid profile email offline_access", config.Scopes); + Assert.AreEqual(TimeSpan.FromMinutes(30), config.StaffIdleTimeout); + Assert.AreEqual(TimeSpan.FromHours(10), config.StaffMaxSession); + Assert.AreEqual(TimeSpan.FromMinutes(10), config.RevalidationInterval); + Assert.IsFalse(config.StaffSsoOnly); + } + + //a zero in appsettings must not mean "refresh at the provider on every request" or "end every session at once" + [TestMethod] + [DataRow(0)] + [DataRow(-5)] + public void NonPositiveValues_FallBackToDefaults(int value) + { + var config = new OidcLoginConfig { + StaffIdleTimeoutMinutes = value, StaffMaxSessionHours = value, RevalidationMinutes = value + }; + Assert.AreEqual(TimeSpan.FromMinutes(30), config.StaffIdleTimeout); + Assert.AreEqual(TimeSpan.FromHours(10), config.StaffMaxSession); + Assert.AreEqual(TimeSpan.FromMinutes(10), config.RevalidationInterval); + } + + [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 })); +} diff --git a/src/Tests/Grand.Business.Authentication.Tests/Services/OidcSessionServiceTests.cs b/src/Tests/Grand.Business.Authentication.Tests/Services/OidcSessionServiceTests.cs new file mode 100644 index 0000000000..52ac0220c4 --- /dev/null +++ b/src/Tests/Grand.Business.Authentication.Tests/Services/OidcSessionServiceTests.cs @@ -0,0 +1,579 @@ +using Grand.Business.Authentication.Services; +using Grand.Business.Core.Interfaces.Authentication; +using Grand.Business.Core.Interfaces.Common.Security; +using Grand.Business.Core.Utilities.Authentication; +using Grand.Data; +using Grand.Data.Tests.MongoDb; +using Grand.Domain.Customers; +using Grand.Domain.Permissions; +using Grand.Infrastructure.Caching; +using Grand.Infrastructure.Caching.Constants; +using Grand.Infrastructure.Configuration; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Business.Authentication.Tests.Services; + +[TestClass] +public class OidcSessionServiceTests +{ + private IRepository _sessions; + private Mock _cache; + private Mock _permissions; + private Mock _revocation; + private Mock _refresher; + private List _refreshers; + private IDataProtectionProvider _dataProtection; + private OidcLoginConfig _config; + private SecurityConfig _securityConfig; + private bool _staff; + + private readonly Customer _customer = new() { Id = "c1" }; + + [TestInitialize] + public void Init() + { + _sessions = new MongoDBRepositoryTest(); + _cache = new Mock(); + //read-through without caching: every call sees the repository + _cache.Setup(x => x.GetAsync(It.IsAny(), It.IsAny>>(), It.IsAny())) + .Returns((string _, Func> acquire, int _) => acquire()); + _permissions = new Mock(); + _permissions.Setup(x => x.Authorize(StandardPermission.ManageAccessAdminPanel, It.IsAny())) + .ReturnsAsync(() => _staff); + _revocation = new Mock(); + _refresher = new Mock(); + _refreshers = [_refresher.Object]; + _dataProtection = new EphemeralDataProtectionProvider(); + _config = new OidcLoginConfig(); + _securityConfig = new SecurityConfig { CookieAuthExpires = 24 }; + _staff = false; + } + + private OidcSessionService Service() => + new(_sessions, _cache.Object, _permissions.Object, _revocation.Object, _refreshers, _dataProtection, _config, + _securityConfig, NullLogger.Instance); + + private string Protect(string token) => + _dataProtection.CreateProtector(OidcSessionDefaults.RefreshTokenPurpose).Protect(token); + + private string Unprotect(string token) => + _dataProtection.CreateProtector(OidcSessionDefaults.RefreshTokenPurpose).Unprotect(token); + + //a session as it would be stored, with clocks moved back by the given amounts + private async Task Session(TimeSpan? age = null, TimeSpan? idle = null, TimeSpan? sinceValidated = null, + string refreshToken = "rt-1", string sid = "sid-1", string subject = "sub-1", string customerId = "c1") + { + var now = DateTime.UtcNow; + var session = new OidcSession { + CustomerId = customerId, StoreId = "s1", Subject = subject, Sid = sid, + CreatedUtc = now - (age ?? TimeSpan.Zero), + LastActivityUtc = now - (idle ?? TimeSpan.Zero), + LastValidatedUtc = now - (sinceValidated ?? TimeSpan.Zero), + ProtectedRefreshToken = refreshToken == null ? null : Protect(refreshToken) + }; + await _sessions.InsertAsync(session); + return session; + } + + private OidcSession Stored(string id) => _sessions.Table.First(x => x.Id == id); + + [TestMethod] + public async Task Start_Customer_RefreshTokenProtected() + { + var id = await Service().Start(_customer, "sub-1", "sid-1", "rt-1", "s1"); + var session = Stored(id); + Assert.AreEqual("rt-1", Unprotect(session.ProtectedRefreshToken)); + Assert.AreEqual("sid-1", session.Sid); + } + + [TestMethod] + public async Task Start_Staff_RefreshTokenProtected() + { + _staff = true; + var id = await Service().Start(_customer, "sub-1", null, "rt-1", "s1"); + var session = Stored(id); + Assert.AreNotEqual("rt-1", session.ProtectedRefreshToken); + Assert.AreEqual("rt-1", Unprotect(session.ProtectedRefreshToken)); + Assert.AreEqual("", session.Sid); + } + + //any account: a session without a refresh token could never be revalidated + [TestMethod] + [DataRow(false)] + [DataRow(true)] + public async Task Start_WithoutRefreshToken_Null(bool staff) + { + _staff = staff; + Assert.IsNull(await Service().Start(_customer, "sub-1", "sid-1", null, "s1")); + Assert.AreEqual(0, _sessions.Table.Count()); + } + + [TestMethod] + public async Task End_SetsReason_OnlyOnce() + { + var session = await Session(); + await Service().End(session.Id, OidcSessionEndReason.SignOut); + var ended = Stored(session.Id).EndedUtc; + await Service().End(session.Id, OidcSessionEndReason.Idle); + Assert.AreEqual(OidcSessionEndReason.SignOut, Stored(session.Id).EndReason); + Assert.AreEqual(ended, Stored(session.Id).EndedUtc); + _cache.Verify(x => x.RemoveAsync(string.Format(CacheKey.OIDC_SESSION_BY_ID_KEY, session.Id), true), Times.AtLeastOnce); + } + + //an ended session is never confirmed again: its refresh token is not kept for the retention period + [TestMethod] + public async Task End_ClearsRefreshToken() + { + var session = await Session(); + await Service().End(session.Id, OidcSessionEndReason.SignOut); + Assert.IsNull(Stored(session.Id).ProtectedRefreshToken); + } + + [TestMethod] + public async Task EndBySid_MatchingSidAndSubject_Only() + { + var match = await Session(sid: "sid-1", subject: "sub-1"); + var otherSid = await Session(sid: "sid-2", subject: "sub-1"); + var otherSubject = await Session(sid: "sid-1", subject: "sub-2"); + + var ended = await Service().EndBySid("sub-1", "sid-1"); + + CollectionAssert.AreEqual(new[] { match.Id }, ended.Select(x => x.Id).ToArray()); + Assert.AreEqual(OidcSessionEndReason.BackChannel, Stored(match.Id).EndReason); + Assert.IsNull(Stored(otherSid.Id).EndedUtc); + Assert.IsNull(Stored(otherSubject.Id).EndedUtc); + } + + //a logout token with sid only: every session of that provider session, whatever the subject + [TestMethod] + public async Task EndBySid_NoSubject_AllWithSid() + { + var a = await Session(sid: "sid-1", subject: "sub-1"); + var b = await Session(sid: "sid-1", subject: "sub-2"); + var ended = await Service().EndBySid(null, "sid-1"); + CollectionAssert.AreEquivalent(new[] { a.Id, b.Id }, ended.Select(x => x.Id).ToArray()); + } + + [TestMethod] + public async Task EndBySubject_AllOpenSessionsOfSubject() + { + var a = await Session(sid: "sid-1"); + var b = await Session(sid: ""); + var other = await Session(subject: "sub-2"); + var ended = await Service().EndBySubject("sub-1"); + CollectionAssert.AreEquivalent(new[] { a.Id, b.Id }, ended.Select(x => x.Id).ToArray()); + Assert.IsNull(Stored(other.Id).EndedUtc); + } + + [TestMethod] + public async Task DeleteExpired_KeepsRecentAndOpen() + { + _securityConfig.CookieAuthExpires = 24; + var endedLongAgo = await Session(); + await _sessions.UpdateField(endedLongAgo.Id, x => x.EndedUtc, DateTime.UtcNow.AddDays(-8)); + var endedRecently = await Session(); + await _sessions.UpdateField(endedRecently.Id, x => x.EndedUtc, DateTime.UtcNow.AddDays(-1)); + //past the cookie lifetime (24 h here) + retention, whoever it belonged to + var expired = await Session(age: TimeSpan.FromHours(24) + TimeSpan.FromDays(8)); + var open = await Session(age: TimeSpan.FromHours(30)); + + await Service().DeleteExpired(); + + CollectionAssert.AreEquivalent(new[] { endedRecently.Id, open.Id }, _sessions.Table.Select(x => x.Id).ToArray()); + } + + //most sessions end with the browser: one not confirmed for the abandonment period is deleted with its refresh token + [TestMethod] + public async Task DeleteExpired_OpenSessionNotConfirmedForLong_Deleted() + { + _securityConfig.CookieAuthExpires = 8760; + var abandoned = await Session(age: TimeSpan.FromDays(40), sinceValidated: OidcSessionDefaults.AbandonedAfter + TimeSpan.FromHours(1)); + var inUse = await Session(age: TimeSpan.FromDays(40), sinceValidated: TimeSpan.FromMinutes(5)); + + await Service().DeleteExpired(); + + CollectionAssert.AreEquivalent(new[] { inUse.Id }, _sessions.Table.Select(x => x.Id).ToArray()); + } + + //every session keeps a refresh token now: the old staff rule (refresh token kept + past staff maximum) would delete live customers + [TestMethod] + public async Task DeleteExpired_KeepsActiveCustomerWithRefreshToken() + { + _securityConfig.CookieAuthExpires = 8760; + var customer = await Session(age: TimeSpan.FromDays(20)); + await Service().DeleteExpired(); + Assert.AreEqual(1, _sessions.Table.Count(x => x.Id == customer.Id)); + } + + private static readonly TimeSpan Interval = TimeSpan.FromMinutes(10); + + [TestMethod] + public async Task Validate_MissingOrEmptyId_False() + { + Assert.IsFalse(await Service().Validate("000000000000000000000000", _customer)); + Assert.IsFalse(await Service().Validate("", _customer)); + } + + [TestMethod] + public async Task Validate_Ended_False() + { + var session = await Session(); + await Service().End(session.Id, OidcSessionEndReason.BackChannel); + Assert.IsFalse(await Service().Validate(session.Id, _customer)); + } + + //a cookie whose session claim was copied into another customer's ticket + [TestMethod] + public async Task Validate_OtherCustomer_False() + { + var session = await Session(customerId: "c2"); + Assert.IsFalse(await Service().Validate(session.Id, _customer)); + } + + //customers keep the long session: no idle or maximum lifetime, only the provider check + [TestMethod] + public async Task Validate_Customer_OldAndIdle_WithinInterval_True_NoRefresh() + { + var session = await Session(age: TimeSpan.FromDays(30), idle: TimeSpan.FromDays(20), + sinceValidated: TimeSpan.FromMinutes(5)); + Assert.IsTrue(await Service().Validate(session.Id, _customer)); + _refresher.Verify(x => x.Refresh(It.IsAny(), It.IsAny()), Times.Never); + Assert.IsNull(Stored(session.Id).EndedUtc); + } + + [TestMethod] + public async Task Validate_CustomerRefused_EndsAndRevokesTokens() + { + _refresher.Setup(x => x.Refresh("rt-1", It.IsAny())).ReturnsAsync(OidcRefreshResult.Refused()); + var session = await Session(age: TimeSpan.FromDays(3), idle: TimeSpan.FromDays(2), sinceValidated: Interval); + Assert.IsFalse(await Service().Validate(session.Id, _customer)); + Assert.AreEqual(OidcSessionEndReason.RevalidationRefused, Stored(session.Id).EndReason); + _revocation.Verify(x => x.RevokeTokens("sub-1"), Times.Once); + } + + [TestMethod] + public async Task Validate_CustomerRefreshed_RotatedTokenStored() + { + _refresher.Setup(x => x.Refresh("rt-1", It.IsAny())).ReturnsAsync(OidcRefreshResult.Refreshed("rt-2")); + var session = await Session(sinceValidated: Interval); + Assert.IsTrue(await Service().Validate(session.Id, _customer)); + Assert.AreEqual("rt-2", Unprotect(Stored(session.Id).ProtectedRefreshToken)); + } + + [TestMethod] + public async Task Validate_CustomerUnavailable_WithinGrace_Valid() + { + _refresher.Setup(x => x.Refresh(It.IsAny(), It.IsAny())).ReturnsAsync(OidcRefreshResult.Unavailable()); + var session = await Session(sinceValidated: Interval + TimeSpan.FromMinutes(5)); + Assert.IsTrue(await Service().Validate(session.Id, _customer)); + } + + //a customer session started before every session kept a refresh token + [TestMethod] + public async Task Validate_CustomerWithoutRefreshToken_Ends() + { + var session = await Session(refreshToken: null); + Assert.IsFalse(await Service().Validate(session.Id, _customer)); + Assert.AreEqual(OidcSessionEndReason.NoRefreshToken, Stored(session.Id).EndReason); + } + + //customers' activity is not written: only the staff idle rule needs it + [TestMethod] + public async Task Validate_Customer_ActivityNotWritten() + { + var session = await Session(idle: TimeSpan.FromMinutes(5)); + var before = Stored(session.Id).LastActivityUtc; + Assert.IsTrue(await Service().Validate(session.Id, _customer)); + Assert.AreEqual(before, Stored(session.Id).LastActivityUtc); + } + + [TestMethod] + public async Task Validate_StaffIdle_Ends() + { + _staff = true; + var session = await Session(idle: TimeSpan.FromMinutes(31)); + Assert.IsFalse(await Service().Validate(session.Id, _customer)); + Assert.AreEqual(OidcSessionEndReason.Idle, Stored(session.Id).EndReason); + } + + [TestMethod] + public async Task Validate_StaffPastMaximum_Ends() + { + _staff = true; + var session = await Session(age: TimeSpan.FromHours(10) + TimeSpan.FromMinutes(1)); + Assert.IsFalse(await Service().Validate(session.Id, _customer)); + Assert.AreEqual(OidcSessionEndReason.MaxLifetime, Stored(session.Id).EndReason); + } + + //a customer promoted to staff during the session has no refresh token to check the account with + [TestMethod] + public async Task Validate_StaffWithoutRefreshToken_Ends() + { + _staff = true; + var session = await Session(refreshToken: null); + Assert.IsFalse(await Service().Validate(session.Id, _customer)); + Assert.AreEqual(OidcSessionEndReason.NoRefreshToken, Stored(session.Id).EndReason); + } + + [TestMethod] + public async Task Validate_StaffWithinInterval_NoRefresh() + { + _staff = true; + var session = await Session(sinceValidated: TimeSpan.FromMinutes(9)); + Assert.IsTrue(await Service().Validate(session.Id, _customer)); + _refresher.Verify(x => x.Refresh(It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task Validate_StaffRefreshed_RotatedTokenStored() + { + _staff = true; + _refresher.Setup(x => x.Refresh("rt-1", It.IsAny())).ReturnsAsync(OidcRefreshResult.Refreshed("rt-2")); + var session = await Session(sinceValidated: Interval); + Assert.IsTrue(await Service().Validate(session.Id, _customer)); + var stored = Stored(session.Id); + Assert.AreEqual("rt-2", Unprotect(stored.ProtectedRefreshToken)); + Assert.IsTrue(stored.LastValidatedUtc > DateTime.UtcNow.AddMinutes(-1)); + } + + [TestMethod] + public async Task Validate_StaffRefreshedWithoutRotation_OldTokenKept() + { + _staff = true; + _refresher.Setup(x => x.Refresh("rt-1", It.IsAny())).ReturnsAsync(OidcRefreshResult.Refreshed(null)); + var session = await Session(sinceValidated: Interval); + Assert.IsTrue(await Service().Validate(session.Id, _customer)); + Assert.AreEqual("rt-1", Unprotect(Stored(session.Id).ProtectedRefreshToken)); + } + + [TestMethod] + public async Task Validate_StaffRefused_EndsAndRevokesTokens() + { + _staff = true; + _refresher.Setup(x => x.Refresh("rt-1", It.IsAny())).ReturnsAsync(OidcRefreshResult.Refused()); + var session = await Session(sinceValidated: Interval); + Assert.IsFalse(await Service().Validate(session.Id, _customer)); + Assert.AreEqual(OidcSessionEndReason.RevalidationRefused, Stored(session.Id).EndReason); + _revocation.Verify(x => x.RevokeTokens("sub-1"), Times.Once); + } + + //another request (or instance) used the rotated token first and stored the new one: the refusal is about the old token + [TestMethod] + public async Task Refused_AfterAnotherRefreshStoredNewToken_StaysValid() + { + _staff = true; + var session = await Session(sinceValidated: Interval); + _refresher.Setup(x => x.Refresh("rt-1", It.IsAny())).Returns(async () => + { + await _sessions.UpdateField(session.Id, x => x.ProtectedRefreshToken, Protect("rt-2")); + return OidcRefreshResult.Refused(); + }); + Assert.IsTrue(await Service().Validate(session.Id, _customer)); + Assert.IsNull(Stored(session.Id).EndedUtc); + _revocation.Verify(x => x.RevokeTokens(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task Validate_StaffUnavailable_WithinTwoIntervals_Valid() + { + _staff = true; + _refresher.Setup(x => x.Refresh(It.IsAny(), It.IsAny())).ReturnsAsync(OidcRefreshResult.Unavailable()); + var session = await Session(sinceValidated: Interval + TimeSpan.FromMinutes(5)); + Assert.IsTrue(await Service().Validate(session.Id, _customer)); + Assert.IsNull(Stored(session.Id).EndedUtc); + } + + [TestMethod] + public async Task Validate_StaffUnavailable_PastTwoIntervals_Ends() + { + _staff = true; + _refresher.Setup(x => x.Refresh(It.IsAny(), It.IsAny())).ReturnsAsync(OidcRefreshResult.Unavailable()); + var session = await Session(sinceValidated: Interval * 2 + TimeSpan.FromMinutes(1)); + Assert.IsFalse(await Service().Validate(session.Id, _customer)); + Assert.AreEqual(OidcSessionEndReason.RevalidationUnavailable, Stored(session.Id).EndReason); + _revocation.Verify(x => x.RevokeTokens(It.IsAny()), Times.Never); + } + + //a refresher that fails is no answer from the provider: the session is kept within the grace time + [TestMethod] + public async Task Validate_RefresherThrows_TreatedAsUnavailable() + { + _staff = true; + _refresher.Setup(x => x.Refresh(It.IsAny(), It.IsAny())) + .ThrowsAsync(new InvalidOperationException("boom")); + var session = await Session(sinceValidated: Interval + TimeSpan.FromMinutes(5)); + Assert.IsTrue(await Service().Validate(session.Id, _customer)); + Assert.IsNull(Stored(session.Id).EndedUtc); + _revocation.Verify(x => x.RevokeTokens(It.IsAny()), Times.Never); + } + + //a request holding a cached record from before another request's refresh must not use the already rotated token + [TestMethod] + public async Task Validate_CachedRecordStale_StoredRecentlyValidated_NoRefresh() + { + _staff = true; + var session = await Session(sinceValidated: Interval + TimeSpan.FromMinutes(1)); + var stale = Stored(session.Id); + await _sessions.UpdateField(session.Id, x => x.LastValidatedUtc, DateTime.UtcNow); + await _sessions.UpdateField(session.Id, x => x.ProtectedRefreshToken, Protect("rt-2")); + _cache.Setup(x => x.GetAsync(It.IsAny(), It.IsAny>>(), It.IsAny())) + .ReturnsAsync(stale); + + Assert.IsTrue(await Service().Validate(session.Id, _customer)); + _refresher.Verify(x => x.Refresh(It.IsAny(), It.IsAny()), Times.Never); + } + + //the token was replaced by another instance while this refresh ran: its token is never overwritten + [TestMethod] + public async Task Refreshed_AfterAnotherRefreshStoredNewToken_NewerTokenKept() + { + _staff = true; + var session = await Session(sinceValidated: Interval); + _refresher.Setup(x => x.Refresh("rt-1", It.IsAny())).Returns(async () => + { + await _sessions.UpdateField(session.Id, x => x.ProtectedRefreshToken, Protect("rt-X")); + return OidcRefreshResult.Refreshed("rt-2"); + }); + Assert.IsTrue(await Service().Validate(session.Id, _customer)); + Assert.AreEqual("rt-X", Unprotect(Stored(session.Id).ProtectedRefreshToken)); + } + + //no cancellation token is passed, so a cancellation is a provider timeout + [TestMethod] + public async Task Validate_RefresherCancelled_TreatedAsUnavailable() + { + _staff = true; + _refresher.Setup(x => x.Refresh(It.IsAny(), It.IsAny())) + .ThrowsAsync(new TaskCanceledException()); + var session = await Session(sinceValidated: Interval + TimeSpan.FromMinutes(5)); + Assert.IsTrue(await Service().Validate(session.Id, _customer)); + Assert.IsNull(Stored(session.Id).EndedUtc); + } + + //the login plugin was uninstalled: every session ends at the next check + [TestMethod] + public async Task NoRefresher_CountsAsRefused() + { + _staff = true; + _refreshers.Clear(); + var session = await Session(sinceValidated: Interval); + Assert.IsFalse(await Service().Validate(session.Id, _customer)); + Assert.AreEqual(OidcSessionEndReason.RevalidationRefused, Stored(session.Id).EndReason); + //the provider refused nothing: the subject's access tokens stay valid + _revocation.Verify(x => x.RevokeTokens(It.IsAny()), Times.Never); + } + + //the Data Protection keys changed: the stored token cannot be used, but the provider was never asked + [TestMethod] + public async Task UnreadableRefreshToken_Ends_TokensNotRevoked() + { + var session = await Session(sinceValidated: Interval); + await _sessions.UpdateField(session.Id, x => x.ProtectedRefreshToken, + new EphemeralDataProtectionProvider().CreateProtector(OidcSessionDefaults.RefreshTokenPurpose).Protect("rt-1")); + Assert.IsFalse(await Service().Validate(session.Id, _customer)); + Assert.AreEqual(OidcSessionEndReason.RevalidationRefused, Stored(session.Id).EndReason); + _refresher.Verify(x => x.Refresh(It.IsAny(), It.IsAny()), Times.Never); + _revocation.Verify(x => x.RevokeTokens(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task NotAsked_Ends_TokensNotRevoked() + { + _refresher.Setup(x => x.Refresh("rt-1", It.IsAny())).ReturnsAsync(OidcRefreshResult.NotAsked()); + var session = await Session(sinceValidated: Interval); + Assert.IsFalse(await Service().Validate(session.Id, _customer)); + Assert.IsNotNull(Stored(session.Id).EndedUtc); + _revocation.Verify(x => x.RevokeTokens(It.IsAny()), Times.Never); + } + + //another instance holds the refresh: this one does not use the same refresh token + [TestMethod] + public async Task LeaseHeldElsewhere_RecentCheck_Valid_NoRefresh() + { + var session = await Session(sinceValidated: Interval); + await _sessions.UpdateField(session.Id, x => x.RefreshLeaseId, "other"); + await _sessions.UpdateField(session.Id, x => x.RefreshLeaseUntilUtc, DateTime.UtcNow.AddSeconds(20)); + + Assert.IsTrue(await Service().Validate(session.Id, _customer)); + _refresher.Verify(x => x.Refresh(It.IsAny(), It.IsAny()), Times.Never); + Assert.AreEqual("other", Stored(session.Id).RefreshLeaseId); + } + + [TestMethod] + public async Task LeaseHeldElsewhere_PastGrace_False_NotEnded() + { + var session = await Session(sinceValidated: Interval * 2); + await _sessions.UpdateField(session.Id, x => x.RefreshLeaseId, "other"); + await _sessions.UpdateField(session.Id, x => x.RefreshLeaseUntilUtc, DateTime.UtcNow.AddSeconds(20)); + + Assert.IsFalse(await Service().Validate(session.Id, _customer)); + //the holder decides the outcome + Assert.IsNull(Stored(session.Id).EndedUtc); + } + + //the holder died: its lease lapses and the next request refreshes + [TestMethod] + public async Task LeaseLapsed_Taken_RefreshedAndReleased() + { + _refresher.Setup(x => x.Refresh("rt-1", It.IsAny())) + .ReturnsAsync(OidcRefreshResult.Refreshed("rt-2")); + var session = await Session(sinceValidated: Interval); + await _sessions.UpdateField(session.Id, x => x.RefreshLeaseId, "dead"); + await _sessions.UpdateField(session.Id, x => x.RefreshLeaseUntilUtc, DateTime.UtcNow.AddSeconds(-1)); + + Assert.IsTrue(await Service().Validate(session.Id, _customer)); + var stored = Stored(session.Id); + Assert.AreEqual("rt-2", Unprotect(stored.ProtectedRefreshToken)); + Assert.IsNull(stored.RefreshLeaseId); + Assert.IsNull(stored.RefreshLeaseUntilUtc); + } + + //a parallel request finds the refresh in progress and goes on while the last check is recent enough + [TestMethod] + public async Task RefreshInProgress_RecentCheck_Valid() + { + _staff = true; + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var started = new TaskCompletionSource(); + _refresher.Setup(x => x.Refresh(It.IsAny(), It.IsAny())) + .Returns(() => + { + started.TrySetResult(); + return release.Task; + }); + var session = await Session(sinceValidated: Interval); + + var first = Service().Validate(session.Id, _customer); + try + { + //the first request holds the refresh before the second one starts + await started.Task.WaitAsync(TimeSpan.FromSeconds(10)); + Assert.IsTrue(await Service().Validate(session.Id, _customer)); + } + finally + { + //always released, so the first request completes + release.TrySetResult(OidcRefreshResult.Refreshed("rt-2")); + } + + Assert.IsTrue(await first); + _refresher.Verify(x => x.Refresh(It.IsAny(), It.IsAny()), Times.Once); + } + + [TestMethod] + public async Task Validate_StaffActivity_WrittenAtMostOncePerMinute() + { + _staff = true; + var fresh = await Session(idle: TimeSpan.FromSeconds(10)); + var before = Stored(fresh.Id).LastActivityUtc; + Assert.IsTrue(await Service().Validate(fresh.Id, _customer)); + Assert.AreEqual(before, Stored(fresh.Id).LastActivityUtc); + + var stale = await Session(idle: TimeSpan.FromMinutes(2)); + Assert.IsTrue(await Service().Validate(stale.Id, _customer)); + Assert.IsTrue(Stored(stale.Id).LastActivityUtc > DateTime.UtcNow.AddMinutes(-1)); + } +} diff --git a/src/Tests/Grand.Business.Authentication.Tests/Services/OidcTokenRevocationServiceTests.cs b/src/Tests/Grand.Business.Authentication.Tests/Services/OidcTokenRevocationServiceTests.cs new file mode 100644 index 0000000000..8b9e23cbcb --- /dev/null +++ b/src/Tests/Grand.Business.Authentication.Tests/Services/OidcTokenRevocationServiceTests.cs @@ -0,0 +1,63 @@ +using Grand.Business.Authentication.Services; +using Grand.Data; +using Grand.Data.Tests.MongoDb; +using Grand.Domain.Customers; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Grand.Business.Authentication.Tests.Services; + +[TestClass] +public class OidcTokenRevocationServiceTests +{ + private IRepository _links; + private OidcTokenRevocationService _service; + + [TestInitialize] + public void Init() + { + _links = new MongoDBRepositoryTest(); + _service = new OidcTokenRevocationService(_links); + } + + private Task Link(string customerId, string sub, string provider = OidcCustomerResolver.ProviderSystemName, string storeId = "") => + _links.InsertAsync(new ExternalAuthentication { + CustomerId = customerId, ExternalIdentifier = sub, ProviderSystemName = provider, StoreId = storeId + }); + + [TestMethod] + public async Task NeverRevoked_NotRevoked() + { + await Link("c1", "sub-1"); + Assert.IsFalse(await _service.IsRevoked("sub-1", DateTime.UtcNow.AddHours(-1))); + } + + [TestMethod] + public async Task Revoke_OlderTokenRevoked_NewerAccepted() + { + await Link("c1", "sub-1"); + await _service.RevokeTokens("sub-1"); + Assert.IsTrue(await _service.IsRevoked("sub-1", DateTime.UtcNow.AddMinutes(-1))); + Assert.IsFalse(await _service.IsRevoked("sub-1", DateTime.UtcNow.AddMinutes(1))); + } + + //with per-store identity a subject has one link per store: all of them stop the old tokens + [TestMethod] + public async Task Revoke_EveryLinkOfTheSubject_OnlyOpenIdConnect() + { + await Link("c1", "sub-1", storeId: "s1"); + await Link("c2", "sub-1", storeId: "s2"); + await Link("c3", "sub-1", provider: "ExternalAuth.Google"); + await Link("c4", "sub-2"); + await _service.RevokeTokens("sub-1"); + + var revoked = _links.Table.Where(x => x.TokensRevokedBeforeUtc != null).Select(x => x.CustomerId).ToList(); + CollectionAssert.AreEquivalent(new[] { "c1", "c2" }, revoked); + } + + [TestMethod] + public async Task UnknownSubject_NotRevoked_RevokeDoesNothing() + { + await _service.RevokeTokens("nobody"); + Assert.IsFalse(await _service.IsRevoked("nobody", DateTime.UtcNow.AddHours(-1))); + } +} diff --git a/src/Tests/Grand.Data.Tests/LiteDb/LiteDbRepositoryTests.cs b/src/Tests/Grand.Data.Tests/LiteDb/LiteDbRepositoryTests.cs index 58c58d2538..346abc4f36 100644 --- a/src/Tests/Grand.Data.Tests/LiteDb/LiteDbRepositoryTests.cs +++ b/src/Tests/Grand.Data.Tests/LiteDb/LiteDbRepositoryTests.cs @@ -508,6 +508,33 @@ await myRepository.UpdateOneAsync(x => x.Name == "Test", Assert.AreEqual("user", pUpdated.FirstOrDefault()!.UpdatedBy); } + //like MongoDB: a filter that matches nothing updates nothing + [TestMethod] + public async Task UpdateOneAsync_NoMatch_UpdatesNothing() + { + var myRepository = new LiteDBRepositoryMock(); + myRepository.Insert(new SampleCollection { Id = "1", Name = "Test" }); + + await myRepository.UpdateOneAsync(x => x.Name == "Missing", + UpdateBuilder.Create().Set(x => x.Name, "UpdateTest")); + + Assert.AreEqual(1, myRepository.Table.Count()); + Assert.AreEqual("Test", myRepository.GetById("1").Name); + Assert.IsFalse(myRepository.GetById("1").UpdatedOnUtc.HasValue); + } + + [TestMethod] + public async Task UpdateManyAsync_NoMatch_UpdatesNothing() + { + var myRepository = new LiteDBRepositoryMock(); + myRepository.Insert(new SampleCollection { Id = "1", Name = "Test" }); + + await myRepository.UpdateManyAsync(x => x.Name == "Missing", + UpdateBuilder.Create().Set(x => x.Name, "UpdateTest")); + + Assert.AreEqual("Test", myRepository.GetById("1").Name); + } + [TestMethod] public async Task UpdateToSet_LiteRepository_Success() { diff --git a/src/Tests/Grand.Modules.Tests/Services/BackgroundService/DeleteOidcSessionsScheduleTaskTests.cs b/src/Tests/Grand.Modules.Tests/Services/BackgroundService/DeleteOidcSessionsScheduleTaskTests.cs new file mode 100644 index 0000000000..951817977c --- /dev/null +++ b/src/Tests/Grand.Modules.Tests/Services/BackgroundService/DeleteOidcSessionsScheduleTaskTests.cs @@ -0,0 +1,18 @@ +using Grand.Business.Core.Interfaces.Authentication; +using Grand.Module.ScheduledTasks.BackgroundServices; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Modules.Tests.Services.BackgroundService; + +[TestClass] +public class DeleteOidcSessionsScheduleTaskTests +{ + [TestMethod] + public async Task Execute_DeletesExpiredSessions() + { + var sessions = new Mock(); + await new DeleteOidcSessionsScheduleTask(sessions.Object).Execute(); + sessions.Verify(x => x.DeleteExpired(), Times.Once); + } +} diff --git a/src/Web/Grand.Web/App_Data/appsettings.json b/src/Web/Grand.Web/App_Data/appsettings.json index b3481d1065..92da6a2d67 100644 --- a/src/Web/Grand.Web/App_Data/appsettings.json +++ b/src/Web/Grand.Web/App_Data/appsettings.json @@ -273,6 +273,21 @@ ] }, + //browser sign-in with the identity provider of OidcResourceServer (plugin Authentication.OpenIdConnect). + //See docs/oidc-resource-server.md, section 11 and 12. + "OidcLogin": { + "ClientId": "", + //keep it out of source control (env var OidcLogin__ClientSecret or a secret store) + "ClientSecret": "", + "Scopes": "openid profile email offline_access", + //every session signed in through the provider confirms the account there this often (minutes); a user disabled at the provider loses access within this time + "RevalidationMinutes": 10, + //back-office accounts only (admin panel, store staff, vendors) signed in through the provider + "StaffIdleTimeoutMinutes": 30, + "StaffMaxSessionHours": 10, + //true: back-office accounts connected to the provider sign in only through it; accounts without a connection keep the password + "StaffSsoOnly": false + }, "Logging": { "LogLevel": { "Default": "Information",