diff --git a/DysonNetwork.Pass/Account/AccountService.cs b/DysonNetwork.Pass/Account/AccountService.cs index 3e64ef0..fea2489 100644 --- a/DysonNetwork.Pass/Account/AccountService.cs +++ b/DysonNetwork.Pass/Account/AccountService.cs @@ -11,6 +11,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Localization; using NodaTime; using OtpNet; +using AuthService = DysonNetwork.Pass.Auth.AuthService; namespace DysonNetwork.Pass.Account; @@ -489,7 +490,7 @@ public class AccountService( .ExecuteDeleteAsync(); foreach (var item in sessions) - await cache.RemoveAsync($"{DysonTokenAuthHandler.AuthCachePrefix}{item.Id}"); + await cache.RemoveAsync($"{AuthService.AuthCachePrefix}{item.Id}"); } public async Task DeleteDevice(Account account, string deviceId) @@ -519,7 +520,7 @@ public class AccountService( .ExecuteDeleteAsync(); foreach (var item in sessions) - await cache.RemoveAsync($"{DysonTokenAuthHandler.AuthCachePrefix}{item.Id}"); + await cache.RemoveAsync($"{AuthService.AuthCachePrefix}{item.Id}"); } public async Task CreateContactMethod(Account account, AccountContactType type, string content) diff --git a/DysonNetwork.Pass/Auth/Auth.cs b/DysonNetwork.Pass/Auth/Auth.cs index afb45f3..b35847b 100644 --- a/DysonNetwork.Pass/Auth/Auth.cs +++ b/DysonNetwork.Pass/Auth/Auth.cs @@ -1,13 +1,8 @@ using System.Security.Claims; -using System.Security.Cryptography; using System.Text.Encodings.Web; -using DysonNetwork.Pass.Account; using Microsoft.AspNetCore.Authentication; -using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; -using DysonNetwork.Pass.Auth.OidcProvider.Services; using DysonNetwork.Pass.Handlers; -using DysonNetwork.Pass.Wallet; using DysonNetwork.Shared.Cache; using SystemClock = NodaTime.SystemClock; @@ -38,19 +33,13 @@ public class DysonTokenAuthOptions : AuthenticationSchemeOptions; public class DysonTokenAuthHandler( IOptionsMonitor options, - IConfiguration configuration, ILoggerFactory logger, UrlEncoder encoder, - AppDatabase database, - OidcProviderService oidc, - SubscriptionService subscriptions, - ICacheService cache, + AuthService authService, FlushBufferService fbs ) : AuthenticationHandler(options, logger, encoder) { - public const string AuthCachePrefix = "auth:"; - protected override async Task HandleAuthenticateAsync() { var tokenInfo = _ExtractToken(Request); @@ -60,48 +49,9 @@ public class DysonTokenAuthHandler( try { - var now = SystemClock.Instance.GetCurrentInstant(); - - // Validate token and extract session ID - if (!ValidateToken(tokenInfo.Token, out var sessionId)) - return AuthenticateResult.Fail("Invalid token."); - - // Try to get session from cache first - var session = await cache.GetAsync($"{AuthCachePrefix}{sessionId}"); - - // If not in cache, load from database - if (session is null) - { - session = await database.AuthSessions - .Where(e => e.Id == sessionId) - .Include(e => e.Challenge) - .ThenInclude(e => e.Client) - .Include(e => e.Account) - .ThenInclude(e => e.Profile) - .FirstOrDefaultAsync(); - - if (session is not null) - { - var perk = await subscriptions.GetPerkSubscriptionAsync(session.AccountId); - session.Account.PerkSubscription = perk?.ToReference(); - - // Store in cache for future requests - await cache.SetWithGroupsAsync( - $"auth:{sessionId}", - session, - [$"{AccountService.AccountCachePrefix}{session.Account.Id}"], - TimeSpan.FromHours(1) - ); - } - } - - // Check if the session exists - if (session == null) - return AuthenticateResult.Fail("Session not found."); - - // Check if the session is expired - if (session.ExpiredAt.HasValue && session.ExpiredAt.Value < now) - return AuthenticateResult.Fail("Session expired."); + var (valid, session, message) = await authService.AuthenticateTokenAsync(tokenInfo.Token); + if (!valid || session is null) + return AuthenticateResult.Fail(message ?? "Authentication failed."); // Store user and session in the HttpContext.Items for easy access in controllers Context.Items["CurrentUser"] = session.Account; @@ -145,77 +95,6 @@ public class DysonTokenAuthHandler( } } - private bool ValidateToken(string token, out Guid sessionId) - { - sessionId = Guid.Empty; - - try - { - var parts = token.Split('.'); - - switch (parts.Length) - { - // Handle JWT tokens (3 parts) - case 3: - { - 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); - } - // Handle compact tokens (2 parts) - case 2: - // Original compact token validation logic - try - { - // Decode the payload - var payloadBytes = Base64UrlDecode(parts[0]); - - // Extract session ID - sessionId = new Guid(payloadBytes); - - // Load public key for verification - var publicKeyPem = File.ReadAllText(configuration["AuthToken:PublicKeyPath"]!); - using var rsa = RSA.Create(); - rsa.ImportFromPem(publicKeyPem); - - // Verify signature - var signature = Base64UrlDecode(parts[1]); - return rsa.VerifyData(payloadBytes, signature, HashAlgorithmName.SHA256, - RSASignaturePadding.Pkcs1); - } - catch - { - return false; - } - default: - return false; - } - } - catch (Exception ex) - { - Logger.LogWarning(ex, "Token validation failed"); - 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); - } - private TokenInfo? _ExtractToken(HttpRequest request) { // Check for token in query parameters diff --git a/DysonNetwork.Pass/Auth/AuthService.cs b/DysonNetwork.Pass/Auth/AuthService.cs index 6d5209c..20068bc 100644 --- a/DysonNetwork.Pass/Auth/AuthService.cs +++ b/DysonNetwork.Pass/Auth/AuthService.cs @@ -1,6 +1,9 @@ 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; @@ -13,10 +16,13 @@ public class AuthService( IHttpClientFactory httpClientFactory, IHttpContextAccessor httpContextAccessor, ICacheService cache, - ILogger logger + ILogger logger, + OidcProviderService oidc, + SubscriptionService subscriptions ) { private HttpContext HttpContext => httpContextAccessor.HttpContext!; + public const string AuthCachePrefix = "auth:"; /// /// Detect the risk of the current request to login @@ -74,6 +80,137 @@ 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) { @@ -323,25 +460,35 @@ public class AuthService( try { - // Split the token var parts = token.Split('.'); - if (parts.Length != 2) - return false; - // Decode the payload - var payloadBytes = Base64UrlDecode(parts[0]); + 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); - // Extract session ID - sessionId = new Guid(payloadBytes); + var publicKeyPem = File.ReadAllText(config["AuthToken:PublicKeyPath"]!); + using var rsa = RSA.Create(); + rsa.ImportFromPem(publicKeyPem); - // Load public key for verification - var publicKeyPem = File.ReadAllText(config["AuthToken:PublicKeyPath"]!); - using var rsa = RSA.Create(); - rsa.ImportFromPem(publicKeyPem); - - // Verify signature - var signature = Base64UrlDecode(parts[1]); - return rsa.VerifyData(payloadBytes, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + var signature = Base64UrlDecode(parts[1]); + return rsa.VerifyData(payloadBytes, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + } + default: + return false; + } } catch { diff --git a/DysonNetwork.Pass/Auth/AuthServiceGrpc.cs b/DysonNetwork.Pass/Auth/AuthServiceGrpc.cs index fc4b3b9..b4a8c46 100644 --- a/DysonNetwork.Pass/Auth/AuthServiceGrpc.cs +++ b/DysonNetwork.Pass/Auth/AuthServiceGrpc.cs @@ -20,35 +20,9 @@ public class AuthServiceGrpc( ServerCallContext context ) { - if (!authService.ValidateToken(request.Token, out var sessionId)) - return new AuthenticateResponse { Valid = false, Message = "Invalid token." }; - - var session = await cache.GetAsync($"{DysonTokenAuthHandler.AuthCachePrefix}{sessionId}"); - if (session is not null) - return new AuthenticateResponse { Valid = true, Session = session.ToProtoValue() }; - - 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 == null) - return new AuthenticateResponse { Valid = false, Message = "Session was not found." }; - var now = SystemClock.Instance.GetCurrentInstant(); - if (session.ExpiredAt.HasValue && session.ExpiredAt < now) - return new AuthenticateResponse { Valid = false, Message = "Session has been expired." }; - - var perk = await subscriptions.GetPerkSubscriptionAsync(session.AccountId); - session.Account.PerkSubscription = perk?.ToReference(); - - await cache.SetWithGroupsAsync( - $"auth:{sessionId}", - session, - [$"{Account.AccountService.AccountCachePrefix}{session.Account.Id}"], - TimeSpan.FromHours(1) - ); + var (valid, session, message) = await authService.AuthenticateTokenAsync(request.Token); + if (!valid || session is null) + return new AuthenticateResponse { Valid = false, Message = message ?? "Authentication failed." }; return new AuthenticateResponse { Valid = true, Session = session.ToProtoValue() }; }