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 | |
| 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
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( |