diff --git a/DysonNetwork.Pass/Auth/Auth.cs b/DysonNetwork.Pass/Auth/Auth.cs index b35847b..0f5b019 100644 --- a/DysonNetwork.Pass/Auth/Auth.cs +++ b/DysonNetwork.Pass/Auth/Auth.cs @@ -35,7 +35,7 @@ public class DysonTokenAuthHandler( IOptionsMonitor options, ILoggerFactory logger, UrlEncoder encoder, - AuthService authService, + TokenAuthService token, FlushBufferService fbs ) : AuthenticationHandler(options, logger, encoder) @@ -49,7 +49,7 @@ public class DysonTokenAuthHandler( try { - var (valid, session, message) = await authService.AuthenticateTokenAsync(tokenInfo.Token); + var (valid, session, message) = await token.AuthenticateTokenAsync(tokenInfo.Token); if (!valid || session is null) return AuthenticateResult.Fail(message ?? "Authentication failed."); diff --git a/DysonNetwork.Pass/Auth/AuthCacheConstants.cs b/DysonNetwork.Pass/Auth/AuthCacheConstants.cs new file mode 100644 index 0000000..b6c367e --- /dev/null +++ b/DysonNetwork.Pass/Auth/AuthCacheConstants.cs @@ -0,0 +1,8 @@ +using NodaTime; + +namespace DysonNetwork.Pass.Auth; + +public static class AuthCacheConstants +{ + public const string Prefix = "auth:"; +} diff --git a/DysonNetwork.Pass/Auth/AuthService.cs b/DysonNetwork.Pass/Auth/AuthService.cs index 20068bc..baa928d 100644 --- a/DysonNetwork.Pass/Auth/AuthService.cs +++ b/DysonNetwork.Pass/Auth/AuthService.cs @@ -1,9 +1,6 @@ using System.Security.Cryptography; -using System.Text; using System.Text.Json; using DysonNetwork.Pass.Account; -using DysonNetwork.Pass.Auth.OidcProvider.Services; -using DysonNetwork.Pass.Wallet; using DysonNetwork.Shared.Cache; using Microsoft.EntityFrameworkCore; using NodaTime; @@ -16,9 +13,7 @@ public class AuthService( IHttpClientFactory httpClientFactory, IHttpContextAccessor httpContextAccessor, ICacheService cache, - ILogger logger, - OidcProviderService oidc, - SubscriptionService subscriptions + ILogger logger ) { private HttpContext HttpContext => httpContextAccessor.HttpContext!; @@ -80,137 +75,6 @@ public class AuthService( return totalRequiredSteps; } - /// - /// Universal authenticate method: validate token (JWT or compact), - /// load session from cache/DB, check expiry, enrich with subscription, - /// then cache and return. - /// - /// Incoming token string - /// (Valid, Session, Message) - public async Task<(bool Valid, AuthSession? Session, string? Message)> AuthenticateTokenAsync(string token) - { - try - { - if (string.IsNullOrWhiteSpace(token)) - { - logger.LogWarning("AuthenticateTokenAsync: no token provided"); - return (false, null, "No token provided."); - } - - // token fingerprint for correlation - var tokenHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token))); - var tokenFp = tokenHash[..8]; - - var partsLen = token.Split('.').Length; - var format = partsLen switch - { - 3 => "JWT", - 2 => "Compact", - _ => "Unknown" - }; - logger.LogDebug("AuthenticateTokenAsync: token format detected: {Format} (fp={TokenFp})", format, tokenFp); - - if (!ValidateToken(token, out var sessionId)) - { - logger.LogWarning("AuthenticateTokenAsync: token validation failed (format={Format}, fp={TokenFp})", format, tokenFp); - return (false, null, "Invalid token."); - } - - logger.LogDebug("AuthenticateTokenAsync: token validated, sessionId={SessionId} (fp={TokenFp})", sessionId, tokenFp); - - // Try cache first - var cacheKey = $"{AuthCachePrefix}{sessionId}"; - var session = await cache.GetAsync(cacheKey); - if (session is not null) - { - logger.LogDebug("AuthenticateTokenAsync: cache hit for {CacheKey}", cacheKey); - var nowHit = SystemClock.Instance.GetCurrentInstant(); - if (session.ExpiredAt.HasValue && session.ExpiredAt < nowHit) - { - logger.LogWarning("AuthenticateTokenAsync: cached session expired (sessionId={SessionId})", sessionId); - return (false, null, "Session has been expired."); - } - logger.LogInformation( - "AuthenticateTokenAsync: success via cache (sessionId={SessionId}, accountId={AccountId}, scopes={ScopeCount}, expiresAt={ExpiresAt})", - sessionId, - session.AccountId, - session.Challenge.Scopes.Count, - session.ExpiredAt - ); - return (true, session, null); - } - - logger.LogDebug("AuthenticateTokenAsync: cache miss for {CacheKey}, loading from DB", cacheKey); - - session = await db.AuthSessions - .AsNoTracking() - .Include(e => e.Challenge) - .ThenInclude(e => e.Client) - .Include(e => e.Account) - .ThenInclude(e => e.Profile) - .FirstOrDefaultAsync(s => s.Id == sessionId); - - if (session is null) - { - logger.LogWarning("AuthenticateTokenAsync: session not found (sessionId={SessionId})", sessionId); - return (false, null, "Session was not found."); - } - - var now = SystemClock.Instance.GetCurrentInstant(); - if (session.ExpiredAt.HasValue && session.ExpiredAt < now) - { - logger.LogWarning("AuthenticateTokenAsync: session expired (sessionId={SessionId}, expiredAt={ExpiredAt}, now={Now})", sessionId, session.ExpiredAt, now); - return (false, null, "Session has been expired."); - } - - logger.LogInformation( - "AuthenticateTokenAsync: DB session loaded (sessionId={SessionId}, accountId={AccountId}, clientId={ClientId}, appId={AppId}, scopes={ScopeCount}, ip={Ip}, uaLen={UaLen})", - sessionId, - session.AccountId, - session.Challenge.ClientId, - session.AppId, - session.Challenge.Scopes.Count, - session.Challenge.IpAddress, - (session.Challenge.UserAgent ?? string.Empty).Length - ); - - logger.LogDebug("AuthenticateTokenAsync: enriching account with subscription (accountId={AccountId})", session.AccountId); - var perk = await subscriptions.GetPerkSubscriptionAsync(session.AccountId); - session.Account.PerkSubscription = perk?.ToReference(); - logger.LogInformation( - "AuthenticateTokenAsync: subscription attached (accountId={AccountId}, hasPerk={HasPerk}, identifier={Identifier}, status={Status}, available={Available})", - session.AccountId, - perk is not null, - perk?.Identifier, - perk?.Status, - perk?.IsAvailable - ); - - await cache.SetWithGroupsAsync( - cacheKey, - session, - [$"{AccountService.AccountCachePrefix}{session.Account.Id}"], - TimeSpan.FromHours(1) - ); - logger.LogDebug("AuthenticateTokenAsync: cached session with key {CacheKey} (groups=[{GroupKey}])", - cacheKey, - $"{AccountService.AccountCachePrefix}{session.Account.Id}"); - - logger.LogInformation( - "AuthenticateTokenAsync: success via DB (sessionId={SessionId}, accountId={AccountId}, clientId={ClientId})", - sessionId, - session.AccountId, - session.Challenge.ClientId - ); - return (true, session, null); - } - catch (Exception ex) - { - logger.LogError(ex, "AuthenticateTokenAsync: unexpected error"); - return (false, null, "Authentication error."); - } - } - public async Task CreateSessionForOidcAsync(Account.Account account, Instant time, Guid? customAppId = null) { @@ -454,91 +318,6 @@ public class AuthService( return factor.VerifyPassword(pinCode); } - public bool ValidateToken(string token, out Guid sessionId) - { - sessionId = Guid.Empty; - - try - { - var parts = token.Split('.'); - - switch (parts.Length) - { - case 3: - { - // JWT via OIDC - var (isValid, jwtResult) = oidc.ValidateToken(token); - if (!isValid) return false; - var jti = jwtResult?.Claims.FirstOrDefault(c => c.Type == "jti")?.Value; - if (jti is null) return false; - return Guid.TryParse(jti, out sessionId); - } - case 2: - { - // Compact token - var payloadBytes = Base64UrlDecode(parts[0]); - sessionId = new Guid(payloadBytes); - - var publicKeyPem = File.ReadAllText(config["AuthToken:PublicKeyPath"]!); - using var rsa = RSA.Create(); - rsa.ImportFromPem(publicKeyPem); - - var signature = Base64UrlDecode(parts[1]); - return rsa.VerifyData(payloadBytes, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); - } - default: - return false; - } - } - catch - { - return false; - } - } - - public async Task MigrateDeviceIdToClient() - { - logger.LogInformation("Migrating device IDs to clients..."); - - await using var transaction = await db.Database.BeginTransactionAsync(); - - try - { - var challenges = await db.AuthChallenges - .Where(c => c.DeviceId != null && c.ClientId == null) - .ToListAsync(); - var clients = challenges.GroupBy(c => c.DeviceId) - .Select(c => new AuthClient - { - DeviceId = c.Key!, - AccountId = c.First().AccountId, - DeviceName = c.First().UserAgent ?? string.Empty, - Platform = ClientPlatform.Unidentified - }) - .ToList(); - await db.AuthClients.AddRangeAsync(clients); - await db.SaveChangesAsync(); - - var clientsMap = clients.ToDictionary(c => c.DeviceId, c => c.Id); - foreach (var challenge in challenges.Where(challenge => challenge.ClientId == null && challenge.DeviceId != null)) - { - if (clientsMap.TryGetValue(challenge.DeviceId!, out var clientId)) - challenge.ClientId = clientId; - db.AuthChallenges.Update(challenge); - } - - await db.SaveChangesAsync(); - await transaction.CommitAsync(); - logger.LogInformation("Migrated {Count} device IDs to clients", challenges.Count); - } - catch - { - logger.LogError("Failed to migrate device IDs to clients"); - await transaction.RollbackAsync(); - throw; - } - } - // Helper methods for Base64Url encoding/decoding private static string Base64UrlEncode(byte[] data) { diff --git a/DysonNetwork.Pass/Auth/AuthServiceGrpc.cs b/DysonNetwork.Pass/Auth/AuthServiceGrpc.cs index b4a8c46..3fb1ffe 100644 --- a/DysonNetwork.Pass/Auth/AuthServiceGrpc.cs +++ b/DysonNetwork.Pass/Auth/AuthServiceGrpc.cs @@ -8,10 +8,8 @@ using NodaTime; namespace DysonNetwork.Pass.Auth; public class AuthServiceGrpc( - AuthService authService, - SubscriptionService subscriptions, - ICacheService cache, - AppDatabase db + TokenAuthService token, + AuthService auth ) : Shared.Proto.AuthService.AuthServiceBase { @@ -20,7 +18,7 @@ public class AuthServiceGrpc( ServerCallContext context ) { - var (valid, session, message) = await authService.AuthenticateTokenAsync(request.Token); + var (valid, session, message) = await token.AuthenticateTokenAsync(request.Token); if (!valid || session is null) return new AuthenticateResponse { Valid = false, Message = message ?? "Authentication failed." }; @@ -30,13 +28,13 @@ public class AuthServiceGrpc( public override async Task ValidatePin(ValidatePinRequest request, ServerCallContext context) { var accountId = Guid.Parse(request.AccountId); - var valid = await authService.ValidatePinCode(accountId, request.Pin); + var valid = await auth.ValidatePinCode(accountId, request.Pin); return new ValidateResponse { Valid = valid }; } public override async Task ValidateCaptcha(ValidateCaptchaRequest request, ServerCallContext context) { - var valid = await authService.ValidateCaptcha(request.Token); + var valid = await auth.ValidateCaptcha(request.Token); return new ValidateResponse { Valid = valid }; } } \ No newline at end of file diff --git a/DysonNetwork.Pass/Auth/TokenAuthService.cs b/DysonNetwork.Pass/Auth/TokenAuthService.cs new file mode 100644 index 0000000..371eb9d --- /dev/null +++ b/DysonNetwork.Pass/Auth/TokenAuthService.cs @@ -0,0 +1,206 @@ +using System.Security.Cryptography; +using System.Text; +using DysonNetwork.Pass.Wallet; +using DysonNetwork.Shared.Cache; +using Microsoft.EntityFrameworkCore; +using NodaTime; + +namespace DysonNetwork.Pass.Auth; + +public class TokenAuthService( + AppDatabase db, + IConfiguration config, + ICacheService cache, + ILogger logger, + OidcProvider.Services.OidcProviderService oidc, + SubscriptionService subscriptions +) +{ + /// + /// Universal authenticate method: validate token (JWT or compact), + /// load session from cache/DB, check expiry, enrich with subscription, + /// then cache and return. + /// + /// Incoming token string + /// (Valid, Session, Message) + public async Task<(bool Valid, AuthSession? Session, string? Message)> AuthenticateTokenAsync(string token) + { + try + { + if (string.IsNullOrWhiteSpace(token)) + { + logger.LogWarning("AuthenticateTokenAsync: no token provided"); + return (false, null, "No token provided."); + } + + // token fingerprint for correlation + var tokenHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token))); + var tokenFp = tokenHash[..8]; + + var partsLen = token.Split('.').Length; + var format = partsLen switch + { + 3 => "JWT", + 2 => "Compact", + _ => "Unknown" + }; + logger.LogDebug("AuthenticateTokenAsync: token format detected: {Format} (fp={TokenFp})", format, tokenFp); + + if (!ValidateToken(token, out var sessionId)) + { + logger.LogWarning("AuthenticateTokenAsync: token validation failed (format={Format}, fp={TokenFp})", format, tokenFp); + return (false, null, "Invalid token."); + } + + logger.LogDebug("AuthenticateTokenAsync: token validated, sessionId={SessionId} (fp={TokenFp})", sessionId, tokenFp); + + // Try cache first + var cacheKey = $"{AuthCacheConstants.Prefix}{sessionId}"; + var session = await cache.GetAsync(cacheKey); + if (session is not null) + { + logger.LogDebug("AuthenticateTokenAsync: cache hit for {CacheKey}", cacheKey); + var nowHit = SystemClock.Instance.GetCurrentInstant(); + if (session.ExpiredAt.HasValue && session.ExpiredAt < nowHit) + { + logger.LogWarning("AuthenticateTokenAsync: cached session expired (sessionId={SessionId})", sessionId); + return (false, null, "Session has been expired."); + } + logger.LogInformation( + "AuthenticateTokenAsync: success via cache (sessionId={SessionId}, accountId={AccountId}, scopes={ScopeCount}, expiresAt={ExpiresAt})", + sessionId, + session.AccountId, + session.Challenge.Scopes.Count, + session.ExpiredAt + ); + return (true, session, null); + } + + logger.LogDebug("AuthenticateTokenAsync: cache miss for {CacheKey}, loading from DB", cacheKey); + + session = await db.AuthSessions + .AsNoTracking() + .Include(e => e.Challenge) + .ThenInclude(e => e.Client) + .Include(e => e.Account) + .ThenInclude(e => e.Profile) + .FirstOrDefaultAsync(s => s.Id == sessionId); + + if (session is null) + { + logger.LogWarning("AuthenticateTokenAsync: session not found (sessionId={SessionId})", sessionId); + return (false, null, "Session was not found."); + } + + var now = SystemClock.Instance.GetCurrentInstant(); + if (session.ExpiredAt.HasValue && session.ExpiredAt < now) + { + logger.LogWarning("AuthenticateTokenAsync: session expired (sessionId={SessionId}, expiredAt={ExpiredAt}, now={Now})", sessionId, session.ExpiredAt, now); + return (false, null, "Session has been expired."); + } + + logger.LogInformation( + "AuthenticateTokenAsync: DB session loaded (sessionId={SessionId}, accountId={AccountId}, clientId={ClientId}, appId={AppId}, scopes={ScopeCount}, ip={Ip}, uaLen={UaLen})", + sessionId, + session.AccountId, + session.Challenge.ClientId, + session.AppId, + session.Challenge.Scopes.Count, + session.Challenge.IpAddress, + (session.Challenge.UserAgent ?? string.Empty).Length + ); + + logger.LogDebug("AuthenticateTokenAsync: enriching account with subscription (accountId={AccountId})", session.AccountId); + var perk = await subscriptions.GetPerkSubscriptionAsync(session.AccountId); + session.Account.PerkSubscription = perk?.ToReference(); + logger.LogInformation( + "AuthenticateTokenAsync: subscription attached (accountId={AccountId}, hasPerk={HasPerk}, identifier={Identifier}, status={Status}, available={Available})", + session.AccountId, + perk is not null, + perk?.Identifier, + perk?.Status, + perk?.IsAvailable + ); + + await cache.SetWithGroupsAsync( + cacheKey, + session, + [$"{AuthCacheConstants.Prefix}{session.Account.Id}"], + TimeSpan.FromHours(1) + ); + logger.LogDebug("AuthenticateTokenAsync: cached session with key {CacheKey} (groups=[{GroupKey}])", + cacheKey, + $"{AuthCacheConstants.Prefix}{session.Account.Id}"); + + logger.LogInformation( + "AuthenticateTokenAsync: success via DB (sessionId={SessionId}, accountId={AccountId}, clientId={ClientId})", + sessionId, + session.AccountId, + session.Challenge.ClientId + ); + return (true, session, null); + } + catch (Exception ex) + { + logger.LogError(ex, "AuthenticateTokenAsync: unexpected error"); + return (false, null, "Authentication error."); + } + } + + public bool ValidateToken(string token, out Guid sessionId) + { + sessionId = Guid.Empty; + + try + { + var parts = token.Split('.'); + + switch (parts.Length) + { + case 3: + { + // JWT via OIDC + var (isValid, jwtResult) = oidc.ValidateToken(token); + if (!isValid) return false; + var jti = jwtResult?.Claims.FirstOrDefault(c => c.Type == "jti")?.Value; + if (jti is null) return false; + return Guid.TryParse(jti, out sessionId); + } + case 2: + { + // Compact token + var payloadBytes = Base64UrlDecode(parts[0]); + sessionId = new Guid(payloadBytes); + + var publicKeyPem = File.ReadAllText(config["AuthToken:PublicKeyPath"]!); + using var rsa = RSA.Create(); + rsa.ImportFromPem(publicKeyPem); + + var signature = Base64UrlDecode(parts[1]); + return rsa.VerifyData(payloadBytes, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + } + default: + return false; + } + } + catch + { + return false; + } + } + + private static byte[] Base64UrlDecode(string base64Url) + { + var padded = base64Url + .Replace('-', '+') + .Replace('_', '/'); + + switch (padded.Length % 4) + { + case 2: padded += "=="; break; + case 3: padded += "="; break; + } + + return Convert.FromBase64String(padded); + } +} diff --git a/DysonNetwork.Pass/Startup/ServiceCollectionExtensions.cs b/DysonNetwork.Pass/Startup/ServiceCollectionExtensions.cs index 871d046..0652699 100644 --- a/DysonNetwork.Pass/Startup/ServiceCollectionExtensions.cs +++ b/DysonNetwork.Pass/Startup/ServiceCollectionExtensions.cs @@ -196,6 +196,7 @@ public static class ServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped();