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