# Module: Azaion.Services.UserService ## Purpose Core business logic for user management: registration (web users + provisioned devices), authentication (with rate-limit + lockout enforcement), role management, and account lifecycle. > **Cycle 1 (2026-05-13) note** — hardware-binding methods removed by AZ-197; device auto-provisioning (`RegisterDevice`) added by AZ-196. Post-cycle-1: `RegisterUser` now relies on the `users_email_uidx` UNIQUE INDEX; `Npgsql.PostgresException(SqlState=23505)` is translated to `BusinessException(EmailExists)`. > > **Cycle 2 (2026-05-14) note A (AZ-536)** — password hashing switched to Argon2id. `RegisterUser` calls `Security.HashPassword`; `ValidateUser` calls `Security.VerifyPassword`. On a `NeedsRehash=true` outcome the user's row is updated transactionally with a fresh Argon2id hash (conditional on the original `password_hash` to avoid clobbering a parallel rehash from a concurrent login). > > **Cycle 2 (2026-05-14) note B (AZ-537)** — `ValidateUser` now enforces account lockout (423) and per-account sliding-window rate limit (429-equivalent via `BusinessException(LoginRateLimited)`). The lockout state lives on `users.failed_login_count` / `users.lockout_until`; the rate-limit feed is `audit_events` rows of type `login_failed`. `IAuditLog` and `IOptions` are new constructor dependencies. ## Public Interface ### IUserService | Method | Signature | Description | |--------|-----------|-------------| | `RegisterUser` | `Task RegisterUser(RegisterUserRequest request, CancellationToken ct)` | Creates a new user with Argon2id-hashed password. Translates `users_email_uidx` 23505 violations to `BusinessException(EmailExists)`. | | `RegisterDevice` | `Task RegisterDevice(CancellationToken ct)` | Creates a new `CompanionPC` user with auto-assigned `azj-NNNN` serial / email and a 32-char hex password (returned plaintext exactly once). | | `ValidateUser` | `Task ValidateUser(LoginRequest request, CancellationToken ct)` | Validates email + password; enforces account lockout and per-account rate limit. Returns the user on success (with `failed_login_count` zeroed and any legacy SHA-384 hash transparently upgraded). Throws `NoEmailFound`, `AccountLocked` (with retry-after seconds), `LoginRateLimited` (with retry-after window), `WrongPassword`, or `UserDisabled`. | | `GetByEmail` | `Task GetByEmail(string? email, CancellationToken ct)` | Cached user lookup by email. | | `GetById` | `Task GetById(Guid userId, CancellationToken ct)` | Direct DB lookup by id (used by token-bound flows: refresh, MFA, mission). Not cached. | | `UpdateQueueOffsets` | `Task UpdateQueueOffsets(string email, UserQueueOffsets offsets, CancellationToken ct)` | Updates user's annotation queue offsets. | | `GetUsers` | `Task> GetUsers(string? searchEmail, RoleEnum? searchRole, CancellationToken ct)` | Lists users with optional email/role filters. | | `ChangeRole` | `Task ChangeRole(string email, RoleEnum newRole, CancellationToken ct)` | Changes a user's role. | | `SetEnableStatus` | `Task SetEnableStatus(string email, bool isEnabled, CancellationToken ct)` | Enables or disables a user account. | | `RemoveUser` | `Task RemoveUser(string email, CancellationToken ct)` | Permanently deletes a user. | ## Internal Logic - **RegisterUser**: hashes password via `Security.HashPassword` (Argon2id), inserts via `RunAdmin`. Catches `PostgresException(23505)` on `users_email_uidx` and rethrows as `BusinessException(EmailExists)`. - **RegisterDevice**: queries the most recent `RoleEnum.CompanionPC` user via `dbFactory.Run`, parses the `azj-NNNN` suffix, increments by 1, generates a 32-char hex password from `RandomNumberGenerator.GetBytes(16)`, then delegates the row insert to `RegisterUser` (so future user-creation policy changes apply here too). - **ValidateUser** (sequence — order matters): 1. Lookup by email; missing → `NoEmailFound`. 2. **Lockout gate** — if `lockout_until > now()`, throw `AccountLocked` with the remaining seconds as `RetryAfterSeconds`. This precedes the password check (CMMC AC.L2-3.1.8 — even a correct password is rejected during lockout). 3. **Per-account rate limit** — `IAuditLog.CountRecentFailedLogins` over `AuthConfig.RateLimit.PerAccountWindowSeconds`; if ≥ `PerAccountPermitLimit`, throw `LoginRateLimited` with the window as `RetryAfterSeconds`. 4. **Password verify** via `Security.VerifyPassword`. Failure → `RegisterFailedLogin` (audit row + counter increment + maybe lockout) → throw `WrongPassword` (or `AccountLocked` if the failure crossed the threshold). 5. `IsEnabled` check (after verify so wrong-password and disabled-account look identical to attackers from the outside). 6. **Success path** — `RegisterSuccessfulLogin`: lazy Argon2id rehash if `NeedsRehash=true` (conditional on the original hash to avoid clobbering a parallel rehash), zero `failed_login_count`, clear `lockout_until`, invalidate cache, write `login_success` audit row. - **RegisterFailedLogin**: writes `login_failed` audit row, increments `failed_login_count`. If the new count reaches `Lockout.MaxAttempts`, sets `lockout_until = now() + DurationSeconds`, writes a `login_lockout` audit row, and throws `AccountLocked` immediately so the caller learns the threshold was crossed. - **GetByEmail**: cached via `ICache.GetFromCacheAsync` keyed `User.{email}`. - **GetById**: not cached (used by token-bound flows where the user id is already authenticated). Private constants (device provisioning): - `DeviceEmailPrefix = "azj-"`, `DeviceEmailDomain = "@azaion.com"`, `SerialNumberStart = 4`, `SerialNumberLength = 4`, `DevicePasswordBytes = 16`. ## Dependencies - `IDbFactory` (database access) - `ICache` (user caching) - `IAuditLog` (cycle 2 — audit row writes + per-account rate-limit feed) - `IOptions` (cycle 2 — `RateLimit.*`, `Lockout.*` thresholds) - `Security` (Argon2id hashing — `HashPassword` / `VerifyPassword`) - `System.Security.Cryptography.RandomNumberGenerator` (device password entropy) - `Npgsql` (`PostgresException`, `PostgresErrorCodes.UniqueViolation`) - `BusinessException` / `ExceptionEnum` (`NoEmailFound`, `WrongPassword`, `EmailExists`, `UserDisabled`, `AccountLocked`, `LoginRateLimited`) - `QueryableExtensions.WhereIf` - `User`, `UserConfig`, `UserQueueOffsets`, `RoleEnum` - `RegisterUserRequest`, `LoginRequest`, `RegisterDeviceResponse` ## Consumers - `Program.cs` `/users/*` endpoints — delegate to `IUserService` - `Program.cs` `POST /devices` — calls `RegisterDevice` - `Program.cs` `/login` — calls `ValidateUser` then either short-circuits to MFA step-1 or issues dual tokens - `Program.cs` `/login/mfa`, `/token/refresh`, `/sessions/mission` — call `GetById` after token-side identity is established - `AuthService.GetCurrentUser` — calls `GetByEmail` - `MfaService` — calls `GetById` for re-auth in `Enroll` / `Confirm` / `Disable` / `VerifyForLogin` ## Data Models Operates on `User` entity via `AzaionDb.Users`. Reads `failed_login_count` / `lockout_until` (AZ-537) and `mfa_enabled` (AZ-534). Writes `password_hash`, `failed_login_count`, `lockout_until` along the lockout/rehash paths. The `User.Hardware` column remains a tombstone (nullable, unused) per AZ-197. ## Configuration - `AuthConfig.RateLimit.PerAccountPermitLimit` / `PerAccountWindowSeconds` — sliding-window thresholds. - `AuthConfig.Lockout.MaxAttempts` / `DurationSeconds` — consecutive-failure lockout. ## External Integrations PostgreSQL via `IDbFactory`. ## Security - Passwords hashed with Argon2id (post-AZ-536). Legacy SHA-384 entries still validate and are transparently upgraded on next successful login. - Device passwords are returned plaintext to the caller exactly once at provisioning; the persisted form is the Argon2id hash. The plaintext is never re-derivable. - Lockout precedence (CMMC AC.L2-3.1.8): a locked account returns 423 even for a correct password until `lockout_until` passes. - The per-account rate limit is DB-backed (via `audit_events`) so it survives process restarts — distinct from the in-memory per-IP limiter that lives in `Program.cs`. - Read operations use the read-only DB connection; writes use the admin connection. ## Tests - `e2e/Azaion.E2E/Tests/RateLimitLockoutTests.cs` — AZ-537 ACs (per-IP 429, per-account 429, lockout 423, counter reset, lockout auto-expires, audit_events row on lockout). - `e2e/Azaion.E2E/Tests/PasswordHashingTests.cs` — AZ-536 ACs (Argon2id format, legacy verify, transparent re-hash, wrong-password fail, constant-time verify). - `e2e/Azaion.E2E/Tests/DeviceTests.cs` — AZ-196 device-provisioning ACs. - `e2e/Azaion.E2E/Tests/UserManagementTests.cs` and `LoginTests.cs` — broader user lifecycle coverage.