using Couchbase.Lite; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Options; using WorkTracker.Configuration; using WorkTracker.Services.Storage; namespace WorkTracker.Services.Auth; public sealed class CouchbaseLiteAuthService : IAuthService { private readonly Collection users; private readonly PasswordHasher passwordHasher = new(); private readonly IOptions options; private readonly ILogger logger; public CouchbaseLiteAuthService( CouchbaseLiteDatabaseProvider databaseProvider, IOptions options, ILogger logger) { users = databaseProvider.Users; this.options = options; this.logger = logger; } public Task EnsureSeedUserAsync(CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); var username = options.Value.Username.Trim(); var normalizedUsername = NormalizeUsername(username); if (users.GetDocument(normalizedUsername) is not null) { return Task.CompletedTask; } var user = new AuthUser { Id = normalizedUsername, Username = username, UsernameNormalized = normalizedUsername, PasswordHash = string.Empty, MustChangePassword = true }; var passwordHash = passwordHasher.HashPassword(user, options.Value.Password); var userToCreate = new AuthUser { Id = normalizedUsername, Username = username, UsernameNormalized = normalizedUsername, PasswordHash = passwordHash }; // Ensure the seeded user requires a password change on first login var seeded = new AuthUser { Id = userToCreate.Id, Username = userToCreate.Username, UsernameNormalized = userToCreate.UsernameNormalized, PasswordHash = userToCreate.PasswordHash, MustChangePassword = true }; SaveUser(seeded); logger.LogInformation("Seeded single user account {Username} in Couchbase Lite", username); return Task.CompletedTask; } public Task ValidateCredentialsAsync(string username, string password, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); var normalizedUsername = NormalizeUsername(username); var user = GetUser(normalizedUsername); if (user is null) { logger.LogWarning("Authentication failed: user {Username} not found", username); return Task.FromResult(null); } var result = passwordHasher.VerifyHashedPassword(user, user.PasswordHash, password); if (result == PasswordVerificationResult.Failed) { logger.LogWarning("Authentication failed: invalid password for user {Username}", username); } return Task.FromResult(result == PasswordVerificationResult.Failed ? null : user); } private AuthUser? GetUser(string normalizedUsername) { var document = users.GetDocument(normalizedUsername); if (document is null) { return null; } return new AuthUser { Id = document.Id, Username = document.GetString("username") ?? document.GetString("email") ?? string.Empty, UsernameNormalized = document.GetString("usernameNormalized") ?? document.GetString("emailNormalized") ?? string.Empty, PasswordHash = document.GetString("passwordHash") ?? string.Empty, MustChangePassword = document.GetBoolean("mustChangePassword") }; } private void SaveUser(AuthUser user) { var document = new MutableDocument(user.Id); document.SetString("username", user.Username); document.SetString("usernameNormalized", user.UsernameNormalized); document.SetString("passwordHash", user.PasswordHash); document.SetBoolean("mustChangePassword", user.MustChangePassword); users.Save(document); } public Task ChangePasswordAsync(string userId, string newPassword, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); var document = users.GetDocument(userId); if (document is null) { return Task.FromResult(false); } var user = GetUser(userId); if (user is null) { return Task.FromResult(false); } var newHash = passwordHasher.HashPassword(user, newPassword); var mutable = document.ToMutable(); mutable.SetString("passwordHash", newHash); mutable.SetBoolean("mustChangePassword", false); users.Save(mutable); return Task.FromResult(true); } private static string NormalizeUsername(string username) => username.Trim().ToUpperInvariant(); }