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

summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorLeitwolf2026-11-07 11:43:30 +0300
committerGitHub2026-11-07 11:43:30 +0300
commitd28c8c216708c3bb59773722e6235c4c8cbb2b73 (patch)
tree00101b2c540df4f683a0aae890f8366f473f2be4
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
-rw-r--r--apps/api-tests/AccountEndpointTests.cs58
-rw-r--r--apps/api-tests/AccountServiceTests.cs64
-rw-r--r--apps/api-tests/AccountStoreContractTests.cs20
-rw-r--r--apps/api-tests/AccountStoreSelectionTests.cs9
-rw-r--r--apps/api-tests/OAuthTestClient.cs2
-rw-r--r--apps/api-tests/SessionLifetimeTests.cs2
-rw-r--r--apps/api-tests/UnconfiguredGoogleTests.cs2
-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
-rw-r--r--apps/web/app/globals.css17
-rw-r--r--apps/web/app/privacy/layout.tsx11
-rw-r--r--apps/web/app/privacy/page.tsx13
-rw-r--r--apps/web/app/register/page.tsx1
-rw-r--r--apps/web/app/security/layout.tsx11
-rw-r--r--apps/web/app/security/page.tsx13
-rw-r--r--apps/web/app/site.css32
-rw-r--r--apps/web/app/sitemap.ts10
-rw-r--r--apps/web/components/AuthForm.tsx34
-rw-r--r--apps/web/components/Footer.tsx5
-rw-r--r--apps/web/components/PolicyPage.tsx76
-rw-r--r--apps/web/lib/localization.ts36
-rw-r--r--apps/web/lib/policies.ts43
-rw-r--r--apps/web/lib/policies/de-DE.ts116
-rw-r--r--apps/web/lib/policies/en-US.ts116
-rw-r--r--apps/web/lib/policies/he-IL.ts115
-rw-r--r--apps/web/lib/policies/ru-RU.ts116
-rw-r--r--apps/web/lib/policy-messages.ts21
-rw-r--r--apps/web/tests/policies.test.mjs77
-rw-r--r--ops/README.md55
34 files changed, 1166 insertions, 25 deletions
diff --git a/apps/api-tests/AccountEndpointTests.cs b/apps/api-tests/AccountEndpointTests.cs
index c5c305e..50d43d5 100644
--- a/apps/api-tests/AccountEndpointTests.cs
+++ b/apps/api-tests/AccountEndpointTests.cs
@@ -22,7 +22,7 @@ public sealed class AccountEndpointTests
using HttpClient client = factory.CreateAccountClient(handleCookies: false);
HttpResponseMessage response = await client.GetAsync(
- "/api/v1/accounts/oauth/google/start?accountName=GoogleUser",
+ "/api/v1/accounts/oauth/google/start?accountName=GoogleUser&acceptPolicies=true",
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
@@ -62,6 +62,47 @@ public sealed class AccountEndpointTests
Assert.Equal("/register?google=invalid-account-name", response.Headers.Location?.OriginalString);
}
+ [Theory]
+ [InlineData("/api/v1/accounts/oauth/google/start?accountName=GoogleUser")]
+ [InlineData("/api/v1/accounts/oauth/google/start?accountName=GoogleUser&acceptPolicies=false")]
+ public async Task GoogleRegistrationWithoutThePoliciesStaysOnTheSite(string start)
+ {
+ await using AccountApiFactory factory = new();
+ using HttpClient client = factory.CreateAccountClient(handleCookies: false);
+
+ HttpResponseMessage response = await client.GetAsync(start, TestContext.Current.CancellationToken);
+
+ Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
+ Assert.Equal("/register?google=policies-required", response.Headers.Location?.OriginalString);
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData(false)]
+ public async Task RegistrationWithoutThePoliciesIsRefused(bool? acceptPolicies)
+ {
+ await using AccountApiFactory factory = new();
+ using HttpClient client = factory.CreateAccountClient();
+
+ HttpResponseMessage response = await client.PostAsJsonAsync("/api/v1/accounts/register", new
+ {
+ accountName = "PolicyUser",
+ email = "policy@example.com",
+ password = "a-long-development-password",
+ acceptPolicies
+ }, TestContext.Current.CancellationToken);
+
+ Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
+ using JsonDocument problem = JsonDocument.Parse(
+ await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken));
+ JsonElement messages = problem.RootElement.GetProperty("errors").GetProperty("acceptPolicies");
+ Assert.Equal([PolicyAcceptance.RequiredMessage], messages.EnumerateArray().Select(message => message.GetString()));
+
+ // The refusal kept neither the name nor the address.
+ HttpResponseMessage accepted = await RegisterAsync(client, "PolicyUser", "policy@example.com");
+ Assert.Equal(HttpStatusCode.Created, accepted.StatusCode);
+ }
+
[Fact]
public async Task GoogleCompleteDoesNotIssueASessionForAnExistingPasswordAccountWithTheSameEmail()
{
@@ -109,7 +150,8 @@ public sealed class AccountEndpointTests
{
accountName = "PrimaryUser",
email = "leitwolf1112@gmail.com",
- password = "a-long-development-password"
+ password = "a-long-development-password",
+ acceptPolicies = true
}, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Created, registration.StatusCode);
@@ -139,7 +181,8 @@ public sealed class AccountEndpointTests
{
accountName = "LogoutUser",
email = "logout@example.com",
- password = "a-long-development-password"
+ password = "a-long-development-password",
+ acceptPolicies = true
}, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Created, registration.StatusCode);
@@ -165,7 +208,8 @@ public sealed class AccountEndpointTests
{
accountName,
email = $"{Guid.NewGuid():N}@example.com",
- password = "a-long-development-password"
+ password = "a-long-development-password",
+ acceptPolicies = true
}, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
@@ -229,7 +273,8 @@ public sealed class AccountEndpointTests
HttpResponseMessage missing = await client.PostAsJsonAsync("/api/v1/accounts/login", new
{
email = "missing@example.com",
- password = "a-long-development-password"
+ password = "a-long-development-password",
+ acceptPolicies = true
}, TestContext.Current.CancellationToken);
HttpResponseMessage wrong = await client.PostAsJsonAsync("/api/v1/accounts/login", new
{
@@ -284,6 +329,7 @@ public sealed class AccountEndpointTests
{
accountName,
email,
- password = "a-long-development-password"
+ password = "a-long-development-password",
+ acceptPolicies = true
}, TestContext.Current.CancellationToken);
}
diff --git a/apps/api-tests/AccountServiceTests.cs b/apps/api-tests/AccountServiceTests.cs
index 0ff7b33..27863bf 100644
--- a/apps/api-tests/AccountServiceTests.cs
+++ b/apps/api-tests/AccountServiceTests.cs
@@ -23,6 +23,7 @@ public sealed class AccountServiceTests
null,
"google@example.com",
"GoogleUser",
+ PolicyAcceptance.CurrentVersion,
sessions,
TestContext.Current.CancellationToken);
@@ -44,7 +45,8 @@ public sealed class AccountServiceTests
new RegisterAccountRequest(
"PasswordUser",
"unverified@example.com",
- "a-long-development-password"),
+ "a-long-development-password",
+ true),
sessions,
TestContext.Current.CancellationToken);
Assert.NotNull(registration);
@@ -54,6 +56,7 @@ public sealed class AccountServiceTests
"google-subject-victim",
"unverified@example.com",
null,
+ null,
sessions,
TestContext.Current.CancellationToken);
@@ -85,6 +88,7 @@ public sealed class AccountServiceTests
"google-subject-race",
"race@example.com",
"GoogleUser",
+ PolicyAcceptance.CurrentVersion,
sessions,
TestContext.Current.CancellationToken);
@@ -107,6 +111,7 @@ public sealed class AccountServiceTests
"google-subject-stable",
"google@example.com",
"GoogleUser",
+ PolicyAcceptance.CurrentVersion,
sessions,
TestContext.Current.CancellationToken);
@@ -115,6 +120,7 @@ public sealed class AccountServiceTests
"google-subject-stable",
"changed@example.com",
null,
+ null,
sessions,
TestContext.Current.CancellationToken);
@@ -125,6 +131,62 @@ public sealed class AccountServiceTests
Assert.Equal(first.Account.Id, second.Account.Id);
}
+ [Fact]
+ public async Task RegistrationKeepsTheVersionOfThePoliciesAndTheMoment()
+ {
+ InMemoryAccountStore store = new();
+ AccountService accounts = new(store, TimeProvider.System);
+ SessionService sessions = new(
+ store,
+ TimeProvider.System,
+ Options.Create(new AccountSessionOptions()));
+
+ (AuthenticatedAccount? password, _) = await accounts.RegisterAsync(
+ new RegisterAccountRequest("PasswordUser", "password@example.com", "a-long-development-password", true),
+ sessions,
+ TestContext.Current.CancellationToken);
+ (AuthenticatedAccount? google, _) = await accounts.AuthenticateGoogleAsync(
+ "google-subject-policies",
+ "google@example.com",
+ "GoogleUser",
+ PolicyAcceptance.CurrentVersion,
+ sessions,
+ TestContext.Current.CancellationToken);
+
+ foreach (AuthenticatedAccount? authentication in new[] { password, google })
+ {
+ Assert.NotNull(authentication);
+ Assert.Equal(PolicyAcceptance.CurrentVersion, authentication.Account.PoliciesVersion);
+ Assert.Equal(authentication.Account.CreatedAt, authentication.Account.PoliciesAcceptedAt);
+ }
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("2020-01-01")]
+ public async Task GoogleAuthenticationCreatesNoAccountWithoutTheCurrentPolicies(string? policies)
+ {
+ InMemoryAccountStore store = new();
+ AccountService accounts = new(store, TimeProvider.System);
+ SessionService sessions = new(
+ store,
+ TimeProvider.System,
+ Options.Create(new AccountSessionOptions()));
+
+ (AuthenticatedAccount? authentication, GoogleAccountFailure failure) =
+ await accounts.AuthenticateGoogleAsync(
+ "google-subject-new",
+ "new@example.com",
+ "GoogleUser",
+ policies,
+ sessions,
+ TestContext.Current.CancellationToken);
+
+ Assert.Null(authentication);
+ Assert.Equal(GoogleAccountFailure.PoliciesRequired, failure);
+ Assert.Null(await store.FindByGoogleSubjectAsync("google-subject-new", TestContext.Current.CancellationToken));
+ }
+
private sealed class ConcurrentPasswordRegistrationStore(AccountRecord passwordAccount) : IAccountStore
{
private bool createAttempted;
diff --git a/apps/api-tests/AccountStoreContractTests.cs b/apps/api-tests/AccountStoreContractTests.cs
index 2266273..f9f36be 100644
--- a/apps/api-tests/AccountStoreContractTests.cs
+++ b/apps/api-tests/AccountStoreContractTests.cs
@@ -65,6 +65,26 @@ public abstract class AccountStoreContractTests
Assert.Equal("AQAAAA-hash", found.PasswordHash);
Assert.Equal("Grace", found.AccountName);
Assert.Equal("grace@example.com", found.Email);
+ Assert.Null(found.PoliciesVersion);
+ Assert.Null(found.PoliciesAcceptedAt);
+ }
+
+ [Fact]
+ public async Task AnAccountKeepsThePoliciesItAccepted()
+ {
+ IAccountStore store = await CreateStoreAsync();
+ AccountRecord account = Account("Hopper", "hopper@example.com") with
+ {
+ PoliciesVersion = PolicyAcceptance.CurrentVersion,
+ PoliciesAcceptedAt = Moment
+ };
+
+ AccountRecord stored = Assert.IsType<AccountRecord>((await store.CreateAsync(account, Cancellation)).Account);
+
+ AccountRecord found = Assert.IsType<AccountRecord>(await store.FindByIdAsync(account.Id, Cancellation));
+ Assert.Equal(stored, found);
+ Assert.Equal(PolicyAcceptance.CurrentVersion, found.PoliciesVersion);
+ Assert.True((Moment - found.PoliciesAcceptedAt!.Value).Duration() < TimeSpan.FromMicroseconds(1));
}
[Fact]
diff --git a/apps/api-tests/AccountStoreSelectionTests.cs b/apps/api-tests/AccountStoreSelectionTests.cs
index 1087575..f1153dd 100644
--- a/apps/api-tests/AccountStoreSelectionTests.cs
+++ b/apps/api-tests/AccountStoreSelectionTests.cs
@@ -223,7 +223,8 @@ public sealed class DurableAccountApiTests
{
accountName = "Survivor",
email = "survivor@example.com",
- password = "a-long-development-password"
+ password = "a-long-development-password",
+ acceptPolicies = true
}, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Created, registration.StatusCode);
cookie = await SessionCookieAsync(registration);
@@ -244,7 +245,8 @@ public sealed class DurableAccountApiTests
HttpResponseMessage login = await restarted.PostAsJsonAsync("/api/v1/accounts/login", new
{
email = "survivor@example.com",
- password = "a-long-development-password"
+ password = "a-long-development-password",
+ acceptPolicies = true
}, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, login.StatusCode);
@@ -252,7 +254,8 @@ public sealed class DurableAccountApiTests
{
accountName = "survivor",
email = "other@example.com",
- password = "a-long-development-password"
+ password = "a-long-development-password",
+ acceptPolicies = true
}, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, again.StatusCode);
diff --git a/apps/api-tests/OAuthTestClient.cs b/apps/api-tests/OAuthTestClient.cs
index a176c04..a5d342d 100644
--- a/apps/api-tests/OAuthTestClient.cs
+++ b/apps/api-tests/OAuthTestClient.cs
@@ -37,7 +37,7 @@ internal sealed class OAuthTestClient(AccountApiFactory factory)
{
using HttpClient accounts = factory.CreateAccountClient(handleCookies: false);
HttpResponseMessage registered = await accounts.PostAsJsonAsync("/api/v1/accounts/register",
- new { accountName, email, password = "correct horse battery staple" }, Cancel);
+ new { accountName, email, password = "correct horse battery staple", acceptPolicies = true }, Cancel);
Assert.Equal(HttpStatusCode.Created, registered.StatusCode);
// The console knows the account of the Progmasoft account session; it has no sign-in of its own.
string session = registered.Headers.GetValues("Set-Cookie")
diff --git a/apps/api-tests/SessionLifetimeTests.cs b/apps/api-tests/SessionLifetimeTests.cs
index 8626186..aa9d9ea 100644
--- a/apps/api-tests/SessionLifetimeTests.cs
+++ b/apps/api-tests/SessionLifetimeTests.cs
@@ -22,7 +22,7 @@ public sealed class SessionLifetimeTests
AccountService accounts = new(store, clock);
SessionService sessions = new(store, clock, Options.Create(options));
(AuthenticatedAccount? registration, _) = await accounts.RegisterAsync(
- new RegisterAccountRequest("LifetimeUser", "lifetime@example.com", "a-long-development-password"),
+ new RegisterAccountRequest("LifetimeUser", "lifetime@example.com", "a-long-development-password", true),
sessions,
TestContext.Current.CancellationToken);
return Assert.IsType<AuthenticatedAccount>(registration).ExpiresAt;
diff --git a/apps/api-tests/UnconfiguredGoogleTests.cs b/apps/api-tests/UnconfiguredGoogleTests.cs
index d4e8c53..9a7ca77 100644
--- a/apps/api-tests/UnconfiguredGoogleTests.cs
+++ b/apps/api-tests/UnconfiguredGoogleTests.cs
@@ -36,7 +36,7 @@ public sealed class UnconfiguredGoogleTests
HttpResponseMessage registration = await client.PostAsJsonAsync(
"/api/v1/accounts/register",
- new RegisterAccountRequest("LocalUser1", "local@example.com", password),
+ new RegisterAccountRequest("LocalUser1", "local@example.com", password, true),
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Created, registration.StatusCode);
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(