using System.Security.Cryptography; using Azaion.Common; using Azaion.Common.Configs; using Azaion.Common.Database; using Azaion.Common.Entities; using Azaion.Common.Extensions; using Azaion.Common.Requests; using LinqToDB; using Microsoft.Extensions.Options; using Npgsql; namespace Azaion.Services; public interface IUserService { Task RegisterUser(RegisterUserRequest request, CancellationToken ct = default); Task RegisterDevice(CancellationToken ct = default); Task ValidateUser(LoginRequest request, CancellationToken ct = default); Task GetByEmail(string? email, CancellationToken ct = default); Task GetById(Guid userId, CancellationToken ct = default); Task UpdateQueueOffsets(string email, UserQueueOffsets queueOffsets, CancellationToken ct = default); Task> GetUsers(string? searchEmail, RoleEnum? searchRole, CancellationToken ct = default); Task ChangeRole(string email, RoleEnum newRole, CancellationToken ct = default); Task SetEnableStatus(string email, bool isEnabled, CancellationToken ct = default); Task RemoveUser(string email, CancellationToken ct = default); /// /// AZ-557 — shared failure-accounting path for MFA-side failures. Mirrors what the /// password-side path in does on a wrong-password event: /// records the appropriate audit row, increments failed_login_count, /// crosses-the-threshold trips lockout_until, and signals lockout by throwing /// with /// + . Callers (e.g., /// MfaService.VerifyForLogin) MUST handle the throw branch and rethrow their /// own opaque error if the threshold was not crossed. /// Task RegisterMfaFailedLogin(User user, CancellationToken ct = default); } public class UserService( IDbFactory dbFactory, ICache cache, IAuditLog auditLog, IOptions authConfig) : IUserService { private readonly AuthConfig _auth = authConfig.Value; private const string DeviceEmailPrefix = "azj-"; private const string DeviceEmailDomain = "@azaion.com"; private const int SerialNumberStart = 4; // index of NNNN inside "azj-NNNN..." (length of DeviceEmailPrefix) private const int SerialNumberLength = 4; private const int DevicePasswordBytes = 16; // hex-encoded → 32 chars public async Task RegisterUser(RegisterUserRequest request, CancellationToken ct = default) { try { await dbFactory.RunAdmin(async db => { await db.InsertAsync(new User { Id = Guid.NewGuid(), Email = request.Email, PasswordHash = Security.HashPassword(request.Password), Role = request.Role, CreatedAt = DateTime.UtcNow, IsEnabled = true }, token: ct); }); } catch (PostgresException ex) when (ex.SqlState == PostgresErrorCodes.UniqueViolation) { throw new BusinessException(ExceptionEnum.EmailExists); } } public async Task RegisterDevice(CancellationToken ct = default) { var (serial, email) = await NextDeviceIdentity(ct); var password = Convert.ToHexString(RandomNumberGenerator.GetBytes(DevicePasswordBytes)).ToLowerInvariant(); await RegisterUser(new RegisterUserRequest { Email = email, Password = password, Role = RoleEnum.CompanionPC }, ct); return new RegisterDeviceResponse { Serial = serial, Email = email, Password = password }; } private async Task<(string Serial, string Email)> NextDeviceIdentity(CancellationToken ct) => await dbFactory.Run(async db => { var lastEmail = await db.Users .Where(u => u.Role == RoleEnum.CompanionPC) .OrderByDescending(u => u.CreatedAt) .Select(u => u.Email) .FirstOrDefaultAsync(token: ct); var nextNumber = 0; if (!string.IsNullOrEmpty(lastEmail) && lastEmail.Length >= SerialNumberStart + SerialNumberLength) { var serialPart = lastEmail.Substring(SerialNumberStart, SerialNumberLength); if (int.TryParse(serialPart, out var current)) nextNumber = current + 1; } var serial = $"{DeviceEmailPrefix}{nextNumber.ToString($"D{SerialNumberLength}")}"; var email = $"{serial}{DeviceEmailDomain}"; return (serial, email); }); public async Task GetByEmail(string? email, CancellationToken ct = default) { if (string.IsNullOrWhiteSpace(email)) throw new ArgumentNullException(nameof(email)); return await cache.GetFromCacheAsync(User.GetCacheKey(email), async () => await dbFactory.Run(async db => await db.Users.FirstOrDefaultAsync(x => x.Email == email, ct))); } public async Task GetById(Guid userId, CancellationToken ct = default) => await dbFactory.Run(async db => await db.Users.FirstOrDefaultAsync(x => x.Id == userId, token: ct)); public async Task ValidateUser(LoginRequest request, CancellationToken ct = default) { var user = await dbFactory.Run(async db => await db.Users.FirstOrDefaultAsync(x => x.Email == request.Email, token: ct)); // AZ-556 — unknown email: equalize timing with a dummy Argon2id verify so a // wall-clock observer can't distinguish "no such email" from "wrong password". // No counter to increment (there is no row), so this path skips lockout // accounting entirely; the audit row preserves the attempted email for SecOps. if (user == null) { Security.VerifyDummy(request.Password); await auditLog.RecordLoginFailedUnknownEmail(request.Email, ct); throw new BusinessException(ExceptionEnum.InvalidCredentials); } // AZ-537 AC-3 — active lockout takes precedence over the password check; even // a correct password is rejected until the lockout expires. AZ-556 collapses // the response code to `InvalidCredentials` while keeping the Retry-After // header so legitimate clients can self-throttle. if (user.LockoutUntil is { } until && until > DateTime.UtcNow) { var remaining = (int)Math.Ceiling((until - DateTime.UtcNow).TotalSeconds); throw new BusinessException(ExceptionEnum.InvalidCredentials, Math.Max(remaining, 1)); } // AZ-537 AC-2 — per-account sliding-window rate limit. Counts only failure // events in the recent window (login_failed + mfa_login_failed per AZ-557) so // legitimate retries after a success aren't punished. var recentFailures = await auditLog.CountRecentFailedLogins( user.Email, _auth.RateLimit.PerAccountWindowSeconds, ct); if (recentFailures >= _auth.RateLimit.PerAccountPermitLimit) throw new BusinessException(ExceptionEnum.InvalidCredentials, _auth.RateLimit.PerAccountWindowSeconds); // AZ-556 F-AUTH-3 — disabled-account check moved BEFORE password verify. An // attacker who knows the password of a disabled account no longer learns that // fact via a distinct error code (or via the missing-Argon2id timing tell). // Still run the dummy verify so the wall-clock equalises against a real // wrong-password branch. if (!user.IsEnabled) { Security.VerifyDummy(request.Password); await auditLog.RecordLoginFailedDisabled(user.Email, ct); throw new BusinessException(ExceptionEnum.InvalidCredentials); } var verify = Security.VerifyPassword(request.Password, user.PasswordHash); if (!verify.Valid) { // RegisterFailedLogin may itself throw InvalidCredentials + Retry-After // when the threshold trips; otherwise we fall through and throw the // non-Retry-After variant below. await RegisterFailedLogin(user, ct); throw new BusinessException(ExceptionEnum.InvalidCredentials); } await RegisterSuccessfulLogin(user, request.Password, verify.NeedsRehash, ct); return user; } // Lazy migration of legacy SHA-384 hashes (and future Argon2 param upgrades). // Conditional on the original hash to avoid clobbering a concurrent rehash from // a parallel login of the same account. private async Task RegisterSuccessfulLogin(User user, string plaintext, bool rehash, CancellationToken ct) { var newHash = rehash ? Security.HashPassword(plaintext) : null; var oldHash = user.PasswordHash; await dbFactory.RunAdmin(async db => { if (newHash != null) { await db.Users.UpdateAsync( u => u.Id == user.Id && u.PasswordHash == oldHash, u => new User { PasswordHash = newHash, FailedLoginCount = 0, LockoutUntil = null }, token: ct); } else { await db.Users.UpdateAsync( u => u.Id == user.Id, u => new User { FailedLoginCount = 0, LockoutUntil = null }, token: ct); } }); if (newHash != null) user.PasswordHash = newHash; user.FailedLoginCount = 0; user.LockoutUntil = null; cache.Invalidate(User.GetCacheKey(user.Email)); await auditLog.RecordLoginSuccess(user.Email, ct); } private Task RegisterFailedLogin(User user, CancellationToken ct) => RegisterFailedLoginCore(user, FailureKind.Password, ct); public Task RegisterMfaFailedLogin(User user, CancellationToken ct = default) => RegisterFailedLoginCore(user, FailureKind.Mfa, ct); // AZ-557 — single accounting path shared by the password-side (`ValidateUser`) and // the MFA-side (`MfaService.VerifyForLogin`) failure branches. The audit row type // diverges (`login_failed` vs `mfa_login_failed`) so SecOps can analyse the two // categories separately, but the counter / lockout / Retry-After semantics are // identical. On lockout-trip we throw `InvalidCredentials` + Retry-After so the // caller can rethrow its opaque wire response without losing the cooldown hint. private async Task RegisterFailedLoginCore(User user, FailureKind kind, CancellationToken ct) { if (kind == FailureKind.Password) await auditLog.RecordLoginFailed(user.Email, ct); else await auditLog.RecordMfaLoginFailed(user.Email, ct); var newCount = user.FailedLoginCount + 1; var triggersLock = newCount >= _auth.Lockout.MaxAttempts; DateTime? newLockoutUntil = triggersLock ? DateTime.UtcNow.AddSeconds(_auth.Lockout.DurationSeconds) : user.LockoutUntil; await dbFactory.RunAdmin(async db => await db.Users.UpdateAsync( u => u.Id == user.Id, u => new User { FailedLoginCount = newCount, LockoutUntil = newLockoutUntil }, token: ct)); cache.Invalidate(User.GetCacheKey(user.Email)); if (triggersLock) { await auditLog.RecordLoginLockout(user.Email, ct); // AZ-556 — promote a threshold-crossing failure into the unified lockout // response. The caller sees `InvalidCredentials` + Retry-After regardless // of whether the threshold was crossed by a password or an MFA attempt. throw new BusinessException(ExceptionEnum.InvalidCredentials, _auth.Lockout.DurationSeconds); } } private enum FailureKind { Password, Mfa, } public async Task UpdateQueueOffsets(string email, UserQueueOffsets queueOffsets, CancellationToken ct = default) { await dbFactory.RunAdmin(async db => { var userConfig = await db.Users.Where(x => x.Email == email).Select(x => x.UserConfig).FirstOrDefaultAsync(token: ct); userConfig ??= new UserConfig(); userConfig.QueueOffsets = queueOffsets; await db.Users.UpdateAsync(x => x.Email == email, u => new User { UserConfig = userConfig }, token: ct); }); cache.Invalidate(User.GetCacheKey(email)); } public async Task> GetUsers(string? searchEmail, RoleEnum? searchRole, CancellationToken ct) => await dbFactory.Run(async db => await db.Users .WhereIf(!string.IsNullOrEmpty(searchEmail), u => u.Email.ToLower().Contains(searchEmail!.ToLower())) .WhereIf(searchRole != null, u => u.Role == searchRole) .ToListAsync(token: ct)); public async Task ChangeRole(string email, RoleEnum newRole, CancellationToken ct = default) { await dbFactory.RunAdmin(async db => await db.Users.UpdateAsync(x => x.Email == email, u => new User { Role = newRole }, ct)); } public async Task SetEnableStatus(string email, bool isEnabled, CancellationToken ct = default) { await dbFactory.RunAdmin(async db => await db.Users.UpdateAsync(x => x.Email == email, u => new User { IsEnabled = isEnabled }, ct)); } public async Task RemoveUser(string email, CancellationToken ct = default) { await dbFactory.RunAdmin(async db => await db.Users.DeleteAsync(x => x.Email == email, ct)); } }