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,114 @@
# 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<T>` 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_<guid>`) 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.
@@ -9,7 +9,7 @@ Status legend: 🔴 not built · 🟡 mocked (seam + fake impl in place) · 🟢
| Seam (interface) | Introduced in | What it fakes | Config keys | Make it real → | Status |
| --- | --- | --- | --- | --- | --- |
| `ISmsSender` | backend-phase-2 | OTP/SMS delivery — logs the code instead of sending | _tbd_ | Implement a Kavenegar/Ghasedak/SMS.ir client; keep idempotency + rate-limit | 🔴 |
| `ISmsSender` | backend-phase-2 | OTP/SMS delivery — `LoggingSmsSender` (`Baya.Infrastructure.CrossCutting/Seams/`) logs the OTP code (phone shown as last-4 only) and returns success; registered singleton in `AddCrossCuttingSeams` | none today; real client will need `Seams:Sms:ApiKey` + `Seams:Sms:SenderLine` (+ gateway base URL) | 1) pick a gateway (Kavenegar/Ghasedak/SMS.ir), add its client package to `Directory.Packages.props`; 2) implement `ISmsSender.SendOtpAsync`/`SendAsync` against it (template/pattern-based OTP send); 3) bind the new `Seams:Sms` options; 4) swap the registration in `AddCrossCuttingSeams` (config-selected) — handlers unchanged; 5) keep the per-phone resend window + `otp` rate-limit policy exactly as-is; test with a real SIM | 🟡 |
| `IObjectStorage` | backend-phase-0/6 | File storage — local-disk store under a scratch root (`LocalDiskObjectStorage`, `Baya.Infrastructure.CrossCutting/Seams/`) | `Seams:ObjectStorage:RootPath` (default: temp dir) | Point at MinIO/S3/ArvanCloud; presigned upload/download; bucket + creds | 🟡 |
| `ICacheService` | backend-phase-0 | Caching — in-memory `IMemoryCache` (`MemoryCacheService`, `Baya.Infrastructure.CrossCutting/Seams/`) | _none_ | Swap to Redis (`StackExchange.Redis`); keep key/TTL scheme | 🟡 |
| `IDistributedLock` | backend-phase-10 | Money-path locks — no-op/in-proc | _tbd_ | Redis lock (RedLock); DB constraint remains the backstop | 🔴 |