using System.Security.Cryptography; using System.Text.Json; using Microsoft.EntityFrameworkCore; using NodaTime; namespace DysonNetwork.Sphere.Auth; public class AuthService(AppDatabase db, IConfiguration config, IHttpClientFactory httpClientFactory) { /// /// 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, Account.Account account) { // 1) Find out how many authentication factors the account has enabled. var maxSteps = await db.AccountAuthFactors .Where(f => f.AccountId == account.Id) .Where(f => f.EnabledAt != null) .CountAsync(); // 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; // 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) .Include(s => s.Challenge) .Where(s => s.AccountId == account.Id) .FirstOrDefaultAsync(); // 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.) if (string.IsNullOrWhiteSpace(ipAddress)) riskScore += 1; else { if (!string.IsNullOrEmpty(lastActiveInfo?.Challenge.IpAddress) && !lastActiveInfo.Challenge.IpAddress.Equals(ipAddress, StringComparison.OrdinalIgnoreCase)) riskScore += 1; } // 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) Combine base “maxSteps” (the number of enabled factors) with any accumulated risk score. // You might choose to make “maxSteps + riskScore” your total required steps, // or clamp it to maxSteps if you only want to require existing available factors. var totalRequiredSteps = maxSteps + riskScore; // Clamp the step totalRequiredSteps = Math.Max(Math.Min(totalRequiredSteps, maxSteps), 1); return totalRequiredSteps; } 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 { 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/api/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."); } } public string CreateToken(Session session) { // Load the private key for signing var privateKeyPem = File.ReadAllText(config["Jwt: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["Jwt: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); } }