diff options
| author | Leitwolf11 | 2026-08-20 20:49:49 +0300 |
|---|---|---|
| committer | Leitwolf11 | 2026-08-20 20:49:49 +0300 |
| commit | 9bc662fefa4dad26274d18bbde7a48cf6ec78d19 (patch) | |
| tree | d60ff141c6e7b38f94477552540699e102011d01 /backend/Auth | |
| parent | b3a82aa98d1edb2570c1ca441be40e891b6f4be3 (diff) | |
| download | website-9bc662fefa4dad26274d18bbde7a48cf6ec78d19.tar.gz | |
Add verified publisher usernames to registry signup
Insert a dedicated username step after the password screen and require the same publisher identity after Google authorization. Enforce an uppercase ASCII first letter, ASCII alphanumeric content, 8-128 character bounds, and reserved Progmasoft and Leitwolf names in the backend rather than trusting browser validation.
Preserve canonical case while enforcing case-insensitive ownership through a normalized unique PostgreSQL index. Claim the name only after email-code validation and commit code consumption plus ownership atomically so unverified accounts cannot squat publisher coordinates. Expose the canonical publisher name on the account model, add the EF migration and xUnit policy coverage, version the cached auth flow, document the contract, and run backend tests in CI.
Diffstat (limited to 'backend/Auth')
| -rw-r--r-- | backend/Auth/AuthEndpoints.cs | 111 | ||||
| -rw-r--r-- | backend/Auth/PublisherNamePolicy.cs | 56 |
2 files changed, 152 insertions, 15 deletions
diff --git a/backend/Auth/AuthEndpoints.cs b/backend/Auth/AuthEndpoints.cs index d984727..ca3e16b 100644 --- a/backend/Auth/AuthEndpoints.cs +++ b/backend/Auth/AuthEndpoints.cs @@ -5,6 +5,9 @@ using System.Security.Claims; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.RateLimiting; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage; +using Npgsql; using XSharp.Web.Api.Data; using XSharp.Web.Api.Email; @@ -34,16 +37,28 @@ internal static class AuthEndpoints } private static async Task<IResult> RegisterAsync( - Credentials request, + RegisterRequest request, UserManager<ApplicationUser> users, AuthCodeService codes, RegistryEmailSender emailSender, CancellationToken cancellationToken) { - if (!TryGetCredentials(request, out string email, out string password)) + if (!TryGetCredentials(request.Email, request.Password, out string email, out string password)) { return InvalidRequest(); } + PublisherNameValidation publisherName = PublisherNamePolicy.Validate(request.PublisherName); + if (!publisherName.IsValid) + { + return InvalidPublisherName(publisherName.Error!); + } + if (await PublisherNameExistsAsync(users, publisherName.NormalizedValue, cancellationToken)) + { + return PublisherNameUnavailable(); + } + + // Availability is checked before sending mail for fast feedback, but the name is claimed only after the email code + // succeeds. Unverified accounts therefore cannot squat publisher coordinates. ApplicationUser user = new() { UserName = email, Email = email }; IdentityResult result = await users.CreateAsync(user, password); if (!result.Succeeded) @@ -61,6 +76,7 @@ internal static class AuthEndpoints UserManager<ApplicationUser> users, AuthCodeService codes, SignInManager<ApplicationUser> signIn, + RegistryDbContext database, CancellationToken cancellationToken) { if (!TryNormalizeEmail(request.Email, out string email) || string.IsNullOrWhiteSpace(request.Code)) @@ -68,14 +84,47 @@ internal static class AuthEndpoints return InvalidRequest(); } ApplicationUser? user = await users.FindByEmailAsync(email); - if (user is null || !await codes.ConsumeAsync(user.Id, "signup", request.Code, cancellationToken)) + if (user is null) { return Results.BadRequest(new ApiError("invalid_code", "The verification code is invalid or expired.")); } + await using IDbContextTransaction transaction = await database.Database.BeginTransactionAsync(cancellationToken); + PublisherNameValidation publisherName = default; + if (user.PublisherName is null) + { + publisherName = PublisherNamePolicy.Validate(request.PublisherName); + if (!publisherName.IsValid) + { + return InvalidPublisherName(publisherName.Error!); + } + if (await PublisherNameExistsAsync(users, publisherName.NormalizedValue, cancellationToken)) + { + return PublisherNameUnavailable(); + } + } + if (!await codes.ConsumeAsync(user.Id, "signup", request.Code, cancellationToken)) + { + return Results.BadRequest(new ApiError("invalid_code", "The verification code is invalid or expired.")); + } + + if (user.PublisherName is null) + { + user.PublisherName = publisherName.Value; + user.NormalizedPublisherName = publisherName.NormalizedValue; + } user.EmailConfirmed = true; - IdentityResult result = await users.UpdateAsync(user); + IdentityResult result; + try + { + result = await users.UpdateAsync(user); + } + catch (DbUpdateException exception) when (IsPublisherNameConflict(exception)) + { + return PublisherNameUnavailable(); + } if (!result.Succeeded) return IdentityErrors(result); + await transaction.CommitAsync(cancellationToken); await signIn.SignInAsync(user, isPersistent: true); return Results.Ok(new { verified = true }); } @@ -104,7 +153,7 @@ internal static class AuthEndpoints UserManager<ApplicationUser> users, SignInManager<ApplicationUser> signIn) { - if (!TryGetCredentials(request, out string email, out string password)) + if (!TryGetCredentials(request.Email, request.Password, out string email, out string password)) { return InvalidCredentials(); } @@ -128,13 +177,21 @@ internal static class AuthEndpoints return Results.NoContent(); } - private static IResult MeAsync(ClaimsPrincipal principal) => Results.Ok(new + private static async Task<IResult> MeAsync( + ClaimsPrincipal principal, + UserManager<ApplicationUser> users) { - id = principal.FindFirstValue(ClaimTypes.NameIdentifier), - email = principal.FindFirstValue(ClaimTypes.Email) ?? principal.Identity?.Name, - displayName = principal.FindFirstValue(ClaimTypes.Name) ?? principal.Identity?.Name, - emailVerified = true, - }); + ApplicationUser? user = await users.GetUserAsync(principal); + if (user is null) return Results.Unauthorized(); + return Results.Ok(new + { + id = user.Id, + email = user.Email, + publisherName = user.PublisherName, + displayName = user.PublisherName ?? user.Email, + emailVerified = user.EmailConfirmed, + }); + } private static async Task<IResult> ProvidersAsync(IAuthenticationSchemeProvider schemes) => Results.Ok(new { google = await schemes.GetSchemeAsync("Google") is not null }); @@ -262,12 +319,31 @@ internal static class AuthEndpoints return Results.Ok(new { recovered = true }); } - private static bool TryGetCredentials(Credentials request, out string email, out string password) + private static bool TryGetCredentials( + string? requestedEmail, + string? requestedPassword, + out string email, + out string password) { - password = request.Password ?? string.Empty; - return TryNormalizeEmail(request.Email, out email) && password.Length > 0; + password = requestedPassword ?? string.Empty; + return TryNormalizeEmail(requestedEmail, out email) && password.Length > 0; } + private static Task<bool> PublisherNameExistsAsync( + UserManager<ApplicationUser> users, + string normalizedPublisherName, + CancellationToken cancellationToken) => + users.Users.AnyAsync( + user => user.NormalizedPublisherName == normalizedPublisherName, + cancellationToken); + + private static bool IsPublisherNameConflict(DbUpdateException exception) => + exception.InnerException is PostgresException + { + SqlState: PostgresErrorCodes.UniqueViolation, + ConstraintName: "PublisherNameIndex", + }; + private static bool TryNormalizeEmail(string? value, out string email) { email = value?.Trim().ToLowerInvariant() ?? string.Empty; @@ -280,13 +356,18 @@ internal static class AuthEndpoints private static IResult InvalidCredentials() => Results.Json(new ApiError("invalid_credentials", "The email or password is incorrect."), statusCode: StatusCodes.Status401Unauthorized); + private static IResult InvalidPublisherName(string message) => + Results.BadRequest(new ApiError("invalid_publisher_name", message)); + private static IResult PublisherNameUnavailable() => + Results.Conflict(new ApiError("publisher_name_unavailable", "This username is not available.")); private static IResult IdentityErrors(IdentityResult result) => Results.BadRequest(new { error = "identity_validation", errors = result.Errors.Select(item => item.Description) }); } +internal sealed record RegisterRequest(string? Email, string? Password, string? PublisherName); internal sealed record Credentials(string? Email, string? Password); internal sealed record EmailRequest(string? Email); -internal sealed record VerificationRequest(string? Email, string? Code); +internal sealed record VerificationRequest(string? Email, string? Code, string? PublisherName); internal sealed record RecoveryRequest(string? Email, string? Code, string? Password); internal sealed record DeleteAccountRequest(string? Confirmation); internal sealed record ApiError(string Error, string Message); diff --git a/backend/Auth/PublisherNamePolicy.cs b/backend/Auth/PublisherNamePolicy.cs new file mode 100644 index 0000000..9b687ab --- /dev/null +++ b/backend/Auth/PublisherNamePolicy.cs @@ -0,0 +1,56 @@ +// SPDX-FileCopyrightText: 2026 Leitwolf <xs-lang.chess031@slmails.com> +// SPDX-License-Identifier: AGPL-3.0-or-later + +namespace XSharp.Web.Api.Auth; + +internal static class PublisherNamePolicy +{ + public const int MinimumLength = 8; + public const int MaximumLength = 128; + + private static readonly string[] ReservedNames = ["Progmasoft", "Leitwolf"]; + + public static PublisherNameValidation Validate(string? value) + { + string publisherName = value ?? string.Empty; + if (publisherName.Length is < MinimumLength or > MaximumLength) + { + return PublisherNameValidation.Invalid( + $"Username must be between {MinimumLength} and {MaximumLength} characters."); + } + if (!IsUpperAscii(publisherName[0])) + { + return PublisherNameValidation.Invalid("Username must start with an uppercase ASCII letter."); + } + + // Publisher coordinates are URL path segments. Restricting the complete value to ASCII letters and digits keeps + // canonical package URLs portable across filesystems, shells, registries, and case-sensitive clients. + if (!publisherName.All(IsAsciiLetterOrDigit)) + { + return PublisherNameValidation.Invalid("Username can contain only ASCII letters and digits."); + } + if (ReservedNames.Contains(publisherName, StringComparer.OrdinalIgnoreCase)) + { + return PublisherNameValidation.Invalid("This username is reserved."); + } + + return PublisherNameValidation.Valid(publisherName, publisherName.ToUpperInvariant()); + } + + private static bool IsUpperAscii(char value) => value is >= 'A' and <= 'Z'; + + private static bool IsAsciiLetterOrDigit(char value) => + value is >= 'A' and <= 'Z' or >= 'a' and <= 'z' or >= '0' and <= '9'; +} + +internal readonly record struct PublisherNameValidation( + bool IsValid, + string Value, + string NormalizedValue, + string? Error) +{ + public static PublisherNameValidation Valid(string value, string normalizedValue) => + new(true, value, normalizedValue, null); + + public static PublisherNameValidation Invalid(string error) => new(false, string.Empty, string.Empty, error); +} |