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:
@@ -0,0 +1,130 @@
|
||||
# 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).
|
||||
@@ -7,7 +7,7 @@
|
||||
},
|
||||
"servers": [
|
||||
{
|
||||
"url": "https://localhost:5002"
|
||||
"url": "http://localhost:5188"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
@@ -114,6 +114,304 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/auth/request_otp": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Auth"
|
||||
],
|
||||
"operationId": "Auth_RequestOtp",
|
||||
"requestBody": {
|
||||
"x-name": "command",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RequestOtpCommand"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true,
|
||||
"x-position": 1
|
||||
},
|
||||
"responses": {
|
||||
"400": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResultOfRequestOtpResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/auth/verify_otp": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Auth"
|
||||
],
|
||||
"operationId": "Auth_VerifyOtp",
|
||||
"requestBody": {
|
||||
"x-name": "command",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/VerifyOtpCommand"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true,
|
||||
"x-position": 1
|
||||
},
|
||||
"responses": {
|
||||
"400": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResultOfAuthTokensResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/auth/refresh": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Auth"
|
||||
],
|
||||
"operationId": "Auth_Refresh",
|
||||
"requestBody": {
|
||||
"x-name": "command",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RefreshTokenCommand"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true,
|
||||
"x-position": 1
|
||||
},
|
||||
"responses": {
|
||||
"400": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResultOfAuthTokensResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/auth/logout": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Auth"
|
||||
],
|
||||
"operationId": "Auth_Logout",
|
||||
"requestBody": {
|
||||
"x-name": "command",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/LogoutCommand"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true,
|
||||
"x-position": 1
|
||||
},
|
||||
"responses": {
|
||||
"400": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/holidays/get_holidays": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -376,6 +674,150 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/me": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Me"
|
||||
],
|
||||
"summary": "Retrieves a Me by unique id",
|
||||
"operationId": "Me_GetMe",
|
||||
"responses": {
|
||||
"400": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResultOfMeResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/me/select_role": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Me"
|
||||
],
|
||||
"description": "Role claims live inside the access token — after selecting a role the client should\n refresh its tokens to pick the new role up.",
|
||||
"operationId": "Me_SelectRole",
|
||||
"requestBody": {
|
||||
"x-name": "command",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SelectRoleCommand"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true,
|
||||
"x-position": 1
|
||||
},
|
||||
"responses": {
|
||||
"400": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResultOfMeResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/notifications/get_notifications": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -1466,6 +1908,141 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ApiResultOfRequestOtpResult": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/ApiResult"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"data": {
|
||||
"nullable": true,
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/RequestOtpResult"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"RequestOtpResult": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"otpSent": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"resendAvailableInSeconds": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
}
|
||||
},
|
||||
"RequestOtpCommand": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"phone": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ApiResultOfAuthTokensResult": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/ApiResult"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"data": {
|
||||
"nullable": true,
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/AuthTokensResult"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"AuthTokensResult": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"accessToken": {
|
||||
"type": "string"
|
||||
},
|
||||
"refreshToken": {
|
||||
"type": "string"
|
||||
},
|
||||
"accessExpiresAt": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"refreshExpiresAt": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"isNewUser": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"roles": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"VerifyOtpCommand": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"phone": {
|
||||
"type": "string"
|
||||
},
|
||||
"code": {
|
||||
"type": "string"
|
||||
},
|
||||
"deviceInfo": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"RefreshTokenCommand": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"refreshToken": {
|
||||
"type": "string"
|
||||
},
|
||||
"deviceInfo": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"LogoutCommand": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"refreshToken": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"everywhere": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ApiResultOfPagedResultOfHolidayDto": {
|
||||
"allOf": [
|
||||
{
|
||||
@@ -1568,6 +2145,80 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ApiResultOfMeResult": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/ApiResult"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"data": {
|
||||
"nullable": true,
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/MeResult"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"MeResult": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"phone": {
|
||||
"type": "string"
|
||||
},
|
||||
"firstName": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"lastName": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"gender": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"isActive": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"roles": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"hasCustomerProfile": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"hasNurseProfile": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"nurseVerificationStatus": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"SelectRoleCommand": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"role": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"ApiResultOfPagedResultOfNotificationDto": {
|
||||
"allOf": [
|
||||
{
|
||||
|
||||
@@ -12,6 +12,27 @@ One block per completed backend phase. Newest at the top. Backend lane writes he
|
||||
- **Notes for frontend:** <anything load-bearing>
|
||||
-->
|
||||
|
||||
## backend-phase-2 — Identity: phone-OTP auth, sessions & roles (REST) — 2026-07-02
|
||||
- **Shipped:** the six-endpoint REST auth surface (`auth/request_otp`, `auth/verify_otp`,
|
||||
`auth/refresh`, `auth/logout`, `me`, `me/select_role`) wrapping the existing JWE/TOTP/RBAC engine;
|
||||
new `usr.UserSessions` (refresh-token rotation + revoke-all on replayed token); `usr.Users` extended
|
||||
(`Gender`, `NationalId` enc NULL, `ShahkarVerifiedAt` auto-reset on phone change, `PhoneHash`
|
||||
UNIQUE, `IsActive`, `DeletedAt` + soft-delete filter); phone/email/national-id **encrypted at rest**
|
||||
(EF value converter over `IFieldEncryptor`); `usr.UserRoles` grant/revoke audit trail + revoked
|
||||
filter; 7 roles seeded; `ISmsSender` seam (mock logs the code); 3 auth config keys;
|
||||
`OperationResult`/`BaseController` learned 401/403.
|
||||
- **Contracts:** dev/contracts/domains/identity-auth.md + openapi snapshot refreshed (yes — 22 paths).
|
||||
- **Mocked:** `ISmsSender` → 🟡 (see reports/mocks-registry.md).
|
||||
- **Gate:** build clean (0 new code warnings) / tests green (47 pass: 10 new `Baya.Test.Api`
|
||||
integration + 14 new handler unit tests). Migration `IdentitySessionsAndUserExtensions` applied to
|
||||
the dev DB; full §7 flow verified live (OTP in log, tokens, 401/403/429, rotation, replay-revoke,
|
||||
logout stamp-kill).
|
||||
- **Handoff:** backend/handoff/after-backend-phase-2.md
|
||||
- **Notes for frontend:** exact paths are `request_otp`/`verify_otp`/`select_role` (snake_case
|
||||
transformer — not the `otp/request` sketch). Bodies camelCase. Fresh users: `roles: []` → role
|
||||
router → `me/select_role` → **refresh tokens** to pick up role claims. `/me` phone is masked.
|
||||
SMS is mocked — read the OTP from the server log.
|
||||
|
||||
## backend-phase-1 — Config, reference & platform signals — 2026-07-02
|
||||
- **Shipped:** first marketplace migration baseline (`InitialMarketplaceBaseline`, new **`ops`** schema)
|
||||
with 6 tables (`PlatformConfigs`, `AuditLogs`, `SystemEvents`, `IranianHolidays`, `Notifications`,
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# After backend-phase-2 — auth is live over REST
|
||||
|
||||
The marketplace has its front door: **phone-OTP login, revocable sessions with refresh-token
|
||||
rotation + stolen-token detection, `/me`, and public role selection** — all over REST, wrapping the
|
||||
pre-existing JWE/TOTP/RBAC engine (nothing was rebuilt). Contract:
|
||||
[`dev/contracts/domains/identity-auth.md`](../../../contracts/domains/identity-auth.md); machine
|
||||
schema: `dev/contracts/openapi/swagger.v1.json` (refreshed — 22 paths).
|
||||
|
||||
## What the frontend (f1-b2) can now build
|
||||
- **Login flow:** `POST api/v1/auth/request_otp` → `POST api/v1/auth/verify_otp` (camelCase bodies;
|
||||
exact snake_case paths per the contract — note `request_otp`, **not** `otp/request`).
|
||||
- **Session handling:** store the token pair; `POST api/v1/auth/refresh` rotates it (never reuse an
|
||||
old refresh token — replay = 401 + logout-everywhere); `POST api/v1/auth/logout` (send `{}`).
|
||||
- **`AuthContext` roles + role router:** `GET api/v1/me` returns masked phone, `roles[]`,
|
||||
profile-completion flags (false until b3) and `nurseVerificationStatus` (`not_started` until b6).
|
||||
Fresh users have `roles: []` → route to `POST api/v1/me/select_role` (`customer`/`nurse`, both
|
||||
allowed, 403 for anything else). **Refresh tokens after role selection** — role claims are baked
|
||||
into the access token.
|
||||
- **Errors:** 400 invalid phone/code (safe, non-enumerating message), 401 with the standard
|
||||
envelope (also written by the auth stack itself), 403 admin self-assign, 429 over the OTP/auth
|
||||
per-IP limits. The envelope is unchanged (camelCase body, snake_case URLs).
|
||||
|
||||
## What's mocked
|
||||
- **SMS delivery (`ISmsSender` → 🟡).** The OTP code is written to the server log instead of a SIM.
|
||||
Local testing: call `request_otp`, read the code from the API console log, `verify_otp` with it.
|
||||
|
||||
## Rules baked into the API (don't fight them client-side)
|
||||
- Phone is the only public credential; email is optional and never a login key.
|
||||
- One resend per `auth_otp_resend_seconds` (response says `otpSent: false` + wait time).
|
||||
- After `auth_otp_max_attempts` wrong codes, verification refuses until a fresh OTP is requested.
|
||||
- Logout rotates the security stamp: **all** devices' access tokens die; they recover via refresh.
|
||||
|
||||
## Schema / migration
|
||||
Migration **`20260701222425_IdentitySessionsAndUserExtensions`** applied to the dev DB on top of
|
||||
b1's baseline: `usr.Users` gains `Gender`, `NationalId` (enc, NULL until b6 KYC),
|
||||
`NationalIdVerifiedAt`, `ShahkarVerifiedAt` (auto-reset on phone change), `PhoneHash` (UNIQUE),
|
||||
`PhoneVerifiedAt`, `IsActive`, `DeletedAt` (+ soft-delete filter); new `usr.UserSessions`;
|
||||
`usr.UserRoles` gains `GrantedById`/`GrantedAt`/`RevokedAt` (revoked grants filtered out globally).
|
||||
`PhoneNumber`/`Email`/`NationalId` are now **encrypted at rest** — never query them by equality;
|
||||
use `PhoneHash`. Roles seeded: `customer`, `nurse`, `admin`, `support`, `finance`, `moderation`,
|
||||
`super_admin`. Config keys added: `auth_otp_resend_seconds` (120), `auth_otp_max_attempts` (5),
|
||||
`auth_session_ttl_days` (30).
|
||||
|
||||
## Follow-ups later phases must close
|
||||
- **b3:** profiles/patients/addresses/bank accounts; `gender` + names become settable; the `/me`
|
||||
profile-completion flags start reading real tables.
|
||||
- **b6:** Shahkar + KYC set `NationalId`/`ShahkarVerifiedAt`; `nurseVerificationStatus` becomes real.
|
||||
- **Legacy `UserRefreshTokens`** still backs the gRPC path only; retire it when gRPC moves to
|
||||
sessions (or gRPC is dropped).
|
||||
- **`ISmsSender` → real gateway** (see mocks-registry row).
|
||||
@@ -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 | 🔴 |
|
||||
|
||||
Reference in New Issue
Block a user