159 lines
6.1 KiB
C#
159 lines
6.1 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// DTO representing the current user's profile (AOT-compliant).
|
|
/// </summary>
|
|
public record UserProfileResponseDto(
|
|
[property: JsonPropertyName("userId")] string UserId,
|
|
[property: JsonPropertyName("email")] string Email,
|
|
[property: JsonPropertyName("fullName")] string FullName,
|
|
[property: JsonPropertyName("role")] string Role
|
|
);
|
|
|
|
/// <summary>
|
|
/// 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 <see cref="AuthController.ChangeInitialPassword"/>),
|
|
/// not from this payload, so it deliberately carries no user identifier.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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 (<c>GET /health</c>), the static asset-logo endpoint (<c>GET /api/v1/logo/{isin}</c>,
|
|
/// which image loaders cannot attach a bearer token to), and the Flutter Web SPA fallback file.
|
|
/// </summary>
|
|
[AllowAnonymous]
|
|
[HttpPost("auth/login")]
|
|
public async Task<IActionResult> 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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 <see cref="Login"/> issues one immediately, carrying <c>RequiresPasswordChange</c> 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.
|
|
/// </summary>
|
|
[Authorize]
|
|
[HttpPost("auth/change-initial-password")]
|
|
public async Task<IActionResult> 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." });
|
|
}
|
|
|
|
/// <summary>
|
|
/// Updates the user's FCM device token.
|
|
/// </summary>
|
|
[Authorize]
|
|
[HttpPost("user/fcm-token")]
|
|
public async Task<IActionResult> 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." });
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the current user's profile details.
|
|
/// </summary>
|
|
[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
|
|
));
|
|
}
|
|
} |