mirror of
https://github.com/ancsemi/Haven
synced 2026-04-21 13:37:41 +00:00
1315 lines
52 KiB
JavaScript
1315 lines
52 KiB
JavaScript
const express = require('express');
|
|
const bcrypt = require('bcryptjs');
|
|
const jwt = require('jsonwebtoken');
|
|
const crypto = require('crypto');
|
|
const { getDb } = require('./database');
|
|
const OTPAuth = require('otpauth');
|
|
const QRCode = require('qrcode');
|
|
const https = require('https');
|
|
const http = require('http');
|
|
|
|
const router = express.Router();
|
|
const JWT_SECRET = process.env.JWT_SECRET;
|
|
if (!JWT_SECRET) {
|
|
console.error('FATAL: JWT_SECRET is not set. Check your .env file or let server.js auto-generate it.');
|
|
process.exit(1);
|
|
}
|
|
const ADMIN_USERNAME = (process.env.ADMIN_USERNAME || 'admin').toLowerCase();
|
|
|
|
// ── TOTP helpers ─────────────────────────────────────────
|
|
// Short-lived tokens for the TOTP verification step (not full session tokens)
|
|
function generateTotpChallengeToken(userId) {
|
|
return jwt.sign({ id: userId, purpose: 'totp_challenge' }, JWT_SECRET, { expiresIn: '5m' });
|
|
}
|
|
|
|
function verifyTotpChallengeToken(token) {
|
|
try {
|
|
const decoded = jwt.verify(token, JWT_SECRET);
|
|
if (decoded.purpose !== 'totp_challenge') return null;
|
|
return decoded;
|
|
} catch { return null; }
|
|
}
|
|
|
|
function generateBackupCodes(count = 8) {
|
|
const codes = [];
|
|
for (let i = 0; i < count; i++) {
|
|
// 8-char alphanumeric codes, grouped as XXXX-XXXX for readability
|
|
const raw = crypto.randomBytes(4).toString('hex').toUpperCase();
|
|
codes.push(raw.slice(0, 4) + '-' + raw.slice(4));
|
|
}
|
|
return codes;
|
|
}
|
|
|
|
// ── Rate Limiting (in-memory, no extra deps) ────────────
|
|
const rateLimitStore = new Map();
|
|
|
|
function authLimiter(req, res, next) {
|
|
const ip = req.ip || req.socket.remoteAddress;
|
|
const now = Date.now();
|
|
const windowMs = 15 * 60 * 1000; // 15 minutes
|
|
const maxAttempts = 20; // 20 auth requests per 15 min per IP
|
|
|
|
if (!rateLimitStore.has(ip)) {
|
|
rateLimitStore.set(ip, []);
|
|
}
|
|
|
|
const timestamps = rateLimitStore.get(ip).filter(t => now - t < windowMs);
|
|
rateLimitStore.set(ip, timestamps);
|
|
|
|
if (timestamps.length >= maxAttempts) {
|
|
return res.status(429).json({
|
|
error: 'Too many attempts. Try again in a few minutes.'
|
|
});
|
|
}
|
|
|
|
timestamps.push(now);
|
|
next();
|
|
}
|
|
|
|
// Clean up stale entries every 30 minutes
|
|
setInterval(() => {
|
|
const now = Date.now();
|
|
const windowMs = 15 * 60 * 1000;
|
|
for (const [ip, timestamps] of rateLimitStore) {
|
|
const fresh = timestamps.filter(t => now - t < windowMs);
|
|
if (fresh.length === 0) rateLimitStore.delete(ip);
|
|
else rateLimitStore.set(ip, fresh);
|
|
}
|
|
}, 30 * 60 * 1000);
|
|
|
|
// ── Input Sanitization ──────────────────────────────────
|
|
function sanitizeString(str, maxLen = 200) {
|
|
if (typeof str !== 'string') return '';
|
|
return str.trim().slice(0, maxLen);
|
|
}
|
|
|
|
// ── SSO Avatar Download ─────────────────────────────────
|
|
// Downloads a profile picture from a remote Haven server and saves it locally.
|
|
// Returns the local /uploads/ path, or throws on failure.
|
|
function downloadSSOAvatar(url) {
|
|
return new Promise((resolve, reject) => {
|
|
// Validate URL
|
|
let parsed;
|
|
try {
|
|
parsed = new URL(url);
|
|
} catch {
|
|
return reject(new Error('Invalid URL'));
|
|
}
|
|
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
|
return reject(new Error('Invalid protocol'));
|
|
}
|
|
|
|
const fetcher = parsed.protocol === 'https:' ? https : http;
|
|
const request = fetcher.get(url, { timeout: 10000 }, (res) => {
|
|
if (res.statusCode !== 200) {
|
|
res.resume();
|
|
return reject(new Error(`HTTP ${res.statusCode}`));
|
|
}
|
|
|
|
const contentType = (res.headers['content-type'] || '').toLowerCase();
|
|
const validTypes = { 'image/jpeg': '.jpg', 'image/png': '.png', 'image/gif': '.gif', 'image/webp': '.webp' };
|
|
const ext = validTypes[contentType.split(';')[0].trim()];
|
|
if (!ext) {
|
|
res.resume();
|
|
return reject(new Error('Not a supported image type'));
|
|
}
|
|
|
|
// Limit to 2 MB
|
|
let size = 0;
|
|
const maxSize = 2 * 1024 * 1024;
|
|
const chunks = [];
|
|
|
|
res.on('data', (chunk) => {
|
|
size += chunk.length;
|
|
if (size > maxSize) {
|
|
res.destroy();
|
|
return reject(new Error('Image too large'));
|
|
}
|
|
chunks.push(chunk);
|
|
});
|
|
|
|
res.on('end', () => {
|
|
try {
|
|
const buffer = Buffer.concat(chunks);
|
|
|
|
// Validate magic bytes
|
|
let validMagic = false;
|
|
if (ext === '.jpg') validMagic = buffer[0] === 0xFF && buffer[1] === 0xD8 && buffer[2] === 0xFF;
|
|
else if (ext === '.png') validMagic = buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4E && buffer[3] === 0x47;
|
|
else if (ext === '.gif') validMagic = buffer.slice(0, 6).toString().startsWith('GIF8');
|
|
else if (ext === '.webp') validMagic = buffer.slice(0, 4).toString() === 'RIFF' && buffer.slice(8, 12).toString() === 'WEBP';
|
|
if (!validMagic) return reject(new Error('File content does not match image type'));
|
|
|
|
const filename = Date.now() + crypto.randomBytes(8).toString('hex') + ext;
|
|
const { UPLOADS_DIR } = require('./paths');
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
const filePath = path.join(UPLOADS_DIR, filename);
|
|
fs.writeFileSync(filePath, buffer);
|
|
resolve(`/uploads/${filename}`);
|
|
} catch (err) {
|
|
reject(err);
|
|
}
|
|
});
|
|
|
|
res.on('error', reject);
|
|
});
|
|
|
|
request.on('error', reject);
|
|
request.on('timeout', () => {
|
|
request.destroy();
|
|
reject(new Error('Download timed out'));
|
|
});
|
|
});
|
|
}
|
|
|
|
// ── Register ──────────────────────────────────────────────
|
|
router.post('/register', async (req, res) => {
|
|
try {
|
|
const username = sanitizeString(req.body.username, 20);
|
|
const password = typeof req.body.password === 'string' ? req.body.password : '';
|
|
const eulaVersion = typeof req.body.eulaVersion === 'string' ? req.body.eulaVersion.trim() : '';
|
|
const ageVerified = req.body.ageVerified === true;
|
|
|
|
if (!username || !password) {
|
|
return res.status(400).json({ error: 'Username and password required' });
|
|
}
|
|
if (!eulaVersion) {
|
|
return res.status(400).json({ error: 'You must accept the Terms of Service & Release of Liability Agreement' });
|
|
}
|
|
if (!ageVerified) {
|
|
return res.status(400).json({ error: 'You must confirm that you are 18 years of age or older' });
|
|
}
|
|
|
|
if (!username || !password) {
|
|
return res.status(400).json({ error: 'Username and password required' });
|
|
}
|
|
if (username.length < 3 || username.length > 20) {
|
|
return res.status(400).json({ error: 'Username must be 3-20 characters' });
|
|
}
|
|
if (password.length < 8 || password.length > 128) {
|
|
return res.status(400).json({ error: 'Password must be 8-128 characters' });
|
|
}
|
|
if (!/^[a-zA-Z0-9_]+$/.test(username)) {
|
|
return res.status(400).json({ error: 'Username: letters, numbers, underscores only' });
|
|
}
|
|
|
|
const db = getDb();
|
|
|
|
// Whitelist check — if enabled, only pre-approved usernames can register
|
|
const wlSetting = db.prepare("SELECT value FROM server_settings WHERE key = 'whitelist_enabled'").get();
|
|
if (wlSetting && wlSetting.value === 'true') {
|
|
const onList = db.prepare('SELECT 1 FROM whitelist WHERE username = ?').get(username);
|
|
if (!onList) {
|
|
return res.status(403).json({ error: 'Registration is restricted. Your username is not on the whitelist.' });
|
|
}
|
|
}
|
|
|
|
const existing = db.prepare('SELECT id FROM users WHERE LOWER(username) = LOWER(?)').get(username);
|
|
if (existing) {
|
|
return res.status(400).json({ error: 'Registration could not be completed' });
|
|
}
|
|
|
|
const hash = await bcrypt.hash(password, 12);
|
|
const isAdmin = username.toLowerCase() === ADMIN_USERNAME ? 1 : 0;
|
|
|
|
// SSO profile picture: download from home server if provided
|
|
const ssoProfilePicture = typeof req.body.ssoProfilePicture === 'string' ? req.body.ssoProfilePicture.trim().slice(0, 500) : null;
|
|
let avatarPath = null;
|
|
if (ssoProfilePicture) {
|
|
try {
|
|
avatarPath = await downloadSSOAvatar(ssoProfilePicture);
|
|
} catch (err) {
|
|
console.warn('[SSO] Avatar download failed:', err.message);
|
|
// Non-fatal — proceed without avatar
|
|
}
|
|
}
|
|
|
|
const result = db.prepare(
|
|
'INSERT INTO users (username, password_hash, is_admin, avatar) VALUES (?, ?, ?, ?)'
|
|
).run(username, hash, isAdmin, avatarPath);
|
|
|
|
// Auto-assign roles flagged as auto_assign to new users
|
|
try {
|
|
const autoRoles = db.prepare("SELECT id FROM roles WHERE auto_assign = 1 AND scope = 'server'").all();
|
|
const insertRole = db.prepare('INSERT OR IGNORE INTO user_roles (user_id, role_id, channel_id, granted_by) VALUES (?, ?, NULL, NULL)');
|
|
for (const role of autoRoles) {
|
|
insertRole.run(result.lastInsertRowid, role.id);
|
|
// Grant linked channel access for this role (fixes #79)
|
|
try {
|
|
const r = db.prepare('SELECT link_channel_access FROM roles WHERE id = ?').get(role.id);
|
|
if (r && r.link_channel_access) {
|
|
const grantChannels = db.prepare(
|
|
'SELECT channel_id FROM role_channel_access WHERE role_id = ? AND grant_on_promote = 1'
|
|
).all(role.id);
|
|
const ins = db.prepare('INSERT OR IGNORE INTO channel_members (channel_id, user_id) VALUES (?, ?)');
|
|
for (const ch of grantChannels) ins.run(ch.channel_id, result.lastInsertRowid);
|
|
}
|
|
} catch { /* non-critical */ }
|
|
}
|
|
} catch { /* non-critical */ }
|
|
|
|
const token = jwt.sign(
|
|
{ id: result.lastInsertRowid, username, isAdmin: !!isAdmin, displayName: username, pwv: 1 },
|
|
JWT_SECRET,
|
|
{ expiresIn: '7d' }
|
|
);
|
|
|
|
// Record EULA acceptance
|
|
if (eulaVersion) {
|
|
try {
|
|
db.prepare(
|
|
'INSERT OR IGNORE INTO eula_acceptances (user_id, version, ip_address, age_verified) VALUES (?, ?, ?, ?)'
|
|
).run(result.lastInsertRowid, eulaVersion, req.ip || req.socket.remoteAddress || '', ageVerified ? 1 : 0);
|
|
} catch { /* non-critical */ }
|
|
}
|
|
|
|
res.json({
|
|
token,
|
|
user: { id: result.lastInsertRowid, username, isAdmin: !!isAdmin, displayName: username }
|
|
});
|
|
} catch (err) {
|
|
console.error('Register error:', err);
|
|
res.status(500).json({ error: 'Server error' });
|
|
}
|
|
});
|
|
|
|
// ── Login ─────────────────────────────────────────────────
|
|
router.post('/login', async (req, res) => {
|
|
try {
|
|
const username = sanitizeString(req.body.username, 20);
|
|
const password = typeof req.body.password === 'string' ? req.body.password : '';
|
|
const eulaVersion = typeof req.body.eulaVersion === 'string' ? req.body.eulaVersion.trim() : '';
|
|
const ageVerified = req.body.ageVerified === true;
|
|
|
|
if (!username || !password) {
|
|
return res.status(400).json({ error: 'Username and password required' });
|
|
}
|
|
if (!eulaVersion) {
|
|
return res.status(400).json({ error: 'You must accept the Terms of Service & Release of Liability Agreement' });
|
|
}
|
|
if (!ageVerified) {
|
|
return res.status(400).json({ error: 'You must confirm that you are 18 years of age or older' });
|
|
}
|
|
|
|
const db = getDb();
|
|
const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username);
|
|
|
|
if (!user) {
|
|
return res.status(401).json({ error: 'Invalid credentials' });
|
|
}
|
|
|
|
// Check if user is banned
|
|
const ban = db.prepare('SELECT reason FROM bans WHERE user_id = ?').get(user.id);
|
|
if (ban) {
|
|
return res.status(403).json({ error: 'You have been banned from this server' });
|
|
}
|
|
|
|
const valid = await bcrypt.compare(password, user.password_hash);
|
|
if (!valid) {
|
|
return res.status(401).json({ error: 'Invalid credentials' });
|
|
}
|
|
|
|
// Bootstrap admin from ADMIN_USERNAME env only when NO admin exists
|
|
// (first run or recovery). Prevents overriding explicit admin transfers.
|
|
const anyAdmin = db.prepare('SELECT id FROM users WHERE is_admin = 1 LIMIT 1').get();
|
|
if (!anyAdmin && user.username.toLowerCase() === ADMIN_USERNAME && !user.is_admin) {
|
|
db.prepare('UPDATE users SET is_admin = 1 WHERE id = ?').run(user.id);
|
|
user.is_admin = 1;
|
|
}
|
|
|
|
const displayName = user.display_name || user.username;
|
|
|
|
// ── TOTP check: if enabled, require a second step ──
|
|
if (user.totp_enabled) {
|
|
const challengeToken = generateTotpChallengeToken(user.id);
|
|
return res.json({ requiresTOTP: true, challengeToken });
|
|
}
|
|
|
|
const token = jwt.sign(
|
|
{ id: user.id, username: user.username, isAdmin: !!user.is_admin, displayName, pwv: user.password_version || 1 },
|
|
JWT_SECRET,
|
|
{ expiresIn: '7d' }
|
|
);
|
|
|
|
// Record EULA acceptance
|
|
if (eulaVersion) {
|
|
try {
|
|
db.prepare(
|
|
'INSERT OR IGNORE INTO eula_acceptances (user_id, version, ip_address, age_verified) VALUES (?, ?, ?, ?)'
|
|
).run(user.id, eulaVersion, req.ip || req.socket.remoteAddress || '', ageVerified ? 1 : 0);
|
|
} catch { /* non-critical */ }
|
|
}
|
|
|
|
res.json({
|
|
token,
|
|
user: { id: user.id, username: user.username, isAdmin: !!user.is_admin, displayName }
|
|
});
|
|
} catch (err) {
|
|
console.error('Login error:', err);
|
|
res.status(500).json({ error: 'Server error' });
|
|
}
|
|
});
|
|
|
|
// ── Validate token (lightweight, for SSO consent page) ───
|
|
router.get('/validate', (req, res) => {
|
|
const token = req.headers.authorization?.split(' ')[1];
|
|
const decoded = token ? verifyToken(token) : null;
|
|
if (!decoded) return res.status(401).json({ error: 'Invalid token' });
|
|
|
|
const db = getDb();
|
|
const user = db.prepare('SELECT username, display_name, avatar FROM users WHERE id = ?').get(decoded.id);
|
|
if (!user) return res.status(404).json({ error: 'User not found' });
|
|
|
|
res.json({
|
|
username: user.username,
|
|
displayName: user.display_name || user.username,
|
|
avatar: user.avatar || null
|
|
});
|
|
});
|
|
|
|
// ── TOTP Validate (second step of login) ─────────────────
|
|
router.post('/totp/validate', async (req, res) => {
|
|
try {
|
|
const challengeToken = typeof req.body.challengeToken === 'string' ? req.body.challengeToken : '';
|
|
const code = typeof req.body.code === 'string' ? req.body.code.replace(/\s/g, '') : '';
|
|
|
|
if (!challengeToken || !code) {
|
|
return res.status(400).json({ error: 'Challenge token and code required' });
|
|
}
|
|
|
|
const challenge = verifyTotpChallengeToken(challengeToken);
|
|
if (!challenge) {
|
|
return res.status(401).json({ error: 'Invalid or expired challenge. Please log in again.' });
|
|
}
|
|
|
|
const db = getDb();
|
|
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(challenge.id);
|
|
if (!user || !user.totp_enabled || !user.totp_secret) {
|
|
return res.status(401).json({ error: 'Invalid credentials' });
|
|
}
|
|
|
|
// Try TOTP code first
|
|
const totp = new OTPAuth.TOTP({
|
|
issuer: 'Haven',
|
|
label: user.username,
|
|
algorithm: 'SHA1',
|
|
digits: 6,
|
|
period: 30,
|
|
secret: OTPAuth.Secret.fromBase32(user.totp_secret)
|
|
});
|
|
|
|
const delta = totp.validate({ token: code, window: 1 });
|
|
let valid = delta !== null;
|
|
|
|
// If not a valid TOTP code, check backup codes
|
|
if (!valid) {
|
|
const normalizedCode = code.toUpperCase().replace(/-/g, '');
|
|
const backupCodes = db.prepare(
|
|
'SELECT id, code_hash FROM totp_backup_codes WHERE user_id = ? AND used = 0'
|
|
).all(user.id);
|
|
|
|
for (const bc of backupCodes) {
|
|
// Compare hashed backup code
|
|
const codeToCheck = normalizedCode.slice(0, 4) + '-' + normalizedCode.slice(4);
|
|
if (crypto.timingSafeEqual(
|
|
Buffer.from(bc.code_hash, 'hex'),
|
|
Buffer.from(crypto.createHash('sha256').update(codeToCheck).digest('hex'), 'hex')
|
|
)) {
|
|
// Mark backup code as used
|
|
db.prepare('UPDATE totp_backup_codes SET used = 1 WHERE id = ?').run(bc.id);
|
|
valid = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!valid) {
|
|
return res.status(401).json({ error: 'Invalid code' });
|
|
}
|
|
|
|
const displayName = user.display_name || user.username;
|
|
const token = jwt.sign(
|
|
{ id: user.id, username: user.username, isAdmin: !!user.is_admin, displayName, pwv: user.password_version || 1 },
|
|
JWT_SECRET,
|
|
{ expiresIn: '7d' }
|
|
);
|
|
|
|
res.json({
|
|
token,
|
|
user: { id: user.id, username: user.username, isAdmin: !!user.is_admin, displayName }
|
|
});
|
|
} catch (err) {
|
|
console.error('TOTP validate error:', err);
|
|
res.status(500).json({ error: 'Server error' });
|
|
}
|
|
});
|
|
|
|
// ── TOTP Setup (generate secret + QR) ────────────────────
|
|
router.post('/totp/setup', async (req, res) => {
|
|
try {
|
|
const token = req.headers.authorization?.split(' ')[1];
|
|
if (!token) return res.status(401).json({ error: 'Unauthorized' });
|
|
const decoded = verifyToken(token);
|
|
if (!decoded) return res.status(401).json({ error: 'Unauthorized' });
|
|
|
|
const db = getDb();
|
|
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(decoded.id);
|
|
if (!user) return res.status(404).json({ error: 'User not found' });
|
|
|
|
if (user.totp_enabled) {
|
|
return res.status(400).json({ error: '2FA is already enabled' });
|
|
}
|
|
|
|
// Generate a new TOTP secret
|
|
const secret = new OTPAuth.Secret({ size: 20 });
|
|
const totp = new OTPAuth.TOTP({
|
|
issuer: 'Haven',
|
|
label: user.username,
|
|
algorithm: 'SHA1',
|
|
digits: 6,
|
|
period: 30,
|
|
secret
|
|
});
|
|
|
|
// Store secret (not yet enabled — user must verify first)
|
|
db.prepare('UPDATE users SET totp_secret = ? WHERE id = ?').run(secret.base32, user.id);
|
|
|
|
const otpauthUri = totp.toString();
|
|
const qrDataUrl = await QRCode.toDataURL(otpauthUri, { width: 256, margin: 2 });
|
|
|
|
res.json({
|
|
base32Secret: secret.base32,
|
|
otpauthUri,
|
|
qrDataUrl
|
|
});
|
|
} catch (err) {
|
|
console.error('TOTP setup error:', err);
|
|
res.status(500).json({ error: 'Server error' });
|
|
}
|
|
});
|
|
|
|
// ── TOTP Verify Setup (confirm code → enable) ────────────
|
|
router.post('/totp/verify-setup', async (req, res) => {
|
|
try {
|
|
const token = req.headers.authorization?.split(' ')[1];
|
|
if (!token) return res.status(401).json({ error: 'Unauthorized' });
|
|
const decoded = verifyToken(token);
|
|
if (!decoded) return res.status(401).json({ error: 'Unauthorized' });
|
|
|
|
const code = typeof req.body.code === 'string' ? req.body.code.replace(/\s/g, '') : '';
|
|
if (!code) return res.status(400).json({ error: 'Verification code required' });
|
|
|
|
const db = getDb();
|
|
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(decoded.id);
|
|
if (!user) return res.status(404).json({ error: 'User not found' });
|
|
|
|
if (user.totp_enabled) {
|
|
return res.status(400).json({ error: '2FA is already enabled' });
|
|
}
|
|
if (!user.totp_secret) {
|
|
return res.status(400).json({ error: 'No 2FA setup in progress. Start setup first.' });
|
|
}
|
|
|
|
const totp = new OTPAuth.TOTP({
|
|
issuer: 'Haven',
|
|
label: user.username,
|
|
algorithm: 'SHA1',
|
|
digits: 6,
|
|
period: 30,
|
|
secret: OTPAuth.Secret.fromBase32(user.totp_secret)
|
|
});
|
|
|
|
const delta = totp.validate({ token: code, window: 1 });
|
|
if (delta === null) {
|
|
return res.status(401).json({ error: 'Invalid code. Make sure your authenticator app is synced and try again.' });
|
|
}
|
|
|
|
// Enable TOTP and bump password_version to invalidate all existing sessions
|
|
const newPwv = (user.password_version || 1) + 1;
|
|
db.prepare('UPDATE users SET totp_enabled = 1, password_version = ? WHERE id = ?').run(newPwv, user.id);
|
|
|
|
// Generate backup codes
|
|
const backupCodes = generateBackupCodes(8);
|
|
const insertCode = db.prepare('INSERT INTO totp_backup_codes (user_id, code_hash) VALUES (?, ?)');
|
|
// Clear any old backup codes
|
|
db.prepare('DELETE FROM totp_backup_codes WHERE user_id = ?').run(user.id);
|
|
for (const c of backupCodes) {
|
|
const hash = crypto.createHash('sha256').update(c).digest('hex');
|
|
insertCode.run(user.id, hash);
|
|
}
|
|
|
|
// Issue a fresh token for the current session (carries new pwv so it stays valid)
|
|
const freshToken = jwt.sign(
|
|
{ id: user.id, username: user.username, isAdmin: !!user.is_admin, displayName: user.display_name || user.username, pwv: newPwv },
|
|
JWT_SECRET,
|
|
{ expiresIn: '7d' }
|
|
);
|
|
|
|
// Send the response first so the client can store the fresh token
|
|
res.json({ success: true, backupCodes, token: freshToken });
|
|
|
|
// After a short delay, disconnect all other sockets for this user (force-logout other devices)
|
|
const io = req.app.get('io');
|
|
if (io) {
|
|
setTimeout(() => {
|
|
for (const [, s] of io.sockets.sockets) {
|
|
if (s.user && s.user.id === user.id) {
|
|
s.emit('force-logout', { reason: 'totp_enabled' });
|
|
s.disconnect(true);
|
|
}
|
|
}
|
|
}, 500);
|
|
}
|
|
} catch (err) {
|
|
console.error('TOTP verify-setup error:', err);
|
|
res.status(500).json({ error: 'Server error' });
|
|
}
|
|
});
|
|
|
|
// ── TOTP Disable ─────────────────────────────────────────
|
|
router.post('/totp/disable', async (req, res) => {
|
|
try {
|
|
const token = req.headers.authorization?.split(' ')[1];
|
|
if (!token) return res.status(401).json({ error: 'Unauthorized' });
|
|
const decoded = verifyToken(token);
|
|
if (!decoded) return res.status(401).json({ error: 'Unauthorized' });
|
|
|
|
const password = typeof req.body.password === 'string' ? req.body.password : '';
|
|
if (!password) return res.status(400).json({ error: 'Password required to disable 2FA' });
|
|
|
|
const db = getDb();
|
|
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(decoded.id);
|
|
if (!user) return res.status(404).json({ error: 'User not found' });
|
|
|
|
if (!user.totp_enabled) {
|
|
return res.status(400).json({ error: '2FA is not enabled' });
|
|
}
|
|
|
|
const valid = await bcrypt.compare(password, user.password_hash);
|
|
if (!valid) return res.status(401).json({ error: 'Incorrect password' });
|
|
|
|
db.prepare('UPDATE users SET totp_enabled = 0, totp_secret = NULL WHERE id = ?').run(user.id);
|
|
db.prepare('DELETE FROM totp_backup_codes WHERE user_id = ?').run(user.id);
|
|
|
|
res.json({ success: true });
|
|
} catch (err) {
|
|
console.error('TOTP disable error:', err);
|
|
res.status(500).json({ error: 'Server error' });
|
|
}
|
|
});
|
|
|
|
// ── TOTP Status ──────────────────────────────────────────
|
|
router.get('/totp/status', (req, res) => {
|
|
try {
|
|
const token = req.headers.authorization?.split(' ')[1];
|
|
if (!token) return res.status(401).json({ error: 'Unauthorized' });
|
|
const decoded = verifyToken(token);
|
|
if (!decoded) return res.status(401).json({ error: 'Unauthorized' });
|
|
|
|
const db = getDb();
|
|
const user = db.prepare('SELECT totp_enabled FROM users WHERE id = ?').get(decoded.id);
|
|
if (!user) return res.status(404).json({ error: 'User not found' });
|
|
|
|
const remaining = db.prepare(
|
|
'SELECT COUNT(*) as count FROM totp_backup_codes WHERE user_id = ? AND used = 0'
|
|
).get(decoded.id);
|
|
|
|
res.json({ enabled: !!user.totp_enabled, backupCodesRemaining: remaining?.count || 0 });
|
|
} catch (err) {
|
|
console.error('TOTP status error:', err);
|
|
res.status(500).json({ error: 'Server error' });
|
|
}
|
|
});
|
|
|
|
// ── TOTP Regenerate Backup Codes ─────────────────────────
|
|
router.post('/totp/regenerate-backup', async (req, res) => {
|
|
try {
|
|
const token = req.headers.authorization?.split(' ')[1];
|
|
if (!token) return res.status(401).json({ error: 'Unauthorized' });
|
|
const decoded = verifyToken(token);
|
|
if (!decoded) return res.status(401).json({ error: 'Unauthorized' });
|
|
|
|
const password = typeof req.body.password === 'string' ? req.body.password : '';
|
|
if (!password) return res.status(400).json({ error: 'Password required' });
|
|
|
|
const db = getDb();
|
|
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(decoded.id);
|
|
if (!user) return res.status(404).json({ error: 'User not found' });
|
|
|
|
if (!user.totp_enabled) {
|
|
return res.status(400).json({ error: '2FA is not enabled' });
|
|
}
|
|
|
|
const valid = await bcrypt.compare(password, user.password_hash);
|
|
if (!valid) return res.status(401).json({ error: 'Incorrect password' });
|
|
|
|
const backupCodes = generateBackupCodes(8);
|
|
db.prepare('DELETE FROM totp_backup_codes WHERE user_id = ?').run(user.id);
|
|
const insertCode = db.prepare('INSERT INTO totp_backup_codes (user_id, code_hash) VALUES (?, ?)');
|
|
for (const c of backupCodes) {
|
|
const hash = crypto.createHash('sha256').update(c).digest('hex');
|
|
insertCode.run(user.id, hash);
|
|
}
|
|
|
|
res.json({ success: true, backupCodes });
|
|
} catch (err) {
|
|
console.error('TOTP regenerate-backup error:', err);
|
|
res.status(500).json({ error: 'Server error' });
|
|
}
|
|
});
|
|
|
|
// ── Change Password ──────────────────────────────────────
|
|
router.post('/change-password', async (req, res) => {
|
|
try {
|
|
const token = req.headers.authorization?.split(' ')[1];
|
|
if (!token) return res.status(401).json({ error: 'Unauthorized' });
|
|
const decoded = verifyToken(token);
|
|
if (!decoded) return res.status(401).json({ error: 'Unauthorized' });
|
|
|
|
const currentPassword = typeof req.body.currentPassword === 'string' ? req.body.currentPassword : '';
|
|
const newPassword = typeof req.body.newPassword === 'string' ? req.body.newPassword : '';
|
|
|
|
if (!currentPassword || !newPassword) {
|
|
return res.status(400).json({ error: 'Current and new password required' });
|
|
}
|
|
if (newPassword.length < 8 || newPassword.length > 128) {
|
|
return res.status(400).json({ error: 'New password must be 8-128 characters' });
|
|
}
|
|
|
|
const db = getDb();
|
|
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(decoded.id);
|
|
if (!user) return res.status(404).json({ error: 'User not found' });
|
|
|
|
const valid = await bcrypt.compare(currentPassword, user.password_hash);
|
|
if (!valid) return res.status(401).json({ error: 'Current password is incorrect' });
|
|
|
|
const hash = await bcrypt.hash(newPassword, 12);
|
|
const newPwv = (user.password_version || 1) + 1;
|
|
db.prepare('UPDATE users SET password_hash = ?, password_version = ? WHERE id = ?').run(hash, newPwv, user.id);
|
|
|
|
// Issue a fresh token so the session stays alive
|
|
const freshToken = jwt.sign(
|
|
{ id: user.id, username: user.username, isAdmin: !!user.is_admin, displayName: user.display_name || user.username, pwv: newPwv },
|
|
JWT_SECRET,
|
|
{ expiresIn: '7d' }
|
|
);
|
|
|
|
// Send the response FIRST so the client can store the fresh token
|
|
// before we disconnect sockets (prevents redirect loop)
|
|
res.json({ message: 'Password changed successfully', token: freshToken });
|
|
|
|
// Disconnect all existing sockets for this user (forces re-login on other sessions)
|
|
const io = req.app.get('io');
|
|
if (io) {
|
|
// Small delay to let the HTTP response reach the client first
|
|
setTimeout(() => {
|
|
for (const [, s] of io.sockets.sockets) {
|
|
if (s.user && s.user.id === user.id) {
|
|
s.emit('force-logout', { reason: 'password_changed' });
|
|
s.disconnect(true);
|
|
}
|
|
}
|
|
}, 500);
|
|
}
|
|
} catch (err) {
|
|
console.error('Change password error:', err);
|
|
res.status(500).json({ error: 'Server error' });
|
|
}
|
|
});
|
|
|
|
// ── Helpers ───────────────────────────────────────────────
|
|
|
|
// ── Verify Password (lightweight, for E2E password prompt) ──
|
|
router.post('/verify-password', async (req, res) => {
|
|
try {
|
|
const username = sanitizeString(req.body.username, 20);
|
|
const password = typeof req.body.password === 'string' ? req.body.password : '';
|
|
if (!username || !password) {
|
|
return res.status(400).json({ valid: false, error: 'Username and password required' });
|
|
}
|
|
const db = getDb();
|
|
const user = db.prepare('SELECT password_hash FROM users WHERE username = ?').get(username);
|
|
if (!user) {
|
|
return res.status(401).json({ valid: false, error: 'Invalid credentials' });
|
|
}
|
|
const valid = await bcrypt.compare(password, user.password_hash);
|
|
if (!valid) {
|
|
return res.status(401).json({ valid: false, error: 'Invalid credentials' });
|
|
}
|
|
res.json({ valid: true });
|
|
} catch (err) {
|
|
console.error('Verify password error:', err);
|
|
res.status(500).json({ valid: false, error: 'Server error' });
|
|
}
|
|
});
|
|
|
|
function verifyToken(token) {
|
|
try {
|
|
return jwt.verify(token, JWT_SECRET);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// ── Account Recovery Codes ─────────────────────────────
|
|
// Users generate these in advance. Each is a one-time token that can reset
|
|
// their password (and clear their E2E keys) without admin involvement.
|
|
|
|
router.get('/recovery-codes/status', async (req, res) => {
|
|
const auth = req.headers.authorization;
|
|
if (!auth || !auth.startsWith('Bearer ')) return res.status(401).json({ error: 'Unauthorized' });
|
|
try {
|
|
const decoded = jwt.verify(auth.slice(7), JWT_SECRET);
|
|
const db = getDb();
|
|
const count = db.prepare(
|
|
'SELECT COUNT(*) as count FROM account_recovery_codes WHERE user_id = ? AND used = 0'
|
|
).get(decoded.id);
|
|
res.json({ count: count?.count || 0 });
|
|
} catch {
|
|
res.status(401).json({ error: 'Invalid token' });
|
|
}
|
|
});
|
|
|
|
router.post('/recovery-codes/generate', authLimiter, async (req, res) => {
|
|
const auth = req.headers.authorization;
|
|
if (!auth || !auth.startsWith('Bearer ')) return res.status(401).json({ error: 'Unauthorized' });
|
|
try {
|
|
const decoded = jwt.verify(auth.slice(7), JWT_SECRET);
|
|
const password = typeof req.body.password === 'string' ? req.body.password : '';
|
|
if (!password) return res.status(400).json({ error: 'Password required' });
|
|
|
|
const db = getDb();
|
|
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(decoded.id);
|
|
if (!user) return res.status(401).json({ error: 'User not found' });
|
|
|
|
const valid = await bcrypt.compare(password, user.password_hash);
|
|
if (!valid) return res.status(401).json({ error: 'Incorrect password' });
|
|
|
|
// Generate 8 new codes, replacing any existing unused ones
|
|
const codes = generateBackupCodes(8);
|
|
db.prepare('DELETE FROM account_recovery_codes WHERE user_id = ?').run(user.id);
|
|
const insertCode = db.prepare('INSERT INTO account_recovery_codes (user_id, code_hash) VALUES (?, ?)');
|
|
for (const c of codes) {
|
|
const hash = await bcrypt.hash(c, 10);
|
|
insertCode.run(user.id, hash);
|
|
}
|
|
|
|
console.log(`🔑 Recovery codes generated for "${user.username}" from ${req.ip || 'unknown'}`);
|
|
res.json({ codes });
|
|
} catch {
|
|
res.status(401).json({ error: 'Invalid token' });
|
|
}
|
|
});
|
|
|
|
router.post('/recover-account', authLimiter, async (req, res) => {
|
|
try {
|
|
const username = sanitizeString(req.body.username, 20);
|
|
const code = typeof req.body.code === 'string' ? req.body.code.trim().toUpperCase() : '';
|
|
const newPassword = typeof req.body.newPassword === 'string' ? req.body.newPassword : '';
|
|
|
|
if (!username || !code || !newPassword) {
|
|
return res.status(400).json({ error: 'Username, recovery code, and new password required' });
|
|
}
|
|
if (newPassword.length < 8) {
|
|
return res.status(400).json({ error: 'New password must be at least 8 characters' });
|
|
}
|
|
|
|
const db = getDb();
|
|
const user = db.prepare('SELECT * FROM users WHERE LOWER(username) = LOWER(?)').get(username);
|
|
if (!user) return res.status(401).json({ error: 'Invalid username or recovery code' });
|
|
|
|
// Find a matching unused code
|
|
const storedCodes = db.prepare(
|
|
'SELECT id, code_hash FROM account_recovery_codes WHERE user_id = ? AND used = 0'
|
|
).all(user.id);
|
|
|
|
let matchedId = null;
|
|
for (const sc of storedCodes) {
|
|
if (await bcrypt.compare(code, sc.code_hash)) {
|
|
matchedId = sc.id;
|
|
break;
|
|
}
|
|
}
|
|
if (!matchedId) return res.status(401).json({ error: 'Invalid username or recovery code' });
|
|
|
|
// Mark code used
|
|
db.prepare('UPDATE account_recovery_codes SET used = 1 WHERE id = ?').run(matchedId);
|
|
|
|
// Reset password, bump version, clear E2E keys, clear TOTP
|
|
const newHash = await bcrypt.hash(newPassword, 12);
|
|
const newVersion = (user.password_version || 1) + 1;
|
|
db.prepare(`
|
|
UPDATE users SET
|
|
password_hash = ?,
|
|
password_version = ?,
|
|
totp_secret = NULL,
|
|
totp_enabled = 0,
|
|
public_key = NULL,
|
|
encrypted_private_key = NULL,
|
|
e2e_key_salt = NULL,
|
|
e2e_secret = NULL
|
|
WHERE id = ?
|
|
`).run(newHash, newVersion, user.id);
|
|
db.prepare('DELETE FROM totp_backup_codes WHERE user_id = ?').run(user.id);
|
|
db.prepare('DELETE FROM account_recovery_codes WHERE user_id = ?').run(user.id);
|
|
|
|
console.log(`🔑 Account recovery used for "${user.username}" from ${req.ip || 'unknown'} — E2E keys cleared`);
|
|
res.json({ success: true });
|
|
} catch (err) {
|
|
console.error('Account recovery error:', err);
|
|
res.status(500).json({ error: 'Server error' });
|
|
}
|
|
});
|
|
|
|
// ── Admin Recovery ──────────────────────────────────────
|
|
// Allows the server owner to reclaim admin access using their .env credentials.
|
|
// This is a last-resort mechanism if the admin gets banned, demoted, or locked out.
|
|
// Requires ADMIN_USERNAME and the admin account's password (verified against DB hash).
|
|
router.post('/admin-recover', authLimiter, async (req, res) => {
|
|
try {
|
|
const username = sanitizeString(req.body.username, 20);
|
|
const password = typeof req.body.password === 'string' ? req.body.password : '';
|
|
if (!username || !password) {
|
|
return res.status(400).json({ error: 'Username and password required' });
|
|
}
|
|
|
|
// Only the ADMIN_USERNAME from .env can use this endpoint
|
|
if (username.toLowerCase() !== ADMIN_USERNAME) {
|
|
return res.status(403).json({ error: 'This endpoint is only available for the server admin account' });
|
|
}
|
|
|
|
const db = getDb();
|
|
const user = db.prepare('SELECT * FROM users WHERE LOWER(username) = LOWER(?)').get(username);
|
|
if (!user) {
|
|
return res.status(401).json({ error: 'Invalid credentials' });
|
|
}
|
|
|
|
const valid = await bcrypt.compare(password, user.password_hash);
|
|
if (!valid) {
|
|
return res.status(401).json({ error: 'Invalid credentials' });
|
|
}
|
|
|
|
// Restore admin status
|
|
db.prepare('UPDATE users SET is_admin = 1 WHERE id = ?').run(user.id);
|
|
|
|
// Remove any active ban on the admin
|
|
db.prepare('DELETE FROM bans WHERE user_id = ?').run(user.id);
|
|
|
|
// Remove any active mute on the admin
|
|
db.prepare('DELETE FROM mutes WHERE user_id = ?').run(user.id);
|
|
|
|
const displayName = user.display_name || user.username;
|
|
const token = jwt.sign(
|
|
{ id: user.id, username: user.username, isAdmin: true, displayName, pwv: user.password_version || 1 },
|
|
JWT_SECRET,
|
|
{ expiresIn: '7d' }
|
|
);
|
|
|
|
console.log(`🔑 Admin recovery used for "${user.username}" from ${req.ip || 'unknown'}`);
|
|
res.json({ token, user: { id: user.id, username: user.username, isAdmin: true, displayName } });
|
|
} catch (err) {
|
|
console.error('Admin recovery error:', err);
|
|
res.status(500).json({ error: 'Server error' });
|
|
}
|
|
});
|
|
|
|
function generateToken(payload) {
|
|
return jwt.sign(payload, JWT_SECRET, { expiresIn: '7d' });
|
|
}
|
|
|
|
function generateChannelCode() {
|
|
return crypto.randomBytes(4).toString('hex'); // 8-char hex string
|
|
}
|
|
|
|
// ── Encrypted Server List (cross-device sync) ───────────
|
|
// Client encrypts/decrypts the server list with the user's password-derived key.
|
|
// Server stores only the opaque blob — no visibility into URLs or network graph.
|
|
|
|
router.get('/user-servers', async (req, res) => {
|
|
const token = req.headers.authorization?.split(' ')[1];
|
|
const decoded = token ? verifyToken(token) : null;
|
|
if (!decoded) return res.status(401).json({ error: 'Unauthorized' });
|
|
|
|
try {
|
|
const db = getDb();
|
|
const row = db.prepare('SELECT encrypted_servers FROM users WHERE id = ?').get(decoded.id);
|
|
res.json({ blob: row?.encrypted_servers || null });
|
|
} catch (err) {
|
|
console.error('Get user-servers error:', err);
|
|
res.status(500).json({ error: 'Server error' });
|
|
}
|
|
});
|
|
|
|
router.put('/user-servers', async (req, res) => {
|
|
const token = req.headers.authorization?.split(' ')[1];
|
|
const decoded = token ? verifyToken(token) : null;
|
|
if (!decoded) return res.status(401).json({ error: 'Unauthorized' });
|
|
|
|
const blob = typeof req.body.blob === 'string' ? req.body.blob : null;
|
|
if (blob && blob.length > 65536) {
|
|
return res.status(400).json({ error: 'Server list too large' });
|
|
}
|
|
|
|
try {
|
|
const db = getDb();
|
|
db.prepare('UPDATE users SET encrypted_servers = ? WHERE id = ?').run(blob, decoded.id);
|
|
res.json({ ok: true });
|
|
} catch (err) {
|
|
console.error('Put user-servers error:', err);
|
|
res.status(500).json({ error: 'Server error' });
|
|
}
|
|
});
|
|
|
|
// ── SSO (Sign in with existing Haven server) ───────────
|
|
// Allows other Haven servers to pre-fill registration with this user's profile.
|
|
// Flow: foreign server opens /SSO?authCode=X → user confirms → foreign server
|
|
// calls /SSO/authenticate?authCode=X to retrieve public profile data.
|
|
|
|
const pendingSSO = new Map();
|
|
|
|
// Rate limiter for SSO authenticate endpoint (prevents auth code brute-force)
|
|
const ssoRateLimitStore = new Map();
|
|
function ssoAuthLimiter(req, res, next) {
|
|
const ip = req.ip || req.socket.remoteAddress;
|
|
const now = Date.now();
|
|
const windowMs = 60 * 1000; // 1 minute
|
|
const maxAttempts = 5;
|
|
|
|
if (!ssoRateLimitStore.has(ip)) ssoRateLimitStore.set(ip, []);
|
|
const timestamps = ssoRateLimitStore.get(ip).filter(t => now - t < windowMs);
|
|
ssoRateLimitStore.set(ip, timestamps);
|
|
|
|
if (timestamps.length >= maxAttempts) {
|
|
return res.status(429).json({ error: 'Too many attempts. Try again in a minute.' });
|
|
}
|
|
timestamps.push(now);
|
|
next();
|
|
}
|
|
|
|
// Clean up SSO rate limit entries every 5 minutes
|
|
setInterval(() => {
|
|
const now = Date.now();
|
|
for (const [ip, timestamps] of ssoRateLimitStore) {
|
|
const fresh = timestamps.filter(t => now - t < 60000);
|
|
if (fresh.length === 0) ssoRateLimitStore.delete(ip);
|
|
else ssoRateLimitStore.set(ip, fresh);
|
|
}
|
|
}, 5 * 60 * 1000);
|
|
|
|
// GET /api/auth/SSO?authCode=X — Consent/authorize page
|
|
// The user must be logged in (valid JWT in localStorage). The page is client-rendered
|
|
// and reads the token from localStorage to make the approve call.
|
|
router.get('/SSO', (req, res) => {
|
|
const authCode = typeof req.query.authCode === 'string' ? req.query.authCode.trim() : '';
|
|
const origin = typeof req.query.origin === 'string' ? req.query.origin.trim().slice(0, 200) : '';
|
|
if (!authCode || authCode.length < 32 || authCode.length > 128) {
|
|
return res.status(400).send('Invalid or missing auth code.');
|
|
}
|
|
|
|
const safeAuthCode = authCode.replace(/[^a-fA-F0-9]/g, '');
|
|
const safeOrigin = origin.replace(/[<>"'&]/g, '');
|
|
|
|
// Serve a self-contained consent page that reads JWT from localStorage
|
|
res.send(`<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Haven SSO</title>
|
|
<style>
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0d0d1a; color: #e0e0e0; display: flex; align-items: center; justify-content: center; min-height: 100vh; }
|
|
.card { background: #1a1a2e; border: 1px solid #333; border-radius: 12px; padding: 32px; max-width: 400px; width: 90%; text-align: center; }
|
|
.card h2 { margin-bottom: 8px; font-size: 20px; }
|
|
.card p { color: #aaa; font-size: 14px; margin-bottom: 20px; }
|
|
.origin { color: #6b4fdb; font-weight: 600; word-break: break-all; }
|
|
.info { background: #12122a; border: 1px solid #2a2a4a; border-radius: 8px; padding: 12px; margin-bottom: 20px; text-align: left; font-size: 13px; }
|
|
.info-row { display: flex; justify-content: space-between; padding: 4px 0; }
|
|
.info-label { color: #888; }
|
|
.info-value { color: #e0e0e0; font-weight: 500; }
|
|
.btn { display: inline-block; padding: 10px 24px; border: none; border-radius: 8px; font-size: 14px; font-weight: 600; cursor: pointer; margin: 4px; transition: opacity 0.2s; }
|
|
.btn-primary { background: #6b4fdb; color: #fff; }
|
|
.btn-primary:hover { opacity: 0.9; }
|
|
.btn-cancel { background: transparent; color: #888; border: 1px solid #444; }
|
|
.btn-cancel:hover { color: #fff; border-color: #666; }
|
|
.success { display: none; color: #4ade80; font-size: 15px; margin-top: 12px; }
|
|
.not-logged-in { color: #ef4444; }
|
|
.loading { color: #888; }
|
|
.debug { margin-top: 10px; font-size: 12px; color: #8f95b2; word-break: break-word; }
|
|
.debug.error { color: #ef4444; }
|
|
.debug.ok { color: #4ade80; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="card">
|
|
<h2>⬡ Haven SSO</h2>
|
|
<div id="loading" class="loading"><p>Checking login status...</p></div>
|
|
<div id="sso-debug" class="debug">Starting SSO checks...</div>
|
|
<div id="not-logged-in" style="display:none">
|
|
<p class="not-logged-in">You are not logged in to this server.</p>
|
|
<p style="font-size:13px;color:#888;margin-top:8px">Log in first, then try again.</p>
|
|
<button class="btn btn-primary" onclick="window.location.href='/'">Go to Login</button>
|
|
</div>
|
|
<div id="consent" style="display:none">
|
|
<p>Another Haven server wants to use your identity to pre-fill registration.</p>
|
|
${safeOrigin ? `<p>Requesting server: <span class="origin">${safeOrigin}</span></p>` : ''}
|
|
<div class="info">
|
|
<div class="info-row"><span class="info-label">Username</span><span class="info-value" id="sso-username">—</span></div>
|
|
<div class="info-row"><span class="info-label">Profile picture</span><span class="info-value" id="sso-avatar">—</span></div>
|
|
</div>
|
|
<p style="font-size:12px;color:#666">Your password is <strong>never</strong> shared. Only your username and profile picture.</p>
|
|
<div id="buttons">
|
|
<button class="btn btn-primary" id="approve-btn">Approve</button>
|
|
<button class="btn btn-cancel" onclick="window.close()">Cancel</button>
|
|
</div>
|
|
<p class="success" id="success-msg">✓ Approved! You can close this tab.</p>
|
|
</div>
|
|
</div>
|
|
<script>
|
|
const authCode = '${safeAuthCode}';
|
|
const origin = '${safeOrigin}';
|
|
let approvedProfile = null;
|
|
|
|
(async function() {
|
|
const loadingEl = document.getElementById('loading');
|
|
const debugEl = document.getElementById('sso-debug');
|
|
|
|
function setDebug(msg, tone = '') {
|
|
if (!debugEl) return;
|
|
debugEl.textContent = msg;
|
|
debugEl.className = 'debug' + (tone ? (' ' + tone) : '');
|
|
}
|
|
|
|
function showNotLoggedIn(reason = 'No active login was found on this server.') {
|
|
document.getElementById('loading').style.display = 'none';
|
|
document.getElementById('not-logged-in').style.display = 'block';
|
|
setDebug(reason, 'error');
|
|
}
|
|
|
|
function showConsentReady() {
|
|
document.getElementById('loading').style.display = 'none';
|
|
document.getElementById('consent').style.display = 'block';
|
|
setDebug('Login verified. You can approve this SSO request.', 'ok');
|
|
}
|
|
|
|
// Safety watchdog: if anything stalls, stop showing an indefinite spinner.
|
|
const bootTimeout = setTimeout(() => {
|
|
if (loadingEl && loadingEl.style.display !== 'none') {
|
|
// If we have a cached user profile, use that instead of failing — server
|
|
// may simply be slow/unreachable for the validate endpoint, but the
|
|
// profile we'll share is already cached locally.
|
|
try {
|
|
const cachedRaw = localStorage.getItem('haven_user');
|
|
const cached = cachedRaw ? JSON.parse(cachedRaw) : null;
|
|
if (cached && cached.username) {
|
|
approvedProfile = {
|
|
username: cached.username,
|
|
displayName: cached.displayName || cached.username,
|
|
profilePicture: cached.avatar || null
|
|
};
|
|
document.getElementById('sso-username').textContent = approvedProfile.displayName || approvedProfile.username;
|
|
document.getElementById('sso-avatar').textContent = cached.avatar ? 'Will be shared' : 'None set';
|
|
showConsentReady();
|
|
setDebug('Using cached profile (validate endpoint did not respond in time).', 'ok');
|
|
return;
|
|
}
|
|
} catch {}
|
|
showNotLoggedIn('SSO check timed out. Try refreshing this page or logging in again.');
|
|
}
|
|
}, 5000);
|
|
|
|
let token;
|
|
try {
|
|
setDebug('Reading local login token...');
|
|
token = localStorage.getItem('haven_token');
|
|
} catch {
|
|
// localStorage blocked (third-party cookies, popup restrictions, etc.)
|
|
showNotLoggedIn('Browser storage is blocked in this tab, so Haven cannot read your login token.');
|
|
clearTimeout(bootTimeout);
|
|
return;
|
|
}
|
|
|
|
if (!token) {
|
|
showNotLoggedIn('No Haven login token found in this browser profile.');
|
|
clearTimeout(bootTimeout);
|
|
return;
|
|
}
|
|
|
|
// Verify token is still valid by calling the server
|
|
try {
|
|
setDebug('Validating token with this server...');
|
|
const ctrl = new AbortController();
|
|
const timer = setTimeout(() => ctrl.abort(), 4000);
|
|
const verifyRes = await fetch('/api/auth/validate', {
|
|
headers: { 'Authorization': 'Bearer ' + token },
|
|
signal: ctrl.signal
|
|
});
|
|
clearTimeout(timer);
|
|
if (!verifyRes.ok) {
|
|
showNotLoggedIn('Token validation failed (' + verifyRes.status + '). Please log in again.');
|
|
clearTimeout(bootTimeout);
|
|
return;
|
|
}
|
|
const userData = await verifyRes.json();
|
|
approvedProfile = {
|
|
username: userData.username || '—',
|
|
displayName: userData.displayName || userData.username || '—',
|
|
profilePicture: userData.avatar || null
|
|
};
|
|
|
|
document.getElementById('sso-username').textContent = approvedProfile.displayName || approvedProfile.username;
|
|
document.getElementById('sso-avatar').textContent = userData.avatar ? 'Will be shared' : 'None set';
|
|
showConsentReady();
|
|
clearTimeout(bootTimeout);
|
|
} catch {
|
|
// Fall back to localStorage user data if validate endpoint unavailable
|
|
try {
|
|
setDebug('Validate endpoint unavailable. Falling back to local profile data...');
|
|
const userStr = localStorage.getItem('haven_user');
|
|
const user = userStr ? JSON.parse(userStr) : null;
|
|
if (!user) {
|
|
showNotLoggedIn('Could not validate token and no cached local user profile was found.');
|
|
clearTimeout(bootTimeout);
|
|
return;
|
|
}
|
|
|
|
approvedProfile = {
|
|
username: user.username || '—',
|
|
displayName: user.displayName || user.username || '—',
|
|
profilePicture: user.avatar || null
|
|
};
|
|
|
|
document.getElementById('sso-username').textContent = approvedProfile.displayName || approvedProfile.username;
|
|
document.getElementById('sso-avatar').textContent = user.avatar ? 'Will be shared' : 'None set';
|
|
showConsentReady();
|
|
clearTimeout(bootTimeout);
|
|
} catch {
|
|
showNotLoggedIn('Failed to read cached local profile for SSO consent.');
|
|
clearTimeout(bootTimeout);
|
|
return;
|
|
}
|
|
}
|
|
|
|
document.getElementById('approve-btn').addEventListener('click', async () => {
|
|
const btn = document.getElementById('approve-btn');
|
|
btn.disabled = true;
|
|
btn.textContent = 'Approving...';
|
|
try {
|
|
const res = await fetch('/api/auth/SSO/approve', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
|
|
body: JSON.stringify({ authCode, origin })
|
|
});
|
|
if (res.ok) {
|
|
setDebug('Approval stored on home server. Returning profile to requesting server...', 'ok');
|
|
if (origin && window.opener && approvedProfile) {
|
|
try {
|
|
window.opener.postMessage({
|
|
type: 'haven-sso-approved',
|
|
authCode,
|
|
profile: approvedProfile,
|
|
serverOrigin: window.location.origin
|
|
}, origin);
|
|
} catch {}
|
|
}
|
|
document.getElementById('buttons').style.display = 'none';
|
|
document.getElementById('success-msg').style.display = 'block';
|
|
} else {
|
|
const data = await res.json().catch(() => ({}));
|
|
setDebug(data.error || 'Approval failed on home server.', 'error');
|
|
alert(data.error || 'Failed to approve');
|
|
btn.disabled = false;
|
|
btn.textContent = 'Approve';
|
|
}
|
|
} catch {
|
|
setDebug('Connection error while approving SSO request.', 'error');
|
|
alert('Connection error');
|
|
btn.disabled = false;
|
|
btn.textContent = 'Approve';
|
|
}
|
|
});
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>`);
|
|
});
|
|
|
|
// POST /api/auth/SSO/approve — User clicks Approve on consent page
|
|
router.post('/SSO/approve', (req, res) => {
|
|
const token = req.headers.authorization?.split(' ')[1];
|
|
const decoded = token ? verifyToken(token) : null;
|
|
if (!decoded) return res.status(401).json({ error: 'Unauthorized' });
|
|
|
|
const authCode = typeof req.body.authCode === 'string' ? req.body.authCode.trim() : '';
|
|
const origin = typeof req.body.origin === 'string' ? req.body.origin.trim().slice(0, 200) : '';
|
|
if (!authCode || authCode.length < 32) {
|
|
return res.status(400).json({ error: 'Invalid auth code' });
|
|
}
|
|
|
|
// Prevent duplicate approvals
|
|
if (pendingSSO.has(authCode)) {
|
|
return res.status(400).json({ error: 'Auth code already used' });
|
|
}
|
|
|
|
pendingSSO.set(authCode, { userId: decoded.id, origin, approvedAt: Date.now() });
|
|
|
|
// Auto-expire after 60 seconds
|
|
setTimeout(() => pendingSSO.delete(authCode), 60000);
|
|
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
// GET /api/auth/SSO/authenticate?authCode=X — Foreign server calls this to retrieve user info
|
|
// This is called by the CLIENT on the foreign server, not server-to-server.
|
|
router.get('/SSO/authenticate', ssoAuthLimiter, (req, res) => {
|
|
const requestOrigin = req.headers.origin;
|
|
if (requestOrigin) {
|
|
res.set('Access-Control-Allow-Origin', requestOrigin);
|
|
res.set('Vary', 'Origin');
|
|
res.set('Access-Control-Allow-Credentials', 'false');
|
|
}
|
|
|
|
const authCode = typeof req.query.authCode === 'string' ? req.query.authCode.trim() : '';
|
|
if (!authCode) return res.status(400).json({ error: 'Missing auth code' });
|
|
|
|
const pending = pendingSSO.get(authCode);
|
|
if (!pending) return res.status(404).json({ error: 'Invalid or expired auth code' });
|
|
|
|
// One-time use: delete immediately
|
|
pendingSSO.delete(authCode);
|
|
|
|
// If this auth code was issued for a specific origin, mirror it for strictness.
|
|
if (pending.origin) res.set('Access-Control-Allow-Origin', pending.origin);
|
|
|
|
const db = getDb();
|
|
const user = db.prepare('SELECT username, avatar, display_name FROM users WHERE id = ?').get(pending.userId);
|
|
if (!user) return res.status(404).json({ error: 'User not found' });
|
|
|
|
// Build the avatar URL — if it's a relative path, make it absolute
|
|
let avatarUrl = user.avatar || null;
|
|
if (avatarUrl && avatarUrl.startsWith('/')) {
|
|
// The client will need to construct the full URL using the home server address
|
|
// We return it as-is (relative) and the client prepends the server URL
|
|
}
|
|
|
|
res.json({
|
|
username: user.username,
|
|
displayName: user.display_name || user.username,
|
|
profilePicture: avatarUrl
|
|
});
|
|
});
|
|
|
|
// CORS preflight for SSO/authenticate (cross-origin requests from foreign Haven clients)
|
|
router.options('/SSO/authenticate', (req, res) => {
|
|
const origin = req.headers.origin;
|
|
if (origin) {
|
|
res.set('Access-Control-Allow-Origin', origin);
|
|
res.set('Access-Control-Allow-Methods', 'GET, OPTIONS');
|
|
res.set('Access-Control-Allow-Headers', 'Content-Type');
|
|
res.set('Access-Control-Max-Age', '600');
|
|
}
|
|
res.sendStatus(204);
|
|
});
|
|
|
|
module.exports = { router, verifyToken, generateChannelCode, generateToken, authLimiter };
|