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:
parent
dba4c80e2f
commit
a066300cf2
@ -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);
|
||||
|
||||
16
packages/api/src/middleware/require-auth.ts
Normal file
16
packages/api/src/middleware/require-auth.ts
Normal 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" });
|
||||
}
|
||||
}
|
||||
72
packages/api/src/routes/auth.ts
Normal file
72
packages/api/src/routes/auth.ts
Normal 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" });
|
||||
}
|
||||
});
|
||||
@ -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);
|
||||
|
||||
367
packages/dashboard/login.html
Normal file
367
packages/dashboard/login.html
Normal 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>
|
||||
Loading…
x
Reference in New Issue
Block a user