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:
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 };
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user