mirror of
https://github.com/JogmansonReal/Radicade.git
synced 2026-10-25 23:18:56 -07:00
128 lines
4.0 KiB
C#
128 lines
4.0 KiB
C#
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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<AdminUser?> 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 });
|
|
}
|
|
}
|