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
@@ -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);
/// <summary>With <see cref="DownloadsService.RequireSignIn"/>, only a
/// signed-in, linked player may read the builds.</summary>
private async Task<bool> SignedInOrOpenAsync() =>
!downloads.RequireSignIn
|| (await HttpContext.AuthenticateAsync(ExternalSignIn.WebScheme)).Succeeded;
/// <summary>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.</summary>
@@ -31,6 +38,7 @@ public class DownloadsController(DownloadsService downloads, DomainConfig domain
public async Task<IActionResult> 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; }
@@ -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;
/// <summary>
/// Sign-in with the external OpenID Connect provider (<see cref="ExternalSignInOptions"/>)
/// for the website and the admin panel. The game client's login is separate.
///
/// <para>Flow: <c>login</c> challenges the provider; its callback
/// (<see cref="ExternalSignIn.CallbackPath"/>, handled by the OpenID Connect
/// middleware) stores the provider identity in the short-lived
/// <c>DorkNet.External</c> cookie and lands on <c>complete</c>. A linked identity
/// becomes a <c>DorkNet.Web</c> session at once; an unlinked one goes to the
/// website's <c>/link</c> page, where the player proves the account with its
/// password once (<c>account/link</c>).</para>
///
/// <para>Each host signs in on its own (website and admin panel), because the
/// hyphen host style puts them under different cookie domains.</para>
/// </summary>
[ApiController]
[AllowAnonymous]
public class ExternalSignInController(
IOptions<ExternalSignInOptions> options,
ExternalSignInService signIn,
LinkAttemptLimiter limiter,
DomainConfig domain,
ILogger<ExternalSignInController> log) : ControllerBase
{
private ExternalSignInOptions Options => options.Value;
/// <summary>GET /api/auth/external/login?returnUrl=/path — start sign-in.</summary>
[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);
}
/// <summary>GET /api/auth/external/complete — after the provider's callback.</summary>
[HttpGet(ExternalSignIn.CompletePath)]
public async Task<IActionResult> 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)}");
}
/// <summary>GET /api/site/v1/account/me — who is signed in, or which
/// provider identity is waiting to be linked.</summary>
[HttpGet("api/site/v1/account/me")]
public async Task<IActionResult> 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);
/// <summary>POST /api/site/v1/account/link {username, password} — link the
/// pending provider identity to the account these credentials prove.</summary>
[HttpPost("api/site/v1/account/link")]
public async Task<IActionResult> 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" });
}
}
/// <summary>POST /api/site/v1/account/unlink — remove this provider identity
/// from the account and sign out.</summary>
[HttpPost("api/site/v1/account/unlink")]
public async Task<IActionResult> 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 });
}
/// <summary>POST /api/site/v1/account/logout — end the session here.</summary>
[HttpPost("api/site/v1/account/logout")]
public async Task<IActionResult> Logout()
{
await HttpContext.SignOutAsync(ExternalSignIn.WebScheme);
await HttpContext.SignOutAsync(ExternalSignIn.ExternalScheme);
return Ok(new { signedOut = true });
}
/// <summary>(issuer, subject, email) from the provider's claims. The
/// subject is the account key; the email is display only.</summary>
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);
}