feat: add JWT auth with db-backed users
Some checks failed
Build & Release / build (push) Has been cancelled
Some checks failed
Build & Release / build (push) Has been cancelled
- users table created on startup via migrate() - POST /api/auth/setup to create first user (blocked once any user exists) - POST /api/auth/login returns httpOnly JWT cookie (7d expiry) - POST /api/auth/logout clears cookie - GET /api/auth/me for auth check - All /api/customers routes require valid JWT - Frontend shows login form when unauthenticated - Fix type errors in k8s, do, and pgbouncer services
This commit is contained in:
15
src/db/manager.ts
Normal file
15
src/db/manager.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import postgres from "postgres";
|
||||
import { config } from "../lib/config";
|
||||
|
||||
export const db = postgres(config.dbUrl);
|
||||
|
||||
export async function migrate() {
|
||||
await db`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
`;
|
||||
}
|
||||
38
src/index.ts
38
src/index.ts
@@ -1,10 +1,35 @@
|
||||
import Fastify from "fastify";
|
||||
import Fastify, { type FastifyRequest, type FastifyReply } from "fastify";
|
||||
import jwtPlugin from "@fastify/jwt";
|
||||
import cookiePlugin from "@fastify/cookie";
|
||||
|
||||
declare module "fastify" {
|
||||
interface FastifyInstance {
|
||||
authenticate: (req: FastifyRequest, reply: FastifyReply) => Promise<void>;
|
||||
}
|
||||
}
|
||||
import staticFiles from "@fastify/static";
|
||||
import { join } from "path";
|
||||
import { config } from "./lib/config";
|
||||
import { migrate } from "./db/manager";
|
||||
import { authRoutes } from "./routes/auth";
|
||||
import { customerRoutes } from "./routes/customers";
|
||||
|
||||
const app = Fastify({ logger: true });
|
||||
|
||||
await app.register(cookiePlugin);
|
||||
await app.register(jwtPlugin, {
|
||||
secret: config.jwtSecret,
|
||||
cookie: { cookieName: "token", signed: false },
|
||||
});
|
||||
|
||||
app.decorate("authenticate", async function (req: any, reply: any) {
|
||||
try {
|
||||
await req.jwtVerify({ onlyCookie: true });
|
||||
} catch {
|
||||
reply.status(401).send({ message: "Unauthorized" });
|
||||
}
|
||||
});
|
||||
|
||||
app.register(staticFiles, {
|
||||
root: join(import.meta.dir, "../frontend"),
|
||||
prefix: "/",
|
||||
@@ -12,9 +37,16 @@ app.register(staticFiles, {
|
||||
|
||||
app.get("/health", async () => ({ status: "ok" }));
|
||||
|
||||
app.register(customerRoutes, { prefix: "/api" });
|
||||
app.register(authRoutes, { prefix: "/api" });
|
||||
|
||||
app.listen({ port: Number(process.env.PORT ?? 3000), host: "0.0.0.0" }, (err) => {
|
||||
app.register(customerRoutes, {
|
||||
prefix: "/api",
|
||||
onRequest: [app.authenticate],
|
||||
} as any);
|
||||
|
||||
await migrate();
|
||||
|
||||
app.listen({ port: config.port, host: "0.0.0.0" }, (err) => {
|
||||
if (err) {
|
||||
app.log.error(err);
|
||||
process.exit(1);
|
||||
|
||||
@@ -6,6 +6,7 @@ export const config = {
|
||||
gitRepoUrl: process.env.GIT_REPO_URL ?? "ssh://git@git-ssh.lunarfront.tech/ryan/lunarfront-charts.git",
|
||||
dbUrl: process.env.DATABASE_URL!,
|
||||
doadminDbUrl: process.env.DOADMIN_DATABASE_URL!,
|
||||
jwtSecret: process.env.JWT_SECRET!,
|
||||
};
|
||||
|
||||
for (const [key, val] of Object.entries(config)) {
|
||||
|
||||
@@ -30,9 +30,9 @@ async function k8sFetch(path: string, options: RequestInit = {}) {
|
||||
}
|
||||
|
||||
export async function getSecret(namespace: string, name: string): Promise<Record<string, string>> {
|
||||
const secret = await k8sFetch(`/api/v1/namespaces/${namespace}/secrets/${name}`);
|
||||
const secret = await k8sFetch(`/api/v1/namespaces/${namespace}/secrets/${name}`) as { data: Record<string, string> };
|
||||
return Object.fromEntries(
|
||||
Object.entries(secret.data as Record<string, string>).map(([k, v]) => [k, Buffer.from(v, "base64").toString()])
|
||||
Object.entries(secret.data).map(([k, v]) => [k, Buffer.from(v, "base64").toString()])
|
||||
);
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ export async function patchConfigMap(namespace: string, name: string, data: Reco
|
||||
}
|
||||
|
||||
export async function getConfigMap(namespace: string, name: string): Promise<Record<string, string>> {
|
||||
const cm = await k8sFetch(`/api/v1/namespaces/${namespace}/configmaps/${name}`);
|
||||
const cm = await k8sFetch(`/api/v1/namespaces/${namespace}/configmaps/${name}`) as { data?: Record<string, string> };
|
||||
return cm.data ?? {};
|
||||
}
|
||||
|
||||
|
||||
70
src/routes/auth.ts
Normal file
70
src/routes/auth.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { db } from "../db/manager";
|
||||
|
||||
declare module "fastify" {
|
||||
interface FastifyInstance {
|
||||
authenticate: (req: FastifyRequest, reply: FastifyReply) => Promise<void>;
|
||||
}
|
||||
}
|
||||
|
||||
const LoginSchema = z.object({
|
||||
username: z.string().min(1),
|
||||
password: z.string().min(1),
|
||||
});
|
||||
|
||||
const SetupSchema = z.object({
|
||||
username: z.string().min(3).max(32).regex(/^[a-z0-9_]+$/),
|
||||
password: z.string().min(12),
|
||||
});
|
||||
|
||||
export async function authRoutes(app: FastifyInstance) {
|
||||
app.post("/auth/setup", async (req, reply) => {
|
||||
const [row] = await db`SELECT COUNT(*)::int AS count FROM users` as [{ count: number }];
|
||||
if (row.count > 0) {
|
||||
return reply.status(403).send({ message: "Setup already complete" });
|
||||
}
|
||||
|
||||
const body = SetupSchema.parse(req.body);
|
||||
const hash = await Bun.password.hash(body.password);
|
||||
const [user] = await db`
|
||||
INSERT INTO users (username, password_hash) VALUES (${body.username}, ${hash}) RETURNING id, username
|
||||
` as [{ id: number; username: string }];
|
||||
return { username: user.username };
|
||||
});
|
||||
|
||||
app.post("/auth/login", async (req, reply) => {
|
||||
const body = LoginSchema.parse(req.body);
|
||||
|
||||
const [user] = await db`SELECT * FROM users WHERE username = ${body.username} LIMIT 1` as [{ id: number; username: string; password_hash: string } | undefined];
|
||||
if (!user) {
|
||||
return reply.status(401).send({ message: "Invalid credentials" });
|
||||
}
|
||||
|
||||
const valid = await Bun.password.verify(body.password, user.password_hash);
|
||||
if (!valid) {
|
||||
return reply.status(401).send({ message: "Invalid credentials" });
|
||||
}
|
||||
|
||||
const token = app.jwt.sign({ sub: user.id, username: user.username }, { expiresIn: "7d" });
|
||||
|
||||
reply.setCookie("token", token, {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: "strict",
|
||||
path: "/",
|
||||
maxAge: 60 * 60 * 24 * 7,
|
||||
});
|
||||
|
||||
return { username: user.username };
|
||||
});
|
||||
|
||||
app.post("/auth/logout", async (_req, reply) => {
|
||||
reply.clearCookie("token", { path: "/" });
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
app.get("/auth/me", { onRequest: [app.authenticate] }, async (req) => {
|
||||
return { username: (req.user as { username: string }).username };
|
||||
});
|
||||
}
|
||||
@@ -30,7 +30,7 @@ export async function createDatabaseUser(name: string): Promise<{ name: string;
|
||||
const res = await doFetch(`/databases/${config.doDbClusterId}/users`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
}) as { user: { name: string; password: string } };
|
||||
return res.user;
|
||||
}
|
||||
|
||||
@@ -43,6 +43,6 @@ export async function deleteDatabase(name: string) {
|
||||
}
|
||||
|
||||
export async function getDatabaseHost(): Promise<string> {
|
||||
const res = await doFetch(`/databases/${config.doDbClusterId}`);
|
||||
const res = await doFetch(`/databases/${config.doDbClusterId}`) as { database: { connection: { host: string } } };
|
||||
return res.database.connection.host;
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ export async function removeCustomerFromPool(slug: string) {
|
||||
|
||||
async function addDbEntry(slug: string) {
|
||||
const cm = await getConfigMap(NAMESPACE, "pgbouncer-config");
|
||||
const ini = cm["pgbouncer.ini"];
|
||||
const ini = cm["pgbouncer.ini"] ?? "";
|
||||
const newLine = ` ${slug} = host=${DO_HOST} port=${DO_PORT} dbname=${slug} user=${slug} pool_mode=session pool_size=3`;
|
||||
const updated = ini.replace("[pgbouncer]", `${newLine}\n [pgbouncer]`);
|
||||
await patchConfigMap(NAMESPACE, "pgbouncer-config", { "pgbouncer.ini": updated });
|
||||
@@ -30,21 +30,21 @@ async function addDbEntry(slug: string) {
|
||||
|
||||
async function removeDbEntry(slug: string) {
|
||||
const cm = await getConfigMap(NAMESPACE, "pgbouncer-config");
|
||||
const ini = cm["pgbouncer.ini"];
|
||||
const ini = cm["pgbouncer.ini"] ?? "";
|
||||
const updated = ini.split("\n").filter((l) => !l.includes(`dbname=${slug}`)).join("\n");
|
||||
await patchConfigMap(NAMESPACE, "pgbouncer-config", { "pgbouncer.ini": updated });
|
||||
}
|
||||
|
||||
async function addUserEntry(slug: string, password: string) {
|
||||
const secret = await getSecret(NAMESPACE, "pgbouncer-userlist");
|
||||
const userlist = secret["userlist.txt"];
|
||||
const userlist = secret["userlist.txt"] ?? "";
|
||||
const updated = `${userlist}\n"${slug}" "${password}"`;
|
||||
await patchSecret(NAMESPACE, "pgbouncer-userlist", { "userlist.txt": updated });
|
||||
}
|
||||
|
||||
async function removeUserEntry(slug: string) {
|
||||
const secret = await getSecret(NAMESPACE, "pgbouncer-userlist");
|
||||
const userlist = secret["userlist.txt"];
|
||||
const userlist = secret["userlist.txt"] ?? "";
|
||||
const updated = userlist.split("\n").filter((l) => !l.startsWith(`"${slug}"`)).join("\n");
|
||||
await patchSecret(NAMESPACE, "pgbouncer-userlist", { "userlist.txt": updated });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user