From 5445df3b61864515bb5c27228423f09b0fdb7869 Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Sun, 2 Nov 2025 14:26:07 +0800 Subject: [PATCH] :recycle: Optimized auth service --- DysonNetwork.Pass/Auth/AuthController.cs | 22 +- DysonNetwork.Pass/Auth/AuthService.cs | 252 +++++++++++++++--- DysonNetwork.Pass/Auth/CaptchaController.cs | 48 +++- DysonNetwork.Pass/Auth/CompactTokenService.cs | 95 ------- .../Startup/ServiceCollectionExtensions.cs | 13 +- 5 files changed, 296 insertions(+), 134 deletions(-) delete mode 100644 DysonNetwork.Pass/Auth/CompactTokenService.cs diff --git a/DysonNetwork.Pass/Auth/AuthController.cs b/DysonNetwork.Pass/Auth/AuthController.cs index 788752d..a8bdd52 100644 --- a/DysonNetwork.Pass/Auth/AuthController.cs +++ b/DysonNetwork.Pass/Auth/AuthController.cs @@ -22,7 +22,8 @@ public class AuthController( ActionLogService als, RingService.RingServiceClient pusher, IConfiguration configuration, - IStringLocalizer localizer + IStringLocalizer localizer, + ILogger logger ) : ControllerBase { private readonly string _cookieDomain = configuration["AuthToken:CookieDomain"]!; @@ -111,9 +112,14 @@ public class AuthController( .ThenInclude(e => e.Profile) .FirstOrDefaultAsync(e => e.Id == id); - return challenge is null - ? NotFound("Auth challenge was not found.") - : challenge; + if (challenge is null) + { + logger.LogWarning("GetChallenge: challenge not found (challengeId={ChallengeId}, ip={IpAddress})", + id, HttpContext.Connection.RemoteIpAddress?.ToString()); + return NotFound("Auth challenge was not found."); + } + + return challenge; } [HttpGet("challenge/{id:guid}/factors")] @@ -212,7 +218,7 @@ public class AuthController( throw new ArgumentException("Invalid password."); } } - catch + catch (Exception ex) { challenge.FailedAttempts++; db.Update(challenge); @@ -224,6 +230,10 @@ public class AuthController( }, Request, challenge.Account ); await db.SaveChangesAsync(); + + logger.LogWarning("DoChallenge: authentication failure (challengeId={ChallengeId}, factorId={FactorId}, accountId={AccountId}, failedAttempts={FailedAttempts}, factorType={FactorType}, ip={IpAddress}, uaLength={UaLength})", + challenge.Id, factor.Id, challenge.AccountId, challenge.FailedAttempts, factor.Type, HttpContext.Connection.RemoteIpAddress?.ToString(), (HttpContext.Request.Headers.UserAgent.ToString() ?? "").Length); + return BadRequest("Invalid password."); } @@ -317,4 +327,4 @@ public class AuthController( }); return Ok(); } -} \ No newline at end of file +} diff --git a/DysonNetwork.Pass/Auth/AuthService.cs b/DysonNetwork.Pass/Auth/AuthService.cs index 394b07e..02bb380 100644 --- a/DysonNetwork.Pass/Auth/AuthService.cs +++ b/DysonNetwork.Pass/Auth/AuthService.cs @@ -29,47 +29,152 @@ public class AuthService( public async Task DetectChallengeRisk(HttpRequest request, SnAccount account) { // 1) Find out how many authentication factors the account has enabled. - var maxSteps = await db.AccountAuthFactors + var enabledFactors = await db.AccountAuthFactors .Where(f => f.AccountId == account.Id) .Where(f => f.EnabledAt != null) - .CountAsync(); + .ToListAsync(); + var maxSteps = enabledFactors.Count; - // We’ll accumulate a “risk score” based on various factors. + // We'll accumulate a "risk score" based on various factors. // Then we can decide how many total steps are required for the challenge. - var riskScore = 0; + var riskScore = 0.0; - // 2) Get the remote IP address from the request (if any). - var ipAddress = request.HttpContext.Connection.RemoteIpAddress?.ToString(); - var lastActiveInfo = await db.AuthSessions - .OrderByDescending(s => s.LastGrantedAt) + // 2) Get login context from recent sessions + var recentSessions = await db.AuthSessions .Include(s => s.Challenge) .Where(s => s.AccountId == account.Id) - .FirstOrDefaultAsync(); + .Where(s => s.LastGrantedAt != null) + .OrderByDescending(s => s.LastGrantedAt) + .Take(10) + .ToListAsync(); - // Example check: if IP is missing or in an unusual range, increase the risk. - // (This is just a placeholder; in reality, you’d integrate with GeoIpService or a custom check.) + var ipAddress = request.HttpContext.Connection.RemoteIpAddress?.ToString(); + var userAgent = request.Headers.UserAgent.ToString(); + + // 3) IP Address Risk Assessment if (string.IsNullOrWhiteSpace(ipAddress)) - riskScore += 1; + { + riskScore += 10; + } else { - if (!string.IsNullOrEmpty(lastActiveInfo?.Challenge?.IpAddress) && - !lastActiveInfo.Challenge.IpAddress.Equals(ipAddress, StringComparison.OrdinalIgnoreCase)) - riskScore += 1; + // Check if IP has been used before + var ipPreviouslyUsed = recentSessions.Any(s => s.Challenge?.IpAddress == ipAddress); + if (!ipPreviouslyUsed) + { + riskScore += 8; + } + + // Check geographical distance for last known location + var lastKnownIp = recentSessions.FirstOrDefault(s => !string.IsNullOrWhiteSpace(s.Challenge?.IpAddress))?.Challenge?.IpAddress; + if (!string.IsNullOrWhiteSpace(lastKnownIp) && lastKnownIp != ipAddress) + { + riskScore += 6; + } } - // 3) (Optional) Check how recent the last login was. - // If it was a long time ago, the risk might be higher. - var now = SystemClock.Instance.GetCurrentInstant(); - var daysSinceLastActive = lastActiveInfo?.LastGrantedAt is not null - ? (now - lastActiveInfo.LastGrantedAt.Value).TotalDays - : double.MaxValue; - if (daysSinceLastActive > 30) - riskScore += 1; + // 4) User Agent Analysis + if (string.IsNullOrWhiteSpace(userAgent)) + { + riskScore += 5; + } + else + { + var uaPreviouslyUsed = recentSessions.Any(s => + !string.IsNullOrWhiteSpace(s.Challenge?.UserAgent) && + string.Equals(s.Challenge.UserAgent, userAgent, StringComparison.OrdinalIgnoreCase)); - // 4) Combine base “maxSteps” (the number of enabled factors) with any accumulated risk score. - const int totalRiskScore = 3; - var totalRequiredSteps = (int)Math.Round((float)maxSteps * riskScore / totalRiskScore); - // Clamp the steps + if (!uaPreviouslyUsed) + { + riskScore += 4; + + // Check for suspicious user agent patterns + if (userAgent.Contains("bot", StringComparison.OrdinalIgnoreCase) || + userAgent.Contains("crawler", StringComparison.OrdinalIgnoreCase) || + userAgent.Contains("spider", StringComparison.OrdinalIgnoreCase)) + { + riskScore += 8; + } + } + } + + // 5) Time-based Risk Assessment + var now = SystemClock.Instance.GetCurrentInstant(); + var lastLogin = recentSessions.FirstOrDefault()?.LastGrantedAt; + + if (lastLogin.HasValue) + { + var hoursSinceLastLogin = (now - lastLogin.Value).TotalHours; + + // Very high risk: long time since last activity + if (hoursSinceLastLogin > 720) // 30 days + { + riskScore += 9; + } + else if (hoursSinceLastLogin > 168) // 7 days + { + riskScore += 6; + } + else if (hoursSinceLastLogin > 24) // 24 hours + { + riskScore += 3; + } + } + else + { + // First login ever for this account + riskScore += 7; + } + + // 6) Failed Login Attempts Assessment + var recentFailedChallenges = await db.AuthChallenges + .Where(c => c.AccountId == account.Id) + .Where(c => c.CreatedAt > now.Minus(Duration.FromHours(1))) // Last hour + .Where(c => c.FailedAttempts > 0) + .SumAsync(c => c.FailedAttempts); + + if (recentFailedChallenges > 0) + { + riskScore += Math.Min(recentFailedChallenges * 2, 10); + } + + // 7) Account Security Status + var totalAuthFactors = enabledFactors.Count; + var timedCodeEnabled = enabledFactors.Any(f => f.Type == AccountAuthFactorType.TimedCode); // TOTP-like + var pinCodeEnabled = enabledFactors.Any(f => f.Type == AccountAuthFactorType.PinCode); + + // Bonus for strong security + if (totalAuthFactors >= 2) + riskScore -= 3; + else if (totalAuthFactors == 1) + riskScore -= 1; + + // Additional bonuses for specific factors + if (timedCodeEnabled) riskScore -= 2; // TOTP-like security + if (pinCodeEnabled) riskScore -= 1; + + // 8) Device Trust Assessment + var trustedDeviceIds = recentSessions + .Where(s => s.CreatedAt > now.Minus(Duration.FromDays(30))) // Trust devices from last 30 days + .Select(s => s.Challenge?.ClientId) + .Where(id => id.HasValue) + .Distinct() + .ToList(); + + // If using a trusted device, reduce risk (this will be validated later) + if (trustedDeviceIds.Any()) + { + riskScore -= 1; + } + + // Clamp risk score between 0 and 20 + riskScore = Math.Max(0, Math.Min(riskScore, 20)); + + // 9) Calculate required steps based on risk + var riskWeight = maxSteps > 0 ? riskScore / 20.0 : 0.5; // Default 50% if no factors + var totalRequiredSteps = (int)Math.Round(maxSteps * riskWeight); + + // Minimum 1 step, maximum all enabled factors totalRequiredSteps = Math.Max(Math.Min(totalRequiredSteps, maxSteps), 1); return totalRequiredSteps; @@ -180,6 +285,72 @@ public class AuthService( } } + /// + /// Immediately revoke a session by setting expiry to now and clearing from cache + /// This provides immediate invalidation of tokens and sessions + /// + /// Session ID to revoke + /// True if session was found and revoked, false otherwise + public async Task RevokeSessionAsync(Guid sessionId) + { + var session = await db.AuthSessions.FirstOrDefaultAsync(s => s.Id == sessionId); + if (session == null) + { + return false; + } + + // Set expiry to now (immediate invalidation) + var now = SystemClock.Instance.GetCurrentInstant(); + session.ExpiredAt = now; + db.AuthSessions.Update(session); + + // Clear from cache immediately + var cacheKey = $"{AuthCachePrefix}{session.Id}"; + await cache.RemoveAsync(cacheKey); + + // Clear account-level cache groups that include this session + await cache.RemoveAsync($"{AuthCachePrefix}{session.AccountId}"); + + await db.SaveChangesAsync(); + + return true; + } + + /// + /// Revoke all sessions for an account (logout everywhere) + /// + /// Account ID to revoke all sessions for + /// Number of sessions revoked + public async Task RevokeAllSessionsForAccountAsync(Guid accountId) + { + var sessions = await db.AuthSessions + .Where(s => s.AccountId == accountId && !s.ExpiredAt.HasValue) + .ToListAsync(); + + if (!sessions.Any()) + { + return 0; + } + + var now = SystemClock.Instance.GetCurrentInstant(); + foreach (var session in sessions) + { + session.ExpiredAt = now; + + // Clear individual session cache + var cacheKey = $"{AuthCachePrefix}{session.Id}"; + await cache.RemoveAsync(cacheKey); + } + + // Clear account-level cache + await cache.RemoveAsync($"{AuthCachePrefix}{accountId}"); + + db.AuthSessions.UpdateRange(sessions); + await db.SaveChangesAsync(); + + return sessions.Count; + } + public string CreateToken(SnAuthSession session) { // Load the private key for signing @@ -370,6 +541,21 @@ public class AuthService( { var oldSessionId = key.SessionId; + // Immediately expire old session and clear from cache + if (oldSessionId != Guid.Empty) + { + var oldSession = await db.AuthSessions.FirstOrDefaultAsync(s => s.Id == oldSessionId); + if (oldSession != null) + { + oldSession.ExpiredAt = SystemClock.Instance.GetCurrentInstant(); + db.AuthSessions.Update(oldSession); + + // Clear old session from cache + var oldCacheKey = $"{AuthCachePrefix}{oldSessionId}"; + await cache.RemoveAsync(oldCacheKey); + } + } + // Create new session var newSession = new SnAuthSession { @@ -386,8 +572,14 @@ public class AuthService( db.ApiKeys.Update(key); await db.SaveChangesAsync(); - // Delete old session - await db.AuthSessions.Where(s => s.Id == oldSessionId).ExecuteDeleteAsync(); + // Delete expired old session + if (oldSessionId != Guid.Empty) + { + await db.AuthSessions.Where(s => s.Id == oldSessionId).ExecuteDeleteAsync(); + } + + // Clear account-level cache to ensure new API key is picked up + await cache.RemoveAsync($"{AuthCachePrefix}{key.AccountId}"); await transaction.CommitAsync(); return key; @@ -422,4 +614,4 @@ public class AuthService( return Convert.FromBase64String(padded); } -} \ No newline at end of file +} diff --git a/DysonNetwork.Pass/Auth/CaptchaController.cs b/DysonNetwork.Pass/Auth/CaptchaController.cs index a51c608..1eb0d7a 100644 --- a/DysonNetwork.Pass/Auth/CaptchaController.cs +++ b/DysonNetwork.Pass/Auth/CaptchaController.cs @@ -1,10 +1,15 @@ using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; namespace DysonNetwork.Pass.Auth; [ApiController] [Route("/api/captcha")] -public class CaptchaController(IConfiguration configuration) : ControllerBase +public class CaptchaController( + IConfiguration configuration, + AuthService authService, + ILogger logger +) : ControllerBase { [HttpGet] public IActionResult GetConfiguration() @@ -15,4 +20,43 @@ public class CaptchaController(IConfiguration configuration) : ControllerBase apiKey = configuration["Captcha:ApiKey"], }); } -} \ No newline at end of file + + [HttpPost("verify")] + [EnableRateLimiting("captcha")] + public async Task Verify([FromBody] CaptchaVerifyRequest request) + { + if (string.IsNullOrWhiteSpace(request.Token)) + { + logger.LogWarning("Captcha verification failed: empty token from {IpAddress}", + HttpContext.Connection.RemoteIpAddress?.ToString()); + return BadRequest("Token is required"); + } + + try + { + var isValid = await authService.ValidateCaptcha(request.Token); + + if (!isValid) + { + logger.LogWarning("Captcha verification failed: invalid token from {IpAddress}", + HttpContext.Connection.RemoteIpAddress?.ToString()); + return BadRequest("Invalid captcha token"); + } + + logger.LogInformation("Captcha verification successful from {IpAddress}", + HttpContext.Connection.RemoteIpAddress?.ToString()); + return Ok(); + } + catch (Exception ex) + { + logger.LogError(ex, "Error during captcha verification from {IpAddress}", + HttpContext.Connection.RemoteIpAddress?.ToString()); + return StatusCode(500, "Internal server error"); + } + } + + public class CaptchaVerifyRequest + { + public string Token { get; set; } = string.Empty; + } +} diff --git a/DysonNetwork.Pass/Auth/CompactTokenService.cs b/DysonNetwork.Pass/Auth/CompactTokenService.cs deleted file mode 100644 index 84caa35..0000000 --- a/DysonNetwork.Pass/Auth/CompactTokenService.cs +++ /dev/null @@ -1,95 +0,0 @@ -using System.Security.Cryptography; -using DysonNetwork.Shared.Models; - -namespace DysonNetwork.Pass.Auth; - -public class CompactTokenService(IConfiguration config) -{ - private readonly string _privateKeyPath = config["AuthToken:PrivateKeyPath"] - ?? throw new InvalidOperationException("AuthToken:PrivateKeyPath configuration is missing"); - - public string CreateToken(SnAuthSession session) - { - // Load the private key for signing - var privateKeyPem = File.ReadAllText(_privateKeyPath); - using var rsa = RSA.Create(); - rsa.ImportFromPem(privateKeyPem); - - // Create and return a single token - return CreateCompactToken(session.Id, rsa); - } - - private string CreateCompactToken(Guid sessionId, RSA rsa) - { - // Create the payload: just the session ID - var payloadBytes = sessionId.ToByteArray(); - - // Base64Url encode the payload - var payloadBase64 = Base64UrlEncode(payloadBytes); - - // Sign the payload with RSA-SHA256 - var signature = rsa.SignData(payloadBytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); - - // Base64Url encode the signature - var signatureBase64 = Base64UrlEncode(signature); - - // Combine payload and signature with a dot - return $"{payloadBase64}.{signatureBase64}"; - } - - public bool ValidateToken(string token, out Guid sessionId) - { - sessionId = Guid.Empty; - - try - { - // Split the token - var parts = token.Split('.'); - if (parts.Length != 2) - return false; - - // Decode the payload - var payloadBytes = Base64UrlDecode(parts[0]); - - // Extract session ID - sessionId = new Guid(payloadBytes); - - // 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); - } - catch - { - return false; - } - } - - // Helper methods for Base64Url encoding/decoding - private static string Base64UrlEncode(byte[] data) - { - return Convert.ToBase64String(data) - .TrimEnd('=') - .Replace('+', '-') - .Replace('/', '_'); - } - - private static byte[] Base64UrlDecode(string base64Url) - { - string padded = base64Url - .Replace('-', '+') - .Replace('_', '/'); - - switch (padded.Length % 4) - { - case 2: padded += "=="; break; - case 3: padded += "="; break; - } - - return Convert.FromBase64String(padded); - } -} \ No newline at end of file diff --git a/DysonNetwork.Pass/Startup/ServiceCollectionExtensions.cs b/DysonNetwork.Pass/Startup/ServiceCollectionExtensions.cs index 0f88593..dbe7050 100644 --- a/DysonNetwork.Pass/Startup/ServiceCollectionExtensions.cs +++ b/DysonNetwork.Pass/Startup/ServiceCollectionExtensions.cs @@ -78,6 +78,18 @@ public static class ServiceCollectionExtensions }); services.AddRazorPages(); + // Configure rate limiting + services.AddRateLimiter(options => + { + options.AddFixedWindowLimiter("captcha", opt => + { + opt.Window = TimeSpan.FromMinutes(1); + opt.PermitLimit = 5; // 5 attempts per minute + opt.QueueProcessingOrder = System.Threading.RateLimiting.QueueProcessingOrder.OldestFirst; + opt.QueueLimit = 2; + }); + }); + services.Configure(options => { var supportedCultures = new[] @@ -118,7 +130,6 @@ public static class ServiceCollectionExtensions public static IServiceCollection AddAppBusinessServices(this IServiceCollection services, IConfiguration configuration) { - services.AddScoped(); services.AddScoped(); services.Configure(configuration.GetSection("GeoIP")); services.AddScoped();