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 @@ -28,6 +28,7 @@ public class CookieAuthenticationService : IGrandAuthenticationService
/// <param name="httpContextAccessor">HTTP context accessor</param>
/// <param name="securityConfig">SecurityConfig</param>
/// <param name="oidcSessionService">Server-side sessions of provider sign-ins</param>
/// <param name="externalSignInPolicy">Accounts that must sign in through an external provider</param>
/// <param name="logger">Logger</param>
public CookieAuthenticationService(
CustomerSettings customerSettings,
Expand All @@ -37,6 +38,7 @@ public CookieAuthenticationService(
ICookieOptionsFactory cookieOptionsFactory,
SecurityConfig securityConfig,
IOidcSessionService oidcSessionService,
IExternalSignInPolicy externalSignInPolicy,
ILogger<CookieAuthenticationService> logger)
{
_customerSettings = customerSettings;
Expand All @@ -46,6 +48,7 @@ public CookieAuthenticationService(
_cookieOptionsFactory = cookieOptionsFactory;
_securityConfig = securityConfig;
_oidcSessionService = oidcSessionService;
_externalSignInPolicy = externalSignInPolicy;
_logger = logger;
}

Expand All @@ -71,6 +74,7 @@ public CookieAuthenticationService(

private readonly SecurityConfig _securityConfig;
private readonly IOidcSessionService _oidcSessionService;
private readonly IExternalSignInPolicy _externalSignInPolicy;
private readonly ILogger<CookieAuthenticationService> _logger;

#endregion
Expand Down Expand Up @@ -275,10 +279,12 @@ private async Task<bool> IsValidCustomer(Customer customer, ClaimsPrincipal prin

//a provider sign-in: the server-side session decides (ended, idle, refused by the provider)
var oidcSessionId = OidcSessionId(principal);
if (!string.IsNullOrEmpty(oidcSessionId) && !await _oidcSessionService.Validate(oidcSessionId, customer))
return false;
if (!string.IsNullOrEmpty(oidcSessionId))
return await _oidcSessionService.Validate(oidcSessionId, customer);

return true;
//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;
}

private async Task EndOidcSession(string sessionId)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
using Grand.Business.Core.Extensions;
using Grand.Business.Core.Interfaces.Authentication;
using Grand.Data;
using Grand.Domain.Customers;
using Grand.Infrastructure.Configuration;

namespace Grand.Business.Authentication.Services;

/// <summary>
/// OidcLogin:SsoOnly - an account connected to the identity provider with its password (a link not made by e-mail)
/// signs in only through the provider, while the OpenID Connect login is installed, active and configured; a user
/// disabled at the provider then cannot use a GrandNode password either. Accounts without such a link keep the
/// password: the break-glass account, and accounts still to be connected
/// </summary>
public class OidcSsoSignInPolicy(
IRepository<ExternalAuthentication> externalAuthenticationRepository,
IEnumerable<IExternalAuthenticationProvider> externalAuthenticationProviders,
ExternalAuthenticationSettings externalAuthenticationSettings,
OidcLoginConfig config,
OidcResourceServerConfig resourceServerConfig) : IExternalSignInPolicy
{
public async Task<IExternalAuthenticationProvider> RequiredProvider(Customer customer)
{
if (customer == null || !config.SsoOnly || !config.IsConfigured(resourceServerConfig))
return null;

var provider = externalAuthenticationProviders.FirstOrDefault(x =>
x.SystemName.Equals(OidcCustomerResolver.ProviderSystemName, StringComparison.OrdinalIgnoreCase));
if (provider == null || !provider.IsMethodActive(externalAuthenticationSettings))
return null;

var confirmed = await externalAuthenticationRepository.GetOneAsync(x =>
x.ProviderSystemName == OidcCustomerResolver.ProviderSystemName && x.CustomerId == customer.Id &&
!x.LinkedByEmail);
return confirmed != null ? provider : null;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ public void ConfigureServices(IServiceCollection services, IConfiguration config
services.AddScoped<IRefreshTokenService, RefreshTokenService>();
services.AddScoped<IOidcTokenRevocationService, OidcTokenRevocationService>();
services.AddScoped<IOidcSessionService, OidcSessionService>();
services.AddScoped<IExternalSignInPolicy, OidcSsoSignInPolicy>();
}

public void Configure(WebApplication application, IWebHostEnvironment webHostEnvironment)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
namespace Grand.Business.Core.Interfaces.Authentication;

/// <summary>
/// An external authentication provider that signs in through its own page: offered on the panels' login pages, and
/// used to send the browser through the provider once more after a registration that connected the identity
/// </summary>
public interface IExternalSignInPathProvider : IExternalAuthenticationProvider
{
/// <summary>
/// Path (from the application root, starting with "/") that signs in and returns to <paramref name="returnUrl" />;
/// null when the provider cannot sign in now (for example, not configured)
/// </summary>
Task<string> GetSignInPath(string returnUrl);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
using Grand.Domain.Customers;

namespace Grand.Business.Core.Interfaces.Authentication;

/// <summary>
/// Accounts that must sign in with an external authentication provider instead of their password
/// </summary>
public interface IExternalSignInPolicy
{
/// <summary>The provider the account must sign in with; null when its password may be used</summary>
Task<IExternalAuthenticationProvider> RequiredProvider(Customer customer);
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Grand.Business.Core.Extensions;
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.Customers;
Expand Down Expand Up @@ -27,14 +28,17 @@ public class CustomerManagerService : ICustomerManagerService
/// <param name="mediator">Mediator</param>
/// <param name="customerHistoryPasswordService">History password</param>
/// <param name="customerSettings">Customer settings</param>
/// <param name="externalSignInPolicy">Accounts that must sign in with an external provider</param>
public CustomerManagerService(
ICustomerService customerService,
IGroupService groupService,
IEncryptionService encryptionService,
IMediator mediator,
ICustomerHistoryPasswordService customerHistoryPasswordService,
CustomerSettings customerSettings)
CustomerSettings customerSettings,
IExternalSignInPolicy externalSignInPolicy)
{
_externalSignInPolicy = externalSignInPolicy;
_customerService = customerService;
_groupService = groupService;
_encryptionService = encryptionService;
Expand All @@ -53,6 +57,7 @@ public CustomerManagerService(
private readonly IMediator _mediator;
private readonly ICustomerHistoryPasswordService _customerHistoryPasswordService;
private readonly CustomerSettings _customerSettings;
private readonly IExternalSignInPolicy _externalSignInPolicy;

#endregion

Expand Down Expand Up @@ -84,6 +89,10 @@ public virtual async Task<CustomerLoginResults> LoginCustomer(string usernameOrE
if (!isValid)
return CustomerLoginResults.WrongPassword;

//checked after the password, so the answer tells nothing to someone who does not know it
if (await _externalSignInPolicy.RequiredProvider(customer) != null)
return CustomerLoginResults.ExternalSignInRequired;

//transparently migrate weak/legacy hashes (SHA-x, Clear, Encrypted) to the modern PBKDF2 format
if (_encryptionService.PasswordHashNeedsUpgrade(customer.PasswordFormatId, customer.Password))
{
Expand Down
7 changes: 6 additions & 1 deletion src/Core/Grand.Domain/Customers/CustomerLoginResults.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,5 +43,10 @@ public enum CustomerLoginResults
/// <summary>
/// Requires 2fa
/// </summary>
RequiresTwoFactor = 8
RequiresTwoFactor = 8,

/// <summary>
/// The account signs in only with an external authentication provider
/// </summary>
ExternalSignInRequired = 9
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,11 @@ public class OidcLoginConfig
public int RevalidationMinutes { get; set; } = DefaultRevalidationMinutes;

/// <summary>
/// Back-office accounts connected to the identity provider with their password sign in only through the provider;
/// an account without such a connection keeps its password (break-glass)
/// Accounts connected to the identity provider with their password sign in only through the provider (storefront,
/// panels, Frontend API) and get no password recovery e-mail; an account without such a connection keeps its
/// password (break-glass)
/// </summary>
public bool StaffSsoOnly { get; set; }
public bool SsoOnly { get; set; }

//zero or less is a configuration mistake, never "check on every request" or "end every session at once"
public TimeSpan StaffIdleTimeout =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,10 @@ public class OidcProtectedResource

public bool DenyVendors { get; set; }
public bool DenyStoreManagers { get; set; }

/// <summary>
/// A token is accepted at most this many minutes after it was issued (iat), whatever its expiry; empty = no limit.
/// Bounds how long a token outlives the user's removal at the identity provider
/// </summary>
public int? MaxTokenAgeMinutes { get; set; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,15 @@ public class TokenWebController : ControllerBase
private readonly ICustomerService _customerService;
private readonly IMediator _mediator;
private readonly IRefreshTokenService _refreshTokenService;
private readonly IExternalSignInPolicy _externalSignInPolicy;
private readonly IContextAccessor _contextAccessor;

public TokenWebController(
ICustomerService customerService,
IMediator mediator,
IContextAccessor contextAccessor,
IRefreshTokenService refreshTokenService,
IExternalSignInPolicy externalSignInPolicy,
IAntiforgery antiforgery,
FrontendAPIConfig apiConfig,
CustomerConfig customerConfig)
Expand All @@ -42,6 +44,7 @@ public TokenWebController(
_mediator = mediator;
_contextAccessor = contextAccessor;
_refreshTokenService = refreshTokenService;
_externalSignInPolicy = externalSignInPolicy;
_antiforgery = antiforgery;
_apiConfig = apiConfig;
_customerConfig = customerConfig;
Expand Down Expand Up @@ -143,6 +146,11 @@ public async Task<IActionResult> Refresh([FromBody] TokenDto tokenDto)
if (customer == null)
return BadRequest("Invalid access token");

//tokens obtained with the password before the account had to sign in through the provider are not renewed:
//the provider could not stop them for a user it disabled
if (await _externalSignInPolicy.RequiredProvider(customer) != null)
return BadRequest("Invalid refresh token");

//rebuild the claims from the resolved customer (registered carry id/email/token, guests carry the guid)
if (!string.IsNullOrEmpty(customer.Email))
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ public static class OidcLoginDefaults
public const string ConfigSection = "OidcLogin";
public const string HttpClientName = "Grand.OidcLogin";
public const string BackChannelLogoutPath = "oidc/backchannel-logout";
public const string LoginPath = "/oidc/login";
public const string FailedPath = "/oidc/failed";
public const string ConfigurationUrl = "../OidcLoginSettings/Configure";
public static readonly TimeSpan PendingLifetime = TimeSpan.FromMinutes(15);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
using Grand.Business.Core.Interfaces.Authentication;
using Microsoft.AspNetCore.Authentication;

namespace Authentication.OpenIdConnect;

public class OidcLoginProvider(OidcLoginSettings settings) : IExternalAuthenticationProvider
public class OidcLoginProvider(OidcLoginSettings settings, IAuthenticationSchemeProvider authenticationSchemeProvider)
: IExternalSignInPathProvider
{
public string ConfigurationUrl => OidcLoginDefaults.ConfigurationUrl;

Expand All @@ -20,4 +22,9 @@ public class OidcLoginProvider(OidcLoginSettings settings) : IExternalAuthentica
/// Gets a view component for displaying plugin in public store
/// </summary>
public Task<string> GetPublicViewComponentName() => Task.FromResult("OidcLogin");

public async Task<string> GetSignInPath(string returnUrl) =>
await authenticationSchemeProvider.GetSchemeAsync(OidcLoginDefaults.AuthenticationScheme) == null
? null
: OidcLoginDefaults.LoginPath + "?returnUrl=" + Uri.EscapeDataString(returnUrl ?? "/");
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ public class CookieAuthenticationServiceTests
private Mock<IServiceProvider> serviceProviderMock;
private CookieOptionsFactory _cookieOptionsFactory;
private Mock<IOidcSessionService> _oidcSessionService;
private Mock<IExternalSignInPolicy> _signInPolicy;

[TestInitialize]
public void Init()
Expand All @@ -44,9 +45,10 @@ public void Init()
};
_cookieOptionsFactory = new CookieOptionsFactory(_config);
_oidcSessionService = new Mock<IOidcSessionService>();
_signInPolicy = new Mock<IExternalSignInPolicy>();
_cookieAuthService = new CookieAuthenticationService(_customerSettings, _customerServiceMock.Object,
_groupServiceMock.Object, _httpAccessorMock.Object, _cookieOptionsFactory, _config,
_oidcSessionService.Object, NullLogger<CookieAuthenticationService>.Instance);
_oidcSessionService.Object, _signInPolicy.Object, NullLogger<CookieAuthenticationService>.Instance);
//For mock HttpContext extension methods like SignOutAsync ,SignInAsync etc..
_authServiceMock = new Mock<IAuthenticationService>();
serviceProviderMock = new Mock<IServiceProvider>();
Expand Down Expand Up @@ -210,6 +212,39 @@ public async Task GetAuthenticatedCustomer_NoSessionClaim_SessionServiceNotAsked
_oidcSessionService.Verify(x => x.Validate(It.IsAny<string>(), It.IsAny<Customer>()), Times.Never);
}

//an account that must sign in through the provider: a cookie without a session (issued before the account was
//connected or before SsoOnly) would escape revalidation and back-channel logout
[TestMethod]
public async Task GetAuthenticatedCustomer_NoSessionClaim_ProviderRequired_Null()
{
var customer = RegisteredCustomer();
_authServiceMock.Setup(x => x.AuthenticateAsync(It.IsAny<HttpContext>(), It.IsAny<string>())).ReturnsAsync(Ticket());
_signInPolicy.Setup(x => x.RequiredProvider(customer)).ReturnsAsync(Mock.Of<IExternalAuthenticationProvider>());
Assert.IsNull(await _cookieAuthService.GetAuthenticatedCustomer());
}

[TestMethod]
public async Task GetAuthenticatedCustomer_NoSessionClaim_PasswordAllowed_ReturnCustomer()
{
var customer = RegisteredCustomer();
_authServiceMock.Setup(x => x.AuthenticateAsync(It.IsAny<HttpContext>(), It.IsAny<string>())).ReturnsAsync(Ticket());
_signInPolicy.Setup(x => x.RequiredProvider(customer)).ReturnsAsync((IExternalAuthenticationProvider)null);
Assert.AreSame(customer, await _cookieAuthService.GetAuthenticatedCustomer());
_signInPolicy.Verify(x => x.RequiredProvider(customer), Times.Once);
}

//a session cookie is decided by its session; the policy is not asked
[TestMethod]
public async Task GetAuthenticatedCustomer_SessionClaim_PolicyNotAsked()
{
var customer = RegisteredCustomer();
_authServiceMock.Setup(x => x.AuthenticateAsync(It.IsAny<HttpContext>(), It.IsAny<string>()))
.ReturnsAsync(Ticket(new Claim(OidcSessionDefaults.SessionIdClaimType, "session-1", ClaimValueTypes.String, "grandnode")));
_oidcSessionService.Setup(x => x.Validate("session-1", customer)).ReturnsAsync(true);
Assert.AreSame(customer, await _cookieAuthService.GetAuthenticatedCustomer());
_signInPolicy.Verify(x => x.RequiredProvider(It.IsAny<Customer>()), Times.Never);
}

[TestMethod]
public async Task GetAuthenticatedCustomer_EndedSession_Null()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ public void Defaults()
Assert.AreEqual(TimeSpan.FromMinutes(30), config.StaffIdleTimeout);
Assert.AreEqual(TimeSpan.FromHours(10), config.StaffMaxSession);
Assert.AreEqual(TimeSpan.FromMinutes(10), config.RevalidationInterval);
Assert.IsFalse(config.StaffSsoOnly);
Assert.IsFalse(config.SsoOnly);
}

//a zero in appsettings must not mean "refresh at the provider on every request" or "end every session at once"
Expand Down
Loading
Loading