135 lines
4.0 KiB
JavaScript
135 lines
4.0 KiB
JavaScript
import bcrypt from "bcryptjs";
|
|
import * as store from "../../store.js";
|
|
|
|
export const COOKIE = "jerapah_flow_token";
|
|
export const OPEN_API_ROUTES = new Set([
|
|
"GET /auth/bootstrap",
|
|
"POST /auth/register",
|
|
"POST /auth/login",
|
|
]);
|
|
|
|
/**
|
|
* Cookie flags for auth JWT.
|
|
* Secure defaults on in production (HTTPS). Override with COOKIE_SECURE=false
|
|
* when serving over plain HTTP (e.g. LAN IP http://192.168.x.x) — browsers
|
|
* refuse to store Secure cookies on non-HTTPS origins.
|
|
*/
|
|
export function cookieOpts() {
|
|
const flag = process.env.COOKIE_SECURE;
|
|
const secure =
|
|
flag === "true" || flag === "1"
|
|
? true
|
|
: flag === "false" || flag === "0"
|
|
? false
|
|
: process.env.NODE_ENV === "production";
|
|
return {
|
|
httpOnly: true,
|
|
path: "/",
|
|
sameSite: "lax",
|
|
secure,
|
|
maxAge: 7 * 24 * 60 * 60,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Require JWT for /api routes except bootstrap, login, and register.
|
|
* @param {import("fastify").FastifyInstance} api
|
|
* @param {import("fastify").FastifyInstance} root
|
|
*/
|
|
export function addApiAuthGuard(api, root) {
|
|
api.addHook("onRequest", async (req, reply) => {
|
|
const raw = (req.url || "").split("?")[0];
|
|
const stripped = raw.replace(/^\/api/, "") || "/";
|
|
const routeUrl = req.routeOptions?.url || stripped;
|
|
const open =
|
|
OPEN_API_ROUTES.has(`${req.method} ${routeUrl}`) ||
|
|
OPEN_API_ROUTES.has(`${req.method} ${stripped}`);
|
|
if (open) return;
|
|
await root.authenticate(req, reply);
|
|
});
|
|
}
|
|
|
|
export function validateCredentials(username, password) {
|
|
if (!/^[A-Za-z0-9_]{3,32}$/.test(username)) {
|
|
return "username must be 3-32 letters, numbers, or underscore";
|
|
}
|
|
if (password.length < 8) {
|
|
return "password must be at least 8 characters";
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* @param {import("fastify").FastifyInstance} fastify
|
|
*/
|
|
export default async function authPlugin(fastify) {
|
|
fastify.get("/auth/bootstrap", async () => {
|
|
const count = await store.countUsers();
|
|
return { needsSetup: count === 0 };
|
|
});
|
|
|
|
fastify.post("/auth/register", async (req, reply) => {
|
|
const count = await store.countUsers();
|
|
if (count > 0) {
|
|
return reply.code(403).send({ error: "setup already complete" });
|
|
}
|
|
const body = /** @type {{ username?: string, password?: string }} */ (req.body ?? {});
|
|
const username = String(body.username ?? "").trim();
|
|
const password = String(body.password ?? "");
|
|
const err = validateCredentials(username, password);
|
|
if (err) return reply.code(400).send({ error: err });
|
|
|
|
const passwordHash = await bcrypt.hash(password, 10);
|
|
const user = await store.createUser({
|
|
username,
|
|
passwordHash,
|
|
role: "admin",
|
|
});
|
|
const token = await reply.jwtSign({
|
|
id: user.id,
|
|
username: user.username,
|
|
role: user.role,
|
|
});
|
|
reply.setCookie(COOKIE, token, cookieOpts());
|
|
return { user };
|
|
});
|
|
|
|
fastify.post("/auth/login", async (req, reply) => {
|
|
const body = /** @type {{ username?: string, password?: string }} */ (req.body ?? {});
|
|
const username = String(body.username ?? "").trim();
|
|
const password = String(body.password ?? "");
|
|
const row = await store.getUserAuthByUsername(username);
|
|
if (!row || !(await bcrypt.compare(password, row.password_hash))) {
|
|
return reply.code(401).send({ error: "invalid credentials" });
|
|
}
|
|
const token = await reply.jwtSign({
|
|
id: row.id,
|
|
username: row.username,
|
|
role: row.role,
|
|
});
|
|
reply.setCookie(COOKIE, token, cookieOpts());
|
|
return {
|
|
user: {
|
|
id: row.id,
|
|
username: row.username,
|
|
role: row.role,
|
|
created_at: row.created_at,
|
|
updated_at: row.updated_at,
|
|
},
|
|
};
|
|
});
|
|
|
|
fastify.post("/auth/logout", async (_req, reply) => {
|
|
reply.clearCookie(COOKIE, cookieOpts());
|
|
return { ok: true };
|
|
});
|
|
|
|
fastify.get("/auth/me", async (req, reply) => {
|
|
const user = await store.getUserById(req.user.id);
|
|
if (!user) {
|
|
return reply.code(401).send({ error: "unauthorized" });
|
|
}
|
|
return { user };
|
|
});
|
|
}
|