fix: login mode card update, OIDC service improvements, auth middleware refinements
This commit is contained in:
parent
26b6fb13e5
commit
a6b2e8bb87
|
|
@ -12,23 +12,29 @@ import {
|
||||||
AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle,
|
AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle,
|
||||||
AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction,
|
AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction,
|
||||||
} from '@/components/ui/alert-dialog';
|
} 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 [modeData, setModeData] = useState(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [loadError, setLoadError] = useState('');
|
const [loadError, setLoadError] = useState('');
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [selected, setSelected] = useState('multi'); // local UI selection
|
||||||
const [selectedUser, setSelectedUser] = useState('');
|
const [selectedUser, setSelectedUser] = useState('');
|
||||||
|
|
||||||
const [confirmSingle, setConfirmSingle] = useState(false);
|
const [confirmSingle, setConfirmSingle] = useState(false);
|
||||||
const [pendingUserId, setPendingUserId] = useState(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.authModeConfig()
|
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'))
|
.catch(err => setLoadError(err.message || 'Failed to load login mode config'))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, []);
|
}, []); // eslint-disable-line
|
||||||
|
|
||||||
const doSetMode = async (mode, userId) => {
|
const doSetMode = async (mode, userId) => {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
|
|
@ -39,77 +45,164 @@ export default function LoginModeCard({ users }) {
|
||||||
});
|
});
|
||||||
const d = await api.authModeConfig();
|
const d = await api.authModeConfig();
|
||||||
setModeData(d);
|
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) {
|
} 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 {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRequestSingle = () => {
|
const handleSave = () => {
|
||||||
if (!selectedUser) { toast.error('Select a user first.'); return; }
|
if (selected === 'single') {
|
||||||
setPendingUserId(selectedUser);
|
if (!selectedUser) { toast.error('Select a user account first.'); return; }
|
||||||
setConfirmSingle(true);
|
setConfirmSingle(true);
|
||||||
};
|
} else {
|
||||||
|
doSetMode('multi', null);
|
||||||
const handleConfirmSingle = () => {
|
}
|
||||||
setConfirmSingle(false);
|
|
||||||
doSetMode('single', pendingUserId);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) return <Card><CardContent className="py-8 text-center text-muted-foreground text-sm">Loading…</CardContent></Card>;
|
if (loading) return <Card><CardContent className="py-8 text-center text-muted-foreground text-sm">Loading…</CardContent></Card>;
|
||||||
if (loadError) return <Card><CardContent className="py-8 text-center text-sm text-destructive">{loadError}</CardContent></Card>;
|
if (loadError) return <Card><CardContent className="py-8 text-center text-sm text-destructive">{loadError}</CardContent></Card>;
|
||||||
|
|
||||||
const isMulti = !modeData || modeData.auth_mode === 'multi';
|
const currentMode = modeData?.auth_mode === 'single' ? 'single' : 'multi';
|
||||||
const activeUser = users?.find(u => u.id === modeData?.default_user_id);
|
const isDirty = selected !== currentMode || (selected === 'single' && selectedUser !== (modeData?.default_user_id?.toString() || ''));
|
||||||
const selectedUsername = users?.find(u => u.id.toString() === selectedUser)?.username ?? selectedUser;
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-4">
|
<CardHeader className="pb-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<CardTitle>Login Mode</CardTitle>
|
<div>
|
||||||
<Badge className={isMulti ? 'bg-sky-500/15 text-sky-400 border-sky-500/20' : 'bg-amber-500/15 text-amber-400 border-amber-500/20'}>
|
<CardTitle>Login Mode</CardTitle>
|
||||||
{isMulti ? 'Multi-user' : 'Single-user'}
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
Choose how users access this app.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge className={
|
||||||
|
currentMode === 'single'
|
||||||
|
? 'bg-violet-500/15 text-violet-400 border-violet-500/20'
|
||||||
|
: 'bg-sky-500/15 text-sky-400 border-sky-500/20'
|
||||||
|
}>
|
||||||
|
{currentMode === 'single' ? 'No Login' : 'Require Login'}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
|
||||||
{isMulti ? (
|
<CardContent className="space-y-3">
|
||||||
<>
|
{/* Option: Require Login */}
|
||||||
<p className="text-sm text-muted-foreground">
|
<button
|
||||||
Single-user mode bypasses the login screen and automatically signs in as the selected user.
|
type="button"
|
||||||
</p>
|
onClick={() => setSelected('multi')}
|
||||||
<div className="space-y-1.5">
|
className={`w-full text-left rounded-lg border px-4 py-3.5 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${
|
||||||
<Label>Default user</Label>
|
selected === 'multi'
|
||||||
<Select value={selectedUser} onValueChange={setSelectedUser}>
|
? 'border-primary/50 bg-primary/5'
|
||||||
<SelectTrigger>
|
: 'border-border/60 bg-muted/20 hover:border-border hover:bg-muted/40'
|
||||||
<SelectValue placeholder="Select a user…" />
|
}`}
|
||||||
</SelectTrigger>
|
>
|
||||||
<SelectContent>
|
<div className="flex items-start gap-3">
|
||||||
{(users || []).filter(u => u.role === 'user').map(u => (
|
<div className={`mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-full border-2 ${
|
||||||
<SelectItem key={u.id} value={u.id.toString()}>{u.username}</SelectItem>
|
selected === 'multi' ? 'border-primary' : 'border-muted-foreground/40'
|
||||||
))}
|
}`}>
|
||||||
</SelectContent>
|
{selected === 'multi' && <div className="h-2 w-2 rounded-full bg-primary" />}
|
||||||
</Select>
|
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={handleRequestSingle} disabled={saving} className="w-full">
|
<div className="min-w-0 flex-1">
|
||||||
{saving ? 'Enabling…' : 'Enable Single-User Mode'}
|
<div className="flex items-center gap-2">
|
||||||
</Button>
|
<LogIn className="h-4 w-4 text-muted-foreground" />
|
||||||
</>
|
<span className="text-sm font-medium">Require Login</span>
|
||||||
) : (
|
</div>
|
||||||
<>
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
<p className="text-sm text-muted-foreground">
|
Users must sign in with a username and password, or via OIDC. Authentication methods are configured below.
|
||||||
Currently auto-signing in as{' '}
|
</p>
|
||||||
<span className="font-medium text-foreground">{activeUser?.username ?? '—'}</span>.
|
{currentMode === 'multi' && (
|
||||||
Restoring login requirement will require all users to sign in manually.
|
<p className="text-xs text-emerald-500 mt-1 font-medium">Currently active</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Option: No Login */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelected('single')}
|
||||||
|
className={`w-full text-left rounded-lg border px-4 py-3.5 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${
|
||||||
|
selected === 'single'
|
||||||
|
? 'border-violet-500/50 bg-violet-500/5'
|
||||||
|
: 'border-border/60 bg-muted/20 hover:border-border hover:bg-muted/40'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className={`mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-full border-2 ${
|
||||||
|
selected === 'single' ? 'border-violet-500' : 'border-muted-foreground/40'
|
||||||
|
}`}>
|
||||||
|
{selected === 'single' && <div className="h-2 w-2 rounded-full bg-violet-500" />}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<UserCheck className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="text-sm font-medium">No Login — Single User</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Anyone who opens the app is automatically signed in as the selected user. No login screen is shown.
|
||||||
|
Admin routes still require a session.
|
||||||
|
</p>
|
||||||
|
{currentMode === 'single' && (
|
||||||
|
<p className="text-xs text-violet-400 mt-1 font-medium">
|
||||||
|
Currently active — signed in as <span className="text-foreground">{activeUser?.username ?? '—'}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* User selector — shown only when No Login is selected */}
|
||||||
|
{selected === 'single' && (
|
||||||
|
<div className="rounded-lg border border-border/60 bg-muted/10 px-4 py-3 space-y-2">
|
||||||
|
<Label className="text-xs text-muted-foreground">Auto sign-in as</Label>
|
||||||
|
<Select value={selectedUser} onValueChange={setSelectedUser}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a user account…" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{regularUsers.map(u => (
|
||||||
|
<SelectItem key={u.id} value={u.id.toString()}>{u.username}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{regularUsers.length === 0 && (
|
||||||
|
<p className="text-xs text-amber-500">No regular user accounts found. Create a user account first.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Security note for single mode */}
|
||||||
|
{selected === 'single' && (
|
||||||
|
<div className="flex gap-2 rounded-lg border border-amber-500/25 bg-amber-500/10 px-3 py-2.5">
|
||||||
|
<ShieldCheck className="h-4 w-4 text-amber-500 shrink-0 mt-0.5" />
|
||||||
|
<p className="text-xs text-amber-600 dark:text-amber-400">
|
||||||
|
Only use this on trusted private networks. Anyone with access to the URL is signed in automatically.
|
||||||
</p>
|
</p>
|
||||||
<Button variant="outline" onClick={() => doSetMode('multi', null)} disabled={saving} className="w-full">
|
</div>
|
||||||
{saving ? 'Restoring…' : 'Restore Login Requirement'}
|
)}
|
||||||
</Button>
|
|
||||||
</>
|
{isDirty && (
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving || (selected === 'single' && !selectedUser)}
|
||||||
|
className="w-full"
|
||||||
|
variant={selected === 'single' ? 'default' : 'outline'}
|
||||||
|
>
|
||||||
|
{saving ? 'Saving…' : selected === 'single' ? 'Enable No Login Mode' : 'Restore Login Requirement'}
|
||||||
|
</Button>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
@ -117,17 +210,17 @@ export default function LoginModeCard({ users }) {
|
||||||
<AlertDialog open={confirmSingle} onOpenChange={setConfirmSingle}>
|
<AlertDialog open={confirmSingle} onOpenChange={setConfirmSingle}>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Enable Single-User Mode?</AlertDialogTitle>
|
<AlertDialogTitle>Enable No Login Mode?</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
Anyone who opens the app will be automatically signed in as{' '}
|
Anyone who opens the app will be automatically signed in as{' '}
|
||||||
<span className="font-medium text-foreground">{selectedUsername}</span>.
|
<span className="font-medium text-foreground">{pendingUsername}</span>.
|
||||||
The admin login still requires a password.
|
The admin login still requires a password.
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
<AlertDialogAction onClick={handleConfirmSingle}>
|
<AlertDialogAction onClick={() => { setConfirmSingle(false); doSetMode('single', selectedUser); }}>
|
||||||
Enable Single-User Mode
|
Enable No Login Mode
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ export default function AdminPage() {
|
||||||
const [hasUsers, setHasUsers] = useState(null);
|
const [hasUsers, setHasUsers] = useState(null);
|
||||||
const [loadError, setLoadError] = useState('');
|
const [loadError, setLoadError] = useState('');
|
||||||
const [users, setUsers] = useState([]);
|
const [users, setUsers] = useState([]);
|
||||||
|
const [authMode, setAuthMode] = useState('multi');
|
||||||
|
|
||||||
const loadMe = useCallback(async () => {
|
const loadMe = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -85,8 +86,8 @@ export default function AdminPage() {
|
||||||
<BankSyncAdminCard />
|
<BankSyncAdminCard />
|
||||||
<BackupManagementCard />
|
<BackupManagementCard />
|
||||||
<CleanupPanel />
|
<CleanupPanel />
|
||||||
<LoginModeCard users={users} />
|
<LoginModeCard users={users} onModeChange={setAuthMode} />
|
||||||
<AuthMethodsCard />
|
{authMode !== 'single' && <AuthMethodsCard />}
|
||||||
<AddUserCard onCreated={loadUsers} />
|
<AddUserCard onCreated={loadUsers} />
|
||||||
<UsersTable users={users} onRefresh={loadUsers} currentUser={me} />
|
<UsersTable users={users} onRefresh={loadUsers} currentUser={me} />
|
||||||
</main>
|
</main>
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ export default function LoginPage() {
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
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 [pendingUser, setPendingUser] = useState(null);
|
||||||
const [showChangePw, setShowChangePw] = useState(false);
|
const [showChangePw, setShowChangePw] = useState(false);
|
||||||
|
|
@ -78,9 +78,9 @@ export default function LoginPage() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const localEnabled = authMode.local_enabled !== false;
|
const localEnabled = authMode?.local_enabled !== false;
|
||||||
const oidcEnabled = !!authMode.oidc_enabled && !!authMode.oidc_login_url;
|
const oidcEnabled = !!authMode?.oidc_enabled && !!authMode?.oidc_login_url;
|
||||||
const providerName = authMode.oidc_provider_name || 'authentik';
|
const providerName = authMode?.oidc_provider_name || 'authentik';
|
||||||
const isAuthentikProvider = providerName.toLowerCase().includes('authentik');
|
const isAuthentikProvider = providerName.toLowerCase().includes('authentik');
|
||||||
|
|
||||||
const handleChangePassword = async (e) => {
|
const handleChangePassword = async (e) => {
|
||||||
|
|
@ -139,7 +139,12 @@ export default function LoginPage() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Card */}
|
{/* Card — hidden while auth mode is still resolving to avoid flash in single-user mode */}
|
||||||
|
{authMode === null ? (
|
||||||
|
<div className="surface-elevated p-8 flex items-center justify-center min-h-[120px]">
|
||||||
|
<span className="text-sm text-muted-foreground">Loading…</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div className="surface-elevated p-8 space-y-6">
|
<div className="surface-elevated p-8 space-y-6">
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -238,6 +243,7 @@ export default function LoginPage() {
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)} {/* end authMode !== null */}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Change Password Dialog */}
|
{/* Change Password Dialog */}
|
||||||
|
|
|
||||||
|
|
@ -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 { getDb, getSetting } = require('../db/database');
|
||||||
const { standardizeError } = require('./errorFormatter');
|
const { standardizeError } = require('./errorFormatter');
|
||||||
|
|
||||||
|
|
@ -24,6 +25,28 @@ function requireAuth(req, res, next) {
|
||||||
if (singleUser) {
|
if (singleUser) {
|
||||||
req.user = singleUser;
|
req.user = singleUser;
|
||||||
req.singleUserMode = true;
|
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();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ function getAppVersion() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const { getDb, getSetting, setSetting } = require('../db/database');
|
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 { decryptSecret } = require('../services/encryptionService');
|
||||||
const { getCsrfToken } = require('../middleware/csrf');
|
const { getCsrfToken } = require('../middleware/csrf');
|
||||||
const { requireAuth, requireAdmin } = require('../middleware/requireAuth');
|
const { requireAuth, requireAdmin } = require('../middleware/requireAuth');
|
||||||
|
|
@ -124,8 +124,11 @@ router.get('/login-history', requireAuth, (req, res) => {
|
||||||
try { return decryptSecret(v); } catch { return null; }
|
try { return decryptSecret(v); } catch { return null; }
|
||||||
};
|
};
|
||||||
|
|
||||||
// Compute fingerprint of the current session cookie to mark "this session"
|
// Compute fingerprint of the current session cookie to mark "this session".
|
||||||
const currentCookie = req.cookies?.[COOKIE_NAME];
|
// 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
|
const currentFingerprint = currentCookie
|
||||||
? require('crypto').createHash('sha256').update(currentCookie).digest('hex').slice(0, 32)
|
? require('crypto').createHash('sha256').update(currentCookie).digest('hex').slice(0, 32)
|
||||||
: null;
|
: null;
|
||||||
|
|
|
||||||
|
|
@ -98,7 +98,7 @@ router.get('/callback', async (req, res) => {
|
||||||
const session = await createSession(user.id);
|
const session = await createSession(user.id);
|
||||||
if (!session) throw new Error('Failed to create local session after OIDC login');
|
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.cookie(COOKIE_NAME, session.sessionId, cookieOpts(req));
|
||||||
res.redirect(savedState.redirect_to || '/');
|
res.redirect(savedState.redirect_to || '/');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ const { buildDeviceFingerprint } = require('./loginFingerprint');
|
||||||
const { encryptSecret, decryptSecret } = require('./encryptionService');
|
const { encryptSecret, decryptSecret } = require('./encryptionService');
|
||||||
|
|
||||||
const COOKIE_NAME = 'bt_session';
|
const COOKIE_NAME = 'bt_session';
|
||||||
|
const SINGLE_COOKIE_NAME = 'bt_single_session';
|
||||||
const SESSION_DAYS = 7;
|
const SESSION_DAYS = 7;
|
||||||
|
|
||||||
function envFlag(name) {
|
function envFlag(name) {
|
||||||
|
|
@ -358,4 +359,4 @@ function invalidateOtherSessions(userId, keepSessionId) {
|
||||||
return result;
|
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 };
|
||||||
|
|
|
||||||
|
|
@ -318,17 +318,21 @@ function applyAuthModeSettings(body = {}) {
|
||||||
? trimOrEmpty(oidc_admin_group)
|
? trimOrEmpty(oidc_admin_group)
|
||||||
: getAdminOidcSettings().oidc_admin_group;
|
: getAdminOidcSettings().oidc_admin_group;
|
||||||
|
|
||||||
if (!nextLocal && !nextOidc) {
|
// Single-user mode bypasses the login screen entirely — lockout checks don't apply
|
||||||
throw serviceError('Cannot disable all login methods. At least one must remain enabled.');
|
const isSingleMode = auth_mode === 'single' || (auth_mode === undefined && getSetting('auth_mode') === 'single');
|
||||||
}
|
if (!isSingleMode) {
|
||||||
if (!nextLocal && !oidcConfigured) {
|
if (!nextLocal && !nextOidc) {
|
||||||
throw serviceError('Cannot disable local login until authentik/OIDC is fully configured.');
|
throw serviceError('Cannot disable all login methods. At least one must remain enabled.');
|
||||||
}
|
}
|
||||||
if (!nextLocal && !nextAdminGroup) {
|
if (!nextLocal && !oidcConfigured) {
|
||||||
throw serviceError('Cannot disable local login until an OIDC admin group is configured.');
|
throw serviceError('Cannot disable local login until authentik/OIDC is fully configured.');
|
||||||
}
|
}
|
||||||
if (nextOidc && !oidcConfigured) {
|
if (!nextLocal && !nextAdminGroup) {
|
||||||
throw serviceError('Cannot enable OIDC login until issuer URL, client ID, client secret, and redirect URI are configured.');
|
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) {
|
if (auth_mode !== undefined) {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue