feat(auth): admin-issued café recovery key login
CI/CD / CI · API (dotnet build + test) (push) Successful in 5m6s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 1m30s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m10s
CI/CD / CI · Admin Web (tsc) (push) Successful in 38s
CI/CD / CI · Website (tsc) (push) Successful in 46s
CI/CD / CI · Koja (tsc) (push) Successful in 1m0s
CI/CD / Deploy · all services (push) Successful in 5m31s
CI/CD / CI · API (dotnet build + test) (push) Successful in 5m6s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 1m30s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m10s
CI/CD / CI · Admin Web (tsc) (push) Successful in 38s
CI/CD / CI · Website (tsc) (push) Successful in 46s
CI/CD / CI · Koja (tsc) (push) Successful in 1m0s
CI/CD / Deploy · all services (push) Successful in 5m31s
Platform admins can generate a permanent recovery key per café (admin
panel → Cafés). The café Owner uses it to sign in when OTP access is lost;
once authenticated, all server-side data syncs as normal (data is per-café
on the server, the device only caches it).
Backend:
- Cafe.RecoveryKeyHash (SHA-256, unique index) + RecoveryKeyCreatedAt; migration
- RecoveryKeyGenerator util: MZ-XXXXX-XXXXX-XXXXX-XXXXX, ~190-bit entropy,
stored as SHA-256 (API-token pattern — raw key shown once, never retrievable)
- Admin: POST/DELETE /api/admin/cafes/{id}/recovery-key (key returned once);
café list now reports HasRecoveryKey + RecoveryKeyCreatedAt
- Login: POST /api/auth/login-key → exact-hash lookup → resolves café Owner →
issues normal JWT; rate-limited (auth-otp), suspended/no-owner guarded, logged
Admin UI: per-café generate / regenerate / revoke with one-time reveal + copy.
Dashboard login: discreet "ورود با کلید بازیابی" link → key field. fa/en/ar.
86 tests pass; all tsc clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -58,6 +58,23 @@ public class AuthController : ControllerBase
|
||||
return Ok(new ApiResponse<AuthTokenResponse>(true, data));
|
||||
}
|
||||
|
||||
[HttpPost("login-key")]
|
||||
[EnableRateLimiting("auth-otp")]
|
||||
[ProducesResponseType(typeof(ApiResponse<AuthTokenResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> LoginWithRecoveryKey(
|
||||
[FromBody] LoginWithRecoveryKeyRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Key))
|
||||
return BadRequest(ValidationError("Recovery key is required."));
|
||||
|
||||
var (success, data, code, message) = await _authService.LoginWithRecoveryKeyAsync(request, cancellationToken);
|
||||
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)]
|
||||
@@ -224,7 +241,9 @@ public class AuthController : ControllerBase
|
||||
"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))),
|
||||
"INVALID_OTP" or "INVALID_TOKEN" or "INVALID_KEY" => Unauthorized(new ApiResponse<object>(false, null, new ApiError(code, message))),
|
||||
"CAFE_SUSPENDED" or "NO_OWNER" => StatusCode(StatusCodes.Status403Forbidden,
|
||||
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))),
|
||||
|
||||
@@ -5,6 +5,9 @@ public record SendOtpRequest(string Phone);
|
||||
/// <summary>Username + password login (alternative to OTP). Optional cafeId to scope to a specific café.</summary>
|
||||
public record LoginWithPasswordRequest(string Username, string Password, string? CafeId = null);
|
||||
|
||||
/// <summary>Admin-issued recovery key login — logs the café Owner in when OTP access is lost.</summary>
|
||||
public record LoginWithRecoveryKeyRequest(string Key);
|
||||
|
||||
public record VerifyOtpRequest(string Phone, string Code, string? CafeId = null);
|
||||
|
||||
public record RefreshTokenRequest(string RefreshToken);
|
||||
|
||||
@@ -509,6 +509,45 @@ public class AuthService : IAuthService
|
||||
return (true, tokens, null, null, null);
|
||||
}
|
||||
|
||||
public async Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> LoginWithRecoveryKeyAsync(
|
||||
LoginWithRecoveryKeyRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Key))
|
||||
return (false, null, "INVALID_KEY", "Invalid recovery key.");
|
||||
|
||||
var hash = RecoveryKeyGenerator.HashOf(request.Key);
|
||||
|
||||
// Exact-hash lookup — the unique index makes this a single index seek.
|
||||
var cafe = await _db.Cafes
|
||||
.FirstOrDefaultAsync(c => c.RecoveryKeyHash == hash && c.DeletedAt == null, cancellationToken);
|
||||
if (cafe is null)
|
||||
return (false, null, "INVALID_KEY", "Invalid recovery key.");
|
||||
|
||||
if (cafe.IsSuspended)
|
||||
return (false, null, "CAFE_SUSPENDED", "This café is suspended. Contact support.");
|
||||
|
||||
// The key authenticates as the café's Owner.
|
||||
var owner = await _db.Employees
|
||||
.Include(e => e.Cafe)
|
||||
.FirstOrDefaultAsync(
|
||||
e => e.CafeId == cafe.Id && e.Role == EmployeeRole.Owner && e.DeletedAt == null,
|
||||
cancellationToken);
|
||||
if (owner?.Cafe is null)
|
||||
return (false, null, "NO_OWNER", "This café has no owner account.");
|
||||
|
||||
_logger.LogWarning(
|
||||
"Recovery-key login for café {CafeId} as owner {OwnerId}", cafe.Id, owner.Id);
|
||||
|
||||
var membershipDtos = new List<CafeMembershipDto>
|
||||
{
|
||||
new(owner.CafeId, owner.Cafe.Name, owner.Role.ToString(), owner.Cafe.PlanTier.ToString())
|
||||
};
|
||||
|
||||
var tokens = await IssueTokensAsync(owner, owner.Cafe, membershipDtos, null, cancellationToken);
|
||||
return (true, tokens, null, null);
|
||||
}
|
||||
|
||||
private async Task<AuthTokenResponse> IssueTokensAsync(
|
||||
Core.Entities.Employee employee,
|
||||
Core.Entities.Cafe cafe,
|
||||
|
||||
@@ -20,6 +20,11 @@ public interface IAuthService
|
||||
LoginWithPasswordRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Log in the café Owner using an admin-issued permanent recovery key.</summary>
|
||||
Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> LoginWithRecoveryKeyAsync(
|
||||
LoginWithRecoveryKeyRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> SwitchCafeAsync(
|
||||
string employeeId, string targetCafeId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Reference in New Issue
Block a user