// SPDX-FileCopyrightText: 2026 Progmasoft // SPDX-License-Identifier: AGPL-3.0-or-later WITH AdditionRef-Progmasoft-Patent-Grant-1.1 using Microsoft.AspNetCore.Identity; namespace Progmasoft.Progmaweb.Api.Accounts; internal sealed class AccountService( IAccountStore store, TimeProvider timeProvider) { private static readonly AccountRecord DummyAccount = new( Guid.Empty, "Missing00", "MISSING00", "missing@example.invalid", "MISSING@EXAMPLE.INVALID", null, string.Empty, DateTimeOffset.UnixEpoch); private static readonly string DummyPasswordHash = new PasswordHasher().HashPassword(DummyAccount, "not-the-provided-password"); 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, null, 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 || request.Password.Length > PasswordPolicy.MaximumLength) { return null; } AccountRecord? account = await store.FindByEmailAsync(normalizedEmail, cancellationToken); if (account is null || account.PasswordHash is null) { // Reuse one private hash so an unknown email follows one expensive verification, like a bad password. _ = passwordHasher.VerifyHashedPassword(DummyAccount, DummyPasswordHash, 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? providerSubject, string? providerEmail, string? requestedAccountName, SessionService sessions, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(providerSubject) || !EmailAddressPolicy.TryNormalize(providerEmail, out string email, out string normalizedEmail, out _)) { return (null, GoogleAccountFailure.ProviderRejected); } AccountRecord? knownGoogleAccount = await store.FindByGoogleSubjectAsync(providerSubject, cancellationToken); if (knownGoogleAccount is not null) { return (await sessions.CreateAsync(knownGoogleAccount, cancellationToken), GoogleAccountFailure.None); } AccountRecord? existing = await store.FindByEmailAsync(normalizedEmail, cancellationToken); if (existing is not null) { // Password registrations do not verify ownership of the supplied email address yet. Automatically // linking a verified Google identity by email would therefore let two unrelated people share an // account (and any already-issued sessions) when one of them registered the other's address. return (null, GoogleAccountFailure.ProviderRejected); } 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, providerSubject, 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) { // Any different identity can win the email race, including an unverified password registration. return (null, GoogleAccountFailure.ProviderRejected); } if (result.Status is CreateAccountStatus.GoogleSubjectUnavailable) { AccountRecord? concurrent = await store.FindByGoogleSubjectAsync(providerSubject, 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 }