c68cca4f17
Introduce an OTP input box on login/register, surface user roles and a cafe chooser, add a dashboard switch button in the POS screen, and register OTP validators explicitly to survive Docker layer caching. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
186 lines
8.2 KiB
C#
186 lines
8.2 KiB
C#
using FluentValidation;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.AspNetCore.RateLimiting;
|
|
using System.IdentityModel.Tokens.Jwt;
|
|
using System.Security.Claims;
|
|
using Meezi.API.Models.Auth;
|
|
using Meezi.API.Services;
|
|
using Meezi.API.Services;
|
|
using Meezi.Core.Constants;
|
|
using Meezi.Shared;
|
|
|
|
namespace Meezi.API.Controllers;
|
|
|
|
[ApiController]
|
|
[Route("api/auth")]
|
|
public class AuthController : ControllerBase
|
|
{
|
|
private readonly IAuthService _authService;
|
|
private readonly IValidator<SendOtpRequest> _sendOtpValidator;
|
|
private readonly IValidator<VerifyOtpRequest> _verifyOtpValidator;
|
|
private readonly IValidator<RefreshTokenRequest> _refreshValidator;
|
|
private readonly IValidator<RegisterRequest> _registerValidator;
|
|
private readonly IValidator<VerifyRegisterRequest> _verifyRegisterValidator;
|
|
|
|
public AuthController(
|
|
IAuthService authService,
|
|
IValidator<SendOtpRequest> sendOtpValidator,
|
|
IValidator<VerifyOtpRequest> verifyOtpValidator,
|
|
IValidator<RefreshTokenRequest> refreshValidator,
|
|
IValidator<RegisterRequest> registerValidator,
|
|
IValidator<VerifyRegisterRequest> verifyRegisterValidator)
|
|
{
|
|
_authService = authService;
|
|
_sendOtpValidator = sendOtpValidator;
|
|
_verifyOtpValidator = verifyOtpValidator;
|
|
_refreshValidator = refreshValidator;
|
|
_registerValidator = registerValidator;
|
|
_verifyRegisterValidator = verifyRegisterValidator;
|
|
}
|
|
|
|
[HttpPost("send-otp")]
|
|
[EnableRateLimiting("auth-otp")]
|
|
[ProducesResponseType(typeof(ApiResponse<SendOtpResponse>), StatusCodes.Status200OK)]
|
|
public async Task<IActionResult> SendOtp([FromBody] SendOtpRequest request, CancellationToken cancellationToken)
|
|
{
|
|
var validation = await _sendOtpValidator.ValidateAsync(request, cancellationToken);
|
|
if (!validation.IsValid)
|
|
return BadRequest(ValidationError(validation));
|
|
|
|
var (success, data, code, message) = await _authService.SendOtpAsync(request, cancellationToken);
|
|
if (!success)
|
|
return ErrorResult(code!, message!);
|
|
|
|
return Ok(new ApiResponse<SendOtpResponse>(true, data));
|
|
}
|
|
|
|
[HttpPost("verify-otp")]
|
|
[ProducesResponseType(typeof(ApiResponse<AuthTokenResponse>), StatusCodes.Status200OK)]
|
|
public async Task<IActionResult> VerifyOtp([FromBody] VerifyOtpRequest request, CancellationToken cancellationToken)
|
|
{
|
|
var validation = await _verifyOtpValidator.ValidateAsync(request, cancellationToken);
|
|
if (!validation.IsValid)
|
|
return BadRequest(ValidationError(validation));
|
|
|
|
var (success, data, code, message, choices) = await _authService.VerifyOtpAsync(request, cancellationToken);
|
|
|
|
if (!success && code == "CHOOSE_CAFE")
|
|
return Ok(new ApiResponse<CafeChoicesResponse>(false, choices, new ApiError("CHOOSE_CAFE", "Please select a café to continue.")));
|
|
|
|
if (!success)
|
|
return ErrorResult(code!, message!);
|
|
|
|
return Ok(new ApiResponse<AuthTokenResponse>(true, data));
|
|
}
|
|
|
|
[HttpPost("switch-cafe")]
|
|
[Authorize]
|
|
[ProducesResponseType(typeof(ApiResponse<AuthTokenResponse>), StatusCodes.Status200OK)]
|
|
public async Task<IActionResult> SwitchCafe([FromBody] SwitchCafeRequest request, CancellationToken cancellationToken)
|
|
{
|
|
var userId = User.FindFirstValue(JwtRegisteredClaimNames.Sub)
|
|
?? User.FindFirstValue(ClaimTypes.NameIdentifier);
|
|
if (string.IsNullOrEmpty(userId))
|
|
return Unauthorized();
|
|
|
|
var (success, data, code, message) = await _authService.SwitchCafeAsync(userId, request.CafeId, cancellationToken);
|
|
if (!success)
|
|
return ErrorResult(code!, message!);
|
|
|
|
return Ok(new ApiResponse<AuthTokenResponse>(true, data));
|
|
}
|
|
|
|
[HttpPost("refresh")]
|
|
[ProducesResponseType(typeof(ApiResponse<AuthTokenResponse>), StatusCodes.Status200OK)]
|
|
public async Task<IActionResult> Refresh([FromBody] RefreshTokenRequest request, CancellationToken cancellationToken)
|
|
{
|
|
var validation = await _refreshValidator.ValidateAsync(request, cancellationToken);
|
|
if (!validation.IsValid)
|
|
return BadRequest(ValidationError(validation));
|
|
|
|
var (success, data, code, message) = await _authService.RefreshAsync(request, cancellationToken);
|
|
if (!success)
|
|
return ErrorResult(code!, message!);
|
|
|
|
return Ok(new ApiResponse<AuthTokenResponse>(true, data));
|
|
}
|
|
|
|
[HttpPost("register")]
|
|
[EnableRateLimiting("auth-otp")]
|
|
[ProducesResponseType(typeof(ApiResponse<SendOtpResponse>), StatusCodes.Status200OK)]
|
|
public async Task<IActionResult> Register([FromBody] RegisterRequest request, CancellationToken cancellationToken)
|
|
{
|
|
var validation = await _registerValidator.ValidateAsync(request, cancellationToken);
|
|
if (!validation.IsValid)
|
|
return BadRequest(ValidationError(validation));
|
|
|
|
var (success, data, code, message) = await _authService.RegisterAsync(request, cancellationToken);
|
|
if (!success)
|
|
return ErrorResult(code!, message!);
|
|
|
|
return Ok(new ApiResponse<SendOtpResponse>(true, data));
|
|
}
|
|
|
|
[HttpPost("verify-register")]
|
|
[ProducesResponseType(typeof(ApiResponse<AuthTokenResponse>), StatusCodes.Status200OK)]
|
|
public async Task<IActionResult> VerifyRegister([FromBody] VerifyRegisterRequest request, CancellationToken cancellationToken)
|
|
{
|
|
var validation = await _verifyRegisterValidator.ValidateAsync(request, cancellationToken);
|
|
if (!validation.IsValid)
|
|
return BadRequest(ValidationError(validation));
|
|
|
|
var (success, data, code, message) = await _authService.VerifyRegisterAsync(request, cancellationToken);
|
|
if (!success)
|
|
return ErrorResult(code!, message!);
|
|
|
|
return Ok(new ApiResponse<AuthTokenResponse>(true, data));
|
|
}
|
|
|
|
[HttpGet("me")]
|
|
[Authorize]
|
|
[ProducesResponseType(typeof(ApiResponse<AuthTokenResponse>), StatusCodes.Status200OK)]
|
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
|
public IActionResult GetMe()
|
|
{
|
|
var userId = User.FindFirstValue(JwtRegisteredClaimNames.Sub)
|
|
?? User.FindFirstValue(ClaimTypes.NameIdentifier)
|
|
?? string.Empty;
|
|
|
|
var expClaim = User.FindFirstValue(JwtRegisteredClaimNames.Exp);
|
|
var expiresAt = expClaim != null && long.TryParse(expClaim, out var exp)
|
|
? DateTimeOffset.FromUnixTimeSeconds(exp).UtcDateTime
|
|
: DateTime.UtcNow;
|
|
|
|
var data = new AuthTokenResponse(
|
|
AccessToken: string.Empty,
|
|
RefreshToken: string.Empty,
|
|
ExpiresAt: expiresAt,
|
|
UserId: userId,
|
|
CafeId: User.FindFirstValue(MeeziClaimTypes.CafeId) ?? string.Empty,
|
|
Role: User.FindFirstValue(MeeziClaimTypes.Role) ?? string.Empty,
|
|
PlanTier: User.FindFirstValue(MeeziClaimTypes.PlanTier) ?? string.Empty,
|
|
Language: User.FindFirstValue(MeeziClaimTypes.Language) ?? string.Empty,
|
|
Actor: User.FindFirstValue(MeeziClaimTypes.Actor) ?? MeeziActorKinds.Merchant,
|
|
BranchId: User.FindFirstValue(MeeziClaimTypes.BranchId));
|
|
|
|
return Ok(new ApiResponse<AuthTokenResponse>(true, data));
|
|
}
|
|
|
|
private static ApiResponse<object> ValidationError(FluentValidation.Results.ValidationResult validation)
|
|
{
|
|
var first = validation.Errors.First();
|
|
return new ApiResponse<object>(false, null, new ApiError("VALIDATION_ERROR", first.ErrorMessage, first.PropertyName));
|
|
}
|
|
|
|
private IActionResult ErrorResult(string code, string message) => code switch
|
|
{
|
|
"RATE_LIMITED" => StatusCode(StatusCodes.Status429TooManyRequests,
|
|
new ApiResponse<object>(false, null, new ApiError(code, message))),
|
|
"NOT_FOUND" => NotFound(new ApiResponse<object>(false, null, new ApiError(code, message))),
|
|
"INVALID_OTP" or "INVALID_TOKEN" => Unauthorized(new ApiResponse<object>(false, null, new ApiError(code, message))),
|
|
"ALREADY_REGISTERED" => Conflict(new ApiResponse<object>(false, null, new ApiError(code, message))),
|
|
_ => BadRequest(new ApiResponse<object>(false, null, new ApiError(code, message)))
|
|
};
|
|
}
|