backend phase 2: identity — phone-OTP auth, sessions & roles (REST)

- six REST endpoints (auth/request_otp, verify_otp, refresh, logout, me,
  me/select_role) wrapping the existing JWE/TOTP/RBAC engine
- usr.UserSessions with refresh-token rotation + stolen-token (replay)
  detection → revoke-all + 401; logout rotates the security stamp
- users extended: gender, national_id (enc, NULL until KYC),
  shahkar_verified_at (auto-reset on phone change), phone_hash UNIQUE,
  is_active, deleted_at + soft-delete filter; phone/email/national_id
  encrypted at rest via IFieldEncryptor value converter
- user_roles grant/revoke audit trail + global revoked filter; 7 roles
  seeded; admin sub-roles never self-assignable (403)
- ISmsSender seam (mock logs the OTP code) replaces the TODO log lines
- OperationResult/BaseController learned enveloped 401/403
- auth knobs as platform_configs rows (resend/attempts/session TTL)
- migration IdentitySessionsAndUserExtensions applied to the dev DB
- 24 new tests incl. Baya.Test.Api (WebApplicationFactory over SQLite);
  47 total green, zero new build warnings; swagger snapshot + contract
  (identity-auth.md), handoff, report, mocks-registry updated

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
hamid
2026-07-02 02:34:11 +03:30
parent 94fdcbe0d1
commit 3a51305343
88 changed files with 4619 additions and 91 deletions
@@ -1,6 +1,8 @@
using System.Reflection;
using System.Reflection;
using Baya.Application.Contracts.Common;
using Baya.Domain.Common;
using Baya.Domain.Entities.User;
using Baya.Infrastructure.Persistence.ValueConversion;
using Baya.SharedKernel.Extensions;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
@@ -9,15 +11,46 @@ namespace Baya.Infrastructure.Persistence;
public class ApplicationDbContext: IdentityDbContext<User, Role, int, UserClaim, UserRole, UserLogin, RoleClaim, UserToken>
{
public ApplicationDbContext(DbContextOptions options)
private readonly IFieldEncryptor _fieldEncryptor;
// The encryptor ends up captured inside the cached EF model (value converters), so it must be a
// process-wide singleton with stable keys — which is how the seam is registered.
public ApplicationDbContext(DbContextOptions options, IFieldEncryptor fieldEncryptor)
: base(options)
{
_fieldEncryptor = fieldEncryptor;
base.SavingChanges += OnSavingChanges;
}
private void OnSavingChanges(object sender, SavingChangesEventArgs e)
{
_cleanString();
_syncUserPhoneIntegrity();
}
/// <summary>
/// Keeps the deterministic <c>PhoneHash</c> lookup column in step with the encrypted phone, and
/// enforces the product rule that a phone change invalidates the Shahkar phone↔national-id binding
/// (reset to NULL so b6 re-verifies) — centrally, so no handler can forget it.
/// </summary>
private void _syncUserPhoneIntegrity()
{
foreach (var entry in ChangeTracker.Entries<User>())
{
if (entry.State == EntityState.Added)
{
entry.Entity.PhoneHash = _fieldEncryptor.Hash(entry.Entity.PhoneNumber);
}
else if (entry.State == EntityState.Modified)
{
var phone = entry.Property(u => u.PhoneNumber);
if (!string.Equals(phone.OriginalValue, phone.CurrentValue, StringComparison.Ordinal))
{
entry.Entity.PhoneHash = _fieldEncryptor.Hash(phone.CurrentValue);
entry.Entity.ShahkarVerifiedAt = null;
}
}
}
}
private void _cleanString()
@@ -59,6 +92,16 @@ public class ApplicationDbContext: IdentityDbContext<User, Role, int, UserClaim,
modelBuilder.AddRestrictDeleteBehaviorConvention();
modelBuilder.AddPluralizingTableNameConvention();
// PII encrypted at rest via the seam. These columns are equality-unqueryable by design —
// phone lookups use PhoneHash. Applied here (not in UserConfig) because the converter needs
// the encryptor instance.
var encrypted = new EncryptedStringConverter(_fieldEncryptor);
modelBuilder.Entity<User>(builder =>
{
builder.Property(u => u.PhoneNumber).HasConversion(encrypted);
builder.Property(u => u.Email).HasConversion(encrypted);
builder.Property(u => u.NormalizedEmail).HasConversion(encrypted);
builder.Property(u => u.NationalId).HasConversion(encrypted);
});
}
}