diff --git a/README.md b/README.md index 9cdf9fc..3ee3189 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,8 @@ A self-hosted, real-time collaborative travel planner for organizing trips with - **Real-Time Collaboration** — Plan together via WebSocket live sync — changes appear instantly across all connected users - **Interactive Map** — Leaflet map with marker clustering, route visualization, and customizable tile sources -- **Google Places Integration** — Search places, auto-fill details including ratings, reviews, opening hours, and photos (requires API key) +- **Place Search** — Search via Google Places (with photos, ratings, opening hours) or OpenStreetMap (free, no API key needed) +- **Single Sign-On (OIDC)** — Login with Google, Apple, Authentik, Keycloak, or any OIDC provider - **Drag & Drop Planner** — Organize places into day plans with reordering and cross-day moves - **Weather Forecasts** — Current weather and 5-day forecasts with smart caching (requires API key) - **Budget Tracking** — Category-based expenses with pie chart, per-person/per-day splitting, and multi-currency support diff --git a/client/src/App.jsx b/client/src/App.jsx index 35ff8b2..1e5beea 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -55,7 +55,7 @@ function RootRedirect() { } export default function App() { - const { loadUser, token, isAuthenticated, demoMode, setDemoMode } = useAuthStore() + const { loadUser, token, isAuthenticated, demoMode, setDemoMode, setHasMapsKey } = useAuthStore() const { loadSettings } = useSettingsStore() useEffect(() => { @@ -64,6 +64,7 @@ export default function App() { } authApi.getAppConfig().then(config => { if (config?.demo_mode) setDemoMode(true) + if (config?.has_maps_key !== undefined) setHasMapsKey(config.has_maps_key) }).catch(() => {}) }, []) diff --git a/client/src/api/client.js b/client/src/api/client.js index 79a8ad7..179c5d8 100644 --- a/client/src/api/client.js +++ b/client/src/api/client.js @@ -53,6 +53,8 @@ export const authApi = { updateAppSettings: (data) => apiClient.put('/auth/app-settings', data).then(r => r.data), validateKeys: () => apiClient.get('/auth/validate-keys').then(r => r.data), travelStats: () => apiClient.get('/auth/travel-stats').then(r => r.data), + changePassword: (data) => apiClient.put('/auth/me/password', data).then(r => r.data), + deleteOwnAccount: () => apiClient.delete('/auth/me').then(r => r.data), demoLogin: () => apiClient.post('/auth/demo-login').then(r => r.data), } @@ -123,6 +125,8 @@ export const adminApi = { deleteUser: (id) => apiClient.delete(`/admin/users/${id}`).then(r => r.data), stats: () => apiClient.get('/admin/stats').then(r => r.data), saveDemoBaseline: () => apiClient.post('/admin/save-demo-baseline').then(r => r.data), + getOidc: () => apiClient.get('/admin/oidc').then(r => r.data), + updateOidc: (data) => apiClient.put('/admin/oidc', data).then(r => r.data), } export const mapsApi = { diff --git a/client/src/components/Admin/BackupPanel.jsx b/client/src/components/Admin/BackupPanel.jsx index 8cb7882..ac08ec4 100644 --- a/client/src/components/Admin/BackupPanel.jsx +++ b/client/src/components/Admin/BackupPanel.jsx @@ -191,7 +191,7 @@ export default function BackupPanel() { @@ -307,7 +307,7 @@ export default function BackupPanel() { onClick={() => handleAutoSettingsChange('interval', opt.value)} className={`px-4 py-2 rounded-lg text-sm font-medium border transition-colors ${ autoSettings.interval === opt.value - ? 'bg-slate-700 text-white border-slate-700' + ? 'bg-slate-900 dark:bg-slate-100 text-white dark:text-slate-900 border-slate-700' : 'bg-white text-gray-600 border-gray-200 hover:border-gray-300' }`} > @@ -327,7 +327,7 @@ export default function BackupPanel() { onClick={() => handleAutoSettingsChange('keep_days', opt.value)} className={`px-4 py-2 rounded-lg text-sm font-medium border transition-colors ${ autoSettings.keep_days === opt.value - ? 'bg-slate-700 text-white border-slate-700' + ? 'bg-slate-900 dark:bg-slate-100 text-white dark:text-slate-900 border-slate-700' : 'bg-white text-gray-600 border-gray-200 hover:border-gray-300' }`} > @@ -344,7 +344,7 @@ export default function BackupPanel() { @@ -263,7 +272,6 @@ export default function PlaceFormModal({ )} - )} {/* Name */}
diff --git a/client/src/components/Planner/PlaceFormModal.jsx b/client/src/components/Planner/PlaceFormModal.jsx index 764539f..bcd8636 100644 --- a/client/src/components/Planner/PlaceFormModal.jsx +++ b/client/src/components/Planner/PlaceFormModal.jsx @@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react' import Modal from '../shared/Modal' import CustomSelect from '../shared/CustomSelect' import { mapsApi } from '../../api/client' +import { useAuthStore } from '../../store/authStore' import { useToast } from '../shared/Toast' import { Search } from 'lucide-react' import { useTranslation } from '../../i18n' @@ -44,6 +45,7 @@ export default function PlaceFormModal({ const [isSaving, setIsSaving] = useState(false) const toast = useToast() const { t, language } = useTranslation() + const { hasMapsKey } = useAuthStore() useEffect(() => { if (place) { @@ -139,8 +141,13 @@ export default function PlaceFormModal({ size="lg" >
- {/* Google Maps Search */} + {/* Place Search */}
+ {!hasMapsKey && ( +

+ {t('places.osmActive')} +

+ )}
s.settings.time_format) === '12h' const TABS = [ { id: 'users', label: t('admin.tabs.users') }, { id: 'categories', label: t('admin.tabs.categories') }, @@ -30,6 +32,10 @@ export default function AdminPage() { const [showCreateUser, setShowCreateUser] = useState(false) const [createForm, setCreateForm] = useState({ username: '', email: '', password: '', role: 'user' }) + // OIDC config + const [oidcConfig, setOidcConfig] = useState({ issuer: '', client_id: '', client_secret: '', display_name: '' }) + const [savingOidc, setSavingOidc] = useState(false) + // Registration toggle const [allowRegistration, setAllowRegistration] = useState(true) @@ -49,6 +55,7 @@ export default function AdminPage() { loadData() loadAppConfig() loadApiKeys() + adminApi.getOidc().then(setOidcConfig).catch(() => {}) }, []) const loadData = async () => { @@ -238,12 +245,11 @@ export default function AdminPage() { {/* Stats */} {stats && ( -
+
{[ { label: t('admin.stats.users'), value: stats.totalUsers, icon: Users }, { label: t('admin.stats.trips'), value: stats.totalTrips, icon: Briefcase }, { label: t('admin.stats.places'), value: stats.totalPlaces, icon: Map }, - { label: t('admin.stats.photos'), value: stats.totalPhotos || 0, icon: Camera }, { label: t('admin.stats.files'), value: stats.totalFiles || 0, icon: FileText }, ].map(({ label, value, icon: Icon }) => (
@@ -303,6 +309,7 @@ export default function AdminPage() { {t('admin.table.email')} {t('admin.table.role')} {t('admin.table.created')} + {t('admin.table.lastLogin')} {t('admin.table.actions')} @@ -334,7 +341,10 @@ export default function AdminPage() { - {new Date(u.created_at).toLocaleDateString('de-DE')} + {new Date(u.created_at).toLocaleDateString(locale)} + + + {u.last_login ? new Date(u.last_login).toLocaleDateString(locale, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit', hour12 }) : '—'}
@@ -403,7 +413,10 @@ export default function AdminPage() {
{/* Google Maps Key */}
- +
-

{t('admin.mapsKeyHint')}

+

{t('admin.mapsKeyHintLong')}

{validation.maps === true && (

@@ -511,6 +524,73 @@ export default function AdminPage() {

+ + {/* OIDC / SSO Configuration */} +
+
+

{t('admin.oidcTitle')}

+

{t('admin.oidcSubtitle')}

+
+
+
+ + setOidcConfig(c => ({ ...c, display_name: e.target.value }))} + placeholder='z.B. Google, Authentik, Keycloak' + className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent" + /> +
+
+ + setOidcConfig(c => ({ ...c, issuer: e.target.value }))} + placeholder='https://accounts.google.com' + className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent" + /> +

{t('admin.oidcIssuerHint')}

+
+
+ + setOidcConfig(c => ({ ...c, client_id: e.target.value }))} + className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent" + /> +
+
+ + setOidcConfig(c => ({ ...c, client_secret: e.target.value }))} + className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent" + /> +
+ +
+
)} diff --git a/client/src/pages/LoginPage.jsx b/client/src/pages/LoginPage.jsx index ce7337e..3d68c9a 100644 --- a/client/src/pages/LoginPage.jsx +++ b/client/src/pages/LoginPage.jsx @@ -4,7 +4,7 @@ import { useAuthStore } from '../store/authStore' import { useSettingsStore } from '../store/settingsStore' import { useTranslation } from '../i18n' import { authApi } from '../api/client' -import { Plane, Eye, EyeOff, Mail, Lock, MapPin, Calendar, Package, User, Globe, Zap, Users, Wallet, Map, CheckSquare, BookMarked, FolderOpen, Route } from 'lucide-react' +import { Plane, Eye, EyeOff, Mail, Lock, MapPin, Calendar, Package, User, Globe, Zap, Users, Wallet, Map, CheckSquare, BookMarked, FolderOpen, Route, Shield } from 'lucide-react' export default function LoginPage() { const { t, language } = useTranslation() @@ -28,6 +28,28 @@ export default function LoginPage() { if (!config.has_users) setMode('register') } }) + + // Handle OIDC callback token + const params = new URLSearchParams(window.location.search) + const token = params.get('token') + const oidcError = params.get('oidc_error') + if (token) { + localStorage.setItem('auth_token', token) + window.history.replaceState({}, '', '/login') + login.__fromOidc = true + navigate('/dashboard') + window.location.reload() + } + if (oidcError) { + const errorMessages = { + registration_disabled: language === 'de' ? 'Registrierung ist deaktiviert. Kontaktiere den Administrator.' : 'Registration is disabled. Contact your administrator.', + no_email: language === 'de' ? 'Keine E-Mail vom Provider erhalten.' : 'No email received from provider.', + token_failed: language === 'de' ? 'Authentifizierung fehlgeschlagen.' : 'Authentication failed.', + invalid_state: language === 'de' ? 'Ungueltige Sitzung. Bitte erneut versuchen.' : 'Invalid session. Please try again.', + } + setError(errorMessages[oidcError] || oidcError) + window.history.replaceState({}, '', '/login') + } }, []) const handleDemoLogin = async () => { @@ -339,6 +361,33 @@ export default function LoginPage() { )}
+ {/* OIDC / SSO login button */} + {appConfig?.oidc_configured && ( + <> +
+ + {/* Change Password */} +
+ +
+ setNewPassword(e.target.value)} + placeholder={t('settings.newPassword')} + className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent" + /> + setConfirmPassword(e.target.value)} + placeholder={t('settings.confirmPassword')} + className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent" + /> + +
+
+
- {user?.avatar_url ? ( - - ) : ( -
- {user?.username?.charAt(0).toUpperCase()} -
- )} -
+
+ {user?.avatar_url ? ( + + ) : ( +
+ {user?.username?.charAt(0).toUpperCase()} +
+ )} + + + {user?.avatar_url && ( + + )} +
+
{user?.role === 'admin' ? <> {t('settings.roleAdmin')} : t('settings.roleUser')} -
-
- - - {user?.avatar_url && ( - + {user?.oidc_issuer && ( + + SSO + )}
+ {user?.oidc_issuer && ( +

+ {t('settings.oidcLinked')} {user.oidc_issuer.replace('https://', '').replace(/\/+$/, '')} +

+ )}
- +
+ + +
+ + {/* Delete Account Confirmation */} + {showDeleteConfirm === 'blocked' && ( +
setShowDeleteConfirm(false)}> +
e.stopPropagation()}> +
+
+ +
+

{t('settings.deleteBlockedTitle')}

+
+

+ {t('settings.deleteBlockedMessage')} +

+
+ +
+
+
+ )} + + {showDeleteConfirm === true && ( +
setShowDeleteConfirm(false)}> +
e.stopPropagation()}> +
+
+ +
+

{t('settings.deleteAccountTitle')}

+
+

+ {t('settings.deleteAccountWarning')} +

+
+ + +
+
+
+ )}
diff --git a/client/src/store/authStore.js b/client/src/store/authStore.js index 2d310b6..1ea717e 100644 --- a/client/src/store/authStore.js +++ b/client/src/store/authStore.js @@ -9,6 +9,7 @@ export const useAuthStore = create((set, get) => ({ isLoading: false, error: null, demoMode: localStorage.getItem('demo_mode') === 'true', + hasMapsKey: false, login: async (email, password) => { set({ isLoading: true, error: null }) @@ -137,6 +138,8 @@ export const useAuthStore = create((set, get) => ({ set({ demoMode: val }) }, + setHasMapsKey: (val) => set({ hasMapsKey: val }), + demoLogin: async () => { set({ isLoading: true, error: null }) try { diff --git a/server/package-lock.json b/server/package-lock.json index 638de08..23a5da1 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -1,12 +1,12 @@ { "name": "nomad-server", - "version": "2.0.0", + "version": "2.3.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nomad-server", - "version": "2.0.0", + "version": "2.3.4", "dependencies": { "archiver": "^6.0.1", "bcryptjs": "^2.4.3", diff --git a/server/package.json b/server/package.json index cbcb588..d038eed 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "nomad-server", - "version": "2.3.5", + "version": "2.4.0", "main": "src/index.js", "scripts": { "start": "node --experimental-sqlite src/index.js", diff --git a/server/src/db/database.js b/server/src/db/database.js index c0f661f..87311cc 100644 --- a/server/src/db/database.js +++ b/server/src/db/database.js @@ -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) diff --git a/server/src/index.js b/server/src/index.js index ba1cd38..7bb320a 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -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); diff --git a/server/src/routes/admin.js b/server/src/routes/admin.js index d74c2ff..55de8df 100644 --- a/server/src/routes/admin.js +++ b/server/src/routes/admin.js @@ -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) diff --git a/server/src/routes/auth.js b/server/src/routes/auth.js index f8da4ee..1bf65d1 100644 --- a/server/src/routes/auth.js +++ b/server/src/routes/auth.js @@ -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; diff --git a/server/src/routes/maps.js b/server/src/routes/maps.js index 149db1b..e67924b 100644 --- a/server/src/routes/maps.js +++ b/server/src/routes/maps.js @@ -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' }); diff --git a/server/src/routes/oidc.js b/server/src/routes/oidc.js new file mode 100644 index 0000000..96b7a4d --- /dev/null +++ b/server/src/routes/oidc.js @@ -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;