From 6b95b49d8425920ea6ab8b2e36a6959a1663b512 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Tue, 26 May 2026 21:20:11 +0200 Subject: [PATCH 1/6] 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 2/6] 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 3/6] 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 4/6] 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 5/6] 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 6/6] 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");