bab3453e41
CI/CD / CI · API (dotnet build + test) (push) Successful in 43s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 32s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m4s
CI/CD / CI · Admin Web (tsc) (push) Successful in 36s
CI/CD / CI · Website (tsc) (push) Successful in 45s
CI/CD / CI · Koja (tsc) (push) Successful in 49s
CI/CD / Deploy · all services (push) Successful in 1m27s
ROOT CAUSE of demo-seed/billing/etc. returning 403 for real owners: .NET's JWT
handler remaps the short "role" claim to ClaimTypes.Role on inbound, so
TenantMiddleware's FindFirst("role") returned null and tenant.Role (EmployeeRole?)
stayed null. EnsureManager/EnsureOwner then rejected even a valid Owner token with
MANAGER_REQUIRED / OWNER_REQUIRED, while reads (no role gate) worked and
[Authorize(Roles=...)] worked (it reads the remapped claim). Now reads the role
under both MeeziClaimTypes.Role ("role") and ClaimTypes.Role. Same fix applied to
the AuthController whoami role. Fixes demo seed, subscription billing, and every
other tenant.Role-gated action.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
234 lines
10 KiB
C#
234 lines
10 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.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("login")]
|
|
[ProducesResponseType(typeof(ApiResponse<AuthTokenResponse>), StatusCodes.Status200OK)]
|
|
public async Task<IActionResult> LoginWithPassword(
|
|
[FromBody] LoginWithPasswordRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Password))
|
|
return BadRequest(ValidationError("Username and password are required."));
|
|
|
|
var (success, data, code, message, choices) = await _authService.LoginWithPasswordAsync(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("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("switch-branch")]
|
|
[Authorize]
|
|
[ProducesResponseType(typeof(ApiResponse<AuthTokenResponse>), StatusCodes.Status200OK)]
|
|
public async Task<IActionResult> SwitchBranch([FromBody] SwitchBranchRequest request, CancellationToken cancellationToken)
|
|
{
|
|
var userId = User.FindFirstValue(JwtRegisteredClaimNames.Sub)
|
|
?? User.FindFirstValue(ClaimTypes.NameIdentifier);
|
|
if (string.IsNullOrEmpty(userId))
|
|
return Unauthorized();
|
|
|
|
var cafeId = User.FindFirstValue(MeeziClaimTypes.CafeId);
|
|
if (string.IsNullOrEmpty(cafeId))
|
|
return Unauthorized();
|
|
|
|
var (success, data, code, message) = await _authService.SwitchBranchAsync(userId, cafeId, request.BranchId, 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,
|
|
// .NET remaps the short "role" claim to ClaimTypes.Role on inbound; read both.
|
|
Role: User.FindFirstValue(MeeziClaimTypes.Role)
|
|
?? User.FindFirstValue(System.Security.Claims.ClaimTypes.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 static ApiResponse<object> ValidationError(string message) =>
|
|
new(false, null, new ApiError("VALIDATION_ERROR", message));
|
|
|
|
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))),
|
|
"BRANCH_FORBIDDEN" => StatusCode(StatusCodes.Status403Forbidden,
|
|
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)))
|
|
};
|
|
}
|