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

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:
soroush.asadi
2026-06-15 15:10:11 +03:30
parent 76d4434581
commit a855cf1d80
19 changed files with 3871 additions and 10 deletions
+39
View File
@@ -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,
+5
View File
@@ -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);