From a6b2e8bb879f92deab9294c2a2c439de4080cce2 Mon Sep 17 00:00:00 2001 From: null Date: Thu, 4 Jun 2026 03:53:38 -0500 Subject: [PATCH] fix: login mode card update, OIDC service improvements, auth middleware refinements --- client/components/admin/LoginModeCard.jsx | 209 ++++++++++++++++------ client/pages/AdminPage.jsx | 5 +- client/pages/LoginPage.jsx | 16 +- middleware/requireAuth.js | 25 ++- routes/auth.js | 9 +- routes/authOidc.js | 2 +- services/authService.js | 3 +- services/oidcService.js | 26 +-- 8 files changed, 213 insertions(+), 82 deletions(-) diff --git a/client/components/admin/LoginModeCard.jsx b/client/components/admin/LoginModeCard.jsx index 35a75df..84f57ca 100644 --- a/client/components/admin/LoginModeCard.jsx +++ b/client/components/admin/LoginModeCard.jsx @@ -12,23 +12,29 @@ import { AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle, AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction, } from '@/components/ui/alert-dialog'; +import { LogIn, UserCheck, ShieldCheck } from 'lucide-react'; -export default function LoginModeCard({ users }) { +export default function LoginModeCard({ users, onModeChange }) { const [modeData, setModeData] = useState(null); const [loading, setLoading] = useState(true); const [loadError, setLoadError] = useState(''); const [saving, setSaving] = useState(false); + const [selected, setSelected] = useState('multi'); // local UI selection const [selectedUser, setSelectedUser] = useState(''); - const [confirmSingle, setConfirmSingle] = useState(false); - const [pendingUserId, setPendingUserId] = useState(null); useEffect(() => { api.authModeConfig() - .then(d => { setModeData(d); setSelectedUser(d.default_user_id?.toString() || ''); }) + .then(d => { + setModeData(d); + const mode = d.auth_mode === 'single' ? 'single' : 'multi'; + setSelected(mode); + setSelectedUser(d.default_user_id?.toString() || ''); + onModeChange?.(mode); + }) .catch(err => setLoadError(err.message || 'Failed to load login mode config')) .finally(() => setLoading(false)); - }, []); + }, []); // eslint-disable-line const doSetMode = async (mode, userId) => { setSaving(true); @@ -39,77 +45,164 @@ export default function LoginModeCard({ users }) { }); const d = await api.authModeConfig(); setModeData(d); - toast.success(mode === 'single' ? 'Single-user mode enabled.' : 'Login requirement restored.'); + const resolved = d.auth_mode === 'single' ? 'single' : 'multi'; + setSelected(resolved); + setSelectedUser(d.default_user_id?.toString() || ''); + onModeChange?.(resolved); + toast.success(mode === 'single' ? 'No Login mode enabled.' : 'Login requirement restored.'); } catch (err) { - toast.error(err.message || 'Failed to update auth mode.'); + toast.error(err.message || 'Failed to update login mode.'); + // revert UI selection on error + setSelected(modeData?.auth_mode === 'single' ? 'single' : 'multi'); } finally { setSaving(false); } }; - const handleRequestSingle = () => { - if (!selectedUser) { toast.error('Select a user first.'); return; } - setPendingUserId(selectedUser); - setConfirmSingle(true); - }; - - const handleConfirmSingle = () => { - setConfirmSingle(false); - doSetMode('single', pendingUserId); + const handleSave = () => { + if (selected === 'single') { + if (!selectedUser) { toast.error('Select a user account first.'); return; } + setConfirmSingle(true); + } else { + doSetMode('multi', null); + } }; if (loading) return Loading…; if (loadError) return {loadError}; - const isMulti = !modeData || modeData.auth_mode === 'multi'; - const activeUser = users?.find(u => u.id === modeData?.default_user_id); - const selectedUsername = users?.find(u => u.id.toString() === selectedUser)?.username ?? selectedUser; + const currentMode = modeData?.auth_mode === 'single' ? 'single' : 'multi'; + const isDirty = selected !== currentMode || (selected === 'single' && selectedUser !== (modeData?.default_user_id?.toString() || '')); + const activeUser = users?.find(u => u.id === modeData?.default_user_id); + const pendingUsername = users?.find(u => u.id.toString() === selectedUser)?.username ?? selectedUser; + const regularUsers = (users || []).filter(u => u.role === 'user'); return ( <>
- Login Mode - - {isMulti ? 'Multi-user' : 'Single-user'} +
+ Login Mode +

+ Choose how users access this app. +

+
+ + {currentMode === 'single' ? 'No Login' : 'Require Login'}
- - {isMulti ? ( - <> -

- Single-user mode bypasses the login screen and automatically signs in as the selected user. -

-
- - + + + {/* Option: Require Login */} + - - ) : ( - <> -

- Currently auto-signing in as{' '} - {activeUser?.username ?? '—'}. - Restoring login requirement will require all users to sign in manually. +

+
+ + Require Login +
+

+ Users must sign in with a username and password, or via OIDC. Authentication methods are configured below. +

+ {currentMode === 'multi' && ( +

Currently active

+ )} +
+
+ + + {/* Option: No Login */} + + + {/* User selector — shown only when No Login is selected */} + {selected === 'single' && ( +
+ + + {regularUsers.length === 0 && ( +

No regular user accounts found. Create a user account first.

+ )} +
+ )} + + {/* Security note for single mode */} + {selected === 'single' && ( +
+ +

+ Only use this on trusted private networks. Anyone with access to the URL is signed in automatically.

- - +
+ )} + + {isDirty && ( + )}
@@ -117,17 +210,17 @@ export default function LoginModeCard({ users }) { - Enable Single-User Mode? + Enable No Login Mode? Anyone who opens the app will be automatically signed in as{' '} - {selectedUsername}. + {pendingUsername}. The admin login still requires a password. Cancel - - Enable Single-User Mode + { setConfirmSingle(false); doSetMode('single', selectedUser); }}> + Enable No Login Mode diff --git a/client/pages/AdminPage.jsx b/client/pages/AdminPage.jsx index 2e7dcc3..130cb17 100644 --- a/client/pages/AdminPage.jsx +++ b/client/pages/AdminPage.jsx @@ -19,6 +19,7 @@ export default function AdminPage() { const [hasUsers, setHasUsers] = useState(null); const [loadError, setLoadError] = useState(''); const [users, setUsers] = useState([]); + const [authMode, setAuthMode] = useState('multi'); const loadMe = useCallback(async () => { try { @@ -85,8 +86,8 @@ export default function AdminPage() { - - + + {authMode !== 'single' && } diff --git a/client/pages/LoginPage.jsx b/client/pages/LoginPage.jsx index 4bc0e08..505a01e 100644 --- a/client/pages/LoginPage.jsx +++ b/client/pages/LoginPage.jsx @@ -22,7 +22,7 @@ export default function LoginPage() { const [password, setPassword] = useState(''); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); - const [authMode, setAuthMode] = useState({ local_enabled: true, oidc_enabled: false }); + const [authMode, setAuthMode] = useState(null); // null = still loading const [pendingUser, setPendingUser] = useState(null); const [showChangePw, setShowChangePw] = useState(false); @@ -78,9 +78,9 @@ export default function LoginPage() { } }; - const localEnabled = authMode.local_enabled !== false; - const oidcEnabled = !!authMode.oidc_enabled && !!authMode.oidc_login_url; - const providerName = authMode.oidc_provider_name || 'authentik'; + const localEnabled = authMode?.local_enabled !== false; + const oidcEnabled = !!authMode?.oidc_enabled && !!authMode?.oidc_login_url; + const providerName = authMode?.oidc_provider_name || 'authentik'; const isAuthentikProvider = providerName.toLowerCase().includes('authentik'); const handleChangePassword = async (e) => { @@ -139,7 +139,12 @@ export default function LoginPage() { /> - {/* Card */} + {/* Card — hidden while auth mode is still resolving to avoid flash in single-user mode */} + {authMode === null ? ( +
+ Loading… +
+ ) : (
@@ -238,6 +243,7 @@ export default function LoginPage() {
+ )} {/* end authMode !== null */} {/* Change Password Dialog */} diff --git a/middleware/requireAuth.js b/middleware/requireAuth.js index c4ff540..c212493 100644 --- a/middleware/requireAuth.js +++ b/middleware/requireAuth.js @@ -1,4 +1,5 @@ -const { getSessionUser, COOKIE_NAME, publicUser } = require('../services/authService'); +const crypto = require('crypto'); +const { getSessionUser, COOKIE_NAME, SINGLE_COOKIE_NAME, cookieOpts, publicUser, recordLogin } = require('../services/authService'); const { getDb, getSetting } = require('../db/database'); const { standardizeError } = require('./errorFormatter'); @@ -24,6 +25,28 @@ function requireAuth(req, res, next) { if (singleUser) { req.user = singleUser; req.singleUserMode = true; + + // Track logins via a presence cookie so login history works without a real session. + // A new cookie = new browser/device visit → record a login entry. + const existing = req.cookies?.[SINGLE_COOKIE_NAME]; + if (existing) { + req.singleSessionId = existing; + } else { + const sessionId = crypto.randomUUID(); + res.cookie(SINGLE_COOKIE_NAME, sessionId, { + httpOnly: true, + sameSite: 'strict', + secure: cookieOpts(req).secure, + maxAge: 30 * 86400 * 1000, // 30 days + path: '/', + }); + req.singleSessionId = sessionId; + // Non-blocking — don't delay the first request + setImmediate(() => { + try { recordLogin(singleUser.id, req.ip, req.get('user-agent'), sessionId); } catch {} + }); + } + return next(); } diff --git a/routes/auth.js b/routes/auth.js index a6da000..70f3879 100644 --- a/routes/auth.js +++ b/routes/auth.js @@ -10,7 +10,7 @@ function getAppVersion() { } const { getDb, getSetting, setSetting } = require('../db/database'); -const { login, logout, hashPassword, cookieOpts, COOKIE_NAME, rotateSessionId, invalidateOtherSessions, recordLogin, recordFailedLogin } = require('../services/authService'); +const { login, logout, hashPassword, cookieOpts, COOKIE_NAME, SINGLE_COOKIE_NAME, rotateSessionId, invalidateOtherSessions, recordLogin, recordFailedLogin } = require('../services/authService'); const { decryptSecret } = require('../services/encryptionService'); const { getCsrfToken } = require('../middleware/csrf'); const { requireAuth, requireAdmin } = require('../middleware/requireAuth'); @@ -124,8 +124,11 @@ router.get('/login-history', requireAuth, (req, res) => { try { return decryptSecret(v); } catch { return null; } }; - // Compute fingerprint of the current session cookie to mark "this session" - const currentCookie = req.cookies?.[COOKIE_NAME]; + // Compute fingerprint of the current session cookie to mark "this session". + // Single-user mode has no COOKIE_NAME — use the presence cookie instead. + const currentCookie = req.singleUserMode + ? req.cookies?.[SINGLE_COOKIE_NAME] + : req.cookies?.[COOKIE_NAME]; const currentFingerprint = currentCookie ? require('crypto').createHash('sha256').update(currentCookie).digest('hex').slice(0, 32) : null; diff --git a/routes/authOidc.js b/routes/authOidc.js index e524c00..c298a0a 100644 --- a/routes/authOidc.js +++ b/routes/authOidc.js @@ -98,7 +98,7 @@ router.get('/callback', async (req, res) => { const session = await createSession(user.id); if (!session) throw new Error('Failed to create local session after OIDC login'); - recordLogin(user.id, req.ip, req.get('user-agent')); + recordLogin(user.id, req.ip, req.get('user-agent'), session.sessionId); res.cookie(COOKIE_NAME, session.sessionId, cookieOpts(req)); res.redirect(savedState.redirect_to || '/'); } catch (err) { diff --git a/services/authService.js b/services/authService.js index 7e68377..341fe65 100644 --- a/services/authService.js +++ b/services/authService.js @@ -5,6 +5,7 @@ const { buildDeviceFingerprint } = require('./loginFingerprint'); const { encryptSecret, decryptSecret } = require('./encryptionService'); const COOKIE_NAME = 'bt_session'; +const SINGLE_COOKIE_NAME = 'bt_single_session'; const SESSION_DAYS = 7; function envFlag(name) { @@ -358,4 +359,4 @@ function invalidateOtherSessions(userId, keepSessionId) { return result; } -module.exports = { login, logout, createSession, getSessionUser, hashPassword, publicUser, pruneExpiredSessions, cookieOpts, COOKIE_NAME, SESSION_DAYS, rotateSessionId, invalidateOtherSessions, recordLogin, recordFailedLogin }; +module.exports = { login, logout, createSession, getSessionUser, hashPassword, publicUser, pruneExpiredSessions, cookieOpts, COOKIE_NAME, SINGLE_COOKIE_NAME, SESSION_DAYS, rotateSessionId, invalidateOtherSessions, recordLogin, recordFailedLogin }; diff --git a/services/oidcService.js b/services/oidcService.js index 6791fa3..5ead3f8 100644 --- a/services/oidcService.js +++ b/services/oidcService.js @@ -318,17 +318,21 @@ function applyAuthModeSettings(body = {}) { ? trimOrEmpty(oidc_admin_group) : getAdminOidcSettings().oidc_admin_group; - if (!nextLocal && !nextOidc) { - throw serviceError('Cannot disable all login methods. At least one must remain enabled.'); - } - if (!nextLocal && !oidcConfigured) { - throw serviceError('Cannot disable local login until authentik/OIDC is fully configured.'); - } - if (!nextLocal && !nextAdminGroup) { - throw serviceError('Cannot disable local login until an OIDC admin group is configured.'); - } - if (nextOidc && !oidcConfigured) { - throw serviceError('Cannot enable OIDC login until issuer URL, client ID, client secret, and redirect URI are configured.'); + // Single-user mode bypasses the login screen entirely — lockout checks don't apply + const isSingleMode = auth_mode === 'single' || (auth_mode === undefined && getSetting('auth_mode') === 'single'); + if (!isSingleMode) { + if (!nextLocal && !nextOidc) { + throw serviceError('Cannot disable all login methods. At least one must remain enabled.'); + } + if (!nextLocal && !oidcConfigured) { + throw serviceError('Cannot disable local login until authentik/OIDC is fully configured.'); + } + if (!nextLocal && !nextAdminGroup) { + throw serviceError('Cannot disable local login until an OIDC admin group is configured.'); + } + if (nextOidc && !oidcConfigured) { + throw serviceError('Cannot enable OIDC login until issuer URL, client ID, client secret, and redirect URI are configured.'); + } } if (auth_mode !== undefined) {