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
+20 -1
View File
@@ -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))),
+3
View File
@@ -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);
+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);
@@ -45,6 +45,36 @@ public class AdminCafesController : AdminApiControllerBase
return Ok(new ApiResponse<object>(true, new { cafeId, request.FeatureKey, request.IsEnabled }));
}
/// <summary>Generate (or regenerate) a permanent recovery key for the café's
/// Owner. The raw key is returned ONCE — only its hash is stored.</summary>
[HttpPost("{cafeId}/recovery-key")]
public async Task<IActionResult> GenerateRecoveryKey(string cafeId, CancellationToken cancellationToken)
{
var (ok, key, code) = await _platform.GenerateRecoveryKeyAsync(cafeId, cancellationToken);
if (!ok)
{
return code switch
{
"NO_OWNER" => BadRequest(new ApiResponse<object>(false, null,
new ApiError("NO_OWNER", "This café has no owner account to attach a recovery key to."))),
_ => NotFound(new ApiResponse<object>(false, null, new ApiError("NOT_FOUND", "Cafe not found.")))
};
}
return Ok(new ApiResponse<object>(true, new { cafeId, key }));
}
/// <summary>Revoke the café's recovery key (clears the stored hash).</summary>
[HttpDelete("{cafeId}/recovery-key")]
public async Task<IActionResult> RevokeRecoveryKey(string cafeId, CancellationToken cancellationToken)
{
var ok = await _platform.RevokeRecoveryKeyAsync(cafeId, cancellationToken);
if (!ok)
return NotFound(new ApiResponse<object>(false, null, new ApiError("NOT_FOUND", "Cafe not found.")));
return Ok(new ApiResponse<object>(true, new { cafeId }));
}
[HttpGet("{cafeId}/discover-profile")]
public async Task<IActionResult> GetDiscoverProfile(string cafeId, CancellationToken cancellationToken)
{
+3 -1
View File
@@ -39,7 +39,9 @@ public record AdminCafeListItemDto(
bool IsVerified,
int BranchCount,
int EmployeeCount,
DateTime CreatedAt);
DateTime CreatedAt,
bool HasRecoveryKey,
DateTime? RecoveryKeyCreatedAt);
public record AdminCafePatchRequest(
PlanTier? PlanTier,
@@ -5,6 +5,7 @@ using Meezi.Core.Entities;
using Meezi.Core.Enums;
using Meezi.Core.Interfaces;
using Meezi.Core.Platform;
using Meezi.Core.Utilities;
using Meezi.Core.Discover;
using Meezi.Infrastructure.Data;
using Meezi.Infrastructure.Discover;
@@ -23,6 +24,8 @@ public interface IAdminPlatformService
Task<bool> UpdateFeatureAsync(string featureKey, UpdateFeatureRequest request, CancellationToken cancellationToken = default);
Task<IReadOnlyList<AdminCafeListItemDto>> ListCafesAsync(CancellationToken cancellationToken = default);
Task<bool> PatchCafeAsync(string cafeId, AdminCafePatchRequest request, CancellationToken cancellationToken = default);
Task<(bool Success, string? Key, string? ErrorCode)> GenerateRecoveryKeyAsync(string cafeId, CancellationToken cancellationToken = default);
Task<bool> RevokeRecoveryKeyAsync(string cafeId, CancellationToken cancellationToken = default);
Task<bool> SetCafeFeatureOverrideAsync(string cafeId, CafeFeatureOverrideRequest request, CancellationToken cancellationToken = default);
Task<AdminCafeDiscoverProfileDto?> GetCafeDiscoverProfileAsync(string cafeId, CancellationToken cancellationToken = default);
Task<AdminCafeDiscoverProfileDto?> UpsertCafeDiscoverProfileAsync(
@@ -146,10 +149,44 @@ public class AdminPlatformService : IAdminPlatformService
c.IsVerified,
c.Branches.Count,
c.Employees.Count(e => e.DeletedAt == null),
c.CreatedAt))
c.CreatedAt,
c.RecoveryKeyHash != null,
c.RecoveryKeyCreatedAt))
.ToListAsync(cancellationToken);
}
public async Task<(bool Success, string? Key, string? ErrorCode)> GenerateRecoveryKeyAsync(
string cafeId,
CancellationToken cancellationToken = default)
{
var cafe = await _db.Cafes.FirstOrDefaultAsync(c => c.Id == cafeId, cancellationToken);
if (cafe is null) return (false, null, "NOT_FOUND");
// The key logs in as the café's Owner, so refuse to mint one with no owner.
var hasOwner = await _db.Employees.AnyAsync(
e => e.CafeId == cafeId && e.Role == EmployeeRole.Owner && e.DeletedAt == null,
cancellationToken);
if (!hasOwner) return (false, null, "NO_OWNER");
var (rawKey, hash) = RecoveryKeyGenerator.Generate();
cafe.RecoveryKeyHash = hash; // replaces any previous key (revokes old)
cafe.RecoveryKeyCreatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync(cancellationToken);
return (true, rawKey, null);
}
public async Task<bool> RevokeRecoveryKeyAsync(string cafeId, CancellationToken cancellationToken = default)
{
var cafe = await _db.Cafes.FirstOrDefaultAsync(c => c.Id == cafeId, cancellationToken);
if (cafe is null) return false;
cafe.RecoveryKeyHash = null;
cafe.RecoveryKeyCreatedAt = null;
await _db.SaveChangesAsync(cancellationToken);
return true;
}
public async Task<bool> PatchCafeAsync(string cafeId, AdminCafePatchRequest request, CancellationToken cancellationToken = default)
{
var cafe = await _db.Cafes.FirstOrDefaultAsync(c => c.Id == cafeId, cancellationToken);
+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(" ", "");
}
@@ -134,6 +134,10 @@ public class AppDbContext : DbContext
{
e.HasKey(x => x.Id);
e.HasIndex(x => x.Slug).IsUnique();
// Recovery-key login looks up the café by exact hash; Postgres treats
// NULLs as distinct so many cafés without a key coexist fine.
e.HasIndex(x => x.RecoveryKeyHash).IsUnique();
e.Property(x => x.RecoveryKeyHash).HasMaxLength(64);
e.Property(x => x.Name).HasMaxLength(200).IsRequired();
e.Property(x => x.Slug).HasMaxLength(100).IsRequired();
e.Property(x => x.SnappfoodVendorId).HasMaxLength(100);
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,50 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Meezi.Infrastructure.Data.Migrations
{
/// <inheritdoc />
public partial class AddCafeRecoveryKey : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTime>(
name: "RecoveryKeyCreatedAt",
table: "Cafes",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "RecoveryKeyHash",
table: "Cafes",
type: "character varying(64)",
maxLength: 64,
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_Cafes_RecoveryKeyHash",
table: "Cafes",
column: "RecoveryKeyHash",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Cafes_RecoveryKeyHash",
table: "Cafes");
migrationBuilder.DropColumn(
name: "RecoveryKeyCreatedAt",
table: "Cafes");
migrationBuilder.DropColumn(
name: "RecoveryKeyHash",
table: "Cafes");
}
}
}
@@ -360,6 +360,13 @@ namespace Meezi.Infrastructure.Data.Migrations
.IsRequired()
.HasColumnType("text");
b.Property<DateTime?>("RecoveryKeyCreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("RecoveryKeyHash")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<bool>("ShowOnKoja")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
@@ -396,6 +403,9 @@ namespace Meezi.Infrastructure.Data.Migrations
b.HasKey("Id");
b.HasIndex("RecoveryKeyHash")
.IsUnique();
b.HasIndex("Slug")
.IsUnique();