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 /apps/api/Accounts | |
| 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
Diffstat (limited to 'apps/api/Accounts')
| -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 |
3 files changed, 138 insertions, 2 deletions
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 +} |