mirror of
https://github.com/tale/headplane.git
synced 2026-08-31 17:28:14 +00:00
feat: initial auth rework
This commit is contained in:
@@ -66,19 +66,11 @@ export async function loginAction({ request, context }: Route.LoaderArgs) {
|
||||
};
|
||||
}
|
||||
|
||||
const expiresDays = Math.round((expiry.getTime() - Date.now()) / 1000 / 60 / 60 / 24);
|
||||
|
||||
return redirect("/machines", {
|
||||
headers: {
|
||||
"Set-Cookie": await context.sessions.createSession(
|
||||
{
|
||||
api_key: apiKey,
|
||||
user: {
|
||||
subject: "unknown-non-oauth",
|
||||
name: `${lookup.prefix}...`,
|
||||
email: `expires@${expiresDays.toString()}-days`,
|
||||
},
|
||||
},
|
||||
"Set-Cookie": await context.auth.createApiKeySession(
|
||||
apiKey,
|
||||
`${lookup.prefix}...`,
|
||||
expiry.getTime() - Date.now(),
|
||||
),
|
||||
},
|
||||
|
||||
@@ -10,7 +10,6 @@ import Link from "~/components/Link";
|
||||
import { useLiveData } from "~/utils/live-data";
|
||||
|
||||
import type { Route } from "./+types/page";
|
||||
|
||||
import { loginAction } from "./action";
|
||||
import { OidcConfigErrorNotice, OidcDiscoveryFailedNotice } from "./config-error";
|
||||
import Logout from "./logout";
|
||||
@@ -18,14 +17,14 @@ import { OidcErrorNotice } from "./oidc-error";
|
||||
|
||||
export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
try {
|
||||
await context.sessions.auth(request);
|
||||
await context.auth.require(request);
|
||||
return redirect("/machines");
|
||||
} catch {}
|
||||
|
||||
const qp = new URL(request.url).searchParams;
|
||||
const urlState = qp.get("s") ?? undefined;
|
||||
|
||||
const oidcConnector = await context.oidcConnector?.get();
|
||||
const oidcConnector = await context.oidc?.connector.get();
|
||||
|
||||
// MARK: This works because the OIDC connector will always return false
|
||||
// for `isExclusive` if the OIDC config isn't usable.
|
||||
|
||||
+18
-22
@@ -1,29 +1,25 @@
|
||||
import { type ActionFunctionArgs, redirect } from 'react-router';
|
||||
import type { LoadContext } from '~/server';
|
||||
import { type ActionFunctionArgs, redirect } from "react-router";
|
||||
|
||||
import type { LoadContext } from "~/server";
|
||||
|
||||
export async function loader() {
|
||||
return redirect('/machines');
|
||||
return redirect("/machines");
|
||||
}
|
||||
|
||||
export async function action({
|
||||
request,
|
||||
context,
|
||||
}: ActionFunctionArgs<LoadContext>) {
|
||||
try {
|
||||
await context.sessions.auth(request);
|
||||
} catch {
|
||||
redirect('/login');
|
||||
}
|
||||
export async function action({ request, context }: ActionFunctionArgs<LoadContext>) {
|
||||
try {
|
||||
await context.auth.require(request);
|
||||
} catch {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
// When API key is disabled, we need to explicitly redirect
|
||||
// with a logout state to prevent auto login again.
|
||||
const url = context.config.oidc?.disable_api_key_login
|
||||
? '/login?s=logout'
|
||||
: '/login';
|
||||
// When API key is disabled, we need to explicitly redirect
|
||||
// with a logout state to prevent auto login again.
|
||||
const url = context.config.oidc?.disable_api_key_login ? "/login?s=logout" : "/login";
|
||||
|
||||
return redirect(url, {
|
||||
headers: {
|
||||
'Set-Cookie': await context.sessions.destroySession(),
|
||||
},
|
||||
});
|
||||
return redirect(url, {
|
||||
headers: {
|
||||
"Set-Cookie": await context.auth.destroySession(request),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
import { count, eq } from "drizzle-orm";
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import * as oidc from "openid-client";
|
||||
import { data, redirect } from "react-router";
|
||||
import { ulid } from "ulidx";
|
||||
|
||||
import { users } from "~/server/db/schema";
|
||||
import { Roles } from "~/server/web/roles";
|
||||
import { findHeadscaleUserBySubject } from "~/server/web/headscale-identity";
|
||||
import log from "~/utils/log";
|
||||
import { createOidcStateCookie } from "~/utils/oidc-state";
|
||||
|
||||
import type { Route } from "./+types/oidc-callback";
|
||||
|
||||
export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
const oidcConnector = await context.oidcConnector?.get();
|
||||
const oidcConnector = await context.oidc?.connector.get();
|
||||
if (!oidcConnector?.isValid) {
|
||||
throw data("OIDC is not enabled or misconfigured", { status: 501 });
|
||||
}
|
||||
@@ -82,47 +80,28 @@ export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
})()
|
||||
: userInfo.picture;
|
||||
|
||||
const [{ count: ownerCount }] = await context.db
|
||||
.select({ count: count() })
|
||||
.from(users)
|
||||
.where(eq(users.caps, Roles.owner));
|
||||
const hasUsers = await context.auth.hasAnyUsers();
|
||||
const defaultRole = hasUsers ? "member" : "owner";
|
||||
const userId = await context.auth.findOrCreateUser(claims.sub, defaultRole);
|
||||
|
||||
const needsOwner = ownerCount === 0;
|
||||
|
||||
if (needsOwner) {
|
||||
await context.db
|
||||
.insert(users)
|
||||
.values({
|
||||
id: ulid(),
|
||||
sub: claims.sub,
|
||||
caps: Roles.owner,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: users.sub,
|
||||
set: { caps: Roles.owner },
|
||||
});
|
||||
} else {
|
||||
await context.db
|
||||
.insert(users)
|
||||
.values({
|
||||
id: ulid(),
|
||||
sub: claims.sub,
|
||||
caps: Roles.member,
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
try {
|
||||
const hsApi = context.hsApi.getRuntimeClient(context.oidc!.apiKey);
|
||||
const hsUsers = await hsApi.getUsers();
|
||||
const hsUser = findHeadscaleUserBySubject(hsUsers, claims.sub, userInfo.email);
|
||||
if (hsUser) {
|
||||
await context.auth.linkHeadscaleUser(userId, hsUser.id);
|
||||
}
|
||||
} catch (error) {
|
||||
log.warn("auth", "Failed to link Headscale user: %s", String(error));
|
||||
}
|
||||
|
||||
return redirect("/", {
|
||||
headers: {
|
||||
"Set-Cookie": await context.sessions.createSession({
|
||||
api_key: oidcConnector.apiKey,
|
||||
user: {
|
||||
subject: claims.sub,
|
||||
username,
|
||||
name,
|
||||
email: userInfo.email,
|
||||
picture,
|
||||
},
|
||||
"Set-Cookie": await context.auth.createOidcSession(userId, {
|
||||
name,
|
||||
email: userInfo.email,
|
||||
username,
|
||||
picture,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -8,11 +8,11 @@ import type { Route } from "./+types/oidc-start";
|
||||
|
||||
export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
try {
|
||||
await context.sessions.auth(request);
|
||||
await context.auth.require(request);
|
||||
return redirect("/");
|
||||
} catch {}
|
||||
|
||||
const oidcConnector = await context.oidcConnector?.get();
|
||||
const oidcConnector = await context.oidc?.connector.get();
|
||||
if (!oidcConnector?.isValid) {
|
||||
throw data("OIDC is not enabled or misconfigured", { status: 501 });
|
||||
}
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
import { ClockIcon, LogOut, RefreshCw, UserCheck } from "lucide-react";
|
||||
import { Form, redirect } from "react-router";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import Card from "~/components/Card";
|
||||
import { Capabilities } from "~/server/web/roles";
|
||||
import toast from "~/utils/toast";
|
||||
|
||||
import type { Route } from "./+types/pending-approval";
|
||||
|
||||
export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
try {
|
||||
const session = await context.sessions.auth(request);
|
||||
|
||||
// API key users skip this page
|
||||
if (session.user.subject === "unknown-non-oauth") {
|
||||
return redirect("/machines");
|
||||
}
|
||||
|
||||
const hasAccess = await context.sessions.check(request, Capabilities.ui_access);
|
||||
if (hasAccess) {
|
||||
return redirect("/machines");
|
||||
}
|
||||
|
||||
const url = context.config.headscale.public_url ?? context.config.headscale.url;
|
||||
|
||||
return {
|
||||
user: session.user,
|
||||
url,
|
||||
};
|
||||
} catch {
|
||||
return redirect("/login", {
|
||||
headers: {
|
||||
"Set-Cookie": await context.sessions.destroySession(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default function PendingApproval({ loaderData }: Route.ComponentProps) {
|
||||
return (
|
||||
<div className="flex h-screen w-screen items-center justify-center">
|
||||
<Card className="m-4 max-w-md sm:m-0">
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<div className="rounded-full bg-amber-100 p-3 dark:bg-amber-900">
|
||||
<ClockIcon className="h-8 w-8 text-amber-600 dark:text-amber-400" />
|
||||
</div>
|
||||
<div>
|
||||
<Card.Title className="mb-0 text-xl">Approval Required</Card.Title>
|
||||
<p className="text-headplane-500 text-sm">
|
||||
{loaderData.user.email ?? loaderData.user.name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card.Text className="mb-4">
|
||||
Your account has been created but requires approval from an administrator before you can
|
||||
access the management console.
|
||||
</Card.Text>
|
||||
|
||||
<div className="bg-headplane-50 dark:bg-headplane-900 mb-4 rounded-lg p-4">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<UserCheck className="text-headplane-500 h-5 w-5" />
|
||||
<p className="font-medium">What happens next?</p>
|
||||
</div>
|
||||
<ul className="text-headplane-600 dark:text-headplane-400 list-inside list-disc space-y-1 text-sm">
|
||||
<li>An administrator will review your account</li>
|
||||
<li>Once approved, you will receive the appropriate access level</li>
|
||||
<li>This page will automatically redirect you once approved</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<Card.Text className="mb-4 text-sm">
|
||||
In the meantime, you can still connect your devices to the Tailnet using the command
|
||||
below:
|
||||
</Card.Text>
|
||||
|
||||
<Button
|
||||
className="w-full font-mono text-sm"
|
||||
variant="light"
|
||||
onPress={async () => {
|
||||
await navigator.clipboard.writeText(`tailscale up --login-server=${loaderData.url}`);
|
||||
toast("Copied to clipboard");
|
||||
}}
|
||||
>
|
||||
tailscale up --login-server={loaderData.url}
|
||||
</Button>
|
||||
<p className="mt-1 text-center text-xs opacity-50">Click to copy the command</p>
|
||||
|
||||
<div className="bg-headplane-100 dark:bg-headplane-800 mb-4 flex items-center justify-center gap-2 rounded-lg p-3 text-sm">
|
||||
<RefreshCw className="text-headplane-500 h-4 w-4 animate-spin" />
|
||||
<span className="text-headplane-600 dark:text-headplane-400">
|
||||
Checking for approval automatically...
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Form action="/logout" method="post">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="heavy"
|
||||
className="flex w-full items-center justify-center gap-2"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
Sign Out
|
||||
</Button>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user