Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions API/Controller/OAuth/SignupFinalize.cs
Original file line number Diff line number Diff line change
Expand Up @@ -93,11 +93,15 @@ public async Task<IActionResult> OAuthSignupFinalize(
isEmailTrusted
);

if (!created.TryPickT0(out var newUser, out _))
if (!created.TryPickT0(out var newUser, out var conflict))
{
// Username or email already exists — conflict.
// Do NOT clear the flow cookie so the frontend can retry with a different username.
return Problem(SignupError.UsernameOrEmailExists);
// Do NOT clear the flow cookie on conflict — the frontend may retry signup-finalize
// with a different username, or upgrade the flow to a link via signup-link-password.
return conflict.Match(
_ => Problem(AccountError.UsernameTaken),
email => Problem(email.HasPassword
? AccountError.EmailTakenLinkAvailable
: AccountError.EmailTakenLinkUnavailable));
}

// Authenticate the client if its activated (create session and set session cookie)
Expand Down
135 changes: 135 additions & 0 deletions API/Controller/OAuth/SignupLinkWithPassword.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
using System.Net.Mime;
using System.Security.Claims;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using OpenShock.API.Models.Requests;
using OpenShock.API.Models.Response;
using OpenShock.API.OAuth;
using OpenShock.API.Services.OAuthConnection;
using OpenShock.Common.Errors;
using OpenShock.Common.Extensions;
using OpenShock.Common.Problems;

namespace OpenShock.API.Controller.OAuth;

public sealed partial class OAuthController
{
/// <summary>
/// Link an in-flight OAuth signup to an existing OpenShock account by authenticating with the
/// account's password. Used when <c>POST /{provider}/signup-finalize</c> returned
/// <c>Account.Email.TakenLinkAvailable</c>: the user holds a valid OAuth flow cookie, the
/// provider-supplied email matches an existing account that has a password, and the user proves
/// ownership of that account here.
/// </summary>
/// <remarks>
/// Authenticates via the temporary OAuth flow cookie (anonymous user) and the account password.
/// On success the OAuth connection is attached to the existing account, a session is created,
/// and the flow cookie is cleared.
/// </remarks>
[EnableRateLimiting("auth")]
[HttpPost("{provider}/signup-link-password")]
[Consumes(MediaTypeNames.Application.Json)]
[ProducesResponseType<LoginV2OkResponse>(StatusCodes.Status200OK, MediaTypeNames.Application.Json)]
[ProducesResponseType<OpenShockProblem>(StatusCodes.Status401Unauthorized, MediaTypeNames.Application.ProblemJson)]
[ProducesResponseType<OpenShockProblem>(StatusCodes.Status409Conflict, MediaTypeNames.Application.ProblemJson)]
[ApiExplorerSettings(IgnoreApi = true)]
public async Task<IActionResult> OAuthSignupLinkWithPassword(
[FromRoute] string provider,
[FromBody] OAuthLinkPasswordRequest body,
[FromServices] IOAuthConnectionService connectionService,
CancellationToken cancellationToken)
{
var domain = GetCurrentCookieDomain();
if (string.IsNullOrEmpty(domain))
{
await HttpContext.SignOutAsync(OAuthConstants.FlowScheme);
return Problem(OAuthError.InternalError);
}

var result = await ValidateOAuthFlowAsync();
if (!result.TryPickT0(out var auth, out var error))
{
return error switch
{
OAuthValidationError.FlowStateMissing => Problem(OAuthError.FlowNotFound),
_ => Problem(OAuthError.InternalError)
};
}

if (User.HasOpenShockUserIdentity())
{
return Problem(OAuthError.FlowRequiresAnonymous);
}

// Defense-in-depth: ensure the flow's provider matches the route and the flow type is right.
if (!string.Equals(auth.Provider, provider, StringComparison.OrdinalIgnoreCase) || auth.Flow != OAuthFlow.LoginOrCreate)
{
await HttpContext.SignOutAsync(OAuthConstants.FlowScheme);
return Problem(OAuthError.FlowMismatch);
}

// The provider-supplied email is the only email we trust here — the client does not pass
// an email of their own. This prevents the endpoint from being used to probe arbitrary
// addresses for password validity.
var providerEmail = auth.Principal.FindFirst(ClaimTypes.Email)?.Value;
if (string.IsNullOrWhiteSpace(auth.ExternalAccountId) || string.IsNullOrWhiteSpace(providerEmail))
{
return Problem(OAuthError.FlowMissingData);
}

// No Turnstile here: the flow cookie already proves a completed OAuth round-trip with the
// provider, which is stronger bot-gating than Turnstile. Password brute-force is bounded
// by the shared `auth` rate-limit bucket.

// Reject if the external identity got linked by a parallel flow between the conflict on
// signup-finalize and now.
var existing = await connectionService.GetByProviderExternalIdAsync(provider, auth.ExternalAccountId, cancellationToken);
if (existing is not null)
{
await HttpContext.SignOutAsync(OAuthConstants.FlowScheme);
return Problem(OAuthError.ExternalAlreadyLinked);
}

// Authenticate using the provider's email as the lookup key. Map failures to the same
// problems LoginV2 returns so error shape & timing don't differ from a normal login attempt.
var getAccountResult = await _accountService.GetAccountByCredentialsAsync(providerEmail, body.Password, cancellationToken);
if (!getAccountResult.TryPickT0(out var account, out var loginErrors))
{
return loginErrors.Match(
notFound => Problem(LoginError.InvalidCredentials),
deactivated => Problem(AccountError.AccountDeactivated),
notActivated => Problem(AccountError.AccountNotActivated),
oauthOnly => Problem(AccountError.AccountOAuthOnly)
);
}

// One connection per (user, provider). If this account already has this provider attached,
// surface a distinct error so the frontend can route the user to settings/connections.
if (await connectionService.HasConnectionAsync(account.Id, provider, cancellationToken))
{
return Problem(OAuthError.ProviderAlreadyLinkedToAccount);
}

var linked = await connectionService.TryAddConnectionAsync(
account.Id,
provider,
auth.ExternalAccountId,
auth.ExternalAccountDisplayName ?? auth.ExternalAccountName,
cancellationToken);
if (!linked)
{
// Race: either the external id or the (user, provider) pair was claimed in parallel.
return Problem(OAuthError.LinkFailed);
}

_logger.LogInformation(
"Linked OAuth provider {Provider} (external id {ExternalId}) to existing user {UserId} via password",
provider, auth.ExternalAccountId, account.Id);

Check warning

Code scanning / CodeQL

Log entries created from user input Medium

This log entry depends on a
user-provided value
.

await CreateSession(account.Id, domain);
await HttpContext.SignOutAsync(OAuthConstants.FlowScheme);

return Ok(LoginV2OkResponse.FromUser(account));
}
}
9 changes: 9 additions & 0 deletions API/Models/Requests/OAuthLinkPasswordRequest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
using System.ComponentModel.DataAnnotations;

namespace OpenShock.API.Models.Requests;

public sealed class OAuthLinkPasswordRequest
{
[Required(AllowEmptyStrings = false)]
public required string Password { get; set; }
}
10 changes: 10 additions & 0 deletions API/OAuth/OAuthError.cs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,16 @@ public static class OAuthError
"This external account is already linked to another user",
HttpStatusCode.Conflict);

public static OpenShockProblem ProviderAlreadyLinkedToAccount => new(
"OAuth.Provider.AlreadyLinkedToAccount",
"This provider is already linked to the target account",
HttpStatusCode.Conflict);

public static OpenShockProblem LinkFailed => new(
"OAuth.Link.Failed",
"Failed to link the external account",
HttpStatusCode.Conflict);

// Misc / generic
public static OpenShockProblem InternalError => new(
"OAuth.InternalError",
Expand Down
50 changes: 42 additions & 8 deletions API/Services/Account/AccountService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ await _emailService.ActivateAccount(new Contact(email, username),
return new Success<User>(user);
}

public async Task<OneOf<Success<User>, AccountWithEmailOrUsernameExists>> CreateOAuthOnlyAccountAsync(
public async Task<OneOf<Success<User>, UsernameAlreadyTaken, EmailAlreadyTaken>> CreateOAuthOnlyAccountAsync(
string email,
string username,
string provider,
Expand All @@ -133,13 +133,23 @@ public async Task<OneOf<Success<User>, AccountWithEmailOrUsernameExists>> Create
email = email.ToLowerInvariant();
provider = provider.ToLowerInvariant();

// Reuse your existing guards
// Reuse existing guards. Blacklist hits are reported as username-taken so we don't leak
// the existence of the blacklist or the validity of the email domain to anonymous callers.
if (await IsUserNameBlacklisted(username) || await IsEmailProviderBlacklisted(email))
return new AccountWithEmailOrUsernameExists();
return new UsernameAlreadyTaken();

Comment on lines +136 to +140
// Fast uniqueness pre-check (optimistic; race handled by unique constraints below).
// Prefer reporting the email collision when both are taken — only the email path lets the
// user recover by signing in to link the provider to the existing account.
var emailOwner = await _db.Users
.Where(u => u.Email == email)
.Select(u => new { HasPassword = u.PasswordHash != null })
.FirstOrDefaultAsync();
if (emailOwner is not null)
return new EmailAlreadyTaken { HasPassword = emailOwner.HasPassword };

// Fast uniqueness check (optimistic; race handled by unique constraints below)
var exists = await _db.Users.AnyAsync(u => u.Email == email || u.Name == username);
if (exists) return new AccountWithEmailOrUsernameExists();
if (await _db.Users.AnyAsync(u => u.Name == username))
return new UsernameAlreadyTaken();

await using var tx = await _db.Database.BeginTransactionAsync();

Expand Down Expand Up @@ -202,10 +212,34 @@ await _emailService.ActivateAccount(

return new Success<User>(user);
}
catch (DbUpdateException ex) when (ex.InnerException is PostgresException { SqlState: "23505" })
catch (DbUpdateException ex) when (ex.InnerException is PostgresException { SqlState: "23505" } pgEx)
{
await tx.RollbackAsync();
return new AccountWithEmailOrUsernameExists();

// Map known unique indexes to specific outcomes.
switch (pgEx.ConstraintName)
Comment on lines 216 to +220
{
case "IX_users_email":
{
var hasPassword = await _db.Users
.Where(u => u.Email == email)
.Select(u => (bool?)(u.PasswordHash != null))
.FirstOrDefaultAsync() ?? false;
return new EmailAlreadyTaken { HasPassword = hasPassword };
}
case "IX_users_name":
return new UsernameAlreadyTaken();
}

// Ambiguous constraint — re-query both. Prefer the email outcome.
var emailRow = await _db.Users
.Where(u => u.Email == email)
.Select(u => new { HasPassword = u.PasswordHash != null })
.FirstOrDefaultAsync();
Comment on lines +234 to +238
if (emailRow is not null)
return new EmailAlreadyTaken { HasPassword = emailRow.HasPassword };

return new UsernameAlreadyTaken();
}
}

Expand Down
14 changes: 12 additions & 2 deletions API/Services/Account/IAccountService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,12 @@ public interface IAccountService
/// <param name="providerAccountId">external subject/id from provider</param>
/// <param name="providerAccountName">display name from provider</param>
/// <param name="isEmailTrusted"></param>
/// <returns>Success with the created user, or AccountWithEmailOrUsernameExists when taken/blocked.</returns>
Task<OneOf<Success<User>, AccountWithEmailOrUsernameExists>> CreateOAuthOnlyAccountAsync(string email, string username, string provider, string providerAccountId, string? providerAccountName, bool isEmailTrusted);
/// <returns>
/// Success with the created user, or a conflict marker distinguishing username vs. email collision.
/// When both collide, prefer <see cref="EmailAlreadyTaken"/> (the email path enables linking via
/// password login; username conflict can only be retried).
/// </returns>
Task<OneOf<Success<User>, UsernameAlreadyTaken, EmailAlreadyTaken>> CreateOAuthOnlyAccountAsync(string email, string username, string provider, string providerAccountId, string? providerAccountName, bool isEmailTrusted);
Comment on lines +42 to +47

/// <summary>
///
Expand Down Expand Up @@ -141,6 +145,12 @@ public interface IAccountService
public readonly struct AccountNotActivated;
public readonly struct AccountDeactivated;
public readonly struct AccountWithEmailOrUsernameExists;
public readonly struct UsernameAlreadyTaken;
public readonly struct EmailAlreadyTaken
{
/// <summary>True if the existing user has a password hash and can therefore log in to link this provider.</summary>
public bool HasPassword { get; init; }
}
public readonly struct CannotDeactivatePrivilegedAccount;
public readonly struct AccountDeactivationAlreadyInProgress;
public readonly struct CannotDeletePrivilegedAccount;
Expand Down
10 changes: 10 additions & 0 deletions Common/Errors/AccountError.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,16 @@ public static class AccountError
public static OpenShockProblem EmailChangeAlreadyInUse => new OpenShockProblem("Account.Email.AlreadyInUse",
"This email address is already in use", HttpStatusCode.Conflict);

public static OpenShockProblem EmailTakenLinkAvailable => new OpenShockProblem(
"Account.Email.TakenLinkAvailable",
"An account with this email already exists. Sign in with your password to link this provider to your existing account.",
HttpStatusCode.Conflict);

public static OpenShockProblem EmailTakenLinkUnavailable => new OpenShockProblem(
"Account.Email.TakenLinkUnavailable",
"An account with this email already exists but cannot be linked via password.",
HttpStatusCode.Conflict);

public static OpenShockProblem EmailChangeUnchanged => new OpenShockProblem("Account.Email.Unchanged",
"The new email address is the same as the current one", HttpStatusCode.BadRequest);

Expand Down
Loading