using System.Security.Cryptography; using System.Text.Json; using System.Text.Json.Serialization; using DysonNetwork.Shared.Cache; using DysonNetwork.Shared.Models; using Microsoft.EntityFrameworkCore; using NodaTime; namespace DysonNetwork.Pass.Auth; public class AuthService( AppDatabase db, IConfiguration config, IHttpClientFactory httpClientFactory, IHttpContextAccessor httpContextAccessor, ICacheService cache ) { private HttpContext HttpContext => httpContextAccessor.HttpContext!; public const string AuthCachePrefix = "auth:"; /// /// Detect the risk of the current request to login /// and returns the required steps to login. /// /// The request context /// The account to login /// The required steps to login public async Task DetectChallengeRisk(HttpRequest request, SnAccount account) { // 1) Find out how many authentication factors the account has enabled. var enabledFactors = await db.AccountAuthFactors .Where(f => f.AccountId == account.Id) .Where(f => f.EnabledAt != null) .ToListAsync(); var maxSteps = enabledFactors.Count; // 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.0; // 2) Get login context from recent sessions var recentSessions = await db.AuthSessions .Include(s => s.Challenge) .Where(s => s.AccountId == account.Id) .Where(s => s.LastGrantedAt != null) .OrderByDescending(s => s.LastGrantedAt) .Take(10) .ToListAsync(); var ipAddress = request.HttpContext.Connection.RemoteIpAddress?.ToString(); var userAgent = request.Headers.UserAgent.ToString(); // 3) IP Address Risk Assessment if (string.IsNullOrWhiteSpace(ipAddress)) { riskScore += 10; } else { // 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; } } // 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)); 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; } public async Task CreateSessionForOidcAsync(SnAccount account, Instant time, Guid? customAppId = null) { var challenge = new SnAuthChallenge { AccountId = account.Id, IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString(), UserAgent = HttpContext.Request.Headers.UserAgent, StepRemain = 1, StepTotal = 1, Type = customAppId is not null ? ChallengeType.OAuth : ChallengeType.Oidc }; var session = new SnAuthSession { AccountId = account.Id, CreatedAt = time, LastGrantedAt = time, Challenge = challenge, AppId = customAppId }; db.AuthChallenges.Add(challenge); db.AuthSessions.Add(session); await db.SaveChangesAsync(); return session; } public async Task GetOrCreateDeviceAsync( Guid accountId, string deviceId, string? deviceName = null, ClientPlatform platform = ClientPlatform.Unidentified ) { var device = await db.AuthClients.FirstOrDefaultAsync(d => d.DeviceId == deviceId && d.AccountId == accountId); if (device is not null) return device; device = new SnAuthClient { Platform = platform, DeviceId = deviceId, AccountId = accountId }; if (deviceName is not null) device.DeviceName = deviceName; db.AuthClients.Add(device); await db.SaveChangesAsync(); return device; } public async Task ValidateCaptcha(string token) { if (string.IsNullOrWhiteSpace(token)) return false; var provider = config.GetSection("Captcha")["Provider"]?.ToLower(); var apiSecret = config.GetSection("Captcha")["ApiSecret"]; var client = httpClientFactory.CreateClient(); var jsonOpts = new JsonSerializerOptions { NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals, PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower }; switch (provider) { case "cloudflare": var content = new StringContent($"secret={apiSecret}&response={token}", System.Text.Encoding.UTF8, "application/x-www-form-urlencoded"); var response = await client.PostAsync("https://challenges.cloudflare.com/turnstile/v0/siteverify", content); response.EnsureSuccessStatusCode(); var json = await response.Content.ReadAsStringAsync(); var result = JsonSerializer.Deserialize(json, options: jsonOpts); return result?.Success == true; case "google": content = new StringContent($"secret={apiSecret}&response={token}", System.Text.Encoding.UTF8, "application/x-www-form-urlencoded"); response = await client.PostAsync("https://www.google.com/recaptcha/siteverify", content); response.EnsureSuccessStatusCode(); json = await response.Content.ReadAsStringAsync(); result = JsonSerializer.Deserialize(json, options: jsonOpts); return result?.Success == true; case "hcaptcha": content = new StringContent($"secret={apiSecret}&response={token}", System.Text.Encoding.UTF8, "application/x-www-form-urlencoded"); response = await client.PostAsync("https://hcaptcha.com/siteverify", content); response.EnsureSuccessStatusCode(); json = await response.Content.ReadAsStringAsync(); result = JsonSerializer.Deserialize(json, options: jsonOpts); return result?.Success == true; default: throw new ArgumentException("The server misconfigured for the captcha."); } } /// /// 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 var privateKeyPem = File.ReadAllText(config["AuthToken:PrivateKeyPath"]!); using var rsa = RSA.Create(); rsa.ImportFromPem(privateKeyPem); // Create and return a single token return CreateCompactToken(session.Id, rsa); } /// /// Create a session for a completed challenge, persist it, issue a token, and set the auth cookie. /// Keeps behavior identical to previous controller implementation. /// /// Completed challenge /// Signed compact token /// If challenge not completed or session already exists public async Task CreateSessionAndIssueToken(SnAuthChallenge challenge) { if (challenge.StepRemain != 0) throw new ArgumentException("Challenge not yet completed."); var hasSession = await db.AuthSessions .AnyAsync(e => e.ChallengeId == challenge.Id); if (hasSession) throw new ArgumentException("Session already exists for this challenge."); var now = SystemClock.Instance.GetCurrentInstant(); var session = new SnAuthSession { LastGrantedAt = now, ExpiredAt = now.Plus(Duration.FromDays(7)), AccountId = challenge.AccountId, ChallengeId = challenge.Id }; db.AuthSessions.Add(session); await db.SaveChangesAsync(); var tk = CreateToken(session); // Set cookie using HttpContext var cookieDomain = config["AuthToken:CookieDomain"]!; HttpContext.Response.Cookies.Append(AuthConstants.CookieTokenName, tk, new CookieOptions { HttpOnly = true, Secure = true, SameSite = SameSiteMode.Lax, Domain = cookieDomain, // Effectively never expire client-side (20 years) Expires = DateTime.UtcNow.AddYears(20) }); return tk; } 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 async Task ValidateSudoMode(SnAuthSession session, string? pinCode) { // Check if the session is already in sudo mode (cached) var sudoModeKey = $"accounts:{session.Id}:sudo"; var (found, _) = await cache.GetAsyncWithStatus(sudoModeKey); if (found) { // Session is already in sudo mode return true; } // Check if the user has a pin code var hasPinCode = await db.AccountAuthFactors .Where(f => f.AccountId == session.AccountId) .Where(f => f.EnabledAt != null) .Where(f => f.Type == AccountAuthFactorType.PinCode) .AnyAsync(); if (!hasPinCode) { // User doesn't have a pin code, no validation needed return true; } // If pin code is not provided, we can't validate if (string.IsNullOrEmpty(pinCode)) { return false; } try { // Validate the pin code var isValid = await ValidatePinCode(session.AccountId, pinCode); if (isValid) { // Set session in sudo mode for 5 minutes await cache.SetAsync(sudoModeKey, true, TimeSpan.FromMinutes(5)); } return isValid; } catch (InvalidOperationException) { // No pin code enabled for this account, so validation is successful return true; } } public async Task ValidatePinCode(Guid accountId, string pinCode) { var factor = await db.AccountAuthFactors .Where(f => f.AccountId == accountId) .Where(f => f.EnabledAt != null) .Where(f => f.Type == AccountAuthFactorType.PinCode) .FirstOrDefaultAsync(); if (factor is null) throw new InvalidOperationException("No pin code enabled for this account."); return factor.VerifyPassword(pinCode); } public async Task GetApiKey(Guid id, Guid? accountId = null) { var key = await db.ApiKeys .Include(e => e.Session) .Where(e => e.Id == id) .If(accountId.HasValue, q => q.Where(e => e.AccountId == accountId!.Value)) .FirstOrDefaultAsync(); return key; } public async Task CreateApiKey(Guid accountId, string label, Instant? expiredAt = null) { var key = new SnApiKey { AccountId = accountId, Label = label, Session = new SnAuthSession { AccountId = accountId, ExpiredAt = expiredAt }, }; db.ApiKeys.Add(key); await db.SaveChangesAsync(); return key; } public async Task IssueApiKeyToken(SnApiKey key) { key.Session.LastGrantedAt = SystemClock.Instance.GetCurrentInstant(); db.Update(key.Session); await db.SaveChangesAsync(); var tk = CreateToken(key.Session); return tk; } public async Task RevokeApiKeyToken(SnApiKey key) { db.Remove(key); db.Remove(key.Session); await db.SaveChangesAsync(); } public async Task RotateApiKeyToken(SnApiKey key) { await using var transaction = await db.Database.BeginTransactionAsync(); try { 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 { AccountId = key.AccountId, ExpiredAt = key.Session?.ExpiredAt }; db.AuthSessions.Add(newSession); await db.SaveChangesAsync(); // Update ApiKey to point to new session key.SessionId = newSession.Id; key.Session = newSession; db.ApiKeys.Update(key); await db.SaveChangesAsync(); // 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; } catch { await transaction.RollbackAsync(); throw; } } // 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) { var padded = base64Url .Replace('-', '+') .Replace('_', '/'); switch (padded.Length % 4) { case 2: padded += "=="; break; case 3: padded += "="; break; } return Convert.FromBase64String(padded); } }