// SPDX-FileCopyrightText: 2026 Progmasoft // SPDX-License-Identifier: AGPL-3.0-or-later WITH AdditionRef-Progmasoft-Patent-Grant-1.0 using Microsoft.AspNetCore.Identity; namespace Progmasoft.Progmaweb.Api.Accounts; internal sealed class AccountService( IAccountStore store, TimeProvider timeProvider) { private readonly PasswordHasher passwordHasher = new(); public async ValueTask<(AuthenticatedAccount? Authentication, Dictionary Errors)> RegisterAsync( RegisterAccountRequest request, SessionService sessions, CancellationToken cancellationToken) { Dictionary errors = ValidateRegistration(request, out string accountName, out string email, out string normalizedEmail); if (errors.Count != 0) { return (null, errors); } AccountRecord provisional = new( Guid.NewGuid(), accountName, AccountNamePolicy.NormalizeForLookup(accountName), email, normalizedEmail, string.Empty, timeProvider.GetUtcNow()); string passwordHash = passwordHasher.HashPassword(provisional, request.Password!); AccountRecord account = provisional with { PasswordHash = passwordHash }; CreateAccountResult result = await store.CreateAsync(account, cancellationToken); if (result.Status is CreateAccountStatus.AccountNameUnavailable) { return (null, new Dictionary { ["accountName"] = ["That account name is unavailable."] }); } if (result.Status is CreateAccountStatus.EmailUnavailable) { return (null, new Dictionary { ["email"] = ["That email address is already registered."] }); } AuthenticatedAccount authentication = await sessions.CreateAsync(account, cancellationToken); return (authentication, errors); } public async ValueTask LoginAsync( LoginAccountRequest request, SessionService sessions, CancellationToken cancellationToken) { if (!EmailAddressPolicy.TryNormalize(request.Email, out _, out string normalizedEmail, out _) || request.Password is null) { return null; } AccountRecord? account = await store.FindByEmailAsync(normalizedEmail, cancellationToken); if (account is null || account.PasswordHash is null) { // A fixed dummy record keeps the missing-account path on the same password-hashing primitive. AccountRecord dummyBase = new(Guid.Empty, "Missing00", "MISSING00", "missing@example.invalid", "MISSING@EXAMPLE.INVALID", string.Empty, DateTimeOffset.UnixEpoch); AccountRecord dummy = dummyBase with { PasswordHash = passwordHasher.HashPassword(dummyBase, "not-the-provided-password") }; _ = passwordHasher.VerifyHashedPassword(dummy, dummy.PasswordHash, request.Password); return null; } PasswordVerificationResult verification = passwordHasher.VerifyHashedPassword(account, account.PasswordHash, request.Password); if (verification is PasswordVerificationResult.Failed) { return null; } return await sessions.CreateAsync(account, cancellationToken); } public async ValueTask<(AuthenticatedAccount? Authentication, GoogleAccountFailure Failure)> AuthenticateGoogleAsync( string? providerEmail, string? requestedAccountName, SessionService sessions, CancellationToken cancellationToken) { if (!EmailAddressPolicy.TryNormalize(providerEmail, out string email, out string normalizedEmail, out _)) { return (null, GoogleAccountFailure.ProviderRejected); } AccountRecord? existing = await store.FindByEmailAsync(normalizedEmail, cancellationToken); if (existing is not null) { return (await sessions.CreateAsync(existing, cancellationToken), GoogleAccountFailure.None); } if (requestedAccountName is null) { return (null, GoogleAccountFailure.AccountNameRequired); } if (!AccountNamePolicy.TryNormalize(requestedAccountName, out string accountName, out _)) { return (null, GoogleAccountFailure.InvalidAccountName); } AccountRecord account = new( Guid.NewGuid(), accountName, AccountNamePolicy.NormalizeForLookup(accountName), email, normalizedEmail, null, timeProvider.GetUtcNow()); CreateAccountResult result = await store.CreateAsync(account, cancellationToken); if (result.Status is CreateAccountStatus.AccountNameUnavailable) { return (null, GoogleAccountFailure.AccountNameUnavailable); } if (result.Status is CreateAccountStatus.EmailUnavailable) { // A concurrent callback may have created the same verified Google identity. AccountRecord? concurrent = await store.FindByEmailAsync(normalizedEmail, cancellationToken); return concurrent is null ? (null, GoogleAccountFailure.ProviderRejected) : (await sessions.CreateAsync(concurrent, cancellationToken), GoogleAccountFailure.None); } return (await sessions.CreateAsync(account, cancellationToken), GoogleAccountFailure.None); } private static Dictionary ValidateRegistration( RegisterAccountRequest request, out string accountName, out string email, out string normalizedEmail) { Dictionary errors = []; if (!AccountNamePolicy.TryNormalize(request.AccountName, out accountName, out string accountNameError)) { errors["accountName"] = [accountNameError]; } if (!EmailAddressPolicy.TryNormalize(request.Email, out email, out normalizedEmail, out string emailError)) { errors["email"] = [emailError]; } if (!PasswordPolicy.TryValidate(request.Password, out string passwordError)) { errors["password"] = [passwordError]; } return errors; } } internal enum GoogleAccountFailure { None, ProviderRejected, AccountNameRequired, InvalidAccountName, AccountNameUnavailable }