feat(auth): admin-configurable session lifetime ceiling (#1706)

The 24h session cap from the M-2 audit finding was hard-coded, so the
  "Remember Me" checkbox could only control storage location, never
  duration. Add session_max_hours setting (default 24, max 720) honoured
  at all four token-issuance sites: plain login, 2FA TOTP/email, 2FA
  backup, OIDC.

  - backend/app/core/auth.py: SESSION_MAX_HOURS_HARD_CEILING + resolver
    that clamps to [1h, 720h] and falls back to 24h on missing/blank/
    unparseable. DB errors propagate — the login transaction must abort
    on a broken DB rather than silently extend or shrink the lifetime.
  - backend/app/api/routes/auth.py, mfa.py: all four sites read the
    resolved value instead of ACCESS_TOKEN_EXPIRE_MINUTES directly.
  - backend/app/schemas/settings.py, routes/settings.py: schema field
    with ge=1 le=720 + int coercion in _build_settings_response.
  - frontend/src/pages/SettingsPage.tsx: half-width card at top of
    Settings -> Users left column with 24h/7d/30d presets, custom input,
    and a yellow warning when value > 24h.
  - frontend/src/i18n/locales/*.ts: 8 new keys per locale, real
    translations in all 11 (en/de/es/fr/it/ja/ko/pt-BR/tr/zh-CN/zh-TW).
  - backend/tests/integration/test_session_policy.py: 15 tests across
    resolver clamping, login JWT exp end-to-end, settings API round-trip.

  Already-issued tokens keep their original expiry; the new setting only
  affects future logins.
This commit is contained in:
maziggy
2026-06-16 12:00:27 +02:00
parent b97f25d819
commit 2940fbdcf7
22 changed files with 544 additions and 69 deletions
+2
View File
@@ -1155,6 +1155,8 @@ export interface AppSettings {
bed_cooled_threshold: number;
// Inventory low stock threshold
low_stock_threshold: number;
// Session policy (#1706) — admin-set ceiling, hours, [1, 720]
session_max_hours: number;
// User email notifications toggle
user_notifications_enabled: boolean;
// Default print options
+11
View File
@@ -2381,6 +2381,17 @@ export default {
linkedAccountsDesc: 'Diese externen Identitätsanbieter sind mit deinem Konto verknüpft.',
oidcUnlinked: 'Konto getrennt.',
},
// Session Policy (#1706)
sessionPolicy: {
title: 'Sitzungsrichtlinie',
description: 'Maximale Sitzungsdauer für neue Benutzeranmeldungen. Bereits ausgegebene Token behalten ihren ursprünglichen Ablauf.',
preset24h: '24 Stunden',
preset7d: '7 Tage',
preset30d: '30 Tage',
customHoursLabel: 'Individuelle Sitzungsdauer in Stunden',
hoursSuffix: 'Stunden',
warning: 'Längere Sitzungen reduzieren den automatischen Abmeldeschutz. Nur für vertrauenswürdige Einzelnutzer-Installationen empfohlen.',
},
// OIDC provider settings
oidc: {
+12
View File
@@ -2392,6 +2392,18 @@ export default {
oidcUnlinked: 'Account unlinked.',
},
// Session Policy (#1706) — admin-configurable session lifetime ceiling.
sessionPolicy: {
title: 'Session Policy',
description: 'Maximum session lifetime for new user logins. Already-issued tokens keep their original expiry.',
preset24h: '24 hours',
preset7d: '7 days',
preset30d: '30 days',
customHoursLabel: 'Custom session lifetime in hours',
hoursSuffix: 'hours',
warning: 'Longer sessions reduce automatic logout protection. Recommended only for trusted single-user deployments.',
},
// OIDC provider settings
oidc: {
title: 'SSO / OIDC Providers',
+11
View File
@@ -2384,6 +2384,17 @@ export default {
linkedAccountsDesc: 'Estos proveedores de identidad externos están vinculados a su cuenta.',
oidcUnlinked: 'Cuenta desvinculada.',
},
// Session Policy (#1706)
sessionPolicy: {
title: 'Política de sesión',
description: 'Duración máxima de sesión para nuevos inicios de sesión. Los tokens ya emitidos conservan su caducidad original.',
preset24h: '24 horas',
preset7d: '7 días',
preset30d: '30 días',
customHoursLabel: 'Duración de sesión personalizada en horas',
hoursSuffix: 'horas',
warning: 'Las sesiones más largas reducen la protección de cierre automático. Recomendado solo para implementaciones de usuario único en entornos de confianza.',
},
// OIDC provider settings
oidc: {
+11
View File
@@ -2324,6 +2324,17 @@ export default {
linkedAccountsDesc: 'Ces fournisseurs d\'identité externes sont liés à votre compte.',
oidcUnlinked: 'Compte dissocié.',
},
// Session Policy (#1706)
sessionPolicy: {
title: 'Politique de session',
description: 'Durée maximale des sessions pour les nouvelles connexions utilisateur. Les jetons déjà émis conservent leur expiration d\'origine.',
preset24h: '24 heures',
preset7d: '7 jours',
preset30d: '30 jours',
customHoursLabel: 'Durée de session personnalisée en heures',
hoursSuffix: 'heures',
warning: 'Les sessions plus longues réduisent la protection de déconnexion automatique. Recommandé uniquement pour les déploiements mono-utilisateur de confiance.',
},
// OIDC provider settings
oidc: {
+11
View File
@@ -2323,6 +2323,17 @@ export default {
linkedAccountsDesc: 'Questi provider di identità esterni sono collegati al tuo account.',
oidcUnlinked: 'Account scollegato.',
},
// Session Policy (#1706)
sessionPolicy: {
title: 'Criterio di sessione',
description: 'Durata massima della sessione per i nuovi accessi utente. I token già emessi mantengono la scadenza originale.',
preset24h: '24 ore',
preset7d: '7 giorni',
preset30d: '30 giorni',
customHoursLabel: 'Durata personalizzata della sessione in ore',
hoursSuffix: 'ore',
warning: 'Le sessioni più lunghe riducono la protezione di disconnessione automatica. Consigliato solo per installazioni mono-utente attendibili.',
},
// OIDC provider settings
oidc: {
+11
View File
@@ -2380,6 +2380,17 @@ export default {
linkedAccountsDesc: 'これらの外部IDプロバイダーがあなたのアカウントにリンクされています。',
oidcUnlinked: 'アカウントのリンクを解除しました。',
},
// Session Policy (#1706)
sessionPolicy: {
title: 'セッションポリシー',
description: '新しいユーザーログインの最大セッション有効期間。すでに発行されたトークンは元の有効期限を保持します。',
preset24h: '24時間',
preset7d: '7日',
preset30d: '30日',
customHoursLabel: 'カスタムセッション有効期間(時間)',
hoursSuffix: '時間',
warning: '長いセッションは自動ログアウト保護を弱めます。信頼できる単一ユーザー環境でのみ推奨されます。',
},
// OIDC provider settings
oidc: {
+11
View File
@@ -2238,6 +2238,17 @@ export default {
linkedAccountsDesc: '이 외부 ID 제공자가 계정에 연결되어 있습니다.',
oidcUnlinked: '계정 연결이 해제되었습니다.'
},
// 세션 정책 (#1706)
sessionPolicy: {
title: '세션 정책',
description: '신규 사용자 로그인의 최대 세션 수명입니다. 이미 발급된 토큰은 원래 만료 시간을 유지합니다.',
preset24h: '24시간',
preset7d: '7일',
preset30d: '30일',
customHoursLabel: '사용자 지정 세션 수명(시간)',
hoursSuffix: '시간',
warning: '세션이 길어질수록 자동 로그아웃 보호가 약해집니다. 신뢰할 수 있는 단일 사용자 배포에서만 권장됩니다.'
},
oidc: {
title: 'SSO / OIDC 제공자',
desc: '외부 ID 제공자를 통해 싱글 사인온을 허용하도록 OpenID Connect 제공자를 설정하세요.',
+11
View File
@@ -2323,6 +2323,17 @@ export default {
linkedAccountsDesc: 'Estes provedores de identidade externos estão vinculados à sua conta.',
oidcUnlinked: 'Conta desvinculada.',
},
// Session Policy (#1706)
sessionPolicy: {
title: 'Política de sessão',
description: 'Duração máxima da sessão para novos logins de usuário. Tokens já emitidos mantêm sua expiração original.',
preset24h: '24 horas',
preset7d: '7 dias',
preset30d: '30 dias',
customHoursLabel: 'Duração personalizada da sessão em horas',
hoursSuffix: 'horas',
warning: 'Sessões mais longas reduzem a proteção de logout automático. Recomendado apenas para implantações de usuário único confiáveis.',
},
// OIDC provider settings
oidc: {
+11
View File
@@ -2384,6 +2384,17 @@ export default {
linkedAccountsDesc: 'Bu harici kimlik sağlayıcıları hesabınıza bağlıdır.',
oidcUnlinked: 'Hesap bağlantısı kaldırıldı.',
},
// Session Policy (#1706)
sessionPolicy: {
title: 'Oturum Politikası',
description: 'Yeni kullanıcı girişleri için maksimum oturum süresi. Daha önce verilmiş belirteçler özgün son kullanma tarihlerini korur.',
preset24h: '24 saat',
preset7d: '7 gün',
preset30d: '30 gün',
customHoursLabel: 'Özel oturum süresi (saat)',
hoursSuffix: 'saat',
warning: 'Daha uzun oturumlar otomatik oturum kapatma korumasını azaltır. Yalnızca güvenilir tek kullanıcılı dağıtımlar için önerilir.',
},
// OIDC sağlayıcı ayarları
oidc: {
+11
View File
@@ -2368,6 +2368,17 @@ export default {
linkedAccountsDesc: '以下外部身份提供商已与您的账户关联。',
oidcUnlinked: '账户已解除关联。',
},
// Session Policy (#1706)
sessionPolicy: {
title: '会话策略',
description: '新用户登录的最长会话有效期。已颁发的令牌保留其原有的过期时间。',
preset24h: '24 小时',
preset7d: '7 天',
preset30d: '30 天',
customHoursLabel: '自定义会话有效期(小时)',
hoursSuffix: '小时',
warning: '更长的会话会减弱自动注销保护。仅建议在受信任的单用户部署中使用。',
},
// OIDC provider settings
oidc: {
+11
View File
@@ -2368,6 +2368,17 @@ export default {
linkedAccountsDesc: '以下外部身份提供者已與您的帳戶連結。',
oidcUnlinked: '帳戶已解除連結。',
},
// Session Policy (#1706)
sessionPolicy: {
title: '工作階段政策',
description: '新使用者登入的最長工作階段有效期。已發行的權杖會保留其原有的到期時間。',
preset24h: '24 小時',
preset7d: '7 天',
preset30d: '30 天',
customHoursLabel: '自訂工作階段有效期(小時)',
hoursSuffix: '小時',
warning: '較長的工作階段會削弱自動登出保護。僅建議在受信任的單一使用者部署中使用。',
},
// OIDC provider settings
oidc: {
+70 -2
View File
@@ -87,6 +87,7 @@ registerSettingsSearch({ labelKey: 'settings.tabs.spoolbuddy', tab: 'spoolbuddy'
registerSettingsSearch({ labelKey: 'settings.currentUser', tab: 'users', subTab: 'users', keywords: 'current user profile password change', anchor: 'card-currentuser' });
registerSettingsSearch({ labelKey: 'settings.users', tab: 'users', subTab: 'users', keywords: 'users accounts list', anchor: 'card-users' });
registerSettingsSearch({ labelKey: 'settings.groups', tab: 'users', subTab: 'users', keywords: 'groups roles permissions administrators operators viewers', anchor: 'card-groups' });
registerSettingsSearch({ labelKey: 'settings.sessionPolicy.title', labelFallback: 'Session Policy', tab: 'users', subTab: 'users', keywords: 'session timeout expiry logout remember me jwt token lifetime', anchor: 'card-session-policy' });
registerSettingsSearch({ labelKey: 'settings.email.smtpSettings', labelFallback: 'SMTP Configuration', tab: 'users', subTab: 'email', keywords: 'smtp email send server port password auth starttls ssl', anchor: 'card-smtp' });
registerSettingsSearch({ labelKey: 'settings.ldap.title', labelFallback: 'LDAP Authentication', tab: 'users', subTab: 'ldap', keywords: 'ldap active directory ad authentication bind dn search base group mapping', anchor: 'card-ldap' });
registerSettingsSearch({ labelKey: 'settings.tabs.backup', tab: 'backup', keywords: 'backup github restore download cloud sync profiles archives', anchor: 'card-backup' });
@@ -1012,7 +1013,8 @@ export function SettingsPage() {
(settings.default_nozzle_offset_cali ?? true) !== (localSettings.default_nozzle_offset_cali ?? true) ||
(settings.stagger_group_size ?? 2) !== (localSettings.stagger_group_size ?? 2) ||
(settings.stagger_interval_minutes ?? 5) !== (localSettings.stagger_interval_minutes ?? 5) ||
(settings.require_plate_clear ?? false) !== (localSettings.require_plate_clear ?? false);
(settings.require_plate_clear ?? false) !== (localSettings.require_plate_clear ?? false) ||
(settings.session_max_hours ?? 24) !== (localSettings.session_max_hours ?? 24);
if (!hasChanges) {
return;
@@ -1099,6 +1101,7 @@ export function SettingsPage() {
stagger_group_size: localSettings.stagger_group_size,
stagger_interval_minutes: localSettings.stagger_interval_minutes,
require_plate_clear: localSettings.require_plate_clear,
session_max_hours: localSettings.session_max_hours,
};
updateMutation.mutate(settingsToSave);
}, 500);
@@ -5117,8 +5120,73 @@ export function SettingsPage() {
{authEnabled && (
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
{/* Left Column: Current User + User List */}
{/* Left Column: Session Policy + Current User + User List */}
<div className="space-y-3">
{/* Session Policy (#1706) — admin-set ceiling for user session lifetime */}
<Card id="card-session-policy">
<CardHeader>
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
<Lock className="w-5 h-5 text-bambu-green" />
{t('settings.sessionPolicy.title')}
</h3>
</CardHeader>
<CardContent>
<p className="text-sm text-bambu-gray mb-4">
{t('settings.sessionPolicy.description')}
</p>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 mb-4">
{[
{ hours: 24, labelKey: 'settings.sessionPolicy.preset24h' },
{ hours: 168, labelKey: 'settings.sessionPolicy.preset7d' },
{ hours: 720, labelKey: 'settings.sessionPolicy.preset30d' },
].map((preset) => {
const current = localSettings?.session_max_hours ?? 24;
const isActive = current === preset.hours;
return (
<button
key={preset.hours}
type="button"
onClick={() => updateSetting('session_max_hours', preset.hours)}
disabled={authEnabled && !hasPermission('settings:update')}
className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
isActive
? 'bg-bambu-green text-white'
: 'bg-bambu-dark-tertiary text-bambu-gray hover:text-white hover:bg-bambu-dark'
} disabled:opacity-50 disabled:cursor-not-allowed`}
>
{t(preset.labelKey)}
</button>
);
})}
<div className="flex items-center gap-1">
<input
type="number"
min={1}
max={720}
value={localSettings?.session_max_hours ?? 24}
onChange={(e) => {
const raw = parseInt(e.target.value, 10);
if (Number.isNaN(raw)) return;
updateSetting('session_max_hours', Math.max(1, Math.min(720, raw)));
}}
disabled={authEnabled && !hasPermission('settings:update')}
aria-label={t('settings.sessionPolicy.customHoursLabel')}
className="w-20 px-2 py-2 bg-bambu-dark-tertiary text-white text-sm rounded-lg border border-bambu-dark-tertiary focus:border-bambu-green focus:outline-none disabled:opacity-50"
/>
<span className="text-sm text-bambu-gray">{t('settings.sessionPolicy.hoursSuffix')}</span>
</div>
</div>
{(localSettings?.session_max_hours ?? 24) > 24 && (
<div className="flex items-start gap-2 p-3 rounded-lg bg-yellow-500/10 border border-yellow-500/30">
<AlertTriangle className="w-4 h-4 text-yellow-400 flex-shrink-0 mt-0.5" />
<p className="text-xs text-yellow-200">
{t('settings.sessionPolicy.warning')}
</p>
</div>
)}
</CardContent>
</Card>
{/* Current User Card */}
{user && (
<Card>