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>
131 lines
6.8 KiB
Markdown
131 lines
6.8 KiB
Markdown
# 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/api-conventions.md) +
|
|
> [`../conventions/money-and-types.md`](../conventions/money-and-types.md). Source of truth for the
|
|
> machine schema: [`../openapi/`](../openapi/README.md) (`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 to `me/select_role` returns `403`.
|
|
- `gender`: `male` | `female` — load-bearing for same-gender matching; **null until the profile flow
|
|
(b3) sets it**; never defaulted.
|
|
- `nurseVerificationStatus`: `not_started` until 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 (`otp` per-IP policy → `429`) · **Idempotency key:** no
|
|
- **Request body:**
|
|
```json
|
|
{ "phone": "09121112233" }
|
|
```
|
|
Accepts `+98…`/`0098…`/Persian digits; normalized server-side to `09xxxxxxxxx`.
|
|
- **Success `200` payload (`data`):**
|
|
```json
|
|
{ "otpSent": true, "resendAvailableInSeconds": 120 }
|
|
```
|
|
Inside the per-phone resend window the same shape returns with `otpSent: false` and the remaining
|
|
seconds. **The shape never reveals whether the phone already had an account** (no enumeration).
|
|
- **Failure cases:** `400` invalid phone; `429` over the per-IP OTP limit.
|
|
- **Notes:** in the mock environment the code is written to the server log (`ISmsSender` mock); 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 (`otp` policy) · **Idempotency key:** no
|
|
- **Request body:**
|
|
```json
|
|
{ "phone": "09121112233", "code": "466036", "deviceInfo": "iPhone 15 / app 1.0 (optional)" }
|
|
```
|
|
- **Success `200` payload (`data`):** `AuthTokensResult` (below). `isNewUser: true` on the first
|
|
successful verify; `roles` is empty for a fresh user — **route them to role selection**.
|
|
- **Failure cases:** `400` wrong/expired code (same safe message whether the phone exists or the
|
|
code is wrong — no enumeration); `400` "too many failed attempts" after `auth_otp_max_attempts`
|
|
wrong codes (request a new OTP to reset); `429` over 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 (`auth` policy) · **Idempotency key:** no
|
|
- **Request body:**
|
|
```json
|
|
{ "refreshToken": "<64-hex refresh token>", "deviceInfo": "optional" }
|
|
```
|
|
- **Success `200` payload (`data`):** `AuthTokensResult` (`isNewUser` always `false`).
|
|
- **Failure cases:** `401` unknown token; `401` expired session; **`401` reuse-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)
|
|
```json
|
|
{ "refreshToken": "optional — revoke just this session", "everywhere": false }
|
|
```
|
|
With `everywhere: true` **or no `refreshToken`**, every active session is revoked.
|
|
- **Success `200`:** empty envelope (no `data`).
|
|
- **Failure cases:** `401` unauthenticated.
|
|
- **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 `200` payload (`data`):** `MeResult` (below).
|
|
- **Failure cases:** `401` missing/expired/stamp-invalidated token.
|
|
|
|
### `POST api/v1/me/select_role`
|
|
- **Purpose:** self-assign a public actor role. Idempotent; `customer` and `nurse` can coexist.
|
|
- **Auth:** authenticated · **Rate-limited:** no
|
|
- **Request body:**
|
|
```json
|
|
{ "role": "customer" }
|
|
```
|
|
- **Success `200` payload (`data`):** the updated `MeResult`.
|
|
- **Failure cases:** **`403` for any non-public role** (`super_admin`, `support`, …); `400` empty
|
|
role; `401` unauthenticated.
|
|
- **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. `/me` reads roles from the DB and
|
|
reflects the change immediately.
|
|
|
|
## Shared shapes
|
|
|
|
- `AuthTokensResult`:
|
|
| field | type | notes |
|
|
|---|---|---|
|
|
| `accessToken` | string | JWE bearer token (send as `Authorization: Bearer …`) |
|
|
| `refreshToken` | string | 64-hex opaque token; **store securely**, only its hash exists server-side |
|
|
| `accessExpiresAt` | ISO-8601 | absolute access-token expiry |
|
|
| `refreshExpiresAt` | ISO-8601 | session expiry (`auth_session_ttl_days`, default 30d) |
|
|
| `isNewUser` | bool | `true` only on the first successful verify for the phone |
|
|
| `roles` | string[] | active roles; empty ⇒ send the user to role selection |
|
|
|
|
- `MeResult`:
|
|
| field | type | notes |
|
|
|---|---|---|
|
|
| `id` | int | user id |
|
|
| `phone` | string | **always masked** (`0912*****33`) — full phone is never returned |
|
|
| `firstName` / `lastName` | string \| null | null until profile (b3) |
|
|
| `gender` | `male`/`female` \| null | null until profile (b3); never defaulted |
|
|
| `isActive` | bool | phone-verified account |
|
|
| `roles` | string[] | active roles (revoked grants excluded) |
|
|
| `hasCustomerProfile` / `hasNurseProfile` | bool | `false` until b3 populates the profile tables |
|
|
| `nurseVerificationStatus` | string | `not_started` until b6 |
|
|
|
|
- `RequestOtpResult`: `otpSent` (bool) + `resendAvailableInSeconds` (int).
|
|
|
|
## Changelog
|
|
- b2 — initial contract (phone-OTP auth, sessions, `/me`, role selection).
|