using EchoHub.Core.Constants; using EchoHub.Core.DTOs; using EchoHub.Core.Models; using EchoHub.Server.Auth; using EchoHub.Server.Data; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; using Microsoft.EntityFrameworkCore; namespace EchoHub.Server.Controllers; [ApiController] [Route("api/auth")] [EnableRateLimiting("auth")] public class AuthController : ControllerBase { private readonly EchoHubDbContext _db; private readonly JwtTokenService _jwt; public AuthController(EchoHubDbContext db, JwtTokenService jwt) { _db = db; _jwt = jwt; } [HttpPost("register")] public async Task Register([FromBody] RegisterRequest request) { if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Password)) return BadRequest(new ErrorResponse("Username and password are required.")); if (!ValidationConstants.UsernameRegex().IsMatch(request.Username)) return BadRequest(new ErrorResponse("Username must be 3-50 characters and contain only letters, digits, underscores, or hyphens.")); if (request.Password.Length < 6) return BadRequest(new ErrorResponse("Password must be at least 6 characters.")); if (request.Password.Length > ValidationConstants.MaxPasswordLength) return BadRequest(new ErrorResponse($"Password must not exceed {ValidationConstants.MaxPasswordLength} characters.")); var normalizedUsername = request.Username.ToLowerInvariant().Trim(); if (await _db.Users.AnyAsync(u => u.Username == normalizedUsername)) return Conflict(new ErrorResponse("Username is already taken.")); // First registered user on the server becomes the Owner var isFirstUser = !await _db.Users.AnyAsync(); var user = new User { Id = Guid.NewGuid(), Username = normalizedUsername, PasswordHash = BCrypt.Net.BCrypt.HashPassword(request.Password), DisplayName = request.DisplayName?.Trim(), Role = isFirstUser ? ServerRole.Owner : ServerRole.Member, }; _db.Users.Add(user); await _db.SaveChangesAsync(); var (accessToken, expiresAt) = _jwt.GenerateAccessToken(user); var refreshToken = JwtTokenService.GenerateRefreshToken(); _db.RefreshTokens.Add(new RefreshToken { Id = Guid.NewGuid(), TokenHash = JwtTokenService.HashToken(refreshToken), UserId = user.Id, ExpiresAt = DateTimeOffset.UtcNow.Add(JwtTokenService.RefreshTokenLifetime), }); await _db.SaveChangesAsync(); return Ok(new LoginResponse(accessToken, refreshToken, expiresAt, user.Username, user.DisplayName, user.NicknameColor)); } [HttpPost("login")] public async Task Login([FromBody] LoginRequest request) { if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Password)) return BadRequest(new ErrorResponse("Username and password are required.")); var normalizedUsername = request.Username.ToLowerInvariant().Trim(); var user = await _db.Users.FirstOrDefaultAsync(u => u.Username == normalizedUsername); if (user is null || !BCrypt.Net.BCrypt.Verify(request.Password, user.PasswordHash)) return Unauthorized(new ErrorResponse("Invalid username or password.")); if (user.IsBanned) return Unauthorized(new ErrorResponse("Your account has been banned.")); user.LastSeenAt = DateTimeOffset.UtcNow; await _db.SaveChangesAsync(); var (accessToken, expiresAt) = _jwt.GenerateAccessToken(user); var refreshToken = JwtTokenService.GenerateRefreshToken(); _db.RefreshTokens.Add(new RefreshToken { Id = Guid.NewGuid(), TokenHash = JwtTokenService.HashToken(refreshToken), UserId = user.Id, ExpiresAt = DateTimeOffset.UtcNow.Add(JwtTokenService.RefreshTokenLifetime), }); await _db.SaveChangesAsync(); return Ok(new LoginResponse(accessToken, refreshToken, expiresAt, user.Username, user.DisplayName, user.NicknameColor)); } [HttpPost("refresh")] public async Task Refresh([FromBody] RefreshRequest request) { if (string.IsNullOrWhiteSpace(request.RefreshToken)) return BadRequest(new ErrorResponse("Refresh token is required.")); var tokenHash = JwtTokenService.HashToken(request.RefreshToken); var storedToken = await _db.RefreshTokens .Include(r => r.User) .FirstOrDefaultAsync(r => r.TokenHash == tokenHash); if (storedToken is null || !storedToken.IsActive || storedToken.User is null) return Unauthorized(new ErrorResponse("Invalid or expired refresh token.")); // Revoke old refresh token (rotation) storedToken.RevokedAt = DateTimeOffset.UtcNow; var user = storedToken.User; user.LastSeenAt = DateTimeOffset.UtcNow; // Issue new token pair var (accessToken, expiresAt) = _jwt.GenerateAccessToken(user); var newRefreshToken = JwtTokenService.GenerateRefreshToken(); _db.RefreshTokens.Add(new RefreshToken { Id = Guid.NewGuid(), TokenHash = JwtTokenService.HashToken(newRefreshToken), UserId = user.Id, ExpiresAt = DateTimeOffset.UtcNow.Add(JwtTokenService.RefreshTokenLifetime), }); await _db.SaveChangesAsync(); return Ok(new LoginResponse(accessToken, newRefreshToken, expiresAt, user.Username, user.DisplayName, user.NicknameColor)); } [HttpPost("logout")] public async Task Logout([FromBody] RefreshRequest request) { if (string.IsNullOrWhiteSpace(request.RefreshToken)) return BadRequest(new ErrorResponse("Refresh token is required.")); var tokenHash = JwtTokenService.HashToken(request.RefreshToken); var storedToken = await _db.RefreshTokens.FirstOrDefaultAsync(r => r.TokenHash == tokenHash); if (storedToken is not null && storedToken.IsActive) { storedToken.RevokedAt = DateTimeOffset.UtcNow; await _db.SaveChangesAsync(); } return Ok(); } }