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();
@@ -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<ExternalSignInOptions>(section);
var signIn = section.Get<ExternalSignInOptions>() ?? new ExternalSignInOptions();
var https = !string.Equals(builder.Configuration["Domain:Scheme"] ?? "https", "http", StringComparison.OrdinalIgnoreCase);
var secure = https ? CookieSecurePolicy.Always : CookieSecurePolicy.SameAsRequest;
services.AddScoped<ExternalSignInService>();
services.AddSingleton<LinkAttemptLimiter>();
// 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<DorkNetDbContext>();
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<ExternalSignInService>();
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<DomainConfig>();
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<DomainConfig>();
var admin = string.Equals(ctx.Request.Host.Host, domain.Sub("admin"), StringComparison.OrdinalIgnoreCase);
ctx.HttpContext.RequestServices.GetRequiredService<ILoggerFactory>()
.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.