From 3a51305343563aa6c71d53a2363a5af4b93f7478 Mon Sep 17 00:00:00 2001 From: hamid Date: Thu, 2 Jul 2026 02:34:11 +0330 Subject: [PATCH] =?UTF-8?q?backend=20phase=202:=20identity=20=E2=80=94=20p?= =?UTF-8?q?hone-OTP=20auth,=20sessions=20&=20roles=20(REST)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .claude/settings.local.json | 28 +- dev/contracts/domains/identity-auth.md | 130 +++ dev/contracts/openapi/swagger.v1.json | 653 ++++++++++- dev/shared-working-context/backend/STATUS.md | 21 + .../backend/handoff/after-backend-phase-2.md | 50 + .../reports/backend-phase-2-report.md | 114 ++ .../reports/mocks-registry.md | 2 +- .../business/01-actors-and-onboarding.html | 1 + product/business/01-actors-and-onboarding.md | 1 + server/Baya.sln | 15 + server/CLAUDE.md | 42 +- server/Directory.Packages.props | 1 + .../Controllers/V1/AuthController.cs | 50 + .../Controllers/V1/MeController.cs | 32 + server/src/API/Baya.Web.Api/Program.cs | 12 +- .../BaseController/BaseController.cs | 15 + .../Contracts/Common/ICurrentUser.cs | 5 +- .../Contracts/Common/ISmsSender.cs | 16 + .../Baya.Application/Contracts/IJwtService.cs | 9 +- .../Contracts/Persistence/IUnitOfWork.cs | 4 +- .../Persistence/IUserAccountRepository.cs | 20 + .../Persistence/IUserSessionRepository.cs | 20 + .../Commands/Logout/LogoutCommand.Handler.cs | 47 + .../Identity/Commands/Logout/LogoutCommand.cs | 13 + .../RefreshTokenCommand.Handler.cs | 82 ++ .../RefreshTokenCommand.Validator.cs | 16 + .../RefreshToken/RefreshTokenCommand.cs | 9 + .../RequestOtp/RequestOtpCommand.Handler.cs | 69 ++ .../RequestOtp/RequestOtpCommand.Validator.cs | 13 + .../Commands/RequestOtp/RequestOtpCommand.cs | 8 + .../SelectRole/SelectRoleCommand.Handler.cs | 60 + .../SelectRole/SelectRoleCommand.Validator.cs | 13 + .../Commands/SelectRole/SelectRoleCommand.cs | 11 + .../VerifyOtp/VerifyOtpCommand.Handler.cs | 77 ++ .../VerifyOtp/VerifyOtpCommand.Validator.cs | 20 + .../Commands/VerifyOtp/VerifyOtpCommand.cs | 9 + .../Features/Identity/IdentityDefaults.cs | 77 ++ .../Features/Identity/IranianPhone.cs | 29 + .../Queries/GetMe/GetMeQuery.Handler.cs | 24 + .../Identity/Queries/GetMe/GetMeQuery.cs | 7 + .../Create/UserCreateCommand.Handler.cs | 17 +- .../UserTokenRequestQuery.Handler.cs | 14 +- .../Models/Common/OperationResult.cs | 24 + .../Models/Identity/AuthTokensResult.cs | 14 + .../Models/Identity/MeResult.cs | 22 + .../Models/Identity/RequestOtpResult.cs | 7 + .../Models/Identity/UserAccountSnapshot.cs | 12 + .../Models/Jwt/JweAccessToken.cs | 4 + .../Baya.Domain/Entities/User/RoleNames.cs | 23 + .../Core/Baya.Domain/Entities/User/User.cs | 29 +- .../Baya.Domain/Entities/User/UserRole.cs | 9 +- .../Baya.Domain/Entities/User/UserSession.cs | 29 + .../Logging/LoggingConfiguration.cs | 3 +- .../Seams/LoggingSmsSender.cs | 28 + .../ServiceCollectionExtension.cs | 6 +- .../CurrentUser/HttpContextCurrentUser.cs | 3 + .../Identity/CurrentUser/NullCurrentUser.cs | 2 + .../SeedDatabaseService/SeedDataBase.cs | 19 +- .../Jwt/JwtService.cs | 80 +- .../AppUserManagerImplementation.cs | 16 +- .../ApplicationDbContext.cs | 49 +- .../PlatformConfigConfig.cs | 3 + .../Configuration/UserConfig/UserConfig.cs | 13 +- .../UserConfig/UserRoleConfig.cs | 8 +- .../UserConfig/UserSessionConfig.cs | 27 + ...ntitySessionsAndUserExtensions.Designer.cs | 1015 +++++++++++++++++ ...22425_IdentitySessionsAndUserExtensions.cs | 280 +++++ .../ApplicationDbContextModelSnapshot.cs | 144 +++ .../Repositories/Common/UnitOfWork.cs | 8 +- .../Repositories/UserAccountRepository.cs | 50 + .../Repositories/UserSessionRepository.cs | 46 + .../EncryptedStringConverter.cs | 14 + .../src/Tests/Baya.Test.Api/AuthTestClient.cs | 58 + .../Tests/Baya.Test.Api/Baya.Test.Api.csproj | 30 + .../src/Tests/Baya.Test.Api/BayaApiFactory.cs | 75 ++ .../Tests/Baya.Test.Api/OtpRequestTests.cs | 37 + .../Baya.Test.Api/RefreshAndLogoutTests.cs | 64 ++ .../Tests/Baya.Test.Api/RoleSelectionTests.cs | 44 + server/src/Tests/Baya.Test.Api/Usings.cs | 1 + .../Baya.Test.Api/VerifyOtpAndMeTests.cs | 80 ++ .../RefreshTokenCommandHandlerTests.cs | 119 ++ .../Identity/RequestOtpCommandHandlerTests.cs | 90 ++ .../Identity/SelectRoleCommandHandlerTests.cs | 104 ++ .../Identity/VerifyOtpCommandHandlerTests.cs | 120 ++ .../Marketplace/OpsTestHost.cs | 4 +- .../Setups/TestApplicationDbContext.cs | 2 +- .../Setups/TestFieldEncryptor.cs | 37 + .../Setups/TestIdentitySetup.cs | 2 + 88 files changed, 4619 insertions(+), 91 deletions(-) create mode 100644 dev/contracts/domains/identity-auth.md create mode 100644 dev/shared-working-context/backend/handoff/after-backend-phase-2.md create mode 100644 dev/shared-working-context/reports/backend-phase-2-report.md create mode 100644 server/src/API/Baya.Web.Api/Controllers/V1/AuthController.cs create mode 100644 server/src/API/Baya.Web.Api/Controllers/V1/MeController.cs create mode 100644 server/src/Core/Baya.Application/Contracts/Common/ISmsSender.cs create mode 100644 server/src/Core/Baya.Application/Contracts/Persistence/IUserAccountRepository.cs create mode 100644 server/src/Core/Baya.Application/Contracts/Persistence/IUserSessionRepository.cs create mode 100644 server/src/Core/Baya.Application/Features/Identity/Commands/Logout/LogoutCommand.Handler.cs create mode 100644 server/src/Core/Baya.Application/Features/Identity/Commands/Logout/LogoutCommand.cs create mode 100644 server/src/Core/Baya.Application/Features/Identity/Commands/RefreshToken/RefreshTokenCommand.Handler.cs create mode 100644 server/src/Core/Baya.Application/Features/Identity/Commands/RefreshToken/RefreshTokenCommand.Validator.cs create mode 100644 server/src/Core/Baya.Application/Features/Identity/Commands/RefreshToken/RefreshTokenCommand.cs create mode 100644 server/src/Core/Baya.Application/Features/Identity/Commands/RequestOtp/RequestOtpCommand.Handler.cs create mode 100644 server/src/Core/Baya.Application/Features/Identity/Commands/RequestOtp/RequestOtpCommand.Validator.cs create mode 100644 server/src/Core/Baya.Application/Features/Identity/Commands/RequestOtp/RequestOtpCommand.cs create mode 100644 server/src/Core/Baya.Application/Features/Identity/Commands/SelectRole/SelectRoleCommand.Handler.cs create mode 100644 server/src/Core/Baya.Application/Features/Identity/Commands/SelectRole/SelectRoleCommand.Validator.cs create mode 100644 server/src/Core/Baya.Application/Features/Identity/Commands/SelectRole/SelectRoleCommand.cs create mode 100644 server/src/Core/Baya.Application/Features/Identity/Commands/VerifyOtp/VerifyOtpCommand.Handler.cs create mode 100644 server/src/Core/Baya.Application/Features/Identity/Commands/VerifyOtp/VerifyOtpCommand.Validator.cs create mode 100644 server/src/Core/Baya.Application/Features/Identity/Commands/VerifyOtp/VerifyOtpCommand.cs create mode 100644 server/src/Core/Baya.Application/Features/Identity/IdentityDefaults.cs create mode 100644 server/src/Core/Baya.Application/Features/Identity/IranianPhone.cs create mode 100644 server/src/Core/Baya.Application/Features/Identity/Queries/GetMe/GetMeQuery.Handler.cs create mode 100644 server/src/Core/Baya.Application/Features/Identity/Queries/GetMe/GetMeQuery.cs create mode 100644 server/src/Core/Baya.Application/Models/Identity/AuthTokensResult.cs create mode 100644 server/src/Core/Baya.Application/Models/Identity/MeResult.cs create mode 100644 server/src/Core/Baya.Application/Models/Identity/RequestOtpResult.cs create mode 100644 server/src/Core/Baya.Application/Models/Identity/UserAccountSnapshot.cs create mode 100644 server/src/Core/Baya.Application/Models/Jwt/JweAccessToken.cs create mode 100644 server/src/Core/Baya.Domain/Entities/User/RoleNames.cs create mode 100644 server/src/Core/Baya.Domain/Entities/User/UserSession.cs create mode 100644 server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/LoggingSmsSender.cs create mode 100644 server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/UserConfig/UserSessionConfig.cs create mode 100644 server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260701222425_IdentitySessionsAndUserExtensions.Designer.cs create mode 100644 server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260701222425_IdentitySessionsAndUserExtensions.cs create mode 100644 server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/UserAccountRepository.cs create mode 100644 server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/UserSessionRepository.cs create mode 100644 server/src/Infrastructure/Baya.Infrastructure.Persistence/ValueConversion/EncryptedStringConverter.cs create mode 100644 server/src/Tests/Baya.Test.Api/AuthTestClient.cs create mode 100644 server/src/Tests/Baya.Test.Api/Baya.Test.Api.csproj create mode 100644 server/src/Tests/Baya.Test.Api/BayaApiFactory.cs create mode 100644 server/src/Tests/Baya.Test.Api/OtpRequestTests.cs create mode 100644 server/src/Tests/Baya.Test.Api/RefreshAndLogoutTests.cs create mode 100644 server/src/Tests/Baya.Test.Api/RoleSelectionTests.cs create mode 100644 server/src/Tests/Baya.Test.Api/Usings.cs create mode 100644 server/src/Tests/Baya.Test.Api/VerifyOtpAndMeTests.cs create mode 100644 server/src/Tests/Baya.Test.Foundation/Identity/RefreshTokenCommandHandlerTests.cs create mode 100644 server/src/Tests/Baya.Test.Foundation/Identity/RequestOtpCommandHandlerTests.cs create mode 100644 server/src/Tests/Baya.Test.Foundation/Identity/SelectRoleCommandHandlerTests.cs create mode 100644 server/src/Tests/Baya.Test.Foundation/Identity/VerifyOtpCommandHandlerTests.cs create mode 100644 server/src/Tests/Baya.Tests.Setup/Setups/TestFieldEncryptor.cs diff --git a/.claude/settings.local.json b/.claude/settings.local.json index b309100..5bcaf71 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -59,7 +59,33 @@ "Bash(nohup dotnet run --project src/API/Baya.Web.Api/Baya.Web.Api.csproj --no-build --no-launch-profile)", "Bash(echo \"started pid $!\")", "Bash(Get-ChildItem -Path \"c:\\\\Users\\\\Lenovo\\\\Desktop\\\\balinyaar\\\\server\" -Recurse -Directory)", - "Bash(Select-Object FullName)" + "Bash(Select-Object FullName)", + "Bash(dotnet ef *)", + "Bash(dotnet test *)", + "Bash(ASPNETCORE_ENVIRONMENT=Development ASPNETCORE_URLS=\"http://localhost:5080\" dotnet run --project src/API/Baya.Web.Api/Baya.Web.Api.csproj --no-build)", + "Bash(curl -sk https://localhost:5002/swagger/v1/swagger.json -o \"c:/Users/Lenovo/Desktop/balinyaar/dev/contracts/openapi/swagger.v1.json\")", + "Bash(curl -sk https://localhost:5002/api/v1/ping/get_status)", + "Bash(taskkill //F //IM Baya.Web.Api.exe)", + "Bash(taskkill //F //IM dotnet.exe //FI \"MEMUSAGE gt 90000\")", + "Bash(tasklist)", + "Bash(ASPNETCORE_ENVIRONMENT=Development dotnet run --project src/API/Baya.Web.Api/Baya.Web.Api.csproj --no-build)", + "Bash(git add *)", + "Bash(git commit *)", + "Bash(xargs echo \"remaining unstaged/untracked entries:\")", + "Bash(git commit -m ' *)", + "Bash(dotnet sln *)", + "Bash(curl -s -o /dev/null -w \"%{http_code}\" http://localhost:5188/swagger/v1/swagger.json)", + "Read(//c/Users/Lenovo/AppData/Local/Temp/claude/c--Users-Lenovo-Desktop-balinyaar/9f660ead-4935-40d5-9ba2-c846d642a0db/scratchpad/**)", + "Bash(curl -s -o /dev/null -w \"%{http_code}\\\\n\" http://localhost:5188/api/v1/me)", + "Bash(curl -s http://localhost:5188/api/v1/me -H 'Authorization: Bearer __TRACKED_VAR__')", + "Bash(curl -s -X POST http://localhost:5188/api/v1/auth/refresh -H 'Content-Type: application/json' -d '{\"refreshToken\":\"__TRACKED_VAR__\"}')", + "Bash(node -e \"const d=JSON.parse\\(require\\('fs'\\).readFileSync\\('step5.json'\\)\\); console.log\\('status ok:',d.isSuccess,'| roles:',JSON.stringify\\(d.data.roles\\)\\); require\\('fs'\\).writeFileSync\\('at2.txt',d.data.accessToken\\)\")", + "Bash(curl -s -o /dev/null -w '%{http_code}\\\\n' -X POST http://localhost:5188/api/v1/auth/refresh -H 'Content-Type: application/json' -d '{\"refreshToken\":\"__TRACKED_VAR__\"}')", + "Bash(curl -s -o /dev/null -w \"%{http_code}\\\\n\" -X POST http://localhost:5188/api/v1/auth/verify_otp -H \"Content-Type: application/json\" -d '{\"phone\":\"09121112233\",\"code\":\"000000\"}')", + "Bash(curl -s -o /dev/null -w '%{http_code}\\\\n' -X POST http://localhost:5188/api/v1/auth/logout -H 'Authorization: Bearer __TRACKED_VAR__' -H 'Content-Type: application/json' -d '{}')", + "Bash(curl -s -o /dev/null -w '%{http_code}\\\\n' http://localhost:5188/api/v1/me -H 'Authorization: Bearer __TRACKED_VAR__')", + "Bash(curl -s -o /dev/null -w \"%{http_code} \" -X POST http://localhost:5188/api/v1/auth/request_otp -H \"Content-Type: application/json\" -d '{\"phone\":\"09121112244\"}')", + "Bash(curl -s http://localhost:5188/swagger/v1/swagger.json -o /c/Users/Lenovo/Desktop/balinyaar/dev/contracts/openapi/swagger.v1.json)" ], "defaultMode": "bypassPermissions", "additionalDirectories": [ diff --git a/dev/contracts/domains/identity-auth.md b/dev/contracts/domains/identity-auth.md new file mode 100644 index 0000000..93956b7 --- /dev/null +++ b/dev/contracts/domains/identity-auth.md @@ -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). diff --git a/dev/contracts/openapi/swagger.v1.json b/dev/contracts/openapi/swagger.v1.json index b49cc11..b8be61f 100644 --- a/dev/contracts/openapi/swagger.v1.json +++ b/dev/contracts/openapi/swagger.v1.json @@ -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": [ { diff --git a/dev/shared-working-context/backend/STATUS.md b/dev/shared-working-context/backend/STATUS.md index cc786cc..bc7ff78 100644 --- a/dev/shared-working-context/backend/STATUS.md +++ b/dev/shared-working-context/backend/STATUS.md @@ -12,6 +12,27 @@ One block per completed backend phase. Newest at the top. Backend lane writes he - **Notes for frontend:** --> +## 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`, diff --git a/dev/shared-working-context/backend/handoff/after-backend-phase-2.md b/dev/shared-working-context/backend/handoff/after-backend-phase-2.md new file mode 100644 index 0000000..f4767b4 --- /dev/null +++ b/dev/shared-working-context/backend/handoff/after-backend-phase-2.md @@ -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). diff --git a/dev/shared-working-context/reports/backend-phase-2-report.md b/dev/shared-working-context/reports/backend-phase-2-report.md new file mode 100644 index 0000000..cde233d --- /dev/null +++ b/dev/shared-working-context/reports/backend-phase-2-report.md @@ -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` 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_`) 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. diff --git a/dev/shared-working-context/reports/mocks-registry.md b/dev/shared-working-context/reports/mocks-registry.md index 67c97a6..7c1cca8 100644 --- a/dev/shared-working-context/reports/mocks-registry.md +++ b/dev/shared-working-context/reports/mocks-registry.md @@ -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 | 🔴 | diff --git a/product/business/01-actors-and-onboarding.html b/product/business/01-actors-and-onboarding.html index 8e4ca35..0e804c3 100644 --- a/product/business/01-actors-and-onboarding.html +++ b/product/business/01-actors-and-onboarding.html @@ -29,6 +29,7 @@
  • Each successful login creates a refresh-token session that can be revoked (logout, stolen-token detection).
  • +
  • As-built decisions (backend-phase-2): each refresh rotates the session (old revoked, new pair issued); a refresh token presented against an already-revoked session is treated as stolen-token reuse → all of the user's sessions are revoked and the call returns 401. Logout rotates the security stamp, so every outstanding access token dies (other devices recover by refreshing). OTP request/verify never reveal whether a phone already has an account; one OTP per phone per resend window (auth_otp_resend_seconds), and after auth_otp_max_attempts wrong codes verification refuses until a fresh OTP. customer/nurse are the only self-selectable roles (a user may hold both; grants audited via granted_by/granted_at); any admin sub-role self-assign attempt returns 403 — admin provisioning is internal-only.
  • (b) Iran-specific considerations

      diff --git a/product/business/01-actors-and-onboarding.md b/product/business/01-actors-and-onboarding.md index 42e4afa..40fb565 100644 --- a/product/business/01-actors-and-onboarding.md +++ b/product/business/01-actors-and-onboarding.md @@ -11,6 +11,7 @@ - A **nurse** must complete the full verification pipeline (Section 2) before any of their service variants become bookable. `national_id` is populated only after the identity step passes. - An **admin** is provisioned internally with RBAC roles. - Each successful login creates a refresh-token session that can be revoked (logout, stolen-token detection). +- **As-built decisions (backend-phase-2):** each refresh **rotates** the session (old revoked, new pair issued); a refresh token presented against an already-revoked session is treated as **stolen-token reuse → all of the user's sessions are revoked and the call returns 401**. Logout rotates the security stamp, so every outstanding access token dies (other devices recover by refreshing). OTP request/verify never reveal whether a phone already has an account; one OTP per phone per resend window (`auth_otp_resend_seconds`), and after `auth_otp_max_attempts` wrong codes verification refuses until a fresh OTP. `customer`/`nurse` are the only self-selectable roles (a user may hold both; grants audited via `granted_by`/`granted_at`); any admin sub-role self-assign attempt returns **403** — admin provisioning is internal-only. ## (b) Iran-specific considerations - Phone-OTP is the dominant Iranian login norm and is also the anchor for **Shahkar** SIM↔national-ID binding (Section 2). diff --git a/server/Baya.sln b/server/Baya.sln index dbb7dab..5517e6d 100644 --- a/server/Baya.sln +++ b/server/Baya.sln @@ -54,6 +54,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Baya.Test.Foundation", "src\Tests\Baya.Test.Foundation\Baya.Test.Foundation.csproj", "{052BF207-440C-4FAB-AF6F-4992B29A3BF4}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Baya.Test.Api", "src\Tests\Baya.Test.Api\Baya.Test.Api.csproj", "{BD38DCB1-A00D-49D2-985C-AC34258365D8}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -220,6 +222,18 @@ Global {052BF207-440C-4FAB-AF6F-4992B29A3BF4}.Release|x64.Build.0 = Release|Any CPU {052BF207-440C-4FAB-AF6F-4992B29A3BF4}.Release|x86.ActiveCfg = Release|Any CPU {052BF207-440C-4FAB-AF6F-4992B29A3BF4}.Release|x86.Build.0 = Release|Any CPU + {BD38DCB1-A00D-49D2-985C-AC34258365D8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BD38DCB1-A00D-49D2-985C-AC34258365D8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BD38DCB1-A00D-49D2-985C-AC34258365D8}.Debug|x64.ActiveCfg = Debug|Any CPU + {BD38DCB1-A00D-49D2-985C-AC34258365D8}.Debug|x64.Build.0 = Debug|Any CPU + {BD38DCB1-A00D-49D2-985C-AC34258365D8}.Debug|x86.ActiveCfg = Debug|Any CPU + {BD38DCB1-A00D-49D2-985C-AC34258365D8}.Debug|x86.Build.0 = Debug|Any CPU + {BD38DCB1-A00D-49D2-985C-AC34258365D8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BD38DCB1-A00D-49D2-985C-AC34258365D8}.Release|Any CPU.Build.0 = Release|Any CPU + {BD38DCB1-A00D-49D2-985C-AC34258365D8}.Release|x64.ActiveCfg = Release|Any CPU + {BD38DCB1-A00D-49D2-985C-AC34258365D8}.Release|x64.Build.0 = Release|Any CPU + {BD38DCB1-A00D-49D2-985C-AC34258365D8}.Release|x86.ActiveCfg = Release|Any CPU + {BD38DCB1-A00D-49D2-985C-AC34258365D8}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -247,6 +261,7 @@ Global {7699705C-2C15-467F-957D-4C5EBE4FD92E} = {2373AFFC-1389-4D78-8465-074AB22084AF} {704FAE1E-F0D2-468E-8B3D-E9E6F323ABE8} = {42CAB060-5D50-4E18-8F85-EBA5EB85B268} {052BF207-440C-4FAB-AF6F-4992B29A3BF4} = {77986571-8153-4120-AD08-36729310A56B} + {BD38DCB1-A00D-49D2-985C-AC34258365D8} = {77986571-8153-4120-AD08-36729310A56B} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {05C223B9-EA89-44B2-B9F5-D01181F85DFE} diff --git a/server/CLAUDE.md b/server/CLAUDE.md index 972d88c..8b2b93a 100644 --- a/server/CLAUDE.md +++ b/server/CLAUDE.md @@ -81,22 +81,23 @@ projects/assemblies, Clean-Architecture layers, and cross-layer dependencies. ``` src/ ├── Core/ -│ ├── Baya.Domain Entities (User, Role…, + Configuration/Audit/Analytics/Holidays/Notifications/SupportAlerts), BaseEntity, IEntity, ITimeModification, IAuditableEntity, IAuditable (audit-row marker) -│ └── Baya.Application Features/ (Commands & Queries; + Configuration/Audit/Analytics/Holidays/Notifications/SupportAlerts areas), Contracts/ (incl. Contracts/Common cross-cutting seams + the platform-signal facade contracts), Models/, pipeline behaviors (Common/) +│ ├── Baya.Domain Entities (User, Role, UserSession, RoleNames…, + Configuration/Audit/Analytics/Holidays/Notifications/SupportAlerts), BaseEntity, IEntity, ITimeModification, IAuditableEntity, IAuditable (audit-row marker) +│ └── Baya.Application Features/ (Commands & Queries; + Identity/Configuration/Audit/Analytics/Holidays/Notifications/SupportAlerts areas), Contracts/ (incl. Contracts/Common cross-cutting seams + the platform-signal facade contracts), Models/, pipeline behaviors (Common/) ├── Infrastructure/ -│ ├── Baya.Infrastructure.Persistence ApplicationDbContext, Repositories/, Configuration/ (per-area EF config), Migrations/, Interceptors/ (AuditFieldInterceptor — audit-fields + audit-log rows), Services/ (DB-backed platform-signal facades + notification-retention hosted service) +│ ├── Baya.Infrastructure.Persistence ApplicationDbContext (+ encrypted-PII value converters & phone-hash sync), ValueConversion/, Repositories/, Configuration/ (per-area EF config), Migrations/, Interceptors/ (AuditFieldInterceptor — audit-fields + audit-log rows), Services/ (DB-backed platform-signal facades + notification-retention hosted service) │ ├── Baya.Infrastructure.Identity Jwt/, Identity/ (Managers, Stores, PermissionManager, Seed, CurrentUser/) -│ ├── Baya.Infrastructure.CrossCutting Serilog wiring + Seams/ (mock impls of the cross-cutting seams) + AddCrossCuttingSeams +│ ├── Baya.Infrastructure.CrossCutting Serilog wiring + Seams/ (mock impls of the cross-cutting seams incl. LoggingSmsSender) + AddCrossCuttingSeams │ └── Baya.Infrastructure.Monitoring HealthChecks, OpenTelemetry, prometheus-net ├── API/ -│ ├── Baya.Web.Api Program.cs, Controllers/V1/ (PingController + admin PlatformConfig/Holidays/Audit/SupportAlerts + current-user Notifications), appsettings*.json -│ ├── Baya.WebFramework BaseController, Filters/, Middlewares/, Swagger/, Routing/, ServiceConfiguration/ (rate limiting) +│ ├── Baya.Web.Api Program.cs, Controllers/V1/ (Ping + Auth/Me phone-OTP surface + admin PlatformConfig/Holidays/Audit/SupportAlerts + current-user Notifications), appsettings*.json +│ ├── Baya.WebFramework BaseController (incl. 401/403 OperationResult mapping), Filters/, Middlewares/, Swagger/, Routing/, ServiceConfiguration/ (rate limiting) │ └── Plugins/Baya.Web.Plugins.Grpc gRPC services + .proto models (User only) ├── Shared/Baya.SharedKernel Extensions + validation base └── Tests/ - ├── Baya.Tests.Setup Shared test infrastructure (SQLite, NSubstitute setup) + ├── Baya.Tests.Setup Shared test infrastructure (SQLite, NSubstitute setup, TestFieldEncryptor) ├── Baya.Test.Infrastructure.Identity xUnit identity tests - └── Baya.Test.Foundation xUnit tests for cross-cutting plumbing (encryptor, audit interceptor, ping) + ├── Baya.Test.Foundation xUnit tests for cross-cutting plumbing + identity handler unit tests + └── Baya.Test.Api WebApplicationFactory integration tests (full HTTP pipeline over in-memory SQLite, env "Testing") ``` **Dependency direction points inward.** Domain has no dependencies. Application depends only on @@ -202,11 +203,34 @@ action to `sender.Send(...)`. Full conventions are in [CONVENTIONS.md](CONVENTIO ## Identity & auth - JWT/JWE issued by `IJwtService` (`Baya.Infrastructure.Identity/Jwt/JwtService.cs`). + `GenerateAccessTokenAsync` mints an access token only (the REST flow); the legacy `GenerateAsync` + additionally writes a `UserRefreshTokens` row and still feeds the gRPC path. +- **Phone-OTP is the public login** (backend-phase-2): `Controllers/V1/AuthController` + (`request_otp`/`verify_otp`/`refresh`/`logout`) + `MeController` (`/me`, `select_role`) drive the + `Features/Identity/` slices. OTP delivery goes through the **`ISmsSender`** seam (mock + `LoggingSmsSender` in CrossCutting logs the code; registered in `AddCrossCuttingSeams`). +- **Sessions & rotation:** every login creates a revocable `usr.UserSessions` row storing only the + refresh token's `IFieldEncryptor.Hash`. Refresh rotates (old session revoked, new pair issued); + a replayed/revoked token revokes **all** the user's sessions and returns 401. Logout revokes the + session **and** rotates the security stamp so outstanding access tokens fail the JWE + `OnTokenValidated` stamp check. +- **Encrypted PII:** `users.PhoneNumber/Email/NationalId` are encrypted at rest via an EF value + converter over `IFieldEncryptor` (wired in `ApplicationDbContext`; the encryptor must stay a + process-wide singleton because EF caches the model). Equality lookups go through the deterministic + `PhoneHash` column (UNIQUE, synced on SaveChanges — which also resets `ShahkarVerifiedAt` when the + phone actually changes). Never query `PhoneNumber == x`. +- **Roles:** full vocabulary in `Domain/Entities/User/RoleNames` (seeded by `SeedDataBase`). + `customer`/`nurse` are self-selectable via `POST me/select_role` (audited + `granted_by`/`granted_at`, idempotent, both can be held); admin sub-roles are internal-only and + return 403 there. `user_roles.revoked_at` has a global query filter, so revoked grants disappear + from every role read automatically. Auth knobs (`auth_otp_resend_seconds`, `auth_otp_max_attempts`, + `auth_session_ttl_days`) are `platform_configs` rows read via `IPlatformConfig`. - Dynamic permission system: `DynamicPermissionHandler` reads `[controller]` + `[action]` route values and checks role claims. Always use `[controller]`/`[action]` tokens so the keys stay consistent (see CONVENTIONS.md §1 Routing). - Settings bound from `appsettings.json` → `IdentitySettings`. -- Auth and OTP endpoints must be rate-limited (CONVENTIONS.md §11). +- Auth and OTP endpoints must be rate-limited (CONVENTIONS.md §11) — `request_otp`/`verify_otp` use + the `otp` policy, `refresh` the `auth` policy; plus a per-phone resend window via `ICacheService`. --- diff --git a/server/Directory.Packages.props b/server/Directory.Packages.props index eb2aff9..73ee819 100644 --- a/server/Directory.Packages.props +++ b/server/Directory.Packages.props @@ -21,6 +21,7 @@ + diff --git a/server/src/API/Baya.Web.Api/Controllers/V1/AuthController.cs b/server/src/API/Baya.Web.Api/Controllers/V1/AuthController.cs new file mode 100644 index 0000000..edc4cca --- /dev/null +++ b/server/src/API/Baya.Web.Api/Controllers/V1/AuthController.cs @@ -0,0 +1,50 @@ +using System.ComponentModel.DataAnnotations; +using Asp.Versioning; +using Baya.Application.Features.Identity.Commands.Logout; +using Baya.Application.Features.Identity.Commands.RefreshToken; +using Baya.Application.Features.Identity.Commands.RequestOtp; +using Baya.Application.Features.Identity.Commands.VerifyOtp; +using Baya.Application.Models.Identity; +using Baya.WebFramework.Attributes; +using Baya.WebFramework.BaseController; +using Baya.WebFramework.ServiceConfiguration; +using Baya.WebFramework.Swagger; +using Mediator; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; + +namespace Baya.Web.Api.Controllers.V1; + +[ApiVersion("1")] +[ApiController] +[Route("api/v{version:apiVersion}/[controller]")] +[Display(Description = "Phone-OTP login, refresh-token rotation and logout")] +public sealed class AuthController(ISender sender) : BaseController +{ + [HttpPost("[action]")] + [EnableRateLimiting(RateLimitingServiceExtension.OtpPolicy)] + [ProducesOkApiResponseType] + public async Task RequestOtp(RequestOtpCommand command, CancellationToken cancellationToken) + => OperationResult(await sender.Send(command, cancellationToken)); + + [HttpPost("[action]")] + [EnableRateLimiting(RateLimitingServiceExtension.OtpPolicy)] + [ProducesOkApiResponseType] + public async Task VerifyOtp(VerifyOtpCommand command, CancellationToken cancellationToken) + => OperationResult(await sender.Send(command, cancellationToken)); + + // Runs without a valid access token — the refresh token itself is the credential. + [HttpPost("[action]")] + [EnableRateLimiting(RateLimitingServiceExtension.AuthPolicy)] + [RequireTokenWithoutAuthorization] + [ProducesOkApiResponseType] + public async Task Refresh(RefreshTokenCommand command, CancellationToken cancellationToken) + => OperationResult(await sender.Send(command, cancellationToken)); + + [HttpPost("[action]")] + [Authorize] + [ProducesOkApiResponseType] + public async Task Logout(LogoutCommand command, CancellationToken cancellationToken) + => OperationResult(await sender.Send(command, cancellationToken)); +} \ No newline at end of file diff --git a/server/src/API/Baya.Web.Api/Controllers/V1/MeController.cs b/server/src/API/Baya.Web.Api/Controllers/V1/MeController.cs new file mode 100644 index 0000000..f2d59e5 --- /dev/null +++ b/server/src/API/Baya.Web.Api/Controllers/V1/MeController.cs @@ -0,0 +1,32 @@ +using System.ComponentModel.DataAnnotations; +using Asp.Versioning; +using Baya.Application.Features.Identity.Commands.SelectRole; +using Baya.Application.Features.Identity.Queries.GetMe; +using Baya.Application.Models.Identity; +using Baya.WebFramework.Attributes; +using Baya.WebFramework.BaseController; +using Mediator; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Baya.Web.Api.Controllers.V1; + +[ApiVersion("1")] +[ApiController] +[Route("api/v{version:apiVersion}/[controller]")] +[Authorize] +[Display(Description = "The signed-in user's identity, roles and role selection")] +public sealed class MeController(ISender sender) : BaseController +{ + [HttpGet] + [ProducesOkApiResponseType] + public async Task GetMe(CancellationToken cancellationToken) + => OperationResult(await sender.Send(new GetMeQuery(), cancellationToken)); + + /// Role claims live inside the access token — after selecting a role the client should + /// refresh its tokens to pick the new role up. + [HttpPost("[action]")] + [ProducesOkApiResponseType] + public async Task SelectRole(SelectRoleCommand command, CancellationToken cancellationToken) + => OperationResult(await sender.Send(command, cancellationToken)); +} \ No newline at end of file diff --git a/server/src/API/Baya.Web.Api/Program.cs b/server/src/API/Baya.Web.Api/Program.cs index 569113b..cdd2422 100644 --- a/server/src/API/Baya.Web.Api/Program.cs +++ b/server/src/API/Baya.Web.Api/Program.cs @@ -94,8 +94,13 @@ builder.Services.ConfigureGrpcPluginServices(); var app = builder.Build(); -await app.ApplyMigrationsAsync(); -await app.SeedDefaultUsersAsync(); +// Integration tests (WebApplicationFactory, env "Testing") run on in-memory SQLite — the SQL Server +// migrations can't apply there; the test factory does EnsureCreated + seeding itself. +if (!app.Environment.IsEnvironment("Testing")) +{ + await app.ApplyMigrationsAsync(); + await app.SeedDefaultUsersAsync(); +} if (app.Environment.IsDevelopment()) { @@ -121,5 +126,8 @@ app.ConfigureGrpcPipeline(); await app.RunAsync(); +/// Exposes the entry point to WebApplicationFactory<Program>-based integration tests. +public partial class Program; + diff --git a/server/src/API/Baya.WebFramework/BaseController/BaseController.cs b/server/src/API/Baya.WebFramework/BaseController/BaseController.cs index ea63b82..522f241 100644 --- a/server/src/API/Baya.WebFramework/BaseController/BaseController.cs +++ b/server/src/API/Baya.WebFramework/BaseController/BaseController.cs @@ -1,7 +1,9 @@ using System.Security.Claims; +using Baya.Application.Models.ApiResult; using Baya.Application.Models.Common; using Baya.SharedKernel.Extensions; using Baya.WebFramework.Filters; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; namespace Baya.WebFramework.BaseController; @@ -34,6 +36,16 @@ public class BaseController : ControllerBase return NotFound(notFoundErrors.Errors); } + // 401/403 are written as the final envelope directly (mirroring the JWT-event responses) — + // the result filters only translate Ok/NotFound/BadRequest. + if (result.IsUnauthorized) + return new JsonResult(new ApiResult(false, ApiResultStatusCode.UnAuthorized, FirstErrorMessage(result))) + { StatusCode = StatusCodes.Status401Unauthorized }; + + if (result.IsForbidden) + return new JsonResult(new ApiResult(false, ApiResultStatusCode.Forbidden, FirstErrorMessage(result))) + { StatusCode = StatusCodes.Status403Forbidden }; + AddErrors(result); var badRequestErrors = new ValidationProblemDetails(ModelState); @@ -49,4 +61,7 @@ public class BaseController : ControllerBase ModelState.AddModelError(error.Key,error.Value); } } + + private static string FirstErrorMessage(OperationResult result) + => result.ErrorMessages.Count > 0 ? result.ErrorMessages[0].Value : null; } \ No newline at end of file diff --git a/server/src/Core/Baya.Application/Contracts/Common/ICurrentUser.cs b/server/src/Core/Baya.Application/Contracts/Common/ICurrentUser.cs index 814aac8..66bb877 100644 --- a/server/src/Core/Baya.Application/Contracts/Common/ICurrentUser.cs +++ b/server/src/Core/Baya.Application/Contracts/Common/ICurrentUser.cs @@ -12,4 +12,7 @@ public interface ICurrentUser bool IsAuthenticated { get; } IReadOnlyList Roles { get; } -} + + /// The caller's remote IP, when there is an HTTP request (session bookkeeping); else null. + string IpAddress { get; } +} \ No newline at end of file diff --git a/server/src/Core/Baya.Application/Contracts/Common/ISmsSender.cs b/server/src/Core/Baya.Application/Contracts/Common/ISmsSender.cs new file mode 100644 index 0000000..fe41456 --- /dev/null +++ b/server/src/Core/Baya.Application/Contracts/Common/ISmsSender.cs @@ -0,0 +1,16 @@ +#nullable enable +namespace Baya.Application.Contracts.Common; + +/// +/// Seam for outbound SMS delivery — the OTP rail plus later transactional messages. The mock logs the +/// OTP code (never the full phone number); the real implementation swaps to an Iranian gateway +/// (Kavenegar/Ghasedak/SMS.ir) behind the same interface via a registration change only. +/// +public interface ISmsSender +{ + /// Delivers a one-time login code to the given (normalized) phone number. + Task SendOtpAsync(string phone, string code, CancellationToken cancellationToken = default); + + /// Delivers a free-form transactional message (booking updates etc., later phases). + Task SendAsync(string phone, string message, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/server/src/Core/Baya.Application/Contracts/IJwtService.cs b/server/src/Core/Baya.Application/Contracts/IJwtService.cs index 50857b4..51713af 100644 --- a/server/src/Core/Baya.Application/Contracts/IJwtService.cs +++ b/server/src/Core/Baya.Application/Contracts/IJwtService.cs @@ -1,4 +1,4 @@ -using System.Security.Claims; +using System.Security.Claims; using Baya.Application.Models.Jwt; using Baya.Domain.Entities.User; @@ -10,4 +10,11 @@ public interface IJwtService Task GetPrincipalFromExpiredToken(string token); Task GenerateByPhoneNumberAsync(string phoneNumber); Task RefreshToken(Guid refreshTokenId); + + /// + /// Mints a JWE access token only — no refresh-token bookkeeping. The REST auth flow (b2) pairs it + /// with a user_sessions row; the legacy keeps feeding the gRPC + /// path its UserRefreshTokens row. + /// + Task GenerateAccessTokenAsync(User user); } \ No newline at end of file diff --git a/server/src/Core/Baya.Application/Contracts/Persistence/IUnitOfWork.cs b/server/src/Core/Baya.Application/Contracts/Persistence/IUnitOfWork.cs index e69cf21..1572774 100644 --- a/server/src/Core/Baya.Application/Contracts/Persistence/IUnitOfWork.cs +++ b/server/src/Core/Baya.Application/Contracts/Persistence/IUnitOfWork.cs @@ -1,8 +1,10 @@ -namespace Baya.Application.Contracts.Persistence; +namespace Baya.Application.Contracts.Persistence; public interface IUnitOfWork { public IUserRefreshTokenRepository UserRefreshTokenRepository { get; } + public IUserSessionRepository UserSessionRepository { get; } + public IUserAccountRepository UserAccountRepository { get; } Task CommitAsync(); ValueTask RollBackAsync(); } \ No newline at end of file diff --git a/server/src/Core/Baya.Application/Contracts/Persistence/IUserAccountRepository.cs b/server/src/Core/Baya.Application/Contracts/Persistence/IUserAccountRepository.cs new file mode 100644 index 0000000..473bbc9 --- /dev/null +++ b/server/src/Core/Baya.Application/Contracts/Persistence/IUserAccountRepository.cs @@ -0,0 +1,20 @@ +#nullable enable +using Baya.Application.Models.Identity; +using Baya.Domain.Entities.User; + +namespace Baya.Application.Contracts.Persistence; + +public interface IUserAccountRepository +{ + /// No-tracking projection of the user's account facts + active role names (for `/me`). + /// The phone comes back decrypted and unmasked — masking is the handler's job. + Task GetAccountSnapshotAsync(int userId, CancellationToken cancellationToken); + + Task GetRoleByNameAsync(string roleName, CancellationToken cancellationToken); + + /// Tracked user-role lookup that bypasses the revoked-filter, so a revoked grant can be + /// re-activated instead of violating the composite key. + Task GetUserRoleIncludingRevokedAsync(int userId, int roleId, CancellationToken cancellationToken); + + Task AddUserRoleAsync(UserRole userRole, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/server/src/Core/Baya.Application/Contracts/Persistence/IUserSessionRepository.cs b/server/src/Core/Baya.Application/Contracts/Persistence/IUserSessionRepository.cs new file mode 100644 index 0000000..866651d --- /dev/null +++ b/server/src/Core/Baya.Application/Contracts/Persistence/IUserSessionRepository.cs @@ -0,0 +1,20 @@ +#nullable enable +using Baya.Domain.Entities.User; + +namespace Baya.Application.Contracts.Persistence; + +public interface IUserSessionRepository +{ + Task AddAsync(UserSession session, CancellationToken cancellationToken); + + /// Tracked lookup by refresh-token hash (with the owning user), regardless of revocation — + /// the refresh flow needs to see revoked sessions to detect stolen-token reuse. + Task GetByTokenHashAsync(string refreshTokenHash, CancellationToken cancellationToken); + + /// Tracked lookup of a caller-owned, still-active session (single-device logout). + Task GetActiveForUserByTokenHashAsync(int userId, string refreshTokenHash, CancellationToken cancellationToken); + + /// Marks every active session of the user revoked (logout-everywhere / reuse detection). + /// Changes are persisted by the caller's commit. + Task RevokeAllActiveForUserAsync(int userId, DateTimeOffset revokedAt, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/server/src/Core/Baya.Application/Features/Identity/Commands/Logout/LogoutCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Identity/Commands/Logout/LogoutCommand.Handler.cs new file mode 100644 index 0000000..3a2687c --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Identity/Commands/Logout/LogoutCommand.Handler.cs @@ -0,0 +1,47 @@ +#nullable enable +using Baya.Application.Contracts.Common; +using Baya.Application.Contracts.Identity; +using Baya.Application.Contracts.Persistence; +using Baya.Application.Models.Common; +using Mediator; + +namespace Baya.Application.Features.Identity.Commands.Logout; + +internal sealed class LogoutCommandHandler( + ICurrentUser currentUser, + IUnitOfWork unitOfWork, + IAppUserManager userManager, + IFieldEncryptor fieldEncryptor, + IDateTimeProvider clock) + : IRequestHandler> +{ + public async ValueTask> Handle(LogoutCommand request, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } userId) + return OperationResult.UnauthorizedResult("Not authenticated."); + + var now = clock.UtcNow; + + if (request.Everywhere || string.IsNullOrEmpty(request.RefreshToken)) + { + await unitOfWork.UserSessionRepository.RevokeAllActiveForUserAsync(userId, now, cancellationToken); + } + else + { + var session = await unitOfWork.UserSessionRepository.GetActiveForUserByTokenHashAsync( + userId, fieldEncryptor.Hash(request.RefreshToken), cancellationToken); + session?.Revoke(now); + } + + // The existing RequestLogout mechanism: rotating the security stamp makes the JWE + // OnTokenValidated stamp check reject every outstanding access token server-side. + var user = await userManager.GetUserByIdAsync(userId); + if (user is null) + return OperationResult.UnauthorizedResult("Not authenticated."); + + await userManager.UpdateSecurityStampAsync(user); + await unitOfWork.CommitAsync(); + + return OperationResult.SuccessResult(true); + } +} \ No newline at end of file diff --git a/server/src/Core/Baya.Application/Features/Identity/Commands/Logout/LogoutCommand.cs b/server/src/Core/Baya.Application/Features/Identity/Commands/Logout/LogoutCommand.cs new file mode 100644 index 0000000..7b4670a --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Identity/Commands/Logout/LogoutCommand.cs @@ -0,0 +1,13 @@ +#nullable enable +using Baya.Application.Models.Common; +using Mediator; + +namespace Baya.Application.Features.Identity.Commands.Logout; + +/// +/// Revokes the session matching ; with +/// (or no token supplied) every active session goes. The security stamp always rotates, so outstanding +/// access tokens die too. +/// +public record LogoutCommand(string? RefreshToken = null, bool Everywhere = false) + : IRequest>; \ No newline at end of file diff --git a/server/src/Core/Baya.Application/Features/Identity/Commands/RefreshToken/RefreshTokenCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Identity/Commands/RefreshToken/RefreshTokenCommand.Handler.cs new file mode 100644 index 0000000..12b0c1e --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Identity/Commands/RefreshToken/RefreshTokenCommand.Handler.cs @@ -0,0 +1,82 @@ +#nullable enable +using Baya.Application.Contracts; +using Baya.Application.Contracts.Common; +using Baya.Application.Contracts.Configuration; +using Baya.Application.Contracts.Identity; +using Baya.Application.Contracts.Persistence; +using Baya.Application.Models.Common; +using Baya.Application.Models.Identity; +using Mediator; +using Microsoft.Extensions.Logging; + +namespace Baya.Application.Features.Identity.Commands.RefreshToken; + +internal sealed class RefreshTokenCommandHandler( + IUnitOfWork unitOfWork, + IJwtService jwtService, + IAppUserManager userManager, + IPlatformConfig platformConfig, + IFieldEncryptor fieldEncryptor, + IDateTimeProvider clock, + ICurrentUser currentUser, + ILogger logger) + : IRequestHandler> +{ + public async ValueTask> Handle(RefreshTokenCommand request, CancellationToken cancellationToken) + { + var tokenHash = fieldEncryptor.Hash(request.RefreshToken); + var session = await unitOfWork.UserSessionRepository.GetByTokenHashAsync(tokenHash, cancellationToken); + + if (session is null) + return OperationResult.UnauthorizedResult("Invalid refresh token."); + + var now = clock.UtcNow; + + if (session.IsRevoked) + { + // A token replayed against an already-rotated session is a stolen-token signal: + // log the user out everywhere and refuse. + var revoked = await unitOfWork.UserSessionRepository.RevokeAllActiveForUserAsync(session.UserId, now, cancellationToken); + await unitOfWork.CommitAsync(); + + logger.LogWarning( + "Refresh-token reuse detected for user {UserId}; revoked {RevokedSessions} active session(s).", + session.UserId, revoked); + + return OperationResult.UnauthorizedResult("Refresh token is no longer valid. Sign in again."); + } + + if (session.ExpiresAt <= now) + { + session.Revoke(now); + await unitOfWork.CommitAsync(); + return OperationResult.UnauthorizedResult("Refresh token expired. Sign in again."); + } + + var user = session.User; + if (user is null || !user.IsActive) + return OperationResult.UnauthorizedResult("Invalid refresh token."); + + // Rotation: the presented session dies, a fresh pair is issued. + session.Revoke(now); + + var roles = await userManager.GetRoleAsync(user); + var accessToken = await jwtService.GenerateAccessTokenAsync(user); + + var sessionTtlDays = await platformConfig.GetConfig(IdentityDefaults.SessionTtlDaysKey, cancellationToken); + var (refreshToken, newSession) = IdentityDefaults.MintSession( + fieldEncryptor, user.Id, now, sessionTtlDays, + request.DeviceInfo ?? session.DeviceInfo, currentUser.IpAddress); + + await unitOfWork.UserSessionRepository.AddAsync(newSession, cancellationToken); + await unitOfWork.CommitAsync(); + + return OperationResult.SuccessResult(new AuthTokensResult( + accessToken.Token, + refreshToken, + accessToken.ExpiresAt, + newSession.ExpiresAt, + IsNewUser: false, + Roles: roles)); + } +} \ No newline at end of file diff --git a/server/src/Core/Baya.Application/Features/Identity/Commands/RefreshToken/RefreshTokenCommand.Validator.cs b/server/src/Core/Baya.Application/Features/Identity/Commands/RefreshToken/RefreshTokenCommand.Validator.cs new file mode 100644 index 0000000..7775a7f --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Identity/Commands/RefreshToken/RefreshTokenCommand.Validator.cs @@ -0,0 +1,16 @@ +using FluentValidation; + +namespace Baya.Application.Features.Identity.Commands.RefreshToken; + +public sealed class RefreshTokenCommandValidator : AbstractValidator +{ + public RefreshTokenCommandValidator() + { + RuleFor(x => x.RefreshToken) + .NotEmpty() + .MaximumLength(200); + + RuleFor(x => x.DeviceInfo) + .MaximumLength(400); + } +} \ No newline at end of file diff --git a/server/src/Core/Baya.Application/Features/Identity/Commands/RefreshToken/RefreshTokenCommand.cs b/server/src/Core/Baya.Application/Features/Identity/Commands/RefreshToken/RefreshTokenCommand.cs new file mode 100644 index 0000000..18f7d32 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Identity/Commands/RefreshToken/RefreshTokenCommand.cs @@ -0,0 +1,9 @@ +#nullable enable +using Baya.Application.Models.Common; +using Baya.Application.Models.Identity; +using Mediator; + +namespace Baya.Application.Features.Identity.Commands.RefreshToken; + +public record RefreshTokenCommand(string RefreshToken, string? DeviceInfo = null) + : IRequest>; \ No newline at end of file diff --git a/server/src/Core/Baya.Application/Features/Identity/Commands/RequestOtp/RequestOtpCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Identity/Commands/RequestOtp/RequestOtpCommand.Handler.cs new file mode 100644 index 0000000..3d21820 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Identity/Commands/RequestOtp/RequestOtpCommand.Handler.cs @@ -0,0 +1,69 @@ +#nullable enable +using Baya.Application.Contracts.Common; +using Baya.Application.Contracts.Configuration; +using Baya.Application.Contracts.Identity; +using Baya.Application.Models.Common; +using Baya.Application.Models.Identity; +using Baya.Domain.Entities.User; +using Mediator; + +namespace Baya.Application.Features.Identity.Commands.RequestOtp; + +internal sealed class RequestOtpCommandHandler( + IAppUserManager userManager, + ISmsSender smsSender, + IPlatformConfig platformConfig, + ICacheService cache, + IFieldEncryptor fieldEncryptor, + IDateTimeProvider clock) + : IRequestHandler> +{ + public async ValueTask> Handle(RequestOtpCommand request, CancellationToken cancellationToken) + { + var phone = IranianPhone.Normalize(request.Phone); + if (phone is null) + return OperationResult.FailureResult(nameof(request.Phone), "A valid Iranian mobile number is required."); + + var resendSeconds = await platformConfig.GetConfig(IdentityDefaults.OtpResendSecondsKey, cancellationToken); + + // Per-phone resend window (the per-IP OTP rate-limit policy guards the endpoint separately). + // Same behaviour whether or not the phone has an account — no enumeration. + var resendKey = IdentityDefaults.OtpResendCacheKey(fieldEncryptor.Hash(phone)); + var windowEndsAt = await cache.GetAsync(resendKey, cancellationToken); + var now = clock.UtcNow; + if (windowEndsAt > now) + return OperationResult.SuccessResult( + new RequestOtpResult(false, (int)Math.Ceiling((windowEndsAt - now).TotalSeconds))); + + var user = await userManager.GetUserByPhoneNumber(phone); + if (user is null) + { + // Inactive-until-verified shell account; no PII beyond the (encrypted) phone. The surrogate + // username keeps the plaintext phone out of Identity's UserName/NormalizedUserName columns. + user = new User + { + UserName = $"u_{Guid.NewGuid():N}", + PhoneNumber = phone, + IsActive = false + }; + + var createResult = await userManager.CreateUser(user); + if (!createResult.Succeeded) + return OperationResult.FailureResult("Unable to process the request. Try again."); + } + + // A fresh code voids the previous attempt counter; brute force stays bounded by the endpoint's + // per-IP rate limit plus the TOTP window. + await userManager.ResetUserLockoutAsync(user); + + var code = user.PhoneNumberConfirmed + ? await userManager.GenerateOtpCode(user) + : await userManager.GeneratePhoneNumberConfirmationToken(user, phone); + + await smsSender.SendOtpAsync(phone, code, cancellationToken); + + await cache.SetAsync(resendKey, now.AddSeconds(resendSeconds), TimeSpan.FromSeconds(resendSeconds), cancellationToken); + + return OperationResult.SuccessResult(new RequestOtpResult(true, resendSeconds)); + } +} \ No newline at end of file diff --git a/server/src/Core/Baya.Application/Features/Identity/Commands/RequestOtp/RequestOtpCommand.Validator.cs b/server/src/Core/Baya.Application/Features/Identity/Commands/RequestOtp/RequestOtpCommand.Validator.cs new file mode 100644 index 0000000..7d32e85 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Identity/Commands/RequestOtp/RequestOtpCommand.Validator.cs @@ -0,0 +1,13 @@ +using FluentValidation; + +namespace Baya.Application.Features.Identity.Commands.RequestOtp; + +public sealed class RequestOtpCommandValidator : AbstractValidator +{ + public RequestOtpCommandValidator() + { + RuleFor(x => x.Phone) + .NotEmpty() + .Must(IranianPhone.IsValid).WithMessage("A valid Iranian mobile number is required (09xxxxxxxxx)."); + } +} \ No newline at end of file diff --git a/server/src/Core/Baya.Application/Features/Identity/Commands/RequestOtp/RequestOtpCommand.cs b/server/src/Core/Baya.Application/Features/Identity/Commands/RequestOtp/RequestOtpCommand.cs new file mode 100644 index 0000000..118f5e0 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Identity/Commands/RequestOtp/RequestOtpCommand.cs @@ -0,0 +1,8 @@ +#nullable enable +using Baya.Application.Models.Common; +using Baya.Application.Models.Identity; +using Mediator; + +namespace Baya.Application.Features.Identity.Commands.RequestOtp; + +public record RequestOtpCommand(string Phone) : IRequest>; \ No newline at end of file diff --git a/server/src/Core/Baya.Application/Features/Identity/Commands/SelectRole/SelectRoleCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Identity/Commands/SelectRole/SelectRoleCommand.Handler.cs new file mode 100644 index 0000000..622cd94 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Identity/Commands/SelectRole/SelectRoleCommand.Handler.cs @@ -0,0 +1,60 @@ +#nullable enable +using Baya.Application.Contracts.Common; +using Baya.Application.Contracts.Persistence; +using Baya.Application.Models.Common; +using Baya.Application.Models.Identity; +using Baya.Domain.Entities.User; +using Mediator; + +namespace Baya.Application.Features.Identity.Commands.SelectRole; + +internal sealed class SelectRoleCommandHandler( + ICurrentUser currentUser, + IUnitOfWork unitOfWork, + IDateTimeProvider clock) + : IRequestHandler> +{ + public async ValueTask> Handle(SelectRoleCommand request, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } userId) + return OperationResult.UnauthorizedResult("Not authenticated."); + + var roleName = request.Role.Trim().ToLowerInvariant(); + if (!RoleNames.SelfAssignable.Contains(roleName)) + return OperationResult.ForbiddenResult("Only the customer or nurse role can be self-selected."); + + var role = await unitOfWork.UserAccountRepository.GetRoleByNameAsync(roleName, cancellationToken); + if (role is null) + return OperationResult.FailureResult("The requested role is not available."); + + var now = clock.UtcNow; + var existing = await unitOfWork.UserAccountRepository.GetUserRoleIncludingRevokedAsync(userId, role.Id, cancellationToken); + + if (existing is null) + { + await unitOfWork.UserAccountRepository.AddUserRoleAsync(new UserRole + { + UserId = userId, + RoleId = role.Id, + GrantedById = userId, + GrantedAt = now, + CreatedUserRoleDate = now.UtcDateTime + }, cancellationToken); + } + else if (existing.RevokedAt is not null) + { + // Re-activate the historical grant instead of colliding with the composite key. + existing.RevokedAt = null; + existing.GrantedById = userId; + existing.GrantedAt = now; + } + // else: already held — idempotent success. + + await unitOfWork.CommitAsync(); + + var snapshot = await unitOfWork.UserAccountRepository.GetAccountSnapshotAsync(userId, cancellationToken); + return snapshot is null + ? OperationResult.NotFoundResult("User not found.") + : OperationResult.SuccessResult(IdentityDefaults.ToMeResult(snapshot)); + } +} \ No newline at end of file diff --git a/server/src/Core/Baya.Application/Features/Identity/Commands/SelectRole/SelectRoleCommand.Validator.cs b/server/src/Core/Baya.Application/Features/Identity/Commands/SelectRole/SelectRoleCommand.Validator.cs new file mode 100644 index 0000000..a468dff --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Identity/Commands/SelectRole/SelectRoleCommand.Validator.cs @@ -0,0 +1,13 @@ +using FluentValidation; + +namespace Baya.Application.Features.Identity.Commands.SelectRole; + +public sealed class SelectRoleCommandValidator : AbstractValidator +{ + public SelectRoleCommandValidator() + { + RuleFor(x => x.Role) + .NotEmpty() + .MaximumLength(50); + } +} \ No newline at end of file diff --git a/server/src/Core/Baya.Application/Features/Identity/Commands/SelectRole/SelectRoleCommand.cs b/server/src/Core/Baya.Application/Features/Identity/Commands/SelectRole/SelectRoleCommand.cs new file mode 100644 index 0000000..c95cba6 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Identity/Commands/SelectRole/SelectRoleCommand.cs @@ -0,0 +1,11 @@ +using Baya.Application.Models.Common; +using Baya.Application.Models.Identity; +using Mediator; + +namespace Baya.Application.Features.Identity.Commands.SelectRole; + +/// +/// Self-assigns one of the public actor roles (customer / nurse; a user may hold both). +/// Any admin sub-role is rejected with 403 — admin provisioning is internal-only. +/// +public record SelectRoleCommand(string Role) : IRequest>; \ No newline at end of file diff --git a/server/src/Core/Baya.Application/Features/Identity/Commands/VerifyOtp/VerifyOtpCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Identity/Commands/VerifyOtp/VerifyOtpCommand.Handler.cs new file mode 100644 index 0000000..8cb5873 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Identity/Commands/VerifyOtp/VerifyOtpCommand.Handler.cs @@ -0,0 +1,77 @@ +#nullable enable +using Baya.Application.Contracts; +using Baya.Application.Contracts.Common; +using Baya.Application.Contracts.Configuration; +using Baya.Application.Contracts.Identity; +using Baya.Application.Contracts.Persistence; +using Baya.Application.Models.Common; +using Baya.Application.Models.Identity; +using Mediator; + +namespace Baya.Application.Features.Identity.Commands.VerifyOtp; + +internal sealed class VerifyOtpCommandHandler( + IAppUserManager userManager, + IJwtService jwtService, + IUnitOfWork unitOfWork, + IPlatformConfig platformConfig, + IFieldEncryptor fieldEncryptor, + IDateTimeProvider clock, + ICurrentUser currentUser) + : IRequestHandler> +{ + // One safe message for every wrong-phone/wrong-code combination — no account enumeration. + private const string InvalidCodeMessage = "The code is invalid or expired."; + + public async ValueTask> Handle(VerifyOtpCommand request, CancellationToken cancellationToken) + { + var phone = IranianPhone.Normalize(request.Phone); + if (phone is null) + return OperationResult.FailureResult(nameof(request.Phone), InvalidCodeMessage); + + var user = await userManager.GetUserByPhoneNumber(phone); + if (user is null) + return OperationResult.FailureResult(InvalidCodeMessage); + + var maxAttempts = await platformConfig.GetConfig(IdentityDefaults.OtpMaxAttemptsKey, cancellationToken); + if (user.AccessFailedCount >= maxAttempts) + return OperationResult.FailureResult("Too many failed attempts. Request a new code."); + + // First-ever verify confirms the phone (ChangePhoneNumber to the same number); afterwards the + // passwordless TOTP path applies. Both rotate the security stamp, so the token is minted after. + var wasPhoneConfirmed = user.PhoneNumberConfirmed; + var verifyResult = wasPhoneConfirmed + ? await userManager.VerifyUserCode(user, request.Code) + : await userManager.ChangePhoneNumber(user, phone, request.Code); + + if (!verifyResult.Succeeded) + { + await userManager.IncrementAccessFailedCountAsync(user); + return OperationResult.FailureResult(InvalidCodeMessage); + } + + var now = clock.UtcNow; + user.IsActive = true; + user.PhoneVerifiedAt ??= now; + await userManager.ResetUserLockoutAsync(user); + await userManager.UpdateUserAsync(user); + + var roles = await userManager.GetRoleAsync(user); + var accessToken = await jwtService.GenerateAccessTokenAsync(user); + + var sessionTtlDays = await platformConfig.GetConfig(IdentityDefaults.SessionTtlDaysKey, cancellationToken); + var (refreshToken, session) = IdentityDefaults.MintSession( + fieldEncryptor, user.Id, now, sessionTtlDays, request.DeviceInfo, currentUser.IpAddress); + + await unitOfWork.UserSessionRepository.AddAsync(session, cancellationToken); + await unitOfWork.CommitAsync(); + + return OperationResult.SuccessResult(new AuthTokensResult( + accessToken.Token, + refreshToken, + accessToken.ExpiresAt, + session.ExpiresAt, + IsNewUser: !wasPhoneConfirmed, + Roles: roles)); + } +} \ No newline at end of file diff --git a/server/src/Core/Baya.Application/Features/Identity/Commands/VerifyOtp/VerifyOtpCommand.Validator.cs b/server/src/Core/Baya.Application/Features/Identity/Commands/VerifyOtp/VerifyOtpCommand.Validator.cs new file mode 100644 index 0000000..5b7cca3 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Identity/Commands/VerifyOtp/VerifyOtpCommand.Validator.cs @@ -0,0 +1,20 @@ +using FluentValidation; + +namespace Baya.Application.Features.Identity.Commands.VerifyOtp; + +public sealed class VerifyOtpCommandValidator : AbstractValidator +{ + public VerifyOtpCommandValidator() + { + RuleFor(x => x.Phone) + .NotEmpty() + .Must(IranianPhone.IsValid).WithMessage("A valid Iranian mobile number is required (09xxxxxxxxx)."); + + RuleFor(x => x.Code) + .NotEmpty() + .MaximumLength(10); + + RuleFor(x => x.DeviceInfo) + .MaximumLength(400); + } +} \ No newline at end of file diff --git a/server/src/Core/Baya.Application/Features/Identity/Commands/VerifyOtp/VerifyOtpCommand.cs b/server/src/Core/Baya.Application/Features/Identity/Commands/VerifyOtp/VerifyOtpCommand.cs new file mode 100644 index 0000000..c482098 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Identity/Commands/VerifyOtp/VerifyOtpCommand.cs @@ -0,0 +1,9 @@ +#nullable enable +using Baya.Application.Models.Common; +using Baya.Application.Models.Identity; +using Mediator; + +namespace Baya.Application.Features.Identity.Commands.VerifyOtp; + +public record VerifyOtpCommand(string Phone, string Code, string? DeviceInfo = null) + : IRequest>; \ No newline at end of file diff --git a/server/src/Core/Baya.Application/Features/Identity/IdentityDefaults.cs b/server/src/Core/Baya.Application/Features/Identity/IdentityDefaults.cs new file mode 100644 index 0000000..c12802f --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Identity/IdentityDefaults.cs @@ -0,0 +1,77 @@ +#nullable enable +using System.Security.Cryptography; +using Baya.Application.Contracts.Common; +using Baya.Application.Models.Identity; +using Baya.Domain.Entities.User; + +namespace Baya.Application.Features.Identity; + +/// +/// Shared vocabulary of the identity slices: the `platform_configs` keys the handlers read at compute +/// time (never hardcode the values), refresh-token minting, and phone masking for `/me`-style payloads. +/// +internal static class IdentityDefaults +{ + /// Seconds a caller must wait before the same phone can be sent another OTP. + public const string OtpResendSecondsKey = "auth_otp_resend_seconds"; + + /// Wrong-code attempts allowed before verification is refused until a fresh OTP. + public const string OtpMaxAttemptsKey = "auth_otp_max_attempts"; + + /// Refresh-token session lifetime, in days. + public const string SessionTtlDaysKey = "auth_session_ttl_days"; + + /// Cache key of the per-phone resend window (keyed by phone hash, never the raw phone). + public static string OtpResendCacheKey(string phoneHash) => $"auth:otp:resend:{phoneHash}"; + + /// 256-bit random opaque refresh token, hex-encoded (URL-safe). Only its keyed hash is stored. + public static string NewRefreshToken() => Convert.ToHexString(RandomNumberGenerator.GetBytes(32)); + + /// Mints a raw refresh token plus its user_sessions row (hash stored, never the token). + public static (string RefreshToken, UserSession Session) MintSession( + IFieldEncryptor fieldEncryptor, + int userId, + DateTimeOffset now, + int ttlDays, + string? deviceInfo, + string? ipAddress) + { + var refreshToken = NewRefreshToken(); + + var session = new UserSession + { + UserId = userId, + RefreshTokenHash = fieldEncryptor.Hash(refreshToken), + DeviceInfo = deviceInfo, + IpAddress = ipAddress, + IsRevoked = false, + ExpiresAt = now.AddDays(ttlDays) + }; + + return (refreshToken, session); + } + + /// Masks all but the first four and last two digits (e.g. 0912*****89). + public static string MaskPhone(string? phone) + { + if (string.IsNullOrEmpty(phone)) + return string.Empty; + + return phone.Length <= 6 + ? new string('*', phone.Length) + : $"{phone[..4]}{new string('*', phone.Length - 6)}{phone[^2..]}"; + } + + public static MeResult ToMeResult(UserAccountSnapshot snapshot) => + new( + snapshot.Id, + MaskPhone(snapshot.Phone), + snapshot.FirstName, + snapshot.LastName, + snapshot.Gender, + snapshot.IsActive, + snapshot.Roles, + HasCustomerProfile: false, + HasNurseProfile: false, + NurseVerificationStatus: MeResult.VerificationNotStarted); +} \ No newline at end of file diff --git a/server/src/Core/Baya.Application/Features/Identity/IranianPhone.cs b/server/src/Core/Baya.Application/Features/Identity/IranianPhone.cs new file mode 100644 index 0000000..f1994bf --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Identity/IranianPhone.cs @@ -0,0 +1,29 @@ +#nullable enable +using System.Text.RegularExpressions; +using Baya.SharedKernel.Extensions; + +namespace Baya.Application.Features.Identity; + +/// +/// Normalizes an Iranian mobile number to the canonical 09xxxxxxxxx form (Persian digits +/// translated, +98/0098/98 prefixes folded). The canonical form is what gets stored, hashed, and +/// rate-limit-keyed — one identity per phone requires one spelling per phone. +/// +internal static partial class IranianPhone +{ + [GeneratedRegex(@"^(?:\+98|0098|98|0)?(9\d{9})$")] + private static partial Regex MobilePattern(); + + public static string? Normalize(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) + return null; + + var digits = raw.Trim().Fa2En().Replace(" ", string.Empty).Replace("-", string.Empty); + + var match = MobilePattern().Match(digits); + return match.Success ? $"0{match.Groups[1].Value}" : null; + } + + public static bool IsValid(string? raw) => Normalize(raw) is not null; +} \ No newline at end of file diff --git a/server/src/Core/Baya.Application/Features/Identity/Queries/GetMe/GetMeQuery.Handler.cs b/server/src/Core/Baya.Application/Features/Identity/Queries/GetMe/GetMeQuery.Handler.cs new file mode 100644 index 0000000..c1c97ed --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Identity/Queries/GetMe/GetMeQuery.Handler.cs @@ -0,0 +1,24 @@ +#nullable enable +using Baya.Application.Contracts.Common; +using Baya.Application.Contracts.Persistence; +using Baya.Application.Models.Common; +using Baya.Application.Models.Identity; +using Mediator; + +namespace Baya.Application.Features.Identity.Queries.GetMe; + +internal sealed class GetMeQueryHandler(ICurrentUser currentUser, IUnitOfWork unitOfWork) + : IRequestHandler> +{ + public async ValueTask> Handle(GetMeQuery request, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } userId) + return OperationResult.UnauthorizedResult("Not authenticated."); + + var snapshot = await unitOfWork.UserAccountRepository.GetAccountSnapshotAsync(userId, cancellationToken); + + return snapshot is null + ? OperationResult.NotFoundResult("User not found.") + : OperationResult.SuccessResult(IdentityDefaults.ToMeResult(snapshot)); + } +} \ No newline at end of file diff --git a/server/src/Core/Baya.Application/Features/Identity/Queries/GetMe/GetMeQuery.cs b/server/src/Core/Baya.Application/Features/Identity/Queries/GetMe/GetMeQuery.cs new file mode 100644 index 0000000..e4b7794 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Identity/Queries/GetMe/GetMeQuery.cs @@ -0,0 +1,7 @@ +using Baya.Application.Models.Common; +using Baya.Application.Models.Identity; +using Mediator; + +namespace Baya.Application.Features.Identity.Queries.GetMe; + +public record GetMeQuery : IRequest>; \ No newline at end of file diff --git a/server/src/Core/Baya.Application/Features/Users/Commands/Create/UserCreateCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Users/Commands/Create/UserCreateCommand.Handler.cs index 0cb280e..9d20db0 100644 --- a/server/src/Core/Baya.Application/Features/Users/Commands/Create/UserCreateCommand.Handler.cs +++ b/server/src/Core/Baya.Application/Features/Users/Commands/Create/UserCreateCommand.Handler.cs @@ -1,15 +1,15 @@ -using Baya.Application.Contracts.Identity; +using Baya.Application.Contracts.Common; +using Baya.Application.Contracts.Identity; using Baya.Application.Models.Common; using Baya.Domain.Entities.User; using MapsterMapper; using Mediator; -using Microsoft.Extensions.Logging; namespace Baya.Application.Features.Users.Commands.Create; internal class UserCreateCommandHandler( IAppUserManager userManager, - ILogger logger, + ISmsSender smsSender, IMapper mapper) : IRequestHandler> { @@ -26,12 +26,10 @@ internal class UserCreateCommandHandler( if (phoneNumberExist) return OperationResult.FailureResult("Username already exists"); - //var user = new User { UserName = request.UserName, Name = request.FirstName, FamilyName = request.LastName, PhoneNumber = request.PhoneNumber }; - var user = mapper.Map(request); - - var createResult =string.IsNullOrEmpty(request.Password)? + + var createResult =string.IsNullOrEmpty(request.Password)? await userManager.CreateUser(user) :await userManager.CreateUser(user, request.Password); @@ -43,10 +41,7 @@ internal class UserCreateCommandHandler( var code = await userManager.GeneratePhoneNumberConfirmationToken(user, user.PhoneNumber); - - logger.LogWarning($"Generated Code for User ID {user.Id} is {code}"); - - //TODO Send Code Via Sms Provider + await smsSender.SendOtpAsync(user.PhoneNumber, code, cancellationToken); return OperationResult.SuccessResult(new UserCreateCommandResult { UserGeneratedKey = user.GeneratedCode }); diff --git a/server/src/Core/Baya.Application/Features/Users/Queries/TokenRequest/UserTokenRequestQuery.Handler.cs b/server/src/Core/Baya.Application/Features/Users/Queries/TokenRequest/UserTokenRequestQuery.Handler.cs index 50607d5..496c355 100644 --- a/server/src/Core/Baya.Application/Features/Users/Queries/TokenRequest/UserTokenRequestQuery.Handler.cs +++ b/server/src/Core/Baya.Application/Features/Users/Queries/TokenRequest/UserTokenRequestQuery.Handler.cs @@ -1,17 +1,17 @@ -using Baya.Application.Contracts.Identity; +using Baya.Application.Contracts.Common; +using Baya.Application.Contracts.Identity; using Baya.Application.Models.Common; using Mediator; -using Microsoft.Extensions.Logging; namespace Baya.Application.Features.Users.Queries.TokenRequest; public class UserTokenRequestQueryHandler( IAppUserManager userManager, - ILogger logger) + ISmsSender smsSender) : IRequestHandler> { - - + + public async ValueTask> Handle(UserTokenRequestQuery request, CancellationToken cancellationToken) { var user = await userManager.GetUserByPhoneNumber(request.UserPhoneNumber); @@ -21,9 +21,7 @@ public class UserTokenRequestQueryHandler( var code = user.PhoneNumberConfirmed? await userManager.GenerateOtpCode(user) : await userManager.GeneratePhoneNumberConfirmationToken(user,user.PhoneNumber); - logger.LogWarning($"Generated Code for user Id {user.Id} is {code}"); - - //TODO Send Code Via Sms Provider + await smsSender.SendOtpAsync(user.PhoneNumber, code, cancellationToken); return OperationResult.SuccessResult(new UserTokenRequestQueryResponse {UserKey = user.GeneratedCode}); } diff --git a/server/src/Core/Baya.Application/Models/Common/OperationResult.cs b/server/src/Core/Baya.Application/Models/Common/OperationResult.cs index 7a94464..a0287cd 100644 --- a/server/src/Core/Baya.Application/Models/Common/OperationResult.cs +++ b/server/src/Core/Baya.Application/Models/Common/OperationResult.cs @@ -20,6 +20,12 @@ public class OperationResult : IOperationResult public bool IsException { get; set; } public bool IsNotFound { get; set; } + /// Maps to HTTP 401 — e.g. a rejected/reused refresh token (backend-phase-2). + public bool IsUnauthorized { get; set; } + + /// Maps to HTTP 403 — e.g. self-assigning an internal admin role (backend-phase-2). + public bool IsForbidden { get; set; } + public static OperationResult SuccessResult(TResult result) { return new OperationResult { Result = result, IsSuccess = true }; @@ -50,6 +56,24 @@ public class OperationResult : IOperationResult return operationResult; } + public static OperationResult UnauthorizedResult(string message) + { + var operationResult = new OperationResult { IsSuccess = false, IsUnauthorized = true }; + + operationResult.ErrorMessages.Add(new("GeneralError", message)); + + return operationResult; + } + + public static OperationResult ForbiddenResult(string message) + { + var operationResult = new OperationResult { IsSuccess = false, IsForbidden = true }; + + operationResult.ErrorMessages.Add(new("GeneralError", message)); + + return operationResult; + } + public void AddError(string propertyName, string message) { IsSuccess = false; diff --git a/server/src/Core/Baya.Application/Models/Identity/AuthTokensResult.cs b/server/src/Core/Baya.Application/Models/Identity/AuthTokensResult.cs new file mode 100644 index 0000000..7da21b6 --- /dev/null +++ b/server/src/Core/Baya.Application/Models/Identity/AuthTokensResult.cs @@ -0,0 +1,14 @@ +#nullable enable +namespace Baya.Application.Models.Identity; + +/// +/// The token pair returned by OTP verification and refresh. is empty for a fresh +/// user — the client's role router sends them to role selection. +/// +public record AuthTokensResult( + string AccessToken, + string RefreshToken, + DateTimeOffset AccessExpiresAt, + DateTimeOffset RefreshExpiresAt, + bool IsNewUser, + IReadOnlyList Roles); \ No newline at end of file diff --git a/server/src/Core/Baya.Application/Models/Identity/MeResult.cs b/server/src/Core/Baya.Application/Models/Identity/MeResult.cs new file mode 100644 index 0000000..a528b2e --- /dev/null +++ b/server/src/Core/Baya.Application/Models/Identity/MeResult.cs @@ -0,0 +1,22 @@ +#nullable enable +namespace Baya.Application.Models.Identity; + +/// +/// The `/me` payload. is always masked. The profile-completion flags stay false +/// until the profile tables land (b3); reads +/// nurse_verifications.status once b6 exists — until then it is not_started. +/// +public record MeResult( + int Id, + string Phone, + string? FirstName, + string? LastName, + string? Gender, + bool IsActive, + IReadOnlyList Roles, + bool HasCustomerProfile, + bool HasNurseProfile, + string NurseVerificationStatus) +{ + public const string VerificationNotStarted = "not_started"; +} \ No newline at end of file diff --git a/server/src/Core/Baya.Application/Models/Identity/RequestOtpResult.cs b/server/src/Core/Baya.Application/Models/Identity/RequestOtpResult.cs new file mode 100644 index 0000000..6707b98 --- /dev/null +++ b/server/src/Core/Baya.Application/Models/Identity/RequestOtpResult.cs @@ -0,0 +1,7 @@ +namespace Baya.Application.Models.Identity; + +/// +/// Deliberately non-enumerating: the same shape comes back whether or not the phone already had an +/// account. is false only when the per-phone resend window is still open. +/// +public record RequestOtpResult(bool OtpSent, int ResendAvailableInSeconds); \ No newline at end of file diff --git a/server/src/Core/Baya.Application/Models/Identity/UserAccountSnapshot.cs b/server/src/Core/Baya.Application/Models/Identity/UserAccountSnapshot.cs new file mode 100644 index 0000000..50331e3 --- /dev/null +++ b/server/src/Core/Baya.Application/Models/Identity/UserAccountSnapshot.cs @@ -0,0 +1,12 @@ +#nullable enable +namespace Baya.Application.Models.Identity; + +/// Raw account projection for the current user (phone unmasked — handlers mask it). +public record UserAccountSnapshot( + int Id, + string? Phone, + string? FirstName, + string? LastName, + string? Gender, + bool IsActive, + IReadOnlyList Roles); \ No newline at end of file diff --git a/server/src/Core/Baya.Application/Models/Jwt/JweAccessToken.cs b/server/src/Core/Baya.Application/Models/Jwt/JweAccessToken.cs new file mode 100644 index 0000000..4c733dc --- /dev/null +++ b/server/src/Core/Baya.Application/Models/Jwt/JweAccessToken.cs @@ -0,0 +1,4 @@ +namespace Baya.Application.Models.Jwt; + +/// A freshly minted JWE access token and its absolute expiry. +public record JweAccessToken(string Token, DateTimeOffset ExpiresAt); \ No newline at end of file diff --git a/server/src/Core/Baya.Domain/Entities/User/RoleNames.cs b/server/src/Core/Baya.Domain/Entities/User/RoleNames.cs new file mode 100644 index 0000000..5e9ea64 --- /dev/null +++ b/server/src/Core/Baya.Domain/Entities/User/RoleNames.cs @@ -0,0 +1,23 @@ +namespace Baya.Domain.Entities.User; + +/// +/// The platform role vocabulary. and are the public actor +/// roles a user may self-select (a user can hold both). The admin sub-roles are provisioned internally +/// only and are never self-assignable through any public endpoint. +/// +public static class RoleNames +{ + public const string Customer = "customer"; + public const string Nurse = "nurse"; + + public const string Admin = "admin"; + public const string Support = "support"; + public const string Finance = "finance"; + public const string Moderation = "moderation"; + public const string SuperAdmin = "super_admin"; + + public static readonly IReadOnlyList SelfAssignable = [Customer, Nurse]; + + public static readonly IReadOnlyList All = + [Customer, Nurse, Admin, Support, Finance, Moderation, SuperAdmin]; +} \ No newline at end of file diff --git a/server/src/Core/Baya.Domain/Entities/User/User.cs b/server/src/Core/Baya.Domain/Entities/User/User.cs index f98f414..28c724f 100644 --- a/server/src/Core/Baya.Domain/Entities/User/User.cs +++ b/server/src/Core/Baya.Domain/Entities/User/User.cs @@ -1,4 +1,4 @@ -using Baya.Domain.Common; +using Baya.Domain.Common; using Microsoft.AspNetCore.Identity; namespace Baya.Domain.Entities.User; @@ -13,10 +13,35 @@ public class User:IdentityUser,IEntity public string Name { get; set; } public string FamilyName { get; set; } public string GeneratedCode { get; set; } - + + /// + /// "male" / "female". Load-bearing for same-gender caregiver matching — never defaulted. Not + /// collected at OTP signup; populated later via the profile flow (b3). + /// + public string Gender { get; set; } + + /// Encrypted at rest. Stays NULL until the KYC pipeline (b6) verifies it — an unverified + /// registration must never look KYC-complete. + public string NationalId { get; set; } + public DateTimeOffset? NationalIdVerifiedAt { get; set; } + + /// When the phone↔national-id binding was confirmed via Shahkar. Reset to NULL whenever + /// the stored phone changes so b6 re-verifies (enforced centrally on SaveChanges). + public DateTimeOffset? ShahkarVerifiedAt { get; set; } + + /// Deterministic keyed hash of the (encrypted) phone — carries the UNIQUE index and all + /// equality lookups, since the ciphertext itself is non-deterministic. Synced on SaveChanges. + public string PhoneHash { get; set; } + + public DateTimeOffset? PhoneVerifiedAt { get; set; } + + public bool IsActive { get; set; } + public DateTimeOffset? DeletedAt { get; set; } + public ICollection UserRoles { get; set; } public ICollection Logins { get; set; } public ICollection Claims { get; set; } public ICollection Tokens { get; set; } public ICollection UserRefreshTokens { get; set; } + public ICollection Sessions { get; set; } } \ No newline at end of file diff --git a/server/src/Core/Baya.Domain/Entities/User/UserRole.cs b/server/src/Core/Baya.Domain/Entities/User/UserRole.cs index bbc95f4..25c1721 100644 --- a/server/src/Core/Baya.Domain/Entities/User/UserRole.cs +++ b/server/src/Core/Baya.Domain/Entities/User/UserRole.cs @@ -1,4 +1,4 @@ -using Baya.Domain.Common; +using Baya.Domain.Common; using Microsoft.AspNetCore.Identity; namespace Baya.Domain.Entities.User; @@ -9,4 +9,11 @@ public class UserRole : IdentityUserRole,IEntity public Role Role { get; set; } public DateTime CreatedUserRoleDate { get; set; } + /// Who granted the role — the user themself for the public customer/nurse self-select, + /// an admin for internal RBAC grants. NULL for legacy/seeded rows. + public int? GrantedById { get; set; } + public DateTimeOffset GrantedAt { get; set; } + + /// A revoked grant is kept as history (global query filter hides it from role reads). + public DateTimeOffset? RevokedAt { get; set; } } \ No newline at end of file diff --git a/server/src/Core/Baya.Domain/Entities/User/UserSession.cs b/server/src/Core/Baya.Domain/Entities/User/UserSession.cs new file mode 100644 index 0000000..fae3a50 --- /dev/null +++ b/server/src/Core/Baya.Domain/Entities/User/UserSession.cs @@ -0,0 +1,29 @@ +using Baya.Domain.Common; + +namespace Baya.Domain.Entities.User; + +/// +/// A revocable refresh-token session. Only the deterministic hash of the refresh token is stored — +/// never the raw token. Each refresh rotates: the presented session is revoked and a new one issued; +/// a token presented against an already-revoked session is treated as a stolen-token signal and all of +/// the user's sessions are revoked. +/// +public class UserSession : BaseEntity +{ + public int UserId { get; set; } + public User User { get; set; } + + public string RefreshTokenHash { get; set; } + public string DeviceInfo { get; set; } + public string IpAddress { get; set; } + + public bool IsRevoked { get; set; } + public DateTimeOffset? RevokedAt { get; set; } + public DateTimeOffset ExpiresAt { get; set; } + + public void Revoke(DateTimeOffset now) + { + IsRevoked = true; + RevokedAt = now; + } +} \ No newline at end of file diff --git a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Logging/LoggingConfiguration.cs b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Logging/LoggingConfiguration.cs index ef513f3..d96d396 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Logging/LoggingConfiguration.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Logging/LoggingConfiguration.cs @@ -36,7 +36,8 @@ public static class LoggingConfiguration columnOpts.PrimaryKey = columnOpts.Id; columnOpts.Id.DataType = SqlDbType.Int; - if (!context.HostingEnvironment.IsDevelopment()) + // Development and Testing (WebApplicationFactory) log locally; the SQL sink is for deployed envs. + if (!context.HostingEnvironment.IsDevelopment() && !context.HostingEnvironment.IsEnvironment("Testing")) { configuration.WriteTo .MSSqlServer( diff --git a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/LoggingSmsSender.cs b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/LoggingSmsSender.cs new file mode 100644 index 0000000..874f3d7 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/LoggingSmsSender.cs @@ -0,0 +1,28 @@ +using Baya.Application.Contracts.Common; +using Microsoft.Extensions.Logging; + +namespace Baya.Infrastructure.CrossCutting.Seams; + +/// +/// Mock : "delivers" by logging. The OTP code is written to the log so a +/// developer can complete the login flow; the phone number is never logged in full (PII policy) — +/// only its last four digits. The real implementation swaps to an Iranian SMS gateway +/// (Kavenegar/Ghasedak/SMS.ir) behind the same interface via a registration change. +/// +public sealed class LoggingSmsSender(ILogger logger) : ISmsSender +{ + public Task SendOtpAsync(string phone, string code, CancellationToken cancellationToken = default) + { + logger.LogWarning("MOCK SMS — OTP code {OtpCode} for phone ending in {PhoneTail}", code, Tail(phone)); + return Task.CompletedTask; + } + + public Task SendAsync(string phone, string message, CancellationToken cancellationToken = default) + { + logger.LogWarning("MOCK SMS — message to phone ending in {PhoneTail}: {Message}", Tail(phone), message); + return Task.CompletedTask; + } + + private static string Tail(string phone) => + string.IsNullOrEmpty(phone) ? "????" : phone[^Math.Min(4, phone.Length)..]; +} \ No newline at end of file diff --git a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/ServiceConfiguration/ServiceCollectionExtension.cs b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/ServiceConfiguration/ServiceCollectionExtension.cs index a28db14..c0777fc 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/ServiceConfiguration/ServiceCollectionExtension.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/ServiceConfiguration/ServiceCollectionExtension.cs @@ -8,7 +8,7 @@ namespace Baya.Infrastructure.CrossCutting.ServiceConfiguration; public static class ServiceCollectionExtension { /// - /// Registers the cross-cutting seams (time, PII encryption, cache, object storage) with their + /// Registers the cross-cutting seams (time, PII encryption, cache, object storage, SMS) with their /// in-memory/local mock implementations. Swapping in a real provider later is a registration change /// here — callers depend only on the Application contracts. (The real in-app /// INotificationDispatcher needs the database, so it is registered in the Persistence layer.) @@ -24,6 +24,10 @@ public static class ServiceCollectionExtension services.AddSingleton(); services.AddSingleton(); + // OTP/SMS delivery rail (backend-phase-2). The mock logs the code; a real gateway client + // (Kavenegar/Ghasedak/SMS.ir) replaces this registration only. + services.AddSingleton(); + return services; } } diff --git a/server/src/Infrastructure/Baya.Infrastructure.Identity/Identity/CurrentUser/HttpContextCurrentUser.cs b/server/src/Infrastructure/Baya.Infrastructure.Identity/Identity/CurrentUser/HttpContextCurrentUser.cs index 651ac76..13187df 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.Identity/Identity/CurrentUser/HttpContextCurrentUser.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.Identity/Identity/CurrentUser/HttpContextCurrentUser.cs @@ -27,4 +27,7 @@ public sealed class HttpContextCurrentUser(IHttpContextAccessor httpContextAcces public IReadOnlyList Roles => Principal?.FindAll(ClaimTypes.Role).Select(c => c.Value).ToArray() ?? []; + + public string? IpAddress => + httpContextAccessor.HttpContext?.Connection.RemoteIpAddress?.ToString(); } diff --git a/server/src/Infrastructure/Baya.Infrastructure.Identity/Identity/CurrentUser/NullCurrentUser.cs b/server/src/Infrastructure/Baya.Infrastructure.Identity/Identity/CurrentUser/NullCurrentUser.cs index 74e8eda..fc4fe36 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.Identity/Identity/CurrentUser/NullCurrentUser.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.Identity/Identity/CurrentUser/NullCurrentUser.cs @@ -13,4 +13,6 @@ public sealed class NullCurrentUser : ICurrentUser public bool IsAuthenticated => false; public IReadOnlyList Roles => []; + + public string IpAddress => null; } diff --git a/server/src/Infrastructure/Baya.Infrastructure.Identity/Identity/SeedDatabaseService/SeedDataBase.cs b/server/src/Infrastructure/Baya.Infrastructure.Identity/Identity/SeedDatabaseService/SeedDataBase.cs index 456f071..558168b 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.Identity/Identity/SeedDatabaseService/SeedDataBase.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.Identity/Identity/SeedDatabaseService/SeedDataBase.cs @@ -1,4 +1,4 @@ -using Baya.Domain.Entities.User; +using Baya.Domain.Entities.User; using Baya.Infrastructure.Identity.Identity.Manager; using Microsoft.EntityFrameworkCore; @@ -22,13 +22,17 @@ public class SeedDataBase : ISeedDataBase public async Task Seed() { - if (!_roleManager.Roles.AsNoTracking().Any(r => r.Name.Equals("admin"))) + // The full role vocabulary: public actor roles (customer/nurse — self-selectable) and the + // admin sub-roles (internally provisioned only, never self-assignable). + foreach (var roleName in RoleNames.All) { - var role=new Role + if (!_roleManager.Roles.AsNoTracking().Any(r => r.Name.Equals(roleName))) { - Name = "admin", - }; - await _roleManager.CreateAsync(role); + await _roleManager.CreateAsync(new Role + { + Name = roleName, + }); + } } if (!_userManager.Users.AsNoTracking().Any(u => u.UserName.Equals("admin"))) @@ -37,7 +41,8 @@ public class SeedDataBase : ISeedDataBase { UserName = "admin", Email = "admin@site.com", - PhoneNumberConfirmed = true + PhoneNumberConfirmed = true, + IsActive = true }; await _userManager.CreateAsync(user, "qw123321"); diff --git a/server/src/Infrastructure/Baya.Infrastructure.Identity/Jwt/JwtService.cs b/server/src/Infrastructure/Baya.Infrastructure.Identity/Jwt/JwtService.cs index 61d8ea9..8e24019 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.Identity/Jwt/JwtService.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.Identity/Jwt/JwtService.cs @@ -1,7 +1,8 @@ -using System.IdentityModel.Tokens.Jwt; +using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using System.Text; using Baya.Application.Contracts; +using Baya.Application.Contracts.Common; using Baya.Application.Contracts.Persistence; using Baya.Application.Models.Jwt; using Baya.Domain.Entities.User; @@ -19,44 +20,21 @@ public class JwtService : IJwtService private readonly IdentitySettings _siteSetting; private readonly AppUserManager _userManager; private IUserClaimsPrincipalFactory _claimsPrincipal; + private readonly IFieldEncryptor _fieldEncryptor; private readonly IUnitOfWork _unitOfWork; - //private readonly AppUserClaimsPrincipleFactory claimsPrincipleFactory; - public JwtService(IOptions siteSetting, AppUserManager userManager, IUserClaimsPrincipalFactory claimsPrincipal, IUnitOfWork unitOfWork) + public JwtService(IOptions siteSetting, AppUserManager userManager, IUserClaimsPrincipalFactory claimsPrincipal, IUnitOfWork unitOfWork, IFieldEncryptor fieldEncryptor) { _siteSetting = siteSetting.Value; _userManager = userManager; _claimsPrincipal = claimsPrincipal; _unitOfWork = unitOfWork; + _fieldEncryptor = fieldEncryptor; } public async Task GenerateAsync(User user) { - var secretKey = Encoding.UTF8.GetBytes(_siteSetting.SecretKey); // longer that 16 character - var signingCredentials = new SigningCredentials(new SymmetricSecurityKey(secretKey), SecurityAlgorithms.HmacSha256Signature); - - var encryptionkey = Encoding.UTF8.GetBytes(_siteSetting.Encryptkey); //must be 16 character - var encryptingCredentials = new EncryptingCredentials(new SymmetricSecurityKey(encryptionkey), SecurityAlgorithms.Aes128KW, SecurityAlgorithms.Aes128CbcHmacSha256); - - - var claims = await _getClaimsAsync(user); - - var descriptor = new SecurityTokenDescriptor - { - Issuer = _siteSetting.Issuer, - Audience = _siteSetting.Audience, - IssuedAt = DateTime.Now, - NotBefore = DateTime.Now.AddMinutes(0), - Expires = DateTime.Now.AddMinutes(_siteSetting.ExpirationMinutes), - SigningCredentials = signingCredentials, - EncryptingCredentials = encryptingCredentials, - Subject = new ClaimsIdentity(claims) - }; - - var tokenHandler = new JwtSecurityTokenHandler(); - - var securityToken = tokenHandler.CreateJwtSecurityToken(descriptor); - + var securityToken = await _createSecurityTokenAsync(user); var refreshToken = await _unitOfWork.UserRefreshTokenRepository.CreateToken(user.Id); await _unitOfWork.CommitAsync(); @@ -64,6 +42,19 @@ public class JwtService : IJwtService return new AccessToken(securityToken,refreshToken.ToString()); } + /// + /// Access token only — no UserRefreshTokens row. The REST auth flow (backend-phase-2) + /// pairs this with its own revocable user_sessions refresh token. + /// + public async Task GenerateAccessTokenAsync(User user) + { + var securityToken = await _createSecurityTokenAsync(user); + + return new JweAccessToken( + new JwtSecurityTokenHandler().WriteToken(securityToken), + new DateTimeOffset(securityToken.ValidTo, TimeSpan.Zero)); + } + public Task GetPrincipalFromExpiredToken(string token) { var tokenValidationParameters = new TokenValidationParameters @@ -88,7 +79,9 @@ public class JwtService : IJwtService public async Task GenerateByPhoneNumberAsync(string phoneNumber) { - var user = await _userManager.Users.AsNoTracking().FirstOrDefaultAsync(u => u.PhoneNumber == phoneNumber); + // The phone column is encrypted (non-deterministic); equality goes through the hash. + var phoneHash = _fieldEncryptor.Hash(phoneNumber); + var user = await _userManager.Users.AsNoTracking().FirstOrDefaultAsync(u => u.PhoneHash == phoneHash); var result = await this.GenerateAsync(user); return result; } @@ -96,7 +89,7 @@ public class JwtService : IJwtService public async Task RefreshToken(Guid refreshTokenId) { var refreshToken = await _unitOfWork.UserRefreshTokenRepository.GetTokenWithInvalidation(refreshTokenId); - + if (refreshToken is null) return null; @@ -114,6 +107,33 @@ public class JwtService : IJwtService return result; } + private async Task _createSecurityTokenAsync(User user) + { + var secretKey = Encoding.UTF8.GetBytes(_siteSetting.SecretKey); // longer that 16 character + var signingCredentials = new SigningCredentials(new SymmetricSecurityKey(secretKey), SecurityAlgorithms.HmacSha256Signature); + + var encryptionkey = Encoding.UTF8.GetBytes(_siteSetting.Encryptkey); //must be 16 character + var encryptingCredentials = new EncryptingCredentials(new SymmetricSecurityKey(encryptionkey), SecurityAlgorithms.Aes128KW, SecurityAlgorithms.Aes128CbcHmacSha256); + + var claims = await _getClaimsAsync(user); + + var descriptor = new SecurityTokenDescriptor + { + Issuer = _siteSetting.Issuer, + Audience = _siteSetting.Audience, + IssuedAt = DateTime.Now, + NotBefore = DateTime.Now.AddMinutes(0), + Expires = DateTime.Now.AddMinutes(_siteSetting.ExpirationMinutes), + SigningCredentials = signingCredentials, + EncryptingCredentials = encryptingCredentials, + Subject = new ClaimsIdentity(claims) + }; + + var tokenHandler = new JwtSecurityTokenHandler(); + + return tokenHandler.CreateJwtSecurityToken(descriptor); + } + private async Task> _getClaimsAsync(User user) { var result = await _claimsPrincipal.CreateAsync(user); diff --git a/server/src/Infrastructure/Baya.Infrastructure.Identity/UserManager/AppUserManagerImplementation.cs b/server/src/Infrastructure/Baya.Infrastructure.Identity/UserManager/AppUserManagerImplementation.cs index 817774a..36d7042 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.Identity/UserManager/AppUserManagerImplementation.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.Identity/UserManager/AppUserManagerImplementation.cs @@ -1,4 +1,5 @@ -using Baya.Application.Contracts.Identity; +using Baya.Application.Contracts.Common; +using Baya.Application.Contracts.Identity; using Baya.Domain.Entities.User; using Baya.Infrastructure.Identity.Identity.Dtos; using Baya.Infrastructure.Identity.Identity.Manager; @@ -10,9 +11,12 @@ namespace Baya.Infrastructure.Identity.UserManager; public class AppUserManagerImplementation : IAppUserManager { private readonly AppUserManager _userManager; - public AppUserManagerImplementation(AppUserManager userManager) + private readonly IFieldEncryptor _fieldEncryptor; + + public AppUserManagerImplementation(AppUserManager userManager, IFieldEncryptor fieldEncryptor) { _userManager = userManager; + _fieldEncryptor = fieldEncryptor; } public Task CreateUser(User user) @@ -27,7 +31,10 @@ public class AppUserManagerImplementation : IAppUserManager public Task IsExistUser(string phoneNumber) { - return _userManager.Users.AnyAsync(c => c.PhoneNumber == phoneNumber); + // The phone column is encrypted (non-deterministic ciphertext) — equality goes through the + // deterministic hash column. + var phoneHash = _fieldEncryptor.Hash(phoneNumber); + return _userManager.Users.AnyAsync(c => c.PhoneHash == phoneHash); } public Task IsExistUserName(string userName) @@ -71,7 +78,8 @@ public class AppUserManagerImplementation : IAppUserManager public Task GetUserByPhoneNumber(string phoneNumber) { - return _userManager.Users.FirstOrDefaultAsync(c => c.PhoneNumber.Equals(phoneNumber)); + var phoneHash = _fieldEncryptor.Hash(phoneNumber); + return _userManager.Users.FirstOrDefaultAsync(c => c.PhoneHash == phoneHash); } diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/ApplicationDbContext.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/ApplicationDbContext.cs index f116d6d..6d83609 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.Persistence/ApplicationDbContext.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/ApplicationDbContext.cs @@ -1,6 +1,8 @@ -using System.Reflection; +using System.Reflection; +using Baya.Application.Contracts.Common; using Baya.Domain.Common; using Baya.Domain.Entities.User; +using Baya.Infrastructure.Persistence.ValueConversion; using Baya.SharedKernel.Extensions; using Microsoft.AspNetCore.Identity.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; @@ -9,15 +11,46 @@ namespace Baya.Infrastructure.Persistence; public class ApplicationDbContext: IdentityDbContext { - public ApplicationDbContext(DbContextOptions options) + private readonly IFieldEncryptor _fieldEncryptor; + + // The encryptor ends up captured inside the cached EF model (value converters), so it must be a + // process-wide singleton with stable keys — which is how the seam is registered. + public ApplicationDbContext(DbContextOptions options, IFieldEncryptor fieldEncryptor) : base(options) { + _fieldEncryptor = fieldEncryptor; base.SavingChanges += OnSavingChanges; } private void OnSavingChanges(object sender, SavingChangesEventArgs e) { _cleanString(); + _syncUserPhoneIntegrity(); + } + + /// + /// Keeps the deterministic PhoneHash lookup column in step with the encrypted phone, and + /// enforces the product rule that a phone change invalidates the Shahkar phone↔national-id binding + /// (reset to NULL so b6 re-verifies) — centrally, so no handler can forget it. + /// + private void _syncUserPhoneIntegrity() + { + foreach (var entry in ChangeTracker.Entries()) + { + if (entry.State == EntityState.Added) + { + entry.Entity.PhoneHash = _fieldEncryptor.Hash(entry.Entity.PhoneNumber); + } + else if (entry.State == EntityState.Modified) + { + var phone = entry.Property(u => u.PhoneNumber); + if (!string.Equals(phone.OriginalValue, phone.CurrentValue, StringComparison.Ordinal)) + { + entry.Entity.PhoneHash = _fieldEncryptor.Hash(phone.CurrentValue); + entry.Entity.ShahkarVerifiedAt = null; + } + } + } } private void _cleanString() @@ -59,6 +92,16 @@ public class ApplicationDbContext: IdentityDbContext(builder => + { + builder.Property(u => u.PhoneNumber).HasConversion(encrypted); + builder.Property(u => u.Email).HasConversion(encrypted); + builder.Property(u => u.NormalizedEmail).HasConversion(encrypted); + builder.Property(u => u.NationalId).HasConversion(encrypted); + }); } } \ No newline at end of file diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/ConfigurationConfig/PlatformConfigConfig.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/ConfigurationConfig/PlatformConfigConfig.cs index 417e8d9..8a3be8c 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/ConfigurationConfig/PlatformConfigConfig.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/ConfigurationConfig/PlatformConfigConfig.cs @@ -40,6 +40,9 @@ internal sealed class PlatformConfigConfig : IEntityTypeConfiguration public void Configure(EntityTypeBuilder builder) { builder.ToTable("Users","usr").Property(p => p.Id).HasColumnName("UserId"); + + builder.Property(u => u.Gender).HasMaxLength(10); + builder.Property(u => u.PhoneHash).HasMaxLength(64); + builder.Property(u => u.IsActive).HasDefaultValue(false); + + // One identity per phone. The unique index lives on the deterministic hash because the + // encrypted phone column itself is non-deterministic ciphertext. Filtered: users without a + // phone (internally provisioned admins) don't collide on NULL. + builder.HasIndex(u => u.PhoneHash).IsUnique().HasFilter("[PhoneHash] IS NOT NULL"); + + builder.HasQueryFilter(u => u.DeletedAt == null); } } \ No newline at end of file diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/UserConfig/UserRoleConfig.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/UserConfig/UserRoleConfig.cs index c949765..4820d90 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/UserConfig/UserRoleConfig.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/UserConfig/UserRoleConfig.cs @@ -1,4 +1,4 @@ -using Baya.Domain.Entities.User; +using Baya.Domain.Entities.User; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; @@ -11,6 +11,12 @@ internal class UserRoleConfig:IEntityTypeConfiguration builder.HasOne(u => u.User).WithMany(u => u.UserRoles).HasForeignKey(u => u.UserId); builder.HasOne(u => u.Role).WithMany(u => u.Users).HasForeignKey(u => u.RoleId); + builder.HasOne().WithMany().HasForeignKey(u => u.GrantedById); + + // Revoked grants are history, not membership: hiding them here makes every role read + // (Identity's GetRoles, the JWT claims factory, /me) respect revocation automatically. + builder.HasQueryFilter(ur => ur.RevokedAt == null); + builder.ToTable("UserRoles","usr"); } } \ No newline at end of file diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/UserConfig/UserSessionConfig.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/UserConfig/UserSessionConfig.cs new file mode 100644 index 0000000..89afd19 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/UserConfig/UserSessionConfig.cs @@ -0,0 +1,27 @@ +using Baya.Domain.Entities.User; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Baya.Infrastructure.Persistence.Configuration.UserConfig; + +internal sealed class UserSessionConfig : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("UserSessions", "usr"); + + builder.Property(s => s.RefreshTokenHash).HasMaxLength(128).IsRequired(); + builder.Property(s => s.DeviceInfo).HasMaxLength(400); + builder.Property(s => s.IpAddress).HasMaxLength(64); + builder.Property(s => s.IsRevoked).HasDefaultValue(false); + + // Rotation looks sessions up by the presented token's hash; revoke-all scans by (user, active). + builder.HasIndex(s => s.RefreshTokenHash).IsUnique(); + builder.HasIndex(s => new { s.UserId, s.IsRevoked }); + + builder.HasOne(s => s.User).WithMany(u => u.Sessions).HasForeignKey(s => s.UserId); + + // Mirrors the owner's soft-delete filter so a deleted user's sessions are unreachable. + builder.HasQueryFilter(s => s.User.DeletedAt == null); + } +} \ No newline at end of file diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260701222425_IdentitySessionsAndUserExtensions.Designer.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260701222425_IdentitySessionsAndUserExtensions.Designer.cs new file mode 100644 index 0000000..fd40906 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260701222425_IdentitySessionsAndUserExtensions.Designer.cs @@ -0,0 +1,1015 @@ +// +using System; +using Baya.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Baya.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260701222425_IdentitySessionsAndUserExtensions")] + partial class IdentitySessionsAndUserExtensions + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Baya.Domain.Entities.Analytics.SystemEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("OccurredAt") + .HasColumnType("datetimeoffset"); + + b.Property("PropsJson") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Name"); + + b.HasIndex("OccurredAt"); + + b.HasIndex("UserId"); + + b.ToTable("SystemEvents", "ops"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Audit.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("ActorUserId") + .HasColumnType("int"); + + b.Property("ChangedFieldsJson") + .HasColumnType("nvarchar(max)"); + + b.Property("EntityId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("OccurredAt") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("ActorUserId"); + + b.HasIndex("OccurredAt"); + + b.HasIndex("EntityType", "EntityId"); + + b.ToTable("AuditLogs", "ops"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Configuration.PlatformConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DataType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("Value") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("PlatformConfigs", "ops"); + + b.HasData( + new + { + Id = 1L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "decimal", + Description = "Balinyaar commission rate on the booking gross (fraction).", + Key = "platform_fee_rate", + Value = "0.15" + }, + new + { + Id = 2L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "decimal", + Description = "VAT rate applied to the commission line only (fraction).", + Key = "vat_rate", + Value = "0.10" + }, + new + { + Id = 3L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Hours after check-out a booking can be disputed.", + Key = "dispute_window_hours", + Value = "72" + }, + new + { + Id = 4L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Minutes a family has to pay before a pending booking expires.", + Key = "booking_payment_deadline_minutes", + Value = "30" + }, + new + { + Id = 5L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Hours a nurse has to accept/decline a booking request.", + Key = "nurse_response_deadline_hours", + Value = "24" + }, + new + { + Id = 6L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Weekly payout cadence in days.", + Key = "nurse_payout_interval_days", + Value = "7" + }, + new + { + Id = 7L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Allowed EVV check-in distance from the care address.", + Key = "evv_location_tolerance_meters", + Value = "200" + }, + new + { + Id = 8L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "decimal", + Description = "A review at or below this rating raises a support alert.", + Key = "min_rating_for_support_alert", + Value = "2" + }, + new + { + Id = 9L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "string", + Description = "Who is merchant of record for BNPL orders (platform|nurse).", + Key = "bnpl_merchant_of_record", + Value = "platform" + }, + new + { + Id = 10L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "decimal", + Description = "BNPL provider commission rate (fraction).", + Key = "bnpl_provider_commission_rate", + Value = "0.07" + }, + new + { + Id = 11L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "string", + Description = "When BNPL settles funds to the platform (immediate|deferred).", + Key = "bnpl_settlement_timing", + Value = "immediate" + }, + new + { + Id = 12L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "json", + Description = "Tiered cancellation refund policy: refund_percent by hours before the visit.", + Key = "cancellation_tiers", + Value = "[{\"min_hours_before\":48,\"refund_percent\":100},{\"min_hours_before\":24,\"refund_percent\":50},{\"min_hours_before\":0,\"refund_percent\":0}]" + }, + new + { + Id = 13L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Seconds a phone must wait before another OTP can be requested.", + Key = "auth_otp_resend_seconds", + Value = "120" + }, + new + { + Id = 14L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Wrong-code attempts allowed before OTP verification is refused until a fresh code.", + Key = "auth_otp_max_attempts", + Value = "5" + }, + new + { + Id = 15L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Refresh-token session lifetime in days.", + Key = "auth_session_ttl_days", + Value = "30" + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Holidays.IranianHoliday", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("HolidayDate") + .HasColumnType("date"); + + b.Property("IsBankClosed") + .HasColumnType("bit"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NameFa") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.HasKey("Id"); + + b.HasIndex("HolidayDate") + .IsUnique(); + + b.ToTable("IranianHolidays", "ops"); + + b.HasData( + new + { + Id = 1L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + HolidayDate = new DateOnly(2026, 2, 11), + IsBankClosed = true, + NameFa = "پیروزی انقلاب اسلامی", + Type = "national" + }, + new + { + Id = 2L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + HolidayDate = new DateOnly(2026, 3, 21), + IsBankClosed = true, + NameFa = "نوروز", + Type = "national" + }, + new + { + Id = 3L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + HolidayDate = new DateOnly(2026, 3, 22), + IsBankClosed = true, + NameFa = "نوروز", + Type = "national" + }, + new + { + Id = 4L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + HolidayDate = new DateOnly(2026, 3, 23), + IsBankClosed = true, + NameFa = "نوروز", + Type = "national" + }, + new + { + Id = 5L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + HolidayDate = new DateOnly(2026, 3, 24), + IsBankClosed = true, + NameFa = "نوروز", + Type = "national" + }, + new + { + Id = 6L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + HolidayDate = new DateOnly(2026, 4, 1), + IsBankClosed = true, + NameFa = "روز طبیعت (سیزده‌به‌در)", + Type = "official" + }, + new + { + Id = 7L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + HolidayDate = new DateOnly(2026, 6, 26), + IsBankClosed = true, + NameFa = "عید سعید قربان", + Type = "religious" + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Notifications.Notification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Body") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DataJson") + .HasColumnType("nvarchar(max)"); + + b.Property("IsRead") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("ReadAt") + .HasColumnType("datetimeoffset"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "IsRead", "CreatedAt"); + + b.ToTable("Notifications", "ops"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.SupportAlerts.SupportAlert", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BookingId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("EntityId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("OwnerUserId") + .HasColumnType("int"); + + b.Property("ResolutionNote") + .HasColumnType("nvarchar(max)"); + + b.Property("ResolvedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ReviewId") + .HasColumnType("bigint"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId"); + + b.HasIndex("Status"); + + b.HasIndex("Type"); + + b.ToTable("SupportAlerts", "ops"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("Roles", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.RoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedClaim") + .HasColumnType("datetime2"); + + b.Property("RoleId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("RoleClaims", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("UserId"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("FamilyName") + .HasColumnType("nvarchar(max)"); + + b.Property("Gender") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("GeneratedCode") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("NationalId") + .HasColumnType("nvarchar(max)"); + + b.Property("NationalIdVerifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneHash") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("PhoneVerifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("ShahkarVerifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.HasIndex("PhoneHash") + .IsUnique() + .HasFilter("[PhoneHash] IS NOT NULL"); + + b.ToTable("Users", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserClaims", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("LoggedOn") + .HasColumnType("datetime2"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("UserLogins", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserRefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("IsValid") + .HasColumnType("bit"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserRefreshTokens", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserRole", b => + { + b.Property("UserId") + .HasColumnType("int"); + + b.Property("RoleId") + .HasColumnType("int"); + + b.Property("CreatedUserRoleDate") + .HasColumnType("datetime2"); + + b.Property("GrantedAt") + .HasColumnType("datetimeoffset"); + + b.Property("GrantedById") + .HasColumnType("int"); + + b.Property("RevokedAt") + .HasColumnType("datetimeoffset"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("GrantedById"); + + b.HasIndex("RoleId"); + + b.ToTable("UserRoles", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeviceInfo") + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.Property("ExpiresAt") + .HasColumnType("datetimeoffset"); + + b.Property("IpAddress") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("IsRevoked") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("RefreshTokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("RevokedAt") + .HasColumnType("datetimeoffset"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("RefreshTokenHash") + .IsUnique(); + + b.HasIndex("UserId", "IsRevoked"); + + b.ToTable("UserSessions", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserToken", b => + { + b.Property("UserId") + .HasColumnType("int"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("GeneratedTime") + .HasColumnType("datetime2"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("UserTokens", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Analytics.SystemEvent", b => + { + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("UserId"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Audit.AuditLog", b => + { + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("ActorUserId"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Notifications.Notification", b => + { + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Baya.Domain.Entities.SupportAlerts.SupportAlert", b => + { + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("OwnerUserId"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.RoleClaim", b => + { + b.HasOne("Baya.Domain.Entities.User.Role", "Role") + .WithMany("Claims") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserClaim", b => + { + b.HasOne("Baya.Domain.Entities.User.User", "User") + .WithMany("Claims") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserLogin", b => + { + b.HasOne("Baya.Domain.Entities.User.User", "User") + .WithMany("Logins") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserRefreshToken", b => + { + b.HasOne("Baya.Domain.Entities.User.User", "User") + .WithMany("UserRefreshTokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserRole", b => + { + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("GrantedById"); + + b.HasOne("Baya.Domain.Entities.User.Role", "Role") + .WithMany("Users") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.User.User", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserSession", b => + { + b.HasOne("Baya.Domain.Entities.User.User", "User") + .WithMany("Sessions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserToken", b => + { + b.HasOne("Baya.Domain.Entities.User.User", "User") + .WithMany("Tokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.Role", b => + { + b.Navigation("Claims"); + + b.Navigation("Users"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.User", b => + { + b.Navigation("Claims"); + + b.Navigation("Logins"); + + b.Navigation("Sessions"); + + b.Navigation("Tokens"); + + b.Navigation("UserRefreshTokens"); + + b.Navigation("UserRoles"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260701222425_IdentitySessionsAndUserExtensions.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260701222425_IdentitySessionsAndUserExtensions.cs new file mode 100644 index 0000000..0cd2eb4 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260701222425_IdentitySessionsAndUserExtensions.cs @@ -0,0 +1,280 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional + +namespace Baya.Infrastructure.Persistence.Migrations +{ + /// + public partial class IdentitySessionsAndUserExtensions : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "DeletedAt", + schema: "usr", + table: "Users", + type: "datetimeoffset", + nullable: true); + + migrationBuilder.AddColumn( + name: "Gender", + schema: "usr", + table: "Users", + type: "nvarchar(10)", + maxLength: 10, + nullable: true); + + migrationBuilder.AddColumn( + name: "IsActive", + schema: "usr", + table: "Users", + type: "bit", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "NationalId", + schema: "usr", + table: "Users", + type: "nvarchar(max)", + nullable: true); + + migrationBuilder.AddColumn( + name: "NationalIdVerifiedAt", + schema: "usr", + table: "Users", + type: "datetimeoffset", + nullable: true); + + migrationBuilder.AddColumn( + name: "PhoneHash", + schema: "usr", + table: "Users", + type: "nvarchar(64)", + maxLength: 64, + nullable: true); + + migrationBuilder.AddColumn( + name: "PhoneVerifiedAt", + schema: "usr", + table: "Users", + type: "datetimeoffset", + nullable: true); + + migrationBuilder.AddColumn( + name: "ShahkarVerifiedAt", + schema: "usr", + table: "Users", + type: "datetimeoffset", + nullable: true); + + migrationBuilder.AddColumn( + name: "GrantedAt", + schema: "usr", + table: "UserRoles", + type: "datetimeoffset", + nullable: false, + defaultValue: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0))); + + migrationBuilder.AddColumn( + name: "GrantedById", + schema: "usr", + table: "UserRoles", + type: "int", + nullable: true); + + migrationBuilder.AddColumn( + name: "RevokedAt", + schema: "usr", + table: "UserRoles", + type: "datetimeoffset", + nullable: true); + + migrationBuilder.CreateTable( + name: "UserSessions", + schema: "usr", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + UserId = table.Column(type: "int", nullable: false), + RefreshTokenHash = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: false), + DeviceInfo = table.Column(type: "nvarchar(400)", maxLength: 400, nullable: true), + IpAddress = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: true), + IsRevoked = table.Column(type: "bit", nullable: false, defaultValue: false), + RevokedAt = table.Column(type: "datetimeoffset", nullable: true), + ExpiresAt = table.Column(type: "datetimeoffset", nullable: false), + CreatedAt = table.Column(type: "datetimeoffset", nullable: false), + ModifiedAt = table.Column(type: "datetimeoffset", nullable: true), + CreatedById = table.Column(type: "int", nullable: true), + ModifiedById = table.Column(type: "int", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_UserSessions", x => x.Id); + table.ForeignKey( + name: "FK_UserSessions_Users_UserId", + column: x => x.UserId, + principalSchema: "usr", + principalTable: "Users", + principalColumn: "UserId", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.InsertData( + schema: "ops", + table: "PlatformConfigs", + columns: new[] { "Id", "CreatedAt", "CreatedById", "DataType", "Description", "Key", "ModifiedAt", "ModifiedById", "Value" }, + values: new object[,] + { + { 13L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, "int", "Seconds a phone must wait before another OTP can be requested.", "auth_otp_resend_seconds", null, null, "120" }, + { 14L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, "int", "Wrong-code attempts allowed before OTP verification is refused until a fresh code.", "auth_otp_max_attempts", null, null, "5" }, + { 15L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, "int", "Refresh-token session lifetime in days.", "auth_session_ttl_days", null, null, "30" } + }); + + // Phone/email are encrypted at rest from this migration on, and reads now decrypt. Pre-b2 + // rows hold plaintext the decryptor would choke on — clear them (pre-launch dev/test + // accounts only) and keep the seeded admin sign-in-able. + migrationBuilder.Sql( + "UPDATE [usr].[Users] SET [Email] = NULL, [NormalizedEmail] = NULL, [PhoneNumber] = NULL, [PhoneNumberConfirmed] = 0;"); + migrationBuilder.Sql( + "UPDATE [usr].[Users] SET [IsActive] = 1 WHERE [UserName] = 'admin';"); + + migrationBuilder.CreateIndex( + name: "IX_Users_PhoneHash", + schema: "usr", + table: "Users", + column: "PhoneHash", + unique: true, + filter: "[PhoneHash] IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_UserRoles_GrantedById", + schema: "usr", + table: "UserRoles", + column: "GrantedById"); + + migrationBuilder.CreateIndex( + name: "IX_UserSessions_RefreshTokenHash", + schema: "usr", + table: "UserSessions", + column: "RefreshTokenHash", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_UserSessions_UserId_IsRevoked", + schema: "usr", + table: "UserSessions", + columns: new[] { "UserId", "IsRevoked" }); + + migrationBuilder.AddForeignKey( + name: "FK_UserRoles_Users_GrantedById", + schema: "usr", + table: "UserRoles", + column: "GrantedById", + principalSchema: "usr", + principalTable: "Users", + principalColumn: "UserId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_UserRoles_Users_GrantedById", + schema: "usr", + table: "UserRoles"); + + migrationBuilder.DropTable( + name: "UserSessions", + schema: "usr"); + + migrationBuilder.DropIndex( + name: "IX_Users_PhoneHash", + schema: "usr", + table: "Users"); + + migrationBuilder.DropIndex( + name: "IX_UserRoles_GrantedById", + schema: "usr", + table: "UserRoles"); + + migrationBuilder.DeleteData( + schema: "ops", + table: "PlatformConfigs", + keyColumn: "Id", + keyValue: 13L); + + migrationBuilder.DeleteData( + schema: "ops", + table: "PlatformConfigs", + keyColumn: "Id", + keyValue: 14L); + + migrationBuilder.DeleteData( + schema: "ops", + table: "PlatformConfigs", + keyColumn: "Id", + keyValue: 15L); + + migrationBuilder.DropColumn( + name: "DeletedAt", + schema: "usr", + table: "Users"); + + migrationBuilder.DropColumn( + name: "Gender", + schema: "usr", + table: "Users"); + + migrationBuilder.DropColumn( + name: "IsActive", + schema: "usr", + table: "Users"); + + migrationBuilder.DropColumn( + name: "NationalId", + schema: "usr", + table: "Users"); + + migrationBuilder.DropColumn( + name: "NationalIdVerifiedAt", + schema: "usr", + table: "Users"); + + migrationBuilder.DropColumn( + name: "PhoneHash", + schema: "usr", + table: "Users"); + + migrationBuilder.DropColumn( + name: "PhoneVerifiedAt", + schema: "usr", + table: "Users"); + + migrationBuilder.DropColumn( + name: "ShahkarVerifiedAt", + schema: "usr", + table: "Users"); + + migrationBuilder.DropColumn( + name: "GrantedAt", + schema: "usr", + table: "UserRoles"); + + migrationBuilder.DropColumn( + name: "GrantedById", + schema: "usr", + table: "UserRoles"); + + migrationBuilder.DropColumn( + name: "RevokedAt", + schema: "usr", + table: "UserRoles"); + } + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/ApplicationDbContextModelSnapshot.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/ApplicationDbContextModelSnapshot.cs index ecbe96a..3da177a 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/ApplicationDbContextModelSnapshot.cs @@ -251,6 +251,33 @@ namespace Baya.Infrastructure.Persistence.Migrations Description = "Tiered cancellation refund policy: refund_percent by hours before the visit.", Key = "cancellation_tiers", Value = "[{\"min_hours_before\":48,\"refund_percent\":100},{\"min_hours_before\":24,\"refund_percent\":50},{\"min_hours_before\":0,\"refund_percent\":0}]" + }, + new + { + Id = 13L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Seconds a phone must wait before another OTP can be requested.", + Key = "auth_otp_resend_seconds", + Value = "120" + }, + new + { + Id = 14L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Wrong-code attempts allowed before OTP verification is refused until a fresh code.", + Key = "auth_otp_max_attempts", + Value = "5" + }, + new + { + Id = 15L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Refresh-token session lifetime in days.", + Key = "auth_session_ttl_days", + Value = "30" }); }); @@ -558,6 +585,9 @@ namespace Baya.Infrastructure.Persistence.Migrations .IsConcurrencyToken() .HasColumnType("nvarchar(max)"); + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + b.Property("Email") .HasMaxLength(256) .HasColumnType("nvarchar(256)"); @@ -568,9 +598,18 @@ namespace Baya.Infrastructure.Persistence.Migrations b.Property("FamilyName") .HasColumnType("nvarchar(max)"); + b.Property("Gender") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + b.Property("GeneratedCode") .HasColumnType("nvarchar(max)"); + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + b.Property("LockoutEnabled") .HasColumnType("bit"); @@ -580,6 +619,12 @@ namespace Baya.Infrastructure.Persistence.Migrations b.Property("Name") .HasColumnType("nvarchar(max)"); + b.Property("NationalId") + .HasColumnType("nvarchar(max)"); + + b.Property("NationalIdVerifiedAt") + .HasColumnType("datetimeoffset"); + b.Property("NormalizedEmail") .HasMaxLength(256) .HasColumnType("nvarchar(256)"); @@ -591,15 +636,25 @@ namespace Baya.Infrastructure.Persistence.Migrations b.Property("PasswordHash") .HasColumnType("nvarchar(max)"); + b.Property("PhoneHash") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + b.Property("PhoneNumber") .HasColumnType("nvarchar(max)"); b.Property("PhoneNumberConfirmed") .HasColumnType("bit"); + b.Property("PhoneVerifiedAt") + .HasColumnType("datetimeoffset"); + b.Property("SecurityStamp") .HasColumnType("nvarchar(max)"); + b.Property("ShahkarVerifiedAt") + .HasColumnType("datetimeoffset"); + b.Property("TwoFactorEnabled") .HasColumnType("bit"); @@ -617,6 +672,10 @@ namespace Baya.Infrastructure.Persistence.Migrations .HasDatabaseName("UserNameIndex") .HasFilter("[NormalizedUserName] IS NOT NULL"); + b.HasIndex("PhoneHash") + .IsUnique() + .HasFilter("[PhoneHash] IS NOT NULL"); + b.ToTable("Users", "usr"); }); @@ -710,13 +769,81 @@ namespace Baya.Infrastructure.Persistence.Migrations b.Property("CreatedUserRoleDate") .HasColumnType("datetime2"); + b.Property("GrantedAt") + .HasColumnType("datetimeoffset"); + + b.Property("GrantedById") + .HasColumnType("int"); + + b.Property("RevokedAt") + .HasColumnType("datetimeoffset"); + b.HasKey("UserId", "RoleId"); + b.HasIndex("GrantedById"); + b.HasIndex("RoleId"); b.ToTable("UserRoles", "usr"); }); + modelBuilder.Entity("Baya.Domain.Entities.User.UserSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeviceInfo") + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.Property("ExpiresAt") + .HasColumnType("datetimeoffset"); + + b.Property("IpAddress") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("IsRevoked") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("RefreshTokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("RevokedAt") + .HasColumnType("datetimeoffset"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("RefreshTokenHash") + .IsUnique(); + + b.HasIndex("UserId", "IsRevoked"); + + b.ToTable("UserSessions", "usr"); + }); + modelBuilder.Entity("Baya.Domain.Entities.User.UserToken", b => { b.Property("UserId") @@ -815,6 +942,10 @@ namespace Baya.Infrastructure.Persistence.Migrations modelBuilder.Entity("Baya.Domain.Entities.User.UserRole", b => { + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("GrantedById"); + b.HasOne("Baya.Domain.Entities.User.Role", "Role") .WithMany("Users") .HasForeignKey("RoleId") @@ -832,6 +963,17 @@ namespace Baya.Infrastructure.Persistence.Migrations b.Navigation("User"); }); + modelBuilder.Entity("Baya.Domain.Entities.User.UserSession", b => + { + b.HasOne("Baya.Domain.Entities.User.User", "User") + .WithMany("Sessions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + }); + modelBuilder.Entity("Baya.Domain.Entities.User.UserToken", b => { b.HasOne("Baya.Domain.Entities.User.User", "User") @@ -856,6 +998,8 @@ namespace Baya.Infrastructure.Persistence.Migrations b.Navigation("Logins"); + b.Navigation("Sessions"); + b.Navigation("Tokens"); b.Navigation("UserRefreshTokens"); diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/Common/UnitOfWork.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/Common/UnitOfWork.cs index c37067f..ed25107 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/Common/UnitOfWork.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/Common/UnitOfWork.cs @@ -1,17 +1,21 @@ -using Baya.Application.Contracts.Persistence; +using Baya.Application.Contracts.Persistence; namespace Baya.Infrastructure.Persistence.Repositories.Common; public class UnitOfWork : IUnitOfWork { private readonly ApplicationDbContext _db; - + public IUserRefreshTokenRepository UserRefreshTokenRepository { get; } + public IUserSessionRepository UserSessionRepository { get; } + public IUserAccountRepository UserAccountRepository { get; } public UnitOfWork(ApplicationDbContext db) { _db = db; UserRefreshTokenRepository = new UserRefreshTokenRepository(_db); + UserSessionRepository = new UserSessionRepository(_db); + UserAccountRepository = new UserAccountRepository(_db); } public Task CommitAsync() diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/UserAccountRepository.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/UserAccountRepository.cs new file mode 100644 index 0000000..4a3e51e --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/UserAccountRepository.cs @@ -0,0 +1,50 @@ +using Baya.Application.Contracts.Persistence; +using Baya.Application.Models.Identity; +using Baya.Domain.Entities.User; +using Baya.Infrastructure.Persistence.Repositories.Common; +using Microsoft.EntityFrameworkCore; + +namespace Baya.Infrastructure.Persistence.Repositories; + +internal class UserAccountRepository : BaseAsyncRepository, IUserAccountRepository +{ + public UserAccountRepository(ApplicationDbContext dbContext) : base(dbContext) + { + } + + public Task GetAccountSnapshotAsync(int userId, CancellationToken cancellationToken) + { + // UserRoles carries a RevokedAt-is-null global filter, so revoked grants never surface here. + return TableNoTracking + .Where(u => u.Id == userId) + .Select(u => new UserAccountSnapshot( + u.Id, + u.PhoneNumber, + u.Name, + u.FamilyName, + u.Gender, + u.IsActive, + u.UserRoles.Select(ur => ur.Role.Name).ToList())) + .FirstOrDefaultAsync(cancellationToken); + } + + public Task GetRoleByNameAsync(string roleName, CancellationToken cancellationToken) + { + return DbContext.Set() + .FirstOrDefaultAsync(r => r.Name == roleName, cancellationToken); + } + + public Task GetUserRoleIncludingRevokedAsync(int userId, int roleId, CancellationToken cancellationToken) + { + // Bypasses the revoked-filter on purpose: a revoked grant must be re-activated, not + // re-inserted (composite PK). + return DbContext.Set() + .IgnoreQueryFilters() + .FirstOrDefaultAsync(ur => ur.UserId == userId && ur.RoleId == roleId, cancellationToken); + } + + public async Task AddUserRoleAsync(UserRole userRole, CancellationToken cancellationToken) + { + await DbContext.Set().AddAsync(userRole, cancellationToken); + } +} \ No newline at end of file diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/UserSessionRepository.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/UserSessionRepository.cs new file mode 100644 index 0000000..8bc2ebc --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/UserSessionRepository.cs @@ -0,0 +1,46 @@ +using Baya.Application.Contracts.Persistence; +using Baya.Domain.Entities.User; +using Baya.Infrastructure.Persistence.Repositories.Common; +using Microsoft.EntityFrameworkCore; + +namespace Baya.Infrastructure.Persistence.Repositories; + +internal class UserSessionRepository : BaseAsyncRepository, IUserSessionRepository +{ + public UserSessionRepository(ApplicationDbContext dbContext) : base(dbContext) + { + } + + public Task AddAsync(UserSession session, CancellationToken cancellationToken) + { + return base.AddAsync(session); + } + + public Task GetByTokenHashAsync(string refreshTokenHash, CancellationToken cancellationToken) + { + return Table + .Include(s => s.User) + .FirstOrDefaultAsync(s => s.RefreshTokenHash == refreshTokenHash, cancellationToken); + } + + public Task GetActiveForUserByTokenHashAsync(int userId, string refreshTokenHash, CancellationToken cancellationToken) + { + return Table.FirstOrDefaultAsync( + s => s.UserId == userId && s.RefreshTokenHash == refreshTokenHash && !s.IsRevoked, + cancellationToken); + } + + public async Task RevokeAllActiveForUserAsync(int userId, DateTimeOffset revokedAt, CancellationToken cancellationToken) + { + // Sessions-per-user is small; tracked mutation keeps the revocation inside the caller's + // single commit instead of an out-of-band ExecuteUpdate. + var activeSessions = await Table + .Where(s => s.UserId == userId && !s.IsRevoked) + .ToListAsync(cancellationToken); + + foreach (var session in activeSessions) + session.Revoke(revokedAt); + + return activeSessions.Count; + } +} \ No newline at end of file diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/ValueConversion/EncryptedStringConverter.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/ValueConversion/EncryptedStringConverter.cs new file mode 100644 index 0000000..a405660 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/ValueConversion/EncryptedStringConverter.cs @@ -0,0 +1,14 @@ +using Baya.Application.Contracts.Common; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace Baya.Infrastructure.Persistence.ValueConversion; + +/// +/// Encrypts a string column at rest through the seam. The ciphertext is +/// non-deterministic (random IV), so encrypted columns can never be equality-queried — lookups go +/// through a deterministic companion hash column (e.g. PhoneHash) instead. +/// +internal sealed class EncryptedStringConverter(IFieldEncryptor fieldEncryptor) + : ValueConverter( + plaintext => fieldEncryptor.Encrypt(plaintext), + ciphertext => fieldEncryptor.Decrypt(ciphertext)); \ No newline at end of file diff --git a/server/src/Tests/Baya.Test.Api/AuthTestClient.cs b/server/src/Tests/Baya.Test.Api/AuthTestClient.cs new file mode 100644 index 0000000..fad5cae --- /dev/null +++ b/server/src/Tests/Baya.Test.Api/AuthTestClient.cs @@ -0,0 +1,58 @@ +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using Baya.Application.Contracts.Identity; +using Baya.Domain.Entities.User; +using Microsoft.Extensions.DependencyInjection; + +namespace Baya.Test.Api; + +/// +/// Shared plumbing for the auth flows: creates users + valid OTP codes through the real Identity +/// services (so tests don't scrape logs or burn the OTP endpoint's rate-limit budget on setup), and +/// unwraps the ApiResult envelope. +/// +internal static class AuthTestClient +{ + public static async Task CreateUserWithOtpCodeAsync(BayaApiFactory factory, string phone) + { + using var scope = factory.Services.CreateScope(); + var userManager = scope.ServiceProvider.GetRequiredService(); + + var user = await userManager.GetUserByPhoneNumber(phone); + if (user is null) + { + user = new User { UserName = $"u_{Guid.NewGuid():N}", PhoneNumber = phone }; + var created = await userManager.CreateUser(user); + Assert.True(created.Succeeded, $"test user creation failed: {string.Join(",", created.Errors.Select(e => e.Description))}"); + } + + // Same token family the verify endpoint checks: phone-confirmation token before the phone is + // confirmed, passwordless TOTP afterwards. + return user.PhoneNumberConfirmed + ? await userManager.GenerateOtpCode(user) + : await userManager.GeneratePhoneNumberConfirmationToken(user, phone); + } + + /// Full endpoint login: mints a code, POSTs verify_otp, returns the token payload. + public static async Task LoginAsync(BayaApiFactory factory, HttpClient client, string phone) + { + var code = await CreateUserWithOtpCodeAsync(factory, phone); + + var response = await client.PostAsJsonAsync("/api/v1/auth/verify_otp", new { phone, code }); + Assert.Equal(System.Net.HttpStatusCode.OK, response.StatusCode); + + return await ReadDataAsync(response); + } + + public static void UseBearer(HttpClient client, string accessToken) => + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + + /// Unwraps the ApiResult envelope and returns its data element. + public static async Task ReadDataAsync(HttpResponseMessage response) + { + var json = await response.Content.ReadAsStringAsync(); + using var document = JsonDocument.Parse(json); + return document.RootElement.GetProperty("data").Clone(); + } +} \ No newline at end of file diff --git a/server/src/Tests/Baya.Test.Api/Baya.Test.Api.csproj b/server/src/Tests/Baya.Test.Api/Baya.Test.Api.csproj new file mode 100644 index 0000000..db76989 --- /dev/null +++ b/server/src/Tests/Baya.Test.Api/Baya.Test.Api.csproj @@ -0,0 +1,30 @@ + + + + net10.0 + enable + enable + + false + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/server/src/Tests/Baya.Test.Api/BayaApiFactory.cs b/server/src/Tests/Baya.Test.Api/BayaApiFactory.cs new file mode 100644 index 0000000..98564c9 --- /dev/null +++ b/server/src/Tests/Baya.Test.Api/BayaApiFactory.cs @@ -0,0 +1,75 @@ +using Baya.Infrastructure.Identity.Identity.SeedDatabaseService; +using Baya.Infrastructure.Persistence; +using Baya.Infrastructure.Persistence.Interceptors; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Hosting; + +namespace Baya.Test.Api; + +/// +/// Boots the whole API (Program.cs wiring: envelope filters, JWE auth, rate limiter, Mediator) in the +/// "Testing" environment over an isolated in-memory SQLite database. Program skips SQL Server +/// migrations/seeding for this environment; the factory does EnsureCreated + the role/admin seed. +/// Use one factory per test class — the rate limiter is per-host, so a fresh host keeps each class +/// inside the OTP/auth per-IP budgets. +/// +public sealed class BayaApiFactory : WebApplicationFactory +{ + // A named shared-cache in-memory database (kept alive by this connection) instead of a single + // shared SqliteConnection instance: request scopes and hosted services open their own + // connections, so nothing initializes one connection concurrently. + private readonly string _connectionString = + $"Data Source={Guid.NewGuid():N};Mode=Memory;Cache=Shared"; + + private readonly SqliteConnection _keepAlive; + + public BayaApiFactory() + { + _keepAlive = new SqliteConnection(_connectionString); + _keepAlive.Open(); + } + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseEnvironment("Testing"); + + builder.ConfigureServices(services => + { + // Swap the SQL Server DbContext for in-memory SQLite. EF 8+ keeps AddDbContext's + // option lambda in IDbContextOptionsConfiguration — it must go too, or both providers + // end up configured on the same options. + services.RemoveAll(typeof(IDbContextOptionsConfiguration)); + services.RemoveAll(typeof(DbContextOptions)); + + services.AddDbContext((serviceProvider, options) => + { + options + .UseSqlite(_connectionString) + .AddInterceptors(serviceProvider.GetRequiredService()); + }); + }); + } + + protected override IHost CreateHost(IHostBuilder builder) + { + var host = base.CreateHost(builder); + + using var scope = host.Services.CreateScope(); + scope.ServiceProvider.GetRequiredService().Database.EnsureCreated(); + scope.ServiceProvider.GetRequiredService().Seed().GetAwaiter().GetResult(); + + return host; + } + + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + _keepAlive.Dispose(); + } +} \ No newline at end of file diff --git a/server/src/Tests/Baya.Test.Api/OtpRequestTests.cs b/server/src/Tests/Baya.Test.Api/OtpRequestTests.cs new file mode 100644 index 0000000..b27f1b6 --- /dev/null +++ b/server/src/Tests/Baya.Test.Api/OtpRequestTests.cs @@ -0,0 +1,37 @@ +using System.Net; +using System.Net.Http.Json; + +namespace Baya.Test.Api; + +public class OtpRequestTests(BayaApiFactory factory) : IClassFixture +{ + [Fact] + public async Task RequestOtp_ValidPhone_SendsOtpAndOpensResendWindow() + { + var client = factory.CreateClient(); + + var first = await client.PostAsJsonAsync("/api/v1/auth/request_otp", new { phone = "09120000001" }); + + Assert.Equal(HttpStatusCode.OK, first.StatusCode); + var firstData = await AuthTestClient.ReadDataAsync(first); + Assert.True(firstData.GetProperty("otpSent").GetBoolean()); + Assert.True(firstData.GetProperty("resendAvailableInSeconds").GetInt32() > 0); + + // Same phone inside the resend window: same non-enumerating shape, otpSent=false. + var second = await client.PostAsJsonAsync("/api/v1/auth/request_otp", new { phone = "09120000001" }); + + Assert.Equal(HttpStatusCode.OK, second.StatusCode); + var secondData = await AuthTestClient.ReadDataAsync(second); + Assert.False(secondData.GetProperty("otpSent").GetBoolean()); + } + + [Fact] + public async Task RequestOtp_InvalidPhone_Returns400() + { + var client = factory.CreateClient(); + + var response = await client.PostAsJsonAsync("/api/v1/auth/request_otp", new { phone = "12345" }); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } +} \ No newline at end of file diff --git a/server/src/Tests/Baya.Test.Api/RefreshAndLogoutTests.cs b/server/src/Tests/Baya.Test.Api/RefreshAndLogoutTests.cs new file mode 100644 index 0000000..d7e64bc --- /dev/null +++ b/server/src/Tests/Baya.Test.Api/RefreshAndLogoutTests.cs @@ -0,0 +1,64 @@ +using System.Net; +using System.Net.Http.Json; +using Baya.Application.Contracts.Common; +using Baya.Domain.Entities.User; +using Baya.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace Baya.Test.Api; + +public class RefreshAndLogoutTests(BayaApiFactory factory) : IClassFixture +{ + [Fact] + public async Task Refresh_RotatesSession_AndReplayRevokesEverything() + { + var client = factory.CreateClient(); + const string phone = "09120000030"; + var tokens = await AuthTestClient.LoginAsync(factory, client, phone); + var originalRefreshToken = tokens.GetProperty("refreshToken").GetString()!; + + var rotated = await client.PostAsJsonAsync("/api/v1/auth/refresh", new { refreshToken = originalRefreshToken }); + Assert.Equal(HttpStatusCode.OK, rotated.StatusCode); + var rotatedData = await AuthTestClient.ReadDataAsync(rotated); + Assert.NotEqual(originalRefreshToken, rotatedData.GetProperty("refreshToken").GetString()); + + // Replaying the rotated-out token is stolen-token reuse: 401 and logout-everywhere. + var replay = await client.PostAsJsonAsync("/api/v1/auth/refresh", new { refreshToken = originalRefreshToken }); + Assert.Equal(HttpStatusCode.Unauthorized, replay.StatusCode); + + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var phoneHash = scope.ServiceProvider.GetRequiredService().Hash(phone); + var user = await db.Set().SingleAsync(u => u.PhoneHash == phoneHash); + var sessions = await db.Set().Where(s => s.UserId == user.Id).ToListAsync(); + + Assert.NotEmpty(sessions); + Assert.All(sessions, s => Assert.True(s.IsRevoked)); + } + + [Fact] + public async Task Logout_RevokesSession_AndKillsAccessToken() + { + var client = factory.CreateClient(); + const string phone = "09120000031"; + var tokens = await AuthTestClient.LoginAsync(factory, client, phone); + AuthTestClient.UseBearer(client, tokens.GetProperty("accessToken").GetString()!); + + var logout = await client.PostAsJsonAsync("/api/v1/auth/logout", new { }); + Assert.Equal(HttpStatusCode.OK, logout.StatusCode); + + // Security-stamp rotation makes the still-unexpired access token fail server-side. + var me = await client.GetAsync("/api/v1/me"); + Assert.Equal(HttpStatusCode.Unauthorized, me.StatusCode); + + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var phoneHash = scope.ServiceProvider.GetRequiredService().Hash(phone); + var user = await db.Set().SingleAsync(u => u.PhoneHash == phoneHash); + var sessions = await db.Set().Where(s => s.UserId == user.Id).ToListAsync(); + + Assert.NotEmpty(sessions); + Assert.All(sessions, s => Assert.True(s.IsRevoked)); + } +} \ No newline at end of file diff --git a/server/src/Tests/Baya.Test.Api/RoleSelectionTests.cs b/server/src/Tests/Baya.Test.Api/RoleSelectionTests.cs new file mode 100644 index 0000000..dba713b --- /dev/null +++ b/server/src/Tests/Baya.Test.Api/RoleSelectionTests.cs @@ -0,0 +1,44 @@ +using System.Net; +using System.Net.Http.Json; + +namespace Baya.Test.Api; + +public class RoleSelectionTests(BayaApiFactory factory) : IClassFixture +{ + [Fact] + public async Task SelectRole_PublicRoles_AreGrantedIdempotentlyAndCanCoexist() + { + var client = factory.CreateClient(); + var tokens = await AuthTestClient.LoginAsync(factory, client, "09120000020"); + AuthTestClient.UseBearer(client, tokens.GetProperty("accessToken").GetString()!); + + var customer = await client.PostAsJsonAsync("/api/v1/me/select_role", new { role = "customer" }); + Assert.Equal(HttpStatusCode.OK, customer.StatusCode); + var afterCustomer = await AuthTestClient.ReadDataAsync(customer); + Assert.Contains("customer", afterCustomer.GetProperty("roles").EnumerateArray().Select(r => r.GetString())); + + // Selecting the same role again is an idempotent success. + var repeat = await client.PostAsJsonAsync("/api/v1/me/select_role", new { role = "customer" }); + Assert.Equal(HttpStatusCode.OK, repeat.StatusCode); + + // A user may hold customer and nurse at once. + var nurse = await client.PostAsJsonAsync("/api/v1/me/select_role", new { role = "nurse" }); + Assert.Equal(HttpStatusCode.OK, nurse.StatusCode); + var afterNurse = await AuthTestClient.ReadDataAsync(nurse); + var roles = afterNurse.GetProperty("roles").EnumerateArray().Select(r => r.GetString()).ToList(); + Assert.Contains("customer", roles); + Assert.Contains("nurse", roles); + } + + [Fact] + public async Task SelectRole_AdminSubRole_Returns403() + { + var client = factory.CreateClient(); + var tokens = await AuthTestClient.LoginAsync(factory, client, "09120000021"); + AuthTestClient.UseBearer(client, tokens.GetProperty("accessToken").GetString()!); + + var response = await client.PostAsJsonAsync("/api/v1/me/select_role", new { role = "super_admin" }); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } +} \ No newline at end of file diff --git a/server/src/Tests/Baya.Test.Api/Usings.cs b/server/src/Tests/Baya.Test.Api/Usings.cs new file mode 100644 index 0000000..8c927eb --- /dev/null +++ b/server/src/Tests/Baya.Test.Api/Usings.cs @@ -0,0 +1 @@ +global using Xunit; \ No newline at end of file diff --git a/server/src/Tests/Baya.Test.Api/VerifyOtpAndMeTests.cs b/server/src/Tests/Baya.Test.Api/VerifyOtpAndMeTests.cs new file mode 100644 index 0000000..8bb2bd7 --- /dev/null +++ b/server/src/Tests/Baya.Test.Api/VerifyOtpAndMeTests.cs @@ -0,0 +1,80 @@ +using System.Net; +using System.Net.Http.Json; +using Baya.Application.Contracts.Common; +using Baya.Domain.Entities.User; +using Baya.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace Baya.Test.Api; + +public class VerifyOtpAndMeTests(BayaApiFactory factory) : IClassFixture +{ + [Fact] + public async Task VerifyOtp_ValidCode_MintsTokensAndCreatesSession() + { + var client = factory.CreateClient(); + const string phone = "09120000010"; + + var tokens = await AuthTestClient.LoginAsync(factory, client, phone); + + Assert.False(string.IsNullOrEmpty(tokens.GetProperty("accessToken").GetString())); + Assert.False(string.IsNullOrEmpty(tokens.GetProperty("refreshToken").GetString())); + Assert.True(tokens.GetProperty("isNewUser").GetBoolean()); + Assert.Empty(tokens.GetProperty("roles").EnumerateArray()); + + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var phoneHash = scope.ServiceProvider.GetRequiredService().Hash(phone); + var user = await db.Set().SingleAsync(u => u.PhoneHash == phoneHash); + + Assert.True(user.IsActive); + Assert.NotNull(user.PhoneVerifiedAt); + var session = await db.Set().SingleAsync(s => s.UserId == user.Id); + Assert.False(session.IsRevoked); + } + + [Fact] + public async Task VerifyOtp_WrongCode_Returns400() + { + var client = factory.CreateClient(); + const string phone = "09120000011"; + await AuthTestClient.CreateUserWithOtpCodeAsync(factory, phone); + + var response = await client.PostAsJsonAsync("/api/v1/auth/verify_otp", new { phone, code = "000000" }); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task Me_WithoutToken_Returns401() + { + var client = factory.CreateClient(); + + var response = await client.GetAsync("/api/v1/me"); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task Me_WithToken_ReturnsMaskedPhoneAndDefaults() + { + var client = factory.CreateClient(); + const string phone = "09120000012"; + var tokens = await AuthTestClient.LoginAsync(factory, client, phone); + AuthTestClient.UseBearer(client, tokens.GetProperty("accessToken").GetString()!); + + var response = await client.GetAsync("/api/v1/me"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var me = await AuthTestClient.ReadDataAsync(response); + var maskedPhone = me.GetProperty("phone").GetString()!; + Assert.StartsWith("0912", maskedPhone); + Assert.Contains('*', maskedPhone); + Assert.DoesNotContain(phone, maskedPhone); + Assert.Empty(me.GetProperty("roles").EnumerateArray()); + Assert.False(me.GetProperty("hasCustomerProfile").GetBoolean()); + Assert.False(me.GetProperty("hasNurseProfile").GetBoolean()); + Assert.Equal("not_started", me.GetProperty("nurseVerificationStatus").GetString()); + } +} \ No newline at end of file diff --git a/server/src/Tests/Baya.Test.Foundation/Identity/RefreshTokenCommandHandlerTests.cs b/server/src/Tests/Baya.Test.Foundation/Identity/RefreshTokenCommandHandlerTests.cs new file mode 100644 index 0000000..b3d2ec1 --- /dev/null +++ b/server/src/Tests/Baya.Test.Foundation/Identity/RefreshTokenCommandHandlerTests.cs @@ -0,0 +1,119 @@ +using Baya.Application.Contracts; +using Baya.Application.Contracts.Common; +using Baya.Application.Contracts.Configuration; +using Baya.Application.Contracts.Identity; +using Baya.Application.Contracts.Persistence; +using Baya.Application.Features.Identity; +using Baya.Application.Features.Identity.Commands.RefreshToken; +using Baya.Application.Models.Jwt; +using Baya.Domain.Entities.User; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using NSubstitute.ReturnsExtensions; + +namespace Baya.Test.Foundation.Identity; + +public class RefreshTokenCommandHandlerTests +{ + private static readonly DateTimeOffset Now = new(2026, 7, 1, 12, 0, 0, TimeSpan.Zero); + + private readonly IUnitOfWork _unitOfWork = Substitute.For(); + private readonly IUserSessionRepository _sessions = Substitute.For(); + private readonly IJwtService _jwtService = Substitute.For(); + private readonly IAppUserManager _userManager = Substitute.For(); + private readonly IPlatformConfig _platformConfig = Substitute.For(); + private readonly IFieldEncryptor _fieldEncryptor = Substitute.For(); + private readonly IDateTimeProvider _clock = Substitute.For(); + private readonly ICurrentUser _currentUser = Substitute.For(); + + private RefreshTokenCommandHandler CreateHandler() + { + _clock.UtcNow.Returns(Now); + _unitOfWork.UserSessionRepository.Returns(_sessions); + _fieldEncryptor.Hash(Arg.Any()).Returns("TOKEN_HASH"); + _platformConfig.GetConfig(IdentityDefaults.SessionTtlDaysKey, Arg.Any()).Returns(30); + _jwtService.GenerateAccessTokenAsync(Arg.Any()) + .Returns(new JweAccessToken("new-access-token", Now.AddMinutes(15))); + + return new RefreshTokenCommandHandler( + _unitOfWork, _jwtService, _userManager, _platformConfig, _fieldEncryptor, _clock, _currentUser, + NullLogger.Instance); + } + + [Fact] + public async Task Handle_ActiveSession_RotatesAndReturnsNewPair() + { + // Arrange + var user = new User { Id = 7, IsActive = true }; + var session = new UserSession + { + UserId = 7, + User = user, + RefreshTokenHash = "TOKEN_HASH", + ExpiresAt = Now.AddDays(10) + }; + _sessions.GetByTokenHashAsync("TOKEN_HASH", Arg.Any()).Returns(session); + _userManager.GetRoleAsync(user).Returns(["customer"]); + var handler = CreateHandler(); + + // Act + var result = await handler.Handle(new RefreshTokenCommand("raw-refresh-token"), CancellationToken.None); + + // Assert + Assert.True(result.IsSuccess); + Assert.True(session.IsRevoked); + Assert.Equal(Now, session.RevokedAt); + Assert.Equal("new-access-token", result.Result.AccessToken); + Assert.Contains("customer", result.Result.Roles); + await _sessions.Received(1).AddAsync(Arg.Is(s => !s.IsRevoked && s.UserId == 7), Arg.Any()); + await _unitOfWork.Received(1).CommitAsync(); + } + + [Fact] + public async Task Handle_RevokedSessionReplay_RevokesEverythingAndReturns401() + { + // Arrange — a token presented against an already-rotated session is a stolen-token signal. + var session = new UserSession { UserId = 7, RefreshTokenHash = "TOKEN_HASH", IsRevoked = true, ExpiresAt = Now.AddDays(10) }; + _sessions.GetByTokenHashAsync("TOKEN_HASH", Arg.Any()).Returns(session); + var handler = CreateHandler(); + + // Act + var result = await handler.Handle(new RefreshTokenCommand("raw-refresh-token"), CancellationToken.None); + + // Assert + Assert.False(result.IsSuccess); + Assert.True(result.IsUnauthorized); + await _sessions.Received(1).RevokeAllActiveForUserAsync(7, Now, Arg.Any()); + await _sessions.DidNotReceive().AddAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Handle_ExpiredSession_Returns401AndRevokesIt() + { + // Arrange + var session = new UserSession { UserId = 7, RefreshTokenHash = "TOKEN_HASH", ExpiresAt = Now.AddMinutes(-1) }; + _sessions.GetByTokenHashAsync("TOKEN_HASH", Arg.Any()).Returns(session); + var handler = CreateHandler(); + + // Act + var result = await handler.Handle(new RefreshTokenCommand("raw-refresh-token"), CancellationToken.None); + + // Assert + Assert.True(result.IsUnauthorized); + Assert.True(session.IsRevoked); + } + + [Fact] + public async Task Handle_UnknownToken_Returns401() + { + // Arrange + _sessions.GetByTokenHashAsync(Arg.Any(), Arg.Any()).ReturnsNull(); + var handler = CreateHandler(); + + // Act + var result = await handler.Handle(new RefreshTokenCommand("bogus"), CancellationToken.None); + + // Assert + Assert.True(result.IsUnauthorized); + } +} \ No newline at end of file diff --git a/server/src/Tests/Baya.Test.Foundation/Identity/RequestOtpCommandHandlerTests.cs b/server/src/Tests/Baya.Test.Foundation/Identity/RequestOtpCommandHandlerTests.cs new file mode 100644 index 0000000..291a213 --- /dev/null +++ b/server/src/Tests/Baya.Test.Foundation/Identity/RequestOtpCommandHandlerTests.cs @@ -0,0 +1,90 @@ +using Baya.Application.Contracts.Common; +using Baya.Application.Contracts.Configuration; +using Baya.Application.Contracts.Identity; +using Baya.Application.Features.Identity; +using Baya.Application.Features.Identity.Commands.RequestOtp; +using Baya.Domain.Entities.User; +using Microsoft.AspNetCore.Identity; +using NSubstitute; +using NSubstitute.ReturnsExtensions; + +namespace Baya.Test.Foundation.Identity; + +public class RequestOtpCommandHandlerTests +{ + private static readonly DateTimeOffset Now = new(2026, 7, 1, 12, 0, 0, TimeSpan.Zero); + + private readonly IAppUserManager _userManager = Substitute.For(); + private readonly ISmsSender _smsSender = Substitute.For(); + private readonly IPlatformConfig _platformConfig = Substitute.For(); + private readonly ICacheService _cache = Substitute.For(); + private readonly IFieldEncryptor _fieldEncryptor = Substitute.For(); + private readonly IDateTimeProvider _clock = Substitute.For(); + + private RequestOtpCommandHandler CreateHandler() + { + _clock.UtcNow.Returns(Now); + _fieldEncryptor.Hash(Arg.Any()).Returns("PHONE_HASH"); + _platformConfig.GetConfig(IdentityDefaults.OtpResendSecondsKey, Arg.Any()) + .Returns(120); + + return new RequestOtpCommandHandler(_userManager, _smsSender, _platformConfig, _cache, _fieldEncryptor, _clock); + } + + [Fact] + public async Task Handle_NewPhone_CreatesInactiveUserAndSendsOtp() + { + // Arrange + _userManager.GetUserByPhoneNumber("09123456789").ReturnsNull(); + _userManager.CreateUser(Arg.Any()).Returns(IdentityResult.Success); + _userManager.GeneratePhoneNumberConfirmationToken(Arg.Any(), "09123456789").Returns("123456"); + var handler = CreateHandler(); + + // Act + var result = await handler.Handle(new RequestOtpCommand("09123456789"), CancellationToken.None); + + // Assert + Assert.True(result.IsSuccess); + Assert.True(result.Result.OtpSent); + Assert.Equal(120, result.Result.ResendAvailableInSeconds); + await _userManager.Received(1).CreateUser(Arg.Is(u => !u.IsActive && u.PhoneNumber == "09123456789")); + await _smsSender.Received(1).SendOtpAsync("09123456789", "123456", Arg.Any()); + } + + [Fact] + public async Task Handle_ResendWindowOpen_DoesNotSendAndReportsRemainingSeconds() + { + // Arrange + _cache.GetAsync(Arg.Any(), Arg.Any()) + .Returns(Now.AddSeconds(60)); + var handler = CreateHandler(); + + // Act + var result = await handler.Handle(new RequestOtpCommand("09123456789"), CancellationToken.None); + + // Assert + Assert.True(result.IsSuccess); + Assert.False(result.Result.OtpSent); + Assert.Equal(60, result.Result.ResendAvailableInSeconds); + await _smsSender.DidNotReceive().SendOtpAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Handle_ExistingConfirmedUser_UsesPasswordlessOtpAndSameShape() + { + // Arrange + var user = new User { PhoneNumber = "09123456789", PhoneNumberConfirmed = true }; + _userManager.GetUserByPhoneNumber("09123456789").Returns(user); + _userManager.GenerateOtpCode(user).Returns("654321"); + var handler = CreateHandler(); + + // Act + var result = await handler.Handle(new RequestOtpCommand("+989123456789"), CancellationToken.None); + + // Assert — normalized phone, no enumeration (same shape as the new-user path). + Assert.True(result.IsSuccess); + Assert.True(result.Result.OtpSent); + await _smsSender.Received(1).SendOtpAsync("09123456789", "654321", Arg.Any()); + await _userManager.DidNotReceive().CreateUser(Arg.Any()); + } +} \ No newline at end of file diff --git a/server/src/Tests/Baya.Test.Foundation/Identity/SelectRoleCommandHandlerTests.cs b/server/src/Tests/Baya.Test.Foundation/Identity/SelectRoleCommandHandlerTests.cs new file mode 100644 index 0000000..006d1fc --- /dev/null +++ b/server/src/Tests/Baya.Test.Foundation/Identity/SelectRoleCommandHandlerTests.cs @@ -0,0 +1,104 @@ +using Baya.Application.Contracts.Common; +using Baya.Application.Contracts.Persistence; +using Baya.Application.Features.Identity.Commands.SelectRole; +using Baya.Application.Models.Identity; +using Baya.Domain.Entities.User; +using NSubstitute; +using NSubstitute.ReturnsExtensions; + +namespace Baya.Test.Foundation.Identity; + +public class SelectRoleCommandHandlerTests +{ + private static readonly DateTimeOffset Now = new(2026, 7, 1, 12, 0, 0, TimeSpan.Zero); + + private readonly ICurrentUser _currentUser = Substitute.For(); + private readonly IUnitOfWork _unitOfWork = Substitute.For(); + private readonly IUserAccountRepository _accounts = Substitute.For(); + private readonly IDateTimeProvider _clock = Substitute.For(); + + private SelectRoleCommandHandler CreateHandler() + { + _clock.UtcNow.Returns(Now); + _currentUser.UserId.Returns(7); + _unitOfWork.UserAccountRepository.Returns(_accounts); + _accounts.GetAccountSnapshotAsync(7, Arg.Any()) + .Returns(new UserAccountSnapshot(7, "09123456789", null, null, null, true, ["customer"])); + + return new SelectRoleCommandHandler(_currentUser, _unitOfWork, _clock); + } + + [Fact] + public async Task Handle_AdminSubRole_IsForbidden() + { + // Arrange + var handler = CreateHandler(); + + // Act + var result = await handler.Handle(new SelectRoleCommand("super_admin"), CancellationToken.None); + + // Assert + Assert.False(result.IsSuccess); + Assert.True(result.IsForbidden); + await _accounts.DidNotReceive().AddUserRoleAsync(Arg.Any(), Arg.Any()); + await _unitOfWork.DidNotReceive().CommitAsync(); + } + + [Fact] + public async Task Handle_NewCustomerRole_GrantsWithSelfAudit() + { + // Arrange + _accounts.GetRoleByNameAsync("customer", Arg.Any()).Returns(new Role { Id = 3, Name = "customer" }); + _accounts.GetUserRoleIncludingRevokedAsync(7, 3, Arg.Any()).ReturnsNull(); + var handler = CreateHandler(); + + // Act + var result = await handler.Handle(new SelectRoleCommand("Customer"), CancellationToken.None); + + // Assert — case-insensitive input, granted_by = self, masked phone in the payload. + Assert.True(result.IsSuccess); + Assert.Contains("customer", result.Result.Roles); + Assert.DoesNotContain("09123456789", result.Result.Phone); + await _accounts.Received(1).AddUserRoleAsync( + Arg.Is(ur => ur.UserId == 7 && ur.RoleId == 3 && ur.GrantedById == 7 && ur.GrantedAt == Now), + Arg.Any()); + await _unitOfWork.Received(1).CommitAsync(); + } + + [Fact] + public async Task Handle_RoleAlreadyHeld_IsIdempotent() + { + // Arrange + _accounts.GetRoleByNameAsync("customer", Arg.Any()).Returns(new Role { Id = 3, Name = "customer" }); + _accounts.GetUserRoleIncludingRevokedAsync(7, 3, Arg.Any()) + .Returns(new UserRole { UserId = 7, RoleId = 3, GrantedAt = Now.AddDays(-1) }); + var handler = CreateHandler(); + + // Act + var result = await handler.Handle(new SelectRoleCommand("customer"), CancellationToken.None); + + // Assert + Assert.True(result.IsSuccess); + await _accounts.DidNotReceive().AddUserRoleAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Handle_RevokedGrant_IsReactivatedNotDuplicated() + { + // Arrange + var revoked = new UserRole { UserId = 7, RoleId = 3, GrantedAt = Now.AddDays(-10), RevokedAt = Now.AddDays(-5) }; + _accounts.GetRoleByNameAsync("nurse", Arg.Any()).Returns(new Role { Id = 3, Name = "nurse" }); + _accounts.GetUserRoleIncludingRevokedAsync(7, 3, Arg.Any()).Returns(revoked); + var handler = CreateHandler(); + + // Act + var result = await handler.Handle(new SelectRoleCommand("nurse"), CancellationToken.None); + + // Assert + Assert.True(result.IsSuccess); + Assert.Null(revoked.RevokedAt); + Assert.Equal(Now, revoked.GrantedAt); + Assert.Equal(7, revoked.GrantedById); + await _accounts.DidNotReceive().AddUserRoleAsync(Arg.Any(), Arg.Any()); + } +} \ No newline at end of file diff --git a/server/src/Tests/Baya.Test.Foundation/Identity/VerifyOtpCommandHandlerTests.cs b/server/src/Tests/Baya.Test.Foundation/Identity/VerifyOtpCommandHandlerTests.cs new file mode 100644 index 0000000..86e1018 --- /dev/null +++ b/server/src/Tests/Baya.Test.Foundation/Identity/VerifyOtpCommandHandlerTests.cs @@ -0,0 +1,120 @@ +using Baya.Application.Contracts; +using Baya.Application.Contracts.Common; +using Baya.Application.Contracts.Configuration; +using Baya.Application.Contracts.Identity; +using Baya.Application.Contracts.Persistence; +using Baya.Application.Features.Identity; +using Baya.Application.Features.Identity.Commands.VerifyOtp; +using Baya.Application.Models.Jwt; +using Baya.Domain.Entities.User; +using Microsoft.AspNetCore.Identity; +using NSubstitute; +using NSubstitute.ReturnsExtensions; + +namespace Baya.Test.Foundation.Identity; + +public class VerifyOtpCommandHandlerTests +{ + private static readonly DateTimeOffset Now = new(2026, 7, 1, 12, 0, 0, TimeSpan.Zero); + + private readonly IAppUserManager _userManager = Substitute.For(); + private readonly IJwtService _jwtService = Substitute.For(); + private readonly IUnitOfWork _unitOfWork = Substitute.For(); + private readonly IUserSessionRepository _sessions = Substitute.For(); + private readonly IPlatformConfig _platformConfig = Substitute.For(); + private readonly IFieldEncryptor _fieldEncryptor = Substitute.For(); + private readonly IDateTimeProvider _clock = Substitute.For(); + private readonly ICurrentUser _currentUser = Substitute.For(); + + private VerifyOtpCommandHandler CreateHandler() + { + _clock.UtcNow.Returns(Now); + _unitOfWork.UserSessionRepository.Returns(_sessions); + _fieldEncryptor.Hash(Arg.Any()).Returns("TOKEN_HASH"); + _platformConfig.GetConfig(IdentityDefaults.OtpMaxAttemptsKey, Arg.Any()).Returns(5); + _platformConfig.GetConfig(IdentityDefaults.SessionTtlDaysKey, Arg.Any()).Returns(30); + _jwtService.GenerateAccessTokenAsync(Arg.Any()) + .Returns(new JweAccessToken("jwe-access-token", Now.AddMinutes(15))); + + return new VerifyOtpCommandHandler( + _userManager, _jwtService, _unitOfWork, _platformConfig, _fieldEncryptor, _clock, _currentUser); + } + + [Fact] + public async Task Handle_ValidCodeForNewUser_ActivatesUserMintsTokensAndSession() + { + // Arrange + var user = new User { PhoneNumber = "09123456789", PhoneNumberConfirmed = false }; + _userManager.GetUserByPhoneNumber("09123456789").Returns(user); + _userManager.ChangePhoneNumber(user, "09123456789", "123456").Returns(IdentityResult.Success); + _userManager.GetRoleAsync(user).Returns([]); + var handler = CreateHandler(); + + // Act + var result = await handler.Handle(new VerifyOtpCommand("09123456789", "123456", "test-device"), CancellationToken.None); + + // Assert + Assert.True(result.IsSuccess); + Assert.True(result.Result.IsNewUser); + Assert.Empty(result.Result.Roles); + Assert.Equal("jwe-access-token", result.Result.AccessToken); + Assert.False(string.IsNullOrEmpty(result.Result.RefreshToken)); + Assert.Equal(Now.AddDays(30), result.Result.RefreshExpiresAt); + Assert.True(user.IsActive); + Assert.Equal(Now, user.PhoneVerifiedAt); + await _sessions.Received(1).AddAsync( + Arg.Is(s => s.RefreshTokenHash == "TOKEN_HASH" && !s.IsRevoked && s.DeviceInfo == "test-device"), + Arg.Any()); + await _unitOfWork.Received(1).CommitAsync(); + } + + [Fact] + public async Task Handle_WrongCode_IncrementsAttemptsAndFailsSafely() + { + // Arrange + var user = new User { PhoneNumber = "09123456789", PhoneNumberConfirmed = true }; + _userManager.GetUserByPhoneNumber("09123456789").Returns(user); + _userManager.VerifyUserCode(user, "999999") + .Returns(IdentityResult.Failed(new IdentityError { Description = "Incorrect Code" })); + var handler = CreateHandler(); + + // Act + var result = await handler.Handle(new VerifyOtpCommand("09123456789", "999999"), CancellationToken.None); + + // Assert + Assert.False(result.IsSuccess); + await _userManager.Received(1).IncrementAccessFailedCountAsync(user); + await _sessions.DidNotReceive().AddAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Handle_TooManyFailedAttempts_RefusesWithoutCheckingTheCode() + { + // Arrange + var user = new User { PhoneNumber = "09123456789", PhoneNumberConfirmed = true, AccessFailedCount = 5 }; + _userManager.GetUserByPhoneNumber("09123456789").Returns(user); + var handler = CreateHandler(); + + // Act + var result = await handler.Handle(new VerifyOtpCommand("09123456789", "123456"), CancellationToken.None); + + // Assert + Assert.False(result.IsSuccess); + await _userManager.DidNotReceive().VerifyUserCode(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Handle_UnknownPhone_FailsWithTheSameSafeMessageAsAWrongCode() + { + // Arrange — no enumeration: unknown phone and wrong code are indistinguishable. + _userManager.GetUserByPhoneNumber(Arg.Any()).ReturnsNull(); + var handler = CreateHandler(); + + // Act + var result = await handler.Handle(new VerifyOtpCommand("09123456789", "123456"), CancellationToken.None); + + // Assert + Assert.False(result.IsSuccess); + Assert.Contains(result.ErrorMessages, e => e.Value.Contains("invalid or expired", StringComparison.OrdinalIgnoreCase)); + } +} \ No newline at end of file diff --git a/server/src/Tests/Baya.Test.Foundation/Marketplace/OpsTestHost.cs b/server/src/Tests/Baya.Test.Foundation/Marketplace/OpsTestHost.cs index 71b08b5..1ed9e1c 100644 --- a/server/src/Tests/Baya.Test.Foundation/Marketplace/OpsTestHost.cs +++ b/server/src/Tests/Baya.Test.Foundation/Marketplace/OpsTestHost.cs @@ -3,6 +3,7 @@ using Baya.Domain.Entities.User; using Baya.Infrastructure.CrossCutting.Seams; using Baya.Infrastructure.Persistence; using Baya.Infrastructure.Persistence.Interceptors; +using Baya.Tests.Setup.Setups; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Memory; @@ -36,7 +37,7 @@ internal sealed class OpsTestHost : IDisposable .AddInterceptors(interceptor) .Options; - Db = new ApplicationDbContext(options); + Db = new ApplicationDbContext(options, TestFieldEncryptor.Instance); Db.Database.EnsureCreated(); } @@ -66,4 +67,5 @@ internal sealed class TestCurrentUser : ICurrentUser public int? UserId { get; set; } public bool IsAuthenticated => UserId is not null; public IReadOnlyList Roles { get; set; } = []; + public string? IpAddress { get; set; } } diff --git a/server/src/Tests/Baya.Tests.Setup/Setups/TestApplicationDbContext.cs b/server/src/Tests/Baya.Tests.Setup/Setups/TestApplicationDbContext.cs index 71b63a3..4d51cb2 100644 --- a/server/src/Tests/Baya.Tests.Setup/Setups/TestApplicationDbContext.cs +++ b/server/src/Tests/Baya.Tests.Setup/Setups/TestApplicationDbContext.cs @@ -18,6 +18,6 @@ public abstract class TestApplicationDbContext .UseSqlite(connection) .Options; - UnitTestDbContext = new ApplicationDbContext(options); + UnitTestDbContext = new ApplicationDbContext(options, TestFieldEncryptor.Instance); } } \ No newline at end of file diff --git a/server/src/Tests/Baya.Tests.Setup/Setups/TestFieldEncryptor.cs b/server/src/Tests/Baya.Tests.Setup/Setups/TestFieldEncryptor.cs new file mode 100644 index 0000000..8f2c1ed --- /dev/null +++ b/server/src/Tests/Baya.Tests.Setup/Setups/TestFieldEncryptor.cs @@ -0,0 +1,37 @@ +using System.Security.Cryptography; +using System.Text; +using Baya.Application.Contracts.Common; + +namespace Baya.Tests.Setup.Setups; + +/// +/// Deterministic-key for tests: reversible base64 "encryption" plus an +/// HMAC lookup hash. Exposed as a single shared instance because EF caches the model (and therefore +/// the value converters built from the encryptor) — every test context must use the same instance, +/// mirroring the production singleton registration. +/// +public sealed class TestFieldEncryptor : IFieldEncryptor +{ + public static readonly TestFieldEncryptor Instance = new(); + + private static readonly byte[] HashKey = "test-field-hash-key"u8.ToArray(); + + private TestFieldEncryptor() + { + } + + public string Encrypt(string plaintext) => + string.IsNullOrEmpty(plaintext) ? plaintext : Convert.ToBase64String(Encoding.UTF8.GetBytes(plaintext)); + + public string Decrypt(string ciphertext) => + string.IsNullOrEmpty(ciphertext) ? ciphertext : Encoding.UTF8.GetString(Convert.FromBase64String(ciphertext)); + + public string Hash(string value) + { + if (string.IsNullOrEmpty(value)) + return value; + + using var hmac = new HMACSHA256(HashKey); + return Convert.ToHexString(hmac.ComputeHash(Encoding.UTF8.GetBytes(value))); + } +} \ No newline at end of file diff --git a/server/src/Tests/Baya.Tests.Setup/Setups/TestIdentitySetup.cs b/server/src/Tests/Baya.Tests.Setup/Setups/TestIdentitySetup.cs index 6806bec..85f8291 100644 --- a/server/src/Tests/Baya.Tests.Setup/Setups/TestIdentitySetup.cs +++ b/server/src/Tests/Baya.Tests.Setup/Setups/TestIdentitySetup.cs @@ -1,4 +1,5 @@ using Baya.Application.Contracts; +using Baya.Application.Contracts.Common; using Baya.Application.Contracts.Identity; using Baya.Application.Contracts.Persistence; using Baya.Domain.Entities.User; @@ -33,6 +34,7 @@ public abstract class TestIdentitySetup serviceCollection.AddLogging(); + serviceCollection.AddSingleton(TestFieldEncryptor.Instance); serviceCollection.AddDbContext(options => options.UseSqlite(connection)); var context = serviceCollection.BuildServiceProvider().GetService();