diff options
| author | Leitwolf11 | 2026-10-03 18:27:22 +0300 |
|---|---|---|
| committer | Leitwolf11 | 2026-10-03 18:27:22 +0300 |
| commit | b1cf9a3bea0c81c1cb3ed2e0ddce2b9a56e175ef (patch) | |
| tree | 134f15d6734c4e98d5b38121645a8e72c687ac8e /apps/api | |
| parent | e9eb2812a47b075c82a138db7f43cc08cccf072a (diff) | |
| download | progmaweb-b1cf9a3bea0c81c1cb3ed2e0ddce2b9a56e175ef.tar.gz | |
Require documentation for every item of the API reference
The DocFX gate only failed on a broken link or an unresolved reference.
DocFX does not report a declaration without a comment, and the API had
none, so the gate passed over a reference with no descriptions at all.
The gate now has three parts:
- DocFX still builds the reference with warnings as errors.
- The API project compiles its XML documentation. With warnings as errors
already on, an undocumented public declaration or a wrong cref now fails
the build.
- docs/api/CheckDocumentation.cs reads the metadata DocFX generated and
fails when a namespace, type or member has no summary. This covers
internal and private members, which the compiler does not check. It runs
as a file-based .NET program, so the gate needs no tool beyond the SDK.
It refuses to pass when the metadata is missing or defines nothing.
To satisfy the gate, every declaration of apps/api has documentation
comments, 131 reference items in all, and the two namespaces are described
by overwrite files in docs/api/namespaces. Only comment lines were added to
the C# sources; no statement changed.
docs/api/filterConfig.yml leaves generator-written code out of the
reference: the generated regular-expression namespace, and the one partial
method whose generated half replaces the comment of its declaration.
Verified locally: the solution builds with no warnings, the 23 API tests
pass, DocFX builds with no warnings and the coverage check passes. Removing
one summary and one namespace file made the check fail and name both.
Diffstat (limited to 'apps/api')
| -rw-r--r-- | apps/api/Accounts/AccountContracts.cs | 38 | ||||
| -rw-r--r-- | apps/api/Accounts/AccountEndpoints.cs | 67 | ||||
| -rw-r--r-- | apps/api/Accounts/AccountNamePolicy.cs | 19 | ||||
| -rw-r--r-- | apps/api/Accounts/AccountRecord.cs | 18 | ||||
| -rw-r--r-- | apps/api/Accounts/AccountService.cs | 53 | ||||
| -rw-r--r-- | apps/api/Accounts/EmailAddressPolicy.cs | 12 | ||||
| -rw-r--r-- | apps/api/Accounts/IAccountStore.cs | 33 | ||||
| -rw-r--r-- | apps/api/Accounts/InMemoryAccountStore.cs | 18 | ||||
| -rw-r--r-- | apps/api/Accounts/PasswordPolicy.cs | 11 | ||||
| -rw-r--r-- | apps/api/Accounts/SessionService.cs | 36 | ||||
| -rw-r--r-- | apps/api/Progmaweb.Api.csproj | 3 | ||||
| -rw-r--r-- | apps/api/Program.cs | 5 | ||||
| -rw-r--r-- | apps/api/Security/ForwardedHeadersPolicy.cs | 9 | ||||
| -rw-r--r-- | apps/api/Security/SecurityHeadersMiddleware.cs | 9 |
14 files changed, 331 insertions, 0 deletions
diff --git a/apps/api/Accounts/AccountContracts.cs b/apps/api/Accounts/AccountContracts.cs index ca7ddb1..4a803a0 100644 --- a/apps/api/Accounts/AccountContracts.cs +++ b/apps/api/Accounts/AccountContracts.cs @@ -3,27 +3,65 @@ namespace Progmasoft.Progmaweb.Api.Accounts; +/// <summary>Body of <c>POST /api/v1/accounts/register</c>.</summary> +/// <remarks> +/// Every field is optional on the wire so that a missing value is reported as a validation error of that field +/// instead of a malformed request. +/// </remarks> +/// <param name="AccountName">Requested account name; see <see cref="AccountNamePolicy"/>.</param> +/// <param name="Email">Email address of the account; see <see cref="EmailAddressPolicy"/>.</param> +/// <param name="Password">Password in plain text; see <see cref="PasswordPolicy"/>. It is hashed before it is stored.</param> public sealed record RegisterAccountRequest(string? AccountName, string? Email, string? Password); +/// <summary>Body of <c>POST /api/v1/accounts/login</c>.</summary> +/// <param name="Email">Email address the account was registered with.</param> +/// <param name="Password">Password in plain text.</param> public sealed record LoginAccountRequest(string? Email, string? Password); +/// <summary>Public view of an account, returned by the register, login and <c>/me</c> endpoints.</summary> +/// <param name="AccountName">Account name in its canonical spelling.</param> +/// <param name="Email">Email address as the owner entered it.</param> +/// <param name="CreatedAt">Moment the account was created, in UTC.</param> public sealed record AccountResponse(string AccountName, string Email, DateTimeOffset CreatedAt); +/// <summary>An account together with the session that was just opened for it.</summary> +/// <param name="Account">The signed-in account.</param> +/// <param name="SessionToken"> +/// Session token for the cookie. It exists only here and in the browser; the store keeps its digest. +/// </param> +/// <param name="ExpiresAt">Moment the session stops being valid, in UTC.</param> internal sealed record AuthenticatedAccount(AccountRecord Account, string SessionToken, DateTimeOffset ExpiresAt); +/// <summary>Outcome of asking the store to create an account.</summary> internal enum CreateAccountStatus { + /// <summary>The account was stored.</summary> Created, + /// <summary>Another account already has this name, compared without case.</summary> AccountNameUnavailable, + /// <summary>Another account already has this email address, compared without case.</summary> EmailUnavailable, + /// <summary>Another account is already linked to this Google identity.</summary> GoogleSubjectUnavailable } +/// <summary>Result of <see cref="IAccountStore.CreateAsync"/>.</summary> +/// <param name="Status">Whether the account was stored, and if not, which uniqueness rule refused it.</param> +/// <param name="Account">The stored account; <see langword="null"/> unless <paramref name="Status"/> is created.</param> internal sealed record CreateAccountResult(CreateAccountStatus Status, AccountRecord? Account) { + /// <summary>Creates the result of a stored account.</summary> + /// <param name="account">The account that was stored.</param> + /// <returns>A result with the created status and the account.</returns> public static CreateAccountResult Created(AccountRecord account) => new(CreateAccountStatus.Created, account); + /// <summary>Creates the result of a refused account name.</summary> + /// <returns>A result without an account.</returns> public static CreateAccountResult AccountNameUnavailable() => new(CreateAccountStatus.AccountNameUnavailable, null); + /// <summary>Creates the result of a refused email address.</summary> + /// <returns>A result without an account.</returns> public static CreateAccountResult EmailUnavailable() => new(CreateAccountStatus.EmailUnavailable, null); + /// <summary>Creates the result of a refused Google identity.</summary> + /// <returns>A result without an account.</returns> public static CreateAccountResult GoogleSubjectUnavailable() => new(CreateAccountStatus.GoogleSubjectUnavailable, null); } diff --git a/apps/api/Accounts/AccountEndpoints.cs b/apps/api/Accounts/AccountEndpoints.cs index 06f4997..c5be51c 100644 --- a/apps/api/Accounts/AccountEndpoints.cs +++ b/apps/api/Accounts/AccountEndpoints.cs @@ -7,10 +7,29 @@ using System.Security.Claims; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Google; +/// <summary>HTTP endpoints of the account service under <c>/api/v1/accounts</c>.</summary> +/// <remarks> +/// <list type="table"> +/// <item><term><c>POST /register</c></term><description>Creates a password account and signs it in.</description></item> +/// <item><term><c>POST /login</c></term><description>Signs in with email address and password.</description></item> +/// <item><term><c>POST /logout</c></term><description>Ends the current session.</description></item> +/// <item><term><c>GET /me</c></term><description>Returns the signed-in account.</description></item> +/// <item><term><c>GET /oauth/google/start</c></term><description>Begins sign-in with Google.</description></item> +/// <item><term><c>GET /oauth/google/complete</c></term><description>Finishes sign-in with Google.</description></item> +/// </list> +/// The endpoints that accept credentials share the <c>account-auth</c> rate limit. +/// </remarks> internal static class AccountEndpoints { + /// <summary> + /// Authentication scheme of the short-lived cookie that carries the Google identity between the provider callback + /// and <c>/oauth/google/complete</c>. + /// </summary> internal const string ExternalCookieScheme = "ProgmasoftExternal"; + /// <summary>Registers the account endpoints.</summary> + /// <param name="endpoints">The route builder of the application.</param> + /// <returns>The same route builder, for chaining.</returns> public static IEndpointRouteBuilder MapAccountEndpoints(this IEndpointRouteBuilder endpoints) { RouteGroupBuilder accounts = endpoints.MapGroup("/api/v1/accounts"); @@ -25,6 +44,15 @@ internal static class AccountEndpoints return endpoints; } + /// <summary>Begins sign-in with Google.</summary> + /// <param name="accountName"> + /// Account name to create when the Google identity is new; omitted when signing in to an existing account. + /// </param> + /// <param name="configuration">Application configuration with the Google client credentials.</param> + /// <returns> + /// A challenge that sends the browser to Google, a redirect back to registration when the name is invalid, or + /// 503 when Google sign-in is not configured. + /// </returns> private static IResult StartGoogleAsync(string? accountName, IConfiguration configuration) { if (string.IsNullOrWhiteSpace(configuration["Authentication:Google:ClientId"]) || @@ -54,6 +82,12 @@ internal static class AccountEndpoints return Results.Challenge(properties, [GoogleDefaults.AuthenticationScheme]); } + /// <summary>Finishes sign-in with Google after the provider callback.</summary> + /// <param name="accounts">Account service.</param> + /// <param name="sessions">Session service.</param> + /// <param name="context">The current request, holding the external cookie.</param> + /// <param name="cancellationToken">Cancels the operation.</param> + /// <returns>A redirect to the dashboard on success, or to the page that explains the failure.</returns> private static async Task<IResult> CompleteGoogleAsync( AccountService accounts, SessionService sessions, @@ -90,6 +124,13 @@ internal static class AccountEndpoints return Results.Redirect($"/{Uri.EscapeDataString(authentication.Account.AccountName)}/dashboard"); } + /// <summary>Creates a password account and opens a session for it.</summary> + /// <param name="request">The registration body.</param> + /// <param name="accounts">Account service.</param> + /// <param name="sessions">Session service.</param> + /// <param name="context">The current request; receives the session cookie.</param> + /// <param name="cancellationToken">Cancels the operation.</param> + /// <returns>201 with the account, or a validation problem that names each refused field.</returns> private static async Task<IResult> RegisterAsync( RegisterAccountRequest request, AccountService accounts, @@ -110,6 +151,15 @@ internal static class AccountEndpoints ToResponse(authentication.Account)); } + /// <summary>Signs in with email address and password.</summary> + /// <param name="request">The login body.</param> + /// <param name="accounts">Account service.</param> + /// <param name="sessions">Session service.</param> + /// <param name="context">The current request; receives the session cookie.</param> + /// <param name="cancellationToken">Cancels the operation.</param> + /// <returns> + /// 200 with the account, or 401 with one message that does not reveal whether the address or the password was wrong. + /// </returns> private static async Task<IResult> LoginAsync( LoginAccountRequest request, AccountService accounts, @@ -130,6 +180,11 @@ internal static class AccountEndpoints return Results.Ok(ToResponse(authentication.Account)); } + /// <summary>Ends the current session and deletes its cookie.</summary> + /// <param name="sessions">Session service.</param> + /// <param name="context">The current request.</param> + /// <param name="cancellationToken">Cancels the operation.</param> + /// <returns>204, also when no session existed.</returns> private static async Task<IResult> LogoutAsync( SessionService sessions, HttpContext context, @@ -147,6 +202,11 @@ internal static class AccountEndpoints return Results.NoContent(); } + /// <summary>Returns the account of the current session.</summary> + /// <param name="sessions">Session service.</param> + /// <param name="context">The current request.</param> + /// <param name="cancellationToken">Cancels the operation.</param> + /// <returns>200 with the account, or 401 when the request has no valid session.</returns> private static async Task<IResult> MeAsync( SessionService sessions, HttpContext context, @@ -157,6 +217,10 @@ internal static class AccountEndpoints return account is null ? Results.Unauthorized() : Results.Ok(ToResponse(account)); } + /// <summary>Writes the session cookie of a new session to the response.</summary> + /// <param name="context">The current request.</param> + /// <param name="sessions">Session service, which names the cookie.</param> + /// <param name="authentication">The session that was opened.</param> private static void WriteSessionCookie( HttpContext context, SessionService sessions, @@ -166,6 +230,9 @@ internal static class AccountEndpoints authentication.SessionToken, SessionService.CreateCookieOptions(authentication.ExpiresAt)); + /// <summary>Projects a stored account to its public view.</summary> + /// <param name="account">The stored account.</param> + /// <returns>The fields a client may see.</returns> private static AccountResponse ToResponse(AccountRecord account) => new(account.AccountName, account.Email, account.CreatedAt); } diff --git a/apps/api/Accounts/AccountNamePolicy.cs b/apps/api/Accounts/AccountNamePolicy.cs index 76e9dcf..3b2328d 100644 --- a/apps/api/Accounts/AccountNamePolicy.cs +++ b/apps/api/Accounts/AccountNamePolicy.cs @@ -6,19 +6,35 @@ using System.Text.RegularExpressions; namespace Progmasoft.Progmaweb.Api.Accounts; +/// <summary>Rules for account names.</summary> +/// <remarks> +/// A name is 8 to 128 ASCII letters or digits and begins with an upper-case letter. Names are unique without regard +/// to case, so two names that differ only in case cannot both exist, while each keeps the spelling its owner chose. +/// The same name is the ViGet publisher name. +/// </remarks> internal static partial class AccountNamePolicy { + /// <summary>Fewest characters an account name may have.</summary> public const int MinimumLength = 8; + /// <summary>Most characters an account name may have.</summary> public const int MaximumLength = 128; + /// <summary>Names nobody may register, in their lookup form.</summary> // These names identify Progmasoft itself and the initial administrative identity. They are provisioned only by // trusted operations tooling, never by the public registration endpoint. private static readonly FrozenSet<string> ReservedNames = new[] { "LEITWOLF", "PROGMASOFT" }.ToFrozenSet(StringComparer.Ordinal); + /// <summary>Matches a name of the allowed length, alphabet and first letter.</summary> + /// <returns>The compiled expression.</returns> [GeneratedRegex("^[A-Z][A-Za-z0-9]{7,127}$", RegexOptions.CultureInvariant)] private static partial Regex CanonicalPattern(); + /// <summary>Validates an account name and returns its canonical spelling.</summary> + /// <param name="value">The name as entered; surrounding white space is ignored.</param> + /// <param name="canonical">The trimmed name. It is meaningful only when the method returns <see langword="true"/>.</param> + /// <param name="error">Message for the user when the name is refused; empty otherwise.</param> + /// <returns><see langword="true"/> when the name satisfies every rule and is not reserved.</returns> public static bool TryNormalize(string? value, out string canonical, out string error) { canonical = value?.Trim() ?? string.Empty; @@ -45,5 +61,8 @@ internal static partial class AccountNamePolicy return true; } + /// <summary>Returns the key under which a name is compared for uniqueness.</summary> + /// <param name="canonical">A name that <see cref="TryNormalize"/> accepted.</param> + /// <returns>The name in upper case.</returns> public static string NormalizeForLookup(string canonical) => canonical.ToUpperInvariant(); } diff --git a/apps/api/Accounts/AccountRecord.cs b/apps/api/Accounts/AccountRecord.cs index 7625168..5e27f3e 100644 --- a/apps/api/Accounts/AccountRecord.cs +++ b/apps/api/Accounts/AccountRecord.cs @@ -3,6 +3,19 @@ namespace Progmasoft.Progmaweb.Api.Accounts; +/// <summary>An account as the store keeps it.</summary> +/// <param name="Id">Stable identifier; sessions refer to the account by it.</param> +/// <param name="AccountName">Account name in its canonical spelling, as shown and used in URLs.</param> +/// <param name="NormalizedAccountName">Upper-case form of the name; the uniqueness key.</param> +/// <param name="Email">Email address as the owner entered it.</param> +/// <param name="NormalizedEmail">Upper-case form of the address; the uniqueness key.</param> +/// <param name="GoogleSubject"> +/// Subject identifier of the linked Google identity; <see langword="null"/> for a password account. +/// </param> +/// <param name="PasswordHash"> +/// ASP.NET Core Identity password hash; <see langword="null"/> for an account that signs in through Google only. +/// </param> +/// <param name="CreatedAt">Moment the account was created, in UTC.</param> internal sealed record AccountRecord( Guid Id, string AccountName, @@ -13,6 +26,11 @@ internal sealed record AccountRecord( string? PasswordHash, DateTimeOffset CreatedAt); +/// <summary>A session as the store keeps it.</summary> +/// <param name="TokenDigest">SHA-256 digest of the session token. The token itself is never stored.</param> +/// <param name="AccountId">Identifier of the account the session belongs to.</param> +/// <param name="CreatedAt">Moment the session was opened, in UTC.</param> +/// <param name="ExpiresAt">Moment the session stops being valid, in UTC.</param> internal sealed record SessionRecord( byte[] TokenDigest, Guid AccountId, diff --git a/apps/api/Accounts/AccountService.cs b/apps/api/Accounts/AccountService.cs index 7168eb6..cd3e711 100644 --- a/apps/api/Accounts/AccountService.cs +++ b/apps/api/Accounts/AccountService.cs @@ -5,10 +5,14 @@ using Microsoft.AspNetCore.Identity; namespace Progmasoft.Progmaweb.Api.Accounts; +/// <summary>Registration and sign-in rules of the account service.</summary> +/// <param name="store">Store that keeps accounts.</param> +/// <param name="timeProvider">Clock used for creation times.</param> internal sealed class AccountService( IAccountStore store, TimeProvider timeProvider) { + /// <summary>An account that never exists; the subject of the dummy verification.</summary> private static readonly AccountRecord DummyAccount = new( Guid.Empty, "Missing00", @@ -18,11 +22,24 @@ internal sealed class AccountService( null, string.Empty, DateTimeOffset.UnixEpoch); + /// <summary> + /// 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. + /// </summary> private static readonly string DummyPasswordHash = new PasswordHasher<AccountRecord>().HashPassword(DummyAccount, "not-the-provided-password"); + /// <summary>Hashes and verifies passwords with the ASP.NET Core Identity algorithm.</summary> private readonly PasswordHasher<AccountRecord> passwordHasher = new(); + /// <summary>Creates a password account and opens a session for it.</summary> + /// <param name="request">The registration body.</param> + /// <param name="sessions">Session service.</param> + /// <param name="cancellationToken">Cancels the operation.</param> + /// <returns> + /// The signed-in account and no errors, or no account and the messages of each refused field, keyed by + /// <c>accountName</c>, <c>email</c> and <c>password</c>. + /// </returns> public async ValueTask<(AuthenticatedAccount? Authentication, Dictionary<string, string[]> Errors)> RegisterAsync( RegisterAccountRequest request, SessionService sessions, @@ -63,6 +80,15 @@ internal sealed class AccountService( return (authentication, errors); } + /// <summary>Signs in with email address and password.</summary> + /// <remarks> + /// An unknown address costs one password verification against a private dummy hash, so the response time does not + /// reveal whether an address is registered. + /// </remarks> + /// <param name="request">The login body.</param> + /// <param name="sessions">Session service.</param> + /// <param name="cancellationToken">Cancels the operation.</param> + /// <returns>The signed-in account, or <see langword="null"/> when the credentials are refused.</returns> public async ValueTask<AuthenticatedAccount?> LoginAsync( LoginAccountRequest request, SessionService sessions, @@ -92,6 +118,18 @@ internal sealed class AccountService( return await sessions.CreateAsync(account, cancellationToken); } + /// <summary>Signs in with a Google identity, creating the account on first use.</summary> + /// <remarks> + /// 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. + /// </remarks> + /// <param name="providerSubject">Subject identifier Google issued for the identity.</param> + /// <param name="providerEmail">Email address Google reported for the identity.</param> + /// <param name="requestedAccountName">Account name to create for a new identity; may be <see langword="null"/>.</param> + /// <param name="sessions">Session service.</param> + /// <param name="cancellationToken">Cancels the operation.</param> + /// <returns>The signed-in account, or no account and the reason.</returns> public async ValueTask<(AuthenticatedAccount? Authentication, GoogleAccountFailure Failure)> AuthenticateGoogleAsync( string? providerSubject, @@ -165,6 +203,12 @@ internal sealed class AccountService( return (await sessions.CreateAsync(account, cancellationToken), GoogleAccountFailure.None); } + /// <summary>Validates every field of a registration and collects the messages of the refused ones.</summary> + /// <param name="request">The registration body.</param> + /// <param name="accountName">The canonical account name.</param> + /// <param name="email">The email address as entered, trimmed.</param> + /// <param name="normalizedEmail">The email address in its lookup form.</param> + /// <returns>Messages keyed by field name; empty when the registration is valid.</returns> private static Dictionary<string, string[]> ValidateRegistration( RegisterAccountRequest request, out string accountName, @@ -192,11 +236,20 @@ internal sealed class AccountService( } } +/// <summary>Reason a sign-in with Google did not produce a session.</summary> internal enum GoogleAccountFailure { + /// <summary>The sign-in succeeded.</summary> None, + /// <summary> + /// The identity cannot be used: Google reported no usable subject or address, or the address belongs to another + /// account. + /// </summary> ProviderRejected, + /// <summary>The identity is new and no account name was supplied.</summary> AccountNameRequired, + /// <summary>The supplied account name breaks <see cref="AccountNamePolicy"/>.</summary> InvalidAccountName, + /// <summary>The supplied account name is taken.</summary> AccountNameUnavailable } diff --git a/apps/api/Accounts/EmailAddressPolicy.cs b/apps/api/Accounts/EmailAddressPolicy.cs index 9710b0c..898d07c 100644 --- a/apps/api/Accounts/EmailAddressPolicy.cs +++ b/apps/api/Accounts/EmailAddressPolicy.cs @@ -5,10 +5,22 @@ using System.Net.Mail; namespace Progmasoft.Progmaweb.Api.Accounts; +/// <summary>Rules for email addresses.</summary> +/// <remarks> +/// An address is accepted when it parses as a single mailbox without a display name and is at most 254 characters +/// long. Ownership of the address is not verified. +/// </remarks> internal static class EmailAddressPolicy { + /// <summary>Most characters an email address may have.</summary> public const int MaximumLength = 254; + /// <summary>Validates an email address and returns its stored and lookup forms.</summary> + /// <param name="value">The address as entered; surrounding white space is ignored.</param> + /// <param name="canonical">The trimmed address, kept as the owner wrote it.</param> + /// <param name="normalized">The address in upper case; the uniqueness key. Empty when the address is refused.</param> + /// <param name="error">Message for the user when the address is refused; empty otherwise.</param> + /// <returns><see langword="true"/> when the address is a single valid mailbox.</returns> public static bool TryNormalize(string? value, out string canonical, out string normalized, out string error) { canonical = value?.Trim() ?? string.Empty; diff --git a/apps/api/Accounts/IAccountStore.cs b/apps/api/Accounts/IAccountStore.cs index 6bb2955..cef8bee 100644 --- a/apps/api/Accounts/IAccountStore.cs +++ b/apps/api/Accounts/IAccountStore.cs @@ -3,14 +3,47 @@ namespace Progmasoft.Progmaweb.Api.Accounts; +/// <summary>Persistence of accounts and sessions.</summary> +/// <remarks> +/// The store owns the uniqueness rules: account name, email address and Google identity are each unique, and a +/// creation that would break one of them is refused atomically. It offers no enumeration and no bulk deletion. +/// </remarks> internal interface IAccountStore { + /// <summary>Stores a new account unless its name, email address or Google identity is taken.</summary> + /// <param name="account">The account to store, with its normalized keys filled in.</param> + /// <param name="cancellationToken">Cancels the operation.</param> + /// <returns>The stored account, or the uniqueness rule that refused it.</returns> ValueTask<CreateAccountResult> CreateAsync(AccountRecord account, CancellationToken cancellationToken); + /// <summary>Finds an account by its normalized email address.</summary> + /// <param name="normalizedEmail">Upper-case email address, as <see cref="EmailAddressPolicy"/> produces it.</param> + /// <param name="cancellationToken">Cancels the operation.</param> + /// <returns>The account, or <see langword="null"/> when no account has this address.</returns> ValueTask<AccountRecord?> FindByEmailAsync(string normalizedEmail, CancellationToken cancellationToken); + /// <summary>Finds the account linked to a Google identity.</summary> + /// <param name="googleSubject">Subject identifier Google issued for the identity.</param> + /// <param name="cancellationToken">Cancels the operation.</param> + /// <returns>The account, or <see langword="null"/> when the identity is not linked.</returns> ValueTask<AccountRecord?> FindByGoogleSubjectAsync(string googleSubject, CancellationToken cancellationToken); + /// <summary>Finds an account by its identifier.</summary> + /// <param name="id">Identifier of the account.</param> + /// <param name="cancellationToken">Cancels the operation.</param> + /// <returns>The account, or <see langword="null"/> when it does not exist.</returns> ValueTask<AccountRecord?> FindByIdAsync(Guid id, CancellationToken cancellationToken); + /// <summary>Stores a session, replacing one with the same token digest.</summary> + /// <param name="session">The session to store.</param> + /// <param name="cancellationToken">Cancels the operation.</param> + /// <returns>A task that completes when the session is stored.</returns> ValueTask StoreSessionAsync(SessionRecord session, CancellationToken cancellationToken); + /// <summary>Finds a session by the digest of its token.</summary> + /// <param name="tokenDigest">SHA-256 digest of the session token.</param> + /// <param name="cancellationToken">Cancels the operation.</param> + /// <returns>The session, or <see langword="null"/> when it does not exist or was revoked.</returns> ValueTask<SessionRecord?> FindSessionAsync(byte[] tokenDigest, CancellationToken cancellationToken); + /// <summary>Removes a session. Revoking an unknown session is not an error.</summary> + /// <param name="tokenDigest">SHA-256 digest of the session token.</param> + /// <param name="cancellationToken">Cancels the operation.</param> + /// <returns>A task that completes when the session is gone.</returns> ValueTask RevokeSessionAsync(byte[] tokenDigest, CancellationToken cancellationToken); } diff --git a/apps/api/Accounts/InMemoryAccountStore.cs b/apps/api/Accounts/InMemoryAccountStore.cs index 9f183e7..7a6930f 100644 --- a/apps/api/Accounts/InMemoryAccountStore.cs +++ b/apps/api/Accounts/InMemoryAccountStore.cs @@ -5,15 +5,27 @@ namespace Progmasoft.Progmaweb.Api.Accounts; // This development store models the uniqueness and session semantics required by the future PostgreSQL implementation. // It deliberately exposes no enumeration or bulk-delete surface to public endpoints. +/// <summary>An <see cref="IAccountStore"/> that keeps everything in the memory of the process.</summary> +/// <remarks> +/// It implements the uniqueness and session rules a durable store must keep, under one lock. Nothing survives a +/// restart of the service: every account and every session is lost when the process stops. +/// </remarks> internal sealed class InMemoryAccountStore : IAccountStore { + /// <summary>Guards every dictionary below; each operation holds it for its whole duration.</summary> private readonly Lock gate = new(); + /// <summary>Every account, by identifier.</summary> private readonly Dictionary<Guid, AccountRecord> accountsById = []; + /// <summary>Account identifiers by normalized email address; the uniqueness index of addresses.</summary> private readonly Dictionary<string, Guid> accountIdsByEmail = new(StringComparer.Ordinal); + /// <summary>Account identifiers by Google subject; the uniqueness index of linked identities.</summary> private readonly Dictionary<string, Guid> accountIdsByGoogleSubject = new(StringComparer.Ordinal); + /// <summary>Account identifiers by normalized account name; the uniqueness index of names.</summary> private readonly Dictionary<string, Guid> accountIdsByName = new(StringComparer.Ordinal); + /// <summary>Sessions by the hexadecimal form of their token digest.</summary> private readonly Dictionary<string, SessionRecord> sessionsByDigest = new(StringComparer.Ordinal); + /// <inheritdoc/> public ValueTask<CreateAccountResult> CreateAsync(AccountRecord account, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); @@ -46,6 +58,7 @@ internal sealed class InMemoryAccountStore : IAccountStore } } + /// <inheritdoc/> public ValueTask<AccountRecord?> FindByGoogleSubjectAsync( string googleSubject, CancellationToken cancellationToken) @@ -58,6 +71,7 @@ internal sealed class InMemoryAccountStore : IAccountStore } } + /// <inheritdoc/> public ValueTask<AccountRecord?> FindByEmailAsync(string normalizedEmail, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); @@ -68,6 +82,7 @@ internal sealed class InMemoryAccountStore : IAccountStore } } + /// <inheritdoc/> public ValueTask<AccountRecord?> FindByIdAsync(Guid id, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); @@ -78,6 +93,7 @@ internal sealed class InMemoryAccountStore : IAccountStore } } + /// <inheritdoc/> public ValueTask StoreSessionAsync(SessionRecord session, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); @@ -88,6 +104,7 @@ internal sealed class InMemoryAccountStore : IAccountStore } } + /// <inheritdoc/> public ValueTask<SessionRecord?> FindSessionAsync(byte[] tokenDigest, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); @@ -98,6 +115,7 @@ internal sealed class InMemoryAccountStore : IAccountStore } } + /// <inheritdoc/> public ValueTask RevokeSessionAsync(byte[] tokenDigest, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); diff --git a/apps/api/Accounts/PasswordPolicy.cs b/apps/api/Accounts/PasswordPolicy.cs index baf8c75..6a5bf38 100644 --- a/apps/api/Accounts/PasswordPolicy.cs +++ b/apps/api/Accounts/PasswordPolicy.cs @@ -3,11 +3,22 @@ namespace Progmasoft.Progmaweb.Api.Accounts; +/// <summary>Rules for passwords.</summary> +/// <rem |