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,7 +1,8 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate, useLocation, useSearchParams } from 'react-router';
|
||||
import { api, ApiError } from '../lib/api';
|
||||
import { notifyAuthChange, setSession } from '../lib/auth';
|
||||
import { adminApex } from '../lib/types';
|
||||
import { BrandMark } from '../components/BrandMark';
|
||||
|
||||
interface LoginResponse {
|
||||
@@ -12,14 +13,81 @@ interface LoginResponse {
|
||||
display_name: string;
|
||||
}
|
||||
|
||||
// The website, where an identity from the sign-in provider is linked to an
|
||||
// account (the admin panel only signs in identities that are linked already).
|
||||
function siteUrl(path: string): string {
|
||||
const apex = adminApex();
|
||||
return `${apex === 'localhost' ? 'http' : 'https'}://${apex}${path}`;
|
||||
}
|
||||
|
||||
const SSO_ERRORS: Record<string, React.ReactNode> = {
|
||||
not_admin: 'That account is not an admin.',
|
||||
not_signed_in: "Single sign-on didn't finish. Try again.",
|
||||
account_unavailable: 'The account linked to that 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.',
|
||||
};
|
||||
|
||||
export function Login() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [params, setParams] = useSearchParams();
|
||||
const from = (location.state as { from?: { pathname: string } } | null)?.from?.pathname ?? '/';
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [err, setErr] = useState<React.ReactNode | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
// Single sign-on through the server's OpenID Connect provider, if set up.
|
||||
const [sso, setSso] = useState<{ provider: string } | null>(null);
|
||||
const ssoHandled = useRef(false);
|
||||
|
||||
const finish = (res: LoginResponse, to: string) => {
|
||||
setSession(res.access_token, {
|
||||
id: res.account_id,
|
||||
username: res.username,
|
||||
displayName: res.display_name,
|
||||
});
|
||||
notifyAuthChange();
|
||||
navigate(to, { replace: true });
|
||||
};
|
||||
|
||||
const errorText = (e: unknown, codes: Record<string, React.ReactNode>): React.ReactNode => {
|
||||
if (e instanceof ApiError && e.body && typeof e.body === 'object' && 'error' in e.body) {
|
||||
const code = (e.body as { error: string }).error;
|
||||
return codes[code] ?? e.message;
|
||||
}
|
||||
return e instanceof Error ? e.message : String(e);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/site/v1/account/me', { cache: 'no-store' })
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((me) => { if (me?.signInAvailable) setSso({ provider: me.provider }); })
|
||||
.catch(() => { /* no single sign-on: password only */ });
|
||||
}, []);
|
||||
|
||||
// Back from the provider: ?sso=1 swaps the session for the admin token;
|
||||
// ?signin_error=… explains why it didn't work.
|
||||
useEffect(() => {
|
||||
if (ssoHandled.current) return;
|
||||
const signinError = params.get('signin_error');
|
||||
if (signinError) {
|
||||
ssoHandled.current = true;
|
||||
setErr(signinError === 'not_linked'
|
||||
? <>This sign-in isn't linked to a DorkNet account yet. Link it on the <a className="underline" href={siteUrl('/api/auth/external/login?returnUrl=%2Faccount')}>website</a> first, then sign in here again.</>
|
||||
: SSO_ERRORS[signinError] ?? `Single sign-on failed (${signinError}).`);
|
||||
setParams({}, { replace: true });
|
||||
return;
|
||||
}
|
||||
if (params.get('sso') !== '1') return;
|
||||
ssoHandled.current = true;
|
||||
setLoading(true);
|
||||
api<LoginResponse>('/sso')
|
||||
.then((res) => finish(res, params.get('to') || '/'))
|
||||
.catch((e) => { setErr(errorText(e, SSO_ERRORS)); setParams({}, { replace: true }); })
|
||||
.finally(() => setLoading(false));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const onSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -30,27 +98,19 @@ export function Login() {
|
||||
method: 'POST',
|
||||
body: { username, password },
|
||||
});
|
||||
setSession(res.access_token, {
|
||||
id: res.account_id,
|
||||
username: res.username,
|
||||
displayName: res.display_name,
|
||||
});
|
||||
notifyAuthChange();
|
||||
navigate(from, { replace: true });
|
||||
finish(res, from);
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError) {
|
||||
if (e.body && typeof e.body === 'object' && 'error' in e.body) {
|
||||
const code = (e.body as { error: string }).error;
|
||||
if (code === 'not_admin') setErr('That account is not an admin.');
|
||||
else if (code === 'invalid_credentials') setErr('Invalid username or password.');
|
||||
else setErr(e.message);
|
||||
} else setErr(e.message);
|
||||
} else setErr(String(e));
|
||||
setErr(errorText(e, {
|
||||
not_admin: 'That account is not an admin.',
|
||||
invalid_credentials: 'Invalid username or password.',
|
||||
}));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const ssoHref = `/api/auth/external/login?returnUrl=${encodeURIComponent(`/login?sso=1&to=${encodeURIComponent(from)}`)}`;
|
||||
|
||||
return (
|
||||
<div className="min-h-full flex items-center justify-center px-4 py-12 bg-[linear-gradient(135deg,#0a0b0e_0%,#0d1d25_48%,#131418_100%)]">
|
||||
<div className="w-full max-w-sm">
|
||||
@@ -92,6 +152,14 @@ export function Login() {
|
||||
<button type="submit" disabled={loading} className="btn-primary mt-1">
|
||||
{loading ? 'Signing in…' : 'Sign in'}
|
||||
</button>
|
||||
{sso && (
|
||||
<>
|
||||
<div className="flex items-center gap-3 text-[11px] uppercase tracking-wide text-ink-500">
|
||||
<span className="h-px flex-1 bg-ink-800" />or<span className="h-px flex-1 bg-ink-800" />
|
||||
</div>
|
||||
<a href={ssoHref} className="btn-secondary text-center">Sign in with {sso.provider}</a>
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
|
||||
<p className="mt-6 text-center text-[11px] text-ink-500">DorkNet private server · admin console</p>
|
||||
|
||||
Reference in New Issue
Block a user