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
+7
View File
@@ -54,6 +54,13 @@ public class Cafe : BaseEntity
/// <summary>Café's own SMS sender line number (e.g. 10004346).</summary>
public string? SmsSenderNumber { get; set; }
/// <summary>SHA-256 hex of an admin-generated permanent recovery key. When set,
/// the café Owner can log in with the raw key if they lose OTP access. Null = no
/// key issued. Cleared when the platform admin revokes it.</summary>
public string? RecoveryKeyHash { get; set; }
/// <summary>When the current recovery key was generated (for admin display).</summary>
public DateTime? RecoveryKeyCreatedAt { get; set; }
public ICollection<Branch> Branches { get; set; } = [];
public ICollection<Table> Tables { get; set; } = [];
public ICollection<Employee> Employees { get; set; } = [];
@@ -0,0 +1,45 @@
using System.Security.Cryptography;
namespace Meezi.Core.Utilities;
/// <summary>
/// Admin-generated café recovery keys. The raw key is shown to the admin exactly
/// once; only its SHA-256 hash is stored, looked up at login by exact hash match.
/// A salt is unnecessary (and would break lookup) because the key carries ~190
/// bits of entropy — the same model password-manager / API-token systems use.
/// </summary>
public static class RecoveryKeyGenerator
{
// Crockford-ish base32 alphabet, no easily-confused chars (0/O, 1/I/L).
private const string Alphabet = "23456789ABCDEFGHJKMNPQRSTUVWXYZ";
private const int GroupCount = 4;
private const int GroupSize = 5;
/// <summary>Make a fresh key. Returns the raw key (give to the owner) and its
/// hash (store on the café). Format: <c>MZ-XXXXX-XXXXX-XXXXX-XXXXX</c>.</summary>
public static (string RawKey, string Hash) Generate()
{
var chars = new char[GroupCount * GroupSize];
for (var i = 0; i < chars.Length; i++)
chars[i] = Alphabet[RandomNumberGenerator.GetInt32(Alphabet.Length)];
var groups = new string[GroupCount];
for (var g = 0; g < GroupCount; g++)
groups[g] = new string(chars, g * GroupSize, GroupSize);
var rawKey = "MZ-" + string.Join("-", groups);
return (rawKey, HashOf(rawKey));
}
/// <summary>SHA-256 hex of a normalized key, for storage and exact-match lookup.</summary>
public static string HashOf(string rawKey)
{
var normalized = Normalize(rawKey);
var bytes = SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(normalized));
return Convert.ToHexString(bytes).ToLowerInvariant();
}
/// <summary>Uppercase, trim, and collapse spaces so a hand-typed key still matches.</summary>
public static string Normalize(string rawKey) =>
rawKey.Trim().ToUpperInvariant().Replace(" ", "");
}