diff options
| author | Leitwolf11 | 2026-11-06 12:57:27 +0300 |
|---|---|---|
| committer | Leitwolf11 | 2026-11-06 12:57:27 +0300 |
| commit | 53fb8b8bde857025d12655ae53857fc30226b186 (patch) | |
| tree | d0963feb1dddeedbd0980eb242dcd7e3e7c923e6 /apps/api | |
| parent | 8f18eba55a8980141bd7b989378cb557ae927e2e (diff) | |
| download | progmaweb-53fb8b8bde857025d12655ae53857fc30226b186.tar.gz | |
Ask for the Privacy and Security Policies at registration
progmasoft.com/privacy and progmasoft.com/security publish the two
policies in en-US, de-DE, ru-RU and he-IL, and the footer links both.
The Privacy Policy says what an account keeps and that Progmasoft keeps
an IP address for one purpose only: with a ViGet upload, to establish
where an upload that turns out to be malicious software came from and
to give it to the competent authorities. It says that no data, the IP
address included, is sold, rented or traded. The Security Policy covers
passwords, sessions, HTTPS, the ban on malicious software in ViGet and
how to report a weakness.
The registration form asks the person to accept both policies, and the
API refuses a registration without that acceptance, with a password
(acceptPolicies, a validation error of that field) or through Google:
the start of a Google registration without acceptPolicies=true goes
back to /register?google=policies-required, and the completion creates
no account unless the authentication state carries the current version.
An account keeps the version it accepted and the moment, in two new
columns that schema step 2 adds; accounts created before it keep null.
PolicyAcceptance.CurrentVersion and policyVersion on the web side name
the same date, and a web test reads the C# constant to keep them equal.
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> |