Rene Fichtmueller a066300cf2 feat: password-protected login page + API auth middleware
- 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
2026-04-02 07:31:15 +02:00

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" });
}
});