v2.4.0 — OIDC login, OpenStreetMap search, account management
Features: - Single Sign-On (OIDC) — login with Google, Apple, Authentik, Keycloak - OpenStreetMap place search as free fallback when no Google API key - Change password in user settings - Delete own account (with last-admin protection) - Last login column in admin user management - SSO badge and provider info in user settings - Google API key "Recommended" badge in admin panel Improvements: - API keys load correctly after page reload - Validate auto-saves keys before testing - Time format respects 12h/24h setting everywhere - Dark mode fixes for popups and backup buttons - Admin stats: removed photos, 4-column layout - Profile picture upload button on avatar overlay - TravelStats duplicate key fix - Backup panel dark mode support
This commit is contained in:
@@ -250,6 +250,9 @@ function initDb() {
|
||||
`ALTER TABLE trips ADD COLUMN is_archived INTEGER DEFAULT 0`,
|
||||
`ALTER TABLE categories ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE SET NULL`,
|
||||
`ALTER TABLE users ADD COLUMN avatar TEXT`,
|
||||
`ALTER TABLE users ADD COLUMN oidc_sub TEXT`,
|
||||
`ALTER TABLE users ADD COLUMN oidc_issuer TEXT`,
|
||||
`ALTER TABLE users ADD COLUMN last_login DATETIME`,
|
||||
];
|
||||
|
||||
// Recreate budget_items to allow NULL persons (SQLite can't ALTER NOT NULL)
|
||||
|
||||
@@ -76,7 +76,9 @@ const settingsRoutes = require('./routes/settings');
|
||||
const budgetRoutes = require('./routes/budget');
|
||||
const backupRoutes = require('./routes/backup');
|
||||
|
||||
const oidcRoutes = require('./routes/oidc');
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/auth/oidc', oidcRoutes);
|
||||
app.use('/api/trips', tripsRoutes);
|
||||
app.use('/api/trips/:tripId/days', daysRoutes);
|
||||
app.use('/api/trips/:tripId/places', placesRoutes);
|
||||
|
||||
@@ -11,7 +11,7 @@ router.use(authenticate, adminOnly);
|
||||
// GET /api/admin/users
|
||||
router.get('/users', (req, res) => {
|
||||
const users = db.prepare(
|
||||
'SELECT id, username, email, role, created_at, updated_at FROM users ORDER BY created_at DESC'
|
||||
'SELECT id, username, email, role, created_at, updated_at, last_login FROM users ORDER BY created_at DESC'
|
||||
).all();
|
||||
res.json({ users });
|
||||
});
|
||||
@@ -104,10 +104,31 @@ router.get('/stats', (req, res) => {
|
||||
const totalUsers = db.prepare('SELECT COUNT(*) as count FROM users').get().count;
|
||||
const totalTrips = db.prepare('SELECT COUNT(*) as count FROM trips').get().count;
|
||||
const totalPlaces = db.prepare('SELECT COUNT(*) as count FROM places').get().count;
|
||||
const totalPhotos = db.prepare('SELECT COUNT(*) as count FROM photos').get().count;
|
||||
const totalFiles = db.prepare('SELECT COUNT(*) as count FROM trip_files').get().count;
|
||||
|
||||
res.json({ totalUsers, totalTrips, totalPlaces, totalPhotos, totalFiles });
|
||||
res.json({ totalUsers, totalTrips, totalPlaces, totalFiles });
|
||||
});
|
||||
|
||||
// GET /api/admin/oidc — get OIDC config
|
||||
router.get('/oidc', (req, res) => {
|
||||
const get = (key) => db.prepare("SELECT value FROM app_settings WHERE key = ?").get(key)?.value || '';
|
||||
res.json({
|
||||
issuer: get('oidc_issuer'),
|
||||
client_id: get('oidc_client_id'),
|
||||
client_secret: get('oidc_client_secret'),
|
||||
display_name: get('oidc_display_name'),
|
||||
});
|
||||
});
|
||||
|
||||
// PUT /api/admin/oidc — update OIDC config
|
||||
router.put('/oidc', (req, res) => {
|
||||
const { issuer, client_id, client_secret, display_name } = req.body;
|
||||
const set = (key, val) => db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)").run(key, val || '');
|
||||
set('oidc_issuer', issuer);
|
||||
set('oidc_client_id', client_id);
|
||||
set('oidc_client_secret', client_secret);
|
||||
set('oidc_display_name', display_name);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// POST /api/admin/save-demo-baseline (demo mode only)
|
||||
|
||||
@@ -67,10 +67,19 @@ router.get('/app-config', (req, res) => {
|
||||
const allowRegistration = userCount === 0 || (setting?.value ?? 'true') === 'true';
|
||||
const isDemo = process.env.DEMO_MODE === 'true';
|
||||
const { version } = require('../../package.json');
|
||||
const hasGoogleKey = !!db.prepare("SELECT maps_api_key FROM users WHERE role = 'admin' AND maps_api_key IS NOT NULL AND maps_api_key != '' LIMIT 1").get();
|
||||
const oidcDisplayName = db.prepare("SELECT value FROM app_settings WHERE key = 'oidc_display_name'").get()?.value || null;
|
||||
const oidcConfigured = !!(
|
||||
db.prepare("SELECT value FROM app_settings WHERE key = 'oidc_issuer'").get()?.value &&
|
||||
db.prepare("SELECT value FROM app_settings WHERE key = 'oidc_client_id'").get()?.value
|
||||
);
|
||||
res.json({
|
||||
allow_registration: isDemo ? false : allowRegistration,
|
||||
has_users: userCount > 0,
|
||||
version,
|
||||
has_maps_key: hasGoogleKey,
|
||||
oidc_configured: oidcConfigured,
|
||||
oidc_display_name: oidcConfigured ? (oidcDisplayName || 'SSO') : undefined,
|
||||
demo_mode: isDemo,
|
||||
demo_email: isDemo ? 'demo@nomad.app' : undefined,
|
||||
demo_password: isDemo ? 'demo12345' : undefined,
|
||||
@@ -158,6 +167,7 @@ router.post('/login', authLimiter, (req, res) => {
|
||||
return res.status(401).json({ error: 'Ungültige E-Mail oder Passwort' });
|
||||
}
|
||||
|
||||
db.prepare('UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?').run(user.id);
|
||||
const token = generateToken(user);
|
||||
const { password_hash, maps_api_key, openweather_api_key, unsplash_api_key, ...userWithoutSensitive } = user;
|
||||
|
||||
@@ -167,7 +177,7 @@ router.post('/login', authLimiter, (req, res) => {
|
||||
// GET /api/auth/me
|
||||
router.get('/me', authenticate, (req, res) => {
|
||||
const user = db.prepare(
|
||||
'SELECT id, username, email, role, avatar, created_at FROM users WHERE id = ?'
|
||||
'SELECT id, username, email, role, avatar, oidc_issuer, created_at FROM users WHERE id = ?'
|
||||
).get(req.user.id);
|
||||
|
||||
if (!user) {
|
||||
@@ -177,6 +187,30 @@ router.get('/me', authenticate, (req, res) => {
|
||||
res.json({ user: { ...user, avatar_url: avatarUrl(user) } });
|
||||
});
|
||||
|
||||
// PUT /api/auth/me/password
|
||||
router.put('/me/password', authenticate, (req, res) => {
|
||||
const { new_password } = req.body;
|
||||
if (!new_password) return res.status(400).json({ error: 'New password is required' });
|
||||
if (new_password.length < 8) return res.status(400).json({ error: 'Password must be at least 8 characters' });
|
||||
|
||||
const hash = bcrypt.hashSync(new_password, 10);
|
||||
db.prepare('UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(hash, req.user.id);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// DELETE /api/auth/me — delete own account
|
||||
router.delete('/me', authenticate, (req, res) => {
|
||||
// Prevent deleting last admin
|
||||
if (req.user.role === 'admin') {
|
||||
const adminCount = db.prepare("SELECT COUNT(*) as count FROM users WHERE role = 'admin'").get().count;
|
||||
if (adminCount <= 1) {
|
||||
return res.status(400).json({ error: 'Cannot delete the last admin account' });
|
||||
}
|
||||
}
|
||||
db.prepare('DELETE FROM users WHERE id = ?').run(req.user.id);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// PUT /api/auth/me/maps-key
|
||||
router.put('/me/maps-key', authenticate, (req, res) => {
|
||||
const { maps_api_key } = req.body;
|
||||
|
||||
@@ -17,6 +17,34 @@ function getMapsKey(userId) {
|
||||
const photoCache = new Map();
|
||||
const PHOTO_TTL = 12 * 60 * 60 * 1000; // 12 hours
|
||||
|
||||
// Nominatim search (OpenStreetMap) — free fallback when no Google API key
|
||||
async function searchNominatim(query, lang) {
|
||||
const params = new URLSearchParams({
|
||||
q: query,
|
||||
format: 'json',
|
||||
addressdetails: '1',
|
||||
limit: '10',
|
||||
'accept-language': lang || 'en',
|
||||
});
|
||||
const response = await fetch(`https://nominatim.openstreetmap.org/search?${params}`, {
|
||||
headers: { 'User-Agent': 'NOMAD Travel Planner (https://github.com/mauriceboe/NOMAD)' },
|
||||
});
|
||||
if (!response.ok) throw new Error('Nominatim API error');
|
||||
const data = await response.json();
|
||||
return data.map(item => ({
|
||||
google_place_id: null,
|
||||
osm_id: `${item.osm_type}/${item.osm_id}`,
|
||||
name: item.name || item.display_name?.split(',')[0] || '',
|
||||
address: item.display_name || '',
|
||||
lat: parseFloat(item.lat) || null,
|
||||
lng: parseFloat(item.lon) || null,
|
||||
rating: null,
|
||||
website: null,
|
||||
phone: null,
|
||||
source: 'openstreetmap',
|
||||
}));
|
||||
}
|
||||
|
||||
// POST /api/maps/search
|
||||
router.post('/search', authenticate, async (req, res) => {
|
||||
const { query } = req.body;
|
||||
@@ -24,8 +52,16 @@ router.post('/search', authenticate, async (req, res) => {
|
||||
if (!query) return res.status(400).json({ error: 'Suchanfrage ist erforderlich' });
|
||||
|
||||
const apiKey = getMapsKey(req.user.id);
|
||||
|
||||
// No Google API key → use Nominatim (OpenStreetMap)
|
||||
if (!apiKey) {
|
||||
return res.status(400).json({ error: 'Google Maps API-Schlüssel nicht konfiguriert. Bitte in den Einstellungen hinzufügen.' });
|
||||
try {
|
||||
const places = await searchNominatim(query, req.query.lang);
|
||||
return res.json({ places, source: 'openstreetmap' });
|
||||
} catch (err) {
|
||||
console.error('Nominatim search error:', err);
|
||||
return res.status(500).json({ error: 'Fehler bei der OpenStreetMap Suche' });
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -54,9 +90,10 @@ router.post('/search', authenticate, async (req, res) => {
|
||||
rating: p.rating || null,
|
||||
website: p.websiteUri || null,
|
||||
phone: p.nationalPhoneNumber || null,
|
||||
source: 'google',
|
||||
}));
|
||||
|
||||
res.json({ places });
|
||||
res.json({ places, source: 'google' });
|
||||
} catch (err) {
|
||||
console.error('Maps search error:', err);
|
||||
res.status(500).json({ error: 'Fehler bei der Google Places Suche' });
|
||||
|
||||
206
server/src/routes/oidc.js
Normal file
206
server/src/routes/oidc.js
Normal file
@@ -0,0 +1,206 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user