using System.Security.Claims; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using ROLAC.API.DTOs.Auth; using ROLAC.API.DTOs.Invitations; using ROLAC.API.Entities; using ROLAC.API.Services; namespace ROLAC.API.Controllers; [ApiController] [Route("api/auth")] public class AuthController : ControllerBase { private const string CookieName = "rolac_rt"; private const int CookieMaxAge = 30 * 24 * 60 * 60; // 30 days in seconds private readonly IAuthService _authService; private readonly IInvitationService _invitations; private readonly UserManager _userManager; private readonly IWebHostEnvironment _env; public AuthController( IAuthService authService, IInvitationService invitations, UserManager userManager, IWebHostEnvironment env) { _authService = authService; _invitations = invitations; _userManager = userManager; _env = env; } // ------------------------------------------------------------------------- // POST /api/auth/login // ------------------------------------------------------------------------- /// Authenticates a user and returns an access token. [HttpPost("login")] [AllowAnonymous] [ProducesResponseType(typeof(LoginResponse), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] public async Task Login([FromBody] LoginRequest request) { try { var ip = HttpContext.Connection.RemoteIpAddress?.ToString(); var device = Request.Headers.UserAgent.FirstOrDefault(); var (response, raw) = await _authService.LoginAsync(request, ip, device); SetRefreshCookie(raw); return Ok(response); } catch (UnauthorizedAccessException ex) { return Unauthorized(new { message = ex.Message }); } } // ------------------------------------------------------------------------- // POST /api/auth/refresh // ------------------------------------------------------------------------- /// /// Rotates the refresh token (read from the HttpOnly cookie) and returns a /// new access token. /// [HttpPost("refresh")] [AllowAnonymous] [ProducesResponseType(typeof(LoginResponse), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] public async Task Refresh() { var raw = Request.Cookies[CookieName]; if (string.IsNullOrEmpty(raw)) return Unauthorized(new { message = "Refresh token not found." }); try { var ip = HttpContext.Connection.RemoteIpAddress?.ToString(); var (response, newRaw) = await _authService.RefreshAsync(raw, ip); SetRefreshCookie(newRaw); return Ok(response); } catch (UnauthorizedAccessException ex) { ClearRefreshCookie(); return Unauthorized(new { message = ex.Message }); } } // ------------------------------------------------------------------------- // GET /api/auth/me // ------------------------------------------------------------------------- /// /// Returns the current user's identity, roles, and effective permissions. /// The SPA calls this on startup and after an admin edits the permission matrix /// to refresh what the UI shows — without forcing a re-login. /// [HttpGet("me")] [Authorize] [ProducesResponseType(typeof(UserInfo), StatusCodes.Status200OK)] public async Task GetMe() { var userId = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub"); if (string.IsNullOrEmpty(userId)) return Unauthorized(); var user = await _userManager.FindByIdAsync(userId); if (user is null || !user.IsActive) return Unauthorized(); var roles = await _userManager.GetRolesAsync(user); return Ok(await _authService.BuildUserInfoAsync(user, roles)); } // ------------------------------------------------------------------------- // GET /api/auth/claims (dev-only diagnostic) // ------------------------------------------------------------------------- /// /// Returns the raw claims ASP.NET Core parsed from the Bearer token. /// Use this to debug 401 vs 403: if you get 200 here, the JWT validates fine; /// if you then get 403 on a protected endpoint the role/permission isn't matching. /// [HttpGet("claims")] [Authorize] public IActionResult GetClaims() { var claims = User.Claims .Select(c => new { c.Type, c.Value }) .ToList(); return Ok(new { isAuthenticated = User.Identity?.IsAuthenticated, authenticationType = User.Identity?.AuthenticationType, name = User.Identity?.Name, claims, }); } // ------------------------------------------------------------------------- // POST /api/auth/logout // ------------------------------------------------------------------------- /// Revokes the current refresh token and clears the cookie. [HttpPost("logout")] [AllowAnonymous] [ProducesResponseType(StatusCodes.Status204NoContent)] public async Task Logout() { var raw = Request.Cookies[CookieName]; if (!string.IsNullOrEmpty(raw)) await _authService.LogoutAsync(raw); ClearRefreshCookie(); return NoContent(); } // ------------------------------------------------------------------------- // POST /api/auth/change-password // ------------------------------------------------------------------------- /// /// Changes the current user's password. Requires the correct current password and a /// new password meeting the configured policy. On success the user's *other* sessions /// are revoked while the current session stays active. /// [HttpPost("change-password")] [Authorize] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status400BadRequest)] public async Task ChangePassword([FromBody] ChangePasswordRequest request) { var userId = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub"); if (string.IsNullOrEmpty(userId)) return Unauthorized(); var currentRefresh = Request.Cookies[CookieName]; var result = await _authService.ChangePasswordAsync( userId, request.CurrentPassword, request.NewPassword, currentRefresh); if (!result.Succeeded) return BadRequest(new { message = string.Join(" ", result.Errors.Select(error => error.Description)), }); return NoContent(); } // ------------------------------------------------------------------------- // GET /api/auth/invitation/validate?token=... // ------------------------------------------------------------------------- /// /// Checks whether an invitation token can still be used. Anonymous so the public /// "set your password" page can decide what to show before the member types anything. /// [HttpGet("invitation/validate")] [AllowAnonymous] [ProducesResponseType(typeof(ValidateInvitationResult), StatusCodes.Status200OK)] public async Task ValidateInvitation([FromQuery] string token) => Ok(await _invitations.ValidateAsync(token)); // ------------------------------------------------------------------------- // POST /api/auth/accept-invitation // ------------------------------------------------------------------------- /// /// Consumes an invitation: sets the account password and, on success, logs the member in /// (issues the access token + refresh cookie) so first login lands straight on the portal. /// [HttpPost("accept-invitation")] [AllowAnonymous] [ProducesResponseType(typeof(LoginResponse), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] public async Task AcceptInvitation([FromBody] AcceptInvitationRequest request) { var (user, error) = await _invitations.AcceptAsync(request.Token, request.NewPassword); if (user is null) return BadRequest(new { message = error }); var ip = HttpContext.Connection.RemoteIpAddress?.ToString(); var device = Request.Headers.UserAgent.FirstOrDefault(); var (response, raw) = await _authService.IssueSessionAsync(user, ip, device); SetRefreshCookie(raw); return Ok(response); } // ------------------------------------------------------------------------- // Private helpers // ------------------------------------------------------------------------- /// /// Secure is set to true everywhere except in the local /// Development environment so that the cookie works over plain HTTP during /// dev and smoke-test runs, but is always HTTPS-only in staging/production. /// private void SetRefreshCookie(string rawToken) => Response.Cookies.Append(CookieName, rawToken, new CookieOptions { HttpOnly = true, Secure = !_env.IsDevelopment(), SameSite = SameSiteMode.Strict, MaxAge = TimeSpan.FromSeconds(CookieMaxAge), Path = "/api/auth", }); private void ClearRefreshCookie() => Response.Cookies.Delete(CookieName, new CookieOptions { HttpOnly = true, Secure = !_env.IsDevelopment(), SameSite = SameSiteMode.Strict, Path = "/api/auth", }); }