Files
2026-06-28 21:59:59 +03:30

26 KiB

Backend Phase 2 — Identity: phone-OTP auth, sessions & roles (REST)

Mission: give the marketplace its front door. The server already has a working phone-OTP/JWE/passwordless-TOTP engine and a dynamic-permission RBAC system — but it is reachable only over gRPC, with OTP delivery stubbed to a log line and no session, refresh-rotation, or role-selection surface. This phase wraps that existing machinery in REST: /auth/otp/request, /auth/otp/verify, /auth/refresh, /auth/logout, /me, /me/role; adds revocable user_sessions with refresh-token rotation + stolen-token (reuse) detection; extends users with the load-bearing identity columns (gender, national_id NULL-until-KYC, shahkar_verified_at); and wires the real ISmsSender seam so an OTP actually leaves the building (mock = log the code). Do not rebuild auth — reuse JwtService, AppUserManager OTP, and the RBAC. After this phase every authenticated feature in the chain has something to authenticate against.

Track: backend · Depends on: b0 (REST surface, rate limiter, cross-cutting seams), b1 (config accessor, notifications, marketplace migration baseline) · Unlocks: every authenticated backend feature; frontend f1-b2 Before you start, read ../_shared/agent-operating-rules.md. It is not optional.


1. Context — where this sits

This is backend phase b2, the root of the human-actor identity domain and the gate everything else hangs off — no booking, profile, verification, or payment work can exist without it. The platform has three actor types — customer (payer/family), nurse (caregiver/seller), admin (back-office) — and phone number is the primary login credential via phone-OTP; email is optional and only required for admin (product/business/01-actors-and-onboarding.md). KYC is role-staged: a customer registers and browses with a verified phone alone; a nurse must later pass the full verification pipeline (b6) before becoming bookable. This phase delivers login + session + role selection — it deliberately stops short of profiles, patients, and bank accounts (those are b3).

What already exists (do not rebuild) — confirmed in the codebase:

  • The whole auth engine. b0 kept the working spine: ASP.NET Core Identity, JWE access tokens (Jwt/JwtService : IJwtServiceGenerateAsync(User), GenerateByPhoneNumberAsync, GetPrincipalFromExpiredToken, RefreshToken(Guid)), phone-OTP passwordless TOTP (AppUserManagerImplementation.GenerateOtpCode / VerifyUserCode, GeneratePhoneNumberConfirmationToken, ChangePhoneNumber; provider constants in Identity/Dtos/CustomIdentityConstants), and the dynamic-permission RBAC (DynamicPermissionRequirement/Handler, DynamicPermissionService.CanAccess, ConstantPolicies.DynamicPermission, IRoleManagerService). Wrap these — never reimplement them.
  • The REST surface & rate limiter. b0 created the first versioned controllers under Baya.Web.Api/Controllers/V1/ (sealed, BaseController, inject ISender, [controller]/[action] snake_case tokens, base.OperationResult(...)), registered LoggingBehavior, and wired AddRateLimiter
    • UseRateLimiter() with named per-IP policies ready for auth/OTP to apply. This phase applies those policies — it does not define new middleware.
  • Cross-cutting seams. b0 introduced and DI-registered IDateTimeProvider, IFieldEncryptor (encrypt/decrypt + deterministic Hash for lookups), ICacheService, IObjectStorage, and INotificationDispatcher. Reuse IFieldEncryptor for the encrypted phone/ email/national_id columns and for the refresh_token_hash; reuse IDateTimeProvider for all expiry/timestamp math. Do not introduce a second clock or encryptor.
  • Config & the first migration baseline. b1 created the marketplace migration baseline, the typed cached platform_configs accessor, and the notifications write path behind INotificationDispatcher. Read OTP TTL / session lifetime / max-attempt knobs through the config accessor — never hardcode. Your new tables migrate onto b1's baseline.
  • ICurrentUser + audit interceptor. b0 wired ICurrentUser (Scoped, HTTP- context-backed, null-object for jobs/tests) and the SaveChanges interceptor that stamps CreatedAt/ModifiedAt/CreatedById/ModifiedById. Handlers never set audit fields by hand.
  • Identity tables & their config. User : IdentityUser<int>, Role, UserRole, UserRefreshToken, the 8 IEntityTypeConfigurations mapping Identity into the usr schema, and Baya.Tests.Setup (in-memory SQLite, full Identity stack incl. passwordless TOTP, real JwtService/AppUserManager). You extend these in placeusers/roles/user_roles keep their baseline columns and gain new ones; user_sessions is a new table off users.

What this phase introduces: the REST auth controllers, the users column extensions, user_sessions (rotation + reuse detection), the role-selection flow over the existing RBAC, and one new seam — ISmsSender (the OTP delivery rail; mock = log the code). The customer national-ID KYC path, push/social login, and org self-onboarding are (DEFERRED) per the product doc — do not build them.

2. Required reading (do this first)

  • ../_shared/agent-operating-rules.md and ../_shared/backend-conventions-checklist.md.
  • Product — business truth. product/business/01-actors-and-onboarding.md — phone-as-primary-credential, the customer↔patient split, role-staged KYC (national_id NULL until KYC), revocable sessions, MVP-vs-DEFERRED. product/overview/platform-summary.md — the four ground truths and where identity sits in the actor model.
  • Product — data model. product/data-model/01-identity-and-access.md — the exact users / user_sessions / roles / user_roles columns and constraints you must mirror (gender NVARCHAR(10) NULL, shahkar_verified_at reset-on-phone-change, phone enc UNIQUE, user_sessions.refresh_token_hash/is_revoked/revoked_at/expires_at, user_roles.granted_by/ granted_at/revoked_at).
  • Engineering truth. server/CLAUDE.mdIdentity & auth, Persistence, Startup wiring, Project map; server/CONVENTIONS.md — §1 routing, §4 controllers, §6 persistence (audit fields, soft-delete, encrypted PII), §11 security (rate limiting), §12 service registration.
  • Contract conventions you must honour. ../../contracts/conventions/api-conventions.md (envelope, status codes, auth header, pagination) and ../../contracts/conventions/money-and-types.md (the gender enum is load-bearingmale/female, never defaulted/dropped). Section 8 publishes dev/contracts/domains/identity-auth.md from the _TEMPLATE.md.
  • Prior handoff & registry. dev/shared-working-context/backend/handoff/after-backend-phase-0.md and after-backend-phase-1.md (what is live), and reports/mocks-registry.md (the ISmsSender row you will fill).
  • Code to read and mirror (do not duplicate): Baya.Application/Features/Users/** (the existing UserCreateCommand, RefreshUserTokenCommand, RequestLogout, GenerateUserToken, TokenRequest/UserTokenRequestQuery — these already drive OTP/JWE and are your reuse targets; note the //TODO Send Code Via Sms Provider log line you are replacing), Baya.Application/Features/Admin/** and Baya.Application/Features/Role/** (role/RBAC patterns), Baya.Infrastructure.Identity/Jwt/JwtService.cs, Baya.Infrastructure.Identity/AppUserManagerImplementation.cs, the existing UserGrpcServices (shows the request→IMediator→token shape you are re-exposing over REST), BaseController, and WebFramework/Swagger/RequireTokenWithoutAuthorizationAttribute (mark /auth/refresh with it).

3. Scope — build this

Vertical slices: entity/migration → command/query handler → controller endpoint → contract. All amounts/IDs follow project types (User.Id is int; UserRefreshToken is Guid-keyed). Reuse the existing OTP/JWE/RBAC plumbing throughout — your handlers orchestrate IAppUserManager, IJwtService, and the new session repository; they do not re-derive tokens or hash passwords.

3.1 Entity & migration changes

Add one EF migration (onto b1's baseline) covering:

  • Extend users (the usr schema Identity table — keep all baseline columns, add via the UserConfig configuration + entity properties on User):
    • gender NVARCHAR(10) NULL — male / female. Load-bearing for same-gender matching downstream; never defaulted or silently dropped. Not collected at OTP signup; settable later via profile (b3) — here it must merely exist and round-trip.
    • national_id (enc) NULLABLE — stays NULL until KYC (b6). Do not add it to any signup/verify request. national_id_verified_at DATETIME2 NULL.
    • shahkar_verified_at DATETIME2 NULL — reset to NULL on phone change (see §5). b6 sets it.
    • phone (enc) — must be UNIQUE (one identity per phone). email (enc) NULLABLE.
    • is_active BIT and deleted_at DATETIME2 NULL (soft-delete) with a global query filter (deleted_at IS NULL) — if the baseline User lacks these, add them; otherwise confirm they map.
    • Encrypted columns route through IFieldEncryptor; the UNIQUE on phone uses a deterministic phone_hash (via IFieldEncryptor.Hash) so an encrypted column can still be uniquely indexed — mirror the iban_hash pattern documented for b3.
  • New user_sessions entity + IEntityTypeConfiguration (in a Persistence/Configuration/UserConfig/ sibling, usr schema), off users:
    • id (PK), user_id (FK → users), refresh_token_hash NVARCHAR (store the hash via IFieldEncryptor.Hash, never the raw token), device_info NVARCHAR NULL, ip_address NVARCHAR NULL, is_revoked BIT, revoked_at DATETIME2 NULL, expires_at DATETIME2 NOT NULL, created_at.
    • Index (user_id, is_revoked) for fast active-session lookup; index on refresh_token_hash for rotation lookups.
  • Extend roles / user_roles (keep baseline RBAC columns): user_roles gains the grant/revoke audit trailgranted_by (FK → users NULL), granted_at DATETIME2, revoked_at DATETIME2 NULL. Ensure the actor roles customer / nurse and the admin sub-roles exist (seed customer/nurse if the b1 seed did not; admin sub-roles support/finance/moderation/super_admin are seeded but only ever assigned internally).

Add an IUserSessionRepository contract in Application/Contracts/Persistence/ and expose it on IUnitOfWork (mirror IUserRefreshTokenRepository); implement in Persistence.

3.2 Commands / queries (Baya.Application/Features/Identity/ — new area, or extend Users/)

Each is a record request + internal sealed handler; input-bearing commands get a FluentValidation validator; expected failures return OperationResult (never throw). Reads are AsNoTracking() + .Select() projections.

  • RequestOtpCommand(phone)RequestOtpResult — normalize/validate the Iranian phone; upsert the users row (create an inactive-until-verified user if new, via the existing UserCreateCommand path / IAppUserManager); call GenerateOtpCode; deliver via ISmsSender (replacing the //TODO Send Code Via Sms Provider log). Return a non-enumerating result (e.g. otp_sent: true, resend_available_in_seconds from config) — do not leak whether the phone already existed.
  • VerifyOtpCommand(phone, code, device_info?)AuthTokensResultVerifyUserCode; on success mark phone_verified_at/is_active, mint a JWE access token (IJwtService.GenerateByPhoneNumberAsync) and a refresh token, and create a user_sessions row storing refresh_token_hash, expires_at (from config), device_info, ip_address (from ICurrentUser/HTTP context). Return { access_token, refresh_token, access_expires_at, refresh_expires_at, is_new_user, roles[] }. On a fresh user roles is empty (drives the f1 role-router to /me/role).
  • RefreshTokenCommand(refresh_token)AuthTokensResult — look up the session by Hash(refresh_token). Rotation + reuse detection: if the session is found and active → mark it revoked, mint a new access+refresh pair, create a new session (rotation). If the presented token hashes to a session that is already revoked → treat as stolen-token reuse: revoke all of that user's active sessions (logout-everywhere) and return 401. Expired session → 401. Mark the controller action with RequireTokenWithoutAuthorizationAttribute (it runs without a valid access token).
  • LogoutCommand(refresh_token? | current session)OperationResult — revoke the current session (is_revoked=true, revoked_at=now); rotate the security stamp via the existing RequestLogout path so the JWE OnTokenValidated security-stamp check rejects the old access token too. Optional everywhere: true revokes all the user's sessions.
  • GetMeQuery()MeResult — current user (id, phone-masked, first/last name, gender, is_active), roles[], profile-completion flags (e.g. has_customer_profile, has_nurse_profile — false for now; b3 populates the underlying tables), and nurse verification status (read from nurse_verifications.status when present; until b6 it returns not_started/null — never read a removed nurse_profiles.verification_status). Projection only; AsNoTracking().
  • SelectRoleCommand(role)MeResult — assign customer or nurse to the current user via user_roles (audit granted_by = self / system, granted_at). Reject any admin sub-role with 403 — admin roles are internal-only and never self-assignable (§5). Idempotent if the role is already held. A user may hold both customer and nurse.

3.3 Controllers (Baya.Web.Api/Controllers/V1/)

Sealed, inherit BaseController, inject ISender, return base.OperationResult(...), snake_case [controller]/[action] routes, CancellationToken threaded. Apply the b0 rate-limit policies as noted.

Method & route (snake_case) Request → handler Auth Rate-limited
POST api/v1/auth/otp/request RequestOtpCommand none yes (per-phone + per-IP OTP policy)
POST api/v1/auth/otp/verify VerifyOtpCommand none yes (verify/brute-force policy)
POST api/v1/auth/refresh RefreshTokenCommand RequireTokenWithoutAuthorization yes (refresh policy)
POST api/v1/auth/logout LogoutCommand authenticated no
GET api/v1/me GetMeQuery authenticated no
POST api/v1/me/role SelectRoleCommand authenticated no

Routes render through the snake_case transformer; document the exact emitted paths in the contract from the published swagger.json (don't assume the token expansion).

3.4 Wire ISmsSender for OTP delivery

Introduce ISmsSender (Application contract) with a real-shaped signature (Task SendOtpAsync(string phone, string code, CancellationToken ct) plus a generic SendAsync(string phone, string message, ...) for later transactional SMS). The mock Infrastructure implementation logs the code (structured, never the full PII number in plaintext beyond what logging policy allows) and returns success; selection is by config/registration (ServiceConfiguration/ extension), never an if (mock) in the handler. RequestOtpCommand calls it after GenerateOtpCode.

3.5 Out of scope (DEFERRED — do not build)

  • Customer national-ID KYC (customer_profiles.national_id_verified_at) — column deferred; never gate browsing on it. → deferred per product/business/01-actors-and-onboarding.md §(c).
  • Push-notification registration, social login, nursing-company (org) self-onboarding. → same doc.
  • Profiles, patients, addresses, nurse bank accounts → b3.
  • The is_verified flip, Shahkar re-verification on phone change (the handler that re-runs Shahkar) → b6. This phase only resets shahkar_verified_at to NULL when the phone changes; it does not run Shahkar.

4. Mocks & seams in this phase

Seam Owner Mock behaviour Registry
ISmsSender introduced here logs the OTP code (structured); returns success. Selected by config; real Kavenegar/Ghasedak client swaps in later (implement the same interface, keep idempotency/rate-limit logic). add row → 🟡
IFieldEncryptor reuse (b0) encrypt/decrypt phone/email/national_id, hash phone/refresh_token. no change
IDateTimeProvider reuse (b0) clock for OTP/session expiry. no change
ICacheService reuse (b0) config-accessor cache (b1) only; not new here. no change
INotificationDispatcher reuse (b0/b1) optional "new login" notification (in-app, b1 write). no change

Record ISmsSender in reports/mocks-registry.md: seam name + file, what's faked, config keys (gateway api-key, sender line, OTP TTL), step-by-step how to make it real (pick gateway, add client package to Directory.Packages.props, implement SendOtpAsync, register over the mock by config), status 🟡.

5. Critical rules you must not get wrong

  • Phone is the primary credential. OTP is the only public login. Email is optional and required only for admin — never make email mandatory, never make it a login key.
  • national_id is NOT collected at signup and stays NULL until KYC (b6). An unverified registration must never look KYC-complete. Do not add national_id to any auth request body.
  • shahkar_verified_at resets to NULL on phone change. Whenever the stored phone changes (via ChangePhoneNumber), null out shahkar_verified_at so b6 re-verifies. Don't silently keep a stale binding.
  • Sessions are revocable with refresh-token rotation + reuse detection. Each refresh rotates (old session revoked, new one issued). A refresh presented against an already-revoked session is a stolen-token signal → revoke all the user's sessions and 401. Store only the hash of the refresh token (IFieldEncryptor.Hash), never the raw token. Logout must invalidate the session server-side (and rotate the security stamp so the access token dies too).
  • Admin roles are NEVER self-assignable via the public /me/role. SelectRoleCommand accepts only customer / nurse; any admin sub-role → 403. Admin provisioning is internal-only and goes through the RBAC grant path (audited granted_by).
  • OTP is rate-limited and codes expire. Apply the b0 rate-limit policies to otp/request, otp/verify, and refresh. Read TTL/max-attempt/lockout knobs from the b1 config accessor. Treat brute-force / lockout as an explicit handled 400/429 state — never an unhandled exception. Do not enumerate accounts: otp/request returns the same shape whether or not the phone existed.
  • users.gender is load-bearing (male/female) for same-gender matching — the column must exist and round-trip cleanly now even though it is populated later; never default it to a value or drop it.
  • Encrypt PII at rest (phone, email, national_id) via IFieldEncryptor; never log plaintext PII or project it into non-authorized responses (/me masks the phone).
  • Reuse, don't rebuild. Tokens come from IJwtService; OTP from IAppUserManager; RBAC from the existing permission system. No parallel JWT issuer, no second OTP generator, no if (mock) branches.

6. Definition of Done

The shared definition-of-done.md, plus:

  • The six endpoints exist, are reachable through Swagger, and return the OperationResult envelope; otp/request/otp/verify/refresh are rate-limited; refresh carries RequireTokenWithoutAuthorization.
  • users is extended (gender, national_id enc NULL, national_id_verified_at, shahkar_verified_at, phone enc UNIQUE via hash, email enc NULL, is_active, deleted_at + soft-delete query filter); user_sessions and the user_roles audit columns exist; one clean migration onto b1's baseline (dotnet build zero new warnings, dotnet ef migrations applies).
  • Refresh rotation works and a replayed/old refresh token is rejected with all sessions revoked; logout invalidates the session and the access token (security-stamp rotation).
  • SelectRoleCommand assigns customer/nurse only; an admin sub-role attempt returns 403.
  • ISmsSender is introduced behind DI, the mock logs the code, the registry row is added (🟡), and RequestOtpCommand calls it (the old TODO log is gone).
  • Handler unit tests (NSubstitute) for verify/refresh/role + at least one WebApplicationFactory integration test per endpoint area (happy path, 401 on /me unauthenticated, 400 on bad OTP, reuse-detection 401 on refresh, 403 on admin self-assign). dotnet test Baya.sln green.
  • The Project map in server/CLAUDE.md notes the new Identity feature area, user_sessions, the auth controllers, and the ISmsSender seam.

7. How to test (what a human can verify after this phase)

A reachable SQL Server is required. Run the API (dotnet run --project src/API/Baya.Web.Api/...), open /swagger.

  1. OTP request logs a code. POST api/v1/auth/otp/request with { "phone": "09120000000" }200 { otp_sent: true, ... }; the OTP code appears in the server log (the ISmsSender mock). Repeat immediately past the limit → 429.
  2. Verify mints tokens + creates a session. POST api/v1/auth/otp/verify with the phone + the logged code → 200 with access_token, refresh_token, expiries, is_new_user: true, roles: []. Confirm a user_sessions row exists (is_revoked=false).
  3. /me returns the user + roles. GET api/v1/me with Authorization: Bearer <access_token>200 with masked phone, empty roles, profile-completion flags false, nurse verification not_started/null. Without the header → 401.
  4. Role selection. POST api/v1/me/role { "role": "customer" }200, /me now shows ["customer"]. { "role": "nurse" } also succeeds (a user can be both). { "role": "super_admin" }403.
  5. Refresh rotates; replay is rejected. POST api/v1/auth/refresh with the refresh token → 200 new pair (old session now revoked). Replay the same old refresh token → 401, and confirm all that user's sessions are revoked (stolen-token detection).
  6. Logout kills the session and the access token. POST api/v1/auth/logout200; the prior access_token now fails /me with 401 (security-stamp rotation), and the session is is_revoked.
  7. Bad OTP. otp/verify with a wrong/expired code → 400 with a safe message (no enumeration).

These steps become the "what is now testable" section of your report.

8. Hand off & document (close the phase)

  • Docs to update (same change): server/CLAUDE.mdProject map (new Features/Identity area, user_sessions table + config, the V1 auth controllers, the ISmsSender seam and where it's registered) and Identity & auth (note OTP delivery is now real-seamed, sessions exist, role-selection flow). If you decided any rule not already in the product docs (e.g. the exact reuse-detection response), reflect it in product/business/01-actors-and-onboarding.md — record decisions, don't invent rules.
  • Contract to write: publish dev/contracts/domains/identity-auth.md from the _TEMPLATE.md — the six endpoints (routes, request/response shapes, auth, rate-limited flags, status codes incl. 401 reuse-detection and 403 admin self-assign), the role enum (customer/nurse public; admin sub-roles internal), the AuthTokensResult / MeResult / RequestOtpResult shared shapes (mark the masked phone), and the gender enum note. Then publish the swagger.json snapshot per openapi/README.md so f1-b2 derives types from it, not by guessing.
  • Handoff & report: write dev/shared-working-context/backend/handoff/after-backend-phase-2.md (auth is live over REST; what f1-b2 can now build — login, OTP, role router, AuthContext roles; what's mocked = SMS delivery), append to backend/STATUS.md, write dev/shared-working-context/reports/backend-phase-2-report.md (what was built, what is testable and exactly how — the §7 steps, what is mocked + how to make ISmsSender real, the identity-auth contract produced, follow-ups: profiles/patients/bank in b3, Shahkar/is_verified in b6), and update reports/mocks-registry.md (the ISmsSender row → 🟡).
  • Memory: save a project memory note for the non-obvious decisions — how REST wraps the existing gRPC/OTP/JWE engine without duplication, the refresh-rotation + reuse-detection design (revoke-all on replay), the phone_hash UNIQUE-on-encrypted pattern, and the rule that admin roles are never self-assignable — with a one-line MEMORY.md pointer.