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); } 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)); if (user == null) throw new BusinessException(ExceptionEnum.NoEmailFound); // AZ-537 AC-3 — active lockout takes precedence over the password check; even // a correct password is rejected with 423 Locked until the lockout expires. if (user.LockoutUntil is { } until && until > DateTime.UtcNow) { var remaining = (int)Math.Ceiling((until - DateTime.UtcNow).TotalSeconds); throw new BusinessException(ExceptionEnum.AccountLocked, Math.Max(remaining, 1)); } // AZ-537 AC-2 — per-account sliding-window rate limit. Counts only failed // logins in the recent window so legitimate retries after success aren't punished. var recentFailures = await auditLog.CountRecentFailedLogins( user.Email, _auth.RateLimit.PerAccountWindowSeconds, ct); if (recentFailures >= _auth.RateLimit.PerAccountPermitLimit) throw new BusinessException(ExceptionEnum.LoginRateLimited, _auth.RateLimit.PerAccountWindowSeconds); var verify = Security.VerifyPassword(request.Password, user.PasswordHash); if (!verify.Valid) { await RegisterFailedLogin(user, ct); throw new BusinessException(ExceptionEnum.WrongPassword); } if (!user.IsEnabled) throw new BusinessException(ExceptionEnum.UserDisabled); 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 async Task RegisterFailedLogin(User user, CancellationToken ct) { await auditLog.RecordLoginFailed(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); // Promote a wrong-password into a lockout response so the caller learns the // account is locked the moment the threshold is crossed. throw new BusinessException(ExceptionEnum.AccountLocked, _auth.Lockout.DurationSeconds); } } 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)); } }