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
18 changes: 18 additions & 0 deletions src/Indice.Features.Identity.Core/Constants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -203,4 +203,22 @@ public static partial class RateLimiterPolicies
ProfilePage,
VerifyPhonePage
};
}

/// <summary>Contains constants for keys used in <see cref="Microsoft.AspNetCore.Http.HttpContext.Items"/>.</summary>
public static class HttpContextItemKeys
{
/// <summary>
/// Key for the sign-in session id used to correlate login-related events and flowed into the issued token's <c>SessionId</c>.
/// </summary>
Comment thread
NikosDevPhp marked this conversation as resolved.
public const string SessionId = "__session_id__";
}

/// <summary>Functional prefixes for the sign-in session id, identifying the auth flow that created it.</summary>
public static class SessionIdPrefixes
{
/// <summary>Resource owner password (ROPC) flow.</summary>
public const string Password = "pwd";
/// <summary>Device authentication (biometric/pin) flow.</summary>
public const string DeviceAuthentication = "dv";
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,22 @@ public static MfaDeviceIdentifier ResolveDeviceId(this HttpContext? httpContext)
return MfaDeviceIdentifier.Empty;
}

/// <summary>Gets the current sign-in session id or creates one prefixed with <paramref name="prefix"/> (the originating auth flow).</summary>
public static ValueTask<string?> GetOrCreateSessionId(this HttpContext httpContext, string prefix) {
if (httpContext.Items.TryGetValue(HttpContextItemKeys.SessionId, out var value) && value is not null) {
return new ValueTask<string?>(value.ToString());
}

var sessionId = $"{prefix}.{Guid.NewGuid()}";
httpContext.Items[HttpContextItemKeys.SessionId] = sessionId;

return new ValueTask<string?>(sessionId);
}

/// <summary>Tries to resolve the sign-in session id from the current HTTP request.</summary>
public static string? GetSessionId(this HttpContext httpContext) =>
httpContext.Items.TryGetValue(Indice.Features.Identity.Core.HttpContextItemKeys.SessionId, out var value) ? value?.ToString() : null;

private static string? FindDeviceId(HttpContext httpContext) {
ArgumentNullException.ThrowIfNull(httpContext);
var deviceId = default(StringValues);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
using Indice.Features.Identity.Core.DeviceAuthentication.Stores;
using Indice.Features.Identity.Core.DeviceAuthentication.Validation;
using Indice.Features.Identity.Core.Events;
using Indice.Features.Identity.Core.Extensions;
using Indice.Security;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
Expand Down Expand Up @@ -155,12 +156,13 @@ public async Task ValidateAsync(ExtensionGrantValidationContext context) {
}
}

private Task RaiseUserLoginSuccessEvent(User user, ExtensionGrantValidationContext context) => EventService.RaiseAsync(new ExtendedUserLoginSuccessEvent(
private async Task RaiseUserLoginSuccessEvent(User user, ExtensionGrantValidationContext context) => await EventService.RaiseAsync(new ExtendedUserLoginSuccessEvent(
user!.UserName!,
user.Id,
user!.UserName!,
clientId: context.Request.ClientId,
clientName: context.Request.Client.ClientName,
sessionId: await HttpContextAccessor.HttpContext!.GetOrCreateSessionId(SessionIdPrefixes.DeviceAuthentication),
authenticationMethods: [context.Result.Subject.Identity?.AuthenticationType!]
));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
using Indice.Features.Identity.Core.Data.Models;
using Indice.Features.Identity.Core.DeviceAuthentication.Configuration;
using Indice.Features.Identity.Core.Events;
using Indice.Features.Identity.Core.Extensions;
using Indice.Features.Identity.Core.ImpossibleTravel;
using Indice.Features.Identity.Core.Totp;
using Indice.Security;
Expand All @@ -34,17 +35,20 @@ namespace Indice.Features.Identity.Core.Grants;
/// <param name="userManager">Provides the APIs for managing user in a persistence store.</param>
/// <param name="logger">Represents a type used to perform logging.</param>
/// <param name="eventService">Interface for the event service.</param>
/// <param name="httpContextAccessor">Used to access the <see cref="HttpContext"/> through the <see cref="IHttpContextAccessor"/> interface and its default implementation <see cref="HttpContextAccessor"/>.</param>
/// <exception cref="ArgumentNullException"></exception>
public class ExtendedResourceOwnerPasswordValidator<TUser>(
IEnumerable<IResourceOwnerPasswordValidationFilter<TUser>> filters,
ExtendedUserManager<TUser> userManager,
ILogger<ExtendedResourceOwnerPasswordValidator<TUser>> logger,
IEventService eventService) : IResourceOwnerPasswordValidator where TUser : User
IEventService eventService,
IHttpContextAccessor httpContextAccessor) : IResourceOwnerPasswordValidator where TUser : User
{
private readonly ILogger<ExtendedResourceOwnerPasswordValidator<TUser>> _logger = logger ?? throw new ArgumentNullException(nameof(logger));
private readonly IEventService _eventService = eventService ?? throw new ArgumentNullException(nameof(eventService));
private readonly IEnumerable<IResourceOwnerPasswordValidationFilter<TUser>> _filters = filters ?? throw new ArgumentNullException(nameof(filters));
private readonly ExtendedUserManager<TUser> _userManager = userManager ?? throw new ArgumentNullException(nameof(userManager));
private readonly IHttpContextAccessor _httpContextAccessor = httpContextAccessor ?? throw new ArgumentNullException(nameof(httpContextAccessor));

private readonly IDictionary<string, string> _errors = new Dictionary<string, string> {
[ResourceOwnerPasswordErrorCodes.LockedOut] = "User is locked out.",
Expand Down Expand Up @@ -96,9 +100,10 @@ await _eventService.RaiseAsync(new ExtendedUserLoginSuccessEvent(
user.UserName!,
clientId: context.Request.ClientId,
clientName: context.Request.Client.ClientName,
sessionId: await _httpContextAccessor.HttpContext!.GetOrCreateSessionId(SessionIdPrefixes.Password),
authenticationMethods: [context.Result.Subject.Identity?.AuthenticationType!]
));
await _userManager.SetLastSignInDateAsync(user, DateTimeOffset.UtcNow);
await _userManager.SetLastSignInDateAsync(user, DateTimeOffset.UtcNow);
}
else {
await _eventService.RaiseAsync(new ExtendedUserLoginFailureEvent(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
using Microsoft.AspNetCore.Authentication;
#endif
using Indice.AspNetCore.Extensions;
using Indice.Features.Identity.Core.Extensions;
using Indice.Features.Identity.Core.Grants;
using Indice.Security;
using Microsoft.AspNetCore.Http;
Expand Down Expand Up @@ -86,7 +87,8 @@ protected override Task<TokenResponse> ProcessAuthorizationCodeRequestAsync(Toke

/// <inheritdoc />
protected override async Task<TokenResponse> ProcessPasswordRequestAsync(TokenRequestValidationResult request) {
request.ValidatedRequest.SessionId ??= Guid.NewGuid().ToString("N");
var httpContext = ServiceProvider.GetRequiredService<IHttpContextAccessor>().HttpContext!;
request.ValidatedRequest.SessionId ??= httpContext.GetSessionId();
var tokenResponse = await base.ProcessPasswordRequestAsync(request);
var config = ServiceProvider.GetService<ResourceOwnerPasswordValidatorOptions>();
if (config?.IncludeIdToken == false) {
Expand Down Expand Up @@ -121,8 +123,8 @@ protected override async Task<TokenResponse> ProcessPasswordRequestAsync(TokenRe

/// <inheritdoc/>
protected override async Task<TokenResponse> ProcessExtensionGrantRequestAsync(TokenRequestValidationResult request) {
request.ValidatedRequest.SessionId ??= Guid.NewGuid().ToString("N");
var httpContext = ServiceProvider.GetRequiredService<IHttpContextAccessor>().HttpContext!;
request.ValidatedRequest.SessionId ??= httpContext.GetSessionId();
var ip = httpContext.GetClientIpAddress();
request.ValidatedRequest.Subject!.AddIdentity(new(claims: [
new(BasicClaimTypes.IPAddress, ip == IPAddress.None ? string.Empty : ip.ToString())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,16 @@
using Duende.IdentityServer.ResponseHandling;
using Duende.IdentityServer.Services;
using Duende.IdentityServer.Stores;
using Duende.IdentityServer.Events;
#else
using IdentityServer4;
using IdentityServer4.Models;
using IdentityServer4.ResponseHandling;
using IdentityServer4.Services;
using Indice.Features.Identity.Core.TokenCreation;
using IdentityServer4.Events;
#endif
using Indice.Features.Identity.Core.Events;
using Indice.Features.Identity.Core;
using Indice.Features.Identity.Core.Data;
using Indice.Features.Identity.Core.Data.Models;
Expand Down Expand Up @@ -221,6 +224,7 @@ public class CustomGrantsIntegrationTests : IAsyncLifetime
private IServiceProvider _serviceProvider;
private string _identityDatabaseName = $"IdentityDb.Test_{Environment.Version.Major}_{Guid.NewGuid()}";
private string _signInLogDatabaseName = $"SignInLogDb.Test_{Environment.Version.Major}_{Guid.NewGuid()}";
private readonly List<Event> _raisedEvents = [];

public CustomGrantsIntegrationTests(ITestOutputHelper output) {
_output = output;
Expand Down Expand Up @@ -272,6 +276,13 @@ public CustomGrantsIntegrationTests(ITestOutputHelper output) {
options.ImpossibleTravel.AcceptableSpeed = 90d;
options.ImpossibleTravel.FlowType = ImpossibleTravelFlowType.PromptMfa;
});
// Replace the default event sink with a composite one that captures events for testing purposes.
var sinkDescriptor = services.Last(d => d.ServiceType == typeof(IEventSink));
services.Remove(sinkDescriptor);
services.Add(new ServiceDescriptor(typeof(IEventSink), sp => {
var inner = (IEventSink)ActivatorUtilities.CreateInstance(sp, sinkDescriptor.ImplementationType!);
return new CompositeEventSink(inner, _raisedEvents);
}, sinkDescriptor.Lifetime));
services.AddTransient<ITokenResponseGenerator, ExtendedTokenResponseGenerator>();
#if !NET9_0_OR_GREATER
services.AddTransient<ITokenCreationService, ExtendedTokenCreationService>();
Expand Down Expand Up @@ -335,13 +346,16 @@ public async Task Password_Grant_Issues_New_SessionId() {
var tokenResponse = await LoginWithPasswordGrant(userName: "someone@indice.gr", password: "xxxxxxx");
var sessionId = GetSessionId(tokenResponse);
Assert.False(string.IsNullOrWhiteSpace(sessionId));
Assert.Contains(_raisedEvents.OfType<ExtendedUserLoginSuccessEvent>(), loginEvent => loginEvent.SessionId == sessionId);
}

[Fact]
public async Task Password_Grant_Issues_Different_SessionId_Per_Login() {
var firstLogin = await LoginWithPasswordGrant(userName: "someone@indice.gr", password: "xxxxxxx");
var secondLogin = await LoginWithPasswordGrant(userName: "someone@indice.gr", password: "xxxxxxx");
Assert.NotEqual(GetSessionId(firstLogin), GetSessionId(secondLogin));
Assert.Contains(_raisedEvents.OfType<ExtendedUserLoginSuccessEvent>(), loginEvent => loginEvent.SessionId == GetSessionId(firstLogin));
Assert.Contains(_raisedEvents.OfType<ExtendedUserLoginSuccessEvent>(), loginEvent => loginEvent.SessionId == GetSessionId(secondLogin));
}

[Fact]
Expand All @@ -350,6 +364,7 @@ public async Task DeviceAuthentication_Pin_Issues_New_SessionId() {
var tokenResponse = await LoginWithDevicePin(registrationResult.RegistrationId);
var sessionId = GetSessionId(tokenResponse);
Assert.False(string.IsNullOrWhiteSpace(sessionId));
Assert.Contains(_raisedEvents.OfType<ExtendedUserLoginSuccessEvent>(), loginEvent => loginEvent.SessionId == sessionId);
}

[Fact]
Expand All @@ -376,6 +391,7 @@ public async Task DeviceAuthentication_Fingerprint_Issues_New_SessionId() {
var tokenResponse = await _httpClient.RequestTokenAsync(tokenRequest);
var sessionId = GetSessionId(tokenResponse);
Assert.False(string.IsNullOrWhiteSpace(sessionId));
Assert.Contains(_raisedEvents.OfType<ExtendedUserLoginSuccessEvent>(), loginEvent => loginEvent.SessionId == sessionId);
}

[Fact]
Expand Down Expand Up @@ -403,6 +419,7 @@ public async Task Refresh_Token_Preserves_SessionId() {
var secondRefresh = await _httpClient.RequestRefreshTokenAsync(secondRefreshRequest);

Assert.Equal(loginSessionId, GetSessionId(secondRefresh));
Assert.Contains(_raisedEvents.OfType<ExtendedUserLoginSuccessEvent>(), loginEvent => loginEvent.SessionId == loginSessionId);
}

[Fact(Skip = "Known discrepancy between IS4 and Duende on handling of UpdateAccessTokenClaimsOnRefresh with SessionId.")]
Expand Down Expand Up @@ -1171,5 +1188,13 @@ public class TrustedDeviceCompleteRegistrationResultDto
[JsonPropertyName("registrationId")]
public Guid RegistrationId { get; set; }
}

private sealed class CompositeEventSink(IEventSink inner, List<Event> captured) : IEventSink
{
public async Task PersistAsync(Event @event) {
captured.Add(@event);
await inner.PersistAsync(@event);
}
}
#endregion
}
Loading