Read-only mirrors. Development, issues and pull requests are on GitHub.

summaryrefslogtreecommitdiffhomepage
path: root/apps/api
diff options
context:
space:
mode:
authorLeitwolf2026-11-07 11:43:30 +0300
committerGitHub2026-11-07 11:43:30 +0300
commitd28c8c216708c3bb59773722e6235c4c8cbb2b73 (patch)
tree00101b2c540df4f683a0aae890f8366f473f2be4 /apps/api
parent097fb096966117fe259bc2cd721527bbec04e57d (diff)
parent5379c74171c0ef0d08c999ce93e2a03c37d25889 (diff)
downloadprogmaweb-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.cs10
-rw-r--r--apps/api/Accounts/AccountEndpoints.cs18
-rw-r--r--apps/api/Accounts/AccountRecord.cs9
-rw-r--r--apps/api/Accounts/AccountSchema.cs11
-rw-r--r--apps/api/Accounts/AccountService.cs31
-rw-r--r--apps/api/Accounts/PolicyAcceptance.cs20
-rw-r--r--apps/api/Accounts/PostgresAccountStore.cs17
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>