- 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>
7.8 KiB
Backend phase 2 report — Identity: phone-OTP auth, sessions & roles (REST)
Date: 2026-07-02 · Gate: dotnet build clean (0 new code warnings) · dotnet test green
(47 pass — 33 Foundation, 4 Identity, 10 new Api) · migration applied to the dev DB · full manual
flow verified against a live run.
What was built
REST auth surface (wraps the existing JWE/TOTP/RBAC engine — nothing rebuilt):
Features/Identity/—RequestOtpCommand,VerifyOtpCommand,RefreshTokenCommand,LogoutCommand,GetMeQuery,SelectRoleCommand(records +internal sealedhandlers + FluentValidation validators; expected failures viaOperationResult).Controllers/V1/AuthController(request_otp/verify_otpon theotprate-limit policy,refreshonauth+RequireTokenWithoutAuthorization,logoutauthenticated) andMeController(GET me,POST me/select_role,[Authorize]).- OTP delivery through the new
ISmsSenderseam (LoggingSmsSendermock in CrossCutting — logs the code, phone masked to last-4; registered inAddCrossCuttingSeams). The old//TODO Send Code Via Sms Providerlog lines inUserCreateCommand/UserTokenRequestQueryhandlers are gone — they call the seam too.
Schema (migration 20260701222425_IdentitySessionsAndUserExtensions, onto b1's baseline):
usr.Users+Gender(NVARCHAR(10) NULL, load-bearing, not collected at signup),NationalId(enc, NULL until b6 KYC),NationalIdVerifiedAt,ShahkarVerifiedAt,PhoneHash(NVARCHAR(64), filtered UNIQUE),PhoneVerifiedAt,IsActive(default 0),DeletedAt+ global soft-delete filter.- New
usr.UserSessions(RefreshTokenHashunique-indexed,DeviceInfo,IpAddress,IsRevoked,RevokedAt,ExpiresAt, audit fields; index(UserId, IsRevoked)). usr.UserRoles+GrantedById(FK→Users NULL) /GrantedAt/RevokedAt, with a globalRevokedAt IS NULLfilter so every role read (Identity store, JWT claims factory,/me) respects revocation automatically.- Data-fix SQL clears pre-b2 plaintext phone/email values (encrypted from now on) and re-activates the seeded admin.
- Roles seeded at startup (
SeedDataBase):customer,nurse,admin,support,finance,moderation,super_admin. platform_configs+auth_otp_resend_seconds(120),auth_otp_max_attempts(5),auth_session_ttl_days(30) — read viaIPlatformConfigat compute time.
Cross-cutting changes (in place, noted per operating-rules §0.3):
ApplicationDbContextnow takesIFieldEncryptor;PhoneNumber/Email/NormalizedEmail/NationalIdare encrypted at rest viaValueConversion/EncryptedStringConverter; a SaveChanges hook syncsPhoneHashand resetsShahkarVerifiedAton any real phone change (rule enforced centrally, no handler can forget it).AppUserManagerImplementation.IsExistUser/GetUserByPhoneNumberandJwtService.GenerateByPhoneNumberAsyncnow look up byPhoneHash(encrypted columns are equality-unqueryable by design).IJwtService.GenerateAccessTokenAsyncadded — access token only; the legacyGenerateAsync(which also writes aUserRefreshTokensrow) still feeds the gRPC path. I deliberately did not useGenerateByPhoneNumberAsyncfor the REST flow (the phase sketch suggested it) because it would double-book a legacy refresh row next to the new session.ICurrentUsergainedIpAddress(session bookkeeping), implemented inHttpContextCurrentUser/ null inNullCurrentUser.OperationResult<T>gainedIsUnauthorized/IsForbidden(+ factories) andBaseControllermaps them to enveloped 401/403 — the phase requires handler-driven 401 (reuse detection) and 403 (admin self-assign), which the envelope previously couldn't express.Program.csskips migrations/seed in the Testing environment and exposespublic partial class ProgramforWebApplicationFactory; Serilog logs to console for Testing.
Design decisions (the "why")
- Rotation + reuse detection: refresh looks the session up by
Hash(token)(raw token never stored). Active → revoke + issue a new pair/session. Already-revoked → stolen-token signal → revoke all the user's sessions, 401. Expired → revoke, 401. - Logout: revokes the presented session (or all, when
everywhere/no token) and rotates the security stamp (the existingRequestLogoutmechanism), so the JWEOnTokenValidatedstamp check kills outstanding access tokens on every device; other devices recover via refresh. - No enumeration:
request_otpreturns the same shape for known/unknown phones;verify_otpreturns one safe message for unknown-phone and wrong-code. - Per-phone resend window lives in the handler behind
ICacheService(keyed by phone hash) — the per-IPotprate-limit policy can't see request bodies. Repeated requests inside the window return200 { otpSent: false, resendAvailableInSeconds }; hammering the endpoint returns429. - Attempt limiting: wrong codes increment Identity's
AccessFailedCount; atauth_otp_max_attemptsverification refuses. Requesting a fresh OTP resets the counter (bounded by the per-IP limit + the ~3-min TOTP window). - New-user accounts are created at
request_otpwith a surrogateUserName(u_<guid>) so the plaintext phone never lands inUserName/NormalizedUserName;IsActivestays false until the first successful verify. isNewUser= "phone was not previously confirmed".
What is now testable (and how)
Run the API (Development, reachable SQL Server), then:
POST api/v1/auth/request_otp{"phone":"09120000000"}→200 {otpSent:true}; the code appears in the server log (mock SMS). Immediate repeat →200 {otpSent:false}; >5/min per IP →429.POST api/v1/auth/verify_otpwith the logged code →200access/refresh pair,isNewUser:true,roles:[]; ausr.UserSessionsrow exists (IsRevoked=0).GET api/v1/mewith the bearer →200masked phone, empty roles, flags false,nurseVerificationStatus:"not_started"; without it →401.POST api/v1/me/select_role{"role":"customer"}→200(roles now["customer"]);"nurse"also succeeds (both held);"super_admin"→403.POST api/v1/auth/refresh→200new pair; replaying the old refresh token →401and all of the user's sessions are revoked.POST api/v1/auth/logout→200; the prior access token now fails/mewith401.verify_otpwith a wrong code →400(safe message).
All seven were executed against a live run on 2026-07-02 (plus the 429) — exact behaviour
confirmed. The same flows are automated in Baya.Test.Api (WebApplicationFactory over in-memory
SQLite) and 14 NSubstitute handler unit tests in Baya.Test.Foundation/Identity/.
Mocked / waiting on a real service
ISmsSender→ 🟡 (seemocks-registry.mdfor the make-it-real steps: gateway client package,Seams:Smsconfig keys, swap the registration).- OTP TTL is the TOTP provider's window (~3 min), provider-fixed — the config keys govern resend, attempts, and session TTL.
Contracts produced
dev/contracts/domains/identity-auth.md(routes, shapes, enums, failure semantics, masked-phone note, gender note).dev/contracts/openapi/swagger.v1.jsonrefreshed (22 paths).
Follow-ups
- b3: profiles/patients/addresses/nurse bank accounts; gender/names become settable;
/mecompletion flags read real tables. - b6: Shahkar/KYC populate
NationalId/ShahkarVerifiedAt; realnurseVerificationStatus. - Retire legacy
UserRefreshTokensonce the gRPC path moves to sessions (or is dropped). - Model-validation WRN logs about the
Userssoft-delete filter vs required Identity navs (UserClaim/UserLogin/UserToken/UserRefreshToken) are benign (log-only); tidy if they annoy.