From f0e4679887c83d86acffa02abad2de1bbda712c1 Mon Sep 17 00:00:00 2001 From: Alexa <53471801+Alexa-RR@users.noreply.github.com> Date: Sun, 25 Oct 2026 15:47:56 +0200 Subject: [PATCH 1/5] feat(auth): external sign-in for the website and admin panel Players sign in to the website, and admins to the admin panel, with an OpenID Connect provider (DorkNet:SignIn:*). The first sign-in links the provider identity (issuer + subject) to a DorkNet account with that account's password; after that the provider sign-in is enough. - Cookie sessions per host, keys persisted in the database so redeploys keep people signed in; unlink, deletion or a ban ends the session. - Link attempts limited to 5 failures per 15 minutes per identity and per username. - Admin panel: "Sign in with ..." exchanges the session for the usual admin tokens via GET /api/admin/v1/sso (IsAdmin still required). - Downloads: optional DorkNet:Downloads:RequireSignIn serves the manifest and files only to signed-in players; the Downloads and Quest pages offer sign-in instead. - Website: sign-in in the header, /link and /account pages. The game client's login is unchanged. --- DorkNet.Server.Tests/ExternalSignInTests.cs | 231 + .../Controllers/Admin/AdminLoginController.cs | 22 + .../Controllers/Site/DownloadsController.cs | 9 + .../Site/ExternalSignInController.cs | 178 + DorkNet.Server/Data/DorkNetDbContext.cs | 13 +- .../Data/Entities/ExternalLoginEntity.cs | 29 + DorkNet.Server/DorkNet.Server.csproj | 2 + .../20261025134141_ExternalSignIn.Designer.cs | 3772 +++++++++++++++++ .../20261025134141_ExternalSignIn.cs | 68 + .../DorkNetDbContextModelSnapshot.cs | 56 +- DorkNet.Server/Services/DownloadsService.cs | 5 + DorkNet.Server/Services/ExternalSignIn.cs | 151 + DorkNet.Server/Startup/DatabaseBootstrap.cs | 61 + .../Startup/ServiceCollectionExtensions.cs | 111 + DorkNet.Server/admin-ui/src/pages/Login.tsx | 104 +- DorkNet.Server/site/src/App.tsx | 4 + DorkNet.Server/site/src/components/Header.tsx | 40 +- DorkNet.Server/site/src/lib/account.ts | 46 + DorkNet.Server/site/src/lib/api.ts | 6 +- DorkNet.Server/site/src/lib/downloads.ts | 7 + DorkNet.Server/site/src/pages/Account.tsx | 64 + DorkNet.Server/site/src/pages/Downloads.tsx | 17 +- DorkNet.Server/site/src/pages/Link.tsx | 79 + DorkNet.Server/site/src/pages/Quest.tsx | 15 +- README.md | 10 + docs/admin.md | 7 + docs/deploy.md | 62 + 27 files changed, 5138 insertions(+), 31 deletions(-) create mode 100644 DorkNet.Server.Tests/ExternalSignInTests.cs create mode 100644 DorkNet.Server/Controllers/Site/ExternalSignInController.cs create mode 100644 DorkNet.Server/Data/Entities/ExternalLoginEntity.cs create mode 100644 DorkNet.Server/Migrations/20261025134141_ExternalSignIn.Designer.cs create mode 100644 DorkNet.Server/Migrations/20261025134141_ExternalSignIn.cs create mode 100644 DorkNet.Server/Services/ExternalSignIn.cs create mode 100644 DorkNet.Server/site/src/lib/account.ts create mode 100644 DorkNet.Server/site/src/pages/Account.tsx create mode 100644 DorkNet.Server/site/src/pages/Link.tsx diff --git a/DorkNet.Server.Tests/ExternalSignInTests.cs b/DorkNet.Server.Tests/ExternalSignInTests.cs new file mode 100644 index 0000000..67e9507 --- /dev/null +++ b/DorkNet.Server.Tests/ExternalSignInTests.cs @@ -0,0 +1,231 @@ +using System.Net; +using System.Net.Http.Json; +using System.Security.Claims; +using System.Text.Json; +using DorkNet.Server.Data; +using DorkNet.Server.Data.Entities; +using DorkNet.Server.Services; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace DorkNet.Server.Tests; + +/// +/// Website / admin-panel sign-in with an external OpenID Connect provider. +/// The provider round-trip can't run in-process, so each test starts where the +/// provider callback leaves off: a DorkNet.External cookie holding the +/// provider identity, encrypted with the app's own cookie format. Everything +/// from there (link, session, admin SSO, unlink, limits) is the real code. +/// +public sealed class ExternalSignInTests : IClassFixture +{ + private const string Issuer = "https://idp.example.com"; + private readonly DorkNetServerFactory _factory; + + public ExternalSignInTests(DorkNetServerFactory factory) => _factory = factory; + + [Fact] + public async Task Linking_with_the_account_password_signs_in_and_the_next_sign_in_needs_no_password() + { + var (player, password) = await PlayerAsync(); + var subject = Guid.NewGuid().ToString("N"); + + using var site = Client(); + var link = await PostJsonAsync(site, "/api/site/v1/account/link", + new { username = player.Username, password }, ExternalCookie(subject, "player@example.com")); + Assert.Equal(HttpStatusCode.OK, link.StatusCode); + var session = SessionCookie(link); + Assert.NotNull(session); + + var me = await GetJsonAsync(site, "/api/site/v1/account/me", session); + Assert.True(me.GetProperty("signedIn").GetBoolean()); + Assert.Equal(player.Username, me.GetProperty("player").GetProperty("username").GetString()); + Assert.Equal("player@example.com", me.GetProperty("player").GetProperty("email").GetString()); + + // Next time the provider identity alone is enough. + await using var scope = _factory.Services.CreateAsyncScope(); + var signIn = scope.ServiceProvider.GetRequiredService(); + var stored = await signIn.FindAsync(Issuer, subject); + Assert.Equal(player.Id, stored?.PlayerId); + } + + [Fact] + public async Task Wrong_passwords_are_refused_then_rate_limited() + { + var (player, _) = await PlayerAsync(); + var external = ExternalCookie(Guid.NewGuid().ToString("N"), "guess@example.com"); + using var site = Client(); + for (var i = 0; i < 5; i++) + { + using var r = await PostJsonAsync(site, "/api/site/v1/account/link", + new { username = player.Username, password = "wrong-" + i }, external); + Assert.Equal(HttpStatusCode.Unauthorized, r.StatusCode); + } + using var blocked = await PostJsonAsync(site, "/api/site/v1/account/link", + new { username = player.Username, password = "still-wrong" }, external); + Assert.Equal(HttpStatusCode.TooManyRequests, blocked.StatusCode); + } + + [Fact] + public async Task An_identity_linked_to_one_account_cannot_be_linked_to_another() + { + var (first, firstPassword) = await PlayerAsync(); + var (second, secondPassword) = await PlayerAsync(); + var subject = Guid.NewGuid().ToString("N"); + using var site = Client(); + + using var ok = await PostJsonAsync(site, "/api/site/v1/account/link", + new { username = first.Username, password = firstPassword }, ExternalCookie(subject, "a@example.com")); + Assert.Equal(HttpStatusCode.OK, ok.StatusCode); + using var refused = await PostJsonAsync(site, "/api/site/v1/account/link", + new { username = second.Username, password = secondPassword }, ExternalCookie(subject, "a@example.com")); + Assert.Equal(HttpStatusCode.Conflict, refused.StatusCode); + } + + [Fact] + public async Task Admin_sso_issues_tokens_only_to_admins() + { + var (admin, adminPassword) = await PlayerAsync(isAdmin: true); + var (regular, regularPassword) = await PlayerAsync(); + using var admins = Client("admin"); + + var adminSession = SessionCookie(await PostJsonAsync(admins, "/api/site/v1/account/link", + new { username = admin.Username, password = adminPassword }, ExternalCookie(Guid.NewGuid().ToString("N"), ""))); + using var tokens = await SendAsync(admins, HttpMethod.Get, "/api/admin/v1/sso", adminSession); + Assert.Equal(HttpStatusCode.OK, tokens.StatusCode); + var body = JsonDocument.Parse(await tokens.Content.ReadAsStringAsync()).RootElement; + Assert.False(string.IsNullOrEmpty(body.GetProperty("access_token").GetString())); + Assert.Equal(admin.Id, body.GetProperty("account_id").GetInt64()); + + var regularSession = SessionCookie(await PostJsonAsync(admins, "/api/site/v1/account/link", + new { username = regular.Username, password = regularPassword }, ExternalCookie(Guid.NewGuid().ToString("N"), ""))); + using var refused = await SendAsync(admins, HttpMethod.Get, "/api/admin/v1/sso", regularSession); + Assert.Equal(HttpStatusCode.Forbidden, refused.StatusCode); + + using var anonymous = await SendAsync(admins, HttpMethod.Get, "/api/admin/v1/sso", null); + Assert.Equal(HttpStatusCode.Unauthorized, anonymous.StatusCode); + } + + [Fact] + public async Task Unlinking_ends_the_session() + { + var (player, password) = await PlayerAsync(); + using var site = Client(); + var session = SessionCookie(await PostJsonAsync(site, "/api/site/v1/account/link", + new { username = player.Username, password }, ExternalCookie(Guid.NewGuid().ToString("N"), ""))); + + using var unlink = await SendAsync(site, HttpMethod.Post, "/api/site/v1/account/unlink", session); + Assert.Equal(HttpStatusCode.OK, unlink.StatusCode); + + // The old cookie is still in the browser, but the link behind it is gone. + var me = await GetJsonAsync(site, "/api/site/v1/account/me", session); + Assert.False(me.GetProperty("signedIn").GetBoolean()); + } + + [Fact] + public async Task A_banned_player_cannot_link_and_loses_an_existing_session() + { + var (player, password) = await PlayerAsync(); + using var site = Client(); + var session = SessionCookie(await PostJsonAsync(site, "/api/site/v1/account/link", + new { username = player.Username, password }, ExternalCookie(Guid.NewGuid().ToString("N"), ""))); + + await using (var scope = _factory.Services.CreateAsyncScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + await db.Players.Where(p => p.Id == player.Id) + .ExecuteUpdateAsync(s => s.SetProperty(p => p.BannedUntil, DateTime.UtcNow.AddDays(1))); + } + + var me = await GetJsonAsync(site, "/api/site/v1/account/me", session); + Assert.False(me.GetProperty("signedIn").GetBoolean()); + using var relink = await PostJsonAsync(site, "/api/site/v1/account/link", + new { username = player.Username, password }, ExternalCookie(Guid.NewGuid().ToString("N"), "")); + Assert.Equal(HttpStatusCode.Forbidden, relink.StatusCode); + } + + [Fact] + public async Task Downloads_can_require_sign_in() + { + await using var factory = _factory.WithWebHostBuilder(b => + { + b.UseSetting("DorkNet:Downloads:BaseUrl", "http://127.0.0.1:9"); + b.UseSetting("DorkNet:Downloads:RequireSignIn", "true"); + }); + using var site = factory.CreateClient(new() { BaseAddress = new Uri($"http://{_factory.ApexDomain}") }); + using var manifest = await site.GetAsync("/api/site/v1/downloads"); + Assert.Equal(HttpStatusCode.Unauthorized, manifest.StatusCode); + using var file = await site.GetAsync("/api/site/v1/downloads/files/2023.03/DorkNet-2023.03-Quest.xapk"); + Assert.Equal(HttpStatusCode.Unauthorized, file.StatusCode); + } + + // ── helpers ──────────────────────────────────────────────────────────── + + private async Task<(PlayerEntity Player, string Password)> PlayerAsync(bool isAdmin = false) + { + var password = "pw-" + Guid.NewGuid().ToString("N")[..12]; + var player = new PlayerEntity + { + Id = 9_400_000 + Random.Shared.Next(1, 99_999), + Username = "sso" + Guid.NewGuid().ToString("N")[..10], + DisplayName = "SSO test", + PasswordHash = BCrypt.Net.BCrypt.HashPassword(password), + IsAdmin = isAdmin, + }; + await using var scope = _factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + db.Players.Add(player); + await db.SaveChangesAsync(); + return (player, password); + } + + /// The cookie the provider callback leaves behind: the provider + /// identity in the DorkNet.External scheme, in the app's cookie format. + private string ExternalCookie(string subject, string email) + { + var options = _factory.Services.GetRequiredService>() + .Get(ExternalSignIn.ExternalScheme); + var identity = new ClaimsIdentity(new[] + { + new Claim("iss", Issuer), new Claim("sub", subject), new Claim("email", email), + }, "test"); + var ticket = new AuthenticationTicket(new ClaimsPrincipal(identity), ExternalSignIn.ExternalScheme); + return $"{options.Cookie.Name}={options.TicketDataFormat.Protect(ticket)}"; + } + + private static string? SessionCookie(HttpResponseMessage response) => + response.Headers.TryGetValues("Set-Cookie", out var values) + ? values.Select(v => v.Split(';')[0]).FirstOrDefault(v => v.StartsWith("dorknet_session=") && v.Length > "dorknet_session=".Length) + : null; + + private HttpClient Client(string? subdomain = null) + { + var host = subdomain is null ? _factory.ApexDomain : $"{subdomain}.{_factory.ApexDomain}"; + return _factory.CreateClient(new() { BaseAddress = new Uri($"http://{host}"), HandleCookies = false }); + } + + private static Task SendAsync(HttpClient client, HttpMethod method, string path, string? cookie) + { + var request = new HttpRequestMessage(method, path); + if (cookie is not null) request.Headers.Add("Cookie", cookie); + return client.SendAsync(request); + } + + private static Task PostJsonAsync(HttpClient client, string path, object body, string? cookie) + { + var request = new HttpRequestMessage(HttpMethod.Post, path) { Content = JsonContent.Create(body) }; + if (cookie is not null) request.Headers.Add("Cookie", cookie); + return client.SendAsync(request); + } + + private static async Task GetJsonAsync(HttpClient client, string path, string? cookie) + { + using var response = await SendAsync(client, HttpMethod.Get, path, cookie); + var body = await response.Content.ReadAsStringAsync(); + Assert.True(response.IsSuccessStatusCode, $"GET {path} -> {(int)response.StatusCode}: {body}"); + return JsonDocument.Parse(body).RootElement.Clone(); + } +} diff --git a/DorkNet.Server/Controllers/Admin/AdminLoginController.cs b/DorkNet.Server/Controllers/Admin/AdminLoginController.cs index 697a0ae..1c0486d 100644 --- a/DorkNet.Server/Controllers/Admin/AdminLoginController.cs +++ b/DorkNet.Server/Controllers/Admin/AdminLoginController.cs @@ -1,3 +1,4 @@ +using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using DorkNet.Server.Data; @@ -41,6 +42,27 @@ public class AdminLoginController( if (!player.IsAdmin) return StatusCode(403, new { error = "not_admin" }); + return TokenResponse(player); + } + + /// GET api/admin/v1/sso — exchange a single sign-on session + /// (, signed in on this admin host via + /// the external provider) for the same token pair login returns. + /// Admin rights still come from the account's IsAdmin. + [HttpGet("sso")] + public async Task Sso() + { + var web = await HttpContext.AuthenticateAsync(ExternalSignIn.WebScheme); + if (!web.Succeeded || ExternalSignIn.PlayerId(web.Principal) is not { } playerId) + return Unauthorized(new { error = "not_signed_in" }); + var player = await db.Players.FirstOrDefaultAsync(p => p.Id == playerId); + if (player is null) return Unauthorized(new { error = "not_signed_in" }); + if (!player.IsAdmin) return StatusCode(403, new { error = "not_admin" }); + return TokenResponse(player); + } + + private OkObjectResult TokenResponse(Data.Entities.PlayerEntity player) + { var (access, refresh) = authService.GenerateTokenPair(player.Id); return Ok(new { diff --git a/DorkNet.Server/Controllers/Site/DownloadsController.cs b/DorkNet.Server/Controllers/Site/DownloadsController.cs index 2de2c94..9d91c9f 100644 --- a/DorkNet.Server/Controllers/Site/DownloadsController.cs +++ b/DorkNet.Server/Controllers/Site/DownloadsController.cs @@ -1,3 +1,4 @@ +using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using DorkNet.Server.Services; @@ -24,6 +25,12 @@ public class DownloadsController(DownloadsService downloads, DomainConfig domain private bool Available => downloads.IsConfigured && string.Equals(Request.Host.Host, domain.Apex, StringComparison.OrdinalIgnoreCase); + /// With , only a + /// signed-in, linked player may read the builds. + private async Task SignedInOrOpenAsync() => + !downloads.RequireSignIn + || (await HttpContext.AuthenticateAsync(ExternalSignIn.WebScheme)).Succeeded; + /// GET /api/site/v1/downloads — the manifest as published /// (snake_case keys), or 404 when downloads aren't set up or the request /// isn't for the site's host. @@ -31,6 +38,7 @@ public class DownloadsController(DownloadsService downloads, DomainConfig domain public async Task GetManifest(CancellationToken ct) { if (!Available) return NotFound(new { error = "Downloads aren't set up on this server." }); + if (!await SignedInOrOpenAsync()) return Unauthorized(new { error = "sign_in_required" }); var manifest = await downloads.GetManifestAsync(ct); Response.Headers.CacheControl = "no-cache"; return Content((manifest ?? new System.Text.Json.Nodes.JsonObject { ["releases"] = new System.Text.Json.Nodes.JsonArray() }) @@ -43,6 +51,7 @@ public class DownloadsController(DownloadsService downloads, DomainConfig domain public async Task GetFile(string path, CancellationToken ct) { if (!Available) { Response.StatusCode = StatusCodes.Status404NotFound; return; } + if (!await SignedInOrOpenAsync()) { Response.StatusCode = StatusCodes.Status401Unauthorized; return; } var manifest = await downloads.GetManifestAsync(ct); var entry = manifest is null ? null : DownloadsService.FindFile(manifest, path); if (entry is null) { Response.StatusCode = StatusCodes.Status404NotFound; return; } diff --git a/DorkNet.Server/Controllers/Site/ExternalSignInController.cs b/DorkNet.Server/Controllers/Site/ExternalSignInController.cs new file mode 100644 index 0000000..2a90fdc --- /dev/null +++ b/DorkNet.Server/Controllers/Site/ExternalSignInController.cs @@ -0,0 +1,178 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; +using DorkNet.Server.Services; + +namespace DorkNet.Server.Controllers.Site; + +/// +/// Sign-in with the external OpenID Connect provider () +/// for the website and the admin panel. The game client's login is separate. +/// +/// Flow: login challenges the provider; its callback +/// (, handled by the OpenID Connect +/// middleware) stores the provider identity in the short-lived +/// DorkNet.External cookie and lands on complete. A linked identity +/// becomes a DorkNet.Web session at once; an unlinked one goes to the +/// website's /link page, where the player proves the account with its +/// password once (account/link). +/// +/// Each host signs in on its own (website and admin panel), because the +/// hyphen host style puts them under different cookie domains. +/// +[ApiController] +[AllowAnonymous] +public class ExternalSignInController( + IOptions options, + ExternalSignInService signIn, + LinkAttemptLimiter limiter, + DomainConfig domain, + ILogger log) : ControllerBase +{ + private ExternalSignInOptions Options => options.Value; + + /// GET /api/auth/external/login?returnUrl=/path — start sign-in. + [HttpGet(ExternalSignIn.LoginPath)] + public IActionResult Login([FromQuery] string? returnUrl) + { + if (!Options.IsConfigured) return NotFound(new { error = "sign_in_not_configured" }); + var complete = $"{ExternalSignIn.CompletePath}?returnUrl={Uri.EscapeDataString(LocalUrl(returnUrl))}"; + return Challenge(new AuthenticationProperties { RedirectUri = complete }, ExternalSignIn.OidcScheme); + } + + /// GET /api/auth/external/complete — after the provider's callback. + [HttpGet(ExternalSignIn.CompletePath)] + public async Task Complete([FromQuery] string? returnUrl) + { + returnUrl = LocalUrl(returnUrl); + var external = await HttpContext.AuthenticateAsync(ExternalSignIn.ExternalScheme); + if (!external.Succeeded || ExternalIdentity(external.Principal) is not { } identity) + return Redirect(WithQuery(returnUrl, "signin_error", "no_identity")); + var (issuer, subject, email) = identity; + + var link = await signIn.FindAsync(issuer, subject); + var player = link is null ? null : await signIn.ActivePlayerAsync(link); + if (link is not null && player is not null) + { + await signIn.TouchAsync(link, email); + await HttpContext.SignInAsync(ExternalSignIn.WebScheme, ExternalSignIn.PlayerPrincipal(player, link)); + await HttpContext.SignOutAsync(ExternalSignIn.ExternalScheme); + log.LogInformation("[signin] player={Player} via {Issuer}", player.Id, issuer); + return Redirect(returnUrl); + } + if (link is not null) + return Redirect(WithQuery(returnUrl, "signin_error", "account_unavailable")); + + // Not linked yet. Linking happens on the website; the admin panel + // tells its user to link there first. + if (IsAdminHost()) + return Redirect(WithQuery(returnUrl, "signin_error", "not_linked")); + return Redirect($"/link?returnUrl={Uri.EscapeDataString(returnUrl)}"); + } + + /// GET /api/site/v1/account/me — who is signed in, or which + /// provider identity is waiting to be linked. + [HttpGet("api/site/v1/account/me")] + public async Task Me() + { + var web = await HttpContext.AuthenticateAsync(ExternalSignIn.WebScheme); + var external = await HttpContext.AuthenticateAsync(ExternalSignIn.ExternalScheme); + var pending = external.Succeeded ? ExternalIdentity(external.Principal) : null; + return Ok(new + { + signInAvailable = Options.IsConfigured, + provider = Options.DisplayName, + signedIn = web.Succeeded, + player = web.Succeeded + ? new + { + id = ExternalSignIn.PlayerId(web.Principal)?.ToString(), + username = web.Principal!.Identity?.Name, + email = web.Principal.FindFirst(ExternalSignIn.EmailClaim)?.Value, + } + : null, + pendingLink = pending is { } p ? new { email = p.Email } : null, + }); + } + + public sealed record LinkRequest(string? Username, string? Password); + + /// POST /api/site/v1/account/link {username, password} — link the + /// pending provider identity to the account these credentials prove. + [HttpPost("api/site/v1/account/link")] + public async Task Link([FromBody] LinkRequest body) + { + var external = await HttpContext.AuthenticateAsync(ExternalSignIn.ExternalScheme); + if (!external.Succeeded || ExternalIdentity(external.Principal) is not { } identity) + return Unauthorized(new { error = "no_pending_identity" }); + var (issuer, subject, email) = identity; + var username = body.Username?.Trim() ?? string.Empty; + if (username.Length == 0 || string.IsNullOrEmpty(body.Password)) + return BadRequest(new { error = "missing_credentials" }); + + var keys = new[] { $"sub:{issuer}|{subject}", $"user:{username.ToLowerInvariant()}" }; + if (limiter.IsBlocked(keys)) + return StatusCode(StatusCodes.Status429TooManyRequests, new { error = "too_many_attempts" }); + + var (outcome, player, link) = await signIn.LinkAsync(issuer, subject, email, username, body.Password); + switch (outcome) + { + case LinkOutcome.Linked: + limiter.Reset(keys); + await HttpContext.SignInAsync(ExternalSignIn.WebScheme, ExternalSignIn.PlayerPrincipal(player!, link!)); + await HttpContext.SignOutAsync(ExternalSignIn.ExternalScheme); + log.LogInformation("[signin] linked player={Player} to {Issuer}", player!.Id, issuer); + return Ok(new { linked = true, username = player.Username }); + case LinkOutcome.InvalidCredentials: + limiter.RecordFailure(keys); + return Unauthorized(new { error = "invalid_credentials" }); + case LinkOutcome.Banned: + return StatusCode(StatusCodes.Status403Forbidden, new { error = "banned" }); + default: + return Conflict(new { error = "identity_linked_to_another_account" }); + } + } + + /// POST /api/site/v1/account/unlink — remove this provider identity + /// from the account and sign out. + [HttpPost("api/site/v1/account/unlink")] + public async Task Unlink() + { + var web = await HttpContext.AuthenticateAsync(ExternalSignIn.WebScheme); + if (!web.Succeeded || ExternalSignIn.PlayerId(web.Principal) is not { } playerId) + return Unauthorized(new { error = "not_signed_in" }); + var issuer = web.Principal!.FindFirst(ExternalSignIn.IssuerClaim)?.Value ?? string.Empty; + await signIn.UnlinkAsync(playerId, issuer); + await HttpContext.SignOutAsync(ExternalSignIn.WebScheme); + return Ok(new { unlinked = true }); + } + + /// POST /api/site/v1/account/logout — end the session here. + [HttpPost("api/site/v1/account/logout")] + public async Task Logout() + { + await HttpContext.SignOutAsync(ExternalSignIn.WebScheme); + await HttpContext.SignOutAsync(ExternalSignIn.ExternalScheme); + return Ok(new { signedOut = true }); + } + + /// (issuer, subject, email) from the provider's claims. The + /// subject is the account key; the email is display only. + private (string Issuer, string Subject, string Email)? ExternalIdentity(System.Security.Claims.ClaimsPrincipal? p) + { + var subject = p?.FindFirst("sub")?.Value; + if (string.IsNullOrEmpty(subject)) return null; + var issuer = p!.FindFirst("iss")?.Value ?? Options.Authority; + return (issuer.TrimEnd('/'), subject, p.FindFirst("email")?.Value ?? string.Empty); + } + + private string LocalUrl(string? url) => + !string.IsNullOrEmpty(url) && Url.IsLocalUrl(url) ? url : "/"; + + private static string WithQuery(string url, string key, string value) => + url + (url.Contains('?') ? "&" : "?") + key + "=" + Uri.EscapeDataString(value); + + private bool IsAdminHost() => + string.Equals(Request.Host.Host, domain.Sub("admin"), StringComparison.OrdinalIgnoreCase); +} diff --git a/DorkNet.Server/Data/DorkNetDbContext.cs b/DorkNet.Server/Data/DorkNetDbContext.cs index f6461d2..b56d8fe 100644 --- a/DorkNet.Server/Data/DorkNetDbContext.cs +++ b/DorkNet.Server/Data/DorkNetDbContext.cs @@ -1,9 +1,10 @@ +using Microsoft.AspNetCore.DataProtection.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; using DorkNet.Server.Data.Entities; namespace DorkNet.Server.Data; -public class DorkNetDbContext(DbContextOptions options) : DbContext(options) +public class DorkNetDbContext(DbContextOptions options) : DbContext(options), IDataProtectionKeyContext { public DbSet Players => Set(); public DbSet Avatars => Set(); @@ -88,9 +89,19 @@ public class DorkNetDbContext(DbContextOptions options) : DbCo public DbSet UgcPurchasables => Set(); public DbSet RoomConsumables => Set(); public DbSet RoomConsumableOwnership => Set(); + public DbSet ExternalLogins => Set(); + // ASP.NET Core data-protection keys (sign-in cookies), kept in the + // database so a redeploy or a second replica doesn't sign everyone out. + public DbSet DataProtectionKeys => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { + modelBuilder.Entity(e => + { + e.HasIndex(x => new { x.Issuer, x.Subject }).IsUnique(); + e.HasIndex(x => x.PlayerId); + }); + // SQLite-specific NOCASE collation works via UseCollation; Postgres // doesn't have NOCASE. Provider-conditional helper applied to the // three string columns the wire API matches case-insensitively diff --git a/DorkNet.Server/Data/Entities/ExternalLoginEntity.cs b/DorkNet.Server/Data/Entities/ExternalLoginEntity.cs new file mode 100644 index 0000000..0ce82a8 --- /dev/null +++ b/DorkNet.Server/Data/Entities/ExternalLoginEntity.cs @@ -0,0 +1,29 @@ +namespace DorkNet.Server.Data.Entities; + +/// +/// An identity from the external sign-in provider (OpenID Connect) linked to a +/// DorkNet account. One row per (Issuer, Subject): the provider's stable user +/// id, never the email, which can change or be reused. A player proves they own +/// the account once, with its password, when the link is made +/// (ExternalSignInController.Link); after that the provider alone signs +/// them in to the site and, for admins, the admin panel. +/// +public class ExternalLoginEntity +{ + public long Id { get; set; } + + public long PlayerId { get; set; } + + /// The provider's issuer URL (the iss claim). + public string Issuer { get; set; } = string.Empty; + + /// The provider's user id (the sub claim). + public string Subject { get; set; } = string.Empty; + + /// The email the provider reported when the link was made or last + /// used. Shown to the player; never used to find the account. + public string Email { get; set; } = string.Empty; + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime LastUsedAt { get; set; } = DateTime.UtcNow; +} diff --git a/DorkNet.Server/DorkNet.Server.csproj b/DorkNet.Server/DorkNet.Server.csproj index b0af7d9..e9d2246 100644 --- a/DorkNet.Server/DorkNet.Server.csproj +++ b/DorkNet.Server/DorkNet.Server.csproj @@ -8,6 +8,8 @@ + + runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/DorkNet.Server/Migrations/20261025134141_ExternalSignIn.Designer.cs b/DorkNet.Server/Migrations/20261025134141_ExternalSignIn.Designer.cs new file mode 100644 index 0000000..a58d136 --- /dev/null +++ b/DorkNet.Server/Migrations/20261025134141_ExternalSignIn.Designer.cs @@ -0,0 +1,3772 @@ +// +using System; +using DorkNet.Server.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace DorkNet.Server.Migrations +{ + [DbContext(typeof(DorkNetDbContext))] + [Migration("20261025134141_ExternalSignIn")] + partial class ExternalSignIn + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.13"); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.AdminActionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("AdminPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("TargetId") + .HasColumnType("INTEGER"); + + b.Property("TargetType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AdminPlayerId"); + + b.HasIndex("Timestamp"); + + b.HasIndex("TargetType", "TargetId"); + + b.ToTable("AdminActions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CustomAvatarItemsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("EquippedItemsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FaceFeatures") + .IsRequired() + .HasMaxLength(8192) + .HasColumnType("TEXT"); + + b.Property("HairColor") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("InventoryJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OutfitSelections") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("OutfitSelectionsV2") + .IsRequired() + .HasMaxLength(8192) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("SavedOutfitsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SkinColor") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId") + .IsUnique(); + + b.ToTable("Avatars"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.BugReportEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("ClientVersion") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("GameSessionId") + .HasColumnType("INTEGER"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("ReadAt") + .HasColumnType("TEXT"); + + b.Property("ReporterPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ReadAt"); + + b.HasIndex("ReporterPlayerId"); + + b.ToTable("BugReports"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CardEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ActionUrl") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Priority") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAt"); + + b.HasIndex("PlayerId"); + + b.ToTable("Cards"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CharadesWordListEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("IsBuiltIn") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("WordsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("CharadesWordLists"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ChatMessageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ModerationState") + .HasColumnType("INTEGER"); + + b.Property("SenderPlayerId") + .HasColumnType("INTEGER"); + + b.Property("SentAt") + .HasColumnType("TEXT"); + + b.Property("ThreadKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ThreadKey", "SentAt"); + + b.ToTable("ChatMessages"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ChatThreadEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ThreadKey") + .IsRequired() + .HasMaxLength(96) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ThreadKey") + .IsUnique(); + + b.ToTable("ChatThreads"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ChatThreadMemberEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("JoinedAt") + .HasColumnType("TEXT"); + + b.Property("LastReadMessageId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("SnoozeUntil") + .HasColumnType("TEXT"); + + b.Property("ThreadKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("ThreadKey", "PlayerId") + .IsUnique(); + + b.ToTable("ChatThreadMembers"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CheerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CheeredAt") + .HasColumnType("TEXT"); + + b.Property("FromPlayerId") + .HasColumnType("INTEGER"); + + b.Property("TargetInventionId") + .HasColumnType("INTEGER"); + + b.Property("TargetPhotoId") + .HasColumnType("INTEGER"); + + b.Property("TargetPlayerId") + .HasColumnType("INTEGER"); + + b.Property("TargetRoomId") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("TargetPhotoId"); + + b.HasIndex("TargetPlayerId"); + + b.HasIndex("TargetRoomId"); + + b.HasIndex("FromPlayerId", "TargetPlayerId", "TargetRoomId", "TargetPhotoId", "Type") + .IsUnique(); + + b.ToTable("Cheers"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubAdditionalImageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClubId") + .HasColumnType("INTEGER"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Slot") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ClubId", "Slot") + .IsUnique(); + + b.ToTable("ClubAdditionalImages"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubAnnouncementEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AuthorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("ClubId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ClubId", "CreatedAt"); + + b.ToTable("ClubAnnouncements"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubAnnouncementReadEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AnnouncementId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("ReadAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("AnnouncementId", "PlayerId") + .IsUnique(); + + b.ToTable("ClubAnnouncementReads"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubCategoryAssignmentEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CategoryTagId") + .HasColumnType("INTEGER"); + + b.Property("ClubId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CategoryTagId"); + + b.HasIndex("ClubId", "CategoryTagId") + .IsUnique(); + + b.ToTable("ClubCategoryAssignments"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubCategoryTagEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Active") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("OrderIndex") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Name"); + + b.ToTable("ClubCategoryTags"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowJuniors") + .HasColumnType("INTEGER"); + + b.Property("BanStatus") + .HasColumnType("INTEGER"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("ClubChatEnabled") + .HasColumnType("INTEGER"); + + b.Property("ClubType") + .HasColumnType("INTEGER"); + + b.Property("ClubhouseRoomId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsRRO") + .HasColumnType("INTEGER"); + + b.Property("Joinability") + .HasColumnType("INTEGER"); + + b.Property("MinLevel") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.Property("State") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Visibility") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Clubs"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubMembershipEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClubId") + .HasColumnType("INTEGER"); + + b.Property("JoinedAt") + .HasColumnType("TEXT"); + + b.Property("Permissions") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("ClubId", "PlayerId") + .IsUnique(); + + b.ToTable("ClubMemberships"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubRolePermissionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApproveMember") + .HasColumnType("INTEGER"); + + b.Property("BanUnban") + .HasColumnType("INTEGER"); + + b.Property("ClubId") + .HasColumnType("INTEGER"); + + b.Property("CreateEvent") + .HasColumnType("INTEGER"); + + b.Property("EditDetails") + .HasColumnType("INTEGER"); + + b.Property("EditPermissionSettings") + .HasColumnType("INTEGER"); + + b.Property("MembershipType") + .HasColumnType("INTEGER"); + + b.Property("PostAnnouncement") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ClubId", "MembershipType") + .IsUnique(); + + b.ToTable("ClubRolePermissions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubSubscriptionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClubId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("SubscribedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ClubId"); + + b.HasIndex("PlayerId", "ClubId") + .IsUnique(); + + b.ToTable("ClubSubscriptions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CohortAssignmentEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AssignedAt") + .HasColumnType("TEXT"); + + b.Property("CohortKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Variant") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "CohortKey") + .IsUnique(); + + b.ToTable("CohortAssignments"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CommunityBoardEntity", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("Json") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("CommunityBoardRows"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CouponEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("MaxRedemptions") + .HasColumnType("INTEGER"); + + b.Property("RedemptionCount") + .HasColumnType("INTEGER"); + + b.Property("RewardAmount") + .HasColumnType("INTEGER"); + + b.Property("RewardCurrencyType") + .HasColumnType("INTEGER"); + + b.Property("RewardItemSlug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("RewardType") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("Coupons"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CouponRedemptionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CouponId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RedeemedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CouponId", "PlayerId") + .IsUnique(); + + b.ToTable("CouponRedemptions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CurrencyBalanceEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Balance") + .HasColumnType("INTEGER"); + + b.Property("CurrencyType") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "CurrencyType") + .IsUnique(); + + b.ToTable("CurrencyBalances"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CustomAvatarItemEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AssetName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("BaseAvatarItemId") + .HasColumnType("INTEGER"); + + b.Property("CheerCount") + .HasColumnType("INTEGER"); + + b.Property("Color") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsFeatured") + .HasColumnType("INTEGER"); + + b.Property("IsPublic") + .HasColumnType("INTEGER"); + + b.Property("ItemType") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Price") + .HasColumnType("INTEGER"); + + b.Property("PublicId") + .HasColumnType("TEXT"); + + b.Property("PurchaseCount") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("IsFeatured"); + + b.HasIndex("IsPublic"); + + b.HasIndex("PublicId") + .IsUnique(); + + b.ToTable("CustomAvatarItems"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CustomAvatarItemOwnershipEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AcquiredAt") + .HasColumnType("TEXT"); + + b.Property("CustomAvatarItemId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CustomAvatarItemId"); + + b.HasIndex("PlayerId", "CustomAvatarItemId") + .IsUnique(); + + b.ToTable("CustomAvatarItemOwnership"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.DormStateEntity", b => + { + b.Property("PlayerId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CurrentDataBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("PlayerId"); + + b.ToTable("DormStates"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ExternalLoginEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Email") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Issuer") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LastUsedAt") + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Subject") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("Issuer", "Subject") + .IsUnique(); + + b.ToTable("ExternalLogins"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.GameRewardSelectionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("GiftContext") + .HasColumnType("INTEGER"); + + b.Property("GrantedItemId") + .HasColumnType("INTEGER"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Offer1ItemId") + .HasColumnType("INTEGER"); + + b.Property("Offer2ItemId") + .HasColumnType("INTEGER"); + + b.Property("Offer3ItemId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RewardType") + .HasColumnType("INTEGER"); + + b.Property("SelectedAt") + .HasColumnType("TEXT"); + + b.Property("SelectedGiftDropId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "SelectedAt"); + + b.ToTable("GameRewardSelections"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.GameSessionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ActivityLevelId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("MaxCapacity") + .HasColumnType("INTEGER"); + + b.Property("PhotonRoomName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PlayerCount") + .HasColumnType("INTEGER"); + + b.Property("Region") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("RoomId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoomId", "Region"); + + b.ToTable("GameSessions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.GiftPackageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvatarItemDescOrHairDyeDesc") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("AvatarItemType") + .HasColumnType("INTEGER"); + + b.Property("ConsumableItemDesc") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Consumed") + .HasColumnType("INTEGER"); + + b.Property("ConsumedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Currency") + .HasColumnType("INTEGER"); + + b.Property("CurrencyType") + .HasColumnType("INTEGER"); + + b.Property("EquipmentModificationGuid") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("EquipmentPrefabName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("FromPlayerId") + .HasColumnType("INTEGER"); + + b.Property("GiftContext") + .HasColumnType("INTEGER"); + + b.Property("GiftRarity") + .HasColumnType("INTEGER"); + + b.Property("IsGifted") + .HasColumnType("INTEGER"); + + b.Property("IsValid") + .HasColumnType("INTEGER"); + + b.Property("Level") + .HasColumnType("INTEGER"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("PackageMaterial") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PackageVariant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.Property("RecipientPlayerId") + .HasColumnType("INTEGER"); + + b.Property("SourceStoreItemId") + .HasColumnType("INTEGER"); + + b.Property("SupportsCurrentPlatform") + .HasColumnType("INTEGER"); + + b.Property("Xp") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RecipientPlayerId", "Consumed"); + + b.ToTable("GiftPackages"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.InventionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CheerCount") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreationRoomId") + .HasColumnType("INTEGER"); + + b.Property("CreatorPermission") + .HasColumnType("INTEGER"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CurrentBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("CurrentVersionNumber") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("FirstPublishedAt") + .HasColumnType("TEXT"); + + b.Property("GeneralPermission") + .HasColumnType("INTEGER"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsAgInvention") + .HasColumnType("INTEGER"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("IsPublished") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("NumPlayersHaveUsedInRoom") + .HasColumnType("INTEGER"); + + b.Property("Permission") + .HasColumnType("INTEGER"); + + b.Property("Price") + .HasColumnType("INTEGER"); + + b.Property("ReplicationId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("SpawnCount") + .HasColumnType("INTEGER"); + + b.Property("TagsCsv") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CheerCount"); + + b.HasIndex("CreatorPlayerId"); + + b.ToTable("Inventions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.InventionVersionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("InstantiationCost") + .HasColumnType("INTEGER"); + + b.Property("InventionId") + .HasColumnType("INTEGER"); + + b.Property("LightsCost") + .HasColumnType("INTEGER"); + + b.Property("ReplicationId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("VersionNumber") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("InventionId", "VersionNumber") + .IsUnique(); + + b.ToTable("InventionVersions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.IpBanEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BannedAt") + .HasColumnType("TEXT"); + + b.Property("BannedByAdminId") + .HasColumnType("INTEGER"); + + b.Property("Cidr") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Until") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Cidr"); + + b.ToTable("IpBans"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ItemWishlistEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ItemKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ItemType") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "ItemKey", "ItemType") + .IsUnique(); + + b.ToTable("ItemWishlists"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.KeepsakeEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("EarnedAt") + .HasColumnType("TEXT"); + + b.Property("EventKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("PlayerId", "EventKey"); + + b.ToTable("Keepsakes"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.LeaderboardChannelMetaEntity", b => + { + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("Channel") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("LowerIsBetter") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("ValueFormat") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("RoomId", "Channel"); + + b.HasIndex("RoomId"); + + b.ToTable("LeaderboardChannelMeta"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.LeaderboardStatEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("StatChannel") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoomId", "PlayerId", "StatChannel") + .IsUnique(); + + b.HasIndex("RoomId", "StatChannel", "Value"); + + b.ToTable("LeaderboardStats"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.LoadingScreenTipEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Context") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("PlatformMask") + .HasColumnType("INTEGER"); + + b.Property("RoomNamesCsv") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("LoadingScreenTips"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.MessageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ReadAt") + .HasColumnType("TEXT"); + + b.Property("RecipientPlayerId") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("SenderPlayerId") + .HasColumnType("INTEGER"); + + b.Property("SentAt") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RecipientPlayerId", "SentAt"); + + b.HasIndex("SenderPlayerId", "SentAt"); + + b.ToTable("Messages"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.NotificationPrefsEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowAll") + .HasColumnType("INTEGER"); + + b.Property("AllowAnnouncements") + .HasColumnType("INTEGER"); + + b.Property("AllowEventInvite") + .HasColumnType("INTEGER"); + + b.Property("AllowFriendRequest") + .HasColumnType("INTEGER"); + + b.Property("AllowMessage") + .HasColumnType("INTEGER"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "Platform") + .IsUnique(); + + b.ToTable("NotificationPrefs"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ObjectiveProgressEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClearedAt") + .HasColumnType("TEXT"); + + b.Property("IsCompleted") + .HasColumnType("INTEGER"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "Key") + .IsUnique(); + + b.ToTable("ObjectiveProgress"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PendingDeviceEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DeviceId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FirstSeenAt") + .HasColumnType("TEXT"); + + b.Property("LastIp") + .HasColumnType("TEXT"); + + b.Property("LastSeenAt") + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.Property("PlatformId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("PendingDevices"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PhotoEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BlobName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Caption") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("CheerCount") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("IsPublic") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("TaggedPlayerIdsCsv") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("UploaderPlayerId") + .HasColumnType("INTEGER"); + + b.Property("ViewCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("BlobName"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("RoomId"); + + b.HasIndex("UploaderPlayerId"); + + b.ToTable("Photos"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlatformIgnoreEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.Property("PlatformUserId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "Platform", "PlatformUserId") + .IsUnique(); + + b.ToTable("PlatformIgnores"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerDeviceEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("FirstSeen") + .HasColumnType("TEXT"); + + b.Property("IsBanned") + .HasColumnType("INTEGER"); + + b.Property("LastSeen") + .HasColumnType("TEXT"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId"); + + b.HasIndex("PlayerId"); + + b.ToTable("PlayerDevices"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEloEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Elo") + .HasColumnType("INTEGER"); + + b.Property("GameMode") + .HasColumnType("INTEGER"); + + b.Property("Losses") + .HasColumnType("INTEGER"); + + b.Property("MatchesPlayed") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Wins") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GameMode", "Elo"); + + b.HasIndex("PlayerId", "GameMode") + .IsUnique(); + + b.ToTable("PlayerElo"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BannedUntil") + .HasColumnType("TEXT"); + + b.Property("BannerImageName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Bio") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Birthday") + .HasColumnType("TEXT"); + + b.Property("CanReceiveInvites") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedFromDeviceId") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DisplayEmoji") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IdentityFlags") + .HasColumnType("INTEGER"); + + b.Property("IsAdmin") + .HasColumnType("INTEGER"); + + b.Property("IsCommunityTeam") + .HasColumnType("INTEGER"); + + b.Property("IsDeveloper") + .HasColumnType("INTEGER"); + + b.Property("IsJunior") + .HasColumnType("INTEGER"); + + b.Property("IsVerified") + .HasColumnType("INTEGER"); + + b.Property("LastIp") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("LastPlatform") + .HasColumnType("INTEGER"); + + b.Property("LastPlatformId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("LastSeenAt") + .HasColumnType("TEXT"); + + b.Property("Level") + .HasColumnType("INTEGER"); + + b.Property("Locale") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PersonalPronouns") + .HasColumnType("INTEGER"); + + b.Property("Phone") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("ProfileImageName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Reputation") + .HasColumnType("INTEGER"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("XP") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Players"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEventEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Capacity") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("EndsAt") + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("StartsAt") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("StartsAt"); + + b.ToTable("PlayerEvents"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEventResponseEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EventId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Response") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("EventId", "PlayerId") + .IsUnique(); + + b.ToTable("PlayerEventResponses"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerInventoryEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AcquiredAt") + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("ItemSlug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Quantity") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("PlayerId", "ItemSlug") + .IsUnique(); + + b.ToTable("PlayerInventory"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerSettingEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "Key") + .IsUnique(); + + b.ToTable("PlayerSettings"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlaylistEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Accessibility") + .HasColumnType("INTEGER"); + + b.Property("CheerCount") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CustomWarning") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("FavoriteCount") + .HasColumnType("INTEGER"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsCurated") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.Property("OrderIndex") + .HasColumnType("INTEGER"); + + b.Property("SupportsJuniors") + .HasColumnType("INTEGER"); + + b.Property("SupportsLevelVoting") + .HasColumnType("INTEGER"); + + b.Property("SupportsScreens") + .HasColumnType("INTEGER"); + + b.Property("SupportsTeleportVR") + .HasColumnType("INTEGER"); + + b.Property("SupportsWalkVR") + .HasColumnType("INTEGER"); + + b.Property("TagsCsv") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("VisitCount") + .HasColumnType("INTEGER"); + + b.Property("VisitorCount") + .HasColumnType("INTEGER"); + + b.Property("WarningMask") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("IsCurated"); + + b.HasIndex("Name"); + + b.ToTable("Playlists"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlaylistInteractionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Cheered") + .HasColumnType("INTEGER"); + + b.Property("Favorited") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("PlaylistId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("PlaylistId", "PlayerId") + .IsUnique(); + + b.ToTable("PlaylistInteractions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlaylistRoomEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("OrderIndex") + .HasColumnType("INTEGER"); + + b.Property("PlaylistId") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlaylistId"); + + b.HasIndex("RoomId"); + + b.HasIndex("PlaylistId", "OrderIndex"); + + b.ToTable("PlaylistRooms"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PrivateInstanceEntity", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DataBlob") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Location") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("MaxCapacity") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("OwnerPlayerId") + .HasColumnType("INTEGER"); + + b.Property("PhotonRegion") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("PhotonRoomId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("RoomCode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("SubRoomId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("OwnerPlayerId"); + + b.HasIndex("RoomId", "SubRoomId"); + + b.ToTable("PrivateInstances"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PrivateInstanceInviteeEntity", b => + { + b.Property("PrivateInstanceId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("InvitedAt") + .HasColumnType("TEXT"); + + b.Property("LatestInviteMessageId") + .HasColumnType("INTEGER"); + + b.HasKey("PrivateInstanceId", "PlayerId"); + + b.HasIndex("PlayerId"); + + b.ToTable("PrivateInstanceInvitees"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PushTokenEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Token"); + + b.HasIndex("PlayerId", "Platform") + .IsUnique(); + + b.ToTable("PushTokens"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RelationshipEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Favorited") + .HasColumnType("INTEGER"); + + b.Property("Ignored") + .HasColumnType("INTEGER"); + + b.Property("Muted") + .HasColumnType("INTEGER"); + + b.Property("RequesterId") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("TargetId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RequesterId", "TargetId") + .IsUnique(); + + b.ToTable("Relationships"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ReportEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Category") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("GameSessionId") + .HasColumnType("INTEGER"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("ReporterPlayerId") + .HasColumnType("INTEGER"); + + b.Property("ResolutionNote") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("ResolvedAt") + .HasColumnType("TEXT"); + + b.Property("ResolverAdminId") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("TargetEventId") + .HasColumnType("INTEGER"); + + b.Property("TargetInventionId") + .HasColumnType("INTEGER"); + + b.Property("TargetPlayerId") + .HasColumnType("INTEGER"); + + b.Property("TargetRoomId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ReporterPlayerId"); + + b.HasIndex("ResolvedAt"); + + b.HasIndex("TargetPlayerId"); + + b.ToTable("Reports"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomBanEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BanType") + .HasColumnType("INTEGER"); + + b.Property("BannedByPlayerId") + .HasColumnType("INTEGER"); + + b.Property("BannedPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("Until") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("BannedPlayerId"); + + b.HasIndex("RoomId", "BannedPlayerId"); + + b.ToTable("RoomBans"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomBookmarkEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("PlayerId", "RoomId") + .IsUnique(); + + b.ToTable("RoomBookmarks"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomConsumableEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CurrencyId") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Price") + .HasColumnType("INTEGER"); + + b.Property("PublicId") + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("PublicId") + .IsUnique(); + + b.HasIndex("RoomId"); + + b.ToTable("RoomConsumables"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomConsumableOwnershipEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyCode") + .HasColumnType("TEXT"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("ModifiedAt") + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RoomConsumableId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoomConsumableId"); + + b.HasIndex("PlayerId", "RoomConsumableId") + .IsUnique(); + + b.ToTable("RoomConsumableOwnership"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomCurrencyBalanceEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Balance") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RoomCurrencyId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoomCurrencyId"); + + b.HasIndex("PlayerId", "RoomCurrencyId") + .IsUnique(); + + b.ToTable("RoomCurrencyBalances"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomCurrencyEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("DailyLimit") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("PublicId") + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("PublicId") + .IsUnique(); + + b.HasIndex("RoomId"); + + b.ToTable("RoomCurrencies"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomCurrencyPurchaseOfferEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Amount") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CurrencyType") + .HasColumnType("INTEGER"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Price") + .HasColumnType("INTEGER"); + + b.Property("PublicId") + .HasColumnType("TEXT"); + + b.Property("RoomCurrencyId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PublicId") + .IsUnique(); + + b.HasIndex("RoomCurrencyId"); + + b.ToTable("RoomCurrencyPurchaseOffers"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomDataBlobEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Bytes") + .HasColumnType("BLOB"); + + b.Property("ReferencedFilenamesCsv") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("SubRoomId") + .HasColumnType("INTEGER"); + + b.Property("UploadedAt") + .HasColumnType("TEXT"); + + b.Property("UploadedByPlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("BlobName") + .IsUnique(); + + b.HasIndex("RoomId"); + + b.ToTable("RoomDataBlobs"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Accessibility") + .HasColumnType("INTEGER"); + + b.Property("AllowNewUsers") + .HasColumnType("INTEGER"); + + b.Property("AllowsJuniors") + .HasColumnType("INTEGER"); + + b.Property("CheerCount") + .HasColumnType("INTEGER"); + + b.Property("CloningAllowed") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CurrentDataBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("CustomRoomWarning") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("DisableMicAutoMute") + .HasColumnType("INTEGER"); + + b.Property("FavoriteCount") + .HasColumnType("INTEGER"); + + b.Property("HiddenFromBrowse") + .HasColumnType("INTEGER"); + + b.Property("HotScore") + .HasColumnType("REAL"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsAGRoom") + .HasColumnType("INTEGER"); + + b.Property("IsDormRoom") + .HasColumnType("INTEGER"); + + b.Property("IsRoomLinkedToRecRoomStudio") + .HasColumnType("INTEGER"); + + b.Property("IsStudioRoom") + .HasColumnType("INTEGER"); + + b.Property("LoadScreensJson") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("LocationReplicationId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("MaxCapacity") + .HasColumnType("INTEGER"); + + b.Property("MaxPlayerCalculationMode") + .HasColumnType("INTEGER"); + + b.Property("MinLevel") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.Property("PromoExternalContentJson") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("PromoImagesJson") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("RoomWarningMask") + .HasColumnType("INTEGER"); + + b.Property("State") + .HasColumnType("INTEGER"); + + b.Property("StudioSessionId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("SupportsLevelVoting") + .HasColumnType("INTEGER"); + + b.Property("SupportsMobile") + .HasColumnType("INTEGER"); + + b.Property("SupportsScreens") + .HasColumnType("INTEGER"); + + b.Property("SupportsTeleportVR") + .HasColumnType("INTEGER"); + + b.Property("SupportsVRLow") + .HasColumnType("INTEGER"); + + b.Property("SupportsWalkVR") + .HasColumnType("INTEGER"); + + b.Property("TagsCsv") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("VisitCount") + .HasColumnType("INTEGER"); + + b.Property("VisitorCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("HotScore"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Rooms"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomKeyEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(174) + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("TEXT"); + + b.Property("Price") + .HasColumnType("INTEGER"); + + b.Property("ReplicationId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("RoomId"); + + b.HasIndex("RoomId", "Name"); + + b.ToTable("RoomKeys"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomKeyPurchaseEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("PaidPrice") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("PurchasedAt") + .HasColumnType("TEXT"); + + b.Property("RoomKeyId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("RoomKeyId", "PlayerId") + .IsUnique(); + + b.ToTable("RoomKeyPurchases"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomRoleEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Accepted") + .HasColumnType("INTEGER"); + + b.Property("GrantedAt") + .HasColumnType("TEXT"); + + b.Property("GrantedByPlayerId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoomId"); + + b.HasIndex("RoomId", "PlayerId", "Role") + .IsUnique(); + + b.ToTable("RoomRoles"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomRolePermissionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Override") + .HasColumnType("INTEGER"); + + b.Property("Permission") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("SubRoomId") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UpdatedByPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoomId", "SubRoomId", "Role", "Permission") + .IsUnique(); + + b.ToTable("RoomRolePermissions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomSceneEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CanMatchmakeInto") + .HasColumnType("INTEGER"); + + b.Property("DataBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DataModifiedAt") + .HasColumnType("TEXT"); + + b.Property("IsSandbox") + .HasColumnType("INTEGER"); + + b.Property("MaxPlayers") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.Property("OrderIndex") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("RoomSceneLocationId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("StudioAssetBundleNamesCsv") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("StudioSubRoomDataSaveId") + .HasColumnType("INTEGER"); + + b.Property("StudioUnityAssetId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoomId", "Name"); + + b.HasIndex("RoomId", "OrderIndex") + .IsUnique(); + + b.ToTable("RoomScenes"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomVisitEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("FirstVisitAt") + .HasColumnType("TEXT"); + + b.Property("LastVisitAt") + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("VisitCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("RoomId", "LastVisitAt"); + + b.HasIndex("RoomId", "PlayerId") + .IsUnique(); + + b.ToTable("RoomVisits"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoyaleMatchEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChestsOpened") + .HasColumnType("INTEGER"); + + b.Property("CompletedAt") + .HasColumnType("TEXT"); + + b.Property("CustomGame") + .HasColumnType("INTEGER"); + + b.Property("HealthPotionsConsumed") + .HasColumnType("INTEGER"); + + b.Property("NumEliminations") + .HasColumnType("INTEGER"); + + b.Property("Rank") + .HasColumnType("INTEGER"); + + b.Property("SecondsAlive") + .HasColumnType("INTEGER"); + + b.Property("SecondsInAir") + .HasColumnType("INTEGER"); + + b.Property("ShieldPotionsConsumed") + .HasColumnType("INTEGER"); + + b.Property("WalkGame") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CompletedAt"); + + b.ToTable("RoyaleMatches"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoyaleMatchPlayerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("MatchId") + .HasColumnType("INTEGER"); + + b.Property("NumEliminations") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Rank") + .HasColumnType("INTEGER"); + + b.Property("SecondsAlive") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("MatchId", "PlayerId"); + + b.ToTable("RoyaleMatchPlayers"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoyalePlayerProgressEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CurrentLevelXPThreshold") + .HasColumnType("INTEGER"); + + b.Property("Level") + .HasColumnType("INTEGER"); + + b.Property("NextLevelAcornReward") + .HasColumnType("INTEGER"); + + b.Property("NextLevelXPThreshold") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RankIdx") + .HasColumnType("INTEGER"); + + b.Property("RankName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("TotalXP") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId") + .IsUnique(); + + b.ToTable("RoyalePlayerProgress"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ServerSettingsEntity", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("AllAvatarItemsOwned") + .HasColumnType("INTEGER"); + + b.Property("BaseRoomNamesJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CharadesSlotBindingsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CircuitsV2LimitsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DiscoveredGameConfigsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("GlobalFriendsEnabled") + .HasColumnType("INTEGER"); + + b.Property("PlayMenuTagsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("ProfanityFilterDisabled") + .HasColumnType("INTEGER"); + + b.Property("RecCenterDoorsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("RoomBlobVersionClampDisabled") + .HasColumnType("INTEGER"); + + b.Property("SignupsDisabled") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("WeeklyChallengeRewardJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WeeklyChallengesCompletedRequired") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("WeeklyChallengesJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.HasKey("Id"); + + b.ToTable("ServerSettings"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.SignupCodeEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Code") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedByPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Descriptor") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("RedeemedAt") + .HasColumnType("TEXT"); + + b.Property("RedeemedByPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Revoked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("SignupCodes"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.StoreItemEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvailableUntil") + .HasColumnType("TEXT"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CurrencyType") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("IsLimitedTime") + .HasColumnType("INTEGER"); + + b.Property("Price") + .HasColumnType("INTEGER"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Storefront") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.HasIndex("Storefront"); + + b.HasIndex("IsActive", "Category"); + + b.ToTable("StoreItems"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.SubscriptionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("SubscriberPlayerId") + .HasColumnType("INTEGER"); + + b.Property("TargetPlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SubscriberPlayerId"); + + b.HasIndex("TargetPlayerId"); + + b.HasIndex("SubscriberPlayerId", "TargetPlayerId") + .IsUnique(); + + b.ToTable("Subscriptions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.TestCaseEntity", b => + { + b.Property("Pk") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AssignedPlayerIdsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("AssignedPlayerNamesJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CommentsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("JiraBugUrl") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("JiraUrl") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("MinNumAssignedPlayers") + .HasColumnType("INTEGER"); + + b.Property("RoomName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("TagsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TestPassId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Pk"); + + b.HasIndex("Id") + .IsUnique(); + + b.HasIndex("Status"); + + b.HasIndex("TestPassId"); + + b.ToTable("TestCases"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.TestPassEntity", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("EndDate") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("StartDate") + .HasColumnType("TEXT"); + + b.Property("TagsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("WasManuallyClosed") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("TestPasses"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.UgcPurchasableEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CurrencyType") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("IsFeatured") + .HasColumnType("INTEGER"); + + b.Property("ItemType") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Price") + .HasColumnType("INTEGER"); + + b.Property("PublicId") + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("PublicId") + .IsUnique(); + + b.HasIndex("RoomId"); + + b.ToTable("UgcPurchasables"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("FriendlyName") + .HasColumnType("TEXT"); + + b.Property("Xml") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("DataProtectionKeys"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player") + .WithOne("Avatar") + .HasForeignKey("DorkNet.Server.Data.Entities.AvatarEntity", "PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerSettingEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player") + .WithMany("Settings") + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RelationshipEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Requester") + .WithMany("Relationships") + .HasForeignKey("RequesterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Requester"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEntity", b => + { + b.Navigation("Avatar"); + + b.Navigation("Relationships"); + + b.Navigation("Settings"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/DorkNet.Server/Migrations/20261025134141_ExternalSignIn.cs b/DorkNet.Server/Migrations/20261025134141_ExternalSignIn.cs new file mode 100644 index 0000000..841bba2 --- /dev/null +++ b/DorkNet.Server/Migrations/20261025134141_ExternalSignIn.cs @@ -0,0 +1,68 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace DorkNet.Server.Migrations +{ + /// + public partial class ExternalSignIn : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "DataProtectionKeys", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + FriendlyName = table.Column(type: "TEXT", nullable: true), + Xml = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_DataProtectionKeys", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "ExternalLogins", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + PlayerId = table.Column(type: "INTEGER", nullable: false), + Issuer = table.Column(type: "TEXT", nullable: false), + Subject = table.Column(type: "TEXT", nullable: false), + Email = table.Column(type: "TEXT", nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false), + LastUsedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ExternalLogins", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_ExternalLogins_Issuer_Subject", + table: "ExternalLogins", + columns: new[] { "Issuer", "Subject" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_ExternalLogins_PlayerId", + table: "ExternalLogins", + column: "PlayerId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "DataProtectionKeys"); + + migrationBuilder.DropTable( + name: "ExternalLogins"); + } + } +} diff --git a/DorkNet.Server/Migrations/DorkNetDbContextModelSnapshot.cs b/DorkNet.Server/Migrations/DorkNetDbContextModelSnapshot.cs index 23e6c14..caa7c28 100644 --- a/DorkNet.Server/Migrations/DorkNetDbContextModelSnapshot.cs +++ b/DorkNet.Server/Migrations/DorkNetDbContextModelSnapshot.cs @@ -15,7 +15,7 @@ namespace DorkNet.Server.Migrations protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "9.0.4"); + modelBuilder.HasAnnotation("ProductVersion", "10.0.13"); modelBuilder.Entity("DorkNet.Server.Data.Entities.AdminActionEntity", b => { @@ -972,6 +972,43 @@ namespace DorkNet.Server.Migrations b.ToTable("DormStates"); }); + modelBuilder.Entity("DorkNet.Server.Data.Entities.ExternalLoginEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Email") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Issuer") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LastUsedAt") + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Subject") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("Issuer", "Subject") + .IsUnique(); + + b.ToTable("ExternalLogins"); + }); + modelBuilder.Entity("DorkNet.Server.Data.Entities.GameRewardSelectionEntity", b => { b.Property("Id") @@ -3668,6 +3705,23 @@ namespace DorkNet.Server.Migrations b.ToTable("UgcPurchasables"); }); + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("FriendlyName") + .HasColumnType("TEXT"); + + b.Property("Xml") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("DataProtectionKeys"); + }); + modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b => { b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player") diff --git a/DorkNet.Server/Services/DownloadsService.cs b/DorkNet.Server/Services/DownloadsService.cs index 1d2ee57..07f03b8 100644 --- a/DorkNet.Server/Services/DownloadsService.cs +++ b/DorkNet.Server/Services/DownloadsService.cs @@ -39,6 +39,7 @@ public sealed class DownloadsService } var host = config["DorkNet:Downloads:Host"]?.Trim(); _host = string.IsNullOrEmpty(host) ? null : host; + RequireSignIn = config.GetValue("DorkNet:Downloads:RequireSignIn"); if (_baseUri is null) log.LogInformation("[downloads] no DorkNet:Downloads:BaseUrl; the Downloads page and Quest latest-build install are off"); @@ -48,6 +49,10 @@ public sealed class DownloadsService public bool IsConfigured => _baseUri is not null; + /// DorkNet:Downloads:RequireSignIn: serve the manifest and + /// files only to a signed-in, linked player (website sign-in). + public bool RequireSignIn { get; } + /// The published manifest, or null when none has been published yet. public async Task GetManifestAsync(CancellationToken ct) { diff --git a/DorkNet.Server/Services/ExternalSignIn.cs b/DorkNet.Server/Services/ExternalSignIn.cs new file mode 100644 index 0000000..e2657b2 --- /dev/null +++ b/DorkNet.Server/Services/ExternalSignIn.cs @@ -0,0 +1,151 @@ +using System.Collections.Concurrent; +using System.Security.Claims; +using Microsoft.EntityFrameworkCore; +using DorkNet.Server.Data; +using DorkNet.Server.Data.Entities; + +namespace DorkNet.Server.Services; + +/// +/// External sign-in settings (DorkNet:SignIn:*): an OpenID Connect +/// provider players sign in with on the website and, for admins, the admin +/// panel. Any standard provider works (e.g. a Cloudflare Access SaaS app, +/// Google, Authentik). Unset, the feature is off and only the existing +/// username/password logins exist. +/// +public sealed class ExternalSignInOptions +{ + /// The provider's issuer URL; discovery is read from + /// {Authority}/.well-known/openid-configuration. + public string Authority { get; set; } = string.Empty; + public string ClientId { get; set; } = string.Empty; + public string ClientSecret { get; set; } = string.Empty; + + /// The button label: "Sign in with {DisplayName}". + public string DisplayName { get; set; } = "single sign-on"; + + public string Scopes { get; set; } = "openid email profile"; + + public bool IsConfigured => + Authority.Length > 0 && ClientId.Length > 0 && ClientSecret.Length > 0; +} + +/// Scheme, path and claim names shared by the sign-in pieces. +public static class ExternalSignIn +{ + /// The signed-in session (a linked DorkNet player), per host. + public const string WebScheme = "DorkNet.Web"; + + /// A provider identity that isn't linked to an account yet. Lives + /// only until the player links it (or 15 minutes). + public const string ExternalScheme = "DorkNet.External"; + + public const string OidcScheme = "DorkNet.Oidc"; + + // Everything lives under /api/: the website and admin hosts answer every + // other GET with their SPA's index.html before authentication runs. + public const string LoginPath = "/api/auth/external/login"; + public const string CallbackPath = "/api/auth/external/callback"; + public const string CompletePath = "/api/auth/external/complete"; + + public const string PlayerIdClaim = "dorknet:player_id"; + public const string IssuerClaim = "dorknet:issuer"; + public const string SubjectClaim = "dorknet:subject"; + public const string EmailClaim = "dorknet:email"; + + public static ClaimsPrincipal PlayerPrincipal(PlayerEntity player, ExternalLoginEntity link) => + new(new ClaimsIdentity(new[] + { + new Claim(PlayerIdClaim, player.Id.ToString()), + new Claim(ClaimTypes.Name, player.Username), + new Claim(IssuerClaim, link.Issuer), + new Claim(SubjectClaim, link.Subject), + new Claim(EmailClaim, link.Email), + }, WebScheme, ClaimTypes.Name, ClaimTypes.Role)); + + public static long? PlayerId(ClaimsPrincipal? principal) => + long.TryParse(principal?.FindFirst(PlayerIdClaim)?.Value, out var id) ? id : null; +} + +public enum LinkOutcome { Linked, InvalidCredentials, Banned, AlreadyLinkedElsewhere } + +/// Links provider identities to DorkNet accounts and resolves them +/// back. An identity is (issuer, subject); the email is only shown. +public sealed class ExternalSignInService(DorkNetDbContext db) +{ + public Task FindAsync(string issuer, string subject) => + db.ExternalLogins.FirstOrDefaultAsync(l => l.Issuer == issuer && l.Subject == subject); + + /// The linked player, unless the link or the player is gone or the + /// player is banned. + public async Task ActivePlayerAsync(ExternalLoginEntity link) + { + var player = await db.Players.FirstOrDefaultAsync(p => p.Id == link.PlayerId); + return player is null || IsBanned(player) ? null : player; + } + + public async Task TouchAsync(ExternalLoginEntity link, string email) + { + link.LastUsedAt = DateTime.UtcNow; + if (!string.IsNullOrEmpty(email)) link.Email = email; + await db.SaveChangesAsync(); + } + + /// Links (issuer, subject) to the account the credentials prove. + /// A player has one identity per provider: linking a new one replaces the + /// old. An identity already linked to a different account is refused. + public async Task<(LinkOutcome Outcome, PlayerEntity? Player, ExternalLoginEntity? Link)> LinkAsync( + string issuer, string subject, string email, string username, string password) + { + var player = await db.Players.FirstOrDefaultAsync(p => p.Username == username); + if (player?.PasswordHash is null || !BCrypt.Net.BCrypt.Verify(password, player.PasswordHash)) + return (LinkOutcome.InvalidCredentials, null, null); + if (IsBanned(player)) return (LinkOutcome.Banned, null, null); + + var existing = await FindAsync(issuer, subject); + if (existing is not null && existing.PlayerId != player.Id) + return (LinkOutcome.AlreadyLinkedElsewhere, null, null); + + if (existing is null) + { + db.ExternalLogins.RemoveRange( + db.ExternalLogins.Where(l => l.PlayerId == player.Id && l.Issuer == issuer)); + existing = new ExternalLoginEntity { PlayerId = player.Id, Issuer = issuer, Subject = subject }; + db.ExternalLogins.Add(existing); + } + existing.Email = email; + existing.LastUsedAt = DateTime.UtcNow; + await db.SaveChangesAsync(); + return (LinkOutcome.Linked, player, existing); + } + + public async Task UnlinkAsync(long playerId, string issuer) => + await db.ExternalLogins.Where(l => l.PlayerId == playerId && l.Issuer == issuer).ExecuteDeleteAsync() > 0; + + private static bool IsBanned(PlayerEntity p) => p.BannedUntil is { } until && until > DateTime.UtcNow; +} + +/// Caps failed link attempts (a password guess each) per provider +/// identity and per username: 5 per 15 minutes each. In memory, per process, +/// which is enough to stop guessing through the link form. +public sealed class LinkAttemptLimiter +{ + private const int MaxFailures = 5; + private static readonly TimeSpan Window = TimeSpan.FromMinutes(15); + private readonly ConcurrentDictionary _failures = new(); + + public bool IsBlocked(params string[] keys) => keys.Any(k => + _failures.TryGetValue(k, out var f) && DateTime.UtcNow - f.Since < Window && f.Count >= MaxFailures); + + public void RecordFailure(params string[] keys) + { + var now = DateTime.UtcNow; + foreach (var k in keys) + _failures.AddOrUpdate(k, (1, now), (_, f) => now - f.Since >= Window ? (1, now) : (f.Count + 1, f.Since)); + } + + public void Reset(params string[] keys) + { + foreach (var k in keys) _failures.TryRemove(k, out _); + } +} diff --git a/DorkNet.Server/Startup/DatabaseBootstrap.cs b/DorkNet.Server/Startup/DatabaseBootstrap.cs index 64ed35a..f6e0314 100644 --- a/DorkNet.Server/Startup/DatabaseBootstrap.cs +++ b/DorkNet.Server/Startup/DatabaseBootstrap.cs @@ -151,6 +151,7 @@ public static class DatabaseBootstrap // idempotent raw SQL that works on a fresh DB too (CREATE TABLE // IF NOT EXISTS no-ops when EnsureCreated already built them). await EnsureSignupCodeTablesAsync(db); + await EnsureExternalSignInTablesAsync(db); await EnsureServerSettingsColumnsAsync(db); await EnsureCharadesWordListTableAsync(db); await EnsureInventorySchemaAsync(db); @@ -265,6 +266,66 @@ public static class DatabaseBootstrap /// tables on whichever provider is in use. Safe to run every boot: /// CREATE TABLE / INDEX IF NOT EXISTS no-op once they exist (whether /// created here or by a fresh-DB EnsureCreated). + /// External sign-in: the identities linked to accounts + /// (ExternalLogins) and the data-protection keys that encrypt the + /// sign-in cookies (DataProtectionKeys). Both post-date the Initial + /// migration, so like the signup-code tables they're created here for + /// either provider (a no-op once EnsureCreated or an earlier boot made them). + private static async Task EnsureExternalSignInTablesAsync(DorkNetDbContext db) + { + var pg = db.Database.IsNpgsql(); + var statements = pg + ? new[] + { + """ + CREATE TABLE IF NOT EXISTS "ExternalLogins" ( + "Id" bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + "PlayerId" bigint NOT NULL, + "Issuer" text NOT NULL, + "Subject" text NOT NULL, + "Email" text NOT NULL DEFAULT '', + "CreatedAt" timestamp with time zone NOT NULL DEFAULT now(), + "LastUsedAt" timestamp with time zone NOT NULL DEFAULT now() + ); + """, + @"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_ExternalLogins_Issuer_Subject"" ON ""ExternalLogins"" (""Issuer"", ""Subject"");", + @"CREATE INDEX IF NOT EXISTS ""IX_ExternalLogins_PlayerId"" ON ""ExternalLogins"" (""PlayerId"");", + """ + CREATE TABLE IF NOT EXISTS "DataProtectionKeys" ( + "Id" integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + "FriendlyName" text NULL, + "Xml" text NULL + ); + """, + } + : new[] + { + """ + CREATE TABLE IF NOT EXISTS "ExternalLogins" ( + "Id" INTEGER NOT NULL CONSTRAINT "PK_ExternalLogins" PRIMARY KEY AUTOINCREMENT, + "PlayerId" INTEGER NOT NULL, + "Issuer" TEXT NOT NULL, + "Subject" TEXT NOT NULL, + "Email" TEXT NOT NULL DEFAULT '', + "CreatedAt" TEXT NOT NULL, + "LastUsedAt" TEXT NOT NULL + ); + """, + @"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_ExternalLogins_Issuer_Subject"" ON ""ExternalLogins"" (""Issuer"", ""Subject"");", + @"CREATE INDEX IF NOT EXISTS ""IX_ExternalLogins_PlayerId"" ON ""ExternalLogins"" (""PlayerId"");", + """ + CREATE TABLE IF NOT EXISTS "DataProtectionKeys" ( + "Id" INTEGER NOT NULL CONSTRAINT "PK_DataProtectionKeys" PRIMARY KEY AUTOINCREMENT, + "FriendlyName" TEXT NULL, + "Xml" TEXT NULL + ); + """, + }; + + foreach (var sql in statements) + await db.Database.ExecuteSqlRawAsync(sql); + } + private static async Task EnsureSignupCodeTablesAsync(DorkNetDbContext db) { var pg = db.Database.IsNpgsql(); diff --git a/DorkNet.Server/Startup/ServiceCollectionExtensions.cs b/DorkNet.Server/Startup/ServiceCollectionExtensions.cs index e023f4b..f6d247e 100644 --- a/DorkNet.Server/Startup/ServiceCollectionExtensions.cs +++ b/DorkNet.Server/Startup/ServiceCollectionExtensions.cs @@ -1,4 +1,8 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Authentication.OpenIdConnect; +using Microsoft.AspNetCore.DataProtection; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.IdentityModel.Tokens; @@ -29,6 +33,7 @@ public static class ServiceCollectionExtensions AddVersionCompatibility(builder); var redisConn = AddRedis(builder); AddAuthentication(builder); + AddExternalSignIn(builder); AddKestrelAndSignalR(builder, redisConn); } @@ -397,6 +402,112 @@ public static class ServiceCollectionExtensions opt.JsonSerializerOptions.PropertyNamingPolicy = null); } + // ── External sign-in (OpenID Connect) ───────────────────────────────────── + // Players sign in to the website (and admins to the admin panel) with an + // OpenID Connect provider, configured under DorkNet:SignIn. The game + // client's own token flow is untouched. Two cookie schemes, both per host + // (the hyphen host style puts the admin panel outside the apex's cookie + // domain): DorkNet.Web is the signed-in player, DorkNet.External a + // provider identity waiting to be linked to an account. + private static void AddExternalSignIn(WebApplicationBuilder builder) + { + var services = builder.Services; + var section = builder.Configuration.GetSection("DorkNet:SignIn"); + services.Configure(section); + var signIn = section.Get() ?? new ExternalSignInOptions(); + var https = !string.Equals(builder.Configuration["Domain:Scheme"] ?? "https", "http", StringComparison.OrdinalIgnoreCase); + var secure = https ? CookieSecurePolicy.Always : CookieSecurePolicy.SameAsRequest; + + services.AddScoped(); + services.AddSingleton(); + + // Cookie encryption keys live in the database, so a redeploy or a + // second replica doesn't invalidate everyone's session. + services.AddDataProtection() + .SetApplicationName("DorkNet") + .PersistKeysToDbContext(); + + var auth = services.AddAuthentication() + .AddCookie(ExternalSignIn.WebScheme, o => + { + o.Cookie.Name = "dorknet_session"; + o.Cookie.HttpOnly = true; + o.Cookie.SameSite = SameSiteMode.Lax; + o.Cookie.SecurePolicy = secure; + o.ExpireTimeSpan = TimeSpan.FromDays(30); + o.SlidingExpiration = true; + // API endpoints answer 401/403 instead of redirecting. + o.Events.OnRedirectToLogin = ctx => { ctx.Response.StatusCode = StatusCodes.Status401Unauthorized; return Task.CompletedTask; }; + o.Events.OnRedirectToAccessDenied = ctx => { ctx.Response.StatusCode = StatusCodes.Status403Forbidden; return Task.CompletedTask; }; + // An unlinked identity, a deleted or banned player ends the session. + o.Events.OnValidatePrincipal = async ctx => + { + var issuer = ctx.Principal?.FindFirst(ExternalSignIn.IssuerClaim)?.Value; + var subject = ctx.Principal?.FindFirst(ExternalSignIn.SubjectClaim)?.Value; + var svc = ctx.HttpContext.RequestServices.GetRequiredService(); + var link = issuer is null || subject is null ? null : await svc.FindAsync(issuer, subject); + if (link is null || link.PlayerId != ExternalSignIn.PlayerId(ctx.Principal) + || await svc.ActivePlayerAsync(link) is null) + { + ctx.RejectPrincipal(); + await ctx.HttpContext.SignOutAsync(ExternalSignIn.WebScheme); + } + }; + }) + .AddCookie(ExternalSignIn.ExternalScheme, o => + { + o.Cookie.Name = "dorknet_external"; + o.Cookie.HttpOnly = true; + o.Cookie.SameSite = SameSiteMode.Lax; + o.Cookie.SecurePolicy = secure; + o.ExpireTimeSpan = TimeSpan.FromMinutes(15); + o.Events.OnRedirectToLogin = ctx => { ctx.Response.StatusCode = StatusCodes.Status401Unauthorized; return Task.CompletedTask; }; + }); + + if (!signIn.IsConfigured) return; + + auth.AddOpenIdConnect(ExternalSignIn.OidcScheme, o => + { + o.Authority = signIn.Authority; + o.ClientId = signIn.ClientId; + o.ClientSecret = signIn.ClientSecret; + o.ResponseType = "code"; + o.UsePkce = true; + o.Scope.Clear(); + foreach (var scope in signIn.Scopes.Split(' ', StringSplitOptions.RemoveEmptyEntries)) + o.Scope.Add(scope); + o.SignInScheme = ExternalSignIn.ExternalScheme; + o.CallbackPath = ExternalSignIn.CallbackPath; + o.GetClaimsFromUserInfoEndpoint = true; + o.MapInboundClaims = false; + o.SaveTokens = false; + o.CorrelationCookie.SecurePolicy = secure; + o.NonceCookie.SecurePolicy = secure; + o.Events.OnRedirectToIdentityProvider = ctx => + { + // Behind TLS-terminating proxies the request arrives as http; + // the provider must redirect back to the public URL of the + // host the sign-in started on (website or admin panel). + var domain = ctx.HttpContext.RequestServices.GetRequiredService(); + ctx.ProtocolMessage.RedirectUri = $"{domain.Scheme}://{ctx.Request.Host}{ExternalSignIn.CallbackPath}"; + return Task.CompletedTask; + }; + o.Events.OnRemoteFailure = ctx => + { + // e.g. the player cancelled at the provider: back to the login + // page (admin panel) or the site with an error instead of an + // exception page. The message is logged, not shown. + var domain = ctx.HttpContext.RequestServices.GetRequiredService(); + var admin = string.Equals(ctx.Request.Host.Host, domain.Sub("admin"), StringComparison.OrdinalIgnoreCase); + ctx.HttpContext.RequestServices.GetRequiredService() + .CreateLogger("DorkNet.SignIn").LogWarning(ctx.Failure, "[signin] provider sign-in failed"); + ctx.Response.Redirect((admin ? "/login" : "/") + "?signin_error=provider_failed"); + ctx.HandleResponse(); + return Task.CompletedTask; + }; + }); + } + // ── Kestrel + SignalR ──────────────────────────────────────────────────── // Kestrel-level limits: defaults trim long-lived sockets earlier than our // SignalR ping interval allows. diff --git a/DorkNet.Server/admin-ui/src/pages/Login.tsx b/DorkNet.Server/admin-ui/src/pages/Login.tsx index 14c43d6..cb5452a 100644 --- a/DorkNet.Server/admin-ui/src/pages/Login.tsx +++ b/DorkNet.Server/admin-ui/src/pages/Login.tsx @@ -1,7 +1,8 @@ -import { useState } from 'react'; -import { useNavigate, useLocation } from 'react-router'; +import { useEffect, useRef, useState } from 'react'; +import { useNavigate, useLocation, useSearchParams } from 'react-router'; import { api, ApiError } from '../lib/api'; import { notifyAuthChange, setSession } from '../lib/auth'; +import { adminApex } from '../lib/types'; import { BrandMark } from '../components/BrandMark'; interface LoginResponse { @@ -12,14 +13,81 @@ interface LoginResponse { display_name: string; } +// The website, where an identity from the sign-in provider is linked to an +// account (the admin panel only signs in identities that are linked already). +function siteUrl(path: string): string { + const apex = adminApex(); + return `${apex === 'localhost' ? 'http' : 'https'}://${apex}${path}`; +} + +const SSO_ERRORS: Record = { + not_admin: 'That account is not an admin.', + not_signed_in: "Single sign-on didn't finish. Try again.", + account_unavailable: 'The account linked to that sign-in is unavailable (deleted or banned).', + no_identity: "The sign-in provider didn't return an identity. Try again.", + provider_failed: 'The sign-in was cancelled or failed at the provider. Try again.', +}; + export function Login() { const navigate = useNavigate(); const location = useLocation(); + const [params, setParams] = useSearchParams(); const from = (location.state as { from?: { pathname: string } } | null)?.from?.pathname ?? '/'; const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); - const [err, setErr] = useState(null); + const [err, setErr] = useState(null); const [loading, setLoading] = useState(false); + // Single sign-on through the server's OpenID Connect provider, if set up. + const [sso, setSso] = useState<{ provider: string } | null>(null); + const ssoHandled = useRef(false); + + const finish = (res: LoginResponse, to: string) => { + setSession(res.access_token, { + id: res.account_id, + username: res.username, + displayName: res.display_name, + }); + notifyAuthChange(); + navigate(to, { replace: true }); + }; + + const errorText = (e: unknown, codes: Record): React.ReactNode => { + if (e instanceof ApiError && e.body && typeof e.body === 'object' && 'error' in e.body) { + const code = (e.body as { error: string }).error; + return codes[code] ?? e.message; + } + return e instanceof Error ? e.message : String(e); + }; + + useEffect(() => { + fetch('/api/site/v1/account/me', { cache: 'no-store' }) + .then((r) => (r.ok ? r.json() : null)) + .then((me) => { if (me?.signInAvailable) setSso({ provider: me.provider }); }) + .catch(() => { /* no single sign-on: password only */ }); + }, []); + + // Back from the provider: ?sso=1 swaps the session for the admin token; + // ?signin_error=… explains why it didn't work. + useEffect(() => { + if (ssoHandled.current) return; + const signinError = params.get('signin_error'); + if (signinError) { + ssoHandled.current = true; + setErr(signinError === 'not_linked' + ? <>This sign-in isn't linked to a DorkNet account yet. Link it on the website first, then sign in here again. + : SSO_ERRORS[signinError] ?? `Single sign-on failed (${signinError}).`); + setParams({}, { replace: true }); + return; + } + if (params.get('sso') !== '1') return; + ssoHandled.current = true; + setLoading(true); + api('/sso') + .then((res) => finish(res, params.get('to') || '/')) + .catch((e) => { setErr(errorText(e, SSO_ERRORS)); setParams({}, { replace: true }); }) + .finally(() => setLoading(false)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); const onSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -30,27 +98,19 @@ export function Login() { method: 'POST', body: { username, password }, }); - setSession(res.access_token, { - id: res.account_id, - username: res.username, - displayName: res.display_name, - }); - notifyAuthChange(); - navigate(from, { replace: true }); + finish(res, from); } catch (e) { - if (e instanceof ApiError) { - if (e.body && typeof e.body === 'object' && 'error' in e.body) { - const code = (e.body as { error: string }).error; - if (code === 'not_admin') setErr('That account is not an admin.'); - else if (code === 'invalid_credentials') setErr('Invalid username or password.'); - else setErr(e.message); - } else setErr(e.message); - } else setErr(String(e)); + setErr(errorText(e, { + not_admin: 'That account is not an admin.', + invalid_credentials: 'Invalid username or password.', + })); } finally { setLoading(false); } }; + const ssoHref = `/api/auth/external/login?returnUrl=${encodeURIComponent(`/login?sso=1&to=${encodeURIComponent(from)}`)}`; + return (
@@ -92,6 +152,14 @@ export function Login() { + {sso && ( + <> +
+ or +
+ Sign in with {sso.provider} + + )}

DorkNet private server · admin console

diff --git a/DorkNet.Server/site/src/App.tsx b/DorkNet.Server/site/src/App.tsx index 0e17931..e066f27 100644 --- a/DorkNet.Server/site/src/App.tsx +++ b/DorkNet.Server/site/src/App.tsx @@ -9,6 +9,8 @@ import { PlayerProfile } from './pages/PlayerProfile'; import { Rooms } from './pages/Rooms'; import { Join } from './pages/Join'; import { Downloads } from './pages/Downloads'; +import { Link as LinkAccount } from './pages/Link'; +import { Account } from './pages/Account'; import { NotFound } from './pages/NotFound'; // The Quest installer pulls in the WebUSB/ADB stack; load it only on /quest. @@ -29,6 +31,8 @@ export function App() { } /> } /> } /> + } /> + } /> {/* Legacy paths from the vanilla-JS feed.localhost site */} } /> } /> diff --git a/DorkNet.Server/site/src/components/Header.tsx b/DorkNet.Server/site/src/components/Header.tsx index ecfe66d..3801c24 100644 --- a/DorkNet.Server/site/src/components/Header.tsx +++ b/DorkNet.Server/site/src/components/Header.tsx @@ -1,7 +1,8 @@ -import { NavLink, Link, useLocation, useNavigate } from 'react-router'; +import { NavLink, Link, useLocation, useNavigate, useSearchParams } from 'react-router'; import { useEffect, useRef, useState } from 'react'; import { BrandMark } from './BrandMark'; -import { getDownloads } from '../lib/downloads'; +import { getDownloads, SignInRequiredError } from '../lib/downloads'; +import { getMe, signInUrl, signOut, SIGNIN_ERRORS, type AccountMe } from '../lib/account'; const NAV = [ { to: '/', label: 'Home', exact: true }, @@ -23,10 +24,31 @@ export function Header() { const [hasDownloads, setHasDownloads] = useState(false); const navItems = hasDownloads ? [...NAV, DOWNLOADS] : NAV; + const [me, setMe] = useState(null); + const [params, setParams] = useSearchParams(); + const signinError = params.get('signin_error'); + useEffect(() => { - getDownloads().then((m) => setHasDownloads(m !== null)).catch(() => {}); + getDownloads().then((m) => setHasDownloads(m !== null)) + .catch((e) => setHasDownloads(e instanceof SignInRequiredError)); + getMe().then(setMe).catch(() => { /* site works without sign-in */ }); }, []); + const doSignOut = async () => { + await signOut().catch(() => {}); + window.location.assign('/'); + }; + + // Sign in / account, shown only when the server has a sign-in provider. + const account = !me?.signInAvailable ? null : me.signedIn && me.player ? ( + + {me.player.username} + + + ) : ( + Sign in + ); + // Close the mobile drawer on route change so tapping a nav item // doesn't leave a half-open overlay covering the page. useEffect(() => { setOpen(false); }, [loc.pathname]); @@ -72,6 +94,8 @@ export function Header() { /> +
{account}
+
+
+ )} + + {signinError && ( +
+
+ Sign-in didn't work: {SIGNIN_ERRORS[signinError] ?? signinError} +
)} diff --git a/DorkNet.Server/site/src/lib/account.ts b/DorkNet.Server/site/src/lib/account.ts new file mode 100644 index 0000000..04f3c5e --- /dev/null +++ b/DorkNet.Server/site/src/lib/account.ts @@ -0,0 +1,46 @@ +// Website sign-in through the server's OpenID Connect provider +// (ExternalSignInController). The session is an HttpOnly cookie on this host, +// so there is no token to keep here: same-origin fetches carry it. +import { get, post } from './api'; + +export interface AccountMe { + signInAvailable: boolean; + provider: string; + signedIn: boolean; + player: { id: string; username: string; email: string } | null; + pendingLink: { email: string } | null; +} + +let cached: Promise | null = null; + +/** Who is signed in. Fetched once per page load; `fresh` re-reads it. */ +export function getMe(fresh = false): Promise { + if (!cached || fresh) cached = get('/account/me').catch((e) => { cached = null; throw e; }); + return cached; +} + +/** Starts sign-in at the provider; comes back to `returnUrl` on this site. */ +export function signInUrl(returnUrl = window.location.pathname + window.location.search): string { + return `/api/auth/external/login?returnUrl=${encodeURIComponent(returnUrl)}`; +} + +export const link = (username: string, password: string) => + post<{ linked: boolean; username: string }>('/account/link', { username, password }); +export const signOut = () => post('/account/logout', {}); +export const unlink = () => post('/account/unlink', {}); + +// Server error code -> friendly copy. +export const LINK_ERRORS: Record = { + invalid_credentials: "That username and password don't match a DorkNet account.", + missing_credentials: 'Enter your DorkNet username and password.', + too_many_attempts: 'Too many wrong attempts. Wait 15 minutes and try again.', + banned: 'That account is banned.', + identity_linked_to_another_account: 'This sign-in is already linked to a different DorkNet account.', + no_pending_identity: 'Your sign-in expired. Start again with "Sign in".', +}; + +export const SIGNIN_ERRORS: Record = { + account_unavailable: 'The account linked to this sign-in is unavailable (deleted or banned).', + no_identity: "The sign-in provider didn't return an identity. Try again.", + provider_failed: 'The sign-in was cancelled or failed at the provider. Try again.', +}; diff --git a/DorkNet.Server/site/src/lib/api.ts b/DorkNet.Server/site/src/lib/api.ts index a928f6c..888e5ef 100644 --- a/DorkNet.Server/site/src/lib/api.ts +++ b/DorkNet.Server/site/src/lib/api.ts @@ -1,7 +1,7 @@ // Lightweight fetch wrapper for the public-facing site. Talks to the -// apex-hosted PublicSiteController at /api/site/v1/*. Anonymous-only — -// the public site has no login flow, so there's no Authorization -// header or token plumbing. +// apex-hosted PublicSiteController at /api/site/v1/*. No Authorization +// header or token plumbing: the optional sign-in (lib/account.ts) is an +// HttpOnly cookie that same-origin fetches send on their own. const BASE = '/api/site/v1'; diff --git a/DorkNet.Server/site/src/lib/downloads.ts b/DorkNet.Server/site/src/lib/downloads.ts index f7fa09b..116c210 100644 --- a/DorkNet.Server/site/src/lib/downloads.ts +++ b/DorkNet.Server/site/src/lib/downloads.ts @@ -28,6 +28,12 @@ export interface DownloadsManifest { updated?: string; } +/** The server only serves downloads to signed-in players + * (DorkNet:Downloads:RequireSignIn) and nobody is signed in. */ +export class SignInRequiredError extends Error { + constructor() { super('sign_in_required'); this.name = 'SignInRequiredError'; } +} + let cached: Promise | null = null; /** The manifest, or null when this server doesn't offer downloads. Fetched @@ -36,6 +42,7 @@ export function getDownloads(fresh = false): Promise { if (!cached || fresh) { cached = get('/downloads').catch((e) => { if (e instanceof ApiError && e.status === 404) return null; + if (e instanceof ApiError && e.status === 401) { cached = null; throw new SignInRequiredError(); } cached = null; throw e; }); diff --git a/DorkNet.Server/site/src/pages/Account.tsx b/DorkNet.Server/site/src/pages/Account.tsx new file mode 100644 index 0000000..17d67e2 --- /dev/null +++ b/DorkNet.Server/site/src/pages/Account.tsx @@ -0,0 +1,64 @@ +import { useEffect, useState } from 'react'; +import { Link as RouterLink } from 'react-router'; +import { getMe, signInUrl, signOut, unlink, type AccountMe } from '../lib/account'; + +// The signed-in account: which DorkNet account and provider email are +// linked, plus sign out and unlink. +export function Account() { + const [me, setMe] = useState(null); + const [err, setErr] = useState(null); + const [busy, setBusy] = useState(false); + + useEffect(() => { + getMe(true).then(setMe).catch((e) => setErr((e as Error).message)); + }, []); + + const run = async (action: () => Promise) => { + setBusy(true); + try { + await action(); + window.location.assign('/'); + } catch (e) { + setErr((e as Error).message); + setBusy(false); + } + }; + + if (err) return
{err}
; + if (!me) return

Loading…

; + + if (!me.signedIn || !me.player) { + return ( +
+

Account

+

You're not signed in.

+ {me.signInAvailable + ? Sign in with {me.provider} + :

Sign-in isn't set up on this server.

} +
+ ); + } + + return ( +
+

Account

+
+
+ DorkNet account + {me.player.username} +
+
+ Signed in with {me.provider} + {me.player.email || '—'} +
+
+ + +
+
+
+ ); +} diff --git a/DorkNet.Server/site/src/pages/Downloads.tsx b/DorkNet.Server/site/src/pages/Downloads.tsx index 27111fd..644925b 100644 --- a/DorkNet.Server/site/src/pages/Downloads.tsx +++ b/DorkNet.Server/site/src/pages/Downloads.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react'; import { Link } from 'react-router'; -import { fileUrl, getDownloads, type DownloadFile, type DownloadRelease, type DownloadsManifest } from '../lib/downloads'; +import { fileUrl, getDownloads, SignInRequiredError, type DownloadFile, type DownloadRelease, type DownloadsManifest } from '../lib/downloads'; +import { signInUrl } from '../lib/account'; const fmtSize = (n: number) => n >= 2 ** 30 ? `${(n / 2 ** 30).toFixed(2)} GB` : n >= 2 ** 20 ? `${(n / 2 ** 20).toFixed(1)} MB` : `${Math.max(1, Math.round(n / 2 ** 10))} KB`; @@ -96,9 +97,13 @@ function FileCard({ f, rel }: { f: DownloadFile; rel: DownloadRelease }) { export function Downloads() { const [m, setM] = useState(undefined); const [err, setErr] = useState(null); + const [signInNeeded, setSignInNeeded] = useState(false); useEffect(() => { - getDownloads(true).then(setM).catch((e) => setErr((e as Error).message)); + getDownloads(true).then(setM).catch((e) => { + if (e instanceof SignInRequiredError) setSignInNeeded(true); + else setErr((e as Error).message); + }); }, []); const releases = (m?.releases ?? []).filter((r) => r.files.length); @@ -114,7 +119,13 @@ export function Downloads() { {err &&
Couldn't load the builds: {err}
} - {m === undefined && !err &&

Loading builds…

} + {signInNeeded && ( +
+ Sign in with your DorkNet account to see and download the builds. + Sign in +
+ )} + {m === undefined && !err && !signInNeeded &&

Loading builds…

} {m === null &&

This server doesn't offer downloads.

} {m && !releases.length &&

No builds have been published yet.

} diff --git a/DorkNet.Server/site/src/pages/Link.tsx b/DorkNet.Server/site/src/pages/Link.tsx new file mode 100644 index 0000000..ad9bb3a --- /dev/null +++ b/DorkNet.Server/site/src/pages/Link.tsx @@ -0,0 +1,79 @@ +import { useEffect, useState } from 'react'; +import { Link as RouterLink, useNavigate, useSearchParams } from 'react-router'; +import { getMe, link, LINK_ERRORS, signInUrl, type AccountMe } from '../lib/account'; + +// After a first sign-in with the provider: prove which DorkNet account this +// identity belongs to, once, with that account's password. +export function Link() { + const [params] = useSearchParams(); + const returnUrl = params.get('returnUrl') || '/'; + const navigate = useNavigate(); + const [me, setMe] = useState(null); + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [busy, setBusy] = useState(false); + const [err, setErr] = useState(null); + + useEffect(() => { + getMe(true).then((m) => { + if (m.signedIn) navigate(returnUrl, { replace: true }); + else setMe(m); + }).catch((e) => setErr((e as Error).message)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const submit = async (e: React.FormEvent) => { + e.preventDefault(); + setBusy(true); + setErr(null); + try { + await link(username.trim(), password); + await getMe(true); + window.location.assign(returnUrl); // the header re-reads who is signed in + } catch (e) { + const key = (e as Error).message; + setErr(LINK_ERRORS[key] ?? key); + } finally { + setBusy(false); + } + }; + + if (me && !me.pendingLink) { + return ( +
+

Link your account

+

Your sign-in expired before it was linked.

+ {me.signInAvailable && Sign in again} +
+ ); + } + + return ( +
+
+

Link your DorkNet account

+

+ You signed in{me?.pendingLink?.email ? <> as {me.pendingLink.email} : null}. + Enter your DorkNet username and password once to connect them; after that, signing in is enough. +

+
+
+
+ + setUsername(e.target.value)} /> +
+
+ + setPassword(e.target.value)} /> +
+ {err &&
{err}
} + +

+ No account yet? Join with a signup code, then come back here. +

+
+
+ ); +} diff --git a/DorkNet.Server/site/src/pages/Quest.tsx b/DorkNet.Server/site/src/pages/Quest.tsx index 98f8a8d..3d129ed 100644 --- a/DorkNet.Server/site/src/pages/Quest.tsx +++ b/DorkNet.Server/site/src/pages/Quest.tsx @@ -2,7 +2,8 @@ import { useEffect, useRef, useState } from 'react'; import { useSearchParams } from 'react-router'; import { fileSource, openXapk, urlSource, type Xapk } from '../lib/xapk'; import { connectHeadset, installXapk, webUsbSupported, type Headset, type Progress } from '../lib/questInstall'; -import { fileUrl, getDownloads, questBuild, type QuestBuild } from '../lib/downloads'; +import { fileUrl, getDownloads, questBuild, SignInRequiredError, type QuestBuild } from '../lib/downloads'; +import { signInUrl } from '../lib/account'; const fmtSize = (n: number) => n >= 1e9 ? `${(n / 1e9).toFixed(2)} GB` : n >= 1e6 ? `${(n / 1e6).toFixed(1)} MB` : `${Math.max(1, Math.round(n / 1e3))} KB`; @@ -47,6 +48,8 @@ export function Quest() { const fileRef = useRef(null); // The server's latest published Quest build, if it offers downloads. const [latest, setLatest] = useState(null); + // Downloads exist but need a signed-in player (DorkNet:Downloads:RequireSignIn). + const [signInForLatest, setSignInForLatest] = useState(false); const [params] = useSearchParams(); const open = async (load: () => Promise) => { @@ -71,7 +74,10 @@ export function Quest() { // that card's own file, which may not be the newest release's. const wanted = params.get('file'); if (wanted) void pickBuild(questBuild(m, wanted)); - }).catch(() => { /* no downloads: only the file picker */ }); + }).catch((e) => { + // No downloads: only the file picker. Sign-in required: offer it. + if (e instanceof SignInRequiredError) setSignInForLatest(true); + }); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -140,6 +146,11 @@ export function Quest() { ) : (

Pick the DorkNet-….xapk you were given. It stays on your computer; nothing is uploaded.

)} + {signInForLatest && !latest && ( +

+ Sign in to install the latest build straight from this server. +

+ )} pickFile(e.target.files?.[0])} />
{latest && ( diff --git a/README.md b/README.md index 0138128..44b0327 100644 --- a/README.md +++ b/README.md @@ -431,6 +431,16 @@ hand it to the player, and they redeem it on the site's **`/join`** page — which creates their account bound to the device their game client reported, so the next launch logs straight in. +### Website and admin sign-in + +Optionally, players sign in to the website (and admins to the admin panel) +with an OpenID Connect provider such as a Cloudflare Access SaaS app. Each +player links the provider identity to their DorkNet account once, with the +account's password, on the site's **`/link`** page; the Quest page's direct +install and the Downloads page can then be limited to signed-in players. The +game client's login is unchanged. Setup: +[`docs/deploy.md`](docs/deploy.md#external-sign-in-openid-connect). + ### Everyone-is-friends toggle For small servers where searching + friend-requesting each other is diff --git a/docs/admin.md b/docs/admin.md index c3d8e37..8ba4829 100644 --- a/docs/admin.md +++ b/docs/admin.md @@ -41,6 +41,13 @@ there is always a bootstrap operator. The login form posts to | `dorknet.admin.token` | DorkNet admin JWT | | `dorknet.admin.me` | Admin identity object used by the layout | +With [external sign-in](deploy.md#external-sign-in-openid-connect) +configured, the login page also shows **Sign in with …**. It signs in with +the provider on the admin host, then `GET /api/admin/v1/sso` swaps that +session for the same token pair the password login returns. The identity +must already be linked to an account on the website, and the account still +needs `IsAdmin = true`. + Every admin API request sends `Authorization: Bearer `. Protected actions are guarded by `AdminOnlyAttribute`, which checks the resolved player row still has `IsAdmin = true`. A `401` clears the local session diff --git a/docs/deploy.md b/docs/deploy.md index 85ad524..22d578e 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -443,6 +443,68 @@ answer only on the apex host, where the site lives, and return 404 on every other host the server accepts, so leaving the game's own subdomains open for the client doesn't expose the files. Put the apex behind the proxy. +To serve the builds only to players signed in with a linked DorkNet account +(see [External sign-in](#external-sign-in-openid-connect)), also set: + +```env +DorkNet__Downloads__RequireSignIn=true +``` + +The manifest and files then answer `401` until the visitor signs in, and the +Downloads and Quest pages offer **Sign in** instead. + +--- + +## External sign-in (OpenID Connect) + +Optional. Players sign in to the website, and admins to the admin panel, +with any OpenID Connect provider: a Cloudflare Access SaaS application, +Google, Authentik, Keycloak and so on. The game client keeps its own login. + +The first time someone signs in, the website asks for their DorkNet username +and password, once, to link the provider identity to that account. After that +the provider sign-in is enough. The link is keyed on the provider's issuer and +subject (`iss` + `sub`), never on the email address, so a changed or reused +email can't take over an account. A player has one linked identity per +provider; **Account** (`/account`) shows it and can unlink it. + +```env +# The provider's issuer URL (discovery at {Authority}/.well-known/openid-configuration). +DorkNet__SignIn__Authority=https:///... +DorkNet__SignIn__ClientId= +DorkNet__SignIn__ClientSecret= +# Optional: button label ("Sign in with ..."), default "single sign-on". +DorkNet__SignIn__DisplayName=Cloudflare +# Optional: scopes, default "openid email profile". +DorkNet__SignIn__Scopes=openid email profile +``` + +Register these redirect URIs at the provider, one per host people sign in on: + +```text +https:///api/auth/external/callback +https:///api/auth/external/callback +``` + +The server builds the redirect URI from `Domain:Scheme` and the request's +host, so a TLS-terminating proxy in front doesn't turn it into `http://`. + +**Cloudflare Access as the provider.** In Zero Trust, add an application of +type **SaaS**, choose **OIDC**, and enter the redirect URIs above. Give it the +same Access policies as the site, so the people who can open the site can +sign in. Copy the issuer, client ID and client secret into the settings +above. + +Sessions are HttpOnly cookies per host (`dorknet_session`, 30 days, sliding). +The keys that encrypt them are stored in the database (`DataProtectionKeys`), +so redeploys and extra replicas keep everyone signed in. Unlinking the +identity, deleting the account or banning the player ends the session on the +next request. Wrong passwords on the link form are limited to 5 per 15 +minutes per provider identity and per username. + +Unset, the website shows no sign-in, and the admin panel only the password +form. + --- ## Photon Cloud From 891ec89899b739c0aac84e75cce709477361fa25 Mon Sep 17 00:00:00 2001 From: Alexa <53471801+Alexa-RR@users.noreply.github.com> Date: Sun, 25 Oct 2026 15:52:29 +0200 Subject: [PATCH 2/5] fix(auth): encrypt stored cookie keys; tighten the link and sign-out flows - Data-protection keys are encrypted (AES-256-GCM, key derived from the server secret) before they reach the DataProtectionKeys table. - /link only follows local return paths and keeps the form when a signed-in visitor has an identity waiting to be linked. - A failed sign-out says so instead of reloading as if it worked. --- DorkNet.Server.Tests/ExternalSignInTests.cs | 31 +++++++++ DorkNet.Server/Services/AuthService.cs | 11 ++- .../Services/DataProtectionKeyEncryption.cs | 67 +++++++++++++++++++ .../Startup/ServiceCollectionExtensions.cs | 14 ++-- DorkNet.Server/site/src/components/Header.tsx | 19 +++++- DorkNet.Server/site/src/lib/account.ts | 7 ++ DorkNet.Server/site/src/pages/Link.tsx | 9 ++- docs/deploy.md | 5 +- 8 files changed, 150 insertions(+), 13 deletions(-) create mode 100644 DorkNet.Server/Services/DataProtectionKeyEncryption.cs diff --git a/DorkNet.Server.Tests/ExternalSignInTests.cs b/DorkNet.Server.Tests/ExternalSignInTests.cs index 67e9507..562df61 100644 --- a/DorkNet.Server.Tests/ExternalSignInTests.cs +++ b/DorkNet.Server.Tests/ExternalSignInTests.cs @@ -147,6 +147,37 @@ public sealed class ExternalSignInTests : IClassFixture Assert.Equal(HttpStatusCode.Forbidden, relink.StatusCode); } + [Fact] + public async Task Cookie_keys_are_stored_encrypted() + { + var (player, password) = await PlayerAsync(); + using var site = Client(); + using var link = await PostJsonAsync(site, "/api/site/v1/account/link", + new { username = player.Username, password }, ExternalCookie(Guid.NewGuid().ToString("N"), "")); + Assert.Equal(HttpStatusCode.OK, link.StatusCode); + + await using var scope = _factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var stored = await db.DataProtectionKeys.Select(k => k.Xml).ToListAsync(); + Assert.NotEmpty(stored); + Assert.All(stored, xml => + { + Assert.Contains("dorknetEncryptedKey", xml); + Assert.DoesNotContain("masterKey", xml); + }); + } + + [Fact] + public void Key_encryption_round_trips_and_rejects_another_secret() + { + var element = new System.Xml.Linq.XElement("key", new System.Xml.Linq.XElement("masterKey", "secret-bytes")); + var encrypted = new DataProtectionKeyEncryption("first-secret").Encrypt(element).EncryptedElement; + Assert.DoesNotContain("secret-bytes", encrypted.ToString()); + Assert.Equal(element.ToString(), new DataProtectionKeyEncryption("first-secret").DecryptElement(encrypted).ToString()); + Assert.ThrowsAny( + () => new DataProtectionKeyEncryption("other-secret").DecryptElement(encrypted)); + } + [Fact] public async Task Downloads_can_require_sign_in() { diff --git a/DorkNet.Server/Services/AuthService.cs b/DorkNet.Server/Services/AuthService.cs index 8b37e3d..d425a13 100644 --- a/DorkNet.Server/Services/AuthService.cs +++ b/DorkNet.Server/Services/AuthService.cs @@ -19,13 +19,18 @@ public class AuthService(IConfiguration config, DorkNetDbContext db) // configs) and leave Jwt:Secret as the appsettings.json placeholder; // the mismatched-secret bug shows up only there. private string SecretKey => - Environment.GetEnvironmentVariable("DORKNET_JWT_SECRET") - ?? Environment.GetEnvironmentVariable("RECNET_JWT_SECRET") - ?? config["Jwt:Secret"] + ConfiguredSecret(config) ?? throw new InvalidOperationException( "JWT secret not configured. Set DORKNET_JWT_SECRET env var or " + "Jwt:Secret in appsettings.Local.json."); + /// The server secret, in the one resolution order every user of + /// it shares (token signing, token validation, key encryption). + public static string? ConfiguredSecret(IConfiguration config) => + Environment.GetEnvironmentVariable("DORKNET_JWT_SECRET") + ?? Environment.GetEnvironmentVariable("RECNET_JWT_SECRET") + ?? config["Jwt:Secret"]; + public string GenerateToken(long playerId) => CreateJwt(playerId, TimeSpan.FromHours(12), "access"); /// diff --git a/DorkNet.Server/Services/DataProtectionKeyEncryption.cs b/DorkNet.Server/Services/DataProtectionKeyEncryption.cs new file mode 100644 index 0000000..fcedd99 --- /dev/null +++ b/DorkNet.Server/Services/DataProtectionKeyEncryption.cs @@ -0,0 +1,67 @@ +using System.Security.Cryptography; +using System.Text; +using System.Xml.Linq; +using Microsoft.AspNetCore.DataProtection.XmlEncryption; +using Microsoft.Extensions.DependencyInjection; + +namespace DorkNet.Server.Services; + +/// +/// Encrypts the data-protection key ring (the keys behind the sign-in +/// cookies) before it is stored in the DataProtectionKeys table: +/// AES-256-GCM under a key derived (HKDF-SHA256) from the server secret. +/// The secret comes from the environment, not the database, so a database +/// backup alone holds no usable keys. Changing the secret makes the stored +/// keys unreadable: the server then creates a new key and everyone signs in +/// again. +/// +public sealed class DataProtectionKeyEncryption : IXmlEncryptor +{ + private const int NonceSize = 12; + private const int TagSize = 16; + private static readonly byte[] Info = Encoding.UTF8.GetBytes("DorkNet.DataProtectionKeys.v1"); + private static readonly XName Root = "dorknetEncryptedKey"; + private readonly byte[] _key; + + public DataProtectionKeyEncryption(string secret) + { + if (string.IsNullOrEmpty(secret)) + throw new InvalidOperationException("The server secret is required to encrypt the data-protection keys."); + _key = HKDF.DeriveKey(HashAlgorithmName.SHA256, Encoding.UTF8.GetBytes(secret), 32, salt: null, info: Info); + } + + public EncryptedXmlInfo Encrypt(XElement plaintextElement) + { + var plaintext = Encoding.UTF8.GetBytes(plaintextElement.ToString(SaveOptions.DisableFormatting)); + var payload = new byte[NonceSize + TagSize + plaintext.Length]; + var nonce = payload.AsSpan(0, NonceSize); + RandomNumberGenerator.Fill(nonce); + using (var aes = new AesGcm(_key, TagSize)) + aes.Encrypt(nonce, plaintext, payload.AsSpan(NonceSize + TagSize), payload.AsSpan(NonceSize, TagSize)); + return new EncryptedXmlInfo( + new XElement(Root, new XAttribute("version", "1"), Convert.ToBase64String(payload)), + typeof(Decryptor)); + } + + public XElement DecryptElement(XElement encrypted) + { + if (encrypted.Name != Root || (string?)encrypted.Attribute("version") != "1") + throw new CryptographicException("Unrecognised encrypted data-protection key."); + var payload = Convert.FromBase64String(encrypted.Value); + if (payload.Length < NonceSize + TagSize) + throw new CryptographicException("Encrypted data-protection key is truncated."); + var plaintext = new byte[payload.Length - NonceSize - TagSize]; + using (var aes = new AesGcm(_key, TagSize)) + aes.Decrypt(payload.AsSpan(0, NonceSize), payload.AsSpan(NonceSize + TagSize), + payload.AsSpan(NonceSize, TagSize), plaintext); + return XElement.Parse(Encoding.UTF8.GetString(plaintext)); + } + + /// Created by the data-protection system from the stored type + /// name, with the app's service provider. + public sealed class Decryptor(IServiceProvider services) : IXmlDecryptor + { + public XElement Decrypt(XElement encryptedElement) => + services.GetRequiredService().DecryptElement(encryptedElement); + } +} diff --git a/DorkNet.Server/Startup/ServiceCollectionExtensions.cs b/DorkNet.Server/Startup/ServiceCollectionExtensions.cs index f6d247e..41e8405 100644 --- a/DorkNet.Server/Startup/ServiceCollectionExtensions.cs +++ b/DorkNet.Server/Startup/ServiceCollectionExtensions.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authentication.OpenIdConnect; using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.DataProtection.KeyManagement; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.IdentityModel.Tokens; @@ -345,9 +346,7 @@ public static class ServiceCollectionExtensions // older Coolify configs. private static void AddAuthentication(WebApplicationBuilder builder) { - var jwtSecret = Environment.GetEnvironmentVariable("DORKNET_JWT_SECRET") - ?? Environment.GetEnvironmentVariable("RECNET_JWT_SECRET") - ?? builder.Configuration["Jwt:Secret"] + var jwtSecret = AuthService.ConfiguredSecret(builder.Configuration) ?? throw new InvalidOperationException( "JWT secret not configured. Set the DORKNET_JWT_SECRET env var or " + "add `Jwt:Secret` to appsettings.Local.json (the install-plugin.ps1 " + @@ -422,10 +421,17 @@ public static class ServiceCollectionExtensions services.AddSingleton(); // Cookie encryption keys live in the database, so a redeploy or a - // second replica doesn't invalidate everyone's session. + // second replica doesn't invalidate everyone's session. They're + // stored encrypted with a key derived from the server secret, which + // lives outside the database, so a database backup alone can't forge + // sessions. + services.AddSingleton(new DataProtectionKeyEncryption( + AuthService.ConfiguredSecret(builder.Configuration) ?? string.Empty)); services.AddDataProtection() .SetApplicationName("DorkNet") .PersistKeysToDbContext(); + services.AddOptions() + .Configure((o, enc) => o.XmlEncryptor = enc); var auth = services.AddAuthentication() .AddCookie(ExternalSignIn.WebScheme, o => diff --git a/DorkNet.Server/site/src/components/Header.tsx b/DorkNet.Server/site/src/components/Header.tsx index 3801c24..a58e13b 100644 --- a/DorkNet.Server/site/src/components/Header.tsx +++ b/DorkNet.Server/site/src/components/Header.tsx @@ -34,9 +34,15 @@ export function Header() { getMe().then(setMe).catch(() => { /* site works without sign-in */ }); }, []); + const [signOutError, setSignOutError] = useState(null); const doSignOut = async () => { - await signOut().catch(() => {}); - window.location.assign('/'); + setSignOutError(null); + try { + await signOut(); + window.location.assign('/'); + } catch (e) { + setSignOutError((e as Error).message); + } }; // Sign in / account, shown only when the server has a sign-in provider. @@ -136,6 +142,15 @@ export function Header() {
)} + {signOutError && ( +
+
+ Couldn't sign out ({signOutError}). You're still signed in; try again. + +
+
+ )} + {signinError && (
diff --git a/DorkNet.Server/site/src/lib/account.ts b/DorkNet.Server/site/src/lib/account.ts index 04f3c5e..c17d848 100644 --- a/DorkNet.Server/site/src/lib/account.ts +++ b/DorkNet.Server/site/src/lib/account.ts @@ -19,6 +19,13 @@ export function getMe(fresh = false): Promise { return cached; } +/** `url` if it is a path on this site, else `/` (the server applies the same + * rule): never navigate to an address taken from the query string otherwise. */ +export function localPath(url: string | null | undefined): string { + if (!url || !url.startsWith('/') || url.startsWith('//') || url.startsWith('/\\')) return '/'; + return url; +} + /** Starts sign-in at the provider; comes back to `returnUrl` on this site. */ export function signInUrl(returnUrl = window.location.pathname + window.location.search): string { return `/api/auth/external/login?returnUrl=${encodeURIComponent(returnUrl)}`; diff --git a/DorkNet.Server/site/src/pages/Link.tsx b/DorkNet.Server/site/src/pages/Link.tsx index ad9bb3a..7338683 100644 --- a/DorkNet.Server/site/src/pages/Link.tsx +++ b/DorkNet.Server/site/src/pages/Link.tsx @@ -1,12 +1,12 @@ import { useEffect, useState } from 'react'; import { Link as RouterLink, useNavigate, useSearchParams } from 'react-router'; -import { getMe, link, LINK_ERRORS, signInUrl, type AccountMe } from '../lib/account'; +import { getMe, link, LINK_ERRORS, localPath, signInUrl, type AccountMe } from '../lib/account'; // After a first sign-in with the provider: prove which DorkNet account this // identity belongs to, once, with that account's password. export function Link() { const [params] = useSearchParams(); - const returnUrl = params.get('returnUrl') || '/'; + const returnUrl = localPath(params.get('returnUrl')); const navigate = useNavigate(); const [me, setMe] = useState(null); const [username, setUsername] = useState(''); @@ -16,7 +16,10 @@ export function Link() { useEffect(() => { getMe(true).then((m) => { - if (m.signedIn) navigate(returnUrl, { replace: true }); + // Signed in with nothing waiting to be linked: nothing to do here. (A + // signed-in visitor can still link a new identity, e.g. a second + // account's.) + if (m.signedIn && !m.pendingLink) navigate(returnUrl, { replace: true }); else setMe(m); }).catch((e) => setErr((e as Error).message)); // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/docs/deploy.md b/docs/deploy.md index 22d578e..b025ccb 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -497,7 +497,10 @@ above. Sessions are HttpOnly cookies per host (`dorknet_session`, 30 days, sliding). The keys that encrypt them are stored in the database (`DataProtectionKeys`), -so redeploys and extra replicas keep everyone signed in. Unlinking the +so redeploys and extra replicas keep everyone signed in. They're stored +encrypted with a key derived from the server secret (`DORKNET_JWT_SECRET`), +so a database backup alone can't forge sessions; changing that secret signs +everyone out. Unlinking the identity, deleting the account or banning the player ends the session on the next request. Wrong passwords on the link form are limited to 5 per 15 minutes per provider identity and per username. From 5bfa463528503f6987f983f1d40ae55f8d7da2a5 Mon Sep 17 00:00:00 2001 From: Alexa <53471801+Alexa-RR@users.noreply.github.com> Date: Sun, 25 Oct 2026 15:54:51 +0200 Subject: [PATCH 3/5] fix(auth): show only known sign-in error codes Sign-in errors arrive in the query string, so unknown codes get a fixed message instead of being echoed into the banner. --- DorkNet.Server/admin-ui/src/pages/Login.tsx | 5 +++-- DorkNet.Server/site/src/components/Header.tsx | 4 ++-- DorkNet.Server/site/src/lib/account.ts | 6 ++++++ DorkNet.Server/site/src/pages/Link.tsx | 4 ++-- 4 files changed, 13 insertions(+), 6 deletions(-) diff --git a/DorkNet.Server/admin-ui/src/pages/Login.tsx b/DorkNet.Server/admin-ui/src/pages/Login.tsx index cb5452a..1e0c33c 100644 --- a/DorkNet.Server/admin-ui/src/pages/Login.tsx +++ b/DorkNet.Server/admin-ui/src/pages/Login.tsx @@ -54,7 +54,7 @@ export function Login() { const errorText = (e: unknown, codes: Record): React.ReactNode => { if (e instanceof ApiError && e.body && typeof e.body === 'object' && 'error' in e.body) { const code = (e.body as { error: string }).error; - return codes[code] ?? e.message; + return Object.hasOwn(codes, code) ? codes[code] : e.message; } return e instanceof Error ? e.message : String(e); }; @@ -75,7 +75,8 @@ export function Login() { ssoHandled.current = true; setErr(signinError === 'not_linked' ? <>This sign-in isn't linked to a DorkNet account yet. Link it on the website first, then sign in here again. - : SSO_ERRORS[signinError] ?? `Single sign-on failed (${signinError}).`); + // Only known codes are shown: the query string is attacker-controllable. + : Object.hasOwn(SSO_ERRORS, signinError) ? SSO_ERRORS[signinError] : 'Single sign-on failed. Try again.'); setParams({}, { replace: true }); return; } diff --git a/DorkNet.Server/site/src/components/Header.tsx b/DorkNet.Server/site/src/components/Header.tsx index a58e13b..6a4a867 100644 --- a/DorkNet.Server/site/src/components/Header.tsx +++ b/DorkNet.Server/site/src/components/Header.tsx @@ -2,7 +2,7 @@ import { NavLink, Link, useLocation, useNavigate, useSearchParams } from 'react- import { useEffect, useRef, useState } from 'react'; import { BrandMark } from './BrandMark'; import { getDownloads, SignInRequiredError } from '../lib/downloads'; -import { getMe, signInUrl, signOut, SIGNIN_ERRORS, type AccountMe } from '../lib/account'; +import { errorCopy, getMe, signInUrl, signOut, SIGNIN_ERRORS, type AccountMe } from '../lib/account'; const NAV = [ { to: '/', label: 'Home', exact: true }, @@ -154,7 +154,7 @@ export function Header() { {signinError && (
- Sign-in didn't work: {SIGNIN_ERRORS[signinError] ?? signinError} + Sign-in didn't work: {errorCopy(SIGNIN_ERRORS, signinError, 'something went wrong. Try again.')}
diff --git a/DorkNet.Server/site/src/lib/account.ts b/DorkNet.Server/site/src/lib/account.ts index c17d848..62789b8 100644 --- a/DorkNet.Server/site/src/lib/account.ts +++ b/DorkNet.Server/site/src/lib/account.ts @@ -46,6 +46,12 @@ export const LINK_ERRORS: Record = { no_pending_identity: 'Your sign-in expired. Start again with "Sign in".', }; +/** Friendly copy for a known error code, else `fallback`. For codes read from + * the query string, pass a fixed fallback: anyone can put text in a link. */ +export function errorCopy(map: Record, code: string | null | undefined, fallback: string): string { + return code && Object.hasOwn(map, code) ? map[code] : fallback; +} + export const SIGNIN_ERRORS: Record = { account_unavailable: 'The account linked to this sign-in is unavailable (deleted or banned).', no_identity: "The sign-in provider didn't return an identity. Try again.", diff --git a/DorkNet.Server/site/src/pages/Link.tsx b/DorkNet.Server/site/src/pages/Link.tsx index 7338683..da6072f 100644 --- a/DorkNet.Server/site/src/pages/Link.tsx +++ b/DorkNet.Server/site/src/pages/Link.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react'; import { Link as RouterLink, useNavigate, useSearchParams } from 'react-router'; -import { getMe, link, LINK_ERRORS, localPath, signInUrl, type AccountMe } from '../lib/account'; +import { errorCopy, getMe, link, LINK_ERRORS, localPath, signInUrl, type AccountMe } from '../lib/account'; // After a first sign-in with the provider: prove which DorkNet account this // identity belongs to, once, with that account's password. @@ -35,7 +35,7 @@ export function Link() { window.location.assign(returnUrl); // the header re-reads who is signed in } catch (e) { const key = (e as Error).message; - setErr(LINK_ERRORS[key] ?? key); + setErr(errorCopy(LINK_ERRORS, key, key)); } finally { setBusy(false); } From c7a087eea188489096675cfbccd8ab7cb3ebaf31 Mon Sep 17 00:00:00 2001 From: Alexa <53471801+Alexa-RR@users.noreply.github.com> Date: Sun, 25 Oct 2026 15:56:45 +0200 Subject: [PATCH 4/5] fix(site): parse return paths as URLs; refresh account state after changes - localPath resolves the path against this origin, so control characters or backslashes can't turn it into another site's address. - link, sign-out and unlink drop the cached account answer. --- DorkNet.Server/site/src/lib/account.ts | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/DorkNet.Server/site/src/lib/account.ts b/DorkNet.Server/site/src/lib/account.ts index 62789b8..e8cd404 100644 --- a/DorkNet.Server/site/src/lib/account.ts +++ b/DorkNet.Server/site/src/lib/account.ts @@ -22,8 +22,15 @@ export function getMe(fresh = false): Promise { /** `url` if it is a path on this site, else `/` (the server applies the same * rule): never navigate to an address taken from the query string otherwise. */ export function localPath(url: string | null | undefined): string { - if (!url || !url.startsWith('/') || url.startsWith('//') || url.startsWith('/\\')) return '/'; - return url; + if (!url || !url.startsWith('/')) return '/'; + // Parse it the way the browser will (it drops tabs and newlines and reads + // \ as /) and keep it only if it stays on this origin. + try { + const u = new URL(url, window.location.origin); + return u.origin === window.location.origin ? u.pathname + u.search + u.hash : '/'; + } catch { + return '/'; + } } /** Starts sign-in at the provider; comes back to `returnUrl` on this site. */ @@ -31,10 +38,17 @@ export function signInUrl(returnUrl = window.location.pathname + window.location return `/api/auth/external/login?returnUrl=${encodeURIComponent(returnUrl)}`; } +// Each of these changes who is signed in, so the cached answer is dropped. +async function changesAccount(request: Promise): Promise { + const result = await request; + cached = null; + return result; +} + export const link = (username: string, password: string) => - post<{ linked: boolean; username: string }>('/account/link', { username, password }); -export const signOut = () => post('/account/logout', {}); -export const unlink = () => post('/account/unlink', {}); + changesAccount(post<{ linked: boolean; username: string }>('/account/link', { username, password })); +export const signOut = () => changesAccount(post('/account/logout', {})); +export const unlink = () => changesAccount(post('/account/unlink', {})); // Server error code -> friendly copy. export const LINK_ERRORS: Record = { From d4ce92f1b43c7345d67eff06a1c3b9f5dd6054b4 Mon Sep 17 00:00:00 2001 From: Alexa <53471801+Alexa-RR@users.noreply.github.com> Date: Sun, 25 Oct 2026 16:55:57 +0200 Subject: [PATCH 5/5] test(auth): give sign-in test players sequential ids --- DorkNet.Server.Tests/ExternalSignInTests.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/DorkNet.Server.Tests/ExternalSignInTests.cs b/DorkNet.Server.Tests/ExternalSignInTests.cs index 562df61..a63de6a 100644 --- a/DorkNet.Server.Tests/ExternalSignInTests.cs +++ b/DorkNet.Server.Tests/ExternalSignInTests.cs @@ -23,6 +23,8 @@ namespace DorkNet.Server.Tests; public sealed class ExternalSignInTests : IClassFixture { private const string Issuer = "https://idp.example.com"; + // Test player ids: sequential, in a range no other test uses. + private static long _nextPlayerId = 9_600_000; private readonly DorkNetServerFactory _factory; public ExternalSignInTests(DorkNetServerFactory factory) => _factory = factory; @@ -200,7 +202,7 @@ public sealed class ExternalSignInTests : IClassFixture var password = "pw-" + Guid.NewGuid().ToString("N")[..12]; var player = new PlayerEntity { - Id = 9_400_000 + Random.Shared.Next(1, 99_999), + Id = Interlocked.Increment(ref _nextPlayerId), Username = "sso" + Guid.NewGuid().ToString("N")[..10], DisplayName = "SSO test", PasswordHash = BCrypt.Net.BCrypt.HashPassword(password),