Files
Radicade/Server/Auth/AdminSession.cs
T
2026-10-16 23:33:13 -07:00

163 lines
4.0 KiB
C#

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