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:
@@ -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