- Fix backup restore: try/finally ensures DB always reopens after closeDb - Fix EBUSY on uploads during restore (in-place overwrite instead of rmSync) - Add DB proxy null guard for clearer errors during restore window - Add red warning modal before backup restore (DE/EN, dark mode support) - JWT secret: empty docker-compose default so auto-generation kicks in - OIDC: pass token via URL fragment instead of query param (no server logs) - Block SVG uploads on photos, files and covers (stored XSS prevention) - Add helmet for security headers (HSTS, X-Frame, nosniff, etc.) - Explicit express.json body size limit (100kb) - Fix XSS in Leaflet map markers (escape image_url in HTML) - Remove verbose WebSocket debug logging from client
207 lines
7.2 KiB
JavaScript
207 lines
7.2 KiB
JavaScript
const express = require('express');
|
|
const crypto = require('crypto');
|
|
const fetch = require('node-fetch');
|
|
const jwt = require('jsonwebtoken');
|
|
const { db } = require('../db/database');
|
|
const { JWT_SECRET } = require('../config');
|
|
|
|
const router = express.Router();
|
|
|
|
// In-memory state store for CSRF protection (state → { createdAt, redirectUri })
|
|
const pendingStates = new Map();
|
|
const STATE_TTL = 5 * 60 * 1000; // 5 minutes
|
|
|
|
// Cleanup expired states periodically
|
|
setInterval(() => {
|
|
const now = Date.now();
|
|
for (const [state, data] of pendingStates) {
|
|
if (now - data.createdAt > STATE_TTL) pendingStates.delete(state);
|
|
}
|
|
}, 60 * 1000);
|
|
|
|
// Read OIDC config from app_settings
|
|
function getOidcConfig() {
|
|
const get = (key) => db.prepare("SELECT value FROM app_settings WHERE key = ?").get(key)?.value || null;
|
|
const issuer = get('oidc_issuer');
|
|
const clientId = get('oidc_client_id');
|
|
const clientSecret = get('oidc_client_secret');
|
|
const displayName = get('oidc_display_name') || 'SSO';
|
|
if (!issuer || !clientId || !clientSecret) return null;
|
|
return { issuer: issuer.replace(/\/+$/, ''), clientId, clientSecret, displayName };
|
|
}
|
|
|
|
// Cache discovery document
|
|
let discoveryCache = null;
|
|
let discoveryCacheTime = 0;
|
|
const DISCOVERY_TTL = 60 * 60 * 1000; // 1 hour
|
|
|
|
async function discover(issuer) {
|
|
if (discoveryCache && Date.now() - discoveryCacheTime < DISCOVERY_TTL && discoveryCache._issuer === issuer) {
|
|
return discoveryCache;
|
|
}
|
|
const res = await fetch(`${issuer}/.well-known/openid-configuration`);
|
|
if (!res.ok) throw new Error('Failed to fetch OIDC discovery document');
|
|
const doc = await res.json();
|
|
doc._issuer = issuer;
|
|
discoveryCache = doc;
|
|
discoveryCacheTime = Date.now();
|
|
return doc;
|
|
}
|
|
|
|
function generateToken(user) {
|
|
return jwt.sign(
|
|
{ id: user.id, username: user.username, email: user.email, role: user.role },
|
|
JWT_SECRET,
|
|
{ expiresIn: '24h' }
|
|
);
|
|
}
|
|
|
|
function frontendUrl(path) {
|
|
const base = process.env.NODE_ENV === 'production' ? '' : 'http://localhost:5173';
|
|
return base + path;
|
|
}
|
|
|
|
// GET /api/auth/oidc/login — redirect to OIDC provider
|
|
router.get('/login', async (req, res) => {
|
|
const config = getOidcConfig();
|
|
if (!config) return res.status(400).json({ error: 'OIDC not configured' });
|
|
|
|
try {
|
|
const doc = await discover(config.issuer);
|
|
const state = crypto.randomBytes(32).toString('hex');
|
|
const proto = req.headers['x-forwarded-proto'] || req.protocol;
|
|
const host = req.headers['x-forwarded-host'] || req.headers.host;
|
|
const redirectUri = `${proto}://${host}/api/auth/oidc/callback`;
|
|
|
|
pendingStates.set(state, { createdAt: Date.now(), redirectUri });
|
|
|
|
const params = new URLSearchParams({
|
|
response_type: 'code',
|
|
client_id: config.clientId,
|
|
redirect_uri: redirectUri,
|
|
scope: 'openid email profile',
|
|
state,
|
|
});
|
|
|
|
res.redirect(`${doc.authorization_endpoint}?${params}`);
|
|
} catch (err) {
|
|
console.error('[OIDC] Login error:', err.message);
|
|
res.status(500).json({ error: 'OIDC login failed' });
|
|
}
|
|
});
|
|
|
|
// GET /api/auth/oidc/callback — handle provider callback
|
|
router.get('/callback', async (req, res) => {
|
|
const { code, state, error: oidcError } = req.query;
|
|
|
|
if (oidcError) {
|
|
console.error('[OIDC] Provider error:', oidcError);
|
|
return res.redirect(frontendUrl('/login?oidc_error=' + encodeURIComponent(oidcError)));
|
|
}
|
|
|
|
if (!code || !state) {
|
|
return res.redirect(frontendUrl('/login?oidc_error=missing_params'));
|
|
}
|
|
|
|
const pending = pendingStates.get(state);
|
|
if (!pending) {
|
|
return res.redirect(frontendUrl('/login?oidc_error=invalid_state'));
|
|
}
|
|
pendingStates.delete(state);
|
|
|
|
const config = getOidcConfig();
|
|
if (!config) return res.redirect(frontendUrl('/login?oidc_error=not_configured'));
|
|
|
|
try {
|
|
const doc = await discover(config.issuer);
|
|
|
|
// Exchange code for tokens
|
|
const tokenRes = await fetch(doc.token_endpoint, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
body: new URLSearchParams({
|
|
grant_type: 'authorization_code',
|
|
code,
|
|
redirect_uri: pending.redirectUri,
|
|
client_id: config.clientId,
|
|
client_secret: config.clientSecret,
|
|
}),
|
|
});
|
|
|
|
const tokenData = await tokenRes.json();
|
|
if (!tokenRes.ok || !tokenData.access_token) {
|
|
console.error('[OIDC] Token exchange failed:', tokenData);
|
|
return res.redirect(frontendUrl('/login?oidc_error=token_failed'));
|
|
}
|
|
|
|
// Get user info
|
|
const userInfoRes = await fetch(doc.userinfo_endpoint, {
|
|
headers: { Authorization: `Bearer ${tokenData.access_token}` },
|
|
});
|
|
const userInfo = await userInfoRes.json();
|
|
|
|
if (!userInfo.email) {
|
|
return res.redirect(frontendUrl('/login?oidc_error=no_email'));
|
|
}
|
|
|
|
const email = userInfo.email.toLowerCase();
|
|
const name = userInfo.name || userInfo.preferred_username || email.split('@')[0];
|
|
const sub = userInfo.sub;
|
|
|
|
// Find existing user by OIDC sub or email
|
|
let user = db.prepare('SELECT * FROM users WHERE oidc_sub = ? AND oidc_issuer = ?').get(sub, config.issuer);
|
|
if (!user) {
|
|
user = db.prepare('SELECT * FROM users WHERE LOWER(email) = ?').get(email);
|
|
}
|
|
|
|
if (user) {
|
|
// Existing user — link OIDC if not already linked
|
|
if (!user.oidc_sub) {
|
|
db.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ? WHERE id = ?').run(sub, config.issuer, user.id);
|
|
}
|
|
} else {
|
|
// New user — check if registration is allowed
|
|
const userCount = db.prepare('SELECT COUNT(*) as count FROM users').get().count;
|
|
const isFirstUser = userCount === 0;
|
|
|
|
if (!isFirstUser) {
|
|
const setting = db.prepare("SELECT value FROM app_settings WHERE key = 'allow_registration'").get();
|
|
if (setting?.value === 'false') {
|
|
return res.redirect(frontendUrl('/login?oidc_error=registration_disabled'));
|
|
}
|
|
}
|
|
|
|
// Create user (first user = admin)
|
|
const role = isFirstUser ? 'admin' : 'user';
|
|
// Generate a random password hash (user won't use password login)
|
|
const randomPass = crypto.randomBytes(32).toString('hex');
|
|
const bcrypt = require('bcryptjs');
|
|
const hash = bcrypt.hashSync(randomPass, 10);
|
|
|
|
// Ensure unique username
|
|
let username = name.replace(/[^a-zA-Z0-9_-]/g, '').substring(0, 30) || 'user';
|
|
const existing = db.prepare('SELECT id FROM users WHERE LOWER(username) = LOWER(?)').get(username);
|
|
if (existing) username = `${username}_${Date.now() % 10000}`;
|
|
|
|
const result = db.prepare(
|
|
'INSERT INTO users (username, email, password_hash, role, oidc_sub, oidc_issuer) VALUES (?, ?, ?, ?, ?, ?)'
|
|
).run(username, email, hash, role, sub, config.issuer);
|
|
|
|
user = { id: Number(result.lastInsertRowid), username, email, role };
|
|
}
|
|
|
|
// Update last login
|
|
db.prepare('UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?').run(user.id);
|
|
|
|
// Generate JWT and redirect to frontend
|
|
const token = generateToken(user);
|
|
// In dev mode, frontend runs on a different port
|
|
res.redirect(frontendUrl(`/login#token=${token}`));
|
|
} catch (err) {
|
|
console.error('[OIDC] Callback error:', err);
|
|
res.redirect(frontendUrl('/login?oidc_error=server_error'));
|
|
}
|
|
});
|
|
|
|
module.exports = router;
|