using System.Security.Claims; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Logging; using SharepointToolbox.Web.Core.Models; using SharepointToolbox.Web.Infrastructure.Persistence; namespace SharepointToolbox.Web.Services.Auth; public class UserService : IUserService { private readonly UserRepository _repo; private readonly IPasswordHasher _hasher; private readonly ILogger _logger; public UserService(UserRepository repo, IPasswordHasher hasher, ILogger logger) { _repo = repo; _hasher = hasher; _logger = logger; } public async Task ProvisionAsync(ClaimsPrincipal principal) { var email = principal.FindFirstValue(ClaimTypes.Email) ?? principal.FindFirstValue("preferred_username") ?? throw new InvalidOperationException("OIDC token has no email claim."); var display = principal.FindFirstValue("name") ?? principal.FindFirstValue(ClaimTypes.Name) ?? email; var existing = await _repo.FindByEmailAsync(email); if (existing is not null) { existing.LastLogin = DateTimeOffset.UtcNow; existing.DisplayName = display; await _repo.UpsertAsync(existing); return existing; } // First user ever → Admin; subsequent → TechN0 var all = await _repo.LoadAsync(); var role = all.Count == 0 ? UserRole.Admin : UserRole.TechN0; var user = new AppUser { Email = email, DisplayName = display, Role = role, Provider = AuthProvider.Entra, CreatedAt = DateTimeOffset.UtcNow, LastLogin = DateTimeOffset.UtcNow }; await _repo.UpsertAsync(user); return user; } public Task GetByEmailAsync(string email) => _repo.FindByEmailAsync(email); public Task> GetAllAsync() => _repo.LoadAsync(); public async Task UpdateRoleAsync(string userId, UserRole role) { var users = (await _repo.LoadAsync()).ToList(); var user = users.FirstOrDefault(u => u.Id == userId) ?? throw new KeyNotFoundException($"User '{userId}' not found among {users.Count} stored users."); var oldRole = user.Role; user.Role = role; await _repo.UpsertAsync(user); // Verify the write landed by re-reading the row from disk. var persisted = (await _repo.LoadAsync()).FirstOrDefault(u => u.Id == user.Id)?.Role; _logger.LogInformation( "UpdateRoleAsync: {Email} (id {Id}) {OldRole} → {NewRole}; persisted value now {Persisted}.", user.Email, user.Id, oldRole, role, persisted); return oldRole; } public Task DeleteAsync(string userId) => _repo.DeleteAsync(userId); public async Task CreateLocalUserAsync(string email, string displayName, UserRole role, string password) { email = email.Trim(); if (string.IsNullOrWhiteSpace(email)) throw new InvalidOperationException("Email is required."); if (string.IsNullOrWhiteSpace(password)) throw new InvalidOperationException("Password is required."); if (await _repo.FindByEmailAsync(email) is not null) throw new InvalidOperationException($"A user with email '{email}' already exists."); // First user ever → Admin; otherwise use the requested role var all = await _repo.LoadAsync(); var effectiveRole = all.Count == 0 ? UserRole.Admin : role; var user = new AppUser { Email = email, DisplayName = string.IsNullOrWhiteSpace(displayName) ? email : displayName.Trim(), Role = effectiveRole, Provider = AuthProvider.Local, CreatedAt = DateTimeOffset.UtcNow, LastLogin = null }; user.PasswordHash = _hasher.HashPassword(user, password); await _repo.UpsertAsync(user); return user; } // Brute-force lockout: after this many consecutive failures the account is locked // for the window below. Tuned to stop guessing while staying usable for a fat-fingered // admin. The counter resets on any successful sign-in. private const int LockoutThreshold = 5; private static readonly TimeSpan LockoutWindow = TimeSpan.FromMinutes(15); public async Task ValidateLocalCredentialsAsync(string email, string password) { var user = await _repo.FindByEmailAsync(email); if (user is null || user.Provider != AuthProvider.Local || string.IsNullOrEmpty(user.PasswordHash)) return null; var now = DateTimeOffset.UtcNow; // Account currently locked → refuse without even checking the password, so a locked // account can't be probed. Returning null (generic failure) avoids account enumeration. if (user.LockoutEndUtc is { } until && until > now) { _logger.LogWarning("Local login blocked: account {Email} is locked until {Until:o}.", user.Email, until); return null; } // Lock window elapsed → clear it before re-evaluating. if (user.LockoutEndUtc is not null) { user.LockoutEndUtc = null; user.FailedLoginCount = 0; } var result = _hasher.VerifyHashedPassword(user, user.PasswordHash, password); if (result == PasswordVerificationResult.Failed) { user.FailedLoginCount++; if (user.FailedLoginCount >= LockoutThreshold) { user.LockoutEndUtc = now + LockoutWindow; _logger.LogWarning( "Local login: account {Email} locked for {Minutes} min after {Count} failed attempts.", user.Email, LockoutWindow.TotalMinutes, user.FailedLoginCount); } await _repo.UpsertAsync(user); return null; } // Transparently upgrade the hash if the algorithm parameters changed if (result == PasswordVerificationResult.SuccessRehashNeeded) user.PasswordHash = _hasher.HashPassword(user, password); // Success → clear the failure trail. user.FailedLoginCount = 0; user.LockoutEndUtc = null; user.LastLogin = now; await _repo.UpsertAsync(user); return user; } public async Task SetPasswordAsync(string userId, string newPassword) { if (string.IsNullOrWhiteSpace(newPassword)) throw new InvalidOperationException("Password is required."); var users = (await _repo.LoadAsync()).ToList(); var user = users.FirstOrDefault(u => u.Id == userId) ?? throw new KeyNotFoundException($"User {userId} not found."); if (user.Provider != AuthProvider.Local) throw new InvalidOperationException("Only local accounts have passwords."); user.PasswordHash = _hasher.HashPassword(user, newPassword); await _repo.UpsertAsync(user); } public async Task ChangePasswordAsync(string userId, string currentPassword, string newPassword) { if (string.IsNullOrWhiteSpace(newPassword)) throw new InvalidOperationException("New password is required."); var users = (await _repo.LoadAsync()).ToList(); var user = users.FirstOrDefault(u => u.Id == userId) ?? throw new KeyNotFoundException($"User {userId} not found."); if (user.Provider != AuthProvider.Local || string.IsNullOrEmpty(user.PasswordHash)) return false; var result = _hasher.VerifyHashedPassword(user, user.PasswordHash, currentPassword); if (result == PasswordVerificationResult.Failed) return false; user.PasswordHash = _hasher.HashPassword(user, newPassword); await _repo.UpsertAsync(user); return true; } }