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:
@@ -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(" ", "");
|
||||
}
|
||||
Reference in New Issue
Block a user