// 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; /// Registration and sign-in rules of the account service. /// Store that keeps accounts. /// Clock used for creation times. internal sealed class AccountService( IAccountStore store, TimeProvider timeProvider) { /// An account that never exists; the subject of the dummy verification. private static readonly AccountRecord DummyAccount = new( Guid.Empty, "Missing00", "MISSING00", "missing@example.invalid", "MISSING@EXAMPLE.INVALID", null, string.Empty, DateTimeOffset.UnixEpoch); /// /// A hash that no submitted password matches. Verifying against it makes a sign-in with an unknown address cost /// as much as one with a wrong password. /// private static readonly string DummyPasswordHash = new PasswordHasher().HashPassword(DummyAccount, "not-the-provided-password"); /// Hashes and verifies passwords with the ASP.NET Core Identity algorithm. private readonly PasswordHasher passwordHasher = new(); /// Creates a password account and opens a session for it. /// The registration body. /// Session service. /// Cancels the operation. /// /// The signed-in account and no errors, or no account and the messages of each refused field, keyed by /// accountName, email, password and acceptPolicies. /// 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); } DateTimeOffset now = timeProvider.GetUtcNow(); AccountRecord provisional = new( Guid.NewGuid(), accountName, AccountNamePolicy.NormalizeForLookup(accountName), email, normalizedEmail, null, string.Empty, now, PolicyAcceptance.CurrentVersion, now); 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."] }); } // The store returns the account as it keeps it, which is what every later read returns. AuthenticatedAccount authentication = await sessions.CreateAsync(result.Account ?? account, cancellationToken); return (authentication, errors); } /// Signs in with email address and password. /// /// An unknown address costs one password verification against a private dummy hash, so the response time does not /// reveal whether an address is registered. /// /// The login body. /// Session service. /// Cancels the operation. /// The signed-in account, or when the credentials are refused. public async ValueTask LoginAsync( LoginAccountRequest request, SessionService sessions, CancellationToken cancellationToken) { AccountRecord? account = await VerifyPasswordAsync(request.Email, request.Password, cancellationToken); return account is null ? null : await sessions.CreateAsync(account, cancellationToken); } /// Checks an email address and password without opening a session. /// /// Progmasoft Credentials signs the account in with its own cookie and must not open a session of the account /// site. The verification is the one uses, with the same cost for an unknown address. /// /// Email address as entered. /// Password as entered. /// Cancels the operation. /// The account, or when the credentials are refused. public async ValueTask VerifyPasswordAsync( string? email, string? password, CancellationToken cancellationToken) { if (!EmailAddressPolicy.TryNormalize(email, out _, out string normalizedEmail, out _) || password is null || 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, password); return null; } PasswordVerificationResult verification = passwordHasher.VerifyHashedPassword(account, account.PasswordHash, password); return verification is PasswordVerificationResult.Failed ? null : account; } /// Finds an account by its identifier, for a Credentials session that names it. /// Identifier of the account. /// Cancels the operation. /// The account, or when it no longer exists. public ValueTask FindAsync(Guid id, CancellationToken cancellationToken) => store.FindByIdAsync(id, cancellationToken); /// Signs in with a Google identity, creating the account on first use. /// /// An identity that is already linked signs in to its account. A new identity creates an account under the /// requested name. A new identity whose email address belongs to an existing account is refused: password /// registrations do not verify the address, so linking by address could hand an account to someone else. /// /// Subject identifier Google issued for the identity. /// Email address Google reported for the identity. /// Account name to create for a new identity; may be . /// /// Version of the policies the person accepted before leaving for Google; a new identity creates an account only /// when it is . /// /// Session service. /// Cancels the operation. /// The signed-in account, or no account and the reason. public async ValueTask<(AuthenticatedAccount? Authentication, GoogleAccountFailure Failure)> AuthenticateGoogleAsync( string? providerSubject, string? providerEmail, string? requestedAccountName, string? acceptedPolicies, 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); } if (acceptedPolicies != PolicyAcceptance.CurrentVersion) { return (null, GoogleAccountFailure.PoliciesRequired); } DateTimeOffset now = timeProvider.GetUtcNow(); AccountRecord account = new( Guid.NewGuid(), accountName, AccountNamePolicy.NormalizeForLookup(accountName), email, normalizedEmail, providerSubject, null, now, PolicyAcceptance.CurrentVersion, now); 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(result.Account ?? account, cancellationToken), GoogleAccountFailure.None); } /// /// Creates the account of a reserved name, which public registration refuses. Only trusted operations tooling /// calls this; see . /// /// The reserved name, as it is to be spelled. /// The email address of the account. /// The password, which the operator typed. /// Cancels the operation. /// The created account, or the message that says why it was refused. public async ValueTask<(AccountRecord? Account, string? Error)> ProvisionReservedAsync( string? accountName, string? email, string? password, CancellationToken cancellationToken) { if (!AccountNamePolicy.TryNormalizeReserved(accountName, out string name, out string nameError)) { return (null, nameError); } if (!EmailAddressPolicy.TryNormalize(email, out string address, out string normalizedEmail, out string emailError)) { return (null, emailError); } if (!PasswordPolicy.TryValidate(password, out string passwordError)) { return (null, passwordError); } AccountRecord provisional = new( Guid.NewGuid(), name, AccountNamePolicy.NormalizeForLookup(name), address, normalizedEmail, null, string.Empty, timeProvider.GetUtcNow()); AccountRecord account = provisional with { PasswordHash = passwordHasher.HashPassword(provisional, password!) }; CreateAccountResult result = await store.CreateAsync(account, cancellationToken); return result.Status switch { CreateAccountStatus.AccountNameUnavailable => (null, $"The account '{name}' exists already."), CreateAccountStatus.EmailUnavailable => (null, "That email address is already registered."), _ => (result.Account ?? account, null) }; } /// Validates every field of a registration and collects the messages of the refused ones. /// The registration body. /// The canonical account name. /// The email address as entered, trimmed. /// The email address in its lookup form. /// Messages keyed by field name; empty when the registration is valid. 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]; } if (request.AcceptPolicies is not true) { errors["acceptPolicies"] = [PolicyAcceptance.RequiredMessage]; } return errors; } } /// Reason a sign-in with Google did not produce a session. internal enum GoogleAccountFailure { /// The sign-in succeeded. None, /// /// The identity cannot be used: Google reported no usable subject or address, or the address belongs to another /// account. /// ProviderRejected, /// The identity is new and no account name was supplied. AccountNameRequired, /// The supplied account name breaks . InvalidAccountName, /// The supplied account name is taken. AccountNameUnavailable, /// The identity is new and the person did not accept the current policies. PoliciesRequired }