mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 23:18:44 -07:00
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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user