using System.Security.Cryptography; using System.Text; using Microsoft.EntityFrameworkCore; using RadicadeServer.Admin; using RadicadeServer.DB; namespace RadicadeServer.Auth; public static class AdminSession { public const string CookieName = "radicade_admin_session"; public const string StateCookieName = "radicade_oauth_state"; public const string RedirectCookieName = "radicade_oauth_redirect"; public static readonly TimeSpan Lifetime = TimeSpan.FromHours(12); public static readonly TimeSpan AbsoluteLifetime = TimeSpan.FromDays(7); private static readonly TimeSpan RotationGrace = TimeSpan.FromMinutes(2); public static async Task CreateAsync(RecDBContext db, long adminUserId) { var token = NewToken(); db.AdminSessions.Add(new AdminSessionRecord { AdminUserId = adminUserId, TokenHash = Hash(token), CreatedAt = DateTime.UtcNow, LastSeenAt = DateTime.UtcNow, ExpiresAt = DateTime.UtcNow.Add(Lifetime) }); await db.SaveChangesAsync(); return token; } public static async Task<(bool Ok, long UserId)> ValidateAsync(RecDBContext db, string? token) { if (string.IsNullOrWhiteSpace(token)) return (false, 0); var now = DateTime.UtcNow; var hash = Hash(token); var record = await db.AdminSessions.FirstOrDefaultAsync(s => s.TokenHash == hash); if (record == null) { record = await db.AdminSessions.FirstOrDefaultAsync(s => s.PreviousTokenHash == hash && s.LastSeenAt > now - RotationGrace); if (record == null) return (false, 0); } if (record.RevokedAt.HasValue || record.ExpiresAt <= now) return (false, 0); var ceiling = record.CreatedAt.Add(AbsoluteLifetime); if (now >= ceiling) return (false, 0); record.LastSeenAt = now; record.ExpiresAt = now.Add(Lifetime) < ceiling ? now.Add(Lifetime) : ceiling; await db.SaveChangesAsync(); return (true, record.AdminUserId); } private static readonly object RotateLock = new(); public static (string Token, bool Rotated) TryRotate(RecDBContext db, string? currentToken) { if (string.IsNullOrWhiteSpace(currentToken)) return ("", false); lock (RotateLock) { var record = db.AdminSessions.FirstOrDefault(s => s.TokenHash == Hash(currentToken)); if (record == null) { record = db.AdminSessions.FirstOrDefault(s => s.PreviousTokenHash == Hash(currentToken)); if (record != null && record.LastSeenAt > DateTime.UtcNow - RotationGrace) return (currentToken, false); return (currentToken, false); } var now = DateTime.UtcNow; var ceiling = record.CreatedAt.Add(AbsoluteLifetime); if (now >= ceiling) return (currentToken, false); var next = NewToken(); record.PreviousTokenHash = record.TokenHash; record.TokenHash = Hash(next); record.LastSeenAt = now; record.ExpiresAt = now.Add(Lifetime) < ceiling ? now.Add(Lifetime) : ceiling; db.SaveChanges(); return (next, true); } } public static async Task RevokeAsync(RecDBContext db, string? token) { if (string.IsNullOrWhiteSpace(token)) return; var record = await db.AdminSessions.FirstOrDefaultAsync(s => s.TokenHash == Hash(token)); if (record == null) return; record.RevokedAt = DateTime.UtcNow; await db.SaveChangesAsync(); } public static async Task RevokeAllAsync(RecDBContext db, long adminUserId) { var sessions = await db.AdminSessions .Where(s => s.AdminUserId == adminUserId && !s.RevokedAt.HasValue) .ToListAsync(); foreach (var session in sessions) session.RevokedAt = DateTime.UtcNow; await db.SaveChangesAsync(); } public static async Task PruneAsync(RecDBContext db) { var cutoff = DateTime.UtcNow.AddDays(-2); var stale = await db.AdminSessions .Where(s => s.RevokedAt.HasValue || s.ExpiresAt < cutoff) .ToListAsync(); if (stale.Count == 0) return; db.AdminSessions.RemoveRange(stale); await db.SaveChangesAsync(); } private static string NewToken() => Convert.ToHexString(RandomNumberGenerator.GetBytes(32)); private static string Hash(string token) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token))); }