- POST /api/auth/login: HMAC-SHA256 signed 7-day token, password from DASHBOARD_PASSWORD env - GET /api/auth/verify: stateless token validation - requireAuth middleware applied to all /api/* routes (except /api/health + /api/auth) - /dashboard/login.html: dark TIP-themed login page with show/hide password toggle - index.html: auth guard redirect to login + Authorization header on all api() calls - No secrets in code — password stored in .env only
73 lines
2.2 KiB
TypeScript
73 lines
2.2 KiB
TypeScript
/**
|
|
* Auth Route — POST /api/auth/login, GET /api/auth/verify
|
|
*
|
|
* Password stored in DASHBOARD_PASSWORD env var (never in code).
|
|
* Token: HMAC-SHA256(expiresAt, password) — signed, time-limited, stateless.
|
|
*/
|
|
import { Router, Request, Response } from "express";
|
|
import { createHmac, timingSafeEqual } from "crypto";
|
|
|
|
export const authRouter = Router();
|
|
|
|
const TOKEN_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
|
|
|
function sign(expiresAt: number): string {
|
|
const password = process.env.DASHBOARD_PASSWORD ?? "";
|
|
return createHmac("sha256", password)
|
|
.update(String(expiresAt))
|
|
.digest("base64url");
|
|
}
|
|
|
|
export function verifyToken(token: string): boolean {
|
|
if (!token || !token.includes(".")) return false;
|
|
const [rawExp, sig] = token.split(".", 2);
|
|
const expiresAt = parseInt(rawExp, 10);
|
|
if (isNaN(expiresAt) || Date.now() > expiresAt) return false;
|
|
const expected = sign(expiresAt);
|
|
try {
|
|
return timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// POST /api/auth/login
|
|
authRouter.post("/login", (req: Request, res: Response) => {
|
|
const { password } = req.body as { password?: string };
|
|
const envPassword = process.env.DASHBOARD_PASSWORD;
|
|
|
|
if (!envPassword) {
|
|
res.status(500).json({ error: "Auth not configured (DASHBOARD_PASSWORD missing)" });
|
|
return;
|
|
}
|
|
if (!password) {
|
|
res.status(400).json({ error: "Password required" });
|
|
return;
|
|
}
|
|
|
|
// Constant-time comparison to prevent timing attacks
|
|
const pwdBuf = Buffer.from(password.padEnd(128, "\0").slice(0, 128));
|
|
const envBuf = Buffer.from(envPassword.padEnd(128, "\0").slice(0, 128));
|
|
const match = timingSafeEqual(pwdBuf, envBuf) && password === envPassword;
|
|
|
|
if (!match) {
|
|
res.status(401).json({ error: "Invalid password" });
|
|
return;
|
|
}
|
|
|
|
const expiresAt = Date.now() + TOKEN_TTL_MS;
|
|
const token = `${expiresAt}.${sign(expiresAt)}`;
|
|
res.json({ token, expiresAt });
|
|
});
|
|
|
|
// GET /api/auth/verify
|
|
authRouter.get("/verify", (req: Request, res: Response) => {
|
|
const auth = req.headers.authorization ?? "";
|
|
const token = auth.startsWith("Bearer ") ? auth.slice(7) : "";
|
|
if (verifyToken(token)) {
|
|
res.json({ ok: true });
|
|
} else {
|
|
res.status(401).json({ ok: false, error: "Unauthorized" });
|
|
}
|
|
});
|