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);
}
}