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.
This commit is contained in:
Alexa
2026-10-25 15:47:56 +02:00
parent 260f6605fc
commit f0e4679887
27 changed files with 5138 additions and 31 deletions
@@ -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).</summary>
/// <summary>External sign-in: the identities linked to accounts
/// (<c>ExternalLogins</c>) and the data-protection keys that encrypt the
/// sign-in cookies (<c>DataProtectionKeys</c>). 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).</summary>
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();