Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -283,8 +283,9 @@ private async Task<bool> IsValidCustomer(Customer customer, ClaimsPrincipal prin
return await _oidcSessionService.Validate(oidcSessionId, customer);

//a cookie without a session (issued before the account was connected or before the provider became required)
//would escape revalidation and back-channel logout: an account that must use the provider signs in again there
return await _externalSignInPolicy.RequiredProvider(customer) == null;
//would escape revalidation and back-channel logout: an account that must use the provider signs in again there;
//in ProviderOnly only a break-glass account (password on the Admin panel) keeps a plain cookie
return (await _externalSignInPolicy.PasswordSignIn(customer, SignInSurface.AdminPanel)).Allowed;
}

private async Task EndOidcSession(string sessionId)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using Grand.Business.Core.Interfaces.Authentication;
using Grand.Business.Core.Interfaces.Customers;
using Grand.Infrastructure.Configuration;
using Microsoft.AspNetCore.Authentication.JwtBearer;

#pragma warning disable CS0618 //API users stay supported until removed
Expand All @@ -10,15 +11,17 @@ public class JwtBearerAuthenticationService : IJwtBearerAuthenticationService
{
private readonly ICustomerService _customerService;
private readonly IUserApiService _userApiService;
private readonly OidcLoginConfig _oidcLoginConfig;
private string _email;

private string _errorMessage;

public JwtBearerAuthenticationService(
ICustomerService customerService, IUserApiService userApiService)
ICustomerService customerService, IUserApiService userApiService, OidcLoginConfig oidcLoginConfig)
{
_customerService = customerService;
_userApiService = userApiService;
_oidcLoginConfig = oidcLoginConfig;
}

/// <summary>
Expand All @@ -29,6 +32,13 @@ public virtual async Task<bool> Valid(TokenValidatedContext context)
{
if (context.Principal == null) return await Task.FromResult(false);

//provider-only: API users are refused, tokens issued before the switch included
if (_oidcLoginConfig.ProviderOnly)
{
_errorMessage = "Sign-in with API users is disabled: use access tokens from the identity provider";
return false;
}

_email = context.Principal.Claims.ToList().FirstOrDefault(x => x.Type == "Email")?.Value;
var token = context.Principal.Claims.ToList().FirstOrDefault(x => x.Type == "Token")?.Value;
if (string.IsNullOrEmpty(token))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using Grand.Domain.Common;
using Grand.Domain.Customers;
using Grand.Domain.Permissions;
using Grand.Infrastructure.Configuration;
using Microsoft.AspNetCore.Authentication.JwtBearer;

namespace Grand.Business.Authentication.Services;
Expand All @@ -15,15 +16,20 @@ public class JwtBearerCustomerAuthenticationService : IJwtBearerCustomerAuthenti
private readonly IGroupService _groupService;
private readonly IPermissionService _permissionService;
private readonly IRefreshTokenService _refreshTokenService;
private readonly IExternalSignInPolicy _externalSignInPolicy;
private readonly OidcLoginConfig _oidcLoginConfig;
private string _errorMessage;

public JwtBearerCustomerAuthenticationService(ICustomerService customerService,
IPermissionService permissionService, IGroupService groupService, IRefreshTokenService refreshTokenService)
IPermissionService permissionService, IGroupService groupService, IRefreshTokenService refreshTokenService,
IExternalSignInPolicy externalSignInPolicy, OidcLoginConfig oidcLoginConfig)
{
_customerService = customerService;
_permissionService = permissionService;
_groupService = groupService;
_refreshTokenService = refreshTokenService;
_externalSignInPolicy = externalSignInPolicy;
_oidcLoginConfig = oidcLoginConfig;
}

public async Task<bool> Valid(TokenValidatedContext context)
Expand Down Expand Up @@ -63,6 +69,15 @@ public async Task<bool> Valid(TokenValidatedContext context)
return false;
}

//provider-only: a token obtained with the password before the switch stops working at once, not at its
//expiry. Guests never had a password: their tokens stay
if (_oidcLoginConfig.ProviderOnly && !string.IsNullOrEmpty(customer.Email) &&
!(await _externalSignInPolicy.PasswordSignIn(customer, SignInSurface.FrontendApi)).Allowed)
{
_errorMessage = "Sign in through the identity provider: tokens obtained with the password are not accepted";
return false;
}

var refreshToken = await _refreshTokenService.GetCustomerRefreshToken(customer);
if (refreshToken is null || string.IsNullOrEmpty(refreshId) || !refreshId.Equals(refreshToken.RefreshId))
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@ namespace Grand.Business.Authentication.Services;
/// Maps a validated access token or a browser sign-in to a customer: stored subject link; customer with the verified e-mail (link
/// stored and marked as made by e-mail; never a back-office account or an account with two-factor authentication, which connect
/// only with their password); registration with the Registered group only. A link made by e-mail is re-checked on every use
/// against the rules of a first link. Only active, registered, non-system accounts are used,
/// against the rules of a first link.
/// In ProviderOnly (OidcLogin:SignInMode) a browser sign-in links any account by a provider-verified e-mail and refuses an
/// unverified one. Only active, registered, non-system accounts are used,
/// and one customer holds at most one identity (enforced by the link's identifier, see <see cref="OidcLinkId" />).
/// 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
Expand All @@ -46,6 +48,7 @@ public class OidcCustomerResolver : IOidcCustomerResolver
private readonly IPermissionService _permissionService;
private readonly IMediator _mediator;
private readonly OidcResourceServerConfig _config;
private readonly OidcLoginConfig _loginConfig;
private readonly CustomerConfig _customerConfig;
private readonly ILogger<OidcCustomerResolver> _logger;

Expand All @@ -58,6 +61,7 @@ public OidcCustomerResolver(
IPermissionService permissionService,
IMediator mediator,
OidcResourceServerConfig config,
OidcLoginConfig loginConfig,
CustomerConfig customerConfig,
ILogger<OidcCustomerResolver> logger)
{
Expand All @@ -69,6 +73,7 @@ public OidcCustomerResolver(
_permissionService = permissionService;
_mediator = mediator;
_config = config;
_loginConfig = loginConfig;
_customerConfig = customerConfig;
_logger = logger;
}
Expand All @@ -88,7 +93,8 @@ public virtual async Task<OidcCustomerResult> Resolve(ClaimsPrincipal principal,
var linkedResult = await Usable(linkedCustomer, sub, storeId);
//a link made by e-mail proves no more than the e-mail: it stops serving an account that has since
//become one that connects only with its password
if (linkedResult.Succeeded && link.LinkedByEmail && await RequiresPassword(linkedCustomer, storeId))
if (linkedResult.Succeeded && link.LinkedByEmail && !_loginConfig.ProviderOnly &&
await RequiresPassword(linkedCustomer, storeId))
return AccountConnectionRequired(linkedCustomer, sub);
return linkedResult;
}
Expand Down Expand Up @@ -146,8 +152,9 @@ public virtual async Task<OidcLoginResult> ResolveForLogin(string sub, string em
if (reason != Unusable.None)
return OidcLoginResult.Refused(ToRefusal(reason));

//a link made by e-mail (by an access token or an earlier sign-in) is held to the rules of a first link
if (link.LinkedByEmail && (!emailVerified || await RequiresPassword(linked, storeId)))
//a link made by e-mail (by an access token or an earlier sign-in) is held to the rules of a first link;
//provider-only installations trust the provider for every account
if (!_loginConfig.ProviderOnly && link.LinkedByEmail && (!emailVerified || await RequiresPassword(linked, storeId)))
return OidcLoginResult.LinkRequired(linked);

return OidcLoginResult.SignIn(linked);
Expand All @@ -156,10 +163,21 @@ public virtual async Task<OidcLoginResult> ResolveForLogin(string sub, string em
if (string.IsNullOrWhiteSpace(email))
return OidcLoginResult.Refused(OidcLoginRefusal.InvalidIdentity);

//provider-only: an account (new or existing) is given only to an e-mail the provider verified
if (_loginConfig.ProviderOnly && !emailVerified)
{
_logger.LogWarning("Sign-in subject {Subject}: the provider did not verify the e-mail", sub);
return OidcLoginResult.Refused(OidcLoginRefusal.EmailNotVerified);
}

var customer = await _customerService.GetCustomerByEmail(email,
_customerConfig.RegisterCustomersPerStore ? storeId : "");
if (customer == null)
{
//provider-only with automatic accounts: the provider decides who gets in, no form and no registration type
if (_loginConfig is { ProviderOnly: true, AutoProvision: true })
return await ProvisionForLogin(sub, email, emailVerified, storeId);

var customerSettings = await _settingService.LoadSetting<CustomerSettings>(storeId);
if (customerSettings.UserRegistrationType == UserRegistrationType.Disabled)
{
Expand All @@ -182,7 +200,7 @@ public virtual async Task<OidcLoginResult> ResolveForLogin(string sub, string em

//the first link of a back-office account, of an account protected by two-factor authentication, and of any account
//whose e-mail nobody verified, needs the account's password (and its second factor)
if (!emailVerified || await RequiresPassword(customer, storeId))
if (!_loginConfig.ProviderOnly && (!emailVerified || await RequiresPassword(customer, storeId)))
return OidcLoginResult.LinkRequired(customer);

return await Link(customer, sub, email, storeId, true) switch {
Expand Down Expand Up @@ -245,13 +263,53 @@ private async Task<OidcCustomerResult> Register(string sub, string email, string
if (existing != null)
return await Usable(await _customerService.GetCustomerById(existing.CustomerId), sub, storeId);

var (outcome, customer, winner) = await CreateLinkedAccount(sub, email, storeId);
return outcome switch {
Provisioning.UserNameTaken => OidcCustomerResult.Forbidden("The user name is already taken"),
Provisioning.LinkedToOtherIdentity => LinkedToOtherIdentity(customer, sub),
Provisioning.LostRace => await Usable(winner, sub, storeId),
_ => OidcCustomerResult.Success(customer)
};
}

/// <summary>
/// ProviderOnly + AutoProvision: the account of an unknown, provider-verified e-mail, created and linked at its first
/// browser sign-in (the profile sync of the sign-in fills in the names)
/// </summary>
private async Task<OidcLoginResult> ProvisionForLogin(string sub, string email, bool emailVerified, string storeId)
{
var (outcome, customer, _) = await CreateLinkedAccount(sub, email, storeId);
return outcome switch {
Provisioning.UserNameTaken => OidcLoginResult.Refused(OidcLoginRefusal.AccountUnusable),
Provisioning.LinkedToOtherIdentity => OidcLoginResult.Refused(OidcLoginRefusal.LinkedToOtherIdentity),
//another request of the same user created the account first: its link answers now
Provisioning.LostRace => await ResolveForLogin(sub, email, emailVerified, storeId),
_ => OidcLoginResult.SignIn(customer)
};
}

private enum Provisioning
{
Created,
UserNameTaken,
LinkedToOtherIdentity,
LostRace
}

/// <summary>
/// Creates an active account with the Registered group for the subject and links it (not by e-mail). The store's
/// e-mail validation / admin approval does not apply: access comes from the provider and from permissions an admin grants
/// </summary>
private async Task<(Provisioning Outcome, Customer Customer, Customer Winner)> CreateLinkedAccount(string sub,
string email, string 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");
_logger.LogWarning("Subject {Subject}: the user name is already taken in store {StoreId}", sub, storeId);
return (Provisioning.UserNameTaken, null, null);
}

var customer = await _customerService.InsertGuestCustomer(new Customer {
Expand All @@ -261,8 +319,6 @@ private async Task<OidcCustomerResult> Register(string sub, string email, string
LastActivityDateUtc = DateTime.UtcNow
});

//access comes from permissions an admin grants (and the provider's e-mail check is configurable), 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));

Expand All @@ -271,14 +327,16 @@ await _customerManagerService.RegisterCustomer(new RegistrationRequest(customer,
{
//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) : await Usable(linked, sub, storeId);
return other != null
? (Provisioning.LinkedToOtherIdentity, customer, null)
: (Provisioning.LostRace, null, linked);
}

//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})",
_logger.LogInformation("Subject {Subject} registered as customer {CustomerId} (store {StoreId})",
sub, customer.Id, storeId);
return OidcCustomerResult.Success(customer);
return (Provisioning.Created, customer, null);
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
using Grand.Infrastructure.Configuration;
using Grand.Infrastructure.Plugins;

namespace Grand.Business.Authentication.Services;

/// <summary>
/// Startup check of OidcLogin:SignInMode = ProviderOnly; whether the method is active is known per store only, so the
/// sign-in policy checks that on every refusal
/// </summary>
public static class OidcSignInModeCheck
{
public static string Problem(OidcLoginConfig config, OidcResourceServerConfig resourceServer,
IEnumerable<PluginInfo> plugins)
{
if (config is not { ProviderOnly: true })
return null;
if (!config.IsConfigured(resourceServer))
return "OidcResourceServer:Authority or OidcLogin:ClientId is empty";
if (plugins?.Any(x => x.Installed &&
string.Equals(x.SystemName, OidcCustomerResolver.ProviderSystemName,
StringComparison.OrdinalIgnoreCase)) != true)
return "the OpenID Connect login plugin is not installed";
return null;
}
}
Loading
Loading