diff --git a/src/api/SIGCM2.Api/Controllers/AuthController.cs b/src/api/SIGCM2.Api/Controllers/AuthController.cs index 253ba49..c5ce1f2 100644 --- a/src/api/SIGCM2.Api/Controllers/AuthController.cs +++ b/src/api/SIGCM2.Api/Controllers/AuthController.cs @@ -1,7 +1,11 @@ +using System.IdentityModel.Tokens.Jwt; using FluentValidation; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using SIGCM2.Application.Abstractions; using SIGCM2.Application.Auth.Login; +using SIGCM2.Application.Auth.Logout; +using SIGCM2.Application.Auth.Refresh; namespace SIGCM2.Api.Controllers; @@ -10,19 +14,22 @@ namespace SIGCM2.Api.Controllers; public sealed class AuthController : ControllerBase { private readonly IDispatcher _dispatcher; - private readonly IValidator _validator; + private readonly IValidator _loginValidator; + private readonly IValidator _refreshValidator; - public AuthController(IDispatcher dispatcher, IValidator validator) + public AuthController( + IDispatcher dispatcher, + IValidator loginValidator, + IValidator refreshValidator) { _dispatcher = dispatcher; - _validator = validator; + _loginValidator = loginValidator; + _refreshValidator = refreshValidator; } - /// Authenticates a user and returns a JWT access token. - /// Returns access token and refresh token. - /// Validation error — missing or empty fields. - /// Invalid credentials. + /// Authenticates a user and returns a JWT access token + refresh token. [HttpPost("login")] + [AllowAnonymous] [ProducesResponseType(typeof(LoginResponseDto), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] @@ -30,7 +37,7 @@ public sealed class AuthController : ControllerBase { var command = new LoginCommand(request.Username ?? string.Empty, request.Password ?? string.Empty); - var validation = await _validator.ValidateAsync(command); + var validation = await _loginValidator.ValidateAsync(command); if (!validation.IsValid) { var errors = validation.Errors @@ -42,7 +49,56 @@ public sealed class AuthController : ControllerBase var result = await _dispatcher.Send(command); return Ok(result); } + + /// + /// Rotates a refresh token pair. Accepts an expired access token to extract the user identity. + /// Returns a new access + refresh token pair. Does NOT require Authorization header. + /// + [HttpPost("refresh")] + [AllowAnonymous] + [ProducesResponseType(typeof(RefreshResponseDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task Refresh([FromBody] RefreshRequest request) + { + var command = new RefreshCommand( + request.AccessToken ?? string.Empty, + request.RefreshToken ?? string.Empty); + + var validation = await _refreshValidator.ValidateAsync(command); + if (!validation.IsValid) + { + var errors = validation.Errors + .GroupBy(e => e.PropertyName) + .ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray()); + return BadRequest(new { errors }); + } + + var result = await _dispatcher.Send(command); + return Ok(result); + } + + /// + /// Revokes all active refresh tokens for the authenticated user. + /// Requires a valid Bearer access token. Client must discard local tokens after this call. + /// + [HttpPost("logout")] + [Authorize] + [ProducesResponseType(typeof(LogoutResponseDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task Logout() + { + var sub = User.FindFirst(JwtRegisteredClaimNames.Sub)?.Value; + if (!int.TryParse(sub, out var userId)) + return Unauthorized(); + + var result = await _dispatcher.Send(new LogoutCommand(userId)); + return Ok(result); + } } /// Login request body — nullable to catch missing field scenarios. public sealed record LoginRequest(string? Username, string? Password); + +/// Refresh request body. +public sealed record RefreshRequest(string? AccessToken, string? RefreshToken);