Files
baya-monorepo/archive/build-chain/working-context/reports/backend-phase-2-report.md
T
2026-08-02 18:48:32 +03:30

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 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/refresh200 new pair; replaying the old refresh token → 401 and all of the user's sessions are revoked.
  6. POST api/v1/auth/logout200; 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.