forked from wmantly/mc-bot-town
fable
This commit is contained in:
@@ -0,0 +1,434 @@
|
||||
'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,
|
||||
};
|
||||
Reference in New Issue
Block a user