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
@@ -0,0 +1,23 @@
namespace Baya.Domain.Entities.User;
/// <summary>
/// The platform role vocabulary. <see cref="Customer"/> and <see cref="Nurse"/> are the public actor
/// roles a user may self-select (a user can hold both). The admin sub-roles are provisioned internally
/// only and are never self-assignable through any public endpoint.
/// </summary>
public static class RoleNames
{
public const string Customer = "customer";
public const string Nurse = "nurse";
public const string Admin = "admin";
public const string Support = "support";
public const string Finance = "finance";
public const string Moderation = "moderation";
public const string SuperAdmin = "super_admin";
public static readonly IReadOnlyList<string> SelfAssignable = [Customer, Nurse];
public static readonly IReadOnlyList<string> All =
[Customer, Nurse, Admin, Support, Finance, Moderation, SuperAdmin];
}
@@ -1,4 +1,4 @@
using Baya.Domain.Common;
using Baya.Domain.Common;
using Microsoft.AspNetCore.Identity;
namespace Baya.Domain.Entities.User;
@@ -13,10 +13,35 @@ public class User:IdentityUser<int>,IEntity
public string Name { get; set; }
public string FamilyName { get; set; }
public string GeneratedCode { get; set; }
/// <summary>
/// "male" / "female". Load-bearing for same-gender caregiver matching — never defaulted. Not
/// collected at OTP signup; populated later via the profile flow (b3).
/// </summary>
public string Gender { get; set; }
/// <summary>Encrypted at rest. Stays NULL until the KYC pipeline (b6) verifies it — an unverified
/// registration must never look KYC-complete.</summary>
public string NationalId { get; set; }
public DateTimeOffset? NationalIdVerifiedAt { get; set; }
/// <summary>When the phone↔national-id binding was confirmed via Shahkar. Reset to NULL whenever
/// the stored phone changes so b6 re-verifies (enforced centrally on SaveChanges).</summary>
public DateTimeOffset? ShahkarVerifiedAt { get; set; }
/// <summary>Deterministic keyed hash of the (encrypted) phone — carries the UNIQUE index and all
/// equality lookups, since the ciphertext itself is non-deterministic. Synced on SaveChanges.</summary>
public string PhoneHash { get; set; }
public DateTimeOffset? PhoneVerifiedAt { get; set; }
public bool IsActive { get; set; }
public DateTimeOffset? DeletedAt { get; set; }
public ICollection<UserRole> UserRoles { get; set; }
public ICollection<UserLogin> Logins { get; set; }
public ICollection<UserClaim> Claims { get; set; }
public ICollection<UserToken> Tokens { get; set; }
public ICollection<UserRefreshToken> UserRefreshTokens { get; set; }
public ICollection<UserSession> Sessions { get; set; }
}
@@ -1,4 +1,4 @@
using Baya.Domain.Common;
using Baya.Domain.Common;
using Microsoft.AspNetCore.Identity;
namespace Baya.Domain.Entities.User;
@@ -9,4 +9,11 @@ public class UserRole : IdentityUserRole<int>,IEntity
public Role Role { get; set; }
public DateTime CreatedUserRoleDate { get; set; }
/// <summary>Who granted the role — the user themself for the public customer/nurse self-select,
/// an admin for internal RBAC grants. NULL for legacy/seeded rows.</summary>
public int? GrantedById { get; set; }
public DateTimeOffset GrantedAt { get; set; }
/// <summary>A revoked grant is kept as history (global query filter hides it from role reads).</summary>
public DateTimeOffset? RevokedAt { get; set; }
}
@@ -0,0 +1,29 @@
using Baya.Domain.Common;
namespace Baya.Domain.Entities.User;
/// <summary>
/// A revocable refresh-token session. Only the deterministic hash of the refresh token is stored —
/// never the raw token. Each refresh rotates: the presented session is revoked and a new one issued;
/// a token presented against an already-revoked session is treated as a stolen-token signal and all of
/// the user's sessions are revoked.
/// </summary>
public class UserSession : BaseEntity<long>
{
public int UserId { get; set; }
public User User { get; set; }
public string RefreshTokenHash { get; set; }
public string DeviceInfo { get; set; }
public string IpAddress { get; set; }
public bool IsRevoked { get; set; }
public DateTimeOffset? RevokedAt { get; set; }
public DateTimeOffset ExpiresAt { get; set; }
public void Revoke(DateTimeOffset now)
{
IsRevoked = true;
RevokedAt = now;
}
}