Refreshes _docs/02_document/ to reflect the cycle-2 auth-modernization
+ CMMC hardening landings (AZ-531..AZ-538). Authoritative source for
the ripple set is ripple_log_cycle2.md.
Covered:
- architecture.md (section 1 rewritten, ADRs 6-9 added)
- data_model.md (sessions, audit_events, user columns, migrations)
- system-flows.md (F1 rewritten; F11-F17 added; F2/F7/F9 minor)
- module-layout.md (cycle-2 sub-component table)
- diagrams/flows/flow_login.md (dual-token + MFA)
- components/{01_data_layer,03_auth_and_security,05_admin_api}
- modules/ (12 new, 8 modified — full Argon2id/ES256/MFA/refresh
/mission/session/audit/jwks rollup)
- tests/{blackbox,security,traceability-matrix}
Step 13 (Update Docs) output for cycle 2.
Co-authored-by: Cursor <cursoragent@cursor.com>
8.6 KiB
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:RegisterUsernow relies on theusers_email_uidxUNIQUE INDEX;Npgsql.PostgresException(SqlState=23505)is translated toBusinessException(EmailExists).Cycle 2 (2026-05-14) note A (AZ-536) — password hashing switched to Argon2id.
RegisterUsercallsSecurity.HashPassword;ValidateUsercallsSecurity.VerifyPassword. On aNeedsRehash=trueoutcome the user's row is updated transactionally with a fresh Argon2id hash (conditional on the originalpassword_hashto avoid clobbering a parallel rehash from a concurrent login).Cycle 2 (2026-05-14) note B (AZ-537) —
ValidateUsernow enforces account lockout (423) and per-account sliding-window rate limit (429-equivalent viaBusinessException(LoginRateLimited)). The lockout state lives onusers.failed_login_count/users.lockout_until; the rate-limit feed isaudit_eventsrows of typelogin_failed.IAuditLogandIOptions<AuthConfig>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<RegisterDeviceResponse> 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<User> 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<User?> GetByEmail(string? email, CancellationToken ct) |
Cached user lookup by email. |
GetById |
Task<User?> 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<IEnumerable<User>> 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 viaRunAdmin. CatchesPostgresException(23505)onusers_email_uidxand rethrows asBusinessException(EmailExists). - RegisterDevice: queries the most recent
RoleEnum.CompanionPCuser viadbFactory.Run, parses theazj-NNNNsuffix, increments by 1, generates a 32-char hex password fromRandomNumberGenerator.GetBytes(16), then delegates the row insert toRegisterUser(so future user-creation policy changes apply here too). - ValidateUser (sequence — order matters):
- Lookup by email; missing →
NoEmailFound. - Lockout gate — if
lockout_until > now(), throwAccountLockedwith the remaining seconds asRetryAfterSeconds. This precedes the password check (CMMC AC.L2-3.1.8 — even a correct password is rejected during lockout). - Per-account rate limit —
IAuditLog.CountRecentFailedLoginsoverAuthConfig.RateLimit.PerAccountWindowSeconds; if ≥PerAccountPermitLimit, throwLoginRateLimitedwith the window asRetryAfterSeconds. - Password verify via
Security.VerifyPassword. Failure →RegisterFailedLogin(audit row + counter increment + maybe lockout) → throwWrongPassword(orAccountLockedif the failure crossed the threshold). IsEnabledcheck (after verify so wrong-password and disabled-account look identical to attackers from the outside).- Success path —
RegisterSuccessfulLogin: lazy Argon2id rehash ifNeedsRehash=true(conditional on the original hash to avoid clobbering a parallel rehash), zerofailed_login_count, clearlockout_until, invalidate cache, writelogin_successaudit row.
- Lookup by email; missing →
- RegisterFailedLogin: writes
login_failedaudit row, incrementsfailed_login_count. If the new count reachesLockout.MaxAttempts, setslockout_until = now() + DurationSeconds, writes alogin_lockoutaudit row, and throwsAccountLockedimmediately so the caller learns the threshold was crossed. - GetByEmail: cached via
ICache.GetFromCacheAsynckeyedUser.{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<AuthConfig>(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.WhereIfUser,UserConfig,UserQueueOffsets,RoleEnumRegisterUserRequest,LoginRequest,RegisterDeviceResponse
Consumers
Program.cs/users/*endpoints — delegate toIUserServiceProgram.csPOST /devices— callsRegisterDeviceProgram.cs/login— callsValidateUserthen either short-circuits to MFA step-1 or issues dual tokensProgram.cs/login/mfa,/token/refresh,/sessions/mission— callGetByIdafter token-side identity is establishedAuthService.GetCurrentUser— callsGetByEmailMfaService— callsGetByIdfor re-auth inEnroll/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_untilpasses. - 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 inProgram.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.csandLoginTests.cs— broader user lifecycle coverage.