mirror of
https://github.com/tale/headplane.git
synced 2026-09-04 11:15:42 +00:00
feat: completely overhaul the auth model
* Cookies are now encrypted JWTs (GHSA-wrqq-v7qw-r5w7) * Authentication is stored in the SQLite database (auto-migrated) * Session logic is much cleaner
This commit is contained in:
@@ -60,25 +60,23 @@ export async function loginAction({
|
||||
};
|
||||
}
|
||||
|
||||
// Set the session
|
||||
const session = await context.sessions.getOrCreate(request);
|
||||
const expiresDays = Math.round(
|
||||
(expiry.getTime() - Date.now()) / 1000 / 60 / 60 / 24,
|
||||
);
|
||||
|
||||
session.set('state', 'auth');
|
||||
session.set('api_key', apiKey);
|
||||
session.set('user', {
|
||||
subject: 'unknown-non-oauth',
|
||||
name: `${lookup.prefix}...`,
|
||||
email: `expires@${expiresDays.toString()}-days`,
|
||||
});
|
||||
|
||||
return redirect('/machines', {
|
||||
headers: {
|
||||
'Set-Cookie': await context.sessions.commit(session, {
|
||||
maxAge: expiry.getTime() - Date.now(),
|
||||
}),
|
||||
'Set-Cookie': await context.sessions.createSession(
|
||||
{
|
||||
api_key: apiKey,
|
||||
user: {
|
||||
subject: 'unknown-non-oauth',
|
||||
name: `${lookup.prefix}...`,
|
||||
email: `expires@${expiresDays.toString()}-days`,
|
||||
},
|
||||
},
|
||||
expiry.getTime() - Date.now(),
|
||||
),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useEffect } from 'react';
|
||||
import {
|
||||
ActionFunctionArgs,
|
||||
data,
|
||||
Form,
|
||||
LoaderFunctionArgs,
|
||||
Link as RemixLink,
|
||||
data,
|
||||
redirect,
|
||||
useActionData,
|
||||
useLoaderData,
|
||||
@@ -24,10 +24,8 @@ export async function loader({
|
||||
context,
|
||||
}: LoaderFunctionArgs<LoadContext>) {
|
||||
try {
|
||||
const session = await context.sessions.auth(request);
|
||||
if (session.has('api_key')) {
|
||||
return redirect('/machines');
|
||||
}
|
||||
await context.sessions.auth(request);
|
||||
return redirect('/machines');
|
||||
} catch {}
|
||||
|
||||
const qp = new URL(request.url).searchParams;
|
||||
@@ -104,26 +102,26 @@ export default function Page() {
|
||||
terminal.
|
||||
</Card.Text>
|
||||
<Input
|
||||
className="mt-8 mb-2"
|
||||
isRequired
|
||||
labelHidden
|
||||
label="API Key"
|
||||
labelHidden
|
||||
name="api_key"
|
||||
placeholder="API Key"
|
||||
type="password"
|
||||
className="mt-8 mb-2"
|
||||
/>
|
||||
{formData?.success === false ? (
|
||||
<Card.Text className="text-sm mb-2 text-red-600 dark:text-red-300">
|
||||
{formData.message}
|
||||
</Card.Text>
|
||||
) : undefined}
|
||||
<Button className="w-full" variant="heavy" type="submit">
|
||||
<Button className="w-full" type="submit" variant="heavy">
|
||||
Sign In
|
||||
</Button>
|
||||
</Form>
|
||||
{oidc ? (
|
||||
<RemixLink to="/oidc/start">
|
||||
<Button variant="light" className="w-full mt-2">
|
||||
<Button className="w-full mt-2" variant="light">
|
||||
Single Sign-On
|
||||
</Button>
|
||||
</RemixLink>
|
||||
|
||||
@@ -9,9 +9,10 @@ export async function action({
|
||||
request,
|
||||
context,
|
||||
}: ActionFunctionArgs<LoadContext>) {
|
||||
const session = await context.sessions.auth(request);
|
||||
if (!session.has('api_key')) {
|
||||
return redirect('/login');
|
||||
try {
|
||||
await context.sessions.auth(request);
|
||||
} catch {
|
||||
redirect('/login');
|
||||
}
|
||||
|
||||
// When API key is disabled, we need to explicitly redirect
|
||||
@@ -22,7 +23,7 @@ export async function action({
|
||||
|
||||
return redirect(url, {
|
||||
headers: {
|
||||
'Set-Cookie': await context.sessions.destroy(session),
|
||||
'Set-Cookie': await context.sessions.destroySession(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
import { type LoaderFunctionArgs, Session, redirect } from 'react-router';
|
||||
import { count } from 'drizzle-orm';
|
||||
import { createCookie, type LoaderFunctionArgs, redirect } from 'react-router';
|
||||
import { ulid } from 'ulidx';
|
||||
import type { LoadContext } from '~/server';
|
||||
import type { AuthSession, OidcFlowSession } from '~/server/web/sessions';
|
||||
import { users } from '~/server/db/schema';
|
||||
import { Roles } from '~/server/web/roles';
|
||||
import { finishAuthFlow, formatError } from '~/utils/oidc';
|
||||
import { send } from '~/utils/res';
|
||||
|
||||
interface OidcFlowSession {
|
||||
state: string;
|
||||
nonce: string;
|
||||
code_verifier: string;
|
||||
redirect_uri: string;
|
||||
}
|
||||
|
||||
export async function loader({
|
||||
request,
|
||||
context,
|
||||
@@ -18,13 +28,21 @@ export async function loader({
|
||||
return redirect('/login');
|
||||
}
|
||||
|
||||
const session = await context.sessions.getOrCreate<OidcFlowSession>(request);
|
||||
if (session.get('state') !== 'flow') {
|
||||
return redirect('/login'); // Haven't started an OIDC flow
|
||||
const cookie = createCookie('__oidc_auth_flow', {
|
||||
httpOnly: true,
|
||||
maxAge: 300, // 5 minutes
|
||||
});
|
||||
|
||||
const data: OidcFlowSession | null = await cookie.parse(
|
||||
request.headers.get('Cookie'),
|
||||
);
|
||||
|
||||
if (data === null) {
|
||||
console.warn('OIDC flow session not found');
|
||||
return redirect('/login');
|
||||
}
|
||||
|
||||
const payload = session.get('oidc')!;
|
||||
const { code_verifier, state, nonce, redirect_uri } = payload;
|
||||
const { code_verifier, state, nonce, redirect_uri } = data;
|
||||
if (!code_verifier || !state || !nonce || !redirect_uri) {
|
||||
return send({ error: 'Missing OIDC state' }, { status: 400 });
|
||||
}
|
||||
@@ -43,19 +61,30 @@ export async function loader({
|
||||
|
||||
try {
|
||||
const user = await finishAuthFlow(context.oidc, flowOptions);
|
||||
session.unset('oidc');
|
||||
const userSession = session as Session<AuthSession>;
|
||||
|
||||
// TODO: This is breaking, to stop the "over-generation" of API
|
||||
// keys because they are currently non-deletable in the headscale
|
||||
// database. Look at this in the future once we have a solution
|
||||
// or we have permissioned API keys.
|
||||
userSession.set('user', user);
|
||||
userSession.set('api_key', context.config.oidc?.headscale_api_key!);
|
||||
userSession.set('state', 'auth');
|
||||
const [{ count: userCount }] = await context.db
|
||||
.select({ count: count() })
|
||||
.from(users);
|
||||
|
||||
await context.db
|
||||
.insert(users)
|
||||
.values({
|
||||
id: ulid(),
|
||||
sub: user.subject,
|
||||
caps: userCount === 0 ? Roles.owner : Roles.member,
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
|
||||
return redirect('/machines', {
|
||||
headers: {
|
||||
'Set-Cookie': await context.sessions.commit(userSession),
|
||||
'Set-Cookie': await context.sessions.createSession({
|
||||
// TODO: This is breaking, to stop the "over-generation" of API
|
||||
// keys because they are currently non-deletable in the headscale
|
||||
// database. Look at this in the future once we have a solution
|
||||
// or we have permissioned API keys.
|
||||
api_key: context.config.oidc?.headscale_api_key!,
|
||||
user,
|
||||
}),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,42 +1,42 @@
|
||||
import { type LoaderFunctionArgs, Session, redirect } from 'react-router';
|
||||
import { createCookie, type LoaderFunctionArgs, redirect } from 'react-router';
|
||||
import type { LoadContext } from '~/server';
|
||||
import { AuthSession, OidcFlowSession } from '~/server/web/sessions';
|
||||
import { beginAuthFlow, getRedirectUri } from '~/utils/oidc';
|
||||
|
||||
export async function loader({
|
||||
request,
|
||||
context,
|
||||
}: LoaderFunctionArgs<LoadContext>) {
|
||||
const session = await context.sessions.getOrCreate<OidcFlowSession>(request);
|
||||
if ((session as Session<AuthSession>).has('api_key')) {
|
||||
try {
|
||||
await context.sessions.auth(request);
|
||||
return redirect('/machines');
|
||||
}
|
||||
} catch {}
|
||||
|
||||
if (!context.oidc) {
|
||||
if (!context.oidc || !context.config.oidc) {
|
||||
throw new Error('OIDC is not enabled');
|
||||
}
|
||||
|
||||
const cookie = createCookie('__oidc_auth_flow', {
|
||||
httpOnly: true,
|
||||
maxAge: 300, // 5 minutes
|
||||
});
|
||||
|
||||
const redirectUri =
|
||||
context.config.oidc?.redirect_uri ?? getRedirectUri(request);
|
||||
const data = await beginAuthFlow(
|
||||
context.oidc,
|
||||
redirectUri,
|
||||
// We can't get here without the OIDC config being defined
|
||||
context.config.oidc!.token_endpoint_auth_method,
|
||||
context.config.oidc.token_endpoint_auth_method,
|
||||
);
|
||||
|
||||
session.set('state', 'flow');
|
||||
session.set('oidc', {
|
||||
state: data.state,
|
||||
nonce: data.nonce,
|
||||
code_verifier: data.codeVerifier,
|
||||
redirect_uri: redirectUri,
|
||||
});
|
||||
|
||||
return redirect(data.url, {
|
||||
status: 302,
|
||||
headers: {
|
||||
'Set-Cookie': await context.sessions.commit(session),
|
||||
'Set-Cookie': await cookie.serialize({
|
||||
state: data.state,
|
||||
nonce: data.nonce,
|
||||
code_verifier: data.codeVerifier,
|
||||
redirect_uri: redirectUri,
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user