mirror of
https://github.com/tale/headplane.git
synced 2026-09-01 09:48:24 +00:00
fix: harden user linking and session pruning
Security: - Add unique constraint on headscale_user_id to prevent hijacking - linkHeadscaleUser now rejects already-claimed Headscale users - Onboarding dropdown filters out claimed users - onboarding-skip action redirects on rejected claims Maintenance: - Replace probabilistic session pruning with setInterval (15m) - Move pruning out of request path into server startup Co-authored-by: Amp <amp@ampcode.com> Amp-Thread-ID: https://ampcode.com/threads/T-019cce57-c9e1-7732-9709-8288127573a9
This commit is contained in:
@@ -80,9 +80,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
|
|||||||
})()
|
})()
|
||||||
: userInfo.picture;
|
: userInfo.picture;
|
||||||
|
|
||||||
const hasUsers = await context.auth.hasAnyUsers();
|
const userId = await context.auth.findOrCreateUser(claims.sub);
|
||||||
const defaultRole = hasUsers ? "member" : "owner";
|
|
||||||
const userId = await context.auth.findOrCreateUser(claims.sub, defaultRole);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const hsApi = context.hsApi.getRuntimeClient(context.oidc!.apiKey);
|
const hsApi = context.hsApi.getRuntimeClient(context.oidc!.apiKey);
|
||||||
|
|||||||
@@ -34,7 +34,10 @@ export async function action({ request, context }: Route.ActionArgs) {
|
|||||||
const headscaleUserId = formData.get("headscale_user_id")?.toString();
|
const headscaleUserId = formData.get("headscale_user_id")?.toString();
|
||||||
|
|
||||||
if (headscaleUserId) {
|
if (headscaleUserId) {
|
||||||
await context.auth.linkHeadscaleUser(principal.user.id, headscaleUserId);
|
const linked = await context.auth.linkHeadscaleUser(principal.user.id, headscaleUserId);
|
||||||
|
if (!linked) {
|
||||||
|
return redirect("/onboarding");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await context.db
|
await context.db
|
||||||
|
|||||||
@@ -74,10 +74,13 @@ export async function loader({ request, context }: Route.LoaderArgs) {
|
|||||||
firstMachine = nodes.find((n) => n.user?.id === matched.id);
|
firstMachine = nodes.find((n) => n.user?.id === matched.id);
|
||||||
} else {
|
} else {
|
||||||
needsUserLink = true;
|
needsUserLink = true;
|
||||||
headscaleUsers = apiUsers.map((u) => ({
|
const claimed = await context.auth.claimedHeadscaleUserIds();
|
||||||
id: u.id,
|
headscaleUsers = apiUsers
|
||||||
name: getUserDisplayName(u),
|
.filter((u) => !claimed.has(u.id))
|
||||||
}));
|
.map((u) => ({
|
||||||
|
id: u.id,
|
||||||
|
name: getUserDisplayName(u),
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export const users = sqliteTable("users", {
|
|||||||
id: text("id").primaryKey(),
|
id: text("id").primaryKey(),
|
||||||
sub: text("sub").notNull().unique(),
|
sub: text("sub").notNull().unique(),
|
||||||
role: text("role").notNull().default("member"),
|
role: text("role").notNull().default("member"),
|
||||||
headscale_user_id: text("headscale_user_id"),
|
headscale_user_id: text("headscale_user_id").unique(),
|
||||||
onboarded: integer("onboarded", { mode: "boolean" }).notNull().default(false),
|
onboarded: integer("onboarded", { mode: "boolean" }).notNull().default(false),
|
||||||
created_at: integer("created_at", { mode: "timestamp" }).$default(() => new Date()),
|
created_at: integer("created_at", { mode: "timestamp" }).$default(() => new Date()),
|
||||||
updated_at: integer("updated_at", { mode: "timestamp" }).$default(() => new Date()),
|
updated_at: integer("updated_at", { mode: "timestamp" }).$default(() => new Date()),
|
||||||
|
|||||||
@@ -129,6 +129,14 @@ export default createHonoServer({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Prune expired auth sessions every 15 minutes
|
||||||
|
setInterval(
|
||||||
|
() => {
|
||||||
|
appLoadContext.auth.pruneExpiredSessions();
|
||||||
|
},
|
||||||
|
15 * 60 * 1000,
|
||||||
|
);
|
||||||
|
|
||||||
process.on("SIGINT", () => {
|
process.on("SIGINT", () => {
|
||||||
log.info("server", "Received SIGINT, shutting down...");
|
log.info("server", "Received SIGINT, shutting down...");
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
|
|||||||
+31
-2
@@ -317,13 +317,42 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update the Headscale user link for a Headplane user.
|
* Link a Headplane user to a Headscale user. Returns false if the
|
||||||
|
* Headscale user is already claimed by another Headplane user.
|
||||||
*/
|
*/
|
||||||
async linkHeadscaleUser(userId: string, headscaleUserId: string): Promise<void> {
|
async linkHeadscaleUser(userId: string, headscaleUserId: string): Promise<boolean> {
|
||||||
|
const [existing] = await this.opts.db
|
||||||
|
.select({ id: users.id })
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.headscale_user_id, headscaleUserId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existing && existing.id !== userId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
await this.opts.db
|
await this.opts.db
|
||||||
.update(users)
|
.update(users)
|
||||||
.set({ headscale_user_id: headscaleUserId, updated_at: new Date() })
|
.set({ headscale_user_id: headscaleUserId, updated_at: new Date() })
|
||||||
.where(eq(users.id, userId));
|
.where(eq(users.id, userId));
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the set of Headscale user IDs that are already claimed
|
||||||
|
* by a Headplane user. Used to filter the onboarding dropdown.
|
||||||
|
*/
|
||||||
|
async claimedHeadscaleUserIds(): Promise<Set<string>> {
|
||||||
|
const rows = await this.opts.db.select({ hsId: users.headscale_user_id }).from(users);
|
||||||
|
|
||||||
|
const ids = new Set<string>();
|
||||||
|
for (const row of rows) {
|
||||||
|
if (row.hsId) {
|
||||||
|
ids.add(row.hsId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ CREATE TABLE `auth_sessions` (
|
|||||||
--> statement-breakpoint
|
--> statement-breakpoint
|
||||||
ALTER TABLE `users` ADD `role` text DEFAULT 'member' NOT NULL;--> statement-breakpoint
|
ALTER TABLE `users` ADD `role` text DEFAULT 'member' NOT NULL;--> statement-breakpoint
|
||||||
ALTER TABLE `users` ADD `headscale_user_id` text;--> statement-breakpoint
|
ALTER TABLE `users` ADD `headscale_user_id` text;--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `users_headscale_user_id_unique` ON `users` (`headscale_user_id`);--> statement-breakpoint
|
||||||
ALTER TABLE `users` ADD `created_at` integer;--> statement-breakpoint
|
ALTER TABLE `users` ADD `created_at` integer;--> statement-breakpoint
|
||||||
ALTER TABLE `users` ADD `updated_at` integer;--> statement-breakpoint
|
ALTER TABLE `users` ADD `updated_at` integer;--> statement-breakpoint
|
||||||
ALTER TABLE `users` ADD `last_login_at` integer;--> statement-breakpoint
|
ALTER TABLE `users` ADD `last_login_at` integer;--> statement-breakpoint
|
||||||
|
|||||||
@@ -193,6 +193,11 @@
|
|||||||
"name": "users_sub_unique",
|
"name": "users_sub_unique",
|
||||||
"columns": ["sub"],
|
"columns": ["sub"],
|
||||||
"isUnique": true
|
"isUnique": true
|
||||||
|
},
|
||||||
|
"users_headscale_user_id_unique": {
|
||||||
|
"name": "users_headscale_user_id_unique",
|
||||||
|
"columns": ["headscale_user_id"],
|
||||||
|
"isUnique": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"foreignKeys": {},
|
"foreignKeys": {},
|
||||||
|
|||||||
Reference in New Issue
Block a user