Files
UNITRONIX 59b3f93d46 fix(oidc): proxy panel callback so RustDesk SSO leaves Waiting (#304)
Panel Redirect URLs on :5000/:5443 used to 404, so auth-query never completed. Proxy the callback to Go, validate the path in Settings, and document the fix.
2026-07-25 08:43:16 +02:00

745 lines
25 KiB
JavaScript

/**
* BetterDesk Console - Auth Routes
* Login, logout, session verification
*/
const express = require('express');
const router = express.Router();
const authService = require('../services/authService');
const db = require('../services/database');
const betterdeskApi = require('../services/betterdeskApi');
const userSync = require('../services/userSync');
let serverAttestation;
try {
serverAttestation = require('../services/serverAttestation');
} catch (err) {
console.warn('[auth] serverAttestation unavailable:', err.message);
serverAttestation = {
getLastResult: async () => null,
buildPublicSummary: () => ({ tier: null, maxConnections: null })
};
}
const { guestOnly, requireAuth } = require('../middleware/auth');
const { clearGuestCookie } = require('../middleware/guestAccess');
const { loginLimiter, passwordChangeLimiter } = require('../middleware/rateLimiter');
/**
* Validate that a return URL is a safe relative path. Mirrors
* auth.IsRelativeReturnURL in the Go server. Rejects absolute URLs,
* protocol-relative URLs (//evil.com/...), CR/LF (response splitting),
* and anything not starting with a single "/".
*/
function isSafeReturnUrl(u) {
if (typeof u !== 'string' || u.length === 0) return false;
if (/[\r\n\x00]/.test(u)) return false;
if (!u.startsWith('/')) return false;
if (u.startsWith('//') || u.startsWith('/\\')) return false;
return true;
}
async function bestEffortUserSync(action, label) {
try {
await action();
} catch (err) {
console.warn(`[auth] ${label} sync failed:`, err.message);
}
}
/**
* GET /login - Login page
* Serves desktop-style login when user previously had desktop mode active
* (detected via localStorage preference or explicit ?desktop=1 query param).
*/
router.get('/login', guestOnly, async (req, res) => {
const useDesktop = req.query.desktop === '1' || req.cookies.betterdesk_desktop_mode === 'true';
let attestationBadge = { tier: null, maxConnections: null };
try {
const last = await serverAttestation.getLastResult();
attestationBadge = serverAttestation.buildPublicSummary(last);
} catch (_) { /* optional */ }
if (useDesktop) {
// Fetch user list for multi-user selector (usernames + roles only, no secrets)
let loginUsers = [];
try {
const users = typeof db.getAllUsersForBackup === 'function'
? await db.getAllUsersForBackup() : [];
loginUsers = (Array.isArray(users) ? users : []).map(u => ({
username: u.username,
role: u.role || 'operator'
}));
} catch (_) { /* empty list is fine */ }
return res.render('desktop-login', {
title: req.t('nav.login'),
activePage: 'login',
loginUsers,
attestationBadge
});
}
res.render('login', {
title: req.t('nav.login'),
activePage: 'login',
attestationBadge
});
});
/**
* POST /api/auth/login - Login API
*/
router.post('/api/auth/login', loginLimiter, async (req, res) => {
try {
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({
success: false,
error: req.t('auth.invalid_credentials')
});
}
// Input length validation to prevent DoS via bcrypt
if (username.length > 128 || password.length > 128) {
return res.status(400).json({
success: false,
error: req.t('auth.invalid_credentials')
});
}
const user = await authService.authenticate(username, password);
if (authService.isAuthFailure(user)) {
authService.recordAttempt(username, req.ip, false);
await db.logAction(null, 'login_failed', `Username collision: ${username}`, req.ip);
if (user.__authFailure === 'username_collision') {
return res.status(409).json({
success: false,
error: req.t('auth.username_collision'),
code: 'username_collision',
});
}
return res.status(401).json({
success: false,
error: req.t('auth.invalid_credentials'),
});
}
if (!user) {
// Record failed attempt (shared brute-force tracker with /api/bd/operator/login)
authService.recordAttempt(username, req.ip, false);
await db.logAction(null, 'login_failed', `Username: ${username}`, req.ip);
return res.status(401).json({
success: false,
error: req.t('auth.invalid_credentials')
});
}
// Clear brute-force lockout on successful auth (shared with operator_login)
authService.recordAttempt(username, req.ip, true);
// Block pro-only accounts from web panel login
if (user.role === 'pro') {
return res.status(403).json({
success: false,
error: req.t('auth.pro_only_account')
});
}
// Check if TOTP verification is required
if (user.totpRequired) {
// Store pending 2FA session
req.session.pendingTotpUserId = user.id;
req.session.pendingTotpUser = user;
// Phase A: store Go partial token for delegated TOTP verification
if (user.goPartialToken) {
req.session.goPartialToken = user.goPartialToken;
}
return res.json({
success: true,
totpRequired: true
});
}
// Regenerate session to prevent session fixation
const oldSession = req.session;
req.session.regenerate(async (err) => {
if (err) {
console.error('Session regeneration error:', err);
return res.status(500).json({ success: false, error: 'Server error' });
}
// Restore session data
req.session.userId = user.id;
req.session.user = {
id: user.id,
username: user.username,
role: user.role
};
// Phase A: store emergency mode flag in session for UI banner
if (user.emergencyMode) {
req.session.emergencyMode = true;
}
// Drop stale guest cookie so Web Remote is not guest-hijacked after login
clearGuestCookie(res);
// Log successful login
await db.logAction(user.id, 'login', `User logged in`, req.ip);
res.json({
success: true,
user: {
username: user.username,
role: user.role
}
});
});
} catch (err) {
console.error('Login error:', err);
res.status(500).json({
success: false,
error: req.t('errors.server_error')
});
}
});
/**
* POST /api/auth/logout - Logout API
*/
router.post('/api/auth/logout', async (req, res) => {
const userId = req.session?.userId;
if (userId) {
await db.logAction(userId, 'logout', 'User logged out', req.ip);
}
req.session.destroy((err) => {
if (err) {
console.error('Session destroy error:', err);
}
res.clearCookie(req.sessionID ? req.session?.cookie?.name : 'betterdesk.sid');
res.clearCookie('betterdesk.sid');
res.clearCookie('bd.sid');
res.json({ success: true });
});
});
/**
* GET /api/auth/verify - Verify session is valid
*/
router.get('/api/auth/verify', requireAuth, (req, res) => {
res.json({
success: true,
user: req.session.user
});
});
/**
* POST /api/auth/password - Change password
*/
router.post('/api/auth/password', requireAuth, passwordChangeLimiter, async (req, res) => {
try {
const { currentPassword, newPassword, confirmPassword } = req.body;
if (!currentPassword || !newPassword) {
return res.status(400).json({
success: false,
error: req.t('auth.password_required')
});
}
if (newPassword !== confirmPassword) {
return res.status(400).json({
success: false,
error: req.t('auth.passwords_mismatch')
});
}
const result = await authService.changePassword(
req.session.userId,
currentPassword,
newPassword
);
if (!result.success) {
return res.status(400).json({
success: false,
error: result.error
});
}
await bestEffortUserSync(
async () => {
const user = req.session.user?.username ? req.session.user : await db.getUserById(req.session.userId);
await userSync.mirrorUpdate(user?.username, { password: newPassword });
},
'password change'
);
// Log password change
await db.logAction(req.session.userId, 'password_changed', 'Password changed', req.ip);
res.json({ success: true });
} catch (err) {
console.error('Password change error:', err);
res.status(500).json({
success: false,
error: req.t('errors.server_error')
});
}
});
/**
* GET /logout - Logout (redirect)
*/
router.get('/logout', (req, res) => {
req.session.destroy(() => {
res.clearCookie('betterdesk.sid');
res.clearCookie('bd.sid');
res.redirect('/login');
});
});
// ==================== OIDC/OAuth2 Routes ====================
const crypto = require('crypto');
const bcrypt = require('bcrypt');
/**
* GET /api/auth/oidc/status - Public endpoint for login page
* Returns whether OIDC is enabled and the display name for the button.
*/
router.get('/api/auth/oidc/status', async (req, res) => {
try {
const result = await betterdeskApi.getOIDCStatus();
if (!result.success) {
return res.json({ enabled: false });
}
res.json(result.data);
} catch (err) {
res.json({ enabled: false });
}
});
/**
* GET /api/auth/oidc/authorize - Redirect browser to OIDC IdP
*
* Go builds state/nonce/PKCE and returns 302 Location to the IdP.
* We resolve that URL server-to-server so the browser is never sent to the
* internal BETTERDESK_API_URL (often http://localhost:21114) — issue #298.
*/
router.get('/api/auth/oidc/authorize', async (req, res) => {
const requested = typeof req.query.return_url === 'string' ? req.query.return_url : '/';
const returnUrl = isSafeReturnUrl(requested) ? requested : '/';
try {
const result = await betterdeskApi.startOIDCAuthorize(returnUrl);
if (!result.success || !result.data?.auth_url) {
return res.redirect('/login?error=oidc_error');
}
return res.redirect(result.data.auth_url);
} catch (err) {
return res.redirect('/login?error=oidc_error');
}
});
/**
* GET /api/auth/oidc/callback and GET /api/oidc/callback
*
* IdP redirect target. Proxies to Go so operators can register the *panel*
* public URL as Redirect URL (common on :5000/:5443) while pending OIDC state
* and RustDesk client completion still live on the Go API (#304).
*/
async function handlePanelOIDCCallback(req, res) {
try {
await betterdeskApi.proxyOIDCCallback(req, res);
} catch (err) {
console.error('[OIDC] panel callback proxy error:', err.message);
if (!res.headersSent) {
res.status(502).type('text/plain').send('OIDC callback proxy failed');
}
}
}
router.get('/api/auth/oidc/callback', handlePanelOIDCCallback);
router.get('/api/oidc/callback', handlePanelOIDCCallback);
/**
* GET /api/auth/oidc/session - Session creation after OIDC callback.
*
* Security model: Go server NEVER passes the JWT or user identity through
* the browser URL bar. Instead the IdP callback handler stores a one-time
* auth code (60s TTL) and redirects here with only `?code=<32 bytes>`.
* Node.js POSTs back to Go's /api/auth/oidc/exchange (server-to-server)
* to retrieve the JWT plus the verified username/role.
*
* This prevents JWT/role leakage via:
* - browser history / Referer headers
* - access logs (Go, Node.js, reverse proxy)
* - role spoofing through URL tampering
*/
router.get('/api/auth/oidc/session', async (req, res) => {
const code = typeof req.query.code === 'string' ? req.query.code : '';
if (!code) {
return res.redirect('/login?error=oidc_invalid');
}
try {
// Server-to-server exchange. Go returns { token, username, role, return_url }
// only for a valid, unconsumed, unexpired code.
const exchange = await betterdeskApi.exchangeOIDCCode(code);
if (!exchange || !exchange.success || !exchange.data || !exchange.data.token || !exchange.data.username) {
console.warn('[OIDC] Exchange rejected by Go server');
return res.redirect('/login?error=oidc_invalid');
}
const { token, username, role, return_url } = exchange.data;
// Defense in depth: re-validate the return URL Go provided.
const safeReturnUrl = isSafeReturnUrl(return_url) ? return_url : '/';
// Look up the local user (may have been auto-provisioned by Go).
let user = await db.getUserByUsername(username);
if (!user) {
// Mirror Go-side auto-provisioning into auth.db so sessions persist
// and middleware/RBAC can resolve the user ID locally.
const randomPass = crypto.randomBytes(32).toString('hex');
const hash = await bcrypt.hash(randomPass, 12);
try {
await db.createUser(username, hash, role || 'viewer', 'oidc');
} catch (err) {
console.error('[OIDC] Failed to auto-provision local user', username, err.message);
}
user = await db.getUserByUsername(username);
} else {
try {
await db.syncUserFromGo(user.id, {
role: role || user.role,
authProvider: 'oidc',
});
user = await db.getUserByUsername(username);
} catch (err) {
console.warn('[OIDC] Failed to sync local user from Go', username, err.message);
}
}
if (!user) {
return res.redirect('/login?error=oidc_error');
}
// Regenerate session to prevent fixation across the authentication boundary.
req.session.regenerate((err) => {
if (err) {
console.error('OIDC session regenerate error:', err);
return res.redirect('/login?error=oidc_error');
}
// Trust the role from the exchange response (server-to-server), not
// the URL. Fall back to the locally stored role only if Go omitted one.
const effectiveRole = role || user.role || 'viewer';
req.session.userId = user.id;
req.session.username = user.username;
req.session.role = effectiveRole;
req.session.user = {
id: user.id,
username: user.username,
role: effectiveRole,
preferred_language: user.preferred_language || null
};
req.session.goToken = token;
req.session.authMethod = 'oidc';
clearGuestCookie(res);
req.session.save((saveErr) => {
if (saveErr) {
console.error('OIDC session save error:', saveErr);
return res.redirect('/login?error=oidc_error');
}
res.redirect(safeReturnUrl);
});
});
} catch (err) {
console.error('OIDC session creation error:', err);
res.redirect('/login?error=oidc_error');
}
});
// ==================== TOTP (2FA) Routes ====================
/**
* POST /api/auth/totp/verify - Verify TOTP code during login
*
* Phase A: If goPartialToken is present in session, delegate verification
* to Go server's POST /api/auth/login/2fa. Otherwise fall back to local
* verification (emergency mode or legacy).
*/
router.post('/api/auth/totp/verify', loginLimiter, async (req, res) => {
try {
const { code, recoveryCode } = req.body;
const pendingUserId = req.session.pendingTotpUserId;
const pendingUser = req.session.pendingTotpUser;
const goPartialToken = req.session.goPartialToken;
if (!pendingUserId || !pendingUser) {
return res.status(400).json({
success: false,
error: req.t('auth.totp_session_expired')
});
}
const totpCode = code || recoveryCode || '';
// --- Phase A: Delegate to Go server when partial token available ---
if (goPartialToken) {
const goResult = await authService.verifyTotpViaGo(goPartialToken, totpCode);
if (!goResult) {
// Go unreachable — don't lock user out, fall through to local
console.warn('[AUTH] Go server unreachable during 2FA — falling back to local verification');
} else if (goResult.rejected) {
if (goResult.rateLimited) {
return res.status(429).json({
success: false,
error: req.t('auth.totp_rate_limited') || 'Too many attempts. Please try again later.'
});
}
await db.logAction(pendingUserId, 'totp_failed', 'Go server rejected TOTP', req.ip);
return res.status(401).json({
success: false,
error: req.t('auth.totp_invalid_code')
});
} else if (goResult.token) {
// Go accepted — create session
return finalizeLoginSession(req, res, pendingUser, 'totp (Go delegated)');
}
}
// --- Local TOTP verification (fallback or emergency mode) ---
let verified = false;
let method = 'totp';
if (recoveryCode) {
verified = await authService.verifyRecoveryCode(pendingUserId, recoveryCode);
method = 'recovery';
} else if (code) {
verified = await authService.verifyTotpCode(pendingUserId, code);
}
if (!verified) {
await db.logAction(pendingUserId, 'totp_failed', `Method: ${method}`, req.ip);
return res.status(401).json({
success: false,
error: req.t('auth.totp_invalid_code')
});
}
return finalizeLoginSession(req, res, pendingUser, `${method} (local)`);
} catch (err) {
console.error('TOTP verify error:', err);
res.status(500).json({
success: false,
error: req.t('errors.server_error')
});
}
});
/**
* Finalize login session after successful 2FA verification.
* Regenerates session, sets user data, logs action.
*/
function finalizeLoginSession(req, res, pendingUser, method) {
return new Promise((resolve) => {
req.session.regenerate(async (regenErr) => {
if (regenErr) {
console.error('TOTP session regeneration error:', regenErr);
res.status(500).json({
success: false,
error: 'Server error'
});
return resolve();
}
req.session.userId = pendingUser.id;
req.session.user = {
id: pendingUser.id,
username: pendingUser.username,
role: pendingUser.role
};
clearGuestCookie(res);
try {
await db.updateLastLogin(pendingUser.id);
await db.logAction(pendingUser.id, 'login', `User logged in (2FA: ${method})`, req.ip);
} catch (logErr) {
console.error('TOTP post-auth bookkeeping error:', logErr);
}
res.json({
success: true,
user: {
username: pendingUser.username,
role: pendingUser.role
}
});
resolve();
});
});
}
/**
* POST /api/auth/totp/setup - Generate TOTP setup (QR code + secret)
*/
router.post('/api/auth/totp/setup', requireAuth, async (req, res) => {
try {
// Check if already enabled
if (await authService.isTotpEnabled(req.session.userId)) {
return res.status(400).json({
success: false,
error: req.t('auth.totp_already_enabled')
});
}
const result = await authService.generateTotpSetup(req.session.userId);
if (!result.success) {
return res.status(400).json({
success: false,
error: result.error
});
}
res.json({
success: true,
qrCode: result.qrCode,
secret: result.secret,
otpauthUrl: result.otpauthUrl
});
} catch (err) {
console.error('TOTP setup error:', err);
res.status(500).json({
success: false,
error: req.t('errors.server_error')
});
}
});
/**
* POST /api/auth/totp/enable - Verify code and enable TOTP
*/
router.post('/api/auth/totp/enable', requireAuth, async (req, res) => {
try {
const { code } = req.body;
if (!code || code.length !== 6) {
return res.status(400).json({
success: false,
error: req.t('auth.totp_invalid_code')
});
}
const result = await authService.verifyAndEnableTotp(req.session.userId, code);
if (!result.success) {
return res.status(400).json({
success: false,
error: result.error
});
}
await bestEffortUserSync(
async () => {
const user = await db.getUserById(req.session.userId);
await userSync.mirrorTotpEnable(user?.username, { secret: user?.totp_secret });
},
'TOTP enable'
);
// Log action
await db.logAction(req.session.userId, 'totp_enabled', '2FA enabled', req.ip);
res.json({
success: true,
recoveryCodes: result.recoveryCodes
});
} catch (err) {
console.error('TOTP enable error:', err);
res.status(500).json({
success: false,
error: req.t('errors.server_error')
});
}
});
/**
* POST /api/auth/totp/disable - Disable TOTP
*/
router.post('/api/auth/totp/disable', requireAuth, async (req, res) => {
try {
const { password } = req.body;
if (!password) {
return res.status(400).json({
success: false,
error: req.t('auth.password_required')
});
}
// Verify password before disabling (supports both bcrypt and PBKDF2 hashes)
const user = await db.getUserById(req.session.userId);
if (!user) {
return res.status(404).json({
success: false,
error: req.t('users.not_found')
});
}
const valid = await authService.verifyPassword(password, user.password_hash);
if (!valid) {
return res.status(401).json({
success: false,
error: req.t('auth.invalid_credentials')
});
}
await authService.disableTotp(req.session.userId);
await bestEffortUserSync(
() => userSync.mirrorTotpDisable(user.username),
'TOTP disable'
);
// Log action
await db.logAction(req.session.userId, 'totp_disabled', '2FA disabled', req.ip);
res.json({ success: true });
} catch (err) {
console.error('TOTP disable error:', err);
res.status(500).json({
success: false,
error: req.t('errors.server_error')
});
}
});
/**
* GET /api/auth/totp/status - Check if TOTP is enabled for current user
*/
router.get('/api/auth/totp/status', requireAuth, async (req, res) => {
try {
const enabled = await authService.isTotpEnabled(req.session.userId);
res.json({ success: true, enabled });
} catch (err) {
console.error('TOTP status error:', err);
res.status(500).json({ success: false, error: req.t('errors.server_error') });
}
});
module.exports = router;