diff --git a/UltimateAuth.slnx b/UltimateAuth.slnx index 22c26b0d..f7eed3a7 100644 --- a/UltimateAuth.slnx +++ b/UltimateAuth.slnx @@ -26,6 +26,7 @@ + @@ -33,6 +34,7 @@ + diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IRefreshTokenStore.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IRefreshTokenStore.cs index cf43b46a..fb8693c1 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IRefreshTokenStore.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IRefreshTokenStore.cs @@ -1,23 +1,22 @@ using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Core.MultiTenancy; namespace CodeBeam.UltimateAuth.Core.Abstractions; -/// -/// Low-level persistence abstraction for refresh tokens. -/// NO validation logic. NO business rules. -/// public interface IRefreshTokenStore { - Task StoreAsync(TenantKey tenant, StoredRefreshToken token, CancellationToken ct = default); + Task ExecuteAsync(Func action, CancellationToken ct = default); - Task FindByHashAsync(TenantKey tenant, string tokenHash, CancellationToken ct = default); + Task ExecuteAsync(Func> action, CancellationToken ct = default); - Task RevokeAsync(TenantKey tenant, string tokenHash, DateTimeOffset revokedAt, string? replacedByTokenHash = null, CancellationToken ct = default); + Task StoreAsync(RefreshToken token, CancellationToken ct = default); - Task RevokeBySessionAsync(TenantKey tenant, AuthSessionId sessionId, DateTimeOffset revokedAt, CancellationToken ct = default); + Task FindByHashAsync(string tokenHash, CancellationToken ct = default); - Task RevokeByChainAsync(TenantKey tenant, SessionChainId chainId, DateTimeOffset revokedAt, CancellationToken ct = default); + Task RevokeAsync(string tokenHash, DateTimeOffset revokedAt, string? replacedByTokenHash = null, CancellationToken ct = default); - Task RevokeAllForUserAsync(TenantKey tenant, UserKey userKey, DateTimeOffset revokedAt, CancellationToken ct = default); + Task RevokeBySessionAsync(AuthSessionId sessionId, DateTimeOffset revokedAt, CancellationToken ct = default); + + Task RevokeByChainAsync(SessionChainId chainId, DateTimeOffset revokedAt, CancellationToken ct = default); + + Task RevokeAllForUserAsync(UserKey userKey, DateTimeOffset revokedAt, CancellationToken ct = default); } diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IRefreshTokenStoreFactory.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IRefreshTokenStoreFactory.cs new file mode 100644 index 00000000..e37debc7 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IRefreshTokenStoreFactory.cs @@ -0,0 +1,8 @@ +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Core.Abstractions; + +public interface IRefreshTokenStoreFactory +{ + IRefreshTokenStore Create(TenantKey tenant); +} diff --git a/src/CodeBeam.UltimateAuth.Core/AssemblyVisibility.cs b/src/CodeBeam.UltimateAuth.Core/AssemblyVisibility.cs index 768f9e27..2f9b3da7 100644 --- a/src/CodeBeam.UltimateAuth.Core/AssemblyVisibility.cs +++ b/src/CodeBeam.UltimateAuth.Core/AssemblyVisibility.cs @@ -2,4 +2,8 @@ [assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Server")] [assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore")] +[assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore")] +[assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Users.EntityFrameworkCore")] +[assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore")] +[assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore")] [assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Tests.Unit")] diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessContext.cs index e526683e..7fa62828 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessContext.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessContext.cs @@ -1,4 +1,5 @@ using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Core.MultiTenancy; using System.Collections; @@ -29,7 +30,7 @@ public sealed class AccessContext public UserKey GetTargetUserKey() { if (TargetUserKey is not UserKey targetUserKey) - throw new InvalidOperationException("Target user is not found."); + throw new UAuthNotFoundException("Target user is not found."); return targetUserKey; } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginResult.cs index 93c84a09..f9d3b280 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginResult.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginResult.cs @@ -7,7 +7,7 @@ public sealed record LoginResult public LoginStatus Status { get; init; } public AuthSessionId? SessionId { get; init; } public AccessToken? AccessToken { get; init; } - public RefreshToken? RefreshToken { get; init; } + public RefreshTokenInfo? RefreshToken { get; init; } public LoginContinuation? Continuation { get; init; } public AuthFailureReason? FailureReason { get; init; } public DateTimeOffset? LockoutUntilUtc { get; init; } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshFlowResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshFlowResult.cs index 51b81396..59b18af1 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshFlowResult.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshFlowResult.cs @@ -9,7 +9,7 @@ public sealed class RefreshFlowResult public AuthSessionId? SessionId { get; init; } public AccessToken? AccessToken { get; init; } - public RefreshToken? RefreshToken { get; init; } + public RefreshTokenInfo? RefreshToken { get; init; } public static RefreshFlowResult ReauthRequired() { @@ -24,7 +24,7 @@ public static RefreshFlowResult Success( RefreshOutcome outcome, AuthSessionId? sessionId = null, AccessToken? accessToken = null, - RefreshToken? refreshToken = null) + RefreshTokenInfo? refreshToken = null) { return new RefreshFlowResult { diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/AuthTokens.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/AuthTokens.cs index be61e290..1b13c185 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/AuthTokens.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/AuthTokens.cs @@ -12,5 +12,5 @@ public sealed record AuthTokens /// public AccessToken AccessToken { get; init; } = default!; - public RefreshToken? RefreshToken { get; init; } + public RefreshTokenInfo? RefreshToken { get; init; } } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshToken.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenInfo.cs similarity index 93% rename from src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshToken.cs rename to src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenInfo.cs index d741b858..6d5648b5 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshToken.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenInfo.cs @@ -3,7 +3,7 @@ /// /// Transport model for refresh token. Returned to client once upon creation. /// -public sealed class RefreshToken +public sealed class RefreshTokenInfo { /// /// Plain refresh token value (returned to client once). diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenRotationResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenRotationResult.cs index b1c50d0d..af715f12 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenRotationResult.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenRotationResult.cs @@ -10,7 +10,7 @@ public sealed record RefreshTokenRotationResult public AuthSessionId? SessionId { get; init; } public AccessToken? AccessToken { get; init; } - public RefreshToken? RefreshToken { get; init; } + public RefreshTokenInfo? RefreshToken { get; init; } private RefreshTokenRotationResult() { } @@ -18,7 +18,7 @@ private RefreshTokenRotationResult() { } public static RefreshTokenRotationResult Success( AccessToken accessToken, - RefreshToken refreshToken) + RefreshTokenInfo refreshToken) => new() { IsSuccess = true, diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Device/DeviceContext.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Device/DeviceContext.cs index 23372748..f3d3049c 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Device/DeviceContext.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Device/DeviceContext.cs @@ -38,11 +38,11 @@ public static DeviceContext Anonymous() public static DeviceContext Create( DeviceId deviceId, - string? deviceType, - string? platform, - string? operatingSystem, - string? browser, - string? ipAddress) + string? deviceType = null, + string? platform = null, + string? operatingSystem = null, + string? browser = null, + string? ipAddress = null) { return new DeviceContext( deviceId, diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs index 806a9c5b..b9351524 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs @@ -1,9 +1,9 @@ using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Core.MultiTenancy; namespace CodeBeam.UltimateAuth.Core.Domain; -// TODO: Add ISoftDeleteable public sealed class UAuthSession : IVersionedEntity { public AuthSessionId SessionId { get; } @@ -12,13 +12,15 @@ public sealed class UAuthSession : IVersionedEntity public SessionChainId ChainId { get; } public DateTimeOffset CreatedAt { get; } public DateTimeOffset ExpiresAt { get; } - public bool IsRevoked { get; } public DateTimeOffset? RevokedAt { get; } public long SecurityVersionAtCreation { get; } + public DeviceContext Device { get; } // For snapshot,main value is chain's device. public ClaimsSnapshot Claims { get; } public SessionMetadata Metadata { get; } public long Version { get; set; } + public bool IsRevoked => RevokedAt != null; + private UAuthSession( AuthSessionId sessionId, TenantKey tenant, @@ -26,9 +28,9 @@ private UAuthSession( SessionChainId chainId, DateTimeOffset createdAt, DateTimeOffset expiresAt, - bool isRevoked, DateTimeOffset? revokedAt, long securityVersionAtCreation, + DeviceContext device, ClaimsSnapshot claims, SessionMetadata metadata, long version) @@ -39,9 +41,9 @@ private UAuthSession( ChainId = chainId; CreatedAt = createdAt; ExpiresAt = expiresAt; - IsRevoked = isRevoked; RevokedAt = revokedAt; SecurityVersionAtCreation = securityVersionAtCreation; + Device = device; Claims = claims; Metadata = metadata; Version = version; @@ -55,6 +57,7 @@ public static UAuthSession Create( DateTimeOffset now, DateTimeOffset expiresAt, long securityVersion, + DeviceContext device, ClaimsSnapshot? claims, SessionMetadata metadata) { @@ -65,9 +68,9 @@ public static UAuthSession Create( chainId, createdAt: now, expiresAt: expiresAt, - isRevoked: false, revokedAt: null, securityVersionAtCreation: securityVersion, + device: device, claims: claims ?? ClaimsSnapshot.Empty, metadata: metadata, version: 0 @@ -76,7 +79,8 @@ public static UAuthSession Create( public UAuthSession Revoke(DateTimeOffset at) { - if (IsRevoked) return this; + if (IsRevoked) + return this; return new UAuthSession( SessionId, @@ -85,9 +89,9 @@ public UAuthSession Revoke(DateTimeOffset at) ChainId, CreatedAt, ExpiresAt, - true, at, SecurityVersionAtCreation, + Device, Claims, Metadata, Version + 1 @@ -101,9 +105,9 @@ internal static UAuthSession FromProjection( SessionChainId chainId, DateTimeOffset createdAt, DateTimeOffset expiresAt, - bool isRevoked, DateTimeOffset? revokedAt, long securityVersionAtCreation, + DeviceContext device, ClaimsSnapshot claims, SessionMetadata metadata, long version) @@ -115,9 +119,9 @@ internal static UAuthSession FromProjection( chainId, createdAt, expiresAt, - isRevoked, revokedAt, securityVersionAtCreation, + device, claims, metadata, version @@ -138,7 +142,7 @@ public SessionState GetState(DateTimeOffset at) public UAuthSession WithChain(SessionChainId chainId) { if (!ChainId.IsUnassigned) - throw new InvalidOperationException("Chain already assigned."); + throw new UAuthConflictException("Chain already assigned."); return new UAuthSession( sessionId: SessionId, @@ -147,9 +151,9 @@ public UAuthSession WithChain(SessionChainId chainId) chainId: chainId, createdAt: CreatedAt, expiresAt: ExpiresAt, - isRevoked: IsRevoked, revokedAt: RevokedAt, securityVersionAtCreation: SecurityVersionAtCreation, + device: Device, claims: Claims, metadata: Metadata, version: Version + 1 diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionChain.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionChain.cs index 7d9139cc..e211cf04 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionChain.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionChain.cs @@ -1,6 +1,5 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.MultiTenancy; -using static CodeBeam.UltimateAuth.Core.Defaults.UAuthActions; namespace CodeBeam.UltimateAuth.Core.Domain; diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionRoot.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionRoot.cs index 0ae69287..d50f9208 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionRoot.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionRoot.cs @@ -11,20 +11,19 @@ public sealed class UAuthSessionRoot : IVersionedEntity public DateTimeOffset CreatedAt { get; } public DateTimeOffset? UpdatedAt { get; } - - public bool IsRevoked { get; } public DateTimeOffset? RevokedAt { get; } public long SecurityVersion { get; } public long Version { get; set; } + public bool IsRevoked => RevokedAt != null; + private UAuthSessionRoot( SessionRootId rootId, TenantKey tenant, UserKey userKey, DateTimeOffset createdAt, DateTimeOffset? updatedAt, - bool isRevoked, DateTimeOffset? revokedAt, long securityVersion, long version) @@ -34,7 +33,6 @@ private UAuthSessionRoot( UserKey = userKey; CreatedAt = createdAt; UpdatedAt = updatedAt; - IsRevoked = isRevoked; RevokedAt = revokedAt; SecurityVersion = securityVersion; Version = version; @@ -51,7 +49,6 @@ public static UAuthSessionRoot Create( userKey, at, null, - false, null, 0, 0 @@ -66,7 +63,6 @@ public UAuthSessionRoot IncreaseSecurityVersion(DateTimeOffset at) UserKey, CreatedAt, at, - IsRevoked, RevokedAt, SecurityVersion + 1, Version + 1 @@ -84,7 +80,6 @@ public UAuthSessionRoot Revoke(DateTimeOffset at) UserKey, CreatedAt, at, - true, at, SecurityVersion + 1, Version + 1 @@ -97,7 +92,6 @@ internal static UAuthSessionRoot FromProjection( UserKey userKey, DateTimeOffset createdAt, DateTimeOffset? updatedAt, - bool isRevoked, DateTimeOffset? revokedAt, long securityVersion, long version) @@ -108,7 +102,6 @@ internal static UAuthSessionRoot FromProjection( userKey, createdAt, updatedAt, - isRevoked, revokedAt, securityVersion, version diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Token/RefreshToken.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Token/RefreshToken.cs new file mode 100644 index 00000000..4cf354c8 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Token/RefreshToken.cs @@ -0,0 +1,88 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Core.Domain; + +public sealed record RefreshToken : IVersionedEntity +{ + public TokenId TokenId { get; init; } + + public string TokenHash { get; init; } = default!; + + public TenantKey Tenant { get; init; } + + public required UserKey UserKey { get; init; } + + public AuthSessionId SessionId { get; init; } + + public SessionChainId? ChainId { get; init; } + + public DateTimeOffset CreatedAt { get; init; } + + public DateTimeOffset ExpiresAt { get; init; } + + public DateTimeOffset? RevokedAt { get; init; } + + public string? ReplacedByTokenHash { get; init; } + + public long Version { get; set; } + + public bool IsRevoked => RevokedAt.HasValue; + + public bool IsExpired(DateTimeOffset now) + => ExpiresAt <= now; + + public bool IsActive(DateTimeOffset now) + => !IsRevoked && !IsExpired(now) && ReplacedByTokenHash is null; + + public static RefreshToken Create( + TokenId tokenId, + string tokenHash, + TenantKey tenant, + UserKey userKey, + AuthSessionId sessionId, + SessionChainId? chainId, + DateTimeOffset createdAt, + DateTimeOffset expiresAt) + { + return new RefreshToken + { + TokenId = tokenId, + TokenHash = tokenHash, + Tenant = tenant, + UserKey = userKey, + SessionId = sessionId, + ChainId = chainId, + CreatedAt = createdAt, + ExpiresAt = expiresAt, + Version = 0 + }; + } + + public RefreshToken Revoke(DateTimeOffset at, string? replacedBy = null) + { + if (IsRevoked) + return this; + + return this with + { + RevokedAt = at, + ReplacedByTokenHash = replacedBy, + Version = Version + 1 + }; + } + + public RefreshToken Replace(string newTokenHash, DateTimeOffset at) + { + if (IsRevoked) + throw new UAuthConflictException("Token already revoked."); + + return this with + { + RevokedAt = at, + ReplacedByTokenHash = newTokenHash, + Version = Version + 1 + }; + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Token/StoredRefreshToken.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Token/StoredRefreshToken.cs deleted file mode 100644 index bd21a678..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Token/StoredRefreshToken.cs +++ /dev/null @@ -1,36 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.MultiTenancy; -using System.ComponentModel.DataAnnotations.Schema; - -namespace CodeBeam.UltimateAuth.Core.Domain; - -/// -/// Represents a persisted refresh token bound to a session. -/// Stored as a hashed value for security reasons. -/// -public sealed record StoredRefreshToken : IVersionedEntity -{ - public string TokenHash { get; init; } = default!; - - public TenantKey Tenant { get; init; } - - public required UserKey UserKey { get; init; } - - public AuthSessionId SessionId { get; init; } = default!; - public SessionChainId? ChainId { get; init; } - - public DateTimeOffset IssuedAt { get; init; } - public DateTimeOffset ExpiresAt { get; init; } - public DateTimeOffset? RevokedAt { get; init; } - - public string? ReplacedByTokenHash { get; init; } - - public long Version { get; set; } - - [NotMapped] - public bool IsRevoked => RevokedAt.HasValue; - - public bool IsExpired(DateTimeOffset now) => ExpiresAt <= now; - - public bool IsActive(DateTimeOffset now) => !IsRevoked && !IsExpired(now) && ReplacedByTokenHash is null; -} diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Token/TokenId.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Token/TokenId.cs new file mode 100644 index 00000000..19c704b8 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Token/TokenId.cs @@ -0,0 +1,51 @@ +using CodeBeam.UltimateAuth.Core.Infrastructure; +using System.Text.Json.Serialization; + +namespace CodeBeam.UltimateAuth.Core.Domain; + +[JsonConverter(typeof(TokenIdJsonConverter))] +public readonly record struct TokenId(Guid Value) : IParsable +{ + public static TokenId New() => new(Guid.NewGuid()); + + public static TokenId From(Guid value) + => value == Guid.Empty + ? throw new ArgumentException("TokenId cannot be empty.", nameof(value)) + : new TokenId(value); + + public static bool TryCreate(string raw, out TokenId id) + { + if (Guid.TryParse(raw, out var guid) && guid != Guid.Empty) + { + id = new TokenId(guid); + return true; + } + + id = default; + return false; + } + + public static TokenId Parse(string s, IFormatProvider? provider) + { + if (TryParse(s, provider, out var id)) + return id; + + throw new FormatException("Invalid TokenId."); + } + + public static bool TryParse(string? s, IFormatProvider? provider, out TokenId result) + { + if (!string.IsNullOrWhiteSpace(s) && + Guid.TryParse(s, out var guid) && + guid != Guid.Empty) + { + result = new TokenId(guid); + return true; + } + + result = default; + return false; + } + + public override string ToString() => Value.ToString("N"); +} diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/AuthSessionIdJsonConverter.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/AuthSessionIdJsonConverter.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Core/Infrastructure/AuthSessionIdJsonConverter.cs rename to src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/AuthSessionIdJsonConverter.cs diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/SessionChainIdJsonConverter.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/SessionChainIdJsonConverter.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Core/Infrastructure/SessionChainIdJsonConverter.cs rename to src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/SessionChainIdJsonConverter.cs diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/SessionRootIdJsonConverter.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/SessionRootIdJsonConverter.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Core/Infrastructure/SessionRootIdJsonConverter.cs rename to src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/SessionRootIdJsonConverter.cs diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/TenantKeyJsonConverter.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/TenantKeyJsonConverter.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Core/Infrastructure/TenantKeyJsonConverter.cs rename to src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/TenantKeyJsonConverter.cs diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/TokenIdJsonConverter.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/TokenIdJsonConverter.cs new file mode 100644 index 00000000..25271f91 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/TokenIdJsonConverter.cs @@ -0,0 +1,26 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace CodeBeam.UltimateAuth.Core.Infrastructure; + +public sealed class TokenIdJsonConverter : JsonConverter +{ + public override TokenId Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.String) + throw new JsonException("TokenId must be a string."); + + var raw = reader.GetString(); + + if (!TokenId.TryCreate(raw!, out var id)) + throw new JsonException($"Invalid TokenId value: '{raw}'"); + + return id; + } + + public override void Write(Utf8JsonWriter writer, TokenId value, JsonSerializerOptions options) + { + writer.WriteStringValue(value.Value.ToString("N")); + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/UAuthUserIdConverter.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/UAuthUserIdConverter.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Core/Infrastructure/UAuthUserIdConverter.cs rename to src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/UAuthUserIdConverter.cs diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/UserKeyJsonConverter.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/UserKeyJsonConverter.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Core/Infrastructure/UserKeyJsonConverter.cs rename to src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/UserKeyJsonConverter.cs diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/UAuthRefreshTokenValidator.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/UAuthRefreshTokenValidator.cs index 53ca29b6..349dd8ab 100644 --- a/src/CodeBeam.UltimateAuth.Core/Infrastructure/UAuthRefreshTokenValidator.cs +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/UAuthRefreshTokenValidator.cs @@ -5,19 +5,20 @@ namespace CodeBeam.UltimateAuth.Core.Infrastructure; public sealed class UAuthRefreshTokenValidator : IRefreshTokenValidator { - private readonly IRefreshTokenStore _store; + private readonly IRefreshTokenStoreFactory _storeFactory; private readonly ITokenHasher _hasher; - public UAuthRefreshTokenValidator(IRefreshTokenStore store, ITokenHasher hasher) + public UAuthRefreshTokenValidator(IRefreshTokenStoreFactory storeFactory, ITokenHasher hasher) { - _store = store; + _storeFactory = storeFactory; _hasher = hasher; } public async Task ValidateAsync(RefreshTokenValidationContext context, CancellationToken ct = default) { + var store = _storeFactory.Create(context.Tenant); var hash = _hasher.Hash(context.RefreshToken); - var stored = await _store.FindByHashAsync(context.Tenant, hash, ct); + var stored = await store.FindByHashAsync(hash, ct); if (stored is null) return RefreshTokenValidationResult.Invalid(); @@ -31,7 +32,7 @@ public async Task ValidateAsync(RefreshTokenValida if (stored.IsExpired(context.Now)) { - await _store.RevokeAsync(context.Tenant, hash, context.Now, null, ct); + await store.RevokeAsync(hash, context.Now, null, ct); return RefreshTokenValidationResult.Invalid(); } diff --git a/src/CodeBeam.UltimateAuth.Server/Abstractions/ICredentialResponseWriter.cs b/src/CodeBeam.UltimateAuth.Server/Abstractions/ICredentialResponseWriter.cs index b364a558..51be3f4b 100644 --- a/src/CodeBeam.UltimateAuth.Server/Abstractions/ICredentialResponseWriter.cs +++ b/src/CodeBeam.UltimateAuth.Server/Abstractions/ICredentialResponseWriter.cs @@ -8,5 +8,5 @@ public interface ICredentialResponseWriter { void Write(HttpContext context, GrantKind kind, AuthSessionId sessionId); void Write(HttpContext context, GrantKind kind, AccessToken accessToken); - void Write(HttpContext context, GrantKind kind, RefreshToken refreshToken); + void Write(HttpContext context, GrantKind kind, RefreshTokenInfo refreshToken); } diff --git a/src/CodeBeam.UltimateAuth.Server/Abstractions/ITokenIssuer.cs b/src/CodeBeam.UltimateAuth.Server/Abstractions/ITokenIssuer.cs index 9bb61132..9b525f5f 100644 --- a/src/CodeBeam.UltimateAuth.Server/Abstractions/ITokenIssuer.cs +++ b/src/CodeBeam.UltimateAuth.Server/Abstractions/ITokenIssuer.cs @@ -10,5 +10,5 @@ namespace CodeBeam.UltimateAuth.Server.Abstactions; public interface ITokenIssuer { Task IssueAccessTokenAsync(AuthFlowContext flow, TokenIssuanceContext context, CancellationToken cancellationToken = default); - Task IssueRefreshTokenAsync(AuthFlowContext flow, TokenIssuanceContext context, RefreshTokenPersistence persistence, CancellationToken cancellationToken = default); + Task IssueRefreshTokenAsync(AuthFlowContext flow, TokenIssuanceContext context, RefreshTokenPersistence persistence, CancellationToken cancellationToken = default); } diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs index 694def16..e3b1ba17 100644 --- a/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs @@ -21,8 +21,7 @@ namespace CodeBeam.UltimateAuth.Server.Flows; internal sealed class LoginOrchestrator : ILoginOrchestrator { private readonly ILoginIdentifierResolver _identifierResolver; - private readonly ICredentialStore _credentialStore; // authentication - private readonly ICredentialValidator _credentialValidator; + private readonly IEnumerable _credentialProviders; // authentication private readonly IUserRuntimeStateProvider _users; // eligible private readonly ILoginAuthority _authority; private readonly ISessionOrchestrator _sessionOrchestrator; @@ -35,8 +34,7 @@ internal sealed class LoginOrchestrator : ILoginOrchestrator public LoginOrchestrator( ILoginIdentifierResolver identifierResolver, - ICredentialStore credentialStore, - ICredentialValidator credentialValidator, + IEnumerable credentialProviders, IUserRuntimeStateProvider users, ILoginAuthority authority, ISessionOrchestrator sessionOrchestrator, @@ -48,8 +46,7 @@ public LoginOrchestrator( IOptions options) { _identifierResolver = identifierResolver; - _credentialStore = credentialStore; - _credentialValidator = credentialValidator; + _credentialProviders = credentialProviders; _users = users; _authority = authority; _sessionOrchestrator = sessionOrchestrator; @@ -99,21 +96,24 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req return LoginResult.Failed(AuthFailureReason.LockedOut, factorState.LockedUntil, 0); } - var credentials = await _credentialStore.GetByUserAsync(request.Tenant, userKey.Value, ct); - - // TODO: Add .Where(c => c.Type == request.Factor) when we support multiple factors per user - foreach (var credential in credentials.OfType()) + foreach (var provider in _credentialProviders) { - if (!credential.Security.IsUsable(now)) - continue; - - var result = await _credentialValidator.ValidateAsync((ICredential)credential, request.Secret, ct); + var credentials = await provider.GetByUserAsync(request.Tenant, userKey.Value, ct); - if (result.IsValid) + foreach (var credential in credentials) { - credentialsValid = true; - break; + if (credential.IsDeleted || !credential.Security.IsUsable(now)) + continue; + + if (await provider.ValidateAsync(credential, request.Secret, ct)) + { + credentialsValid = true; + break; + } } + + if (credentialsValid) + break; } } } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/CredentialResponseWriter.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/CredentialResponseWriter.cs index 6c43139c..9af510d5 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/CredentialResponseWriter.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/CredentialResponseWriter.cs @@ -33,7 +33,7 @@ public void Write(HttpContext context, GrantKind kind, AuthSessionId sessionId) public void Write(HttpContext context, GrantKind kind, AccessToken token) => WriteInternal(context, kind, token.Token); - public void Write(HttpContext context, GrantKind kind, RefreshToken token) + public void Write(HttpContext context, GrantKind kind, RefreshTokenInfo token) => WriteInternal(context, kind, token.Token); public void WriteInternal(HttpContext context, GrantKind kind, string value) diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthSessionIssuer.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthSessionIssuer.cs index dc9ce475..4f1d4909 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthSessionIssuer.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthSessionIssuer.cs @@ -117,6 +117,7 @@ await kernel.ExecuteAsync(async _ => now: now, expiresAt: expiresAt, securityVersion: root.SecurityVersion, + device: context.Device, claims: context.Claims, metadata: context.Metadata ); @@ -193,6 +194,7 @@ await kernel.ExecuteAsync(async _ => now: now, expiresAt: expiresAt, securityVersion: root.SecurityVersion, + device: context.Device, claims: context.Claims, metadata: context.Metadata ); diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthTokenIssuer.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthTokenIssuer.cs index 16cd8025..cb0b5955 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthTokenIssuer.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthTokenIssuer.cs @@ -18,15 +18,15 @@ public sealed class UAuthTokenIssuer : ITokenIssuer private readonly IOpaqueTokenGenerator _opaqueGenerator; private readonly IJwtTokenGenerator _jwtGenerator; private readonly ITokenHasher _tokenHasher; - private readonly IRefreshTokenStore _refreshTokenStore; + private readonly IRefreshTokenStoreFactory _storeFactory; private readonly IClock _clock; - public UAuthTokenIssuer(IOpaqueTokenGenerator opaqueGenerator, IJwtTokenGenerator jwtGenerator, ITokenHasher tokenHasher, IRefreshTokenStore refreshTokenStore, IClock clock) + public UAuthTokenIssuer(IOpaqueTokenGenerator opaqueGenerator, IJwtTokenGenerator jwtGenerator, ITokenHasher tokenHasher, IRefreshTokenStoreFactory storeFactory, IClock clock) { _opaqueGenerator = opaqueGenerator; _jwtGenerator = jwtGenerator; _tokenHasher = tokenHasher; - _refreshTokenStore = refreshTokenStore; + _storeFactory = storeFactory; _clock = clock; } @@ -50,36 +50,37 @@ UAuthMode.SemiHybrid or }; } - public async Task IssueRefreshTokenAsync(AuthFlowContext flow, TokenIssuanceContext context, RefreshTokenPersistence persistence, CancellationToken ct = default) + public async Task IssueRefreshTokenAsync(AuthFlowContext flow, TokenIssuanceContext context, RefreshTokenPersistence persistence, CancellationToken ct = default) { if (flow.EffectiveMode == UAuthMode.PureOpaque) return null; - var expires = _clock.UtcNow.Add(flow.OriginalOptions.Token.RefreshTokenLifetime); + if (context.SessionId is not AuthSessionId sessionId) + return null; + + var now = _clock.UtcNow; + var expires = now.Add(flow.OriginalOptions.Token.RefreshTokenLifetime); var raw = _opaqueGenerator.Generate(); var hash = _tokenHasher.Hash(raw); - if (context.SessionId is not AuthSessionId sessionId) - return null; - - var stored = new StoredRefreshToken - { - Tenant = flow.Tenant, - TokenHash = hash, - UserKey = context.UserKey, - SessionId = sessionId, - ChainId = context.ChainId, - IssuedAt = _clock.UtcNow, - ExpiresAt = expires - }; + var stored = RefreshToken.Create( + tokenId: TokenId.New(), + tokenHash: hash, + tenant: flow.Tenant, + userKey: context.UserKey, + sessionId: sessionId, + chainId: context.ChainId, + createdAt: now, + expiresAt: expires); if (persistence == RefreshTokenPersistence.Persist) { - await _refreshTokenStore.StoreAsync(flow.Tenant, stored, ct); + var store = _storeFactory.Create(flow.Tenant); + await store.StoreAsync(stored, ct); } - return new RefreshToken + return new RefreshTokenInfo { Token = raw, TokenHash = hash, diff --git a/src/CodeBeam.UltimateAuth.Server/Services/RefreshTokenRotationService.cs b/src/CodeBeam.UltimateAuth.Server/Services/RefreshTokenRotationService.cs index 73222294..92f0e6d6 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/RefreshTokenRotationService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/RefreshTokenRotationService.cs @@ -1,6 +1,7 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Server.Abstactions; using CodeBeam.UltimateAuth.Server.Auth; @@ -10,14 +11,14 @@ namespace CodeBeam.UltimateAuth.Server.Services; public sealed class RefreshTokenRotationService : IRefreshTokenRotationService { private readonly IRefreshTokenValidator _validator; - private readonly IRefreshTokenStore _store; + private readonly IRefreshTokenStoreFactory _storeFactory; private readonly ITokenIssuer _tokenIssuer; private readonly IClock _clock; - public RefreshTokenRotationService(IRefreshTokenValidator validator, IRefreshTokenStore store, ITokenIssuer tokenIssuer, IClock clock) + public RefreshTokenRotationService(IRefreshTokenValidator validator, IRefreshTokenStoreFactory storeFactory, ITokenIssuer tokenIssuer, IClock clock) { _validator = validator; - _store = store; + _storeFactory = storeFactory; _tokenIssuer = tokenIssuer; _clock = clock; } @@ -39,34 +40,30 @@ public async Task RotateAsync(AuthFlowContext flo if (!validation.IsValid) return new RefreshTokenRotationExecution() { Result = RefreshTokenRotationResult.Failed() }; + var store = _storeFactory.Create(validation.Tenant); + if (validation.IsReuseDetected) { if (validation.ChainId is not null) { - await _store.RevokeByChainAsync(validation.Tenant, validation.ChainId.Value, context.Now, ct); + await store.RevokeByChainAsync(validation.ChainId.Value, context.Now, ct); } else if (validation.SessionId is not null) { - await _store.RevokeBySessionAsync(validation.Tenant, validation.SessionId.Value, context.Now, ct); + await store.RevokeBySessionAsync(validation.SessionId.Value, context.Now, ct); } return new RefreshTokenRotationExecution() { Result = RefreshTokenRotationResult.Failed() }; } - if (validation.UserKey is not UserKey uKey) - { - throw new InvalidOperationException("Validated refresh token does not contain a UserKey."); - } + if (validation.UserKey is not UserKey userKey) + throw new UAuthValidationException("Validated refresh token does not contain a UserKey."); if (validation.SessionId is not AuthSessionId sessionId) - { - throw new InvalidOperationException("Validated refresh token does not contain a SessionId."); - } + throw new UAuthValidationException("Validated refresh token does not contain a SessionId."); if (validation.TokenHash == null) - { - throw new InvalidOperationException("Validated refresh token does not contain a hashed token."); - } + throw new UAuthValidationException("Validated refresh token does not contain a hashed token."); var tokenContext = new TokenIssuanceContext { @@ -74,7 +71,7 @@ public async Task RotateAsync(AuthFlowContext flo ? validation.Tenant : TenantKey.Single, - UserKey = uKey, + UserKey = userKey, SessionId = validation.SessionId, ChainId = validation.ChainId }; @@ -89,22 +86,27 @@ public async Task RotateAsync(AuthFlowContext flo }; // Never issue new refresh token before revoke old. Upperline doesn't persist token currently. - // TODO: Add _store.ExecuteAsync here to wrap RevokeAsync and StoreAsync - await _store.RevokeAsync(validation.Tenant, validation.TokenHash, context.Now, refreshToken.TokenHash, ct); - - var stored = new StoredRefreshToken + await store.ExecuteAsync(async ct2 => { - Tenant = flow.Tenant, - TokenHash = refreshToken.TokenHash, - UserKey = uKey, - SessionId = sessionId, - ChainId = validation.ChainId, - IssuedAt = _clock.UtcNow, - ExpiresAt = refreshToken.ExpiresAt - }; - await _store.StoreAsync(validation.Tenant, stored); + await store.RevokeAsync(validation.TokenHash, context.Now, refreshToken.TokenHash, ct2); + + var stored = RefreshToken.Create( + tokenId: TokenId.New(), + tokenHash: refreshToken.TokenHash, + tenant: validation.Tenant, + userKey: userKey, + sessionId: sessionId, + chainId: validation.ChainId, + createdAt: _clock.UtcNow, + expiresAt: refreshToken.ExpiresAt + ); + + await store.StoreAsync(stored, ct2); + + }, ct); + - return new RefreshTokenRotationExecution() + return new RefreshTokenRotationExecution { Tenant = validation.Tenant, UserKey = validation.UserKey, diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/UserRoleService.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/UserRoleService.cs index 5d987173..21f50c7b 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/UserRoleService.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/UserRoleService.cs @@ -2,6 +2,7 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Server.Infrastructure; namespace CodeBeam.UltimateAuth.Authorization.Reference; @@ -33,7 +34,7 @@ public async Task AssignAsync(AccessContext context, UserKey targetUserKey, stri var role = await _roles.GetByNameAsync(context.ResourceTenant, normalized, innerCt); if (role is null || role.IsDeleted) - throw new InvalidOperationException("role_not_found"); + throw new UAuthNotFoundException("role_not_found"); await _userRoles.AssignAsync(context.ResourceTenant, targetUserKey, role.Id, now, innerCt); }); diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization/Domain/Role.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization/Domain/Role.cs index b008663c..31f5a681 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization/Domain/Role.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization/Domain/Role.cs @@ -1,6 +1,7 @@ using CodeBeam.UltimateAuth.Authorization.Contracts; using CodeBeam.UltimateAuth.Authorization.Domain; using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Core.MultiTenancy; namespace CodeBeam.UltimateAuth.Authorization; @@ -35,7 +36,7 @@ public static Role Create( DateTimeOffset now) { if (string.IsNullOrWhiteSpace(name)) - throw new InvalidOperationException("role_name_required"); + throw new UAuthValidationException("role_name_required"); var normalized = Normalize(name); @@ -61,7 +62,7 @@ public static Role Create( public Role Rename(string newName, DateTimeOffset now) { if (string.IsNullOrWhiteSpace(newName)) - throw new InvalidOperationException("role_name_required"); + throw new UAuthValidationException("role_name_required"); if (NormalizedName == Normalize(newName)) return this; diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialSecurityState.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialSecurityState.cs index 27436d4a..04edc296 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialSecurityState.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialSecurityState.cs @@ -9,7 +9,7 @@ public sealed class CredentialSecurityState public Guid SecurityStamp { get; } public bool IsRevoked => RevokedAt != null; - public bool IsExpired => ExpiresAt != null; + public bool IsExpired(DateTimeOffset now) => ExpiresAt != null && ExpiresAt <= now; public CredentialSecurityState( DateTimeOffset? revokedAt = null, @@ -26,7 +26,7 @@ public CredentialSecurityStatus Status(DateTimeOffset now) if (RevokedAt is not null) return CredentialSecurityStatus.Revoked; - if (ExpiresAt is not null && ExpiresAt <= now) + if (IsExpired(now)) return CredentialSecurityStatus.Expired; return CredentialSecurityStatus.Active; diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Abstractions/CredentialUserMapping.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Abstractions/CredentialUserMapping.cs deleted file mode 100644 index 5b8773ff..00000000 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Abstractions/CredentialUserMapping.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore; - -internal sealed class CredentialUserMapping -{ - public Func UserId { get; init; } = default!; - public Func Username { get; init; } = default!; - public Func PasswordHash { get; init; } = default!; - public Func SecurityVersion { get; init; } = default!; - public Func CanAuthenticate { get; init; } = default!; -} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore.csproj b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore.csproj index 59c525b9..7cedc479 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore.csproj +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore.csproj @@ -25,6 +25,7 @@ + diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Configuration/ConventionResolver.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Configuration/ConventionResolver.cs deleted file mode 100644 index 08378ed6..00000000 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Configuration/ConventionResolver.cs +++ /dev/null @@ -1,23 +0,0 @@ -using System.Linq.Expressions; - -namespace CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore; - -internal static class ConventionResolver -{ - public static Expression>? TryResolve(params string[] names) - { - var prop = typeof(TUser) - .GetProperties() - .FirstOrDefault(p => - names.Contains(p.Name, StringComparer.OrdinalIgnoreCase) && - typeof(TProp).IsAssignableFrom(p.PropertyType)); - - if (prop is null) - return null; - - var param = Expression.Parameter(typeof(TUser), "u"); - var body = Expression.Property(param, prop); - - return Expression.Lambda>(body, param); - } -} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Configuration/CredentialUserMappingBuilder.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Configuration/CredentialUserMappingBuilder.cs deleted file mode 100644 index 4baa14c7..00000000 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Configuration/CredentialUserMappingBuilder.cs +++ /dev/null @@ -1,74 +0,0 @@ -namespace CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore; - -internal static class CredentialUserMappingBuilder -{ - public static CredentialUserMapping Build(CredentialUserMappingOptions options) - { - if (options.UserId is null) - { - var expr = ConventionResolver.TryResolve("Id", "UserId"); - if (expr != null) - options.ApplyUserId(expr); - } - - if (options.Username is null) - { - var expr = ConventionResolver.TryResolve( - "Username", - "UserName", - "Email", - "EmailAddress", - "Login"); - - if (expr != null) - options.ApplyUsername(expr); - } - - // Never add "Password" as a convention to avoid accidental mapping to plaintext password properties - if (options.PasswordHash is null) - { - var expr = ConventionResolver.TryResolve( - "PasswordHash", - "Passwordhash", - "PasswordHashV2"); - - if (expr != null) - options.ApplyPasswordHash(expr); - } - - if (options.SecurityVersion is null) - { - var expr = ConventionResolver.TryResolve( - "SecurityVersion", - "SecurityStamp", - "AuthVersion"); - - if (expr != null) - options.ApplySecurityVersion(expr); - } - - - if (options.UserId is null) - throw new InvalidOperationException("UserId mapping is required. Use MapUserId(...) or ensure a conventional property exists."); - - if (options.Username is null) - throw new InvalidOperationException("Username mapping is required. Use MapUsername(...) or ensure a conventional property exists."); - - if (options.PasswordHash is null) - throw new InvalidOperationException("PasswordHash mapping is required. Use MapPasswordHash(...) or ensure a conventional property exists."); - - if (options.SecurityVersion is null) - throw new InvalidOperationException("SecurityVersion mapping is required. Use MapSecurityVersion(...) or ensure a conventional property exists."); - - var canAuthenticateExpr = options.CanAuthenticate ?? (_ => true); - - return new CredentialUserMapping - { - UserId = options.UserId.Compile(), - Username = options.Username.Compile(), - PasswordHash = options.PasswordHash.Compile(), - SecurityVersion = options.SecurityVersion.Compile(), - CanAuthenticate = canAuthenticateExpr.Compile() - }; - } -} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Configuration/CredentialUserMappingOptions.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Configuration/CredentialUserMappingOptions.cs deleted file mode 100644 index b8c326fc..00000000 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Configuration/CredentialUserMappingOptions.cs +++ /dev/null @@ -1,29 +0,0 @@ -using System.Linq.Expressions; - -namespace CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore; - -public sealed class CredentialUserMappingOptions -{ - internal Expression>? UserId { get; private set; } - internal Expression>? Username { get; private set; } - internal Expression>? PasswordHash { get; private set; } - internal Expression>? SecurityVersion { get; private set; } - internal Expression>? CanAuthenticate { get; private set; } - - public void MapUserId(Expression> expr) => UserId = expr; - public void MapUsername(Expression> expr) => Username = expr; - public void MapPasswordHash(Expression> expr) => PasswordHash = expr; - public void MapSecurityVersion(Expression> expr) => SecurityVersion = expr; - - /// - /// Optional. If not specified, all users are allowed to authenticate. - /// Use this to enforce custom user state rules (e.g. Active, Locked, Suspended). - /// Users that can't authenticate don't show up in authentication results. - /// - public void MapCanAuthenticate(Expression> expr) => CanAuthenticate = expr; - - internal void ApplyUserId(Expression> expr) => UserId = expr; - internal void ApplyUsername(Expression> expr) => Username = expr; - internal void ApplyPasswordHash(Expression> expr) => PasswordHash = expr; - internal void ApplySecurityVersion(Expression> expr) => SecurityVersion = expr; -} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Data/UAuthCredentialDbContext.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Data/UAuthCredentialDbContext.cs new file mode 100644 index 00000000..ed530b36 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Data/UAuthCredentialDbContext.cs @@ -0,0 +1,70 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; + +namespace CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore; + +internal sealed class UAuthCredentialDbContext : DbContext +{ + public DbSet PasswordCredentials => Set(); + + private readonly TenantContext _tenant; + + public UAuthCredentialDbContext(DbContextOptions options, TenantContext tenant) + : base(options) + { + _tenant = tenant; + } + + protected override void OnModelCreating(ModelBuilder b) + { + ConfigurePasswordCredential(b); + } + + private void ConfigurePasswordCredential(ModelBuilder b) + { + b.Entity(e => + { + e.HasKey(x => x.Id); + + e.Property(x => x.Version).IsConcurrencyToken(); + + e.Property(x => x.Tenant) + .HasConversion( + v => v.Value, + v => TenantKey.FromInternal(v)) + .HasMaxLength(128) + .IsRequired(); + + e.Property(x => x.UserKey) + .HasConversion( + v => v.Value, + v => UserKey.FromString(v)) + .HasMaxLength(128) + .IsRequired(); + + e.Property(x => x.SecretHash) + .HasMaxLength(512) + .IsRequired(); + + e.Property(x => x.SecurityStamp).IsRequired(); + e.Property(x => x.RevokedAt); + e.Property(x => x.ExpiresAt); + e.Property(x => x.LastUsedAt); + e.Property(x => x.Source).HasMaxLength(128); + e.Property(x => x.CreatedAt).IsRequired(); + e.Property(x => x.UpdatedAt); + e.Property(x => x.DeletedAt); + + e.HasIndex(x => new { x.Tenant, x.Id }).IsUnique(); + e.HasIndex(x => new { x.Tenant, x.UserKey }); + e.HasIndex(x => new { x.Tenant, x.UserKey, x.DeletedAt }); + e.HasIndex(x => new { x.Tenant, x.RevokedAt }); + e.HasIndex(x => new { x.Tenant, x.ExpiresAt }); + + e.HasQueryFilter(x => _tenant.IsGlobal || x.Tenant == _tenant.Tenant); + }); + } +} \ No newline at end of file diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/EfCoreAuthUser.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/EfCoreAuthUser.cs deleted file mode 100644 index 61a8b904..00000000 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/EfCoreAuthUser.cs +++ /dev/null @@ -1,15 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; - -namespace CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore; - -internal sealed class EfCoreAuthUser : IAuthSubject -{ - public TUserId UserId { get; } - - IReadOnlyDictionary? IAuthSubject.Claims => null; - - public EfCoreAuthUser(TUserId userId) - { - UserId = userId; - } -} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..e235a2a1 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,15 @@ +using CodeBeam.UltimateAuth.Credentials.Reference; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddUltimateAuthEntityFrameworkCoreCredentials(this IServiceCollection services, Action configureDb) + { + services.AddDbContextPool(configureDb); + services.AddScoped(); + return services; + } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Mappers/PasswordCredentialProjectionMapper.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Mappers/PasswordCredentialProjectionMapper.cs new file mode 100644 index 00000000..8fbf3854 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Mappers/PasswordCredentialProjectionMapper.cs @@ -0,0 +1,67 @@ +using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Credentials.Reference; + +namespace CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore; + +internal static class PasswordCredentialProjectionMapper +{ + public static PasswordCredential ToDomain(this PasswordCredentialProjection p) + { + var security = new CredentialSecurityState( + revokedAt: p.RevokedAt, + expiresAt: p.ExpiresAt, + securityStamp: p.SecurityStamp); + + var metadata = new CredentialMetadata + { + LastUsedAt = p.LastUsedAt, + Source = p.Source + }; + + return PasswordCredential.Create( + id: p.Id, + tenant: p.Tenant, + userKey: p.UserKey, + secretHash: p.SecretHash, + security: security, + metadata: metadata, + now: p.CreatedAt + ); + } + public static PasswordCredentialProjection ToProjection(this PasswordCredential c) + { + return new PasswordCredentialProjection + { + Id = c.Id, + Tenant = c.Tenant, + UserKey = c.UserKey, + SecretHash = c.SecretHash, + + RevokedAt = c.Security.RevokedAt, + ExpiresAt = c.Security.ExpiresAt, + SecurityStamp = c.Security.SecurityStamp, + + LastUsedAt = c.Metadata.LastUsedAt, + Source = c.Metadata.Source, + + CreatedAt = c.CreatedAt, + UpdatedAt = c.UpdatedAt, + DeletedAt = c.DeletedAt, + Version = c.Version + }; + } + + public static void UpdateProjection(this PasswordCredential c, PasswordCredentialProjection p) + { + p.SecretHash = c.SecretHash; + + p.RevokedAt = c.Security.RevokedAt; + p.ExpiresAt = c.Security.ExpiresAt; + p.SecurityStamp = c.Security.SecurityStamp; + + p.LastUsedAt = c.Metadata.LastUsedAt; + p.Source = c.Metadata.Source; + + p.UpdatedAt = c.UpdatedAt; + } +} \ No newline at end of file diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Projections/PasswordCredentialProjection.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Projections/PasswordCredentialProjection.cs new file mode 100644 index 00000000..d25e8edc --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Projections/PasswordCredentialProjection.cs @@ -0,0 +1,33 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore; + +internal sealed class PasswordCredentialProjection +{ + public Guid Id { get; set; } + + public TenantKey Tenant { get; set; } + + public UserKey UserKey { get; set; } + + public string SecretHash { get; set; } = default!; + + public DateTimeOffset? RevokedAt { get; set; } + + public DateTimeOffset? ExpiresAt { get; set; } + + public Guid SecurityStamp { get; set; } + + public DateTimeOffset? LastUsedAt { get; set; } + + public string? Source { get; set; } + + public DateTimeOffset CreatedAt { get; set; } + + public DateTimeOffset? UpdatedAt { get; set; } + + public DateTimeOffset? DeletedAt { get; set; } + + public long Version { get; set; } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/ServiceCollectionExtensions.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/ServiceCollectionExtensions.cs deleted file mode 100644 index 97442ecb..00000000 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/ServiceCollectionExtensions.cs +++ /dev/null @@ -1,13 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; - -namespace CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore; - -public static class ServiceCollectionExtensions -{ - public static IServiceCollection AddUltimateAuthEfCoreCredentials(this IServiceCollection services, Action> configure) where TUser : class - { - services.Configure(configure); - - return services; - } -} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Stores/EfCorePasswordCredentialStore.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Stores/EfCorePasswordCredentialStore.cs new file mode 100644 index 00000000..ae1fe5f0 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Stores/EfCorePasswordCredentialStore.cs @@ -0,0 +1,146 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Credentials.Reference; +using Microsoft.EntityFrameworkCore; + +namespace CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore; + +internal sealed class EfCorePasswordCredentialStore : IPasswordCredentialStore +{ + private readonly UAuthCredentialDbContext _db; + private readonly TenantContext _tenant; + + public EfCorePasswordCredentialStore(UAuthCredentialDbContext db, TenantContext tenant) + { + _db = db; + _tenant = tenant; + } + + public async Task ExistsAsync(CredentialKey key, CancellationToken ct = default) + { + return await _db.PasswordCredentials + .AnyAsync(x => + x.Id == key.Id && + x.Tenant == key.Tenant, + ct); + } + + public async Task AddAsync(PasswordCredential credential, CancellationToken ct = default) + { + var entity = credential.ToProjection(); + _db.PasswordCredentials.Add(entity); + await _db.SaveChangesAsync(ct); + } + + public async Task GetAsync(CredentialKey key, CancellationToken ct = default) + { + var entity = await _db.PasswordCredentials + .AsNoTracking() + .SingleOrDefaultAsync( + x => x.Id == key.Id && + x.Tenant == key.Tenant, + ct); + + return entity?.ToDomain(); + } + + public async Task SaveAsync(PasswordCredential credential, long expectedVersion, CancellationToken ct = default) + { + var entity = await _db.PasswordCredentials + .SingleOrDefaultAsync(x => + x.Id == credential.Id && + x.Tenant == credential.Tenant, + ct); + + if (entity is null) + throw new UAuthNotFoundException("credential_not_found"); + + if (entity.Version != expectedVersion) + throw new UAuthConcurrencyException("credential_version_conflict"); + + credential.UpdateProjection(entity); + entity.Version++; + await _db.SaveChangesAsync(ct); + } + + public async Task RevokeAsync(CredentialKey key, DateTimeOffset revokedAt, long expectedVersion, CancellationToken ct = default) + { + var credential = await GetAsync(key, ct); + + if (credential is null) + throw new UAuthNotFoundException("credential_not_found"); + + var revoked = credential.Revoke(revokedAt); + await SaveAsync(revoked, expectedVersion, ct); + } + + public async Task DeleteAsync(CredentialKey key, long expectedVersion, DeleteMode mode, DateTimeOffset now, CancellationToken ct = default) + { + var entity = await _db.PasswordCredentials + .SingleOrDefaultAsync(x => + x.Id == key.Id && + x.Tenant == key.Tenant, + ct); + + if (entity is null) + throw new UAuthNotFoundException("credential_not_found"); + + if (entity.Version != expectedVersion) + throw new UAuthConcurrencyException("credential_version_conflict"); + + if (mode == DeleteMode.Hard) + { + _db.PasswordCredentials.Remove(entity); + } + else + { + entity.DeletedAt = now; + entity.Version++; + } + + await _db.SaveChangesAsync(ct); + } + + public async Task> GetByUserAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default) + { + var entities = await _db.PasswordCredentials + .AsNoTracking() + .Where(x => + x.Tenant == tenant && + x.UserKey == userKey && + x.DeletedAt == null) + .ToListAsync(ct); + + return entities + .Select(x => x.ToDomain()) + .ToList() + .AsReadOnly(); + } + + public async Task DeleteByUserAsync(TenantKey tenant, UserKey userKey, DeleteMode mode, DateTimeOffset now, CancellationToken ct = default) + { + var entities = await _db.PasswordCredentials + .Where(x => + x.Tenant == tenant && + x.UserKey == userKey) + .ToListAsync(ct); + + foreach (var entity in entities) + { + if (mode == DeleteMode.Hard) + { + _db.PasswordCredentials.Remove(entity); + } + else + { + entity.DeletedAt = now; + entity.Version++; + } + } + + await _db.SaveChangesAsync(ct); + } +} \ No newline at end of file diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialSeedContributor.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialSeedContributor.cs index e4ef841f..ed3e2ade 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialSeedContributor.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialSeedContributor.cs @@ -14,12 +14,12 @@ internal sealed class InMemoryCredentialSeedContributor : ISeedContributor private static readonly Guid _userPasswordId = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); public int Order => 10; - private readonly ICredentialStore _credentials; + private readonly IPasswordCredentialStore _credentials; private readonly IInMemoryUserIdProvider _ids; private readonly IUAuthPasswordHasher _hasher; private readonly IClock _clock; - public InMemoryCredentialSeedContributor(ICredentialStore credentials, IInMemoryUserIdProvider ids, IUAuthPasswordHasher hasher, IClock clock) + public InMemoryCredentialSeedContributor(IPasswordCredentialStore credentials, IInMemoryUserIdProvider ids, IUAuthPasswordHasher hasher, IClock clock) { _credentials = credentials; _ids = ids; diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialStore.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialStore.cs deleted file mode 100644 index 65d6f39c..00000000 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialStore.cs +++ /dev/null @@ -1,87 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Core.Errors; -using CodeBeam.UltimateAuth.Core.MultiTenancy; -using CodeBeam.UltimateAuth.Credentials.Contracts; -using CodeBeam.UltimateAuth.Credentials.Reference; - -namespace CodeBeam.UltimateAuth.Credentials.InMemory; - -internal sealed class InMemoryCredentialStore : InMemoryVersionedStore, ICredentialStore -{ - protected override CredentialKey GetKey(PasswordCredential entity) => new(entity.Tenant, entity.Id); - - public Task> GetByUserAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default) - { - ct.ThrowIfCancellationRequested(); - - var result = Values() - .Where(c => c.Tenant == tenant && c.UserKey == userKey) - .Cast() - .ToArray(); - - return Task.FromResult>(result); - } - - public Task GetByIdAsync(CredentialKey key, CancellationToken ct = default) - { - ct.ThrowIfCancellationRequested(); - - if (TryGet(key, out var entity)) - return Task.FromResult(entity); - - return Task.FromResult(entity); - } - - public Task AddAsync(ICredential credential, CancellationToken ct = default) - { - // TODO: Implement other credential types - if (credential is not PasswordCredential pwd) - throw new NotSupportedException("Only password credentials are supported in-memory."); - - return base.AddAsync(pwd, ct); - } - - public Task SaveAsync(ICredential credential, long expectedVersion, CancellationToken ct = default) - { - if (credential is not PasswordCredential pwd) - throw new NotSupportedException("Only password credentials are supported in-memory."); - - return base.SaveAsync(pwd, expectedVersion, ct); - } - - public Task RevokeAsync(CredentialKey key, DateTimeOffset revokedAt, long expectedVersion, CancellationToken ct = default) - { - ct.ThrowIfCancellationRequested(); - - if (!TryGet(key, out var credential)) - throw new UAuthNotFoundException("credential_not_found"); - - if (credential is not PasswordCredential pwd) - throw new NotSupportedException("Only password credentials are supported in-memory."); - - var revoked = pwd.Revoke(revokedAt); - - return SaveAsync(revoked, expectedVersion, ct); - } - - public Task DeleteAsync(CredentialKey key, DeleteMode mode, DateTimeOffset now, long expectedVersion, CancellationToken ct = default) - { - return base.DeleteAsync(key, expectedVersion, mode, now, ct); - } - - public async Task DeleteByUserAsync(TenantKey tenant, UserKey userKey, DeleteMode mode, DateTimeOffset now, CancellationToken ct = default) - { - ct.ThrowIfCancellationRequested(); - - var credentials = Values() - .Where(c => c.Tenant == tenant && c.UserKey == userKey) - .ToList(); - - foreach (var credential in credentials) - { - await DeleteAsync(new CredentialKey(tenant, credential.Id), mode, now, credential.Version, ct); - } - } -} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryPasswordCredentialState.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryPasswordCredentialState.cs deleted file mode 100644 index 37be6cb4..00000000 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryPasswordCredentialState.cs +++ /dev/null @@ -1,15 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Credentials.Contracts; - -namespace CodeBeam.UltimateAuth.Credentials.InMemory; - -internal sealed class InMemoryPasswordCredentialState -{ - public UserKey UserKey { get; init; } = default!; - public CredentialType Type { get; } = CredentialType.Password; - - public string SecretHash { get; set; } = default!; - - public CredentialSecurityState Security { get; set; } = default!; - public CredentialMetadata Metadata { get; set; } = default!; -} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryPasswordCredentialStore.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryPasswordCredentialStore.cs new file mode 100644 index 00000000..61f22f38 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryPasswordCredentialStore.cs @@ -0,0 +1,64 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Credentials.Reference; + +namespace CodeBeam.UltimateAuth.Credentials.InMemory; + +internal sealed class InMemoryPasswordCredentialStore : InMemoryVersionedStore, IPasswordCredentialStore +{ + protected override CredentialKey GetKey(PasswordCredential entity) + => new(entity.Tenant, entity.Id); + + protected override void BeforeAdd(PasswordCredential entity) + { + var exists = Values() + .Any(x => + x.Tenant == entity.Tenant && + x.UserKey == entity.UserKey && + !x.IsDeleted); + + if (exists) + throw new UAuthConflictException("password_credential_exists"); + } + + public Task> GetByUserAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var result = Values() + .Where(x => + x.Tenant == tenant && + x.UserKey == userKey && + !x.IsDeleted) + .Select(x => x.Snapshot()) + .ToList() + .AsReadOnly(); + + return Task.FromResult>(result); + } + + public Task RevokeAsync(CredentialKey key, DateTimeOffset revokedAt, long expectedVersion, CancellationToken ct = default) + { + if (!TryGet(key, out var credential) || credential is null) + throw new UAuthNotFoundException("credential_not_found"); + + var revoked = credential.Revoke(revokedAt); + return SaveAsync(revoked, expectedVersion, ct); + } + + public async Task DeleteByUserAsync(TenantKey tenant, UserKey userKey, DeleteMode mode, DateTimeOffset now, CancellationToken ct = default) + { + var credentials = Values() + .Where(c => c.Tenant == tenant && c.UserKey == userKey) + .ToList(); + + foreach (var credential in credentials) + { + await DeleteAsync(new CredentialKey(tenant, credential.Id), credential.Version, mode, now, ct); + } + } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/ServiceCollectionExtensions.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/ServiceCollectionExtensions.cs index c078aca1..86118255 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/ServiceCollectionExtensions.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/ServiceCollectionExtensions.cs @@ -1,4 +1,5 @@ using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Credentials.Reference; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -8,8 +9,8 @@ public static class ServiceCollectionExtensions { public static IServiceCollection AddUltimateAuthCredentialsInMemory(this IServiceCollection services) { - services.TryAddScoped(); - services.TryAddSingleton(); + services.TryAddScoped(); + services.TryAddSingleton(); // Never try add seed services.AddSingleton(); diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Domain/PasswordCredential.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Domain/PasswordCredential.cs index 9650be6c..ee5c9c64 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Domain/PasswordCredential.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Domain/PasswordCredential.cs @@ -6,13 +6,14 @@ namespace CodeBeam.UltimateAuth.Credentials.Reference; -public sealed class PasswordCredential : ISecretCredential, ICredentialDescriptor, IVersionedEntity, IEntitySnapshot, ISoftDeletable +public sealed class PasswordCredential : ISecretCredential, IVersionedEntity, IEntitySnapshot, ISoftDeletable { public Guid Id { get; init; } public TenantKey Tenant { get; init; } public UserKey UserKey { get; init; } public CredentialType Type => CredentialType.Password; + // TODO: Add hash algorithm (PasswordHash object with hash and algorithm properties) public string SecretHash { get; private set; } = default!; public CredentialSecurityState Security { get; private set; } = CredentialSecurityState.Active(); public CredentialMetadata Metadata { get; private set; } = new CredentialMetadata(); diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Extensions/ServiceCollectionExtensions.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Extensions/ServiceCollectionExtensions.cs index 12497321..2ee133b7 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Extensions/ServiceCollectionExtensions.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Extensions/ServiceCollectionExtensions.cs @@ -14,7 +14,7 @@ public static IServiceCollection AddUltimateAuthCredentialsReference(this IServi services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); - + services.AddScoped(); return services; } } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/IPasswordCredentialStore.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/IPasswordCredentialStore.cs new file mode 100644 index 00000000..6bdb1d05 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/IPasswordCredentialStore.cs @@ -0,0 +1,14 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Credentials.Contracts; + +namespace CodeBeam.UltimateAuth.Credentials.Reference; + +public interface IPasswordCredentialStore : IVersionedStore +{ + Task> GetByUserAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default); + Task DeleteByUserAsync(TenantKey tenant, UserKey userKey, DeleteMode mode, DateTimeOffset now, CancellationToken ct = default); + Task RevokeAsync(CredentialKey key, DateTimeOffset revokedAt, long expectedVersion, CancellationToken ct = default); +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Infrastructure/PasswordCredentialProvider.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Infrastructure/PasswordCredentialProvider.cs new file mode 100644 index 00000000..cfe691bb --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Infrastructure/PasswordCredentialProvider.cs @@ -0,0 +1,30 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Credentials.Reference; + +internal sealed class PasswordCredentialProvider : ICredentialProvider +{ + private readonly IPasswordCredentialStore _store; + private readonly ICredentialValidator _validator; + + public CredentialType Type => CredentialType.Password; + + public PasswordCredentialProvider(IPasswordCredentialStore store, ICredentialValidator validator) + { + _store = store; + _validator = validator; + } + + public async Task> GetByUserAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default) + { + var creds = await _store.GetByUserAsync(tenant, userKey, ct); + return creds.Cast().ToList(); + } + + public async Task ValidateAsync(ICredential credential, string secret, CancellationToken ct = default) + { + var result = await _validator.ValidateAsync(credential, secret, ct); + return result.IsValid; + } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Infrastructure/PasswordUserLifecycleIntegration.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Infrastructure/PasswordUserLifecycleIntegration.cs index efd94263..039559f7 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Infrastructure/PasswordUserLifecycleIntegration.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Infrastructure/PasswordUserLifecycleIntegration.cs @@ -10,11 +10,11 @@ namespace CodeBeam.UltimateAuth.Credentials.Reference; internal sealed class PasswordUserLifecycleIntegration : IUserLifecycleIntegration { - private readonly ICredentialStore _credentialStore; + private readonly IPasswordCredentialStore _credentialStore; private readonly IUAuthPasswordHasher _passwordHasher; private readonly IClock _clock; - public PasswordUserLifecycleIntegration(ICredentialStore credentialStore, IUAuthPasswordHasher passwordHasher, IClock clock) + public PasswordUserLifecycleIntegration(IPasswordCredentialStore credentialStore, IUAuthPasswordHasher passwordHasher, IClock clock) { _credentialStore = credentialStore; _passwordHasher = passwordHasher; diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs index eaaa8a65..df782936 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs @@ -17,7 +17,7 @@ namespace CodeBeam.UltimateAuth.Credentials.Reference; internal sealed class CredentialManagementService : ICredentialManagementService, IUserCredentialsInternalService { private readonly IAccessOrchestrator _accessOrchestrator; - private readonly ICredentialStore _credentials; + private readonly IPasswordCredentialStore _credentials; private readonly IAuthenticationSecurityManager _authenticationSecurityManager; private readonly IOpaqueTokenGenerator _tokenGenerator; private readonly INumericCodeGenerator _numericCodeGenerator; @@ -30,7 +30,7 @@ internal sealed class CredentialManagementService : ICredentialManagementService public CredentialManagementService( IAccessOrchestrator accessOrchestrator, - ICredentialStore credentials, + IPasswordCredentialStore credentials, IAuthenticationSecurityManager authenticationSecurityManager, IOpaqueTokenGenerator tokenGenerator, INumericCodeGenerator numericCodeGenerator, @@ -66,7 +66,6 @@ public async Task GetAllAsync(AccessContext context, Cance var credentials = await _credentials.GetByUserAsync(context.ResourceTenant, subjectUser, innerCt); var dtos = credentials - .OfType() .Select(c => new CredentialInfo { Id = c.Id, @@ -174,7 +173,7 @@ public async Task RevokeAsync(AccessContext context, Rev var subjectUser = context.GetTargetUserKey(); var now = _clock.UtcNow; - var credential = await _credentials.GetByIdAsync(new CredentialKey(context.ResourceTenant, request.Id), innerCt); + var credential = await _credentials.GetAsync(new CredentialKey(context.ResourceTenant, request.Id), innerCt); if (credential is not PasswordCredential pwd) return CredentialActionResult.Fail("credential_not_found"); @@ -352,7 +351,7 @@ public async Task DeleteAsync(AccessContext context, Del var subjectUser = context.GetTargetUserKey(); var now = _clock.UtcNow; - var credential = await _credentials.GetByIdAsync(new CredentialKey(context.ResourceTenant, request.Id), innerCt); + var credential = await _credentials.GetAsync(new CredentialKey(context.ResourceTenant, request.Id), innerCt); if (credential is not PasswordCredential pwd) return CredentialActionResult.Fail("credential_not_found"); @@ -361,7 +360,7 @@ public async Task DeleteAsync(AccessContext context, Del return CredentialActionResult.Fail("credential_not_found"); var oldVersion = pwd.Version; - await _credentials.DeleteAsync(new CredentialKey(context.ResourceTenant, pwd.Id), request.Mode, now, oldVersion, innerCt); + await _credentials.DeleteAsync(new CredentialKey(context.ResourceTenant, pwd.Id), oldVersion, request.Mode, now, innerCt); return CredentialActionResult.Success(); }); diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredential.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredential.cs index a9cdc74b..71b81e12 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredential.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredential.cs @@ -1,12 +1,24 @@ using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Credentials.Contracts; namespace CodeBeam.UltimateAuth.Credentials; public interface ICredential { Guid Id { get; } - TenantKey Tenant { get; init; } - UserKey UserKey { get; init; } + TenantKey Tenant { get; } + UserKey UserKey { get; } CredentialType Type { get; } + + CredentialSecurityState Security { get; } + CredentialMetadata Metadata { get; } + + DateTimeOffset CreatedAt { get; } + DateTimeOffset? UpdatedAt { get; } + DateTimeOffset? DeletedAt { get; } + + long Version { get; } + + bool IsDeleted { get; } } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialDescriptor.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialDescriptor.cs deleted file mode 100644 index bc513d3a..00000000 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialDescriptor.cs +++ /dev/null @@ -1,13 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Credentials.Contracts; - -namespace CodeBeam.UltimateAuth.Credentials; - -public interface ICredentialDescriptor -{ - Guid Id { get; } - CredentialType Type { get; } - CredentialSecurityState Security { get; } - CredentialMetadata Metadata { get; } - long Version { get; } -} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialProvider.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialProvider.cs new file mode 100644 index 00000000..171a7805 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialProvider.cs @@ -0,0 +1,13 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Credentials; + +public interface ICredentialProvider +{ + CredentialType Type { get; } + + Task> GetByUserAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default); + + Task ValidateAsync(ICredential credential, string secret, CancellationToken ct = default); +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialStore.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialStore.cs index 1e2f3b86..36daf785 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialStore.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialStore.cs @@ -1,17 +1,18 @@ -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Core.MultiTenancy; -using CodeBeam.UltimateAuth.Credentials.Contracts; +//using CodeBeam.UltimateAuth.Core.Abstractions; +//using CodeBeam.UltimateAuth.Core.Contracts; +//using CodeBeam.UltimateAuth.Core.Domain; +//using CodeBeam.UltimateAuth.Core.MultiTenancy; +//using CodeBeam.UltimateAuth.Credentials.Contracts; -namespace CodeBeam.UltimateAuth.Credentials; +//namespace CodeBeam.UltimateAuth.Credentials; -public interface ICredentialStore -{ - Task>GetByUserAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default); - Task GetByIdAsync(CredentialKey key, CancellationToken ct = default); - Task AddAsync(ICredential credential, CancellationToken ct = default); - Task SaveAsync(ICredential credential, long expectedVersion, CancellationToken ct = default); - Task RevokeAsync(CredentialKey key, DateTimeOffset revokedAt, long expectedVersion, CancellationToken ct = default); - Task DeleteAsync(CredentialKey key, DeleteMode mode, DateTimeOffset now, long expectedVersion, CancellationToken ct = default); - Task DeleteByUserAsync(TenantKey tenant, UserKey userKey, DeleteMode mode, DateTimeOffset now, CancellationToken ct = default); -} +//public interface ICredentialStore : IVersionedStore +//{ +// Task> GetByUserAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default); + +// Task GetByIdAsync(CredentialKey key, CancellationToken ct = default); + +// Task RevokeAsync(CredentialKey key, DateTimeOffset revokedAt, long expectedVersion, CancellationToken ct = default); + +// Task DeleteByUserAsync(TenantKey tenant, UserKey userKey, DeleteMode mode, DateTimeOffset now, CancellationToken ct = default); +//} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/Infrastructure/CredentialValidator.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials/Infrastructure/CredentialValidator.cs index ba61b1a8..e55164ab 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials/Infrastructure/CredentialValidator.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/Infrastructure/CredentialValidator.cs @@ -18,7 +18,7 @@ public Task ValidateAsync(ICredential credential, st { ct.ThrowIfCancellationRequested(); - if (credential is ICredentialDescriptor securable) + if (credential is ICredential securable) { if (!securable.Security.IsUsable(_clock.UtcNow)) { diff --git a/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions.csproj b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions.csproj new file mode 100644 index 00000000..4c065564 --- /dev/null +++ b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions.csproj @@ -0,0 +1,28 @@ + + + + net8.0;net9.0;net10.0 + enable + enable + true + $(NoWarn);1591 + + + + + + + + + + + + + + + + + + + + diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Infrastructure/AuthSessionIdEfConverter.cs b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/Infrastructure/AuthSessionIdEfConverter.cs similarity index 84% rename from src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Infrastructure/AuthSessionIdEfConverter.cs rename to src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/Infrastructure/AuthSessionIdEfConverter.cs index 60c6a5b1..dd6adbd5 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Infrastructure/AuthSessionIdEfConverter.cs +++ b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/Infrastructure/AuthSessionIdEfConverter.cs @@ -1,6 +1,6 @@ using CodeBeam.UltimateAuth.Core.Domain; -namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore; +namespace CodeBeam.UltimateAuth.EntityFrameworkCore; internal static class AuthSessionIdEfConverter { @@ -14,8 +14,7 @@ public static AuthSessionId FromDatabase(string raw) return id; } - public static string ToDatabase(AuthSessionId id) - => id.Value; + public static string ToDatabase(AuthSessionId id) => id.Value; public static AuthSessionId? FromDatabaseNullable(string? raw) { diff --git a/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/Infrastructure/JsonSerializeWrapper.cs b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/Infrastructure/JsonSerializeWrapper.cs new file mode 100644 index 00000000..52bb84c2 --- /dev/null +++ b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/Infrastructure/JsonSerializeWrapper.cs @@ -0,0 +1,28 @@ +using System.Text.Json; + +namespace CodeBeam.UltimateAuth.EntityFrameworkCore; + +internal static class JsonSerializerWrapper +{ + private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web); + + public static string Serialize(T value) + { + return JsonSerializer.Serialize(value, Options); + } + + public static T Deserialize(string json) + { + return JsonSerializer.Deserialize(json, Options)!; + } + + public static string? SerializeNullable(T? value) + { + return value is null ? null : JsonSerializer.Serialize(value, Options); + } + + public static T? DeserializeNullable(string? json) + { + return json is null ? default : JsonSerializer.Deserialize(json, Options); + } +} diff --git a/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/Infrastructure/JsonValueComparers.cs b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/Infrastructure/JsonValueComparers.cs new file mode 100644 index 00000000..829e1700 --- /dev/null +++ b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/Infrastructure/JsonValueComparers.cs @@ -0,0 +1,64 @@ +using Microsoft.EntityFrameworkCore.ChangeTracking; + +namespace CodeBeam.UltimateAuth.EntityFrameworkCore; + +internal static class JsonValueComparerHelpers +{ + public static bool AreEqual(T left, T right) + { + return string.Equals( + JsonSerializerWrapper.Serialize(left), + JsonSerializerWrapper.Serialize(right), + StringComparison.Ordinal); + } + + public static int GetHashCodeSafe(T value) + { + return JsonSerializerWrapper.Serialize(value).GetHashCode(); + } + + public static T Snapshot(T value) + { + var json = JsonSerializerWrapper.Serialize(value); + return JsonSerializerWrapper.Deserialize(json); + } + + public static bool AreEqualNullable(T? left, T? right) + { + return string.Equals( + JsonSerializerWrapper.SerializeNullable(left), + JsonSerializerWrapper.SerializeNullable(right), + StringComparison.Ordinal); + } + + public static int GetHashCodeSafeNullable(T? value) + { + var json = JsonSerializerWrapper.SerializeNullable(value); + return json == null ? 0 : json.GetHashCode(); + } + + public static T? SnapshotNullable(T? value) + { + var json = JsonSerializerWrapper.SerializeNullable(value); + return json == null ? default : JsonSerializerWrapper.Deserialize(json); + } +} + +public static class JsonValueComparers +{ + public static ValueComparer Create() + { + return new ValueComparer( + (l, r) => JsonValueComparerHelpers.AreEqual(l, r), + v => JsonValueComparerHelpers.GetHashCodeSafe(v), + v => JsonValueComparerHelpers.Snapshot(v)); + } + + public static ValueComparer CreateNullable() + { + return new ValueComparer( + (l, r) => JsonValueComparerHelpers.AreEqualNullable(l, r), + v => JsonValueComparerHelpers.GetHashCodeSafeNullable(v), + v => JsonValueComparerHelpers.SnapshotNullable(v)); + } +} \ No newline at end of file diff --git a/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/Infrastructure/JsonValueConverter.cs b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/Infrastructure/JsonValueConverter.cs new file mode 100644 index 00000000..44d6293d --- /dev/null +++ b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/Infrastructure/JsonValueConverter.cs @@ -0,0 +1,18 @@ +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using System.Text.Json; + +namespace CodeBeam.UltimateAuth.EntityFrameworkCore; + +public sealed class JsonValueConverter : ValueConverter +{ + private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web); + + public JsonValueConverter() + : base(v => Serialize(v), v => Deserialize(v)) + { + } + + private static string Serialize(T value) => JsonSerializer.Serialize(value, Options); + + private static T Deserialize(string json) => JsonSerializer.Deserialize(json, Options)!; +} diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Infrastructure/NullableAuthSessionIdConverter.cs b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/Infrastructure/NullableAuthSessionIdConverter.cs similarity index 64% rename from src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Infrastructure/NullableAuthSessionIdConverter.cs rename to src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/Infrastructure/NullableAuthSessionIdConverter.cs index 099a69cc..7972f21a 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Infrastructure/NullableAuthSessionIdConverter.cs +++ b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/Infrastructure/NullableAuthSessionIdConverter.cs @@ -1,16 +1,16 @@ using CodeBeam.UltimateAuth.Core.Domain; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore; +namespace CodeBeam.UltimateAuth.EntityFrameworkCore; -internal sealed class AuthSessionIdConverter : ValueConverter +public sealed class AuthSessionIdConverter : ValueConverter { public AuthSessionIdConverter() : base(id => AuthSessionIdEfConverter.ToDatabase(id), raw => AuthSessionIdEfConverter.FromDatabase(raw)) { } } -internal sealed class NullableAuthSessionIdConverter : ValueConverter +public sealed class NullableAuthSessionIdConverter : ValueConverter { public NullableAuthSessionIdConverter() : base(id => AuthSessionIdEfConverter.ToDatabaseNullable(id), raw => AuthSessionIdEfConverter.FromDatabaseNullable(raw)) { diff --git a/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/Infrastructure/NullableJsonValueConverter.cs b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/Infrastructure/NullableJsonValueConverter.cs new file mode 100644 index 00000000..f1ac4d05 --- /dev/null +++ b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/Infrastructure/NullableJsonValueConverter.cs @@ -0,0 +1,18 @@ +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using System.Text.Json; + +namespace CodeBeam.UltimateAuth.EntityFrameworkCore; + +public sealed class NullableJsonValueConverter : ValueConverter +{ + private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web); + + public NullableJsonValueConverter() + : base(v => Serialize(v), v => Deserialize(v)) + { + } + + private static string? Serialize(T? value) => value == null ? null : JsonSerializer.Serialize(value, Options); + + private static T? Deserialize(string? json) => json == null ? default : JsonSerializer.Deserialize(json, Options); +} diff --git a/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/Infrastructure/NullableSessionChainIdConverter.cs b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/Infrastructure/NullableSessionChainIdConverter.cs new file mode 100644 index 00000000..34e22b2e --- /dev/null +++ b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/Infrastructure/NullableSessionChainIdConverter.cs @@ -0,0 +1,14 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace CodeBeam.UltimateAuth.EntityFrameworkCore; + +public sealed class NullableSessionChainIdConverter : ValueConverter +{ + public NullableSessionChainIdConverter() + : base( + id => SessionChainIdEfConverter.ToDatabaseNullable(id), + raw => SessionChainIdEfConverter.FromDatabaseNullable(raw)) + { + } +} diff --git a/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/Infrastructure/SessionChainIdConverter.cs b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/Infrastructure/SessionChainIdConverter.cs new file mode 100644 index 00000000..ff21b301 --- /dev/null +++ b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/Infrastructure/SessionChainIdConverter.cs @@ -0,0 +1,14 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace CodeBeam.UltimateAuth.EntityFrameworkCore; + +public sealed class SessionChainIdConverter : ValueConverter +{ + public SessionChainIdConverter() + : base( + id => SessionChainIdEfConverter.ToDatabase(id), + raw => SessionChainIdEfConverter.FromDatabase(raw)) + { + } +} \ No newline at end of file diff --git a/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/Infrastructure/SessionChainIdEfConverter.cs b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/Infrastructure/SessionChainIdEfConverter.cs new file mode 100644 index 00000000..14dc9eae --- /dev/null +++ b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/Infrastructure/SessionChainIdEfConverter.cs @@ -0,0 +1,23 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.EntityFrameworkCore; + +internal static class SessionChainIdEfConverter +{ + public static SessionChainId FromDatabase(Guid raw) + { + return SessionChainId.From(raw); + } + + public static Guid ToDatabase(SessionChainId id) => id.Value; + + public static SessionChainId? FromDatabaseNullable(Guid? raw) + { + if (raw is null) + return null; + + return SessionChainId.From(raw.Value); + } + + public static Guid? ToDatabaseNullable(SessionChainId? id) => id?.Value; +} diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore.csproj b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore.csproj index e656299b..4cda4e65 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore.csproj +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore.csproj @@ -8,21 +8,9 @@ $(NoWarn);1591 - - - - - - - - - - - - - + diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Data/UAuthSessionDbContext.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Data/UAuthSessionDbContext.cs index b7f0977d..c78491f8 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Data/UAuthSessionDbContext.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Data/UAuthSessionDbContext.cs @@ -1,5 +1,6 @@ using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore; @@ -34,8 +35,13 @@ protected override void OnModelCreating(ModelBuilder b) e.HasKey(x => x.Id); e.Property(x => x.Version).IsConcurrencyToken(); - - e.Property(x => x.UserKey) + e.Property(x => x.UserKey).IsRequired(); + e.Property(x => x.CreatedAt).IsRequired(); + e.Property(x => x.Tenant) + .HasConversion( + v => v.Value, + v => TenantKey.FromInternal(v)) + .HasMaxLength(128) .IsRequired(); e.HasIndex(x => new { x.Tenant, x.UserKey }).IsUnique(); @@ -48,6 +54,7 @@ protected override void OnModelCreating(ModelBuilder b) .HasConversion( v => v.Value, v => SessionRootId.From(v)) + .HasMaxLength(128) .IsRequired(); }); @@ -56,11 +63,25 @@ protected override void OnModelCreating(ModelBuilder b) e.HasKey(x => x.Id); e.Property(x => x.Version).IsConcurrencyToken(); - - e.Property(x => x.UserKey) + e.Property(x => x.UserKey).IsRequired(); + e.Property(x => x.CreatedAt).IsRequired(); + e.Property(x => x.Tenant) + .HasConversion( + v => v.Value, + v => TenantKey.FromInternal(v)) + .HasMaxLength(128) .IsRequired(); e.HasIndex(x => new { x.Tenant, x.ChainId }).IsUnique(); + e.HasIndex(x => new { x.Tenant, x.UserKey }); + e.HasIndex(x => new { x.Tenant, x.UserKey, x.DeviceId }); + e.HasIndex(x => new { x.Tenant, x.RootId }); + + e.HasOne() + .WithMany() + .HasForeignKey(x => new { x.Tenant, x.RootId }) + .HasPrincipalKey(x => new { x.Tenant, x.RootId }) + .OnDelete(DeleteBehavior.Restrict); e.Property(x => x.ChainId) .HasConversion( @@ -68,6 +89,17 @@ protected override void OnModelCreating(ModelBuilder b) v => SessionChainId.From(v)) .IsRequired(); + e.Property(x => x.DeviceId) + .HasConversion( + v => v.Value, + v => DeviceId.Create(v)) + .HasMaxLength(64) + .IsRequired(); + + e.Property(x => x.Device) + .HasConversion(new JsonValueConverter()) + .IsRequired(); + e.Property(x => x.ActiveSessionId) .HasConversion(new NullableAuthSessionIdConverter()); @@ -83,9 +115,26 @@ protected override void OnModelCreating(ModelBuilder b) { e.HasKey(x => x.Id); e.Property(x => x.Version).IsConcurrencyToken(); + e.Property(x => x.CreatedAt).IsRequired(); + e.Property(x => x.Tenant) + .HasConversion( + v => v.Value, + v => TenantKey.FromInternal(v)) + .HasMaxLength(128) + .IsRequired(); e.HasIndex(x => new { x.Tenant, x.SessionId }).IsUnique(); + e.HasIndex(x => new { x.Tenant, x.ChainId }); e.HasIndex(x => new { x.Tenant, x.ChainId, x.RevokedAt }); + e.HasIndex(x => new { x.Tenant, x.UserKey, x.RevokedAt }); + e.HasIndex(x => new { x.Tenant, x.ExpiresAt }); + e.HasIndex(x => new { x.Tenant, x.RevokedAt }); + + e.HasOne() + .WithMany() + .HasForeignKey(x => new { x.Tenant, x.ChainId }) + .HasPrincipalKey(x => new { x.Tenant, x.ChainId }) + .OnDelete(DeleteBehavior.Restrict); e.Property(x => x.SessionId) .HasConversion(new AuthSessionIdConverter()) diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs index 478a44e6..82bb21d0 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs @@ -1,14 +1,15 @@ -using Microsoft.EntityFrameworkCore; +using CodeBeam.UltimateAuth.Core.Abstractions; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore; public static class ServiceCollectionExtensions { - public static IServiceCollection AddUltimateAuthEntityFrameworkCoreSessions(this IServiceCollection services,Action configureDb)where TUserId : notnull + public static IServiceCollection AddUltimateAuthEntityFrameworkCoreSessions(this IServiceCollection services,Action configureDb) { - services.AddDbContext(configureDb); - services.AddScoped(); + services.AddDbContextPool(configureDb); + services.AddScoped(); return services; } diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Infrastructure/JsonValueConverter.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Infrastructure/JsonValueConverter.cs deleted file mode 100644 index 68fb5ff1..00000000 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Infrastructure/JsonValueConverter.cs +++ /dev/null @@ -1,14 +0,0 @@ -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using System.Text.Json; - -namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore; - -internal sealed class JsonValueConverter : ValueConverter -{ - public JsonValueConverter() - : base( - v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null), - v => JsonSerializer.Deserialize(v, (JsonSerializerOptions?)null)!) - { - } -} diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionChainProjectionMapper.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionChainProjectionMapper.cs index 683f9453..271f5c75 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionChainProjectionMapper.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionChainProjectionMapper.cs @@ -27,6 +27,9 @@ public static UAuthSessionChain ToDomain(this SessionChainProjection p) public static SessionChainProjection ToProjection(this UAuthSessionChain chain) { + if (chain.Device.DeviceId is not DeviceId deviceId) + throw new ArgumentException("Device id required."); + return new SessionChainProjection { ChainId = chain.ChainId, @@ -36,6 +39,7 @@ public static SessionChainProjection ToProjection(this UAuthSessionChain chain) CreatedAt = chain.CreatedAt, LastSeenAt = chain.LastSeenAt, AbsoluteExpiresAt = chain.AbsoluteExpiresAt, + DeviceId = deviceId, Device = chain.Device, ClaimsSnapshot = chain.ClaimsSnapshot, ActiveSessionId = chain.ActiveSessionId, diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionProjectionMapper.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionProjectionMapper.cs index 9b591691..6377ea28 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionProjectionMapper.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionProjectionMapper.cs @@ -13,9 +13,9 @@ public static UAuthSession ToDomain(this SessionProjection p) p.ChainId, p.CreatedAt, p.ExpiresAt, - p.IsRevoked, p.RevokedAt, p.SecurityVersionAtCreation, + p.Device, p.Claims, p.Metadata, p.Version @@ -33,15 +33,13 @@ public static SessionProjection ToProjection(this UAuthSession s) CreatedAt = s.CreatedAt, ExpiresAt = s.ExpiresAt, - - IsRevoked = s.IsRevoked, RevokedAt = s.RevokedAt, SecurityVersionAtCreation = s.SecurityVersionAtCreation, + Device = s.Device, Claims = s.Claims, Metadata = s.Metadata, Version = s.Version }; } - } diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionRootProjectionMapper.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionRootProjectionMapper.cs index 63d1fa41..d38a02a5 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionRootProjectionMapper.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionRootProjectionMapper.cs @@ -12,7 +12,6 @@ public static UAuthSessionRoot ToDomain(this SessionRootProjection root) root.UserKey, root.CreatedAt, root.UpdatedAt, - root.IsRevoked, root.RevokedAt, root.SecurityVersion, root.Version @@ -29,8 +28,6 @@ public static SessionRootProjection ToProjection(this UAuthSessionRoot root) CreatedAt = root.CreatedAt, UpdatedAt = root.UpdatedAt, - - IsRevoked = root.IsRevoked, RevokedAt = root.RevokedAt, SecurityVersion = root.SecurityVersion, diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionChainProjection.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Projections/SessionChainProjection.cs similarity index 90% rename from src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionChainProjection.cs rename to src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Projections/SessionChainProjection.cs index 7106b024..ffc23f10 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionChainProjection.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Projections/SessionChainProjection.cs @@ -15,7 +15,8 @@ internal sealed class SessionChainProjection public DateTimeOffset CreatedAt { get; init; } public DateTimeOffset LastSeenAt { get; set; } public DateTimeOffset? AbsoluteExpiresAt { get; set; } - public DeviceContext Device { get; set; } + public DeviceId DeviceId { get; set; } + public DeviceContext Device { get; set; } = DeviceContext.Anonymous(); public ClaimsSnapshot ClaimsSnapshot { get; set; } = ClaimsSnapshot.Empty; public AuthSessionId? ActiveSessionId { get; set; } public int RotationCount { get; set; } diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionProjection.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Projections/SessionProjection.cs similarity index 91% rename from src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionProjection.cs rename to src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Projections/SessionProjection.cs index 58c37604..19061c50 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionProjection.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Projections/SessionProjection.cs @@ -15,9 +15,8 @@ internal sealed class SessionProjection public DateTimeOffset CreatedAt { get; set; } public DateTimeOffset ExpiresAt { get; set; } - public DateTimeOffset? LastSeenAt { get; set; } - public bool IsRevoked { get; set; } + public DateTimeOffset? RevokedAt { get; set; } public long SecurityVersionAtCreation { get; set; } @@ -27,4 +26,6 @@ internal sealed class SessionProjection public SessionMetadata Metadata { get; set; } = SessionMetadata.Empty; public long Version { get; set; } + + public bool IsRevoked => RevokedAt != null; } diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionRootProjection.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Projections/SessionRootProjection.cs similarity index 92% rename from src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionRootProjection.cs rename to src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Projections/SessionRootProjection.cs index 5d1e613f..4d9c0ff8 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionRootProjection.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Projections/SessionRootProjection.cs @@ -13,9 +13,10 @@ internal sealed class SessionRootProjection public DateTimeOffset CreatedAt { get; set; } public DateTimeOffset? UpdatedAt { get; set; } - public bool IsRevoked { get; set; } public DateTimeOffset? RevokedAt { get; set; } public long SecurityVersion { get; set; } public long Version { get; set; } + + public bool IsRevoked => RevokedAt != null; } diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStore.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStore.cs index 65a2a1db..d52c232f 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStore.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStore.cs @@ -10,12 +10,10 @@ namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore; internal sealed class EfCoreSessionStore : ISessionStore { private readonly UltimateAuthSessionDbContext _db; - private readonly TenantContext _tenant; - public EfCoreSessionStore(UltimateAuthSessionDbContext db, TenantContext tenant) + public EfCoreSessionStore(UltimateAuthSessionDbContext db) { _db = db; - _tenant = tenant; } public async Task ExecuteAsync(Func action, CancellationToken ct = default) @@ -24,12 +22,7 @@ public async Task ExecuteAsync(Func action, Cancellatio await strategy.ExecuteAsync(async () => { - var connection = _db.Database.GetDbConnection(); - if (connection.State != ConnectionState.Open) - await connection.OpenAsync(ct); - - await using var tx = await connection.BeginTransactionAsync(IsolationLevel.RepeatableRead, ct); - _db.Database.UseTransaction(tx); + await using var tx = await _db.Database.BeginTransactionAsync(IsolationLevel.RepeatableRead, ct); try { @@ -47,10 +40,6 @@ await strategy.ExecuteAsync(async () => await tx.RollbackAsync(ct); throw; } - finally - { - _db.Database.UseTransaction(null); - } }); } @@ -60,12 +49,7 @@ public async Task ExecuteAsync(Func { - var connection = _db.Database.GetDbConnection(); - if (connection.State != ConnectionState.Open) - await connection.OpenAsync(ct); - - await using var tx = await connection.BeginTransactionAsync(IsolationLevel.RepeatableRead, ct); - _db.Database.UseTransaction(tx); + await using var tx = await _db.Database.BeginTransactionAsync(IsolationLevel.RepeatableRead, ct); try { @@ -74,14 +58,15 @@ public async Task ExecuteAsync(Func ExecuteAsync(Func x.Version).OriginalValue = expectedVersion; - - try - { - await Task.CompletedTask; - } - catch (DbUpdateConcurrencyException) - { - throw new UAuthConcurrencyException("session_concurrency_conflict"); - } + _db.Entry(projection).State = EntityState.Modified; + return Task.CompletedTask; } public async Task CreateSessionAsync(UAuthSession session, CancellationToken ct = default) @@ -133,18 +109,13 @@ public async Task RevokeSessionAsync(AuthSessionId sessionId, DateTimeOffs { ct.ThrowIfCancellationRequested(); - var projection = await _db.Sessions.SingleOrDefaultAsync(x => x.SessionId == sessionId); - - if (projection is null) - return false; + var projection = await _db.Sessions.SingleOrDefaultAsync(x => x.SessionId == sessionId, ct); - var session = projection.ToDomain(); - if (session.IsRevoked) + if (projection is null || projection.IsRevoked) return false; - var revoked = session.Revoke(at); + var revoked = projection.ToDomain().Revoke(at); _db.Sessions.Update(revoked.ToProjection()); - return true; } @@ -153,29 +124,23 @@ public async Task RevokeAllSessionsAsync(UserKey user, DateTimeOffset at, Cancel ct.ThrowIfCancellationRequested(); var chains = await _db.Chains.Where(x => x.UserKey == user).ToListAsync(ct); + var chainIds = chains.Select(x => x.ChainId).ToList(); + var sessions = await _db.Sessions.Where(x => chainIds.Contains(x.ChainId)).ToListAsync(ct); - foreach (var chainProjection in chains) + foreach (var sessionProjection in sessions) { - var chain = chainProjection.ToDomain(); - - var sessions = await _db.Sessions.Where(x => x.ChainId == chain.ChainId).ToListAsync(ct); - - foreach (var sessionProjection in sessions) - { - var session = sessionProjection.ToDomain(); + var session = sessionProjection.ToDomain(); - if (session.IsRevoked) - continue; + if (!session.IsRevoked) + _db.Sessions.Update(session.Revoke(at).ToProjection()); + } - var revoked = session.Revoke(at); - _db.Sessions.Update(revoked.ToProjection()); - } + foreach (var chainProjection in chains) + { + var chain = chainProjection.ToDomain(); if (chain.ActiveSessionId is not null) - { - var updatedChain = chain.DetachSession(at); - _db.Chains.Update(updatedChain.ToProjection()); - } + _db.Chains.Update(chain.DetachSession(at).ToProjection()); } } @@ -184,29 +149,23 @@ public async Task RevokeOtherSessionsAsync(UserKey user, SessionChainId keepChai ct.ThrowIfCancellationRequested(); var chains = await _db.Chains.Where(x => x.UserKey == user && x.ChainId != keepChain).ToListAsync(ct); + var chainIds = chains.Select(x => x.ChainId).ToList(); + var sessions = await _db.Sessions.Where(x => chainIds.Contains(x.ChainId)).ToListAsync(ct); - foreach (var chainProjection in chains) + foreach (var sessionProjection in sessions) { - var chain = chainProjection.ToDomain(); - - var sessions = await _db.Sessions.Where(x => x.ChainId == chain.ChainId).ToListAsync(ct); - - foreach (var sessionProjection in sessions) - { - var session = sessionProjection.ToDomain(); + var session = sessionProjection.ToDomain(); - if (session.IsRevoked) - continue; + if (!session.IsRevoked) + _db.Sessions.Update(session.Revoke(at).ToProjection()); + } - var revoked = session.Revoke(at); - _db.Sessions.Update(revoked.ToProjection()); - } + foreach (var chainProjection in chains) + { + var chain = chainProjection.ToDomain(); if (chain.ActiveSessionId is not null) - { - var updatedChain = chain.DetachSession(at); - _db.Chains.Update(updatedChain.ToProjection()); - } + _db.Chains.Update(chain.DetachSession(at).ToProjection()); } } @@ -231,7 +190,7 @@ public async Task RevokeOtherSessionsAsync(UserKey user, SessionChainId keepChai x.Tenant == tenant && x.UserKey == userKey && x.RevokedAt == null && - x.Device.DeviceId == deviceId) + x.DeviceId == deviceId) .SingleOrDefaultAsync(ct); return projection?.ToDomain(); @@ -392,19 +351,8 @@ public async Task SetActiveSessionIdAsync(SessionChainId chainId, AuthSessionId { ct.ThrowIfCancellationRequested(); - var rootProjection = await _db.Roots - .AsNoTracking() - .SingleOrDefaultAsync(x => x.UserKey == userKey); - - if (rootProjection is null) - return null; - - var chains = await _db.Chains - .AsNoTracking() - .Where(x => x.UserKey == userKey) - .ToListAsync(); - - return rootProjection.ToDomain(); + var rootProjection = await _db.Roots.AsNoTracking().SingleOrDefaultAsync(x => x.UserKey == userKey, ct); + return rootProjection?.ToDomain(); } public Task SaveRootAsync(UAuthSessionRoot root, long expectedVersion, CancellationToken ct = default) @@ -511,19 +459,8 @@ public async Task> GetSessionsByChainAsync(SessionCh { ct.ThrowIfCancellationRequested(); - var rootProjection = await _db.Roots - .AsNoTracking() - .SingleOrDefaultAsync(x => x.RootId == rootId); - - if (rootProjection is null) - return null; - - var chains = await _db.Chains - .AsNoTracking() - .Where(x => x.RootId == rootId) - .ToListAsync(); - - return rootProjection.ToDomain(); + var projection = await _db.Roots.AsNoTracking().SingleOrDefaultAsync(x => x.RootId == rootId, ct); + return projection?.ToDomain(); } public async Task RemoveSessionAsync(AuthSessionId sessionId, CancellationToken ct = default) diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore.csproj b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore.csproj index 9f31a13b..29b9bf38 100644 --- a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore.csproj +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore.csproj @@ -7,22 +7,10 @@ true $(NoWarn);1591 - - - - - - - - - - - - - + diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/UAuthTokenDbContext.cs b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Data/UAuthTokenDbContext.cs similarity index 50% rename from src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/UAuthTokenDbContext.cs rename to src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Data/UAuthTokenDbContext.cs index f8c371ea..a7caf53b 100644 --- a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/UAuthTokenDbContext.cs +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Data/UAuthTokenDbContext.cs @@ -1,4 +1,7 @@ -using Microsoft.EntityFrameworkCore; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; namespace CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore; @@ -18,42 +21,44 @@ protected override void OnModelCreating(ModelBuilder b) { e.HasKey(x => x.Id); - e.Property(x => x.Version).IsConcurrencyToken(); + e.Property(x => x.Version) + .IsConcurrencyToken(); - e.Property(x => x.TokenHash) + e.Property(x => x.Tenant) + .HasConversion( + v => v.Value, + v => TenantKey.FromInternal(v)) + .HasMaxLength(128) + .IsRequired(); + + e.Property(x => x.TokenId) + .HasConversion( + v => v.Value, + v => TokenId.From(v)) .IsRequired(); - e.HasIndex(x => new { x.Tenant, x.TokenHash }) - .IsUnique(); + e.Property(x => x.TokenHash) + .HasMaxLength(128) + .IsRequired(); + e.HasIndex(x => new { x.Tenant, x.TokenHash }).IsUnique(); + e.HasIndex(x => new { x.Tenant, x.TokenHash, x.RevokedAt }); + e.HasIndex(x => new { x.Tenant, x.TokenId }); e.HasIndex(x => new { x.Tenant, x.UserKey }); e.HasIndex(x => new { x.Tenant, x.SessionId }); e.HasIndex(x => new { x.Tenant, x.ChainId }); e.HasIndex(x => new { x.Tenant, x.ExpiresAt }); + e.HasIndex(x => new { x.Tenant, x.ExpiresAt, x.RevokedAt }); e.HasIndex(x => new { x.Tenant, x.ReplacedByTokenHash }); - e.Property(x => x.ExpiresAt).IsRequired(); - }); + e.Property(x => x.SessionId) + .HasConversion(new AuthSessionIdConverter()); - b.Entity(e => - { - e.HasKey(x => x.Id); - - e.Property(x => x.Version).IsConcurrencyToken(); - - e.Property(x => x.Jti) - .IsRequired(); - - e.HasIndex(x => x.Jti) - .IsUnique(); - - e.HasIndex(x => new { x.Tenant, x.Jti }); + e.Property(x => x.ChainId) + .HasConversion(new NullableSessionChainIdConverter()); e.Property(x => x.ExpiresAt) .IsRequired(); - - e.Property(x => x.RevokedAt) - .IsRequired(); }); } } diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/EfCoreTokenStore.cs b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/EfCoreTokenStore.cs deleted file mode 100644 index f4f95708..00000000 --- a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/EfCoreTokenStore.cs +++ /dev/null @@ -1,110 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Core.MultiTenancy; -using Microsoft.EntityFrameworkCore; - -namespace CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore; - -internal sealed class EfCoreRefreshTokenStore : IRefreshTokenStore -{ - private readonly UltimateAuthTokenDbContext _db; - - public EfCoreRefreshTokenStore(UltimateAuthTokenDbContext db, IUserIdConverterResolver converters) - { - _db = db; - } - - public async Task StoreAsync(TenantKey tenantId, StoredRefreshToken token, CancellationToken ct = default) - { - if (token.Tenant != tenantId) - throw new InvalidOperationException("TenantId mismatch between context and token."); - - if (token.ChainId is null) - throw new InvalidOperationException("Refresh token must have a ChainId before being stored."); - - _db.RefreshTokens.Add(new RefreshTokenProjection - { - Tenant = tenantId, - TokenHash = token.TokenHash, - UserKey = token.UserKey, - SessionId = token.SessionId, - ChainId = token.ChainId.Value, - IssuedAt = token.IssuedAt, - ExpiresAt = token.ExpiresAt - }); - - await _db.SaveChangesAsync(ct); - } - - public async Task FindByHashAsync(TenantKey tenant, string tokenHash, CancellationToken ct = default) - { - var e = await _db.RefreshTokens - .AsNoTracking() - .SingleOrDefaultAsync( - x => x.TokenHash == tokenHash && - x.Tenant == tenant, - ct); - - if (e is null) - return null; - - return new StoredRefreshToken - { - Tenant = e.Tenant, - TokenHash = e.TokenHash, - UserKey = e.UserKey, - SessionId = e.SessionId, - ChainId = e.ChainId, - IssuedAt = e.IssuedAt, - ExpiresAt = e.ExpiresAt, - RevokedAt = e.RevokedAt - }; - } - - public Task RevokeAsync(TenantKey tenant, string tokenHash, DateTimeOffset revokedAt, string? replacedByTokenHash = null, CancellationToken ct = default) - { - var query = _db.RefreshTokens - .Where(x => - x.TokenHash == tokenHash && - x.Tenant == tenant && - x.RevokedAt == null); - - if (replacedByTokenHash == null) - { - return query.ExecuteUpdateAsync(x => x.SetProperty(t => t.RevokedAt, revokedAt), ct); - } - - return query.ExecuteUpdateAsync( - x => x - .SetProperty(t => t.RevokedAt, revokedAt) - .SetProperty(t => t.ReplacedByTokenHash, replacedByTokenHash), - ct); - } - - public Task RevokeBySessionAsync(TenantKey tenant, AuthSessionId sessionId, DateTimeOffset revokedAt, CancellationToken ct = default) - => _db.RefreshTokens - .Where(x => - x.Tenant == tenant && - x.SessionId == sessionId.Value && - x.RevokedAt == null) - .ExecuteUpdateAsync(x => x.SetProperty(t => t.RevokedAt, revokedAt), ct); - - public Task RevokeByChainAsync(TenantKey tenant, SessionChainId chainId, DateTimeOffset revokedAt, CancellationToken ct = default) - => _db.RefreshTokens - .Where(x => - x.Tenant == tenant && - x.ChainId == chainId && - x.RevokedAt == null) - .ExecuteUpdateAsync(x => x.SetProperty(t => t.RevokedAt, revokedAt), ct); - - public Task RevokeAllForUserAsync(TenantKey tenant, UserKey userKey, DateTimeOffset revokedAt, CancellationToken ct = default) - { - - return _db.RefreshTokens - .Where(x => - x.Tenant == tenant && - x.UserKey == userKey && - x.RevokedAt == null) - .ExecuteUpdateAsync(x => x.SetProperty(t => t.RevokedAt, revokedAt), ct); - } -} diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/ServiceCollectionExtensions.cs b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs similarity index 72% rename from src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/ServiceCollectionExtensions.cs rename to src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs index 243d161c..c3dcc34f 100644 --- a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/ServiceCollectionExtensions.cs +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs @@ -8,8 +8,8 @@ public static class ServiceCollectionExtensions { public static IServiceCollection AddUltimateAuthEntityFrameworkCoreTokens(this IServiceCollection services, Action configureDb) { - services.AddDbContext(configureDb); - services.AddScoped(typeof(IRefreshTokenStore), typeof(EfCoreRefreshTokenStore)); + services.AddDbContextPool(configureDb); + services.AddScoped(); return services; } diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Mappers/RefreshTokenProjectionMapper.cs b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Mappers/RefreshTokenProjectionMapper.cs new file mode 100644 index 00000000..d7c2e41c --- /dev/null +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Mappers/RefreshTokenProjectionMapper.cs @@ -0,0 +1,43 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore; + +internal static class RefreshTokenProjectionMapper +{ + public static RefreshToken ToDomain(this RefreshTokenProjection p) + { + return RefreshToken.Create( + tokenId: p.TokenId, + tokenHash: p.TokenHash, + tenant: p.Tenant, + userKey: p.UserKey, + sessionId: p.SessionId, + chainId: p.ChainId, + createdAt: p.CreatedAt, + expiresAt: p.ExpiresAt + ) with + { + RevokedAt = p.RevokedAt, + ReplacedByTokenHash = p.ReplacedByTokenHash, + Version = p.Version + }; + } + + public static RefreshTokenProjection ToProjection(this RefreshToken t) + { + return new RefreshTokenProjection + { + TokenId = t.TokenId, + Tenant = t.Tenant, + TokenHash = t.TokenHash, + UserKey = t.UserKey, + SessionId = t.SessionId, + ChainId = t.ChainId, + CreatedAt = t.CreatedAt, + ExpiresAt = t.ExpiresAt, + RevokedAt = t.RevokedAt, + ReplacedByTokenHash = t.ReplacedByTokenHash, + Version = t.Version + }; + } +} diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Projections/RefreshTokenProjection.cs b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Projections/RefreshTokenProjection.cs index d1167f36..3f47970e 100644 --- a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Projections/RefreshTokenProjection.cs +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Projections/RefreshTokenProjection.cs @@ -3,21 +3,28 @@ namespace CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore; -// Add mapper class if needed (adding domain rules etc.) internal sealed class RefreshTokenProjection { - public long Id { get; set; } // Surrogate PK + public long Id { get; set; } // EF PK + + public TokenId TokenId { get; set; } + public TenantKey Tenant { get; set; } public string TokenHash { get; set; } = default!; + public UserKey UserKey { get; set; } = default!; + public AuthSessionId SessionId { get; set; } = default!; - public SessionChainId ChainId { get; set; } = default!; + + public SessionChainId? ChainId { get; set; } public string? ReplacedByTokenHash { get; set; } - public DateTimeOffset IssuedAt { get; set; } + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset ExpiresAt { get; set; } + public DateTimeOffset? RevokedAt { get; set; } public long Version { get; set; } diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Stores/EfCoreRefreshTokenStore.cs b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Stores/EfCoreRefreshTokenStore.cs new file mode 100644 index 00000000..7347936e --- /dev/null +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Stores/EfCoreRefreshTokenStore.cs @@ -0,0 +1,162 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using Microsoft.EntityFrameworkCore; + +namespace CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore; + +internal sealed class EfCoreRefreshTokenStore : IRefreshTokenStore +{ + private readonly UltimateAuthTokenDbContext _db; + private readonly TenantKey _tenant; + + public EfCoreRefreshTokenStore(UltimateAuthTokenDbContext db, TenantKey tenant) + { + _db = db; + _tenant = tenant; + } + + public async Task ExecuteAsync(Func action, CancellationToken ct = default) + { + var strategy = _db.Database.CreateExecutionStrategy(); + + await strategy.ExecuteAsync(async () => + { + await using var tx = + await _db.Database.BeginTransactionAsync(ct); + + try + { + await action(ct); + await _db.SaveChangesAsync(ct); + await tx.CommitAsync(ct); + } + catch + { + await tx.RollbackAsync(ct); + throw; + } + }); + } + + public async Task ExecuteAsync(Func> action, CancellationToken ct = default) + { + var strategy = _db.Database.CreateExecutionStrategy(); + + return await strategy.ExecuteAsync(async () => + { + await using var tx = + await _db.Database.BeginTransactionAsync(ct); + + try + { + var result = await action(ct); + await _db.SaveChangesAsync(ct); + await tx.CommitAsync(ct); + return result; + } + catch + { + await tx.RollbackAsync(ct); + throw; + } + }); + } + + public Task StoreAsync(RefreshToken token, CancellationToken ct = default) + { + if (token.Tenant != _tenant) + throw new InvalidOperationException("Tenant mismatch."); + + var projection = token.ToProjection(); + + _db.RefreshTokens.Add(projection); + + return Task.CompletedTask; + } + + public async Task FindByHashAsync( + string tokenHash, + CancellationToken ct = default) + { + var p = await _db.RefreshTokens + .AsNoTracking() + .SingleOrDefaultAsync( + x => x.Tenant == _tenant && + x.TokenHash == tokenHash, + ct); + + return p?.ToDomain(); + } + + public Task RevokeAsync( + string tokenHash, + DateTimeOffset revokedAt, + string? replacedByTokenHash = null, + CancellationToken ct = default) + { + var query = _db.RefreshTokens + .Where(x => + x.Tenant == _tenant && + x.TokenHash == tokenHash && + x.RevokedAt == null); + + if (replacedByTokenHash is null) + { + return query.ExecuteUpdateAsync( + x => x.SetProperty(t => t.RevokedAt, revokedAt), + ct); + } + + return query.ExecuteUpdateAsync( + x => x + .SetProperty(t => t.RevokedAt, revokedAt) + .SetProperty(t => t.ReplacedByTokenHash, replacedByTokenHash), + ct); + } + + public Task RevokeBySessionAsync( + AuthSessionId sessionId, + DateTimeOffset revokedAt, + CancellationToken ct = default) + { + return _db.RefreshTokens + .Where(x => + x.Tenant == _tenant && + x.SessionId == sessionId && + x.RevokedAt == null) + .ExecuteUpdateAsync( + x => x.SetProperty(t => t.RevokedAt, revokedAt), + ct); + } + + public Task RevokeByChainAsync( + SessionChainId chainId, + DateTimeOffset revokedAt, + CancellationToken ct = default) + { + return _db.RefreshTokens + .Where(x => + x.Tenant == _tenant && + x.ChainId == chainId && + x.RevokedAt == null) + .ExecuteUpdateAsync( + x => x.SetProperty(t => t.RevokedAt, revokedAt), + ct); + } + + public Task RevokeAllForUserAsync( + UserKey userKey, + DateTimeOffset revokedAt, + CancellationToken ct = default) + { + return _db.RefreshTokens + .Where(x => + x.Tenant == _tenant && + x.UserKey == userKey && + x.RevokedAt == null) + .ExecuteUpdateAsync( + x => x.SetProperty(t => t.RevokedAt, revokedAt), + ct); + } +} diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Stores/EfCoreRefreshTokenStoreFactory.cs b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Stores/EfCoreRefreshTokenStoreFactory.cs new file mode 100644 index 00000000..e329766d --- /dev/null +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Stores/EfCoreRefreshTokenStoreFactory.cs @@ -0,0 +1,20 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using Microsoft.Extensions.DependencyInjection; + +namespace CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore; + +public sealed class EfCoreRefreshTokenStoreFactory : IRefreshTokenStoreFactory +{ + private readonly IServiceProvider _sp; + + public EfCoreRefreshTokenStoreFactory(IServiceProvider sp) + { + _sp = sp; + } + + public IRefreshTokenStore Create(TenantKey tenant) + { + return ActivatorUtilities.CreateInstance(_sp, tenant); + } +} diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/InMemoryRefreshTokenStore.cs b/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/InMemoryRefreshTokenStore.cs index 7046a019..683cb61b 100644 --- a/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/InMemoryRefreshTokenStore.cs +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/InMemoryRefreshTokenStore.cs @@ -5,88 +5,121 @@ namespace CodeBeam.UltimateAuth.Tokens.InMemory; -public sealed class InMemoryRefreshTokenStore : IRefreshTokenStore +internal sealed class InMemoryRefreshTokenStore : IRefreshTokenStore { - private static string NormalizeTenant(string? tenantId) => tenantId ?? "__single__"; + private readonly TenantKey _tenant; + private readonly SemaphoreSlim _tx = new(1, 1); - private readonly ConcurrentDictionary _tokens = new(); + private readonly ConcurrentDictionary _tokens = new(); - public Task StoreAsync(TenantKey tenant, StoredRefreshToken token, CancellationToken ct = default) + public InMemoryRefreshTokenStore(TenantKey tenant) { - var key = new TokenKey(NormalizeTenant(tenant), token.TokenHash); + _tenant = tenant; + } + + public async Task ExecuteAsync(Func action, CancellationToken ct = default) + { + await _tx.WaitAsync(ct); + + try + { + await action(ct); + } + finally + { + _tx.Release(); + } + } + + public async Task ExecuteAsync(Func> action, CancellationToken ct = default) + { + await _tx.WaitAsync(ct); + + try + { + return await action(ct); + } + finally + { + _tx.Release(); + } + } + + public Task StoreAsync(RefreshToken token, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + if (token.Tenant != _tenant) + throw new InvalidOperationException("Tenant mismatch."); + + _tokens[token.TokenHash] = token; - _tokens[key] = token; return Task.CompletedTask; } - public Task FindByHashAsync(TenantKey tenant, string tokenHash, CancellationToken ct = default) + public Task FindByHashAsync(string tokenHash, CancellationToken ct = default) { - var key = new TokenKey(NormalizeTenant(tenant), tokenHash); + ct.ThrowIfCancellationRequested(); + + _tokens.TryGetValue(tokenHash, out var token); - _tokens.TryGetValue(key, out var token); return Task.FromResult(token); } - public Task RevokeAsync(TenantKey tenant, string tokenHash, DateTimeOffset revokedAt, string? replacedByTokenHash = null, CancellationToken ct = default) + public Task RevokeAsync(string tokenHash, DateTimeOffset revokedAt, string? replacedByTokenHash = null, CancellationToken ct = default) { - var key = new TokenKey(NormalizeTenant(tenant), tokenHash); + ct.ThrowIfCancellationRequested(); - if (_tokens.TryGetValue(key, out var token) && !token.IsRevoked) + if (_tokens.TryGetValue(tokenHash, out var token) && !token.IsRevoked) { - _tokens[key] = token with - { - RevokedAt = revokedAt, - ReplacedByTokenHash = replacedByTokenHash - }; + _tokens[tokenHash] = token.Revoke(revokedAt, replacedByTokenHash); } return Task.CompletedTask; } - public Task RevokeBySessionAsync(TenantKey tenant, AuthSessionId sessionId, DateTimeOffset revokedAt, CancellationToken ct = default) + public Task RevokeBySessionAsync(AuthSessionId sessionId, DateTimeOffset revokedAt, CancellationToken ct = default) { - foreach (var (key, token) in _tokens) + ct.ThrowIfCancellationRequested(); + + foreach (var (hash, token) in _tokens.ToArray()) { - if (key.TenantId == tenant && - token.SessionId == sessionId && - !token.IsRevoked) + if (token.SessionId == sessionId && !token.IsRevoked) { - _tokens[key] = token with { RevokedAt = revokedAt }; + _tokens[hash] = token.Revoke(revokedAt); } } return Task.CompletedTask; } - public Task RevokeByChainAsync(TenantKey tenant, SessionChainId chainId, DateTimeOffset revokedAt, CancellationToken ct = default) + public Task RevokeByChainAsync(SessionChainId chainId, DateTimeOffset revokedAt, CancellationToken ct = default) { - foreach (var (key, token) in _tokens) + ct.ThrowIfCancellationRequested(); + + foreach (var (hash, token) in _tokens.ToArray()) { - if (key.TenantId == tenant && - token.ChainId == chainId && - !token.IsRevoked) + if (token.ChainId == chainId && !token.IsRevoked) { - _tokens[key] = token with { RevokedAt = revokedAt }; + _tokens[hash] = token.Revoke(revokedAt); } } return Task.CompletedTask; } - public Task RevokeAllForUserAsync(TenantKey tenant, UserKey userKey, DateTimeOffset revokedAt, CancellationToken ct = default) + public Task RevokeAllForUserAsync(UserKey userKey, DateTimeOffset revokedAt, CancellationToken ct = default) { - foreach (var (key, token) in _tokens) + ct.ThrowIfCancellationRequested(); + + foreach (var (hash, token) in _tokens.ToArray()) { - if (key.TenantId == tenant && - token.UserKey == userKey && - !token.IsRevoked) + if (token.UserKey == userKey && !token.IsRevoked) { - _tokens[key] = token with { RevokedAt = revokedAt }; + _tokens[hash] = token.Revoke(revokedAt); } } return Task.CompletedTask; } - - private readonly record struct TokenKey(string TenantId, string TokenHash); -} +} \ No newline at end of file diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/InMemoryRefreshTokenStoreFactory.cs b/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/InMemoryRefreshTokenStoreFactory.cs new file mode 100644 index 00000000..e749cc38 --- /dev/null +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/InMemoryRefreshTokenStoreFactory.cs @@ -0,0 +1,15 @@ +using System.Collections.Concurrent; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Tokens.InMemory; + +public sealed class InMemoryRefreshTokenStoreFactory : IRefreshTokenStoreFactory +{ + private readonly ConcurrentDictionary _stores = new(); + + public IRefreshTokenStore Create(TenantKey tenant) + { + return _stores.GetOrAdd(tenant, _ => new InMemoryRefreshTokenStore(tenant)); + } +} \ No newline at end of file diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/ServiceCollectionExtensions.cs b/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/ServiceCollectionExtensions.cs index 4716253e..76b12711 100644 --- a/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/ServiceCollectionExtensions.cs +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/ServiceCollectionExtensions.cs @@ -7,7 +7,7 @@ public static class ServiceCollectionExtensions { public static IServiceCollection AddUltimateAuthInMemoryTokens(this IServiceCollection services) { - services.AddSingleton(typeof(IRefreshTokenStore), typeof(InMemoryRefreshTokenStore)); + services.AddSingleton(); return services; } diff --git a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/AssemblyVisibility.cs b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/AssemblyVisibility.cs new file mode 100644 index 00000000..ed166fcc --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/AssemblyVisibility.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Tests.Unit")] diff --git a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/CodeBeam.UltimateAuth.Users.EntityFrameworkCore.csproj b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/CodeBeam.UltimateAuth.Users.EntityFrameworkCore.csproj new file mode 100644 index 00000000..985dcf63 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/CodeBeam.UltimateAuth.Users.EntityFrameworkCore.csproj @@ -0,0 +1,17 @@ + + + + net8.0;net9.0;net10.0 + enable + enable + true + $(NoWarn);1591 + + + + + + + + + diff --git a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Data/UAuthUserDbContext.cs b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Data/UAuthUserDbContext.cs new file mode 100644 index 00000000..9cf9db39 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Data/UAuthUserDbContext.cs @@ -0,0 +1,153 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using System.Text.Json; + +namespace CodeBeam.UltimateAuth.Users.EntityFrameworkCore; + +internal sealed class UAuthUserDbContext : DbContext +{ + public DbSet Identifiers => Set(); + public DbSet Lifecycles => Set(); + public DbSet Profiles => Set(); + + private readonly TenantContext _tenant; + + public UAuthUserDbContext(DbContextOptions options, TenantContext tenant) + : base(options) + { + _tenant = tenant; + } + + protected override void OnModelCreating(ModelBuilder b) + { + ConfigureTenantFilters(b); + + ConfigureIdentifiers(b); + ConfigureLifecycles(b); + ConfigureProfiles(b); + } + + private void ConfigureTenantFilters(ModelBuilder b) + { + b.Entity() + .HasQueryFilter(x => _tenant.IsGlobal || x.Tenant == _tenant.Tenant); + + b.Entity() + .HasQueryFilter(x => _tenant.IsGlobal || x.Tenant == _tenant.Tenant); + + b.Entity() + .HasQueryFilter(x => _tenant.IsGlobal || x.Tenant == _tenant.Tenant); + } + + private void ConfigureIdentifiers(ModelBuilder b) + { + b.Entity(e => + { + e.HasKey(x => x.Id); + + e.Property(x => x.Version) + .IsConcurrencyToken(); + + e.Property(x => x.Tenant) + .HasConversion( + v => v.Value, + v => TenantKey.FromInternal(v)) + .HasMaxLength(128) + .IsRequired(); + + e.Property(x => x.UserKey) + .HasConversion( + v => v.Value, + v => UserKey.FromString(v)) + .HasMaxLength(128) + .IsRequired(); + + e.Property(x => x.Value) + .HasMaxLength(256) + .IsRequired(); + + e.Property(x => x.NormalizedValue) + .HasMaxLength(256) + .IsRequired(); + + e.HasIndex(x => new { x.Tenant, x.Type, x.NormalizedValue }).IsUnique(); + e.HasIndex(x => new { x.Tenant, x.UserKey }); + e.HasIndex(x => new { x.Tenant, x.UserKey, x.Type, x.IsPrimary }); + e.HasIndex(x => new { x.Tenant, x.UserKey, x.IsPrimary }); + e.HasIndex(x => new { x.Tenant, x.NormalizedValue }); + + e.Property(x => x.CreatedAt) + .IsRequired(); + }); + } + + private void ConfigureLifecycles(ModelBuilder b) + { + b.Entity(e => + { + e.HasKey(x => x.Id); + + e.Property(x => x.Version) + .IsConcurrencyToken(); + + e.Property(x => x.Tenant) + .HasConversion( + v => v.Value, + v => TenantKey.FromInternal(v)) + .HasMaxLength(128) + .IsRequired(); + + e.Property(x => x.UserKey) + .HasConversion( + v => v.Value, + v => UserKey.FromString(v)) + .HasMaxLength(128) + .IsRequired(); + + e.HasIndex(x => new { x.Tenant, x.UserKey }).IsUnique(); + + e.Property(x => x.SecurityVersion) + .IsRequired(); + + e.Property(x => x.CreatedAt) + .IsRequired(); + }); + } + + private void ConfigureProfiles(ModelBuilder b) + { + b.Entity(e => + { + e.HasKey(x => x.Id); + + e.Property(x => x.Version) + .IsConcurrencyToken(); + + e.Property(x => x.Tenant) + .HasConversion( + v => v.Value, + v => TenantKey.FromInternal(v)) + .HasMaxLength(128) + .IsRequired(); + + e.Property(x => x.UserKey) + .HasConversion( + v => v.Value, + v => UserKey.FromString(v)) + .HasMaxLength(128) + .IsRequired(); + + e.HasIndex(x => new { x.Tenant, x.UserKey }); + + e.Property(x => x.Metadata) + .HasConversion(new NullableJsonValueConverter>()) + .Metadata.SetValueComparer(JsonValueComparers.Create()); + + e.Property(x => x.CreatedAt) + .IsRequired(); + }); + } +} \ No newline at end of file diff --git a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..5a5ed1ba --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,17 @@ +using CodeBeam.UltimateAuth.Users.Reference; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace CodeBeam.UltimateAuth.Users.EntityFrameworkCore.Extensions; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddUltimateAuthEntityFrameworkCoreUsers(this IServiceCollection services, Action configureDb) + { + services.AddDbContextPool(configureDb); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + return services; + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Mappers/UserIdentifierMapper.cs b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Mappers/UserIdentifierMapper.cs new file mode 100644 index 00000000..0f7b2e88 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Mappers/UserIdentifierMapper.cs @@ -0,0 +1,42 @@ +using CodeBeam.UltimateAuth.Users.Reference; + +namespace CodeBeam.UltimateAuth.Users.EntityFrameworkCore; + +internal static class UserIdentifierMapper +{ + public static UserIdentifier ToDomain(this UserIdentifierProjection p) + { + return UserIdentifier.FromProjection( + p.Id, + p.Tenant, + p.UserKey, + p.Type, + p.Value, + p.NormalizedValue, + p.IsPrimary, + p.CreatedAt, + p.VerifiedAt, + p.UpdatedAt, + p.DeletedAt, + p.Version); + } + + public static UserIdentifierProjection ToProjection(this UserIdentifier d) + { + return new UserIdentifierProjection + { + Id = d.Id, + Tenant = d.Tenant, + UserKey = d.UserKey, + Type = d.Type, + Value = d.Value, + NormalizedValue = d.NormalizedValue, + IsPrimary = d.IsPrimary, + CreatedAt = d.CreatedAt, + VerifiedAt = d.VerifiedAt, + UpdatedAt = d.UpdatedAt, + DeletedAt = d.DeletedAt, + Version = d.Version + }; + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Mappers/UserLifecycleMapper.cs b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Mappers/UserLifecycleMapper.cs new file mode 100644 index 00000000..654600c3 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Mappers/UserLifecycleMapper.cs @@ -0,0 +1,36 @@ +using CodeBeam.UltimateAuth.Users.Reference; + +namespace CodeBeam.UltimateAuth.Users.EntityFrameworkCore; + +internal static class UserLifecycleMapper +{ + public static UserLifecycle ToDomain(this UserLifecycleProjection p) + { + return UserLifecycle.FromProjection( + p.Id, + p.Tenant, + p.UserKey, + p.Status, + p.SecurityVersion, + p.CreatedAt, + p.UpdatedAt, + p.DeletedAt, + p.Version); + } + + public static UserLifecycleProjection ToProjection(this UserLifecycle d) + { + return new UserLifecycleProjection + { + Id = d.Id, + Tenant = d.Tenant, + UserKey = d.UserKey, + Status = d.Status, + SecurityVersion = d.SecurityVersion, + CreatedAt = d.CreatedAt, + UpdatedAt = d.UpdatedAt, + DeletedAt = d.DeletedAt, + Version = d.Version + }; + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Mappers/UserProfileMapper.cs b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Mappers/UserProfileMapper.cs new file mode 100644 index 00000000..d18a1bcb --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Mappers/UserProfileMapper.cs @@ -0,0 +1,52 @@ +using CodeBeam.UltimateAuth.Users.Reference; + +namespace CodeBeam.UltimateAuth.Users.EntityFrameworkCore; + +internal static class UserProfileMapper +{ + public static UserProfile ToDomain(this UserProfileProjection p) + { + return UserProfile.FromProjection( + p.Id, + p.Tenant, + p.UserKey, + p.FirstName, + p.LastName, + p.DisplayName, + p.BirthDate, + p.Gender, + p.Bio, + p.Language, + p.TimeZone, + p.Culture, + p.Metadata, + p.CreatedAt, + p.UpdatedAt, + p.DeletedAt, + p.Version); + } + + public static UserProfileProjection ToProjection(this UserProfile d) + { + return new UserProfileProjection + { + Id = d.Id, + Tenant = d.Tenant, + UserKey = d.UserKey, + FirstName = d.FirstName, + LastName = d.LastName, + DisplayName = d.DisplayName, + BirthDate = d.BirthDate, + Gender = d.Gender, + Bio = d.Bio, + Language = d.Language, + TimeZone = d.TimeZone, + Culture = d.Culture, + Metadata = d.Metadata?.ToDictionary(x => x.Key, x => x.Value), + CreatedAt = d.CreatedAt, + UpdatedAt = d.UpdatedAt, + DeletedAt = d.DeletedAt, + Version = d.Version + }; + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Projections/UserIdentifierProjections.cs b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Projections/UserIdentifierProjections.cs new file mode 100644 index 00000000..1600e974 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Projections/UserIdentifierProjections.cs @@ -0,0 +1,32 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Users.Contracts; + +namespace CodeBeam.UltimateAuth.Users.EntityFrameworkCore; + +internal sealed class UserIdentifierProjection +{ + public Guid Id { get; set; } + + public TenantKey Tenant { get; set; } + + public UserKey UserKey { get; set; } = default!; + + public UserIdentifierType Type { get; set; } + + public string Value { get; set; } = default!; + + public string NormalizedValue { get; set; } = default!; + + public bool IsPrimary { get; set; } + + public DateTimeOffset CreatedAt { get; set; } + + public DateTimeOffset? VerifiedAt { get; set; } + + public DateTimeOffset? UpdatedAt { get; set; } + + public DateTimeOffset? DeletedAt { get; set; } + + public long Version { get; set; } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Projections/UserLifecycleProjection.cs b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Projections/UserLifecycleProjection.cs new file mode 100644 index 00000000..0f33546f --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Projections/UserLifecycleProjection.cs @@ -0,0 +1,26 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Users.EntityFrameworkCore; + +internal sealed class UserLifecycleProjection +{ + public Guid Id { get; set; } + + public TenantKey Tenant { get; set; } + + public UserKey UserKey { get; set; } = default!; + + public UserStatus Status { get; set; } + + public long SecurityVersion { get; set; } + + public DateTimeOffset CreatedAt { get; set; } + + public DateTimeOffset? UpdatedAt { get; set; } + + public DateTimeOffset? DeletedAt { get; set; } + + public long Version { get; set; } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Projections/UserProfileProjection.cs b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Projections/UserProfileProjection.cs new file mode 100644 index 00000000..6e5f07cd --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Projections/UserProfileProjection.cs @@ -0,0 +1,41 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Users.EntityFrameworkCore; + +internal sealed class UserProfileProjection +{ + public Guid Id { get; set; } + + public TenantKey Tenant { get; set; } + + public UserKey UserKey { get; set; } = default!; + + public string? FirstName { get; set; } + + public string? LastName { get; set; } + + public string? DisplayName { get; set; } + + public DateOnly? BirthDate { get; set; } + + public string? Gender { get; set; } + + public string? Bio { get; set; } + + public string? Language { get; set; } + + public string? TimeZone { get; set; } + + public string? Culture { get; set; } + + public Dictionary? Metadata { get; set; } + + public DateTimeOffset CreatedAt { get; set; } + + public DateTimeOffset? UpdatedAt { get; set; } + + public DateTimeOffset? DeletedAt { get; set; } + + public long Version { get; set; } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserIdentifierStore.cs b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserIdentifierStore.cs new file mode 100644 index 00000000..86fd189f --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserIdentifierStore.cs @@ -0,0 +1,322 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Users.Contracts; +using CodeBeam.UltimateAuth.Users.Reference; +using Microsoft.EntityFrameworkCore; + +namespace CodeBeam.UltimateAuth.Users.EntityFrameworkCore; + +internal sealed class EfCoreUserIdentifierStore : IUserIdentifierStore +{ + private readonly UAuthUserDbContext _db; + + public EfCoreUserIdentifierStore(UAuthUserDbContext db) + { + _db = db; + } + + public async Task ExistsAsync(Guid key, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + return await _db.Identifiers + .AnyAsync(x => x.Id == key, ct); + } + + public async Task ExistsAsync(IdentifierExistenceQuery query, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var q = _db.Identifiers + .AsNoTracking() + .Where(x => + x.Tenant == query.Tenant && + x.Type == query.Type && + x.NormalizedValue == query.NormalizedValue && + x.DeletedAt == null); + + if (query.ExcludeIdentifierId.HasValue) + q = q.Where(x => x.Id != query.ExcludeIdentifierId.Value); + + q = query.Scope switch + { + IdentifierExistenceScope.WithinUser => + q.Where(x => x.UserKey == query.UserKey), + + IdentifierExistenceScope.TenantPrimaryOnly => + q.Where(x => x.IsPrimary), + + IdentifierExistenceScope.TenantAny => + q, + + _ => q + }; + + var match = await q + .Select(x => new + { + x.Id, + x.UserKey, + x.IsPrimary + }) + .FirstOrDefaultAsync(ct); + + if (match is null) + return new IdentifierExistenceResult(false); + + return new IdentifierExistenceResult( + true, + match.UserKey, + match.Id, + match.IsPrimary); + } + + public async Task GetAsync(Guid key, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var projection = await _db.Identifiers + .AsNoTracking() + .SingleOrDefaultAsync(x => x.Id == key, ct); + + return projection?.ToDomain(); + } + + public async Task GetAsync(TenantKey tenant, UserIdentifierType type, string normalizedValue, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var projection = await _db.Identifiers + .AsNoTracking() + .SingleOrDefaultAsync( + x => + x.Tenant == tenant && + x.Type == type && + x.NormalizedValue == normalizedValue && + x.DeletedAt == null, + ct); + + return projection?.ToDomain(); + } + + public async Task GetByIdAsync(Guid id, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var projection = await _db.Identifiers + .AsNoTracking() + .SingleOrDefaultAsync(x => x.Id == id, ct); + + return projection?.ToDomain(); + } + + public async Task> GetByUserAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var projections = await _db.Identifiers + .AsNoTracking() + .Where(x => + x.Tenant == tenant && + x.UserKey == userKey && + x.DeletedAt == null) + .OrderBy(x => x.CreatedAt) + .ToListAsync(ct); + + return projections.Select(x => x.ToDomain()).ToList(); + } + + public async Task AddAsync(UserIdentifier entity, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var projection = entity.ToProjection(); + + if (entity.Version != 0) + throw new UAuthValidationException("New identifier must have version 0."); + + using var tx = await _db.Database.BeginTransactionAsync(ct); + + if (entity.IsPrimary) + { + await _db.Identifiers + .Where(x => + x.Tenant == entity.Tenant && + x.UserKey == entity.UserKey && + x.Type == entity.Type && + x.IsPrimary && + x.DeletedAt == null) + .ExecuteUpdateAsync( + x => x.SetProperty(i => i.IsPrimary, false), + ct); + } + + _db.Identifiers.Add(projection); + + await _db.SaveChangesAsync(ct); + await tx.CommitAsync(ct); + } + + public async Task SaveAsync(UserIdentifier entity, long expectedVersion, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var projection = entity.ToProjection(); + + using var tx = await _db.Database.BeginTransactionAsync(ct); + + if (entity.IsPrimary) + { + await _db.Identifiers + .Where(x => + x.Tenant == entity.Tenant && + x.UserKey == entity.UserKey && + x.Type == entity.Type && + x.Id != entity.Id && + x.IsPrimary && + x.DeletedAt == null) + .ExecuteUpdateAsync( + x => x.SetProperty(i => i.IsPrimary, false), + ct); + } + + _db.Entry(projection).State = EntityState.Modified; + + _db.Entry(projection) + .Property(x => x.Version) + .OriginalValue = expectedVersion; + + try + { + await _db.SaveChangesAsync(ct); + await tx.CommitAsync(ct); + } + catch (DbUpdateConcurrencyException) + { + throw new UAuthConcurrencyException("identifier_concurrency_conflict"); + } + } + + public async Task> QueryAsync(TenantKey tenant, UserIdentifierQuery query, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + if (query.UserKey is null) + throw new UAuthIdentifierValidationException("userKey_required"); + + var normalized = query.Normalize(); + + var baseQuery = _db.Identifiers + .AsNoTracking() + .Where(x => + x.Tenant == tenant && + x.UserKey == query.UserKey && + (query.IncludeDeleted || x.DeletedAt == null)); + + baseQuery = query.SortBy switch + { + nameof(UserIdentifier.Type) => + query.Descending + ? baseQuery.OrderByDescending(x => x.Type) + : baseQuery.OrderBy(x => x.Type), + + nameof(UserIdentifier.CreatedAt) => + query.Descending + ? baseQuery.OrderByDescending(x => x.CreatedAt) + : baseQuery.OrderBy(x => x.CreatedAt), + + nameof(UserIdentifier.Value) => + query.Descending + ? baseQuery.OrderByDescending(x => x.Value) + : baseQuery.OrderBy(x => x.Value), + + _ => baseQuery.OrderBy(x => x.CreatedAt) + }; + + var total = await baseQuery.CountAsync(ct); + + var items = await baseQuery + .Skip((normalized.PageNumber - 1) * normalized.PageSize) + .Take(normalized.PageSize) + .ToListAsync(ct); + + return new PagedResult( + items.Select(x => x.ToDomain()).ToList(), + total, + normalized.PageNumber, + normalized.PageSize, + query.SortBy, + query.Descending); + } + + public async Task> GetByUsersAsync(TenantKey tenant, IReadOnlyList userKeys, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var projections = await _db.Identifiers + .AsNoTracking() + .Where(x => + x.Tenant == tenant && + userKeys.Contains(x.UserKey) && + x.DeletedAt == null) + .ToListAsync(ct); + + return projections.Select(x => x.ToDomain()).ToList(); + } + + public async Task DeleteAsync(Guid key, long expectedVersion, DeleteMode mode, DateTimeOffset now, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var projection = await _db.Identifiers + .SingleOrDefaultAsync(x => x.Id == key, ct); + + if (projection is null) + throw new UAuthNotFoundException("identifier_not_found"); + + if (projection.Version != expectedVersion) + throw new UAuthConcurrencyException("identifier_concurrency_conflict"); + + if (mode == DeleteMode.Hard) + { + _db.Identifiers.Remove(projection); + } + else + { + projection.DeletedAt = now; + projection.IsPrimary = false; + projection.Version++; + } + + await _db.SaveChangesAsync(ct); + } + + public async Task DeleteByUserAsync(TenantKey tenant, UserKey userKey, DeleteMode mode, DateTimeOffset deletedAt, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + if (mode == DeleteMode.Hard) + { + await _db.Identifiers + .Where(x => + x.Tenant == tenant && + x.UserKey == userKey) + .ExecuteDeleteAsync(ct); + + return; + } + + await _db.Identifiers + .Where(x => + x.Tenant == tenant && + x.UserKey == userKey && + x.DeletedAt == null) + .ExecuteUpdateAsync( + x => x + .SetProperty(i => i.DeletedAt, deletedAt) + .SetProperty(i => i.IsPrimary, false), + ct); + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserLifecycleStore.cs b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserLifecycleStore.cs new file mode 100644 index 00000000..c2b35917 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserLifecycleStore.cs @@ -0,0 +1,159 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Users.Reference; +using Microsoft.EntityFrameworkCore; + +namespace CodeBeam.UltimateAuth.Users.EntityFrameworkCore; + +internal sealed class EfCoreUserLifecycleStore : IUserLifecycleStore +{ + private readonly UAuthUserDbContext _db; + + public EfCoreUserLifecycleStore(UAuthUserDbContext db) + { + _db = db; + } + + public async Task GetAsync(UserLifecycleKey key, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var projection = await _db.Lifecycles + .AsNoTracking() + .SingleOrDefaultAsync( + x => x.Tenant == key.Tenant && + x.UserKey == key.UserKey, + ct); + + return projection?.ToDomain(); + } + + public async Task ExistsAsync(UserLifecycleKey key, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + return await _db.Lifecycles + .AnyAsync( + x => x.Tenant == key.Tenant && + x.UserKey == key.UserKey, + ct); + } + + public async Task AddAsync(UserLifecycle entity, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var projection = entity.ToProjection(); + + if (entity.Version != 0) + throw new InvalidOperationException("New lifecycle must have version 0."); + + _db.Lifecycles.Add(projection); + + await _db.SaveChangesAsync(ct); + } + + public async Task SaveAsync(UserLifecycle entity, long expectedVersion, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var projection = entity.ToProjection(); + + _db.Entry(projection).State = EntityState.Modified; + + _db.Entry(projection) + .Property(x => x.Version) + .OriginalValue = expectedVersion; + + try + { + await _db.SaveChangesAsync(ct); + } + catch (DbUpdateConcurrencyException) + { + throw new UAuthConcurrencyException("user_lifecycle_concurrency_conflict"); + } + } + + public async Task DeleteAsync(UserLifecycleKey key, long expectedVersion, DeleteMode mode, DateTimeOffset now, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var projection = await _db.Lifecycles + .SingleOrDefaultAsync( + x => x.Tenant == key.Tenant && + x.UserKey == key.UserKey, + ct); + + if (projection is null) + throw new UAuthNotFoundException("user_lifecycle_not_found"); + + if (projection.Version != expectedVersion) + throw new UAuthConcurrencyException("user_lifecycle_concurrency_conflict"); + + if (mode == DeleteMode.Hard) + { + _db.Lifecycles.Remove(projection); + } + else + { + projection.DeletedAt = now; + projection.Version++; + } + + await _db.SaveChangesAsync(ct); + } + + public async Task> QueryAsync(TenantKey tenant, UserLifecycleQuery query, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var normalized = query.Normalize(); + + var baseQuery = _db.Lifecycles + .AsNoTracking() + .Where(x => x.Tenant == tenant); + + if (!query.IncludeDeleted) + baseQuery = baseQuery.Where(x => x.DeletedAt == null); + + if (query.Status != null) + baseQuery = baseQuery.Where(x => x.Status == query.Status); + + baseQuery = query.SortBy switch + { + nameof(UserLifecycle.Id) => + query.Descending + ? baseQuery.OrderByDescending(x => x.Id) + : baseQuery.OrderBy(x => x.Id), + + nameof(UserLifecycle.CreatedAt) => + query.Descending + ? baseQuery.OrderByDescending(x => x.CreatedAt) + : baseQuery.OrderBy(x => x.CreatedAt), + + nameof(UserLifecycle.Status) => + query.Descending + ? baseQuery.OrderByDescending(x => x.Status) + : baseQuery.OrderBy(x => x.Status), + + _ => baseQuery.OrderBy(x => x.CreatedAt) + }; + + var total = await baseQuery.CountAsync(ct); + + var items = await baseQuery + .Skip((normalized.PageNumber - 1) * normalized.PageSize) + .Take(normalized.PageSize) + .ToListAsync(ct); + + return new PagedResult( + items.Select(x => x.ToDomain()).ToList(), + total, + normalized.PageNumber, + normalized.PageSize, + query.SortBy, + query.Descending); + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserProfileStore.cs b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserProfileStore.cs new file mode 100644 index 00000000..cf7286de --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserProfileStore.cs @@ -0,0 +1,163 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Users.Reference; +using Microsoft.EntityFrameworkCore; + +namespace CodeBeam.UltimateAuth.Users.EntityFrameworkCore; + +internal sealed class EfCoreUserProfileStore : IUserProfileStore +{ + private readonly UAuthUserDbContext _db; + + public EfCoreUserProfileStore(UAuthUserDbContext db) + { + _db = db; + } + + public async Task GetAsync(UserProfileKey key, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var projection = await _db.Profiles + .AsNoTracking() + .SingleOrDefaultAsync(x => + x.Tenant == key.Tenant && + x.UserKey == key.UserKey, + ct); + + return projection?.ToDomain(); + } + + public async Task ExistsAsync(UserProfileKey key, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + return await _db.Profiles + .AnyAsync(x => + x.Tenant == key.Tenant && + x.UserKey == key.UserKey, + ct); + } + + public async Task AddAsync(UserProfile entity, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var projection = entity.ToProjection(); + _db.Profiles.Add(projection); + await _db.SaveChangesAsync(ct); + } + + public async Task SaveAsync(UserProfile entity, long expectedVersion, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var projection = entity.ToProjection(); + + if (entity.Version != expectedVersion + 1) + throw new InvalidOperationException("Profile version must be incremented by domain."); + + _db.Entry(projection).State = EntityState.Modified; + _db.Entry(projection).Property(x => x.Version).OriginalValue = expectedVersion; + await _db.SaveChangesAsync(ct); + } + + public async Task DeleteAsync(UserProfileKey key, long expectedVersion, DeleteMode mode, DateTimeOffset now, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var projection = await _db.Profiles + .SingleOrDefaultAsync(x => + x.Tenant == key.Tenant && + x.UserKey == key.UserKey, + ct); + + if (projection is null) + throw new UAuthNotFoundException("user_profile_not_found"); + + if (projection.Version != expectedVersion) + throw new UAuthConcurrencyException("user_profile_concurrency_conflict"); + + if (mode == DeleteMode.Hard) + { + _db.Profiles.Remove(projection); + } + else + { + projection.DeletedAt = now; + projection.Version++; + } + + await _db.SaveChangesAsync(ct); + } + + public async Task> QueryAsync(TenantKey tenant, UserProfileQuery query, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var normalized = query.Normalize(); + + var baseQuery = _db.Profiles + .AsNoTracking() + .Where(x => x.Tenant == tenant); + + if (!query.IncludeDeleted) + baseQuery = baseQuery.Where(x => x.DeletedAt == null); + + baseQuery = query.SortBy switch + { + nameof(UserProfile.CreatedAt) => + query.Descending + ? baseQuery.OrderByDescending(x => x.CreatedAt) + : baseQuery.OrderBy(x => x.CreatedAt), + + nameof(UserProfile.DisplayName) => + query.Descending + ? baseQuery.OrderByDescending(x => x.DisplayName) + : baseQuery.OrderBy(x => x.DisplayName), + + nameof(UserProfile.FirstName) => + query.Descending + ? baseQuery.OrderByDescending(x => x.FirstName) + : baseQuery.OrderBy(x => x.FirstName), + + nameof(UserProfile.LastName) => + query.Descending + ? baseQuery.OrderByDescending(x => x.LastName) + : baseQuery.OrderBy(x => x.LastName), + + _ => baseQuery.OrderBy(x => x.CreatedAt) + }; + + var total = await baseQuery.CountAsync(ct); + + var items = await baseQuery + .Skip((normalized.PageNumber - 1) * normalized.PageSize) + .Take(normalized.PageSize) + .ToListAsync(ct); + + return new PagedResult( + items.Select(x => x.ToDomain()).ToList(), + total, + normalized.PageNumber, + normalized.PageSize, + query.SortBy, + query.Descending); + } + + public async Task> GetByUsersAsync(TenantKey tenant, IReadOnlyList userKeys, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var projections = await _db.Profiles + .AsNoTracking() + .Where(x => x.Tenant == tenant) + .Where(x => userKeys.Contains(x.UserKey)) + .Where(x => x.DeletedAt == null) + .ToListAsync(ct); + + return projections.Select(x => x.ToDomain()).ToList(); + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserIdentifier.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserIdentifier.cs index a38424d4..f47dee0b 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserIdentifier.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserIdentifier.cs @@ -144,6 +144,37 @@ public UserIdentifier MarkDeleted(DateTimeOffset at) return this; } + public static UserIdentifier FromProjection( + Guid id, + TenantKey tenant, + UserKey userKey, + UserIdentifierType type, + string value, + string normalizedValue, + bool isPrimary, + DateTimeOffset createdAt, + DateTimeOffset? verifiedAt, + DateTimeOffset? updatedAt, + DateTimeOffset? deletedAt, + long version) + { + return new UserIdentifier + { + Id = id, + Tenant = tenant, + UserKey = userKey, + Type = type, + Value = value, + NormalizedValue = normalizedValue, + IsPrimary = isPrimary, + CreatedAt = createdAt, + VerifiedAt = verifiedAt, + UpdatedAt = updatedAt, + DeletedAt = deletedAt, + Version = version + }; + } + public UserIdentifierInfo ToDto() { return new UserIdentifierInfo() diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserLifecycle.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserLifecycle.cs index 8ee2470b..b0b64139 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserLifecycle.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserLifecycle.cs @@ -93,4 +93,29 @@ public UserLifecycle IncrementSecurityVersion() SecurityVersion++; return this; } + + public static UserLifecycle FromProjection( + Guid id, + TenantKey tenant, + UserKey userKey, + UserStatus status, + long securityVersion, + DateTimeOffset createdAt, + DateTimeOffset? updatedAt, + DateTimeOffset? deletedAt, + long version) + { + return new UserLifecycle + { + Id = id, + Tenant = tenant, + UserKey = userKey, + Status = status, + SecurityVersion = securityVersion, + CreatedAt = createdAt, + UpdatedAt = updatedAt, + DeletedAt = deletedAt, + Version = version + }; + } } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserProfile.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserProfile.cs index ff0227b4..fbf58740 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserProfile.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserProfile.cs @@ -164,4 +164,45 @@ public UserProfile MarkDeleted(DateTimeOffset now) return this; } + + public static UserProfile FromProjection( + Guid id, + TenantKey tenant, + UserKey userKey, + string? firstName, + string? lastName, + string? displayName, + DateOnly? birthDate, + string? gender, + string? bio, + string? language, + string? timeZone, + string? culture, + IReadOnlyDictionary? metadata, + DateTimeOffset createdAt, + DateTimeOffset? updatedAt, + DateTimeOffset? deletedAt, + long version) + { + return new UserProfile + { + Id = id, + Tenant = tenant, + UserKey = userKey, + FirstName = firstName, + LastName = lastName, + DisplayName = displayName, + BirthDate = birthDate, + Gender = gender, + Bio = bio, + Language = language, + TimeZone = timeZone, + Culture = culture, + Metadata = metadata, + CreatedAt = createdAt, + UpdatedAt = updatedAt, + DeletedAt = deletedAt, + Version = version + }; + } } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/CodeBeam.UltimateAuth.Tests.Unit.csproj b/tests/CodeBeam.UltimateAuth.Tests.Unit/CodeBeam.UltimateAuth.Tests.Unit.csproj index f0adf68b..216e9045 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/CodeBeam.UltimateAuth.Tests.Unit.csproj +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/CodeBeam.UltimateAuth.Tests.Unit.csproj @@ -31,6 +31,7 @@ + @@ -38,6 +39,7 @@ + diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/RefreshTokenValidatorTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/RefreshTokenValidatorTests.cs index 3e967fce..18f513ac 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/RefreshTokenValidatorTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/RefreshTokenValidatorTests.cs @@ -14,9 +14,9 @@ public sealed class RefreshTokenValidatorTests { private const string ValidDeviceId = "deviceidshouldbelongandstrongenough!?1234567890"; - private static UAuthRefreshTokenValidator CreateValidator(InMemoryRefreshTokenStore store) + private static UAuthRefreshTokenValidator CreateValidator(InMemoryRefreshTokenStoreFactory factory) { - return new UAuthRefreshTokenValidator(store, CreateHasher()); + return new UAuthRefreshTokenValidator(factory, CreateHasher()); } private static ITokenHasher CreateHasher() @@ -27,8 +27,8 @@ private static ITokenHasher CreateHasher() [Fact] public async Task Invalid_When_Token_Not_Found() { - var store = new InMemoryRefreshTokenStore(); - var validator = CreateValidator(store); + var factory = new InMemoryRefreshTokenStoreFactory(); + var validator = CreateValidator(factory); var result = await validator.ValidateAsync( new RefreshTokenValidationContext @@ -46,26 +46,30 @@ public async Task Invalid_When_Token_Not_Found() [Fact] public async Task Reuse_Detected_When_Token_is_Revoked() { - var store = new InMemoryRefreshTokenStore(); + var factory = new InMemoryRefreshTokenStoreFactory(); + var store = factory.Create(TenantKey.Single); + var hasher = CreateHasher(); - var validator = CreateValidator(store); + var validator = CreateValidator(factory); var now = DateTimeOffset.UtcNow; var rawToken = "refresh-token-1"; var hash = hasher.Hash(rawToken); - await store.StoreAsync(TenantKey.Single, new StoredRefreshToken - { - Tenant = TenantKey.Single, - TokenHash = hash, - UserKey = UserKey.FromString("user-1"), - SessionId = TestIds.Session("session-1-aaaaaaaaaaaaaaaaaaaaaa"), - ChainId = SessionChainId.New(), - IssuedAt = now.AddMinutes(-5), - ExpiresAt = now.AddMinutes(5), - RevokedAt = now - }); + var token = RefreshToken.Create( + TokenId.New(), + hash, + TenantKey.Single, + UserKey.FromString("user-1"), + TestIds.Session("session-1-aaaaaaaaaaaaaaaaaaaaaa"), + SessionChainId.New(), + now.AddMinutes(-5), + now.AddMinutes(5)); + + var revoked = token.Revoke(now); + + await store.StoreAsync(revoked); var result = await validator.ValidateAsync( new RefreshTokenValidationContext @@ -83,21 +87,24 @@ public async Task Reuse_Detected_When_Token_is_Revoked() [Fact] public async Task Invalid_When_Expected_Session_Id_Does_Not_Match() { - var store = new InMemoryRefreshTokenStore(); - var validator = CreateValidator(store); + var factory = new InMemoryRefreshTokenStoreFactory(); + var store = factory.Create(TenantKey.Single); + + var validator = CreateValidator(factory); var now = DateTimeOffset.UtcNow; - await store.StoreAsync(TenantKey.Single, new StoredRefreshToken - { - Tenant = TenantKey.Single, - TokenHash = "hash-2", - UserKey = UserKey.FromString("user-1"), - SessionId = TestIds.Session("session-1-bbbbbbbbbbbbbbbbbbbbbb"), - ChainId = SessionChainId.New(), - IssuedAt = now, - ExpiresAt = now.AddMinutes(10) - }); + var token = RefreshToken.Create( + TokenId.New(), + "hash-2", + TenantKey.Single, + UserKey.FromString("user-1"), + TestIds.Session("session-1-bbbbbbbbbbbbbbbbbbbbbb"), + SessionChainId.New(), + now, + now.AddMinutes(10)); + + await store.StoreAsync(token); var result = await validator.ValidateAsync( new RefreshTokenValidationContext @@ -113,5 +120,114 @@ public async Task Invalid_When_Expected_Session_Id_Does_Not_Match() Assert.False(result.IsReuseDetected); } -} + [Fact] + public async Task Invalid_When_Token_Is_Expired() + { + var factory = new InMemoryRefreshTokenStoreFactory(); + var store = factory.Create(TenantKey.Single); + + var validator = CreateValidator(factory); + + var now = DateTimeOffset.UtcNow; + + var token = RefreshToken.Create( + TokenId.New(), + "expired-hash", + TenantKey.Single, + UserKey.FromString("user-1"), + TestIds.Session("session-expired"), + SessionChainId.New(), + now.AddMinutes(-10), + now.AddMinutes(-1)); + + await store.StoreAsync(token); + + var result = await validator.ValidateAsync( + new RefreshTokenValidationContext + { + Tenant = TenantKey.Single, + RefreshToken = "expired-hash", + Now = now, + Device = DeviceContext.Create(DeviceId.Create(ValidDeviceId), null, null, null, null, null), + }); + + Assert.False(result.IsValid); + Assert.False(result.IsReuseDetected); + } + + [Fact] + public async Task Valid_When_Token_Is_Active() + { + var factory = new InMemoryRefreshTokenStoreFactory(); + var store = factory.Create(TenantKey.Single); + + var validator = CreateValidator(factory); + + var now = DateTimeOffset.UtcNow; + + var raw = "valid-token"; + var hash = CreateHasher().Hash(raw); + + var token = RefreshToken.Create( + TokenId.New(), + hash, + TenantKey.Single, + UserKey.FromString("user-1"), + TestIds.Session("session-valid"), + SessionChainId.New(), + now, + now.AddMinutes(10)); + await store.StoreAsync(token); + + var result = await validator.ValidateAsync( + new RefreshTokenValidationContext + { + Tenant = TenantKey.Single, + RefreshToken = raw, + Now = now, + Device = DeviceContext.Create(DeviceId.Create(ValidDeviceId), null, null, null, null, null), + }); + + Assert.True(result.IsValid); + Assert.False(result.IsReuseDetected); + } + + [Fact] + public async Task Reuse_Detected_When_Old_Token_Is_Reused_After_Rotation() + { + var factory = new InMemoryRefreshTokenStoreFactory(); + var store = factory.Create(TenantKey.Single); + + var validator = CreateValidator(factory); + + var now = DateTimeOffset.UtcNow; + + var raw = "token-1"; + var hash = CreateHasher().Hash(raw); + + var token = RefreshToken.Create( + TokenId.New(), + hash, + TenantKey.Single, + UserKey.FromString("user-1"), + TestIds.Session("session-rotate"), + SessionChainId.New(), + now, + now.AddMinutes(10)); + + await store.StoreAsync(token.Revoke(now, "new-hash")); + + var result = await validator.ValidateAsync( + new RefreshTokenValidationContext + { + Tenant = TenantKey.Single, + RefreshToken = raw, + Now = now, + Device = DeviceContext.Create(DeviceId.Create(ValidDeviceId), null, null, null, null, null), + }); + + Assert.False(result.IsValid); + Assert.True(result.IsReuseDetected); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/UAuthSessionTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/UAuthSessionTests.cs index e988dc1d..c5a3cfb7 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/UAuthSessionTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/UAuthSessionTests.cs @@ -22,6 +22,7 @@ public void Revoke_marks_session_as_revoked() now, now.AddMinutes(10), 0, + DeviceContext.Create(DeviceId.Create(ValidDeviceId)), ClaimsSnapshot.Empty, SessionMetadata.Empty); @@ -46,6 +47,7 @@ public void Revoking_twice_returns_same_instance() now, now.AddMinutes(10), 0, + DeviceContext.Create(DeviceId.Create(ValidDeviceId)), ClaimsSnapshot.Empty, SessionMetadata.Empty); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialUserMappingBuilderTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialUserMappingBuilderTests.cs deleted file mode 100644 index 6ab9d80f..00000000 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialUserMappingBuilderTests.cs +++ /dev/null @@ -1,94 +0,0 @@ -using CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore; - -namespace CodeBeam.UltimateAuth.Tests.Unit; - -public class CredentialUserMappingBuilderTests -{ - private sealed class ConventionUser - { - public Guid Id { get; set; } - public string Email { get; set; } = default!; - public string PasswordHash { get; set; } = default!; - public long SecurityVersion { get; set; } - } - - private sealed class ExplicitUser - { - public Guid UserId { get; set; } - public string LoginName { get; set; } = default!; - public string PasswordHash { get; set; } = default!; - public long SecurityVersion { get; set; } - } - - private sealed class PlainPasswordUser - { - public Guid Id { get; set; } - public string Username { get; set; } = default!; - public string Password { get; set; } = default!; - public long SecurityVersion { get; set; } - } - - - [Fact] - public void Build_UsesConventions_WhenExplicitMappingIsNotProvided() - { - var options = new CredentialUserMappingOptions(); - var mapping = CredentialUserMappingBuilder.Build(options); - var user = new ConventionUser - { - Id = Guid.NewGuid(), - Email = "test@example.com", - PasswordHash = "hash", - SecurityVersion = 3 - }; - - Assert.Equal(user.Id, mapping.UserId(user)); - Assert.Equal(user.Email, mapping.Username(user)); - Assert.Equal(user.PasswordHash, mapping.PasswordHash(user)); - Assert.Equal(user.SecurityVersion, mapping.SecurityVersion(user)); - Assert.True(mapping.CanAuthenticate(user)); - } - - [Fact] - public void Build_ExplicitMapping_OverridesConvention() - { - var options = new CredentialUserMappingOptions(); - options.MapUsername(u => u.LoginName); - var mapping = CredentialUserMappingBuilder.Build(options); - var user = new ExplicitUser - { - UserId = Guid.NewGuid(), - LoginName = "custom-login", - PasswordHash = "hash", - SecurityVersion = 1 - }; - - Assert.Equal("custom-login", mapping.Username(user)); - } - - [Fact] - public void Build_DoesNotMap_PlainPassword_Property() - { - var options = new CredentialUserMappingOptions(); - var ex = Assert.Throws(() => CredentialUserMappingBuilder.Build(options)); - - Assert.Contains("PasswordHash mapping is required", ex.Message); - } - - [Fact] - public void Build_Defaults_CanAuthenticate_ToTrue() - { - var options = new CredentialUserMappingOptions(); - var mapping = CredentialUserMappingBuilder.Build(options); - var user = new ConventionUser - { - Id = Guid.NewGuid(), - Email = "active@example.com", - PasswordHash = "hash", - SecurityVersion = 0 - }; - - var canAuthenticate = mapping.CanAuthenticate(user); - Assert.True(canAuthenticate); - } -} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestIds.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestIds.cs index fca5e3a5..1e4ad3d5 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestIds.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestIds.cs @@ -6,6 +6,11 @@ internal static class TestIds { public static AuthSessionId Session(string raw) { + if (raw.Length < 32) + { + raw = raw.PadRight(32, 'x'); + } + if (!AuthSessionId.TryCreate(raw, out var id)) throw new InvalidOperationException($"Invalid test AuthSessionId: {raw}");