From 6b95b49d8425920ea6ab8b2e36a6959a1663b512 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Tue, 26 May 2026 21:20:11 +0200 Subject: [PATCH 01/16] Initial proposal --- API/Controller/Account/LoginV2.cs | 8 ++ .../Account/PasswordResetInitiateV2.cs | 17 +++- API/Controller/Account/SignupV2.cs | 15 ++- API/Controller/Admin/BypassTokenCreate.cs | 42 +++++++++ API/Controller/Admin/BypassTokenDelete.cs | 17 ++++ API/Controller/Admin/BypassTokenList.cs | 29 ++++++ API/Controller/Admin/BypassTokenPatch.cs | 43 +++++++++ API/Controller/Admin/BypassTokenRotate.cs | 45 +++++++++ API/Controller/Admin/DTOs/BypassTokenDto.cs | 29 ++++++ .../Admin/DTOs/CreateBypassTokenDto.cs | 46 +++++++++ API/Controller/Tokens/ReportTokens.cs | 8 ++ .../Turnstile/CloudflareTurnstileService.cs | 19 +++- Common/Constants/AuthConstants.cs | 1 + Common/Extensions/HttpContextExtensions.cs | 37 ++++++++ Common/Middleware/BypassTokenMiddleware.cs | 33 +++++++ Common/Models/BypassTokenType.cs | 9 ++ Common/OpenShockDb/BypassToken.cs | 32 +++++++ Common/OpenShockDb/BypassTokenUserUse.cs | 18 ++++ Common/OpenShockDb/OpenShockContext.cs | 79 ++++++++++++++++ Common/OpenShockDb/User.cs | 1 + Common/OpenShockMiddlewareHelper.cs | 5 + Common/OpenShockServiceHelper.cs | 25 ++++- Common/Services/Bypass/BypassTokenService.cs | 94 +++++++++++++++++++ Common/Services/Bypass/IBypassTokenService.cs | 41 ++++++++ Common/Services/Bypass/ResolvedBypassToken.cs | 11 +++ Cron/Jobs/DeleteBypassTokenUsedAccountsJob.cs | 55 +++++++++++ 26 files changed, 746 insertions(+), 13 deletions(-) create mode 100644 API/Controller/Admin/BypassTokenCreate.cs create mode 100644 API/Controller/Admin/BypassTokenDelete.cs create mode 100644 API/Controller/Admin/BypassTokenList.cs create mode 100644 API/Controller/Admin/BypassTokenPatch.cs create mode 100644 API/Controller/Admin/BypassTokenRotate.cs create mode 100644 API/Controller/Admin/DTOs/BypassTokenDto.cs create mode 100644 API/Controller/Admin/DTOs/CreateBypassTokenDto.cs create mode 100644 Common/Middleware/BypassTokenMiddleware.cs create mode 100644 Common/Models/BypassTokenType.cs create mode 100644 Common/OpenShockDb/BypassToken.cs create mode 100644 Common/OpenShockDb/BypassTokenUserUse.cs create mode 100644 Common/Services/Bypass/BypassTokenService.cs create mode 100644 Common/Services/Bypass/IBypassTokenService.cs create mode 100644 Common/Services/Bypass/ResolvedBypassToken.cs create mode 100644 Cron/Jobs/DeleteBypassTokenUsedAccountsJob.cs diff --git a/API/Controller/Account/LoginV2.cs b/API/Controller/Account/LoginV2.cs index 0fa3e807..84c81a30 100644 --- a/API/Controller/Account/LoginV2.cs +++ b/API/Controller/Account/LoginV2.cs @@ -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; @@ -30,6 +32,7 @@ public sealed partial class AccountController public async Task LoginV2( [FromBody] LoginV2 body, [FromServices] ICloudflareTurnstileService turnstileService, + [FromServices] IBypassTokenService bypassTokens, CancellationToken cancellationToken) { var cookieDomain = GetCurrentCookieDomain(); @@ -57,6 +60,11 @@ public async Task 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)); diff --git a/API/Controller/Account/PasswordResetInitiateV2.cs b/API/Controller/Account/PasswordResetInitiateV2.cs index 136e0635..c8456ce4 100644 --- a/API/Controller/Account/PasswordResetInitiateV2.cs +++ b/API/Controller/Account/PasswordResetInitiateV2.cs @@ -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; @@ -24,8 +26,8 @@ public sealed partial class AccountController [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status403Forbidden, MediaTypeNames.Application.ProblemJson)] [MapToApiVersion("2")] - public Task PasswordResetInitiateV2([FromBody] PasswordResetRequestV2 body, [FromServices] ICloudflareTurnstileService turnstileService, CancellationToken cancellationToken) - => PasswordResetInitiate(body, turnstileService, cancellationToken); + public Task PasswordResetInitiateV2([FromBody] PasswordResetRequestV2 body, [FromServices] ICloudflareTurnstileService turnstileService, [FromServices] IBypassTokenService bypassTokens, CancellationToken cancellationToken) + => PasswordResetInitiate(body, turnstileService, bypassTokens, cancellationToken); /// /// Initiate a password reset. Deprecated: use POST /password-reset instead. @@ -38,10 +40,10 @@ public Task PasswordResetInitiateV2([FromBody] PasswordResetReque [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status403Forbidden, MediaTypeNames.Application.ProblemJson)] [MapToApiVersion("2")] - public Task PasswordResetInitiateV2Legacy([FromBody] PasswordResetRequestV2 body, [FromServices] ICloudflareTurnstileService turnstileService, CancellationToken cancellationToken) - => PasswordResetInitiate(body, turnstileService, cancellationToken); + public Task PasswordResetInitiateV2Legacy([FromBody] PasswordResetRequestV2 body, [FromServices] ICloudflareTurnstileService turnstileService, [FromServices] IBypassTokenService bypassTokens, CancellationToken cancellationToken) + => PasswordResetInitiate(body, turnstileService, bypassTokens, cancellationToken); - private async Task PasswordResetInitiate(PasswordResetRequestV2 body, ICloudflareTurnstileService turnstileService, CancellationToken cancellationToken) + private async Task PasswordResetInitiate(PasswordResetRequestV2 body, ICloudflareTurnstileService turnstileService, IBypassTokenService bypassTokens, CancellationToken cancellationToken) { var turnStile = await turnstileService.VerifyUserResponseTokenAsync(body.TurnstileResponse, HttpContext.GetRemoteIP(), cancellationToken); if (!turnStile.IsT0) @@ -53,6 +55,11 @@ private async Task 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(); diff --git a/API/Controller/Account/SignupV2.cs b/API/Controller/Account/SignupV2.cs index 9f707bb0..87901f45 100644 --- a/API/Controller/Account/SignupV2.cs +++ b/API/Controller/Account/SignupV2.cs @@ -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; @@ -19,6 +21,7 @@ public sealed partial class AccountController /// /// /// + /// /// /// User successfully signed up /// Username or email already exists @@ -32,6 +35,7 @@ public sealed partial class AccountController public async Task SignUpV2( [FromBody] SignUpV2 body, [FromServices] ICloudflareTurnstileService turnstileService, + [FromServices] IBypassTokenService bypassTokens, CancellationToken cancellationToken) { var turnStile = await turnstileService.VerifyUserResponseTokenAsync(body.TurnstileResponse, HttpContext.GetRemoteIP(), cancellationToken); @@ -45,9 +49,12 @@ public async Task SignUpV2( } var creationAction = await _accountService.CreateAccountWithActivationFlowAsync(body.Email, body.Username, body.Password); - return creationAction.Match( - _ => 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(); } } \ No newline at end of file diff --git a/API/Controller/Admin/BypassTokenCreate.cs b/API/Controller/Admin/BypassTokenCreate.cs new file mode 100644 index 00000000..70e228a4 --- /dev/null +++ b/API/Controller/Admin/BypassTokenCreate.cs @@ -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(StatusCodes.Status200OK)] + public async Task 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, + }; + } +} diff --git a/API/Controller/Admin/BypassTokenDelete.cs b/API/Controller/Admin/BypassTokenDelete.cs new file mode 100644 index 00000000..02aa62d0 --- /dev/null +++ b/API/Controller/Admin/BypassTokenDelete.cs @@ -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 DeleteBypassToken([FromRoute] Guid id, CancellationToken ct) + { + var nDeleted = await _db.BypassTokens.Where(t => t.Id == id).ExecuteDeleteAsync(ct); + + return nDeleted == 0 ? NotFound() : Ok(); + } +} diff --git a/API/Controller/Admin/BypassTokenList.cs b/API/Controller/Admin/BypassTokenList.cs new file mode 100644 index 00000000..fea11685 --- /dev/null +++ b/API/Controller/Admin/BypassTokenList.cs @@ -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 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, + }; + } + } +} diff --git a/API/Controller/Admin/BypassTokenPatch.cs b/API/Controller/Admin/BypassTokenPatch.cs new file mode 100644 index 00000000..64f667e5 --- /dev/null +++ b/API/Controller/Admin/BypassTokenPatch.cs @@ -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(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task 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, + }); + } +} diff --git a/API/Controller/Admin/BypassTokenRotate.cs b/API/Controller/Admin/BypassTokenRotate.cs new file mode 100644 index 00000000..d5bedc3c --- /dev/null +++ b/API/Controller/Admin/BypassTokenRotate.cs @@ -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(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task 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, + }); + } +} diff --git a/API/Controller/Admin/DTOs/BypassTokenDto.cs b/API/Controller/Admin/DTOs/BypassTokenDto.cs new file mode 100644 index 00000000..a04b576a --- /dev/null +++ b/API/Controller/Admin/DTOs/BypassTokenDto.cs @@ -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 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 Types { get; init; } + public required DateTime CreatedAt { get; init; } + public DateTime? LastRotatedAt { get; init; } + public bool AutoCleanupUsers { get; init; } + public TimeSpan? AutoCleanupAfter { get; init; } +} diff --git a/API/Controller/Admin/DTOs/CreateBypassTokenDto.cs b/API/Controller/Admin/DTOs/CreateBypassTokenDto.cs new file mode 100644 index 00000000..cebf0433 --- /dev/null +++ b/API/Controller/Admin/DTOs/CreateBypassTokenDto.cs @@ -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 Types { get; init; } + + public bool AutoCleanupUsers { get; init; } + + public TimeSpan? AutoCleanupAfter { get; init; } + + public IEnumerable 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? Types { get; init; } + + public bool? AutoCleanupUsers { get; init; } + + public TimeSpan? AutoCleanupAfter { get; init; } +} diff --git a/API/Controller/Tokens/ReportTokens.cs b/API/Controller/Tokens/ReportTokens.cs index b91c2956..678b3c4a 100644 --- a/API/Controller/Tokens/ReportTokens.cs +++ b/API/Controller/Tokens/ReportTokens.cs @@ -9,6 +9,8 @@ using Microsoft.AspNetCore.RateLimiting; using OpenShock.API.Errors; using OpenShock.API.Services.Turnstile; +using OpenShock.Common.Extensions; +using OpenShock.Common.Services.Bypass; using OpenShock.Common.Services.Webhook; namespace OpenShock.API.Controller.Tokens; @@ -20,6 +22,7 @@ public sealed partial class TokensController /// /// /// + /// /// /// /// The tokens were deleted if found @@ -30,6 +33,7 @@ public sealed partial class TokensController public async Task ReportTokens( [FromBody] ReportTokensRequest body, [FromServices] ICloudflareTurnstileService turnstileService, + [FromServices] IBypassTokenService bypassTokens, [FromServices] IWebhookService webhookService, CancellationToken cancellationToken) { @@ -45,6 +49,10 @@ public async Task ReportTokens( return Problem(new OpenShockProblem("InternalServerError", "Internal Server Error", HttpStatusCode.InternalServerError)); } + // Caller is already authenticated; admin role on a bypass-using request is rejected. + if (!await bypassTokens.TryRecordUseAsync(CurrentUser.Id, cancellationToken)) + return Problem(TurnstileError.InvalidTurnstile); + var reportId = Guid.CreateVersion7(); int nAffected = 0; diff --git a/API/Services/Turnstile/CloudflareTurnstileService.cs b/API/Services/Turnstile/CloudflareTurnstileService.cs index 99cac74b..f504bafc 100644 --- a/API/Services/Turnstile/CloudflareTurnstileService.cs +++ b/API/Services/Turnstile/CloudflareTurnstileService.cs @@ -2,6 +2,8 @@ using OneOf; using OneOf.Types; using OpenShock.API.Options; +using OpenShock.Common.Extensions; +using BypassTokenType = OpenShock.Common.Models.BypassTokenType; namespace OpenShock.API.Services.Turnstile; @@ -12,13 +14,20 @@ public sealed class CloudflareTurnstileService : ICloudflareTurnstileService private readonly HttpClient _httpClient; private readonly TurnstileOptions _options; private readonly IHostEnvironment _environment; + private readonly IHttpContextAccessor _httpContextAccessor; private readonly ILogger _logger; - public CloudflareTurnstileService(HttpClient httpClient, TurnstileOptions options, IHostEnvironment environment, ILogger logger) + public CloudflareTurnstileService( + HttpClient httpClient, + TurnstileOptions options, + IHostEnvironment environment, + IHttpContextAccessor httpContextAccessor, + ILogger logger) { _httpClient = httpClient; _options = options; _environment = environment; + _httpContextAccessor = httpContextAccessor; _logger = logger; } @@ -48,6 +57,14 @@ public async Task>> VerifyUserR { if (!_options.Enabled) return new Success(); + // An admin-issued bypass token resolved earlier in the pipeline counts as a Turnstile pass + // if it carries the Turnstile type. The middleware already bumped use counters; controllers + // separately call IBypassTokenService.RecordUseAsync after auth so admin-using requests can + // be rejected and per-user cleanup can run. + var resolvedBypass = _httpContextAccessor.HttpContext?.GetResolvedBypassToken(); + if (resolvedBypass is not null && resolvedBypass.Types.Contains(BypassTokenType.Turnstile)) + return new Success(); + if (string.IsNullOrEmpty(responseToken)) return CreateError(CloudflareTurnstileError.MissingResponse); if (_environment.IsDevelopment() && responseToken == "dev-bypass") diff --git a/Common/Constants/AuthConstants.cs b/Common/Constants/AuthConstants.cs index 3f1153d5..340e3545 100644 --- a/Common/Constants/AuthConstants.cs +++ b/Common/Constants/AuthConstants.cs @@ -6,6 +6,7 @@ public static class AuthConstants public const string UserSessionHeaderName = "OpenShockSession"; public const string ApiTokenHeaderName = "OpenShockToken"; public const string HubTokenHeaderName = "DeviceToken"; + public const string BypassTokenHeaderName = "X-OpenShock-Bypass-Token"; public const int GeneratedTokenLength = 32; public const int ApiTokenLength = 64; diff --git a/Common/Extensions/HttpContextExtensions.cs b/Common/Extensions/HttpContextExtensions.cs index 49702675..9e4ab5d2 100644 --- a/Common/Extensions/HttpContextExtensions.cs +++ b/Common/Extensions/HttpContextExtensions.cs @@ -1,10 +1,47 @@ using System.Diagnostics.CodeAnalysis; +using Microsoft.AspNetCore.Http; using OpenShock.Common.Constants; +using OpenShock.Common.Services.Bypass; namespace OpenShock.Common.Extensions; public static class HttpContextExtensions { + private static readonly object ResolvedBypassTokenItemKey = new(); + + public static bool TryGetBypassTokenFromHeader(this HttpContext context, [NotNullWhen(true)] out string? token) + { + ArgumentNullException.ThrowIfNull(context); + + if (context.Request.Headers.TryGetValue(AuthConstants.BypassTokenHeaderName, out var value) && !string.IsNullOrEmpty(value)) + { + token = value!; + return true; + } + + token = null; + return false; + } + + /// + /// Stores the result of resolving the bypass-token header. Called by the bypass middleware. + /// + public static void SetResolvedBypassToken(this HttpContext context, ResolvedBypassToken? resolved) + { + ArgumentNullException.ThrowIfNull(context); + context.Items[ResolvedBypassTokenItemKey] = resolved; + } + + /// + /// Returns the bypass token resolved earlier in the pipeline, or null if no header was + /// present (or it did not resolve to a known token). + /// + public static ResolvedBypassToken? GetResolvedBypassToken(this HttpContext context) + { + ArgumentNullException.ThrowIfNull(context); + return context.Items.TryGetValue(ResolvedBypassTokenItemKey, out var v) ? v as ResolvedBypassToken : null; + } + private static readonly string[] TokenHeaderNames = [ AuthConstants.ApiTokenHeaderName, "Open-Shock-Token", diff --git a/Common/Middleware/BypassTokenMiddleware.cs b/Common/Middleware/BypassTokenMiddleware.cs new file mode 100644 index 00000000..005e698e --- /dev/null +++ b/Common/Middleware/BypassTokenMiddleware.cs @@ -0,0 +1,33 @@ +using OpenShock.Common.Extensions; +using OpenShock.Common.Services.Bypass; + +namespace OpenShock.Common.Middleware; + +/// +/// Resolves the X-OpenShock-Bypass-Token header (if present) into a +/// stored on . Downstream guards (rate limiter partition selectors, +/// the turnstile service, controllers needing post-auth user linkage) read the cached value +/// synchronously and filter on the type they care about. +/// +/// Runs before UseRateLimiter so the rate limiter can honor the bypass for the very same request. +/// +public sealed class BypassTokenMiddleware +{ + private readonly RequestDelegate _next; + + public BypassTokenMiddleware(RequestDelegate next) + { + _next = next; + } + + public async Task InvokeAsync(HttpContext context, IBypassTokenService bypassTokens) + { + if (context.TryGetBypassTokenFromHeader(out var secret)) + { + var resolved = await bypassTokens.ResolveAsync(secret, context.RequestAborted); + context.SetResolvedBypassToken(resolved); + } + + await _next(context); + } +} diff --git a/Common/Models/BypassTokenType.cs b/Common/Models/BypassTokenType.cs new file mode 100644 index 00000000..a669cf08 --- /dev/null +++ b/Common/Models/BypassTokenType.cs @@ -0,0 +1,9 @@ +using NpgsqlTypes; + +namespace OpenShock.Common.Models; + +public enum BypassTokenType +{ + [PgName("turnstile")] Turnstile, + [PgName("rate_limit")] RateLimit +} diff --git a/Common/OpenShockDb/BypassToken.cs b/Common/OpenShockDb/BypassToken.cs new file mode 100644 index 00000000..a0fd4e36 --- /dev/null +++ b/Common/OpenShockDb/BypassToken.cs @@ -0,0 +1,32 @@ +using OpenShock.Common.Models; + +namespace OpenShock.Common.OpenShockDb; + +public sealed class BypassToken +{ + public required Guid Id { get; set; } + + public required string Name { get; set; } + + public required string TokenHash { get; set; } + + public required List Types { get; set; } + + public DateTime CreatedAt { get; set; } + + public DateTime? LastUsedAt { get; set; } + + public Guid? LastUsedByUserId { get; set; } + + public DateTime? LastRotatedAt { get; set; } + + public long UseCount { get; set; } + + public bool AutoCleanupUsers { get; set; } + + public TimeSpan? AutoCleanupAfter { get; set; } + + // Navigations + public User? LastUsedByUser { get; set; } + public ICollection UserUses { get; } = []; +} diff --git a/Common/OpenShockDb/BypassTokenUserUse.cs b/Common/OpenShockDb/BypassTokenUserUse.cs new file mode 100644 index 00000000..d5db3d2a --- /dev/null +++ b/Common/OpenShockDb/BypassTokenUserUse.cs @@ -0,0 +1,18 @@ +namespace OpenShock.Common.OpenShockDb; + +public sealed class BypassTokenUserUse +{ + public required Guid BypassTokenId { get; set; } + + public required Guid UserId { get; set; } + + public DateTime FirstUsedAt { get; set; } + + public DateTime LastUsedAt { get; set; } + + public long UseCount { get; set; } + + // Navigations + public BypassToken BypassToken { get; set; } = null!; + public User User { get; set; } = null!; +} diff --git a/Common/OpenShockDb/OpenShockContext.cs b/Common/OpenShockDb/OpenShockContext.cs index 2742d711..6992389a 100644 --- a/Common/OpenShockDb/OpenShockContext.cs +++ b/Common/OpenShockDb/OpenShockContext.cs @@ -70,6 +70,7 @@ public static void ConfigureOptionsBuilder(DbContextOptionsBuilder optionsBuilde npgsqlBuilder.MapEnum(); npgsqlBuilder.MapEnum(); npgsqlBuilder.MapEnum(); + npgsqlBuilder.MapEnum(); }); if (debug) @@ -126,6 +127,10 @@ public static void ConfigureOptionsBuilder(DbContextOptionsBuilder optionsBuilde public DbSet UserNameBlacklists { get; set; } public DbSet EmailProviderBlacklists { get; set; } + + public DbSet BypassTokens { get; set; } + + public DbSet BypassTokenUserUses { get; set; } public DbSet DataProtectionKeys { get; set; } @@ -149,6 +154,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .HasPostgresEnum("shocker_model_type", ["caiXianlin", "petTrainer", "petrainer998DR", "wellturnT330"]) .HasPostgresEnum("match_type_enum", ["exact", "contains"]) .HasPostgresEnum("configuration_value_type", ["string", "bool", "int", "float", "json"]) + .HasPostgresEnum("bypass_token_type", ["turnstile", "rate_limit"]) .HasCollation("public", "ndcoll", "und-u-ks-level2", "icu", false); // Add case-insensitive, accent-sensitive comparison collation modelBuilder.Entity(entity => @@ -847,6 +853,79 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .HasColumnName("created_at"); }); + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id).HasName("bypass_tokens_pkey"); + + entity.ToTable("bypass_tokens"); + + entity.HasIndex(e => e.TokenHash).IsUnique(); + entity.HasIndex(e => e.LastUsedByUserId); + + entity.Property(e => e.Id) + .ValueGeneratedNever() + .HasColumnName("id"); + entity.Property(e => e.Name) + .VarCharWithLength(HardLimits.ApiKeyNameMaxLength) + .HasColumnName("name"); + entity.Property(e => e.TokenHash) + .UseCollation("C") + .VarCharWithLength(HardLimits.Sha256HashHexLength) + .HasColumnName("token_hash"); + entity.Property(e => e.Types) + .HasColumnType("bypass_token_type[]") + .HasColumnName("types"); + entity.Property(e => e.CreatedAt) + .HasDefaultValueSql("CURRENT_TIMESTAMP") + .HasColumnName("created_at"); + entity.Property(e => e.LastUsedAt).HasColumnName("last_used_at"); + entity.Property(e => e.LastUsedByUserId).HasColumnName("last_used_by_user_id"); + entity.Property(e => e.LastRotatedAt).HasColumnName("last_rotated_at"); + entity.Property(e => e.UseCount) + .HasDefaultValue(0L) + .HasColumnName("use_count"); + entity.Property(e => e.AutoCleanupUsers) + .HasDefaultValue(false) + .HasColumnName("auto_cleanup_users"); + entity.Property(e => e.AutoCleanupAfter).HasColumnName("auto_cleanup_after"); + + entity.HasOne(d => d.LastUsedByUser).WithMany() + .HasForeignKey(d => d.LastUsedByUserId) + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_bypass_tokens_last_used_by_user_id"); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => new { e.BypassTokenId, e.UserId }).HasName("bypass_token_user_uses_pkey"); + + entity.ToTable("bypass_token_user_uses"); + + entity.HasIndex(e => e.LastUsedAt); + + entity.Property(e => e.BypassTokenId).HasColumnName("bypass_token_id"); + entity.Property(e => e.UserId).HasColumnName("user_id"); + entity.Property(e => e.FirstUsedAt) + .HasDefaultValueSql("CURRENT_TIMESTAMP") + .HasColumnName("first_used_at"); + entity.Property(e => e.LastUsedAt) + .HasDefaultValueSql("CURRENT_TIMESTAMP") + .HasColumnName("last_used_at"); + entity.Property(e => e.UseCount) + .HasDefaultValue(0L) + .HasColumnName("use_count"); + + entity.HasOne(d => d.BypassToken).WithMany(p => p.UserUses) + .HasForeignKey(d => d.BypassTokenId) + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_bypass_token_user_uses_bypass_token_id"); + + entity.HasOne(d => d.User).WithMany(p => p.BypassTokenUses) + .HasForeignKey(d => d.UserId) + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_bypass_token_user_uses_user_id"); + }); + modelBuilder.Entity(entity => { entity diff --git a/Common/OpenShockDb/User.cs b/Common/OpenShockDb/User.cs index bf5a2990..75777b10 100644 --- a/Common/OpenShockDb/User.cs +++ b/Common/OpenShockDb/User.cs @@ -41,4 +41,5 @@ public sealed class User public ICollection NameChanges { get; } = []; public ICollection EmailChanges { get; } = []; public ICollection PasswordResets { get; } = []; + public ICollection BypassTokenUses { get; } = []; } diff --git a/Common/OpenShockMiddlewareHelper.cs b/Common/OpenShockMiddlewareHelper.cs index 038304e0..36cee8bc 100644 --- a/Common/OpenShockMiddlewareHelper.cs +++ b/Common/OpenShockMiddlewareHelper.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.HttpOverrides; using Microsoft.EntityFrameworkCore; +using OpenShock.Common.Middleware; using OpenShock.Common.OpenShockDb; using OpenShock.Common.Options; using OpenShock.Common.Redis; @@ -69,6 +70,10 @@ public static async Task UseCommonOpenShockMiddleware(this await redisConnection.CreateIndexAsync(typeof(DevicePair)); await redisConnection.CreateIndexAsync(typeof(LcgNode)); + // Resolve the X-OpenShock-Bypass-Token header (if present) before rate limiting so the + // rate limiter partition selectors can honor the bypass for this same request. + app.UseMiddleware(); + app.UseRateLimiter(); app.UseOpenTelemetryPrometheusScrapingEndpoint(context => diff --git a/Common/OpenShockServiceHelper.cs b/Common/OpenShockServiceHelper.cs index 232a2202..7004ed45 100644 --- a/Common/OpenShockServiceHelper.cs +++ b/Common/OpenShockServiceHelper.cs @@ -17,6 +17,7 @@ using OpenShock.Common.Options; using OpenShock.Common.Problems; using OpenShock.Common.Services.BatchUpdate; +using OpenShock.Common.Services.Bypass; using OpenShock.Common.Services.Configuration; using OpenShock.Common.Services.RedisPubSub; using OpenShock.Common.Services.Session; @@ -199,6 +200,7 @@ public static IServiceCollection AddOpenShockServices(this IServiceCollection se services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddHttpClient(client => { client.Timeout = TimeSpan.FromSeconds(30); @@ -228,6 +230,17 @@ public static IServiceCollection AddOpenShockServices(this IServiceCollection se return; } + // If the request resolved a bypass token granting RateLimit, skip every limiter on it. + // Selectors are sync and the BypassTokenMiddleware (which runs before UseRateLimiter) + // has already populated HttpContext.Items, so this is just a dictionary lookup. + static RateLimitPartition? TryBypass(HttpContext ctx) + { + var bypass = ctx.GetResolvedBypassToken(); + return bypass is not null && bypass.Types.Contains(Models.BypassTokenType.RateLimit) + ? RateLimitPartition.GetNoLimiter($"bypass-{bypass.Id}") + : null; + } + options.OnRejected = async (context, cancellationToken) => { var logger = context.HttpContext.RequestServices.GetRequiredService() @@ -256,6 +269,8 @@ await context.HttpContext.Response.WriteAsync("Too Many Requests. Please try aga // Fixed window at 10k requests allows 20k bursts if burst occurs at window boundary options.GlobalLimiter = PartitionedRateLimiter.Create(context => { + if (TryBypass(context) is { } bypassPartition) return bypassPartition; + var user = context.User; var userId = user.FindFirstValue(ClaimTypes.NameIdentifier); if (string.IsNullOrEmpty(userId)) @@ -291,7 +306,9 @@ await context.HttpContext.Response.WriteAsync("Too Many Requests. Please try aga // Authentication endpoints limiter options.AddPolicy("auth", context => { - var ip = context.GetRemoteIP(); + if (TryBypass(context) is { } bypassPartition) return bypassPartition; + + var ip = context.GetRemoteIP().ToString(); return RateLimitPartition.GetFixedWindowLimiter(ip, _ => new FixedWindowRateLimiterOptions { PermitLimit = 10, @@ -300,7 +317,8 @@ await context.HttpContext.Response.WriteAsync("Too Many Requests. Please try aga }); // Token reporting endpoint concurrency limiter - options.AddPolicy("token-reporting", _ => + options.AddPolicy("token-reporting", context => + TryBypass(context) ?? RateLimitPartition.GetConcurrencyLimiter("token-reporting", _ => new ConcurrencyLimiterOptions { PermitLimit = 5, @@ -309,7 +327,8 @@ await context.HttpContext.Response.WriteAsync("Too Many Requests. Please try aga })); // Log fetching endpoint concurrency limiter - options.AddPolicy("shocker-logs", _ => + options.AddPolicy("shocker-logs", context => + TryBypass(context) ?? RateLimitPartition.GetConcurrencyLimiter("shocker-logs", _ => new ConcurrencyLimiterOptions { PermitLimit = 10, diff --git a/Common/Services/Bypass/BypassTokenService.cs b/Common/Services/Bypass/BypassTokenService.cs new file mode 100644 index 00000000..e54b0f56 --- /dev/null +++ b/Common/Services/Bypass/BypassTokenService.cs @@ -0,0 +1,94 @@ +using Microsoft.EntityFrameworkCore; +using OpenShock.Common.Extensions; +using OpenShock.Common.Models; +using OpenShock.Common.OpenShockDb; +using OpenShock.Common.Utils; + +namespace OpenShock.Common.Services.Bypass; + +public sealed class BypassTokenService : IBypassTokenService +{ + private readonly OpenShockContext _db; + private readonly IHttpContextAccessor _httpContextAccessor; + + public BypassTokenService(OpenShockContext db, IHttpContextAccessor httpContextAccessor) + { + _db = db; + _httpContextAccessor = httpContextAccessor; + } + + public async Task ResolveAsync(string secret, CancellationToken ct) + { + if (string.IsNullOrEmpty(secret) || secret.Length != IBypassTokenService.SecretLength) return null; + + var hash = HashingUtils.HashToken(secret); + + var token = await _db.BypassTokens + .Where(t => t.TokenHash == hash) + .Select(t => new { t.Id, t.Types }) + .FirstOrDefaultAsync(ct); + + if (token is null) return null; + + var tokenId = token.Id; + await _db.BypassTokens + .Where(t => t.Id == tokenId) + .ExecuteUpdateAsync(s => s + .SetProperty(t => t.LastUsedAt, DateTime.UtcNow) + .SetProperty(t => t.UseCount, t => t.UseCount + 1), ct); + + return new ResolvedBypassToken(tokenId, token.Types); + } + + public Task TryRecordUseAsync(Guid userId, CancellationToken ct) + { + var bypass = _httpContextAccessor.HttpContext?.GetResolvedBypassToken(); + if (bypass is null) return Task.FromResult(true); + + return LinkAsync(bypass.Id, userId, ct); + } + + public async Task TryRecordUseByEmailAsync(string email, CancellationToken ct) + { + var bypass = _httpContextAccessor.HttpContext?.GetResolvedBypassToken(); + if (bypass is null) return true; + + var userId = await _db.Users + .Where(u => u.Email == email) + .Select(u => (Guid?)u.Id) + .FirstOrDefaultAsync(ct); + + if (userId is null) return true; + + return await LinkAsync(bypass.Id, userId.Value, ct); + } + + private async Task LinkAsync(Guid bypassTokenId, Guid userId, CancellationToken ct) + { + var roles = await _db.Users + .Where(u => u.Id == userId) + .Select(u => u.Roles) + .FirstOrDefaultAsync(ct); + + if (roles is null) return true; // user not found — nothing to link; treat as non-admin + if (roles.Contains(RoleType.Admin)) return false; + + var now = DateTime.UtcNow; + + await _db.BypassTokens + .Where(t => t.Id == bypassTokenId) + .ExecuteUpdateAsync(s => s.SetProperty(t => t.LastUsedByUserId, userId), ct); + + const string sql = """ + INSERT INTO bypass_token_user_uses (bypass_token_id, user_id, first_used_at, last_used_at, use_count) + VALUES ({0}, {1}, {2}, {2}, 1) + ON CONFLICT (bypass_token_id, user_id) + DO UPDATE SET last_used_at = EXCLUDED.last_used_at, + use_count = bypass_token_user_uses.use_count + 1 + """; + + await _db.Database.ExecuteSqlRawAsync(sql, [bypassTokenId, userId, now], ct); + + return true; + } +} diff --git a/Common/Services/Bypass/IBypassTokenService.cs b/Common/Services/Bypass/IBypassTokenService.cs new file mode 100644 index 00000000..a8bb6306 --- /dev/null +++ b/Common/Services/Bypass/IBypassTokenService.cs @@ -0,0 +1,41 @@ +using OpenShock.Common.Models; +using OpenShock.Common.Utils; + +namespace OpenShock.Common.Services.Bypass; + +public interface IBypassTokenService +{ + /// + /// Length (in chars) of the opaque random secret. The full secret is base62-style alphanumeric. + /// + const int SecretLength = 128; + + /// + /// Generates a new opaque bypass-token secret. The secret is sent via the + /// X-OpenShock-Bypass-Token header — it has no prefix because it lives in a dedicated + /// header and is not confused with any other token. + /// + static string GenerateSecret() => CryptoUtils.RandomAlphaNumericString(SecretLength); + + /// + /// Hashes the supplied secret and looks it up. On a match, increments the global UseCount and + /// LastUsedAt and returns the resolved token. Called once per request by the bypass middleware. + /// + Task ResolveAsync(string secret, CancellationToken ct); + + /// + /// If the current request resolved a bypass token, associates the use with the given user account. + /// Returns false ONLY when a bypass was used AND the target user has the + /// role — callers MUST treat that as "bypass not honored" and + /// reject the request, matching the leak-defense contract. Returns true when no bypass + /// was used, when the user doesn't exist, or when the link was recorded. + /// + Task TryRecordUseAsync(Guid userId, CancellationToken ct); + + /// + /// Same contract as but resolves the user by email first. + /// If no matching user exists, this is a no-op and returns true so the caller does not + /// expose user-existence by behaving differently — relevant on flows like password-reset initiation. + /// + Task TryRecordUseByEmailAsync(string email, CancellationToken ct); +} diff --git a/Common/Services/Bypass/ResolvedBypassToken.cs b/Common/Services/Bypass/ResolvedBypassToken.cs new file mode 100644 index 00000000..a70bdebc --- /dev/null +++ b/Common/Services/Bypass/ResolvedBypassToken.cs @@ -0,0 +1,11 @@ +using OpenShock.Common.Models; + +namespace OpenShock.Common.Services.Bypass; + +/// +/// The result of resolving the X-OpenShock-Bypass-Token header against the database. +/// Set on by the bypass-token middleware +/// so that downstream guards (rate limiter, turnstile, etc.) can read it synchronously without +/// re-hitting the database. +/// +public sealed record ResolvedBypassToken(Guid Id, IReadOnlyList Types); diff --git a/Cron/Jobs/DeleteBypassTokenUsedAccountsJob.cs b/Cron/Jobs/DeleteBypassTokenUsedAccountsJob.cs new file mode 100644 index 00000000..38407c3c --- /dev/null +++ b/Cron/Jobs/DeleteBypassTokenUsedAccountsJob.cs @@ -0,0 +1,55 @@ +using Microsoft.EntityFrameworkCore; +using OpenShock.Common.Models; +using OpenShock.Common.OpenShockDb; +using OpenShock.Cron.Attributes; + +namespace OpenShock.Cron.Jobs; + +/// +/// Hard-deletes user accounts that authenticated through an admin-issued bypass token +/// whose owner enabled auto-cleanup, once the configured grace period has elapsed since +/// the last bypass use. Defends against leaked bypass tokens by ensuring test-style +/// accounts created through them do not persist indefinitely. +/// +[CronJob("0 * * * *")] // Every hour +public sealed class DeleteBypassTokenUsedAccountsJob +{ + private readonly OpenShockContext _db; + private readonly ILogger _logger; + + public DeleteBypassTokenUsedAccountsJob(OpenShockContext db, ILogger logger) + { + _db = db; + _logger = logger; + } + + public async Task Execute() + { + var now = DateTime.UtcNow; + + var userIds = await _db.BypassTokenUserUses + .Where(use => use.BypassToken.AutoCleanupUsers + && use.BypassToken.AutoCleanupAfter != null + && use.LastUsedAt + use.BypassToken.AutoCleanupAfter < now) + .Select(use => use.UserId) + .Distinct() + .ToListAsync(); + + if (userIds.Count == 0) + { + _logger.LogDebug("No bypass-token-used accounts eligible for cleanup"); + return 0; + } + + int nDeleted = await _db.Users + .Where(u => userIds.Contains(u.Id) && !u.Roles.Contains(RoleType.Admin)) + .ExecuteDeleteAsync(); + + _logger.LogInformation( + "Bypass-token cleanup: {DeletedCount}/{CandidateCount} accounts deleted", + nDeleted, + userIds.Count); + + return nDeleted; + } +} From 636b7674c47438178b7f37d5a7a291b91c4ca2d7 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Tue, 26 May 2026 21:23:01 +0200 Subject: [PATCH 02/16] temporary migration --- ...20260526192123_AddBypassTokens.Designer.cs | 1624 +++++++++++++++++ .../20260526192123_AddBypassTokens.cs | 144 ++ .../OpenShockContextModelSnapshot.cs | 149 +- 3 files changed, 1916 insertions(+), 1 deletion(-) create mode 100644 Common/Migrations/20260526192123_AddBypassTokens.Designer.cs create mode 100644 Common/Migrations/20260526192123_AddBypassTokens.cs diff --git a/Common/Migrations/20260526192123_AddBypassTokens.Designer.cs b/Common/Migrations/20260526192123_AddBypassTokens.Designer.cs new file mode 100644 index 00000000..51aac44d --- /dev/null +++ b/Common/Migrations/20260526192123_AddBypassTokens.Designer.cs @@ -0,0 +1,1624 @@ +// +using System; +using System.Collections.Generic; +using System.Net; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using OpenShock.Common.Models; +using OpenShock.Common.OpenShockDb; + +#nullable disable + +namespace OpenShock.Common.Migrations +{ + [DbContext(typeof(MigrationOpenShockContext))] + [Migration("20260526192123_AddBypassTokens")] + partial class AddBypassTokens + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("Npgsql:CollationDefinition:public.ndcoll", "und-u-ks-level2,und-u-ks-level2,icu,False") + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "bypass_token_type", new[] { "turnstile", "rate_limit" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "configuration_value_type", new[] { "string", "bool", "int", "float", "json" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "control_type", new[] { "sound", "vibrate", "shock", "stop" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "match_type_enum", new[] { "exact", "contains" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "ota_update_status", new[] { "started", "running", "finished", "error", "timeout" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "password_encryption_type", new[] { "pbkdf2", "bcrypt_enhanced" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "permission_type", new[] { "shockers.use", "shockers.edit", "shockers.pause", "devices.edit", "devices.auth" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "role_type", new[] { "support", "staff", "admin", "system" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "shocker_model_type", new[] { "caiXianlin", "petTrainer", "petrainer998DR", "wellturnT330" }); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FriendlyName") + .HasColumnType("text"); + + b.Property("Xml") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("DataProtectionKeys"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.AdminUsersView", b => + { + b.Property("ActivatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("activated_at"); + + b.Property("ApiTokenCount") + .HasColumnType("integer") + .HasColumnName("api_token_count"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeactivatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deactivated_at"); + + b.Property("DeactivatedByUserId") + .HasColumnType("uuid") + .HasColumnName("deactivated_by_user_id"); + + b.Property("DeviceCount") + .HasColumnType("integer") + .HasColumnName("device_count"); + + b.Property("Email") + .IsRequired() + .HasColumnType("character varying") + .HasColumnName("email"); + + b.Property("EmailChangeRequestCount") + .HasColumnType("integer") + .HasColumnName("email_change_request_count"); + + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasColumnType("character varying") + .HasColumnName("name"); + + b.Property("NameChangeRequestCount") + .HasColumnType("integer") + .HasColumnName("name_change_request_count"); + + b.Property("PasswordHashType") + .HasColumnType("character varying") + .HasColumnName("password_hash_type"); + + b.Property("PasswordResetCount") + .HasColumnType("integer") + .HasColumnName("password_reset_count"); + + b.Property("Roles") + .IsRequired() + .HasColumnType("role_type[]") + .HasColumnName("roles"); + + b.Property("ShockerControlLogCount") + .HasColumnType("integer") + .HasColumnName("shocker_control_log_count"); + + b.Property("ShockerCount") + .HasColumnType("integer") + .HasColumnName("shocker_count"); + + b.Property("ShockerPublicShareCount") + .HasColumnType("integer") + .HasColumnName("shocker_public_share_count"); + + b.Property("ShockerUserShareCount") + .HasColumnType("integer") + .HasColumnName("shocker_user_share_count"); + + b.ToTable((string)null); + + b.ToView("admin_users_view", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ApiToken", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("CreatedByIp") + .IsRequired() + .HasColumnType("inet") + .HasColumnName("created_by_ip"); + + b.Property("LastUsed") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("last_used") + .HasDefaultValueSql("'-infinity'::timestamp without time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("name"); + + b.PrimitiveCollection>("Permissions") + .IsRequired() + .HasColumnType("permission_type[]") + .HasColumnName("permissions"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("token_hash") + .UseCollation("C"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("ValidUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("valid_until"); + + b.HasKey("Id") + .HasName("api_tokens_pkey"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.HasIndex("ValidUntil"); + + b.ToTable("api_tokens", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ApiTokenReport", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AffectedCount") + .HasColumnType("integer") + .HasColumnName("affected_count"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("IpAddress") + .IsRequired() + .HasColumnType("inet") + .HasColumnName("ip_address"); + + b.Property("IpCountry") + .HasColumnType("text") + .HasColumnName("ip_country"); + + b.Property("SubmittedCount") + .HasColumnType("integer") + .HasColumnName("submitted_count"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("api_token_reports_pkey"); + + b.HasIndex("UserId"); + + b.ToTable("api_token_reports", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.BypassToken", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AutoCleanupAfter") + .HasColumnType("interval") + .HasColumnName("auto_cleanup_after"); + + b.Property("AutoCleanupUsers") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("auto_cleanup_users"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("LastRotatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_rotated_at"); + + b.Property("LastUsedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_used_at"); + + b.Property("LastUsedByUserId") + .HasColumnType("uuid") + .HasColumnName("last_used_by_user_id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("name"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("token_hash") + .UseCollation("C"); + + b.PrimitiveCollection>("Types") + .IsRequired() + .HasColumnType("bypass_token_type[]") + .HasColumnName("types"); + + b.Property("UseCount") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L) + .HasColumnName("use_count"); + + b.HasKey("Id") + .HasName("bypass_tokens_pkey"); + + b.HasIndex("LastUsedByUserId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.ToTable("bypass_tokens", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.BypassTokenUserUse", b => + { + b.Property("BypassTokenId") + .HasColumnType("uuid") + .HasColumnName("bypass_token_id"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("FirstUsedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("first_used_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("LastUsedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("last_used_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("UseCount") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L) + .HasColumnName("use_count"); + + b.HasKey("BypassTokenId", "UserId") + .HasName("bypass_token_user_uses_pkey"); + + b.HasIndex("LastUsedAt"); + + b.HasIndex("UserId"); + + b.ToTable("bypass_token_user_uses", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ConfigurationItem", b => + { + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name") + .UseCollation("C"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Type") + .HasColumnType("configuration_value_type") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.HasKey("Name") + .HasName("configuration_pkey"); + + b.ToTable("configuration", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.Device", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("name"); + + b.Property("OwnerId") + .HasColumnType("uuid") + .HasColumnName("owner_id"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("token") + .UseCollation("C"); + + b.HasKey("Id") + .HasName("devices_pkey"); + + b.HasIndex("OwnerId"); + + b.HasIndex("Token") + .IsUnique(); + + b.ToTable("devices", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.DeviceOtaUpdate", b => + { + b.Property("DeviceId") + .HasColumnType("uuid") + .HasColumnName("device_id"); + + b.Property("UpdateId") + .HasColumnType("integer") + .HasColumnName("update_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Message") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("message"); + + b.Property("Status") + .HasColumnType("ota_update_status") + .HasColumnName("status"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("version"); + + b.HasKey("DeviceId", "UpdateId") + .HasName("device_ota_updates_pkey"); + + b.HasIndex(new[] { "CreatedAt" }, "device_ota_updates_created_at_idx"); + + b.ToTable("device_ota_updates", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.DiscordWebhook", b => + { + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("WebhookId") + .HasColumnType("bigint") + .HasColumnName("webhook_id"); + + b.Property("WebhookToken") + .IsRequired() + .HasColumnType("text") + .HasColumnName("webhook_token"); + + b.HasKey("Name") + .HasName("discord_webhooks_pkey"); + + b.ToTable("discord_webhooks", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.EmailProviderBlacklist", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Domain") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("domain") + .UseCollation("ndcoll"); + + b.HasKey("Id") + .HasName("email_provider_blacklist_pkey"); + + b.HasIndex("Domain") + .IsUnique(); + + NpgsqlIndexBuilderExtensions.UseCollation(b.HasIndex("Domain"), new[] { "ndcoll" }); + + b.ToTable("email_provider_blacklist", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.PublicShare", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("name"); + + b.Property("OwnerId") + .HasColumnType("uuid") + .HasColumnName("owner_id"); + + b.HasKey("Id") + .HasName("public_shares_pkey"); + + b.HasIndex("OwnerId"); + + b.ToTable("public_shares", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.PublicShareShocker", b => + { + b.Property("PublicShareId") + .HasColumnType("uuid") + .HasColumnName("public_share_id"); + + b.Property("ShockerId") + .HasColumnType("uuid") + .HasColumnName("shocker_id"); + + b.Property("AllowLiveControl") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("allow_livecontrol"); + + b.Property("AllowShock") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_shock"); + + b.Property("AllowSound") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_sound"); + + b.Property("AllowVibrate") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_vibrate"); + + b.Property("Cooldown") + .HasColumnType("integer") + .HasColumnName("cooldown"); + + b.Property("IsPaused") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_paused"); + + b.Property("MaxDuration") + .HasColumnType("integer") + .HasColumnName("max_duration"); + + b.Property("MaxIntensity") + .HasColumnType("smallint") + .HasColumnName("max_intensity"); + + b.HasKey("PublicShareId", "ShockerId") + .HasName("public_share_shockers_pkey"); + + b.HasIndex("ShockerId"); + + b.ToTable("public_share_shockers", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.Shocker", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("DeviceId") + .HasColumnType("uuid") + .HasColumnName("device_id"); + + b.Property("IsPaused") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_paused"); + + b.Property("Model") + .HasColumnType("shocker_model_type") + .HasColumnName("model"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("name"); + + b.Property("RfId") + .HasColumnType("integer") + .HasColumnName("rf_id"); + + b.HasKey("Id") + .HasName("shockers_pkey"); + + b.HasIndex("DeviceId"); + + b.ToTable("shockers", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ShockerControlLog", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ControlledByUserId") + .HasColumnType("uuid") + .HasColumnName("controlled_by_user_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("CustomName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("custom_name"); + + b.Property("Duration") + .HasColumnType("bigint") + .HasColumnName("duration"); + + b.Property("Intensity") + .HasColumnType("smallint") + .HasColumnName("intensity"); + + b.Property("LiveControl") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("live_control"); + + b.Property("ShockerId") + .HasColumnType("uuid") + .HasColumnName("shocker_id"); + + b.Property("Type") + .HasColumnType("control_type") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("shocker_control_logs_pkey"); + + b.HasIndex("ControlledByUserId"); + + b.HasIndex("ShockerId"); + + b.ToTable("shocker_control_logs", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ShockerShareCode", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AllowLiveControl") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_livecontrol"); + + b.Property("AllowShock") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_shock"); + + b.Property("AllowSound") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_sound"); + + b.Property("AllowVibrate") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_vibrate"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("IsPaused") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_paused"); + + b.Property("MaxDuration") + .HasColumnType("integer") + .HasColumnName("max_duration"); + + b.Property("MaxIntensity") + .HasColumnType("smallint") + .HasColumnName("max_intensity"); + + b.Property("ShockerId") + .HasColumnType("uuid") + .HasColumnName("shocker_id"); + + b.HasKey("Id") + .HasName("shocker_share_codes_pkey"); + + b.HasIndex("ShockerId"); + + b.ToTable("shocker_share_codes", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.User", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ActivatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("activated_at"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("character varying(320)") + .HasColumnName("email"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("name") + .UseCollation("ndcoll"); + + b.Property("PasswordHash") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("password_hash") + .UseCollation("C"); + + b.PrimitiveCollection>("Roles") + .IsRequired() + .HasColumnType("role_type[]") + .HasColumnName("roles"); + + b.Property("SecurityStamp") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("security_stamp") + .HasDefaultValueSql("gen_random_uuid()"); + + b.HasKey("Id") + .HasName("users_pkey"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("Name") + .IsUnique(); + + NpgsqlIndexBuilderExtensions.UseCollation(b.HasIndex("Name"), new[] { "ndcoll" }); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserActivationRequest", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("EmailSendAttempts") + .HasColumnType("integer") + .HasColumnName("email_send_attempts"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("token_hash") + .UseCollation("C"); + + b.HasKey("UserId") + .HasName("user_activation_requests_pkey"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.ToTable("user_activation_requests", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserDeactivation", b => + { + b.Property("DeactivatedUserId") + .HasColumnType("uuid") + .HasColumnName("deactivated_user_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("DeactivatedByUserId") + .HasColumnType("uuid") + .HasColumnName("deactivated_by_user_id"); + + b.Property("DeleteLater") + .HasColumnType("boolean") + .HasColumnName("delete_later"); + + b.Property("UserModerationId") + .HasColumnType("uuid") + .HasColumnName("user_moderation_id"); + + b.HasKey("DeactivatedUserId") + .HasName("user_deactivations_pkey"); + + b.HasIndex("DeactivatedByUserId"); + + b.ToTable("user_deactivations", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserEmailChange", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("NewEmail") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("character varying(320)") + .HasColumnName("email_new"); + + b.Property("OldEmail") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("character varying(320)") + .HasColumnName("email_old"); + + b.Property("SecurityStampAtCreate") + .HasColumnType("uuid") + .HasColumnName("security_stamp_at_create"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("token_hash") + .UseCollation("C"); + + b.Property("UsedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("used_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("user_email_changes_pkey"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("UsedAt"); + + b.HasIndex("UserId"); + + b.ToTable("user_email_changes", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserNameBlacklist", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("MatchType") + .HasColumnType("match_type_enum") + .HasColumnName("match_type"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("value") + .UseCollation("ndcoll"); + + b.HasKey("Id") + .HasName("user_name_blacklist_pkey"); + + b.HasIndex("Value") + .IsUnique(); + + NpgsqlIndexBuilderExtensions.UseCollation(b.HasIndex("Value"), new[] { "ndcoll" }); + + b.ToTable("user_name_blacklist", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserNameChange", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Id")); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("OldName") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("old_name"); + + b.HasKey("Id", "UserId") + .HasName("user_name_changes_pkey"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("OldName"); + + b.HasIndex("UserId"); + + b.ToTable("user_name_changes", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserOAuthConnection", b => + { + b.Property("ProviderKey") + .HasColumnType("text") + .HasColumnName("provider_key") + .UseCollation("C"); + + b.Property("ExternalId") + .HasColumnType("text") + .HasColumnName("external_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("DisplayName") + .HasColumnType("text") + .HasColumnName("display_name"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("ProviderKey", "ExternalId") + .HasName("user_oauth_connections_pkey"); + + b.HasIndex("UserId"); + + b.ToTable("user_oauth_connections", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserPasswordReset", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("SecurityStampAtCreate") + .HasColumnType("uuid") + .HasColumnName("security_stamp_at_create"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("token_hash") + .UseCollation("C"); + + b.Property("UsedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("used_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("user_password_resets_pkey"); + + b.HasIndex("UserId"); + + b.ToTable("user_password_resets", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShare", b => + { + b.Property("SharedWithUserId") + .HasColumnType("uuid") + .HasColumnName("shared_with_user_id"); + + b.Property("ShockerId") + .HasColumnType("uuid") + .HasColumnName("shocker_id"); + + b.Property("AllowLiveControl") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_livecontrol"); + + b.Property("AllowShock") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_shock"); + + b.Property("AllowSound") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_sound"); + + b.Property("AllowVibrate") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_vibrate"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("IsPaused") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_paused"); + + b.Property("MaxDuration") + .HasColumnType("integer") + .HasColumnName("max_duration"); + + b.Property("MaxIntensity") + .HasColumnType("smallint") + .HasColumnName("max_intensity"); + + b.HasKey("SharedWithUserId", "ShockerId") + .HasName("user_shares_pkey"); + + b.HasIndex("SharedWithUserId"); + + b.HasIndex("ShockerId"); + + b.ToTable("user_shares", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShareInvite", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("OwnerId") + .HasColumnType("uuid") + .HasColumnName("owner_id"); + + b.Property("RecipientUserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("user_share_invites_pkey"); + + b.HasIndex("OwnerId"); + + b.HasIndex("RecipientUserId"); + + b.ToTable("user_share_invites", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShareInviteShocker", b => + { + b.Property("InviteId") + .HasColumnType("uuid") + .HasColumnName("invite_id"); + + b.Property("ShockerId") + .HasColumnType("uuid") + .HasColumnName("shocker_id"); + + b.Property("AllowLiveControl") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_livecontrol"); + + b.Property("AllowShock") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_shock"); + + b.Property("AllowSound") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_sound"); + + b.Property("AllowVibrate") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_vibrate"); + + b.Property("IsPaused") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_paused"); + + b.Property("MaxDuration") + .HasColumnType("integer") + .HasColumnName("max_duration"); + + b.Property("MaxIntensity") + .HasColumnType("smallint") + .HasColumnName("max_intensity"); + + b.HasKey("InviteId", "ShockerId") + .HasName("user_share_invite_shockers_pkey"); + + b.HasIndex("ShockerId"); + + b.ToTable("user_share_invite_shockers", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ApiToken", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "User") + .WithMany("ApiTokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_api_tokens_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ApiTokenReport", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "ReportedByUser") + .WithMany("ReportedApiTokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_api_token_reports_reported_by_user_id"); + + b.Navigation("ReportedByUser"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.BypassToken", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "LastUsedByUser") + .WithMany() + .HasForeignKey("LastUsedByUserId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_bypass_tokens_last_used_by_user_id"); + + b.Navigation("LastUsedByUser"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.BypassTokenUserUse", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.BypassToken", "BypassToken") + .WithMany("UserUses") + .HasForeignKey("BypassTokenId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_bypass_token_user_uses_bypass_token_id"); + + b.HasOne("OpenShock.Common.OpenShockDb.User", "User") + .WithMany("BypassTokenUses") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_bypass_token_user_uses_user_id"); + + b.Navigation("BypassToken"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.Device", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "Owner") + .WithMany("Devices") + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_devices_owner_id"); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.DeviceOtaUpdate", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.Device", "Device") + .WithMany("OtaUpdates") + .HasForeignKey("DeviceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_device_ota_updates_device_id"); + + b.Navigation("Device"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.PublicShare", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "Owner") + .WithMany("OwnedPublicShares") + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_public_shares_owner_id"); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.PublicShareShocker", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.PublicShare", "PublicShare") + .WithMany("ShockerMappings") + .HasForeignKey("PublicShareId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_public_share_shockers_public_share_id"); + + b.HasOne("OpenShock.Common.OpenShockDb.Shocker", "Shocker") + .WithMany("PublicShareMappings") + .HasForeignKey("ShockerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_public_share_shockers_shocker_id"); + + b.Navigation("PublicShare"); + + b.Navigation("Shocker"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.Shocker", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.Device", "Device") + .WithMany("Shockers") + .HasForeignKey("DeviceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_shockers_device_id"); + + b.Navigation("Device"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ShockerControlLog", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "ControlledByUser") + .WithMany("ShockerControlLogs") + .HasForeignKey("ControlledByUserId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_shocker_control_logs_controlled_by_user_id"); + + b.HasOne("OpenShock.Common.OpenShockDb.Shocker", "Shocker") + .WithMany("ShockerControlLogs") + .HasForeignKey("ShockerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_shocker_control_logs_shocker_id"); + + b.Navigation("ControlledByUser"); + + b.Navigation("Shocker"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ShockerShareCode", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.Shocker", "Shocker") + .WithMany("ShockerShareCodes") + .HasForeignKey("ShockerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_shocker_share_codes_shocker_id"); + + b.Navigation("Shocker"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserActivationRequest", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "User") + .WithOne("UserActivationRequest") + .HasForeignKey("OpenShock.Common.OpenShockDb.UserActivationRequest", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_activation_requests_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserDeactivation", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "DeactivatedByUser") + .WithMany() + .HasForeignKey("DeactivatedByUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_deactivations_deactivated_by_user_id"); + + b.HasOne("OpenShock.Common.OpenShockDb.User", "DeactivatedUser") + .WithOne("UserDeactivation") + .HasForeignKey("OpenShock.Common.OpenShockDb.UserDeactivation", "DeactivatedUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_deactivations_deactivated_user_id"); + + b.Navigation("DeactivatedByUser"); + + b.Navigation("DeactivatedUser"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserEmailChange", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "User") + .WithMany("EmailChanges") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_email_changes_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserNameChange", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "User") + .WithMany("NameChanges") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_name_changes_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserOAuthConnection", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "User") + .WithMany("OAuthConnections") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_oauth_connections_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserPasswordReset", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "User") + .WithMany("PasswordResets") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_password_resets_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShare", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "SharedWithUser") + .WithMany("IncomingUserShares") + .HasForeignKey("SharedWithUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_shares_shared_with_user_id"); + + b.HasOne("OpenShock.Common.OpenShockDb.Shocker", "Shocker") + .WithMany("UserShares") + .HasForeignKey("ShockerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_shares_shocker_id"); + + b.Navigation("SharedWithUser"); + + b.Navigation("Shocker"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShareInvite", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "Owner") + .WithMany("OutgoingUserShareInvites") + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_share_invites_owner_id"); + + b.HasOne("OpenShock.Common.OpenShockDb.User", "RecipientUser") + .WithMany("IncomingUserShareInvites") + .HasForeignKey("RecipientUserId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_user_share_invites_recipient_user_id"); + + b.Navigation("Owner"); + + b.Navigation("RecipientUser"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShareInviteShocker", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.UserShareInvite", "Invite") + .WithMany("ShockerMappings") + .HasForeignKey("InviteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_share_invite_shockers_invite_id"); + + b.HasOne("OpenShock.Common.OpenShockDb.Shocker", "Shocker") + .WithMany("UserShareInviteShockerMappings") + .HasForeignKey("ShockerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_share_invite_shockers_shocker_id"); + + b.Navigation("Invite"); + + b.Navigation("Shocker"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.BypassToken", b => + { + b.Navigation("UserUses"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.Device", b => + { + b.Navigation("OtaUpdates"); + + b.Navigation("Shockers"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.PublicShare", b => + { + b.Navigation("ShockerMappings"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.Shocker", b => + { + b.Navigation("PublicShareMappings"); + + b.Navigation("ShockerControlLogs"); + + b.Navigation("ShockerShareCodes"); + + b.Navigation("UserShareInviteShockerMappings"); + + b.Navigation("UserShares"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.User", b => + { + b.Navigation("ApiTokens"); + + b.Navigation("BypassTokenUses"); + + b.Navigation("Devices"); + + b.Navigation("EmailChanges"); + + b.Navigation("IncomingUserShareInvites"); + + b.Navigation("IncomingUserShares"); + + b.Navigation("NameChanges"); + + b.Navigation("OAuthConnections"); + + b.Navigation("OutgoingUserShareInvites"); + + b.Navigation("OwnedPublicShares"); + + b.Navigation("PasswordResets"); + + b.Navigation("ReportedApiTokens"); + + b.Navigation("ShockerControlLogs"); + + b.Navigation("UserActivationRequest"); + + b.Navigation("UserDeactivation"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShareInvite", b => + { + b.Navigation("ShockerMappings"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Common/Migrations/20260526192123_AddBypassTokens.cs b/Common/Migrations/20260526192123_AddBypassTokens.cs new file mode 100644 index 00000000..ece78642 --- /dev/null +++ b/Common/Migrations/20260526192123_AddBypassTokens.cs @@ -0,0 +1,144 @@ +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore.Migrations; +using OpenShock.Common.Models; + +#nullable disable + +namespace OpenShock.Common.Migrations +{ + /// + public partial class AddBypassTokens : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterDatabase() + .Annotation("Npgsql:CollationDefinition:public.ndcoll", "und-u-ks-level2,und-u-ks-level2,icu,False") + .Annotation("Npgsql:Enum:bypass_token_type", "turnstile,rate_limit") + .Annotation("Npgsql:Enum:configuration_value_type", "string,bool,int,float,json") + .Annotation("Npgsql:Enum:control_type", "sound,vibrate,shock,stop") + .Annotation("Npgsql:Enum:match_type_enum", "exact,contains") + .Annotation("Npgsql:Enum:ota_update_status", "started,running,finished,error,timeout") + .Annotation("Npgsql:Enum:password_encryption_type", "pbkdf2,bcrypt_enhanced") + .Annotation("Npgsql:Enum:permission_type", "shockers.use,shockers.edit,shockers.pause,devices.edit,devices.auth") + .Annotation("Npgsql:Enum:role_type", "support,staff,admin,system") + .Annotation("Npgsql:Enum:shocker_model_type", "caiXianlin,petTrainer,petrainer998DR,wellturnT330") + .OldAnnotation("Npgsql:CollationDefinition:public.ndcoll", "und-u-ks-level2,und-u-ks-level2,icu,False") + .OldAnnotation("Npgsql:Enum:configuration_value_type", "string,bool,int,float,json") + .OldAnnotation("Npgsql:Enum:control_type", "sound,vibrate,shock,stop") + .OldAnnotation("Npgsql:Enum:match_type_enum", "exact,contains") + .OldAnnotation("Npgsql:Enum:ota_update_status", "started,running,finished,error,timeout") + .OldAnnotation("Npgsql:Enum:password_encryption_type", "pbkdf2,bcrypt_enhanced") + .OldAnnotation("Npgsql:Enum:permission_type", "shockers.use,shockers.edit,shockers.pause,devices.edit,devices.auth") + .OldAnnotation("Npgsql:Enum:role_type", "support,staff,admin,system") + .OldAnnotation("Npgsql:Enum:shocker_model_type", "caiXianlin,petTrainer,petrainer998DR,wellturnT330"); + + migrationBuilder.CreateTable( + name: "bypass_tokens", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + name = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + token_hash = table.Column(type: "character varying(64)", maxLength: 64, nullable: false, collation: "C"), + types = table.Column>(type: "bypass_token_type[]", nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"), + last_used_at = table.Column(type: "timestamp with time zone", nullable: true), + last_used_by_user_id = table.Column(type: "uuid", nullable: true), + last_rotated_at = table.Column(type: "timestamp with time zone", nullable: true), + use_count = table.Column(type: "bigint", nullable: false, defaultValue: 0L), + auto_cleanup_users = table.Column(type: "boolean", nullable: false, defaultValue: false), + auto_cleanup_after = table.Column(type: "interval", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("bypass_tokens_pkey", x => x.id); + table.ForeignKey( + name: "fk_bypass_tokens_last_used_by_user_id", + column: x => x.last_used_by_user_id, + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.SetNull); + }); + + migrationBuilder.CreateTable( + name: "bypass_token_user_uses", + columns: table => new + { + bypass_token_id = table.Column(type: "uuid", nullable: false), + user_id = table.Column(type: "uuid", nullable: false), + first_used_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"), + last_used_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"), + use_count = table.Column(type: "bigint", nullable: false, defaultValue: 0L) + }, + constraints: table => + { + table.PrimaryKey("bypass_token_user_uses_pkey", x => new { x.bypass_token_id, x.user_id }); + table.ForeignKey( + name: "fk_bypass_token_user_uses_bypass_token_id", + column: x => x.bypass_token_id, + principalTable: "bypass_tokens", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "fk_bypass_token_user_uses_user_id", + column: x => x.user_id, + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_bypass_token_user_uses_last_used_at", + table: "bypass_token_user_uses", + column: "last_used_at"); + + migrationBuilder.CreateIndex( + name: "IX_bypass_token_user_uses_user_id", + table: "bypass_token_user_uses", + column: "user_id"); + + migrationBuilder.CreateIndex( + name: "IX_bypass_tokens_last_used_by_user_id", + table: "bypass_tokens", + column: "last_used_by_user_id"); + + migrationBuilder.CreateIndex( + name: "IX_bypass_tokens_token_hash", + table: "bypass_tokens", + column: "token_hash", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "bypass_token_user_uses"); + + migrationBuilder.DropTable( + name: "bypass_tokens"); + + migrationBuilder.AlterDatabase() + .Annotation("Npgsql:CollationDefinition:public.ndcoll", "und-u-ks-level2,und-u-ks-level2,icu,False") + .Annotation("Npgsql:Enum:configuration_value_type", "string,bool,int,float,json") + .Annotation("Npgsql:Enum:control_type", "sound,vibrate,shock,stop") + .Annotation("Npgsql:Enum:match_type_enum", "exact,contains") + .Annotation("Npgsql:Enum:ota_update_status", "started,running,finished,error,timeout") + .Annotation("Npgsql:Enum:password_encryption_type", "pbkdf2,bcrypt_enhanced") + .Annotation("Npgsql:Enum:permission_type", "shockers.use,shockers.edit,shockers.pause,devices.edit,devices.auth") + .Annotation("Npgsql:Enum:role_type", "support,staff,admin,system") + .Annotation("Npgsql:Enum:shocker_model_type", "caiXianlin,petTrainer,petrainer998DR,wellturnT330") + .OldAnnotation("Npgsql:CollationDefinition:public.ndcoll", "und-u-ks-level2,und-u-ks-level2,icu,False") + .OldAnnotation("Npgsql:Enum:bypass_token_type", "turnstile,rate_limit") + .OldAnnotation("Npgsql:Enum:configuration_value_type", "string,bool,int,float,json") + .OldAnnotation("Npgsql:Enum:control_type", "sound,vibrate,shock,stop") + .OldAnnotation("Npgsql:Enum:match_type_enum", "exact,contains") + .OldAnnotation("Npgsql:Enum:ota_update_status", "started,running,finished,error,timeout") + .OldAnnotation("Npgsql:Enum:password_encryption_type", "pbkdf2,bcrypt_enhanced") + .OldAnnotation("Npgsql:Enum:permission_type", "shockers.use,shockers.edit,shockers.pause,devices.edit,devices.auth") + .OldAnnotation("Npgsql:Enum:role_type", "support,staff,admin,system") + .OldAnnotation("Npgsql:Enum:shocker_model_type", "caiXianlin,petTrainer,petrainer998DR,wellturnT330"); + } + } +} diff --git a/Common/Migrations/OpenShockContextModelSnapshot.cs b/Common/Migrations/OpenShockContextModelSnapshot.cs index 0a030050..42da10d9 100644 --- a/Common/Migrations/OpenShockContextModelSnapshot.cs +++ b/Common/Migrations/OpenShockContextModelSnapshot.cs @@ -21,9 +21,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) #pragma warning disable 612, 618 modelBuilder .HasAnnotation("Npgsql:CollationDefinition:public.ndcoll", "und-u-ks-level2,und-u-ks-level2,icu,False") - .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("ProductVersion", "10.0.8") .HasAnnotation("Relational:MaxIdentifierLength", 63); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "bypass_token_type", new[] { "turnstile", "rate_limit" }); NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "configuration_value_type", new[] { "string", "bool", "int", "float", "json" }); NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "control_type", new[] { "sound", "vibrate", "shock", "stop" }); NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "match_type_enum", new[] { "exact", "contains" }); @@ -238,6 +239,113 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("api_token_reports", (string)null); }); + modelBuilder.Entity("OpenShock.Common.OpenShockDb.BypassToken", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AutoCleanupAfter") + .HasColumnType("interval") + .HasColumnName("auto_cleanup_after"); + + b.Property("AutoCleanupUsers") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("auto_cleanup_users"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("LastRotatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_rotated_at"); + + b.Property("LastUsedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_used_at"); + + b.Property("LastUsedByUserId") + .HasColumnType("uuid") + .HasColumnName("last_used_by_user_id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("name"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("token_hash") + .UseCollation("C"); + + b.PrimitiveCollection>("Types") + .IsRequired() + .HasColumnType("bypass_token_type[]") + .HasColumnName("types"); + + b.Property("UseCount") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L) + .HasColumnName("use_count"); + + b.HasKey("Id") + .HasName("bypass_tokens_pkey"); + + b.HasIndex("LastUsedByUserId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.ToTable("bypass_tokens", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.BypassTokenUserUse", b => + { + b.Property("BypassTokenId") + .HasColumnType("uuid") + .HasColumnName("bypass_token_id"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("FirstUsedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("first_used_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("LastUsedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("last_used_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("UseCount") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L) + .HasColumnName("use_count"); + + b.HasKey("BypassTokenId", "UserId") + .HasName("bypass_token_user_uses_pkey"); + + b.HasIndex("LastUsedAt"); + + b.HasIndex("UserId"); + + b.ToTable("bypass_token_user_uses", (string)null); + }); + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ConfigurationItem", b => { b.Property("Name") @@ -1164,6 +1272,38 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("ReportedByUser"); }); + modelBuilder.Entity("OpenShock.Common.OpenShockDb.BypassToken", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "LastUsedByUser") + .WithMany() + .HasForeignKey("LastUsedByUserId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_bypass_tokens_last_used_by_user_id"); + + b.Navigation("LastUsedByUser"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.BypassTokenUserUse", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.BypassToken", "BypassToken") + .WithMany("UserUses") + .HasForeignKey("BypassTokenId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_bypass_token_user_uses_bypass_token_id"); + + b.HasOne("OpenShock.Common.OpenShockDb.User", "User") + .WithMany("BypassTokenUses") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_bypass_token_user_uses_user_id"); + + b.Navigation("BypassToken"); + + b.Navigation("User"); + }); + modelBuilder.Entity("OpenShock.Common.OpenShockDb.Device", b => { b.HasOne("OpenShock.Common.OpenShockDb.User", "Owner") @@ -1408,6 +1548,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Shocker"); }); + modelBuilder.Entity("OpenShock.Common.OpenShockDb.BypassToken", b => + { + b.Navigation("UserUses"); + }); + modelBuilder.Entity("OpenShock.Common.OpenShockDb.Device", b => { b.Navigation("OtaUpdates"); @@ -1437,6 +1582,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) { b.Navigation("ApiTokens"); + b.Navigation("BypassTokenUses"); + b.Navigation("Devices"); b.Navigation("EmailChanges"); From 1fdec31686d2984ef65fe9f897a5f8374ef818bd Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Tue, 26 May 2026 21:36:42 +0200 Subject: [PATCH 03/16] revert passwordreset endpoints --- .../Account/PasswordResetInitiateV2.cs | 20 ++++++------------- Common/Services/Bypass/BypassTokenService.cs | 15 -------------- Common/Services/Bypass/IBypassTokenService.cs | 7 ------- 3 files changed, 6 insertions(+), 36 deletions(-) diff --git a/API/Controller/Account/PasswordResetInitiateV2.cs b/API/Controller/Account/PasswordResetInitiateV2.cs index c8456ce4..0964d2bc 100644 --- a/API/Controller/Account/PasswordResetInitiateV2.cs +++ b/API/Controller/Account/PasswordResetInitiateV2.cs @@ -1,5 +1,4 @@ -using System; -using System.Net; +using System.Net; using System.Net.Mime; using Microsoft.AspNetCore.Mvc; using Asp.Versioning; @@ -7,9 +6,7 @@ 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; @@ -26,8 +23,8 @@ public sealed partial class AccountController [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status403Forbidden, MediaTypeNames.Application.ProblemJson)] [MapToApiVersion("2")] - public Task PasswordResetInitiateV2([FromBody] PasswordResetRequestV2 body, [FromServices] ICloudflareTurnstileService turnstileService, [FromServices] IBypassTokenService bypassTokens, CancellationToken cancellationToken) - => PasswordResetInitiate(body, turnstileService, bypassTokens, cancellationToken); + public Task PasswordResetInitiateV2([FromBody] PasswordResetRequestV2 body, [FromServices] ICloudflareTurnstileService turnstileService, CancellationToken cancellationToken) + => PasswordResetInitiate(body, turnstileService, cancellationToken); /// /// Initiate a password reset. Deprecated: use POST /password-reset instead. @@ -40,10 +37,10 @@ public Task PasswordResetInitiateV2([FromBody] PasswordResetReque [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status403Forbidden, MediaTypeNames.Application.ProblemJson)] [MapToApiVersion("2")] - public Task PasswordResetInitiateV2Legacy([FromBody] PasswordResetRequestV2 body, [FromServices] ICloudflareTurnstileService turnstileService, [FromServices] IBypassTokenService bypassTokens, CancellationToken cancellationToken) - => PasswordResetInitiate(body, turnstileService, bypassTokens, cancellationToken); + public Task PasswordResetInitiateV2Legacy([FromBody] PasswordResetRequestV2 body, [FromServices] ICloudflareTurnstileService turnstileService, CancellationToken cancellationToken) + => PasswordResetInitiate(body, turnstileService, cancellationToken); - private async Task PasswordResetInitiate(PasswordResetRequestV2 body, ICloudflareTurnstileService turnstileService, IBypassTokenService bypassTokens, CancellationToken cancellationToken) + private async Task PasswordResetInitiate(PasswordResetRequestV2 body, ICloudflareTurnstileService turnstileService, CancellationToken cancellationToken) { var turnStile = await turnstileService.VerifyUserResponseTokenAsync(body.TurnstileResponse, HttpContext.GetRemoteIP(), cancellationToken); if (!turnStile.IsT0) @@ -55,11 +52,6 @@ private async Task 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(); diff --git a/Common/Services/Bypass/BypassTokenService.cs b/Common/Services/Bypass/BypassTokenService.cs index e54b0f56..f82c3166 100644 --- a/Common/Services/Bypass/BypassTokenService.cs +++ b/Common/Services/Bypass/BypassTokenService.cs @@ -48,21 +48,6 @@ public Task TryRecordUseAsync(Guid userId, CancellationToken ct) return LinkAsync(bypass.Id, userId, ct); } - public async Task TryRecordUseByEmailAsync(string email, CancellationToken ct) - { - var bypass = _httpContextAccessor.HttpContext?.GetResolvedBypassToken(); - if (bypass is null) return true; - - var userId = await _db.Users - .Where(u => u.Email == email) - .Select(u => (Guid?)u.Id) - .FirstOrDefaultAsync(ct); - - if (userId is null) return true; - - return await LinkAsync(bypass.Id, userId.Value, ct); - } - private async Task LinkAsync(Guid bypassTokenId, Guid userId, CancellationToken ct) { var roles = await _db.Users diff --git a/Common/Services/Bypass/IBypassTokenService.cs b/Common/Services/Bypass/IBypassTokenService.cs index a8bb6306..a1c0fc07 100644 --- a/Common/Services/Bypass/IBypassTokenService.cs +++ b/Common/Services/Bypass/IBypassTokenService.cs @@ -31,11 +31,4 @@ public interface IBypassTokenService /// was used, when the user doesn't exist, or when the link was recorded. /// Task TryRecordUseAsync(Guid userId, CancellationToken ct); - - /// - /// Same contract as but resolves the user by email first. - /// If no matching user exists, this is a no-op and returns true so the caller does not - /// expose user-existence by behaving differently — relevant on flows like password-reset initiation. - /// - Task TryRecordUseByEmailAsync(string email, CancellationToken ct); } From bec2d4d840619667f31213a1e3fdccf161982d62 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Tue, 26 May 2026 21:36:45 +0200 Subject: [PATCH 04/16] Update LoginV2.cs --- API/Controller/Account/LoginV2.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/API/Controller/Account/LoginV2.cs b/API/Controller/Account/LoginV2.cs index 84c81a30..f8a4f5bb 100644 --- a/API/Controller/Account/LoginV2.cs +++ b/API/Controller/Account/LoginV2.cs @@ -60,8 +60,7 @@ public async Task 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. + // Mark this account as having used a bypass token if the token is present, if the bypass token has set that it should delete accounts that use it, this account will be marked for deletion if (!await bypassTokens.TryRecordUseAsync(account.Id, cancellationToken)) return Problem(TurnstileError.InvalidTurnstile); From af602a0baf279f0aff1281881ba6be6799d28fe9 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Tue, 26 May 2026 23:17:55 +0200 Subject: [PATCH 05/16] a --- API/Controller/Account/LoginV2.cs | 18 ++--- API/Controller/Account/SignupV2.cs | 19 ++--- API/Controller/Admin/BypassTokenCreate.cs | 42 ---------- API/Controller/Admin/BypassTokenDelete.cs | 17 ---- API/Controller/Admin/BypassTokenList.cs | 29 ------- API/Controller/Admin/BypassTokenPatch.cs | 43 ---------- API/Controller/Admin/BypassTokenRotate.cs | 45 ----------- API/Controller/Admin/DTOs/BypassTokenDto.cs | 29 ------- .../Admin/DTOs/CreateBypassTokenDto.cs | 46 ----------- API/Controller/Tokens/ReportTokens.cs | 8 +- .../Turnstile/CloudflareTurnstileService.cs | 9 +-- Common/Extensions/HttpContextExtensions.cs | 18 ++--- Common/Middleware/BypassTokenMiddleware.cs | 44 ++++++++--- Common/Models/BypassTokenType.cs | 8 +- Common/OpenShockDb/BypassToken.cs | 32 -------- Common/OpenShockDb/BypassTokenUserUse.cs | 18 ----- Common/OpenShockDb/OpenShockContext.cs | 79 ------------------- Common/OpenShockDb/User.cs | 1 - Common/OpenShockServiceHelper.cs | 15 ++-- Common/Services/Bypass/BypassTokenService.cs | 79 ------------------- Common/Services/Bypass/IBypassTokenService.cs | 34 -------- Common/Services/Bypass/ResolvedBypassToken.cs | 11 --- Cron/Jobs/DeleteBypassTokenUsedAccountsJob.cs | 55 ------------- 23 files changed, 73 insertions(+), 626 deletions(-) delete mode 100644 API/Controller/Admin/BypassTokenCreate.cs delete mode 100644 API/Controller/Admin/BypassTokenDelete.cs delete mode 100644 API/Controller/Admin/BypassTokenList.cs delete mode 100644 API/Controller/Admin/BypassTokenPatch.cs delete mode 100644 API/Controller/Admin/BypassTokenRotate.cs delete mode 100644 API/Controller/Admin/DTOs/BypassTokenDto.cs delete mode 100644 API/Controller/Admin/DTOs/CreateBypassTokenDto.cs delete mode 100644 Common/OpenShockDb/BypassToken.cs delete mode 100644 Common/OpenShockDb/BypassTokenUserUse.cs delete mode 100644 Common/Services/Bypass/BypassTokenService.cs delete mode 100644 Common/Services/Bypass/IBypassTokenService.cs delete mode 100644 Common/Services/Bypass/ResolvedBypassToken.cs delete mode 100644 Cron/Jobs/DeleteBypassTokenUsedAccountsJob.cs diff --git a/API/Controller/Account/LoginV2.cs b/API/Controller/Account/LoginV2.cs index f8a4f5bb..445af512 100644 --- a/API/Controller/Account/LoginV2.cs +++ b/API/Controller/Account/LoginV2.cs @@ -1,4 +1,4 @@ -using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc; using OpenShock.API.Models.Requests; using System.Net; using System.Net.Mime; @@ -6,8 +6,8 @@ using Microsoft.AspNetCore.RateLimiting; using OpenShock.Common.Errors; using OpenShock.Common.Extensions; +using OpenShock.Common.Models; using OpenShock.Common.Problems; -using OpenShock.Common.Services.Bypass; using OpenShock.Common.Utils; using OpenShock.API.Errors; using OpenShock.API.Models.Response; @@ -32,7 +32,6 @@ public sealed partial class AccountController public async Task LoginV2( [FromBody] LoginV2 body, [FromServices] ICloudflareTurnstileService turnstileService, - [FromServices] IBypassTokenService bypassTokens, CancellationToken cancellationToken) { var cookieDomain = GetCurrentCookieDomain(); @@ -48,7 +47,7 @@ public async Task LoginV2( return Problem(new OpenShockProblem("InternalServerError", "Internal Server Error", HttpStatusCode.InternalServerError)); } - + var getAccountResult = await _accountService.GetAccountByCredentialsAsync(body.UsernameOrEmail, body.Password, cancellationToken); if (!getAccountResult.TryPickT0(out var account, out var errors)) { @@ -59,13 +58,14 @@ public async Task LoginV2( oauthOnly => Problem(AccountError.AccountOAuthOnly) ); } - - // Mark this account as having used a bypass token if the token is present, if the bypass token has set that it should delete accounts that use it, this account will be marked for deletion - if (!await bypassTokens.TryRecordUseAsync(account.Id, cancellationToken)) + + // Admin accounts must never be authenticated through a bypassed flow — the bypass exists for + // automated tests, not as a credential-less back door to a privileged account. + if (HttpContext.IsBypassed(BypassTokenType.Turnstile) && account.Roles.Contains(RoleType.Admin)) return Problem(TurnstileError.InvalidTurnstile); await CreateSession(account.Id, cookieDomain); - + return Ok(LoginV2OkResponse.FromUser(account)); } -} \ No newline at end of file +} diff --git a/API/Controller/Account/SignupV2.cs b/API/Controller/Account/SignupV2.cs index 87901f45..9feaefc7 100644 --- a/API/Controller/Account/SignupV2.cs +++ b/API/Controller/Account/SignupV2.cs @@ -1,4 +1,4 @@ -using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc; using OpenShock.API.Models.Requests; using System.Net; using System.Net.Mime; @@ -7,9 +7,7 @@ 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; @@ -21,7 +19,6 @@ public sealed partial class AccountController /// /// /// - /// /// /// User successfully signed up /// Username or email already exists @@ -35,7 +32,6 @@ public sealed partial class AccountController public async Task SignUpV2( [FromBody] SignUpV2 body, [FromServices] ICloudflareTurnstileService turnstileService, - [FromServices] IBypassTokenService bypassTokens, CancellationToken cancellationToken) { var turnStile = await turnstileService.VerifyUserResponseTokenAsync(body.TurnstileResponse, HttpContext.GetRemoteIP(), cancellationToken); @@ -49,12 +45,9 @@ public async Task SignUpV2( } var creationAction = await _accountService.CreateAccountWithActivationFlowAsync(body.Email, body.Username, body.Password); - 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(); + return creationAction.Match( + _ => Ok(), + _ => Problem(SignupError.UsernameOrEmailExists) + ); } -} \ No newline at end of file +} diff --git a/API/Controller/Admin/BypassTokenCreate.cs b/API/Controller/Admin/BypassTokenCreate.cs deleted file mode 100644 index 70e228a4..00000000 --- a/API/Controller/Admin/BypassTokenCreate.cs +++ /dev/null @@ -1,42 +0,0 @@ -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(StatusCodes.Status200OK)] - public async Task 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, - }; - } -} diff --git a/API/Controller/Admin/BypassTokenDelete.cs b/API/Controller/Admin/BypassTokenDelete.cs deleted file mode 100644 index 02aa62d0..00000000 --- a/API/Controller/Admin/BypassTokenDelete.cs +++ /dev/null @@ -1,17 +0,0 @@ -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 DeleteBypassToken([FromRoute] Guid id, CancellationToken ct) - { - var nDeleted = await _db.BypassTokens.Where(t => t.Id == id).ExecuteDeleteAsync(ct); - - return nDeleted == 0 ? NotFound() : Ok(); - } -} diff --git a/API/Controller/Admin/BypassTokenList.cs b/API/Controller/Admin/BypassTokenList.cs deleted file mode 100644 index fea11685..00000000 --- a/API/Controller/Admin/BypassTokenList.cs +++ /dev/null @@ -1,29 +0,0 @@ -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 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, - }; - } - } -} diff --git a/API/Controller/Admin/BypassTokenPatch.cs b/API/Controller/Admin/BypassTokenPatch.cs deleted file mode 100644 index 64f667e5..00000000 --- a/API/Controller/Admin/BypassTokenPatch.cs +++ /dev/null @@ -1,43 +0,0 @@ -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(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task 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, - }); - } -} diff --git a/API/Controller/Admin/BypassTokenRotate.cs b/API/Controller/Admin/BypassTokenRotate.cs deleted file mode 100644 index d5bedc3c..00000000 --- a/API/Controller/Admin/BypassTokenRotate.cs +++ /dev/null @@ -1,45 +0,0 @@ -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(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task 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, - }); - } -} diff --git a/API/Controller/Admin/DTOs/BypassTokenDto.cs b/API/Controller/Admin/DTOs/BypassTokenDto.cs deleted file mode 100644 index a04b576a..00000000 --- a/API/Controller/Admin/DTOs/BypassTokenDto.cs +++ /dev/null @@ -1,29 +0,0 @@ -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 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 Types { get; init; } - public required DateTime CreatedAt { get; init; } - public DateTime? LastRotatedAt { get; init; } - public bool AutoCleanupUsers { get; init; } - public TimeSpan? AutoCleanupAfter { get; init; } -} diff --git a/API/Controller/Admin/DTOs/CreateBypassTokenDto.cs b/API/Controller/Admin/DTOs/CreateBypassTokenDto.cs deleted file mode 100644 index cebf0433..00000000 --- a/API/Controller/Admin/DTOs/CreateBypassTokenDto.cs +++ /dev/null @@ -1,46 +0,0 @@ -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 Types { get; init; } - - public bool AutoCleanupUsers { get; init; } - - public TimeSpan? AutoCleanupAfter { get; init; } - - public IEnumerable 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? Types { get; init; } - - public bool? AutoCleanupUsers { get; init; } - - public TimeSpan? AutoCleanupAfter { get; init; } -} diff --git a/API/Controller/Tokens/ReportTokens.cs b/API/Controller/Tokens/ReportTokens.cs index 678b3c4a..2e79a438 100644 --- a/API/Controller/Tokens/ReportTokens.cs +++ b/API/Controller/Tokens/ReportTokens.cs @@ -10,7 +10,7 @@ using OpenShock.API.Errors; using OpenShock.API.Services.Turnstile; using OpenShock.Common.Extensions; -using OpenShock.Common.Services.Bypass; +using OpenShock.Common.Models; using OpenShock.Common.Services.Webhook; namespace OpenShock.API.Controller.Tokens; @@ -22,7 +22,6 @@ public sealed partial class TokensController /// /// /// - /// /// /// /// The tokens were deleted if found @@ -33,7 +32,6 @@ public sealed partial class TokensController public async Task ReportTokens( [FromBody] ReportTokensRequest body, [FromServices] ICloudflareTurnstileService turnstileService, - [FromServices] IBypassTokenService bypassTokens, [FromServices] IWebhookService webhookService, CancellationToken cancellationToken) { @@ -49,8 +47,8 @@ public async Task ReportTokens( return Problem(new OpenShockProblem("InternalServerError", "Internal Server Error", HttpStatusCode.InternalServerError)); } - // Caller is already authenticated; admin role on a bypass-using request is rejected. - if (!await bypassTokens.TryRecordUseAsync(CurrentUser.Id, cancellationToken)) + // Admin accounts must never authenticate through a bypassed flow. + if (HttpContext.IsBypassed(BypassTokenType.Turnstile) && CurrentUser.Roles.Contains(RoleType.Admin)) return Problem(TurnstileError.InvalidTurnstile); var reportId = Guid.CreateVersion7(); diff --git a/API/Services/Turnstile/CloudflareTurnstileService.cs b/API/Services/Turnstile/CloudflareTurnstileService.cs index f504bafc..f1f673af 100644 --- a/API/Services/Turnstile/CloudflareTurnstileService.cs +++ b/API/Services/Turnstile/CloudflareTurnstileService.cs @@ -57,12 +57,9 @@ public async Task>> VerifyUserR { if (!_options.Enabled) return new Success(); - // An admin-issued bypass token resolved earlier in the pipeline counts as a Turnstile pass - // if it carries the Turnstile type. The middleware already bumped use counters; controllers - // separately call IBypassTokenService.RecordUseAsync after auth so admin-using requests can - // be rejected and per-user cleanup can run. - var resolvedBypass = _httpContextAccessor.HttpContext?.GetResolvedBypassToken(); - if (resolvedBypass is not null && resolvedBypass.Types.Contains(BypassTokenType.Turnstile)) + // An admin-set bypass secret (matched against the TURNSTILE_BYPASS_TOKEN configuration property) + // counts as a Turnstile pass. The match was resolved upstream by BypassTokenMiddleware. + if (_httpContextAccessor.HttpContext?.IsBypassed(BypassTokenType.Turnstile) == true) return new Success(); if (string.IsNullOrEmpty(responseToken)) return CreateError(CloudflareTurnstileError.MissingResponse); diff --git a/Common/Extensions/HttpContextExtensions.cs b/Common/Extensions/HttpContextExtensions.cs index 9e4ab5d2..cfe70af0 100644 --- a/Common/Extensions/HttpContextExtensions.cs +++ b/Common/Extensions/HttpContextExtensions.cs @@ -1,13 +1,13 @@ using System.Diagnostics.CodeAnalysis; using Microsoft.AspNetCore.Http; using OpenShock.Common.Constants; -using OpenShock.Common.Services.Bypass; +using OpenShock.Common.Models; namespace OpenShock.Common.Extensions; public static class HttpContextExtensions { - private static readonly object ResolvedBypassTokenItemKey = new(); + private static readonly object BypassedTypesItemKey = new(); public static bool TryGetBypassTokenFromHeader(this HttpContext context, [NotNullWhen(true)] out string? token) { @@ -24,22 +24,22 @@ public static bool TryGetBypassTokenFromHeader(this HttpContext context, [NotNul } /// - /// Stores the result of resolving the bypass-token header. Called by the bypass middleware. + /// Stores the set of bypass types that the header matched. Called by the bypass middleware. /// - public static void SetResolvedBypassToken(this HttpContext context, ResolvedBypassToken? resolved) + public static void SetBypassedTypes(this HttpContext context, BypassTokenType types) { ArgumentNullException.ThrowIfNull(context); - context.Items[ResolvedBypassTokenItemKey] = resolved; + context.Items[BypassedTypesItemKey] = types; } /// - /// Returns the bypass token resolved earlier in the pipeline, or null if no header was - /// present (or it did not resolve to a known token). + /// Returns true if the request presented a bypass token that grants . /// - public static ResolvedBypassToken? GetResolvedBypassToken(this HttpContext context) + public static bool IsBypassed(this HttpContext context, BypassTokenType type) { ArgumentNullException.ThrowIfNull(context); - return context.Items.TryGetValue(ResolvedBypassTokenItemKey, out var v) ? v as ResolvedBypassToken : null; + if (!context.Items.TryGetValue(BypassedTypesItemKey, out var v) || v is not BypassTokenType set) return false; + return (set & type) == type; } private static readonly string[] TokenHeaderNames = [ diff --git a/Common/Middleware/BypassTokenMiddleware.cs b/Common/Middleware/BypassTokenMiddleware.cs index 005e698e..80bc7ee9 100644 --- a/Common/Middleware/BypassTokenMiddleware.cs +++ b/Common/Middleware/BypassTokenMiddleware.cs @@ -1,18 +1,25 @@ +using System.Security.Cryptography; +using System.Text; +using OneOf.Types; using OpenShock.Common.Extensions; -using OpenShock.Common.Services.Bypass; +using OpenShock.Common.Models; +using OpenShock.Common.Services.Configuration; namespace OpenShock.Common.Middleware; /// -/// Resolves the X-OpenShock-Bypass-Token header (if present) into a -/// stored on . Downstream guards (rate limiter partition selectors, -/// the turnstile service, controllers needing post-auth user linkage) read the cached value -/// synchronously and filter on the type they care about. +/// Resolves the X-OpenShock-Bypass-Token header by comparing it to admin-set configuration +/// properties (TURNSTILE_BYPASS_TOKEN, RATE_LIMIT_BYPASS_TOKEN). The matched bypass +/// flags are stored on so downstream guards (rate limiter selectors, +/// turnstile service) can read them synchronously. /// -/// Runs before UseRateLimiter so the rate limiter can honor the bypass for the very same request. +/// Runs before UseRateLimiter. /// public sealed class BypassTokenMiddleware { + public const string TurnstileConfigKey = "TURNSTILE_BYPASS_TOKEN"; + public const string RateLimitConfigKey = "RATE_LIMIT_BYPASS_TOKEN"; + private readonly RequestDelegate _next; public BypassTokenMiddleware(RequestDelegate next) @@ -20,14 +27,31 @@ public BypassTokenMiddleware(RequestDelegate next) _next = next; } - public async Task InvokeAsync(HttpContext context, IBypassTokenService bypassTokens) + public async Task InvokeAsync(HttpContext context, IConfigurationService config) { - if (context.TryGetBypassTokenFromHeader(out var secret)) + if (!context.TryGetBypassTokenFromHeader(out var presented)) { - var resolved = await bypassTokens.ResolveAsync(secret, context.RequestAborted); - context.SetResolvedBypassToken(resolved); + await _next(context); + return; } + var matched = BypassTokenType.None; + + if (await MatchesAsync(config, TurnstileConfigKey, presented)) matched |= BypassTokenType.Turnstile; + if (await MatchesAsync(config, RateLimitConfigKey, presented)) matched |= BypassTokenType.RateLimit; + + if (matched != BypassTokenType.None) context.SetBypassedTypes(matched); + await _next(context); } + + private static async Task MatchesAsync(IConfigurationService config, string key, string presented) + { + var result = await config.TryGetStringAsync(key); + return result.TryPickT0(out var configured, out _) + && !string.IsNullOrEmpty(configured) + && CryptographicOperations.FixedTimeEquals( + Encoding.UTF8.GetBytes(configured), + Encoding.UTF8.GetBytes(presented)); + } } diff --git a/Common/Models/BypassTokenType.cs b/Common/Models/BypassTokenType.cs index a669cf08..0b2ff4dc 100644 --- a/Common/Models/BypassTokenType.cs +++ b/Common/Models/BypassTokenType.cs @@ -1,9 +1,9 @@ -using NpgsqlTypes; - namespace OpenShock.Common.Models; +[Flags] public enum BypassTokenType { - [PgName("turnstile")] Turnstile, - [PgName("rate_limit")] RateLimit + None = 0, + Turnstile = 1 << 0, + RateLimit = 1 << 1 } diff --git a/Common/OpenShockDb/BypassToken.cs b/Common/OpenShockDb/BypassToken.cs deleted file mode 100644 index a0fd4e36..00000000 --- a/Common/OpenShockDb/BypassToken.cs +++ /dev/null @@ -1,32 +0,0 @@ -using OpenShock.Common.Models; - -namespace OpenShock.Common.OpenShockDb; - -public sealed class BypassToken -{ - public required Guid Id { get; set; } - - public required string Name { get; set; } - - public required string TokenHash { get; set; } - - public required List Types { get; set; } - - public DateTime CreatedAt { get; set; } - - public DateTime? LastUsedAt { get; set; } - - public Guid? LastUsedByUserId { get; set; } - - public DateTime? LastRotatedAt { get; set; } - - public long UseCount { get; set; } - - public bool AutoCleanupUsers { get; set; } - - public TimeSpan? AutoCleanupAfter { get; set; } - - // Navigations - public User? LastUsedByUser { get; set; } - public ICollection UserUses { get; } = []; -} diff --git a/Common/OpenShockDb/BypassTokenUserUse.cs b/Common/OpenShockDb/BypassTokenUserUse.cs deleted file mode 100644 index d5db3d2a..00000000 --- a/Common/OpenShockDb/BypassTokenUserUse.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace OpenShock.Common.OpenShockDb; - -public sealed class BypassTokenUserUse -{ - public required Guid BypassTokenId { get; set; } - - public required Guid UserId { get; set; } - - public DateTime FirstUsedAt { get; set; } - - public DateTime LastUsedAt { get; set; } - - public long UseCount { get; set; } - - // Navigations - public BypassToken BypassToken { get; set; } = null!; - public User User { get; set; } = null!; -} diff --git a/Common/OpenShockDb/OpenShockContext.cs b/Common/OpenShockDb/OpenShockContext.cs index 6992389a..feddcfc1 100644 --- a/Common/OpenShockDb/OpenShockContext.cs +++ b/Common/OpenShockDb/OpenShockContext.cs @@ -70,7 +70,6 @@ public static void ConfigureOptionsBuilder(DbContextOptionsBuilder optionsBuilde npgsqlBuilder.MapEnum(); npgsqlBuilder.MapEnum(); npgsqlBuilder.MapEnum(); - npgsqlBuilder.MapEnum(); }); if (debug) @@ -128,10 +127,6 @@ public static void ConfigureOptionsBuilder(DbContextOptionsBuilder optionsBuilde public DbSet EmailProviderBlacklists { get; set; } - public DbSet BypassTokens { get; set; } - - public DbSet BypassTokenUserUses { get; set; } - public DbSet DataProtectionKeys { get; set; } protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) @@ -154,7 +149,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .HasPostgresEnum("shocker_model_type", ["caiXianlin", "petTrainer", "petrainer998DR", "wellturnT330"]) .HasPostgresEnum("match_type_enum", ["exact", "contains"]) .HasPostgresEnum("configuration_value_type", ["string", "bool", "int", "float", "json"]) - .HasPostgresEnum("bypass_token_type", ["turnstile", "rate_limit"]) .HasCollation("public", "ndcoll", "und-u-ks-level2", "icu", false); // Add case-insensitive, accent-sensitive comparison collation modelBuilder.Entity(entity => @@ -853,79 +847,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .HasColumnName("created_at"); }); - modelBuilder.Entity(entity => - { - entity.HasKey(e => e.Id).HasName("bypass_tokens_pkey"); - - entity.ToTable("bypass_tokens"); - - entity.HasIndex(e => e.TokenHash).IsUnique(); - entity.HasIndex(e => e.LastUsedByUserId); - - entity.Property(e => e.Id) - .ValueGeneratedNever() - .HasColumnName("id"); - entity.Property(e => e.Name) - .VarCharWithLength(HardLimits.ApiKeyNameMaxLength) - .HasColumnName("name"); - entity.Property(e => e.TokenHash) - .UseCollation("C") - .VarCharWithLength(HardLimits.Sha256HashHexLength) - .HasColumnName("token_hash"); - entity.Property(e => e.Types) - .HasColumnType("bypass_token_type[]") - .HasColumnName("types"); - entity.Property(e => e.CreatedAt) - .HasDefaultValueSql("CURRENT_TIMESTAMP") - .HasColumnName("created_at"); - entity.Property(e => e.LastUsedAt).HasColumnName("last_used_at"); - entity.Property(e => e.LastUsedByUserId).HasColumnName("last_used_by_user_id"); - entity.Property(e => e.LastRotatedAt).HasColumnName("last_rotated_at"); - entity.Property(e => e.UseCount) - .HasDefaultValue(0L) - .HasColumnName("use_count"); - entity.Property(e => e.AutoCleanupUsers) - .HasDefaultValue(false) - .HasColumnName("auto_cleanup_users"); - entity.Property(e => e.AutoCleanupAfter).HasColumnName("auto_cleanup_after"); - - entity.HasOne(d => d.LastUsedByUser).WithMany() - .HasForeignKey(d => d.LastUsedByUserId) - .OnDelete(DeleteBehavior.SetNull) - .HasConstraintName("fk_bypass_tokens_last_used_by_user_id"); - }); - - modelBuilder.Entity(entity => - { - entity.HasKey(e => new { e.BypassTokenId, e.UserId }).HasName("bypass_token_user_uses_pkey"); - - entity.ToTable("bypass_token_user_uses"); - - entity.HasIndex(e => e.LastUsedAt); - - entity.Property(e => e.BypassTokenId).HasColumnName("bypass_token_id"); - entity.Property(e => e.UserId).HasColumnName("user_id"); - entity.Property(e => e.FirstUsedAt) - .HasDefaultValueSql("CURRENT_TIMESTAMP") - .HasColumnName("first_used_at"); - entity.Property(e => e.LastUsedAt) - .HasDefaultValueSql("CURRENT_TIMESTAMP") - .HasColumnName("last_used_at"); - entity.Property(e => e.UseCount) - .HasDefaultValue(0L) - .HasColumnName("use_count"); - - entity.HasOne(d => d.BypassToken).WithMany(p => p.UserUses) - .HasForeignKey(d => d.BypassTokenId) - .OnDelete(DeleteBehavior.Cascade) - .HasConstraintName("fk_bypass_token_user_uses_bypass_token_id"); - - entity.HasOne(d => d.User).WithMany(p => p.BypassTokenUses) - .HasForeignKey(d => d.UserId) - .OnDelete(DeleteBehavior.Cascade) - .HasConstraintName("fk_bypass_token_user_uses_user_id"); - }); - modelBuilder.Entity(entity => { entity diff --git a/Common/OpenShockDb/User.cs b/Common/OpenShockDb/User.cs index 75777b10..bf5a2990 100644 --- a/Common/OpenShockDb/User.cs +++ b/Common/OpenShockDb/User.cs @@ -41,5 +41,4 @@ public sealed class User public ICollection NameChanges { get; } = []; public ICollection EmailChanges { get; } = []; public ICollection PasswordResets { get; } = []; - public ICollection BypassTokenUses { get; } = []; } diff --git a/Common/OpenShockServiceHelper.cs b/Common/OpenShockServiceHelper.cs index 7004ed45..1d0fbe96 100644 --- a/Common/OpenShockServiceHelper.cs +++ b/Common/OpenShockServiceHelper.cs @@ -17,7 +17,6 @@ using OpenShock.Common.Options; using OpenShock.Common.Problems; using OpenShock.Common.Services.BatchUpdate; -using OpenShock.Common.Services.Bypass; using OpenShock.Common.Services.Configuration; using OpenShock.Common.Services.RedisPubSub; using OpenShock.Common.Services.Session; @@ -200,7 +199,6 @@ public static IServiceCollection AddOpenShockServices(this IServiceCollection se services.AddScoped(); services.AddScoped(); - services.AddScoped(); services.AddHttpClient(client => { client.Timeout = TimeSpan.FromSeconds(30); @@ -230,16 +228,13 @@ public static IServiceCollection AddOpenShockServices(this IServiceCollection se return; } - // If the request resolved a bypass token granting RateLimit, skip every limiter on it. - // Selectors are sync and the BypassTokenMiddleware (which runs before UseRateLimiter) - // has already populated HttpContext.Items, so this is just a dictionary lookup. + // If the request presented the configured RATE_LIMIT_BYPASS_TOKEN, skip every limiter on it. + // BypassTokenMiddleware (which runs before UseRateLimiter) has already set HttpContext.Items; + // selectors are sync and this is just a dictionary lookup. static RateLimitPartition? TryBypass(HttpContext ctx) - { - var bypass = ctx.GetResolvedBypassToken(); - return bypass is not null && bypass.Types.Contains(Models.BypassTokenType.RateLimit) - ? RateLimitPartition.GetNoLimiter($"bypass-{bypass.Id}") + => ctx.IsBypassed(Models.BypassTokenType.RateLimit) + ? RateLimitPartition.GetNoLimiter("bypass-ratelimit") : null; - } options.OnRejected = async (context, cancellationToken) => { diff --git a/Common/Services/Bypass/BypassTokenService.cs b/Common/Services/Bypass/BypassTokenService.cs deleted file mode 100644 index f82c3166..00000000 --- a/Common/Services/Bypass/BypassTokenService.cs +++ /dev/null @@ -1,79 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using OpenShock.Common.Extensions; -using OpenShock.Common.Models; -using OpenShock.Common.OpenShockDb; -using OpenShock.Common.Utils; - -namespace OpenShock.Common.Services.Bypass; - -public sealed class BypassTokenService : IBypassTokenService -{ - private readonly OpenShockContext _db; - private readonly IHttpContextAccessor _httpContextAccessor; - - public BypassTokenService(OpenShockContext db, IHttpContextAccessor httpContextAccessor) - { - _db = db; - _httpContextAccessor = httpContextAccessor; - } - - public async Task ResolveAsync(string secret, CancellationToken ct) - { - if (string.IsNullOrEmpty(secret) || secret.Length != IBypassTokenService.SecretLength) return null; - - var hash = HashingUtils.HashToken(secret); - - var token = await _db.BypassTokens - .Where(t => t.TokenHash == hash) - .Select(t => new { t.Id, t.Types }) - .FirstOrDefaultAsync(ct); - - if (token is null) return null; - - var tokenId = token.Id; - await _db.BypassTokens - .Where(t => t.Id == tokenId) - .ExecuteUpdateAsync(s => s - .SetProperty(t => t.LastUsedAt, DateTime.UtcNow) - .SetProperty(t => t.UseCount, t => t.UseCount + 1), ct); - - return new ResolvedBypassToken(tokenId, token.Types); - } - - public Task TryRecordUseAsync(Guid userId, CancellationToken ct) - { - var bypass = _httpContextAccessor.HttpContext?.GetResolvedBypassToken(); - if (bypass is null) return Task.FromResult(true); - - return LinkAsync(bypass.Id, userId, ct); - } - - private async Task LinkAsync(Guid bypassTokenId, Guid userId, CancellationToken ct) - { - var roles = await _db.Users - .Where(u => u.Id == userId) - .Select(u => u.Roles) - .FirstOrDefaultAsync(ct); - - if (roles is null) return true; // user not found — nothing to link; treat as non-admin - if (roles.Contains(RoleType.Admin)) return false; - - var now = DateTime.UtcNow; - - await _db.BypassTokens - .Where(t => t.Id == bypassTokenId) - .ExecuteUpdateAsync(s => s.SetProperty(t => t.LastUsedByUserId, userId), ct); - - const string sql = """ - INSERT INTO bypass_token_user_uses (bypass_token_id, user_id, first_used_at, last_used_at, use_count) - VALUES ({0}, {1}, {2}, {2}, 1) - ON CONFLICT (bypass_token_id, user_id) - DO UPDATE SET last_used_at = EXCLUDED.last_used_at, - use_count = bypass_token_user_uses.use_count + 1 - """; - - await _db.Database.ExecuteSqlRawAsync(sql, [bypassTokenId, userId, now], ct); - - return true; - } -} diff --git a/Common/Services/Bypass/IBypassTokenService.cs b/Common/Services/Bypass/IBypassTokenService.cs deleted file mode 100644 index a1c0fc07..00000000 --- a/Common/Services/Bypass/IBypassTokenService.cs +++ /dev/null @@ -1,34 +0,0 @@ -using OpenShock.Common.Models; -using OpenShock.Common.Utils; - -namespace OpenShock.Common.Services.Bypass; - -public interface IBypassTokenService -{ - /// - /// Length (in chars) of the opaque random secret. The full secret is base62-style alphanumeric. - /// - const int SecretLength = 128; - - /// - /// Generates a new opaque bypass-token secret. The secret is sent via the - /// X-OpenShock-Bypass-Token header — it has no prefix because it lives in a dedicated - /// header and is not confused with any other token. - /// - static string GenerateSecret() => CryptoUtils.RandomAlphaNumericString(SecretLength); - - /// - /// Hashes the supplied secret and looks it up. On a match, increments the global UseCount and - /// LastUsedAt and returns the resolved token. Called once per request by the bypass middleware. - /// - Task ResolveAsync(string secret, CancellationToken ct); - - /// - /// If the current request resolved a bypass token, associates the use with the given user account. - /// Returns false ONLY when a bypass was used AND the target user has the - /// role — callers MUST treat that as "bypass not honored" and - /// reject the request, matching the leak-defense contract. Returns true when no bypass - /// was used, when the user doesn't exist, or when the link was recorded. - /// - Task TryRecordUseAsync(Guid userId, CancellationToken ct); -} diff --git a/Common/Services/Bypass/ResolvedBypassToken.cs b/Common/Services/Bypass/ResolvedBypassToken.cs deleted file mode 100644 index a70bdebc..00000000 --- a/Common/Services/Bypass/ResolvedBypassToken.cs +++ /dev/null @@ -1,11 +0,0 @@ -using OpenShock.Common.Models; - -namespace OpenShock.Common.Services.Bypass; - -/// -/// The result of resolving the X-OpenShock-Bypass-Token header against the database. -/// Set on by the bypass-token middleware -/// so that downstream guards (rate limiter, turnstile, etc.) can read it synchronously without -/// re-hitting the database. -/// -public sealed record ResolvedBypassToken(Guid Id, IReadOnlyList Types); diff --git a/Cron/Jobs/DeleteBypassTokenUsedAccountsJob.cs b/Cron/Jobs/DeleteBypassTokenUsedAccountsJob.cs deleted file mode 100644 index 38407c3c..00000000 --- a/Cron/Jobs/DeleteBypassTokenUsedAccountsJob.cs +++ /dev/null @@ -1,55 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using OpenShock.Common.Models; -using OpenShock.Common.OpenShockDb; -using OpenShock.Cron.Attributes; - -namespace OpenShock.Cron.Jobs; - -/// -/// Hard-deletes user accounts that authenticated through an admin-issued bypass token -/// whose owner enabled auto-cleanup, once the configured grace period has elapsed since -/// the last bypass use. Defends against leaked bypass tokens by ensuring test-style -/// accounts created through them do not persist indefinitely. -/// -[CronJob("0 * * * *")] // Every hour -public sealed class DeleteBypassTokenUsedAccountsJob -{ - private readonly OpenShockContext _db; - private readonly ILogger _logger; - - public DeleteBypassTokenUsedAccountsJob(OpenShockContext db, ILogger logger) - { - _db = db; - _logger = logger; - } - - public async Task Execute() - { - var now = DateTime.UtcNow; - - var userIds = await _db.BypassTokenUserUses - .Where(use => use.BypassToken.AutoCleanupUsers - && use.BypassToken.AutoCleanupAfter != null - && use.LastUsedAt + use.BypassToken.AutoCleanupAfter < now) - .Select(use => use.UserId) - .Distinct() - .ToListAsync(); - - if (userIds.Count == 0) - { - _logger.LogDebug("No bypass-token-used accounts eligible for cleanup"); - return 0; - } - - int nDeleted = await _db.Users - .Where(u => userIds.Contains(u.Id) && !u.Roles.Contains(RoleType.Admin)) - .ExecuteDeleteAsync(); - - _logger.LogInformation( - "Bypass-token cleanup: {DeletedCount}/{CandidateCount} accounts deleted", - nDeleted, - userIds.Count); - - return nDeleted; - } -} From 818aea7daa54c0127ba9c66930ed10ab3fef732c Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Tue, 26 May 2026 23:19:15 +0200 Subject: [PATCH 06/16] Revert "temporary migration" This reverts commit 636b7674c47438178b7f37d5a7a291b91c4ca2d7. --- ...20260526192123_AddBypassTokens.Designer.cs | 1624 ----------------- .../20260526192123_AddBypassTokens.cs | 144 -- .../OpenShockContextModelSnapshot.cs | 149 +- 3 files changed, 1 insertion(+), 1916 deletions(-) delete mode 100644 Common/Migrations/20260526192123_AddBypassTokens.Designer.cs delete mode 100644 Common/Migrations/20260526192123_AddBypassTokens.cs diff --git a/Common/Migrations/20260526192123_AddBypassTokens.Designer.cs b/Common/Migrations/20260526192123_AddBypassTokens.Designer.cs deleted file mode 100644 index 51aac44d..00000000 --- a/Common/Migrations/20260526192123_AddBypassTokens.Designer.cs +++ /dev/null @@ -1,1624 +0,0 @@ -// -using System; -using System.Collections.Generic; -using System.Net; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; -using OpenShock.Common.Models; -using OpenShock.Common.OpenShockDb; - -#nullable disable - -namespace OpenShock.Common.Migrations -{ - [DbContext(typeof(MigrationOpenShockContext))] - [Migration("20260526192123_AddBypassTokens")] - partial class AddBypassTokens - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("Npgsql:CollationDefinition:public.ndcoll", "und-u-ks-level2,und-u-ks-level2,icu,False") - .HasAnnotation("ProductVersion", "10.0.8") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "bypass_token_type", new[] { "turnstile", "rate_limit" }); - NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "configuration_value_type", new[] { "string", "bool", "int", "float", "json" }); - NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "control_type", new[] { "sound", "vibrate", "shock", "stop" }); - NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "match_type_enum", new[] { "exact", "contains" }); - NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "ota_update_status", new[] { "started", "running", "finished", "error", "timeout" }); - NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "password_encryption_type", new[] { "pbkdf2", "bcrypt_enhanced" }); - NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "permission_type", new[] { "shockers.use", "shockers.edit", "shockers.pause", "devices.edit", "devices.auth" }); - NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "role_type", new[] { "support", "staff", "admin", "system" }); - NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "shocker_model_type", new[] { "caiXianlin", "petTrainer", "petrainer998DR", "wellturnT330" }); - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("FriendlyName") - .HasColumnType("text"); - - b.Property("Xml") - .HasColumnType("text"); - - b.HasKey("Id"); - - b.ToTable("DataProtectionKeys"); - }); - - modelBuilder.Entity("OpenShock.Common.OpenShockDb.AdminUsersView", b => - { - b.Property("ActivatedAt") - .HasColumnType("timestamp with time zone") - .HasColumnName("activated_at"); - - b.Property("ApiTokenCount") - .HasColumnType("integer") - .HasColumnName("api_token_count"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone") - .HasColumnName("created_at"); - - b.Property("DeactivatedAt") - .HasColumnType("timestamp with time zone") - .HasColumnName("deactivated_at"); - - b.Property("DeactivatedByUserId") - .HasColumnType("uuid") - .HasColumnName("deactivated_by_user_id"); - - b.Property("DeviceCount") - .HasColumnType("integer") - .HasColumnName("device_count"); - - b.Property("Email") - .IsRequired() - .HasColumnType("character varying") - .HasColumnName("email"); - - b.Property("EmailChangeRequestCount") - .HasColumnType("integer") - .HasColumnName("email_change_request_count"); - - b.Property("Id") - .HasColumnType("uuid") - .HasColumnName("id"); - - b.Property("Name") - .IsRequired() - .HasColumnType("character varying") - .HasColumnName("name"); - - b.Property("NameChangeRequestCount") - .HasColumnType("integer") - .HasColumnName("name_change_request_count"); - - b.Property("PasswordHashType") - .HasColumnType("character varying") - .HasColumnName("password_hash_type"); - - b.Property("PasswordResetCount") - .HasColumnType("integer") - .HasColumnName("password_reset_count"); - - b.Property("Roles") - .IsRequired() - .HasColumnType("role_type[]") - .HasColumnName("roles"); - - b.Property("ShockerControlLogCount") - .HasColumnType("integer") - .HasColumnName("shocker_control_log_count"); - - b.Property("ShockerCount") - .HasColumnType("integer") - .HasColumnName("shocker_count"); - - b.Property("ShockerPublicShareCount") - .HasColumnType("integer") - .HasColumnName("shocker_public_share_count"); - - b.Property("ShockerUserShareCount") - .HasColumnType("integer") - .HasColumnName("shocker_user_share_count"); - - b.ToTable((string)null); - - b.ToView("admin_users_view", (string)null); - }); - - modelBuilder.Entity("OpenShock.Common.OpenShockDb.ApiToken", b => - { - b.Property("Id") - .HasColumnType("uuid") - .HasColumnName("id"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("created_at") - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - b.Property("CreatedByIp") - .IsRequired() - .HasColumnType("inet") - .HasColumnName("created_by_ip"); - - b.Property("LastUsed") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("last_used") - .HasDefaultValueSql("'-infinity'::timestamp without time zone"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("character varying(64)") - .HasColumnName("name"); - - b.PrimitiveCollection>("Permissions") - .IsRequired() - .HasColumnType("permission_type[]") - .HasColumnName("permissions"); - - b.Property("TokenHash") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("character varying(64)") - .HasColumnName("token_hash") - .UseCollation("C"); - - b.Property("UserId") - .HasColumnType("uuid") - .HasColumnName("user_id"); - - b.Property("ValidUntil") - .HasColumnType("timestamp with time zone") - .HasColumnName("valid_until"); - - b.HasKey("Id") - .HasName("api_tokens_pkey"); - - b.HasIndex("TokenHash") - .IsUnique(); - - b.HasIndex("UserId"); - - b.HasIndex("ValidUntil"); - - b.ToTable("api_tokens", (string)null); - }); - - modelBuilder.Entity("OpenShock.Common.OpenShockDb.ApiTokenReport", b => - { - b.Property("Id") - .HasColumnType("uuid") - .HasColumnName("id"); - - b.Property("AffectedCount") - .HasColumnType("integer") - .HasColumnName("affected_count"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("created_at") - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - b.Property("IpAddress") - .IsRequired() - .HasColumnType("inet") - .HasColumnName("ip_address"); - - b.Property("IpCountry") - .HasColumnType("text") - .HasColumnName("ip_country"); - - b.Property("SubmittedCount") - .HasColumnType("integer") - .HasColumnName("submitted_count"); - - b.Property("UserId") - .HasColumnType("uuid") - .HasColumnName("user_id"); - - b.HasKey("Id") - .HasName("api_token_reports_pkey"); - - b.HasIndex("UserId"); - - b.ToTable("api_token_reports", (string)null); - }); - - modelBuilder.Entity("OpenShock.Common.OpenShockDb.BypassToken", b => - { - b.Property("Id") - .HasColumnType("uuid") - .HasColumnName("id"); - - b.Property("AutoCleanupAfter") - .HasColumnType("interval") - .HasColumnName("auto_cleanup_after"); - - b.Property("AutoCleanupUsers") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(false) - .HasColumnName("auto_cleanup_users"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("created_at") - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - b.Property("LastRotatedAt") - .HasColumnType("timestamp with time zone") - .HasColumnName("last_rotated_at"); - - b.Property("LastUsedAt") - .HasColumnType("timestamp with time zone") - .HasColumnName("last_used_at"); - - b.Property("LastUsedByUserId") - .HasColumnType("uuid") - .HasColumnName("last_used_by_user_id"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("character varying(64)") - .HasColumnName("name"); - - b.Property("TokenHash") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("character varying(64)") - .HasColumnName("token_hash") - .UseCollation("C"); - - b.PrimitiveCollection>("Types") - .IsRequired() - .HasColumnType("bypass_token_type[]") - .HasColumnName("types"); - - b.Property("UseCount") - .ValueGeneratedOnAdd() - .HasColumnType("bigint") - .HasDefaultValue(0L) - .HasColumnName("use_count"); - - b.HasKey("Id") - .HasName("bypass_tokens_pkey"); - - b.HasIndex("LastUsedByUserId"); - - b.HasIndex("TokenHash") - .IsUnique(); - - b.ToTable("bypass_tokens", (string)null); - }); - - modelBuilder.Entity("OpenShock.Common.OpenShockDb.BypassTokenUserUse", b => - { - b.Property("BypassTokenId") - .HasColumnType("uuid") - .HasColumnName("bypass_token_id"); - - b.Property("UserId") - .HasColumnType("uuid") - .HasColumnName("user_id"); - - b.Property("FirstUsedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("first_used_at") - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - b.Property("LastUsedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("last_used_at") - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - b.Property("UseCount") - .ValueGeneratedOnAdd() - .HasColumnType("bigint") - .HasDefaultValue(0L) - .HasColumnName("use_count"); - - b.HasKey("BypassTokenId", "UserId") - .HasName("bypass_token_user_uses_pkey"); - - b.HasIndex("LastUsedAt"); - - b.HasIndex("UserId"); - - b.ToTable("bypass_token_user_uses", (string)null); - }); - - modelBuilder.Entity("OpenShock.Common.OpenShockDb.ConfigurationItem", b => - { - b.Property("Name") - .HasColumnType("text") - .HasColumnName("name") - .UseCollation("C"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("created_at") - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - b.Property("Description") - .IsRequired() - .HasColumnType("text") - .HasColumnName("description"); - - b.Property("Type") - .HasColumnType("configuration_value_type") - .HasColumnName("type"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone") - .HasColumnName("updated_at"); - - b.Property("Value") - .IsRequired() - .HasColumnType("text") - .HasColumnName("value"); - - b.HasKey("Name") - .HasName("configuration_pkey"); - - b.ToTable("configuration", (string)null); - }); - - modelBuilder.Entity("OpenShock.Common.OpenShockDb.Device", b => - { - b.Property("Id") - .HasColumnType("uuid") - .HasColumnName("id"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("created_at") - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("character varying(64)") - .HasColumnName("name"); - - b.Property("OwnerId") - .HasColumnType("uuid") - .HasColumnName("owner_id"); - - b.Property("Token") - .IsRequired() - .HasMaxLength(256) - .HasColumnType("character varying(256)") - .HasColumnName("token") - .UseCollation("C"); - - b.HasKey("Id") - .HasName("devices_pkey"); - - b.HasIndex("OwnerId"); - - b.HasIndex("Token") - .IsUnique(); - - b.ToTable("devices", (string)null); - }); - - modelBuilder.Entity("OpenShock.Common.OpenShockDb.DeviceOtaUpdate", b => - { - b.Property("DeviceId") - .HasColumnType("uuid") - .HasColumnName("device_id"); - - b.Property("UpdateId") - .HasColumnType("integer") - .HasColumnName("update_id"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("created_at") - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - b.Property("Message") - .HasMaxLength(128) - .HasColumnType("character varying(128)") - .HasColumnName("message"); - - b.Property("Status") - .HasColumnType("ota_update_status") - .HasColumnName("status"); - - b.Property("Version") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("character varying(64)") - .HasColumnName("version"); - - b.HasKey("DeviceId", "UpdateId") - .HasName("device_ota_updates_pkey"); - - b.HasIndex(new[] { "CreatedAt" }, "device_ota_updates_created_at_idx"); - - b.ToTable("device_ota_updates", (string)null); - }); - - modelBuilder.Entity("OpenShock.Common.OpenShockDb.DiscordWebhook", b => - { - b.Property("Name") - .HasColumnType("text") - .HasColumnName("name"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("created_at") - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - b.Property("Id") - .HasColumnType("uuid") - .HasColumnName("id"); - - b.Property("WebhookId") - .HasColumnType("bigint") - .HasColumnName("webhook_id"); - - b.Property("WebhookToken") - .IsRequired() - .HasColumnType("text") - .HasColumnName("webhook_token"); - - b.HasKey("Name") - .HasName("discord_webhooks_pkey"); - - b.ToTable("discord_webhooks", (string)null); - }); - - modelBuilder.Entity("OpenShock.Common.OpenShockDb.EmailProviderBlacklist", b => - { - b.Property("Id") - .HasColumnType("uuid") - .HasColumnName("id"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("created_at") - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - b.Property("Domain") - .IsRequired() - .HasMaxLength(255) - .HasColumnType("character varying(255)") - .HasColumnName("domain") - .UseCollation("ndcoll"); - - b.HasKey("Id") - .HasName("email_provider_blacklist_pkey"); - - b.HasIndex("Domain") - .IsUnique(); - - NpgsqlIndexBuilderExtensions.UseCollation(b.HasIndex("Domain"), new[] { "ndcoll" }); - - b.ToTable("email_provider_blacklist", (string)null); - }); - - modelBuilder.Entity("OpenShock.Common.OpenShockDb.PublicShare", b => - { - b.Property("Id") - .HasColumnType("uuid") - .HasColumnName("id"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("created_at") - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - b.Property("ExpiresAt") - .HasColumnType("timestamp with time zone") - .HasColumnName("expires_at"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("character varying(64)") - .HasColumnName("name"); - - b.Property("OwnerId") - .HasColumnType("uuid") - .HasColumnName("owner_id"); - - b.HasKey("Id") - .HasName("public_shares_pkey"); - - b.HasIndex("OwnerId"); - - b.ToTable("public_shares", (string)null); - }); - - modelBuilder.Entity("OpenShock.Common.OpenShockDb.PublicShareShocker", b => - { - b.Property("PublicShareId") - .HasColumnType("uuid") - .HasColumnName("public_share_id"); - - b.Property("ShockerId") - .HasColumnType("uuid") - .HasColumnName("shocker_id"); - - b.Property("AllowLiveControl") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(false) - .HasColumnName("allow_livecontrol"); - - b.Property("AllowShock") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(true) - .HasColumnName("allow_shock"); - - b.Property("AllowSound") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(true) - .HasColumnName("allow_sound"); - - b.Property("AllowVibrate") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(true) - .HasColumnName("allow_vibrate"); - - b.Property("Cooldown") - .HasColumnType("integer") - .HasColumnName("cooldown"); - - b.Property("IsPaused") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(false) - .HasColumnName("is_paused"); - - b.Property("MaxDuration") - .HasColumnType("integer") - .HasColumnName("max_duration"); - - b.Property("MaxIntensity") - .HasColumnType("smallint") - .HasColumnName("max_intensity"); - - b.HasKey("PublicShareId", "ShockerId") - .HasName("public_share_shockers_pkey"); - - b.HasIndex("ShockerId"); - - b.ToTable("public_share_shockers", (string)null); - }); - - modelBuilder.Entity("OpenShock.Common.OpenShockDb.Shocker", b => - { - b.Property("Id") - .HasColumnType("uuid") - .HasColumnName("id"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("created_at") - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - b.Property("DeviceId") - .HasColumnType("uuid") - .HasColumnName("device_id"); - - b.Property("IsPaused") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(false) - .HasColumnName("is_paused"); - - b.Property("Model") - .HasColumnType("shocker_model_type") - .HasColumnName("model"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("character varying(64)") - .HasColumnName("name"); - - b.Property("RfId") - .HasColumnType("integer") - .HasColumnName("rf_id"); - - b.HasKey("Id") - .HasName("shockers_pkey"); - - b.HasIndex("DeviceId"); - - b.ToTable("shockers", (string)null); - }); - - modelBuilder.Entity("OpenShock.Common.OpenShockDb.ShockerControlLog", b => - { - b.Property("Id") - .HasColumnType("uuid") - .HasColumnName("id"); - - b.Property("ControlledByUserId") - .HasColumnType("uuid") - .HasColumnName("controlled_by_user_id"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("created_at") - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - b.Property("CustomName") - .HasMaxLength(64) - .HasColumnType("character varying(64)") - .HasColumnName("custom_name"); - - b.Property("Duration") - .HasColumnType("bigint") - .HasColumnName("duration"); - - b.Property("Intensity") - .HasColumnType("smallint") - .HasColumnName("intensity"); - - b.Property("LiveControl") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(false) - .HasColumnName("live_control"); - - b.Property("ShockerId") - .HasColumnType("uuid") - .HasColumnName("shocker_id"); - - b.Property("Type") - .HasColumnType("control_type") - .HasColumnName("type"); - - b.HasKey("Id") - .HasName("shocker_control_logs_pkey"); - - b.HasIndex("ControlledByUserId"); - - b.HasIndex("ShockerId"); - - b.ToTable("shocker_control_logs", (string)null); - }); - - modelBuilder.Entity("OpenShock.Common.OpenShockDb.ShockerShareCode", b => - { - b.Property("Id") - .HasColumnType("uuid") - .HasColumnName("id"); - - b.Property("AllowLiveControl") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(true) - .HasColumnName("allow_livecontrol"); - - b.Property("AllowShock") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(true) - .HasColumnName("allow_shock"); - - b.Property("AllowSound") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(true) - .HasColumnName("allow_sound"); - - b.Property("AllowVibrate") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(true) - .HasColumnName("allow_vibrate"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("created_at") - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - b.Property("IsPaused") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(false) - .HasColumnName("is_paused"); - - b.Property("MaxDuration") - .HasColumnType("integer") - .HasColumnName("max_duration"); - - b.Property("MaxIntensity") - .HasColumnType("smallint") - .HasColumnName("max_intensity"); - - b.Property("ShockerId") - .HasColumnType("uuid") - .HasColumnName("shocker_id"); - - b.HasKey("Id") - .HasName("shocker_share_codes_pkey"); - - b.HasIndex("ShockerId"); - - b.ToTable("shocker_share_codes", (string)null); - }); - - modelBuilder.Entity("OpenShock.Common.OpenShockDb.User", b => - { - b.Property("Id") - .HasColumnType("uuid") - .HasColumnName("id"); - - b.Property("ActivatedAt") - .HasColumnType("timestamp with time zone") - .HasColumnName("activated_at"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("created_at") - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - b.Property("Email") - .IsRequired() - .HasMaxLength(320) - .HasColumnType("character varying(320)") - .HasColumnName("email"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(32) - .HasColumnType("character varying(32)") - .HasColumnName("name") - .UseCollation("ndcoll"); - - b.Property("PasswordHash") - .HasMaxLength(100) - .HasColumnType("character varying(100)") - .HasColumnName("password_hash") - .UseCollation("C"); - - b.PrimitiveCollection>("Roles") - .IsRequired() - .HasColumnType("role_type[]") - .HasColumnName("roles"); - - b.Property("SecurityStamp") - .ValueGeneratedOnAdd() - .HasColumnType("uuid") - .HasColumnName("security_stamp") - .HasDefaultValueSql("gen_random_uuid()"); - - b.HasKey("Id") - .HasName("users_pkey"); - - b.HasIndex("Email") - .IsUnique(); - - b.HasIndex("Name") - .IsUnique(); - - NpgsqlIndexBuilderExtensions.UseCollation(b.HasIndex("Name"), new[] { "ndcoll" }); - - b.ToTable("users", (string)null); - }); - - modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserActivationRequest", b => - { - b.Property("UserId") - .HasColumnType("uuid") - .HasColumnName("user_id"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("created_at") - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - b.Property("EmailSendAttempts") - .HasColumnType("integer") - .HasColumnName("email_send_attempts"); - - b.Property("TokenHash") - .IsRequired() - .HasMaxLength(128) - .HasColumnType("character varying(128)") - .HasColumnName("token_hash") - .UseCollation("C"); - - b.HasKey("UserId") - .HasName("user_activation_requests_pkey"); - - b.HasIndex("TokenHash") - .IsUnique(); - - b.ToTable("user_activation_requests", (string)null); - }); - - modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserDeactivation", b => - { - b.Property("DeactivatedUserId") - .HasColumnType("uuid") - .HasColumnName("deactivated_user_id"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("created_at") - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - b.Property("DeactivatedByUserId") - .HasColumnType("uuid") - .HasColumnName("deactivated_by_user_id"); - - b.Property("DeleteLater") - .HasColumnType("boolean") - .HasColumnName("delete_later"); - - b.Property("UserModerationId") - .HasColumnType("uuid") - .HasColumnName("user_moderation_id"); - - b.HasKey("DeactivatedUserId") - .HasName("user_deactivations_pkey"); - - b.HasIndex("DeactivatedByUserId"); - - b.ToTable("user_deactivations", (string)null); - }); - - modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserEmailChange", b => - { - b.Property("Id") - .HasColumnType("uuid") - .HasColumnName("id"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("created_at") - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - b.Property("NewEmail") - .IsRequired() - .HasMaxLength(320) - .HasColumnType("character varying(320)") - .HasColumnName("email_new"); - - b.Property("OldEmail") - .IsRequired() - .HasMaxLength(320) - .HasColumnType("character varying(320)") - .HasColumnName("email_old"); - - b.Property("SecurityStampAtCreate") - .HasColumnType("uuid") - .HasColumnName("security_stamp_at_create"); - - b.Property("TokenHash") - .IsRequired() - .HasMaxLength(128) - .HasColumnType("character varying(128)") - .HasColumnName("token_hash") - .UseCollation("C"); - - b.Property("UsedAt") - .HasColumnType("timestamp with time zone") - .HasColumnName("used_at"); - - b.Property("UserId") - .HasColumnType("uuid") - .HasColumnName("user_id"); - - b.HasKey("Id") - .HasName("user_email_changes_pkey"); - - b.HasIndex("CreatedAt"); - - b.HasIndex("UsedAt"); - - b.HasIndex("UserId"); - - b.ToTable("user_email_changes", (string)null); - }); - - modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserNameBlacklist", b => - { - b.Property("Id") - .HasColumnType("uuid") - .HasColumnName("id"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("created_at") - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - b.Property("MatchType") - .HasColumnType("match_type_enum") - .HasColumnName("match_type"); - - b.Property("Value") - .IsRequired() - .HasMaxLength(32) - .HasColumnType("character varying(32)") - .HasColumnName("value") - .UseCollation("ndcoll"); - - b.HasKey("Id") - .HasName("user_name_blacklist_pkey"); - - b.HasIndex("Value") - .IsUnique(); - - NpgsqlIndexBuilderExtensions.UseCollation(b.HasIndex("Value"), new[] { "ndcoll" }); - - b.ToTable("user_name_blacklist", (string)null); - }); - - modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserNameChange", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer") - .HasColumnName("id"); - - NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Id")); - - b.Property("UserId") - .HasColumnType("uuid") - .HasColumnName("user_id"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("created_at") - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - b.Property("OldName") - .IsRequired() - .HasMaxLength(32) - .HasColumnType("character varying(32)") - .HasColumnName("old_name"); - - b.HasKey("Id", "UserId") - .HasName("user_name_changes_pkey"); - - b.HasIndex("CreatedAt"); - - b.HasIndex("OldName"); - - b.HasIndex("UserId"); - - b.ToTable("user_name_changes", (string)null); - }); - - modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserOAuthConnection", b => - { - b.Property("ProviderKey") - .HasColumnType("text") - .HasColumnName("provider_key") - .UseCollation("C"); - - b.Property("ExternalId") - .HasColumnType("text") - .HasColumnName("external_id"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("created_at") - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - b.Property("DisplayName") - .HasColumnType("text") - .HasColumnName("display_name"); - - b.Property("UserId") - .HasColumnType("uuid") - .HasColumnName("user_id"); - - b.HasKey("ProviderKey", "ExternalId") - .HasName("user_oauth_connections_pkey"); - - b.HasIndex("UserId"); - - b.ToTable("user_oauth_connections", (string)null); - }); - - modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserPasswordReset", b => - { - b.Property("Id") - .HasColumnType("uuid") - .HasColumnName("id"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("created_at") - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - b.Property("SecurityStampAtCreate") - .HasColumnType("uuid") - .HasColumnName("security_stamp_at_create"); - - b.Property("TokenHash") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)") - .HasColumnName("token_hash") - .UseCollation("C"); - - b.Property("UsedAt") - .HasColumnType("timestamp with time zone") - .HasColumnName("used_at"); - - b.Property("UserId") - .HasColumnType("uuid") - .HasColumnName("user_id"); - - b.HasKey("Id") - .HasName("user_password_resets_pkey"); - - b.HasIndex("UserId"); - - b.ToTable("user_password_resets", (string)null); - }); - - modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShare", b => - { - b.Property("SharedWithUserId") - .HasColumnType("uuid") - .HasColumnName("shared_with_user_id"); - - b.Property("ShockerId") - .HasColumnType("uuid") - .HasColumnName("shocker_id"); - - b.Property("AllowLiveControl") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(true) - .HasColumnName("allow_livecontrol"); - - b.Property("AllowShock") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(true) - .HasColumnName("allow_shock"); - - b.Property("AllowSound") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(true) - .HasColumnName("allow_sound"); - - b.Property("AllowVibrate") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(true) - .HasColumnName("allow_vibrate"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("created_at") - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - b.Property("IsPaused") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(false) - .HasColumnName("is_paused"); - - b.Property("MaxDuration") - .HasColumnType("integer") - .HasColumnName("max_duration"); - - b.Property("MaxIntensity") - .HasColumnType("smallint") - .HasColumnName("max_intensity"); - - b.HasKey("SharedWithUserId", "ShockerId") - .HasName("user_shares_pkey"); - - b.HasIndex("SharedWithUserId"); - - b.HasIndex("ShockerId"); - - b.ToTable("user_shares", (string)null); - }); - - modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShareInvite", b => - { - b.Property("Id") - .HasColumnType("uuid") - .HasColumnName("id"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("created_at") - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - b.Property("OwnerId") - .HasColumnType("uuid") - .HasColumnName("owner_id"); - - b.Property("RecipientUserId") - .HasColumnType("uuid") - .HasColumnName("user_id"); - - b.HasKey("Id") - .HasName("user_share_invites_pkey"); - - b.HasIndex("OwnerId"); - - b.HasIndex("RecipientUserId"); - - b.ToTable("user_share_invites", (string)null); - }); - - modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShareInviteShocker", b => - { - b.Property("InviteId") - .HasColumnType("uuid") - .HasColumnName("invite_id"); - - b.Property("ShockerId") - .HasColumnType("uuid") - .HasColumnName("shocker_id"); - - b.Property("AllowLiveControl") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(true) - .HasColumnName("allow_livecontrol"); - - b.Property("AllowShock") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(true) - .HasColumnName("allow_shock"); - - b.Property("AllowSound") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(true) - .HasColumnName("allow_sound"); - - b.Property("AllowVibrate") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(true) - .HasColumnName("allow_vibrate"); - - b.Property("IsPaused") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(false) - .HasColumnName("is_paused"); - - b.Property("MaxDuration") - .HasColumnType("integer") - .HasColumnName("max_duration"); - - b.Property("MaxIntensity") - .HasColumnType("smallint") - .HasColumnName("max_intensity"); - - b.HasKey("InviteId", "ShockerId") - .HasName("user_share_invite_shockers_pkey"); - - b.HasIndex("ShockerId"); - - b.ToTable("user_share_invite_shockers", (string)null); - }); - - modelBuilder.Entity("OpenShock.Common.OpenShockDb.ApiToken", b => - { - b.HasOne("OpenShock.Common.OpenShockDb.User", "User") - .WithMany("ApiTokens") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired() - .HasConstraintName("fk_api_tokens_user_id"); - - b.Navigation("User"); - }); - - modelBuilder.Entity("OpenShock.Common.OpenShockDb.ApiTokenReport", b => - { - b.HasOne("OpenShock.Common.OpenShockDb.User", "ReportedByUser") - .WithMany("ReportedApiTokens") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired() - .HasConstraintName("fk_api_token_reports_reported_by_user_id"); - - b.Navigation("ReportedByUser"); - }); - - modelBuilder.Entity("OpenShock.Common.OpenShockDb.BypassToken", b => - { - b.HasOne("OpenShock.Common.OpenShockDb.User", "LastUsedByUser") - .WithMany() - .HasForeignKey("LastUsedByUserId") - .OnDelete(DeleteBehavior.SetNull) - .HasConstraintName("fk_bypass_tokens_last_used_by_user_id"); - - b.Navigation("LastUsedByUser"); - }); - - modelBuilder.Entity("OpenShock.Common.OpenShockDb.BypassTokenUserUse", b => - { - b.HasOne("OpenShock.Common.OpenShockDb.BypassToken", "BypassToken") - .WithMany("UserUses") - .HasForeignKey("BypassTokenId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired() - .HasConstraintName("fk_bypass_token_user_uses_bypass_token_id"); - - b.HasOne("OpenShock.Common.OpenShockDb.User", "User") - .WithMany("BypassTokenUses") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired() - .HasConstraintName("fk_bypass_token_user_uses_user_id"); - - b.Navigation("BypassToken"); - - b.Navigation("User"); - }); - - modelBuilder.Entity("OpenShock.Common.OpenShockDb.Device", b => - { - b.HasOne("OpenShock.Common.OpenShockDb.User", "Owner") - .WithMany("Devices") - .HasForeignKey("OwnerId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired() - .HasConstraintName("fk_devices_owner_id"); - - b.Navigation("Owner"); - }); - - modelBuilder.Entity("OpenShock.Common.OpenShockDb.DeviceOtaUpdate", b => - { - b.HasOne("OpenShock.Common.OpenShockDb.Device", "Device") - .WithMany("OtaUpdates") - .HasForeignKey("DeviceId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired() - .HasConstraintName("fk_device_ota_updates_device_id"); - - b.Navigation("Device"); - }); - - modelBuilder.Entity("OpenShock.Common.OpenShockDb.PublicShare", b => - { - b.HasOne("OpenShock.Common.OpenShockDb.User", "Owner") - .WithMany("OwnedPublicShares") - .HasForeignKey("OwnerId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired() - .HasConstraintName("fk_public_shares_owner_id"); - - b.Navigation("Owner"); - }); - - modelBuilder.Entity("OpenShock.Common.OpenShockDb.PublicShareShocker", b => - { - b.HasOne("OpenShock.Common.OpenShockDb.PublicShare", "PublicShare") - .WithMany("ShockerMappings") - .HasForeignKey("PublicShareId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired() - .HasConstraintName("fk_public_share_shockers_public_share_id"); - - b.HasOne("OpenShock.Common.OpenShockDb.Shocker", "Shocker") - .WithMany("PublicShareMappings") - .HasForeignKey("ShockerId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired() - .HasConstraintName("fk_public_share_shockers_shocker_id"); - - b.Navigation("PublicShare"); - - b.Navigation("Shocker"); - }); - - modelBuilder.Entity("OpenShock.Common.OpenShockDb.Shocker", b => - { - b.HasOne("OpenShock.Common.OpenShockDb.Device", "Device") - .WithMany("Shockers") - .HasForeignKey("DeviceId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired() - .HasConstraintName("fk_shockers_device_id"); - - b.Navigation("Device"); - }); - - modelBuilder.Entity("OpenShock.Common.OpenShockDb.ShockerControlLog", b => - { - b.HasOne("OpenShock.Common.OpenShockDb.User", "ControlledByUser") - .WithMany("ShockerControlLogs") - .HasForeignKey("ControlledByUserId") - .OnDelete(DeleteBehavior.Cascade) - .HasConstraintName("fk_shocker_control_logs_controlled_by_user_id"); - - b.HasOne("OpenShock.Common.OpenShockDb.Shocker", "Shocker") - .WithMany("ShockerControlLogs") - .HasForeignKey("ShockerId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired() - .HasConstraintName("fk_shocker_control_logs_shocker_id"); - - b.Navigation("ControlledByUser"); - - b.Navigation("Shocker"); - }); - - modelBuilder.Entity("OpenShock.Common.OpenShockDb.ShockerShareCode", b => - { - b.HasOne("OpenShock.Common.OpenShockDb.Shocker", "Shocker") - .WithMany("ShockerShareCodes") - .HasForeignKey("ShockerId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired() - .HasConstraintName("fk_shocker_share_codes_shocker_id"); - - b.Navigation("Shocker"); - }); - - modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserActivationRequest", b => - { - b.HasOne("OpenShock.Common.OpenShockDb.User", "User") - .WithOne("UserActivationRequest") - .HasForeignKey("OpenShock.Common.OpenShockDb.UserActivationRequest", "UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired() - .HasConstraintName("fk_user_activation_requests_user_id"); - - b.Navigation("User"); - }); - - modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserDeactivation", b => - { - b.HasOne("OpenShock.Common.OpenShockDb.User", "DeactivatedByUser") - .WithMany() - .HasForeignKey("DeactivatedByUserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired() - .HasConstraintName("fk_user_deactivations_deactivated_by_user_id"); - - b.HasOne("OpenShock.Common.OpenShockDb.User", "DeactivatedUser") - .WithOne("UserDeactivation") - .HasForeignKey("OpenShock.Common.OpenShockDb.UserDeactivation", "DeactivatedUserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired() - .HasConstraintName("fk_user_deactivations_deactivated_user_id"); - - b.Navigation("DeactivatedByUser"); - - b.Navigation("DeactivatedUser"); - }); - - modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserEmailChange", b => - { - b.HasOne("OpenShock.Common.OpenShockDb.User", "User") - .WithMany("EmailChanges") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired() - .HasConstraintName("fk_user_email_changes_user_id"); - - b.Navigation("User"); - }); - - modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserNameChange", b => - { - b.HasOne("OpenShock.Common.OpenShockDb.User", "User") - .WithMany("NameChanges") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired() - .HasConstraintName("fk_user_name_changes_user_id"); - - b.Navigation("User"); - }); - - modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserOAuthConnection", b => - { - b.HasOne("OpenShock.Common.OpenShockDb.User", "User") - .WithMany("OAuthConnections") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired() - .HasConstraintName("fk_user_oauth_connections_user_id"); - - b.Navigation("User"); - }); - - modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserPasswordReset", b => - { - b.HasOne("OpenShock.Common.OpenShockDb.User", "User") - .WithMany("PasswordResets") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired() - .HasConstraintName("fk_user_password_resets_user_id"); - - b.Navigation("User"); - }); - - modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShare", b => - { - b.HasOne("OpenShock.Common.OpenShockDb.User", "SharedWithUser") - .WithMany("IncomingUserShares") - .HasForeignKey("SharedWithUserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired() - .HasConstraintName("fk_user_shares_shared_with_user_id"); - - b.HasOne("OpenShock.Common.OpenShockDb.Shocker", "Shocker") - .WithMany("UserShares") - .HasForeignKey("ShockerId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired() - .HasConstraintName("fk_user_shares_shocker_id"); - - b.Navigation("SharedWithUser"); - - b.Navigation("Shocker"); - }); - - modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShareInvite", b => - { - b.HasOne("OpenShock.Common.OpenShockDb.User", "Owner") - .WithMany("OutgoingUserShareInvites") - .HasForeignKey("OwnerId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired() - .HasConstraintName("fk_user_share_invites_owner_id"); - - b.HasOne("OpenShock.Common.OpenShockDb.User", "RecipientUser") - .WithMany("IncomingUserShareInvites") - .HasForeignKey("RecipientUserId") - .OnDelete(DeleteBehavior.Cascade) - .HasConstraintName("fk_user_share_invites_recipient_user_id"); - - b.Navigation("Owner"); - - b.Navigation("RecipientUser"); - }); - - modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShareInviteShocker", b => - { - b.HasOne("OpenShock.Common.OpenShockDb.UserShareInvite", "Invite") - .WithMany("ShockerMappings") - .HasForeignKey("InviteId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired() - .HasConstraintName("fk_user_share_invite_shockers_invite_id"); - - b.HasOne("OpenShock.Common.OpenShockDb.Shocker", "Shocker") - .WithMany("UserShareInviteShockerMappings") - .HasForeignKey("ShockerId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired() - .HasConstraintName("fk_user_share_invite_shockers_shocker_id"); - - b.Navigation("Invite"); - - b.Navigation("Shocker"); - }); - - modelBuilder.Entity("OpenShock.Common.OpenShockDb.BypassToken", b => - { - b.Navigation("UserUses"); - }); - - modelBuilder.Entity("OpenShock.Common.OpenShockDb.Device", b => - { - b.Navigation("OtaUpdates"); - - b.Navigation("Shockers"); - }); - - modelBuilder.Entity("OpenShock.Common.OpenShockDb.PublicShare", b => - { - b.Navigation("ShockerMappings"); - }); - - modelBuilder.Entity("OpenShock.Common.OpenShockDb.Shocker", b => - { - b.Navigation("PublicShareMappings"); - - b.Navigation("ShockerControlLogs"); - - b.Navigation("ShockerShareCodes"); - - b.Navigation("UserShareInviteShockerMappings"); - - b.Navigation("UserShares"); - }); - - modelBuilder.Entity("OpenShock.Common.OpenShockDb.User", b => - { - b.Navigation("ApiTokens"); - - b.Navigation("BypassTokenUses"); - - b.Navigation("Devices"); - - b.Navigation("EmailChanges"); - - b.Navigation("IncomingUserShareInvites"); - - b.Navigation("IncomingUserShares"); - - b.Navigation("NameChanges"); - - b.Navigation("OAuthConnections"); - - b.Navigation("OutgoingUserShareInvites"); - - b.Navigation("OwnedPublicShares"); - - b.Navigation("PasswordResets"); - - b.Navigation("ReportedApiTokens"); - - b.Navigation("ShockerControlLogs"); - - b.Navigation("UserActivationRequest"); - - b.Navigation("UserDeactivation"); - }); - - modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShareInvite", b => - { - b.Navigation("ShockerMappings"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/Common/Migrations/20260526192123_AddBypassTokens.cs b/Common/Migrations/20260526192123_AddBypassTokens.cs deleted file mode 100644 index ece78642..00000000 --- a/Common/Migrations/20260526192123_AddBypassTokens.cs +++ /dev/null @@ -1,144 +0,0 @@ -using System; -using System.Collections.Generic; -using Microsoft.EntityFrameworkCore.Migrations; -using OpenShock.Common.Models; - -#nullable disable - -namespace OpenShock.Common.Migrations -{ - /// - public partial class AddBypassTokens : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AlterDatabase() - .Annotation("Npgsql:CollationDefinition:public.ndcoll", "und-u-ks-level2,und-u-ks-level2,icu,False") - .Annotation("Npgsql:Enum:bypass_token_type", "turnstile,rate_limit") - .Annotation("Npgsql:Enum:configuration_value_type", "string,bool,int,float,json") - .Annotation("Npgsql:Enum:control_type", "sound,vibrate,shock,stop") - .Annotation("Npgsql:Enum:match_type_enum", "exact,contains") - .Annotation("Npgsql:Enum:ota_update_status", "started,running,finished,error,timeout") - .Annotation("Npgsql:Enum:password_encryption_type", "pbkdf2,bcrypt_enhanced") - .Annotation("Npgsql:Enum:permission_type", "shockers.use,shockers.edit,shockers.pause,devices.edit,devices.auth") - .Annotation("Npgsql:Enum:role_type", "support,staff,admin,system") - .Annotation("Npgsql:Enum:shocker_model_type", "caiXianlin,petTrainer,petrainer998DR,wellturnT330") - .OldAnnotation("Npgsql:CollationDefinition:public.ndcoll", "und-u-ks-level2,und-u-ks-level2,icu,False") - .OldAnnotation("Npgsql:Enum:configuration_value_type", "string,bool,int,float,json") - .OldAnnotation("Npgsql:Enum:control_type", "sound,vibrate,shock,stop") - .OldAnnotation("Npgsql:Enum:match_type_enum", "exact,contains") - .OldAnnotation("Npgsql:Enum:ota_update_status", "started,running,finished,error,timeout") - .OldAnnotation("Npgsql:Enum:password_encryption_type", "pbkdf2,bcrypt_enhanced") - .OldAnnotation("Npgsql:Enum:permission_type", "shockers.use,shockers.edit,shockers.pause,devices.edit,devices.auth") - .OldAnnotation("Npgsql:Enum:role_type", "support,staff,admin,system") - .OldAnnotation("Npgsql:Enum:shocker_model_type", "caiXianlin,petTrainer,petrainer998DR,wellturnT330"); - - migrationBuilder.CreateTable( - name: "bypass_tokens", - columns: table => new - { - id = table.Column(type: "uuid", nullable: false), - name = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), - token_hash = table.Column(type: "character varying(64)", maxLength: 64, nullable: false, collation: "C"), - types = table.Column>(type: "bypass_token_type[]", nullable: false), - created_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"), - last_used_at = table.Column(type: "timestamp with time zone", nullable: true), - last_used_by_user_id = table.Column(type: "uuid", nullable: true), - last_rotated_at = table.Column(type: "timestamp with time zone", nullable: true), - use_count = table.Column(type: "bigint", nullable: false, defaultValue: 0L), - auto_cleanup_users = table.Column(type: "boolean", nullable: false, defaultValue: false), - auto_cleanup_after = table.Column(type: "interval", nullable: true) - }, - constraints: table => - { - table.PrimaryKey("bypass_tokens_pkey", x => x.id); - table.ForeignKey( - name: "fk_bypass_tokens_last_used_by_user_id", - column: x => x.last_used_by_user_id, - principalTable: "users", - principalColumn: "id", - onDelete: ReferentialAction.SetNull); - }); - - migrationBuilder.CreateTable( - name: "bypass_token_user_uses", - columns: table => new - { - bypass_token_id = table.Column(type: "uuid", nullable: false), - user_id = table.Column(type: "uuid", nullable: false), - first_used_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"), - last_used_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"), - use_count = table.Column(type: "bigint", nullable: false, defaultValue: 0L) - }, - constraints: table => - { - table.PrimaryKey("bypass_token_user_uses_pkey", x => new { x.bypass_token_id, x.user_id }); - table.ForeignKey( - name: "fk_bypass_token_user_uses_bypass_token_id", - column: x => x.bypass_token_id, - principalTable: "bypass_tokens", - principalColumn: "id", - onDelete: ReferentialAction.Cascade); - table.ForeignKey( - name: "fk_bypass_token_user_uses_user_id", - column: x => x.user_id, - principalTable: "users", - principalColumn: "id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateIndex( - name: "IX_bypass_token_user_uses_last_used_at", - table: "bypass_token_user_uses", - column: "last_used_at"); - - migrationBuilder.CreateIndex( - name: "IX_bypass_token_user_uses_user_id", - table: "bypass_token_user_uses", - column: "user_id"); - - migrationBuilder.CreateIndex( - name: "IX_bypass_tokens_last_used_by_user_id", - table: "bypass_tokens", - column: "last_used_by_user_id"); - - migrationBuilder.CreateIndex( - name: "IX_bypass_tokens_token_hash", - table: "bypass_tokens", - column: "token_hash", - unique: true); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "bypass_token_user_uses"); - - migrationBuilder.DropTable( - name: "bypass_tokens"); - - migrationBuilder.AlterDatabase() - .Annotation("Npgsql:CollationDefinition:public.ndcoll", "und-u-ks-level2,und-u-ks-level2,icu,False") - .Annotation("Npgsql:Enum:configuration_value_type", "string,bool,int,float,json") - .Annotation("Npgsql:Enum:control_type", "sound,vibrate,shock,stop") - .Annotation("Npgsql:Enum:match_type_enum", "exact,contains") - .Annotation("Npgsql:Enum:ota_update_status", "started,running,finished,error,timeout") - .Annotation("Npgsql:Enum:password_encryption_type", "pbkdf2,bcrypt_enhanced") - .Annotation("Npgsql:Enum:permission_type", "shockers.use,shockers.edit,shockers.pause,devices.edit,devices.auth") - .Annotation("Npgsql:Enum:role_type", "support,staff,admin,system") - .Annotation("Npgsql:Enum:shocker_model_type", "caiXianlin,petTrainer,petrainer998DR,wellturnT330") - .OldAnnotation("Npgsql:CollationDefinition:public.ndcoll", "und-u-ks-level2,und-u-ks-level2,icu,False") - .OldAnnotation("Npgsql:Enum:bypass_token_type", "turnstile,rate_limit") - .OldAnnotation("Npgsql:Enum:configuration_value_type", "string,bool,int,float,json") - .OldAnnotation("Npgsql:Enum:control_type", "sound,vibrate,shock,stop") - .OldAnnotation("Npgsql:Enum:match_type_enum", "exact,contains") - .OldAnnotation("Npgsql:Enum:ota_update_status", "started,running,finished,error,timeout") - .OldAnnotation("Npgsql:Enum:password_encryption_type", "pbkdf2,bcrypt_enhanced") - .OldAnnotation("Npgsql:Enum:permission_type", "shockers.use,shockers.edit,shockers.pause,devices.edit,devices.auth") - .OldAnnotation("Npgsql:Enum:role_type", "support,staff,admin,system") - .OldAnnotation("Npgsql:Enum:shocker_model_type", "caiXianlin,petTrainer,petrainer998DR,wellturnT330"); - } - } -} diff --git a/Common/Migrations/OpenShockContextModelSnapshot.cs b/Common/Migrations/OpenShockContextModelSnapshot.cs index 42da10d9..0a030050 100644 --- a/Common/Migrations/OpenShockContextModelSnapshot.cs +++ b/Common/Migrations/OpenShockContextModelSnapshot.cs @@ -21,10 +21,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) #pragma warning disable 612, 618 modelBuilder .HasAnnotation("Npgsql:CollationDefinition:public.ndcoll", "und-u-ks-level2,und-u-ks-level2,icu,False") - .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("ProductVersion", "10.0.7") .HasAnnotation("Relational:MaxIdentifierLength", 63); - NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "bypass_token_type", new[] { "turnstile", "rate_limit" }); NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "configuration_value_type", new[] { "string", "bool", "int", "float", "json" }); NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "control_type", new[] { "sound", "vibrate", "shock", "stop" }); NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "match_type_enum", new[] { "exact", "contains" }); @@ -239,113 +238,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("api_token_reports", (string)null); }); - modelBuilder.Entity("OpenShock.Common.OpenShockDb.BypassToken", b => - { - b.Property("Id") - .HasColumnType("uuid") - .HasColumnName("id"); - - b.Property("AutoCleanupAfter") - .HasColumnType("interval") - .HasColumnName("auto_cleanup_after"); - - b.Property("AutoCleanupUsers") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(false) - .HasColumnName("auto_cleanup_users"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("created_at") - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - b.Property("LastRotatedAt") - .HasColumnType("timestamp with time zone") - .HasColumnName("last_rotated_at"); - - b.Property("LastUsedAt") - .HasColumnType("timestamp with time zone") - .HasColumnName("last_used_at"); - - b.Property("LastUsedByUserId") - .HasColumnType("uuid") - .HasColumnName("last_used_by_user_id"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("character varying(64)") - .HasColumnName("name"); - - b.Property("TokenHash") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("character varying(64)") - .HasColumnName("token_hash") - .UseCollation("C"); - - b.PrimitiveCollection>("Types") - .IsRequired() - .HasColumnType("bypass_token_type[]") - .HasColumnName("types"); - - b.Property("UseCount") - .ValueGeneratedOnAdd() - .HasColumnType("bigint") - .HasDefaultValue(0L) - .HasColumnName("use_count"); - - b.HasKey("Id") - .HasName("bypass_tokens_pkey"); - - b.HasIndex("LastUsedByUserId"); - - b.HasIndex("TokenHash") - .IsUnique(); - - b.ToTable("bypass_tokens", (string)null); - }); - - modelBuilder.Entity("OpenShock.Common.OpenShockDb.BypassTokenUserUse", b => - { - b.Property("BypassTokenId") - .HasColumnType("uuid") - .HasColumnName("bypass_token_id"); - - b.Property("UserId") - .HasColumnType("uuid") - .HasColumnName("user_id"); - - b.Property("FirstUsedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("first_used_at") - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - b.Property("LastUsedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("last_used_at") - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - b.Property("UseCount") - .ValueGeneratedOnAdd() - .HasColumnType("bigint") - .HasDefaultValue(0L) - .HasColumnName("use_count"); - - b.HasKey("BypassTokenId", "UserId") - .HasName("bypass_token_user_uses_pkey"); - - b.HasIndex("LastUsedAt"); - - b.HasIndex("UserId"); - - b.ToTable("bypass_token_user_uses", (string)null); - }); - modelBuilder.Entity("OpenShock.Common.OpenShockDb.ConfigurationItem", b => { b.Property("Name") @@ -1272,38 +1164,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("ReportedByUser"); }); - modelBuilder.Entity("OpenShock.Common.OpenShockDb.BypassToken", b => - { - b.HasOne("OpenShock.Common.OpenShockDb.User", "LastUsedByUser") - .WithMany() - .HasForeignKey("LastUsedByUserId") - .OnDelete(DeleteBehavior.SetNull) - .HasConstraintName("fk_bypass_tokens_last_used_by_user_id"); - - b.Navigation("LastUsedByUser"); - }); - - modelBuilder.Entity("OpenShock.Common.OpenShockDb.BypassTokenUserUse", b => - { - b.HasOne("OpenShock.Common.OpenShockDb.BypassToken", "BypassToken") - .WithMany("UserUses") - .HasForeignKey("BypassTokenId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired() - .HasConstraintName("fk_bypass_token_user_uses_bypass_token_id"); - - b.HasOne("OpenShock.Common.OpenShockDb.User", "User") - .WithMany("BypassTokenUses") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired() - .HasConstraintName("fk_bypass_token_user_uses_user_id"); - - b.Navigation("BypassToken"); - - b.Navigation("User"); - }); - modelBuilder.Entity("OpenShock.Common.OpenShockDb.Device", b => { b.HasOne("OpenShock.Common.OpenShockDb.User", "Owner") @@ -1548,11 +1408,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Shocker"); }); - modelBuilder.Entity("OpenShock.Common.OpenShockDb.BypassToken", b => - { - b.Navigation("UserUses"); - }); - modelBuilder.Entity("OpenShock.Common.OpenShockDb.Device", b => { b.Navigation("OtaUpdates"); @@ -1582,8 +1437,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) { b.Navigation("ApiTokens"); - b.Navigation("BypassTokenUses"); - b.Navigation("Devices"); b.Navigation("EmailChanges"); From d8f6358b4b188f76f6cafe120fe6a19f7b9cf7dc Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Thu, 28 May 2026 14:46:51 +0200 Subject: [PATCH 07/16] restore whitespace changes --- API/Controller/Account/LoginV2.cs | 10 +++++----- API/Controller/Account/PasswordResetInitiateV2.cs | 2 +- API/Controller/Account/SignupV2.cs | 4 ++-- Common/OpenShockDb/OpenShockContext.cs | 2 +- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/API/Controller/Account/LoginV2.cs b/API/Controller/Account/LoginV2.cs index 445af512..b38ebcfa 100644 --- a/API/Controller/Account/LoginV2.cs +++ b/API/Controller/Account/LoginV2.cs @@ -1,4 +1,4 @@ -using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc; using OpenShock.API.Models.Requests; using System.Net; using System.Net.Mime; @@ -47,7 +47,7 @@ public async Task LoginV2( return Problem(new OpenShockProblem("InternalServerError", "Internal Server Error", HttpStatusCode.InternalServerError)); } - + var getAccountResult = await _accountService.GetAccountByCredentialsAsync(body.UsernameOrEmail, body.Password, cancellationToken); if (!getAccountResult.TryPickT0(out var account, out var errors)) { @@ -63,9 +63,9 @@ public async Task LoginV2( // automated tests, not as a credential-less back door to a privileged account. if (HttpContext.IsBypassed(BypassTokenType.Turnstile) && account.Roles.Contains(RoleType.Admin)) return Problem(TurnstileError.InvalidTurnstile); - + await CreateSession(account.Id, cookieDomain); - + return Ok(LoginV2OkResponse.FromUser(account)); } -} +} \ No newline at end of file diff --git a/API/Controller/Account/PasswordResetInitiateV2.cs b/API/Controller/Account/PasswordResetInitiateV2.cs index 0964d2bc..2d5af345 100644 --- a/API/Controller/Account/PasswordResetInitiateV2.cs +++ b/API/Controller/Account/PasswordResetInitiateV2.cs @@ -1,4 +1,4 @@ -using System.Net; +using System.Net; using System.Net.Mime; using Microsoft.AspNetCore.Mvc; using Asp.Versioning; diff --git a/API/Controller/Account/SignupV2.cs b/API/Controller/Account/SignupV2.cs index 9feaefc7..9f707bb0 100644 --- a/API/Controller/Account/SignupV2.cs +++ b/API/Controller/Account/SignupV2.cs @@ -1,4 +1,4 @@ -using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc; using OpenShock.API.Models.Requests; using System.Net; using System.Net.Mime; @@ -50,4 +50,4 @@ public async Task SignUpV2( _ => Problem(SignupError.UsernameOrEmailExists) ); } -} +} \ No newline at end of file diff --git a/Common/OpenShockDb/OpenShockContext.cs b/Common/OpenShockDb/OpenShockContext.cs index feddcfc1..2742d711 100644 --- a/Common/OpenShockDb/OpenShockContext.cs +++ b/Common/OpenShockDb/OpenShockContext.cs @@ -126,7 +126,7 @@ public static void ConfigureOptionsBuilder(DbContextOptionsBuilder optionsBuilde public DbSet UserNameBlacklists { get; set; } public DbSet EmailProviderBlacklists { get; set; } - + public DbSet DataProtectionKeys { get; set; } protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) From c61efd1026d4e2807c09b0c41bba8caca45fa958 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Thu, 28 May 2026 14:47:41 +0200 Subject: [PATCH 08/16] Update PasswordResetInitiateV2.cs --- API/Controller/Account/PasswordResetInitiateV2.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/API/Controller/Account/PasswordResetInitiateV2.cs b/API/Controller/Account/PasswordResetInitiateV2.cs index 2d5af345..136e0635 100644 --- a/API/Controller/Account/PasswordResetInitiateV2.cs +++ b/API/Controller/Account/PasswordResetInitiateV2.cs @@ -1,3 +1,4 @@ +using System; using System.Net; using System.Net.Mime; using Microsoft.AspNetCore.Mvc; From 8d6b508e271de122b72d8314096b28764efb99b4 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Fri, 14 Aug 2026 11:32:31 +0200 Subject: [PATCH 09/16] fix: restore usings dropped by the develop merge develop moved the Turnstile verification out of LoginV2 into _Turnstile.cs and dropped the OpenShock.API.Errors using along with it, but this branch's admin bypass guard still references TurnstileError. RoleType also moved into the OpenShock.Common.OpenShockDb namespace with the Internal.Net extraction. --- API/Controller/Account/LoginV2.cs | 2 ++ API/Controller/Tokens/ReportTokens.cs | 1 + 2 files changed, 3 insertions(+) diff --git a/API/Controller/Account/LoginV2.cs b/API/Controller/Account/LoginV2.cs index 14b47cc7..72bb4a40 100644 --- a/API/Controller/Account/LoginV2.cs +++ b/API/Controller/Account/LoginV2.cs @@ -7,8 +7,10 @@ using OpenShock.Common.Extensions; using OpenShock.Common.Models; using OpenShock.Common.Problems; +using OpenShock.API.Errors; using OpenShock.API.Models.Response; using OpenShock.API.Services.Turnstile; +using OpenShock.Common.OpenShockDb; using OpenShock.Internal.Common.Problems; diff --git a/API/Controller/Tokens/ReportTokens.cs b/API/Controller/Tokens/ReportTokens.cs index 8bf9b504..1daba236 100644 --- a/API/Controller/Tokens/ReportTokens.cs +++ b/API/Controller/Tokens/ReportTokens.cs @@ -11,6 +11,7 @@ using OpenShock.API.Services.Turnstile; using OpenShock.Common.Extensions; using OpenShock.Common.Models; +using OpenShock.Common.OpenShockDb; using OpenShock.Common.Services.Webhook; using OpenShock.Internal.Common.Utils; From b5768a1c6b017d3ccff3f6d17d4ff3afb366fc47 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Wed, 19 Aug 2026 17:19:33 +0200 Subject: [PATCH 10/16] fix: Guard admin accounts on bypassed password reset and log bypass use PasswordResetInitiateV2 had no privileged-account guard, so the turnstile bypass could send admin reset mail with no captcha and no rate limit. The lookup runs only on the bypass path and still returns the generic 200. BypassTokenMiddleware now logs accepted and unmatched tokens, never the token. --- .../Account/PasswordResetInitiateV2.cs | 13 ++++++++++ API/Services/Account/AccountService.cs | 6 +++++ API/Services/Account/IAccountService.cs | 6 +++++ Common/Middleware/BypassTokenMiddleware.cs | 24 ++++++++++++++++--- 4 files changed, 46 insertions(+), 3 deletions(-) diff --git a/API/Controller/Account/PasswordResetInitiateV2.cs b/API/Controller/Account/PasswordResetInitiateV2.cs index 1bc1cc31..97e7495d 100644 --- a/API/Controller/Account/PasswordResetInitiateV2.cs +++ b/API/Controller/Account/PasswordResetInitiateV2.cs @@ -5,6 +5,8 @@ using OpenShock.API.Models.Requests; using OpenShock.API.Services.Turnstile; using OpenShock.Common.Errors; +using OpenShock.Common.Extensions; +using OpenShock.Common.Models; using OpenShock.Common.Problems; using OpenShock.Internal.Common.Problems; @@ -37,6 +39,17 @@ public async Task PasswordResetInitiateV2([FromBody] PasswordRese var turnstileError = await VerifyTurnstileAsync(turnstileService, body.TurnstileResponse, cancellationToken); if (turnstileError is not null) return turnstileError; + // Admin accounts must never be reached through a bypassed flow - the bypass exists for + // automated tests, not as a way to send privileged reset mail without solving Turnstile. + // The lookup runs only on the bypass path, so the normal path keeps its timing profile, and + // the response stays the generic 200 so this does not become an admin-account oracle. + if (HttpContext.IsBypassed(BypassTokenType.Turnstile) + && await _accountService.IsPrivilegedEmailAsync(body.Email, cancellationToken)) + { + _logger.LogWarning("Refused a bypassed password reset for a privileged account"); + return Ok(); + } + await _accountService.CreatePasswordResetFlowAsync(body.Email); return Ok(); diff --git a/API/Services/Account/AccountService.cs b/API/Services/Account/AccountService.cs index 16400755..86d057cc 100644 --- a/API/Services/Account/AccountService.cs +++ b/API/Services/Account/AccountService.cs @@ -157,6 +157,12 @@ public Task IsEmailRegisteredAsync(string email, CancellationToken cancell return _db.Users.AnyAsync(u => u.Email == email, cancellationToken); } + public Task IsPrivilegedEmailAsync(string email, CancellationToken cancellationToken = default) + { + email = email.ToLowerInvariant(); + return _db.Users.AnyAsync(u => u.Email == email && u.Roles.Contains(RoleType.Admin), cancellationToken); + } + public async Task, AccountWithEmailOrUsernameExists>> CreateOAuthOnlyAccountAsync( string email, string username, diff --git a/API/Services/Account/IAccountService.cs b/API/Services/Account/IAccountService.cs index 71c6fce7..af046644 100644 --- a/API/Services/Account/IAccountService.cs +++ b/API/Services/Account/IAccountService.cs @@ -38,6 +38,12 @@ public interface IAccountService /// Task IsEmailRegisteredAsync(string email, CancellationToken cancellationToken = default); + /// + /// True when the email belongs to an account holding a privileged role. + /// Used to keep bypassed flows away from admin accounts, not for authorization decisions. + /// + Task IsPrivilegedEmailAsync(string email, CancellationToken cancellationToken = default); + /// /// /// diff --git a/Common/Middleware/BypassTokenMiddleware.cs b/Common/Middleware/BypassTokenMiddleware.cs index 80bc7ee9..167e5fb7 100644 --- a/Common/Middleware/BypassTokenMiddleware.cs +++ b/Common/Middleware/BypassTokenMiddleware.cs @@ -1,9 +1,10 @@ -using System.Security.Cryptography; +using System.Security.Cryptography; using System.Text; using OneOf.Types; using OpenShock.Common.Extensions; using OpenShock.Common.Models; using OpenShock.Common.Services.Configuration; +using Microsoft.Extensions.Logging; namespace OpenShock.Common.Middleware; @@ -27,7 +28,7 @@ public BypassTokenMiddleware(RequestDelegate next) _next = next; } - public async Task InvokeAsync(HttpContext context, IConfigurationService config) + public async Task InvokeAsync(HttpContext context, IConfigurationService config, ILogger logger) { if (!context.TryGetBypassTokenFromHeader(out var presented)) { @@ -40,7 +41,24 @@ public async Task InvokeAsync(HttpContext context, IConfigurationService config) if (await MatchesAsync(config, TurnstileConfigKey, presented)) matched |= BypassTokenType.Turnstile; if (await MatchesAsync(config, RateLimitConfigKey, presented)) matched |= BypassTokenType.RateLimit; - if (matched != BypassTokenType.None) context.SetBypassedTypes(matched); + if (matched != BypassTokenType.None) + { + context.SetBypassedTypes(matched); + + // A credential that switches off Turnstile and rate limiting should never be used without + // leaving a trace. Logged at warning so it stands out in a production log, and the token + // itself is never written - only which protections it disabled, and for what. + logger.LogWarning( + "Bypass token accepted for {Matched} on {Method} {Path} from {RemoteIp}", + matched, context.Request.Method, context.Request.Path, context.Connection.RemoteIpAddress); + } + else + { + // A presented-but-unmatched token is either a stale secret or someone probing for one. + logger.LogWarning( + "Bypass token presented but matched nothing on {Method} {Path} from {RemoteIp}", + context.Request.Method, context.Request.Path, context.Connection.RemoteIpAddress); + } await _next(context); } From 24fff891ba917dbd5cb946e6c432611fb88f3310 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Wed, 30 Sep 2026 16:05:27 +0200 Subject: [PATCH 11/16] fix: treat System accounts as privileged in bypass guards, bound bypass token logging Every bypass guard checked only RoleType.Admin, while the rest of the codebase treats Admin and System alike as privileged, so a System account could still authenticate, reset a password or report tokens through a Turnstile-bypassed flow. The unmatched-token warning also needed no credential to trigger, letting an unauthenticated caller drive log volume; it is now collapsed to one line per minute carrying the attempt count, and request data is stripped of line endings before it reaches a log. --- API/Controller/Account/LoginV2.cs | 6 +-- .../Account/PasswordResetInitiateV2.cs | 2 +- API/Controller/Tokens/ReportTokens.cs | 4 +- API/Services/Account/AccountService.cs | 2 +- Common/Middleware/BypassTokenMiddleware.cs | 37 +++++++++++++++++-- 5 files changed, 40 insertions(+), 11 deletions(-) diff --git a/API/Controller/Account/LoginV2.cs b/API/Controller/Account/LoginV2.cs index 356d2f82..e25ba202 100644 --- a/API/Controller/Account/LoginV2.cs +++ b/API/Controller/Account/LoginV2.cs @@ -57,9 +57,9 @@ public async Task LoginV2( }; } - // Admin accounts must never be authenticated through a bypassed flow — the bypass exists for - // automated tests, not as a credential-less back door to a privileged account. - if (HttpContext.IsBypassed(BypassTokenType.Turnstile) && account.Roles.Contains(RoleType.Admin)) + // Privileged accounts must never be authenticated through a bypassed flow — the bypass exists + // for automated tests, not as a credential-less back door to a privileged account. + if (HttpContext.IsBypassed(BypassTokenType.Turnstile) && account.Roles.Any(r => r is RoleType.Admin or RoleType.System)) return Problem(TurnstileError.InvalidTurnstile); await CreateSession(account.Id, cookieDomain); diff --git a/API/Controller/Account/PasswordResetInitiateV2.cs b/API/Controller/Account/PasswordResetInitiateV2.cs index 97e7495d..ebc723c3 100644 --- a/API/Controller/Account/PasswordResetInitiateV2.cs +++ b/API/Controller/Account/PasswordResetInitiateV2.cs @@ -39,7 +39,7 @@ public async Task PasswordResetInitiateV2([FromBody] PasswordRese var turnstileError = await VerifyTurnstileAsync(turnstileService, body.TurnstileResponse, cancellationToken); if (turnstileError is not null) return turnstileError; - // Admin accounts must never be reached through a bypassed flow - the bypass exists for + // Privileged accounts must never be reached through a bypassed flow - the bypass exists for // automated tests, not as a way to send privileged reset mail without solving Turnstile. // The lookup runs only on the bypass path, so the normal path keeps its timing profile, and // the response stays the generic 200 so this does not become an admin-account oracle. diff --git a/API/Controller/Tokens/ReportTokens.cs b/API/Controller/Tokens/ReportTokens.cs index 2958f05a..2938af63 100644 --- a/API/Controller/Tokens/ReportTokens.cs +++ b/API/Controller/Tokens/ReportTokens.cs @@ -52,8 +52,8 @@ public async Task ReportTokens( return Problem(new OpenShockProblem("InternalServerError", "Internal Server Error", HttpStatusCode.InternalServerError)); } - // Admin accounts must never authenticate through a bypassed flow. - if (HttpContext.IsBypassed(BypassTokenType.Turnstile) && CurrentUser.Roles.Contains(RoleType.Admin)) + // Privileged accounts must never authenticate through a bypassed flow. + if (HttpContext.IsBypassed(BypassTokenType.Turnstile) && CurrentUser.Roles.Any(r => r is RoleType.Admin or RoleType.System)) return Problem(TurnstileError.InvalidTurnstile); var reportId = Guid.CreateVersion7(); diff --git a/API/Services/Account/AccountService.cs b/API/Services/Account/AccountService.cs index 2facc4eb..1c5aa12e 100644 --- a/API/Services/Account/AccountService.cs +++ b/API/Services/Account/AccountService.cs @@ -157,7 +157,7 @@ public Task IsEmailRegisteredAsync(string email, CancellationToken cancell public Task IsPrivilegedEmailAsync(string email, CancellationToken cancellationToken = default) { email = email.ToLowerInvariant(); - return _db.Users.AnyAsync(u => u.Email == email && u.Roles.Contains(RoleType.Admin), cancellationToken); + return _db.Users.AnyAsync(u => u.Email == email && u.Roles.Any(r => r == RoleType.Admin || r == RoleType.System), cancellationToken); } public async Task CreateOAuthOnlyAccountAsync( diff --git a/Common/Middleware/BypassTokenMiddleware.cs b/Common/Middleware/BypassTokenMiddleware.cs index 59c9e97a..ad7acf72 100644 --- a/Common/Middleware/BypassTokenMiddleware.cs +++ b/Common/Middleware/BypassTokenMiddleware.cs @@ -49,19 +49,48 @@ public async Task InvokeAsync(HttpContext context, IConfigurationService config, // itself is never written - only which protections it disabled, and for what. logger.LogWarning( "Bypass token accepted for {Matched} on {Method} {Path} from {RemoteIp}", - matched, context.Request.Method, context.Request.Path, context.Connection.RemoteIpAddress); + matched, ForLog(context.Request.Method), ForLog(context.Request.Path.Value), context.Connection.RemoteIpAddress); } else { // A presented-but-unmatched token is either a stale secret or someone probing for one. - logger.LogWarning( - "Bypass token presented but matched nothing on {Method} {Path} from {RemoteIp}", - context.Request.Method, context.Request.Path, context.Connection.RemoteIpAddress); + LogUnmatched(logger); } await _next(context); } + // An unmatched token needs no credential to present, so the warning it produces is volume an + // unauthenticated caller controls. Keep the audit signal, but collapse it to at most one line + // per window and report how many attempts that line stands for. + private static readonly TimeSpan UnmatchedWarningInterval = TimeSpan.FromMinutes(1); + private static long _unmatchedWindowStartTicks; + private static long _unmatchedAttempts; + + private static void LogUnmatched(ILogger logger) + { + Interlocked.Increment(ref _unmatchedAttempts); + + var now = Environment.TickCount64; + var windowStart = Interlocked.Read(ref _unmatchedWindowStartTicks); + if (now - windowStart < UnmatchedWarningInterval.TotalMilliseconds) return; + + // Loser of the race has nothing to report; the winner owns this window's line. + if (Interlocked.CompareExchange(ref _unmatchedWindowStartTicks, now, windowStart) != windowStart) return; + + var attempts = Interlocked.Exchange(ref _unmatchedAttempts, 0); + + // Deliberately free of request data: the path, method and header are all caller-controlled, + // and this line exists to say that probing is happening, not to echo the probe back. + logger.LogWarning( + "Bypass token presented but matched nothing ({Attempts} attempt(s) in the last {Interval})", + attempts, UnmatchedWarningInterval); + } + + // Request data would otherwise reach the log verbatim, where a newline in it forges a log line. + private static string ForLog(string? value) => + string.IsNullOrEmpty(value) ? string.Empty : value.ReplaceLineEndings(string.Empty); + private static async Task MatchesAsync(IConfigurationService config, string key, string presented) { var result = await config.TryGetStringAsync(key); From 177dd546fe12804803528a577e3f04750e225c07 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Wed, 30 Sep 2026 22:20:47 +0200 Subject: [PATCH 12/16] feat: restore database-managed bypass tokens Reverts the switch to the TURNSTILE_BYPASS_TOKEN / RATE_LIMIT_BYPASS_TOKEN configuration values (af602a0b) and brings back the table-backed design: bypass_tokens and bypass_token_user_uses, IBypassTokenService, the admin CRUD/rotate endpoints and the auto-cleanup cron job. Adapted to develop: entities and the [PgEnum] BypassTokenType live under OpenShockDb, the resolved token is stashed via SetItemByType, and admin endpoints call PedanticallyEnsureAdmin. Keeps the later hardening: System accounts are treated as privileged alongside Admin (login, token report, password reset, cleanup job), and bypass use is logged with bounded logging for unmatched tokens. Password reset records bypass use per account again. Adds the AddBypassTokens migration; the snapshot is regenerated by the EF 11 design-time tooling. --- API/Controller/Account/LoginV2.cs | 10 +- .../Account/PasswordResetInitiateV2.cs | 9 +- API/Controller/Account/SignupV2.cs | 20 +- API/Controller/Admin/BypassTokenCreate.cs | 47 + API/Controller/Admin/BypassTokenDelete.cs | 22 + API/Controller/Admin/BypassTokenList.cs | 34 + API/Controller/Admin/BypassTokenPatch.cs | 48 + API/Controller/Admin/BypassTokenRotate.cs | 50 + API/Controller/Admin/DTOs/BypassTokenDto.cs | 29 + .../Admin/DTOs/CreateBypassTokenDto.cs | 46 + API/Controller/Tokens/ReportTokens.cs | 8 +- API/Services/Account/AccountService.cs | 6 - API/Services/Account/IAccountService.cs | 6 - .../Turnstile/CloudflareTurnstileService.cs | 10 +- Common/Extensions/HttpContextExtensions.cs | 24 +- Common/Middleware/BypassTokenMiddleware.cs | 53 +- ...20260930202003_AddBypassTokens.Designer.cs | 1824 +++++++++ .../20260930202003_AddBypassTokens.cs | 159 + .../OpenShockContextModelSnapshot.cs | 3373 +++++++++-------- Common/Models/BypassTokenType.cs | 9 - Common/OpenShockDb/Enums/BypassTokenType.cs | 14 + .../Extensions/NpgsqlEnumExtensions.cs | 1 + Common/OpenShockDb/Models/BypassToken.cs | 30 + .../OpenShockDb/Models/BypassTokenUserUse.cs | 18 + Common/OpenShockDb/Models/User.cs | 1 + Common/OpenShockDb/OpenShockContext.cs | 77 + Common/OpenShockServiceHelper.cs | 15 +- Common/Services/Bypass/BypassTokenService.cs | 94 + Common/Services/Bypass/IBypassTokenService.cs | 41 + Common/Services/Bypass/ResolvedBypassToken.cs | 11 + Cron/Jobs/DeleteBypassTokenUsedAccountsJob.cs | 54 + 31 files changed, 4439 insertions(+), 1704 deletions(-) create mode 100644 API/Controller/Admin/BypassTokenCreate.cs create mode 100644 API/Controller/Admin/BypassTokenDelete.cs create mode 100644 API/Controller/Admin/BypassTokenList.cs create mode 100644 API/Controller/Admin/BypassTokenPatch.cs create mode 100644 API/Controller/Admin/BypassTokenRotate.cs create mode 100644 API/Controller/Admin/DTOs/BypassTokenDto.cs create mode 100644 API/Controller/Admin/DTOs/CreateBypassTokenDto.cs create mode 100644 Common/Migrations/20260930202003_AddBypassTokens.Designer.cs create mode 100644 Common/Migrations/20260930202003_AddBypassTokens.cs delete mode 100644 Common/Models/BypassTokenType.cs create mode 100644 Common/OpenShockDb/Enums/BypassTokenType.cs create mode 100644 Common/OpenShockDb/Models/BypassToken.cs create mode 100644 Common/OpenShockDb/Models/BypassTokenUserUse.cs create mode 100644 Common/Services/Bypass/BypassTokenService.cs create mode 100644 Common/Services/Bypass/IBypassTokenService.cs create mode 100644 Common/Services/Bypass/ResolvedBypassToken.cs create mode 100644 Cron/Jobs/DeleteBypassTokenUsedAccountsJob.cs diff --git a/API/Controller/Account/LoginV2.cs b/API/Controller/Account/LoginV2.cs index e25ba202..733c3d30 100644 --- a/API/Controller/Account/LoginV2.cs +++ b/API/Controller/Account/LoginV2.cs @@ -7,8 +7,8 @@ using OpenShock.API.Services.Account; using OpenShock.Common.Errors; using OpenShock.Common.Extensions; -using OpenShock.Common.Models; using OpenShock.Common.Problems; +using OpenShock.Common.Services.Bypass; using OpenShock.API.Errors; using OpenShock.API.Models.Response; using OpenShock.API.Services.Turnstile; @@ -36,6 +36,7 @@ public sealed partial class AccountController public async Task LoginV2( [FromBody] LoginV2 body, [FromServices] ICloudflareTurnstileService turnstileService, + [FromServices] IBypassTokenService bypassTokens, CancellationToken cancellationToken) { var cookieDomain = GetCurrentCookieDomain(); @@ -57,9 +58,10 @@ public async Task LoginV2( }; } - // Privileged accounts must never be authenticated through a bypassed flow — the bypass exists - // for automated tests, not as a credential-less back door to a privileged account. - if (HttpContext.IsBypassed(BypassTokenType.Turnstile) && account.Roles.Any(r => r is RoleType.Admin or RoleType.System)) + // Privileged accounts must never be authenticated through a bypassed flow; the bypass exists + // for automated tests, not as a credential-less back door to a privileged account. For every + // other account this links the use to the token, so auto-cleanup can find it later. + if (!await bypassTokens.TryRecordUseAsync(account.Id, cancellationToken)) return Problem(TurnstileError.InvalidTurnstile); await CreateSession(account.Id, cookieDomain); diff --git a/API/Controller/Account/PasswordResetInitiateV2.cs b/API/Controller/Account/PasswordResetInitiateV2.cs index ebc723c3..f107601b 100644 --- a/API/Controller/Account/PasswordResetInitiateV2.cs +++ b/API/Controller/Account/PasswordResetInitiateV2.cs @@ -5,9 +5,8 @@ using OpenShock.API.Models.Requests; using OpenShock.API.Services.Turnstile; using OpenShock.Common.Errors; -using OpenShock.Common.Extensions; -using OpenShock.Common.Models; using OpenShock.Common.Problems; +using OpenShock.Common.Services.Bypass; using OpenShock.Internal.Common.Problems; @@ -34,7 +33,7 @@ public sealed partial class AccountController [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status403Forbidden, MediaTypeNames.Application.ProblemJson)] [MapToApiVersion("2")] - public async Task PasswordResetInitiateV2([FromBody] PasswordResetRequestV2 body, [FromServices] ICloudflareTurnstileService turnstileService, CancellationToken cancellationToken) + public async Task PasswordResetInitiateV2([FromBody] PasswordResetRequestV2 body, [FromServices] ICloudflareTurnstileService turnstileService, [FromServices] IBypassTokenService bypassTokens, CancellationToken cancellationToken) { var turnstileError = await VerifyTurnstileAsync(turnstileService, body.TurnstileResponse, cancellationToken); if (turnstileError is not null) return turnstileError; @@ -43,8 +42,8 @@ public async Task PasswordResetInitiateV2([FromBody] PasswordRese // automated tests, not as a way to send privileged reset mail without solving Turnstile. // The lookup runs only on the bypass path, so the normal path keeps its timing profile, and // the response stays the generic 200 so this does not become an admin-account oracle. - if (HttpContext.IsBypassed(BypassTokenType.Turnstile) - && await _accountService.IsPrivilegedEmailAsync(body.Email, cancellationToken)) + // For every other account this links the use to the token, so auto-cleanup can find it later. + if (!await bypassTokens.TryRecordUseByEmailAsync(body.Email, cancellationToken)) { _logger.LogWarning("Refused a bypassed password reset for a privileged account"); return Ok(); diff --git a/API/Controller/Account/SignupV2.cs b/API/Controller/Account/SignupV2.cs index 2b02a349..0ee98777 100644 --- a/API/Controller/Account/SignupV2.cs +++ b/API/Controller/Account/SignupV2.cs @@ -10,6 +10,7 @@ using OpenShock.Common.OpenShockDb; using OpenShock.Common.Options; using OpenShock.Common.Problems; +using OpenShock.Common.Services.Bypass; using OpenShock.Common.Results; using OpenShock.Internal.Common.Problems; @@ -24,6 +25,7 @@ public sealed partial class AccountController /// /// /// + /// /// /// User successfully signed up /// Username or email already exists @@ -38,6 +40,7 @@ public async Task SignUpV2( [FromBody] SignUpV2 body, [FromServices] ICloudflareTurnstileService turnstileService, [FromServices] AccountOptions accountOptions, + [FromServices] IBypassTokenService bypassTokens, CancellationToken cancellationToken) { if (!accountOptions.RegistrationEnabled) @@ -47,11 +50,18 @@ public async Task SignUpV2( if (turnstileError is not null) return turnstileError; var creationAction = await _accountService.CreateAccountWithActivationFlowAsync(body.Email, body.Username, body.Password); - return creationAction switch + if (creationAction is not User created) { - User _ => Ok(), - AccountWithEmailOrUsernameExists => Problem(SignupError.UsernameOrEmailExists), - _ => throw new UnreachableException() - }; + return creationAction switch + { + AccountWithEmailOrUsernameExists => Problem(SignupError.UsernameOrEmailExists), + _ => throw new UnreachableException() + }; + } + + // No-op when no bypass token resolved. Signups can't yield a privileged user, so the bool return is ignored. + await bypassTokens.TryRecordUseAsync(created.Id, cancellationToken); + + return Ok(); } } \ No newline at end of file diff --git a/API/Controller/Admin/BypassTokenCreate.cs b/API/Controller/Admin/BypassTokenCreate.cs new file mode 100644 index 00000000..5985b900 --- /dev/null +++ b/API/Controller/Admin/BypassTokenCreate.cs @@ -0,0 +1,47 @@ +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 +{ + /// + /// Creates a bypass token. The secret is only returned once, in this response + /// + [HttpPost("bypassTokens")] + [Consumes(MediaTypeNames.Application.Json)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task CreateBypassToken([FromBody] CreateBypassTokenDto body, CancellationToken ct) + { + PedanticallyEnsureAdmin(); + + 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, + }; + } +} diff --git a/API/Controller/Admin/BypassTokenDelete.cs b/API/Controller/Admin/BypassTokenDelete.cs new file mode 100644 index 00000000..4d47446c --- /dev/null +++ b/API/Controller/Admin/BypassTokenDelete.cs @@ -0,0 +1,22 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace OpenShock.API.Controller.Admin; + +public sealed partial class AdminController +{ + /// + /// Deletes a bypass token + /// + [HttpDelete("bypassTokens/{id}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task DeleteBypassToken([FromRoute] Guid id, CancellationToken ct) + { + PedanticallyEnsureAdmin(); + + var nDeleted = await _db.BypassTokens.Where(t => t.Id == id).ExecuteDeleteAsync(ct); + + return nDeleted == 0 ? NotFound() : Ok(); + } +} diff --git a/API/Controller/Admin/BypassTokenList.cs b/API/Controller/Admin/BypassTokenList.cs new file mode 100644 index 00000000..72e33518 --- /dev/null +++ b/API/Controller/Admin/BypassTokenList.cs @@ -0,0 +1,34 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using OpenShock.API.Controller.Admin.DTOs; + +namespace OpenShock.API.Controller.Admin; + +public sealed partial class AdminController +{ + /// + /// Lists all bypass tokens + /// + [HttpGet("bypassTokens")] + public async IAsyncEnumerable ListBypassTokens() + { + PedanticallyEnsureAdmin(); + + 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, + }; + } + } +} diff --git a/API/Controller/Admin/BypassTokenPatch.cs b/API/Controller/Admin/BypassTokenPatch.cs new file mode 100644 index 00000000..e14b8f83 --- /dev/null +++ b/API/Controller/Admin/BypassTokenPatch.cs @@ -0,0 +1,48 @@ +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 +{ + /// + /// Updates a bypass token + /// + [HttpPatch("bypassTokens/{id}")] + [Consumes(MediaTypeNames.Application.Json)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task PatchBypassToken([FromRoute] Guid id, [FromBody] PatchBypassTokenDto body, CancellationToken ct) + { + PedanticallyEnsureAdmin(); + + 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, + }); + } +} diff --git a/API/Controller/Admin/BypassTokenRotate.cs b/API/Controller/Admin/BypassTokenRotate.cs new file mode 100644 index 00000000..e170c70d --- /dev/null +++ b/API/Controller/Admin/BypassTokenRotate.cs @@ -0,0 +1,50 @@ +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 +{ + /// + /// Rotates the secret of a bypass token. The new secret is only returned once, in this response + /// + [HttpPost("bypassTokens/{id}/rotate")] + [Consumes(MediaTypeNames.Application.Json)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task RotateBypassToken([FromRoute] Guid id, CancellationToken ct) + { + PedanticallyEnsureAdmin(); + + 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, + }); + } +} diff --git a/API/Controller/Admin/DTOs/BypassTokenDto.cs b/API/Controller/Admin/DTOs/BypassTokenDto.cs new file mode 100644 index 00000000..ca2bcd78 --- /dev/null +++ b/API/Controller/Admin/DTOs/BypassTokenDto.cs @@ -0,0 +1,29 @@ +using OpenShock.Common.OpenShockDb; + +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 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 Types { get; init; } + public required DateTime CreatedAt { get; init; } + public DateTime? LastRotatedAt { get; init; } + public bool AutoCleanupUsers { get; init; } + public TimeSpan? AutoCleanupAfter { get; init; } +} diff --git a/API/Controller/Admin/DTOs/CreateBypassTokenDto.cs b/API/Controller/Admin/DTOs/CreateBypassTokenDto.cs new file mode 100644 index 00000000..0a5376b4 --- /dev/null +++ b/API/Controller/Admin/DTOs/CreateBypassTokenDto.cs @@ -0,0 +1,46 @@ +using System.ComponentModel.DataAnnotations; +using OpenShock.Common.Constants; +using OpenShock.Common.OpenShockDb; + +namespace OpenShock.API.Controller.Admin.DTOs; + +public sealed class CreateBypassTokenDto : IValidatableObject +{ + [Required] + [MinLength(1)] + [MaxLength(ApiHardLimits.ApiKeyNameMaxLength)] + public required string Name { get; init; } + + [Required] + [MinLength(1)] + public required IReadOnlyList Types { get; init; } + + public bool AutoCleanupUsers { get; init; } + + public TimeSpan? AutoCleanupAfter { get; init; } + + public IEnumerable 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(ApiHardLimits.ApiKeyNameMaxLength)] + public string? Name { get; init; } + + public IReadOnlyList? Types { get; init; } + + public bool? AutoCleanupUsers { get; init; } + + public TimeSpan? AutoCleanupAfter { get; init; } +} diff --git a/API/Controller/Tokens/ReportTokens.cs b/API/Controller/Tokens/ReportTokens.cs index 2938af63..ef922c68 100644 --- a/API/Controller/Tokens/ReportTokens.cs +++ b/API/Controller/Tokens/ReportTokens.cs @@ -10,9 +10,9 @@ using OpenShock.API.Errors; using OpenShock.API.Services.Turnstile; using OpenShock.Common.Extensions; -using OpenShock.Common.Models; using OpenShock.Common.OpenShockDb; using OpenShock.Common.Results; +using OpenShock.Common.Services.Bypass; using OpenShock.Common.Services.Webhook; using OpenShock.Internal.Common.Utils; @@ -28,6 +28,7 @@ public sealed partial class TokensController /// /// /// + /// /// /// /// The tokens were deleted if found @@ -38,6 +39,7 @@ public sealed partial class TokensController public async Task ReportTokens( [FromBody] ReportTokensRequest body, [FromServices] ICloudflareTurnstileService turnstileService, + [FromServices] IBypassTokenService bypassTokens, [FromServices] IWebhookService webhookService, CancellationToken cancellationToken) { @@ -52,8 +54,8 @@ public async Task ReportTokens( return Problem(new OpenShockProblem("InternalServerError", "Internal Server Error", HttpStatusCode.InternalServerError)); } - // Privileged accounts must never authenticate through a bypassed flow. - if (HttpContext.IsBypassed(BypassTokenType.Turnstile) && CurrentUser.Roles.Any(r => r is RoleType.Admin or RoleType.System)) + // Caller is already authenticated; a privileged account on a bypass-using request is rejected. + if (!await bypassTokens.TryRecordUseAsync(CurrentUser.Id, cancellationToken)) return Problem(TurnstileError.InvalidTurnstile); var reportId = Guid.CreateVersion7(); diff --git a/API/Services/Account/AccountService.cs b/API/Services/Account/AccountService.cs index c67d8625..de1c043d 100644 --- a/API/Services/Account/AccountService.cs +++ b/API/Services/Account/AccountService.cs @@ -155,12 +155,6 @@ public Task IsEmailRegisteredAsync(string email, CancellationToken cancell return _db.Users.AnyAsync(u => u.Email == email, cancellationToken); } - public Task IsPrivilegedEmailAsync(string email, CancellationToken cancellationToken = default) - { - email = email.ToLowerInvariant(); - return _db.Users.AnyAsync(u => u.Email == email && u.Roles.Any(r => r == RoleType.Admin || r == RoleType.System), cancellationToken); - } - public async Task CreateOAuthOnlyAccountAsync( string email, string username, diff --git a/API/Services/Account/IAccountService.cs b/API/Services/Account/IAccountService.cs index 8550f235..9a801d9c 100644 --- a/API/Services/Account/IAccountService.cs +++ b/API/Services/Account/IAccountService.cs @@ -37,12 +37,6 @@ public interface IAccountService /// Task IsEmailRegisteredAsync(string email, CancellationToken cancellationToken = default); - /// - /// True when the email belongs to an account holding a privileged role. - /// Used to keep bypassed flows away from admin accounts, not for authorization decisions. - /// - Task IsPrivilegedEmailAsync(string email, CancellationToken cancellationToken = default); - /// /// /// diff --git a/API/Services/Turnstile/CloudflareTurnstileService.cs b/API/Services/Turnstile/CloudflareTurnstileService.cs index 0159bba3..77ed08d8 100644 --- a/API/Services/Turnstile/CloudflareTurnstileService.cs +++ b/API/Services/Turnstile/CloudflareTurnstileService.cs @@ -2,7 +2,7 @@ using OpenShock.API.Options; using OpenShock.Common.Extensions; using OpenShock.Common.Results; -using BypassTokenType = OpenShock.Common.Models.BypassTokenType; +using OpenShock.Common.OpenShockDb; namespace OpenShock.API.Services.Turnstile; @@ -53,9 +53,11 @@ public async Task> VerifyUserResponse { if (!_options.Enabled) return new Success(); - // An admin-set bypass secret (matched against the TURNSTILE_BYPASS_TOKEN configuration property) - // counts as a Turnstile pass. The match was resolved upstream by BypassTokenMiddleware. - if (_httpContextAccessor.HttpContext?.IsBypassed(BypassTokenType.Turnstile) == true) + // An admin-issued bypass token resolved earlier in the pipeline counts as a Turnstile pass + // if it carries the Turnstile type. The middleware already bumped use counters; controllers + // separately call IBypassTokenService.TryRecordUseAsync after auth so privileged-account + // requests can be rejected and per-user cleanup can run. + if (_httpContextAccessor.HttpContext?.IsBypassed(BypassTokenType.Turnstile) ?? false) return new Success(); if (string.IsNullOrEmpty(responseToken)) return CreateError(CloudflareTurnstileError.MissingResponse); diff --git a/Common/Extensions/HttpContextExtensions.cs b/Common/Extensions/HttpContextExtensions.cs index 784f93a4..56b1dbf5 100644 --- a/Common/Extensions/HttpContextExtensions.cs +++ b/Common/Extensions/HttpContextExtensions.cs @@ -2,14 +2,12 @@ using Microsoft.AspNetCore.Http; using OpenShock.Common.OpenShockDb; using OpenShock.Common.Constants; -using OpenShock.Common.Models; +using OpenShock.Common.Services.Bypass; namespace OpenShock.Common.Extensions; public static class HttpContextExtensions { - private static readonly object BypassedTypesItemKey = new(); - public static bool TryGetBypassTokenFromHeader(this HttpContext context, [NotNullWhen(true)] out string? token) { ArgumentNullException.ThrowIfNull(context); @@ -25,24 +23,30 @@ public static bool TryGetBypassTokenFromHeader(this HttpContext context, [NotNul } /// - /// Stores the set of bypass types that the header matched. Called by the bypass middleware. + /// Stores the result of resolving the bypass-token header. Called by the bypass middleware. /// - public static void SetBypassedTypes(this HttpContext context, BypassTokenType types) + public static void SetResolvedBypassToken(this HttpContext context, ResolvedBypassToken resolved) { ArgumentNullException.ThrowIfNull(context); - context.Items[BypassedTypesItemKey] = types; + context.SetItemByType(resolved); } /// - /// Returns true if the request presented a bypass token that grants . + /// Returns the bypass token resolved earlier in the pipeline, or null if no header was + /// present (or it did not resolve to a known token). /// - public static bool IsBypassed(this HttpContext context, BypassTokenType type) + public static ResolvedBypassToken? GetResolvedBypassToken(this HttpContext context) { ArgumentNullException.ThrowIfNull(context); - if (!context.Items.TryGetValue(BypassedTypesItemKey, out var v) || v is not BypassTokenType set) return false; - return (set & type) == type; + return context.GetItemByType(); } + /// + /// Returns true if the request presented a bypass token that grants . + /// + public static bool IsBypassed(this HttpContext context, BypassTokenType type) => + context.GetResolvedBypassToken()?.Types.Contains(type) ?? false; + // FullName is only null for open generic parameters, which a closed T never is. private static string ItemKeyOf() => typeof(T).FullName!; diff --git a/Common/Middleware/BypassTokenMiddleware.cs b/Common/Middleware/BypassTokenMiddleware.cs index ad7acf72..160bec9b 100644 --- a/Common/Middleware/BypassTokenMiddleware.cs +++ b/Common/Middleware/BypassTokenMiddleware.cs @@ -1,25 +1,19 @@ -using System.Security.Cryptography; -using System.Text; using OpenShock.Common.Extensions; -using OpenShock.Common.Models; -using OpenShock.Common.Services.Configuration; +using OpenShock.Common.Services.Bypass; using Microsoft.Extensions.Logging; namespace OpenShock.Common.Middleware; /// -/// Resolves the X-OpenShock-Bypass-Token header by comparing it to admin-set configuration -/// properties (TURNSTILE_BYPASS_TOKEN, RATE_LIMIT_BYPASS_TOKEN). The matched bypass -/// flags are stored on so downstream guards (rate limiter selectors, -/// turnstile service) can read them synchronously. +/// Resolves the X-OpenShock-Bypass-Token header (if present) into a +/// stored on . Downstream guards (rate limiter partition selectors, +/// the turnstile service, controllers needing post-auth user linkage) read the cached value +/// synchronously and filter on the type they care about. /// -/// Runs before UseRateLimiter. +/// Runs before UseRateLimiter so the rate limiter can honor the bypass for the very same request. /// public sealed class BypassTokenMiddleware { - public const string TurnstileConfigKey = "TURNSTILE_BYPASS_TOKEN"; - public const string RateLimitConfigKey = "RATE_LIMIT_BYPASS_TOKEN"; - private readonly RequestDelegate _next; public BypassTokenMiddleware(RequestDelegate next) @@ -27,33 +21,30 @@ public BypassTokenMiddleware(RequestDelegate next) _next = next; } - public async Task InvokeAsync(HttpContext context, IConfigurationService config, ILogger logger) + public async Task InvokeAsync(HttpContext context, IBypassTokenService bypassTokens, ILogger logger) { - if (!context.TryGetBypassTokenFromHeader(out var presented)) + if (!context.TryGetBypassTokenFromHeader(out var secret)) { await _next(context); return; } - var matched = BypassTokenType.None; - - if (await MatchesAsync(config, TurnstileConfigKey, presented)) matched |= BypassTokenType.Turnstile; - if (await MatchesAsync(config, RateLimitConfigKey, presented)) matched |= BypassTokenType.RateLimit; - - if (matched != BypassTokenType.None) + var resolved = await bypassTokens.ResolveAsync(secret, context.RequestAborted); + if (resolved is not null) { - context.SetBypassedTypes(matched); + context.SetResolvedBypassToken(resolved); // A credential that switches off Turnstile and rate limiting should never be used without - // leaving a trace. Logged at warning so it stands out in a production log, and the token - // itself is never written - only which protections it disabled, and for what. + // leaving a trace. Logged at warning so it stands out in a production log, and the secret + // itself is never written, only which token was used, what it disabled, and for what. logger.LogWarning( - "Bypass token accepted for {Matched} on {Method} {Path} from {RemoteIp}", - matched, ForLog(context.Request.Method), ForLog(context.Request.Path.Value), context.Connection.RemoteIpAddress); + "Bypass token {BypassTokenName} ({BypassTokenId}) accepted for {Types} on {Method} {Path} from {RemoteIp}", + ForLog(resolved.Name), resolved.Id, string.Join(",", resolved.Types), ForLog(context.Request.Method), + ForLog(context.Request.Path.Value), context.Connection.RemoteIpAddress); } else { - // A presented-but-unmatched token is either a stale secret or someone probing for one. + // A presented-but-unmatched token is either a stale or rotated secret, or someone probing for one. LogUnmatched(logger); } @@ -90,14 +81,4 @@ private static void LogUnmatched(ILogger logger) // Request data would otherwise reach the log verbatim, where a newline in it forges a log line. private static string ForLog(string? value) => string.IsNullOrEmpty(value) ? string.Empty : value.ReplaceLineEndings(string.Empty); - - private static async Task MatchesAsync(IConfigurationService config, string key, string presented) - { - var result = await config.TryGetStringAsync(key); - var configured = result.Outcome == ConfigGetOutcome.Value ? result.Value : null; - return !string.IsNullOrEmpty(configured) - && CryptographicOperations.FixedTimeEquals( - Encoding.UTF8.GetBytes(configured), - Encoding.UTF8.GetBytes(presented)); - } } diff --git a/Common/Migrations/20260930202003_AddBypassTokens.Designer.cs b/Common/Migrations/20260930202003_AddBypassTokens.Designer.cs new file mode 100644 index 00000000..d4744895 --- /dev/null +++ b/Common/Migrations/20260930202003_AddBypassTokens.Designer.cs @@ -0,0 +1,1824 @@ +// +using System; +using System.Collections.Generic; +using System.Net; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using OpenShock.Common.OpenShockDb; + +#nullable disable + +namespace OpenShock.Common.Migrations; + +[DbContext(typeof(MigrationOpenShockContext))] +[Migration("20260930202003_AddBypassTokens")] +partial class _20260930202003_AddBypassTokens +{ + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("Npgsql:CollationDefinition:public.ndcoll", "und-u-ks-level2,und-u-ks-level2,icu,False") + .HasAnnotation("ProductVersion", "11.0.0-rc.1.26425.128") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "audit_action", new[] { "login", "logout", "password_changed", "email_change_requested", "email_changed", "username_changed", "api_token_created", "api_token_deleted", "oauth_connected", "oauth_disconnected", "account_deactivated", "account_reactivated", "account_deleted" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "bypass_token_type", new[] { "turnstile", "rate_limit" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "configuration_value_type", new[] { "string", "bool", "int", "float", "json" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "control_limit_mode", new[] { "clamp", "lerp" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "control_type", new[] { "stop", "shock", "vibrate", "sound" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "email_status", new[] { "pending", "sending", "sent", "failed", "skipped" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "email_type", new[] { "account_activation", "password_reset", "email_verification", "email_change_notice" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "match_type_enum", new[] { "exact", "contains" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "ota_update_status", new[] { "started", "running", "finished", "error", "timeout" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "password_encryption_type", new[] { "bcrypt_enhanced", "pbkdf2" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "permission_type", new[] { "shockers.use", "shockers.edit", "shockers.pause", "devices.edit", "devices.auth", "usershares.edit", "usershares.pause", "publicshares.pause", "publicshares.edit" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "role_type", new[] { "support", "staff", "admin", "system" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "shocker_model_type", new[] { "cai_xianlin", "petrainer", "petrainer_998dr", "wellturn_t330" }); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + var id = b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(id); + + b.Property("FriendlyName") + .HasColumnType("text"); + + b.Property("Xml") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("DataProtectionKeys"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.AdminUsersView", b => + { + b.Property("ActivatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("activated_at"); + + b.Property("ApiTokenCount") + .HasColumnType("integer") + .HasColumnName("api_token_count"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeactivatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deactivated_at"); + + b.Property("DeactivatedByUserId") + .HasColumnType("uuid") + .HasColumnName("deactivated_by_user_id"); + + b.Property("DeviceCount") + .HasColumnType("integer") + .HasColumnName("device_count"); + + b.Property("Email") + .IsRequired() + .HasColumnType("character varying") + .HasColumnName("email"); + + b.Property("EmailChangeRequestCount") + .HasColumnType("integer") + .HasColumnName("email_change_request_count"); + + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasColumnType("character varying") + .HasColumnName("name"); + + b.Property("NameChangeRequestCount") + .HasColumnType("integer") + .HasColumnName("name_change_request_count"); + + b.Property("PasswordHashType") + .HasColumnType("character varying") + .HasColumnName("password_hash_type"); + + b.Property("PasswordResetCount") + .HasColumnType("integer") + .HasColumnName("password_reset_count"); + + b.Property("Roles") + .IsRequired() + .HasColumnType("role_type[]") + .HasColumnName("roles"); + + b.Property("ShockerControlLogCount") + .HasColumnType("integer") + .HasColumnName("shocker_control_log_count"); + + b.Property("ShockerCount") + .HasColumnType("integer") + .HasColumnName("shocker_count"); + + b.Property("ShockerPublicShareCount") + .HasColumnType("integer") + .HasColumnName("shocker_public_share_count"); + + b.Property("ShockerUserShareCount") + .HasColumnType("integer") + .HasColumnName("shocker_user_share_count"); + + b.ToTable((string)null); + + b.ToView("admin_users_view", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ApiToken", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("CreatedByIp") + .IsRequired() + .HasColumnType("inet") + .HasColumnName("created_by_ip"); + + b.Property("LastUsed") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_used"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("name"); + + b.Property>("Permissions") + .IsRequired() + .HasColumnType("permission_type[]") + .HasColumnName("permissions"); + + b.Property("ShockerControlDurationMax") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(65535) + .HasColumnName("shocker_control_duration_max"); + + b.Property("ShockerControlDurationMin") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(300) + .HasColumnName("shocker_control_duration_min"); + + b.Property("ShockerControlDurationMode") + .ValueGeneratedOnAdd() + .HasColumnType("control_limit_mode") + .HasDefaultValue(ControlLimitMode.Clamp) + .HasColumnName("shocker_control_duration_mode"); + + b.Property("ShockerControlIntensityMax") + .ValueGeneratedOnAdd() + .HasColumnType("smallint") + .HasDefaultValue((byte)100) + .HasColumnName("shocker_control_intensity_max"); + + b.Property("ShockerControlIntensityMin") + .ValueGeneratedOnAdd() + .HasColumnType("smallint") + .HasDefaultValue((byte)0) + .HasColumnName("shocker_control_intensity_min"); + + b.Property("ShockerControlIntensityMode") + .ValueGeneratedOnAdd() + .HasColumnType("control_limit_mode") + .HasDefaultValue(ControlLimitMode.Clamp) + .HasColumnName("shocker_control_intensity_mode"); + + b.Property("ShockerControlPaused") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("shocker_control_paused"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("token_hash") + .UseCollation("C"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("ValidUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("valid_until"); + + b.HasKey("Id") + .HasName("api_tokens_pkey"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.HasIndex("ValidUntil"); + + b.ToTable("api_tokens"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ApiTokenReport", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AffectedCount") + .HasColumnType("integer") + .HasColumnName("affected_count"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("IpAddress") + .IsRequired() + .HasColumnType("inet") + .HasColumnName("ip_address"); + + b.Property("IpCountry") + .HasColumnType("text") + .HasColumnName("ip_country"); + + b.Property("SubmittedCount") + .HasColumnType("integer") + .HasColumnName("submitted_count"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("api_token_reports_pkey"); + + b.HasIndex("UserId"); + + b.ToTable("api_token_reports"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.BypassToken", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AutoCleanupAfter") + .HasColumnType("interval") + .HasColumnName("auto_cleanup_after"); + + b.Property("AutoCleanupUsers") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("auto_cleanup_users"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("LastRotatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_rotated_at"); + + b.Property("LastUsedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_used_at"); + + b.Property("LastUsedByUserId") + .HasColumnType("uuid") + .HasColumnName("last_used_by_user_id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("name"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("token_hash") + .UseCollation("C"); + + b.Property>("Types") + .IsRequired() + .HasColumnType("bypass_token_type[]") + .HasColumnName("types"); + + b.Property("UseCount") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L) + .HasColumnName("use_count"); + + b.HasKey("Id") + .HasName("bypass_tokens_pkey"); + + b.HasIndex("LastUsedByUserId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.ToTable("bypass_tokens"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.BypassTokenUserUse", b => + { + b.Property("BypassTokenId") + .HasColumnType("uuid") + .HasColumnName("bypass_token_id"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("FirstUsedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("first_used_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("LastUsedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("last_used_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("UseCount") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L) + .HasColumnName("use_count"); + + b.HasKey("BypassTokenId", "UserId") + .HasName("bypass_token_user_uses_pkey"); + + b.HasIndex("LastUsedAt"); + + b.HasIndex("UserId"); + + b.ToTable("bypass_token_user_uses"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ConfigurationItem", b => + { + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name") + .UseCollation("C"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Type") + .HasColumnType("configuration_value_type") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.HasKey("Name") + .HasName("configuration_pkey"); + + b.ToTable("configuration"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.Device", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("name"); + + b.Property("OwnerId") + .HasColumnType("uuid") + .HasColumnName("owner_id"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("token") + .UseCollation("C"); + + b.HasKey("Id") + .HasName("devices_pkey"); + + b.HasIndex("OwnerId"); + + b.HasIndex("Token") + .IsUnique(); + + b.ToTable("devices"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.DeviceOtaUpdate", b => + { + b.Property("DeviceId") + .HasColumnType("uuid") + .HasColumnName("device_id"); + + b.Property("UpdateId") + .HasColumnType("integer") + .HasColumnName("update_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Message") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("message"); + + b.Property("Status") + .HasColumnType("ota_update_status") + .HasColumnName("status"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("version"); + + b.HasKey("DeviceId", "UpdateId") + .HasName("device_ota_updates_pkey"); + + b.HasIndex(new[] { "CreatedAt" }, "device_ota_updates_created_at_idx"); + + b.ToTable("device_ota_updates"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.DiscordWebhook", b => + { + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("WebhookId") + .HasColumnType("bigint") + .HasColumnName("webhook_id"); + + b.Property("WebhookToken") + .IsRequired() + .HasColumnType("text") + .HasColumnName("webhook_token"); + + b.HasKey("Name") + .HasName("discord_webhooks_pkey"); + + b.ToTable("discord_webhooks"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.EmailOutboxMessage", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AttemptCount") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("attempt_count"); + + b.Property("CoalesceKey") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("coalesce_key"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("FailedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("failed_at"); + + b.Property("LastError") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("last_error"); + + b.Property("NextAttemptAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("next_attempt_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property>("Payload") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("payload"); + + b.Property("Recipient") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("character varying(320)") + .HasColumnName("recipient"); + + b.Property("RecipientName") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("recipient_name"); + + b.Property("SentAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("sent_at"); + + b.Property("Status") + .HasColumnType("email_status") + .HasColumnName("status"); + + b.Property("Type") + .HasColumnType("email_type") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("email_outbox_pkey"); + + b.HasIndex("CoalesceKey"); + + b.HasIndex("Recipient"); + + b.HasIndex("Status", "NextAttemptAt"); + + b.ToTable("email_outbox"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.EmailProviderBlacklist", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Domain") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("domain") + .UseCollation("ndcoll"); + + b.HasKey("Id") + .HasName("email_provider_blacklist_pkey"); + + var index = b.HasIndex("Domain") + .IsUnique(); + + NpgsqlIndexBuilderExtensions.UseCollation(index, new[] { "ndcoll" }); + + b.ToTable("email_provider_blacklist"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.PublicShare", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("name"); + + b.Property("OwnerId") + .HasColumnType("uuid") + .HasColumnName("owner_id"); + + b.HasKey("Id") + .HasName("public_shares_pkey"); + + b.HasIndex("OwnerId"); + + b.ToTable("public_shares"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.PublicShareShocker", b => + { + b.Property("PublicShareId") + .HasColumnType("uuid") + .HasColumnName("public_share_id"); + + b.Property("ShockerId") + .HasColumnType("uuid") + .HasColumnName("shocker_id"); + + b.Property("AllowLiveControl") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("allow_livecontrol"); + + b.Property("AllowShock") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_shock"); + + b.Property("AllowSound") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_sound"); + + b.Property("AllowVibrate") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_vibrate"); + + b.Property("Cooldown") + .HasColumnType("integer") + .HasColumnName("cooldown"); + + b.Property("IsPaused") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_paused"); + + b.Property("MaxDuration") + .HasColumnType("integer") + .HasColumnName("max_duration"); + + b.Property("MaxIntensity") + .HasColumnType("smallint") + .HasColumnName("max_intensity"); + + b.HasKey("PublicShareId", "ShockerId") + .HasName("public_share_shockers_pkey"); + + b.HasIndex("ShockerId"); + + b.ToTable("public_share_shockers"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.Shocker", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("DeviceId") + .HasColumnType("uuid") + .HasColumnName("device_id"); + + b.Property("IsPaused") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_paused"); + + b.Property("Model") + .HasColumnType("shocker_model_type") + .HasColumnName("model"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("name"); + + b.Property("RfId") + .HasColumnType("integer") + .HasColumnName("rf_id"); + + b.HasKey("Id") + .HasName("shockers_pkey"); + + b.HasIndex("DeviceId"); + + b.ToTable("shockers"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ShockerControlLog", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ControlledByUserId") + .HasColumnType("uuid") + .HasColumnName("controlled_by_user_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("CustomName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("custom_name"); + + b.Property("Duration") + .HasColumnType("bigint") + .HasColumnName("duration"); + + b.Property("Intensity") + .HasColumnType("smallint") + .HasColumnName("intensity"); + + b.Property("LiveControl") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("live_control"); + + b.Property("ShockerId") + .HasColumnType("uuid") + .HasColumnName("shocker_id"); + + b.Property("Type") + .HasColumnType("control_type") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("shocker_control_logs_pkey"); + + b.HasIndex("ControlledByUserId"); + + b.HasIndex("ShockerId"); + + b.ToTable("shocker_control_logs"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ShockerShareCode", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AllowLiveControl") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_livecontrol"); + + b.Property("AllowShock") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_shock"); + + b.Property("AllowSound") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_sound"); + + b.Property("AllowVibrate") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_vibrate"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("IsPaused") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_paused"); + + b.Property("MaxDuration") + .HasColumnType("integer") + .HasColumnName("max_duration"); + + b.Property("MaxIntensity") + .HasColumnType("smallint") + .HasColumnName("max_intensity"); + + b.Property("ShockerId") + .HasColumnType("uuid") + .HasColumnName("shocker_id"); + + b.HasKey("Id") + .HasName("shocker_share_codes_pkey"); + + b.HasIndex("ShockerId"); + + b.ToTable("shocker_share_codes"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.User", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ActivatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("activated_at"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("character varying(320)") + .HasColumnName("email"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("name") + .UseCollation("ndcoll"); + + b.Property("PasswordHash") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("password_hash") + .UseCollation("C"); + + b.Property>("Roles") + .IsRequired() + .HasColumnType("role_type[]") + .HasColumnName("roles"); + + b.Property("SecurityStamp") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("security_stamp") + .HasDefaultValueSql("gen_random_uuid()"); + + b.HasKey("Id") + .HasName("users_pkey"); + + b.HasIndex("Email") + .IsUnique(); + + var index = b.HasIndex("Name") + .IsUnique(); + + NpgsqlIndexBuilderExtensions.UseCollation(index, new[] { "ndcoll" }); + + b.ToTable("users"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserActivationRequest", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("EmailSendAttempts") + .HasColumnType("integer") + .HasColumnName("email_send_attempts"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("token_hash") + .UseCollation("C"); + + b.HasKey("UserId") + .HasName("user_activation_requests_pkey"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.ToTable("user_activation_requests"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserAuditLog", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Action") + .HasColumnType("audit_action") + .HasColumnName("action"); + + b.Property("ActorId") + .HasColumnType("uuid") + .HasColumnName("actor_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("IpAddress") + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ip_address"); + + b.Property("Metadata") + .HasColumnType("jsonb") + .HasColumnName("metadata"); + + b.Property("Reason") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("reason"); + + b.Property("UserAgent") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("user_agent"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("user_audit_logs_pkey"); + + b.HasIndex("ActorId"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("UserId"); + + b.ToTable("user_audit_logs"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserDeactivation", b => + { + b.Property("DeactivatedUserId") + .HasColumnType("uuid") + .HasColumnName("deactivated_user_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("DeactivatedByUserId") + .HasColumnType("uuid") + .HasColumnName("deactivated_by_user_id"); + + b.Property("DeleteLater") + .HasColumnType("boolean") + .HasColumnName("delete_later"); + + b.Property("UserModerationId") + .HasColumnType("uuid") + .HasColumnName("user_moderation_id"); + + b.HasKey("DeactivatedUserId") + .HasName("user_deactivations_pkey"); + + b.HasIndex("DeactivatedByUserId"); + + b.ToTable("user_deactivations"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserEmailChange", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("NewEmail") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("character varying(320)") + .HasColumnName("email_new"); + + b.Property("OldEmail") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("character varying(320)") + .HasColumnName("email_old"); + + b.Property("SecurityStampAtCreate") + .HasColumnType("uuid") + .HasColumnName("security_stamp_at_create"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("token_hash") + .UseCollation("C"); + + b.Property("UsedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("used_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("user_email_changes_pkey"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("UsedAt"); + + b.HasIndex("UserId"); + + b.ToTable("user_email_changes"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserNameBlacklist", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("MatchType") + .HasColumnType("match_type_enum") + .HasColumnName("match_type"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("value") + .UseCollation("ndcoll"); + + b.HasKey("Id") + .HasName("user_name_blacklist_pkey"); + + var index = b.HasIndex("Value") + .IsUnique(); + + NpgsqlIndexBuilderExtensions.UseCollation(index, new[] { "ndcoll" }); + + b.ToTable("user_name_blacklist"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserNameChange", b => + { + var id = b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(id); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("OldName") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("old_name"); + + b.HasKey("Id", "UserId") + .HasName("user_name_changes_pkey"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("OldName"); + + b.HasIndex("UserId"); + + b.ToTable("user_name_changes"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserOAuthConnection", b => + { + b.Property("ProviderKey") + .HasColumnType("text") + .HasColumnName("provider_key") + .UseCollation("C"); + + b.Property("ExternalId") + .HasColumnType("text") + .HasColumnName("external_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("DisplayName") + .HasColumnType("text") + .HasColumnName("display_name"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("ProviderKey", "ExternalId") + .HasName("user_oauth_connections_pkey"); + + b.HasIndex("UserId"); + + b.ToTable("user_oauth_connections"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserPasswordReset", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("SecurityStampAtCreate") + .HasColumnType("uuid") + .HasColumnName("security_stamp_at_create"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("token_hash") + .UseCollation("C"); + + b.Property("UsedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("used_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("user_password_resets_pkey"); + + b.HasIndex("UserId"); + + b.ToTable("user_password_resets"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShare", b => + { + b.Property("SharedWithUserId") + .HasColumnType("uuid") + .HasColumnName("shared_with_user_id"); + + b.Property("ShockerId") + .HasColumnType("uuid") + .HasColumnName("shocker_id"); + + b.Property("AllowLiveControl") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_livecontrol"); + + b.Property("AllowShock") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_shock"); + + b.Property("AllowSound") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_sound"); + + b.Property("AllowVibrate") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_vibrate"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("IsPaused") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_paused"); + + b.Property("MaxDuration") + .HasColumnType("integer") + .HasColumnName("max_duration"); + + b.Property("MaxIntensity") + .HasColumnType("smallint") + .HasColumnName("max_intensity"); + + b.HasKey("SharedWithUserId", "ShockerId") + .HasName("user_shares_pkey"); + + b.HasIndex("SharedWithUserId"); + + b.HasIndex("ShockerId"); + + b.ToTable("user_shares"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShareInvite", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("OwnerId") + .HasColumnType("uuid") + .HasColumnName("owner_id"); + + b.Property("RecipientUserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("user_share_invites_pkey"); + + b.HasIndex("OwnerId"); + + b.HasIndex("RecipientUserId"); + + b.ToTable("user_share_invites"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShareInviteShocker", b => + { + b.Property("InviteId") + .HasColumnType("uuid") + .HasColumnName("invite_id"); + + b.Property("ShockerId") + .HasColumnType("uuid") + .HasColumnName("shocker_id"); + + b.Property("AllowLiveControl") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_livecontrol"); + + b.Property("AllowShock") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_shock"); + + b.Property("AllowSound") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_sound"); + + b.Property("AllowVibrate") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_vibrate"); + + b.Property("IsPaused") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_paused"); + + b.Property("MaxDuration") + .HasColumnType("integer") + .HasColumnName("max_duration"); + + b.Property("MaxIntensity") + .HasColumnType("smallint") + .HasColumnName("max_intensity"); + + b.HasKey("InviteId", "ShockerId") + .HasName("user_share_invite_shockers_pkey"); + + b.HasIndex("ShockerId"); + + b.ToTable("user_share_invite_shockers"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ApiToken", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "User") + .WithMany("ApiTokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_api_tokens_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ApiTokenReport", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "ReportedByUser") + .WithMany("ReportedApiTokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_api_token_reports_reported_by_user_id"); + + b.Navigation("ReportedByUser"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.BypassToken", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "LastUsedByUser") + .WithMany() + .HasForeignKey("LastUsedByUserId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_bypass_tokens_last_used_by_user_id"); + + b.Navigation("LastUsedByUser"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.BypassTokenUserUse", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.BypassToken", "BypassToken") + .WithMany("UserUses") + .HasForeignKey("BypassTokenId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_bypass_token_user_uses_bypass_token_id"); + + b.HasOne("OpenShock.Common.OpenShockDb.User", "User") + .WithMany("BypassTokenUses") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_bypass_token_user_uses_user_id"); + + b.Navigation("BypassToken"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.Device", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "Owner") + .WithMany("Devices") + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_devices_owner_id"); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.DeviceOtaUpdate", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.Device", "Device") + .WithMany("OtaUpdates") + .HasForeignKey("DeviceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_device_ota_updates_device_id"); + + b.Navigation("Device"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.PublicShare", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "Owner") + .WithMany("OwnedPublicShares") + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_public_shares_owner_id"); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.PublicShareShocker", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.PublicShare", "PublicShare") + .WithMany("ShockerMappings") + .HasForeignKey("PublicShareId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_public_share_shockers_public_share_id"); + + b.HasOne("OpenShock.Common.OpenShockDb.Shocker", "Shocker") + .WithMany("PublicShareMappings") + .HasForeignKey("ShockerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_public_share_shockers_shocker_id"); + + b.Navigation("PublicShare"); + + b.Navigation("Shocker"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.Shocker", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.Device", "Device") + .WithMany("Shockers") + .HasForeignKey("DeviceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_shockers_device_id"); + + b.Navigation("Device"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ShockerControlLog", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "ControlledByUser") + .WithMany("ShockerControlLogs") + .HasForeignKey("ControlledByUserId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_shocker_control_logs_controlled_by_user_id"); + + b.HasOne("OpenShock.Common.OpenShockDb.Shocker", "Shocker") + .WithMany("ShockerControlLogs") + .HasForeignKey("ShockerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_shocker_control_logs_shocker_id"); + + b.Navigation("ControlledByUser"); + + b.Navigation("Shocker"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ShockerShareCode", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.Shocker", "Shocker") + .WithMany("ShockerShareCodes") + .HasForeignKey("ShockerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_shocker_share_codes_shocker_id"); + + b.Navigation("Shocker"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserActivationRequest", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "User") + .WithOne("UserActivationRequest") + .HasForeignKey("OpenShock.Common.OpenShockDb.UserActivationRequest", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_activation_requests_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserAuditLog", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "Actor") + .WithMany("ActorAuditLogs") + .HasForeignKey("ActorId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_user_audit_logs_actor_id"); + + b.HasOne("OpenShock.Common.OpenShockDb.User", "User") + .WithMany("AuditLogs") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_audit_logs_user_id"); + + b.Navigation("Actor"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserDeactivation", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "DeactivatedByUser") + .WithMany() + .HasForeignKey("DeactivatedByUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_deactivations_deactivated_by_user_id"); + + b.HasOne("OpenShock.Common.OpenShockDb.User", "DeactivatedUser") + .WithOne("UserDeactivation") + .HasForeignKey("OpenShock.Common.OpenShockDb.UserDeactivation", "DeactivatedUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_deactivations_deactivated_user_id"); + + b.Navigation("DeactivatedByUser"); + + b.Navigation("DeactivatedUser"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserEmailChange", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "User") + .WithMany("EmailChanges") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_email_changes_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserNameChange", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "User") + .WithMany("NameChanges") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_name_changes_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserOAuthConnection", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "User") + .WithMany("OAuthConnections") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_oauth_connections_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserPasswordReset", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "User") + .WithMany("PasswordResets") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_password_resets_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShare", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "SharedWithUser") + .WithMany("IncomingUserShares") + .HasForeignKey("SharedWithUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_shares_shared_with_user_id"); + + b.HasOne("OpenShock.Common.OpenShockDb.Shocker", "Shocker") + .WithMany("UserShares") + .HasForeignKey("ShockerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_shares_shocker_id"); + + b.Navigation("SharedWithUser"); + + b.Navigation("Shocker"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShareInvite", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "Owner") + .WithMany("OutgoingUserShareInvites") + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_share_invites_owner_id"); + + b.HasOne("OpenShock.Common.OpenShockDb.User", "RecipientUser") + .WithMany("IncomingUserShareInvites") + .HasForeignKey("RecipientUserId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_user_share_invites_recipient_user_id"); + + b.Navigation("Owner"); + + b.Navigation("RecipientUser"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShareInviteShocker", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.UserShareInvite", "Invite") + .WithMany("ShockerMappings") + .HasForeignKey("InviteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_share_invite_shockers_invite_id"); + + b.HasOne("OpenShock.Common.OpenShockDb.Shocker", "Shocker") + .WithMany("UserShareInviteShockerMappings") + .HasForeignKey("ShockerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_share_invite_shockers_shocker_id"); + + b.Navigation("Invite"); + + b.Navigation("Shocker"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.BypassToken", b => + { + b.Navigation("UserUses"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.Device", b => + { + b.Navigation("OtaUpdates"); + + b.Navigation("Shockers"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.PublicShare", b => + { + b.Navigation("ShockerMappings"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.Shocker", b => + { + b.Navigation("PublicShareMappings"); + + b.Navigation("ShockerControlLogs"); + + b.Navigation("ShockerShareCodes"); + + b.Navigation("UserShareInviteShockerMappings"); + + b.Navigation("UserShares"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.User", b => + { + b.Navigation("ActorAuditLogs"); + + b.Navigation("ApiTokens"); + + b.Navigation("AuditLogs"); + + b.Navigation("BypassTokenUses"); + + b.Navigation("Devices"); + + b.Navigation("EmailChanges"); + + b.Navigation("IncomingUserShareInvites"); + + b.Navigation("IncomingUserShares"); + + b.Navigation("NameChanges"); + + b.Navigation("OAuthConnections"); + + b.Navigation("OutgoingUserShareInvites"); + + b.Navigation("OwnedPublicShares"); + + b.Navigation("PasswordResets"); + + b.Navigation("ReportedApiTokens"); + + b.Navigation("ShockerControlLogs"); + + b.Navigation("UserActivationRequest"); + + b.Navigation("UserDeactivation"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShareInvite", b => + { + b.Navigation("ShockerMappings"); + }); +#pragma warning restore 612, 618 + } +} diff --git a/Common/Migrations/20260930202003_AddBypassTokens.cs b/Common/Migrations/20260930202003_AddBypassTokens.cs new file mode 100644 index 00000000..e122aebc --- /dev/null +++ b/Common/Migrations/20260930202003_AddBypassTokens.cs @@ -0,0 +1,159 @@ +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore.Migrations; +using OpenShock.Common.OpenShockDb; + +#nullable disable + +namespace OpenShock.Common.Migrations; + +/// +public partial class _20260930202003_AddBypassTokens : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterDatabase() + .Annotation("Npgsql:CollationDefinition:public.ndcoll", "und-u-ks-level2,und-u-ks-level2,icu,False") + .Annotation("Npgsql:Enum:audit_action", "login,logout,password_changed,email_change_requested,email_changed,username_changed,api_token_created,api_token_deleted,oauth_connected,oauth_disconnected,account_deactivated,account_reactivated,account_deleted") + .Annotation("Npgsql:Enum:bypass_token_type", "turnstile,rate_limit") + .Annotation("Npgsql:Enum:configuration_value_type", "string,bool,int,float,json") + .Annotation("Npgsql:Enum:control_limit_mode", "clamp,lerp") + .Annotation("Npgsql:Enum:control_type", "stop,shock,vibrate,sound") + .Annotation("Npgsql:Enum:email_status", "pending,sending,sent,failed,skipped") + .Annotation("Npgsql:Enum:email_type", "account_activation,password_reset,email_verification,email_change_notice") + .Annotation("Npgsql:Enum:match_type_enum", "exact,contains") + .Annotation("Npgsql:Enum:ota_update_status", "started,running,finished,error,timeout") + .Annotation("Npgsql:Enum:password_encryption_type", "bcrypt_enhanced,pbkdf2") + .Annotation("Npgsql:Enum:permission_type", "shockers.use,shockers.edit,shockers.pause,devices.edit,devices.auth,usershares.edit,usershares.pause,publicshares.pause,publicshares.edit") + .Annotation("Npgsql:Enum:role_type", "support,staff,admin,system") + .Annotation("Npgsql:Enum:shocker_model_type", "cai_xianlin,petrainer,petrainer_998dr,wellturn_t330") + .OldAnnotation("Npgsql:CollationDefinition:public.ndcoll", "und-u-ks-level2,und-u-ks-level2,icu,False") + .OldAnnotation("Npgsql:Enum:audit_action", "login,logout,password_changed,email_change_requested,email_changed,username_changed,api_token_created,api_token_deleted,oauth_connected,oauth_disconnected,account_deactivated,account_reactivated,account_deleted") + .OldAnnotation("Npgsql:Enum:configuration_value_type", "string,bool,int,float,json") + .OldAnnotation("Npgsql:Enum:control_limit_mode", "clamp,lerp") + .OldAnnotation("Npgsql:Enum:control_type", "stop,shock,vibrate,sound") + .OldAnnotation("Npgsql:Enum:email_status", "pending,sending,sent,failed,skipped") + .OldAnnotation("Npgsql:Enum:email_type", "account_activation,password_reset,email_verification,email_change_notice") + .OldAnnotation("Npgsql:Enum:match_type_enum", "exact,contains") + .OldAnnotation("Npgsql:Enum:ota_update_status", "started,running,finished,error,timeout") + .OldAnnotation("Npgsql:Enum:password_encryption_type", "bcrypt_enhanced,pbkdf2") + .OldAnnotation("Npgsql:Enum:permission_type", "shockers.use,shockers.edit,shockers.pause,devices.edit,devices.auth,usershares.edit,usershares.pause,publicshares.pause,publicshares.edit") + .OldAnnotation("Npgsql:Enum:role_type", "support,staff,admin,system") + .OldAnnotation("Npgsql:Enum:shocker_model_type", "cai_xianlin,petrainer,petrainer_998dr,wellturn_t330"); + + migrationBuilder.CreateTable( + name: "bypass_tokens", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + name = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + token_hash = table.Column(type: "character varying(64)", maxLength: 64, nullable: false, collation: "C"), + types = table.Column>(type: "bypass_token_type[]", nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"), + last_used_at = table.Column(type: "timestamp with time zone", nullable: true), + last_used_by_user_id = table.Column(type: "uuid", nullable: true), + last_rotated_at = table.Column(type: "timestamp with time zone", nullable: true), + use_count = table.Column(type: "bigint", nullable: false, defaultValue: 0L), + auto_cleanup_users = table.Column(type: "boolean", nullable: false, defaultValue: false), + auto_cleanup_after = table.Column(type: "interval", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("bypass_tokens_pkey", x => x.id); + table.ForeignKey( + name: "fk_bypass_tokens_last_used_by_user_id", + column: x => x.last_used_by_user_id, + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.SetNull); + }); + + migrationBuilder.CreateTable( + name: "bypass_token_user_uses", + columns: table => new + { + bypass_token_id = table.Column(type: "uuid", nullable: false), + user_id = table.Column(type: "uuid", nullable: false), + first_used_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"), + last_used_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"), + use_count = table.Column(type: "bigint", nullable: false, defaultValue: 0L) + }, + constraints: table => + { + table.PrimaryKey("bypass_token_user_uses_pkey", x => new { x.bypass_token_id, x.user_id }); + table.ForeignKey( + name: "fk_bypass_token_user_uses_bypass_token_id", + column: x => x.bypass_token_id, + principalTable: "bypass_tokens", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "fk_bypass_token_user_uses_user_id", + column: x => x.user_id, + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_bypass_token_user_uses_last_used_at", + table: "bypass_token_user_uses", + column: "last_used_at"); + + migrationBuilder.CreateIndex( + name: "IX_bypass_token_user_uses_user_id", + table: "bypass_token_user_uses", + column: "user_id"); + + migrationBuilder.CreateIndex( + name: "IX_bypass_tokens_last_used_by_user_id", + table: "bypass_tokens", + column: "last_used_by_user_id"); + + migrationBuilder.CreateIndex( + name: "IX_bypass_tokens_token_hash", + table: "bypass_tokens", + column: "token_hash", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "bypass_token_user_uses"); + + migrationBuilder.DropTable( + name: "bypass_tokens"); + + migrationBuilder.AlterDatabase() + .Annotation("Npgsql:CollationDefinition:public.ndcoll", "und-u-ks-level2,und-u-ks-level2,icu,False") + .Annotation("Npgsql:Enum:audit_action", "login,logout,password_changed,email_change_requested,email_changed,username_changed,api_token_created,api_token_deleted,oauth_connected,oauth_disconnected,account_deactivated,account_reactivated,account_deleted") + .Annotation("Npgsql:Enum:configuration_value_type", "string,bool,int,float,json") + .Annotation("Npgsql:Enum:control_limit_mode", "clamp,lerp") + .Annotation("Npgsql:Enum:control_type", "stop,shock,vibrate,sound") + .Annotation("Npgsql:Enum:email_status", "pending,sending,sent,failed,skipped") + .Annotation("Npgsql:Enum:email_type", "account_activation,password_reset,email_verification,email_change_notice") + .Annotation("Npgsql:Enum:match_type_enum", "exact,contains") + .Annotation("Npgsql:Enum:ota_update_status", "started,running,finished,error,timeout") + .Annotation("Npgsql:Enum:password_encryption_type", "bcrypt_enhanced,pbkdf2") + .Annotation("Npgsql:Enum:permission_type", "shockers.use,shockers.edit,shockers.pause,devices.edit,devices.auth,usershares.edit,usershares.pause,publicshares.pause,publicshares.edit") + .Annotation("Npgsql:Enum:role_type", "support,staff,admin,system") + .Annotation("Npgsql:Enum:shocker_model_type", "cai_xianlin,petrainer,petrainer_998dr,wellturn_t330") + .OldAnnotation("Npgsql:CollationDefinition:public.ndcoll", "und-u-ks-level2,und-u-ks-level2,icu,False") + .OldAnnotation("Npgsql:Enum:audit_action", "login,logout,password_changed,email_change_requested,email_changed,username_changed,api_token_created,api_token_deleted,oauth_connected,oauth_disconnected,account_deactivated,account_reactivated,account_deleted") + .OldAnnotation("Npgsql:Enum:bypass_token_type", "turnstile,rate_limit") + .OldAnnotation("Npgsql:Enum:configuration_value_type", "string,bool,int,float,json") + .OldAnnotation("Npgsql:Enum:control_limit_mode", "clamp,lerp") + .OldAnnotation("Npgsql:Enum:control_type", "stop,shock,vibrate,sound") + .OldAnnotation("Npgsql:Enum:email_status", "pending,sending,sent,failed,skipped") + .OldAnnotation("Npgsql:Enum:email_type", "account_activation,password_reset,email_verification,email_change_notice") + .OldAnnotation("Npgsql:Enum:match_type_enum", "exact,contains") + .OldAnnotation("Npgsql:Enum:ota_update_status", "started,running,finished,error,timeout") + .OldAnnotation("Npgsql:Enum:password_encryption_type", "bcrypt_enhanced,pbkdf2") + .OldAnnotation("Npgsql:Enum:permission_type", "shockers.use,shockers.edit,shockers.pause,devices.edit,devices.auth,usershares.edit,usershares.pause,publicshares.pause,publicshares.edit") + .OldAnnotation("Npgsql:Enum:role_type", "support,staff,admin,system") + .OldAnnotation("Npgsql:Enum:shocker_model_type", "cai_xianlin,petrainer,petrainer_998dr,wellturn_t330"); + } +} diff --git a/Common/Migrations/OpenShockContextModelSnapshot.cs b/Common/Migrations/OpenShockContextModelSnapshot.cs index a51b5749..e25764f6 100644 --- a/Common/Migrations/OpenShockContextModelSnapshot.cs +++ b/Common/Migrations/OpenShockContextModelSnapshot.cs @@ -1,4 +1,4 @@ -// +// using System; using System.Collections.Generic; using System.Net; @@ -10,1666 +10,1817 @@ #nullable disable -namespace OpenShock.Common.Migrations +namespace OpenShock.Common.Migrations; + +[DbContext(typeof(MigrationOpenShockContext))] +partial class OpenShockContextModelSnapshot : ModelSnapshot { - [DbContext(typeof(MigrationOpenShockContext))] - partial class OpenShockContextModelSnapshot : ModelSnapshot + // If you encounter a merge conflict in the line below, it means you need to + // discard one of the migration branches and recreate its migrations on top of + // the other branch. See https://aka.ms/efcore-docs-migrations-conflicts for more info. + public override string LastMigrationId => "20260930202003_AddBypassTokens"; + + protected override void BuildModel(ModelBuilder modelBuilder) { - protected override void BuildModel(ModelBuilder modelBuilder) - { #pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("Npgsql:CollationDefinition:public.ndcoll", "und-u-ks-level2,und-u-ks-level2,icu,False") - .HasAnnotation("ProductVersion", "10.0.9") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "audit_action", new[] { "login", "logout", "password_changed", "email_change_requested", "email_changed", "username_changed", "api_token_created", "api_token_deleted", "oauth_connected", "oauth_disconnected", "account_deactivated", "account_reactivated", "account_deleted" }); - NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "configuration_value_type", new[] { "string", "bool", "int", "float", "json" }); - NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "control_limit_mode", new[] { "clamp", "lerp" }); - NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "control_type", new[] { "stop", "shock", "vibrate", "sound" }); - NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "email_status", new[] { "pending", "sending", "sent", "failed", "skipped" }); - NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "email_type", new[] { "account_activation", "password_reset", "email_verification", "email_change_notice" }); - NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "match_type_enum", new[] { "exact", "contains" }); - NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "ota_update_status", new[] { "started", "running", "finished", "error", "timeout" }); - NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "password_encryption_type", new[] { "bcrypt_enhanced", "pbkdf2" }); - NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "permission_type", new[] { "shockers.use", "shockers.edit", "shockers.pause", "devices.edit", "devices.auth", "usershares.edit", "usershares.pause", "publicshares.pause", "publicshares.edit" }); - NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "role_type", new[] { "support", "staff", "admin", "system" }); - NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "shocker_model_type", new[] { "cai_xianlin", "petrainer", "petrainer_998dr", "wellturn_t330" }); - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("FriendlyName") - .HasColumnType("text"); - - b.Property("Xml") - .HasColumnType("text"); - - b.HasKey("Id"); - - b.ToTable("DataProtectionKeys"); - }); - - modelBuilder.Entity("OpenShock.Common.OpenShockDb.AdminUsersView", b => - { - b.Property("ActivatedAt") - .HasColumnType("timestamp with time zone") - .HasColumnName("activated_at"); - - b.Property("ApiTokenCount") - .HasColumnType("integer") - .HasColumnName("api_token_count"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone") - .HasColumnName("created_at"); - - b.Property("DeactivatedAt") - .HasColumnType("timestamp with time zone") - .HasColumnName("deactivated_at"); - - b.Property("DeactivatedByUserId") - .HasColumnType("uuid") - .HasColumnName("deactivated_by_user_id"); - - b.Property("DeviceCount") - .HasColumnType("integer") - .HasColumnName("device_count"); - - b.Property("Email") - .IsRequired() - .HasColumnType("character varying") - .HasColumnName("email"); - - b.Property("EmailChangeRequestCount") - .HasColumnType("integer") - .HasColumnName("email_change_request_count"); - - b.Property("Id") - .HasColumnType("uuid") - .HasColumnName("id"); - - b.Property("Name") - .IsRequired() - .HasColumnType("character varying") - .HasColumnName("name"); - - b.Property("NameChangeRequestCount") - .HasColumnType("integer") - .HasColumnName("name_change_request_count"); - - b.Property("PasswordHashType") - .HasColumnType("character varying") - .HasColumnName("password_hash_type"); - - b.Property("PasswordResetCount") - .HasColumnType("integer") - .HasColumnName("password_reset_count"); - - b.Property("Roles") - .IsRequired() - .HasColumnType("role_type[]") - .HasColumnName("roles"); - - b.Property("ShockerControlLogCount") - .HasColumnType("integer") - .HasColumnName("shocker_control_log_count"); - - b.Property("ShockerCount") - .HasColumnType("integer") - .HasColumnName("shocker_count"); - - b.Property("ShockerPublicShareCount") - .HasColumnType("integer") - .HasColumnName("shocker_public_share_count"); - - b.Property("ShockerUserShareCount") - .HasColumnType("integer") - .HasColumnName("shocker_user_share_count"); - - b.ToTable((string)null); - - b.ToView("admin_users_view", (string)null); - }); - - modelBuilder.Entity("OpenShock.Common.OpenShockDb.ApiToken", b => - { - b.Property("Id") - .HasColumnType("uuid") - .HasColumnName("id"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("created_at") - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - b.Property("CreatedByIp") - .IsRequired() - .HasColumnType("inet") - .HasColumnName("created_by_ip"); - - b.Property("LastUsed") - .HasColumnType("timestamp with time zone") - .HasColumnName("last_used"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("character varying(64)") - .HasColumnName("name"); - - b.PrimitiveCollection>("Permissions") - .IsRequired() - .HasColumnType("permission_type[]") - .HasColumnName("permissions"); - - b.Property("ShockerControlDurationMax") - .ValueGeneratedOnAdd() - .HasColumnType("integer") - .HasDefaultValue(65535) - .HasColumnName("shocker_control_duration_max"); - - b.Property("ShockerControlDurationMin") - .ValueGeneratedOnAdd() - .HasColumnType("integer") - .HasDefaultValue(300) - .HasColumnName("shocker_control_duration_min"); - - b.Property("ShockerControlDurationMode") - .ValueGeneratedOnAdd() - .HasColumnType("control_limit_mode") - .HasDefaultValue(ControlLimitMode.Clamp) - .HasColumnName("shocker_control_duration_mode"); - - b.Property("ShockerControlIntensityMax") - .ValueGeneratedOnAdd() - .HasColumnType("smallint") - .HasDefaultValue((byte)100) - .HasColumnName("shocker_control_intensity_max"); - - b.Property("ShockerControlIntensityMin") - .ValueGeneratedOnAdd() - .HasColumnType("smallint") - .HasDefaultValue((byte)0) - .HasColumnName("shocker_control_intensity_min"); - - b.Property("ShockerControlIntensityMode") - .ValueGeneratedOnAdd() - .HasColumnType("control_limit_mode") - .HasDefaultValue(ControlLimitMode.Clamp) - .HasColumnName("shocker_control_intensity_mode"); - - b.Property("ShockerControlPaused") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(false) - .HasColumnName("shocker_control_paused"); - - b.Property("TokenHash") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("character varying(64)") - .HasColumnName("token_hash") - .UseCollation("C"); - - b.Property("UserId") - .HasColumnType("uuid") - .HasColumnName("user_id"); - - b.Property("ValidUntil") - .HasColumnType("timestamp with time zone") - .HasColumnName("valid_until"); - - b.HasKey("Id") - .HasName("api_tokens_pkey"); - - b.HasIndex("TokenHash") - .IsUnique(); - - b.HasIndex("UserId"); - - b.HasIndex("ValidUntil"); - - b.ToTable("api_tokens", (string)null); - }); - - modelBuilder.Entity("OpenShock.Common.OpenShockDb.ApiTokenReport", b => - { - b.Property("Id") - .HasColumnType("uuid") - .HasColumnName("id"); - - b.Property("AffectedCount") - .HasColumnType("integer") - .HasColumnName("affected_count"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("created_at") - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - b.Property("IpAddress") - .IsRequired() - .HasColumnType("inet") - .HasColumnName("ip_address"); - - b.Property("IpCountry") - .HasColumnType("text") - .HasColumnName("ip_country"); - - b.Property("SubmittedCount") - .HasColumnType("integer") - .HasColumnName("submitted_count"); - - b.Property("UserId") - .HasColumnType("uuid") - .HasColumnName("user_id"); - - b.HasKey("Id") - .HasName("api_token_reports_pkey"); - - b.HasIndex("UserId"); - - b.ToTable("api_token_reports", (string)null); - }); - - modelBuilder.Entity("OpenShock.Common.OpenShockDb.ConfigurationItem", b => - { - b.Property("Name") - .HasColumnType("text") - .HasColumnName("name") - .UseCollation("C"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("created_at") - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - b.Property("Description") - .IsRequired() - .HasColumnType("text") - .HasColumnName("description"); - - b.Property("Type") - .HasColumnType("configuration_value_type") - .HasColumnName("type"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone") - .HasColumnName("updated_at"); - - b.Property("Value") - .IsRequired() - .HasColumnType("text") - .HasColumnName("value"); - - b.HasKey("Name") - .HasName("configuration_pkey"); - - b.ToTable("configuration", (string)null); - }); - - modelBuilder.Entity("OpenShock.Common.OpenShockDb.Device", b => - { - b.Property("Id") - .HasColumnType("uuid") - .HasColumnName("id"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("created_at") - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("character varying(64)") - .HasColumnName("name"); - - b.Property("OwnerId") - .HasColumnType("uuid") - .HasColumnName("owner_id"); - - b.Property("Token") - .IsRequired() - .HasMaxLength(256) - .HasColumnType("character varying(256)") - .HasColumnName("token") - .UseCollation("C"); - - b.HasKey("Id") - .HasName("devices_pkey"); - - b.HasIndex("OwnerId"); - - b.HasIndex("Token") - .IsUnique(); - - b.ToTable("devices", (string)null); - }); - - modelBuilder.Entity("OpenShock.Common.OpenShockDb.DeviceOtaUpdate", b => - { - b.Property("DeviceId") - .HasColumnType("uuid") - .HasColumnName("device_id"); - - b.Property("UpdateId") - .HasColumnType("integer") - .HasColumnName("update_id"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("created_at") - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - b.Property("Message") - .HasMaxLength(128) - .HasColumnType("character varying(128)") - .HasColumnName("message"); - - b.Property("Status") - .HasColumnType("ota_update_status") - .HasColumnName("status"); - - b.Property("Version") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("character varying(64)") - .HasColumnName("version"); - - b.HasKey("DeviceId", "UpdateId") - .HasName("device_ota_updates_pkey"); - - b.HasIndex(new[] { "CreatedAt" }, "device_ota_updates_created_at_idx"); - - b.ToTable("device_ota_updates", (string)null); - }); - - modelBuilder.Entity("OpenShock.Common.OpenShockDb.DiscordWebhook", b => - { - b.Property("Name") - .HasColumnType("text") - .HasColumnName("name"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("created_at") - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - b.Property("Id") - .HasColumnType("uuid") - .HasColumnName("id"); - - b.Property("WebhookId") - .HasColumnType("bigint") - .HasColumnName("webhook_id"); - - b.Property("WebhookToken") - .IsRequired() - .HasColumnType("text") - .HasColumnName("webhook_token"); - - b.HasKey("Name") - .HasName("discord_webhooks_pkey"); - - b.ToTable("discord_webhooks", (string)null); - }); - - modelBuilder.Entity("OpenShock.Common.OpenShockDb.EmailOutboxMessage", b => - { - b.Property("Id") - .HasColumnType("uuid") - .HasColumnName("id"); - - b.Property("AttemptCount") - .IsConcurrencyToken() - .ValueGeneratedOnAdd() - .HasColumnType("integer") - .HasDefaultValue(0) - .HasColumnName("attempt_count"); - - b.Property("CoalesceKey") - .HasMaxLength(128) - .HasColumnType("character varying(128)") - .HasColumnName("coalesce_key"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("created_at") - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - b.Property("FailedAt") - .HasColumnType("timestamp with time zone") - .HasColumnName("failed_at"); - - b.Property("LastError") - .HasMaxLength(1024) - .HasColumnType("character varying(1024)") - .HasColumnName("last_error"); - - b.Property("NextAttemptAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("next_attempt_at") - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - b.Property>("Payload") - .IsRequired() - .HasColumnType("jsonb") - .HasColumnName("payload"); - - b.Property("Recipient") - .IsRequired() - .HasMaxLength(320) - .HasColumnType("character varying(320)") - .HasColumnName("recipient"); - - b.Property("RecipientName") - .HasMaxLength(32) - .HasColumnType("character varying(32)") - .HasColumnName("recipient_name"); - - b.Property("SentAt") - .HasColumnType("timestamp with time zone") - .HasColumnName("sent_at"); - - b.Property("Status") - .HasColumnType("email_status") - .HasColumnName("status"); - - b.Property("Type") - .HasColumnType("email_type") - .HasColumnName("type"); - - b.HasKey("Id") - .HasName("email_outbox_pkey"); - - b.HasIndex("CoalesceKey"); - - b.HasIndex("Recipient"); - - b.HasIndex("Status", "NextAttemptAt"); - - b.ToTable("email_outbox", (string)null); - }); - - modelBuilder.Entity("OpenShock.Common.OpenShockDb.EmailProviderBlacklist", b => - { - b.Property("Id") - .HasColumnType("uuid") - .HasColumnName("id"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("created_at") - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - b.Property("Domain") - .IsRequired() - .HasMaxLength(255) - .HasColumnType("character varying(255)") - .HasColumnName("domain") - .UseCollation("ndcoll"); - - b.HasKey("Id") - .HasName("email_provider_blacklist_pkey"); - - b.HasIndex("Domain") - .IsUnique(); - - NpgsqlIndexBuilderExtensions.UseCollation(b.HasIndex("Domain"), new[] { "ndcoll" }); - - b.ToTable("email_provider_blacklist", (string)null); - }); - - modelBuilder.Entity("OpenShock.Common.OpenShockDb.PublicShare", b => - { - b.Property("Id") - .HasColumnType("uuid") - .HasColumnName("id"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("created_at") - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - b.Property("ExpiresAt") - .HasColumnType("timestamp with time zone") - .HasColumnName("expires_at"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("character varying(64)") - .HasColumnName("name"); - - b.Property("OwnerId") - .HasColumnType("uuid") - .HasColumnName("owner_id"); - - b.HasKey("Id") - .HasName("public_shares_pkey"); - - b.HasIndex("OwnerId"); - - b.ToTable("public_shares", (string)null); - }); - - modelBuilder.Entity("OpenShock.Common.OpenShockDb.PublicShareShocker", b => - { - b.Property("PublicShareId") - .HasColumnType("uuid") - .HasColumnName("public_share_id"); - - b.Property("ShockerId") - .HasColumnType("uuid") - .HasColumnName("shocker_id"); - - b.Property("AllowLiveControl") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(false) - .HasColumnName("allow_livecontrol"); - - b.Property("AllowShock") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(true) - .HasColumnName("allow_shock"); - - b.Property("AllowSound") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(true) - .HasColumnName("allow_sound"); - - b.Property("AllowVibrate") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(true) - .HasColumnName("allow_vibrate"); - - b.Property("Cooldown") - .HasColumnType("integer") - .HasColumnName("cooldown"); - - b.Property("IsPaused") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(false) - .HasColumnName("is_paused"); - - b.Property("MaxDuration") - .HasColumnType("integer") - .HasColumnName("max_duration"); - - b.Property("MaxIntensity") - .HasColumnType("smallint") - .HasColumnName("max_intensity"); - - b.HasKey("PublicShareId", "ShockerId") - .HasName("public_share_shockers_pkey"); - - b.HasIndex("ShockerId"); - - b.ToTable("public_share_shockers", (string)null); - }); - - modelBuilder.Entity("OpenShock.Common.OpenShockDb.Shocker", b => - { - b.Property("Id") - .HasColumnType("uuid") - .HasColumnName("id"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("created_at") - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - b.Property("DeviceId") - .HasColumnType("uuid") - .HasColumnName("device_id"); - - b.Property("IsPaused") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(false) - .HasColumnName("is_paused"); - - b.Property("Model") - .HasColumnType("shocker_model_type") - .HasColumnName("model"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("character varying(64)") - .HasColumnName("name"); - - b.Property("RfId") - .HasColumnType("integer") - .HasColumnName("rf_id"); - - b.HasKey("Id") - .HasName("shockers_pkey"); - - b.HasIndex("DeviceId"); - - b.ToTable("shockers", (string)null); - }); - - modelBuilder.Entity("OpenShock.Common.OpenShockDb.ShockerControlLog", b => - { - b.Property("Id") - .HasColumnType("uuid") - .HasColumnName("id"); - - b.Property("ControlledByUserId") - .HasColumnType("uuid") - .HasColumnName("controlled_by_user_id"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("created_at") - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - b.Property("CustomName") - .HasMaxLength(64) - .HasColumnType("character varying(64)") - .HasColumnName("custom_name"); - - b.Property("Duration") - .HasColumnType("bigint") - .HasColumnName("duration"); - - b.Property("Intensity") - .HasColumnType("smallint") - .HasColumnName("intensity"); - - b.Property("LiveControl") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(false) - .HasColumnName("live_control"); - - b.Property("ShockerId") - .HasColumnType("uuid") - .HasColumnName("shocker_id"); - - b.Property("Type") - .HasColumnType("control_type") - .HasColumnName("type"); - - b.HasKey("Id") - .HasName("shocker_control_logs_pkey"); - - b.HasIndex("ControlledByUserId"); - - b.HasIndex("ShockerId"); - - b.ToTable("shocker_control_logs", (string)null); - }); - - modelBuilder.Entity("OpenShock.Common.OpenShockDb.ShockerShareCode", b => - { - b.Property("Id") - .HasColumnType("uuid") - .HasColumnName("id"); - - b.Property("AllowLiveControl") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(true) - .HasColumnName("allow_livecontrol"); - - b.Property("AllowShock") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(true) - .HasColumnName("allow_shock"); - - b.Property("AllowSound") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(true) - .HasColumnName("allow_sound"); - - b.Property("AllowVibrate") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(true) - .HasColumnName("allow_vibrate"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("created_at") - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - b.Property("IsPaused") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(false) - .HasColumnName("is_paused"); - - b.Property("MaxDuration") - .HasColumnType("integer") - .HasColumnName("max_duration"); - - b.Property("MaxIntensity") - .HasColumnType("smallint") - .HasColumnName("max_intensity"); - - b.Property("ShockerId") - .HasColumnType("uuid") - .HasColumnName("shocker_id"); - - b.HasKey("Id") - .HasName("shocker_share_codes_pkey"); - - b.HasIndex("ShockerId"); - - b.ToTable("shocker_share_codes", (string)null); - }); - - modelBuilder.Entity("OpenShock.Common.OpenShockDb.User", b => - { - b.Property("Id") - .HasColumnType("uuid") - .HasColumnName("id"); - - b.Property("ActivatedAt") - .HasColumnType("timestamp with time zone") - .HasColumnName("activated_at"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("created_at") - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - b.Property("Email") - .IsRequired() - .HasMaxLength(320) - .HasColumnType("character varying(320)") - .HasColumnName("email"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(32) - .HasColumnType("character varying(32)") - .HasColumnName("name") - .UseCollation("ndcoll"); - - b.Property("PasswordHash") - .HasMaxLength(100) - .HasColumnType("character varying(100)") - .HasColumnName("password_hash") - .UseCollation("C"); - - b.PrimitiveCollection>("Roles") - .IsRequired() - .HasColumnType("role_type[]") - .HasColumnName("roles"); - - b.Property("SecurityStamp") - .ValueGeneratedOnAdd() - .HasColumnType("uuid") - .HasColumnName("security_stamp") - .HasDefaultValueSql("gen_random_uuid()"); - - b.HasKey("Id") - .HasName("users_pkey"); - - b.HasIndex("Email") - .IsUnique(); - - b.HasIndex("Name") - .IsUnique(); - - NpgsqlIndexBuilderExtensions.UseCollation(b.HasIndex("Name"), new[] { "ndcoll" }); - - b.ToTable("users", (string)null); - }); - - modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserActivationRequest", b => - { - b.Property("UserId") - .HasColumnType("uuid") - .HasColumnName("user_id"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("created_at") - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - b.Property("EmailSendAttempts") - .HasColumnType("integer") - .HasColumnName("email_send_attempts"); - - b.Property("TokenHash") - .IsRequired() - .HasMaxLength(128) - .HasColumnType("character varying(128)") - .HasColumnName("token_hash") - .UseCollation("C"); - - b.HasKey("UserId") - .HasName("user_activation_requests_pkey"); - - b.HasIndex("TokenHash") - .IsUnique(); - - b.ToTable("user_activation_requests", (string)null); - }); - - modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserAuditLog", b => - { - b.Property("Id") - .HasColumnType("uuid") - .HasColumnName("id"); - - b.Property("Action") - .HasColumnType("audit_action") - .HasColumnName("action"); - - b.Property("ActorId") - .HasColumnType("uuid") - .HasColumnName("actor_id"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("created_at") - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - b.Property("IpAddress") - .HasMaxLength(40) - .HasColumnType("character varying(40)") - .HasColumnName("ip_address"); - - b.Property("Metadata") - .HasColumnType("jsonb") - .HasColumnName("metadata"); - - b.Property("Reason") - .HasMaxLength(512) - .HasColumnType("character varying(512)") - .HasColumnName("reason"); - - b.Property("UserAgent") - .HasMaxLength(1024) - .HasColumnType("character varying(1024)") - .HasColumnName("user_agent"); - - b.Property("UserId") - .HasColumnType("uuid") - .HasColumnName("user_id"); + modelBuilder + .HasAnnotation("Npgsql:CollationDefinition:public.ndcoll", "und-u-ks-level2,und-u-ks-level2,icu,False") + .HasAnnotation("ProductVersion", "11.0.0-rc.1.26425.128") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "audit_action", new[] { "login", "logout", "password_changed", "email_change_requested", "email_changed", "username_changed", "api_token_created", "api_token_deleted", "oauth_connected", "oauth_disconnected", "account_deactivated", "account_reactivated", "account_deleted" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "bypass_token_type", new[] { "turnstile", "rate_limit" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "configuration_value_type", new[] { "string", "bool", "int", "float", "json" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "control_limit_mode", new[] { "clamp", "lerp" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "control_type", new[] { "stop", "shock", "vibrate", "sound" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "email_status", new[] { "pending", "sending", "sent", "failed", "skipped" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "email_type", new[] { "account_activation", "password_reset", "email_verification", "email_change_notice" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "match_type_enum", new[] { "exact", "contains" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "ota_update_status", new[] { "started", "running", "finished", "error", "timeout" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "password_encryption_type", new[] { "bcrypt_enhanced", "pbkdf2" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "permission_type", new[] { "shockers.use", "shockers.edit", "shockers.pause", "devices.edit", "devices.auth", "usershares.edit", "usershares.pause", "publicshares.pause", "publicshares.edit" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "role_type", new[] { "support", "staff", "admin", "system" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "shocker_model_type", new[] { "cai_xianlin", "petrainer", "petrainer_998dr", "wellturn_t330" }); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + var id = b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(id); + + b.Property("FriendlyName") + .HasColumnType("text"); + + b.Property("Xml") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("DataProtectionKeys"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.AdminUsersView", b => + { + b.Property("ActivatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("activated_at"); + + b.Property("ApiTokenCount") + .HasColumnType("integer") + .HasColumnName("api_token_count"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeactivatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deactivated_at"); + + b.Property("DeactivatedByUserId") + .HasColumnType("uuid") + .HasColumnName("deactivated_by_user_id"); + + b.Property("DeviceCount") + .HasColumnType("integer") + .HasColumnName("device_count"); + + b.Property("Email") + .IsRequired() + .HasColumnType("character varying") + .HasColumnName("email"); + + b.Property("EmailChangeRequestCount") + .HasColumnType("integer") + .HasColumnName("email_change_request_count"); + + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasColumnType("character varying") + .HasColumnName("name"); + + b.Property("NameChangeRequestCount") + .HasColumnType("integer") + .HasColumnName("name_change_request_count"); + + b.Property("PasswordHashType") + .HasColumnType("character varying") + .HasColumnName("password_hash_type"); + + b.Property("PasswordResetCount") + .HasColumnType("integer") + .HasColumnName("password_reset_count"); + + b.Property("Roles") + .IsRequired() + .HasColumnType("role_type[]") + .HasColumnName("roles"); + + b.Property("ShockerControlLogCount") + .HasColumnType("integer") + .HasColumnName("shocker_control_log_count"); + + b.Property("ShockerCount") + .HasColumnType("integer") + .HasColumnName("shocker_count"); + + b.Property("ShockerPublicShareCount") + .HasColumnType("integer") + .HasColumnName("shocker_public_share_count"); + + b.Property("ShockerUserShareCount") + .HasColumnType("integer") + .HasColumnName("shocker_user_share_count"); + + b.ToTable((string)null); + + b.ToView("admin_users_view", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ApiToken", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("CreatedByIp") + .IsRequired() + .HasColumnType("inet") + .HasColumnName("created_by_ip"); + + b.Property("LastUsed") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_used"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("name"); + + b.Property>("Permissions") + .IsRequired() + .HasColumnType("permission_type[]") + .HasColumnName("permissions"); + + b.Property("ShockerControlDurationMax") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(65535) + .HasColumnName("shocker_control_duration_max"); + + b.Property("ShockerControlDurationMin") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(300) + .HasColumnName("shocker_control_duration_min"); + + b.Property("ShockerControlDurationMode") + .ValueGeneratedOnAdd() + .HasColumnType("control_limit_mode") + .HasDefaultValue(ControlLimitMode.Clamp) + .HasColumnName("shocker_control_duration_mode"); + + b.Property("ShockerControlIntensityMax") + .ValueGeneratedOnAdd() + .HasColumnType("smallint") + .HasDefaultValue((byte)100) + .HasColumnName("shocker_control_intensity_max"); + + b.Property("ShockerControlIntensityMin") + .ValueGeneratedOnAdd() + .HasColumnType("smallint") + .HasDefaultValue((byte)0) + .HasColumnName("shocker_control_intensity_min"); + + b.Property("ShockerControlIntensityMode") + .ValueGeneratedOnAdd() + .HasColumnType("control_limit_mode") + .HasDefaultValue(ControlLimitMode.Clamp) + .HasColumnName("shocker_control_intensity_mode"); + + b.Property("ShockerControlPaused") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("shocker_control_paused"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("token_hash") + .UseCollation("C"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("ValidUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("valid_until"); + + b.HasKey("Id") + .HasName("api_tokens_pkey"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.HasIndex("ValidUntil"); + + b.ToTable("api_tokens"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ApiTokenReport", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AffectedCount") + .HasColumnType("integer") + .HasColumnName("affected_count"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("IpAddress") + .IsRequired() + .HasColumnType("inet") + .HasColumnName("ip_address"); + + b.Property("IpCountry") + .HasColumnType("text") + .HasColumnName("ip_country"); + + b.Property("SubmittedCount") + .HasColumnType("integer") + .HasColumnName("submitted_count"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("api_token_reports_pkey"); + + b.HasIndex("UserId"); + + b.ToTable("api_token_reports"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.BypassToken", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AutoCleanupAfter") + .HasColumnType("interval") + .HasColumnName("auto_cleanup_after"); + + b.Property("AutoCleanupUsers") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("auto_cleanup_users"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("LastRotatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_rotated_at"); + + b.Property("LastUsedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_used_at"); + + b.Property("LastUsedByUserId") + .HasColumnType("uuid") + .HasColumnName("last_used_by_user_id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("name"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("token_hash") + .UseCollation("C"); + + b.Property>("Types") + .IsRequired() + .HasColumnType("bypass_token_type[]") + .HasColumnName("types"); + + b.Property("UseCount") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L) + .HasColumnName("use_count"); + + b.HasKey("Id") + .HasName("bypass_tokens_pkey"); + + b.HasIndex("LastUsedByUserId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.ToTable("bypass_tokens"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.BypassTokenUserUse", b => + { + b.Property("BypassTokenId") + .HasColumnType("uuid") + .HasColumnName("bypass_token_id"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("FirstUsedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("first_used_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("LastUsedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("last_used_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("UseCount") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L) + .HasColumnName("use_count"); + + b.HasKey("BypassTokenId", "UserId") + .HasName("bypass_token_user_uses_pkey"); + + b.HasIndex("LastUsedAt"); + + b.HasIndex("UserId"); + + b.ToTable("bypass_token_user_uses"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ConfigurationItem", b => + { + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name") + .UseCollation("C"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Type") + .HasColumnType("configuration_value_type") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.HasKey("Name") + .HasName("configuration_pkey"); + + b.ToTable("configuration"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.Device", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("name"); + + b.Property("OwnerId") + .HasColumnType("uuid") + .HasColumnName("owner_id"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("token") + .UseCollation("C"); + + b.HasKey("Id") + .HasName("devices_pkey"); + + b.HasIndex("OwnerId"); + + b.HasIndex("Token") + .IsUnique(); + + b.ToTable("devices"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.DeviceOtaUpdate", b => + { + b.Property("DeviceId") + .HasColumnType("uuid") + .HasColumnName("device_id"); + + b.Property("UpdateId") + .HasColumnType("integer") + .HasColumnName("update_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Message") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("message"); + + b.Property("Status") + .HasColumnType("ota_update_status") + .HasColumnName("status"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("version"); + + b.HasKey("DeviceId", "UpdateId") + .HasName("device_ota_updates_pkey"); + + b.HasIndex(new[] { "CreatedAt" }, "device_ota_updates_created_at_idx"); + + b.ToTable("device_ota_updates"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.DiscordWebhook", b => + { + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("WebhookId") + .HasColumnType("bigint") + .HasColumnName("webhook_id"); + + b.Property("WebhookToken") + .IsRequired() + .HasColumnType("text") + .HasColumnName("webhook_token"); + + b.HasKey("Name") + .HasName("discord_webhooks_pkey"); + + b.ToTable("discord_webhooks"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.EmailOutboxMessage", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AttemptCount") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("attempt_count"); + + b.Property("CoalesceKey") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("coalesce_key"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("FailedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("failed_at"); + + b.Property("LastError") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("last_error"); + + b.Property("NextAttemptAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("next_attempt_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property>("Payload") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("payload"); + + b.Property("Recipient") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("character varying(320)") + .HasColumnName("recipient"); + + b.Property("RecipientName") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("recipient_name"); + + b.Property("SentAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("sent_at"); + + b.Property("Status") + .HasColumnType("email_status") + .HasColumnName("status"); + + b.Property("Type") + .HasColumnType("email_type") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("email_outbox_pkey"); + + b.HasIndex("CoalesceKey"); + + b.HasIndex("Recipient"); + + b.HasIndex("Status", "NextAttemptAt"); + + b.ToTable("email_outbox"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.EmailProviderBlacklist", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Domain") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("domain") + .UseCollation("ndcoll"); + + b.HasKey("Id") + .HasName("email_provider_blacklist_pkey"); + + var index = b.HasIndex("Domain") + .IsUnique(); + + NpgsqlIndexBuilderExtensions.UseCollation(index, new[] { "ndcoll" }); + + b.ToTable("email_provider_blacklist"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.PublicShare", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("name"); + + b.Property("OwnerId") + .HasColumnType("uuid") + .HasColumnName("owner_id"); + + b.HasKey("Id") + .HasName("public_shares_pkey"); + + b.HasIndex("OwnerId"); + + b.ToTable("public_shares"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.PublicShareShocker", b => + { + b.Property("PublicShareId") + .HasColumnType("uuid") + .HasColumnName("public_share_id"); + + b.Property("ShockerId") + .HasColumnType("uuid") + .HasColumnName("shocker_id"); + + b.Property("AllowLiveControl") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("allow_livecontrol"); + + b.Property("AllowShock") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_shock"); + + b.Property("AllowSound") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_sound"); + + b.Property("AllowVibrate") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_vibrate"); + + b.Property("Cooldown") + .HasColumnType("integer") + .HasColumnName("cooldown"); + + b.Property("IsPaused") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_paused"); + + b.Property("MaxDuration") + .HasColumnType("integer") + .HasColumnName("max_duration"); + + b.Property("MaxIntensity") + .HasColumnType("smallint") + .HasColumnName("max_intensity"); + + b.HasKey("PublicShareId", "ShockerId") + .HasName("public_share_shockers_pkey"); + + b.HasIndex("ShockerId"); + + b.ToTable("public_share_shockers"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.Shocker", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("DeviceId") + .HasColumnType("uuid") + .HasColumnName("device_id"); + + b.Property("IsPaused") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_paused"); + + b.Property("Model") + .HasColumnType("shocker_model_type") + .HasColumnName("model"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("name"); + + b.Property("RfId") + .HasColumnType("integer") + .HasColumnName("rf_id"); + + b.HasKey("Id") + .HasName("shockers_pkey"); + + b.HasIndex("DeviceId"); + + b.ToTable("shockers"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ShockerControlLog", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ControlledByUserId") + .HasColumnType("uuid") + .HasColumnName("controlled_by_user_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("CustomName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("custom_name"); + + b.Property("Duration") + .HasColumnType("bigint") + .HasColumnName("duration"); + + b.Property("Intensity") + .HasColumnType("smallint") + .HasColumnName("intensity"); + + b.Property("LiveControl") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("live_control"); + + b.Property("ShockerId") + .HasColumnType("uuid") + .HasColumnName("shocker_id"); + + b.Property("Type") + .HasColumnType("control_type") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("shocker_control_logs_pkey"); + + b.HasIndex("ControlledByUserId"); + + b.HasIndex("ShockerId"); + + b.ToTable("shocker_control_logs"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ShockerShareCode", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AllowLiveControl") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_livecontrol"); + + b.Property("AllowShock") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_shock"); + + b.Property("AllowSound") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_sound"); + + b.Property("AllowVibrate") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_vibrate"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("IsPaused") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_paused"); + + b.Property("MaxDuration") + .HasColumnType("integer") + .HasColumnName("max_duration"); + + b.Property("MaxIntensity") + .HasColumnType("smallint") + .HasColumnName("max_intensity"); + + b.Property("ShockerId") + .HasColumnType("uuid") + .HasColumnName("shocker_id"); + + b.HasKey("Id") + .HasName("shocker_share_codes_pkey"); + + b.HasIndex("ShockerId"); + + b.ToTable("shocker_share_codes"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.User", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ActivatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("activated_at"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("character varying(320)") + .HasColumnName("email"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("name") + .UseCollation("ndcoll"); + + b.Property("PasswordHash") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("password_hash") + .UseCollation("C"); + + b.Property>("Roles") + .IsRequired() + .HasColumnType("role_type[]") + .HasColumnName("roles"); + + b.Property("SecurityStamp") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("security_stamp") + .HasDefaultValueSql("gen_random_uuid()"); + + b.HasKey("Id") + .HasName("users_pkey"); + + b.HasIndex("Email") + .IsUnique(); + + var index = b.HasIndex("Name") + .IsUnique(); + + NpgsqlIndexBuilderExtensions.UseCollation(index, new[] { "ndcoll" }); + + b.ToTable("users"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserActivationRequest", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("EmailSendAttempts") + .HasColumnType("integer") + .HasColumnName("email_send_attempts"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("token_hash") + .UseCollation("C"); + + b.HasKey("UserId") + .HasName("user_activation_requests_pkey"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.ToTable("user_activation_requests"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserAuditLog", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Action") + .HasColumnType("audit_action") + .HasColumnName("action"); + + b.Property("ActorId") + .HasColumnType("uuid") + .HasColumnName("actor_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("IpAddress") + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ip_address"); + + b.Property("Metadata") + .HasColumnType("jsonb") + .HasColumnName("metadata"); + + b.Property("Reason") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("reason"); + + b.Property("UserAgent") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("user_agent"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); - b.HasKey("Id") - .HasName("user_audit_logs_pkey"); - - b.HasIndex("ActorId"); + b.HasKey("Id") + .HasName("user_audit_logs_pkey"); + + b.HasIndex("ActorId"); - b.HasIndex("CreatedAt"); - - b.HasIndex("UserId"); - - b.ToTable("user_audit_logs", (string)null); - }); - - modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserDeactivation", b => - { - b.Property("DeactivatedUserId") - .HasColumnType("uuid") - .HasColumnName("deactivated_user_id"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("created_at") - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - b.Property("DeactivatedByUserId") - .HasColumnType("uuid") - .HasColumnName("deactivated_by_user_id"); - - b.Property("DeleteLater") - .HasColumnType("boolean") - .HasColumnName("delete_later"); - - b.Property("UserModerationId") - .HasColumnType("uuid") - .HasColumnName("user_moderation_id"); - - b.HasKey("DeactivatedUserId") - .HasName("user_deactivations_pkey"); - - b.HasIndex("DeactivatedByUserId"); - - b.ToTable("user_deactivations", (string)null); - }); - - modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserEmailChange", b => - { - b.Property("Id") - .HasColumnType("uuid") - .HasColumnName("id"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("created_at") - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - b.Property("NewEmail") - .IsRequired() - .HasMaxLength(320) - .HasColumnType("character varying(320)") - .HasColumnName("email_new"); - - b.Property("OldEmail") - .IsRequired() - .HasMaxLength(320) - .HasColumnType("character varying(320)") - .HasColumnName("email_old"); + b.HasIndex("CreatedAt"); + + b.HasIndex("UserId"); + + b.ToTable("user_audit_logs"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserDeactivation", b => + { + b.Property("DeactivatedUserId") + .HasColumnType("uuid") + .HasColumnName("deactivated_user_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("DeactivatedByUserId") + .HasColumnType("uuid") + .HasColumnName("deactivated_by_user_id"); + + b.Property("DeleteLater") + .HasColumnType("boolean") + .HasColumnName("delete_later"); + + b.Property("UserModerationId") + .HasColumnType("uuid") + .HasColumnName("user_moderation_id"); + + b.HasKey("DeactivatedUserId") + .HasName("user_deactivations_pkey"); + + b.HasIndex("DeactivatedByUserId"); + + b.ToTable("user_deactivations"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserEmailChange", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("NewEmail") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("character varying(320)") + .HasColumnName("email_new"); + + b.Property("OldEmail") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("character varying(320)") + .HasColumnName("email_old"); - b.Property("SecurityStampAtCreate") - .HasColumnType("uuid") - .HasColumnName("security_stamp_at_create"); + b.Property("SecurityStampAtCreate") + .HasColumnType("uuid") + .HasColumnName("security_stamp_at_create"); - b.Property("TokenHash") - .IsRequired() - .HasMaxLength(128) - .HasColumnType("character varying(128)") - .HasColumnName("token_hash") - .UseCollation("C"); + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("token_hash") + .UseCollation("C"); - b.Property("UsedAt") - .HasColumnType("timestamp with time zone") - .HasColumnName("used_at"); + b.Property("UsedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("used_at"); - b.Property("UserId") - .HasColumnType("uuid") - .HasColumnName("user_id"); + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); - b.HasKey("Id") - .HasName("user_email_changes_pkey"); + b.HasKey("Id") + .HasName("user_email_changes_pkey"); - b.HasIndex("CreatedAt"); + b.HasIndex("CreatedAt"); - b.HasIndex("UsedAt"); + b.HasIndex("UsedAt"); - b.HasIndex("UserId"); + b.HasIndex("UserId"); - b.ToTable("user_email_changes", (string)null); - }); + b.ToTable("user_email_changes"); + }); - modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserNameBlacklist", b => - { - b.Property("Id") - .HasColumnType("uuid") - .HasColumnName("id"); + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserNameBlacklist", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("created_at") - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - b.Property("MatchType") - .HasColumnType("match_type_enum") - .HasColumnName("match_type"); - - b.Property("Value") - .IsRequired() - .HasMaxLength(32) - .HasColumnType("character varying(32)") - .HasColumnName("value") - .UseCollation("ndcoll"); - - b.HasKey("Id") - .HasName("user_name_blacklist_pkey"); - - b.HasIndex("Value") - .IsUnique(); - - NpgsqlIndexBuilderExtensions.UseCollation(b.HasIndex("Value"), new[] { "ndcoll" }); - - b.ToTable("user_name_blacklist", (string)null); - }); - - modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserNameChange", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer") - .HasColumnName("id"); - - NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Id")); - - b.Property("UserId") - .HasColumnType("uuid") - .HasColumnName("user_id"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("created_at") - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - b.Property("OldName") - .IsRequired() - .HasMaxLength(32) - .HasColumnType("character varying(32)") - .HasColumnName("old_name"); - - b.HasKey("Id", "UserId") - .HasName("user_name_changes_pkey"); - - b.HasIndex("CreatedAt"); - - b.HasIndex("OldName"); - - b.HasIndex("UserId"); - - b.ToTable("user_name_changes", (string)null); - }); - - modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserOAuthConnection", b => - { - b.Property("ProviderKey") - .HasColumnType("text") - .HasColumnName("provider_key") - .UseCollation("C"); - - b.Property("ExternalId") - .HasColumnType("text") - .HasColumnName("external_id"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("created_at") - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - b.Property("DisplayName") - .HasColumnType("text") - .HasColumnName("display_name"); - - b.Property("UserId") - .HasColumnType("uuid") - .HasColumnName("user_id"); - - b.HasKey("ProviderKey", "ExternalId") - .HasName("user_oauth_connections_pkey"); - - b.HasIndex("UserId"); - - b.ToTable("user_oauth_connections", (string)null); - }); - - modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserPasswordReset", b => - { - b.Property("Id") - .HasColumnType("uuid") - .HasColumnName("id"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("created_at") - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - b.Property("SecurityStampAtCreate") - .HasColumnType("uuid") - .HasColumnName("security_stamp_at_create"); - - b.Property("TokenHash") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)") - .HasColumnName("token_hash") - .UseCollation("C"); - - b.Property("UsedAt") - .HasColumnType("timestamp with time zone") - .HasColumnName("used_at"); - - b.Property("UserId") - .HasColumnType("uuid") - .HasColumnName("user_id"); - - b.HasKey("Id") - .HasName("user_password_resets_pkey"); - - b.HasIndex("UserId"); - - b.ToTable("user_password_resets", (string)null); - }); - - modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShare", b => - { - b.Property("SharedWithUserId") - .HasColumnType("uuid") - .HasColumnName("shared_with_user_id"); - - b.Property("ShockerId") - .HasColumnType("uuid") - .HasColumnName("shocker_id"); - - b.Property("AllowLiveControl") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(true) - .HasColumnName("allow_livecontrol"); - - b.Property("AllowShock") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(true) - .HasColumnName("allow_shock"); - - b.Property("AllowSound") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(true) - .HasColumnName("allow_sound"); - - b.Property("AllowVibrate") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(true) - .HasColumnName("allow_vibrate"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("created_at") - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - b.Property("IsPaused") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(false) - .HasColumnName("is_paused"); - - b.Property("MaxDuration") - .HasColumnType("integer") - .HasColumnName("max_duration"); - - b.Property("MaxIntensity") - .HasColumnType("smallint") - .HasColumnName("max_intensity"); - - b.HasKey("SharedWithUserId", "ShockerId") - .HasName("user_shares_pkey"); - - b.HasIndex("SharedWithUserId"); - - b.HasIndex("ShockerId"); - - b.ToTable("user_shares", (string)null); - }); - - modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShareInvite", b => - { - b.Property("Id") - .HasColumnType("uuid") - .HasColumnName("id"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("created_at") - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - b.Property("OwnerId") - .HasColumnType("uuid") - .HasColumnName("owner_id"); - - b.Property("RecipientUserId") - .HasColumnType("uuid") - .HasColumnName("user_id"); - - b.HasKey("Id") - .HasName("user_share_invites_pkey"); - - b.HasIndex("OwnerId"); - - b.HasIndex("RecipientUserId"); - - b.ToTable("user_share_invites", (string)null); - }); - - modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShareInviteShocker", b => - { - b.Property("InviteId") - .HasColumnType("uuid") - .HasColumnName("invite_id"); - - b.Property("ShockerId") - .HasColumnType("uuid") - .HasColumnName("shocker_id"); - - b.Property("AllowLiveControl") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(true) - .HasColumnName("allow_livecontrol"); - - b.Property("AllowShock") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(true) - .HasColumnName("allow_shock"); - - b.Property("AllowSound") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(true) - .HasColumnName("allow_sound"); - - b.Property("AllowVibrate") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(true) - .HasColumnName("allow_vibrate"); - - b.Property("IsPaused") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(false) - .HasColumnName("is_paused"); - - b.Property("MaxDuration") - .HasColumnType("integer") - .HasColumnName("max_duration"); - - b.Property("MaxIntensity") - .HasColumnType("smallint") - .HasColumnName("max_intensity"); - - b.HasKey("InviteId", "ShockerId") - .HasName("user_share_invite_shockers_pkey"); - - b.HasIndex("ShockerId"); - - b.ToTable("user_share_invite_shockers", (string)null); - }); - - modelBuilder.Entity("OpenShock.Common.OpenShockDb.ApiToken", b => - { - b.HasOne("OpenShock.Common.OpenShockDb.User", "User") - .WithMany("ApiTokens") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired() - .HasConstraintName("fk_api_tokens_user_id"); - - b.Navigation("User"); - }); - - modelBuilder.Entity("OpenShock.Common.OpenShockDb.ApiTokenReport", b => - { - b.HasOne("OpenShock.Common.OpenShockDb.User", "ReportedByUser") - .WithMany("ReportedApiTokens") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired() - .HasConstraintName("fk_api_token_reports_reported_by_user_id"); - - b.Navigation("ReportedByUser"); - }); - - modelBuilder.Entity("OpenShock.Common.OpenShockDb.Device", b => - { - b.HasOne("OpenShock.Common.OpenShockDb.User", "Owner") - .WithMany("Devices") - .HasForeignKey("OwnerId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired() - .HasConstraintName("fk_devices_owner_id"); - - b.Navigation("Owner"); - }); - - modelBuilder.Entity("OpenShock.Common.OpenShockDb.DeviceOtaUpdate", b => - { - b.HasOne("OpenShock.Common.OpenShockDb.Device", "Device") - .WithMany("OtaUpdates") - .HasForeignKey("DeviceId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired() - .HasConstraintName("fk_device_ota_updates_device_id"); - - b.Navigation("Device"); - }); - - modelBuilder.Entity("OpenShock.Common.OpenShockDb.PublicShare", b => - { - b.HasOne("OpenShock.Common.OpenShockDb.User", "Owner") - .WithMany("OwnedPublicShares") - .HasForeignKey("OwnerId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired() - .HasConstraintName("fk_public_shares_owner_id"); - - b.Navigation("Owner"); - }); - - modelBuilder.Entity("OpenShock.Common.OpenShockDb.PublicShareShocker", b => - { - b.HasOne("OpenShock.Common.OpenShockDb.PublicShare", "PublicShare") - .WithMany("ShockerMappings") - .HasForeignKey("PublicShareId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired() - .HasConstraintName("fk_public_share_shockers_public_share_id"); - - b.HasOne("OpenShock.Common.OpenShockDb.Shocker", "Shocker") - .WithMany("PublicShareMappings") - .HasForeignKey("ShockerId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired() - .HasConstraintName("fk_public_share_shockers_shocker_id"); - - b.Navigation("PublicShare"); - - b.Navigation("Shocker"); - }); - - modelBuilder.Entity("OpenShock.Common.OpenShockDb.Shocker", b => - { - b.HasOne("OpenShock.Common.OpenShockDb.Device", "Device") - .WithMany("Shockers") - .HasForeignKey("DeviceId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired() - .HasConstraintName("fk_shockers_device_id"); - - b.Navigation("Device"); - }); - - modelBuilder.Entity("OpenShock.Common.OpenShockDb.ShockerControlLog", b => - { - b.HasOne("OpenShock.Common.OpenShockDb.User", "ControlledByUser") - .WithMany("ShockerControlLogs") - .HasForeignKey("ControlledByUserId") - .OnDelete(DeleteBehavior.Cascade) - .HasConstraintName("fk_shocker_control_logs_controlled_by_user_id"); - - b.HasOne("OpenShock.Common.OpenShockDb.Shocker", "Shocker") - .WithMany("ShockerControlLogs") - .HasForeignKey("ShockerId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired() - .HasConstraintName("fk_shocker_control_logs_shocker_id"); - - b.Navigation("ControlledByUser"); - - b.Navigation("Shocker"); - }); - - modelBuilder.Entity("OpenShock.Common.OpenShockDb.ShockerShareCode", b => - { - b.HasOne("OpenShock.Common.OpenShockDb.Shocker", "Shocker") - .WithMany("ShockerShareCodes") - .HasForeignKey("ShockerId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired() - .HasConstraintName("fk_shocker_share_codes_shocker_id"); - - b.Navigation("Shocker"); - }); - - modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserActivationRequest", b => - { - b.HasOne("OpenShock.Common.OpenShockDb.User", "User") - .WithOne("UserActivationRequest") - .HasForeignKey("OpenShock.Common.OpenShockDb.UserActivationRequest", "UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired() - .HasConstraintName("fk_user_activation_requests_user_id"); - - b.Navigation("User"); - }); - - modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserAuditLog", b => - { - b.HasOne("OpenShock.Common.OpenShockDb.User", "Actor") - .WithMany("ActorAuditLogs") - .HasForeignKey("ActorId") - .OnDelete(DeleteBehavior.SetNull) - .HasConstraintName("fk_user_audit_logs_actor_id"); - - b.HasOne("OpenShock.Common.OpenShockDb.User", "User") - .WithMany("AuditLogs") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired() - .HasConstraintName("fk_user_audit_logs_user_id"); - - b.Navigation("Actor"); - - b.Navigation("User"); - }); - - modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserDeactivation", b => - { - b.HasOne("OpenShock.Common.OpenShockDb.User", "DeactivatedByUser") - .WithMany() - .HasForeignKey("DeactivatedByUserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired() - .HasConstraintName("fk_user_deactivations_deactivated_by_user_id"); - - b.HasOne("OpenShock.Common.OpenShockDb.User", "DeactivatedUser") - .WithOne("UserDeactivation") - .HasForeignKey("OpenShock.Common.OpenShockDb.UserDeactivation", "DeactivatedUserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired() - .HasConstraintName("fk_user_deactivations_deactivated_user_id"); - - b.Navigation("DeactivatedByUser"); - - b.Navigation("DeactivatedUser"); - }); - - modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserEmailChange", b => - { - b.HasOne("OpenShock.Common.OpenShockDb.User", "User") - .WithMany("EmailChanges") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired() - .HasConstraintName("fk_user_email_changes_user_id"); - - b.Navigation("User"); - }); - - modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserNameChange", b => - { - b.HasOne("OpenShock.Common.OpenShockDb.User", "User") - .WithMany("NameChanges") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired() - .HasConstraintName("fk_user_name_changes_user_id"); - - b.Navigation("User"); - }); - - modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserOAuthConnection", b => - { - b.HasOne("OpenShock.Common.OpenShockDb.User", "User") - .WithMany("OAuthConnections") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired() - .HasConstraintName("fk_user_oauth_connections_user_id"); - - b.Navigation("User"); - }); - - modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserPasswordReset", b => - { - b.HasOne("OpenShock.Common.OpenShockDb.User", "User") - .WithMany("PasswordResets") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired() - .HasConstraintName("fk_user_password_resets_user_id"); - - b.Navigation("User"); - }); - - modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShare", b => - { - b.HasOne("OpenShock.Common.OpenShockDb.User", "SharedWithUser") - .WithMany("IncomingUserShares") - .HasForeignKey("SharedWithUserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired() - .HasConstraintName("fk_user_shares_shared_with_user_id"); - - b.HasOne("OpenShock.Common.OpenShockDb.Shocker", "Shocker") - .WithMany("UserShares") - .HasForeignKey("ShockerId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired() - .HasConstraintName("fk_user_shares_shocker_id"); - - b.Navigation("SharedWithUser"); + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("MatchType") + .HasColumnType("match_type_enum") + .HasColumnName("match_type"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("value") + .UseCollation("ndcoll"); + + b.HasKey("Id") + .HasName("user_name_blacklist_pkey"); + + var index = b.HasIndex("Value") + .IsUnique(); + + NpgsqlIndexBuilderExtensions.UseCollation(index, new[] { "ndcoll" }); + + b.ToTable("user_name_blacklist"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserNameChange", b => + { + var id = b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(id); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("OldName") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("old_name"); + + b.HasKey("Id", "UserId") + .HasName("user_name_changes_pkey"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("OldName"); + + b.HasIndex("UserId"); + + b.ToTable("user_name_changes"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserOAuthConnection", b => + { + b.Property("ProviderKey") + .HasColumnType("text") + .HasColumnName("provider_key") + .UseCollation("C"); + + b.Property("ExternalId") + .HasColumnType("text") + .HasColumnName("external_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("DisplayName") + .HasColumnType("text") + .HasColumnName("display_name"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("ProviderKey", "ExternalId") + .HasName("user_oauth_connections_pkey"); + + b.HasIndex("UserId"); + + b.ToTable("user_oauth_connections"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserPasswordReset", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("SecurityStampAtCreate") + .HasColumnType("uuid") + .HasColumnName("security_stamp_at_create"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("token_hash") + .UseCollation("C"); + + b.Property("UsedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("used_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("user_password_resets_pkey"); + + b.HasIndex("UserId"); + + b.ToTable("user_password_resets"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShare", b => + { + b.Property("SharedWithUserId") + .HasColumnType("uuid") + .HasColumnName("shared_with_user_id"); + + b.Property("ShockerId") + .HasColumnType("uuid") + .HasColumnName("shocker_id"); + + b.Property("AllowLiveControl") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_livecontrol"); + + b.Property("AllowShock") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_shock"); + + b.Property("AllowSound") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_sound"); + + b.Property("AllowVibrate") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_vibrate"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("IsPaused") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_paused"); + + b.Property("MaxDuration") + .HasColumnType("integer") + .HasColumnName("max_duration"); + + b.Property("MaxIntensity") + .HasColumnType("smallint") + .HasColumnName("max_intensity"); + + b.HasKey("SharedWithUserId", "ShockerId") + .HasName("user_shares_pkey"); + + b.HasIndex("SharedWithUserId"); + + b.HasIndex("ShockerId"); + + b.ToTable("user_shares"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShareInvite", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("OwnerId") + .HasColumnType("uuid") + .HasColumnName("owner_id"); + + b.Property("RecipientUserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("user_share_invites_pkey"); + + b.HasIndex("OwnerId"); + + b.HasIndex("RecipientUserId"); + + b.ToTable("user_share_invites"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShareInviteShocker", b => + { + b.Property("InviteId") + .HasColumnType("uuid") + .HasColumnName("invite_id"); + + b.Property("ShockerId") + .HasColumnType("uuid") + .HasColumnName("shocker_id"); + + b.Property("AllowLiveControl") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_livecontrol"); + + b.Property("AllowShock") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_shock"); + + b.Property("AllowSound") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_sound"); + + b.Property("AllowVibrate") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_vibrate"); + + b.Property("IsPaused") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_paused"); + + b.Property("MaxDuration") + .HasColumnType("integer") + .HasColumnName("max_duration"); + + b.Property("MaxIntensity") + .HasColumnType("smallint") + .HasColumnName("max_intensity"); + + b.HasKey("InviteId", "ShockerId") + .HasName("user_share_invite_shockers_pkey"); + + b.HasIndex("ShockerId"); + + b.ToTable("user_share_invite_shockers"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ApiToken", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "User") + .WithMany("ApiTokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_api_tokens_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ApiTokenReport", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "ReportedByUser") + .WithMany("ReportedApiTokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_api_token_reports_reported_by_user_id"); + + b.Navigation("ReportedByUser"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.BypassToken", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "LastUsedByUser") + .WithMany() + .HasForeignKey("LastUsedByUserId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_bypass_tokens_last_used_by_user_id"); + + b.Navigation("LastUsedByUser"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.BypassTokenUserUse", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.BypassToken", "BypassToken") + .WithMany("UserUses") + .HasForeignKey("BypassTokenId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_bypass_token_user_uses_bypass_token_id"); + + b.HasOne("OpenShock.Common.OpenShockDb.User", "User") + .WithMany("BypassTokenUses") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_bypass_token_user_uses_user_id"); + + b.Navigation("BypassToken"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.Device", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "Owner") + .WithMany("Devices") + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_devices_owner_id"); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.DeviceOtaUpdate", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.Device", "Device") + .WithMany("OtaUpdates") + .HasForeignKey("DeviceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_device_ota_updates_device_id"); + + b.Navigation("Device"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.PublicShare", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "Owner") + .WithMany("OwnedPublicShares") + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_public_shares_owner_id"); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.PublicShareShocker", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.PublicShare", "PublicShare") + .WithMany("ShockerMappings") + .HasForeignKey("PublicShareId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_public_share_shockers_public_share_id"); + + b.HasOne("OpenShock.Common.OpenShockDb.Shocker", "Shocker") + .WithMany("PublicShareMappings") + .HasForeignKey("ShockerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_public_share_shockers_shocker_id"); + + b.Navigation("PublicShare"); + + b.Navigation("Shocker"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.Shocker", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.Device", "Device") + .WithMany("Shockers") + .HasForeignKey("DeviceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_shockers_device_id"); + + b.Navigation("Device"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ShockerControlLog", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "ControlledByUser") + .WithMany("ShockerControlLogs") + .HasForeignKey("ControlledByUserId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_shocker_control_logs_controlled_by_user_id"); + + b.HasOne("OpenShock.Common.OpenShockDb.Shocker", "Shocker") + .WithMany("ShockerControlLogs") + .HasForeignKey("ShockerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_shocker_control_logs_shocker_id"); + + b.Navigation("ControlledByUser"); + + b.Navigation("Shocker"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ShockerShareCode", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.Shocker", "Shocker") + .WithMany("ShockerShareCodes") + .HasForeignKey("ShockerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_shocker_share_codes_shocker_id"); + + b.Navigation("Shocker"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserActivationRequest", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "User") + .WithOne("UserActivationRequest") + .HasForeignKey("OpenShock.Common.OpenShockDb.UserActivationRequest", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_activation_requests_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserAuditLog", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "Actor") + .WithMany("ActorAuditLogs") + .HasForeignKey("ActorId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_user_audit_logs_actor_id"); + + b.HasOne("OpenShock.Common.OpenShockDb.User", "User") + .WithMany("AuditLogs") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_audit_logs_user_id"); + + b.Navigation("Actor"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserDeactivation", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "DeactivatedByUser") + .WithMany() + .HasForeignKey("DeactivatedByUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_deactivations_deactivated_by_user_id"); + + b.HasOne("OpenShock.Common.OpenShockDb.User", "DeactivatedUser") + .WithOne("UserDeactivation") + .HasForeignKey("OpenShock.Common.OpenShockDb.UserDeactivation", "DeactivatedUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_deactivations_deactivated_user_id"); + + b.Navigation("DeactivatedByUser"); + + b.Navigation("DeactivatedUser"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserEmailChange", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "User") + .WithMany("EmailChanges") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_email_changes_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserNameChange", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "User") + .WithMany("NameChanges") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_name_changes_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserOAuthConnection", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "User") + .WithMany("OAuthConnections") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_oauth_connections_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserPasswordReset", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "User") + .WithMany("PasswordResets") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_password_resets_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShare", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "SharedWithUser") + .WithMany("IncomingUserShares") + .HasForeignKey("SharedWithUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_shares_shared_with_user_id"); + + b.HasOne("OpenShock.Common.OpenShockDb.Shocker", "Shocker") + .WithMany("UserShares") + .HasForeignKey("ShockerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_shares_shocker_id"); + + b.Navigation("SharedWithUser"); + + b.Navigation("Shocker"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShareInvite", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "Owner") + .WithMany("OutgoingUserShareInvites") + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_share_invites_owner_id"); - b.Navigation("Shocker"); - }); + b.HasOne("OpenShock.Common.OpenShockDb.User", "RecipientUser") + .WithMany("IncomingUserShareInvites") + .HasForeignKey("RecipientUserId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_user_share_invites_recipient_user_id"); - modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShareInvite", b => - { - b.HasOne("OpenShock.Common.OpenShockDb.User", "Owner") - .WithMany("OutgoingUserShareInvites") - .HasForeignKey("OwnerId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired() - .HasConstraintName("fk_user_share_invites_owner_id"); + b.Navigation("Owner"); - b.HasOne("OpenShock.Common.OpenShockDb.User", "RecipientUser") - .WithMany("IncomingUserShareInvites") - .HasForeignKey("RecipientUserId") - .OnDelete(DeleteBehavior.Cascade) - .HasConstraintName("fk_user_share_invites_recipient_user_id"); + b.Navigation("RecipientUser"); + }); - b.Navigation("Owner"); + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShareInviteShocker", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.UserShareInvite", "Invite") + .WithMany("ShockerMappings") + .HasForeignKey("InviteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_share_invite_shockers_invite_id"); - b.Navigation("RecipientUser"); - }); + b.HasOne("OpenShock.Common.OpenShockDb.Shocker", "Shocker") + .WithMany("UserShareInviteShockerMappings") + .HasForeignKey("ShockerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_share_invite_shockers_shocker_id"); - modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShareInviteShocker", b => - { - b.HasOne("OpenShock.Common.OpenShockDb.UserShareInvite", "Invite") - .WithMany("ShockerMappings") - .HasForeignKey("InviteId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired() - .HasConstraintName("fk_user_share_invite_shockers_invite_id"); + b.Navigation("Invite"); - b.HasOne("OpenShock.Common.OpenShockDb.Shocker", "Shocker") - .WithMany("UserShareInviteShockerMappings") - .HasForeignKey("ShockerId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired() - .HasConstraintName("fk_user_share_invite_shockers_shocker_id"); + b.Navigation("Shocker"); + }); - b.Navigation("Invite"); + modelBuilder.Entity("OpenShock.Common.OpenShockDb.BypassToken", b => + { + b.Navigation("UserUses"); + }); - b.Navigation("Shocker"); - }); + modelBuilder.Entity("OpenShock.Common.OpenShockDb.Device", b => + { + b.Navigation("OtaUpdates"); - modelBuilder.Entity("OpenShock.Common.OpenShockDb.Device", b => - { - b.Navigation("OtaUpdates"); + b.Navigation("Shockers"); + }); - b.Navigation("Shockers"); - }); + modelBuilder.Entity("OpenShock.Common.OpenShockDb.PublicShare", b => + { + b.Navigation("ShockerMappings"); + }); - modelBuilder.Entity("OpenShock.Common.OpenShockDb.PublicShare", b => - { - b.Navigation("ShockerMappings"); - }); + modelBuilder.Entity("OpenShock.Common.OpenShockDb.Shocker", b => + { + b.Navigation("PublicShareMappings"); - modelBuilder.Entity("OpenShock.Common.OpenShockDb.Shocker", b => - { - b.Navigation("PublicShareMappings"); + b.Navigation("ShockerControlLogs"); - b.Navigation("ShockerControlLogs"); + b.Navigation("ShockerShareCodes"); - b.Navigation("ShockerShareCodes"); + b.Navigation("UserShareInviteShockerMappings"); - b.Navigation("UserShareInviteShockerMappings"); + b.Navigation("UserShares"); + }); - b.Navigation("UserShares"); - }); + modelBuilder.Entity("OpenShock.Common.OpenShockDb.User", b => + { + b.Navigation("ActorAuditLogs"); - modelBuilder.Entity("OpenShock.Common.OpenShockDb.User", b => - { - b.Navigation("ActorAuditLogs"); + b.Navigation("ApiTokens"); - b.Navigation("ApiTokens"); + b.Navigation("AuditLogs"); - b.Navigation("AuditLogs"); + b.Navigation("BypassTokenUses"); - b.Navigation("Devices"); + b.Navigation("Devices"); - b.Navigation("EmailChanges"); + b.Navigation("EmailChanges"); - b.Navigation("IncomingUserShareInvites"); + b.Navigation("IncomingUserShareInvites"); - b.Navigation("IncomingUserShares"); + b.Navigation("IncomingUserShares"); - b.Navigation("NameChanges"); + b.Navigation("NameChanges"); - b.Navigation("OAuthConnections"); + b.Navigation("OAuthConnections"); - b.Navigation("OutgoingUserShareInvites"); + b.Navigation("OutgoingUserShareInvites"); - b.Navigation("OwnedPublicShares"); + b.Navigation("OwnedPublicShares"); - b.Navigation("PasswordResets"); + b.Navigation("PasswordResets"); - b.Navigation("ReportedApiTokens"); + b.Navigation("ReportedApiTokens"); - b.Navigation("ShockerControlLogs"); + b.Navigation("ShockerControlLogs"); - b.Navigation("UserActivationRequest"); + b.Navigation("UserActivationRequest"); - b.Navigation("UserDeactivation"); - }); + b.Navigation("UserDeactivation"); + }); - modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShareInvite", b => - { - b.Navigation("ShockerMappings"); - }); + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShareInvite", b => + { + b.Navigation("ShockerMappings"); + }); #pragma warning restore 612, 618 - } } } diff --git a/Common/Models/BypassTokenType.cs b/Common/Models/BypassTokenType.cs deleted file mode 100644 index 0b2ff4dc..00000000 --- a/Common/Models/BypassTokenType.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace OpenShock.Common.Models; - -[Flags] -public enum BypassTokenType -{ - None = 0, - Turnstile = 1 << 0, - RateLimit = 1 << 1 -} diff --git a/Common/OpenShockDb/Enums/BypassTokenType.cs b/Common/OpenShockDb/Enums/BypassTokenType.cs new file mode 100644 index 00000000..5619739e --- /dev/null +++ b/Common/OpenShockDb/Enums/BypassTokenType.cs @@ -0,0 +1,14 @@ +using NpgsqlTypes; +using OpenShock.Common.Utils; + +namespace OpenShock.Common.OpenShockDb; + +/// +/// A protection that a bypass token is allowed to switch off. +/// +[PgEnum(Name = "bypass_token_type")] +public enum BypassTokenType +{ + [PgName("turnstile")] Turnstile = 0, + [PgName("rate_limit")] RateLimit = 1, +} diff --git a/Common/OpenShockDb/Extensions/NpgsqlEnumExtensions.cs b/Common/OpenShockDb/Extensions/NpgsqlEnumExtensions.cs index 4ab9640f..6cd7f49c 100644 --- a/Common/OpenShockDb/Extensions/NpgsqlEnumExtensions.cs +++ b/Common/OpenShockDb/Extensions/NpgsqlEnumExtensions.cs @@ -46,6 +46,7 @@ private static PgEnumInfo BuildInfo() where TEnum : struct, Enum BuildInfo(), BuildInfo(), BuildInfo(), + BuildInfo(), ]; public static NpgsqlDbContextOptionsBuilder MapPgEnums(this NpgsqlDbContextOptionsBuilder builder) diff --git a/Common/OpenShockDb/Models/BypassToken.cs b/Common/OpenShockDb/Models/BypassToken.cs new file mode 100644 index 00000000..f69405c1 --- /dev/null +++ b/Common/OpenShockDb/Models/BypassToken.cs @@ -0,0 +1,30 @@ +namespace OpenShock.Common.OpenShockDb; + +public sealed class BypassToken +{ + public required Guid Id { get; set; } + + public required string Name { get; set; } + + public required string TokenHash { get; set; } + + public required List Types { get; set; } + + public DateTime CreatedAt { get; set; } + + public DateTime? LastUsedAt { get; set; } + + public Guid? LastUsedByUserId { get; set; } + + public DateTime? LastRotatedAt { get; set; } + + public long UseCount { get; set; } + + public bool AutoCleanupUsers { get; set; } + + public TimeSpan? AutoCleanupAfter { get; set; } + + // Navigations + public User? LastUsedByUser { get; set; } + public ICollection UserUses { get; } = []; +} diff --git a/Common/OpenShockDb/Models/BypassTokenUserUse.cs b/Common/OpenShockDb/Models/BypassTokenUserUse.cs new file mode 100644 index 00000000..d5db3d2a --- /dev/null +++ b/Common/OpenShockDb/Models/BypassTokenUserUse.cs @@ -0,0 +1,18 @@ +namespace OpenShock.Common.OpenShockDb; + +public sealed class BypassTokenUserUse +{ + public required Guid BypassTokenId { get; set; } + + public required Guid UserId { get; set; } + + public DateTime FirstUsedAt { get; set; } + + public DateTime LastUsedAt { get; set; } + + public long UseCount { get; set; } + + // Navigations + public BypassToken BypassToken { get; set; } = null!; + public User User { get; set; } = null!; +} diff --git a/Common/OpenShockDb/Models/User.cs b/Common/OpenShockDb/Models/User.cs index 7fb234eb..4f044db4 100644 --- a/Common/OpenShockDb/Models/User.cs +++ b/Common/OpenShockDb/Models/User.cs @@ -41,4 +41,5 @@ public sealed class User public ICollection PasswordResets { get; } = []; public ICollection AuditLogs { get; } = []; public ICollection ActorAuditLogs { get; } = []; + public ICollection BypassTokenUses { get; } = []; } diff --git a/Common/OpenShockDb/OpenShockContext.cs b/Common/OpenShockDb/OpenShockContext.cs index 4a03292f..3cee2b6e 100644 --- a/Common/OpenShockDb/OpenShockContext.cs +++ b/Common/OpenShockDb/OpenShockContext.cs @@ -116,6 +116,10 @@ public static void ConfigureOptionsBuilder(DbContextOptionsBuilder optionsBuilde public DbSet EmailProviderBlacklists { get; set; } public DbSet EmailOutbox { get; set; } + + public DbSet BypassTokens { get; set; } + + public DbSet BypassTokenUserUses { get; set; } public DbSet DataProtectionKeys { get; set; } @@ -854,6 +858,79 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .HasColumnName("created_at"); }); + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id).HasName("bypass_tokens_pkey"); + + entity.ToTable("bypass_tokens"); + + entity.HasIndex(e => e.TokenHash).IsUnique(); + entity.HasIndex(e => e.LastUsedByUserId); + + entity.Property(e => e.Id) + .ValueGeneratedNever() + .HasColumnName("id"); + entity.Property(e => e.Name) + .VarCharWithLength(ApiHardLimits.ApiKeyNameMaxLength) + .HasColumnName("name"); + entity.Property(e => e.TokenHash) + .UseCollation("C") + .VarCharWithLength(HardLimits.Sha256HashHexLength) + .HasColumnName("token_hash"); + entity.Property(e => e.Types) + .HasColumnType("bypass_token_type[]") + .HasColumnName("types"); + entity.Property(e => e.CreatedAt) + .HasDefaultValueSql("CURRENT_TIMESTAMP") + .HasColumnName("created_at"); + entity.Property(e => e.LastUsedAt).HasColumnName("last_used_at"); + entity.Property(e => e.LastUsedByUserId).HasColumnName("last_used_by_user_id"); + entity.Property(e => e.LastRotatedAt).HasColumnName("last_rotated_at"); + entity.Property(e => e.UseCount) + .HasDefaultValue(0L) + .HasColumnName("use_count"); + entity.Property(e => e.AutoCleanupUsers) + .HasDefaultValue(false) + .HasColumnName("auto_cleanup_users"); + entity.Property(e => e.AutoCleanupAfter).HasColumnName("auto_cleanup_after"); + + entity.HasOne(d => d.LastUsedByUser).WithMany() + .HasForeignKey(d => d.LastUsedByUserId) + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_bypass_tokens_last_used_by_user_id"); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => new { e.BypassTokenId, e.UserId }).HasName("bypass_token_user_uses_pkey"); + + entity.ToTable("bypass_token_user_uses"); + + entity.HasIndex(e => e.LastUsedAt); + + entity.Property(e => e.BypassTokenId).HasColumnName("bypass_token_id"); + entity.Property(e => e.UserId).HasColumnName("user_id"); + entity.Property(e => e.FirstUsedAt) + .HasDefaultValueSql("CURRENT_TIMESTAMP") + .HasColumnName("first_used_at"); + entity.Property(e => e.LastUsedAt) + .HasDefaultValueSql("CURRENT_TIMESTAMP") + .HasColumnName("last_used_at"); + entity.Property(e => e.UseCount) + .HasDefaultValue(0L) + .HasColumnName("use_count"); + + entity.HasOne(d => d.BypassToken).WithMany(p => p.UserUses) + .HasForeignKey(d => d.BypassTokenId) + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_bypass_token_user_uses_bypass_token_id"); + + entity.HasOne(d => d.User).WithMany(p => p.BypassTokenUses) + .HasForeignKey(d => d.UserId) + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_bypass_token_user_uses_user_id"); + }); + modelBuilder.Entity(entity => { entity.HasKey(e => e.Id).HasName("email_outbox_pkey"); diff --git a/Common/OpenShockServiceHelper.cs b/Common/OpenShockServiceHelper.cs index 03bc1e87..f6446cce 100644 --- a/Common/OpenShockServiceHelper.cs +++ b/Common/OpenShockServiceHelper.cs @@ -23,6 +23,7 @@ using OpenShock.Common.Problems; using OpenShock.Common.Services.Audit; using OpenShock.Common.Services.BatchUpdate; +using OpenShock.Common.Services.Bypass; using OpenShock.Common.Services.Configuration; using OpenShock.Common.Services.RedisPubSub; using OpenShock.Common.Services.Geo; @@ -345,6 +346,7 @@ public static IServiceCollection AddOpenShockServices(this IServiceCollection se services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); // Ensure GeoOptions is always resolvable so IpEnrichmentService can activate even in hosts // (Cron, LiveControlGateway, SeedE2E) that don't call RegisterGeoOptions(). TryAdd leaves the @@ -380,13 +382,16 @@ public static IServiceCollection AddOpenShockServices(this IServiceCollection se return; } - // If the request presented the configured RATE_LIMIT_BYPASS_TOKEN, skip every limiter on it. - // BypassTokenMiddleware (which runs before UseRateLimiter) has already set HttpContext.Items; - // selectors are sync and this is just a dictionary lookup. + // If the request resolved a bypass token granting RateLimit, skip every limiter on it. + // Selectors are sync and the BypassTokenMiddleware (which runs before UseRateLimiter) + // has already populated HttpContext.Items, so this is just a dictionary lookup. static RateLimitPartition? TryBypass(HttpContext ctx) - => ctx.IsBypassed(Models.BypassTokenType.RateLimit) - ? RateLimitPartition.GetNoLimiter("bypass-ratelimit") + { + var bypass = ctx.GetResolvedBypassToken(); + return bypass is not null && bypass.Types.Contains(BypassTokenType.RateLimit) + ? RateLimitPartition.GetNoLimiter($"bypass-{bypass.Id}") : null; + } options.OnRejected = async (context, cancellationToken) => { diff --git a/Common/Services/Bypass/BypassTokenService.cs b/Common/Services/Bypass/BypassTokenService.cs new file mode 100644 index 00000000..3e0f0e73 --- /dev/null +++ b/Common/Services/Bypass/BypassTokenService.cs @@ -0,0 +1,94 @@ +using Microsoft.EntityFrameworkCore; +using OpenShock.Common.Extensions; +using OpenShock.Common.OpenShockDb; +using OpenShock.Common.Utils; + +namespace OpenShock.Common.Services.Bypass; + +public sealed class BypassTokenService : IBypassTokenService +{ + private readonly OpenShockContext _db; + private readonly IHttpContextAccessor _httpContextAccessor; + + public BypassTokenService(OpenShockContext db, IHttpContextAccessor httpContextAccessor) + { + _db = db; + _httpContextAccessor = httpContextAccessor; + } + + public async Task ResolveAsync(string secret, CancellationToken ct) + { + if (string.IsNullOrEmpty(secret) || secret.Length != IBypassTokenService.SecretLength) return null; + + var hash = HashingUtils.HashToken(secret); + + var token = await _db.BypassTokens + .Where(t => t.TokenHash == hash) + .Select(t => new { t.Id, t.Name, t.Types }) + .FirstOrDefaultAsync(ct); + + if (token is null) return null; + + var tokenId = token.Id; + await _db.BypassTokens + .Where(t => t.Id == tokenId) + .ExecuteUpdateAsync(s => s + .SetProperty(t => t.LastUsedAt, DateTime.UtcNow) + .SetProperty(t => t.UseCount, t => t.UseCount + 1), ct); + + return new ResolvedBypassToken(tokenId, token.Name, token.Types); + } + + public Task TryRecordUseAsync(Guid userId, CancellationToken ct) + { + var bypass = _httpContextAccessor.HttpContext?.GetResolvedBypassToken(); + if (bypass is null) return Task.FromResult(true); + + return LinkAsync(bypass.Id, userId, ct); + } + + public async Task TryRecordUseByEmailAsync(string email, CancellationToken ct) + { + var bypass = _httpContextAccessor.HttpContext?.GetResolvedBypassToken(); + if (bypass is null) return true; + + email = email.ToLowerInvariant(); + var userId = await _db.Users + .Where(u => u.Email == email) + .Select(u => (Guid?)u.Id) + .FirstOrDefaultAsync(ct); + + if (userId is null) return true; + + return await LinkAsync(bypass.Id, userId.Value, ct); + } + + private async Task LinkAsync(Guid bypassTokenId, Guid userId, CancellationToken ct) + { + var roles = await _db.Users + .Where(u => u.Id == userId) + .Select(u => u.Roles) + .FirstOrDefaultAsync(ct); + + if (roles is null) return true; // user not found, nothing to link; treat as unprivileged + if (roles.Any(r => r is RoleType.Admin or RoleType.System)) return false; + + var now = DateTime.UtcNow; + + await _db.BypassTokens + .Where(t => t.Id == bypassTokenId) + .ExecuteUpdateAsync(s => s.SetProperty(t => t.LastUsedByUserId, userId), ct); + + const string sql = """ + INSERT INTO bypass_token_user_uses (bypass_token_id, user_id, first_used_at, last_used_at, use_count) + VALUES ({0}, {1}, {2}, {2}, 1) + ON CONFLICT (bypass_token_id, user_id) + DO UPDATE SET last_used_at = EXCLUDED.last_used_at, + use_count = bypass_token_user_uses.use_count + 1 + """; + + await _db.Database.ExecuteSqlRawAsync(sql, [bypassTokenId, userId, now], ct); + + return true; + } +} diff --git a/Common/Services/Bypass/IBypassTokenService.cs b/Common/Services/Bypass/IBypassTokenService.cs new file mode 100644 index 00000000..a07e1d11 --- /dev/null +++ b/Common/Services/Bypass/IBypassTokenService.cs @@ -0,0 +1,41 @@ +using OpenShock.Common.OpenShockDb; +using OpenShock.Internal.Common.Utils; + +namespace OpenShock.Common.Services.Bypass; + +public interface IBypassTokenService +{ + /// + /// Length (in chars) of the opaque random secret. The full secret is base62-style alphanumeric. + /// + const int SecretLength = 128; + + /// + /// Generates a new opaque bypass-token secret. The secret is sent via the + /// X-OpenShock-Bypass-Token header. It has no prefix because it lives in a dedicated + /// header and is not confused with any other token. + /// + static string GenerateSecret() => CryptoUtils.RandomString(SecretLength); + + /// + /// Hashes the supplied secret and looks it up. On a match, increments the global UseCount and + /// LastUsedAt and returns the resolved token. Called once per request by the bypass middleware. + /// + Task ResolveAsync(string secret, CancellationToken ct); + + /// + /// If the current request resolved a bypass token, associates the use with the given user account. + /// Returns false ONLY when a bypass was used AND the target user holds a privileged role + /// ( or ). Callers MUST treat that as + /// "bypass not honored" and reject the request, matching the leak-defense contract. Returns + /// true when no bypass was used, when the user doesn't exist, or when the link was recorded. + /// + Task TryRecordUseAsync(Guid userId, CancellationToken ct); + + /// + /// Same contract as but resolves the user by email first. + /// If no matching user exists, this is a no-op and returns true so the caller does not + /// expose user-existence by behaving differently, which matters on flows like password-reset initiation. + /// + Task TryRecordUseByEmailAsync(string email, CancellationToken ct); +} diff --git a/Common/Services/Bypass/ResolvedBypassToken.cs b/Common/Services/Bypass/ResolvedBypassToken.cs new file mode 100644 index 00000000..5d411f11 --- /dev/null +++ b/Common/Services/Bypass/ResolvedBypassToken.cs @@ -0,0 +1,11 @@ +using OpenShock.Common.OpenShockDb; + +namespace OpenShock.Common.Services.Bypass; + +/// +/// The result of resolving the X-OpenShock-Bypass-Token header against the database. +/// Set on by the bypass-token middleware +/// so that downstream guards (rate limiter, turnstile, etc.) can read it synchronously without +/// re-hitting the database. +/// +public sealed record ResolvedBypassToken(Guid Id, string Name, IReadOnlyList Types); diff --git a/Cron/Jobs/DeleteBypassTokenUsedAccountsJob.cs b/Cron/Jobs/DeleteBypassTokenUsedAccountsJob.cs new file mode 100644 index 00000000..8cd59683 --- /dev/null +++ b/Cron/Jobs/DeleteBypassTokenUsedAccountsJob.cs @@ -0,0 +1,54 @@ +using Microsoft.EntityFrameworkCore; +using OpenShock.Common.OpenShockDb; +using OpenShock.Cron.Attributes; + +namespace OpenShock.Cron.Jobs; + +/// +/// Hard-deletes user accounts that authenticated through an admin-issued bypass token +/// whose owner enabled auto-cleanup, once the configured grace period has elapsed since +/// the last bypass use. Defends against leaked bypass tokens by ensuring test-style +/// accounts created through them do not persist indefinitely. +/// +[CronJob("0 * * * *")] // Every hour +public sealed class DeleteBypassTokenUsedAccountsJob +{ + private readonly OpenShockContext _db; + private readonly ILogger _logger; + + public DeleteBypassTokenUsedAccountsJob(OpenShockContext db, ILogger logger) + { + _db = db; + _logger = logger; + } + + public async Task Execute() + { + var now = DateTime.UtcNow; + + var userIds = await _db.BypassTokenUserUses + .Where(use => use.BypassToken.AutoCleanupUsers + && use.BypassToken.AutoCleanupAfter != null + && use.LastUsedAt + use.BypassToken.AutoCleanupAfter < now) + .Select(use => use.UserId) + .Distinct() + .ToListAsync(); + + if (userIds.Count == 0) + { + _logger.LogDebug("No bypass-token-used accounts eligible for cleanup"); + return 0; + } + + int nDeleted = await _db.Users + .Where(u => userIds.Contains(u.Id) && !u.Roles.Any(r => r == RoleType.Admin || r == RoleType.System)) + .ExecuteDeleteAsync(); + + _logger.LogInformation( + "Bypass-token cleanup: {DeletedCount}/{CandidateCount} accounts deleted", + nDeleted, + userIds.Count); + + return nDeleted; + } +} From 88a8ca6ddb9269f462cd0229af9ace1a72c56b44 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Wed, 30 Sep 2026 22:50:29 +0200 Subject: [PATCH 13/16] feat: automation tokens with automated accounts and auditing Rename bypass tokens to automation tokens (header X-OpenShock-Automation-Token, admin routes /admin/automationTokens). They let automated clients, mainly automated tests, skip Turnstile and/or rate limits. - Never honored for Staff, Admin or System accounts. Login checks this before verifying the password, and the middleware drops the token for signed-in privileged users. - Accounts created through a token link to it via users.created_by_automation_token_id, which makes them automated accounts. They are deleted together with the token; deleting a token is refused while any of its accounts holds a privileged role. - Auto-cleanup is off by default and only deletes accounts the token created, once the configured time has passed since creation. - Uses in signup, login, password reset and token reporting are written to the affected account's audit log (automation_token_used). Creating, updating, rotating and deleting a token are audited with the admin as actor. - Tokens are resolved from a 30s in-memory snapshot and usage counters are batched through BatchUpdateService, so requests carrying a token don't hit the database. - Patch validation, rotate no longer requires a JSON content type, and a dedicated AutomationToken.NotAllowedForAccount error. - The admin user detail and admin_users_view expose the creating token. --- API/Controller/Account/LoginV2.cs | 18 +- .../Account/PasswordResetInitiateV2.cs | 15 +- API/Controller/Account/SignupV2.cs | 11 +- API/Controller/Admin/AutomationTokenCreate.cs | 66 +++++++ API/Controller/Admin/AutomationTokenDelete.cs | 71 ++++++++ API/Controller/Admin/AutomationTokenList.cs | 22 +++ API/Controller/Admin/AutomationTokenPatch.cs | 64 +++++++ API/Controller/Admin/AutomationTokenRotate.cs | 68 ++++++++ API/Controller/Admin/BypassTokenCreate.cs | 47 ----- API/Controller/Admin/BypassTokenDelete.cs | 22 --- API/Controller/Admin/BypassTokenList.cs | 34 ---- API/Controller/Admin/BypassTokenPatch.cs | 48 ------ API/Controller/Admin/BypassTokenRotate.cs | 50 ------ ...ypassTokenDto.cs => AutomationTokenDto.cs} | 22 ++- .../Admin/DTOs/CreateAutomationTokenDto.cs | 70 ++++++++ .../Admin/DTOs/CreateBypassTokenDto.cs | 46 ----- API/Controller/Admin/GetUser.cs | 4 + API/Controller/Tokens/ReportTokens.cs | 12 +- API/Errors/AutomationTokenError.cs | 11 ++ .../Turnstile/CloudflareTurnstileService.cs | 9 +- Common/Constants/AuthConstants.cs | 2 +- Common/Extensions/HttpContextExtensions.cs | 22 +-- ...leware.cs => AutomationTokenMiddleware.cs} | 58 ++++--- ...930204951_AddAutomationTokens.Designer.cs} | 121 ++++--------- ... => 20260930204951_AddAutomationTokens.cs} | 161 +++++++++++------- .../OpenShockContextModelSnapshot.cs | 119 ++++--------- Common/Models/AuditMetadata.cs | 18 ++ Common/OpenShockDb/Enums/AuditAction.cs | 5 + .../OpenShockDb/Enums/AutomationTokenFlow.cs | 12 ++ ...assTokenType.cs => AutomationTokenType.cs} | 6 +- .../Extensions/NpgsqlEnumExtensions.cs | 2 +- Common/OpenShockDb/Models/AdminUsersView.cs | 2 + .../{BypassToken.cs => AutomationToken.cs} | 12 +- .../OpenShockDb/Models/BypassTokenUserUse.cs | 18 -- Common/OpenShockDb/Models/User.cs | 9 +- Common/OpenShockDb/OpenShockContext.cs | 60 ++----- Common/OpenShockMiddlewareHelper.cs | 4 +- Common/OpenShockServiceHelper.cs | 14 +- .../AutomationTokenService.cs | 149 ++++++++++++++++ .../IAutomationTokenService.cs | 75 ++++++++ .../ResolvedAutomationToken.cs | 11 ++ .../BatchUpdate/BatchUpdateService.cs | 51 +++++- .../BatchUpdate/IBatchUpdateService.cs | 5 + Common/Services/Bypass/BypassTokenService.cs | 94 ---------- Common/Services/Bypass/IBypassTokenService.cs | 41 ----- Common/Services/Bypass/ResolvedBypassToken.cs | 11 -- Cron/Jobs/DeleteBypassTokenUsedAccountsJob.cs | 54 ------ .../Jobs/DeleteExpiredAutomatedAccountsJob.cs | 67 ++++++++ 48 files changed, 1076 insertions(+), 837 deletions(-) create mode 100644 API/Controller/Admin/AutomationTokenCreate.cs create mode 100644 API/Controller/Admin/AutomationTokenDelete.cs create mode 100644 API/Controller/Admin/AutomationTokenList.cs create mode 100644 API/Controller/Admin/AutomationTokenPatch.cs create mode 100644 API/Controller/Admin/AutomationTokenRotate.cs delete mode 100644 API/Controller/Admin/BypassTokenCreate.cs delete mode 100644 API/Controller/Admin/BypassTokenDelete.cs delete mode 100644 API/Controller/Admin/BypassTokenList.cs delete mode 100644 API/Controller/Admin/BypassTokenPatch.cs delete mode 100644 API/Controller/Admin/BypassTokenRotate.cs rename API/Controller/Admin/DTOs/{BypassTokenDto.cs => AutomationTokenDto.cs} (55%) create mode 100644 API/Controller/Admin/DTOs/CreateAutomationTokenDto.cs delete mode 100644 API/Controller/Admin/DTOs/CreateBypassTokenDto.cs create mode 100644 API/Errors/AutomationTokenError.cs rename Common/Middleware/{BypassTokenMiddleware.cs => AutomationTokenMiddleware.cs} (51%) rename Common/Migrations/{20260930202003_AddBypassTokens.Designer.cs => 20260930204951_AddAutomationTokens.Designer.cs} (95%) rename Common/Migrations/{20260930202003_AddBypassTokens.cs => 20260930204951_AddAutomationTokens.cs} (61%) create mode 100644 Common/OpenShockDb/Enums/AutomationTokenFlow.cs rename Common/OpenShockDb/Enums/{BypassTokenType.cs => AutomationTokenType.cs} (59%) rename Common/OpenShockDb/Models/{BypassToken.cs => AutomationToken.cs} (61%) delete mode 100644 Common/OpenShockDb/Models/BypassTokenUserUse.cs create mode 100644 Common/Services/AutomationTokens/AutomationTokenService.cs create mode 100644 Common/Services/AutomationTokens/IAutomationTokenService.cs create mode 100644 Common/Services/AutomationTokens/ResolvedAutomationToken.cs delete mode 100644 Common/Services/Bypass/BypassTokenService.cs delete mode 100644 Common/Services/Bypass/IBypassTokenService.cs delete mode 100644 Common/Services/Bypass/ResolvedBypassToken.cs delete mode 100644 Cron/Jobs/DeleteBypassTokenUsedAccountsJob.cs create mode 100644 Cron/Jobs/DeleteExpiredAutomatedAccountsJob.cs diff --git a/API/Controller/Account/LoginV2.cs b/API/Controller/Account/LoginV2.cs index 733c3d30..efe52411 100644 --- a/API/Controller/Account/LoginV2.cs +++ b/API/Controller/Account/LoginV2.cs @@ -8,7 +8,7 @@ using OpenShock.Common.Errors; using OpenShock.Common.Extensions; using OpenShock.Common.Problems; -using OpenShock.Common.Services.Bypass; +using OpenShock.Common.Services.AutomationTokens; using OpenShock.API.Errors; using OpenShock.API.Models.Response; using OpenShock.API.Services.Turnstile; @@ -31,12 +31,12 @@ public sealed partial class AccountController [Consumes(MediaTypeNames.Application.Json)] [ProducesResponseType(StatusCodes.Status200OK, MediaTypeNames.Application.Json)] [ProducesResponseType(StatusCodes.Status401Unauthorized, MediaTypeNames.Application.ProblemJson)] // InvalidCredentials - [ProducesResponseType(StatusCodes.Status403Forbidden, MediaTypeNames.Application.ProblemJson)] // InvalidDomain + [ProducesResponseType(StatusCodes.Status403Forbidden, MediaTypeNames.Application.ProblemJson)] // InvalidDomain, AutomationTokenNotAllowedForAccount [MapToApiVersion("2")] public async Task LoginV2( [FromBody] LoginV2 body, [FromServices] ICloudflareTurnstileService turnstileService, - [FromServices] IBypassTokenService bypassTokens, + [FromServices] IAutomationTokenService automationTokens, CancellationToken cancellationToken) { var cookieDomain = GetCurrentCookieDomain(); @@ -45,6 +45,11 @@ public async Task LoginV2( var turnstileError = await VerifyTurnstileAsync(turnstileService, body.TurnstileResponse, cancellationToken); if (turnstileError is not null) return turnstileError; + // Checked before the password is: with Turnstile and rate limits lifted, an automation token must not + // be usable to guess passwords of privileged accounts. + if (!await automationTokens.CanUseForLoginAsync(body.UsernameOrEmail, cancellationToken)) + return Problem(AutomationTokenError.NotAllowedForAccount); + var getAccountResult = await _accountService.GetAccountByCredentialsAsync(body.UsernameOrEmail, body.Password, cancellationToken); if (getAccountResult is not User account) { @@ -58,11 +63,8 @@ public async Task LoginV2( }; } - // Privileged accounts must never be authenticated through a bypassed flow; the bypass exists - // for automated tests, not as a credential-less back door to a privileged account. For every - // other account this links the use to the token, so auto-cleanup can find it later. - if (!await bypassTokens.TryRecordUseAsync(account.Id, cancellationToken)) - return Problem(TurnstileError.InvalidTurnstile); + if (!await automationTokens.TryRecordUseAsync(account.Id, AutomationTokenFlow.Login, cancellationToken)) + return Problem(AutomationTokenError.NotAllowedForAccount); await CreateSession(account.Id, cookieDomain); diff --git a/API/Controller/Account/PasswordResetInitiateV2.cs b/API/Controller/Account/PasswordResetInitiateV2.cs index f107601b..7e40bf5d 100644 --- a/API/Controller/Account/PasswordResetInitiateV2.cs +++ b/API/Controller/Account/PasswordResetInitiateV2.cs @@ -5,8 +5,9 @@ using OpenShock.API.Models.Requests; using OpenShock.API.Services.Turnstile; using OpenShock.Common.Errors; +using OpenShock.Common.OpenShockDb; using OpenShock.Common.Problems; -using OpenShock.Common.Services.Bypass; +using OpenShock.Common.Services.AutomationTokens; using OpenShock.Internal.Common.Problems; @@ -33,17 +34,15 @@ public sealed partial class AccountController [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status403Forbidden, MediaTypeNames.Application.ProblemJson)] [MapToApiVersion("2")] - public async Task PasswordResetInitiateV2([FromBody] PasswordResetRequestV2 body, [FromServices] ICloudflareTurnstileService turnstileService, [FromServices] IBypassTokenService bypassTokens, CancellationToken cancellationToken) + public async Task PasswordResetInitiateV2([FromBody] PasswordResetRequestV2 body, [FromServices] ICloudflareTurnstileService turnstileService, [FromServices] IAutomationTokenService automationTokens, CancellationToken cancellationToken) { var turnstileError = await VerifyTurnstileAsync(turnstileService, body.TurnstileResponse, cancellationToken); if (turnstileError is not null) return turnstileError; - // Privileged accounts must never be reached through a bypassed flow - the bypass exists for - // automated tests, not as a way to send privileged reset mail without solving Turnstile. - // The lookup runs only on the bypass path, so the normal path keeps its timing profile, and - // the response stays the generic 200 so this does not become an admin-account oracle. - // For every other account this links the use to the token, so auto-cleanup can find it later. - if (!await bypassTokens.TryRecordUseByEmailAsync(body.Email, cancellationToken)) + // Privileged accounts must never be reached through a bypassed flow. The lookup runs only on the + // bypass path, so the normal path keeps its timing profile, and the response stays the generic + // 200 so this does not become an admin-account oracle. + if (!await automationTokens.TryRecordUseByEmailAsync(body.Email, AutomationTokenFlow.PasswordReset, cancellationToken)) { _logger.LogWarning("Refused a bypassed password reset for a privileged account"); return Ok(); diff --git a/API/Controller/Account/SignupV2.cs b/API/Controller/Account/SignupV2.cs index 0ee98777..837a1cee 100644 --- a/API/Controller/Account/SignupV2.cs +++ b/API/Controller/Account/SignupV2.cs @@ -10,7 +10,7 @@ using OpenShock.Common.OpenShockDb; using OpenShock.Common.Options; using OpenShock.Common.Problems; -using OpenShock.Common.Services.Bypass; +using OpenShock.Common.Services.AutomationTokens; using OpenShock.Common.Results; using OpenShock.Internal.Common.Problems; @@ -25,7 +25,7 @@ public sealed partial class AccountController /// /// /// - /// + /// /// /// User successfully signed up /// Username or email already exists @@ -40,7 +40,7 @@ public async Task SignUpV2( [FromBody] SignUpV2 body, [FromServices] ICloudflareTurnstileService turnstileService, [FromServices] AccountOptions accountOptions, - [FromServices] IBypassTokenService bypassTokens, + [FromServices] IAutomationTokenService automationTokens, CancellationToken cancellationToken) { if (!accountOptions.RegistrationEnabled) @@ -59,8 +59,9 @@ public async Task SignUpV2( }; } - // No-op when no bypass token resolved. Signups can't yield a privileged user, so the bool return is ignored. - await bypassTokens.TryRecordUseAsync(created.Id, cancellationToken); + // Links the new account to the automation token that created it, making it an automated account. + // No-op when no automation token resolved. + await automationTokens.RecordSignupAsync(created.Id, cancellationToken); return Ok(); } diff --git a/API/Controller/Admin/AutomationTokenCreate.cs b/API/Controller/Admin/AutomationTokenCreate.cs new file mode 100644 index 00000000..2ef0eb77 --- /dev/null +++ b/API/Controller/Admin/AutomationTokenCreate.cs @@ -0,0 +1,66 @@ +using System.Net.Mime; +using Microsoft.AspNetCore.Mvc; +using OpenShock.API.Controller.Admin.DTOs; +using OpenShock.Common.Models; +using OpenShock.Common.OpenShockDb; +using OpenShock.Common.Services.Audit; +using OpenShock.Common.Services.AutomationTokens; +using OpenShock.Common.Utils; + +namespace OpenShock.API.Controller.Admin; + +public sealed partial class AdminController +{ + /// + /// Creates an automation token. The secret is only returned once, in this response + /// + [HttpPost("automationTokens")] + [Consumes(MediaTypeNames.Application.Json)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task CreateAutomationToken( + [FromBody] CreateAutomationTokenDto body, + [FromServices] IAutomationTokenService automationTokens, + [FromServices] IAuditService auditService, + CancellationToken ct) + { + PedanticallyEnsureAdmin(); + + var secret = IAutomationTokenService.GenerateSecret(); + var token = new AutomationToken + { + Id = Guid.CreateVersion7(), + Name = body.Name.Trim(), + TokenHash = HashingUtils.HashToken(secret), + Types = [.. body.Types.Distinct()], + AutoCleanupUsers = body.AutoCleanupUsers, + AutoCleanupAfter = body.AutoCleanupAfter, + }; + + await using var transaction = await _db.Database.BeginTransactionAsync(ct); + + _db.AutomationTokens.Add(token); + await _db.SaveChangesAsync(ct); + + await auditService.LogAsync( + CurrentUser.Id, + action: AuditAction.AutomationTokenCreated, + actorId: CurrentUser.Id, + metadata: new AutomationTokenCreatedMetadata(token.Id, token.Name, [.. token.Types.Select(t => t.ToString())], token.AutoCleanupUsers, token.AutoCleanupAfter), + cancellationToken: ct); + + await transaction.CommitAsync(ct); + + automationTokens.InvalidateCache(); + + return new CreatedAutomationTokenDto + { + Id = token.Id, + Name = token.Name, + Secret = secret, + Types = token.Types, + CreatedAt = token.CreatedAt, + AutoCleanupUsers = token.AutoCleanupUsers, + AutoCleanupAfter = token.AutoCleanupAfter, + }; + } +} diff --git a/API/Controller/Admin/AutomationTokenDelete.cs b/API/Controller/Admin/AutomationTokenDelete.cs new file mode 100644 index 00000000..9fdcb5f7 --- /dev/null +++ b/API/Controller/Admin/AutomationTokenDelete.cs @@ -0,0 +1,71 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using OpenShock.Common.Models; +using OpenShock.Common.OpenShockDb; +using OpenShock.Common.Services.Audit; +using OpenShock.Common.Services.AutomationTokens; + +namespace OpenShock.API.Controller.Admin; + +public sealed partial class AdminController +{ + /// + /// Deletes an automation token together with every account it created. Refused while any of those + /// accounts holds a privileged role. Other API instances may keep honoring the token for up to 30 seconds + /// + [HttpDelete("automationTokens/{id}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task DeleteAutomationToken( + [FromRoute] Guid id, + [FromServices] IAutomationTokenService automationTokens, + [FromServices] IAuditService auditService, + CancellationToken ct) + { + PedanticallyEnsureAdmin(); + + var token = await _db.AutomationTokens.FirstOrDefaultAsync(t => t.Id == id, ct); + if (token is null) return NotFound(); + + await using var transaction = await _db.Database.BeginTransactionAsync(ct); + + var createdUsers = await _db.Users + .Where(u => u.CreatedByAutomationTokenId == id) + .Select(u => new + { + u.Id, + IsPrivileged = u.Roles.Any(r => r == RoleType.Staff || r == RoleType.Admin || r == RoleType.System) + }) + .ToListAsync(ct); + + // The accounts go through the database's cascade, which can't skip privileged ones, so refuse instead. + // The same roles AccountService.DeleteAccountAsync treats as privileged. + if (createdUsers.Any(u => u.IsPrivileged)) + return Problem("This automation token created a privileged account; remove its privileged roles before deleting the token.", statusCode: StatusCodes.Status409Conflict); + + _db.AutomationTokens.Remove(token); + await _db.SaveChangesAsync(ct); + + await auditService.LogAsync( + CurrentUser.Id, + action: AuditAction.AutomationTokenDeleted, + actorId: CurrentUser.Id, + metadata: new AutomationTokenDeletedMetadata(token.Id, token.Name, createdUsers.Count), + cancellationToken: ct); + + await transaction.CommitAsync(ct); + + automationTokens.InvalidateCache(); + + // Each deleted account's own audit log goes with it, so the log line is the lasting record of what went. + foreach (var user in createdUsers) + { + _logger.LogInformation( + "Deleted account {UserId} together with automation token {AutomationTokenId}", + user.Id, token.Id); + } + + return Ok(); + } +} diff --git a/API/Controller/Admin/AutomationTokenList.cs b/API/Controller/Admin/AutomationTokenList.cs new file mode 100644 index 00000000..57672668 --- /dev/null +++ b/API/Controller/Admin/AutomationTokenList.cs @@ -0,0 +1,22 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using OpenShock.API.Controller.Admin.DTOs; + +namespace OpenShock.API.Controller.Admin; + +public sealed partial class AdminController +{ + /// + /// Lists all automation tokens + /// + [HttpGet("automationTokens")] + public async IAsyncEnumerable ListAutomationTokens() + { + PedanticallyEnsureAdmin(); + + await foreach (var token in _db.AutomationTokens.AsNoTracking().AsAsyncEnumerable()) + { + yield return AutomationTokenDto.FromEntity(token); + } + } +} diff --git a/API/Controller/Admin/AutomationTokenPatch.cs b/API/Controller/Admin/AutomationTokenPatch.cs new file mode 100644 index 00000000..f3e89e50 --- /dev/null +++ b/API/Controller/Admin/AutomationTokenPatch.cs @@ -0,0 +1,64 @@ +using System.Net.Mime; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using OpenShock.API.Controller.Admin.DTOs; +using OpenShock.Common.Models; +using OpenShock.Common.OpenShockDb; +using OpenShock.Common.Services.Audit; +using OpenShock.Common.Services.AutomationTokens; + +namespace OpenShock.API.Controller.Admin; + +public sealed partial class AdminController +{ + /// + /// Updates an automation token. Other API instances may keep the old settings for up to 30 seconds + /// + [HttpPatch("automationTokens/{id}")] + [Consumes(MediaTypeNames.Application.Json)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task PatchAutomationToken( + [FromRoute] Guid id, + [FromBody] PatchAutomationTokenDto body, + [FromServices] IAutomationTokenService automationTokens, + [FromServices] IAuditService auditService, + CancellationToken ct) + { + PedanticallyEnsureAdmin(); + + var token = await _db.AutomationTokens.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.AutoCleanupAfter is not null) token.AutoCleanupAfter = body.AutoCleanupAfter; + if (body.AutoCleanupUsers is not null) + { + token.AutoCleanupUsers = body.AutoCleanupUsers.Value; + + // Switching cleanup off without naming a delay clears the delay, so it can be unset at all. + if (!token.AutoCleanupUsers && body.AutoCleanupAfter is null) token.AutoCleanupAfter = null; + } + + if (token.AutoCleanupUsers && token.AutoCleanupAfter is null) + return Problem("AutoCleanupAfter is required when AutoCleanupUsers is true.", statusCode: StatusCodes.Status400BadRequest); + + await using var transaction = await _db.Database.BeginTransactionAsync(ct); + + await _db.SaveChangesAsync(ct); + + await auditService.LogAsync( + CurrentUser.Id, + action: AuditAction.AutomationTokenUpdated, + actorId: CurrentUser.Id, + metadata: new AutomationTokenUpdatedMetadata(token.Id, token.Name, [.. token.Types.Select(t => t.ToString())], token.AutoCleanupUsers, token.AutoCleanupAfter), + cancellationToken: ct); + + await transaction.CommitAsync(ct); + + automationTokens.InvalidateCache(); + + return Ok(AutomationTokenDto.FromEntity(token)); + } +} diff --git a/API/Controller/Admin/AutomationTokenRotate.cs b/API/Controller/Admin/AutomationTokenRotate.cs new file mode 100644 index 00000000..5aca785c --- /dev/null +++ b/API/Controller/Admin/AutomationTokenRotate.cs @@ -0,0 +1,68 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using OpenShock.API.Controller.Admin.DTOs; +using OpenShock.Common.Models; +using OpenShock.Common.OpenShockDb; +using OpenShock.Common.Services.Audit; +using OpenShock.Common.Services.AutomationTokens; +using OpenShock.Common.Utils; + +namespace OpenShock.API.Controller.Admin; + +public sealed partial class AdminController +{ + /// + /// Rotates the secret of an automation token. The new secret is only returned once, in this response. + /// Other API instances may keep accepting the old secret for up to 30 seconds + /// + [HttpPost("automationTokens/{id}/rotate")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task RotateAutomationToken( + [FromRoute] Guid id, + [FromServices] IAutomationTokenService automationTokens, + [FromServices] IAuditService auditService, + CancellationToken ct) + { + PedanticallyEnsureAdmin(); + + var token = await _db.AutomationTokens.FirstOrDefaultAsync(t => t.Id == id, ct); + if (token is null) return NotFound(); + + var secret = IAutomationTokenService.GenerateSecret(); + token.TokenHash = HashingUtils.HashToken(secret); + token.LastUsedAt = null; + token.UseCount = 0; + token.LastRotatedAt = DateTime.UtcNow; + + await using var transaction = await _db.Database.BeginTransactionAsync(ct); + + await _db.SaveChangesAsync(ct); + + await auditService.LogAsync( + CurrentUser.Id, + action: AuditAction.AutomationTokenRotated, + actorId: CurrentUser.Id, + metadata: new AutomationTokenRotatedMetadata(token.Id, token.Name), + cancellationToken: ct); + + await transaction.CommitAsync(ct); + + automationTokens.InvalidateCache(); + + // Accounts the token created stay linked to it (and to its cleanup schedule) across rotations, + // since the token id doesn't change. + + return Ok(new CreatedAutomationTokenDto + { + Id = token.Id, + Name = token.Name, + Secret = secret, + Types = token.Types, + CreatedAt = token.CreatedAt, + LastRotatedAt = token.LastRotatedAt, + AutoCleanupUsers = token.AutoCleanupUsers, + AutoCleanupAfter = token.AutoCleanupAfter, + }); + } +} diff --git a/API/Controller/Admin/BypassTokenCreate.cs b/API/Controller/Admin/BypassTokenCreate.cs deleted file mode 100644 index 5985b900..00000000 --- a/API/Controller/Admin/BypassTokenCreate.cs +++ /dev/null @@ -1,47 +0,0 @@ -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 -{ - /// - /// Creates a bypass token. The secret is only returned once, in this response - /// - [HttpPost("bypassTokens")] - [Consumes(MediaTypeNames.Application.Json)] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task CreateBypassToken([FromBody] CreateBypassTokenDto body, CancellationToken ct) - { - PedanticallyEnsureAdmin(); - - 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, - }; - } -} diff --git a/API/Controller/Admin/BypassTokenDelete.cs b/API/Controller/Admin/BypassTokenDelete.cs deleted file mode 100644 index 4d47446c..00000000 --- a/API/Controller/Admin/BypassTokenDelete.cs +++ /dev/null @@ -1,22 +0,0 @@ -using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; - -namespace OpenShock.API.Controller.Admin; - -public sealed partial class AdminController -{ - /// - /// Deletes a bypass token - /// - [HttpDelete("bypassTokens/{id}")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task DeleteBypassToken([FromRoute] Guid id, CancellationToken ct) - { - PedanticallyEnsureAdmin(); - - var nDeleted = await _db.BypassTokens.Where(t => t.Id == id).ExecuteDeleteAsync(ct); - - return nDeleted == 0 ? NotFound() : Ok(); - } -} diff --git a/API/Controller/Admin/BypassTokenList.cs b/API/Controller/Admin/BypassTokenList.cs deleted file mode 100644 index 72e33518..00000000 --- a/API/Controller/Admin/BypassTokenList.cs +++ /dev/null @@ -1,34 +0,0 @@ -using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; -using OpenShock.API.Controller.Admin.DTOs; - -namespace OpenShock.API.Controller.Admin; - -public sealed partial class AdminController -{ - /// - /// Lists all bypass tokens - /// - [HttpGet("bypassTokens")] - public async IAsyncEnumerable ListBypassTokens() - { - PedanticallyEnsureAdmin(); - - 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, - }; - } - } -} diff --git a/API/Controller/Admin/BypassTokenPatch.cs b/API/Controller/Admin/BypassTokenPatch.cs deleted file mode 100644 index e14b8f83..00000000 --- a/API/Controller/Admin/BypassTokenPatch.cs +++ /dev/null @@ -1,48 +0,0 @@ -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 -{ - /// - /// Updates a bypass token - /// - [HttpPatch("bypassTokens/{id}")] - [Consumes(MediaTypeNames.Application.Json)] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task PatchBypassToken([FromRoute] Guid id, [FromBody] PatchBypassTokenDto body, CancellationToken ct) - { - PedanticallyEnsureAdmin(); - - 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, - }); - } -} diff --git a/API/Controller/Admin/BypassTokenRotate.cs b/API/Controller/Admin/BypassTokenRotate.cs deleted file mode 100644 index e170c70d..00000000 --- a/API/Controller/Admin/BypassTokenRotate.cs +++ /dev/null @@ -1,50 +0,0 @@ -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 -{ - /// - /// Rotates the secret of a bypass token. The new secret is only returned once, in this response - /// - [HttpPost("bypassTokens/{id}/rotate")] - [Consumes(MediaTypeNames.Application.Json)] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task RotateBypassToken([FromRoute] Guid id, CancellationToken ct) - { - PedanticallyEnsureAdmin(); - - 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, - }); - } -} diff --git a/API/Controller/Admin/DTOs/BypassTokenDto.cs b/API/Controller/Admin/DTOs/AutomationTokenDto.cs similarity index 55% rename from API/Controller/Admin/DTOs/BypassTokenDto.cs rename to API/Controller/Admin/DTOs/AutomationTokenDto.cs index ca2bcd78..bef4b4d1 100644 --- a/API/Controller/Admin/DTOs/BypassTokenDto.cs +++ b/API/Controller/Admin/DTOs/AutomationTokenDto.cs @@ -2,26 +2,38 @@ namespace OpenShock.API.Controller.Admin.DTOs; -public sealed class BypassTokenDto +public sealed class AutomationTokenDto { public required Guid Id { get; init; } public required string Name { get; init; } - public required IReadOnlyList Types { get; init; } + public required IReadOnlyList 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 static AutomationTokenDto FromEntity(AutomationToken token) => new() + { + Id = token.Id, + Name = token.Name, + Types = token.Types, + CreatedAt = token.CreatedAt, + LastUsedAt = token.LastUsedAt, + LastRotatedAt = token.LastRotatedAt, + UseCount = token.UseCount, + AutoCleanupUsers = token.AutoCleanupUsers, + AutoCleanupAfter = token.AutoCleanupAfter, + }; } -public sealed class CreatedBypassTokenDto +public sealed class CreatedAutomationTokenDto { public required Guid Id { get; init; } public required string Name { get; init; } public required string Secret { get; init; } - public required IReadOnlyList Types { get; init; } + public required IReadOnlyList Types { get; init; } public required DateTime CreatedAt { get; init; } public DateTime? LastRotatedAt { get; init; } public bool AutoCleanupUsers { get; init; } diff --git a/API/Controller/Admin/DTOs/CreateAutomationTokenDto.cs b/API/Controller/Admin/DTOs/CreateAutomationTokenDto.cs new file mode 100644 index 00000000..524376ed --- /dev/null +++ b/API/Controller/Admin/DTOs/CreateAutomationTokenDto.cs @@ -0,0 +1,70 @@ +using System.ComponentModel.DataAnnotations; +using OpenShock.Common.Constants; +using OpenShock.Common.OpenShockDb; + +namespace OpenShock.API.Controller.Admin.DTOs; + +public sealed class CreateAutomationTokenDto : IValidatableObject +{ + [Required] + [MinLength(1)] + [MaxLength(ApiHardLimits.ApiKeyNameMaxLength)] + public required string Name { get; init; } + + [Required] + [MinLength(1)] + public required IReadOnlyList Types { get; init; } + + /// + /// Whether the automated accounts this token creates are deleted once has + /// passed since they were created. Off by default; accounts the token didn't create are never deleted. + /// + public bool AutoCleanupUsers { get; init; } = false; + + public TimeSpan? AutoCleanupAfter { get; init; } + + public IEnumerable Validate(ValidationContext _) + { + if (string.IsNullOrWhiteSpace(Name)) + yield return new ValidationResult( + $"{nameof(Name)} must not be blank.", + [nameof(Name)]); + + 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 PatchAutomationTokenDto : IValidatableObject +{ + [MinLength(1)] + [MaxLength(ApiHardLimits.ApiKeyNameMaxLength)] + public string? Name { get; init; } + + [MinLength(1)] + public IReadOnlyList? Types { get; init; } + + public bool? AutoCleanupUsers { get; init; } + + public TimeSpan? AutoCleanupAfter { get; init; } + + public IEnumerable Validate(ValidationContext _) + { + if (Name is not null && string.IsNullOrWhiteSpace(Name)) + yield return new ValidationResult( + $"{nameof(Name)} must not be blank.", + [nameof(Name)]); + + if (AutoCleanupAfter is { } d && d <= TimeSpan.Zero) + yield return new ValidationResult( + $"{nameof(AutoCleanupAfter)} must be positive.", + [nameof(AutoCleanupAfter)]); + } +} diff --git a/API/Controller/Admin/DTOs/CreateBypassTokenDto.cs b/API/Controller/Admin/DTOs/CreateBypassTokenDto.cs deleted file mode 100644 index 0a5376b4..00000000 --- a/API/Controller/Admin/DTOs/CreateBypassTokenDto.cs +++ /dev/null @@ -1,46 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using OpenShock.Common.Constants; -using OpenShock.Common.OpenShockDb; - -namespace OpenShock.API.Controller.Admin.DTOs; - -public sealed class CreateBypassTokenDto : IValidatableObject -{ - [Required] - [MinLength(1)] - [MaxLength(ApiHardLimits.ApiKeyNameMaxLength)] - public required string Name { get; init; } - - [Required] - [MinLength(1)] - public required IReadOnlyList Types { get; init; } - - public bool AutoCleanupUsers { get; init; } - - public TimeSpan? AutoCleanupAfter { get; init; } - - public IEnumerable 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(ApiHardLimits.ApiKeyNameMaxLength)] - public string? Name { get; init; } - - public IReadOnlyList? Types { get; init; } - - public bool? AutoCleanupUsers { get; init; } - - public TimeSpan? AutoCleanupAfter { get; init; } -} diff --git a/API/Controller/Admin/GetUser.cs b/API/Controller/Admin/GetUser.cs index 2571f56c..c13ce360 100644 --- a/API/Controller/Admin/GetUser.cs +++ b/API/Controller/Admin/GetUser.cs @@ -87,6 +87,8 @@ public sealed class AdminUserView public required DateTimeOffset? ActivatedAt { get; init; } + public required Guid? CreatedByAutomationTokenId { get; init; } + public required AdminUserView_UserActivationRequest? ActivationRequest { get; init; } public required AdminUserView_UserDeactivation? Deactivation { get; init; } @@ -113,6 +115,7 @@ public sealed class AdminUserView u.Roles, u.CreatedAt, u.ActivatedAt, + u.CreatedByAutomationTokenId, Hubs = u.Devices.Select(hub => new AdminUserView_Hub { @@ -201,6 +204,7 @@ public sealed class AdminUserView Roles = user.Roles, CreatedAt = user.CreatedAt, ActivatedAt = user.ActivatedAt, + CreatedByAutomationTokenId = user.CreatedByAutomationTokenId, Hubs = user.Hubs, ApiTokens = user.ApiTokens, PasswordResets = user.PasswordResets, diff --git a/API/Controller/Tokens/ReportTokens.cs b/API/Controller/Tokens/ReportTokens.cs index ef922c68..c41c4102 100644 --- a/API/Controller/Tokens/ReportTokens.cs +++ b/API/Controller/Tokens/ReportTokens.cs @@ -12,7 +12,7 @@ using OpenShock.Common.Extensions; using OpenShock.Common.OpenShockDb; using OpenShock.Common.Results; -using OpenShock.Common.Services.Bypass; +using OpenShock.Common.Services.AutomationTokens; using OpenShock.Common.Services.Webhook; using OpenShock.Internal.Common.Utils; @@ -28,7 +28,7 @@ public sealed partial class TokensController /// /// /// - /// + /// /// /// /// The tokens were deleted if found @@ -39,7 +39,7 @@ public sealed partial class TokensController public async Task ReportTokens( [FromBody] ReportTokensRequest body, [FromServices] ICloudflareTurnstileService turnstileService, - [FromServices] IBypassTokenService bypassTokens, + [FromServices] IAutomationTokenService automationTokens, [FromServices] IWebhookService webhookService, CancellationToken cancellationToken) { @@ -54,9 +54,9 @@ public async Task ReportTokens( return Problem(new OpenShockProblem("InternalServerError", "Internal Server Error", HttpStatusCode.InternalServerError)); } - // Caller is already authenticated; a privileged account on a bypass-using request is rejected. - if (!await bypassTokens.TryRecordUseAsync(CurrentUser.Id, cancellationToken)) - return Problem(TurnstileError.InvalidTurnstile); + // The middleware only screens cookie sessions for privileged accounts; this also covers API-token callers. + if (!await automationTokens.TryRecordUseAsync(CurrentUser.Id, AutomationTokenFlow.ReportTokens, cancellationToken)) + return Problem(AutomationTokenError.NotAllowedForAccount); var reportId = Guid.CreateVersion7(); diff --git a/API/Errors/AutomationTokenError.cs b/API/Errors/AutomationTokenError.cs new file mode 100644 index 00000000..8e760d22 --- /dev/null +++ b/API/Errors/AutomationTokenError.cs @@ -0,0 +1,11 @@ +using System.Net; +using OpenShock.Common.Problems; + +using OpenShock.Internal.Common.Problems; + +namespace OpenShock.API.Errors; + +public static class AutomationTokenError +{ + public static OpenShockProblem NotAllowedForAccount => new("AutomationToken.NotAllowedForAccount", "Automation tokens cannot be used with privileged accounts", HttpStatusCode.Forbidden); +} diff --git a/API/Services/Turnstile/CloudflareTurnstileService.cs b/API/Services/Turnstile/CloudflareTurnstileService.cs index 77ed08d8..6ae33e58 100644 --- a/API/Services/Turnstile/CloudflareTurnstileService.cs +++ b/API/Services/Turnstile/CloudflareTurnstileService.cs @@ -53,11 +53,10 @@ public async Task> VerifyUserResponse { if (!_options.Enabled) return new Success(); - // An admin-issued bypass token resolved earlier in the pipeline counts as a Turnstile pass - // if it carries the Turnstile type. The middleware already bumped use counters; controllers - // separately call IBypassTokenService.TryRecordUseAsync after auth so privileged-account - // requests can be rejected and per-user cleanup can run. - if (_httpContextAccessor.HttpContext?.IsBypassed(BypassTokenType.Turnstile) ?? false) + // An admin-issued automation token resolved earlier in the pipeline counts as a Turnstile pass + // if it carries the Turnstile type. Controllers separately check, through IAutomationTokenService, + // that the account they act on was created by that token. + if (_httpContextAccessor.HttpContext?.IsBypassed(AutomationTokenType.Turnstile) ?? false) return new Success(); if (string.IsNullOrEmpty(responseToken)) return CreateError(CloudflareTurnstileError.MissingResponse); diff --git a/Common/Constants/AuthConstants.cs b/Common/Constants/AuthConstants.cs index 340e3545..91d36e13 100644 --- a/Common/Constants/AuthConstants.cs +++ b/Common/Constants/AuthConstants.cs @@ -6,7 +6,7 @@ public static class AuthConstants public const string UserSessionHeaderName = "OpenShockSession"; public const string ApiTokenHeaderName = "OpenShockToken"; public const string HubTokenHeaderName = "DeviceToken"; - public const string BypassTokenHeaderName = "X-OpenShock-Bypass-Token"; + public const string AutomationTokenHeaderName = "X-OpenShock-Automation-Token"; public const int GeneratedTokenLength = 32; public const int ApiTokenLength = 64; diff --git a/Common/Extensions/HttpContextExtensions.cs b/Common/Extensions/HttpContextExtensions.cs index 56b1dbf5..77e209ba 100644 --- a/Common/Extensions/HttpContextExtensions.cs +++ b/Common/Extensions/HttpContextExtensions.cs @@ -2,17 +2,17 @@ using Microsoft.AspNetCore.Http; using OpenShock.Common.OpenShockDb; using OpenShock.Common.Constants; -using OpenShock.Common.Services.Bypass; +using OpenShock.Common.Services.AutomationTokens; namespace OpenShock.Common.Extensions; public static class HttpContextExtensions { - public static bool TryGetBypassTokenFromHeader(this HttpContext context, [NotNullWhen(true)] out string? token) + public static bool TryGetAutomationTokenFromHeader(this HttpContext context, [NotNullWhen(true)] out string? token) { ArgumentNullException.ThrowIfNull(context); - if (context.Request.Headers.TryGetValue(AuthConstants.BypassTokenHeaderName, out var value) && !string.IsNullOrEmpty(value)) + if (context.Request.Headers.TryGetValue(AuthConstants.AutomationTokenHeaderName, out var value) && !string.IsNullOrEmpty(value)) { token = value!; return true; @@ -23,29 +23,29 @@ public static bool TryGetBypassTokenFromHeader(this HttpContext context, [NotNul } /// - /// Stores the result of resolving the bypass-token header. Called by the bypass middleware. + /// Stores the result of resolving the automation-token header. Called by the automation-token middleware. /// - public static void SetResolvedBypassToken(this HttpContext context, ResolvedBypassToken resolved) + public static void SetResolvedAutomationToken(this HttpContext context, ResolvedAutomationToken resolved) { ArgumentNullException.ThrowIfNull(context); context.SetItemByType(resolved); } /// - /// Returns the bypass token resolved earlier in the pipeline, or null if no header was + /// Returns the automation token resolved earlier in the pipeline, or null if no header was /// present (or it did not resolve to a known token). /// - public static ResolvedBypassToken? GetResolvedBypassToken(this HttpContext context) + public static ResolvedAutomationToken? GetResolvedAutomationToken(this HttpContext context) { ArgumentNullException.ThrowIfNull(context); - return context.GetItemByType(); + return context.GetItemByType(); } /// - /// Returns true if the request presented a bypass token that grants . + /// Returns true if the request presented an automation token that grants . /// - public static bool IsBypassed(this HttpContext context, BypassTokenType type) => - context.GetResolvedBypassToken()?.Types.Contains(type) ?? false; + public static bool IsBypassed(this HttpContext context, AutomationTokenType type) => + context.GetResolvedAutomationToken()?.Types.Contains(type) ?? false; // FullName is only null for open generic parameters, which a closed T never is. private static string ItemKeyOf() => typeof(T).FullName!; diff --git a/Common/Middleware/BypassTokenMiddleware.cs b/Common/Middleware/AutomationTokenMiddleware.cs similarity index 51% rename from Common/Middleware/BypassTokenMiddleware.cs rename to Common/Middleware/AutomationTokenMiddleware.cs index 160bec9b..2e170499 100644 --- a/Common/Middleware/BypassTokenMiddleware.cs +++ b/Common/Middleware/AutomationTokenMiddleware.cs @@ -1,53 +1,69 @@ +using System.Security.Claims; using OpenShock.Common.Extensions; -using OpenShock.Common.Services.Bypass; +using OpenShock.Common.Services.AutomationTokens; using Microsoft.Extensions.Logging; namespace OpenShock.Common.Middleware; /// -/// Resolves the X-OpenShock-Bypass-Token header (if present) into a +/// Resolves the X-OpenShock-Automation-Token header (if present) into a /// stored on . Downstream guards (rate limiter partition selectors, /// the turnstile service, controllers needing post-auth user linkage) read the cached value /// synchronously and filter on the type they care about. /// /// Runs before UseRateLimiter so the rate limiter can honor the bypass for the very same request. /// -public sealed class BypassTokenMiddleware +public sealed class AutomationTokenMiddleware { private readonly RequestDelegate _next; - public BypassTokenMiddleware(RequestDelegate next) + public AutomationTokenMiddleware(RequestDelegate next) { _next = next; } - public async Task InvokeAsync(HttpContext context, IBypassTokenService bypassTokens, ILogger logger) + public async Task InvokeAsync(HttpContext context, IAutomationTokenService automationTokens, ILogger logger) { - if (!context.TryGetBypassTokenFromHeader(out var secret)) + if (!context.TryGetAutomationTokenFromHeader(out var secret)) { await _next(context); return; } - var resolved = await bypassTokens.ResolveAsync(secret, context.RequestAborted); - if (resolved is not null) - { - context.SetResolvedBypassToken(resolved); - - // A credential that switches off Turnstile and rate limiting should never be used without - // leaving a trace. Logged at warning so it stands out in a production log, and the secret - // itself is never written, only which token was used, what it disabled, and for what. - logger.LogWarning( - "Bypass token {BypassTokenName} ({BypassTokenId}) accepted for {Types} on {Method} {Path} from {RemoteIp}", - ForLog(resolved.Name), resolved.Id, string.Join(",", resolved.Types), ForLog(context.Request.Method), - ForLog(context.Request.Path.Value), context.Connection.RemoteIpAddress); - } - else + var resolved = await automationTokens.ResolveAsync(secret, context.RequestAborted); + if (resolved is null) { // A presented-but-unmatched token is either a stale or rotated secret, or someone probing for one. LogUnmatched(logger); + await _next(context); + return; } + // Authentication already ran, so a signed-in caller is known here. An automation token never applies + // to a privileged account, so it is dropped for one, rate limits and Turnstile included. + // Flows that pick the account from the request body (login, password reset) check the same rule. + if (context.User.FindFirstValue(ClaimTypes.NameIdentifier) is { } userIdClaim && + Guid.TryParse(userIdClaim, out var userId) && + !await automationTokens.IsAllowedForUserAsync(userId, context.RequestAborted)) + { + logger.LogWarning( + "Automation token {AutomationTokenName} ({AutomationTokenId}) refused for privileged user {UserId}", + ForLog(resolved.Name), resolved.Id, userId); + await _next(context); + return; + } + + context.SetResolvedAutomationToken(resolved); + automationTokens.RecordRequest(resolved.Id); + + // A credential that switches off Turnstile and rate limiting should never be used without + // leaving a trace. Logged at warning so it stands out in a production log, and the secret + // itself is never written, only which token was used, what it disabled, and for what. + logger.LogWarning( + "Automation token {AutomationTokenName} ({AutomationTokenId}) accepted for {Types} on {Method} {Path} from {RemoteIp}", + ForLog(resolved.Name), resolved.Id, string.Join(",", resolved.Types), ForLog(context.Request.Method), + ForLog(context.Request.Path.Value), context.Connection.RemoteIpAddress); + await _next(context); } @@ -74,7 +90,7 @@ private static void LogUnmatched(ILogger logger) // Deliberately free of request data: the path, method and header are all caller-controlled, // and this line exists to say that probing is happening, not to echo the probe back. logger.LogWarning( - "Bypass token presented but matched nothing ({Attempts} attempt(s) in the last {Interval})", + "Automation token presented but matched nothing ({Attempts} attempt(s) in the last {Interval})", attempts, UnmatchedWarningInterval); } diff --git a/Common/Migrations/20260930202003_AddBypassTokens.Designer.cs b/Common/Migrations/20260930204951_AddAutomationTokens.Designer.cs similarity index 95% rename from Common/Migrations/20260930202003_AddBypassTokens.Designer.cs rename to Common/Migrations/20260930204951_AddAutomationTokens.Designer.cs index d4744895..7c4458a6 100644 --- a/Common/Migrations/20260930202003_AddBypassTokens.Designer.cs +++ b/Common/Migrations/20260930204951_AddAutomationTokens.Designer.cs @@ -14,8 +14,8 @@ namespace OpenShock.Common.Migrations; [DbContext(typeof(MigrationOpenShockContext))] -[Migration("20260930202003_AddBypassTokens")] -partial class _20260930202003_AddBypassTokens +[Migration("20260930204951_AddAutomationTokens")] +partial class _20260930204951_AddAutomationTokens { /// protected override void BuildTargetModel(ModelBuilder modelBuilder) @@ -26,8 +26,8 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasAnnotation("ProductVersion", "11.0.0-rc.1.26425.128") .HasAnnotation("Relational:MaxIdentifierLength", 63); - NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "audit_action", new[] { "login", "logout", "password_changed", "email_change_requested", "email_changed", "username_changed", "api_token_created", "api_token_deleted", "oauth_connected", "oauth_disconnected", "account_deactivated", "account_reactivated", "account_deleted" }); - NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "bypass_token_type", new[] { "turnstile", "rate_limit" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "audit_action", new[] { "login", "logout", "password_changed", "email_change_requested", "email_changed", "username_changed", "api_token_created", "api_token_deleted", "oauth_connected", "oauth_disconnected", "account_deactivated", "account_reactivated", "account_deleted", "automation_token_created", "automation_token_updated", "automation_token_rotated", "automation_token_deleted", "automation_token_used" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "automation_token_type", new[] { "turnstile", "rate_limit" }); NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "configuration_value_type", new[] { "string", "bool", "int", "float", "json" }); NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "control_limit_mode", new[] { "clamp", "lerp" }); NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "control_type", new[] { "stop", "shock", "vibrate", "sound" }); @@ -74,6 +74,10 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasColumnType("timestamp with time zone") .HasColumnName("created_at"); + b.Property("CreatedByAutomationTokenId") + .HasColumnType("uuid") + .HasColumnName("created_by_automation_token_id"); + b.Property("DeactivatedAt") .HasColumnType("timestamp with time zone") .HasColumnName("deactivated_at"); @@ -285,7 +289,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.ToTable("api_token_reports"); }); - modelBuilder.Entity("OpenShock.Common.OpenShockDb.BypassToken", b => + modelBuilder.Entity("OpenShock.Common.OpenShockDb.AutomationToken", b => { b.Property("Id") .HasColumnType("uuid") @@ -315,10 +319,6 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasColumnType("timestamp with time zone") .HasColumnName("last_used_at"); - b.Property("LastUsedByUserId") - .HasColumnType("uuid") - .HasColumnName("last_used_by_user_id"); - b.Property("Name") .IsRequired() .HasMaxLength(64) @@ -332,9 +332,9 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasColumnName("token_hash") .UseCollation("C"); - b.Property>("Types") + b.Property>("Types") .IsRequired() - .HasColumnType("bypass_token_type[]") + .HasColumnType("automation_token_type[]") .HasColumnName("types"); b.Property("UseCount") @@ -344,52 +344,12 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasColumnName("use_count"); b.HasKey("Id") - .HasName("bypass_tokens_pkey"); - - b.HasIndex("LastUsedByUserId"); + .HasName("automation_tokens_pkey"); b.HasIndex("TokenHash") .IsUnique(); - b.ToTable("bypass_tokens"); - }); - - modelBuilder.Entity("OpenShock.Common.OpenShockDb.BypassTokenUserUse", b => - { - b.Property("BypassTokenId") - .HasColumnType("uuid") - .HasColumnName("bypass_token_id"); - - b.Property("UserId") - .HasColumnType("uuid") - .HasColumnName("user_id"); - - b.Property("FirstUsedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("first_used_at") - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - b.Property("LastUsedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("last_used_at") - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - b.Property("UseCount") - .ValueGeneratedOnAdd() - .HasColumnType("bigint") - .HasDefaultValue(0L) - .HasColumnName("use_count"); - - b.HasKey("BypassTokenId", "UserId") - .HasName("bypass_token_user_uses_pkey"); - - b.HasIndex("LastUsedAt"); - - b.HasIndex("UserId"); - - b.ToTable("bypass_token_user_uses"); + b.ToTable("automation_tokens"); }); modelBuilder.Entity("OpenShock.Common.OpenShockDb.ConfigurationItem", b => @@ -917,6 +877,10 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasColumnName("created_at") .HasDefaultValueSql("CURRENT_TIMESTAMP"); + b.Property("CreatedByAutomationTokenId") + .HasColumnType("uuid") + .HasColumnName("created_by_automation_token_id"); + b.Property("Email") .IsRequired() .HasMaxLength(320) @@ -950,6 +914,8 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.HasKey("Id") .HasName("users_pkey"); + b.HasIndex("CreatedByAutomationTokenId"); + b.HasIndex("Email") .IsUnique(); @@ -1452,38 +1418,6 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Navigation("ReportedByUser"); }); - modelBuilder.Entity("OpenShock.Common.OpenShockDb.BypassToken", b => - { - b.HasOne("OpenShock.Common.OpenShockDb.User", "LastUsedByUser") - .WithMany() - .HasForeignKey("LastUsedByUserId") - .OnDelete(DeleteBehavior.SetNull) - .HasConstraintName("fk_bypass_tokens_last_used_by_user_id"); - - b.Navigation("LastUsedByUser"); - }); - - modelBuilder.Entity("OpenShock.Common.OpenShockDb.BypassTokenUserUse", b => - { - b.HasOne("OpenShock.Common.OpenShockDb.BypassToken", "BypassToken") - .WithMany("UserUses") - .HasForeignKey("BypassTokenId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired() - .HasConstraintName("fk_bypass_token_user_uses_bypass_token_id"); - - b.HasOne("OpenShock.Common.OpenShockDb.User", "User") - .WithMany("BypassTokenUses") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired() - .HasConstraintName("fk_bypass_token_user_uses_user_id"); - - b.Navigation("BypassToken"); - - b.Navigation("User"); - }); - modelBuilder.Entity("OpenShock.Common.OpenShockDb.Device", b => { b.HasOne("OpenShock.Common.OpenShockDb.User", "Owner") @@ -1585,6 +1519,17 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Navigation("Shocker"); }); + modelBuilder.Entity("OpenShock.Common.OpenShockDb.User", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.AutomationToken", "CreatedByAutomationToken") + .WithMany("CreatedUsers") + .HasForeignKey("CreatedByAutomationTokenId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_users_created_by_automation_token_id"); + + b.Navigation("CreatedByAutomationToken"); + }); + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserActivationRequest", b => { b.HasOne("OpenShock.Common.OpenShockDb.User", "User") @@ -1748,9 +1693,9 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Navigation("Shocker"); }); - modelBuilder.Entity("OpenShock.Common.OpenShockDb.BypassToken", b => + modelBuilder.Entity("OpenShock.Common.OpenShockDb.AutomationToken", b => { - b.Navigation("UserUses"); + b.Navigation("CreatedUsers"); }); modelBuilder.Entity("OpenShock.Common.OpenShockDb.Device", b => @@ -1786,8 +1731,6 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Navigation("AuditLogs"); - b.Navigation("BypassTokenUses"); - b.Navigation("Devices"); b.Navigation("EmailChanges"); diff --git a/Common/Migrations/20260930202003_AddBypassTokens.cs b/Common/Migrations/20260930204951_AddAutomationTokens.cs similarity index 61% rename from Common/Migrations/20260930202003_AddBypassTokens.cs rename to Common/Migrations/20260930204951_AddAutomationTokens.cs index e122aebc..dac188de 100644 --- a/Common/Migrations/20260930202003_AddBypassTokens.cs +++ b/Common/Migrations/20260930204951_AddAutomationTokens.cs @@ -8,15 +8,71 @@ namespace OpenShock.Common.Migrations; /// -public partial class _20260930202003_AddBypassTokens : Migration +public partial class _20260930204951_AddAutomationTokens : Migration { + // Same as AddOAuthSupport's view, plus users.created_by_automation_token_id + public const string Query_Create_AdminUsersView = + """ + CREATE VIEW admin_users_view AS + SELECT + u.id, + u.name, + u.email, + (CASE + WHEN u.password_hash IS NULL THEN NULL + ELSE SPLIT_PART(u.password_hash, ':', 1) + END) AS password_hash_type, + u.roles, + u.created_at, + u.activated_at, + u.created_by_automation_token_id, + deact.created_at AS deactivated_at, + deact.deactivated_by_user_id, + (SELECT COUNT(*) FROM api_tokens token WHERE token.user_id = u.id) AS api_token_count, + (SELECT COUNT(*) FROM user_password_resets reset WHERE reset.user_id = u.id) AS password_reset_count, + ( + SELECT COUNT(*) FROM devices device + INNER JOIN shockers shocker ON shocker.device_id = device.id + INNER JOIN user_shares share ON share.shocker_id = shocker.id + WHERE device.owner_id = u.id + ) AS shocker_user_share_count, + (SELECT COUNT(*) FROM public_shares share WHERE share.owner_id = u.id) AS shocker_public_share_count, + (SELECT COUNT(*) FROM user_email_changes entry WHERE entry.user_id = u.id) AS email_change_request_count, + (SELECT COUNT(*) FROM user_name_changes entry WHERE entry.user_id = u.id) AS name_change_request_count, + (SELECT COUNT(*) FROM devices device WHERE device.owner_id = u.id) AS device_count, + ( + SELECT COUNT(*) FROM devices device + INNER JOIN shockers shocker ON shocker.device_id = device.id + WHERE device.owner_id = u.id + ) AS shocker_count, + ( + SELECT COUNT(*) FROM devices device + INNER JOIN shockers shocker ON shocker.device_id = device.id + INNER JOIN shocker_control_logs log ON log.shocker_id = shocker.id + WHERE device.owner_id = u.id + ) AS shocker_control_log_count + FROM + users u + LEFT JOIN LATERAL ( + SELECT + d.created_at, + d.deactivated_by_user_id + FROM user_deactivations d + WHERE d.deactivated_user_id = u.id + ORDER BY d.created_at DESC + LIMIT 1 + ) AS deact ON TRUE; + """; + /// protected override void Up(MigrationBuilder migrationBuilder) { + migrationBuilder.Sql(AddAdminUsersView.Query_Drop_AdminUsersView); + migrationBuilder.AlterDatabase() .Annotation("Npgsql:CollationDefinition:public.ndcoll", "und-u-ks-level2,und-u-ks-level2,icu,False") - .Annotation("Npgsql:Enum:audit_action", "login,logout,password_changed,email_change_requested,email_changed,username_changed,api_token_created,api_token_deleted,oauth_connected,oauth_disconnected,account_deactivated,account_reactivated,account_deleted") - .Annotation("Npgsql:Enum:bypass_token_type", "turnstile,rate_limit") + .Annotation("Npgsql:Enum:audit_action", "login,logout,password_changed,email_change_requested,email_changed,username_changed,api_token_created,api_token_deleted,oauth_connected,oauth_disconnected,account_deactivated,account_reactivated,account_deleted,automation_token_created,automation_token_updated,automation_token_rotated,automation_token_deleted,automation_token_used") + .Annotation("Npgsql:Enum:automation_token_type", "turnstile,rate_limit") .Annotation("Npgsql:Enum:configuration_value_type", "string,bool,int,float,json") .Annotation("Npgsql:Enum:control_limit_mode", "clamp,lerp") .Annotation("Npgsql:Enum:control_type", "stop,shock,vibrate,sound") @@ -42,17 +98,22 @@ protected override void Up(MigrationBuilder migrationBuilder) .OldAnnotation("Npgsql:Enum:role_type", "support,staff,admin,system") .OldAnnotation("Npgsql:Enum:shocker_model_type", "cai_xianlin,petrainer,petrainer_998dr,wellturn_t330"); + migrationBuilder.AddColumn( + name: "created_by_automation_token_id", + table: "users", + type: "uuid", + nullable: true); + migrationBuilder.CreateTable( - name: "bypass_tokens", + name: "automation_tokens", columns: table => new { id = table.Column(type: "uuid", nullable: false), name = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), token_hash = table.Column(type: "character varying(64)", maxLength: 64, nullable: false, collation: "C"), - types = table.Column>(type: "bypass_token_type[]", nullable: false), + types = table.Column>(type: "automation_token_type[]", nullable: false), created_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"), last_used_at = table.Column(type: "timestamp with time zone", nullable: true), - last_used_by_user_id = table.Column(type: "uuid", nullable: true), last_rotated_at = table.Column(type: "timestamp with time zone", nullable: true), use_count = table.Column(type: "bigint", nullable: false, defaultValue: 0L), auto_cleanup_users = table.Column(type: "boolean", nullable: false, defaultValue: false), @@ -60,72 +121,50 @@ protected override void Up(MigrationBuilder migrationBuilder) }, constraints: table => { - table.PrimaryKey("bypass_tokens_pkey", x => x.id); - table.ForeignKey( - name: "fk_bypass_tokens_last_used_by_user_id", - column: x => x.last_used_by_user_id, - principalTable: "users", - principalColumn: "id", - onDelete: ReferentialAction.SetNull); - }); - - migrationBuilder.CreateTable( - name: "bypass_token_user_uses", - columns: table => new - { - bypass_token_id = table.Column(type: "uuid", nullable: false), - user_id = table.Column(type: "uuid", nullable: false), - first_used_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"), - last_used_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"), - use_count = table.Column(type: "bigint", nullable: false, defaultValue: 0L) - }, - constraints: table => - { - table.PrimaryKey("bypass_token_user_uses_pkey", x => new { x.bypass_token_id, x.user_id }); - table.ForeignKey( - name: "fk_bypass_token_user_uses_bypass_token_id", - column: x => x.bypass_token_id, - principalTable: "bypass_tokens", - principalColumn: "id", - onDelete: ReferentialAction.Cascade); - table.ForeignKey( - name: "fk_bypass_token_user_uses_user_id", - column: x => x.user_id, - principalTable: "users", - principalColumn: "id", - onDelete: ReferentialAction.Cascade); + table.PrimaryKey("automation_tokens_pkey", x => x.id); }); migrationBuilder.CreateIndex( - name: "IX_bypass_token_user_uses_last_used_at", - table: "bypass_token_user_uses", - column: "last_used_at"); + name: "IX_users_created_by_automation_token_id", + table: "users", + column: "created_by_automation_token_id"); migrationBuilder.CreateIndex( - name: "IX_bypass_token_user_uses_user_id", - table: "bypass_token_user_uses", - column: "user_id"); - - migrationBuilder.CreateIndex( - name: "IX_bypass_tokens_last_used_by_user_id", - table: "bypass_tokens", - column: "last_used_by_user_id"); - - migrationBuilder.CreateIndex( - name: "IX_bypass_tokens_token_hash", - table: "bypass_tokens", + name: "IX_automation_tokens_token_hash", + table: "automation_tokens", column: "token_hash", unique: true); + + migrationBuilder.AddForeignKey( + name: "fk_users_created_by_automation_token_id", + table: "users", + column: "created_by_automation_token_id", + principalTable: "automation_tokens", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + + migrationBuilder.Sql(Query_Create_AdminUsersView); } /// protected override void Down(MigrationBuilder migrationBuilder) { - migrationBuilder.DropTable( - name: "bypass_token_user_uses"); + migrationBuilder.Sql(AddAdminUsersView.Query_Drop_AdminUsersView); + + migrationBuilder.DropForeignKey( + name: "fk_users_created_by_automation_token_id", + table: "users"); migrationBuilder.DropTable( - name: "bypass_tokens"); + name: "automation_tokens"); + + migrationBuilder.DropIndex( + name: "IX_users_created_by_automation_token_id", + table: "users"); + + migrationBuilder.DropColumn( + name: "created_by_automation_token_id", + table: "users"); migrationBuilder.AlterDatabase() .Annotation("Npgsql:CollationDefinition:public.ndcoll", "und-u-ks-level2,und-u-ks-level2,icu,False") @@ -142,8 +181,8 @@ protected override void Down(MigrationBuilder migrationBuilder) .Annotation("Npgsql:Enum:role_type", "support,staff,admin,system") .Annotation("Npgsql:Enum:shocker_model_type", "cai_xianlin,petrainer,petrainer_998dr,wellturn_t330") .OldAnnotation("Npgsql:CollationDefinition:public.ndcoll", "und-u-ks-level2,und-u-ks-level2,icu,False") - .OldAnnotation("Npgsql:Enum:audit_action", "login,logout,password_changed,email_change_requested,email_changed,username_changed,api_token_created,api_token_deleted,oauth_connected,oauth_disconnected,account_deactivated,account_reactivated,account_deleted") - .OldAnnotation("Npgsql:Enum:bypass_token_type", "turnstile,rate_limit") + .OldAnnotation("Npgsql:Enum:audit_action", "login,logout,password_changed,email_change_requested,email_changed,username_changed,api_token_created,api_token_deleted,oauth_connected,oauth_disconnected,account_deactivated,account_reactivated,account_deleted,automation_token_created,automation_token_updated,automation_token_rotated,automation_token_deleted,automation_token_used") + .OldAnnotation("Npgsql:Enum:automation_token_type", "turnstile,rate_limit") .OldAnnotation("Npgsql:Enum:configuration_value_type", "string,bool,int,float,json") .OldAnnotation("Npgsql:Enum:control_limit_mode", "clamp,lerp") .OldAnnotation("Npgsql:Enum:control_type", "stop,shock,vibrate,sound") @@ -155,5 +194,7 @@ protected override void Down(MigrationBuilder migrationBuilder) .OldAnnotation("Npgsql:Enum:permission_type", "shockers.use,shockers.edit,shockers.pause,devices.edit,devices.auth,usershares.edit,usershares.pause,publicshares.pause,publicshares.edit") .OldAnnotation("Npgsql:Enum:role_type", "support,staff,admin,system") .OldAnnotation("Npgsql:Enum:shocker_model_type", "cai_xianlin,petrainer,petrainer_998dr,wellturn_t330"); + + migrationBuilder.Sql(AddOAuthSupport.Query_Create_AdminUsersView); } } diff --git a/Common/Migrations/OpenShockContextModelSnapshot.cs b/Common/Migrations/OpenShockContextModelSnapshot.cs index e25764f6..ced7e1c7 100644 --- a/Common/Migrations/OpenShockContextModelSnapshot.cs +++ b/Common/Migrations/OpenShockContextModelSnapshot.cs @@ -18,7 +18,7 @@ partial class OpenShockContextModelSnapshot : ModelSnapshot // If you encounter a merge conflict in the line below, it means you need to // discard one of the migration branches and recreate its migrations on top of // the other branch. See https://aka.ms/efcore-docs-migrations-conflicts for more info. - public override string LastMigrationId => "20260930202003_AddBypassTokens"; + public override string LastMigrationId => "20260930204951_AddAutomationTokens"; protected override void BuildModel(ModelBuilder modelBuilder) { @@ -28,8 +28,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasAnnotation("ProductVersion", "11.0.0-rc.1.26425.128") .HasAnnotation("Relational:MaxIdentifierLength", 63); - NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "audit_action", new[] { "login", "logout", "password_changed", "email_change_requested", "email_changed", "username_changed", "api_token_created", "api_token_deleted", "oauth_connected", "oauth_disconnected", "account_deactivated", "account_reactivated", "account_deleted" }); - NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "bypass_token_type", new[] { "turnstile", "rate_limit" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "audit_action", new[] { "login", "logout", "password_changed", "email_change_requested", "email_changed", "username_changed", "api_token_created", "api_token_deleted", "oauth_connected", "oauth_disconnected", "account_deactivated", "account_reactivated", "account_deleted", "automation_token_created", "automation_token_updated", "automation_token_rotated", "automation_token_deleted", "automation_token_used" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "automation_token_type", new[] { "turnstile", "rate_limit" }); NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "configuration_value_type", new[] { "string", "bool", "int", "float", "json" }); NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "control_limit_mode", new[] { "clamp", "lerp" }); NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "control_type", new[] { "stop", "shock", "vibrate", "sound" }); @@ -76,6 +76,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("timestamp with time zone") .HasColumnName("created_at"); + b.Property("CreatedByAutomationTokenId") + .HasColumnType("uuid") + .HasColumnName("created_by_automation_token_id"); + b.Property("DeactivatedAt") .HasColumnType("timestamp with time zone") .HasColumnName("deactivated_at"); @@ -287,7 +291,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("api_token_reports"); }); - modelBuilder.Entity("OpenShock.Common.OpenShockDb.BypassToken", b => + modelBuilder.Entity("OpenShock.Common.OpenShockDb.AutomationToken", b => { b.Property("Id") .HasColumnType("uuid") @@ -317,10 +321,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("timestamp with time zone") .HasColumnName("last_used_at"); - b.Property("LastUsedByUserId") - .HasColumnType("uuid") - .HasColumnName("last_used_by_user_id"); - b.Property("Name") .IsRequired() .HasMaxLength(64) @@ -334,9 +334,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnName("token_hash") .UseCollation("C"); - b.Property>("Types") + b.Property>("Types") .IsRequired() - .HasColumnType("bypass_token_type[]") + .HasColumnType("automation_token_type[]") .HasColumnName("types"); b.Property("UseCount") @@ -346,52 +346,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnName("use_count"); b.HasKey("Id") - .HasName("bypass_tokens_pkey"); - - b.HasIndex("LastUsedByUserId"); + .HasName("automation_tokens_pkey"); b.HasIndex("TokenHash") .IsUnique(); - b.ToTable("bypass_tokens"); - }); - - modelBuilder.Entity("OpenShock.Common.OpenShockDb.BypassTokenUserUse", b => - { - b.Property("BypassTokenId") - .HasColumnType("uuid") - .HasColumnName("bypass_token_id"); - - b.Property("UserId") - .HasColumnType("uuid") - .HasColumnName("user_id"); - - b.Property("FirstUsedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("first_used_at") - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - b.Property("LastUsedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("last_used_at") - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - b.Property("UseCount") - .ValueGeneratedOnAdd() - .HasColumnType("bigint") - .HasDefaultValue(0L) - .HasColumnName("use_count"); - - b.HasKey("BypassTokenId", "UserId") - .HasName("bypass_token_user_uses_pkey"); - - b.HasIndex("LastUsedAt"); - - b.HasIndex("UserId"); - - b.ToTable("bypass_token_user_uses"); + b.ToTable("automation_tokens"); }); modelBuilder.Entity("OpenShock.Common.OpenShockDb.ConfigurationItem", b => @@ -919,6 +879,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnName("created_at") .HasDefaultValueSql("CURRENT_TIMESTAMP"); + b.Property("CreatedByAutomationTokenId") + .HasColumnType("uuid") + .HasColumnName("created_by_automation_token_id"); + b.Property("Email") .IsRequired() .HasMaxLength(320) @@ -952,6 +916,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id") .HasName("users_pkey"); + b.HasIndex("CreatedByAutomationTokenId"); + b.HasIndex("Email") .IsUnique(); @@ -1454,38 +1420,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("ReportedByUser"); }); - modelBuilder.Entity("OpenShock.Common.OpenShockDb.BypassToken", b => - { - b.HasOne("OpenShock.Common.OpenShockDb.User", "LastUsedByUser") - .WithMany() - .HasForeignKey("LastUsedByUserId") - .OnDelete(DeleteBehavior.SetNull) - .HasConstraintName("fk_bypass_tokens_last_used_by_user_id"); - - b.Navigation("LastUsedByUser"); - }); - - modelBuilder.Entity("OpenShock.Common.OpenShockDb.BypassTokenUserUse", b => - { - b.HasOne("OpenShock.Common.OpenShockDb.BypassToken", "BypassToken") - .WithMany("UserUses") - .HasForeignKey("BypassTokenId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired() - .HasConstraintName("fk_bypass_token_user_uses_bypass_token_id"); - - b.HasOne("OpenShock.Common.OpenShockDb.User", "User") - .WithMany("BypassTokenUses") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired() - .HasConstraintName("fk_bypass_token_user_uses_user_id"); - - b.Navigation("BypassToken"); - - b.Navigation("User"); - }); - modelBuilder.Entity("OpenShock.Common.OpenShockDb.Device", b => { b.HasOne("OpenShock.Common.OpenShockDb.User", "Owner") @@ -1587,6 +1521,17 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Shocker"); }); + modelBuilder.Entity("OpenShock.Common.OpenShockDb.User", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.AutomationToken", "CreatedByAutomationToken") + .WithMany("CreatedUsers") + .HasForeignKey("CreatedByAutomationTokenId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_users_created_by_automation_token_id"); + + b.Navigation("CreatedByAutomationToken"); + }); + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserActivationRequest", b => { b.HasOne("OpenShock.Common.OpenShockDb.User", "User") @@ -1750,9 +1695,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Shocker"); }); - modelBuilder.Entity("OpenShock.Common.OpenShockDb.BypassToken", b => + modelBuilder.Entity("OpenShock.Common.OpenShockDb.AutomationToken", b => { - b.Navigation("UserUses"); + b.Navigation("CreatedUsers"); }); modelBuilder.Entity("OpenShock.Common.OpenShockDb.Device", b => @@ -1788,8 +1733,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("AuditLogs"); - b.Navigation("BypassTokenUses"); - b.Navigation("Devices"); b.Navigation("EmailChanges"); diff --git a/Common/Models/AuditMetadata.cs b/Common/Models/AuditMetadata.cs index 1aa651f5..7e3930d2 100644 --- a/Common/Models/AuditMetadata.cs +++ b/Common/Models/AuditMetadata.cs @@ -12,6 +12,11 @@ namespace OpenShock.Common.Models; [JsonDerivedType(typeof(OAuthConnectedMetadata), "oauthConnected")] [JsonDerivedType(typeof(OAuthDisconnectedMetadata), "oauthDisconnected")] [JsonDerivedType(typeof(AccountDeactivatedMetadata), "accountDeactivated")] +[JsonDerivedType(typeof(AutomationTokenCreatedMetadata), "automationTokenCreated")] +[JsonDerivedType(typeof(AutomationTokenUpdatedMetadata), "automationTokenUpdated")] +[JsonDerivedType(typeof(AutomationTokenRotatedMetadata), "automationTokenRotated")] +[JsonDerivedType(typeof(AutomationTokenDeletedMetadata), "automationTokenDeleted")] +[JsonDerivedType(typeof(AutomationTokenUsedMetadata), "automationTokenUsed")] public abstract record AuditMetadata; public sealed record LoginMetadata(Guid SessionId) : AuditMetadata; @@ -31,3 +36,16 @@ public sealed record OAuthConnectedMetadata(string Provider) : AuditMetadata; public sealed record OAuthDisconnectedMetadata(string Provider) : AuditMetadata; public sealed record AccountDeactivatedMetadata(bool DeleteLater) : AuditMetadata; + +public sealed record AutomationTokenCreatedMetadata(Guid TokenId, string Name, IReadOnlyList Types, bool AutoCleanupUsers, TimeSpan? AutoCleanupAfter) : AuditMetadata; + +/// The token's settings after the update. +public sealed record AutomationTokenUpdatedMetadata(Guid TokenId, string Name, IReadOnlyList Types, bool AutoCleanupUsers, TimeSpan? AutoCleanupAfter) : AuditMetadata; + +public sealed record AutomationTokenRotatedMetadata(Guid TokenId, string Name) : AuditMetadata; + +/// The token was deleted together with the accounts it created. +public sealed record AutomationTokenDeletedMetadata(Guid TokenId, string Name, int DeletedAccountCount) : AuditMetadata; + +/// An automation token was used on this account. is a AutomationTokenFlow name. +public sealed record AutomationTokenUsedMetadata(Guid TokenId, string Name, string Flow) : AuditMetadata; diff --git a/Common/OpenShockDb/Enums/AuditAction.cs b/Common/OpenShockDb/Enums/AuditAction.cs index 157ec5e0..ae4a67d8 100644 --- a/Common/OpenShockDb/Enums/AuditAction.cs +++ b/Common/OpenShockDb/Enums/AuditAction.cs @@ -19,4 +19,9 @@ public enum AuditAction [PgName("account_deactivated")] AccountDeactivated, [PgName("account_reactivated")] AccountReactivated, [PgName("account_deleted")] AccountDeleted, + [PgName("automation_token_created")] AutomationTokenCreated, + [PgName("automation_token_updated")] AutomationTokenUpdated, + [PgName("automation_token_rotated")] AutomationTokenRotated, + [PgName("automation_token_deleted")] AutomationTokenDeleted, + [PgName("automation_token_used")] AutomationTokenUsed, } diff --git a/Common/OpenShockDb/Enums/AutomationTokenFlow.cs b/Common/OpenShockDb/Enums/AutomationTokenFlow.cs new file mode 100644 index 00000000..d2a37805 --- /dev/null +++ b/Common/OpenShockDb/Enums/AutomationTokenFlow.cs @@ -0,0 +1,12 @@ +namespace OpenShock.Common.OpenShockDb; + +/// +/// The account flow an automation token was used in, recorded on entries. +/// +public enum AutomationTokenFlow +{ + Signup, + Login, + PasswordReset, + ReportTokens, +} diff --git a/Common/OpenShockDb/Enums/BypassTokenType.cs b/Common/OpenShockDb/Enums/AutomationTokenType.cs similarity index 59% rename from Common/OpenShockDb/Enums/BypassTokenType.cs rename to Common/OpenShockDb/Enums/AutomationTokenType.cs index 5619739e..61cdd96f 100644 --- a/Common/OpenShockDb/Enums/BypassTokenType.cs +++ b/Common/OpenShockDb/Enums/AutomationTokenType.cs @@ -4,10 +4,10 @@ namespace OpenShock.Common.OpenShockDb; /// -/// A protection that a bypass token is allowed to switch off. +/// A protection that an automation token is allowed to switch off. /// -[PgEnum(Name = "bypass_token_type")] -public enum BypassTokenType +[PgEnum(Name = "automation_token_type")] +public enum AutomationTokenType { [PgName("turnstile")] Turnstile = 0, [PgName("rate_limit")] RateLimit = 1, diff --git a/Common/OpenShockDb/Extensions/NpgsqlEnumExtensions.cs b/Common/OpenShockDb/Extensions/NpgsqlEnumExtensions.cs index 6cd7f49c..4bea31ed 100644 --- a/Common/OpenShockDb/Extensions/NpgsqlEnumExtensions.cs +++ b/Common/OpenShockDb/Extensions/NpgsqlEnumExtensions.cs @@ -46,7 +46,7 @@ private static PgEnumInfo BuildInfo() where TEnum : struct, Enum BuildInfo(), BuildInfo(), BuildInfo(), - BuildInfo(), + BuildInfo(), ]; public static NpgsqlDbContextOptionsBuilder MapPgEnums(this NpgsqlDbContextOptionsBuilder builder) diff --git a/Common/OpenShockDb/Models/AdminUsersView.cs b/Common/OpenShockDb/Models/AdminUsersView.cs index 0d7f64e8..ac6dbaad 100644 --- a/Common/OpenShockDb/Models/AdminUsersView.cs +++ b/Common/OpenShockDb/Models/AdminUsersView.cs @@ -19,6 +19,8 @@ public sealed class AdminUsersView public required DateTime? ActivatedAt { get; set; } + public required Guid? CreatedByAutomationTokenId { get; set; } + public required DateTime? DeactivatedAt { get; set; } public required Guid? DeactivatedByUserId { get; set; } diff --git a/Common/OpenShockDb/Models/BypassToken.cs b/Common/OpenShockDb/Models/AutomationToken.cs similarity index 61% rename from Common/OpenShockDb/Models/BypassToken.cs rename to Common/OpenShockDb/Models/AutomationToken.cs index f69405c1..d1ebd54e 100644 --- a/Common/OpenShockDb/Models/BypassToken.cs +++ b/Common/OpenShockDb/Models/AutomationToken.cs @@ -1,6 +1,6 @@ namespace OpenShock.Common.OpenShockDb; -public sealed class BypassToken +public sealed class AutomationToken { public required Guid Id { get; set; } @@ -8,23 +8,23 @@ public sealed class BypassToken public required string TokenHash { get; set; } - public required List Types { get; set; } + public required List Types { get; set; } public DateTime CreatedAt { get; set; } public DateTime? LastUsedAt { get; set; } - public Guid? LastUsedByUserId { get; set; } - public DateTime? LastRotatedAt { get; set; } public long UseCount { get; set; } public bool AutoCleanupUsers { get; set; } + /// + /// With , how long after creation the accounts this token created are deleted. + /// public TimeSpan? AutoCleanupAfter { get; set; } // Navigations - public User? LastUsedByUser { get; set; } - public ICollection UserUses { get; } = []; + public ICollection CreatedUsers { get; } = []; } diff --git a/Common/OpenShockDb/Models/BypassTokenUserUse.cs b/Common/OpenShockDb/Models/BypassTokenUserUse.cs deleted file mode 100644 index d5db3d2a..00000000 --- a/Common/OpenShockDb/Models/BypassTokenUserUse.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace OpenShock.Common.OpenShockDb; - -public sealed class BypassTokenUserUse -{ - public required Guid BypassTokenId { get; set; } - - public required Guid UserId { get; set; } - - public DateTime FirstUsedAt { get; set; } - - public DateTime LastUsedAt { get; set; } - - public long UseCount { get; set; } - - // Navigations - public BypassToken BypassToken { get; set; } = null!; - public User User { get; set; } = null!; -} diff --git a/Common/OpenShockDb/Models/User.cs b/Common/OpenShockDb/Models/User.cs index 4f044db4..ea267f86 100644 --- a/Common/OpenShockDb/Models/User.cs +++ b/Common/OpenShockDb/Models/User.cs @@ -24,6 +24,13 @@ public sealed class User public DateTime CreatedAt { get; set; } public DateTime? ActivatedAt { get; set; } + /// + /// The admin-issued automation token that created this account, which makes it an automated (bot) + /// account rather than one a person signed up for; these are mainly used by automated tests. + /// The account is deleted together with the token. + /// + public Guid? CreatedByAutomationTokenId { get; set; } + // Navigations public UserActivationRequest? UserActivationRequest { get; set; } public UserDeactivation? UserDeactivation { get; set; } @@ -41,5 +48,5 @@ public sealed class User public ICollection PasswordResets { get; } = []; public ICollection AuditLogs { get; } = []; public ICollection ActorAuditLogs { get; } = []; - public ICollection BypassTokenUses { get; } = []; + public AutomationToken? CreatedByAutomationToken { get; set; } } diff --git a/Common/OpenShockDb/OpenShockContext.cs b/Common/OpenShockDb/OpenShockContext.cs index 3cee2b6e..b2a6efd2 100644 --- a/Common/OpenShockDb/OpenShockContext.cs +++ b/Common/OpenShockDb/OpenShockContext.cs @@ -117,9 +117,8 @@ public static void ConfigureOptionsBuilder(DbContextOptionsBuilder optionsBuilde public DbSet EmailOutbox { get; set; } - public DbSet BypassTokens { get; set; } + public DbSet AutomationTokens { get; set; } - public DbSet BypassTokenUserUses { get; set; } public DbSet DataProtectionKeys { get; set; } @@ -630,6 +629,15 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .HasColumnName("created_at"); entity.Property(e => e.ActivatedAt) .HasColumnName("activated_at"); + entity.Property(e => e.CreatedByAutomationTokenId) + .HasColumnName("created_by_automation_token_id"); + + entity.HasIndex(e => e.CreatedByAutomationTokenId); + + entity.HasOne(d => d.CreatedByAutomationToken).WithMany(p => p.CreatedUsers) + .HasForeignKey(d => d.CreatedByAutomationTokenId) + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_users_created_by_automation_token_id"); }); modelBuilder.Entity(entity => @@ -858,14 +866,13 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .HasColumnName("created_at"); }); - modelBuilder.Entity(entity => + modelBuilder.Entity(entity => { - entity.HasKey(e => e.Id).HasName("bypass_tokens_pkey"); + entity.HasKey(e => e.Id).HasName("automation_tokens_pkey"); - entity.ToTable("bypass_tokens"); + entity.ToTable("automation_tokens"); entity.HasIndex(e => e.TokenHash).IsUnique(); - entity.HasIndex(e => e.LastUsedByUserId); entity.Property(e => e.Id) .ValueGeneratedNever() @@ -878,13 +885,12 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .VarCharWithLength(HardLimits.Sha256HashHexLength) .HasColumnName("token_hash"); entity.Property(e => e.Types) - .HasColumnType("bypass_token_type[]") + .HasColumnType("automation_token_type[]") .HasColumnName("types"); entity.Property(e => e.CreatedAt) .HasDefaultValueSql("CURRENT_TIMESTAMP") .HasColumnName("created_at"); entity.Property(e => e.LastUsedAt).HasColumnName("last_used_at"); - entity.Property(e => e.LastUsedByUserId).HasColumnName("last_used_by_user_id"); entity.Property(e => e.LastRotatedAt).HasColumnName("last_rotated_at"); entity.Property(e => e.UseCount) .HasDefaultValue(0L) @@ -893,42 +899,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .HasDefaultValue(false) .HasColumnName("auto_cleanup_users"); entity.Property(e => e.AutoCleanupAfter).HasColumnName("auto_cleanup_after"); - - entity.HasOne(d => d.LastUsedByUser).WithMany() - .HasForeignKey(d => d.LastUsedByUserId) - .OnDelete(DeleteBehavior.SetNull) - .HasConstraintName("fk_bypass_tokens_last_used_by_user_id"); - }); - - modelBuilder.Entity(entity => - { - entity.HasKey(e => new { e.BypassTokenId, e.UserId }).HasName("bypass_token_user_uses_pkey"); - - entity.ToTable("bypass_token_user_uses"); - - entity.HasIndex(e => e.LastUsedAt); - - entity.Property(e => e.BypassTokenId).HasColumnName("bypass_token_id"); - entity.Property(e => e.UserId).HasColumnName("user_id"); - entity.Property(e => e.FirstUsedAt) - .HasDefaultValueSql("CURRENT_TIMESTAMP") - .HasColumnName("first_used_at"); - entity.Property(e => e.LastUsedAt) - .HasDefaultValueSql("CURRENT_TIMESTAMP") - .HasColumnName("last_used_at"); - entity.Property(e => e.UseCount) - .HasDefaultValue(0L) - .HasColumnName("use_count"); - - entity.HasOne(d => d.BypassToken).WithMany(p => p.UserUses) - .HasForeignKey(d => d.BypassTokenId) - .OnDelete(DeleteBehavior.Cascade) - .HasConstraintName("fk_bypass_token_user_uses_bypass_token_id"); - - entity.HasOne(d => d.User).WithMany(p => p.BypassTokenUses) - .HasForeignKey(d => d.UserId) - .OnDelete(DeleteBehavior.Cascade) - .HasConstraintName("fk_bypass_token_user_uses_user_id"); }); modelBuilder.Entity(entity => @@ -1013,6 +983,8 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .HasColumnName("created_at"); entity.Property(e => e.ActivatedAt) .HasColumnName("activated_at"); + entity.Property(e => e.CreatedByAutomationTokenId) + .HasColumnName("created_by_automation_token_id"); entity.Property(e => e.DeactivatedAt) .HasColumnName("deactivated_at"); entity.Property(e => e.DeactivatedByUserId) diff --git a/Common/OpenShockMiddlewareHelper.cs b/Common/OpenShockMiddlewareHelper.cs index dc28cdfd..c617bd03 100644 --- a/Common/OpenShockMiddlewareHelper.cs +++ b/Common/OpenShockMiddlewareHelper.cs @@ -115,9 +115,9 @@ exception is null await redisConnection.CreateIndexAsync(typeof(DevicePair)); await redisConnection.CreateIndexAsync(typeof(LcgNode)); - // Resolve the X-OpenShock-Bypass-Token header (if present) before rate limiting so the + // Resolve the X-OpenShock-Automation-Token header (if present) before rate limiting so the // rate limiter partition selectors can honor the bypass for this same request. - app.UseMiddleware(); + app.UseMiddleware(); app.UseRateLimiter(); diff --git a/Common/OpenShockServiceHelper.cs b/Common/OpenShockServiceHelper.cs index f6446cce..5a47ae60 100644 --- a/Common/OpenShockServiceHelper.cs +++ b/Common/OpenShockServiceHelper.cs @@ -23,7 +23,7 @@ using OpenShock.Common.Problems; using OpenShock.Common.Services.Audit; using OpenShock.Common.Services.BatchUpdate; -using OpenShock.Common.Services.Bypass; +using OpenShock.Common.Services.AutomationTokens; using OpenShock.Common.Services.Configuration; using OpenShock.Common.Services.RedisPubSub; using OpenShock.Common.Services.Geo; @@ -346,7 +346,7 @@ public static IServiceCollection AddOpenShockServices(this IServiceCollection se services.AddScoped(); services.AddScoped(); services.AddScoped(); - services.AddScoped(); + services.AddScoped(); // Ensure GeoOptions is always resolvable so IpEnrichmentService can activate even in hosts // (Cron, LiveControlGateway, SeedE2E) that don't call RegisterGeoOptions(). TryAdd leaves the @@ -382,14 +382,14 @@ public static IServiceCollection AddOpenShockServices(this IServiceCollection se return; } - // If the request resolved a bypass token granting RateLimit, skip every limiter on it. - // Selectors are sync and the BypassTokenMiddleware (which runs before UseRateLimiter) + // If the request resolved an automation token granting RateLimit, skip every limiter on it. + // Selectors are sync and the AutomationTokenMiddleware (which runs before UseRateLimiter) // has already populated HttpContext.Items, so this is just a dictionary lookup. static RateLimitPartition? TryBypass(HttpContext ctx) { - var bypass = ctx.GetResolvedBypassToken(); - return bypass is not null && bypass.Types.Contains(BypassTokenType.RateLimit) - ? RateLimitPartition.GetNoLimiter($"bypass-{bypass.Id}") + var bypass = ctx.GetResolvedAutomationToken(); + return bypass is not null && bypass.Types.Contains(AutomationTokenType.RateLimit) + ? RateLimitPartition.GetNoLimiter($"automation-token-{bypass.Id}") : null; } diff --git a/Common/Services/AutomationTokens/AutomationTokenService.cs b/Common/Services/AutomationTokens/AutomationTokenService.cs new file mode 100644 index 00000000..e6d99b35 --- /dev/null +++ b/Common/Services/AutomationTokens/AutomationTokenService.cs @@ -0,0 +1,149 @@ +using System.Collections.Frozen; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Memory; +using OpenShock.Common.Extensions; +using OpenShock.Common.Models; +using OpenShock.Common.OpenShockDb; +using OpenShock.Common.Services.Audit; +using OpenShock.Common.Services.BatchUpdate; +using OpenShock.Common.Utils; + +namespace OpenShock.Common.Services.AutomationTokens; + +public sealed class AutomationTokenService : IAutomationTokenService +{ + private static readonly TimeSpan CacheDuration = TimeSpan.FromSeconds(30); + private const string TokensCacheKey = "automation-tokens"; + private const string UserAllowedCacheKey = "automation-token-user-allowed"; + + private readonly OpenShockContext _db; + private readonly IMemoryCache _cache; + private readonly IBatchUpdateService _batchUpdateService; + private readonly IAuditService _auditService; + private readonly IHttpContextAccessor _httpContextAccessor; + + public AutomationTokenService( + OpenShockContext db, + IMemoryCache cache, + IBatchUpdateService batchUpdateService, + IAuditService auditService, + IHttpContextAccessor httpContextAccessor) + { + _db = db; + _cache = cache; + _batchUpdateService = batchUpdateService; + _auditService = auditService; + _httpContextAccessor = httpContextAccessor; + } + + public async Task ResolveAsync(string secret, CancellationToken ct) + { + if (string.IsNullOrEmpty(secret) || secret.Length != IAutomationTokenService.SecretLength) return null; + + var tokens = await GetTokensAsync(ct); + + return tokens.GetValueOrDefault(HashingUtils.HashToken(secret)); + } + + private async Task> GetTokensAsync(CancellationToken ct) + { + var tokens = await _cache.GetOrCreateAsync(TokensCacheKey, async entry => + { + entry.AbsoluteExpirationRelativeToNow = CacheDuration; + + var rows = await _db.AutomationTokens + .AsNoTracking() + .Select(t => new { t.Id, t.Name, t.TokenHash, t.Types }) + .ToListAsync(ct); + + return rows.ToFrozenDictionary( + t => t.TokenHash, + t => new ResolvedAutomationToken(t.Id, t.Name, t.Types), + StringComparer.Ordinal); + }); + + return tokens!; + } + + public void InvalidateCache() => _cache.Remove(TokensCacheKey); + + public void RecordRequest(Guid automationTokenId) => _batchUpdateService.UpdateAutomationTokenUsed(automationTokenId); + + public async Task IsAllowedForUserAsync(Guid userId, CancellationToken ct) + { + return await _cache.GetOrCreateAsync((UserAllowedCacheKey, userId), async entry => + { + entry.AbsoluteExpirationRelativeToNow = CacheDuration; + + // The same roles AccountService.DeleteAccountAsync treats as privileged. + return !await _db.Users.AnyAsync(u => + u.Id == userId && + u.Roles.Any(r => r == RoleType.Staff || r == RoleType.Admin || r == RoleType.System), ct); + }); + } + + public async Task RecordSignupAsync(Guid userId, CancellationToken ct) + { + var automationToken = _httpContextAccessor.HttpContext?.GetResolvedAutomationToken(); + if (automationToken is null) return; + + await _db.Users + .Where(u => u.Id == userId) + .ExecuteUpdateAsync(s => s.SetProperty(u => u.CreatedByAutomationTokenId, automationToken.Id), ct); + + await AuditUseAsync(automationToken, userId, AutomationTokenFlow.Signup, ct); + } + + public async Task CanUseForLoginAsync(string usernameOrEmail, CancellationToken ct) + { + var automationToken = _httpContextAccessor.HttpContext?.GetResolvedAutomationToken(); + if (automationToken is null) return true; + + // Same lookup as AccountService.GetAccountByCredentialsAsync + var lowercaseUsernameOrEmail = usernameOrEmail.ToLowerInvariant(); + var userId = await _db.Users + .Where(u => u.Email == lowercaseUsernameOrEmail || u.Name == lowercaseUsernameOrEmail) + .Select(u => (Guid?)u.Id) + .FirstOrDefaultAsync(ct); + + return userId is null || await IsAllowedForUserAsync(userId.Value, ct); + } + + public async Task TryRecordUseAsync(Guid userId, AutomationTokenFlow flow, CancellationToken ct) + { + var automationToken = _httpContextAccessor.HttpContext?.GetResolvedAutomationToken(); + if (automationToken is null) return true; + + if (!await IsAllowedForUserAsync(userId, ct)) return false; + + await AuditUseAsync(automationToken, userId, flow, ct); + + return true; + } + + public async Task TryRecordUseByEmailAsync(string email, AutomationTokenFlow flow, CancellationToken ct) + { + var automationToken = _httpContextAccessor.HttpContext?.GetResolvedAutomationToken(); + if (automationToken is null) return true; + + email = email.ToLowerInvariant(); + var userId = await _db.Users + .Where(u => u.Email == email) + .Select(u => (Guid?)u.Id) + .FirstOrDefaultAsync(ct); + + if (userId is null) return true; + + return await TryRecordUseAsync(userId.Value, flow, ct); + } + + private Task AuditUseAsync(ResolvedAutomationToken automationToken, Guid userId, AutomationTokenFlow flow, CancellationToken ct) => + _auditService.LogAsync( + userId, + action: AuditAction.AutomationTokenUsed, + // A password reset request proves nothing about who is asking, so it has no actor. In every + // other flow the caller is the account itself, or has just created it. + actorId: flow == AutomationTokenFlow.PasswordReset ? null : userId, + metadata: new AutomationTokenUsedMetadata(automationToken.Id, automationToken.Name, flow.ToString()), + cancellationToken: ct); +} diff --git a/Common/Services/AutomationTokens/IAutomationTokenService.cs b/Common/Services/AutomationTokens/IAutomationTokenService.cs new file mode 100644 index 00000000..94f98eee --- /dev/null +++ b/Common/Services/AutomationTokens/IAutomationTokenService.cs @@ -0,0 +1,75 @@ +using OpenShock.Common.OpenShockDb; +using OpenShock.Internal.Common.Utils; + +namespace OpenShock.Common.Services.AutomationTokens; + +/// +/// An automation token may be used with any account except privileged ones (, +/// , ). Every use in an account flow is written to +/// that account's audit log. Accounts the token itself created are linked to it, which is what makes them +/// automated accounts; only those are ever eligible for auto-cleanup, and they are deleted with the token. +/// +public interface IAutomationTokenService +{ + /// + /// Length (in chars) of the opaque random secret. The full secret is base62-style alphanumeric. + /// + const int SecretLength = 128; + + /// + /// Generates a new opaque automation-token secret. The secret is sent via the + /// X-OpenShock-Automation-Token header. It has no prefix because it lives in a dedicated + /// header and is not confused with any other token. + /// + static string GenerateSecret() => CryptoUtils.RandomString(SecretLength); + + /// + /// Hashes the supplied secret and looks it up in a short-lived in-memory snapshot of all automation + /// tokens, so neither valid nor bogus secrets cost a database round trip per request. + /// Changes made on another instance take up to the cache lifetime to be seen here. + /// + Task ResolveAsync(string secret, CancellationToken ct); + + /// + /// True if holds no privileged role. Cached briefly, since the middleware + /// asks this for every bypassed request of a signed-in user. + /// + Task IsAllowedForUserAsync(Guid userId, CancellationToken ct); + + /// + /// Counts one accepted request against the token. Batched, never written per request. + /// + void RecordRequest(Guid automationTokenId); + + /// + /// Drops this instance's cached token snapshot, so an admin change applies here immediately. + /// + void InvalidateCache(); + + /// + /// If the current request resolved an automation token, links the new account to it, making it an + /// automated account, and audits the use. Only such accounts are ever eligible for the token's auto-cleanup. + /// + Task RecordSignupAsync(Guid userId, CancellationToken ct); + + /// + /// Checked before the password is verified, so an automation token (which also lifts Turnstile and rate + /// limits) can never be used to guess passwords of privileged accounts. Returns true when + /// no automation token was used or no such account exists; the normal credential check handles the latter. + /// + Task CanUseForLoginAsync(string usernameOrEmail, CancellationToken ct); + + /// + /// If the current request resolved an automation token, audits a use against . + /// Returns false ONLY when an automation token was used AND the account is privileged. Callers MUST treat + /// that as "bypass not honored" and reject the request. + /// + Task TryRecordUseAsync(Guid userId, AutomationTokenFlow flow, CancellationToken ct); + + /// + /// Same contract as but resolves the user by email first. + /// If no matching user exists, this is a no-op and returns true so the caller does not + /// expose user-existence by behaving differently, which matters on flows like password-reset initiation. + /// + Task TryRecordUseByEmailAsync(string email, AutomationTokenFlow flow, CancellationToken ct); +} diff --git a/Common/Services/AutomationTokens/ResolvedAutomationToken.cs b/Common/Services/AutomationTokens/ResolvedAutomationToken.cs new file mode 100644 index 00000000..58bd2881 --- /dev/null +++ b/Common/Services/AutomationTokens/ResolvedAutomationToken.cs @@ -0,0 +1,11 @@ +using OpenShock.Common.OpenShockDb; + +namespace OpenShock.Common.Services.AutomationTokens; + +/// +/// The result of resolving the X-OpenShock-Automation-Token header against the database. +/// Set on by the automation-token middleware +/// so that downstream guards (rate limiter, turnstile, etc.) can read it synchronously without +/// re-hitting the database. +/// +public sealed record ResolvedAutomationToken(Guid Id, string Name, IReadOnlyList Types); diff --git a/Common/Services/BatchUpdate/BatchUpdateService.cs b/Common/Services/BatchUpdate/BatchUpdateService.cs index 04bc83f1..78f182d9 100644 --- a/Common/Services/BatchUpdate/BatchUpdateService.cs +++ b/Common/Services/BatchUpdate/BatchUpdateService.cs @@ -34,6 +34,30 @@ public Dictionary DequeueAll() } } +internal sealed class ConcurrentCountingBatchQueue where TKey : notnull +{ + private readonly Lock _lock = new(); + private Dictionary _dictionary = new(); + + public void Increment(TKey key) + { + lock (_lock) + { + _dictionary[key] = _dictionary.GetValueOrDefault(key) + 1; + } + } + + public Dictionary DequeueAll() + { + lock (_lock) + { + var items = _dictionary; + _dictionary = new Dictionary(); + return items; + } + } +} + public sealed class BatchUpdateService : BackgroundService, IBatchUpdateService { private static readonly TimeSpan Interval = TimeSpan.FromSeconds(10); @@ -44,6 +68,7 @@ public sealed class BatchUpdateService : BackgroundService, IBatchUpdateService private readonly ConcurrentUniqueBatchQueue _tokenLastUsed = new(); private readonly ConcurrentUniqueBatchQueue _sessionLastUsed = new(); + private readonly ConcurrentCountingBatchQueue _automationTokenUses = new(); public BatchUpdateService(IDbContextFactory dbFactory, ILogger logger, IConnectionMultiplexer connectionMultiplexer) { @@ -66,6 +91,25 @@ await db.ApiTokens.Where(x => keys.Contains(x.Id)) await db.SaveChangesAsync(); } + private async Task UpdateAutomationTokens() + { + var uses = _automationTokenUses.DequeueAll(); + + // Skip if there is nothing + if (uses.Count < 1) return; + + var now = DateTime.UtcNow; + + await using var db = await _dbFactory.CreateDbContextAsync(); + foreach (var (automationTokenId, count) in uses) + { + await db.AutomationTokens.Where(x => x.Id == automationTokenId) + .ExecuteUpdateAsync(x => x + .SetProperty(y => y.LastUsedAt, now) + .SetProperty(y => y.UseCount, y => y.UseCount + count)); + } + } + private async Task UpdateSessions() { var sessionsToUpdate = new List>(); @@ -91,6 +135,11 @@ public void UpdateApiTokenLastUsed(Guid apiTokenId) _tokenLastUsed.Enqueue(apiTokenId, false); } + public void UpdateAutomationTokenUsed(Guid automationTokenId) + { + _automationTokenUses.Increment(automationTokenId); + } + public void UpdateSessionLastUsed(string sessionToken, DateTimeOffset lastUsed) { // Only hash new tokens, old ones are 64 chars long @@ -127,7 +176,7 @@ private async Task Flush() { try { - await Task.WhenAll(UpdateTokens(), UpdateSessions()); + await Task.WhenAll(UpdateTokens(), UpdateAutomationTokens(), UpdateSessions()); } catch (Exception e) { diff --git a/Common/Services/BatchUpdate/IBatchUpdateService.cs b/Common/Services/BatchUpdate/IBatchUpdateService.cs index ea42604a..941960cc 100644 --- a/Common/Services/BatchUpdate/IBatchUpdateService.cs +++ b/Common/Services/BatchUpdate/IBatchUpdateService.cs @@ -7,5 +7,10 @@ public interface IBatchUpdateService /// /// public void UpdateApiTokenLastUsed(Guid apiTokenId); + /// + /// Count one accepted request for an automation token and bump its time of last use + /// + /// + public void UpdateAutomationTokenUsed(Guid automationTokenId); public void UpdateSessionLastUsed(string sessionToken, DateTimeOffset lastUsed); } \ No newline at end of file diff --git a/Common/Services/Bypass/BypassTokenService.cs b/Common/Services/Bypass/BypassTokenService.cs deleted file mode 100644 index 3e0f0e73..00000000 --- a/Common/Services/Bypass/BypassTokenService.cs +++ /dev/null @@ -1,94 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using OpenShock.Common.Extensions; -using OpenShock.Common.OpenShockDb; -using OpenShock.Common.Utils; - -namespace OpenShock.Common.Services.Bypass; - -public sealed class BypassTokenService : IBypassTokenService -{ - private readonly OpenShockContext _db; - private readonly IHttpContextAccessor _httpContextAccessor; - - public BypassTokenService(OpenShockContext db, IHttpContextAccessor httpContextAccessor) - { - _db = db; - _httpContextAccessor = httpContextAccessor; - } - - public async Task ResolveAsync(string secret, CancellationToken ct) - { - if (string.IsNullOrEmpty(secret) || secret.Length != IBypassTokenService.SecretLength) return null; - - var hash = HashingUtils.HashToken(secret); - - var token = await _db.BypassTokens - .Where(t => t.TokenHash == hash) - .Select(t => new { t.Id, t.Name, t.Types }) - .FirstOrDefaultAsync(ct); - - if (token is null) return null; - - var tokenId = token.Id; - await _db.BypassTokens - .Where(t => t.Id == tokenId) - .ExecuteUpdateAsync(s => s - .SetProperty(t => t.LastUsedAt, DateTime.UtcNow) - .SetProperty(t => t.UseCount, t => t.UseCount + 1), ct); - - return new ResolvedBypassToken(tokenId, token.Name, token.Types); - } - - public Task TryRecordUseAsync(Guid userId, CancellationToken ct) - { - var bypass = _httpContextAccessor.HttpContext?.GetResolvedBypassToken(); - if (bypass is null) return Task.FromResult(true); - - return LinkAsync(bypass.Id, userId, ct); - } - - public async Task TryRecordUseByEmailAsync(string email, CancellationToken ct) - { - var bypass = _httpContextAccessor.HttpContext?.GetResolvedBypassToken(); - if (bypass is null) return true; - - email = email.ToLowerInvariant(); - var userId = await _db.Users - .Where(u => u.Email == email) - .Select(u => (Guid?)u.Id) - .FirstOrDefaultAsync(ct); - - if (userId is null) return true; - - return await LinkAsync(bypass.Id, userId.Value, ct); - } - - private async Task LinkAsync(Guid bypassTokenId, Guid userId, CancellationToken ct) - { - var roles = await _db.Users - .Where(u => u.Id == userId) - .Select(u => u.Roles) - .FirstOrDefaultAsync(ct); - - if (roles is null) return true; // user not found, nothing to link; treat as unprivileged - if (roles.Any(r => r is RoleType.Admin or RoleType.System)) return false; - - var now = DateTime.UtcNow; - - await _db.BypassTokens - .Where(t => t.Id == bypassTokenId) - .ExecuteUpdateAsync(s => s.SetProperty(t => t.LastUsedByUserId, userId), ct); - - const string sql = """ - INSERT INTO bypass_token_user_uses (bypass_token_id, user_id, first_used_at, last_used_at, use_count) - VALUES ({0}, {1}, {2}, {2}, 1) - ON CONFLICT (bypass_token_id, user_id) - DO UPDATE SET last_used_at = EXCLUDED.last_used_at, - use_count = bypass_token_user_uses.use_count + 1 - """; - - await _db.Database.ExecuteSqlRawAsync(sql, [bypassTokenId, userId, now], ct); - - return true; - } -} diff --git a/Common/Services/Bypass/IBypassTokenService.cs b/Common/Services/Bypass/IBypassTokenService.cs deleted file mode 100644 index a07e1d11..00000000 --- a/Common/Services/Bypass/IBypassTokenService.cs +++ /dev/null @@ -1,41 +0,0 @@ -using OpenShock.Common.OpenShockDb; -using OpenShock.Internal.Common.Utils; - -namespace OpenShock.Common.Services.Bypass; - -public interface IBypassTokenService -{ - /// - /// Length (in chars) of the opaque random secret. The full secret is base62-style alphanumeric. - /// - const int SecretLength = 128; - - /// - /// Generates a new opaque bypass-token secret. The secret is sent via the - /// X-OpenShock-Bypass-Token header. It has no prefix because it lives in a dedicated - /// header and is not confused with any other token. - /// - static string GenerateSecret() => CryptoUtils.RandomString(SecretLength); - - /// - /// Hashes the supplied secret and looks it up. On a match, increments the global UseCount and - /// LastUsedAt and returns the resolved token. Called once per request by the bypass middleware. - /// - Task ResolveAsync(string secret, CancellationToken ct); - - /// - /// If the current request resolved a bypass token, associates the use with the given user account. - /// Returns false ONLY when a bypass was used AND the target user holds a privileged role - /// ( or ). Callers MUST treat that as - /// "bypass not honored" and reject the request, matching the leak-defense contract. Returns - /// true when no bypass was used, when the user doesn't exist, or when the link was recorded. - /// - Task TryRecordUseAsync(Guid userId, CancellationToken ct); - - /// - /// Same contract as but resolves the user by email first. - /// If no matching user exists, this is a no-op and returns true so the caller does not - /// expose user-existence by behaving differently, which matters on flows like password-reset initiation. - /// - Task TryRecordUseByEmailAsync(string email, CancellationToken ct); -} diff --git a/Common/Services/Bypass/ResolvedBypassToken.cs b/Common/Services/Bypass/ResolvedBypassToken.cs deleted file mode 100644 index 5d411f11..00000000 --- a/Common/Services/Bypass/ResolvedBypassToken.cs +++ /dev/null @@ -1,11 +0,0 @@ -using OpenShock.Common.OpenShockDb; - -namespace OpenShock.Common.Services.Bypass; - -/// -/// The result of resolving the X-OpenShock-Bypass-Token header against the database. -/// Set on by the bypass-token middleware -/// so that downstream guards (rate limiter, turnstile, etc.) can read it synchronously without -/// re-hitting the database. -/// -public sealed record ResolvedBypassToken(Guid Id, string Name, IReadOnlyList Types); diff --git a/Cron/Jobs/DeleteBypassTokenUsedAccountsJob.cs b/Cron/Jobs/DeleteBypassTokenUsedAccountsJob.cs deleted file mode 100644 index 8cd59683..00000000 --- a/Cron/Jobs/DeleteBypassTokenUsedAccountsJob.cs +++ /dev/null @@ -1,54 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using OpenShock.Common.OpenShockDb; -using OpenShock.Cron.Attributes; - -namespace OpenShock.Cron.Jobs; - -/// -/// Hard-deletes user accounts that authenticated through an admin-issued bypass token -/// whose owner enabled auto-cleanup, once the configured grace period has elapsed since -/// the last bypass use. Defends against leaked bypass tokens by ensuring test-style -/// accounts created through them do not persist indefinitely. -/// -[CronJob("0 * * * *")] // Every hour -public sealed class DeleteBypassTokenUsedAccountsJob -{ - private readonly OpenShockContext _db; - private readonly ILogger _logger; - - public DeleteBypassTokenUsedAccountsJob(OpenShockContext db, ILogger logger) - { - _db = db; - _logger = logger; - } - - public async Task Execute() - { - var now = DateTime.UtcNow; - - var userIds = await _db.BypassTokenUserUses - .Where(use => use.BypassToken.AutoCleanupUsers - && use.BypassToken.AutoCleanupAfter != null - && use.LastUsedAt + use.BypassToken.AutoCleanupAfter < now) - .Select(use => use.UserId) - .Distinct() - .ToListAsync(); - - if (userIds.Count == 0) - { - _logger.LogDebug("No bypass-token-used accounts eligible for cleanup"); - return 0; - } - - int nDeleted = await _db.Users - .Where(u => userIds.Contains(u.Id) && !u.Roles.Any(r => r == RoleType.Admin || r == RoleType.System)) - .ExecuteDeleteAsync(); - - _logger.LogInformation( - "Bypass-token cleanup: {DeletedCount}/{CandidateCount} accounts deleted", - nDeleted, - userIds.Count); - - return nDeleted; - } -} diff --git a/Cron/Jobs/DeleteExpiredAutomatedAccountsJob.cs b/Cron/Jobs/DeleteExpiredAutomatedAccountsJob.cs new file mode 100644 index 00000000..05a7e136 --- /dev/null +++ b/Cron/Jobs/DeleteExpiredAutomatedAccountsJob.cs @@ -0,0 +1,67 @@ +using Microsoft.EntityFrameworkCore; +using OpenShock.Common.OpenShockDb; +using OpenShock.Cron.Attributes; + +namespace OpenShock.Cron.Jobs; + +/// +/// Hard-deletes automated accounts (mainly test accounts) that were created through an admin-issued +/// automation token whose owner enabled auto-cleanup (off by default), once the token's configured lifetime +/// has passed since the account was created. Accounts the token was merely used with are never touched, +/// only the ones it created. +/// +[CronJob("0 * * * *")] // Every hour +public sealed class DeleteExpiredAutomatedAccountsJob +{ + private readonly OpenShockContext _db; + private readonly ILogger _logger; + + public DeleteExpiredAutomatedAccountsJob(OpenShockContext db, ILogger logger) + { + _db = db; + _logger = logger; + } + + public async Task Execute() + { + var now = DateTime.UtcNow; + + // The same roles AccountService.DeleteAccountAsync treats as privileged. + var candidates = await _db.Users + .Where(u => u.CreatedByAutomationToken != null + && u.CreatedByAutomationToken.AutoCleanupUsers + && u.CreatedByAutomationToken.AutoCleanupAfter != null + && u.CreatedAt + u.CreatedByAutomationToken.AutoCleanupAfter < now + && !u.Roles.Any(r => r == RoleType.Staff || r == RoleType.Admin || r == RoleType.System)) + .Select(u => new { u.Id, u.CreatedByAutomationTokenId }) + .ToListAsync(); + + if (candidates.Count == 0) + { + _logger.LogDebug("No automation-token-created accounts eligible for cleanup"); + return 0; + } + + var userIds = candidates.Select(c => c.Id).ToArray(); + + // Re-checked in the delete itself, in case an account was promoted since it was selected. + int nDeleted = await _db.Users + .Where(u => userIds.Contains(u.Id) && u.CreatedByAutomationTokenId != null && !u.Roles.Any(r => r == RoleType.Staff || r == RoleType.Admin || r == RoleType.System)) + .ExecuteDeleteAsync(); + + // A user's audit log is deleted with the user, so the log line is the lasting record of what went. + foreach (var candidate in candidates) + { + _logger.LogInformation( + "Automation-token cleanup deleted account {UserId} created by automation token {AutomationTokenId}", + candidate.Id, candidate.CreatedByAutomationTokenId); + } + + _logger.LogInformation( + "Automation-token cleanup: {DeletedCount}/{CandidateCount} accounts deleted", + nDeleted, + userIds.Length); + + return nDeleted; + } +} From f60468cb62245e02269d95f38d67dd1a9b79dc0d Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Wed, 30 Sep 2026 23:35:59 +0200 Subject: [PATCH 14/16] fix: harden automation token deletion, cleanup and privileged checks - Check privileged roles in memory or in SQL; Npgsql can't translate predicates over the roles enum array, which made these paths throw - Token delete refuses via a restricting FK instead of cascading, so a privileged account is never deleted with its token - Cleanup job logs only the accounts it actually deleted - Link automated accounts to their token on insert - Refuse the token for privileged API-token callers too - Drop the token and user caches; changes apply immediately - Integration tests for the token flows and the cleanup job --- .../Tests/AutomationTokenTests.cs | 407 ++++++++++++++++++ API/Controller/Account/LoginV2.cs | 3 +- API/Controller/Account/SignupV2.cs | 6 +- API/Controller/Admin/AutomationTokenCreate.cs | 3 - API/Controller/Admin/AutomationTokenDelete.cs | 48 ++- API/Controller/Admin/AutomationTokenPatch.cs | 26 +- API/Controller/Admin/AutomationTokenRotate.cs | 6 +- API/Controller/Tokens/ReportTokens.cs | 3 +- API/Services/Account/AccountService.cs | 9 +- API/Services/Account/IAccountService.cs | 3 +- .../Turnstile/CloudflareTurnstileService.cs | 2 +- .../Middleware/AutomationTokenMiddleware.cs | 29 +- ...0930204951_AddAutomationTokens.Designer.cs | 4 +- .../20260930204951_AddAutomationTokens.cs | 4 +- .../OpenShockContextModelSnapshot.cs | 2 +- Common/OpenShockDb/OpenShockContext.cs | 3 +- Common/OpenShockDb/PrivilegedRoles.cs | 19 + .../AutomationTokenService.cs | 96 ++--- .../IAutomationTokenService.cs | 34 +- .../DeleteExpiredAutomatedAccountsTests.cs | 89 ++++ .../Jobs/DeleteExpiredAutomatedAccountsJob.cs | 37 +- 21 files changed, 673 insertions(+), 160 deletions(-) create mode 100644 API.IntegrationTests/Tests/AutomationTokenTests.cs create mode 100644 Common/OpenShockDb/PrivilegedRoles.cs create mode 100644 Cron.IntegrationTests/Tests/DeleteExpiredAutomatedAccountsTests.cs diff --git a/API.IntegrationTests/Tests/AutomationTokenTests.cs b/API.IntegrationTests/Tests/AutomationTokenTests.cs new file mode 100644 index 00000000..ca3a653b --- /dev/null +++ b/API.IntegrationTests/Tests/AutomationTokenTests.cs @@ -0,0 +1,407 @@ +using System.Net; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using OpenShock.API.IntegrationTests.Helpers; +using OpenShock.Common.Constants; +using OpenShock.Common.OpenShockDb; +using OpenShock.Common.Services.AutomationTokens; +using OpenShock.Common.Utils; + +namespace OpenShock.API.IntegrationTests.Tests; + +/// +/// Automation tokens lift Turnstile (and rate limits, which the test server disables anyway) for any +/// account except privileged ones, so these tests use an invalid Turnstile response to observe whether +/// a token was honored. +/// +public sealed class AutomationTokenTests +{ + private const string Password = "SecurePassword123#"; + + [ClassDataSource(Shared = SharedType.PerTestSession)] + public required WebApplicationFactory WebApplicationFactory { get; init; } + + // --- Signup --- + + [Test] + public async Task Signup_WithTurnstileToken_BypassesTurnstileAndLinksAccount() + { + var (tokenId, secret) = await CreateAutomationTokenInDb(); + using var client = CreateAutomationClient(secret); + var email = TestHelper.UniqueEmail("autosignup"); + + var response = await client.PostAsync("/2/account/signup", TestHelper.JsonContent(new + { + username = TestHelper.UniqueUsername("autosignup"), + password = Password, + email, + turnstileResponse = "invalid-token" + })); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + + await using var scope = WebApplicationFactory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var user = await db.Users.AsNoTracking().FirstAsync(u => u.Email == email); + await Assert.That(user.CreatedByAutomationTokenId).IsEqualTo(tokenId); + await Assert.That(await HasUsedAuditAsync(db, user.Id)).IsTrue(); + } + + [Test] + public async Task Signup_WithUnknownToken_StillRequiresTurnstile() + { + using var client = CreateAutomationClient(IAutomationTokenService.GenerateSecret()); + + var response = await client.PostAsync("/2/account/signup", TestHelper.JsonContent(new + { + username = TestHelper.UniqueUsername("autounknown"), + password = Password, + email = TestHelper.UniqueEmail("autounknown"), + turnstileResponse = "invalid-token" + })); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Forbidden); + } + + [Test] + public async Task Signup_WithRateLimitOnlyToken_StillRequiresTurnstile() + { + var (_, secret) = await CreateAutomationTokenInDb([AutomationTokenType.RateLimit]); + using var client = CreateAutomationClient(secret); + + var response = await client.PostAsync("/2/account/signup", TestHelper.JsonContent(new + { + username = TestHelper.UniqueUsername("autoratelimit"), + password = Password, + email = TestHelper.UniqueEmail("autoratelimit"), + turnstileResponse = "invalid-token" + })); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Forbidden); + } + + // --- Privileged callers (middleware) --- + + [Test] + public async Task PrivilegedSession_TokenIsRefused() + { + var (_, secret) = await CreateAutomationTokenInDb(); + var staff = await CreateUserWithRoles("autostaffsession", [RoleType.Staff]); + + using var client = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, staff.SessionToken); + client.DefaultRequestHeaders.Add(AuthConstants.AutomationTokenHeaderName, secret); + + var response = await client.PostAsync("/2/account/signup", TestHelper.JsonContent(new + { + username = TestHelper.UniqueUsername("autostaffsessionsignup"), + password = Password, + email = TestHelper.UniqueEmail("autostaffsessionsignup"), + turnstileResponse = "invalid-token" + })); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Forbidden); + } + + [Test] + public async Task PrivilegedApiToken_TokenIsRefused() + { + var (_, secret) = await CreateAutomationTokenInDb(); + var staff = await CreateUserWithRoles("autostaffapi", [RoleType.Staff]); + var (_, apiToken) = await TestHelper.CreateApiTokenInDb(WebApplicationFactory, staff.Id); + + using var client = TestHelper.CreateApiTokenClient(WebApplicationFactory, apiToken); + client.DefaultRequestHeaders.Add(AuthConstants.AutomationTokenHeaderName, secret); + + var response = await client.PostAsync("/2/account/signup", TestHelper.JsonContent(new + { + username = TestHelper.UniqueUsername("autostaffapisignup"), + password = Password, + email = TestHelper.UniqueEmail("autostaffapisignup"), + turnstileResponse = "invalid-token" + })); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Forbidden); + } + + [Test] + public async Task NormalApiToken_TokenIsHonored() + { + var (_, secret) = await CreateAutomationTokenInDb(); + var userId = await TestHelper.CreateUserInDb(WebApplicationFactory, TestHelper.UniqueUsername("autonormalapi"), TestHelper.UniqueEmail("autonormalapi"), Password); + var (_, apiToken) = await TestHelper.CreateApiTokenInDb(WebApplicationFactory, userId); + + using var client = TestHelper.CreateApiTokenClient(WebApplicationFactory, apiToken); + client.DefaultRequestHeaders.Add(AuthConstants.AutomationTokenHeaderName, secret); + + var response = await client.PostAsync("/2/account/signup", TestHelper.JsonContent(new + { + username = TestHelper.UniqueUsername("autonormalapisignup"), + password = Password, + email = TestHelper.UniqueEmail("autonormalapisignup"), + turnstileResponse = "invalid-token" + })); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + } + + // --- Login --- + + [Test] + public async Task Login_ForNormalAccount_BypassesTurnstileAndAudits() + { + var (_, secret) = await CreateAutomationTokenInDb(); + var email = TestHelper.UniqueEmail("autologin"); + var userId = await TestHelper.CreateUserInDb(WebApplicationFactory, TestHelper.UniqueUsername("autologin"), email, Password); + using var client = CreateAutomationClient(secret); + + var response = await client.PostAsync("/2/account/login", TestHelper.JsonContent(new + { + usernameOrEmail = email, + password = Password, + turnstileResponse = "invalid-token" + })); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + + await using var scope = WebApplicationFactory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await Assert.That(await HasUsedAuditAsync(db, userId)).IsTrue(); + } + + [Test] + public async Task Login_ForPrivilegedAccount_Returns403() + { + var (_, secret) = await CreateAutomationTokenInDb(); + var admin = await CreateUserWithRoles("autologinadmin", [RoleType.Admin]); + using var client = CreateAutomationClient(secret); + + var response = await client.PostAsync("/2/account/login", TestHelper.JsonContent(new + { + usernameOrEmail = admin.Email, + password = Password, + turnstileResponse = "invalid-token" + })); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Forbidden); + var body = await response.Content.ReadAsStringAsync(); + await Assert.That(body).Contains("AutomationToken.NotAllowedForAccount"); + } + + // --- Password reset --- + + [Test] + public async Task PasswordReset_ForPrivilegedAccount_ReturnsOkWithoutStartingReset() + { + var (_, secret) = await CreateAutomationTokenInDb(); + var staff = await CreateUserWithRoles("autoresetstaff", [RoleType.Staff]); + using var client = CreateAutomationClient(secret); + + var response = await client.PostAsync("/2/account/password-reset", TestHelper.JsonContent(new + { + email = staff.Email, + turnstileResponse = "invalid-token" + })); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + + await using var scope = WebApplicationFactory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await Assert.That(await db.UserPasswordResets.AnyAsync(r => r.UserId == staff.Id)).IsFalse(); + } + + [Test] + public async Task PasswordReset_ForNormalAccount_StartsResetAndAudits() + { + var (_, secret) = await CreateAutomationTokenInDb(); + var email = TestHelper.UniqueEmail("autoreset"); + var userId = await TestHelper.CreateUserInDb(WebApplicationFactory, TestHelper.UniqueUsername("autoreset"), email, Password); + using var client = CreateAutomationClient(secret); + + var response = await client.PostAsync("/2/account/password-reset", TestHelper.JsonContent(new + { + email, + turnstileResponse = "invalid-token" + })); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + + await using var scope = WebApplicationFactory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await Assert.That(await db.UserPasswordResets.AnyAsync(r => r.UserId == userId)).IsTrue(); + await Assert.That(await HasUsedAuditAsync(db, userId)).IsTrue(); + } + + // --- Admin endpoints --- + + [Test] + public async Task Create_ReturnsWorkingSecret() + { + using var client = await CreateAdminClient("autocreate"); + + var response = await client.PostAsync("/1/admin/automationTokens", TestHelper.JsonContent(new + { + name = "created-by-test", + types = new[] { "Turnstile" } + })); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + var id = json.RootElement.GetProperty("id").GetGuid(); + var secret = json.RootElement.GetProperty("secret").GetString()!; + + await using var scope = WebApplicationFactory.Services.CreateAsyncScope(); + var service = scope.ServiceProvider.GetRequiredService(); + var resolved = await service.ResolveAsync(secret, CancellationToken.None); + await Assert.That(resolved).IsNotNull(); + await Assert.That(resolved!.Id).IsEqualTo(id); + await Assert.That(resolved.Types).Contains(AutomationTokenType.Turnstile); + } + + [Test] + public async Task Rotate_OldSecretStopsResolving() + { + var (tokenId, oldSecret) = await CreateAutomationTokenInDb(); + using var client = await CreateAdminClient("autorotate"); + + var response = await client.PostAsync($"/1/admin/automationTokens/{tokenId}/rotate", null); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + var newSecret = json.RootElement.GetProperty("secret").GetString()!; + + await using var scope = WebApplicationFactory.Services.CreateAsyncScope(); + var service = scope.ServiceProvider.GetRequiredService(); + await Assert.That(await service.ResolveAsync(oldSecret, CancellationToken.None)).IsNull(); + await Assert.That((await service.ResolveAsync(newSecret, CancellationToken.None))?.Id).IsEqualTo(tokenId); + } + + [Test] + public async Task Patch_CleanupRequiresDelay_AndSwitchingOffClearsIt() + { + var (tokenId, _) = await CreateAutomationTokenInDb(); + using var client = await CreateAdminClient("autopatch"); + + var missingDelay = await client.PatchAsync($"/1/admin/automationTokens/{tokenId}", TestHelper.JsonContent(new + { + autoCleanupUsers = true + })); + await Assert.That(missingDelay.StatusCode).IsEqualTo(HttpStatusCode.BadRequest); + + var enable = await client.PatchAsync($"/1/admin/automationTokens/{tokenId}", TestHelper.JsonContent(new + { + autoCleanupUsers = true, + autoCleanupAfter = "01:00:00" + })); + await Assert.That(enable.StatusCode).IsEqualTo(HttpStatusCode.OK); + + var disable = await client.PatchAsync($"/1/admin/automationTokens/{tokenId}", TestHelper.JsonContent(new + { + autoCleanupUsers = false + })); + await Assert.That(disable.StatusCode).IsEqualTo(HttpStatusCode.OK); + + await using var scope = WebApplicationFactory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var token = await db.AutomationTokens.AsNoTracking().FirstAsync(t => t.Id == tokenId); + await Assert.That(token.AutoCleanupUsers).IsFalse(); + await Assert.That(token.AutoCleanupAfter).IsNull(); + } + + [Test] + public async Task Delete_WithPrivilegedCreatedAccount_Returns409AndKeepsEverything() + { + var (tokenId, _) = await CreateAutomationTokenInDb(); + var normalId = await CreateUserCreatedByToken(tokenId, "autodelnormal", []); + var staffId = await CreateUserCreatedByToken(tokenId, "autodelstaff", [RoleType.Staff]); + using var client = await CreateAdminClient("autodelete409"); + + var response = await client.DeleteAsync($"/1/admin/automationTokens/{tokenId}"); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Conflict); + + await using var scope = WebApplicationFactory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await Assert.That(await db.AutomationTokens.AnyAsync(t => t.Id == tokenId)).IsTrue(); + await Assert.That(await db.Users.AnyAsync(u => u.Id == normalId)).IsTrue(); + await Assert.That(await db.Users.AnyAsync(u => u.Id == staffId)).IsTrue(); + } + + [Test] + public async Task Delete_DeletesTokenAndTheAccountsItCreated() + { + var (tokenId, _) = await CreateAutomationTokenInDb(); + var createdId = await CreateUserCreatedByToken(tokenId, "autodelcreated", []); + var unrelatedId = await TestHelper.CreateUserInDb(WebApplicationFactory, TestHelper.UniqueUsername("autodelunrelated"), TestHelper.UniqueEmail("autodelunrelated"), Password); + using var client = await CreateAdminClient("autodelete"); + + var response = await client.DeleteAsync($"/1/admin/automationTokens/{tokenId}"); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + + await using var scope = WebApplicationFactory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await Assert.That(await db.AutomationTokens.AnyAsync(t => t.Id == tokenId)).IsFalse(); + await Assert.That(await db.Users.AnyAsync(u => u.Id == createdId)).IsFalse(); + await Assert.That(await db.Users.AnyAsync(u => u.Id == unrelatedId)).IsTrue(); + } + + // --- Helpers --- + + private HttpClient CreateAutomationClient(string secret) + { + var client = WebApplicationFactory.CreateClient(); + client.DefaultRequestHeaders.Add(AuthConstants.AutomationTokenHeaderName, secret); + return client; + } + + private async Task<(Guid Id, string Secret)> CreateAutomationTokenInDb(List? types = null) + { + await using var scope = WebApplicationFactory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var secret = IAutomationTokenService.GenerateSecret(); + var id = Guid.CreateVersion7(); + db.AutomationTokens.Add(new AutomationToken + { + Id = id, + Name = "test-token", + TokenHash = HashingUtils.HashToken(secret), + Types = types ?? [AutomationTokenType.Turnstile, AutomationTokenType.RateLimit] + }); + await db.SaveChangesAsync(); + return (id, secret); + } + + private async Task CreateUserWithRoles(string prefix, List roles) + { + var user = await TestHelper.CreateAndLoginUser(WebApplicationFactory, TestHelper.UniqueUsername(prefix), TestHelper.UniqueEmail(prefix), Password); + + await using var scope = WebApplicationFactory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await db.Users.Where(u => u.Id == user.Id).ExecuteUpdateAsync(s => s.SetProperty(u => u.Roles, roles)); + + return user; + } + + private async Task CreateUserCreatedByToken(Guid tokenId, string prefix, List roles) + { + var userId = await TestHelper.CreateUserInDb(WebApplicationFactory, TestHelper.UniqueUsername(prefix), TestHelper.UniqueEmail(prefix), Password); + + await using var scope = WebApplicationFactory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await db.Users.Where(u => u.Id == userId).ExecuteUpdateAsync(s => s + .SetProperty(u => u.CreatedByAutomationTokenId, tokenId) + .SetProperty(u => u.Roles, roles)); + + return userId; + } + + private async Task CreateAdminClient(string prefix) + { + var admin = await CreateUserWithRoles(prefix, [RoleType.Admin]); + return TestHelper.CreateAuthenticatedClient(WebApplicationFactory, admin.SessionToken); + } + + private static Task HasUsedAuditAsync(OpenShockContext db, Guid userId) => + db.UserAuditLogs.AnyAsync(l => l.UserId == userId && l.Action == AuditAction.AutomationTokenUsed); +} diff --git a/API/Controller/Account/LoginV2.cs b/API/Controller/Account/LoginV2.cs index efe52411..bbf67df7 100644 --- a/API/Controller/Account/LoginV2.cs +++ b/API/Controller/Account/LoginV2.cs @@ -6,13 +6,12 @@ using Microsoft.AspNetCore.RateLimiting; using OpenShock.API.Services.Account; using OpenShock.Common.Errors; -using OpenShock.Common.Extensions; +using OpenShock.Common.OpenShockDb; using OpenShock.Common.Problems; using OpenShock.Common.Services.AutomationTokens; using OpenShock.API.Errors; using OpenShock.API.Models.Response; using OpenShock.API.Services.Turnstile; -using OpenShock.Common.OpenShockDb; using Results = OpenShock.Common.Results; using OpenShock.Internal.Common.Problems; diff --git a/API/Controller/Account/SignupV2.cs b/API/Controller/Account/SignupV2.cs index 837a1cee..e8210f30 100644 --- a/API/Controller/Account/SignupV2.cs +++ b/API/Controller/Account/SignupV2.cs @@ -49,7 +49,9 @@ public async Task SignUpV2( var turnstileError = await VerifyTurnstileAsync(turnstileService, body.TurnstileResponse, cancellationToken); if (turnstileError is not null) return turnstileError; - var creationAction = await _accountService.CreateAccountWithActivationFlowAsync(body.Email, body.Username, body.Password); + // Created with an automation token, the account is linked to it on insert, which makes it an automated account. + var creationAction = await _accountService.CreateAccountWithActivationFlowAsync( + body.Email, body.Username, body.Password, automationTokens.Current?.Id); if (creationAction is not User created) { return creationAction switch @@ -59,8 +61,6 @@ public async Task SignUpV2( }; } - // Links the new account to the automation token that created it, making it an automated account. - // No-op when no automation token resolved. await automationTokens.RecordSignupAsync(created.Id, cancellationToken); return Ok(); diff --git a/API/Controller/Admin/AutomationTokenCreate.cs b/API/Controller/Admin/AutomationTokenCreate.cs index 2ef0eb77..f5b34f89 100644 --- a/API/Controller/Admin/AutomationTokenCreate.cs +++ b/API/Controller/Admin/AutomationTokenCreate.cs @@ -19,7 +19,6 @@ public sealed partial class AdminController [ProducesResponseType(StatusCodes.Status200OK)] public async Task CreateAutomationToken( [FromBody] CreateAutomationTokenDto body, - [FromServices] IAutomationTokenService automationTokens, [FromServices] IAuditService auditService, CancellationToken ct) { @@ -50,8 +49,6 @@ await auditService.LogAsync( await transaction.CommitAsync(ct); - automationTokens.InvalidateCache(); - return new CreatedAutomationTokenDto { Id = token.Id, diff --git a/API/Controller/Admin/AutomationTokenDelete.cs b/API/Controller/Admin/AutomationTokenDelete.cs index 9fdcb5f7..e552a9bc 100644 --- a/API/Controller/Admin/AutomationTokenDelete.cs +++ b/API/Controller/Admin/AutomationTokenDelete.cs @@ -1,9 +1,9 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using Npgsql; using OpenShock.Common.Models; using OpenShock.Common.OpenShockDb; using OpenShock.Common.Services.Audit; -using OpenShock.Common.Services.AutomationTokens; namespace OpenShock.API.Controller.Admin; @@ -11,7 +11,7 @@ public sealed partial class AdminController { /// /// Deletes an automation token together with every account it created. Refused while any of those - /// accounts holds a privileged role. Other API instances may keep honoring the token for up to 30 seconds + /// accounts holds a privileged role /// [HttpDelete("automationTokens/{id}")] [ProducesResponseType(StatusCodes.Status200OK)] @@ -19,53 +19,61 @@ public sealed partial class AdminController [ProducesResponseType(StatusCodes.Status409Conflict)] public async Task DeleteAutomationToken( [FromRoute] Guid id, - [FromServices] IAutomationTokenService automationTokens, [FromServices] IAuditService auditService, CancellationToken ct) { PedanticallyEnsureAdmin(); - var token = await _db.AutomationTokens.FirstOrDefaultAsync(t => t.Id == id, ct); + var token = await _db.AutomationTokens.AsNoTracking().FirstOrDefaultAsync(t => t.Id == id, ct); if (token is null) return NotFound(); await using var transaction = await _db.Database.BeginTransactionAsync(ct); var createdUsers = await _db.Users .Where(u => u.CreatedByAutomationTokenId == id) - .Select(u => new - { - u.Id, - IsPrivileged = u.Roles.Any(r => r == RoleType.Staff || r == RoleType.Admin || r == RoleType.System) - }) + .Select(u => new { u.Id, u.Roles }) .ToListAsync(ct); - // The accounts go through the database's cascade, which can't skip privileged ones, so refuse instead. - // The same roles AccountService.DeleteAccountAsync treats as privileged. - if (createdUsers.Any(u => u.IsPrivileged)) - return Problem("This automation token created a privileged account; remove its privileged roles before deleting the token.", statusCode: StatusCodes.Status409Conflict); + if (createdUsers.Any(u => PrivilegedRoles.Any(u.Roles))) return PrivilegedAccountConflict(); - _db.AutomationTokens.Remove(token); - await _db.SaveChangesAsync(ct); + var userIds = createdUsers.Select(u => u.Id).ToArray(); + + // The roles are checked again as the rows are deleted, so an account promoted since it was read is kept. + await _db.Database.ExecuteSqlAsync( + $"DELETE FROM users WHERE id = ANY({userIds}) AND NOT (roles && {PrivilegedRoles.All})", ct); + + // The foreign key restricts, so this fails while any account still references the token: a privileged + // one, or one created or promoted meanwhile. On success, exactly userIds went with it. + try + { + await _db.AutomationTokens.Where(t => t.Id == id).ExecuteDeleteAsync(ct); + } + catch (PostgresException e) when (e.SqlState == PostgresErrorCodes.ForeignKeyViolation) + { + return PrivilegedAccountConflict(); + } await auditService.LogAsync( CurrentUser.Id, action: AuditAction.AutomationTokenDeleted, actorId: CurrentUser.Id, - metadata: new AutomationTokenDeletedMetadata(token.Id, token.Name, createdUsers.Count), + metadata: new AutomationTokenDeletedMetadata(token.Id, token.Name, userIds.Length), cancellationToken: ct); await transaction.CommitAsync(ct); - automationTokens.InvalidateCache(); - // Each deleted account's own audit log goes with it, so the log line is the lasting record of what went. - foreach (var user in createdUsers) + foreach (var userId in userIds) { _logger.LogInformation( "Deleted account {UserId} together with automation token {AutomationTokenId}", - user.Id, token.Id); + userId, token.Id); } return Ok(); + + IActionResult PrivilegedAccountConflict() => Problem( + "This automation token created a privileged account; remove its privileged roles before deleting the token.", + statusCode: StatusCodes.Status409Conflict); } } diff --git a/API/Controller/Admin/AutomationTokenPatch.cs b/API/Controller/Admin/AutomationTokenPatch.cs index f3e89e50..09012466 100644 --- a/API/Controller/Admin/AutomationTokenPatch.cs +++ b/API/Controller/Admin/AutomationTokenPatch.cs @@ -5,14 +5,13 @@ using OpenShock.Common.Models; using OpenShock.Common.OpenShockDb; using OpenShock.Common.Services.Audit; -using OpenShock.Common.Services.AutomationTokens; namespace OpenShock.API.Controller.Admin; public sealed partial class AdminController { /// - /// Updates an automation token. Other API instances may keep the old settings for up to 30 seconds + /// Updates an automation token /// [HttpPatch("automationTokens/{id}")] [Consumes(MediaTypeNames.Application.Json)] @@ -21,7 +20,6 @@ public sealed partial class AdminController public async Task PatchAutomationToken( [FromRoute] Guid id, [FromBody] PatchAutomationTokenDto body, - [FromServices] IAutomationTokenService automationTokens, [FromServices] IAuditService auditService, CancellationToken ct) { @@ -30,20 +28,20 @@ public async Task PatchAutomationToken( var token = await _db.AutomationTokens.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.AutoCleanupAfter is not null) token.AutoCleanupAfter = body.AutoCleanupAfter; - if (body.AutoCleanupUsers is not null) - { - token.AutoCleanupUsers = body.AutoCleanupUsers.Value; + var autoCleanupUsers = body.AutoCleanupUsers ?? token.AutoCleanupUsers; - // Switching cleanup off without naming a delay clears the delay, so it can be unset at all. - if (!token.AutoCleanupUsers && body.AutoCleanupAfter is null) token.AutoCleanupAfter = null; - } + // Switching cleanup off without naming a delay clears the delay, so it can be unset at all. + var autoCleanupAfter = body.AutoCleanupAfter ?? + (body.AutoCleanupUsers == false ? null : token.AutoCleanupAfter); - if (token.AutoCleanupUsers && token.AutoCleanupAfter is null) + if (autoCleanupUsers && autoCleanupAfter is null) return Problem("AutoCleanupAfter is required when AutoCleanupUsers is true.", statusCode: StatusCodes.Status400BadRequest); + if (body.Name is not null) token.Name = body.Name.Trim(); + if (body.Types is not null) token.Types = [.. body.Types.Distinct()]; + token.AutoCleanupUsers = autoCleanupUsers; + token.AutoCleanupAfter = autoCleanupAfter; + await using var transaction = await _db.Database.BeginTransactionAsync(ct); await _db.SaveChangesAsync(ct); @@ -57,8 +55,6 @@ await auditService.LogAsync( await transaction.CommitAsync(ct); - automationTokens.InvalidateCache(); - return Ok(AutomationTokenDto.FromEntity(token)); } } diff --git a/API/Controller/Admin/AutomationTokenRotate.cs b/API/Controller/Admin/AutomationTokenRotate.cs index 5aca785c..482e7a57 100644 --- a/API/Controller/Admin/AutomationTokenRotate.cs +++ b/API/Controller/Admin/AutomationTokenRotate.cs @@ -12,15 +12,13 @@ namespace OpenShock.API.Controller.Admin; public sealed partial class AdminController { /// - /// Rotates the secret of an automation token. The new secret is only returned once, in this response. - /// Other API instances may keep accepting the old secret for up to 30 seconds + /// Rotates the secret of an automation token. The new secret is only returned once, in this response /// [HttpPost("automationTokens/{id}/rotate")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task RotateAutomationToken( [FromRoute] Guid id, - [FromServices] IAutomationTokenService automationTokens, [FromServices] IAuditService auditService, CancellationToken ct) { @@ -48,8 +46,6 @@ await auditService.LogAsync( await transaction.CommitAsync(ct); - automationTokens.InvalidateCache(); - // Accounts the token created stay linked to it (and to its cleanup schedule) across rotations, // since the token id doesn't change. diff --git a/API/Controller/Tokens/ReportTokens.cs b/API/Controller/Tokens/ReportTokens.cs index c41c4102..a5f2289a 100644 --- a/API/Controller/Tokens/ReportTokens.cs +++ b/API/Controller/Tokens/ReportTokens.cs @@ -9,7 +9,6 @@ using Microsoft.AspNetCore.RateLimiting; using OpenShock.API.Errors; using OpenShock.API.Services.Turnstile; -using OpenShock.Common.Extensions; using OpenShock.Common.OpenShockDb; using OpenShock.Common.Results; using OpenShock.Common.Services.AutomationTokens; @@ -54,7 +53,7 @@ public async Task ReportTokens( return Problem(new OpenShockProblem("InternalServerError", "Internal Server Error", HttpStatusCode.InternalServerError)); } - // The middleware only screens cookie sessions for privileged accounts; this also covers API-token callers. + // The middleware already refused the token for a privileged session; this audits the use. if (!await automationTokens.TryRecordUseAsync(CurrentUser.Id, AutomationTokenFlow.ReportTokens, cancellationToken)) return Problem(AutomationTokenError.NotAllowedForAccount); diff --git a/API/Services/Account/AccountService.cs b/API/Services/Account/AccountService.cs index de1c043d..a0241a67 100644 --- a/API/Services/Account/AccountService.cs +++ b/API/Services/Account/AccountService.cs @@ -91,7 +91,7 @@ private async Task IsEmailProviderBlacklisted(string email) return await _db.EmailProviderBlacklists.AnyAsync(e => e.Domain == domain); } - private async Task CreateAccount(string email, string username, string password, bool verifyOnCreation) + private async Task CreateAccount(string email, string username, string password, bool verifyOnCreation, Guid? createdByAutomationTokenId) { email = email.ToLowerInvariant(); @@ -106,7 +106,8 @@ private async Task CreateAccount(string email, string use Id = Guid.CreateVersion7(), Name = username, Email = email, - PasswordHash = HashingUtils.HashPassword(password) + PasswordHash = HashingUtils.HashPassword(password), + CreatedByAutomationTokenId = createdByAutomationTokenId }; _db.Users.Add(user); @@ -124,13 +125,13 @@ await _db.Users } /// - public async Task CreateAccountWithActivationFlowAsync(string email, string username, string password) + public async Task CreateAccountWithActivationFlowAsync(string email, string username, string password, Guid? createdByAutomationTokenId = null) { // With mail disabled the activation email is never delivered, so an activation flow would leave // the account permanently unusable. Activate on creation instead. var mailEnabled = _mailOptions.IsEnabled; - var accountCreate = await CreateAccount(email, username, password, !mailEnabled); + var accountCreate = await CreateAccount(email, username, password, !mailEnabled, createdByAutomationTokenId); if (accountCreate.Value is not User user || !mailEnabled) return accountCreate; // The real activation token is minted by the outbox delivery job at send time; here we record the diff --git a/API/Services/Account/IAccountService.cs b/API/Services/Account/IAccountService.cs index 9a801d9c..4eb834e5 100644 --- a/API/Services/Account/IAccountService.cs +++ b/API/Services/Account/IAccountService.cs @@ -15,8 +15,9 @@ public interface IAccountService /// /// /// + /// The automation token creating the account, which makes it an automated account /// - public Task CreateAccountWithActivationFlowAsync(string email, string username, string password); + public Task CreateAccountWithActivationFlowAsync(string email, string username, string password, Guid? createdByAutomationTokenId = null); /// /// Creates an OAuth-only (passwordless) account and links the external identity in a single transaction. diff --git a/API/Services/Turnstile/CloudflareTurnstileService.cs b/API/Services/Turnstile/CloudflareTurnstileService.cs index 6ae33e58..cc7bd4d5 100644 --- a/API/Services/Turnstile/CloudflareTurnstileService.cs +++ b/API/Services/Turnstile/CloudflareTurnstileService.cs @@ -55,7 +55,7 @@ public async Task> VerifyUserResponse // An admin-issued automation token resolved earlier in the pipeline counts as a Turnstile pass // if it carries the Turnstile type. Controllers separately check, through IAutomationTokenService, - // that the account they act on was created by that token. + // that the account they act on is not privileged. if (_httpContextAccessor.HttpContext?.IsBypassed(AutomationTokenType.Turnstile) ?? false) return new Success(); diff --git a/Common/Middleware/AutomationTokenMiddleware.cs b/Common/Middleware/AutomationTokenMiddleware.cs index 2e170499..88c819ce 100644 --- a/Common/Middleware/AutomationTokenMiddleware.cs +++ b/Common/Middleware/AutomationTokenMiddleware.cs @@ -1,5 +1,5 @@ -using System.Security.Claims; using OpenShock.Common.Extensions; +using OpenShock.Common.OpenShockDb; using OpenShock.Common.Services.AutomationTokens; using Microsoft.Extensions.Logging; @@ -39,16 +39,14 @@ public async Task InvokeAsync(HttpContext context, IAutomationTokenService autom return; } - // Authentication already ran, so a signed-in caller is known here. An automation token never applies - // to a privileged account, so it is dropped for one, rate limits and Turnstile included. - // Flows that pick the account from the request body (login, password reset) check the same rule. - if (context.User.FindFirstValue(ClaimTypes.NameIdentifier) is { } userIdClaim && - Guid.TryParse(userIdClaim, out var userId) && - !await automationTokens.IsAllowedForUserAsync(userId, context.RequestAborted)) + // An automation token never applies to a privileged account, so it is dropped for one, rate limits + // and Turnstile included. Flows that pick the account from the request body (login, password reset) + // check the same rule. + if (await IsPrivilegedCallerAsync(context, automationTokens)) { logger.LogWarning( - "Automation token {AutomationTokenName} ({AutomationTokenId}) refused for privileged user {UserId}", - ForLog(resolved.Name), resolved.Id, userId); + "Automation token {AutomationTokenName} ({AutomationTokenId}) refused for a privileged account on {Method} {Path}", + ForLog(resolved.Name), resolved.Id, ForLog(context.Request.Method), ForLog(context.Request.Path.Value)); await _next(context); return; } @@ -67,6 +65,19 @@ public async Task InvokeAsync(HttpContext context, IAutomationTokenService autom await _next(context); } + private static async Task IsPrivilegedCallerAsync(HttpContext context, IAutomationTokenService automationTokens) + { + // Authentication already ran for the default scheme, so a cookie session's roles are on its claims. + if (context.User.TryGetOpenShockUserIdentity() is { } identity) + return PrivilegedRoles.Any(identity.GetRoles()); + + // API tokens are only authenticated once an endpoint asks for that scheme, which is after this runs. + if (context.TryGetApiTokenFromHeader(out var apiToken)) + return await automationTokens.IsApiTokenOwnerPrivilegedAsync(apiToken, context.RequestAborted); + + return false; + } + // An unmatched token needs no credential to present, so the warning it produces is volume an // unauthenticated caller controls. Keep the audit signal, but collapse it to at most one line // per window and report how many attempts that line stands for. diff --git a/Common/Migrations/20260930204951_AddAutomationTokens.Designer.cs b/Common/Migrations/20260930204951_AddAutomationTokens.Designer.cs index 7c4458a6..c6f41cae 100644 --- a/Common/Migrations/20260930204951_AddAutomationTokens.Designer.cs +++ b/Common/Migrations/20260930204951_AddAutomationTokens.Designer.cs @@ -15,7 +15,7 @@ namespace OpenShock.Common.Migrations; [DbContext(typeof(MigrationOpenShockContext))] [Migration("20260930204951_AddAutomationTokens")] -partial class _20260930204951_AddAutomationTokens +partial class AddAutomationTokens { /// protected override void BuildTargetModel(ModelBuilder modelBuilder) @@ -1524,7 +1524,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.HasOne("OpenShock.Common.OpenShockDb.AutomationToken", "CreatedByAutomationToken") .WithMany("CreatedUsers") .HasForeignKey("CreatedByAutomationTokenId") - .OnDelete(DeleteBehavior.Cascade) + .OnDelete(DeleteBehavior.Restrict) .HasConstraintName("fk_users_created_by_automation_token_id"); b.Navigation("CreatedByAutomationToken"); diff --git a/Common/Migrations/20260930204951_AddAutomationTokens.cs b/Common/Migrations/20260930204951_AddAutomationTokens.cs index dac188de..141fb21d 100644 --- a/Common/Migrations/20260930204951_AddAutomationTokens.cs +++ b/Common/Migrations/20260930204951_AddAutomationTokens.cs @@ -8,7 +8,7 @@ namespace OpenShock.Common.Migrations; /// -public partial class _20260930204951_AddAutomationTokens : Migration +public partial class AddAutomationTokens : Migration { // Same as AddOAuthSupport's view, plus users.created_by_automation_token_id public const string Query_Create_AdminUsersView = @@ -141,7 +141,7 @@ protected override void Up(MigrationBuilder migrationBuilder) column: "created_by_automation_token_id", principalTable: "automation_tokens", principalColumn: "id", - onDelete: ReferentialAction.Cascade); + onDelete: ReferentialAction.Restrict); migrationBuilder.Sql(Query_Create_AdminUsersView); } diff --git a/Common/Migrations/OpenShockContextModelSnapshot.cs b/Common/Migrations/OpenShockContextModelSnapshot.cs index ced7e1c7..a6e656a0 100644 --- a/Common/Migrations/OpenShockContextModelSnapshot.cs +++ b/Common/Migrations/OpenShockContextModelSnapshot.cs @@ -1526,7 +1526,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasOne("OpenShock.Common.OpenShockDb.AutomationToken", "CreatedByAutomationToken") .WithMany("CreatedUsers") .HasForeignKey("CreatedByAutomationTokenId") - .OnDelete(DeleteBehavior.Cascade) + .OnDelete(DeleteBehavior.Restrict) .HasConstraintName("fk_users_created_by_automation_token_id"); b.Navigation("CreatedByAutomationToken"); diff --git a/Common/OpenShockDb/OpenShockContext.cs b/Common/OpenShockDb/OpenShockContext.cs index b2a6efd2..ea03bab8 100644 --- a/Common/OpenShockDb/OpenShockContext.cs +++ b/Common/OpenShockDb/OpenShockContext.cs @@ -118,7 +118,6 @@ public static void ConfigureOptionsBuilder(DbContextOptionsBuilder optionsBuilde public DbSet EmailOutbox { get; set; } public DbSet AutomationTokens { get; set; } - public DbSet DataProtectionKeys { get; set; } @@ -636,7 +635,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.HasOne(d => d.CreatedByAutomationToken).WithMany(p => p.CreatedUsers) .HasForeignKey(d => d.CreatedByAutomationTokenId) - .OnDelete(DeleteBehavior.Cascade) + .OnDelete(DeleteBehavior.Restrict) .HasConstraintName("fk_users_created_by_automation_token_id"); }); diff --git a/Common/OpenShockDb/PrivilegedRoles.cs b/Common/OpenShockDb/PrivilegedRoles.cs new file mode 100644 index 00000000..8be5165d --- /dev/null +++ b/Common/OpenShockDb/PrivilegedRoles.cs @@ -0,0 +1,19 @@ +namespace OpenShock.Common.OpenShockDb; + +/// +/// The roles that make an account privileged: automation tokens never apply to such an account, and +/// automated-account cleanup never deletes one. +/// +/// +/// Npgsql can't translate LINQ predicates over the roles enum array (Roles.Any(...) and +/// Roles.Contains(...) both fail), so queries read the roles and check them with , +/// or pass to SQL as a role_type[] parameter. +/// +public static class PrivilegedRoles +{ + public static readonly RoleType[] All = [RoleType.Staff, RoleType.Admin, RoleType.System]; + + public static bool Contains(RoleType role) => role is RoleType.Staff or RoleType.Admin or RoleType.System; + + public static bool Any(IEnumerable roles) => roles.Any(Contains); +} diff --git a/Common/Services/AutomationTokens/AutomationTokenService.cs b/Common/Services/AutomationTokens/AutomationTokenService.cs index e6d99b35..185f8494 100644 --- a/Common/Services/AutomationTokens/AutomationTokenService.cs +++ b/Common/Services/AutomationTokens/AutomationTokenService.cs @@ -1,6 +1,4 @@ -using System.Collections.Frozen; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Caching.Memory; using OpenShock.Common.Extensions; using OpenShock.Common.Models; using OpenShock.Common.OpenShockDb; @@ -12,109 +10,78 @@ namespace OpenShock.Common.Services.AutomationTokens; public sealed class AutomationTokenService : IAutomationTokenService { - private static readonly TimeSpan CacheDuration = TimeSpan.FromSeconds(30); - private const string TokensCacheKey = "automation-tokens"; - private const string UserAllowedCacheKey = "automation-token-user-allowed"; - private readonly OpenShockContext _db; - private readonly IMemoryCache _cache; private readonly IBatchUpdateService _batchUpdateService; private readonly IAuditService _auditService; private readonly IHttpContextAccessor _httpContextAccessor; public AutomationTokenService( OpenShockContext db, - IMemoryCache cache, IBatchUpdateService batchUpdateService, IAuditService auditService, IHttpContextAccessor httpContextAccessor) { _db = db; - _cache = cache; _batchUpdateService = batchUpdateService; _auditService = auditService; _httpContextAccessor = httpContextAccessor; } + public ResolvedAutomationToken? Current => _httpContextAccessor.HttpContext?.GetResolvedAutomationToken(); + public async Task ResolveAsync(string secret, CancellationToken ct) { - if (string.IsNullOrEmpty(secret) || secret.Length != IAutomationTokenService.SecretLength) return null; + if (secret.Length != IAutomationTokenService.SecretLength) return null; - var tokens = await GetTokensAsync(ct); + var tokenHash = HashingUtils.HashToken(secret); - return tokens.GetValueOrDefault(HashingUtils.HashToken(secret)); + return await _db.AutomationTokens + .AsNoTracking() + .Where(t => t.TokenHash == tokenHash) + .Select(t => new ResolvedAutomationToken(t.Id, t.Name, t.Types)) + .FirstOrDefaultAsync(ct); } - private async Task> GetTokensAsync(CancellationToken ct) + public async Task IsApiTokenOwnerPrivilegedAsync(string apiToken, CancellationToken ct) { - var tokens = await _cache.GetOrCreateAsync(TokensCacheKey, async entry => - { - entry.AbsoluteExpirationRelativeToNow = CacheDuration; - - var rows = await _db.AutomationTokens - .AsNoTracking() - .Select(t => new { t.Id, t.Name, t.TokenHash, t.Types }) - .ToListAsync(ct); - - return rows.ToFrozenDictionary( - t => t.TokenHash, - t => new ResolvedAutomationToken(t.Id, t.Name, t.Types), - StringComparer.Ordinal); - }); - - return tokens!; - } - - public void InvalidateCache() => _cache.Remove(TokensCacheKey); + var tokenHash = HashingUtils.HashToken(apiToken); - public void RecordRequest(Guid automationTokenId) => _batchUpdateService.UpdateAutomationTokenUsed(automationTokenId); + var roles = await _db.ApiTokens + .Where(t => t.TokenHash == tokenHash) + .Select(t => t.User.Roles) + .FirstOrDefaultAsync(ct); - public async Task IsAllowedForUserAsync(Guid userId, CancellationToken ct) - { - return await _cache.GetOrCreateAsync((UserAllowedCacheKey, userId), async entry => - { - entry.AbsoluteExpirationRelativeToNow = CacheDuration; - - // The same roles AccountService.DeleteAccountAsync treats as privileged. - return !await _db.Users.AnyAsync(u => - u.Id == userId && - u.Roles.Any(r => r == RoleType.Staff || r == RoleType.Admin || r == RoleType.System), ct); - }); + return roles is not null && PrivilegedRoles.Any(roles); } + public void RecordRequest(Guid automationTokenId) => _batchUpdateService.UpdateAutomationTokenUsed(automationTokenId); + public async Task RecordSignupAsync(Guid userId, CancellationToken ct) { - var automationToken = _httpContextAccessor.HttpContext?.GetResolvedAutomationToken(); - if (automationToken is null) return; - - await _db.Users - .Where(u => u.Id == userId) - .ExecuteUpdateAsync(s => s.SetProperty(u => u.CreatedByAutomationTokenId, automationToken.Id), ct); + if (Current is not { } automationToken) return; await AuditUseAsync(automationToken, userId, AutomationTokenFlow.Signup, ct); } public async Task CanUseForLoginAsync(string usernameOrEmail, CancellationToken ct) { - var automationToken = _httpContextAccessor.HttpContext?.GetResolvedAutomationToken(); - if (automationToken is null) return true; + if (Current is null) return true; // Same lookup as AccountService.GetAccountByCredentialsAsync var lowercaseUsernameOrEmail = usernameOrEmail.ToLowerInvariant(); - var userId = await _db.Users + var roles = await _db.Users .Where(u => u.Email == lowercaseUsernameOrEmail || u.Name == lowercaseUsernameOrEmail) - .Select(u => (Guid?)u.Id) + .Select(u => u.Roles) .FirstOrDefaultAsync(ct); - return userId is null || await IsAllowedForUserAsync(userId.Value, ct); + return roles is null || !PrivilegedRoles.Any(roles); } public async Task TryRecordUseAsync(Guid userId, AutomationTokenFlow flow, CancellationToken ct) { - var automationToken = _httpContextAccessor.HttpContext?.GetResolvedAutomationToken(); - if (automationToken is null) return true; + if (Current is not { } automationToken) return true; - if (!await IsAllowedForUserAsync(userId, ct)) return false; + if (await IsPrivilegedAsync(userId, ct)) return false; await AuditUseAsync(automationToken, userId, flow, ct); @@ -123,8 +90,7 @@ public async Task TryRecordUseAsync(Guid userId, AutomationTokenFlow flow, public async Task TryRecordUseByEmailAsync(string email, AutomationTokenFlow flow, CancellationToken ct) { - var automationToken = _httpContextAccessor.HttpContext?.GetResolvedAutomationToken(); - if (automationToken is null) return true; + if (Current is null) return true; email = email.ToLowerInvariant(); var userId = await _db.Users @@ -137,6 +103,16 @@ public async Task TryRecordUseByEmailAsync(string email, AutomationTokenFl return await TryRecordUseAsync(userId.Value, flow, ct); } + private async Task IsPrivilegedAsync(Guid userId, CancellationToken ct) + { + var roles = await _db.Users + .Where(u => u.Id == userId) + .Select(u => u.Roles) + .FirstOrDefaultAsync(ct); + + return roles is not null && PrivilegedRoles.Any(roles); + } + private Task AuditUseAsync(ResolvedAutomationToken automationToken, Guid userId, AutomationTokenFlow flow, CancellationToken ct) => _auditService.LogAsync( userId, diff --git a/Common/Services/AutomationTokens/IAutomationTokenService.cs b/Common/Services/AutomationTokens/IAutomationTokenService.cs index 94f98eee..ea4b7b2f 100644 --- a/Common/Services/AutomationTokens/IAutomationTokenService.cs +++ b/Common/Services/AutomationTokens/IAutomationTokenService.cs @@ -4,10 +4,10 @@ namespace OpenShock.Common.Services.AutomationTokens; /// -/// An automation token may be used with any account except privileged ones (, -/// , ). Every use in an account flow is written to -/// that account's audit log. Accounts the token itself created are linked to it, which is what makes them -/// automated accounts; only those are ever eligible for auto-cleanup, and they are deleted with the token. +/// An automation token may be used with any account except privileged ones (see ). +/// Every use in an account flow is written to that account's audit log. Accounts the token itself created are +/// linked to it, which is what makes them automated accounts; only those are ever eligible for auto-cleanup, +/// and they are deleted with the token. /// public interface IAutomationTokenService { @@ -24,31 +24,31 @@ public interface IAutomationTokenService static string GenerateSecret() => CryptoUtils.RandomString(SecretLength); /// - /// Hashes the supplied secret and looks it up in a short-lived in-memory snapshot of all automation - /// tokens, so neither valid nor bogus secrets cost a database round trip per request. - /// Changes made on another instance take up to the cache lifetime to be seen here. + /// The automation token the current request resolved, or null if it presented none (or one + /// that was refused). /// - Task ResolveAsync(string secret, CancellationToken ct); + ResolvedAutomationToken? Current { get; } /// - /// True if holds no privileged role. Cached briefly, since the middleware - /// asks this for every bypassed request of a signed-in user. + /// Looks up the token the supplied secret belongs to. Secrets of the wrong length are rejected + /// without a database round trip. /// - Task IsAllowedForUserAsync(Guid userId, CancellationToken ct); + Task ResolveAsync(string secret, CancellationToken ct); /// - /// Counts one accepted request against the token. Batched, never written per request. + /// True if belongs to a privileged account. Used before the API-token + /// scheme has authenticated the request, so the token itself is not validated here. /// - void RecordRequest(Guid automationTokenId); + Task IsApiTokenOwnerPrivilegedAsync(string apiToken, CancellationToken ct); /// - /// Drops this instance's cached token snapshot, so an admin change applies here immediately. + /// Counts one accepted request against the token. Batched, never written per request. /// - void InvalidateCache(); + void RecordRequest(Guid automationTokenId); /// - /// If the current request resolved an automation token, links the new account to it, making it an - /// automated account, and audits the use. Only such accounts are ever eligible for the token's auto-cleanup. + /// If the current request resolved an automation token, audits its use to create . + /// The account itself is linked to the token when it is created. /// Task RecordSignupAsync(Guid userId, CancellationToken ct); diff --git a/Cron.IntegrationTests/Tests/DeleteExpiredAutomatedAccountsTests.cs b/Cron.IntegrationTests/Tests/DeleteExpiredAutomatedAccountsTests.cs new file mode 100644 index 00000000..2ba8cd40 --- /dev/null +++ b/Cron.IntegrationTests/Tests/DeleteExpiredAutomatedAccountsTests.cs @@ -0,0 +1,89 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using OpenShock.Common.OpenShockDb; +using OpenShock.Common.Utils; +using OpenShock.Cron.Jobs; + +namespace OpenShock.Cron.IntegrationTests.Tests; + +/// +/// Runs the automated-account cleanup against real Postgres, so the interval arithmetic, the join to the +/// token and the raw delete's role_type[] parameter are exercised, not just the LINQ shape. +/// +public sealed class DeleteExpiredAutomatedAccountsTests +{ + [ClassDataSource(Shared = SharedType.PerTestSession)] + public required CronApplicationFactory Factory { get; init; } + + [Test] + public async Task Execute_DeletesOnlyExpiredNonPrivilegedAccountsOfCleanupTokens() + { + var cleanupToken = Guid.CreateVersion7(); + var keepToken = Guid.CreateVersion7(); + var longAgo = DateTime.UtcNow - TimeSpan.FromHours(2); + + var expired = Guid.CreateVersion7(); + var expiredStaff = Guid.CreateVersion7(); + var fresh = Guid.CreateVersion7(); + var expiredOfKeepToken = Guid.CreateVersion7(); + var expiredUnlinked = Guid.CreateVersion7(); + + await using (var db = await Factory.DbContextFactory.CreateDbContextAsync()) + { + db.AutomationTokens.Add(NewToken(cleanupToken, autoCleanup: true)); + db.AutomationTokens.Add(NewToken(keepToken, autoCleanup: false)); + + db.Users.Add(NewUser(expired, longAgo, cleanupToken, [])); + db.Users.Add(NewUser(expiredStaff, longAgo, cleanupToken, [RoleType.Staff])); + db.Users.Add(NewUser(fresh, DateTime.UtcNow, cleanupToken, [])); + db.Users.Add(NewUser(expiredOfKeepToken, longAgo, keepToken, [])); + db.Users.Add(NewUser(expiredUnlinked, longAgo, null, [])); + + await db.SaveChangesAsync(); + } + + await using var scope = Factory.Services.CreateAsyncScope(); + var job = ActivatorUtilities.CreateInstance(scope.ServiceProvider); + + var deleted = await job.Execute(); + + await using var verify = await Factory.DbContextFactory.CreateDbContextAsync(); + var remaining = await verify.Users + .Where(u => new[] { expired, expiredStaff, fresh, expiredOfKeepToken, expiredUnlinked }.Contains(u.Id)) + .Select(u => u.Id) + .ToListAsync(); + + await Assert.That(remaining).DoesNotContain(expired); + await Assert.That(remaining).Contains(expiredStaff); + await Assert.That(remaining).Contains(fresh); + await Assert.That(remaining).Contains(expiredOfKeepToken); + await Assert.That(remaining).Contains(expiredUnlinked); + await Assert.That(deleted).IsGreaterThanOrEqualTo(1); + } + + private static AutomationToken NewToken(Guid id, bool autoCleanup) => new() + { + Id = id, + Name = "cleanup-test", + TokenHash = HashingUtils.HashToken(Guid.NewGuid().ToString("N")), + Types = [AutomationTokenType.Turnstile], + AutoCleanupUsers = autoCleanup, + AutoCleanupAfter = TimeSpan.FromHours(1) + }; + + private static User NewUser(Guid id, DateTime createdAt, Guid? tokenId, List roles) + { + var suffix = Guid.NewGuid().ToString("N")[..12]; + return new User + { + Id = id, + Name = $"auto{suffix}", + Email = $"auto-{suffix}@test.org", + SecurityStamp = Guid.CreateVersion7(), + Roles = roles, + CreatedAt = createdAt, + ActivatedAt = createdAt, + CreatedByAutomationTokenId = tokenId + }; + } +} diff --git a/Cron/Jobs/DeleteExpiredAutomatedAccountsJob.cs b/Cron/Jobs/DeleteExpiredAutomatedAccountsJob.cs index 05a7e136..52003c0f 100644 --- a/Cron/Jobs/DeleteExpiredAutomatedAccountsJob.cs +++ b/Cron/Jobs/DeleteExpiredAutomatedAccountsJob.cs @@ -26,32 +26,47 @@ public async Task Execute() { var now = DateTime.UtcNow; - // The same roles AccountService.DeleteAccountAsync treats as privileged. - var candidates = await _db.Users + var expired = await _db.Users .Where(u => u.CreatedByAutomationToken != null && u.CreatedByAutomationToken.AutoCleanupUsers && u.CreatedByAutomationToken.AutoCleanupAfter != null - && u.CreatedAt + u.CreatedByAutomationToken.AutoCleanupAfter < now - && !u.Roles.Any(r => r == RoleType.Staff || r == RoleType.Admin || r == RoleType.System)) - .Select(u => new { u.Id, u.CreatedByAutomationTokenId }) + && u.CreatedAt + u.CreatedByAutomationToken.AutoCleanupAfter < now) + .Select(u => new { u.Id, u.CreatedByAutomationTokenId, u.Roles }) .ToListAsync(); + var candidates = expired.Where(u => !PrivilegedRoles.Any(u.Roles)).ToList(); if (candidates.Count == 0) { _logger.LogDebug("No automation-token-created accounts eligible for cleanup"); return 0; } - var userIds = candidates.Select(c => c.Id).ToArray(); + var candidateIds = candidates.Select(c => c.Id).ToArray(); - // Re-checked in the delete itself, in case an account was promoted since it was selected. - int nDeleted = await _db.Users - .Where(u => userIds.Contains(u.Id) && u.CreatedByAutomationTokenId != null && !u.Roles.Any(r => r == RoleType.Staff || r == RoleType.Admin || r == RoleType.System)) - .ExecuteDeleteAsync(); + // The roles and the token's cleanup setting are checked again as the rows are deleted, in case an account + // was promoted or its token's cleanup switched off since it was selected. + await _db.Database.ExecuteSqlAsync($""" + DELETE FROM users AS u + USING automation_tokens AS t + WHERE u.id = ANY({candidateIds}) + AND t.id = u.created_by_automation_token_id + AND t.auto_cleanup_users + AND NOT (u.roles && {PrivilegedRoles.All}) + """); + + // The delete can't say which rows it removed, so read back the candidates the re-check kept. + var kept = await _db.Users + .Where(u => candidateIds.Contains(u.Id)) + .Select(u => u.Id) + .ToHashSetAsync(); // A user's audit log is deleted with the user, so the log line is the lasting record of what went. + var nDeleted = 0; foreach (var candidate in candidates) { + if (kept.Contains(candidate.Id)) continue; + + nDeleted++; _logger.LogInformation( "Automation-token cleanup deleted account {UserId} created by automation token {AutomationTokenId}", candidate.Id, candidate.CreatedByAutomationTokenId); @@ -60,7 +75,7 @@ public async Task Execute() _logger.LogInformation( "Automation-token cleanup: {DeletedCount}/{CandidateCount} accounts deleted", nDeleted, - userIds.Length); + candidates.Count); return nDeleted; } From a108957f55eaa8e15f9866f87417e9bf6fb1444b Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Thu, 1 Oct 2026 23:36:44 +0200 Subject: [PATCH 15/16] refactor: delete expired automated accounts in a single statement Replace the select/delete/read-back sequence with one DELETE ... RETURNING that rechecks the full expiry predicate, and drop the unisolated deleted-count assertion from the integration test. --- .../DeleteExpiredAutomatedAccountsTests.cs | 3 +- .../Jobs/DeleteExpiredAutomatedAccountsJob.cs | 58 ++++++------------- 2 files changed, 20 insertions(+), 41 deletions(-) diff --git a/Cron.IntegrationTests/Tests/DeleteExpiredAutomatedAccountsTests.cs b/Cron.IntegrationTests/Tests/DeleteExpiredAutomatedAccountsTests.cs index 2ba8cd40..9ea04479 100644 --- a/Cron.IntegrationTests/Tests/DeleteExpiredAutomatedAccountsTests.cs +++ b/Cron.IntegrationTests/Tests/DeleteExpiredAutomatedAccountsTests.cs @@ -45,7 +45,7 @@ public async Task Execute_DeletesOnlyExpiredNonPrivilegedAccountsOfCleanupTokens await using var scope = Factory.Services.CreateAsyncScope(); var job = ActivatorUtilities.CreateInstance(scope.ServiceProvider); - var deleted = await job.Execute(); + await job.Execute(); await using var verify = await Factory.DbContextFactory.CreateDbContextAsync(); var remaining = await verify.Users @@ -58,7 +58,6 @@ public async Task Execute_DeletesOnlyExpiredNonPrivilegedAccountsOfCleanupTokens await Assert.That(remaining).Contains(fresh); await Assert.That(remaining).Contains(expiredOfKeepToken); await Assert.That(remaining).Contains(expiredUnlinked); - await Assert.That(deleted).IsGreaterThanOrEqualTo(1); } private static AutomationToken NewToken(Guid id, bool autoCleanup) => new() diff --git a/Cron/Jobs/DeleteExpiredAutomatedAccountsJob.cs b/Cron/Jobs/DeleteExpiredAutomatedAccountsJob.cs index 52003c0f..4cab6684 100644 --- a/Cron/Jobs/DeleteExpiredAutomatedAccountsJob.cs +++ b/Cron/Jobs/DeleteExpiredAutomatedAccountsJob.cs @@ -26,57 +26,37 @@ public async Task Execute() { var now = DateTime.UtcNow; - var expired = await _db.Users - .Where(u => u.CreatedByAutomationToken != null - && u.CreatedByAutomationToken.AutoCleanupUsers - && u.CreatedByAutomationToken.AutoCleanupAfter != null - && u.CreatedAt + u.CreatedByAutomationToken.AutoCleanupAfter < now) - .Select(u => new { u.Id, u.CreatedByAutomationTokenId, u.Roles }) - .ToListAsync(); - - var candidates = expired.Where(u => !PrivilegedRoles.Any(u.Roles)).ToList(); - if (candidates.Count == 0) - { - _logger.LogDebug("No automation-token-created accounts eligible for cleanup"); - return 0; - } - - var candidateIds = candidates.Select(c => c.Id).ToArray(); - - // The roles and the token's cleanup setting are checked again as the rows are deleted, in case an account - // was promoted or its token's cleanup switched off since it was selected. - await _db.Database.ExecuteSqlAsync($""" + // Selecting and deleting in one statement means an account promoted, or a token whose cleanup was switched + // off, between a read and the delete can't slip through. + var deleted = await _db.Database.SqlQuery($""" DELETE FROM users AS u USING automation_tokens AS t - WHERE u.id = ANY({candidateIds}) - AND t.id = u.created_by_automation_token_id + WHERE t.id = u.created_by_automation_token_id AND t.auto_cleanup_users + AND t.auto_cleanup_after IS NOT NULL + AND u.created_at + t.auto_cleanup_after < {now} AND NOT (u.roles && {PrivilegedRoles.All}) - """); + RETURNING u.id AS "UserId", u.created_by_automation_token_id AS "AutomationTokenId" + """).ToListAsync(); - // The delete can't say which rows it removed, so read back the candidates the re-check kept. - var kept = await _db.Users - .Where(u => candidateIds.Contains(u.Id)) - .Select(u => u.Id) - .ToHashSetAsync(); + if (deleted.Count == 0) + { + _logger.LogDebug("No automation-token-created accounts eligible for cleanup"); + return 0; + } // A user's audit log is deleted with the user, so the log line is the lasting record of what went. - var nDeleted = 0; - foreach (var candidate in candidates) + foreach (var account in deleted) { - if (kept.Contains(candidate.Id)) continue; - - nDeleted++; _logger.LogInformation( "Automation-token cleanup deleted account {UserId} created by automation token {AutomationTokenId}", - candidate.Id, candidate.CreatedByAutomationTokenId); + account.UserId, account.AutomationTokenId); } - _logger.LogInformation( - "Automation-token cleanup: {DeletedCount}/{CandidateCount} accounts deleted", - nDeleted, - candidates.Count); + _logger.LogInformation("Automation-token cleanup: {DeletedCount} accounts deleted", deleted.Count); - return nDeleted; + return deleted.Count; } + + private sealed record DeletedAccount(Guid UserId, Guid AutomationTokenId); } From 5f51cb8e5c7e30b47f932717c5c6785143bd7a90 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Fri, 2 Oct 2026 00:10:22 +0200 Subject: [PATCH 16/16] fix: address automation token review findings - Check privileged roles for hub and API-token identities authenticated by an endpoint's scheme, not only cookie sessions - Create an account and audit its automation-token signup in one transaction; notify the email outbox only after commit - Count token usage per secret so uses of a rotated-away secret are dropped instead of refilling the reset counters - Requeue usage counts a failed flush did not write - Lock automation tokens in the cleanup job so a concurrent change to cleanup settings is honored --- API/Controller/Account/SignupV2.cs | 11 ++--- API/Services/Account/AccountService.cs | 43 +++++++++++++------ API/Services/Account/IAccountService.cs | 4 +- .../Middleware/AutomationTokenMiddleware.cs | 12 +++++- .../AutomationTokenService.cs | 9 ++-- .../IAutomationTokenService.cs | 7 ++- .../ResolvedAutomationToken.cs | 4 +- .../BatchUpdate/BatchUpdateService.cs | 38 +++++++++++----- .../BatchUpdate/IBatchUpdateService.cs | 3 +- .../Jobs/DeleteExpiredAutomatedAccountsJob.cs | 17 +++++--- 10 files changed, 100 insertions(+), 48 deletions(-) diff --git a/API/Controller/Account/SignupV2.cs b/API/Controller/Account/SignupV2.cs index e8210f30..f666a0a5 100644 --- a/API/Controller/Account/SignupV2.cs +++ b/API/Controller/Account/SignupV2.cs @@ -10,7 +10,6 @@ using OpenShock.Common.OpenShockDb; using OpenShock.Common.Options; using OpenShock.Common.Problems; -using OpenShock.Common.Services.AutomationTokens; using OpenShock.Common.Results; using OpenShock.Internal.Common.Problems; @@ -25,7 +24,6 @@ public sealed partial class AccountController /// /// /// - /// /// /// User successfully signed up /// Username or email already exists @@ -40,7 +38,6 @@ public async Task SignUpV2( [FromBody] SignUpV2 body, [FromServices] ICloudflareTurnstileService turnstileService, [FromServices] AccountOptions accountOptions, - [FromServices] IAutomationTokenService automationTokens, CancellationToken cancellationToken) { if (!accountOptions.RegistrationEnabled) @@ -49,10 +46,10 @@ public async Task SignUpV2( var turnstileError = await VerifyTurnstileAsync(turnstileService, body.TurnstileResponse, cancellationToken); if (turnstileError is not null) return turnstileError; - // Created with an automation token, the account is linked to it on insert, which makes it an automated account. + // Created with an automation token, the account is linked to it and the use audited in the same transaction. var creationAction = await _accountService.CreateAccountWithActivationFlowAsync( - body.Email, body.Username, body.Password, automationTokens.Current?.Id); - if (creationAction is not User created) + body.Email, body.Username, body.Password, cancellationToken); + if (creationAction is not User _) { return creationAction switch { @@ -61,8 +58,6 @@ public async Task SignUpV2( }; } - await automationTokens.RecordSignupAsync(created.Id, cancellationToken); - return Ok(); } } \ No newline at end of file diff --git a/API/Services/Account/AccountService.cs b/API/Services/Account/AccountService.cs index a0241a67..47c7ef48 100644 --- a/API/Services/Account/AccountService.cs +++ b/API/Services/Account/AccountService.cs @@ -7,6 +7,7 @@ using OpenShock.Common.Options; using OpenShock.Common.Results; using OpenShock.Common.Services.Audit; +using OpenShock.Common.Services.AutomationTokens; using OpenShock.Common.Services.RedisPubSub; using OpenShock.Common.Services.Session; using OpenShock.Common.Utils; @@ -26,6 +27,7 @@ public sealed class AccountService : IAccountService private readonly IRedisPubService _redisPubService; private readonly ISessionService _sessionService; private readonly IAuditService _auditService; + private readonly IAutomationTokenService _automationTokens; private readonly MailOptions _mailOptions; private readonly ILogger _logger; @@ -36,14 +38,16 @@ public sealed class AccountService : IAccountService /// Used to notify the email outbox delivery job that mail was enqueued. /// /// + /// The automation token this request carries, if any; an account it creates is linked to it. /// Decides whether an activation link can ever reach the user. /// - public AccountService(OpenShockContext db, IRedisPubService redisPubService, ISessionService sessionService, IAuditService auditService, MailOptions mailOptions, ILogger logger) + public AccountService(OpenShockContext db, IRedisPubService redisPubService, ISessionService sessionService, IAuditService auditService, IAutomationTokenService automationTokens, MailOptions mailOptions, ILogger logger) { _db = db; _redisPubService = redisPubService; _sessionService = sessionService; _auditService = auditService; + _automationTokens = automationTokens; _mailOptions = mailOptions; _logger = logger; } @@ -125,27 +129,40 @@ await _db.Users } /// - public async Task CreateAccountWithActivationFlowAsync(string email, string username, string password, Guid? createdByAutomationTokenId = null) + public async Task CreateAccountWithActivationFlowAsync(string email, string username, string password, CancellationToken cancellationToken = default) { // With mail disabled the activation email is never delivered, so an activation flow would leave // the account permanently unusable. Activate on creation instead. var mailEnabled = _mailOptions.IsEnabled; - var accountCreate = await CreateAccount(email, username, password, !mailEnabled, createdByAutomationTokenId); - if (accountCreate.Value is not User user || !mailEnabled) return accountCreate; + // An account an automation token creates must never exist without the audit entry for that use, so the + // account, its activation request and the audit entry commit together. + await using var transaction = await _db.Database.BeginTransactionAsync(cancellationToken); + + // Created with an automation token, the account is linked to it on insert, which makes it an automated account. + var accountCreate = await CreateAccount(email, username, password, !mailEnabled, _automationTokens.Current?.Id); + if (accountCreate.Value is not User user) return accountCreate; - // The real activation token is minted by the outbox delivery job at send time; here we record the - // request (with a seeded hash) and durably enqueue the email. - user.UserActivationRequest = new UserActivationRequest + if (mailEnabled) { - UserId = user.Id, - TokenHash = SeedTokenHash() - }; + // The real activation token is minted by the outbox delivery job at send time; here we record the + // request (with a seeded hash) and durably enqueue the email. + user.UserActivationRequest = new UserActivationRequest + { + UserId = user.Id, + TokenHash = SeedTokenHash() + }; - _db.EmailOutbox.Add(EmailOutboxMessage.ForAccountActivation(user.Id, email, username)); + _db.EmailOutbox.Add(EmailOutboxMessage.ForAccountActivation(user.Id, email, username)); - await _db.SaveChangesAsync(); - await NotifyEmailOutboxAsync(); + await _db.SaveChangesAsync(cancellationToken); + } + + await _automationTokens.RecordSignupAsync(user.Id, cancellationToken); + + await transaction.CommitAsync(cancellationToken); + + if (mailEnabled) await NotifyEmailOutboxAsync(); return user; } diff --git a/API/Services/Account/IAccountService.cs b/API/Services/Account/IAccountService.cs index 4eb834e5..069019bb 100644 --- a/API/Services/Account/IAccountService.cs +++ b/API/Services/Account/IAccountService.cs @@ -15,9 +15,9 @@ public interface IAccountService /// /// /// - /// The automation token creating the account, which makes it an automated account + /// /// - public Task CreateAccountWithActivationFlowAsync(string email, string username, string password, Guid? createdByAutomationTokenId = null); + public Task CreateAccountWithActivationFlowAsync(string email, string username, string password, CancellationToken cancellationToken = default); /// /// Creates an OAuth-only (passwordless) account and links the external identity in a single transaction. diff --git a/Common/Middleware/AutomationTokenMiddleware.cs b/Common/Middleware/AutomationTokenMiddleware.cs index 88c819ce..0a6fb02c 100644 --- a/Common/Middleware/AutomationTokenMiddleware.cs +++ b/Common/Middleware/AutomationTokenMiddleware.cs @@ -1,3 +1,4 @@ +using System.Security.Claims; using OpenShock.Common.Extensions; using OpenShock.Common.OpenShockDb; using OpenShock.Common.Services.AutomationTokens; @@ -52,7 +53,7 @@ public async Task InvokeAsync(HttpContext context, IAutomationTokenService autom } context.SetResolvedAutomationToken(resolved); - automationTokens.RecordRequest(resolved.Id); + automationTokens.RecordRequest(resolved); // A credential that switches off Turnstile and rate limiting should never be used without // leaving a trace. Logged at warning so it stands out in a production log, and the secret @@ -71,7 +72,14 @@ private static async Task IsPrivilegedCallerAsync(HttpContext context, IAu if (context.User.TryGetOpenShockUserIdentity() is { } identity) return PrivilegedRoles.Any(identity.GetRoles()); - // API tokens are only authenticated once an endpoint asks for that scheme, which is after this runs. + // Authorization also ran, so an endpoint's own scheme (hub or API token) may have authenticated the + // request too. Those identities carry the owning account's id but no roles, so look the roles up. + if (context.User.Identities.FirstOrDefault(x => x.IsAuthenticated) is { } other && + Guid.TryParse(other.FindFirst(ClaimTypes.NameIdentifier)?.Value, out var userId)) + return await automationTokens.IsUserPrivilegedAsync(userId, context.RequestAborted); + + // Not authenticated yet, as on an anonymous endpoint, but the header still names the account the + // caller acts as. if (context.TryGetApiTokenFromHeader(out var apiToken)) return await automationTokens.IsApiTokenOwnerPrivilegedAsync(apiToken, context.RequestAborted); diff --git a/Common/Services/AutomationTokens/AutomationTokenService.cs b/Common/Services/AutomationTokens/AutomationTokenService.cs index 185f8494..7ee6d8e4 100644 --- a/Common/Services/AutomationTokens/AutomationTokenService.cs +++ b/Common/Services/AutomationTokens/AutomationTokenService.cs @@ -38,7 +38,7 @@ public AutomationTokenService( return await _db.AutomationTokens .AsNoTracking() .Where(t => t.TokenHash == tokenHash) - .Select(t => new ResolvedAutomationToken(t.Id, t.Name, t.Types)) + .Select(t => new ResolvedAutomationToken(t.Id, t.Name, t.Types, t.TokenHash)) .FirstOrDefaultAsync(ct); } @@ -54,7 +54,8 @@ public async Task IsApiTokenOwnerPrivilegedAsync(string apiToken, Cancella return roles is not null && PrivilegedRoles.Any(roles); } - public void RecordRequest(Guid automationTokenId) => _batchUpdateService.UpdateAutomationTokenUsed(automationTokenId); + public void RecordRequest(ResolvedAutomationToken automationToken) => + _batchUpdateService.UpdateAutomationTokenUsed(automationToken.Id, automationToken.TokenHash); public async Task RecordSignupAsync(Guid userId, CancellationToken ct) { @@ -81,7 +82,7 @@ public async Task TryRecordUseAsync(Guid userId, AutomationTokenFlow flow, { if (Current is not { } automationToken) return true; - if (await IsPrivilegedAsync(userId, ct)) return false; + if (await IsUserPrivilegedAsync(userId, ct)) return false; await AuditUseAsync(automationToken, userId, flow, ct); @@ -103,7 +104,7 @@ public async Task TryRecordUseByEmailAsync(string email, AutomationTokenFl return await TryRecordUseAsync(userId.Value, flow, ct); } - private async Task IsPrivilegedAsync(Guid userId, CancellationToken ct) + public async Task IsUserPrivilegedAsync(Guid userId, CancellationToken ct) { var roles = await _db.Users .Where(u => u.Id == userId) diff --git a/Common/Services/AutomationTokens/IAutomationTokenService.cs b/Common/Services/AutomationTokens/IAutomationTokenService.cs index ea4b7b2f..2f363aac 100644 --- a/Common/Services/AutomationTokens/IAutomationTokenService.cs +++ b/Common/Services/AutomationTokens/IAutomationTokenService.cs @@ -41,10 +41,15 @@ public interface IAutomationTokenService /// Task IsApiTokenOwnerPrivilegedAsync(string apiToken, CancellationToken ct); + /// + /// True if holds a privileged role. + /// + Task IsUserPrivilegedAsync(Guid userId, CancellationToken ct); + /// /// Counts one accepted request against the token. Batched, never written per request. /// - void RecordRequest(Guid automationTokenId); + void RecordRequest(ResolvedAutomationToken automationToken); /// /// If the current request resolved an automation token, audits its use to create . diff --git a/Common/Services/AutomationTokens/ResolvedAutomationToken.cs b/Common/Services/AutomationTokens/ResolvedAutomationToken.cs index 58bd2881..3f5e855a 100644 --- a/Common/Services/AutomationTokens/ResolvedAutomationToken.cs +++ b/Common/Services/AutomationTokens/ResolvedAutomationToken.cs @@ -7,5 +7,7 @@ namespace OpenShock.Common.Services.AutomationTokens; /// Set on by the automation-token middleware /// so that downstream guards (rate limiter, turnstile, etc.) can read it synchronously without /// re-hitting the database. +/// identifies which secret of the token was presented, so usage counted against +/// a secret that has since been rotated away is not credited to its replacement. /// -public sealed record ResolvedAutomationToken(Guid Id, string Name, IReadOnlyList Types); +public sealed record ResolvedAutomationToken(Guid Id, string Name, IReadOnlyList Types, string TokenHash); diff --git a/Common/Services/BatchUpdate/BatchUpdateService.cs b/Common/Services/BatchUpdate/BatchUpdateService.cs index 78f182d9..4d4a4e89 100644 --- a/Common/Services/BatchUpdate/BatchUpdateService.cs +++ b/Common/Services/BatchUpdate/BatchUpdateService.cs @@ -39,11 +39,13 @@ internal sealed class ConcurrentCountingBatchQueue where TKey : notnull private readonly Lock _lock = new(); private Dictionary _dictionary = new(); - public void Increment(TKey key) + public void Increment(TKey key) => Add(key, 1); + + public void Add(TKey key, long count) { lock (_lock) { - _dictionary[key] = _dictionary.GetValueOrDefault(key) + 1; + _dictionary[key] = _dictionary.GetValueOrDefault(key) + count; } } @@ -68,7 +70,7 @@ public sealed class BatchUpdateService : BackgroundService, IBatchUpdateService private readonly ConcurrentUniqueBatchQueue _tokenLastUsed = new(); private readonly ConcurrentUniqueBatchQueue _sessionLastUsed = new(); - private readonly ConcurrentCountingBatchQueue _automationTokenUses = new(); + private readonly ConcurrentCountingBatchQueue<(Guid Id, string TokenHash)> _automationTokenUses = new(); public BatchUpdateService(IDbContextFactory dbFactory, ILogger logger, IConnectionMultiplexer connectionMultiplexer) { @@ -100,13 +102,27 @@ private async Task UpdateAutomationTokens() var now = DateTime.UtcNow; - await using var db = await _dbFactory.CreateDbContextAsync(); - foreach (var (automationTokenId, count) in uses) + try + { + await using var db = await _dbFactory.CreateDbContextAsync(); + foreach (var ((automationTokenId, tokenHash), count) in uses.ToArray()) + { + // Matching the hash drops uses of a secret rotated away since, so they never count towards + // the new one. Rotation resets the counters, and this keeps them reset. + await db.AutomationTokens.Where(x => x.Id == automationTokenId && x.TokenHash == tokenHash) + .ExecuteUpdateAsync(x => x + .SetProperty(y => y.LastUsedAt, now) + .SetProperty(y => y.UseCount, y => y.UseCount + count)); + + uses.Remove((automationTokenId, tokenHash)); + } + } + catch { - await db.AutomationTokens.Where(x => x.Id == automationTokenId) - .ExecuteUpdateAsync(x => x - .SetProperty(y => y.LastUsedAt, now) - .SetProperty(y => y.UseCount, y => y.UseCount + count)); + // Unlike a time of last use, a count can't be rebuilt by the next request, so put back what + // wasn't written for the next flush to retry. + foreach (var (key, count) in uses) _automationTokenUses.Add(key, count); + throw; } } @@ -135,9 +151,9 @@ public void UpdateApiTokenLastUsed(Guid apiTokenId) _tokenLastUsed.Enqueue(apiTokenId, false); } - public void UpdateAutomationTokenUsed(Guid automationTokenId) + public void UpdateAutomationTokenUsed(Guid automationTokenId, string tokenHash) { - _automationTokenUses.Increment(automationTokenId); + _automationTokenUses.Increment((automationTokenId, tokenHash)); } public void UpdateSessionLastUsed(string sessionToken, DateTimeOffset lastUsed) diff --git a/Common/Services/BatchUpdate/IBatchUpdateService.cs b/Common/Services/BatchUpdate/IBatchUpdateService.cs index 941960cc..4b5444da 100644 --- a/Common/Services/BatchUpdate/IBatchUpdateService.cs +++ b/Common/Services/BatchUpdate/IBatchUpdateService.cs @@ -11,6 +11,7 @@ public interface IBatchUpdateService /// Count one accepted request for an automation token and bump its time of last use /// /// - public void UpdateAutomationTokenUsed(Guid automationTokenId); + /// Hash of the secret presented; the use is dropped if the token was rotated since + public void UpdateAutomationTokenUsed(Guid automationTokenId, string tokenHash); public void UpdateSessionLastUsed(string sessionToken, DateTimeOffset lastUsed); } \ No newline at end of file diff --git a/Cron/Jobs/DeleteExpiredAutomatedAccountsJob.cs b/Cron/Jobs/DeleteExpiredAutomatedAccountsJob.cs index 4cab6684..fd364a85 100644 --- a/Cron/Jobs/DeleteExpiredAutomatedAccountsJob.cs +++ b/Cron/Jobs/DeleteExpiredAutomatedAccountsJob.cs @@ -26,14 +26,21 @@ public async Task Execute() { var now = DateTime.UtcNow; - // Selecting and deleting in one statement means an account promoted, or a token whose cleanup was switched - // off, between a read and the delete can't slip through. + // Selecting and deleting in one statement means an account promoted between a read and the delete can't + // slip through: Postgres rechecks a deleted row that changed under it. It doesn't recheck joined rows, so + // the tokens are locked first. That waits out a cleanup setting being changed and evaluates the committed + // one, and holds off a later change until these deletes commit. var deleted = await _db.Database.SqlQuery($""" + WITH t AS ( + SELECT id, auto_cleanup_after + FROM automation_tokens + WHERE auto_cleanup_users + AND auto_cleanup_after IS NOT NULL + FOR SHARE + ) DELETE FROM users AS u - USING automation_tokens AS t + USING t WHERE t.id = u.created_by_automation_token_id - AND t.auto_cleanup_users - AND t.auto_cleanup_after IS NOT NULL AND u.created_at + t.auto_cleanup_after < {now} AND NOT (u.roles && {PrivilegedRoles.All}) RETURNING u.id AS "UserId", u.created_by_automation_token_id AS "AutomationTokenId"