diff options
| author | Leitwolf | 2026-11-07 11:43:30 +0300 |
|---|---|---|
| committer | GitHub | 2026-11-07 11:43:30 +0300 |
| commit | d28c8c216708c3bb59773722e6235c4c8cbb2b73 (patch) | |
| tree | 00101b2c540df4f683a0aae890f8366f473f2be4 /apps/api | |
| parent | 097fb096966117fe259bc2cd721527bbec04e57d (diff) | |
| parent | 5379c74171c0ef0d08c999ce93e2a03c37d25889 (diff) | |
| download | progmaweb-d28c8c216708c3bb59773722e6235c4c8cbb2b73.tar.gz | |
Merge pull request #58 from Progmasoft/feature/policies-consent
Ask for the Privacy and Security Policies at registration
Diffstat (limited to 'apps/api')
| -rw-r--r-- | apps/api/Accounts/AccountContracts.cs | 10 | ||||
| -rw-r--r-- | apps/api/Accounts/AccountEndpoints.cs | 18 | ||||
| -rw-r--r-- | apps/api/Accounts/AccountRecord.cs | 9 | ||||
| -rw-r--r-- | apps/api/Accounts/AccountSchema.cs | 11 | ||||
| -rw-r--r-- | apps/api/Accounts/AccountService.cs | 31 | ||||
| -rw-r--r-- | apps/api/Accounts/PolicyAcceptance.cs | 20 | ||||
| -rw-r--r-- | apps/api/Accounts/PostgresAccountStore.cs | 17 |
7 files changed, 104 insertions, 12 deletions
diff --git a/apps/api/Accounts/AccountContracts.cs b/apps/api/Accounts/AccountContracts.cs index 4a803a0..c33db6c 100644 --- a/apps/api/Accounts/AccountContracts.cs +++ b/apps/api/Accounts/AccountContracts.cs @@ -11,7 +11,15 @@ namespace Progmasoft.Progmaweb.Api.Accounts; /// <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); +/// <param name="AcceptPolicies"> +/// Whether the person accepted the Privacy Policy and the Security Policy; anything but <see langword="true"/> is +/// refused. See <see cref="PolicyAcceptance"/>. +/// </param> +public sealed record RegisterAccountRequest( + string? AccountName, + string? Email, + string? Password, + bool? AcceptPolicies = null); /// <summary>Body of <c>POST /api/v1/accounts/login</c>.</summary> /// <param name="Email">Email address the account was registered with.</param> diff --git a/apps/api/Accounts/AccountEndpoints.cs b/apps/api/Accounts/AccountEndpoints.cs index f15b1be..72d0bbc 100644 --- a/apps/api/Accounts/AccountEndpoints.cs +++ b/apps/api/Accounts/AccountEndpoints.cs @@ -48,12 +48,15 @@ internal static class AccountEndpoints /// <param name="accountName"> /// Account name to create when the Google identity is new; omitted when signing in to an existing account. /// </param> + /// <param name="acceptPolicies"> + /// Whether the person accepted the Privacy Policy and the Security Policy; required with an account name. + /// </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) + private static IResult StartGoogleAsync(string? accountName, bool? acceptPolicies, IConfiguration configuration) { if (string.IsNullOrWhiteSpace(configuration["Authentication:Google:ClientId"]) || string.IsNullOrWhiteSpace(configuration["Authentication:Google:ClientSecret"])) @@ -70,6 +73,13 @@ internal static class AccountEndpoints return Results.Redirect("/register?google=invalid-account-name"); } + // A registration leaves for Google only after the person accepted the policies, so a refusal is shown on + // the form rather than after the round trip. + if (normalized is not null && acceptPolicies is not true) + { + return Results.Redirect("/register?google=policies-required"); + } + AuthenticationProperties properties = new() { RedirectUri = "/api/v1/accounts/oauth/google/complete" @@ -77,6 +87,7 @@ internal static class AccountEndpoints if (normalized is not null) { properties.Items["accountName"] = normalized; + properties.Items["policies"] = PolicyAcceptance.CurrentVersion; } return Results.Challenge(properties, [GoogleDefaults.AuthenticationScheme]); @@ -104,8 +115,10 @@ internal static class AccountEndpoints string? subject = external.Principal.FindFirstValue(ClaimTypes.NameIdentifier); string? accountName = null; external.Properties?.Items.TryGetValue("accountName", out accountName); + string? policies = null; + external.Properties?.Items.TryGetValue("policies", out policies); (AuthenticatedAccount? authentication, GoogleAccountFailure failure) = - await accounts.AuthenticateGoogleAsync(subject, email, accountName, sessions, cancellationToken); + await accounts.AuthenticateGoogleAsync(subject, email, accountName, policies, sessions, cancellationToken); await context.SignOutAsync(ExternalCookieScheme); if (authentication is null) @@ -115,6 +128,7 @@ internal static class AccountEndpoints GoogleAccountFailure.AccountNameRequired => "/register?google=account-name-required", GoogleAccountFailure.InvalidAccountName => "/register?google=invalid-account-name", GoogleAccountFailure.AccountNameUnavailable => "/register?google=account-name-unavailable", + GoogleAccountFailure.PoliciesRequired => "/register?google=policies-required", _ => "/login?google=failed" }; return Results.Redirect(destination); diff --git a/apps/api/Accounts/AccountRecord.cs b/apps/api/Accounts/AccountRecord.cs index 5e27f3e..4139043 100644 --- a/apps/api/Accounts/AccountRecord.cs +++ b/apps/api/Accounts/AccountRecord.cs @@ -16,6 +16,11 @@ namespace Progmasoft.Progmaweb.Api.Accounts; /// 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> +/// <param name="PoliciesVersion"> +/// Version of the policies the owner accepted, <see cref="PolicyAcceptance.CurrentVersion"/> at the time; +/// <see langword="null"/> for an account created before registration asked for it, or by operations tooling. +/// </param> +/// <param name="PoliciesAcceptedAt">Moment the owner accepted them, in UTC; null when the version is.</param> internal sealed record AccountRecord( Guid Id, string AccountName, @@ -24,7 +29,9 @@ internal sealed record AccountRecord( string NormalizedEmail, string? GoogleSubject, string? PasswordHash, - DateTimeOffset CreatedAt); + DateTimeOffset CreatedAt, + string? PoliciesVersion = null, + DateTimeOffset? PoliciesAcceptedAt = null); /// <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> diff --git a/apps/api/Accounts/AccountSchema.cs b/apps/api/Accounts/AccountSchema.cs index 3fc3707..eda80c0 100644 --- a/apps/api/Accounts/AccountSchema.cs +++ b/apps/api/Accounts/AccountSchema.cs @@ -53,6 +53,17 @@ internal static class AccountSchema CREATE INDEX sessions_expires_at_idx ON sessions (expires_at); CREATE INDEX sessions_account_id_idx ON sessions (account_id); + """, + // 2: the policies an account accepted at registration. + // + // Accounts created before step 2 accepted none, and keep null in both columns; the constraint keeps the + // version and the moment together. + """ + ALTER TABLE accounts + ADD COLUMN policies_version text, + ADD COLUMN policies_accepted_at timestamptz, + ADD CONSTRAINT accounts_policies_check + CHECK ((policies_version IS NULL) = (policies_accepted_at IS NULL)); """ ]; diff --git a/apps/api/Accounts/AccountService.cs b/apps/api/Accounts/AccountService.cs index 40e971d..996e222 100644 --- a/apps/api/Accounts/AccountService.cs +++ b/apps/api/Accounts/AccountService.cs @@ -38,7 +38,7 @@ internal sealed class AccountService( /// <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>. + /// <c>accountName</c>, <c>email</c>, <c>password</c> and <c>acceptPolicies</c>. /// </returns> public async ValueTask<(AuthenticatedAccount? Authentication, Dictionary<string, string[]> Errors)> RegisterAsync( RegisterAccountRequest request, @@ -52,6 +52,7 @@ internal sealed class AccountService( return (null, errors); } + DateTimeOffset now = timeProvider.GetUtcNow(); AccountRecord provisional = new( Guid.NewGuid(), accountName, @@ -60,7 +61,9 @@ internal sealed class AccountService( normalizedEmail, null, string.Empty, - timeProvider.GetUtcNow()); + now, + PolicyAcceptance.CurrentVersion, + now); string passwordHash = passwordHasher.HashPassword(provisional, request.Password!); AccountRecord account = provisional with { PasswordHash = passwordHash }; @@ -148,6 +151,10 @@ internal sealed class AccountService( /// <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="acceptedPolicies"> + /// Version of the policies the person accepted before leaving for Google; a new identity creates an account only + /// when it is <see cref="PolicyAcceptance.CurrentVersion"/>. + /// </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> @@ -156,6 +163,7 @@ internal sealed class AccountService( string? providerSubject, string? providerEmail, string? requestedAccountName, + string? acceptedPolicies, SessionService sessions, CancellationToken cancellationToken) { @@ -191,6 +199,12 @@ internal sealed class AccountService( return (null, GoogleAccountFailure.InvalidAccountName); } + if (acceptedPolicies != PolicyAcceptance.CurrentVersion) + { + return (null, GoogleAccountFailure.PoliciesRequired); + } + + DateTimeOffset now = timeProvider.GetUtcNow(); AccountRecord account = new( Guid.NewGuid(), accountName, @@ -199,7 +213,9 @@ internal sealed class AccountService( normalizedEmail, providerSubject, null, - timeProvider.GetUtcNow()); + now, + PolicyAcceptance.CurrentVersion, + now); CreateAccountResult result = await store.CreateAsync(account, cancellationToken); if (result.Status is CreateAccountStatus.AccountNameUnavailable) @@ -302,6 +318,11 @@ internal sealed class AccountService( errors["password"] = [passwordError]; } + if (request.AcceptPolicies is not true) + { + errors["acceptPolicies"] = [PolicyAcceptance.RequiredMessage]; + } + return errors; } } @@ -321,5 +342,7 @@ internal enum GoogleAccountFailure /// <summary>The supplied account name breaks <see cref="AccountNamePolicy"/>.</summary> InvalidAccountName, /// <summary>The supplied account name is taken.</summary> - AccountNameUnavailable + AccountNameUnavailable, + /// <summary>The identity is new and the person did not accept the current policies.</summary> + PoliciesRequired } diff --git a/apps/api/Accounts/PolicyAcceptance.cs b/apps/api/Accounts/PolicyAcceptance.cs new file mode 100644 index 0000000..f31760d --- /dev/null +++ b/apps/api/Accounts/PolicyAcceptance.cs @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com> +// SPDX-License-Identifier: AGPL-3.0-or-later WITH AdditionRef-Progmasoft-Patent-Grant-1.1 + +namespace Progmasoft.Progmaweb.Api.Accounts; + +/// <summary>The Privacy Policy and Security Policy a new account accepts.</summary> +/// <remarks> +/// Registration, with a password or through Google, creates an account only after the person accepted both policies. +/// The account keeps the version it accepted and the moment, so a later version of the policies can tell who has +/// accepted it. The text lives on the <c>/privacy</c> and <c>/security</c> pages of the web application, which name +/// the same version. +/// </remarks> +internal static class PolicyAcceptance +{ + /// <summary>The version of the policies, the date they took effect.</summary> + public const string CurrentVersion = "2026-11-06"; + + /// <summary>The message of a registration that did not accept the policies.</summary> + public const string RequiredMessage = "Accept the Privacy Policy and the Security Policy to create an account."; +} diff --git a/apps/api/Accounts/PostgresAccountStore.cs b/apps/api/Accounts/PostgresAccountStore.cs index 3a3ea82..9f93701 100644 --- a/apps/api/Accounts/PostgresAccountStore.cs +++ b/apps/api/Accounts/PostgresAccountStore.cs @@ -25,19 +25,24 @@ internal sealed class PostgresAccountStore(NpgsqlDataSource dataSource) : IAccou /// <summary>The columns of an account, in the order <see cref="ReadAccount"/> reads them.</summary> private const string AccountColumns = - "id, account_name, normalized_account_name, email, normalized_email, google_subject, password_hash, created_at"; + "id, account_name, normalized_account_name, email, normalized_email, google_subject, password_hash, created_at, " + + "policies_version, policies_accepted_at"; /// <inheritdoc/> public async ValueTask<CreateAccountResult> CreateAsync(AccountRecord account, CancellationToken cancellationToken) { - AccountRecord stored = account with { CreatedAt = ToStoredPrecision(account.CreatedAt) }; + AccountRecord stored = account with + { + CreatedAt = ToStoredPrecision(account.CreatedAt), + PoliciesAcceptedAt = account.PoliciesAcceptedAt is { } accepted ? ToStoredPrecision(accepted) : null + }; for (int attempt = 0; attempt < CreateAttempts; attempt++) { // A row that breaks a unique index is skipped instead of raising an error, so that a taken name is an // ordinary answer and the transaction log holds no failed statement for it. await using (NpgsqlCommand insert = dataSource.CreateCommand( - $"INSERT INTO accounts ({AccountColumns}) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) ON CONFLICT DO NOTHING")) + $"INSERT INTO accounts ({AccountColumns}) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) ON CONFLICT DO NOTHING")) { insert.Parameters.AddWithValue(stored.Id); insert.Parameters.AddWithValue(stored.AccountName); @@ -47,6 +52,8 @@ internal sealed class PostgresAccountStore(NpgsqlDataSource dataSource) : IAccou insert.Parameters.AddWithValue(NullableText(stored.GoogleSubject)); insert.Parameters.AddWithValue(NullableText(stored.PasswordHash)); insert.Parameters.AddWithValue(stored.CreatedAt); + insert.Parameters.AddWithValue(NullableText(stored.PoliciesVersion)); + insert.Parameters.Add(new NpgsqlParameter<DateTimeOffset?> { TypedValue = stored.PoliciesAcceptedAt }); if (await insert.ExecuteNonQueryAsync(cancellationToken) == 1) { return CreateAccountResult.Created(stored); @@ -188,7 +195,9 @@ internal sealed class PostgresAccountStore(NpgsqlDataSource dataSource) : IAccou reader.GetString(4), reader.IsDBNull(5) ? null : reader.GetString(5), reader.IsDBNull(6) ? null : reader.GetString(6), - reader.GetFieldValue<DateTimeOffset>(7)); + reader.GetFieldValue<DateTimeOffset>(7), + reader.IsDBNull(8) ? null : reader.GetString(8), + reader.IsDBNull(9) ? null : reader.GetFieldValue<DateTimeOffset>(9)); /// <summary>Converts an optional text to a parameter value.</summary> /// <param name="value">The text, or <see langword="null"/>.</param> |