diff options
| author | Leitwolf11 | 2026-08-30 21:45:01 +0300 |
|---|---|---|
| committer | Leitwolf11 | 2026-08-30 21:45:01 +0300 |
| commit | 76c4c1876a86dbca0adf86750362b3581d6fc69d (patch) | |
| tree | 52bc17d6a977d6c1b13c26c1e016a0e00db99ca5 | |
| parent | c89a3f20a0f477573c41b831d993850e0adb30e7 (diff) | |
| download | progmaweb-1.0.0.tar.gz | |
Add secure Google sign-in to Progmasoft Account. Use the server-side authorization-code flow with an exact account-domain callback, temporary hardened external cookies, verified provider email matching, canonical Account-name creation for new users, and no separate ViGet publisher identity. Add frontend entry points, provider failure guidance, locked Google authentication dependencies, service secret injection, and backend regression coverage without committing OAuth credentials.1.0.0
| -rw-r--r-- | README.md | 6 | ||||
| -rw-r--r-- | apps/api-tests/AccountApiFactory.cs | 4 | ||||
| -rw-r--r-- | apps/api-tests/AccountEndpointTests.cs | 32 | ||||
| -rw-r--r-- | apps/api-tests/packages.lock.json | 10 | ||||
| -rw-r--r-- | apps/api/Accounts/AccountEndpoints.cs | 72 | ||||
| -rw-r--r-- | apps/api/Accounts/AccountRecord.cs | 2 | ||||
| -rw-r--r-- | apps/api/Accounts/AccountService.cs | 66 | ||||
| -rw-r--r-- | apps/api/Progmaweb.Api.csproj | 3 | ||||
| -rw-r--r-- | apps/api/Program.cs | 22 | ||||
| -rw-r--r-- | apps/api/packages.lock.json | 9 | ||||
| -rw-r--r-- | apps/web/app/globals.css | 28 | ||||
| -rw-r--r-- | apps/web/app/login/page.tsx | 14 | ||||
| -rw-r--r-- | apps/web/app/register/page.tsx | 22 | ||||
| -rw-r--r-- | apps/web/components/AuthForm.tsx | 40 | ||||
| -rw-r--r-- | ops/systemd/progmaweb-api.service | 1 |
15 files changed, 319 insertions, 12 deletions
@@ -59,6 +59,12 @@ The frontend defaults to `http://localhost:5085` for server-side API requests. S uses another origin. Browser requests use the same-origin `/api` boundary so production can proxy them without exposing an internal address. +Google sign-in uses a server-side authorization-code flow. Configure `Authentication__Google__ClientId` and +`Authentication__Google__ClientSecret` only in the process environment or secret manager. The production OAuth client +uses `https://account.progmasoft.com` as its JavaScript origin and +`https://account.progmasoft.com/api/v1/accounts/oauth/google/callback` as its exact redirect URI. Never commit the +downloaded Google client JSON. + ## Verification ```text diff --git a/apps/api-tests/AccountApiFactory.cs b/apps/api-tests/AccountApiFactory.cs index b1cbca2..3e9d399 100644 --- a/apps/api-tests/AccountApiFactory.cs +++ b/apps/api-tests/AccountApiFactory.cs @@ -17,7 +17,9 @@ internal sealed class AccountApiFactory : WebApplicationFactory<Program> configuration.AddInMemoryCollection(new Dictionary<string, string?> { ["AccountSessions:LifetimeHours"] = "2", - ["AccountSessions:CookieName"] = "__Host-ProgmasoftSession" + ["AccountSessions:CookieName"] = "__Host-ProgmasoftSession", + ["Authentication:Google:ClientId"] = "test-client.apps.googleusercontent.com", + ["Authentication:Google:ClientSecret"] = "test-secret" }); }); } diff --git a/apps/api-tests/AccountEndpointTests.cs b/apps/api-tests/AccountEndpointTests.cs index a794e96..c59f6b8 100644 --- a/apps/api-tests/AccountEndpointTests.cs +++ b/apps/api-tests/AccountEndpointTests.cs @@ -10,6 +10,38 @@ namespace Progmasoft.Progmaweb.Api.Tests; public sealed class AccountEndpointTests { [Fact] + public async Task GoogleStartUsesTheConfiguredCallbackAndPreservesAccountNameInState() + { + await using AccountApiFactory factory = new(); + using HttpClient client = factory.CreateAccountClient(handleCookies: false); + + HttpResponseMessage response = await client.GetAsync( + "/api/v1/accounts/oauth/google/start?accountName=GoogleUser", + TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.Redirect, response.StatusCode); + Uri location = Assert.IsType<Uri>(response.Headers.Location); + Assert.Equal("accounts.google.com", location.Host); + Assert.Contains("redirect_uri=https%3A%2F%2Flocalhost%2Fapi%2Fv1%2Faccounts%2Foauth%2Fgoogle%2Fcallback", + location.Query, StringComparison.Ordinal); + Assert.Contains("state=", location.Query, StringComparison.Ordinal); + } + + [Fact] + public async Task GoogleStartRejectsInvalidAccountNameBeforeLeavingTheSite() + { + await using AccountApiFactory factory = new(); + using HttpClient client = factory.CreateAccountClient(handleCookies: false); + + HttpResponseMessage response = await client.GetAsync( + "/api/v1/accounts/oauth/google/start?accountName=lowercase", + TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.Redirect, response.StatusCode); + Assert.Equal("/register?google=invalid-account-name", response.Headers.Location?.OriginalString); + } + + [Fact] public async Task RegisterCreatesSessionAndMeReturnsAccount() { await using AccountApiFactory factory = new(); diff --git a/apps/api-tests/packages.lock.json b/apps/api-tests/packages.lock.json index 0a5ef16..c4ac317 100644 --- a/apps/api-tests/packages.lock.json +++ b/apps/api-tests/packages.lock.json @@ -43,6 +43,11 @@ "resolved": "2.23.0", "contentHash": "nWArUZTdU7iqZLycLKWe0TDms48KKGE6pONH2terYNa8REXiqixrMOkf1sk5DHGMaUTqONU2YkS4SAXBhLStgw==" }, + "Microsoft.AspNetCore.Authentication.Google": { + "type": "Transitive", + "resolved": "10.0.11", + "contentHash": "v/OZld8HxGa5hudLWiChuCf5asMrE36n19sR1RK5cC65SGJoYvQAcwnz3yoAOLDSw90PYc3hgFCh1FW83sakUg==" + }, "Microsoft.AspNetCore.TestHost": { "type": "Transitive", "resolved": "10.0.11", @@ -469,7 +474,10 @@ } }, "progmaweb.api": { - "type": "Project" + "type": "Project", + "dependencies": { + "Microsoft.AspNetCore.Authentication.Google": "[10.0.11, )" + } } } } diff --git a/apps/api/Accounts/AccountEndpoints.cs b/apps/api/Accounts/AccountEndpoints.cs index b1bdfe0..5e9fb9d 100644 --- a/apps/api/Accounts/AccountEndpoints.cs +++ b/apps/api/Accounts/AccountEndpoints.cs @@ -3,8 +3,14 @@ namespace Progmasoft.Progmaweb.Api.Accounts; +using System.Security.Claims; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Google; + internal static class AccountEndpoints { + internal const string ExternalCookieScheme = "ProgmasoftExternal"; + public static IEndpointRouteBuilder MapAccountEndpoints(this IEndpointRouteBuilder endpoints) { RouteGroupBuilder accounts = endpoints.MapGroup("/api/v1/accounts"); @@ -13,10 +19,76 @@ internal static class AccountEndpoints accounts.MapPost("/login", LoginAsync).RequireRateLimiting("account-auth"); accounts.MapPost("/logout", LogoutAsync); accounts.MapGet("/me", MeAsync); + accounts.MapGet("/oauth/google/start", StartGoogleAsync).RequireRateLimiting("account-auth"); + accounts.MapGet("/oauth/google/complete", CompleteGoogleAsync).RequireRateLimiting("account-auth"); return endpoints; } + private static IResult StartGoogleAsync(string? accountName, IConfiguration configuration) + { + if (string.IsNullOrWhiteSpace(configuration["Authentication:Google:ClientId"]) || + string.IsNullOrWhiteSpace(configuration["Authentication:Google:ClientSecret"])) + { + return Results.Problem( + title: "Google sign-in is unavailable", + detail: "The Google identity provider has not been configured.", + statusCode: StatusCodes.Status503ServiceUnavailable); + } + + string? normalized = null; + if (accountName is not null && !AccountNamePolicy.TryNormalize(accountName, out normalized, out _)) + { + return Results.Redirect("/register?google=invalid-account-name"); + } + + AuthenticationProperties properties = new() + { + RedirectUri = "/api/v1/accounts/oauth/google/complete" + }; + if (normalized is not null) + { + properties.Items["accountName"] = normalized; + } + + return Results.Challenge(properties, [GoogleDefaults.AuthenticationScheme]); + } + + private static async Task<IResult> CompleteGoogleAsync( + AccountService accounts, + SessionService sessions, + HttpContext context, + CancellationToken cancellationToken) + { + AuthenticateResult external = await context.AuthenticateAsync(ExternalCookieScheme); + if (!external.Succeeded || external.Principal is null) + { + return Results.Redirect("/login?google=failed"); + } + + string? email = external.Principal.FindFirstValue(ClaimTypes.Email); + string? accountName = null; + external.Properties?.Items.TryGetValue("accountName", out accountName); + (AuthenticatedAccount? authentication, GoogleAccountFailure failure) = + await accounts.AuthenticateGoogleAsync(email, accountName, sessions, cancellationToken); + await context.SignOutAsync(ExternalCookieScheme); + + if (authentication is null) + { + string destination = failure switch + { + GoogleAccountFailure.AccountNameRequired => "/register?google=account-name-required", + GoogleAccountFailure.InvalidAccountName => "/register?google=invalid-account-name", + GoogleAccountFailure.AccountNameUnavailable => "/register?google=account-name-unavailable", + _ => "/login?google=failed" + }; + return Results.Redirect(destination); + } + + WriteSessionCookie(context, sessions, authentication); + return Results.Redirect($"/{Uri.EscapeDataString(authentication.Account.AccountName)}/dashboard"); + } + private static async Task<IResult> RegisterAsync( RegisterAccountRequest request, AccountService accounts, diff --git a/apps/api/Accounts/AccountRecord.cs b/apps/api/Accounts/AccountRecord.cs index a098fd1..253b380 100644 --- a/apps/api/Accounts/AccountRecord.cs +++ b/apps/api/Accounts/AccountRecord.cs @@ -9,7 +9,7 @@ internal sealed record AccountRecord( string NormalizedAccountName, string Email, string NormalizedEmail, - string PasswordHash, + string? PasswordHash, DateTimeOffset CreatedAt); internal sealed record SessionRecord( diff --git a/apps/api/Accounts/AccountService.cs b/apps/api/Accounts/AccountService.cs index a3f83ad..3e7ced1 100644 --- a/apps/api/Accounts/AccountService.cs +++ b/apps/api/Accounts/AccountService.cs @@ -62,7 +62,7 @@ internal sealed class AccountService( } AccountRecord? account = await store.FindByEmailAsync(normalizedEmail, cancellationToken); - if (account is null) + if (account is null || account.PasswordHash is null) { // A fixed dummy record keeps the missing-account path on the same password-hashing primitive. AccountRecord dummyBase = new(Guid.Empty, "Missing00", "MISSING00", "missing@example.invalid", @@ -85,6 +85,61 @@ internal sealed class AccountService( return await sessions.CreateAsync(account, cancellationToken); } + public async ValueTask<(AuthenticatedAccount? Authentication, GoogleAccountFailure Failure)> + AuthenticateGoogleAsync( + string? providerEmail, + string? requestedAccountName, + SessionService sessions, + CancellationToken cancellationToken) + { + if (!EmailAddressPolicy.TryNormalize(providerEmail, out string email, out string normalizedEmail, out _)) + { + return (null, GoogleAccountFailure.ProviderRejected); + } + + AccountRecord? existing = await store.FindByEmailAsync(normalizedEmail, cancellationToken); + if (existing is not null) + { + return (await sessions.CreateAsync(existing, cancellationToken), GoogleAccountFailure.None); + } + + if (requestedAccountName is null) + { + return (null, GoogleAccountFailure.AccountNameRequired); + } + + if (!AccountNamePolicy.TryNormalize(requestedAccountName, out string accountName, out _)) + { + return (null, GoogleAccountFailure.InvalidAccountName); + } + + AccountRecord account = new( + Guid.NewGuid(), + accountName, + AccountNamePolicy.NormalizeForLookup(accountName), + email, + normalizedEmail, + null, + timeProvider.GetUtcNow()); + CreateAccountResult result = await store.CreateAsync(account, cancellationToken); + + if (result.Status is CreateAccountStatus.AccountNameUnavailable) + { + return (null, GoogleAccountFailure.AccountNameUnavailable); + } + + if (result.Status is CreateAccountStatus.EmailUnavailable) + { + // A concurrent callback may have created the same verified Google identity. + AccountRecord? concurrent = await store.FindByEmailAsync(normalizedEmail, cancellationToken); + return concurrent is null + ? (null, GoogleAccountFailure.ProviderRejected) + : (await sessions.CreateAsync(concurrent, cancellationToken), GoogleAccountFailure.None); + } + + return (await sessions.CreateAsync(account, cancellationToken), GoogleAccountFailure.None); + } + private static Dictionary<string, string[]> ValidateRegistration( RegisterAccountRequest request, out string accountName, @@ -111,3 +166,12 @@ internal sealed class AccountService( return errors; } } + +internal enum GoogleAccountFailure +{ + None, + ProviderRejected, + AccountNameRequired, + InvalidAccountName, + AccountNameUnavailable +} diff --git a/apps/api/Progmaweb.Api.csproj b/apps/api/Progmaweb.Api.csproj index 57255cd..ce76f58 100644 --- a/apps/api/Progmaweb.Api.csproj +++ b/apps/api/Progmaweb.Api.csproj @@ -8,4 +8,7 @@ <AssemblyName>Progmaweb.Api</AssemblyName> <Version>1.0.0</Version> </PropertyGroup> + <ItemGroup> + <PackageReference Include="Microsoft.AspNetCore.Authentication.Google" Version="10.0.11" /> + </ItemGroup> </Project> diff --git a/apps/api/Program.cs b/apps/api/Program.cs index 68d3f26..cdfe360 100644 --- a/apps/api/Program.cs +++ b/apps/api/Program.cs @@ -2,6 +2,8 @@ // SPDX-License-Identifier: AGPL-3.0-or-later WITH AdditionRef-Progmasoft-Patent-Grant-1.0 using System.Threading.RateLimiting; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authentication.Google; using Microsoft.AspNetCore.HttpOverrides; using Progmasoft.Progmaweb.Api.Accounts; using Progmasoft.Progmaweb.Api.Security; @@ -13,6 +15,25 @@ builder.Services.AddSingleton(TimeProvider.System); builder.Services.AddSingleton<IAccountStore, InMemoryAccountStore>(); builder.Services.AddSingleton<AccountService>(); builder.Services.AddSingleton<SessionService>(); +builder.Services + .AddAuthentication() + .AddCookie(AccountEndpoints.ExternalCookieScheme, options => + { + options.Cookie.Name = "__Host-ProgmasoftExternal"; + options.Cookie.HttpOnly = true; + options.Cookie.IsEssential = true; + options.Cookie.SameSite = SameSiteMode.Lax; + options.Cookie.SecurePolicy = CookieSecurePolicy.Always; + options.ExpireTimeSpan = TimeSpan.FromMinutes(10); + }) + .AddGoogle(GoogleDefaults.AuthenticationScheme, options => + { + options.ClientId = builder.Configuration["Authentication:Google:ClientId"] ?? string.Empty; + options.ClientSecret = builder.Configuration["Authentication:Google:ClientSecret"] ?? string.Empty; + options.CallbackPath = "/api/v1/accounts/oauth/google/callback"; + options.SignInScheme = AccountEndpoints.ExternalCookieScheme; + options.SaveTokens = false; + }); builder.Services.AddProblemDetails(); builder.Services.AddHealthChecks(); builder.Services.AddRateLimiter(options => @@ -50,6 +71,7 @@ if (!app.Environment.IsDevelopment()) app.UseHttpsRedirection(); app.UseMiddleware<SecurityHeadersMiddleware>(); +app.UseAuthentication(); app.UseRateLimiter(); app.UseExceptionHandler(); diff --git a/apps/api/packages.lock.json b/apps/api/packages.lock.json index 4a91a8c..b7d7ff4 100644 --- a/apps/api/packages.lock.json +++ b/apps/api/packages.lock.json @@ -1,6 +1,13 @@ { "version": 1, "dependencies": { - "net10.0": {} + "net10.0": { + "Microsoft.AspNetCore.Authentication.Google": { + "type": "Direct", + "requested": "[10.0.11, )", + "resolved": "10.0.11", + "contentHash": "v/OZld8HxGa5hudLWiChuCf5asMrE36n19sR1RK5cC65SGJoYvQAcwnz3yoAOLDSw90PYc3hgFCh1FW83sakUg==" + } + } } }
\ No newline at end of file diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index a481a52..8ec857b 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -863,6 +863,34 @@ a { margin-top: 3px; } +.auth-divider { + align-items: center; + color: var(--muted); + display: flex; + font-size: 0.78rem; + gap: 0.75rem; + text-transform: uppercase; +} + +.auth-divider::before, +.auth-divider::after { + background: var(--line); + content: ""; + flex: 1; + height: 1px; +} + +.google-auth-button { + background: transparent; + border-color: var(--line); + color: var(--ink); + width: 100%; +} + +.google-auth-button:hover { + border-color: var(--accent); +} + .form-switch { margin: 0; color: var(--ink-650); diff --git a/apps/web/app/login/page.tsx b/apps/web/app/login/page.tsx index 2162ffe..90c2910 100644 --- a/apps/web/app/login/page.tsx +++ b/apps/web/app/login/page.tsx @@ -10,14 +10,24 @@ export const metadata: Metadata = { robots: { index: false, follow: false }, }; -export default function LoginPage() { +interface LoginPageProps { + searchParams: Promise<{ google?: string }>; +} + +export default async function LoginPage({ searchParams }: LoginPageProps) { + const { google } = await searchParams; + const initialMessage = + google === "failed" + ? "Google sign-in could not be completed. Try again." + : undefined; + return ( <AccountShell eyebrow="Progmasoft account" title="Sign in securely." description="Continue to your account dashboard and connected services." > - <AuthForm mode="login" /> + <AuthForm initialMessage={initialMessage} mode="login" /> </AccountShell> ); } diff --git a/apps/web/app/register/page.tsx b/apps/web/app/register/page.tsx index 97c63e5..d120d45 100644 --- a/apps/web/app/register/page.tsx +++ b/apps/web/app/register/page.tsx @@ -10,14 +10,32 @@ export const metadata: Metadata = { robots: { index: false, follow: false }, }; -export default function RegisterPage() { +interface RegisterPageProps { + searchParams: Promise<{ google?: string }>; +} + +const googleMessages: Record<string, string> = { + "account-name-required": + "Choose an Account name before continuing with Google.", + "account-name-unavailable": + "That Account name is unavailable. Choose another name.", + "invalid-account-name": + "Use a valid Account name before continuing with Google.", +}; + +export default async function RegisterPage({ + searchParams, +}: RegisterPageProps) { + const { google } = await searchParams; + const initialMessage = google ? googleMessages[google] : undefined; + return ( <AccountShell eyebrow="Create an identity" title="Start with a durable account." description="Choose the name used in your Progmasoft account URL." > - <AuthForm mode="register" /> + <AuthForm initialMessage={initialMessage} mode="register" /> </AccountShell> ); } diff --git a/apps/web/components/AuthForm.tsx b/apps/web/components/AuthForm.tsx index 56fb78d..f32c49d 100644 --- a/apps/web/components/AuthForm.tsx +++ b/apps/web/components/AuthForm.tsx @@ -5,11 +5,12 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; -import { type FormEvent, useId, useState } from "react"; +import { type FormEvent, type MouseEvent, useId, useState } from "react"; type AuthMode = "login" | "register"; interface AuthFormProps { + initialMessage?: string; mode: AuthMode; } @@ -30,13 +31,34 @@ function firstProblemMessage(problem: ProblemDetails): string { return firstError ?? problem.title ?? "The request could not be completed."; } -export function AuthForm({ mode }: AuthFormProps) { +export function AuthForm({ initialMessage, mode }: AuthFormProps) { const router = useRouter(); const formId = useId(); const [pending, setPending] = useState(false); - const [message, setMessage] = useState<string | null>(null); + const [message, setMessage] = useState<string | null>(initialMessage ?? null); const [showPassword, setShowPassword] = useState(false); + function continueWithGoogle(event: MouseEvent<HTMLButtonElement>) { + const form = event.currentTarget.form; + const parameters = new URLSearchParams(); + + if (mode === "register") { + const accountName = form?.elements.namedItem("accountName"); + if (!(accountName instanceof HTMLInputElement)) { + setMessage("The Account name field could not be read."); + return; + } + if (!accountName.checkValidity()) { + accountName.reportValidity(); + return; + } + parameters.set("accountName", accountName.value); + } + + const query = parameters.size === 0 ? "" : `?${parameters.toString()}`; + window.open(`/api/v1/accounts/oauth/google/start${query}`, "_self"); + } + async function submit(event: FormEvent<HTMLFormElement>) { event.preventDefault(); setMessage(null); @@ -217,6 +239,18 @@ export function AuthForm({ mode }: AuthFormProps) { : "Sign in"} </button> + <div className="auth-divider" aria-hidden="true"> + <span>or</span> + </div> + <button + className="button google-auth-button" + type="button" + disabled={pending} + onClick={continueWithGoogle} + > + Continue with Google + </button> + <p className="form-switch"> {mode === "register" ? "Already have an account?" diff --git a/ops/systemd/progmaweb-api.service b/ops/systemd/progmaweb-api.service index 201b402..e26aad4 100644 --- a/ops/systemd/progmaweb-api.service +++ b/ops/systemd/progmaweb-api.service @@ -14,6 +14,7 @@ WorkingDirectory=/srv/progmaweb/current/api ExecStart=/usr/bin/env /srv/progmaweb/current/api/Progmaweb.Api Environment=ASPNETCORE_ENVIRONMENT=Production Environment=ASPNETCORE_URLS=http://127.0.0.1:5085 +EnvironmentFile=-/etc/progmaweb/account.env Restart=on-failure RestartSec=5s TimeoutStopSec=30s |