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