Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
8 changes: 8 additions & 0 deletions API/Controller/Account/LoginV2.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@
using Asp.Versioning;
using Microsoft.AspNetCore.RateLimiting;
using OpenShock.Common.Errors;
using OpenShock.Common.Extensions;
using OpenShock.Common.Problems;
using OpenShock.Common.Services.Bypass;
using OpenShock.Common.Utils;
using OpenShock.API.Errors;
using OpenShock.API.Models.Response;
Expand All @@ -30,6 +32,7 @@ public sealed partial class AccountController
public async Task<IActionResult> LoginV2(
[FromBody] LoginV2 body,
[FromServices] ICloudflareTurnstileService turnstileService,
[FromServices] IBypassTokenService bypassTokens,
CancellationToken cancellationToken)
{
var cookieDomain = GetCurrentCookieDomain();
Expand Down Expand Up @@ -57,6 +60,11 @@ public async Task<IActionResult> LoginV2(
);
}

// Admin accounts must never be authenticated through a bypassed flow — RecordUseAsync returns
// false in that one case and the request is rejected with the same shape as a bad turnstile token.
if (!await bypassTokens.TryRecordUseAsync(account.Id, cancellationToken))
return Problem(TurnstileError.InvalidTurnstile);

await CreateSession(account.Id, cookieDomain);

return Ok(LoginV2OkResponse.FromUser(account));
Expand Down
17 changes: 12 additions & 5 deletions API/Controller/Account/PasswordResetInitiateV2.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@
using OpenShock.API.Errors;
using OpenShock.API.Models.Requests;
using OpenShock.API.Services.Turnstile;
using OpenShock.Common.Extensions;
using OpenShock.Common.Problems;
using OpenShock.Common.Services.Bypass;
using OpenShock.Common.Utils;

namespace OpenShock.API.Controller.Account;
Expand All @@ -24,8 +26,8 @@ public sealed partial class AccountController
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType<OpenShockProblem>(StatusCodes.Status403Forbidden, MediaTypeNames.Application.ProblemJson)]
[MapToApiVersion("2")]
public Task<IActionResult> PasswordResetInitiateV2([FromBody] PasswordResetRequestV2 body, [FromServices] ICloudflareTurnstileService turnstileService, CancellationToken cancellationToken)
=> PasswordResetInitiate(body, turnstileService, cancellationToken);
public Task<IActionResult> PasswordResetInitiateV2([FromBody] PasswordResetRequestV2 body, [FromServices] ICloudflareTurnstileService turnstileService, [FromServices] IBypassTokenService bypassTokens, CancellationToken cancellationToken)
=> PasswordResetInitiate(body, turnstileService, bypassTokens, cancellationToken);

/// <summary>
/// Initiate a password reset. Deprecated: use POST /password-reset instead.
Expand All @@ -38,10 +40,10 @@ public Task<IActionResult> PasswordResetInitiateV2([FromBody] PasswordResetReque
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType<OpenShockProblem>(StatusCodes.Status403Forbidden, MediaTypeNames.Application.ProblemJson)]
[MapToApiVersion("2")]
public Task<IActionResult> PasswordResetInitiateV2Legacy([FromBody] PasswordResetRequestV2 body, [FromServices] ICloudflareTurnstileService turnstileService, CancellationToken cancellationToken)
=> PasswordResetInitiate(body, turnstileService, cancellationToken);
public Task<IActionResult> PasswordResetInitiateV2Legacy([FromBody] PasswordResetRequestV2 body, [FromServices] ICloudflareTurnstileService turnstileService, [FromServices] IBypassTokenService bypassTokens, CancellationToken cancellationToken)
=> PasswordResetInitiate(body, turnstileService, bypassTokens, cancellationToken);

private async Task<IActionResult> PasswordResetInitiate(PasswordResetRequestV2 body, ICloudflareTurnstileService turnstileService, CancellationToken cancellationToken)
private async Task<IActionResult> PasswordResetInitiate(PasswordResetRequestV2 body, ICloudflareTurnstileService turnstileService, IBypassTokenService bypassTokens, CancellationToken cancellationToken)
{
var turnStile = await turnstileService.VerifyUserResponseTokenAsync(body.TurnstileResponse, HttpContext.GetRemoteIP(), cancellationToken);
if (!turnStile.IsT0)
Expand All @@ -53,6 +55,11 @@ private async Task<IActionResult> PasswordResetInitiate(PasswordResetRequestV2 b
return Problem(new OpenShockProblem("InternalServerError", "Internal Server Error", HttpStatusCode.InternalServerError));
}

// If a bypass token resolved against an admin email, abort silently — same response shape as
// a missing/non-admin email so the bypass scheme can't be used to enumerate admin addresses.
if (!await bypassTokens.TryRecordUseByEmailAsync(body.Email, cancellationToken))
return Ok();

await _accountService.CreatePasswordResetFlowAsync(body.Email);

return Ok();
Expand Down
15 changes: 11 additions & 4 deletions API/Controller/Account/SignupV2.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@
using OpenShock.API.Errors;
using OpenShock.API.Services.Turnstile;
using OpenShock.Common.Errors;
using OpenShock.Common.Extensions;
using OpenShock.Common.Problems;
using OpenShock.Common.Services.Bypass;
using OpenShock.Common.Utils;

namespace OpenShock.API.Controller.Account;
Expand All @@ -19,6 +21,7 @@ public sealed partial class AccountController
/// </summary>
/// <param name="body"></param>
/// <param name="turnstileService"></param>
/// <param name="bypassTokens"></param>
/// <param name="cancellationToken"></param>
/// <response code="200">User successfully signed up</response>
/// <response code="400">Username or email already exists</response>
Expand All @@ -32,6 +35,7 @@ public sealed partial class AccountController
public async Task<IActionResult> SignUpV2(
[FromBody] SignUpV2 body,
[FromServices] ICloudflareTurnstileService turnstileService,
[FromServices] IBypassTokenService bypassTokens,
CancellationToken cancellationToken)
{
var turnStile = await turnstileService.VerifyUserResponseTokenAsync(body.TurnstileResponse, HttpContext.GetRemoteIP(), cancellationToken);
Expand All @@ -45,9 +49,12 @@ public async Task<IActionResult> SignUpV2(
}

var creationAction = await _accountService.CreateAccountWithActivationFlowAsync(body.Email, body.Username, body.Password);
return creationAction.Match<IActionResult>(
_ => Ok(),
_ => Problem(SignupError.UsernameOrEmailExists)
);
if (!creationAction.TryPickT0(out var created, out _))
return Problem(SignupError.UsernameOrEmailExists);

// No-op when no bypass token resolved. Signups can't yield an Admin user, so the bool return is ignored.
await bypassTokens.TryRecordUseAsync(created.Value.Id, cancellationToken);

return Ok();
}
}
42 changes: 42 additions & 0 deletions API/Controller/Admin/BypassTokenCreate.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
using System.Net.Mime;
using Microsoft.AspNetCore.Mvc;
using OpenShock.API.Controller.Admin.DTOs;
using OpenShock.Common.OpenShockDb;
using OpenShock.Common.Services.Bypass;
using OpenShock.Common.Utils;

namespace OpenShock.API.Controller.Admin;

public sealed partial class AdminController
{
[HttpPost("bypassTokens")]
[Consumes(MediaTypeNames.Application.Json)]
[ProducesResponseType<CreatedBypassTokenDto>(StatusCodes.Status200OK)]
public async Task<CreatedBypassTokenDto> CreateBypassToken([FromBody] CreateBypassTokenDto body, CancellationToken ct)
{
var secret = IBypassTokenService.GenerateSecret();
var token = new BypassToken
{
Id = Guid.CreateVersion7(),
Name = body.Name.Trim(),
TokenHash = HashingUtils.HashToken(secret),
Types = [.. body.Types.Distinct()],
AutoCleanupUsers = body.AutoCleanupUsers,
AutoCleanupAfter = body.AutoCleanupAfter,
};

_db.BypassTokens.Add(token);
await _db.SaveChangesAsync(ct);

return new CreatedBypassTokenDto
{
Id = token.Id,
Name = token.Name,
Secret = secret,
Types = token.Types,
CreatedAt = token.CreatedAt,
AutoCleanupUsers = token.AutoCleanupUsers,
AutoCleanupAfter = token.AutoCleanupAfter,
};
}
}
17 changes: 17 additions & 0 deletions API/Controller/Admin/BypassTokenDelete.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;

namespace OpenShock.API.Controller.Admin;

public sealed partial class AdminController
{
[HttpDelete("bypassTokens/{id}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> DeleteBypassToken([FromRoute] Guid id, CancellationToken ct)
{
var nDeleted = await _db.BypassTokens.Where(t => t.Id == id).ExecuteDeleteAsync(ct);

return nDeleted == 0 ? NotFound() : Ok();
}
}
29 changes: 29 additions & 0 deletions API/Controller/Admin/BypassTokenList.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using OpenShock.API.Controller.Admin.DTOs;

namespace OpenShock.API.Controller.Admin;

public sealed partial class AdminController
{
[HttpGet("bypassTokens")]
public async IAsyncEnumerable<BypassTokenDto> ListBypassTokens()
{
await foreach (var token in _db.BypassTokens.AsNoTracking().AsAsyncEnumerable())
{
yield return new BypassTokenDto
{
Id = token.Id,
Name = token.Name,
Types = token.Types,
CreatedAt = token.CreatedAt,
LastUsedAt = token.LastUsedAt,
LastUsedByUserId = token.LastUsedByUserId,
LastRotatedAt = token.LastRotatedAt,
UseCount = token.UseCount,
AutoCleanupUsers = token.AutoCleanupUsers,
AutoCleanupAfter = token.AutoCleanupAfter,
};
}
}
}
43 changes: 43 additions & 0 deletions API/Controller/Admin/BypassTokenPatch.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
using System.Net.Mime;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using OpenShock.API.Controller.Admin.DTOs;

namespace OpenShock.API.Controller.Admin;

public sealed partial class AdminController
{
[HttpPatch("bypassTokens/{id}")]
[Consumes(MediaTypeNames.Application.Json)]
[ProducesResponseType<BypassTokenDto>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> PatchBypassToken([FromRoute] Guid id, [FromBody] PatchBypassTokenDto body, CancellationToken ct)
{
var token = await _db.BypassTokens.FirstOrDefaultAsync(t => t.Id == id, ct);
if (token is null) return NotFound();

if (body.Name is not null) token.Name = body.Name.Trim();
if (body.Types is not null) token.Types = [.. body.Types.Distinct()];
if (body.AutoCleanupUsers is not null) token.AutoCleanupUsers = body.AutoCleanupUsers.Value;
if (body.AutoCleanupAfter is not null) token.AutoCleanupAfter = body.AutoCleanupAfter;

if (token.AutoCleanupUsers && token.AutoCleanupAfter is null)
return Problem("AutoCleanupAfter is required when AutoCleanupUsers is true.", statusCode: StatusCodes.Status400BadRequest);

await _db.SaveChangesAsync(ct);

return Ok(new BypassTokenDto
{
Id = token.Id,
Name = token.Name,
Types = token.Types,
CreatedAt = token.CreatedAt,
LastUsedAt = token.LastUsedAt,
LastUsedByUserId = token.LastUsedByUserId,
LastRotatedAt = token.LastRotatedAt,
UseCount = token.UseCount,
AutoCleanupUsers = token.AutoCleanupUsers,
AutoCleanupAfter = token.AutoCleanupAfter,
});
}
}
45 changes: 45 additions & 0 deletions API/Controller/Admin/BypassTokenRotate.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
using System.Net.Mime;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using OpenShock.API.Controller.Admin.DTOs;
using OpenShock.Common.Services.Bypass;
using OpenShock.Common.Utils;

namespace OpenShock.API.Controller.Admin;

public sealed partial class AdminController
{
[HttpPost("bypassTokens/{id}/rotate")]
[Consumes(MediaTypeNames.Application.Json)]
[ProducesResponseType<CreatedBypassTokenDto>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> RotateBypassToken([FromRoute] Guid id, CancellationToken ct)
{
var token = await _db.BypassTokens.FirstOrDefaultAsync(t => t.Id == id, ct);
if (token is null) return NotFound();

var secret = IBypassTokenService.GenerateSecret();
token.TokenHash = HashingUtils.HashToken(secret);
token.LastUsedAt = null;
token.LastUsedByUserId = null;
token.UseCount = 0;
token.LastRotatedAt = DateTime.UtcNow;

await _db.SaveChangesAsync(ct);

// BypassTokenUserUse rows are intentionally NOT cleared — the auto-cleanup pact is
// per-user-account, not per-secret-version. Rotating doesn't pardon previously-used accounts.

return Ok(new CreatedBypassTokenDto
{
Id = token.Id,
Name = token.Name,
Secret = secret,
Types = token.Types,
CreatedAt = token.CreatedAt,
LastRotatedAt = token.LastRotatedAt,
AutoCleanupUsers = token.AutoCleanupUsers,
AutoCleanupAfter = token.AutoCleanupAfter,
});
}
}
29 changes: 29 additions & 0 deletions API/Controller/Admin/DTOs/BypassTokenDto.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
using OpenShock.Common.Models;

namespace OpenShock.API.Controller.Admin.DTOs;

public sealed class BypassTokenDto
{
public required Guid Id { get; init; }
public required string Name { get; init; }
public required IReadOnlyList<BypassTokenType> Types { get; init; }
public required DateTime CreatedAt { get; init; }
public DateTime? LastUsedAt { get; init; }
public Guid? LastUsedByUserId { get; init; }
public DateTime? LastRotatedAt { get; init; }
public long UseCount { get; init; }
public bool AutoCleanupUsers { get; init; }
public TimeSpan? AutoCleanupAfter { get; init; }
}

public sealed class CreatedBypassTokenDto
{
public required Guid Id { get; init; }
public required string Name { get; init; }
public required string Secret { get; init; }
public required IReadOnlyList<BypassTokenType> Types { get; init; }
public required DateTime CreatedAt { get; init; }
public DateTime? LastRotatedAt { get; init; }
public bool AutoCleanupUsers { get; init; }
public TimeSpan? AutoCleanupAfter { get; init; }
}
46 changes: 46 additions & 0 deletions API/Controller/Admin/DTOs/CreateBypassTokenDto.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
using System.ComponentModel.DataAnnotations;
using OpenShock.Common.Constants;
using OpenShock.Common.Models;

namespace OpenShock.API.Controller.Admin.DTOs;

public sealed class CreateBypassTokenDto : IValidatableObject
{
[Required]
[MinLength(1)]
[MaxLength(HardLimits.ApiKeyNameMaxLength)]
public required string Name { get; init; }

[Required]
[MinLength(1)]
public required IReadOnlyList<BypassTokenType> Types { get; init; }

public bool AutoCleanupUsers { get; init; }

public TimeSpan? AutoCleanupAfter { get; init; }

public IEnumerable<ValidationResult> Validate(ValidationContext _)
{
if (AutoCleanupUsers && AutoCleanupAfter is null)
yield return new ValidationResult(
$"{nameof(AutoCleanupAfter)} is required when {nameof(AutoCleanupUsers)} is true.",
[nameof(AutoCleanupAfter)]);

if (AutoCleanupAfter is { } d && d <= TimeSpan.Zero)
yield return new ValidationResult(
$"{nameof(AutoCleanupAfter)} must be positive.",
[nameof(AutoCleanupAfter)]);
}
}

public sealed class PatchBypassTokenDto
{
[MaxLength(HardLimits.ApiKeyNameMaxLength)]
public string? Name { get; init; }

public IReadOnlyList<BypassTokenType>? Types { get; init; }

public bool? AutoCleanupUsers { get; init; }

public TimeSpan? AutoCleanupAfter { get; init; }
}
Loading
Loading