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
This commit is contained in:
Rene Fichtmueller 2026-04-02 07:31:15 +02:00
parent dba4c80e2f
commit a066300cf2
5 changed files with 499 additions and 1 deletions

View File

@ -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);

View File

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

View File

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

View File

@ -710,6 +710,24 @@
</head>
<body>
<!-- Auth guard — redirect to login if no valid token -->
<script>
(function() {
var token = localStorage.getItem('tip_token');
if (!token) { window.location.replace('/dashboard/login.html'); return; }
fetch('/api/auth/verify', { headers: { Authorization: 'Bearer ' + token } })
.then(function(r) {
if (!r.ok) {
localStorage.removeItem('tip_token');
window.location.replace('/dashboard/login.html');
}
})
.catch(function() {
// Network error — stay on page (API might be loading)
});
})();
</script>
<div class="app">
<div id="toast" class="toast"><div class="toast-title"></div><div class="toast-body"></div></div>
@ -1071,6 +1089,18 @@
<script>
var API = window.location.origin;
// Auth helpers
function getAuthHeaders() {
var token = localStorage.getItem('tip_token');
return token ? { 'Authorization': 'Bearer ' + token } : {};
}
function handleAuthError(status) {
if (status === 401) {
localStorage.removeItem('tip_token');
window.location.replace('/dashboard/login.html');
}
}
function esc(str) {
if (str == null) return '';
var d = document.createElement('div');
@ -1081,7 +1111,8 @@ function esc(str) {
function el(id) { return document.getElementById(id); }
function api(path) {
return fetch(API + path).then(function(r) {
return fetch(API + path, { headers: getAuthHeaders() }).then(function(r) {
if (r.status === 401) { handleAuthError(401); throw new Error('Unauthorized'); }
var ct = r.headers.get('content-type') || '';
if (ct.indexOf('application/json') === -1) {
if (!r.ok) throw new Error('HTTP ' + r.status);

View File

@ -0,0 +1,367 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>TIP — Login</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<style>
:root {
--bg: #0d0d0d;
--surface: #161616;
--surface2: #1f1f1f;
--border: #2a2a2a;
--text: #e0e0e0;
--text-dim: #666;
--accent: #FF8100;
--accent-dark: #e07000;
--accent-glow: rgba(255,129,0,0.15);
--red: #c1121f;
--red-light: rgba(193,18,31,0.12);
--mono: 'JetBrains Mono', monospace;
--font: 'DM Sans', system-ui, sans-serif;
--radius: 10px;
}
* { margin:0; padding:0; box-sizing:border-box; }
body {
font-family: var(--font);
background: var(--bg);
color: var(--text);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
}
/* Subtle grid background */
body::before {
content: '';
position: fixed;
inset: 0;
background-image:
linear-gradient(rgba(255,129,0,0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,129,0,0.03) 1px, transparent 1px);
background-size: 40px 40px;
pointer-events: none;
}
/* Glow orb */
body::after {
content: '';
position: fixed;
top: -20%;
left: 50%;
transform: translateX(-50%);
width: 600px;
height: 600px;
background: radial-gradient(circle, rgba(255,129,0,0.08) 0%, transparent 70%);
pointer-events: none;
}
.card {
position: relative;
z-index: 1;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 16px;
padding: 2.5rem;
width: 100%;
max-width: 400px;
box-shadow: 0 24px 64px rgba(0,0,0,0.5), 0 0 0 1px rgba(255,129,0,0.05);
}
.logo-row {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 2rem;
}
.logo-mark {
width: 40px; height: 40px;
border-radius: 10px;
background: var(--accent);
display: flex; align-items: center; justify-content: center;
font-weight: 800; font-size: 1rem; color: #fff;
letter-spacing: -0.02em;
box-shadow: 0 4px 16px rgba(255,129,0,0.3);
}
.logo-text {
font-size: 1.1rem;
font-weight: 700;
color: #fff;
letter-spacing: -0.02em;
}
.logo-sub {
font-size: 0.72rem;
color: var(--text-dim);
font-family: var(--mono);
margin-top: 1px;
}
h1 {
font-size: 1.5rem;
font-weight: 700;
color: #fff;
letter-spacing: -0.03em;
margin-bottom: 0.4rem;
}
.subtitle {
font-size: 0.82rem;
color: var(--text-dim);
margin-bottom: 2rem;
}
.field {
margin-bottom: 1.25rem;
}
label {
display: block;
font-size: 0.78rem;
font-weight: 600;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: 0.5rem;
}
.input-wrap {
position: relative;
}
input[type="password"] {
width: 100%;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 0.75rem 2.75rem 0.75rem 0.9rem;
font-family: var(--mono);
font-size: 0.9rem;
color: var(--text);
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
letter-spacing: 0.1em;
}
input[type="password"]:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-glow);
}
input[type="password"].error {
border-color: var(--red);
box-shadow: 0 0 0 3px var(--red-light);
}
input[type="password"]::placeholder {
letter-spacing: 0.05em;
color: #444;
}
.toggle-pw {
position: absolute;
right: 0.75rem;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: var(--text-dim);
cursor: pointer;
padding: 0;
font-size: 1rem;
line-height: 1;
transition: color 0.2s;
}
.toggle-pw:hover { color: var(--text); }
.error-msg {
display: none;
align-items: center;
gap: 0.4rem;
background: var(--red-light);
border: 1px solid rgba(193,18,31,0.3);
border-radius: var(--radius);
padding: 0.6rem 0.85rem;
font-size: 0.8rem;
color: #f87171;
margin-bottom: 1.25rem;
}
.error-msg.show { display: flex; }
button[type="submit"] {
width: 100%;
background: var(--accent);
color: #fff;
border: none;
border-radius: var(--radius);
padding: 0.85rem 1rem;
font-family: var(--font);
font-size: 0.9rem;
font-weight: 700;
cursor: pointer;
transition: background 0.2s, box-shadow 0.2s, transform 0.1s;
letter-spacing: -0.01em;
}
button[type="submit"]:hover {
background: var(--accent-dark);
box-shadow: 0 4px 16px rgba(255,129,0,0.25);
}
button[type="submit"]:active { transform: scale(0.98); }
button[type="submit"]:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.spinner {
display: none;
width: 16px; height: 16px;
border: 2px solid rgba(255,255,255,0.3);
border-top-color: #fff;
border-radius: 50%;
animation: spin 0.7s linear infinite;
margin: 0 auto;
}
@keyframes spin { to { transform: rotate(360deg); } }
.footer-note {
margin-top: 1.75rem;
text-align: center;
font-size: 0.72rem;
color: var(--text-dim);
font-family: var(--mono);
}
.footer-note span {
color: var(--accent);
opacity: 0.7;
}
</style>
</head>
<body>
<div class="card">
<div class="logo-row">
<div class="logo-mark">TIP</div>
<div>
<div class="logo-text">Transceiver Intelligence</div>
<div class="logo-sub">Platform · v0.3</div>
</div>
</div>
<h1>Sign in</h1>
<p class="subtitle">Enter your access password to continue.</p>
<div id="errorBox" class="error-msg">
<span></span>
<span id="errorText">Invalid password. Please try again.</span>
</div>
<form id="loginForm" autocomplete="off">
<div class="field">
<label for="password">Password</label>
<div class="input-wrap">
<input
id="password"
type="password"
placeholder="••••••••••••"
autofocus
autocomplete="current-password"
/>
<button type="button" class="toggle-pw" id="togglePw" title="Show / hide password">
<span id="eyeIcon">👁</span>
</button>
</div>
</div>
<button type="submit" id="submitBtn">
<span id="btnLabel">Sign in</span>
<div class="spinner" id="spinner"></div>
</button>
</form>
<div class="footer-note">
<span>Flexoptix</span> · Internal use only
</div>
</div>
<script>
var API = '/api/auth/login';
var REDIRECT = '/dashboard/';
// If already logged in → skip straight to dashboard
(function() {
var t = localStorage.getItem('tip_token');
if (!t) return;
fetch('/api/auth/verify', { headers: { Authorization: 'Bearer ' + t } })
.then(function(r) { if (r.ok) window.location.replace(REDIRECT); })
.catch(function() {});
})();
var form = document.getElementById('loginForm');
var pwInput = document.getElementById('password');
var errBox = document.getElementById('errorBox');
var errText = document.getElementById('errorText');
var btn = document.getElementById('submitBtn');
var lbl = document.getElementById('btnLabel');
var spin = document.getElementById('spinner');
var toggle = document.getElementById('togglePw');
var eye = document.getElementById('eyeIcon');
// Toggle password visibility
toggle.addEventListener('click', function() {
var isText = pwInput.type === 'text';
pwInput.type = isText ? 'password' : 'text';
eye.textContent = isText ? '👁' : '🙈';
});
// Clear error on typing
pwInput.addEventListener('input', function() {
pwInput.classList.remove('error');
errBox.classList.remove('show');
});
form.addEventListener('submit', async function(e) {
e.preventDefault();
var pw = pwInput.value;
if (!pw) { showError('Please enter your password.'); return; }
// Loading state
btn.disabled = true;
lbl.style.display = 'none';
spin.style.display = 'block';
errBox.classList.remove('show');
try {
var res = await fetch(API, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: pw })
});
var data = await res.json();
if (res.ok && data.token) {
localStorage.setItem('tip_token', data.token);
window.location.replace(REDIRECT);
} else {
showError(data.error || 'Invalid password. Please try again.');
}
} catch (err) {
showError('Network error — please try again.');
} finally {
btn.disabled = false;
lbl.style.display = '';
spin.style.display = 'none';
}
});
function showError(msg) {
errText.textContent = msg;
errBox.classList.add('show');
pwInput.classList.add('error');
pwInput.focus();
}
// Enter key on input
pwInput.addEventListener('keydown', function(e) {
if (e.key === 'Enter') form.dispatchEvent(new Event('submit'));
});
</script>
</body>
</html>