Skip to content

Commit 6b95b49

Browse files
committed
Initial proposal
1 parent c962bd1 commit 6b95b49

26 files changed

Lines changed: 746 additions & 13 deletions

API/Controller/Account/LoginV2.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@
55
using Asp.Versioning;
66
using Microsoft.AspNetCore.RateLimiting;
77
using OpenShock.Common.Errors;
8+
using OpenShock.Common.Extensions;
89
using OpenShock.Common.Problems;
10+
using OpenShock.Common.Services.Bypass;
911
using OpenShock.Common.Utils;
1012
using OpenShock.API.Errors;
1113
using OpenShock.API.Models.Response;
@@ -30,6 +32,7 @@ public sealed partial class AccountController
3032
public async Task<IActionResult> LoginV2(
3133
[FromBody] LoginV2 body,
3234
[FromServices] ICloudflareTurnstileService turnstileService,
35+
[FromServices] IBypassTokenService bypassTokens,
3336
CancellationToken cancellationToken)
3437
{
3538
var cookieDomain = GetCurrentCookieDomain();
@@ -57,6 +60,11 @@ public async Task<IActionResult> LoginV2(
5760
);
5861
}
5962

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

6270
return Ok(LoginV2OkResponse.FromUser(account));

API/Controller/Account/PasswordResetInitiateV2.cs

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@
77
using OpenShock.API.Errors;
88
using OpenShock.API.Models.Requests;
99
using OpenShock.API.Services.Turnstile;
10+
using OpenShock.Common.Extensions;
1011
using OpenShock.Common.Problems;
12+
using OpenShock.Common.Services.Bypass;
1113
using OpenShock.Common.Utils;
1214

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

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

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

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

5865
return Ok();

API/Controller/Account/SignupV2.cs

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@
77
using OpenShock.API.Errors;
88
using OpenShock.API.Services.Turnstile;
99
using OpenShock.Common.Errors;
10+
using OpenShock.Common.Extensions;
1011
using OpenShock.Common.Problems;
12+
using OpenShock.Common.Services.Bypass;
1113
using OpenShock.Common.Utils;
1214

1315
namespace OpenShock.API.Controller.Account;
@@ -19,6 +21,7 @@ public sealed partial class AccountController
1921
/// </summary>
2022
/// <param name="body"></param>
2123
/// <param name="turnstileService"></param>
24+
/// <param name="bypassTokens"></param>
2225
/// <param name="cancellationToken"></param>
2326
/// <response code="200">User successfully signed up</response>
2427
/// <response code="400">Username or email already exists</response>
@@ -32,6 +35,7 @@ public sealed partial class AccountController
3235
public async Task<IActionResult> SignUpV2(
3336
[FromBody] SignUpV2 body,
3437
[FromServices] ICloudflareTurnstileService turnstileService,
38+
[FromServices] IBypassTokenService bypassTokens,
3539
CancellationToken cancellationToken)
3640
{
3741
var turnStile = await turnstileService.VerifyUserResponseTokenAsync(body.TurnstileResponse, HttpContext.GetRemoteIP(), cancellationToken);
@@ -45,9 +49,12 @@ public async Task<IActionResult> SignUpV2(
4549
}
4650

4751
var creationAction = await _accountService.CreateAccountWithActivationFlowAsync(body.Email, body.Username, body.Password);
48-
return creationAction.Match<IActionResult>(
49-
_ => Ok(),
50-
_ => Problem(SignupError.UsernameOrEmailExists)
51-
);
52+
if (!creationAction.TryPickT0(out var created, out _))
53+
return Problem(SignupError.UsernameOrEmailExists);
54+
55+
// No-op when no bypass token resolved. Signups can't yield an Admin user, so the bool return is ignored.
56+
await bypassTokens.TryRecordUseAsync(created.Value.Id, cancellationToken);
57+
58+
return Ok();
5259
}
5360
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
using System.Net.Mime;
2+
using Microsoft.AspNetCore.Mvc;
3+
using OpenShock.API.Controller.Admin.DTOs;
4+
using OpenShock.Common.OpenShockDb;
5+
using OpenShock.Common.Services.Bypass;
6+
using OpenShock.Common.Utils;
7+
8+
namespace OpenShock.API.Controller.Admin;
9+
10+
public sealed partial class AdminController
11+
{
12+
[HttpPost("bypassTokens")]
13+
[Consumes(MediaTypeNames.Application.Json)]
14+
[ProducesResponseType<CreatedBypassTokenDto>(StatusCodes.Status200OK)]
15+
public async Task<CreatedBypassTokenDto> CreateBypassToken([FromBody] CreateBypassTokenDto body, CancellationToken ct)
16+
{
17+
var secret = IBypassTokenService.GenerateSecret();
18+
var token = new BypassToken
19+
{
20+
Id = Guid.CreateVersion7(),
21+
Name = body.Name.Trim(),
22+
TokenHash = HashingUtils.HashToken(secret),
23+
Types = [.. body.Types.Distinct()],
24+
AutoCleanupUsers = body.AutoCleanupUsers,
25+
AutoCleanupAfter = body.AutoCleanupAfter,
26+
};
27+
28+
_db.BypassTokens.Add(token);
29+
await _db.SaveChangesAsync(ct);
30+
31+
return new CreatedBypassTokenDto
32+
{
33+
Id = token.Id,
34+
Name = token.Name,
35+
Secret = secret,
36+
Types = token.Types,
37+
CreatedAt = token.CreatedAt,
38+
AutoCleanupUsers = token.AutoCleanupUsers,
39+
AutoCleanupAfter = token.AutoCleanupAfter,
40+
};
41+
}
42+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
using Microsoft.AspNetCore.Mvc;
2+
using Microsoft.EntityFrameworkCore;
3+
4+
namespace OpenShock.API.Controller.Admin;
5+
6+
public sealed partial class AdminController
7+
{
8+
[HttpDelete("bypassTokens/{id}")]
9+
[ProducesResponseType(StatusCodes.Status200OK)]
10+
[ProducesResponseType(StatusCodes.Status404NotFound)]
11+
public async Task<IActionResult> DeleteBypassToken([FromRoute] Guid id, CancellationToken ct)
12+
{
13+
var nDeleted = await _db.BypassTokens.Where(t => t.Id == id).ExecuteDeleteAsync(ct);
14+
15+
return nDeleted == 0 ? NotFound() : Ok();
16+
}
17+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
using Microsoft.AspNetCore.Mvc;
2+
using Microsoft.EntityFrameworkCore;
3+
using OpenShock.API.Controller.Admin.DTOs;
4+
5+
namespace OpenShock.API.Controller.Admin;
6+
7+
public sealed partial class AdminController
8+
{
9+
[HttpGet("bypassTokens")]
10+
public async IAsyncEnumerable<BypassTokenDto> ListBypassTokens()
11+
{
12+
await foreach (var token in _db.BypassTokens.AsNoTracking().AsAsyncEnumerable())
13+
{
14+
yield return new BypassTokenDto
15+
{
16+
Id = token.Id,
17+
Name = token.Name,
18+
Types = token.Types,
19+
CreatedAt = token.CreatedAt,
20+
LastUsedAt = token.LastUsedAt,
21+
LastUsedByUserId = token.LastUsedByUserId,
22+
LastRotatedAt = token.LastRotatedAt,
23+
UseCount = token.UseCount,
24+
AutoCleanupUsers = token.AutoCleanupUsers,
25+
AutoCleanupAfter = token.AutoCleanupAfter,
26+
};
27+
}
28+
}
29+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
using System.Net.Mime;
2+
using Microsoft.AspNetCore.Mvc;
3+
using Microsoft.EntityFrameworkCore;
4+
using OpenShock.API.Controller.Admin.DTOs;
5+
6+
namespace OpenShock.API.Controller.Admin;
7+
8+
public sealed partial class AdminController
9+
{
10+
[HttpPatch("bypassTokens/{id}")]
11+
[Consumes(MediaTypeNames.Application.Json)]
12+
[ProducesResponseType<BypassTokenDto>(StatusCodes.Status200OK)]
13+
[ProducesResponseType(StatusCodes.Status404NotFound)]
14+
public async Task<IActionResult> PatchBypassToken([FromRoute] Guid id, [FromBody] PatchBypassTokenDto body, CancellationToken ct)
15+
{
16+
var token = await _db.BypassTokens.FirstOrDefaultAsync(t => t.Id == id, ct);
17+
if (token is null) return NotFound();
18+
19+
if (body.Name is not null) token.Name = body.Name.Trim();
20+
if (body.Types is not null) token.Types = [.. body.Types.Distinct()];
21+
if (body.AutoCleanupUsers is not null) token.AutoCleanupUsers = body.AutoCleanupUsers.Value;
22+
if (body.AutoCleanupAfter is not null) token.AutoCleanupAfter = body.AutoCleanupAfter;
23+
24+
if (token.AutoCleanupUsers && token.AutoCleanupAfter is null)
25+
return Problem("AutoCleanupAfter is required when AutoCleanupUsers is true.", statusCode: StatusCodes.Status400BadRequest);
26+
27+
await _db.SaveChangesAsync(ct);
28+
29+
return Ok(new BypassTokenDto
30+
{
31+
Id = token.Id,
32+
Name = token.Name,
33+
Types = token.Types,
34+
CreatedAt = token.CreatedAt,
35+
LastUsedAt = token.LastUsedAt,
36+
LastUsedByUserId = token.LastUsedByUserId,
37+
LastRotatedAt = token.LastRotatedAt,
38+
UseCount = token.UseCount,
39+
AutoCleanupUsers = token.AutoCleanupUsers,
40+
AutoCleanupAfter = token.AutoCleanupAfter,
41+
});
42+
}
43+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
using System.Net.Mime;
2+
using Microsoft.AspNetCore.Mvc;
3+
using Microsoft.EntityFrameworkCore;
4+
using OpenShock.API.Controller.Admin.DTOs;
5+
using OpenShock.Common.Services.Bypass;
6+
using OpenShock.Common.Utils;
7+
8+
namespace OpenShock.API.Controller.Admin;
9+
10+
public sealed partial class AdminController
11+
{
12+
[HttpPost("bypassTokens/{id}/rotate")]
13+
[Consumes(MediaTypeNames.Application.Json)]
14+
[ProducesResponseType<CreatedBypassTokenDto>(StatusCodes.Status200OK)]
15+
[ProducesResponseType(StatusCodes.Status404NotFound)]
16+
public async Task<IActionResult> RotateBypassToken([FromRoute] Guid id, CancellationToken ct)
17+
{
18+
var token = await _db.BypassTokens.FirstOrDefaultAsync(t => t.Id == id, ct);
19+
if (token is null) return NotFound();
20+
21+
var secret = IBypassTokenService.GenerateSecret();
22+
token.TokenHash = HashingUtils.HashToken(secret);
23+
token.LastUsedAt = null;
24+
token.LastUsedByUserId = null;
25+
token.UseCount = 0;
26+
token.LastRotatedAt = DateTime.UtcNow;
27+
28+
await _db.SaveChangesAsync(ct);
29+
30+
// BypassTokenUserUse rows are intentionally NOT cleared — the auto-cleanup pact is
31+
// per-user-account, not per-secret-version. Rotating doesn't pardon previously-used accounts.
32+
33+
return Ok(new CreatedBypassTokenDto
34+
{
35+
Id = token.Id,
36+
Name = token.Name,
37+
Secret = secret,
38+
Types = token.Types,
39+
CreatedAt = token.CreatedAt,
40+
LastRotatedAt = token.LastRotatedAt,
41+
AutoCleanupUsers = token.AutoCleanupUsers,
42+
AutoCleanupAfter = token.AutoCleanupAfter,
43+
});
44+
}
45+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
using OpenShock.Common.Models;
2+
3+
namespace OpenShock.API.Controller.Admin.DTOs;
4+
5+
public sealed class BypassTokenDto
6+
{
7+
public required Guid Id { get; init; }
8+
public required string Name { get; init; }
9+
public required IReadOnlyList<BypassTokenType> Types { get; init; }
10+
public required DateTime CreatedAt { get; init; }
11+
public DateTime? LastUsedAt { get; init; }
12+
public Guid? LastUsedByUserId { get; init; }
13+
public DateTime? LastRotatedAt { get; init; }
14+
public long UseCount { get; init; }
15+
public bool AutoCleanupUsers { get; init; }
16+
public TimeSpan? AutoCleanupAfter { get; init; }
17+
}
18+
19+
public sealed class CreatedBypassTokenDto
20+
{
21+
public required Guid Id { get; init; }
22+
public required string Name { get; init; }
23+
public required string Secret { get; init; }
24+
public required IReadOnlyList<BypassTokenType> Types { get; init; }
25+
public required DateTime CreatedAt { get; init; }
26+
public DateTime? LastRotatedAt { get; init; }
27+
public bool AutoCleanupUsers { get; init; }
28+
public TimeSpan? AutoCleanupAfter { get; init; }
29+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
using System.ComponentModel.DataAnnotations;
2+
using OpenShock.Common.Constants;
3+
using OpenShock.Common.Models;
4+
5+
namespace OpenShock.API.Controller.Admin.DTOs;
6+
7+
public sealed class CreateBypassTokenDto : IValidatableObject
8+
{
9+
[Required]
10+
[MinLength(1)]
11+
[MaxLength(HardLimits.ApiKeyNameMaxLength)]
12+
public required string Name { get; init; }
13+
14+
[Required]
15+
[MinLength(1)]
16+
public required IReadOnlyList<BypassTokenType> Types { get; init; }
17+
18+
public bool AutoCleanupUsers { get; init; }
19+
20+
public TimeSpan? AutoCleanupAfter { get; init; }
21+
22+
public IEnumerable<ValidationResult> Validate(ValidationContext _)
23+
{
24+
if (AutoCleanupUsers && AutoCleanupAfter is null)
25+
yield return new ValidationResult(
26+
$"{nameof(AutoCleanupAfter)} is required when {nameof(AutoCleanupUsers)} is true.",
27+
[nameof(AutoCleanupAfter)]);
28+
29+
if (AutoCleanupAfter is { } d && d <= TimeSpan.Zero)
30+
yield return new ValidationResult(
31+
$"{nameof(AutoCleanupAfter)} must be positive.",
32+
[nameof(AutoCleanupAfter)]);
33+
}
34+
}
35+
36+
public sealed class PatchBypassTokenDto
37+
{
38+
[MaxLength(HardLimits.ApiKeyNameMaxLength)]
39+
public string? Name { get; init; }
40+
41+
public IReadOnlyList<BypassTokenType>? Types { get; init; }
42+
43+
public bool? AutoCleanupUsers { get; init; }
44+
45+
public TimeSpan? AutoCleanupAfter { get; init; }
46+
}

0 commit comments

Comments
 (0)