using System; using System.Security.Claims; using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; using FinlyticBackend.Services; using FinlyticCore.Models.Auth; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Cors; using Microsoft.AspNetCore.Mvc; namespace FinlyticBackend.Controllers; /// /// DTO representing the current user's profile (AOT-compliant). /// public record UserProfileResponseDto( [property: JsonPropertyName("userId")] string UserId, [property: JsonPropertyName("email")] string Email, [property: JsonPropertyName("fullName")] string FullName, [property: JsonPropertyName("role")] string Role ); /// /// Request payload for changing a user's initial (admin-issued) password. The target user is derived /// from the caller's own JWT identity claim (see ), /// not from this payload, so it deliberately carries no user identifier. /// public record ChangeInitialPasswordDto( [property: JsonPropertyName("newPassword")] string NewPassword ); [ApiController] [Route("api/v1")] public class AuthController : ControllerBase { private readonly IUserService _userService; public AuthController(IUserService userService) { _userService = userService; } /// /// Authenticates a user and returns a JWT token. /// Deliberately the single anonymous authentication entry point of the Gateway (Rules.md §7): a client /// has no JWT to present before it has logged in, so there is no way to require authentication here. /// Every other route in the Gateway requires authentication except three other sanctioned exceptions: /// the Docker healthcheck (GET /health), the static asset-logo endpoint (GET /api/v1/logo/{isin}, /// which image loaders cannot attach a bearer token to), and the Flutter Web SPA fallback file. /// [AllowAnonymous] [HttpPost("auth/login")] public async Task Login([FromBody] LoginRequestDto request, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(request.Email) || string.IsNullOrWhiteSpace(request.Password)) { return BadRequest(new { error = "Email and Password are required." }); } var authResult = await _userService.AuthenticateAsync(request, cancellationToken); if (authResult == null) { return Unauthorized(new { error = "Invalid credentials or user account is inactive." }); } return Ok(authResult); } /// /// Changes the initial password required after an admin-driven account creation or password reset. /// Requires authentication (Rules.md §7): a caller always already holds a valid JWT at this point, /// because issues one immediately, carrying RequiresPasswordChange in the /// response body, before the client ever calls this endpoint. The target user is derived from the /// caller's own token claim rather than accepted as a request parameter, so a caller can never /// change another account's initial password by supplying an arbitrary user identifier. /// [Authorize] [HttpPost("auth/change-initial-password")] public async Task ChangeInitialPassword([FromBody] ChangeInitialPasswordDto request, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(request?.NewPassword)) { return BadRequest(new { error = "NewPassword is required." }); } var userIdClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? User.FindFirst("sub")?.Value; if (!Guid.TryParse(userIdClaim, out var userId)) { return Unauthorized(new { error = "Invalid User Token claim." }); } bool changed = await _userService.ChangeInitialPasswordAsync(userId, request.NewPassword, cancellationToken); if (!changed) { return BadRequest(new { error = "Password change failed. User not found or password change not required." }); } return Ok(new { message = "Password changed successfully. You may now login." }); } /// /// Updates the user's FCM device token. /// [Authorize] [HttpPost("user/fcm-token")] public async Task UpdateFcmToken([FromBody] UpdateFcmTokenRequestDto request, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(request?.FcmToken)) { return BadRequest(new { error = "FcmToken is required." }); } var userIdClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? User.FindFirst("sub")?.Value; if (!Guid.TryParse(userIdClaim, out var userId)) { return Unauthorized(new { error = "Invalid User Token claim." }); } bool success = await _userService.RegisterOrUpdateFcmTokenAsync(userId, request.FcmToken, request.DeviceName ?? "Unknown Device", cancellationToken); if (!success) { return BadRequest(new { error = "Failed to update FCM device token." }); } return Ok(new { message = "FCM device token registered successfully." }); } /// /// Gets the current user's profile details. /// [Authorize] [HttpGet("user/me")] public IActionResult GetCurrentUserProfile() { var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? User.FindFirst("sub")?.Value ?? string.Empty; var email = User.FindFirst(ClaimTypes.Email)?.Value ?? User.FindFirst("email")?.Value ?? string.Empty; var name = User.FindFirst(ClaimTypes.Name)?.Value ?? User.FindFirst("name")?.Value ?? string.Empty; var role = User.FindFirst(ClaimTypes.Role)?.Value ?? User.FindFirst("role")?.Value ?? "User"; return Ok(new UserProfileResponseDto( UserId: userId, Email: email, FullName: name, Role: role )); } }