diff --git a/src/Business/Grand.Business.Authentication/Services/ExternalAuthenticationService.cs b/src/Business/Grand.Business.Authentication/Services/ExternalAuthenticationService.cs index 1e9724d50f..0a08767243 100644 --- a/src/Business/Grand.Business.Authentication/Services/ExternalAuthenticationService.cs +++ b/src/Business/Grand.Business.Authentication/Services/ExternalAuthenticationService.cs @@ -310,7 +310,7 @@ public virtual async Task GetCustomer(ExternalAuthParam parameters) ArgumentNullException.ThrowIfNull(parameters); var associationRecord = (from q in _externalAuthenticationRecordRepository.Table - where q.ExternalIdentifier.ToLowerInvariant() == parameters.Identifier + where q.ExternalIdentifier == parameters.Identifier && q.ProviderSystemName.ToLowerInvariant() == parameters.ProviderSystemName.ToLowerInvariant() select q).FirstOrDefault(); diff --git a/src/Business/Grand.Business.Authentication/Services/OidcCustomerResolver.cs b/src/Business/Grand.Business.Authentication/Services/OidcCustomerResolver.cs new file mode 100644 index 0000000000..bf09e11d67 --- /dev/null +++ b/src/Business/Grand.Business.Authentication/Services/OidcCustomerResolver.cs @@ -0,0 +1,319 @@ +using Grand.Business.Core.Events.Customers; +using Grand.Business.Core.Interfaces.Authentication; +using Grand.Business.Core.Interfaces.Common.Configuration; +using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Business.Core.Interfaces.Common.Security; +using Grand.Business.Core.Interfaces.Customers; +using Grand.Business.Core.Utilities.Authentication; +using Grand.Business.Core.Utilities.Customers; +using Grand.Data; +using Grand.Domain.Customers; +using Grand.Domain.Permissions; +using Grand.Infrastructure.Configuration; +using Grand.Mediator; +using Grand.SharedKernel.Extensions; +using Microsoft.Extensions.Logging; +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; + +namespace Grand.Business.Authentication.Services; + +/// +/// Maps a validated access token to a customer: stored subject link; customer with the verified e-mail (link +/// stored); registration with the Registered group only. Only active, registered, non-system accounts are used, +/// and one customer holds at most one identity (enforced by the link's identifier, see ). +/// With per-store identity a subject has one link per store (plus the link of a customer of no store, valid in +/// every store). A unique index on (provider, identifier, store) makes the first concurrent requests of one user +/// converge on one account. +/// +public class OidcCustomerResolver : IOidcCustomerResolver +{ + public const string ProviderSystemName = "ExternalAuth.OpenIdConnect"; + + private readonly IRepository _externalAuthenticationRepository; + private readonly ICustomerService _customerService; + private readonly ICustomerManagerService _customerManagerService; + private readonly ISettingService _settingService; + private readonly IGroupService _groupService; + private readonly IPermissionService _permissionService; + private readonly IMediator _mediator; + private readonly OidcResourceServerConfig _config; + private readonly CustomerConfig _customerConfig; + private readonly ILogger _logger; + + public OidcCustomerResolver( + IRepository externalAuthenticationRepository, + ICustomerService customerService, + ICustomerManagerService customerManagerService, + ISettingService settingService, + IGroupService groupService, + IPermissionService permissionService, + IMediator mediator, + OidcResourceServerConfig config, + CustomerConfig customerConfig, + ILogger logger) + { + _externalAuthenticationRepository = externalAuthenticationRepository; + _customerService = customerService; + _customerManagerService = customerManagerService; + _settingService = settingService; + _groupService = groupService; + _permissionService = permissionService; + _mediator = mediator; + _config = config; + _customerConfig = customerConfig; + _logger = logger; + } + + public virtual async Task Resolve(ClaimsPrincipal principal, string storeId) + { + ArgumentNullException.ThrowIfNull(principal); + + var sub = principal.FindFirst(_config.Claims.Subject)?.Value; + if (string.IsNullOrEmpty(sub)) + return OidcCustomerResult.InvalidToken("The token has no subject"); + + var link = await GetLink(sub, storeId); + if (link != null) + return await Usable(await _customerService.GetCustomerById(link.CustomerId), sub, storeId); + + var email = principal.FindFirst(_config.Claims.Email)?.Value; + if (string.IsNullOrWhiteSpace(email)) + return OidcCustomerResult.InvalidToken("The token has no e-mail address"); + + if (_config.RequireEmailVerified && + !string.Equals(principal.FindFirst(_config.Claims.EmailVerified)?.Value, "true", StringComparison.OrdinalIgnoreCase)) + return OidcCustomerResult.InvalidToken("The e-mail address in the token is not verified"); + + //without per-store identity the e-mail is unique across stores - a store-scoped lookup would miss customers + //of other stores and register a duplicate + var customer = await _customerService.GetCustomerByEmail(email, + _customerConfig.RegisterCustomersPerStore ? storeId : ""); + if (customer == null) + return await Register(sub, email, storeId); + + var result = await Usable(customer, sub, storeId); + if (!result.Succeeded) + return result; + + //an e-mail nobody verified could take over a back-office account, so those are never linked by it + if (!_config.RequireEmailVerified && await IsBackOfficeAccount(customer)) + { + _logger.LogWarning("Access token subject {Subject}: customer {CustomerId} is a back-office account and the e-mail is not verified", + sub, customer.Id); + return OidcCustomerResult.Forbidden("Back-office accounts can only be linked with a verified e-mail"); + } + + //one identity per customer - a second subject with the same e-mail must not take over the account; the + //link's identifier enforces it against concurrent requests, this check gives the answer in the normal case + var otherLink = await _externalAuthenticationRepository.GetOneAsync(x => + x.ProviderSystemName == ProviderSystemName && x.CustomerId == customer.Id && x.ExternalIdentifier != sub); + if (otherLink != null) + return LinkedToOtherIdentity(customer, sub, otherLink); + + var (linked, other) = await LinkOrGetWinner(customer, sub, email, storeId); + if (other != null) + return LinkedToOtherIdentity(customer, sub, other); + if (linked?.Id != customer.Id) + return await Usable(linked, sub, storeId); + + _logger.LogInformation("Access token subject {Subject} linked to existing customer {CustomerId} (store {StoreId})", + sub, customer.Id, storeId); + return result; + } + + private async Task Register(string sub, string email, string storeId) + { + if (!_config.AutoRegister) + { + _logger.LogWarning("Access token subject {Subject}: no customer matches and automatic registration is off", sub); + return OidcCustomerResult.Forbidden("No customer account matches the token"); + } + + var customerSettings = await _settingService.LoadSetting(storeId); + if (customerSettings.UserRegistrationType == UserRegistrationType.Disabled) + { + _logger.LogWarning("Access token subject {Subject}: registration is disabled in store {StoreId}", sub, storeId); + return OidcCustomerResult.Forbidden("Registration is disabled"); + } + + //a concurrent request may have registered this subject since the first lookup + var existing = await GetLink(sub, storeId); + if (existing != null) + return await Usable(await _customerService.GetCustomerById(existing.CustomerId), sub, storeId); + + //the e-mail becomes the user name - another account may hold it as its user name without that e-mail + var normalizedEmail = email.ToLowerInvariant(); + if (await _customerService.GetCustomerByUsername(normalizedEmail, + _customerConfig.RegisterCustomersPerStore ? storeId : "") != null) + { + _logger.LogWarning("Access token subject {Subject}: the user name is already taken in store {StoreId}", sub, storeId); + return OidcCustomerResult.Forbidden("The user name is already taken"); + } + + var customer = await _customerService.InsertGuestCustomer(new Customer { + CustomerGuid = Guid.NewGuid(), + Active = true, + StoreId = storeId, + LastActivityDateUtc = DateTime.UtcNow + }); + + //the provider verified the e-mail and access comes from permissions an admin grants, so the store's e-mail + //validation / admin approval does not apply; the account gets the Registered group only + await _customerManagerService.RegisterCustomer(new RegistrationRequest(customer, normalizedEmail, + normalizedEmail, CommonHelper.GenerateRandomDigitCode(20), PasswordFormat.Hashed, storeId)); + + var (linked, other) = await LinkOrGetWinner(customer, sub, email, storeId); + if (other != null || linked?.Id != customer.Id) + { + //lost the race: drop the account just created and use the one the winner linked + await _customerService.DeleteCustomer(customer, true); + return other != null ? LinkedToOtherIdentity(customer, sub, other) : await Usable(linked, sub, storeId); + } + + //only after the link is stored, so the loser of a race sends no welcome message + await _mediator.Publish(new CustomerRegisteredEvent(customer)); + _logger.LogInformation("Access token subject {Subject} registered as customer {CustomerId} (store {StoreId})", + sub, customer.Id, storeId); + return OidcCustomerResult.Success(customer); + } + + /// + /// The link of the subject usable in the store. With per-store identity: the link of the store, otherwise + /// the link without a store (empty or, for older records, missing) unless its customer belongs to another + /// store - such a link was stored before customers were registered per store and must not keep the + /// subject from linking this store's customer; without per-store identity: any link. + /// Subjects are case-sensitive - exact comparison. + /// + private async Task GetLink(string sub, string storeId) + { + if (!_customerConfig.RegisterCustomersPerStore) + return await _externalAuthenticationRepository.GetOneAsync(x => + x.ProviderSystemName == ProviderSystemName && x.ExternalIdentifier == sub); + + if (!string.IsNullOrEmpty(storeId)) + { + var storeLink = await _externalAuthenticationRepository.GetOneAsync(x => + x.ProviderSystemName == ProviderSystemName && x.ExternalIdentifier == sub && x.StoreId == storeId); + if (storeLink != null) + return storeLink; + } + + var globalLink = await _externalAuthenticationRepository.GetOneAsync(x => + x.ProviderSystemName == ProviderSystemName && x.ExternalIdentifier == sub && + (x.StoreId == null || x.StoreId == "")); + if (globalLink == null) + return null; + + var linkedCustomer = await _customerService.GetCustomerById(globalLink.CustomerId); + if (linkedCustomer != null && !string.IsNullOrEmpty(linkedCustomer.StoreId) && linkedCustomer.StoreId != storeId) + return null; + + return globalLink; + } + + /// + /// Store part of the link key: the customer's store with per-store identity, empty otherwise - never null, + /// so the unique index sees one value for "no store" + /// + private string LinkStoreId(Customer customer) => + _customerConfig.RegisterCustomersPerStore ? customer.StoreId ?? "" : ""; + + private OidcCustomerResult LinkedToOtherIdentity(Customer customer, string sub, ExternalAuthentication otherLink) + { + _logger.LogWarning("Access token subject {Subject}: customer {CustomerId} is already linked to subject {LinkedSubject}", + sub, customer.Id, otherLink.ExternalIdentifier); + return OidcCustomerResult.Forbidden("The customer account is linked to another identity"); + } + + /// + /// Stores the link under the customer's . When a concurrent request stored a link + /// first: the link of another subject now holding this customer (OtherLink), or the customer the subject's + /// winning link points to (Customer). Rethrows when the insert failed for another reason. + /// + private async Task<(Customer Customer, ExternalAuthentication OtherLink)> LinkOrGetWinner(Customer customer, + string sub, string email, string storeId) + { + var linkId = OidcLinkId(customer.Id); + try + { + await _externalAuthenticationRepository.InsertAsync(new ExternalAuthentication { + Id = linkId, + CustomerId = customer.Id, + Email = email.ToLowerInvariant(), + ExternalIdentifier = sub, + ExternalDisplayIdentifier = email, + ProviderSystemName = ProviderSystemName, + StoreId = LinkStoreId(customer) + }); + return (customer, null); + } + catch (Exception ex) + { + _logger.LogInformation("Storing the identity link for subject {Subject} failed ({ExceptionType}); re-reading", + sub, ex.GetType().Name); + var customerLink = await _externalAuthenticationRepository.GetByIdAsync(linkId); + if (customerLink != null && customerLink.ExternalIdentifier != sub) + return (null, customerLink); + var winner = await GetLink(sub, storeId); + if (winner == null) + throw; + return (await _customerService.GetCustomerById(winner.CustomerId), null); + } + } + + /// + /// Identifier of a customer's OIDC link: the first 12 bytes of SHA-256 over "ExternalAuth.OpenIdConnect:" + + /// customer id, as 24 lower-case hex characters (shaped like an ObjectId). Every OIDC link gets it, so the + /// primary key lets one customer hold one OIDC link - on MongoDB and LiteDB, without an extra index and + /// without restricting other providers. Lookups go by subject or customer (only a failed insert reads by + /// this value), so links stored with a random identifier keep working. + /// + public static string OidcLinkId(string customerId) + { + ArgumentException.ThrowIfNullOrEmpty(customerId); + var hash = SHA256.HashData(Encoding.UTF8.GetBytes(ProviderSystemName + ":" + customerId)); + return Convert.ToHexStringLower(hash, 0, 12); + } + + /// + /// The checks every other login path applies: an active, registered, non-system account, and with per-store + /// identity one of the request's store (or of no store) + /// + private async Task Usable(Customer customer, string sub, string storeId) + { + if (customer == null) + { + _logger.LogWarning("Access token subject {Subject} is linked to a customer that no longer exists", sub); + return OidcCustomerResult.Forbidden("The customer account no longer exists"); + } + + if (!customer.Active || customer.Deleted) + { + _logger.LogWarning("Access token subject {Subject}: customer {CustomerId} is inactive or deleted", sub, customer.Id); + return OidcCustomerResult.Forbidden("The customer account is inactive"); + } + + if (customer.IsSystemAccount() || !await _groupService.IsRegistered(customer)) + { + _logger.LogWarning("Access token subject {Subject}: customer {CustomerId} is a system or non-registered account", + sub, customer.Id); + return OidcCustomerResult.Forbidden("The customer account cannot be used"); + } + + if (_customerConfig.RegisterCustomersPerStore && !string.IsNullOrEmpty(customer.StoreId) && + customer.StoreId != storeId) + { + _logger.LogWarning("Access token subject {Subject}: customer {CustomerId} belongs to store {CustomerStoreId}, not {StoreId}", + sub, customer.Id, customer.StoreId, storeId); + return OidcCustomerResult.Forbidden("The account belongs to another store"); + } + + return OidcCustomerResult.Success(customer); + } + + private async Task IsBackOfficeAccount(Customer customer) => + !string.IsNullOrEmpty(customer.VendorId) || !string.IsNullOrEmpty(customer.StaffStoreId) || + await _permissionService.Authorize(StandardPermission.ManageAccessAdminPanel, customer); +} diff --git a/src/Business/Grand.Business.Authentication/Startup/StartupApplication.cs b/src/Business/Grand.Business.Authentication/Startup/StartupApplication.cs index 72320e8b0d..ab3818cfad 100644 --- a/src/Business/Grand.Business.Authentication/Startup/StartupApplication.cs +++ b/src/Business/Grand.Business.Authentication/Startup/StartupApplication.cs @@ -18,6 +18,7 @@ public void ConfigureServices(IServiceCollection services, IConfiguration config services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); } diff --git a/src/Business/Grand.Business.Core/Interfaces/Authentication/IOidcCustomerResolver.cs b/src/Business/Grand.Business.Core/Interfaces/Authentication/IOidcCustomerResolver.cs new file mode 100644 index 0000000000..74aad3ecc3 --- /dev/null +++ b/src/Business/Grand.Business.Core/Interfaces/Authentication/IOidcCustomerResolver.cs @@ -0,0 +1,15 @@ +using Grand.Business.Core.Utilities.Authentication; +using System.Security.Claims; + +namespace Grand.Business.Core.Interfaces.Authentication; + +/// +/// Maps a validated access token of the identity provider to a customer: by the stored link to the token +/// subject, on first use by the verified e-mail address, otherwise by registering one +/// +public interface IOidcCustomerResolver +{ + /// Principal of the validated access token + /// Store resolved from the request host + Task Resolve(ClaimsPrincipal principal, string storeId); +} diff --git a/src/Business/Grand.Business.Core/Utilities/Authentication/OidcCustomerResult.cs b/src/Business/Grand.Business.Core/Utilities/Authentication/OidcCustomerResult.cs new file mode 100644 index 0000000000..db765231dc --- /dev/null +++ b/src/Business/Grand.Business.Core/Utilities/Authentication/OidcCustomerResult.cs @@ -0,0 +1,28 @@ +using Grand.Domain.Customers; + +namespace Grand.Business.Core.Utilities.Authentication; + +/// +/// Outcome of mapping a validated access token to a customer +/// +public class OidcCustomerResult +{ + private OidcCustomerResult(Customer customer, OidcFailureKind? failure, string description) + { + Customer = customer; + Failure = failure; + Description = description; + } + + public Customer Customer { get; } + public OidcFailureKind? Failure { get; } + + /// Fixed, token-independent text safe for error_description + public string Description { get; } + + public bool Succeeded => Customer != null; + + public static OidcCustomerResult Success(Customer customer) => new(customer, null, null); + public static OidcCustomerResult InvalidToken(string description) => new(null, OidcFailureKind.InvalidToken, description); + public static OidcCustomerResult Forbidden(string description) => new(null, OidcFailureKind.Forbidden, description); +} diff --git a/src/Core/Grand.Domain/Customers/ExternalAuthentication.cs b/src/Core/Grand.Domain/Customers/ExternalAuthentication.cs index df4f865da1..30647d7b0e 100644 --- a/src/Core/Grand.Domain/Customers/ExternalAuthentication.cs +++ b/src/Core/Grand.Domain/Customers/ExternalAuthentication.cs @@ -34,4 +34,9 @@ public class ExternalAuthentication : BaseEntity /// Gets or sets the provider /// public string ProviderSystemName { get; set; } + + /// + /// Gets or sets the store of the linked customer when customers are registered per store; empty otherwise + /// + public string StoreId { get; set; } } \ No newline at end of file diff --git a/src/Core/Grand.Infrastructure/Configuration/OidcResourceServerConfig.cs b/src/Core/Grand.Infrastructure/Configuration/OidcResourceServerConfig.cs index 6faaebcc67..85e4dc7183 100644 --- a/src/Core/Grand.Infrastructure/Configuration/OidcResourceServerConfig.cs +++ b/src/Core/Grand.Infrastructure/Configuration/OidcResourceServerConfig.cs @@ -18,14 +18,17 @@ public class OidcResourceServerConfig public OidcClaimNames Claims { get; set; } = new(); /// - /// An unverified e-mail address never links to an account and never registers one + /// True: a token whose e-mail is not verified never links to an account and never registers one. False: the + /// e-mail in the token is trusted as is, except that back-office accounts (admin panel access, vendor, store + /// staff) are never linked by it /// public bool RequireEmailVerified { get; set; } = true; /// - /// Register an unknown user (customer group Registered only) + /// Register an unknown user on first use (customer group Registered only). Off by default: an account should exist, + /// or be created through the store's registration, before a token can use it /// - public bool AutoRegister { get; set; } = true; + public bool AutoRegister { get; set; } public List Resources { get; set; } = []; } diff --git a/src/Modules/Grand.Module.Installer/Services/InstallationService.cs b/src/Modules/Grand.Module.Installer/Services/InstallationService.cs index 6657a617a1..50b31aa431 100644 --- a/src/Modules/Grand.Module.Installer/Services/InstallationService.cs +++ b/src/Modules/Grand.Module.Installer/Services/InstallationService.cs @@ -908,6 +908,13 @@ await dbContext.CreateIndex(_permissionAction, //externalauth await dbContext.CreateIndex(_externalAuthenticationRepository, OrderBuilder.Create().Ascending(x => x.CustomerId), CustomerId); + //unique: one customer per external identity and store (the store is set only when customers are registered + //per store) - makes linking an access token subject safe against concurrent requests (indexes are created + //for MongoDB only; LiteDB gets a non-unique one from the 2.5 migration) + await dbContext.CreateIndex(_externalAuthenticationRepository, + OrderBuilder.Create().Ascending(x => x.ProviderSystemName) + .Ascending(x => x.ExternalIdentifier).Ascending(x => x.StoreId), + "ProviderSystemName_ExternalIdentifier_StoreId", true); //merchandise return await dbContext.CreateIndex(_merchandiseReturnRepository, diff --git a/src/Modules/Grand.Module.Migration/Migrations/2.5/MigrationExternalAuthenticationIdentifierIndex.cs b/src/Modules/Grand.Module.Migration/Migrations/2.5/MigrationExternalAuthenticationIdentifierIndex.cs new file mode 100644 index 0000000000..07ece5f720 --- /dev/null +++ b/src/Modules/Grand.Module.Migration/Migrations/2.5/MigrationExternalAuthenticationIdentifierIndex.cs @@ -0,0 +1,61 @@ +using Grand.Data; +using Grand.Domain.Customers; +using Grand.Infrastructure.Migrations; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Grand.Module.Migration.Migrations._2._5; + +public class MigrationExternalAuthenticationIdentifierIndex : IMigration +{ + private const string IndexName = "ProviderSystemName_ExternalIdentifier_StoreId"; + + //created by development builds of this migration, before identity links became per store + private const string PreviousIndexName = "ProviderSystemName_ExternalIdentifier"; + + public int Priority => 1; + public DbVersion Version => new(2, 5); + public Guid Identity => new("5d33a2b4-84d9-467b-8067-3b2fc2336225"); + public string Name => "Unique index on ExternalAuthentication provider + external identifier + store"; + + public bool UpgradeProcess(IServiceProvider serviceProvider) + { + var repository = serviceProvider.GetRequiredService>(); + var databaseContext = serviceProvider.GetRequiredService(); + var logService = serviceProvider.GetRequiredService>(); + + try + { + var duplicates = repository.Table + .Select(x => new { x.ProviderSystemName, x.ExternalIdentifier, x.StoreId }).ToList() + //the same key as the index: a missing store and an empty one are different values + .GroupBy(x => (x.ProviderSystemName, x.ExternalIdentifier, x.StoreId)) + .Where(x => x.Count() > 1) + .Select(x => $"{x.Key.ProviderSystemName}/{x.Key.ExternalIdentifier}/{x.Key.StoreId}") + .ToList(); + + //duplicates left by older external logins must not stop the upgrade - keep a non-unique index for the + //lookups and report them + //LiteDB indexes every field of a composite index on its own, so a unique flag would make each field unique + var isMongo = DataSettingsManager.Instance.LoadSettings().DbProvider == DbProvider.MongoDB; + var unique = duplicates.Count == 0 && isMongo; + if (duplicates.Count != 0) + logService.LogError( + "The index {IndexName} was created as non-unique - these external identities are linked more than once: {Identities}. Remove the duplicates and recreate the index as unique", + IndexName, string.Join(", ", duplicates)); + + //the two-field unique index would forbid a second link of the subject in another store + databaseContext.DeleteIndex(repository, PreviousIndexName).GetAwaiter().GetResult(); + databaseContext.CreateIndex(repository, + OrderBuilder.Create().Ascending(x => x.ProviderSystemName) + .Ascending(x => x.ExternalIdentifier).Ascending(x => x.StoreId), IndexName, unique) + .GetAwaiter().GetResult(); + } + catch (Exception ex) + { + logService.LogError(ex, "UpgradeProcess - ExternalAuthenticationIdentifierIndex (2.5)"); + } + + return true; + } +} diff --git a/src/Tests/Grand.Business.Authentication.Tests/Services/ExternalAuthenticationServiceTests.cs b/src/Tests/Grand.Business.Authentication.Tests/Services/ExternalAuthenticationServiceTests.cs index fa5d1810a1..ec0ed70820 100644 --- a/src/Tests/Grand.Business.Authentication.Tests/Services/ExternalAuthenticationServiceTests.cs +++ b/src/Tests/Grand.Business.Authentication.Tests/Services/ExternalAuthenticationServiceTests.cs @@ -131,6 +131,51 @@ await _externalAuthenticationService.AssociateCustomer(expectedCustomer, Assert.AreEqual(customer.Username, expectedCustomer.Username); } + [TestMethod] + public async Task GetCustomer_IdentifierWithUpperCaseLetters_IsFound() + { + var expectedCustomer = new Customer { Username = "John", Active = true }; + _customerServiceMock.Setup(c => c.GetCustomerById(It.IsAny())) + .Returns(() => Task.FromResult(expectedCustomer)); + await _externalAuthenticationService.AssociateCustomer(expectedCustomer, + new ExternalAuthParam { ProviderSystemName = "ExternalAuthenticationProviderTest", Identifier = "AbC-123" }); + + var customer = await _externalAuthenticationService.GetCustomer(new ExternalAuthParam + { ProviderSystemName = "ExternalAuthenticationProviderTest", Identifier = "AbC-123" }); + + Assert.IsNotNull(customer); + } + + [TestMethod] + public async Task GetCustomer_IdentifierInDifferentCase_IsNotFound() + { + var expectedCustomer = new Customer { Username = "John", Active = true }; + _customerServiceMock.Setup(c => c.GetCustomerById(It.IsAny())) + .Returns(() => Task.FromResult(expectedCustomer)); + await _externalAuthenticationService.AssociateCustomer(expectedCustomer, + new ExternalAuthParam { ProviderSystemName = "ExternalAuthenticationProviderTest", Identifier = "AbC-123" }); + + var customer = await _externalAuthenticationService.GetCustomer(new ExternalAuthParam + { ProviderSystemName = "ExternalAuthenticationProviderTest", Identifier = "abc-123" }); + + Assert.IsNull(customer); + } + + [TestMethod] + public async Task GetCustomer_ProviderSystemNameInDifferentCase_IsFound() + { + var expectedCustomer = new Customer { Username = "John", Active = true }; + _customerServiceMock.Setup(c => c.GetCustomerById(It.IsAny())) + .Returns(() => Task.FromResult(expectedCustomer)); + await _externalAuthenticationService.AssociateCustomer(expectedCustomer, + new ExternalAuthParam { ProviderSystemName = "ExternalAuthenticationProviderTest", Identifier = "AbC-123" }); + + var customer = await _externalAuthenticationService.GetCustomer(new ExternalAuthParam + { ProviderSystemName = "externalauthenticationprovidertest", Identifier = "AbC-123" }); + + Assert.IsNotNull(customer); + } + [TestMethod] public async Task GetExternalIdentifiersTest() { diff --git a/src/Tests/Grand.Business.Authentication.Tests/Services/OidcCustomerResolverTests.cs b/src/Tests/Grand.Business.Authentication.Tests/Services/OidcCustomerResolverTests.cs new file mode 100644 index 0000000000..5015ecd76e --- /dev/null +++ b/src/Tests/Grand.Business.Authentication.Tests/Services/OidcCustomerResolverTests.cs @@ -0,0 +1,686 @@ +using Grand.Business.Authentication.Services; +using Grand.Business.Core.Events.Customers; +using Grand.Business.Core.Interfaces.Common.Configuration; +using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Business.Core.Interfaces.Common.Security; +using Grand.Business.Core.Interfaces.Customers; +using Grand.Business.Core.Utilities.Authentication; +using Grand.Business.Core.Utilities.Customers; +using Grand.Data; +using Grand.Data.Tests.MongoDb; +using Grand.Domain.Customers; +using Grand.Domain.Permissions; +using Grand.Infrastructure.Configuration; +using Grand.Mediator; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using System.Linq.Expressions; +using System.Security.Claims; + +namespace Grand.Business.Authentication.Tests.Services; + +[TestClass] +public class OidcCustomerResolverTests +{ + private IRepository _links; + private Mock _customerService; + private Mock _customerManagerService; + private Mock _settingService; + private Mock _groupService; + private Mock _permissionService; + private Mock _mediator; + private OidcResourceServerConfig _config; + private CustomerConfig _customerConfig; + private CustomerSettings _customerSettings; + + [TestInitialize] + public void Init() + { + _links = new MongoDBRepositoryTest(); + _customerService = new Mock(); + _customerManagerService = new Mock(); + _settingService = new Mock(); + _groupService = new Mock(); + _groupService.Setup(x => x.IsRegistered(It.IsAny())).ReturnsAsync(true); + _permissionService = new Mock(); + _mediator = new Mock(); + _config = new OidcResourceServerConfig { Enabled = true, AutoRegister = false }; + _customerConfig = new CustomerConfig(); + _customerSettings = new CustomerSettings { UserRegistrationType = UserRegistrationType.Standard }; + _settingService.Setup(x => x.LoadSetting(It.IsAny())).ReturnsAsync(_customerSettings); + _customerService.Setup(x => x.InsertGuestCustomer(It.IsAny())) + .ReturnsAsync((Customer c) => { c.Id = "new1"; return c; }); + } + + private OidcCustomerResolver Resolver(IRepository links = null) => + new(links ?? _links, _customerService.Object, _customerManagerService.Object, _settingService.Object, + _groupService.Object, _permissionService.Object, _mediator.Object, _config, _customerConfig, NullLogger.Instance); + + private static ClaimsPrincipal Principal(string sub = "sub-1", string email = "John@Example.com", + string emailVerified = "true") + { + var claims = new List(); + if (sub != null) claims.Add(new Claim("sub", sub)); + if (email != null) claims.Add(new Claim("email", email)); + if (emailVerified != null) claims.Add(new Claim("email_verified", emailVerified)); + return new ClaimsPrincipal(new ClaimsIdentity(claims, "Oidc")); + } + + private Task Link(string customerId, string sub = "sub-1", string provider = OidcCustomerResolver.ProviderSystemName, + string storeId = null) => + _links.InsertAsync(new ExternalAuthentication { + CustomerId = customerId, ProviderSystemName = provider, ExternalIdentifier = sub, StoreId = storeId + }); + + private List LinksOf(string sub) => + _links.Table.Where(x => x.ExternalIdentifier == sub).ToList(); + + [TestMethod] + public async Task NoSubject_InvalidToken() => + Assert.AreEqual(OidcFailureKind.InvalidToken, (await Resolver().Resolve(Principal(sub: null), "store1")).Failure); + + [TestMethod] + public async Task CustomSubjectClaim_IsUsed() + { + _config.Claims.Subject = "oid"; + await Link("c1", sub: "abc"); + _customerService.Setup(x => x.GetCustomerById("c1")).ReturnsAsync(new Customer { Id = "c1", Active = true }); + var principal = new ClaimsPrincipal(new ClaimsIdentity([new Claim("oid", "abc")], "Oidc")); + Assert.AreEqual("c1", (await Resolver().Resolve(principal, "store1")).Customer.Id); + } + + [TestMethod] + public async Task Linked_ReturnsCustomer_WithoutEmail() + { + await Link("c1"); + _customerService.Setup(x => x.GetCustomerById("c1")).ReturnsAsync(new Customer { Id = "c1", Active = true }); + + Assert.AreEqual("c1", (await Resolver().Resolve(Principal(email: null), "store1")).Customer.Id); + _customerService.Verify(x => x.GetCustomerByEmail(It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task LinkOfAnotherProvider_Ignored() + { + await Link("c9", provider: "ExternalAuth.Google"); + Assert.IsFalse((await Resolver().Resolve(Principal(), "store1")).Succeeded); + _customerService.Verify(x => x.GetCustomerById("c9"), Times.Never); + } + + [TestMethod] + public async Task SubjectIsCaseSensitive() + { + await Link("c1", sub: "ABC"); + Assert.IsFalse((await Resolver().Resolve(Principal(sub: "abc"), "store1")).Succeeded); + _customerService.Verify(x => x.GetCustomerById("c1"), Times.Never); + } + + [TestMethod] + [DataRow(false, false)] + [DataRow(true, true)] + public async Task LinkedInactiveOrDeleted_Forbidden(bool active, bool deleted) + { + await Link("c1"); + _customerService.Setup(x => x.GetCustomerById("c1")).ReturnsAsync(new Customer { Id = "c1", Active = active, Deleted = deleted }); + Assert.AreEqual(OidcFailureKind.Forbidden, (await Resolver().Resolve(Principal(), "store1")).Failure); + } + + [TestMethod] + public async Task LinkedMissingCustomer_Forbidden() + { + await Link("gone"); + Assert.AreEqual(OidcFailureKind.Forbidden, (await Resolver().Resolve(Principal(), "store1")).Failure); + } + + [TestMethod] + public async Task NoEmail_InvalidToken() => + Assert.AreEqual(OidcFailureKind.InvalidToken, (await Resolver().Resolve(Principal(email: null), "store1")).Failure); + + [TestMethod] + [DataRow(null)] + [DataRow("false")] + public async Task EmailNotVerified_InvalidToken_NoLookup(string verified) + { + Assert.AreEqual(OidcFailureKind.InvalidToken, (await Resolver().Resolve(Principal(emailVerified: verified), "store1")).Failure); + _customerService.Verify(x => x.GetCustomerByEmail(It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + [DataRow("true")] + [DataRow("True")] + public async Task EmailVerified_AnyCase(string verified) + { + _customerService.Setup(x => x.GetCustomerByEmail("John@Example.com", "")).ReturnsAsync(new Customer { Id = "c1", Active = true }); + Assert.IsTrue((await Resolver().Resolve(Principal(emailVerified: verified), "store1")).Succeeded); + } + + [TestMethod] + public async Task VerificationNotRequired() + { + _config.RequireEmailVerified = false; + _customerService.Setup(x => x.GetCustomerByEmail("John@Example.com", "")).ReturnsAsync(new Customer { Id = "c1", Active = true }); + Assert.IsTrue((await Resolver().Resolve(Principal(emailVerified: null), "store1")).Succeeded); + } + + [TestMethod] + public void AutoRegister_DefaultsToFalse() => Assert.IsFalse(new OidcResourceServerConfig().AutoRegister); + + [TestMethod] + public async Task ExistingCustomer_NotPerStore_GlobalLookup_Linked() + { + _customerService.Setup(x => x.GetCustomerByEmail("John@Example.com", "")).ReturnsAsync(new Customer { Id = "c1", Active = true, StoreId = "store2" }); + + Assert.IsTrue((await Resolver().Resolve(Principal(), "store1")).Succeeded); + + var link = await _links.GetOneAsync(x => x.ExternalIdentifier == "sub-1"); + Assert.AreEqual("c1", link.CustomerId); + Assert.AreEqual("ExternalAuth.OpenIdConnect", link.ProviderSystemName); + Assert.AreEqual("john@example.com", link.Email); + } + + [TestMethod] + public async Task ExistingCustomer_PerStore_StoreLookup() + { + _customerConfig.RegisterCustomersPerStore = true; + _customerService.Setup(x => x.GetCustomerByEmail("John@Example.com", "store1")).ReturnsAsync(new Customer { Id = "c1", Active = true }); + Assert.AreEqual("c1", (await Resolver().Resolve(Principal(), "store1")).Customer.Id); + } + + [TestMethod] + public async Task ExistingCustomerInactive_Forbidden_NotLinked() + { + _customerService.Setup(x => x.GetCustomerByEmail("John@Example.com", "")).ReturnsAsync(new Customer { Id = "c1", Active = false }); + Assert.AreEqual(OidcFailureKind.Forbidden, (await Resolver().Resolve(Principal(), "store1")).Failure); + Assert.IsNull(await _links.GetOneAsync(x => x.ExternalIdentifier == "sub-1")); + } + + [TestMethod] + public async Task NoCustomer_AutoRegisterOff_Forbidden() + { + Assert.AreEqual(OidcFailureKind.Forbidden, (await Resolver().Resolve(Principal(), "store1")).Failure); + _customerService.Verify(x => x.InsertGuestCustomer(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task NoCustomer_Registers_Links_Publishes() + { + _config.AutoRegister = true; + RegistrationRequest captured = null; + _customerManagerService.Setup(x => x.RegisterCustomer(It.IsAny())) + .Callback(r => captured = r).Returns(Task.CompletedTask); + + var result = await Resolver().Resolve(Principal(), "store1"); + + Assert.AreEqual("new1", result.Customer.Id); + Assert.AreEqual("john@example.com", captured.Email); + Assert.AreEqual("john@example.com", captured.Username); + Assert.AreEqual(PasswordFormat.Hashed, captured.PasswordFormat); + Assert.AreEqual("store1", captured.StoreId); + Assert.IsTrue(captured.IsApproved); + Assert.AreEqual(20, captured.Password.Length); + Assert.AreEqual("store1", captured.Customer.StoreId); + Assert.AreEqual("new1", (await _links.GetOneAsync(x => x.ExternalIdentifier == "sub-1")).CustomerId); + _mediator.Verify(x => x.Publish(It.IsAny(), It.IsAny()), Times.Once); + } + + [TestMethod] + public async Task RegistrationDisabledInStore_Forbidden() + { + _config.AutoRegister = true; + _customerSettings.UserRegistrationType = UserRegistrationType.Disabled; + + Assert.AreEqual(OidcFailureKind.Forbidden, (await Resolver().Resolve(Principal(), "store1")).Failure); + _settingService.Verify(x => x.LoadSetting("store1"), Times.Once); + _customerManagerService.Verify(x => x.RegisterCustomer(It.IsAny()), Times.Never); + } + + [TestMethod] + [DataRow(UserRegistrationType.EmailValidation)] + [DataRow(UserRegistrationType.AdminApproval)] + public async Task OtherRegistrationTypes_RegisterApproved(UserRegistrationType type) + { + _config.AutoRegister = true; + _customerSettings.UserRegistrationType = type; + RegistrationRequest captured = null; + _customerManagerService.Setup(x => x.RegisterCustomer(It.IsAny())) + .Callback(r => captured = r).Returns(Task.CompletedTask); + + Assert.IsTrue((await Resolver().Resolve(Principal(), "store1")).Succeeded); + Assert.IsTrue(captured.IsApproved); + } + + [TestMethod] + public async Task UnverifiedEmail_NeverRegisters() + { + _config.AutoRegister = true; + Assert.AreEqual(OidcFailureKind.InvalidToken, (await Resolver().Resolve(Principal(emailVerified: "false"), "store1")).Failure); + _customerService.Verify(x => x.InsertGuestCustomer(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task Race_Registration_LoserDeletesOwnAccount_ReturnsWinner_NoEvent() + { + _config.AutoRegister = true; + var links = new Mock>(); + links.SetupSequence(x => x.GetOneAsync(It.IsAny>>())) + .ReturnsAsync((ExternalAuthentication)null) + .ReturnsAsync((ExternalAuthentication)null) + .ReturnsAsync(new ExternalAuthentication { CustomerId = "winner", ProviderSystemName = "Oidc", ExternalIdentifier = "sub-1" }); + links.Setup(x => x.InsertAsync(It.IsAny())).ThrowsAsync(new Exception("duplicate key")); + _customerService.Setup(x => x.GetCustomerById("winner")).ReturnsAsync(new Customer { Id = "winner", Active = true }); + + var result = await Resolver(links.Object).Resolve(Principal(), "store1"); + + Assert.AreEqual("winner", result.Customer.Id); + _customerService.Verify(x => x.DeleteCustomer(It.Is(c => c.Id == "new1"), true), Times.Once); + _mediator.Verify(x => x.Publish(It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task Race_LinkingExisting_ReturnsLinkedCustomer() + { + var links = new Mock>(); + links.SetupSequence(x => x.GetOneAsync(It.IsAny>>())) + .ReturnsAsync((ExternalAuthentication)null) + .ReturnsAsync((ExternalAuthentication)null) //no other identity linked to the matched customer + .ReturnsAsync(new ExternalAuthentication { CustomerId = "c1", ProviderSystemName = "Oidc", ExternalIdentifier = "sub-1" }); + links.Setup(x => x.InsertAsync(It.IsAny())).ThrowsAsync(new Exception("duplicate key")); + _customerService.Setup(x => x.GetCustomerByEmail("John@Example.com", "")).ReturnsAsync(new Customer { Id = "c1", Active = true }); + _customerService.Setup(x => x.GetCustomerById("c1")).ReturnsAsync(new Customer { Id = "c1", Active = true }); + + Assert.AreEqual("c1", (await Resolver(links.Object).Resolve(Principal(), "store1")).Customer.Id); + } + + [TestMethod] + public async Task InsertFailsWithoutWinner_Rethrows() + { + _config.AutoRegister = true; + var links = new Mock>(); + links.Setup(x => x.GetOneAsync(It.IsAny>>())).ReturnsAsync((ExternalAuthentication)null); + links.Setup(x => x.InsertAsync(It.IsAny())).ThrowsAsync(new InvalidOperationException("db down")); + + await Assert.ThrowsExactlyAsync(() => Resolver(links.Object).Resolve(Principal(), "store1")); + } + + [TestMethod] + public async Task LinkedSystemAccount_Forbidden() + { + await Link("c1"); + _customerService.Setup(x => x.GetCustomerById("c1")).ReturnsAsync(new Customer { + Id = "c1", Active = true, IsSystemAccount = true, SystemName = SystemCustomerNames.BackgroundTask + }); + Assert.AreEqual(OidcFailureKind.Forbidden, (await Resolver().Resolve(Principal(), "store1")).Failure); + } + + [TestMethod] + public async Task EmailMatchesGuest_Forbidden_NotLinked() + { + var guest = new Customer { Id = "c1", Active = true }; + _customerService.Setup(x => x.GetCustomerByEmail("John@Example.com", "")).ReturnsAsync(guest); + _groupService.Setup(x => x.IsRegistered(guest)).ReturnsAsync(false); + + Assert.AreEqual(OidcFailureKind.Forbidden, (await Resolver().Resolve(Principal(), "store1")).Failure); + Assert.IsNull(await _links.GetOneAsync(x => x.ExternalIdentifier == "sub-1")); + } + + [TestMethod] + public async Task EmailMatchesCustomerLinkedToAnotherSubject_Forbidden_NotLinked() + { + await Link("c1", sub: "other-sub"); + _customerService.Setup(x => x.GetCustomerByEmail("John@Example.com", "")).ReturnsAsync(new Customer { Id = "c1", Active = true }); + + Assert.AreEqual(OidcFailureKind.Forbidden, (await Resolver().Resolve(Principal(), "store1")).Failure); + Assert.IsNull(await _links.GetOneAsync(x => x.ExternalIdentifier == "sub-1")); + } + + [TestMethod] + public async Task UnverifiedEmail_AdminPanelAccount_Forbidden_NotLinked() + { + _config.RequireEmailVerified = false; + var admin = new Customer { Id = "c1", Active = true }; + _customerService.Setup(x => x.GetCustomerByEmail("John@Example.com", "")).ReturnsAsync(admin); + _permissionService.Setup(x => x.Authorize(StandardPermission.ManageAccessAdminPanel, admin)).ReturnsAsync(true); + + Assert.AreEqual(OidcFailureKind.Forbidden, (await Resolver().Resolve(Principal(emailVerified: null), "store1")).Failure); + Assert.IsNull(await _links.GetOneAsync(x => x.ExternalIdentifier == "sub-1")); + } + + [TestMethod] + [DataRow("vendor1", null)] + [DataRow(null, "store1")] + public async Task UnverifiedEmail_VendorOrStaffAccount_Forbidden_NotLinked(string vendorId, string staffStoreId) + { + _config.RequireEmailVerified = false; + _customerService.Setup(x => x.GetCustomerByEmail("John@Example.com", "")).ReturnsAsync(new Customer { + Id = "c1", Active = true, VendorId = vendorId, StaffStoreId = staffStoreId + }); + + Assert.AreEqual(OidcFailureKind.Forbidden, (await Resolver().Resolve(Principal(emailVerified: null), "store1")).Failure); + Assert.IsNull(await _links.GetOneAsync(x => x.ExternalIdentifier == "sub-1")); + } + + [TestMethod] + public async Task UnverifiedEmail_PlainRegisteredCustomer_Linked() + { + _config.RequireEmailVerified = false; + _customerService.Setup(x => x.GetCustomerByEmail("John@Example.com", "")).ReturnsAsync(new Customer { Id = "c1", Active = true }); + + Assert.IsTrue((await Resolver().Resolve(Principal(emailVerified: null), "store1")).Succeeded); + Assert.AreEqual("c1", (await _links.GetOneAsync(x => x.ExternalIdentifier == "sub-1")).CustomerId); + } + + [TestMethod] + public async Task VerifiedEmail_BackOfficeAccount_Linked() + { + var admin = new Customer { Id = "c1", Active = true, StaffStoreId = "store1" }; + _customerService.Setup(x => x.GetCustomerByEmail("John@Example.com", "")).ReturnsAsync(admin); + _permissionService.Setup(x => x.Authorize(StandardPermission.ManageAccessAdminPanel, admin)).ReturnsAsync(true); + + Assert.IsTrue((await Resolver().Resolve(Principal(), "store1")).Succeeded); + } + + [TestMethod] + public async Task PerStore_LinkedCustomerOfAnotherStore_Forbidden() + { + _customerConfig.RegisterCustomersPerStore = true; + await Link("c1"); + _customerService.Setup(x => x.GetCustomerById("c1")).ReturnsAsync(new Customer { Id = "c1", Active = true, StoreId = "store2" }); + + Assert.AreEqual(OidcFailureKind.Forbidden, (await Resolver().Resolve(Principal(), "store1")).Failure); + } + + [TestMethod] + [DataRow(null)] + [DataRow("")] + [DataRow("store1")] + public async Task PerStore_LinkedStorelessOrSameStoreCustomer_Allowed(string customerStoreId) + { + _customerConfig.RegisterCustomersPerStore = true; + await Link("c1"); + _customerService.Setup(x => x.GetCustomerById("c1")).ReturnsAsync(new Customer { Id = "c1", Active = true, StoreId = customerStoreId }); + + Assert.AreEqual("c1", (await Resolver().Resolve(Principal(), "store1")).Customer.Id); + } + + [TestMethod] + public async Task NotPerStore_LinkedCustomerOfAnotherStore_Allowed() + { + await Link("c1"); + _customerService.Setup(x => x.GetCustomerById("c1")).ReturnsAsync(new Customer { Id = "c1", Active = true, StoreId = "store2" }); + + Assert.AreEqual("c1", (await Resolver().Resolve(Principal(), "store1")).Customer.Id); + } + + [TestMethod] + public async Task UsernameTaken_Forbidden_NoRegistration() + { + _config.AutoRegister = true; + _customerService.Setup(x => x.GetCustomerByUsername("john@example.com", "")).ReturnsAsync(new Customer { Id = "other" }); + + Assert.AreEqual(OidcFailureKind.Forbidden, (await Resolver().Resolve(Principal(), "store1")).Failure); + _customerService.Verify(x => x.InsertGuestCustomer(It.IsAny()), Times.Never); + _customerManagerService.Verify(x => x.RegisterCustomer(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task UsernameTaken_PerStore_StoreLookup() + { + _config.AutoRegister = true; + _customerConfig.RegisterCustomersPerStore = true; + _customerService.Setup(x => x.GetCustomerByUsername("john@example.com", "store1")).ReturnsAsync(new Customer { Id = "other" }); + + Assert.AreEqual(OidcFailureKind.Forbidden, (await Resolver().Resolve(Principal(), "store1")).Failure); + _customerService.Verify(x => x.InsertGuestCustomer(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task PerStore_SameSubject_EachStoreLinksAndResolvesItsOwnCustomer() + { + _customerConfig.RegisterCustomersPerStore = true; + await Link("c1", storeId: "store1"); + _customerService.Setup(x => x.GetCustomerById("c1")).ReturnsAsync(new Customer { Id = "c1", Active = true, StoreId = "store1" }); + _customerService.Setup(x => x.GetCustomerByEmail("John@Example.com", "store2")).ReturnsAsync(new Customer { Id = "c2", Active = true, StoreId = "store2" }); + _customerService.Setup(x => x.GetCustomerById("c2")).ReturnsAsync(new Customer { Id = "c2", Active = true, StoreId = "store2" }); + + Assert.AreEqual("c2", (await Resolver().Resolve(Principal(), "store2")).Customer.Id); + Assert.AreEqual("c2", (await Resolver().Resolve(Principal(), "store2")).Customer.Id); + Assert.AreEqual("c1", (await Resolver().Resolve(Principal(), "store1")).Customer.Id); + + var links = LinksOf("sub-1"); + Assert.HasCount(2, links); + Assert.AreEqual("store2", links.Single(x => x.CustomerId == "c2").StoreId); + } + + [TestMethod] + public async Task PerStore_Registration_LinkCarriesTheStore() + { + _config.AutoRegister = true; + _customerConfig.RegisterCustomersPerStore = true; + await Link("c1", storeId: "store1"); + _customerService.Setup(x => x.GetCustomerById("c1")).ReturnsAsync(new Customer { Id = "c1", Active = true, StoreId = "store1" }); + + Assert.AreEqual("new1", (await Resolver().Resolve(Principal(), "store2")).Customer.Id); + + Assert.AreEqual("store2", LinksOf("sub-1").Single(x => x.CustomerId == "new1").StoreId); + } + + [TestMethod] + [DataRow(null)] + [DataRow("")] + public async Task PerStore_GlobalCustomer_ResolvesInEveryStore(string linkStoreId) + { + _customerConfig.RegisterCustomersPerStore = true; + await Link("c1", storeId: linkStoreId); + _customerService.Setup(x => x.GetCustomerById("c1")).ReturnsAsync(new Customer { Id = "c1", Active = true, StoreId = "" }); + + Assert.AreEqual("c1", (await Resolver().Resolve(Principal(), "store1")).Customer.Id); + Assert.AreEqual("c1", (await Resolver().Resolve(Principal(), "store2")).Customer.Id); + Assert.HasCount(1, LinksOf("sub-1")); + } + + [TestMethod] + public async Task PerStore_GlobalCustomerLinked_WithEmptyStore() + { + _customerConfig.RegisterCustomersPerStore = true; + _customerService.Setup(x => x.GetCustomerByEmail("John@Example.com", "store1")).ReturnsAsync(new Customer { Id = "c1", Active = true, StoreId = "" }); + + Assert.AreEqual("c1", (await Resolver().Resolve(Principal(), "store1")).Customer.Id); + + Assert.AreEqual("", LinksOf("sub-1").Single().StoreId); + } + + [TestMethod] + public async Task PerStore_StoreLinkPreferredOverGlobalLink() + { + _customerConfig.RegisterCustomersPerStore = true; + await Link("global", storeId: ""); + await Link("c1", storeId: "store1"); + _customerService.Setup(x => x.GetCustomerById("global")).ReturnsAsync(new Customer { Id = "global", Active = true }); + _customerService.Setup(x => x.GetCustomerById("c1")).ReturnsAsync(new Customer { Id = "c1", Active = true, StoreId = "store1" }); + + Assert.AreEqual("c1", (await Resolver().Resolve(Principal(), "store1")).Customer.Id); + Assert.AreEqual("global", (await Resolver().Resolve(Principal(), "store2")).Customer.Id); + } + + [TestMethod] + public async Task NotPerStore_LinkStoredWithoutStore_AndFoundInAnyStore() + { + _customerService.Setup(x => x.GetCustomerByEmail("John@Example.com", "")).ReturnsAsync(new Customer { Id = "c1", Active = true, StoreId = "store2" }); + _customerService.Setup(x => x.GetCustomerById("c1")).ReturnsAsync(new Customer { Id = "c1", Active = true, StoreId = "store2" }); + + Assert.AreEqual("c1", (await Resolver().Resolve(Principal(), "store1")).Customer.Id); + Assert.AreEqual("c1", (await Resolver().Resolve(Principal(), "store3")).Customer.Id); + + Assert.AreEqual("", LinksOf("sub-1").Single().StoreId); + } + + [TestMethod] + public async Task NotPerStore_LinkWrittenPerStore_StillFoundInAnyStore() + { + await Link("c1", storeId: "store2"); + _customerService.Setup(x => x.GetCustomerById("c1")).ReturnsAsync(new Customer { Id = "c1", Active = true, StoreId = "store2" }); + + Assert.AreEqual("c1", (await Resolver().Resolve(Principal(email: null), "store1")).Customer.Id); + } + + [TestMethod] + public async Task PerStore_Race_RereadsTheWinnerOfTheSameStore() + { + _customerConfig.RegisterCustomersPerStore = true; + //another store's link exists; a concurrent request of this store stores its link first + var stored = new List { + new() { CustomerId = "c1", ProviderSystemName = OidcCustomerResolver.ProviderSystemName, ExternalIdentifier = "sub-1", StoreId = "store1" } + }; + var links = new Mock>(); + links.Setup(x => x.GetOneAsync(It.IsAny>>())) + .ReturnsAsync((Expression> p) => stored.FirstOrDefault(p.Compile())); + links.Setup(x => x.InsertAsync(It.IsAny())) + .Callback(() => stored.Add(new ExternalAuthentication { + CustomerId = "winner", ProviderSystemName = OidcCustomerResolver.ProviderSystemName, ExternalIdentifier = "sub-1", StoreId = "store2" + })) + .ThrowsAsync(new Exception("duplicate key")); + _customerService.Setup(x => x.GetCustomerByEmail("John@Example.com", "store2")).ReturnsAsync(new Customer { Id = "c2", Active = true, StoreId = "store2" }); + _customerService.Setup(x => x.GetCustomerById("winner")).ReturnsAsync(new Customer { Id = "winner", Active = true, StoreId = "store2" }); + + Assert.AreEqual("winner", (await Resolver(links.Object).Resolve(Principal(), "store2")).Customer.Id); + } + + [TestMethod] + [DataRow(null)] + [DataRow("")] + public async Task PerStore_LegacyGlobalLinkToStoreCustomer_OtherStoreLinksItsOwnCustomer(string legacyStoreId) + { + //a link written before customers were registered per store, to a customer of store1 + _customerConfig.RegisterCustomersPerStore = true; + await Link("c1", storeId: legacyStoreId); + _customerService.Setup(x => x.GetCustomerById("c1")).ReturnsAsync(new Customer { Id = "c1", Active = true, StoreId = "store1" }); + _customerService.Setup(x => x.GetCustomerByEmail("John@Example.com", "store2")).ReturnsAsync(new Customer { Id = "c2", Active = true, StoreId = "store2" }); + _customerService.Setup(x => x.GetCustomerById("c2")).ReturnsAsync(new Customer { Id = "c2", Active = true, StoreId = "store2" }); + + Assert.AreEqual("c2", (await Resolver().Resolve(Principal(), "store2")).Customer.Id); + Assert.AreEqual("c2", (await Resolver().Resolve(Principal(), "store2")).Customer.Id); + Assert.AreEqual("c1", (await Resolver().Resolve(Principal(), "store1")).Customer.Id); + + var links = LinksOf("sub-1"); + Assert.HasCount(2, links); + Assert.AreEqual("store2", links.Single(x => x.CustomerId == "c2").StoreId); + } + /// + /// Repository that lets a concurrent request store its record right before this one's insert + /// + private sealed class RacingLinks : MongoDBRepositoryTest + { + public Func BeforeInsert { get; set; } + + public override async Task InsertAsync(ExternalAuthentication entity) + { + var before = BeforeInsert; + BeforeInsert = null; + if (before != null) + await before(); + return await base.InsertAsync(entity); + } + } + + [TestMethod] + public void OidcLinkId_Stable_24LowerHex_DerivedFromCustomer() + { + var id = OidcCustomerResolver.OidcLinkId("c1"); + + var hash = System.Security.Cryptography.SHA256.HashData( + System.Text.Encoding.UTF8.GetBytes("ExternalAuth.OpenIdConnect:c1")); + Assert.AreEqual(Convert.ToHexStringLower(hash, 0, 12), id); + Assert.AreEqual(id, OidcCustomerResolver.OidcLinkId("c1")); + Assert.MatchesRegex("^[0-9a-f]{24}$", id); + Assert.AreNotEqual(id, OidcCustomerResolver.OidcLinkId("c2")); + } + + [TestMethod] + public async Task LinkingExisting_StoresLinkWithCustomerDerivedId() + { + _customerService.Setup(x => x.GetCustomerByEmail("John@Example.com", "")).ReturnsAsync(new Customer { Id = "c1", Active = true }); + + Assert.IsTrue((await Resolver().Resolve(Principal(), "store1")).Succeeded); + + Assert.AreEqual(OidcCustomerResolver.OidcLinkId("c1"), LinksOf("sub-1").Single().Id); + } + + [TestMethod] + public async Task Registration_StoresLinkWithCustomerDerivedId() + { + _config.AutoRegister = true; + + Assert.AreEqual("new1", (await Resolver().Resolve(Principal(), "store1")).Customer.Id); + + Assert.AreEqual(OidcCustomerResolver.OidcLinkId("new1"), LinksOf("sub-1").Single().Id); + } + + [TestMethod] + public async Task Race_SameCustomer_SecondSubject_LoserRefused_NoException() + { + //both requests pass the "no other identity" check; the other subject stores its link first + var links = new RacingLinks(); + links.BeforeInsert = () => links.InsertAsync(new ExternalAuthentication { + Id = OidcCustomerResolver.OidcLinkId("c1"), CustomerId = "c1", + ProviderSystemName = OidcCustomerResolver.ProviderSystemName, ExternalIdentifier = "other-sub", StoreId = "" + }); + _customerService.Setup(x => x.GetCustomerByEmail("John@Example.com", "")).ReturnsAsync(new Customer { Id = "c1", Active = true }); + + var result = await Resolver(links).Resolve(Principal(), "store1"); + + Assert.AreEqual(OidcFailureKind.Forbidden, result.Failure); + Assert.AreEqual("The customer account is linked to another identity", result.Description); + var stored = links.Table.Where(x => x.CustomerId == "c1").ToList(); + Assert.HasCount(1, stored); + Assert.AreEqual("other-sub", stored.Single().ExternalIdentifier); + } + + [TestMethod] + public async Task Race_SameCustomer_SameSubject_ReturnsCustomer() + { + var links = new RacingLinks(); + links.BeforeInsert = () => links.InsertAsync(new ExternalAuthentication { + Id = OidcCustomerResolver.OidcLinkId("c1"), CustomerId = "c1", + ProviderSystemName = OidcCustomerResolver.ProviderSystemName, ExternalIdentifier = "sub-1", StoreId = "" + }); + _customerService.Setup(x => x.GetCustomerByEmail("John@Example.com", "")).ReturnsAsync(new Customer { Id = "c1", Active = true }); + _customerService.Setup(x => x.GetCustomerById("c1")).ReturnsAsync(new Customer { Id = "c1", Active = true }); + + Assert.AreEqual("c1", (await Resolver(links).Resolve(Principal(), "store1")).Customer.Id); + Assert.HasCount(1, LinksOf("sub-1")); + } + + [TestMethod] + public async Task Unlink_ThenOtherSubject_RelinksWithTheSameId() + { + _customerService.Setup(x => x.GetCustomerByEmail("John@Example.com", "")).ReturnsAsync(new Customer { Id = "c1", Active = true }); + Assert.IsTrue((await Resolver().Resolve(Principal(), "store1")).Succeeded); + var first = LinksOf("sub-1").Single(); + + //My account - remove the external association by its record id + await _links.DeleteAsync(first); + + Assert.AreEqual("c1", (await Resolver().Resolve(Principal(sub: "sub-2"), "store1")).Customer.Id); + var relinked = LinksOf("sub-2").Single(); + Assert.AreEqual(first.Id, relinked.Id); + Assert.AreEqual("c1", relinked.CustomerId); + Assert.IsEmpty(LinksOf("sub-1")); + } + + [TestMethod] + public async Task PerStore_CustomersOfEachStore_GetTheirOwnLinkIds() + { + _customerConfig.RegisterCustomersPerStore = true; + _customerService.Setup(x => x.GetCustomerByEmail("John@Example.com", "store1")).ReturnsAsync(new Customer { Id = "c1", Active = true, StoreId = "store1" }); + _customerService.Setup(x => x.GetCustomerByEmail("John@Example.com", "store2")).ReturnsAsync(new Customer { Id = "c2", Active = true, StoreId = "store2" }); + + Assert.AreEqual("c1", (await Resolver().Resolve(Principal(), "store1")).Customer.Id); + Assert.AreEqual("c2", (await Resolver().Resolve(Principal(), "store2")).Customer.Id); + + var links = LinksOf("sub-1"); + Assert.HasCount(2, links); + Assert.AreEqual(OidcCustomerResolver.OidcLinkId("c1"), links.Single(x => x.StoreId == "store1").Id); + Assert.AreEqual(OidcCustomerResolver.OidcLinkId("c2"), links.Single(x => x.StoreId == "store2").Id); + } +} diff --git a/src/Tests/Grand.Web.Common.Tests/Middleware/ContextMiddlewareTests.cs b/src/Tests/Grand.Web.Common.Tests/Middleware/ContextMiddlewareTests.cs index 365c446983..dced229a6c 100644 --- a/src/Tests/Grand.Web.Common.Tests/Middleware/ContextMiddlewareTests.cs +++ b/src/Tests/Grand.Web.Common.Tests/Middleware/ContextMiddlewareTests.cs @@ -17,7 +17,7 @@ public class ContextMiddlewareTests private Mock _storeContextSetter; private Mock _workContextSetter; - private async Task Invoke(string routePattern) + private async Task Invoke(string routePattern, Action request = null) { _storeContextSetter = new Mock(); var storeContext = new Mock(); @@ -31,6 +31,7 @@ private async Task Invoke(string routePattern) var http = new DefaultHttpContext { RequestServices = services.BuildServiceProvider() }; http.SetEndpoint(new RouteEndpoint(_ => Task.CompletedTask, RoutePatternFactory.Parse(routePattern), 0, EndpointMetadataCollection.Empty, null)); + request?.Invoke(http); var nextCalled = false; var middleware = new ContextMiddleware(_ => @@ -54,10 +55,33 @@ public async Task ProtectedResourceMetadata_NoStoreOrWorkContext(string routePat _workContextSetter.Verify(x => x.InitializeWorkContext(It.IsAny()), Times.Never); } + [TestMethod] + [DataRow(OidcProtectedResourceMetadata.WellKnownPath + "-x")] + [DataRow(OidcProtectedResourceMetadata.WellKnownPath + "x/{**path}")] + public async Task RouteOnlyPrefixedByMetadataPath_InitializesStoreAndWorkContext(string routePattern) + { + Assert.IsTrue(await Invoke(routePattern)); + _workContextSetter.Verify(x => x.InitializeWorkContext("store1"), Times.Once); + } + [TestMethod] public async Task OtherRoute_InitializesStoreAndWorkContext() { Assert.IsTrue(await Invoke("/api/{controller}")); _workContextSetter.Verify(x => x.InitializeWorkContext("store1"), Times.Once); } + + [TestMethod] + public async Task NoPinnedStore_StoreContextFromCookieOrHost() + { + Assert.IsTrue(await Invoke("/api/{controller}")); + _storeContextSetter.Verify(x => x.InitializeStoreContext(null), Times.Once); + } + + [TestMethod] + public async Task PinnedStore_PassedToTheStoreContext() + { + Assert.IsTrue(await Invoke("/api/{controller}", http => RequestStorePin.Set(http, "store2"))); + _storeContextSetter.Verify(x => x.InitializeStoreContext("store2"), Times.Once); + } } diff --git a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcChallengeWriterTests.cs b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcChallengeWriterTests.cs index 5f69da0fca..ad9a78d5c2 100644 --- a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcChallengeWriterTests.cs +++ b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcChallengeWriterTests.cs @@ -54,6 +54,7 @@ await OidcChallengeWriter.Write(context, new OidcAuthenticationFailure { Kind = OidcFailureKind.Forbidden, Description = "The customer account is inactive" }, Meta, null); Assert.AreEqual(403, context.Response.StatusCode); + Assert.IsFalse(context.Response.Headers.ContainsKey("Retry-After")); Assert.IsFalse(context.Response.Headers.ContainsKey("WWW-Authenticate")); StringAssert.StartsWith(context.Response.ContentType, "application/problem+json"); } @@ -76,10 +77,12 @@ await OidcChallengeWriter.Write(context, new OidcAuthenticationFailure { Kind = OidcFailureKind.ServiceUnavailable, Description = "The authorization server is unavailable" }, Meta, null); Assert.AreEqual(503, context.Response.StatusCode); + Assert.AreEqual("30", context.Response.Headers.RetryAfter.ToString()); Assert.IsFalse(context.Response.Headers.ContainsKey("WWW-Authenticate")); StringAssert.StartsWith(context.Response.ContentType, "application/problem+json"); context.Response.Body.Position = 0; - var body = await new StreamReader(context.Response.Body).ReadToEndAsync(); + using var reader = new StreamReader(context.Response.Body); + var body = await reader.ReadToEndAsync(); StringAssert.Contains(body, "\"status\":503"); StringAssert.Contains(body, "\"title\":\"Service unavailable\""); } diff --git a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcCustomerPipelineTests.cs b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcCustomerPipelineTests.cs new file mode 100644 index 0000000000..a7ba60200f --- /dev/null +++ b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcCustomerPipelineTests.cs @@ -0,0 +1,131 @@ +using Grand.Business.Core.Interfaces.Authentication; +using Grand.Business.Core.Interfaces.Common.Security; +using Grand.Business.Core.Utilities.Authentication; +using Grand.Domain.Customers; +using Grand.Domain.Permissions; +using Grand.Infrastructure.Configuration; +using Grand.Web.Common.Security.Oidc; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using System.Net; +using System.Security.Claims; + +namespace Grand.Web.Common.Tests.Security.Oidc; + +[TestClass] +public class OidcCustomerPipelineTests +{ + private OidcTestHost _host; + private HttpClient _client; + private Mock _resolver; + private Mock _permissions; + + private async Task Start(OidcResourceServerConfig config = null) + { + if (_host != null) await _host.DisposeAsync(); + _host = new OidcTestHost(); + _client = await _host.Start(config ?? OidcTestHost.Config(), + services: s => + { + s.AddSingleton(_resolver.Object); + s.AddSingleton(_permissions.Object); + }, + endpoints: e => + { + e.MapGet("/api/whoami", (HttpContext c) => (c.Items[OidcResourceServerDefaults.CustomerItemKey] as Customer)?.Id ?? "none") + .RequireAuthorization(new AuthorizeAttribute { AuthenticationSchemes = OidcResourceServerDefaults.AuthenticationScheme }); + //no [Authorize]: the work context path - the request resolver authenticates on demand + e.MapGet("/api/plain", async (HttpContext c) => + { + var resolver = c.RequestServices.GetServices().Single(); + return (await resolver.Resolve())?.Id ?? "none"; + }); + }); + } + + [TestInitialize] + public async Task Init() + { + _resolver = new Mock(); + _resolver.Setup(x => x.Resolve(It.IsAny(), It.IsAny())) + .ReturnsAsync(OidcCustomerResult.Success(new Customer { Id = "c1", Active = true })); + _permissions = new Mock(); + _permissions.Setup(x => x.Authorize(It.IsAny(), It.IsAny())).ReturnsAsync(true); + await Start(); + } + + [TestCleanup] + public async Task Cleanup() => await _host.DisposeAsync(); + + private Task Get(string path, string token = null) => OidcTestHost.Send(_client, path, token); + + [TestMethod] + public async Task ValidToken_CustomerInItems_StoreFromHost() + { + using var response = await Get("/api/whoami", OidcTestHost.Token()); + Assert.AreEqual("c1", await response.Content.ReadAsStringAsync()); + _resolver.Verify(x => x.Resolve(It.IsAny(), "store1"), Times.Once); + } + + [TestMethod] + public async Task ResolverInvalidToken_401_WithDescription() + { + _resolver.Setup(x => x.Resolve(It.IsAny(), It.IsAny())) + .ReturnsAsync(OidcCustomerResult.InvalidToken("The e-mail address in the token is not verified")); + using var response = await Get("/api/whoami", OidcTestHost.Token()); + Assert.AreEqual(HttpStatusCode.Unauthorized, response.StatusCode); + StringAssert.Contains(response.Headers.WwwAuthenticate.ToString(), "not verified"); + } + + [TestMethod] + public async Task ResolverForbidden_403_NoWwwAuthenticate() + { + _resolver.Setup(x => x.Resolve(It.IsAny(), It.IsAny())) + .ReturnsAsync(OidcCustomerResult.Forbidden("The customer account is inactive")); + using var response = await Get("/api/whoami", OidcTestHost.Token()); + Assert.AreEqual(HttpStatusCode.Forbidden, response.StatusCode); + Assert.AreEqual(0, response.Headers.WwwAuthenticate.Count); + } + + [TestMethod] + public async Task MissingScope_ResolverNeverCalled() + { + using var response = await Get("/api/whoami", OidcTestHost.Token(scope: "openid")); + Assert.AreEqual(HttpStatusCode.Forbidden, response.StatusCode); + _resolver.Verify(x => x.Resolve(It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task RequireAdminPanelAccess_NoPermission_403() + { + _permissions.Setup(x => x.Authorize(StandardPermission.ManageAccessAdminPanel, It.IsAny())).ReturnsAsync(false); + await Start(OidcTestHost.Config(new OidcProtectedResource { + Name = "BackendApi", PathPrefix = "/api", RequireAdminPanelAccess = true + })); + using var response = await Get("/api/whoami", OidcTestHost.Token()); + Assert.AreEqual(HttpStatusCode.Forbidden, response.StatusCode); + } + + [TestMethod] + public async Task DenyVendors_VendorCustomer_403() + { + _resolver.Setup(x => x.Resolve(It.IsAny(), It.IsAny())) + .ReturnsAsync(OidcCustomerResult.Success(new Customer { Id = "v", Active = true, VendorId = "vendor-1" })); + await Start(OidcTestHost.Config(new OidcProtectedResource { Name = "BackendApi", PathPrefix = "/api", DenyVendors = true })); + using var response = await Get("/api/whoami", OidcTestHost.Token()); + Assert.AreEqual(HttpStatusCode.Forbidden, response.StatusCode); + } + + [TestMethod] + public async Task RequestCustomerResolver_WithAndWithoutToken() + { + using var withToken = await Get("/api/plain", OidcTestHost.Token()); + Assert.AreEqual("c1", await withToken.Content.ReadAsStringAsync()); + using var withoutToken = await Get("/api/plain"); + Assert.AreEqual("none", await withoutToken.Content.ReadAsStringAsync()); + } +} diff --git a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcPipelineTests.cs b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcPipelineTests.cs index 5a24428f2c..42683c5536 100644 --- a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcPipelineTests.cs +++ b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcPipelineTests.cs @@ -41,12 +41,13 @@ public async Task ForeignHostHeader_TokenForThatHost_401() } [TestMethod] - public async Task AccountConditionFlag_ValidToken_403() + public async Task RootMetadata_ReturnsDocumentBody() { - await Start(OidcTestHost.Config(new OidcProtectedResource { - Name = "BackendApi", PathPrefix = "/api", RequiredScopes = ["grandnode.api"], RequireAdminPanelAccess = true - })); - Assert.AreEqual(HttpStatusCode.Forbidden, (await Get("/api/ping", OidcTestHost.Token())).StatusCode); + using var response = await _client.GetAsync("/.well-known/oauth-protected-resource"); + var body = await response.Content.ReadAsStringAsync(); + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + StringAssert.Contains(body, "\"resource\":\"http://localhost\""); + StringAssert.Contains(body, "\"authorization_servers\""); } [TestMethod] @@ -55,8 +56,10 @@ public async Task Disabled_OidcEndpoint_401NotServerError() var config = OidcTestHost.Config(); config.Enabled = false; await Start(config); - Assert.AreEqual(HttpStatusCode.Unauthorized, (await Get("/api/ping", OidcTestHost.Token())).StatusCode); - Assert.AreEqual(HttpStatusCode.Unauthorized, (await Get("/api/ping")).StatusCode); + using var withToken = await Get("/api/ping", OidcTestHost.Token()); + Assert.AreEqual(HttpStatusCode.Unauthorized, withToken.StatusCode); + using var withoutToken = await Get("/api/ping"); + Assert.AreEqual(HttpStatusCode.Unauthorized, withoutToken.StatusCode); } private Task Get(string path, string token = null, string scheme = "Bearer") => @@ -65,7 +68,7 @@ private Task Get(string path, string token = null, string s [TestMethod] public async Task ValidToken_200() { - var response = await Get("/api/ping", OidcTestHost.Token()); + using var response = await Get("/api/ping", OidcTestHost.Token()); Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); Assert.AreEqual("sub-1", await response.Content.ReadAsStringAsync()); } @@ -73,7 +76,7 @@ public async Task ValidToken_200() [TestMethod] public async Task NoToken_401_AdvertisesMetadataAndScope() { - var response = await Get("/api/ping"); + using var response = await Get("/api/ping"); Assert.AreEqual(HttpStatusCode.Unauthorized, response.StatusCode); Assert.AreEqual("Bearer resource_metadata=\"http://localhost/.well-known/oauth-protected-resource/api\", scope=\"grandnode.api\"", response.Headers.WwwAuthenticate.ToString()); @@ -82,7 +85,7 @@ public async Task NoToken_401_AdvertisesMetadataAndScope() [TestMethod] public async Task WrongAudience_401() { - var response = await Get("/api/ping", OidcTestHost.Token(audience: "http://localhost/other")); + using var response = await Get("/api/ping", OidcTestHost.Token(audience: "http://localhost/other")); Assert.AreEqual(HttpStatusCode.Unauthorized, response.StatusCode); StringAssert.Contains(response.Headers.WwwAuthenticate.ToString(), "another audience"); } @@ -91,21 +94,24 @@ public async Task WrongAudience_401() public async Task ConfiguredAudience_200() { await Start(OidcTestHost.Config(new OidcProtectedResource { Name = "BackendApi", PathPrefix = "/api", Audiences = ["318274561"] })); - Assert.AreEqual(HttpStatusCode.OK, (await Get("/api/ping", OidcTestHost.Token(audience: "318274561"))).StatusCode); + using var response = await Get("/api/ping", OidcTestHost.Token(audience: "318274561")); + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); } [TestMethod] public async Task AuthorizedParty_Enforced() { await Start(OidcTestHost.Config(new OidcProtectedResource { Name = "BackendApi", PathPrefix = "/api", AuthorizedParties = ["cli"] })); - Assert.AreEqual(HttpStatusCode.Unauthorized, (await Get("/api/ping", OidcTestHost.Token(extra: new Claim("azp", "other")))).StatusCode); - Assert.AreEqual(HttpStatusCode.OK, (await Get("/api/ping", OidcTestHost.Token(extra: new Claim("azp", "cli")))).StatusCode); + using var otherParty = await Get("/api/ping", OidcTestHost.Token(extra: new Claim("azp", "other"))); + Assert.AreEqual(HttpStatusCode.Unauthorized, otherParty.StatusCode); + using var allowedParty = await Get("/api/ping", OidcTestHost.Token(extra: new Claim("azp", "cli"))); + Assert.AreEqual(HttpStatusCode.OK, allowedParty.StatusCode); } [TestMethod] public async Task MissingScope_403_InsufficientScope() { - var response = await Get("/api/ping", OidcTestHost.Token(scope: "openid")); + using var response = await Get("/api/ping", OidcTestHost.Token(scope: "openid")); Assert.AreEqual(HttpStatusCode.Forbidden, response.StatusCode); var header = response.Headers.WwwAuthenticate.ToString(); StringAssert.Contains(header, "error=\"insufficient_scope\""); @@ -114,21 +120,33 @@ public async Task MissingScope_403_InsufficientScope() } [TestMethod] - public async Task ForgedSignature_401() => - Assert.AreEqual(HttpStatusCode.Unauthorized, (await Get("/api/ping", OidcTestHost.Token(key: OidcTestHost.ForeignKey))).StatusCode); + public async Task ForgedSignature_401() + { + using var response = await Get("/api/ping", OidcTestHost.Token(key: OidcTestHost.ForeignKey)); + Assert.AreEqual(HttpStatusCode.Unauthorized, response.StatusCode); + } [TestMethod] - public async Task OtherIssuer_401() => - Assert.AreEqual(HttpStatusCode.Unauthorized, (await Get("/api/ping", OidcTestHost.Token(issuer: "https://evil.test"))).StatusCode); + public async Task OtherIssuer_401() + { + using var response = await Get("/api/ping", OidcTestHost.Token(issuer: "https://evil.test")); + Assert.AreEqual(HttpStatusCode.Unauthorized, response.StatusCode); + } [TestMethod] - public async Task Expired_401() => - Assert.AreEqual(HttpStatusCode.Unauthorized, (await Get("/api/ping", OidcTestHost.Token(expires: DateTime.UtcNow.AddMinutes(-5)))).StatusCode); + public async Task Expired_401() + { + using var response = await Get("/api/ping", OidcTestHost.Token(expires: DateTime.UtcNow.AddMinutes(-5))); + Assert.AreEqual(HttpStatusCode.Unauthorized, response.StatusCode); + } [TestMethod] - public async Task Hmac_401() => - Assert.AreEqual(HttpStatusCode.Unauthorized, (await Get("/api/ping", OidcTestHost.Token( - key: new SymmetricSecurityKey(Encoding.ASCII.GetBytes(new string('k', 40))), algorithm: SecurityAlgorithms.HmacSha256))).StatusCode); + public async Task Hmac_401() + { + using var response = await Get("/api/ping", OidcTestHost.Token( + key: new SymmetricSecurityKey(Encoding.ASCII.GetBytes(new string('k', 40))), algorithm: SecurityAlgorithms.HmacSha256)); + Assert.AreEqual(HttpStatusCode.Unauthorized, response.StatusCode); + } [TestMethod] public async Task Unsigned_401() @@ -136,27 +154,36 @@ public async Task Unsigned_401() var header = Base64UrlEncoder.Encode("{\"alg\":\"none\",\"typ\":\"JWT\"}"); var payload = Base64UrlEncoder.Encode( $"{{\"iss\":\"{OidcTestHost.Issuer}\",\"aud\":\"http://localhost/api\",\"sub\":\"sub-1\",\"scope\":\"grandnode.api\",\"exp\":{DateTimeOffset.UtcNow.AddMinutes(10).ToUnixTimeSeconds()}}}"); - Assert.AreEqual(HttpStatusCode.Unauthorized, (await Get("/api/ping", $"{header}.{payload}.")).StatusCode); + using var response = await Get("/api/ping", $"{header}.{payload}."); + Assert.AreEqual(HttpStatusCode.Unauthorized, response.StatusCode); } [TestMethod] [DataRow("garbage", "Bearer")] [DataRow("dXNlcjpwYXNz", "Basic")] - public async Task Malformed_401_Never500(string token, string scheme) => - Assert.AreEqual(HttpStatusCode.Unauthorized, (await Get("/api/ping", token, scheme)).StatusCode); + public async Task Malformed_401_Never500(string token, string scheme) + { + using var response = await Get("/api/ping", token, scheme); + Assert.AreEqual(HttpStatusCode.Unauthorized, response.StatusCode); + } [TestMethod] - public async Task OidcEndpointOutsideAnyResource_401() => - Assert.AreEqual(HttpStatusCode.Unauthorized, (await Get("/other/ping", OidcTestHost.Token(audience: "http://localhost/other"))).StatusCode); + public async Task OidcEndpointOutsideAnyResource_401() + { + using var response = await Get("/other/ping", OidcTestHost.Token(audience: "http://localhost/other")); + Assert.AreEqual(HttpStatusCode.Unauthorized, response.StatusCode); + } [TestMethod] public async Task Metadata_ServedAnd404ForUnknown() { - var response = await _client.GetAsync("/.well-known/oauth-protected-resource/api"); + using var response = await _client.GetAsync("/.well-known/oauth-protected-resource/api"); Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); StringAssert.Contains(await response.Content.ReadAsStringAsync(), "\"resource\":\"http://localhost/api\""); - Assert.AreEqual(HttpStatusCode.OK, (await _client.GetAsync("/.well-known/oauth-protected-resource")).StatusCode); - Assert.AreEqual(HttpStatusCode.NotFound, (await _client.GetAsync("/.well-known/oauth-protected-resource/other")).StatusCode); + using var root = await _client.GetAsync("/.well-known/oauth-protected-resource"); + Assert.AreEqual(HttpStatusCode.OK, root.StatusCode); + using var unknown = await _client.GetAsync("/.well-known/oauth-protected-resource/other"); + Assert.AreEqual(HttpStatusCode.NotFound, unknown.StatusCode); } private async Task MetadataResource(string path, string host = null) @@ -175,7 +202,8 @@ public async Task ConfiguredResource_MetadataAtItsHostRoot() await Start(OidcTestHost.Config(new OidcProtectedResource { Name = "BackendApi", PathPrefix = "/api", Resource = "https://api.x.test/v1", RequiredScopes = ["grandnode.api"] })); - StringAssert.Contains((await Get("/api/ping")).Headers.WwwAuthenticate.ToString(), + using var challenge = await Get("/api/ping"); + StringAssert.Contains(challenge.Headers.WwwAuthenticate.ToString(), "resource_metadata=\"https://api.x.test/.well-known/oauth-protected-resource/v1\""); Assert.AreEqual("https://api.x.test/v1", await MetadataResource("/.well-known/oauth-protected-resource/v1")); Assert.AreEqual("404", await MetadataResource("/.well-known/oauth-protected-resource/api")); @@ -185,13 +213,22 @@ await Start(OidcTestHost.Config(new OidcProtectedResource { public async Task StoreUnderSubPath_MetadataPathCarriesStorePath() { await Start(null, new Store { Id = "store1", Url = "http://localhost/shop/" }); - StringAssert.Contains((await Get("/api/ping")).Headers.WwwAuthenticate.ToString(), + using var challenge = await Get("/api/ping"); + StringAssert.Contains(challenge.Headers.WwwAuthenticate.ToString(), "resource_metadata=\"http://localhost/.well-known/oauth-protected-resource/shop/api\""); Assert.AreEqual("http://localhost/shop/api", await MetadataResource("/.well-known/oauth-protected-resource/shop/api")); Assert.AreEqual("http://localhost/shop", await MetadataResource("/.well-known/oauth-protected-resource/shop")); Assert.AreEqual("404", await MetadataResource("/.well-known/oauth-protected-resource/api")); } + [TestMethod] + public async Task StoreUnderSubPath_RootMetadata404() + { + //RFC 9728 section 3.3: the root URL describes http://localhost, not the store at http://localhost/shop + await Start(null, new Store { Id = "store1", Url = "http://localhost/shop/" }); + Assert.AreEqual("404", await MetadataResource("/.well-known/oauth-protected-resource")); + } + [TestMethod] public async Task SameResourcePath_PickedByRequestAuthority() { @@ -216,8 +253,32 @@ public async Task StoreWithoutUrl_Metadata404AndWarning_TokenRejected() Assert.AreEqual("404", await MetadataResource("/.well-known/oauth-protected-resource/api")); Assert.AreEqual("404", await MetadataResource("/.well-known/oauth-protected-resource")); - Assert.IsTrue(logs.Messages.Any(m => m.Contains("BackendApi")), string.Join(" | ", logs.Messages)); - Assert.AreEqual(HttpStatusCode.Unauthorized, (await Get("/api/ping", OidcTestHost.Token())).StatusCode); + Assert.IsTrue(logs.Messages.Any(m => m.StartsWith("Warning:") && m.Contains("BackendApi")), string.Join(" | ", logs.Messages)); + using var response = await Get("/api/ping", OidcTestHost.Token()); + Assert.AreEqual(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [TestMethod] + public async Task NoStoreForHost_Metadata404_DebugNotWarning() + { + //an anonymous request naming an unknown host is not a configuration problem: no warning it could flood + var logs = new ListLoggerProvider(); + await _host.DisposeAsync(); + _host = new OidcTestHost(); + _client = await _host.Start(OidcTestHost.Config(), s => + { + s.AddSingleton(OidcTestHost.StoreService( + new Store { Id = "A", Url = "http://a.test/", Domains = [new DomainHost { HostName = "a.test" }] }, + new Store { Id = "B", Url = "http://b.test/", Domains = [new DomainHost { HostName = "b.test" }] })); + s.Configure(o => o.MinLevel = Microsoft.Extensions.Logging.LogLevel.Trace); + s.AddSingleton(logs); + }); + + Assert.AreEqual("404", await MetadataResource("/.well-known/oauth-protected-resource/api", "evil.test")); + Assert.AreEqual("404", await MetadataResource("/.well-known/oauth-protected-resource", "evil.test")); + var messages = string.Join(" | ", logs.Messages); + Assert.IsFalse(logs.Messages.Any(m => m.StartsWith("Warning:") && m.Contains("evil.test")), messages); + Assert.IsTrue(logs.Messages.Any(m => m.StartsWith("Debug:") && m.Contains("evil.test")), messages); } [TestMethod] @@ -226,7 +287,8 @@ public async Task NegativeClockSkew_TreatedAsZero_Not500() var config = OidcTestHost.Config(); config.ClockSkewSeconds = -30; await Start(config); - Assert.AreEqual(HttpStatusCode.OK, (await Get("/api/ping", OidcTestHost.Token())).StatusCode); + using var response = await Get("/api/ping", OidcTestHost.Token()); + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); } [TestMethod] @@ -247,6 +309,7 @@ public async Task AuthorizationServerUnreachable_503_NotInvalidToken() var body = await response.Content.ReadAsStringAsync(); Assert.AreEqual(HttpStatusCode.ServiceUnavailable, response.StatusCode, body); + Assert.AreEqual(TimeSpan.FromSeconds(30), response.Headers.RetryAfter?.Delta); Assert.IsFalse(response.Headers.WwwAuthenticate.ToString().Contains("invalid_token")); Assert.AreEqual("application/problem+json", response.Content.Headers.ContentType?.MediaType); Assert.IsTrue(logs.Messages.Any(m => m.StartsWith("Error:")), string.Join(" | ", logs.Messages)); @@ -270,9 +333,51 @@ public async Task AuthorizationServerReachable_ForgedToken401_ValidToken200() Assert.AreEqual(HttpStatusCode.OK, valid.StatusCode); } + [TestMethod] + public async Task AuthorizationServerBackAfterOutage_503Then200AndForged401() + { + var handler = new SwitchableHandler(); + await _host.DisposeAsync(); + _host = new OidcTestHost(); + _client = await _host.Start(OidcTestHost.Config(), jwtBearer: o => + { + o.Configuration = null; + o.Backchannel = new HttpClient(handler); + }); + + using (var outage = await Get("/api/ping", OidcTestHost.Token())) + Assert.AreEqual(HttpStatusCode.ServiceUnavailable, outage.StatusCode); + + //the identity provider answers again: the unavailable flag clears, tokens are judged on their own merits + handler.Reachable = true; + using var valid = await Get("/api/ping", OidcTestHost.Token()); + Assert.AreEqual(HttpStatusCode.OK, valid.StatusCode); + using var forged = await Get("/api/ping", OidcTestHost.Token(key: OidcTestHost.ForeignKey)); + Assert.AreEqual(HttpStatusCode.Unauthorized, forged.StatusCode); + StringAssert.Contains(forged.Headers.WwwAuthenticate.ToString(), "invalid_token"); + } + + private sealed class SwitchableHandler : HttpMessageHandler + { + private readonly HttpMessageInvoker _discovery = new(new DiscoveryHandler()); + public volatile bool Reachable; + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => + Reachable + ? _discovery.SendAsync(request, cancellationToken) + : throw new HttpRequestException("No such host is known."); + + protected override void Dispose(bool disposing) + { + if (disposing) _discovery.Dispose(); + base.Dispose(disposing); + } + } + private sealed class DiscoveryHandler : HttpMessageHandler { - protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + //the response is returned to the caller (HttpClient), which owns and disposes it + protected override HttpResponseMessage Send(HttpRequestMessage request, CancellationToken cancellationToken) { var body = request.RequestUri!.AbsolutePath.EndsWith("/jwks") ? System.Text.Json.JsonSerializer.Serialize(new { @@ -280,8 +385,11 @@ protected override Task SendAsync(HttpRequestMessage reques .Select(k => new { kty = k.Kty, kid = k.Kid, n = k.N, e = k.E, use = "sig" }) }) : $"{{\"issuer\":\"{OidcTestHost.Issuer}\",\"jwks_uri\":\"{OidcTestHost.Issuer}/jwks\"}}"; - return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(body, Encoding.UTF8, "application/json") }); + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(body, Encoding.UTF8, "application/json") }; } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => + Task.FromResult(Send(request, cancellationToken)); } private sealed class UnreachableHandler : HttpMessageHandler diff --git a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcProviderNameTests.cs b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcProviderNameTests.cs new file mode 100644 index 0000000000..337be6152e --- /dev/null +++ b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcProviderNameTests.cs @@ -0,0 +1,14 @@ +using Grand.Business.Authentication.Services; +using Grand.Web.Common.Security.Oidc; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Grand.Web.Common.Tests.Security.Oidc; + +[TestClass] +public class OidcProviderNameTests +{ + //the link written by the resolver must be the one the web layer and the login plugin look up + [TestMethod] + public void ResolverAndWebDefaults_UseTheSameLinkName() => + Assert.AreEqual(OidcCustomerResolver.ProviderSystemName, OidcResourceServerDefaults.ProviderSystemName); +} diff --git a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcRequestStoreTests.cs b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcRequestStoreTests.cs index 610c38458d..886ca883fb 100644 --- a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcRequestStoreTests.cs +++ b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcRequestStoreTests.cs @@ -49,6 +49,16 @@ private static HttpContext Context(string host, params Store[] stores) public async Task HostOfStoreA_WithStoreBCookie_StoreA() => Assert.AreEqual("https://a.test", await OidcCanonicalUri.StoreBaseUrl(Context("a.test", StoreA, StoreB))); + [TestMethod] + public async Task HostWithPort_StoreListsHostWithPort_ThatStore() => + //the installer seeds HostName with the port of the install request + Assert.AreEqual("https://c.test:8443", await OidcCanonicalUri.StoreBaseUrl(Context("c.test:8443", StoreA, + Store("C", "https://c.test:8443/", "c.test:8443")))); + + [TestMethod] + public async Task HostWithPort_StoreListsHostWithoutPort_ThatStore() => + Assert.AreEqual("https://a.test", await OidcCanonicalUri.StoreBaseUrl(Context("a.test:8443", StoreA, StoreB))); + [TestMethod] public async Task UnknownHost_SeveralStores_Null() => Assert.IsNull(await OidcCanonicalUri.StoreBaseUrl(Context("evil.test", StoreA, StoreB))); diff --git a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcResourceAccessTests.cs b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcResourceAccessTests.cs new file mode 100644 index 0000000000..7d27abf1e6 --- /dev/null +++ b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcResourceAccessTests.cs @@ -0,0 +1,62 @@ +using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Business.Core.Interfaces.Common.Security; +using Grand.Domain.Customers; +using Grand.Domain.Permissions; +using Grand.Infrastructure.Configuration; +using Grand.Web.Common.Security.Oidc; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Web.Common.Tests.Security.Oidc; + +[TestClass] +public class OidcResourceAccessTests +{ + private Mock _permission; + private Mock _groups; + private OidcResourceAccess _access; + + [TestInitialize] + public void Init() + { + _permission = new Mock(); + _groups = new Mock(); + _access = new OidcResourceAccess(_permission.Object, _groups.Object); + } + + [TestMethod] + public async Task NoFlags_Allowed() => Assert.IsNull(await _access.Refusal(new OidcProtectedResource(), new Customer())); + + [TestMethod] + public async Task AdminPanel_RequiresPermission() + { + var customer = new Customer { Id = "c1" }; + var resource = new OidcProtectedResource { RequireAdminPanelAccess = true }; + _permission.Setup(x => x.Authorize(StandardPermission.ManageAccessAdminPanel, customer)).ReturnsAsync(false); + Assert.IsNotNull(await _access.Refusal(resource, customer)); + _permission.Setup(x => x.Authorize(StandardPermission.ManageAccessAdminPanel, customer)).ReturnsAsync(true); + Assert.IsNull(await _access.Refusal(resource, customer)); + } + + [TestMethod] + public async Task DenyVendors_ByVendorIdOrGroup() + { + var resource = new OidcProtectedResource { DenyVendors = true }; + Assert.IsNotNull(await _access.Refusal(resource, new Customer { VendorId = "v1" })); + var inGroup = new Customer { Id = "c2" }; + _groups.Setup(x => x.IsVendor(inGroup)).ReturnsAsync(true); + Assert.IsNotNull(await _access.Refusal(resource, inGroup)); + Assert.IsNull(await _access.Refusal(resource, new Customer { Id = "c3" })); + } + + [TestMethod] + public async Task DenyStoreManagers_ByStaffStoreOrGroup() + { + var resource = new OidcProtectedResource { DenyStoreManagers = true }; + Assert.IsNotNull(await _access.Refusal(resource, new Customer { StaffStoreId = "s1" })); + var inGroup = new Customer { Id = "c2" }; + _groups.Setup(x => x.IsStoreManager(inGroup)).ReturnsAsync(true); + Assert.IsNotNull(await _access.Refusal(resource, inGroup)); + Assert.IsNull(await _access.Refusal(resource, new Customer { Id = "c3" })); + } +} diff --git a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcStoreContextPipelineTests.cs b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcStoreContextPipelineTests.cs new file mode 100644 index 0000000000..b66f6cedbd --- /dev/null +++ b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcStoreContextPipelineTests.cs @@ -0,0 +1,132 @@ +using Grand.Business.Core.Interfaces.Authentication; +using Grand.Business.Core.Interfaces.Common.Stores; +using Grand.Business.Core.Utilities.Authentication; +using Grand.Domain.Customers; +using Grand.Domain.Stores; +using Grand.Infrastructure; +using Grand.SharedKernel.Extensions; +using Grand.Web.Common.Middleware; +using Grand.Web.Common.Security.Oidc; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using System.Net; +using System.Net.Http.Headers; +using System.Security.Claims; + +namespace Grand.Web.Common.Tests.Security.Oidc; + +/// +/// The store of the work context on a protected resource: the store the token was validated for, whatever store +/// cookie the client also sends; without a validated token the cookie comes first, as before +/// +[TestClass] +public class OidcStoreContextPipelineTests +{ + private static readonly Store HostStore = new() { + Id = "store1", Url = "http://localhost/", Domains = { new DomainHost { HostName = "localhost", Url = "http://localhost/" } } + }; + + private static readonly Store CookieStore = new() { + Id = "store2", Url = "http://shop2.test/", Domains = { new DomainHost { HostName = "shop2.test", Url = "http://shop2.test/" } } + }; + + private OidcTestHost _host; + private HttpClient _client; + private Mock _resolver; + private Mock _workContextSetter; + + [TestInitialize] + public async Task Init() + { + _resolver = new Mock(); + _resolver.Setup(x => x.Resolve(It.IsAny(), It.IsAny())) + .ReturnsAsync(OidcCustomerResult.Success(new Customer { Id = "c1", Active = true })); + _workContextSetter = new Mock(); + _workContextSetter.Setup(x => x.InitializeWorkContext(It.IsAny())).ReturnsAsync(Mock.Of()); + + _host = new OidcTestHost(); + _client = await _host.Start(OidcTestHost.Config(), + services: s => + { + s.AddSingleton(_resolver.Object); + s.AddSingleton(OidcTestHost.StoreService(HostStore, CookieStore)); + s.AddScoped(); + s.AddSingleton(_workContextSetter.Object); + s.AddSingleton(); + }, + endpoints: e => + { + e.MapGet("/api/store", StoreOfTheWorkContext) + .RequireAuthorization(new AuthorizeAttribute { AuthenticationSchemes = OidcResourceServerDefaults.AuthenticationScheme }); + //no [Authorize]: the token is authenticated on demand, not by the authorization middleware + e.MapGet("/api/plain/store", StoreOfTheWorkContext); + e.MapGet("/other/store", StoreOfTheWorkContext); + }, + afterAuthorization: app => + { + //as UseGrandAuthentication and AuthenticationStartup order them + app.UseMiddleware(); + app.UseMiddleware(); + }); + } + + private static string StoreOfTheWorkContext(HttpContext context) => + context.RequestServices.GetRequiredService().StoreContext.CurrentStore.Id; + + [TestCleanup] + public async Task Cleanup() => await _host.DisposeAsync(); + + private async Task Get(string path, string token = null, string storeCookie = null) + { + using var request = new HttpRequestMessage(HttpMethod.Get, path); + if (token != null) request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + if (storeCookie != null) request.Headers.Add("Cookie", $"{CommonHelper.StoreCookieName}={storeCookie}"); + using var response = await _client.SendAsync(request); + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + return await response.Content.ReadAsStringAsync(); + } + + [TestMethod] + [DataRow("/api/store")] + [DataRow("/api/plain/store")] + public async Task ValidToken_AnotherStoresCookie_WorkContextInTheTokensStore(string path) + { + Assert.AreEqual("store1", await Get(path, OidcTestHost.Token(), CookieStore.Id)); + _resolver.Verify(x => x.Resolve(It.IsAny(), "store1"), Times.Once); + _workContextSetter.Verify(x => x.InitializeWorkContext("store1"), Times.Once); + } + + [TestMethod] + public async Task NoToken_StoreCookieFirst() + { + Assert.AreEqual("store2", await Get("/api/plain/store", storeCookie: CookieStore.Id)); + Assert.AreEqual("store1", await Get("/api/plain/store")); + _resolver.Verify(x => x.Resolve(It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task InvalidToken_StoreCookieFirst() + { + var expired = OidcTestHost.Token(expires: DateTime.UtcNow.AddMinutes(-10)); + Assert.AreEqual("store2", await Get("/api/plain/store", expired, CookieStore.Id)); + } + + [TestMethod] + public async Task RefusedCustomer_StoreCookieFirst() + { + _resolver.Setup(x => x.Resolve(It.IsAny(), It.IsAny())) + .ReturnsAsync(OidcCustomerResult.Forbidden("The customer account is inactive")); + Assert.AreEqual("store2", await Get("/api/plain/store", OidcTestHost.Token(), CookieStore.Id)); + } + + [TestMethod] + public async Task OutsideProtectedResources_TokenIgnored_StoreCookieFirst() + { + Assert.AreEqual("store2", await Get("/other/store", OidcTestHost.Token(), CookieStore.Id)); + _resolver.Verify(x => x.Resolve(It.IsAny(), It.IsAny()), Times.Never); + } +} diff --git a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcTestHost.cs b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcTestHost.cs index 37edaf7ef8..9d70dfa06a 100644 --- a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcTestHost.cs +++ b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcTestHost.cs @@ -1,4 +1,10 @@ +using Grand.Business.Core.Interfaces.Authentication; +using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Business.Core.Interfaces.Common.Security; using Grand.Business.Core.Interfaces.Common.Stores; +using Grand.Business.Core.Utilities.Authentication; +using Grand.Domain.Customers; +using Grand.Domain.Permissions; using Grand.Domain.Stores; using Grand.Infrastructure.Configuration; using Grand.Web.Common.Security.Oidc; @@ -81,7 +87,8 @@ public async Task Start(OidcResourceServerConfig config, Action services = null, Action auth = null, Action endpoints = null, - Action jwtBearer = null) + Action jwtBearer = null, + Action afterAuthorization = null) { _host = await new HostBuilder().ConfigureWebHost(web => web .UseTestServer() @@ -95,6 +102,18 @@ public async Task Start(OidcResourceServerConfig config, services?.Invoke(s); s.AddProblemDetails(); s.TryAddSingleton(StoreService(new Store { Id = "store1", Url = "http://localhost/" })); + //defaults for what the Oidc events need; a test registers its own mock before these to override + var defaultResolver = new Mock(); + defaultResolver.Setup(x => x.Resolve(It.IsAny(), It.IsAny())) + .ReturnsAsync(OidcCustomerResult.Success(new Customer { Id = "c1", Active = true })); + s.TryAddSingleton(defaultResolver.Object); + var permissions = new Mock(); + permissions.Setup(x => x.Authorize(It.IsAny(), It.IsAny())).ReturnsAsync(true); + s.TryAddSingleton(permissions.Object); + s.TryAddSingleton(new Mock().Object); + s.TryAddScoped(); + s.AddHttpContextAccessor(); + s.TryAddScoped(); var builder = s.AddAuthentication(); auth?.Invoke(builder); builder.AddOidcResourceServer(config, o => @@ -110,6 +129,8 @@ public async Task Start(OidcResourceServerConfig config, app.UseRouting(); app.UseAuthentication(); app.UseAuthorization(); + //what the app runs between authorization and the endpoints (the store and work context) + afterAuthorization?.Invoke(app); app.UseEndpoints(e => { OidcProtectedResourceMetadata.Map(e); diff --git a/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcWorkContextTests.cs b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcWorkContextTests.cs new file mode 100644 index 0000000000..60ea005145 --- /dev/null +++ b/src/Tests/Grand.Web.Common.Tests/Security/Oidc/OidcWorkContextTests.cs @@ -0,0 +1,197 @@ +using Grand.Business.Core.Interfaces.Authentication; +using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Business.Core.Interfaces.Common.Localization; +using Grand.Business.Core.Interfaces.Common.Security; +using Grand.Business.Core.Interfaces.Common.Stores; +using Grand.Business.Core.Interfaces.Customers; +using Grand.Domain.Common; +using Grand.Domain.Customers; +using Grand.Domain.Stores; +using Grand.Domain.Tax; +using Grand.Infrastructure.Configuration; +using Grand.Web.Common.RequestCustomerResolvers; +using Grand.Web.Common.Security.Oidc; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using System.Security.Claims; +using Wangkanai.Detection.Services; + +namespace Grand.Web.Common.Tests.Security.Oidc; + +/// +/// The OIDC request customer resolver in the work context, next to the built-in resolvers +/// +[TestClass] +public class OidcWorkContextTests +{ + private Mock _customerService; + private Mock _authentication; + private Mock _apiAuthentication; + private Mock _aspNetAuthentication; + private Mock _registry; + private DefaultHttpContext _httpContext; + private Mock _accessor; + + [TestInitialize] + public void Setup() + { + _customerService = new Mock(); + _customerService.Setup(c => c.GetCustomerBySystemName(SystemCustomerNames.Anonymous)) + .ReturnsAsync(new Customer { Id = "anonymous", IsSystemAccount = true }); + _authentication = new Mock(); + _authentication.Setup(a => a.GetAuthenticatedCustomer()).ReturnsAsync(new Customer { Id = "cookie", Active = true }); + _apiAuthentication = new Mock(); + _aspNetAuthentication = new Mock(); + _registry = new Mock(); + _registry.Setup(r => r.Match(It.Is(p => p.StartsWithSegments("/api")))) + .Returns(new OidcProtectedResource { Name = "BackendApi", PathPrefix = "/api" }); + + var requestServices = new ServiceCollection(); + requestServices.AddSingleton(_aspNetAuthentication.Object); + _httpContext = new DefaultHttpContext { RequestServices = requestServices.BuildServiceProvider() }; + _httpContext.Request.Path = "/api/orders"; + _httpContext.Request.Headers.Authorization = "Bearer " + OidcTestHost.Token(); + _accessor = new Mock(); + _accessor.Setup(a => a.HttpContext).Returns(_httpContext); + } + + private void TokenValidatedFor(Customer customer) + { + _aspNetAuthentication.Setup(a => a.AuthenticateAsync(_httpContext, OidcResourceServerDefaults.AuthenticationScheme)) + .ReturnsAsync(() => + { + //what the Oidc JwtBearer events do once the token maps to a customer + _httpContext.Items[OidcResourceServerDefaults.CustomerItemKey] = customer; + return AuthenticateResult.Success(new AuthenticationTicket( + new ClaimsPrincipal(new ClaimsIdentity("Oidc")), OidcResourceServerDefaults.AuthenticationScheme)); + }); + } + + private void TokenRefused() + { + _aspNetAuthentication.Setup(a => a.AuthenticateAsync(_httpContext, OidcResourceServerDefaults.AuthenticationScheme)) + .ReturnsAsync(AuthenticateResult.Fail("The customer account is inactive")); + } + + private TestableSetter CreateSetter() + { + var services = new ServiceCollection(); + //registered in a scrambled order on purpose: the setter sorts by Order + services.AddSingleton( + new ApiUserCustomerResolver(_apiAuthentication.Object, _accessor.Object, _customerService.Object)); + services.AddSingleton(new CookieCustomerResolver(_authentication.Object)); + services.AddSingleton( + new OidcRequestCustomerResolver(_accessor.Object, _registry.Object)); + services.AddSingleton( + new GuestCookieCustomerResolver(_authentication.Object, _customerService.Object, Mock.Of())); + services.AddSingleton( + new SearchEngineCustomerResolver(Mock.Of(), _customerService.Object)); + services.AddSingleton( + new AllowAnonymousCustomerResolver(_accessor.Object, _customerService.Object)); + return new TestableSetter(_accessor.Object, _authentication.Object, Mock.Of(), + _customerService.Object, Mock.Of(), Mock.Of(), Mock.Of(), + Mock.Of(), new TaxSettings(), new AppConfig(), services.BuildServiceProvider()); + } + + [TestMethod] + public void Order_BeforeEveryBuiltInResolver() + { + var resolver = new OidcRequestCustomerResolver(_accessor.Object, _registry.Object); + Assert.IsTrue(resolver.Order < RequestCustomerResolverOrder.AllowAnonymous); + } + + [TestMethod] + public async Task ValidToken_TokenCustomerWinsOverCookieCustomer() + { + TokenValidatedFor(new Customer { Id = "token", Active = true }); + + var customer = await CreateSetter().CurrentCustomerForTest(new Store()); + + Assert.AreEqual("token", customer.Id); + _authentication.Verify(a => a.GetAuthenticatedCustomer(), Times.Never); + } + + [TestMethod] + public async Task ValidToken_TokenCustomerWinsOnAllowAnonymousEndpoint() + { + _httpContext.SetEndpoint(new Endpoint(null, new EndpointMetadataCollection(new AllowAnonymousAttribute()), "test")); + TokenValidatedFor(new Customer { Id = "token", Active = true }); + + var customer = await CreateSetter().CurrentCustomerForTest(new Store()); + + Assert.AreEqual("token", customer.Id); + } + + [TestMethod] + public async Task ValidToken_TokenCustomerNeverImpersonated() + { + var tokenCustomer = new Customer { Id = "token", Active = true }; + tokenCustomer.UserFields.Add(new UserField { Key = SystemCustomerFieldNames.ImpersonatedCustomerId, Value = "victim", StoreId = "" }); + _customerService.Setup(c => c.GetCustomerById("victim")).ReturnsAsync(new Customer { Id = "victim", Active = true }); + TokenValidatedFor(tokenCustomer); + var setter = CreateSetter(); + + var customer = await setter.CurrentCustomerForTest(new Store()); + + Assert.AreEqual("token", customer.Id); + Assert.IsNull(setter.OriginalCustomerForTest); + } + + [TestMethod] + public async Task RefusedToken_BuiltInStepsDecide() + { + TokenRefused(); + + var customer = await CreateSetter().CurrentCustomerForTest(new Store()); + + Assert.AreEqual("cookie", customer.Id); + } + + [TestMethod] + public async Task RefusedToken_NoOtherCredential_AnonymousNotNewGuest() + { + TokenRefused(); + _authentication.Setup(a => a.GetAuthenticatedCustomer()).ReturnsAsync((Customer)null); + + var customer = await CreateSetter().CurrentCustomerForTest(new Store()); + + Assert.AreEqual("anonymous", customer.Id); + _customerService.Verify(c => c.InsertGuestCustomer(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task PathOutsideProtectedResources_TokenNotAuthenticated() + { + _httpContext.Request.Path = "/checkout"; + TokenValidatedFor(new Customer { Id = "token", Active = true }); + + var customer = await CreateSetter().CurrentCustomerForTest(new Store()); + + Assert.AreEqual("cookie", customer.Id); + _aspNetAuthentication.Verify(a => a.AuthenticateAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + private class TestableSetter( + IHttpContextAccessor httpContextAccessor, IGrandAuthenticationService authenticationService, + ICurrencyService currencyService, ICustomerService customerService, + ILanguageService languageService, IStoreService storeService, IAclService aclService, + IVendorService vendorService, TaxSettings taxSettings, AppConfig config, + IServiceProvider serviceProvider) + : WorkContextSetter(httpContextAccessor, authenticationService, currencyService, customerService, + languageService, storeService, aclService, vendorService, taxSettings, config, serviceProvider, + NullLogger.Instance) + { + public Customer OriginalCustomerForTest => + (Customer)typeof(WorkContextSetter) + .GetField("_originalCustomerIfImpersonated", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)! + .GetValue(this); + + public Task CurrentCustomerForTest(Store store) => CurrentCustomer(store); + } +} diff --git a/src/Web/Grand.Web.Common/Infrastructure/ApplicationBuilderExtensions.cs b/src/Web/Grand.Web.Common/Infrastructure/ApplicationBuilderExtensions.cs index 24ed158e1a..1daa895786 100644 --- a/src/Web/Grand.Web.Common/Infrastructure/ApplicationBuilderExtensions.cs +++ b/src/Web/Grand.Web.Common/Infrastructure/ApplicationBuilderExtensions.cs @@ -4,6 +4,7 @@ using Grand.Infrastructure.TypeSearch; using Grand.SharedKernel.Extensions; using Grand.Web.Common.Middleware; +using Grand.Web.Common.Security.Oidc; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Diagnostics; @@ -125,6 +126,8 @@ public static void UseGrandAuthentication(this WebApplication application) { application.UseAuthentication(); application.UseAuthorization(); + //before ContextMiddleware: a validated access token pins the store of the request + application.UseMiddleware(); } /// diff --git a/src/Web/Grand.Web.Common/Middleware/ContextMiddleware.cs b/src/Web/Grand.Web.Common/Middleware/ContextMiddleware.cs index b4a775c38f..3de2918910 100644 --- a/src/Web/Grand.Web.Common/Middleware/ContextMiddleware.cs +++ b/src/Web/Grand.Web.Common/Middleware/ContextMiddleware.cs @@ -15,7 +15,7 @@ public class ContextMiddleware //anonymous endpoints that need neither store nor customer: a cookieless request would otherwise insert a guest private readonly List skipRoutePattern = [ - "/scalar/{documentName}", "/openapi/{documentName}.json", "install", OidcProtectedResourceMetadata.WellKnownPath + "/scalar/{documentName}", "/openapi/{documentName}.json", "install" ]; #endregion @@ -49,7 +49,8 @@ public async Task InvokeAsync(HttpContext context, IContextAccessor contextAcces if (endpoint != null) { var routePattern = (endpoint as RouteEndpoint)?.RoutePattern.RawText; - if (routePattern != null && skipRoutePattern.Any(pattern => routePattern.StartsWith(pattern, StringComparison.OrdinalIgnoreCase))) + if (routePattern != null && (skipRoutePattern.Any(pattern => routePattern.StartsWith(pattern, StringComparison.OrdinalIgnoreCase)) || + IsProtectedResourceMetadata(routePattern))) { await _next(context); return; @@ -57,7 +58,8 @@ public async Task InvokeAsync(HttpContext context, IContextAccessor contextAcces } var storeContext = context.RequestServices.GetRequiredService(); - contextAccessor.StoreContext = await storeContext.InitializeStoreContext(); + //a store pinned by the authentication of the request (a token validated for one store) wins over the store cookie + contextAccessor.StoreContext = await storeContext.InitializeStoreContext(RequestStorePin.Get(context)); var workContextSetter = context.RequestServices.GetRequiredService(); contextAccessor.WorkContext = await workContextSetter.InitializeWorkContext(contextAccessor.StoreContext.CurrentStore.Id); @@ -66,5 +68,10 @@ public async Task InvokeAsync(HttpContext context, IContextAccessor contextAcces await _next(context); } + //the metadata route and its sub-paths only, not a route that merely starts with the same characters + private static bool IsProtectedResourceMetadata(string routePattern) => + routePattern.Equals(OidcProtectedResourceMetadata.WellKnownPath, StringComparison.OrdinalIgnoreCase) || + routePattern.StartsWith(OidcProtectedResourceMetadata.WellKnownPath + "/", StringComparison.OrdinalIgnoreCase); + #endregion } \ No newline at end of file diff --git a/src/Web/Grand.Web.Common/RequestStorePin.cs b/src/Web/Grand.Web.Common/RequestStorePin.cs new file mode 100644 index 0000000000..8878ea279f --- /dev/null +++ b/src/Web/Grand.Web.Common/RequestStorePin.cs @@ -0,0 +1,29 @@ +using Microsoft.AspNetCore.Http; + +namespace Grand.Web.Common; + +/// +/// Pins the store of a request. An authentication handler that validated a credential for one store sets it, so +/// builds the store and work context in that store instead of the one +/// named by the store cookie or the host. Requests without a pin keep the usual order: cookie, host, first store. +/// +public static class RequestStorePin +{ + public const string ItemKey = "Grand.RequestStoreId"; + + /// + /// Pins the request to the store; an empty id leaves the request unpinned + /// + public static void Set(HttpContext context, string storeId) + { + ArgumentNullException.ThrowIfNull(context); + if (!string.IsNullOrEmpty(storeId)) + context.Items[ItemKey] = storeId; + } + + /// + /// The pinned store id, or null when the request is not pinned + /// + public static string Get(HttpContext context) => + context?.Items.TryGetValue(ItemKey, out var storeId) == true ? storeId as string : null; +} diff --git a/src/Web/Grand.Web.Common/Security/Oidc/OidcAuthenticationMiddleware.cs b/src/Web/Grand.Web.Common/Security/Oidc/OidcAuthenticationMiddleware.cs new file mode 100644 index 0000000000..026efb2eb3 --- /dev/null +++ b/src/Web/Grand.Web.Common/Security/Oidc/OidcAuthenticationMiddleware.cs @@ -0,0 +1,36 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; + +namespace Grand.Web.Common.Security.Oidc; + +/// +/// Runs between the authorization middleware and ContextMiddleware: authenticates the access token of a request on +/// a protected resource before the store context is set, so a validated token pins the request to its store +/// (). Endpoints with [Authorize] are already authenticated by then and the result +/// is cached per request; this covers the rest (no authorization metadata), which would otherwise authenticate only +/// while the work context resolves the customer - after the store was taken from the cookie. Requests without an +/// Authorization header are left alone. +/// +public class OidcAuthenticationMiddleware +{ + private readonly RequestDelegate _next; + + public OidcAuthenticationMiddleware(RequestDelegate next) + { + _next = next; + } + + public async Task InvokeAsync(HttpContext context) + { + if (context.Request.Headers.ContainsKey("Authorization")) + { + //the same decision the work context makes later: a protected path and a token for the Oidc scheme + var resolver = context.RequestServices.GetServices() + .OfType().FirstOrDefault(); + if (resolver != null) + await resolver.Resolve(); + } + + await _next(context); + } +} diff --git a/src/Web/Grand.Web.Common/Security/Oidc/OidcCanonicalUri.cs b/src/Web/Grand.Web.Common/Security/Oidc/OidcCanonicalUri.cs index 5b2083ff09..2ce084520e 100644 --- a/src/Web/Grand.Web.Common/Security/Oidc/OidcCanonicalUri.cs +++ b/src/Web/Grand.Web.Common/Security/Oidc/OidcCanonicalUri.cs @@ -14,7 +14,8 @@ public static class OidcCanonicalUri private const string StoreItemKey = "Grand.Oidc.Store"; /// - /// The store serving the request host ( on Request.Host); when no + /// The store serving the request host ( on Request.Host with its + /// port, then without it); when no /// store lists the host, the only store of a single-store installation, otherwise null. Resolved once per /// request. The store cookie and the ambient store context are never used: they can name another store than /// the one whose host the request reached, and a token issued for that store would then pass the audience check. @@ -25,8 +26,11 @@ public static async Task RequestStore(HttpContext context) return cached as Store; var storeService = context.RequestServices.GetRequiredService(); - var host = context.Request.Host.Host; - var store = string.IsNullOrEmpty(host) ? null : await storeService.GetStoreByHost(host); + //host:port first (the installer seeds HostName with the port of the install request), then the host alone + var host = context.Request.Host; + var store = string.IsNullOrEmpty(host.Value) ? null : await storeService.GetStoreByHost(host.Value); + if (store == null && host.Port.HasValue && !string.IsNullOrEmpty(host.Host)) + store = await storeService.GetStoreByHost(host.Host); if (store == null) { var stores = await storeService.GetAllStores(); diff --git a/src/Web/Grand.Web.Common/Security/Oidc/OidcChallengeWriter.cs b/src/Web/Grand.Web.Common/Security/Oidc/OidcChallengeWriter.cs index 85483133cb..615029d6ef 100644 --- a/src/Web/Grand.Web.Common/Security/Oidc/OidcChallengeWriter.cs +++ b/src/Web/Grand.Web.Common/Security/Oidc/OidcChallengeWriter.cs @@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Net.Http.Headers; +using System.Globalization; using System.Text; using System.Text.Json; @@ -12,6 +13,9 @@ namespace Grand.Web.Common.Security.Oidc; /// public static class OidcChallengeWriter { + /// Retry-After of the 503 answered while the authorization server is unreachable + public const int RetryAfterSeconds = 30; + public static async Task Write(HttpContext context, OidcAuthenticationFailure failure, string resourceMetadataUrl, string scope) { @@ -25,6 +29,10 @@ public static async Task Write(HttpContext context, OidcAuthenticationFailure fa if (failure?.Kind is not (OidcFailureKind.Forbidden or OidcFailureKind.ServiceUnavailable)) context.Response.Headers[HeaderNames.WWWAuthenticate] = BuildHeader(failure, resourceMetadataUrl, scope); + //RFC 9110 section 10.2.3: a hint when to try again; the identity provider is retried on later requests + if (status == StatusCodes.Status503ServiceUnavailable) + context.Response.Headers[HeaderNames.RetryAfter] = RetryAfterSeconds.ToString(CultureInfo.InvariantCulture); + context.Response.StatusCode = status; var problem = new ProblemDetails { Status = status, diff --git a/src/Web/Grand.Web.Common/Security/Oidc/OidcJwtBearerEvents.cs b/src/Web/Grand.Web.Common/Security/Oidc/OidcJwtBearerEvents.cs index a5b4fa162e..2c2e16defb 100644 --- a/src/Web/Grand.Web.Common/Security/Oidc/OidcJwtBearerEvents.cs +++ b/src/Web/Grand.Web.Common/Security/Oidc/OidcJwtBearerEvents.cs @@ -1,3 +1,4 @@ +using Grand.Business.Core.Interfaces.Authentication; using Grand.Business.Core.Utilities.Authentication; using Grand.Infrastructure.Configuration; using Microsoft.AspNetCore.Authentication.JwtBearer; @@ -60,16 +61,34 @@ public override async Task TokenValidated(TokenValidatedContext context) } /// - /// Runs after the resource, audience, party and scope checks; calls Reject and returns false to refuse. - /// The base refuses resources with account conditions: checking them needs the customer an override resolves. + /// Runs after the resource, audience, party and scope checks: maps the token to a customer, applies the + /// resource's account conditions and hands the customer to the request. Calls Reject and returns false to refuse. /// - protected virtual Task AfterTokenChecks(TokenValidatedContext context, OidcProtectedResource resource) + protected virtual async Task AfterTokenChecks(TokenValidatedContext context, OidcProtectedResource resource) { - if (!resource.RequireAdminPanelAccess && !resource.DenyVendors && !resource.DenyStoreManagers) - return Task.FromResult(true); + var http = context.HttpContext; + var services = http.RequestServices; + //the store whose URL the audience was checked against (from the host, never the store cookie) + var storeId = (await OidcCanonicalUri.RequestStore(http))?.Id ?? ""; + var result = await services.GetRequiredService().Resolve(context.Principal!, storeId); + if (!result.Succeeded) + { + Reject(context, result.Failure ?? OidcFailureKind.InvalidToken, result.Description); + return false; + } + + var refusal = await services.GetRequiredService().Refusal(resource, result.Customer); + if (refusal != null) + { + Reject(context, OidcFailureKind.Forbidden, refusal); + return false; + } - Reject(context, OidcFailureKind.Forbidden, "Account conditions for this resource are not available"); - return Task.FromResult(false); + http.Items[OidcResourceServerDefaults.CustomerItemKey] = result.Customer; + //the work context is built in the same store: prices, settings and per-store checks follow the token, not a + //store cookie the client also sends (authenticated before ContextMiddleware, see OidcAuthenticationMiddleware) + RequestStorePin.Set(http, storeId); + return true; } public override Task AuthenticationFailed(AuthenticationFailedContext context) diff --git a/src/Web/Grand.Web.Common/Security/Oidc/OidcProtectedResourceMetadata.cs b/src/Web/Grand.Web.Common/Security/Oidc/OidcProtectedResourceMetadata.cs index 94c853fe40..8ccafa911e 100644 --- a/src/Web/Grand.Web.Common/Security/Oidc/OidcProtectedResourceMetadata.cs +++ b/src/Web/Grand.Web.Common/Security/Oidc/OidcProtectedResourceMetadata.cs @@ -23,15 +23,22 @@ public static void Map(IEndpointRouteBuilder endpoints) //typed as Func<..., Task>: a lambda taking only HttpContext binds to RequestDelegate and its result //is discarded endpoints.MapGet(WellKnownPath, (Func>)(async context => - Document(context, null, await OidcCanonicalUri.StoreBaseUrl(context)))).AllowAnonymous(); + { + var baseUrl = await OidcCanonicalUri.StoreBaseUrl(context); + //RFC 9728 section 3.3: the root URL describes the bare host; a store under a path is served by {**path} + return HasPath(baseUrl) ? Results.NotFound() : await Document(context, null, baseUrl); + })).AllowAnonymous(); endpoints.MapGet(WellKnownPath + "/{**path}", async (HttpContext context, string path) => { var baseUrl = await OidcCanonicalUri.StoreBaseUrl(context); var (found, resource) = Find(context, "/" + path, baseUrl); - return found ? Document(context, resource, baseUrl) : Results.NotFound(); + return found ? await Document(context, resource, baseUrl) : Results.NotFound(); }).AllowAnonymous(); } + private static bool HasPath(string url) => + Uri.TryCreate(url, UriKind.Absolute, out var uri) && uri.AbsolutePath.TrimEnd('/').Length > 0; + /// /// The resource whose canonical URI has the requested path: a protected resource, or the store base URL itself /// (null resource). When several have that path, the one on the authority the request reached. @@ -68,18 +75,25 @@ private static (bool Found, OidcProtectedResource Resource) Find(HttpContext con } /// - /// 404 with a warning when the resource has no URI: RFC 9728 requires "resource", and tokens for it are - /// rejected anyway (no audience to match) + /// 404 when the resource has no URI: RFC 9728 requires "resource", and tokens for it are rejected anyway (no + /// audience to match). A warning when a store serves the host but has no URL (a configuration to fix); only a + /// debug message when no store serves the host, which any anonymous request can cause with its Host header. /// - private static IResult Document(HttpContext context, OidcProtectedResource resource, string baseUrl) + private static async Task Document(HttpContext context, OidcProtectedResource resource, string baseUrl) { var config = context.RequestServices.GetRequiredService(); var resourceUri = resource != null ? OidcCanonicalUri.ResourceFor(resource, baseUrl) : baseUrl; if (resourceUri == null) { - context.RequestServices.GetRequiredService().CreateLogger("Grand.Oidc").LogWarning( - "Protected resource metadata for {Resource} on host {Host} is unavailable: the store serving the host has no URL and no Resource URI is configured", - resource?.Name ?? "the store", context.Request.Host.Host); + var logger = context.RequestServices.GetRequiredService().CreateLogger("Grand.Oidc"); + if (await OidcCanonicalUri.RequestStore(context) == null) + logger.LogDebug( + "Protected resource metadata for {Resource} on host {Host} is unavailable: no store serves the host", + resource?.Name ?? "the store", context.Request.Host.Value); + else + logger.LogWarning( + "Protected resource metadata for {Resource} on host {Host} is unavailable: the store serving the host has no URL and no Resource URI is configured", + resource?.Name ?? "the store", context.Request.Host.Value); return Results.NotFound(); } diff --git a/src/Web/Grand.Web.Common/Security/Oidc/OidcRequestCustomerResolver.cs b/src/Web/Grand.Web.Common/Security/Oidc/OidcRequestCustomerResolver.cs new file mode 100644 index 0000000000..ad63a0068d --- /dev/null +++ b/src/Web/Grand.Web.Common/Security/Oidc/OidcRequestCustomerResolver.cs @@ -0,0 +1,41 @@ +using Grand.Domain.Customers; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; + +namespace Grand.Web.Common.Security.Oidc; + +/// +/// The customer of a request authenticated by the Oidc scheme on a protected resource +/// +public class OidcRequestCustomerResolver : IRequestCustomerResolver +{ + /// + /// Before every built-in step ( is the first): a + /// validated access token on a protected resource names the customer of the request, whatever cookie the + /// client also sends. A request that is not the resolver's, or whose token is refused, gets null and goes + /// through the built-in steps. + /// + private const int ResolverOrder = 100; + + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly IOidcProtectedResourceRegistry _registry; + + public OidcRequestCustomerResolver(IHttpContextAccessor httpContextAccessor, IOidcProtectedResourceRegistry registry) + { + _httpContextAccessor = httpContextAccessor; + _registry = registry; + } + + public int Order => ResolverOrder; + + public async Task Resolve() + { + var httpContext = _httpContextAccessor.HttpContext; + if (httpContext == null || _registry.Match(httpContext.Request.Path) == null) + return null; + + //for [Authorize] endpoints the authorization middleware already did this - the result is cached per request + var result = await httpContext.AuthenticateAsync(OidcResourceServerDefaults.AuthenticationScheme); + return result.Succeeded ? httpContext.Items[OidcResourceServerDefaults.CustomerItemKey] as Customer : null; + } +} diff --git a/src/Web/Grand.Web.Common/Security/Oidc/OidcResourceAccess.cs b/src/Web/Grand.Web.Common/Security/Oidc/OidcResourceAccess.cs new file mode 100644 index 0000000000..3279fb4917 --- /dev/null +++ b/src/Web/Grand.Web.Common/Security/Oidc/OidcResourceAccess.cs @@ -0,0 +1,39 @@ +using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Business.Core.Interfaces.Common.Security; +using Grand.Domain.Customers; +using Grand.Domain.Permissions; +using Grand.Infrastructure.Configuration; + +namespace Grand.Web.Common.Security.Oidc; + +/// +/// Account-level conditions a protected resource can set on top of the endpoint's own permission checks +/// +public class OidcResourceAccess +{ + private readonly IPermissionService _permissionService; + private readonly IGroupService _groupService; + + public OidcResourceAccess(IPermissionService permissionService, IGroupService groupService) + { + _permissionService = permissionService; + _groupService = groupService; + } + + /// null when allowed, otherwise a fixed description + public async Task Refusal(OidcProtectedResource resource, Customer customer) + { + if (resource.DenyVendors && (!string.IsNullOrEmpty(customer.VendorId) || await _groupService.IsVendor(customer))) + return "Vendor accounts cannot use this resource"; + + if (resource.DenyStoreManagers && + (!string.IsNullOrEmpty(customer.StaffStoreId) || await _groupService.IsStoreManager(customer))) + return "Store manager accounts cannot use this resource"; + + if (resource.RequireAdminPanelAccess && + !await _permissionService.Authorize(StandardPermission.ManageAccessAdminPanel, customer)) + return "The account has no access to the administration panel"; + + return null; + } +} diff --git a/src/Web/Grand.Web.Common/Security/Oidc/OidcResourceServerDefaults.cs b/src/Web/Grand.Web.Common/Security/Oidc/OidcResourceServerDefaults.cs index bc1d8ad6bf..aa207f0efd 100644 --- a/src/Web/Grand.Web.Common/Security/Oidc/OidcResourceServerDefaults.cs +++ b/src/Web/Grand.Web.Common/Security/Oidc/OidcResourceServerDefaults.cs @@ -4,8 +4,8 @@ public static class OidcResourceServerDefaults { public const string AuthenticationScheme = "Oidc"; - /// ExternalAuthentication.ProviderSystemName of the link between token subject and customer - public const string ProviderSystemName = "Oidc"; + /// ExternalAuthentication.ProviderSystemName of the link between token subject and customer; also the system name of the OpenID Connect login plugin + public const string ProviderSystemName = "ExternalAuth.OpenIdConnect"; public const string CustomerItemKey = "Grand.Oidc.Customer"; public const string FailureItemKey = "Grand.Oidc.Failure"; diff --git a/src/Web/Grand.Web.Common/Startup/OidcResourceServerStartup.cs b/src/Web/Grand.Web.Common/Startup/OidcResourceServerStartup.cs index 22fa1b1f6c..9e0d13b1dc 100644 --- a/src/Web/Grand.Web.Common/Startup/OidcResourceServerStartup.cs +++ b/src/Web/Grand.Web.Common/Startup/OidcResourceServerStartup.cs @@ -20,6 +20,8 @@ public class OidcResourceServerStartup : IStartupApplication public void ConfigureServices(IServiceCollection services, IConfiguration configuration) { services.AddSingleton(); + services.AddScoped(); + services.AddScoped(); } public void Configure(WebApplication application, IWebHostEnvironment webHostEnvironment) diff --git a/src/Web/Grand.Web/App_Data/appsettings.json b/src/Web/Grand.Web/App_Data/appsettings.json index ecb30dae3e..7c0dc1c110 100644 --- a/src/Web/Grand.Web/App_Data/appsettings.json +++ b/src/Web/Grand.Web/App_Data/appsettings.json @@ -252,7 +252,7 @@ //an unverified e-mail address never links to an account and never registers one "RequireEmailVerified": true, //register an unknown user (customer group Registered only - no admin access until an admin grants it) - "AutoRegister": true, + "AutoRegister": false, "Resources": [] }, "Logging": {