# 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 sealed` handlers + FluentValidation validators; expected failures via `OperationResult`). - `Controllers/V1/AuthController` (`request_otp`/`verify_otp` on the `otp` rate-limit policy, `refresh` on `auth` + `RequireTokenWithoutAuthorization`, `logout` authenticated) and `MeController` (`GET me`, `POST me/select_role`, `[Authorize]`). - OTP delivery through the new **`ISmsSender`** seam (`LoggingSmsSender` mock in CrossCutting — logs the code, phone masked to last-4; registered in `AddCrossCuttingSeams`). The old `//TODO Send Code Via Sms Provider` log lines in `UserCreateCommand`/`UserTokenRequestQuery` handlers 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` (`RefreshTokenHash` unique-indexed, `DeviceInfo`, `IpAddress`, `IsRevoked`, `RevokedAt`, `ExpiresAt`, audit fields; index `(UserId, IsRevoked)`). - `usr.UserRoles` + `GrantedById` (FK→Users NULL) / `GrantedAt` / `RevokedAt`, with a global `RevokedAt IS NULL` filter 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 via `IPlatformConfig` at compute time. **Cross-cutting changes (in place, noted per operating-rules §0.3):** - `ApplicationDbContext` now takes `IFieldEncryptor`; `PhoneNumber`/`Email`/`NormalizedEmail`/ `NationalId` are encrypted at rest via `ValueConversion/EncryptedStringConverter`; a SaveChanges hook syncs `PhoneHash` and **resets `ShahkarVerifiedAt` on any real phone change** (rule enforced centrally, no handler can forget it). - `AppUserManagerImplementation.IsExistUser/GetUserByPhoneNumber` and `JwtService.GenerateByPhoneNumberAsync` now look up by `PhoneHash` (encrypted columns are equality-unqueryable by design). - `IJwtService.GenerateAccessTokenAsync` added — access token only; the legacy `GenerateAsync` (which also writes a `UserRefreshTokens` row) still feeds the gRPC path. I deliberately did **not** use `GenerateByPhoneNumberAsync` for the REST flow (the phase sketch suggested it) because it would double-book a legacy refresh row next to the new session. - `ICurrentUser` gained `IpAddress` (session bookkeeping), implemented in `HttpContextCurrentUser` / null in `NullCurrentUser`. - `OperationResult` gained `IsUnauthorized`/`IsForbidden` (+ factories) and `BaseController` maps 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.cs` skips migrations/seed in the **Testing** environment and exposes `public partial class Program` for `WebApplicationFactory`; 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 existing `RequestLogout` mechanism), so the JWE `OnTokenValidated` stamp check kills outstanding access tokens on every device; other devices recover via refresh. - **No enumeration:** `request_otp` returns the same shape for known/unknown phones; `verify_otp` returns 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-IP `otp` rate-limit policy can't see request bodies. Repeated requests inside the window return `200 { otpSent: false, resendAvailableInSeconds }`; hammering the endpoint returns `429`. - **Attempt limiting:** wrong codes increment Identity's `AccessFailedCount`; at `auth_otp_max_attempts` verification 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_otp` with a surrogate `UserName` (`u_`) so the plaintext phone never lands in `UserName`/`NormalizedUserName`; `IsActive` stays 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: 1. `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`. 2. `POST api/v1/auth/verify_otp` with the logged code → `200` access/refresh pair, `isNewUser:true`, `roles:[]`; a `usr.UserSessions` row exists (`IsRevoked=0`). 3. `GET api/v1/me` with the bearer → `200` masked phone, empty roles, flags false, `nurseVerificationStatus:"not_started"`; without it → `401`. 4. `POST api/v1/me/select_role` `{"role":"customer"}` → `200` (roles now `["customer"]`); `"nurse"` also succeeds (both held); `"super_admin"` → `403`. 5. `POST api/v1/auth/refresh` → `200` new pair; **replaying the old refresh token → `401`** and all of the user's sessions are revoked. 6. `POST api/v1/auth/logout` → `200`; the prior access token now fails `/me` with `401`. 7. `verify_otp` with 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` → 🟡** (see `mocks-registry.md` for the make-it-real steps: gateway client package, `Seams:Sms` config 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.json` refreshed (22 paths). ## Follow-ups - **b3:** profiles/patients/addresses/nurse bank accounts; gender/names become settable; `/me` completion flags read real tables. - **b6:** Shahkar/KYC populate `NationalId`/`ShahkarVerifiedAt`; real `nurseVerificationStatus`. - Retire legacy `UserRefreshTokens` once the gRPC path moves to sessions (or is dropped). - Model-validation WRN logs about the `Users` soft-delete filter vs required Identity navs (`UserClaim`/`UserLogin`/`UserToken`/`UserRefreshToken`) are benign (log-only); tidy if they annoy.