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 @@ -5,6 +5,7 @@
using Grand.Business.Core.Interfaces.Common.Directory;
using Grand.Business.Core.Utilities.Authentication;
using Grand.Infrastructure;
using Grand.Infrastructure.Configuration;
using Grand.Mediator;
using Grand.Web.Common.Controllers;
using Grand.Web.Common.Security.ExternalRegistration;
Expand All @@ -23,6 +24,8 @@ public class OidcLoginController : BasePluginController
public OidcLoginController(
IExternalRegistrationContext externalRegistrationContext,
IOidcCustomerResolver oidcCustomerResolver,
OidcProfileSynchronizer profileSynchronizer,
OidcLoginConfig loginConfig,
IGrandAuthenticationService authenticationService,
IOidcSessionService oidcSessionService,
IMediator mediator,
Expand All @@ -34,6 +37,8 @@ public OidcLoginController(
{
_externalRegistrationContext = externalRegistrationContext;
_oidcCustomerResolver = oidcCustomerResolver;
_profileSynchronizer = profileSynchronizer;
_loginConfig = loginConfig;
_authenticationService = authenticationService;
_oidcSessionService = oidcSessionService;
_mediator = mediator;
Expand All @@ -52,6 +57,8 @@ public OidcLoginController(

private readonly IExternalRegistrationContext _externalRegistrationContext;
private readonly IOidcCustomerResolver _oidcCustomerResolver;
private readonly OidcProfileSynchronizer _profileSynchronizer;
private readonly OidcLoginConfig _loginConfig;
private readonly IGrandAuthenticationService _authenticationService;
private readonly IOidcSessionService _oidcSessionService;
private readonly IMediator _mediator;
Expand Down Expand Up @@ -118,6 +125,16 @@ public async Task<IActionResult> Callback(string returnUrl)
{
case OidcLoginOutcome.SignIn:
{
//the profile never blocks the sign-in; the pending identity is still needed here
try
{
await _profileSynchronizer.Sync(result.Customer, pending, _contextAccessor.StoreContext.CurrentStore.Id);
}
catch (Exception e)
{
_logger.LogWarning(e, "OpenID Connect profile sync failed for customer {CustomerId}", result.Customer.Id);
}
Comment on lines +133 to +136

//read before the pending identity is cleared: the same cookie carries them
var (refreshToken, sid) = await OidcLoginSessionData.Read(HttpContext);
await _externalRegistrationContext.Clear();
Expand All @@ -143,6 +160,9 @@ public async Task<IActionResult> Callback(string returnUrl)

public async Task<IActionResult> Link(string returnUrl)
{
if (await RefuseLinkInProviderOnly() is { } refused)
return refused;

var pending = await _externalRegistrationContext.GetPending();
if (pending == null)
return RedirectToRoute("OidcLoginFailed");
Expand Down Expand Up @@ -173,6 +193,9 @@ public async Task<IActionResult> Link(string returnUrl)

public async Task<IActionResult> LinkComplete(string returnUrl)
{
if (await RefuseLinkInProviderOnly() is { } refused)
return refused;

returnUrl = LocalOrNull(returnUrl);
var customer = _contextAccessor.WorkContext.CurrentCustomer;
if (!await _groupService.IsRegistered(customer))
Expand Down Expand Up @@ -224,6 +247,15 @@ public IActionResult Failed(int? reason, bool mismatch = false, bool taken = fal

#region Utilities

//ProviderOnly has no password path: a stale link URL must not open one
private async Task<IActionResult> RefuseLinkInProviderOnly()
{
if (!_loginConfig.ProviderOnly)
return null;
await _externalRegistrationContext.Clear();
return RedirectToRoute("OidcLoginFailed");
}

private bool IsAvailable() =>
_externalAuthenticationService.AuthenticationProviderIsAvailable(OidcLoginDefaults.ProviderSystemName);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
using Grand.Business.Core.Interfaces.Customers;
using Grand.Domain.Common;
using Grand.Domain.Customers;
using Grand.Infrastructure.Configuration;
using Grand.Web.Common.Security.ExternalRegistration;
using Microsoft.Extensions.Logging;

namespace Authentication.OpenIdConnect.Infrastructure;

/// <summary>
/// ProviderOnly: the identity provider owns the name and the e-mail, so a provider sign-in copies them onto the
/// account. Mixed mode never touches the account.
/// </summary>
public class OidcProfileSynchronizer(
ICustomerService customerService,
CustomerSettings customerSettings,
CustomerConfig customerConfig,
OidcLoginConfig loginConfig,
ILogger<OidcProfileSynchronizer> logger)
{
public async Task Sync(Customer customer, ExternalRegistrationIdentity identity, string storeId)
{
if (!loginConfig.ProviderOnly || customer == null || identity == null)
return;

await SyncUserField(customer, SystemCustomerFieldNames.FirstName, identity.FirstName);
await SyncUserField(customer, SystemCustomerFieldNames.LastName, identity.LastName);
await SyncEmail(customer, identity, storeId);
}

private async Task SyncUserField(Customer customer, string key, string value)
{
if (string.IsNullOrEmpty(value))
return;
var stored = customer.GetUserFieldFromEntity<string>(key);
if (!string.Equals(stored, value, StringComparison.Ordinal))
await customerService.UpdateUserField(customer, key, value);
}

private async Task SyncEmail(Customer customer, ExternalRegistrationIdentity identity, string storeId)
{
if (!identity.EmailVerified || string.IsNullOrWhiteSpace(identity.Email))
return;

var newEmail = identity.Email.Trim().ToLowerInvariant();
if (string.Equals(newEmail, customer.Email, StringComparison.OrdinalIgnoreCase))
return;

//a break-glass account is listed by its e-mail in OidcLogin:BreakGlassAccounts: renaming it would lose the
//emergency access
if (loginConfig.IsBreakGlass(customer.Email))
{
logger.LogWarning("OpenID Connect profile sync: break-glass customer {CustomerId} keeps {OldEmail} " +
"(the identity provider has {NewEmail})", customer.Id, customer.Email, newEmail);
return;
}

//nor may an account take an address listed there: the password it still has would open the Admin login page
//and keep it signed in past the provider
if (loginConfig.IsBreakGlass(newEmail))
{
logger.LogWarning("OpenID Connect profile sync: customer {CustomerId} keeps {OldEmail}: {NewEmail} is a " +
"break-glass address", customer.Id, customer.Email, newEmail);
return;
}

var owner = await customerService.GetCustomerByEmail(newEmail, customerConfig.RegisterCustomersPerStore ? storeId : "");
if (owner != null && owner.Id != customer.Id)
{
logger.LogWarning("OpenID Connect profile sync: customer {CustomerId} keeps {OldEmail}: {NewEmail} belongs to customer {OtherId}",
customer.Id, customer.Email, newEmail, owner.Id);
return;
}

await customerService.UpdateCustomerField(customer, x => x.Email, newEmail);
customer.Email = newEmail;
if (!customerSettings.UsernamesEnabled)
{
await customerService.UpdateCustomerField(customer, x => x.Username, newEmail);
customer.Username = newEmail;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
using Grand.Business.Core.Interfaces.Authentication;
using Grand.Infrastructure.Configuration;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;

namespace Authentication.OpenIdConnect.Infrastructure;

/// <summary>
/// Ends the session at the identity provider (OpenID Connect RP-Initiated Logout 1.0) through the end-session endpoint of its metadata.
/// No id_token_hint is sent: the ID token is not stored.
/// </summary>
public class OidcSignOutProvider(
IOptionsMonitor<OpenIdConnectOptions> optionsMonitor,
OidcLoginConfig config,
IExternalAuthenticationService externalAuthenticationService,
ILogger<OidcSignOutProvider> logger) : IExternalSignOutProvider
{
public async Task<string> GetSignOutUrl(string postLogoutRedirectUri)
{
if (!externalAuthenticationService.AuthenticationProviderIsAvailable(OidcLoginDefaults.ProviderSystemName))
return null;

var options = optionsMonitor.Get(OidcLoginDefaults.AuthenticationScheme);
if (options.ConfigurationManager == null)
return null;

try
{
var configuration = await options.ConfigurationManager.GetConfigurationAsync(CancellationToken.None);
if (string.IsNullOrEmpty(configuration.EndSessionEndpoint))
return null;

var separator = configuration.EndSessionEndpoint.Contains('?') ? '&' : '?';
return $"{configuration.EndSessionEndpoint}{separator}client_id={Uri.EscapeDataString(config.ClientId)}" +
$"&post_logout_redirect_uri={Uri.EscapeDataString(postLogoutRedirectUri)}";
}
catch (Exception ex)
{
//sign-out still finishes locally
logger.LogWarning("The identity provider metadata could not be read to end the session there ({Exception})",
ex.GetType().Name);
return null;
}
Comment on lines +38 to +44
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ public void ConfigureServices(IServiceCollection services, IConfiguration config
services.AddHttpClient(OidcLoginDefaults.HttpClientName, client => client.Timeout = TimeSpan.FromSeconds(10))
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler { AllowAutoRedirect = false });
services.AddScoped<IOidcTokenRefresher, OidcTokenRefresher>();
services.AddScoped<IExternalSignOutProvider, OidcSignOutProvider>();
services.AddSingleton<OidcLogoutTokenValidator>();
services.AddScoped<OidcProfileSynchronizer>();
}

public int Priority => 10;
Expand Down
2 changes: 2 additions & 0 deletions src/Plugins/Authentication.OpenIdConnect/OidcLoginPlugin.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ private static readonly (string Key, string Value)[] Resources = [
"You are already signed in with another account. Sign out first, then sign in with your identity provider."),
("Failed.SessionUnavailable",
"Signing in with the identity provider is not set up completely. Use another sign-in method, or ask the administrator."),
("Failed.EmailNotVerified",
"Your identity provider has not verified your e-mail address. Verify it there, then sign in again."),
("DisplayOrder", "Display order"),
("ClientId", "Client ID"),
("ClientSecret", "Client secret"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
OidcLoginRefusal.AccountInactive => "Plugins.ExternalAuth.OpenIdConnect.Failed.AccountInactive",
OidcLoginRefusal.RegistrationDisabled => "Plugins.ExternalAuth.OpenIdConnect.Failed.RegistrationDisabled",
OidcLoginRefusal.LinkedToOtherIdentity => "Plugins.ExternalAuth.OpenIdConnect.Failed.LinkedToOtherIdentity",
OidcLoginRefusal.EmailNotVerified => "Plugins.ExternalAuth.OpenIdConnect.Failed.EmailNotVerified",
_ => "Plugins.ExternalAuth.OpenIdConnect.Failed.Generic"
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
using Authentication.OpenIdConnect.Controllers;
using Authentication.OpenIdConnect.Infrastructure;
using Authentication.OpenIdConnect.Models;
using Grand.Business.Core.Interfaces.Customers;
using Grand.Infrastructure.Configuration;
using Grand.Business.Core.Events.Customers;
using Grand.Business.Core.Interfaces.Authentication;
using Grand.Business.Core.Interfaces.Common.Directory;
Expand Down Expand Up @@ -44,6 +46,8 @@ public class OidcLoginControllerTests
private OidcLoginController _controller;
private Mock<IAuthenticationService> _httpAuth;
private Mock<ILogger<OidcLoginController>> _logger;
private Mock<ICustomerService> _customers;
private OidcLoginConfig _loginConfig;
private DateTimeOffset? _ticketIssued;
private readonly List<UrlRouteContext> _routes = [];

Expand Down Expand Up @@ -99,11 +103,15 @@ public void Init()
.ReturnsAsync(() => AuthenticateResult.Success(new AuthenticationTicket(new ClaimsPrincipal(new ClaimsIdentity("ext")),
new AuthenticationProperties(_externalItems), GrandCookieAuthenticationDefaults.ExternalAuthenticationScheme)));
_logger = new Mock<ILogger<OidcLoginController>>();
_customers = new Mock<ICustomerService>();
_loginConfig = new OidcLoginConfig();
var sync = new OidcProfileSynchronizer(_customers.Object, new CustomerSettings(), new CustomerConfig(), _loginConfig,
new Mock<ILogger<OidcProfileSynchronizer>>().Object);
var http = new DefaultHttpContext {
Session = new TestSession(),
RequestServices = new ServiceCollection().AddSingleton(_httpAuth.Object).BuildServiceProvider()
};
_controller = new OidcLoginController(_external.Object, _resolver.Object, _auth.Object, _sessions.Object, _mediator.Object,
_controller = new OidcLoginController(_external.Object, _resolver.Object, sync, _loginConfig, _auth.Object, _sessions.Object, _mediator.Object,
_externalAuth.Object, _schemes.Object, _groups.Object, ctx.Object, _logger.Object) {
ControllerContext = new ControllerContext { HttpContext = http }
};
Expand Down Expand Up @@ -579,4 +587,70 @@ public async Task LinkComplete_OtherProviderPending_ClearsAndFails()
_external.Verify(x => x.Complete(It.IsAny<Customer>(), It.IsAny<string>()), Times.Never);
_external.Verify(x => x.Clear(), Times.Once);
}

[TestMethod]
public async Task Callback_ProviderOnly_SignIn_SyncsProfile()
{
_loginConfig.SignInMode = OidcSignInMode.ProviderOnly;
var customer = new Customer { Id = "c9", Email = "john@example.com" };
_external.Setup(x => x.GetPending()).ReturnsAsync(Pending);
_resolver.Setup(x => x.ResolveForLogin(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<bool>(), It.IsAny<string>()))
.ReturnsAsync(OidcLoginResult.SignIn(customer));
await _controller.Callback("/");
_customers.Verify(x => x.UpdateUserField(customer, SystemCustomerFieldNames.FirstName, "John", ""), Times.Once);
_customers.Verify(x => x.UpdateUserField(customer, SystemCustomerFieldNames.LastName, "Doe", ""), Times.Once);
_auth.Verify(x => x.SignIn(customer, false, "session-1"), Times.Once);
}

[TestMethod]
public async Task Callback_SyncThrows_StillSignsIn()
{
_loginConfig.SignInMode = OidcSignInMode.ProviderOnly;
var customer = new Customer { Id = "c9", Email = "john@example.com" };
_customers.Setup(x => x.UpdateUserField(It.IsAny<Customer>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()))
.ThrowsAsync(new InvalidOperationException());
_external.Setup(x => x.GetPending()).ReturnsAsync(Pending);
_resolver.Setup(x => x.ResolveForLogin(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<bool>(), It.IsAny<string>()))
.ReturnsAsync(OidcLoginResult.SignIn(customer));
await _controller.Callback("/");
_auth.Verify(x => x.SignIn(customer, false, "session-1"), Times.Once);
}

[TestMethod]
public async Task Callback_Mixed_SignIn_NoSync()
{
var customer = new Customer { Id = "c9", Email = "john@example.com" };
_external.Setup(x => x.GetPending()).ReturnsAsync(Pending);
_resolver.Setup(x => x.ResolveForLogin(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<bool>(), It.IsAny<string>()))
.ReturnsAsync(OidcLoginResult.SignIn(customer));
await _controller.Callback("/");
_customers.Verify(x => x.UpdateUserField(It.IsAny<Customer>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}

[TestMethod]
public async Task Link_ProviderOnly_Failed()
{
_loginConfig.SignInMode = OidcSignInMode.ProviderOnly;
_external.Setup(x => x.GetPending()).ReturnsAsync(Pending);
Assert.AreEqual("OidcLoginFailed", ((RedirectToRouteResult)await _controller.Link("/")).RouteName);
_external.Verify(x => x.Clear(), Times.Once);
}

[TestMethod]
public async Task LinkComplete_ProviderOnly_Failed()
{
_loginConfig.SignInMode = OidcSignInMode.ProviderOnly;
_groups.Setup(x => x.IsRegistered(_current)).ReturnsAsync(true);
_external.Setup(x => x.GetPending()).ReturnsAsync(Pending);
Marker("sub-1");
Assert.AreEqual("OidcLoginFailed", ((RedirectToRouteResult)await _controller.LinkComplete("/")).RouteName);
_external.Verify(x => x.Complete(It.IsAny<Customer>(), It.IsAny<string>()), Times.Never);
}

[TestMethod]
public void Failed_EmailNotVerified_ShowsText()
{
var model = (FailedModel)((ViewResult)_controller.Failed((int)OidcLoginRefusal.EmailNotVerified)).Model!;
Assert.AreEqual(OidcLoginRefusal.EmailNotVerified, model.Reason);
}
}
Loading
Loading