# Module: Azaion.Common.Entities.User ## Purpose Domain entity representing a system user, plus related value objects `UserConfig` and `UserQueueOffsets`. ## Public Interface > **Cycle 2 (2026-05-14) note** — six new properties: > - **AZ-537 (CMMC AC.L2-3.1.8)**: `FailedLoginCount` (consecutive failed-login counter) and `LockoutUntil` (active lockout deadline). Both reset on successful login. > - **AZ-534 (TOTP 2FA)**: `MfaEnabled`, `MfaSecret` (encrypted via `IDataProtector`), `MfaRecoveryCodes` (JSONB array of `{ hash, used_at }`), `MfaEnrolledAt`, `MfaLastUsedWindow` (RFC 6238 time-step counter — defends in-window replay). > > `MfaEnabled`, `MfaSecret`, `MfaRecoveryCodes`, and `MfaLastUsedWindow` are `[JsonIgnore]` — they never leave the server in API responses. `PasswordHash` is also `[JsonIgnore]` (this attribute was always there). > > The `PasswordHash` column now holds an Argon2id PHC string for new + rehashed users (AZ-536); legacy SHA-384 entries still validate and are transparently upgraded on next successful login. ### User | Property | Type | Description | |----------|------|-------------| | `Id` | `Guid` | Primary key | | `Email` | `string` | Unique user email | | `PasswordHash` | `string` | Argon2id PHC string (`$argon2id$…`) for new users; legacy 64-char Base64 SHA-384 still accepted by `Security.VerifyPassword` | | `Hardware` | `string?` | TOMBSTONED — kept nullable, not read or written by any code path (AZ-197 removed the hardware-binding feature) | | `Role` | `RoleEnum` | Authorization role | | `CreatedAt` | `DateTime` | Account creation timestamp | | `LastLogin` | `DateTime?` | Currently unused — left for forward compatibility | | `UserConfig` | `UserConfig?` | JSON-serialized user configuration | | `IsEnabled` | `bool` | Account active flag | | `FailedLoginCount` | `int` | AZ-537 — consecutive failed-login counter; resets to 0 on success | | `LockoutUntil` | `DateTime?` | AZ-537 — active lockout deadline (UTC). `>= now()` blocks login even with correct password | | `MfaEnabled` | `bool` | AZ-534 — true after `/users/me/mfa/confirm` succeeds | | `MfaSecret` | `string?` | AZ-534 — base32 TOTP secret encrypted at rest via `IDataProtector` (purpose `Azaion.Mfa.Secret.v1`) | | `MfaRecoveryCodes` | `string?` | AZ-534 — JSONB array of `{ Hash, UsedAt }` | | `MfaEnrolledAt` | `DateTime?` | AZ-534 — set by `Confirm` | | `MfaLastUsedWindow` | `long?` | AZ-534 — RFC 6238 time-step counter of the most recently accepted code; rejects in-window replay | | Method | Signature | Description | |--------|-----------|-------------| | `GetCacheKey` | `static string GetCacheKey(string email)` | Returns cache key `"User.{email}"` | ### UserConfig | Property | Type | Description | |----------|------|-------------| | `QueueOffsets` | `UserQueueOffsets?` | Annotation queue offset tracking | ### UserQueueOffsets | Property | Type | Description | |----------|------|-------------| | `AnnotationsOffset` | `ulong` | Offset for annotations queue | | `AnnotationsConfirmOffset` | `ulong` | Offset for annotation confirmations | | `AnnotationsCommandsOffset` | `ulong` | Offset for annotation commands | ## Internal Logic `GetCacheKey` returns empty string for null/empty email to avoid cache key collisions. ## Dependencies - `RoleEnum` ## Consumers - All services (`UserService`, `AuthService`, `ResourcesService`, `MfaService`, `MissionTokenService`) work with `User` - `AzaionDb` exposes `ITable` - `AzaionDbSchemaHolder` maps `User` to the `users` PostgreSQL table; `MfaRecoveryCodes` carries an explicit `DataType.BinaryJson` mapping so Npgsql sends the JSON oid (otherwise inserts fail with "column is of type jsonb but expression is of type text") - `SetUserQueueOffsetsRequest` uses `UserQueueOffsets` - `Session` rows reference `User` via `UserId` (and via `AircraftId` for mission sessions targeting `RoleEnum.CompanionPC` users) ## Data Models Maps to PostgreSQL table `users` with columns: `id`, `email`, `password_hash`, `hardware`, `role`, `user_config` (JSON text), `created_at`, `last_login`, `is_enabled`, `failed_login_count` (AZ-537), `lockout_until` (AZ-537), `mfa_enabled` (AZ-534), `mfa_secret` (AZ-534), `mfa_recovery_codes` (jsonb, AZ-534), `mfa_enrolled_at` (AZ-534), `mfa_last_used_window` (AZ-534). Migration files: `env/db/02_structure.sql` (initial), `03_add_timestamp_columns.sql`, `06_users_email_unique.sql` (UNIQUE INDEX on email), `07_auth_lockout_and_audit.sql` (AZ-537 lockout columns + `audit_events` table), `10_users_mfa.sql` (AZ-534 MFA columns). ## Configuration None directly. `MfaSecret` encryption depends on the application-level `DataProtection:KeysFolder` setting (Production must point this at a persistent volume). ## External Integrations None directly — but `MfaSecret` depends on ASP.NET Core DataProtection for at-rest encryption. ## Security - `PasswordHash` stores Argon2id PHC strings for new + rehashed users; legacy SHA-384 still accepted (lazy-migrated on next successful login). - `MfaSecret` is encrypted at rest via `IDataProtector` (purpose `Azaion.Mfa.Secret.v1`). - `MfaRecoveryCodes` are SHA-256-hashed at rest; the plaintext list is shown only in the `/users/me/mfa/enroll` response. - `MfaLastUsedWindow` defends against in-window replay of the same TOTP code. - `FailedLoginCount` + `LockoutUntil` enforce CMMC AC.L2-3.1.8 (lockout after 10 consecutive failed logins; 15-min default duration). - `Hardware` is a tombstone (no application code reads or writes it) per AZ-197. ## Tests Indirectly tested end-to-end via `e2e/Azaion.E2E/Tests/LoginTests.cs`, `UserManagementTests.cs`, `DeviceTests.cs`, `RateLimitLockoutTests.cs`, `MfaEnrollmentTests.cs`, `MfaLoginTests.cs`.