diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index c7db5c8..9c87d9d 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -4,6 +4,8 @@ import helmet from "helmet"; import rateLimit from "express-rate-limit"; import { join } from "path"; import { cfg } from "./config"; +import { authRouter } from "./routes/auth"; +import { requireAuth } from "./middleware/require-auth"; import { transceiverRouter } from "./routes/transceivers"; import { switchRouter } from "./routes/switches"; import { vendorRouter } from "./routes/vendors"; @@ -41,6 +43,16 @@ app.use( }) ); +// Auth (public — no requireAuth here) +app.use("/api/auth", authRouter); + +// All other API routes require a valid token +app.use("/api", (req, res, next) => { + // Always allow: health check, auth endpoints + if (req.path.startsWith("/health") || req.path.startsWith("/auth")) return next(); + requireAuth(req, res, next); +}); + // Routes app.use("/api/transceivers", transceiverRouter); app.use("/api/switches", switchRouter); diff --git a/packages/api/src/middleware/require-auth.ts b/packages/api/src/middleware/require-auth.ts new file mode 100644 index 0000000..ae10ec6 --- /dev/null +++ b/packages/api/src/middleware/require-auth.ts @@ -0,0 +1,16 @@ +/** + * requireAuth middleware — validates Bearer token on protected routes. + * Skip for: /api/auth/login, /api/health + */ +import { Request, Response, NextFunction } from "express"; +import { verifyToken } from "../routes/auth"; + +export function requireAuth(req: Request, res: Response, next: NextFunction): void { + const auth = req.headers.authorization ?? ""; + const token = auth.startsWith("Bearer ") ? auth.slice(7) : ""; + if (verifyToken(token)) { + next(); + } else { + res.status(401).json({ error: "Unauthorized — please log in" }); + } +} diff --git a/packages/api/src/routes/auth.ts b/packages/api/src/routes/auth.ts new file mode 100644 index 0000000..a016c2c --- /dev/null +++ b/packages/api/src/routes/auth.ts @@ -0,0 +1,72 @@ +/** + * 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" }); + } +}); diff --git a/packages/dashboard/index.html b/packages/dashboard/index.html index 86dca05..78f26fd 100644 --- a/packages/dashboard/index.html +++ b/packages/dashboard/index.html @@ -710,6 +710,24 @@
+ + +