using Microsoft.AspNetCore.Cors; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using RadicadeServer.Auth; using RadicadeServer.DB; using RadicadeServer.Utils; namespace RadicadeServer.Admin; [Route("admin/auth")] [EnableCors("AdminPanel")] public class AdminAuthAPI(RecDBContext db) : Controller { private CookieOptions SessionCookieOptions => new() { HttpOnly = true, Secure = true, SameSite = SameSiteMode.Strict, MaxAge = AdminSession.Lifetime, Path = "/" }; public record LoginRequest(string? Username, string? Password); [HttpPost("login")] public async Task Login([FromBody] LoginRequest request) { var username = request.Username?.Trim() ?? ""; var ip = Request.GetClientIp(); if (AdminLoginThrottle.IsLocked(username, ip, out var remaining)) return LockoutResponse(remaining); if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(request.Password)) return Unauthorized(new { error = "invalid_credentials", message = "Invalid username or password." }); var user = await db.AdminUsers.FirstOrDefaultAsync(x => x.Username == username); if (user == null || !AdminSecurity.VerifyPassword(user.PasswordHash, request.Password)) { await Task.Delay(350); AdminLoginThrottle.RecordFailure(username, ip); if (AdminLoginThrottle.IsLocked(username, ip, out remaining)) return LockoutResponse(remaining); return Unauthorized(new { error = "invalid_credentials", message = "Invalid username or password." }); } if (AdminSecurity.NeedsRehash(user.PasswordHash)) { user.PasswordHash = AdminSecurity.HashPassword(request.Password); await db.SaveChangesAsync(); } AdminLoginThrottle.Clear(username, ip); await AdminSession.PruneAsync(db); return await CompleteLoginAsync(user); } private async Task CompleteLoginAsync(AdminUser user) { var token = await AdminSession.CreateAsync(db, user.Id); user.LastLoginAt = DateTime.UtcNow; await db.SaveChangesAsync(); Response.Cookies.Append(AdminSession.CookieName, token, SessionCookieOptions); return Ok(new { ok = true, user = Identity(user) }); } [HttpGet("session")] public async Task Session() { var user = await RequireSessionAsync(); if (user == null) return Unauthorized(new { error = "not_authenticated" }); var (rotated, didRotate) = AdminSession.TryRotate(db, CurrentToken()); if (didRotate && !string.IsNullOrEmpty(rotated)) Response.Cookies.Append(AdminSession.CookieName, rotated, SessionCookieOptions); return Ok(new { user = Identity(user) }); } [HttpGet("me")] public async Task Me() { var user = await RequireSessionAsync(); if (user == null) return Unauthorized(new { error = "not_authenticated" }); return Ok(new { user = Identity(user) }); } [HttpPost("logout")] public async Task Logout() { await AdminSession.RevokeAsync(db, CurrentToken()); Response.Cookies.Delete(AdminSession.CookieName, new CookieOptions { Path = "/" }); return Ok(new { ok = true }); } private string? CurrentToken() => Request.Cookies.TryGetValue(AdminSession.CookieName, out var token) ? token : null; private async Task RequireSessionAsync() { var (ok, userId) = await AdminSession.ValidateAsync(db, CurrentToken()); if (!ok) return null; return await db.AdminUsers.FirstOrDefaultAsync(x => x.Id == userId); } private static object Identity(AdminUser user) => new { id = user.Id, username = user.Username }; private static string Display(string ip) => string.IsNullOrWhiteSpace(ip) ? "an unknown address" : ip; private IActionResult LockoutResponse(TimeSpan remaining) { var seconds = (int)Math.Ceiling(remaining.TotalSeconds); Response.Headers["Retry-After"] = seconds.ToString(); var message = seconds > 60 ? $"Too many failed sign-in attempts. Try again in {(int)Math.Ceiling(remaining.TotalMinutes)} minutes." : $"Too many failed sign-in attempts. Try again in {seconds} seconds."; return StatusCode(429, new { error = "locked_out", message, retry_after_seconds = seconds }); } }