435 lines
14 KiB
JavaScript
435 lines
14 KiB
JavaScript
'use strict';
|
|
|
|
const crypto = require('crypto');
|
|
const express = require('express');
|
|
const Database = require('../storage/database');
|
|
|
|
/**
|
|
* OpenID Connect (authorization-code + PKCE) login for the web dashboard,
|
|
* modeled on theta42/proxy's auth flow.
|
|
*
|
|
* - Config lives in the settings manager under auth.* (SSO endpoints, client
|
|
* credentials, allowed users/groups). auth.enabled=false (default) leaves
|
|
* the dashboard open exactly as before.
|
|
* - Sessions are opaque random tokens in the storage sqlite DB, delivered as
|
|
* an HttpOnly SameSite=Lax cookie so the existing dashboard fetch() calls
|
|
* work unchanged. API clients may instead send the token in an
|
|
* `auth-token` header.
|
|
* - The in-flight OIDC state (PKCE verifier + post-login redirect) is held
|
|
* in memory with a 5-minute TTL — single process, no cleanup job needed.
|
|
*
|
|
* Identity is read from the SSO's userinfo endpoint server-side; ID-token
|
|
* signatures are not verified (same trade-off as the reference impl).
|
|
*/
|
|
|
|
const COOKIE_NAME = 'mcbt_session';
|
|
const STATE_TTL_MS = 5 * 60 * 1000;
|
|
|
|
// ========================================
|
|
// Config
|
|
// ========================================
|
|
|
|
function authConf() {
|
|
const settings = require('../settings/manager');
|
|
return {
|
|
enabled: settings.get('auth.enabled') === true,
|
|
authorizationEndpoint: settings.get('auth.authorizationEndpoint'),
|
|
tokenEndpoint: settings.get('auth.tokenEndpoint'),
|
|
userinfoEndpoint: settings.get('auth.userinfoEndpoint'),
|
|
clientId: settings.get('auth.clientId'),
|
|
clientSecret: settings.get('auth.clientSecret'),
|
|
redirectUri: settings.get('auth.redirectUri'),
|
|
scopes: settings.get('auth.scopes') || ['openid', 'profile', 'email', 'groups'],
|
|
usernameClaim: settings.get('auth.usernameClaim') || 'preferred_username',
|
|
groupsClaim: settings.get('auth.groupsClaim') || 'groups',
|
|
allowedUsers: settings.get('auth.allowedUsers') || [],
|
|
allowedGroups: settings.get('auth.allowedGroups') || [],
|
|
tokenTTL: settings.get('auth.tokenTTL') || 30 * 24 * 3600, // seconds
|
|
};
|
|
}
|
|
|
|
// ========================================
|
|
// Small helpers (ported from theta42/proxy)
|
|
// ========================================
|
|
|
|
const base64url = buf => buf.toString('base64')
|
|
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
|
|
function randomToken(bytes = 32) {
|
|
return base64url(crypto.randomBytes(bytes));
|
|
}
|
|
|
|
function codeChallengeS256(verifier) {
|
|
return base64url(crypto.createHash('sha256').update(verifier).digest());
|
|
}
|
|
|
|
/**
|
|
* Constrain a post-login redirect target to a same-origin path.
|
|
* Rejects absolute URLs, protocol-relative ("//evil.com"), and scheme
|
|
* targets ("javascript:..."). Anything not a plain "/path" becomes "/".
|
|
*/
|
|
function safeInternalPath(path) {
|
|
if (typeof path !== 'string' || path.charAt(0) !== '/'
|
|
|| path.charAt(1) === '/' || path.charAt(1) === '\\') {
|
|
return '/';
|
|
}
|
|
return path;
|
|
}
|
|
|
|
/** Minimal per-IP fixed-window rate limiter (no external dependency). */
|
|
function rateLimiter(max = 60, windowMs = 15 * 60 * 1000) {
|
|
const hits = new Map();
|
|
return (req, res, next) => {
|
|
const now = Date.now();
|
|
const ip = req.ip || req.socket.remoteAddress || 'unknown';
|
|
let rec = hits.get(ip);
|
|
if (!rec || now > rec.reset) {
|
|
rec = { count: 0, reset: now + windowMs };
|
|
hits.set(ip, rec);
|
|
}
|
|
if (++rec.count > max) {
|
|
return res.status(429).json({ error: 'Too many attempts, please try again later.' });
|
|
}
|
|
if (hits.size > 1000) {
|
|
for (const [k, v] of hits) if (now > v.reset) hits.delete(k);
|
|
}
|
|
next();
|
|
};
|
|
}
|
|
|
|
function parseCookies(req) {
|
|
const header = req.headers.cookie;
|
|
if (!header) return {};
|
|
const out = {};
|
|
for (const part of header.split(';')) {
|
|
const idx = part.indexOf('=');
|
|
if (idx === -1) continue;
|
|
out[part.slice(0, idx).trim()] = decodeURIComponent(part.slice(idx + 1).trim());
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function isSecureRequest(req) {
|
|
return req.secure || req.headers['x-forwarded-proto'] === 'https';
|
|
}
|
|
|
|
// ========================================
|
|
// OIDC client
|
|
// ========================================
|
|
|
|
function createAuthRequest() {
|
|
const state = randomToken(32);
|
|
const codeVerifier = randomToken(32);
|
|
return { state, codeVerifier, codeChallenge: codeChallengeS256(codeVerifier) };
|
|
}
|
|
|
|
function buildAuthUrl(state, codeChallenge) {
|
|
const o = authConf();
|
|
const params = new URLSearchParams({
|
|
response_type: 'code',
|
|
client_id: o.clientId,
|
|
redirect_uri: o.redirectUri,
|
|
scope: o.scopes.join(' '),
|
|
state,
|
|
code_challenge: codeChallenge,
|
|
code_challenge_method: 'S256',
|
|
});
|
|
return `${o.authorizationEndpoint}?${params.toString()}`;
|
|
}
|
|
|
|
async function exchangeCode(code, codeVerifier) {
|
|
const o = authConf();
|
|
const body = new URLSearchParams({
|
|
grant_type: 'authorization_code',
|
|
code,
|
|
redirect_uri: o.redirectUri,
|
|
client_id: o.clientId,
|
|
client_secret: o.clientSecret,
|
|
code_verifier: codeVerifier,
|
|
});
|
|
|
|
const res = await fetch(o.tokenEndpoint, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
'Accept': 'application/json',
|
|
},
|
|
body: body.toString(),
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const text = await res.text().catch(() => '');
|
|
throw new Error(`Token exchange failed (${res.status}): ${text.slice(0, 200)}`);
|
|
}
|
|
return res.json();
|
|
}
|
|
|
|
async function fetchUserInfo(accessToken) {
|
|
const o = authConf();
|
|
const res = await fetch(o.userinfoEndpoint, {
|
|
headers: {
|
|
'Authorization': `Bearer ${accessToken}`,
|
|
'Accept': 'application/json',
|
|
},
|
|
});
|
|
if (!res.ok) throw new Error(`Userinfo request failed (${res.status})`);
|
|
return res.json();
|
|
}
|
|
|
|
function claimsToIdentity(claims) {
|
|
const o = authConf();
|
|
const username = claims[o.usernameClaim] || claims.sub;
|
|
let groups = claims[o.groupsClaim] || [];
|
|
if (!Array.isArray(groups)) groups = [groups].filter(Boolean);
|
|
return { username, groups };
|
|
}
|
|
|
|
/** allowedUsers / allowedGroups gate — both empty means any SSO user. */
|
|
function identityAllowed(identity) {
|
|
const o = authConf();
|
|
const users = (o.allowedUsers || []).map(u => String(u).toLowerCase());
|
|
const groups = (o.allowedGroups || []).map(g => String(g).toLowerCase());
|
|
if (users.length === 0 && groups.length === 0) return true;
|
|
if (users.includes(String(identity.username).toLowerCase())) return true;
|
|
return identity.groups.some(g => groups.includes(String(g).toLowerCase()));
|
|
}
|
|
|
|
// ========================================
|
|
// One-time OIDC state store (in-memory, TTL)
|
|
// ========================================
|
|
|
|
const _states = new Map(); // state -> { codeVerifier, redirect, expires }
|
|
|
|
function saveState(state, data) {
|
|
_states.set(state, { ...data, expires: Date.now() + STATE_TTL_MS });
|
|
// Opportunistic sweep of expired/abandoned logins
|
|
for (const [k, v] of _states) if (Date.now() > v.expires) _states.delete(k);
|
|
}
|
|
|
|
/** Consume a state record — one-time use bounds replay. */
|
|
function takeState(state) {
|
|
const rec = _states.get(state);
|
|
if (!rec) return null;
|
|
_states.delete(state);
|
|
if (Date.now() > rec.expires) return null;
|
|
return rec;
|
|
}
|
|
|
|
// ========================================
|
|
// Session token store (sqlite)
|
|
// ========================================
|
|
|
|
let _tableReady = false;
|
|
async function ensureTable() {
|
|
if (_tableReady) return;
|
|
await Database.db.run(`
|
|
CREATE TABLE IF NOT EXISTS auth_tokens (
|
|
token TEXT PRIMARY KEY,
|
|
username TEXT NOT NULL,
|
|
groups TEXT DEFAULT '[]',
|
|
created_at INTEGER NOT NULL,
|
|
expires_at INTEGER NOT NULL
|
|
)
|
|
`);
|
|
_tableReady = true;
|
|
}
|
|
|
|
async function createSession(identity) {
|
|
await ensureTable();
|
|
const token = randomToken(32);
|
|
const now = Date.now();
|
|
await Database.db.run(
|
|
'INSERT INTO auth_tokens (token, username, groups, created_at, expires_at) VALUES (?, ?, ?, ?, ?)',
|
|
[token, identity.username, JSON.stringify(identity.groups || []), now, now + authConf().tokenTTL * 1000]
|
|
);
|
|
// Opportunistic cleanup of expired sessions
|
|
await Database.db.run('DELETE FROM auth_tokens WHERE expires_at < ?', [now]);
|
|
return token;
|
|
}
|
|
|
|
async function checkSession(token) {
|
|
if (!token) return null;
|
|
await ensureTable();
|
|
const row = await Database.db.get('SELECT * FROM auth_tokens WHERE token = ?', [token]);
|
|
if (!row) return null;
|
|
if (row.expires_at < Date.now()) {
|
|
await Database.db.run('DELETE FROM auth_tokens WHERE token = ?', [token]);
|
|
return null;
|
|
}
|
|
return { username: row.username, groups: JSON.parse(row.groups || '[]') };
|
|
}
|
|
|
|
async function destroySession(token) {
|
|
if (!token) return;
|
|
await ensureTable();
|
|
await Database.db.run('DELETE FROM auth_tokens WHERE token = ?', [token]);
|
|
}
|
|
|
|
// ========================================
|
|
// Middleware
|
|
// ========================================
|
|
|
|
function readToken(req) {
|
|
return parseCookies(req)[COOKIE_NAME] || req.header('auth-token') || null;
|
|
}
|
|
|
|
/**
|
|
* Gate every route behind a session when auth.enabled. Browsers get a
|
|
* redirect to the login page; API callers get a 401.
|
|
*/
|
|
async function middleware(req, res, next) {
|
|
try {
|
|
if (!authConf().enabled) return next();
|
|
if (req.path === '/health' || req.path === '/auth' || req.path.startsWith('/auth/')) return next();
|
|
|
|
const session = await checkSession(readToken(req));
|
|
if (session) {
|
|
req.user = session.username;
|
|
req.groups = session.groups;
|
|
return next();
|
|
}
|
|
|
|
if (req.method === 'GET' && req.accepts(['json', 'html']) === 'html') {
|
|
return res.redirect('/auth/login?redirect=' + encodeURIComponent(safeInternalPath(req.originalUrl)));
|
|
}
|
|
return res.status(401).json({ error: 'Authentication required' });
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
}
|
|
|
|
// ========================================
|
|
// Router
|
|
// ========================================
|
|
|
|
function createRouter() {
|
|
const router = express.Router();
|
|
const limiter = rateLimiter(60, 15 * 60 * 1000);
|
|
|
|
router.get('/login', (req, res) => {
|
|
const redirect = safeInternalPath(req.query.redirect || '/');
|
|
const error = req.query.error ? String(req.query.error).slice(0, 200) : null;
|
|
res.send(loginPageHTML(redirect, error));
|
|
});
|
|
|
|
// OIDC login start: create a PKCE + state challenge, stash it, redirect
|
|
// the browser to the SSO authorize endpoint.
|
|
router.get('/oidc/start', limiter, (req, res) => {
|
|
const o = authConf();
|
|
if (!o.enabled) return res.status(404).json({ error: 'Auth is not enabled' });
|
|
if (!o.authorizationEndpoint || !o.clientId) {
|
|
return res.status(500).json({ error: 'OIDC is not configured (auth.authorizationEndpoint / auth.clientId)' });
|
|
}
|
|
|
|
const { state, codeVerifier, codeChallenge } = createAuthRequest();
|
|
saveState(state, {
|
|
codeVerifier,
|
|
// Sanitize now so a hostile ?redirect= can't be stored and later
|
|
// reflected into navigation.
|
|
redirect: safeInternalPath(req.query.redirect || '/'),
|
|
});
|
|
|
|
res.redirect(buildAuthUrl(state, codeChallenge));
|
|
});
|
|
|
|
// OIDC callback: validate + consume state, exchange the code, read
|
|
// identity from userinfo, set the session cookie, redirect into the app.
|
|
router.get('/oidc/callback', limiter, async (req, res) => {
|
|
try {
|
|
const { code, state } = req.query;
|
|
if (!code || !state) throw new Error('Missing code or state');
|
|
|
|
const saved = takeState(String(state));
|
|
if (!saved) throw new Error('Unknown or expired login attempt — try again');
|
|
|
|
const tokens = await exchangeCode(String(code), saved.codeVerifier);
|
|
const claims = await fetchUserInfo(tokens.access_token);
|
|
const identity = claimsToIdentity(claims);
|
|
|
|
if (!identityAllowed(identity)) {
|
|
console.log(`Auth: DENIED login for '${identity.username}' (groups: ${identity.groups.join(', ') || 'none'})`);
|
|
return res.redirect('/auth/login?error=' + encodeURIComponent(`Account '${identity.username}' is not authorized for this dashboard.`));
|
|
}
|
|
|
|
const token = await createSession(identity);
|
|
console.log(`Auth: '${identity.username}' logged in`);
|
|
|
|
const flags = [
|
|
`${COOKIE_NAME}=${encodeURIComponent(token)}`,
|
|
'HttpOnly', 'Path=/', 'SameSite=Lax',
|
|
`Max-Age=${authConf().tokenTTL}`,
|
|
];
|
|
if (isSecureRequest(req)) flags.push('Secure');
|
|
res.setHeader('Set-Cookie', flags.join('; '));
|
|
|
|
res.redirect(safeInternalPath(saved.redirect || '/'));
|
|
} catch (error) {
|
|
console.error('Auth: OIDC callback error:', error.message);
|
|
res.redirect('/auth/login?error=' + encodeURIComponent(error.message));
|
|
}
|
|
});
|
|
|
|
router.all('/logout', async (req, res) => {
|
|
try {
|
|
await destroySession(readToken(req));
|
|
} catch (error) {
|
|
console.error('Auth: logout error:', error.message);
|
|
}
|
|
res.setHeader('Set-Cookie', `${COOKIE_NAME}=; HttpOnly; Path=/; SameSite=Lax; Max-Age=0`);
|
|
if (req.accepts(['json', 'html']) === 'html') return res.redirect('/auth/login');
|
|
res.json({ message: 'Bye' });
|
|
});
|
|
|
|
// Who am I — lets the UI show the logged-in user
|
|
router.get('/me', async (req, res) => {
|
|
if (!authConf().enabled) return res.json({ enabled: false });
|
|
const session = await checkSession(readToken(req));
|
|
if (!session) return res.status(401).json({ enabled: true, error: 'Not logged in' });
|
|
res.json({ enabled: true, username: session.username, groups: session.groups });
|
|
});
|
|
|
|
return router;
|
|
}
|
|
|
|
function escapeHtml(s) {
|
|
return String(s).replace(/&/g, '&').replace(/</g, '<')
|
|
.replace(/>/g, '>').replace(/"/g, '"');
|
|
}
|
|
|
|
function loginPageHTML(redirect, error) {
|
|
return `<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>MC Bot Town — Login</title>
|
|
<style>
|
|
*{box-sizing:border-box;margin:0;padding:0}
|
|
body{font-family:'Segoe UI',Tahoma,sans-serif;background:#111827;color:#e5e7eb;min-height:100vh;display:flex;align-items:center;justify-content:center}
|
|
.card{background:#1f2937;border:1px solid #374151;border-radius:12px;padding:40px;width:360px;text-align:center}
|
|
.card h1{font-size:1.3em;color:#60a5fa;margin-bottom:8px}
|
|
.card p{color:#9ca3af;font-size:.9em;margin-bottom:24px}
|
|
.sso-btn{display:block;width:100%;background:#2563eb;color:#fff;border:none;padding:12px;border-radius:8px;font-size:1em;cursor:pointer;text-decoration:none}
|
|
.sso-btn:hover{background:#1d4ed8}
|
|
.error{background:#7f1d1d;border:1px solid #dc2626;color:#fecaca;padding:10px;border-radius:8px;font-size:.85em;margin-bottom:16px}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="card">
|
|
<h1>MC Bot Town</h1>
|
|
<p>Sign in to manage the bot fleet</p>
|
|
${error ? `<div class="error">${escapeHtml(error)}</div>` : ''}
|
|
<a class="sso-btn" href="/auth/oidc/start?redirect=${encodeURIComponent(redirect)}">Sign in with SSO</a>
|
|
</div>
|
|
</body>
|
|
</html>`;
|
|
}
|
|
|
|
module.exports = {
|
|
middleware,
|
|
createRouter,
|
|
authConf,
|
|
safeInternalPath,
|
|
checkSession,
|
|
};
|