3a51305343
- 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>
6.8 KiB
6.8 KiB
Contract — Identity & Auth (backend phase b2)
One-line: phone-OTP login, revocable refresh-token sessions with rotation + reuse detection, the current-user profile (
/me) and public role selection. Assumes../conventions/api-conventions.md+../conventions/money-and-types.md. Source of truth for the machine schema:../openapi/(swagger.v1.json, refreshed for b2).
Status: live as of backend-phase-2 · Frontend consumer: frontend-phase-f1-b2
Exact paths (snake_case transformer output — differs from early sketches that showed
otp/request/me/role):auth/request_otp,auth/verify_otp,auth/refresh,auth/logout,me,me/select_role. JSON bodies are camelCase (confirmed against the live envelope).
Enums used
role(self-selectable):customer|nurse— a user may hold both. Admin sub-roles (admin,support,finance,moderation,super_admin) exist but are internal-only; sending one tome/select_rolereturns403.gender:male|female— load-bearing for same-gender matching; null until the profile flow (b3) sets it; never defaulted.nurseVerificationStatus:not_starteduntil the b6 verification pipeline exists.
Endpoints
POST api/v1/auth/request_otp
- Purpose: send a one-time login code to an Iranian mobile; silently creates an inactive-until-verified account for a new phone.
- Auth: none · Rate-limited: yes (
otpper-IP policy →429) · Idempotency key: no - Request body:
Accepts
{ "phone": "09121112233" }+98…/0098…/Persian digits; normalized server-side to09xxxxxxxxx. - Success
200payload (data):Inside the per-phone resend window the same shape returns with{ "otpSent": true, "resendAvailableInSeconds": 120 }otpSent: falseand the remaining seconds. The shape never reveals whether the phone already had an account (no enumeration). - Failure cases:
400invalid phone;429over the per-IP OTP limit. - Notes: in the mock environment the code is written to the server log (
ISmsSendermock); the OTP itself expires on the TOTP provider's window (~3 min).
POST api/v1/auth/verify_otp
- Purpose: verify the code, activate the account, mint the token pair + a revocable session.
- Auth: none · Rate-limited: yes (
otppolicy) · Idempotency key: no - Request body:
{ "phone": "09121112233", "code": "466036", "deviceInfo": "iPhone 15 / app 1.0 (optional)" } - Success
200payload (data):AuthTokensResult(below).isNewUser: trueon the first successful verify;rolesis empty for a fresh user — route them to role selection. - Failure cases:
400wrong/expired code (same safe message whether the phone exists or the code is wrong — no enumeration);400"too many failed attempts" afterauth_otp_max_attemptswrong codes (request a new OTP to reset);429over limit.
POST api/v1/auth/refresh
- Purpose: rotate the refresh token: the presented session is revoked, a new pair is issued.
- Auth: none required (
RequireTokenWithoutAuthorization— the refresh token is the credential) · Rate-limited: yes (authpolicy) · Idempotency key: no - Request body:
{ "refreshToken": "<64-hex refresh token>", "deviceInfo": "optional" } - Success
200payload (data):AuthTokensResult(isNewUseralwaysfalse). - Failure cases:
401unknown token;401expired session;401reuse-detection — a token that hashes to an already-revoked session is treated as stolen: all of that user's sessions are revoked (logout-everywhere) and the client must sign in again.
POST api/v1/auth/logout
- Purpose: revoke the session server-side and kill outstanding access tokens.
- Auth: authenticated (Bearer) · Rate-limited: no
- Request body: (send
{}at minimum)With{ "refreshToken": "optional — revoke just this session", "everywhere": false }everywhere: trueor norefreshToken, every active session is revoked. - Success
200: empty envelope (nodata). - Failure cases:
401unauthenticated. - Notes: the security stamp rotates on every logout, so all of the user's outstanding access tokens fail immediately (other devices recover by refreshing their still-valid refresh tokens).
GET api/v1/me
- Purpose: the signed-in user's identity, roles and onboarding state (drives the role router).
- Auth: authenticated · Rate-limited: no
- Success
200payload (data):MeResult(below). - Failure cases:
401missing/expired/stamp-invalidated token.
POST api/v1/me/select_role
- Purpose: self-assign a public actor role. Idempotent;
customerandnursecan coexist. - Auth: authenticated · Rate-limited: no
- Request body:
{ "role": "customer" } - Success
200payload (data): the updatedMeResult. - Failure cases:
403for any non-public role (super_admin,support, …);400empty role;401unauthenticated. - Notes: role claims live inside the (JWE) access token — after selecting a role, refresh the
token pair so subsequent role-gated calls carry the new claim.
/mereads roles from the DB and reflects the change immediately.
Shared shapes
-
AuthTokensResult:field type notes accessTokenstring JWE bearer token (send as Authorization: Bearer …)refreshTokenstring 64-hex opaque token; store securely, only its hash exists server-side accessExpiresAtISO-8601 absolute access-token expiry refreshExpiresAtISO-8601 session expiry ( auth_session_ttl_days, default 30d)isNewUserbool trueonly on the first successful verify for the phonerolesstring[] active roles; empty ⇒ send the user to role selection -
MeResult:field type notes idint user id phonestring always masked ( 0912*****33) — full phone is never returnedfirstName/lastNamestring | null null until profile (b3) gendermale/female| nullnull until profile (b3); never defaulted isActivebool phone-verified account rolesstring[] active roles (revoked grants excluded) hasCustomerProfile/hasNurseProfilebool falseuntil b3 populates the profile tablesnurseVerificationStatusstring not_starteduntil b6 -
RequestOtpResult:otpSent(bool) +resendAvailableInSeconds(int).
Changelog
- b2 — initial contract (phone-OTP auth, sessions,
/me, role selection).