add build development phases
This commit is contained in:
@@ -0,0 +1,334 @@
|
||||
# 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](./backend-phase-0.md) (REST surface, rate limiter, cross-cutting seams), [b1](./backend-phase-1.md) (config accessor, `notifications`, marketplace migration baseline) · **Unlocks:** every authenticated backend feature; frontend **f1-b2**
|
||||
> **Before you start, read [`../_shared/agent-operating-rules.md`](../_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`](../../../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](./backend-phase-0.md) kept the working spine: ASP.NET Core Identity,
|
||||
**JWE** access tokens (`Jwt/JwtService : IJwtService` — `GenerateAsync(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](./backend-phase-0.md) 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](./backend-phase-0.md) 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](./backend-phase-1.md) 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](./backend-phase-0.md) 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 `IEntityTypeConfiguration`s 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 place** — `users`/`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`](../_shared/agent-operating-rules.md) and
|
||||
[`../_shared/backend-conventions-checklist.md`](../_shared/backend-conventions-checklist.md).
|
||||
- **Product — business truth.** [`product/business/01-actors-and-onboarding.md`](../../../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`](../../../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`](../../../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.md`](../../../server/CLAUDE.md) — *Identity & auth*, *Persistence*,
|
||||
*Startup wiring*, *Project map*; [`server/CONVENTIONS.md`](../../../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`](../../contracts/conventions/api-conventions.md)
|
||||
(envelope, status codes, auth header, pagination) and
|
||||
[`../../contracts/conventions/money-and-types.md`](../../contracts/conventions/money-and-types.md) (the
|
||||
**gender enum is load-bearing** — `male`/`female`, never defaulted/dropped). Section 8 publishes
|
||||
`dev/contracts/domains/identity-auth.md` from the [`_TEMPLATE.md`](../../contracts/domains/_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`](../../shared-working-context/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 trail** — `granted_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?)` → `AuthTokensResult`** — `VerifyUserCode`; 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`](../../../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](./backend-phase-3.md)**.
|
||||
- The `is_verified` flip, Shahkar re-verification on phone change (the *handler* that re-runs Shahkar) →
|
||||
**[b6](./backend-phase-6.md)**. 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`](../../shared-working-context/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](../_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/logout` → `200`; 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.md` — *Project 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`](../../../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`](../../contracts/domains/_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`](../../contracts/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`](../../shared-working-context/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.
|
||||
Reference in New Issue
Block a user