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] 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