mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-26 07:28:45 -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:
@@ -9,6 +9,8 @@ import { PlayerProfile } from './pages/PlayerProfile';
|
||||
import { Rooms } from './pages/Rooms';
|
||||
import { Join } from './pages/Join';
|
||||
import { Downloads } from './pages/Downloads';
|
||||
import { Link as LinkAccount } from './pages/Link';
|
||||
import { Account } from './pages/Account';
|
||||
import { NotFound } from './pages/NotFound';
|
||||
|
||||
// The Quest installer pulls in the WebUSB/ADB stack; load it only on /quest.
|
||||
@@ -29,6 +31,8 @@ export function App() {
|
||||
<Route path="/join" element={<Join />} />
|
||||
<Route path="/quest" element={<Suspense fallback={null}><Quest /></Suspense>} />
|
||||
<Route path="/downloads" element={<Downloads />} />
|
||||
<Route path="/link" element={<LinkAccount />} />
|
||||
<Route path="/account" element={<Account />} />
|
||||
{/* Legacy paths from the vanilla-JS feed.localhost site */}
|
||||
<Route path="/p/:id" element={<RedirectPhoto />} />
|
||||
<Route path="/u/:id" element={<RedirectPlayer />} />
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { NavLink, Link, useLocation, useNavigate } from 'react-router';
|
||||
import { NavLink, Link, useLocation, useNavigate, useSearchParams } from 'react-router';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { BrandMark } from './BrandMark';
|
||||
import { getDownloads } from '../lib/downloads';
|
||||
import { getDownloads, SignInRequiredError } from '../lib/downloads';
|
||||
import { getMe, signInUrl, signOut, SIGNIN_ERRORS, type AccountMe } from '../lib/account';
|
||||
|
||||
const NAV = [
|
||||
{ to: '/', label: 'Home', exact: true },
|
||||
@@ -23,10 +24,31 @@ export function Header() {
|
||||
const [hasDownloads, setHasDownloads] = useState(false);
|
||||
const navItems = hasDownloads ? [...NAV, DOWNLOADS] : NAV;
|
||||
|
||||
const [me, setMe] = useState<AccountMe | null>(null);
|
||||
const [params, setParams] = useSearchParams();
|
||||
const signinError = params.get('signin_error');
|
||||
|
||||
useEffect(() => {
|
||||
getDownloads().then((m) => setHasDownloads(m !== null)).catch(() => {});
|
||||
getDownloads().then((m) => setHasDownloads(m !== null))
|
||||
.catch((e) => setHasDownloads(e instanceof SignInRequiredError));
|
||||
getMe().then(setMe).catch(() => { /* site works without sign-in */ });
|
||||
}, []);
|
||||
|
||||
const doSignOut = async () => {
|
||||
await signOut().catch(() => {});
|
||||
window.location.assign('/');
|
||||
};
|
||||
|
||||
// Sign in / account, shown only when the server has a sign-in provider.
|
||||
const account = !me?.signInAvailable ? null : me.signedIn && me.player ? (
|
||||
<span className="flex items-center gap-2 text-sm">
|
||||
<Link to="/account" className="text-ink-200 hover:text-ink-50">{me.player.username}</Link>
|
||||
<button type="button" onClick={doSignOut} className="btn-ghost !px-2 !py-1 text-xs">Sign out</button>
|
||||
</span>
|
||||
) : (
|
||||
<a href={signInUrl()} className="btn-secondary !px-3 !py-1.5 text-sm">Sign in</a>
|
||||
);
|
||||
|
||||
// Close the mobile drawer on route change so tapping a nav item
|
||||
// doesn't leave a half-open overlay covering the page.
|
||||
useEffect(() => { setOpen(false); }, [loc.pathname]);
|
||||
@@ -72,6 +94,8 @@ export function Header() {
|
||||
/>
|
||||
</form>
|
||||
|
||||
<div className="hidden md:flex shrink-0">{account}</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(o => !o)}
|
||||
@@ -107,6 +131,16 @@ export function Header() {
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
{account && <div className="pt-1">{account}</div>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{signinError && (
|
||||
<div className="border-t border-danger/30 bg-danger/10">
|
||||
<div className="mx-auto max-w-6xl px-4 sm:px-6 py-2 flex items-center justify-between gap-3 text-sm text-danger">
|
||||
<span>Sign-in didn't work: {SIGNIN_ERRORS[signinError] ?? signinError}</span>
|
||||
<button type="button" className="btn-ghost !px-2 !py-1 text-xs" onClick={() => { params.delete('signin_error'); setParams(params, { replace: true }); }}>Dismiss</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
// Website sign-in through the server's OpenID Connect provider
|
||||
// (ExternalSignInController). The session is an HttpOnly cookie on this host,
|
||||
// so there is no token to keep here: same-origin fetches carry it.
|
||||
import { get, post } from './api';
|
||||
|
||||
export interface AccountMe {
|
||||
signInAvailable: boolean;
|
||||
provider: string;
|
||||
signedIn: boolean;
|
||||
player: { id: string; username: string; email: string } | null;
|
||||
pendingLink: { email: string } | null;
|
||||
}
|
||||
|
||||
let cached: Promise<AccountMe> | null = null;
|
||||
|
||||
/** Who is signed in. Fetched once per page load; `fresh` re-reads it. */
|
||||
export function getMe(fresh = false): Promise<AccountMe> {
|
||||
if (!cached || fresh) cached = get<AccountMe>('/account/me').catch((e) => { cached = null; throw e; });
|
||||
return cached;
|
||||
}
|
||||
|
||||
/** Starts sign-in at the provider; comes back to `returnUrl` on this site. */
|
||||
export function signInUrl(returnUrl = window.location.pathname + window.location.search): string {
|
||||
return `/api/auth/external/login?returnUrl=${encodeURIComponent(returnUrl)}`;
|
||||
}
|
||||
|
||||
export const link = (username: string, password: string) =>
|
||||
post<{ linked: boolean; username: string }>('/account/link', { username, password });
|
||||
export const signOut = () => post('/account/logout', {});
|
||||
export const unlink = () => post('/account/unlink', {});
|
||||
|
||||
// Server error code -> friendly copy.
|
||||
export const LINK_ERRORS: Record<string, string> = {
|
||||
invalid_credentials: "That username and password don't match a DorkNet account.",
|
||||
missing_credentials: 'Enter your DorkNet username and password.',
|
||||
too_many_attempts: 'Too many wrong attempts. Wait 15 minutes and try again.',
|
||||
banned: 'That account is banned.',
|
||||
identity_linked_to_another_account: 'This sign-in is already linked to a different DorkNet account.',
|
||||
no_pending_identity: 'Your sign-in expired. Start again with "Sign in".',
|
||||
};
|
||||
|
||||
export const SIGNIN_ERRORS: Record<string, string> = {
|
||||
account_unavailable: 'The account linked to this sign-in is unavailable (deleted or banned).',
|
||||
no_identity: "The sign-in provider didn't return an identity. Try again.",
|
||||
provider_failed: 'The sign-in was cancelled or failed at the provider. Try again.',
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
// Lightweight fetch wrapper for the public-facing site. Talks to the
|
||||
// apex-hosted PublicSiteController at /api/site/v1/*. Anonymous-only —
|
||||
// the public site has no login flow, so there's no Authorization
|
||||
// header or token plumbing.
|
||||
// apex-hosted PublicSiteController at /api/site/v1/*. No Authorization
|
||||
// header or token plumbing: the optional sign-in (lib/account.ts) is an
|
||||
// HttpOnly cookie that same-origin fetches send on their own.
|
||||
|
||||
const BASE = '/api/site/v1';
|
||||
|
||||
|
||||
@@ -28,6 +28,12 @@ export interface DownloadsManifest {
|
||||
updated?: string;
|
||||
}
|
||||
|
||||
/** The server only serves downloads to signed-in players
|
||||
* (DorkNet:Downloads:RequireSignIn) and nobody is signed in. */
|
||||
export class SignInRequiredError extends Error {
|
||||
constructor() { super('sign_in_required'); this.name = 'SignInRequiredError'; }
|
||||
}
|
||||
|
||||
let cached: Promise<DownloadsManifest | null> | null = null;
|
||||
|
||||
/** The manifest, or null when this server doesn't offer downloads. Fetched
|
||||
@@ -36,6 +42,7 @@ export function getDownloads(fresh = false): Promise<DownloadsManifest | null> {
|
||||
if (!cached || fresh) {
|
||||
cached = get<DownloadsManifest>('/downloads').catch((e) => {
|
||||
if (e instanceof ApiError && e.status === 404) return null;
|
||||
if (e instanceof ApiError && e.status === 401) { cached = null; throw new SignInRequiredError(); }
|
||||
cached = null;
|
||||
throw e;
|
||||
});
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link as RouterLink } from 'react-router';
|
||||
import { getMe, signInUrl, signOut, unlink, type AccountMe } from '../lib/account';
|
||||
|
||||
// The signed-in account: which DorkNet account and provider email are
|
||||
// linked, plus sign out and unlink.
|
||||
export function Account() {
|
||||
const [me, setMe] = useState<AccountMe | null>(null);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
getMe(true).then(setMe).catch((e) => setErr((e as Error).message));
|
||||
}, []);
|
||||
|
||||
const run = async (action: () => Promise<unknown>) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await action();
|
||||
window.location.assign('/');
|
||||
} catch (e) {
|
||||
setErr((e as Error).message);
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (err) return <div className="max-w-lg mx-auto rounded-lg border border-danger/30 bg-danger/5 px-4 py-3 text-sm text-danger">{err}</div>;
|
||||
if (!me) return <p className="max-w-lg mx-auto text-sm text-ink-400">Loading…</p>;
|
||||
|
||||
if (!me.signedIn || !me.player) {
|
||||
return (
|
||||
<div className="max-w-lg mx-auto card !p-6 space-y-3 text-sm text-ink-300">
|
||||
<h1 className="text-xl font-semibold text-ink-50">Account</h1>
|
||||
<p>You're not signed in.</p>
|
||||
{me.signInAvailable
|
||||
? <a className="btn-primary inline-block" href={signInUrl('/account')}>Sign in with {me.provider}</a>
|
||||
: <p className="text-ink-400">Sign-in isn't set up on this server.</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-lg mx-auto space-y-4">
|
||||
<h1 className="text-2xl font-semibold text-ink-50">Account</h1>
|
||||
<div className="card !p-6 space-y-3 text-sm">
|
||||
<div className="flex justify-between gap-3">
|
||||
<span className="text-ink-400">DorkNet account</span>
|
||||
<RouterLink className="text-brand-300 hover:text-brand-200" to={`/players/${me.player.id}`}>{me.player.username}</RouterLink>
|
||||
</div>
|
||||
<div className="flex justify-between gap-3">
|
||||
<span className="text-ink-400">Signed in with {me.provider}</span>
|
||||
<span className="text-ink-200">{me.player.email || '—'}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 pt-2">
|
||||
<button type="button" className="btn-secondary" disabled={busy} onClick={() => run(signOut)}>Sign out</button>
|
||||
<button type="button" className="btn-ghost text-danger" disabled={busy}
|
||||
onClick={() => { if (window.confirm(`Unlink this sign-in from ${me.player!.username}? You'll need the account's password to link it again.`)) void run(unlink); }}>
|
||||
Unlink
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import { fileUrl, getDownloads, type DownloadFile, type DownloadRelease, type DownloadsManifest } from '../lib/downloads';
|
||||
import { fileUrl, getDownloads, SignInRequiredError, type DownloadFile, type DownloadRelease, type DownloadsManifest } from '../lib/downloads';
|
||||
import { signInUrl } from '../lib/account';
|
||||
|
||||
const fmtSize = (n: number) =>
|
||||
n >= 2 ** 30 ? `${(n / 2 ** 30).toFixed(2)} GB` : n >= 2 ** 20 ? `${(n / 2 ** 20).toFixed(1)} MB` : `${Math.max(1, Math.round(n / 2 ** 10))} KB`;
|
||||
@@ -96,9 +97,13 @@ function FileCard({ f, rel }: { f: DownloadFile; rel: DownloadRelease }) {
|
||||
export function Downloads() {
|
||||
const [m, setM] = useState<DownloadsManifest | null | undefined>(undefined);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [signInNeeded, setSignInNeeded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
getDownloads(true).then(setM).catch((e) => setErr((e as Error).message));
|
||||
getDownloads(true).then(setM).catch((e) => {
|
||||
if (e instanceof SignInRequiredError) setSignInNeeded(true);
|
||||
else setErr((e as Error).message);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const releases = (m?.releases ?? []).filter((r) => r.files.length);
|
||||
@@ -114,7 +119,13 @@ export function Downloads() {
|
||||
</div>
|
||||
|
||||
{err && <div className="rounded-lg border border-danger/30 bg-danger/5 px-4 py-3 text-sm text-danger">Couldn't load the builds: {err}</div>}
|
||||
{m === undefined && !err && <p className="text-sm text-ink-400">Loading builds…</p>}
|
||||
{signInNeeded && (
|
||||
<div className="card !p-5 flex flex-wrap items-center justify-between gap-3 text-sm text-ink-300">
|
||||
<span>Sign in with your DorkNet account to see and download the builds.</span>
|
||||
<a className="btn-primary" href={signInUrl('/downloads')}>Sign in</a>
|
||||
</div>
|
||||
)}
|
||||
{m === undefined && !err && !signInNeeded && <p className="text-sm text-ink-400">Loading builds…</p>}
|
||||
{m === null && <p className="text-sm text-ink-400">This server doesn't offer downloads.</p>}
|
||||
{m && !releases.length && <p className="text-sm text-ink-400">No builds have been published yet.</p>}
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link as RouterLink, useNavigate, useSearchParams } from 'react-router';
|
||||
import { getMe, link, LINK_ERRORS, signInUrl, type AccountMe } from '../lib/account';
|
||||
|
||||
// After a first sign-in with the provider: prove which DorkNet account this
|
||||
// identity belongs to, once, with that account's password.
|
||||
export function Link() {
|
||||
const [params] = useSearchParams();
|
||||
const returnUrl = params.get('returnUrl') || '/';
|
||||
const navigate = useNavigate();
|
||||
const [me, setMe] = useState<AccountMe | null>(null);
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getMe(true).then((m) => {
|
||||
if (m.signedIn) navigate(returnUrl, { replace: true });
|
||||
else setMe(m);
|
||||
}).catch((e) => setErr((e as Error).message));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setErr(null);
|
||||
try {
|
||||
await link(username.trim(), password);
|
||||
await getMe(true);
|
||||
window.location.assign(returnUrl); // the header re-reads who is signed in
|
||||
} catch (e) {
|
||||
const key = (e as Error).message;
|
||||
setErr(LINK_ERRORS[key] ?? key);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (me && !me.pendingLink) {
|
||||
return (
|
||||
<div className="max-w-lg mx-auto card !p-6 space-y-3 text-sm text-ink-300">
|
||||
<h1 className="text-xl font-semibold text-ink-50">Link your account</h1>
|
||||
<p>Your sign-in expired before it was linked.</p>
|
||||
{me.signInAvailable && <a className="btn-primary inline-block" href={signInUrl(returnUrl)}>Sign in again</a>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-lg mx-auto space-y-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-ink-50">Link your DorkNet account</h1>
|
||||
<p className="text-sm text-ink-400">
|
||||
You signed in{me?.pendingLink?.email ? <> as <span className="text-ink-200">{me.pendingLink.email}</span></> : null}.
|
||||
Enter your DorkNet username and password once to connect them; after that, signing in is enough.
|
||||
</p>
|
||||
</div>
|
||||
<form onSubmit={submit} className="card !p-6 space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="label" htmlFor="username">DorkNet username</label>
|
||||
<input id="username" className="input" autoComplete="username" required
|
||||
value={username} onChange={(e) => setUsername(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="label" htmlFor="password">Password</label>
|
||||
<input id="password" type="password" className="input" autoComplete="current-password" required
|
||||
value={password} onChange={(e) => setPassword(e.target.value)} />
|
||||
</div>
|
||||
{err && <div className="rounded-lg border border-danger/30 bg-danger/5 px-3 py-2 text-sm text-danger">{err}</div>}
|
||||
<button type="submit" disabled={busy} className="btn-primary w-full">{busy ? 'Linking…' : 'Link account'}</button>
|
||||
<p className="text-xs text-ink-500">
|
||||
No account yet? <RouterLink className="text-brand-300 hover:text-brand-200" to="/join">Join with a signup code</RouterLink>, then come back here.
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,8 @@ import { useEffect, useRef, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router';
|
||||
import { fileSource, openXapk, urlSource, type Xapk } from '../lib/xapk';
|
||||
import { connectHeadset, installXapk, webUsbSupported, type Headset, type Progress } from '../lib/questInstall';
|
||||
import { fileUrl, getDownloads, questBuild, type QuestBuild } from '../lib/downloads';
|
||||
import { fileUrl, getDownloads, questBuild, SignInRequiredError, type QuestBuild } from '../lib/downloads';
|
||||
import { signInUrl } from '../lib/account';
|
||||
|
||||
const fmtSize = (n: number) =>
|
||||
n >= 1e9 ? `${(n / 1e9).toFixed(2)} GB` : n >= 1e6 ? `${(n / 1e6).toFixed(1)} MB` : `${Math.max(1, Math.round(n / 1e3))} KB`;
|
||||
@@ -47,6 +48,8 @@ export function Quest() {
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
// The server's latest published Quest build, if it offers downloads.
|
||||
const [latest, setLatest] = useState<QuestBuild | null>(null);
|
||||
// Downloads exist but need a signed-in player (DorkNet:Downloads:RequireSignIn).
|
||||
const [signInForLatest, setSignInForLatest] = useState(false);
|
||||
const [params] = useSearchParams();
|
||||
|
||||
const open = async (load: () => Promise<Xapk>) => {
|
||||
@@ -71,7 +74,10 @@ export function Quest() {
|
||||
// that card's own file, which may not be the newest release's.
|
||||
const wanted = params.get('file');
|
||||
if (wanted) void pickBuild(questBuild(m, wanted));
|
||||
}).catch(() => { /* no downloads: only the file picker */ });
|
||||
}).catch((e) => {
|
||||
// No downloads: only the file picker. Sign-in required: offer it.
|
||||
if (e instanceof SignInRequiredError) setSignInForLatest(true);
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
@@ -140,6 +146,11 @@ export function Quest() {
|
||||
) : (
|
||||
<p className="text-sm text-ink-400">Pick the <code className="text-ink-200">DorkNet-….xapk</code> you were given. It stays on your computer; nothing is uploaded.</p>
|
||||
)}
|
||||
{signInForLatest && !latest && (
|
||||
<p className="text-sm text-ink-400">
|
||||
<a className="text-brand-300 hover:text-brand-200" href={signInUrl('/quest')}>Sign in</a> to install the latest build straight from this server.
|
||||
</p>
|
||||
)}
|
||||
<input ref={fileRef} type="file" accept=".xapk,.zip" className="hidden" onChange={(e) => pickFile(e.target.files?.[0])} />
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{latest && (
|
||||
|
||||
Reference in New Issue
Block a user