Jelajahi Sumber

feat: user auth

Thomas Zhang 3 bulan lalu
induk
melakukan
918701c9c6

+ 32 - 0
package-lock.json

@@ -23,6 +23,7 @@
 				"@tanstack/table-core": "^8.21.3",
 				"@types/node": "^22",
 				"@vitest/browser-playwright": "^4.1.0",
+				"bcryptjs": "^3.0.3",
 				"bits-ui": "^2.16.5",
 				"clsx": "^2.1.1",
 				"eslint": "^9.39.2",
@@ -31,6 +32,7 @@
 				"eslint-plugin-svelte": "^3.14.0",
 				"formsnap": "^2.0.1",
 				"globals": "^17.3.0",
+				"jose": "^6.2.3",
 				"layerchart": "^2.0.0-next.48",
 				"mode-watcher": "^1.1.0",
 				"openapi-typescript": "^7.13.0",
@@ -2794,6 +2796,16 @@
 			"dev": true,
 			"license": "MIT"
 		},
+		"node_modules/bcryptjs": {
+			"version": "3.0.3",
+			"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
+			"integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
+			"dev": true,
+			"license": "BSD-3-Clause",
+			"bin": {
+				"bcrypt": "bin/bcrypt"
+			}
+		},
 		"node_modules/bits-ui": {
 			"version": "2.18.0",
 			"resolved": "https://registry.npmjs.org/bits-ui/-/bits-ui-2.18.0.tgz",
@@ -2997,6 +3009,16 @@
 				"node": ">=14.6"
 			}
 		},
+		"node_modules/color/node_modules/color-name": {
+			"version": "2.1.0",
+			"resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.0.tgz",
+			"integrity": "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==",
+			"dev": true,
+			"license": "MIT",
+			"engines": {
+				"node": ">=12.20"
+			}
+		},
 		"node_modules/colorette": {
 			"version": "1.4.0",
 			"resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz",
@@ -4291,6 +4313,16 @@
 				"@sideway/pinpoint": "^2.0.0"
 			}
 		},
+		"node_modules/jose": {
+			"version": "6.2.3",
+			"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz",
+			"integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==",
+			"dev": true,
+			"license": "MIT",
+			"funding": {
+				"url": "https://github.com/sponsors/panva"
+			}
+		},
 		"node_modules/js-levenshtein": {
 			"version": "1.1.6",
 			"resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz",

+ 2 - 0
package.json

@@ -33,6 +33,7 @@
 		"@tanstack/table-core": "^8.21.3",
 		"@types/node": "^22",
 		"@vitest/browser-playwright": "^4.1.0",
+		"bcryptjs": "^3.0.3",
 		"bits-ui": "^2.16.5",
 		"clsx": "^2.1.1",
 		"eslint": "^9.39.2",
@@ -41,6 +42,7 @@
 		"eslint-plugin-svelte": "^3.14.0",
 		"formsnap": "^2.0.1",
 		"globals": "^17.3.0",
+		"jose": "^6.2.3",
 		"layerchart": "^2.0.0-next.48",
 		"mode-watcher": "^1.1.0",
 		"openapi-typescript": "^7.13.0",

+ 2 - 0
src/app.d.ts

@@ -1,4 +1,5 @@
 // See https://svelte.dev/docs/kit/types#app.d.ts
+import type { User } from '$lib/types/user';
 import type { Workspace } from '$lib/types/workspace';
 
 // for information about these interfaces
@@ -14,6 +15,7 @@ declare global {
 			requestId: string;
 			workspaceId: string | null;
 			workspace: Workspace | undefined;
+			user: User | null;
 		}
 		// interface PageData {}
 		// interface PageState {}

+ 29 - 1
src/hooks.server.ts

@@ -1,19 +1,24 @@
 import crypto from 'node:crypto';
 
 import type { Handle, HandleServerError } from '@sveltejs/kit';
+import { redirect } from '@sveltejs/kit';
 
 import { resolve } from '$app/paths';
 
 import {
+	ForbiddenError,
 	isAppError,
 	logger as rootLogger,
 	NotFoundError,
 	runWithContext,
 	server,
 	serverLogger as log,
+	UnauthorizedError,
 	ValidationError,
 } from '$lib/server';
+import type { User } from '$lib/types/user';
 
+const LOGIN = resolve('/auth/login');
 const WORKSPACE_RESOURCES = resolve('/api/workspaces');
 
 const ready = server.init();
@@ -35,12 +40,36 @@ const handle: Handle = async ({ event, resolve: resolveRequest }) => {
 	event.locals.requestId = requestId;
 
 	return runWithContext({ requestId }, async () => {
+		const auth = server.auth;
+		let user: User | undefined;
+		if (auth) {
+			const token = event.cookies.get('session');
+			user = token ? await auth.verifyToken(token) : undefined;
+			if (!user && event.url.pathname !== LOGIN) {
+				redirect(302, LOGIN);
+			}
+		} else {
+			user = {
+				id: 'admin',
+				name: 'Admin',
+				email: 'admin@locostack.com',
+				workspaces: ['*'],
+			};
+		}
+		event.locals.user = user || null;
+
 		const { pathname } = event.url;
 		if (pathname.startsWith(WORKSPACE_RESOURCES) && pathname !== WORKSPACE_RESOURCES) {
+			if (!user) {
+				throw new UnauthorizedError('Authentication required');
+			}
 			const workspaceId = event.params.workspaceId;
 			if (workspaceId === undefined) {
 				throw new ValidationError('Workspace ID is required');
 			}
+			if (!user.workspaces.includes(workspaceId) && !user.workspaces.includes('*')) {
+				throw new ForbiddenError('User does not have access to this workspace');
+			}
 			const workspace = server.ws.list().find((ws) => ws.id === workspaceId);
 			if (!workspace) {
 				throw new NotFoundError('Workspace not found');
@@ -50,7 +79,6 @@ const handle: Handle = async ({ event, resolve: resolveRequest }) => {
 		} else {
 			event.locals.workspaceId = event.cookies.get('workspaceId') || null;
 		}
-
 		return resolveRequest(event);
 	});
 };

+ 104 - 0
src/lib/server/auth.ts

@@ -0,0 +1,104 @@
+/**
+ * File-based credential validation.
+ *
+ * Credentials are read from a local JSON file mounted by Kubernetes from a ConfigMap:
+ *
+ *   apiVersion: v1
+ *   kind: ConfigMap
+ *   metadata:
+ *     name: agent-paas-users
+ *   data:
+ *     users.json: |
+ *       [
+ *         { "username": "admin", "passwordHash": "<bcrypt hash>" }
+ *       ]
+ *
+ * Mount it as a volume at the path supplied via AuthConfig.usersFile.
+ *
+ * Generate a bcrypt hash: `htpasswd -nbB admin mypassword | cut -d: -f2`
+ *
+ * Future: replace `Auth` with an OIDC/SSO-backed implementation without changing callers.
+ */
+import { readFile } from 'node:fs/promises';
+
+import bcrypt from 'bcryptjs';
+import { jwtVerify, SignJWT } from 'jose';
+
+import { authLogger as log } from '$lib/server';
+import type { User } from '$lib/types/user';
+
+type AuthConfig = {
+	jwtSecret: string;
+	/** Path to the JSON users file. Default: /etc/agent-paas/users.json */
+	usersFile: string;
+};
+
+type UserEntry = {
+	id: string;
+	passwordHash: string;
+	name?: string;
+	email?: string;
+	workspaces?: string[];
+};
+
+class Auth {
+	#ALG = 'HS256';
+	#TTL = '8h';
+	#users: UserEntry[] = [];
+	#cfg: AuthConfig;
+
+	constructor(cfg: AuthConfig) {
+		this.#cfg = cfg;
+	}
+
+	async init(): Promise<void> {
+		log.info('Loading users file', { path: this.#cfg.usersFile });
+		const raw = await readFile(this.#cfg.usersFile, 'utf-8');
+		this.#users = JSON.parse(raw) as UserEntry[];
+		log.info('Users loaded', { count: this.#users.length });
+	}
+
+	async validateCredentials(userId: string, password: string): Promise<boolean> {
+		const user = this.#users.find((u) => u.id === userId);
+		if (!user) {
+			log.warn('Login attempt for unknown user', { username: userId });
+			return false;
+		}
+		const valid = await bcrypt.compare(password, user.passwordHash);
+		if (valid) {
+			log.info('Login successful', { username: userId });
+		} else {
+			log.warn('Login failed: wrong password', { username: userId });
+		}
+		return valid;
+	}
+
+	#secret(): Uint8Array {
+		if (!this.#cfg.jwtSecret) throw new Error('jwtSecret is not set');
+		return new TextEncoder().encode(this.#cfg.jwtSecret);
+	}
+
+	async signToken(userId: string): Promise<string> {
+		return new SignJWT({ username: userId })
+			.setProtectedHeader({ alg: this.#ALG })
+			.setIssuedAt()
+			.setExpirationTime(this.#TTL)
+			.sign(this.#secret());
+	}
+
+	async verifyToken(token: string): Promise<User | undefined> {
+		try {
+			const { payload } = await jwtVerify(token, this.#secret(), { algorithms: [this.#ALG] });
+			const userId = payload['username'];
+			if (typeof userId !== 'string') return undefined;
+			const user = this.#users.find((u) => u.id === userId);
+			if (!user) return undefined;
+			return { id: user.id, name: user.name, email: user.email, workspaces: user.workspaces || [] };
+		} catch {
+			return undefined;
+		}
+	}
+}
+
+export type { AuthConfig };
+export { Auth };

+ 2 - 1
src/lib/server/logger.ts

@@ -58,5 +58,6 @@ const child = (domain: string) => root.child({ domain });
 // --- Domain loggers ---
 
 const serverLogger = child('server');
+const authLogger = child('auth');
 
-export { root as logger, serverLogger };
+export { authLogger, root as logger, serverLogger };

+ 23 - 1
src/lib/server/server.ts

@@ -1,5 +1,8 @@
 import { serverLogger as log } from '$lib/server/logger';
 
+import { env } from '$env/dynamic/private';
+
+import { Auth } from './auth';
 import { WorkspaceManager } from './workspace';
 
 class Server {
@@ -7,13 +10,14 @@ class Server {
 	#initialized = false;
 
 	#ws!: WorkspaceManager;
+	#auth: Auth | undefined;
 
 	async init(): Promise<void> {
 		if (this.#initialized) return;
 		this.#initialized = true;
 		log.info('Server initializing');
 
-		await this.#createWorkspaceManager();
+		await Promise.all([this.#createWorkspaceManager(), this.#createAuth()]);
 
 		log.info('Server ready');
 	}
@@ -26,9 +30,27 @@ class Server {
 		this.#ws = ws;
 	}
 
+	async #createAuth(): Promise<void> {
+		if (!env.AUTH_JWT_SECRET || !env.AUTH_USERS_FILE) {
+			log.info('Auth is disabled because AUTH_JWT_SECRET or AUTH_USERS_FILE is not set');
+			return;
+		}
+		const auth = new Auth({
+			jwtSecret: env.AUTH_JWT_SECRET,
+			usersFile: env.AUTH_USERS_FILE,
+		});
+		log.info(`Auth is enabled, users loaded from ${env.AUTH_USERS_FILE}`);
+		await auth.init();
+		this.#auth = auth;
+	}
+
 	get ws(): WorkspaceManager {
 		return this.#ws;
 	}
+
+	get auth(): Auth | undefined {
+		return this.#auth;
+	}
 }
 
 // Singleton shared across hooks and route handlers.

+ 8 - 0
src/lib/types/user.ts

@@ -0,0 +1,8 @@
+type User = {
+	id: string;
+	name?: string;
+	email?: string;
+	workspaces: string[];
+};
+
+export type { User };

+ 16 - 0
src/routes/+layout.server.ts

@@ -0,0 +1,16 @@
+import { server } from '$lib/server';
+
+import type { LayoutServerLoad } from './$types';
+
+const load: LayoutServerLoad = async ({ locals, cookies }) => {
+	const { user, workspaceId } = locals;
+	const workspaces = server.ws.list();
+	const workspaceIdValid =
+		workspaceId && workspaces.some((w) => w.id === workspaceId && user?.workspaces.includes(w.id));
+	if (!workspaceIdValid && workspaces.length > 0) {
+		cookies.set('workspaceId', workspaces[0]?.id || '', { path: '/' });
+	}
+	return { authEnabled: server.auth !== undefined, user: locals.user, workspaces, workspaceId };
+};
+
+export { load };

+ 17 - 0
src/routes/api/workspaces/+server.ts

@@ -0,0 +1,17 @@
+import { json } from '@sveltejs/kit';
+
+import { UnauthorizedError } from '$lib/server';
+import { server } from '$lib/server/server';
+
+import type { RequestHandler } from './$types';
+
+const GET: RequestHandler = async ({ locals }) => {
+	const { user } = locals;
+	if (!user) {
+		throw new UnauthorizedError('Authentication required');
+	}
+	const workspaces = server.ws.list().filter((ws) => user.workspaces.includes(ws.id));
+	return json(workspaces);
+};
+
+export { GET };

+ 53 - 0
src/routes/auth/login/+page.server.ts

@@ -0,0 +1,53 @@
+import { fail, redirect } from '@sveltejs/kit';
+
+import { resolve } from '$app/paths';
+
+import { server } from '$lib/server/server';
+
+import type { Actions, PageServerLoad } from './$types';
+
+// Redirect already-authenticated users away from the login page.
+const load: PageServerLoad = ({ locals }) => {
+	if (locals.user) redirect(302, resolve('/'));
+};
+
+const actions: Actions = {
+	default: async ({ request, cookies }) => {
+		const data = await request.formData();
+		const username = data.get('username');
+		const password = data.get('password');
+
+		if (typeof username !== 'string' || typeof password !== 'string') {
+			return fail(400, { error: 'Invalid form data.' });
+		}
+
+		const auth = server.auth;
+		if (!auth) {
+			return fail(500, { error: 'Authentication service unavailable. Please try again.' });
+		}
+
+		let valid: boolean;
+		try {
+			valid = await auth.validateCredentials(username, password);
+		} catch {
+			return fail(500, { error: 'Authentication service unavailable. Please try again.' });
+		}
+
+		if (!valid) {
+			return fail(401, { error: 'Invalid username or password.' });
+		}
+
+		const token = await auth.signToken(username);
+		cookies.set('session', token, {
+			path: '/',
+			httpOnly: true,
+			sameSite: 'lax',
+			secure: true,
+			maxAge: 60 * 60 * 8, // 8 hours, matches JWT TTL
+		});
+
+		redirect(302, resolve('/'));
+	},
+};
+
+export { actions, load };

+ 5 - 0
src/routes/auth/login/+page.svelte

@@ -0,0 +1,5 @@
+<form action="/auth/login" method="POST">
+	<input name="username" placeholder="Username" required type="text" />
+	<input name="password" placeholder="Password" required type="password" />
+	<button type="submit">Login</button>
+</form>

+ 10 - 0
src/routes/auth/logout/+page.server.ts

@@ -0,0 +1,10 @@
+import { redirect } from '@sveltejs/kit';
+
+import type { PageServerLoad } from './$types';
+
+const load: PageServerLoad = async ({ cookies }) => {
+	cookies.delete('session', { path: '/' });
+	redirect(302, '/auth/login');
+};
+
+export { load };

+ 8 - 0
users.dev.json

@@ -0,0 +1,8 @@
+[
+	{
+		"id": "admin",
+		"passwordHash": "$2y$05$VJNArWm5Xa2sJ5aw9GORguwllALoN3bCbUGODXXueWzqG.4IhTQWu",
+		"name": "Admin",
+		"workspaces": ["*"]
+	}
+]