Files
meezi/src/Meezi.API/Controllers/AuthController.cs
T
soroush.asadi c68cca4f17 Add OTP login flow and multi-cafe role switching
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>
2026-05-29 17:14:46 +03:30

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)))
};
}