Просмотр исходного кода

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.
maziggy 2 месяцев назад
Родитель
Сommit
2940fbdcf7

Разница между файлами не показана из-за своего большого размера
+ 1 - 0
CHANGELOG.md


+ 4 - 3
backend/app/api/routes/auth.py

@@ -15,7 +15,6 @@ from sqlalchemy.orm import selectinload
 
 from backend.app.api.routes.settings import get_external_login_url
 from backend.app.core.auth import (
-    ACCESS_TOKEN_EXPIRE_MINUTES,
     ALGORITHM,
     SECRET_KEY,
     Permission,
@@ -31,6 +30,7 @@ from backend.app.core.auth import (
     get_user_by_email,
     get_user_by_username,
     is_jti_revoked,
+    resolve_session_max_minutes,
     revoke_jti,
     security,
 )
@@ -495,8 +495,9 @@ async def login(raw_request: Request, request: LoginRequest, response: Response,
             two_fa_methods=methods,
         )
 
-    # No 2FA — issue full token immediately
-    access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
+    # No 2FA — issue full token immediately. Session lifetime honours the
+    # admin-configurable ceiling (#1706); resolver clamps to [1h, 720h].
+    access_token_expires = timedelta(minutes=await resolve_session_max_minutes(db))
     access_token = create_access_token(data={"sub": user.username}, expires_delta=access_token_expires)
 
     return LoginResponse(

+ 4 - 4
backend/app/api/routes/mfa.py

@@ -41,13 +41,13 @@ from sqlalchemy.orm import selectinload, undefer
 from backend.app.api.routes._oidc_helpers import assert_safe_public_https_url
 from backend.app.api.routes.settings import get_setting, set_setting
 from backend.app.core.auth import (
-    ACCESS_TOKEN_EXPIRE_MINUTES,
     RequirePermissionIfAuthEnabled,
     create_access_token,
     get_current_active_user,
     get_user_by_email,
     get_user_by_username,
     is_auth_enabled,
+    resolve_session_max_minutes,
     verify_password,
 )
 from backend.app.core.database import get_db
@@ -1242,7 +1242,7 @@ async def verify_2fa(
 
         access_token = create_access_token(
             data={"sub": user.username},
-            expires_delta=timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES),
+            expires_delta=timedelta(minutes=await resolve_session_max_minutes(db)),
         )
         result = await db.execute(select(User).where(User.id == user.id).options(selectinload(User.groups)))
         user = result.scalar_one()
@@ -1258,7 +1258,7 @@ async def verify_2fa(
 
     access_token = create_access_token(
         data={"sub": user.username},
-        expires_delta=timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES),
+        expires_delta=timedelta(minutes=await resolve_session_max_minutes(db)),
     )
 
     # Reload with groups for permission calculation
@@ -2146,7 +2146,7 @@ async def oidc_exchange(
 
     access_token = create_access_token(
         data={"sub": user.username},
-        expires_delta=timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES),
+        expires_delta=timedelta(minutes=await resolve_session_max_minutes(db)),
     )
 
     return LoginResponse(

+ 1 - 0
backend/app/api/routes/settings.py

@@ -159,6 +159,7 @@ async def _build_settings_response(db: AsyncSession, is_api_key: bool = False) -
             "stagger_group_size",
             "stagger_interval_minutes",
             "forecast_global_lead_time_days",
+            "session_max_hours",
         ]:
             settings_dict[setting.key] = int(setting.value)
         elif setting.key == "default_printer_id":

+ 35 - 1
backend/app/core/auth.py

@@ -421,10 +421,42 @@ def _get_jwt_secret() -> str:
 SECRET_KEY = _get_jwt_secret()
 ALGORITHM = "HS256"
 ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24  # 24 hours (M-2: reduced from 7 days)
+# Hard ceiling for the admin-configurable session policy (#1706). 30 days
+# matches the Pydantic le=720 on AppSettings.session_max_hours; defense in
+# depth so a tampered settings row can't request an absurd lifetime.
+SESSION_MAX_HOURS_HARD_CEILING = 720
 
 # HTTP Bearer token
 security = HTTPBearer(auto_error=False)
 
+
+async def resolve_session_max_minutes(db: AsyncSession) -> int:
+    """Return the session-lifetime ceiling (minutes) honoured by login routes.
+
+    Reads ``session_max_hours`` from the settings table (#1706), clamps to
+    [1h, 720h], and falls back to the audit-default 24h if the row is
+    missing, blank, or unparseable.
+
+    DB errors are NOT caught here — login is already in a DB transaction and
+    a broken DB must abort the login rather than silently extend or shrink
+    the session lifetime.
+    """
+    default_minutes = ACCESS_TOKEN_EXPIRE_MINUTES
+    result = await db.execute(select(Settings).where(Settings.key == "session_max_hours"))
+    row = result.scalar_one_or_none()
+    if row is None or not row.value:
+        return default_minutes
+    try:
+        hours = int(row.value)
+    except (TypeError, ValueError):
+        return default_minutes
+    if hours < 1:
+        return default_minutes
+    if hours > SESSION_MAX_HOURS_HARD_CEILING:
+        hours = SESSION_MAX_HOURS_HARD_CEILING
+    return hours * 60
+
+
 # --- Slicer download tokens ---
 # Short-lived, single-use tokens for slicer protocol handlers that can't send
 # auth headers.  Stored in AuthEphemeralToken (token_type=TokenType.SLICER_DOWNLOAD)
@@ -649,7 +681,9 @@ def _is_token_fresh(iat: int | float | None, user: User) -> bool:
     Used to invalidate all sessions after a password reset/change (M-R7-B).
     All tokens without an iat claim are unconditionally rejected — every token
     issued by this server carries iat, so absence means the token is forged or
-    from a pre-iat code path whose max TTL (24 h) has long since expired.
+    from a pre-iat code path whose max TTL at the time (24 h) has long since
+    expired. The post-#1706 admin-set ceiling does not relax this — an iat-less
+    token still cannot have been issued by current code.
     """
     if iat is None:
         return False

+ 15 - 0
backend/app/schemas/settings.py

@@ -240,6 +240,20 @@ class AppSettings(BaseModel):
         description="Low stock threshold percentage (%) for inventory filtering and display",
     )
 
+    # Session policy (#1706) — admin-set ceiling for user session lifetime.
+    # Default 24h preserves the M-2 audit reduction from 7 days. Max 720h
+    # (30 days) bounds blast radius if an admin chooses a long session.
+    session_max_hours: int = Field(
+        default=24,
+        ge=1,
+        le=720,
+        description=(
+            "Maximum session lifetime in hours for user logins (default 24, max 720). "
+            "Applies to new logins only; already-issued tokens keep their original expiry. "
+            "Longer sessions reduce automatic logout protection."
+        ),
+    )
+
     # User email notifications (requires Advanced Authentication)
     user_notifications_enabled: bool = Field(
         default=True,
@@ -414,6 +428,7 @@ class AppSettingsUpdate(BaseModel):
     prometheus_enabled: bool | None = None
     prometheus_token: str | None = None
     low_stock_threshold: float | None = Field(default=None, ge=0.1, le=99.9)
+    session_max_hours: int | None = Field(default=None, ge=1, le=720)
     user_notifications_enabled: bool | None = None
     default_bed_levelling: bool | None = None
     default_flow_cali: bool | None = None

+ 229 - 0
backend/tests/integration/test_session_policy.py

@@ -0,0 +1,229 @@
+"""Integration tests for the admin-set session-lifetime ceiling (#1706).
+
+Covers the four token-issuance sites that read ``session_max_hours``:
+plain login, 2FA backup-code login, 2FA TOTP/email login, OIDC login.
+Only the first is exercised end-to-end via ``async_client``; the helper
+``resolve_session_max_minutes`` itself is unit-tested below so the MFA
+and OIDC paths inherit the same clamping behaviour by construction.
+"""
+
+import time
+
+import jwt
+import pytest
+from httpx import AsyncClient
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.core.auth import (
+    ACCESS_TOKEN_EXPIRE_MINUTES,
+    ALGORITHM,
+    SECRET_KEY,
+    SESSION_MAX_HOURS_HARD_CEILING,
+    resolve_session_max_minutes,
+)
+from backend.app.models.settings import Settings
+
+
+async def _set_session_max_hours(db: AsyncSession, value: str | None) -> None:
+    """Upsert the session_max_hours setting row (value=None deletes it)."""
+    result = await db.execute(select(Settings).where(Settings.key == "session_max_hours"))
+    existing = result.scalar_one_or_none()
+    if value is None:
+        if existing is not None:
+            await db.delete(existing)
+            await db.commit()
+        return
+    if existing is None:
+        db.add(Settings(key="session_max_hours", value=value))
+    else:
+        existing.value = value
+    await db.commit()
+
+
+class TestResolveSessionMaxMinutes:
+    """Unit-style tests for the clamping resolver."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_missing_row_returns_24h_default(self, db_session: AsyncSession):
+        await _set_session_max_hours(db_session, None)
+        assert await resolve_session_max_minutes(db_session) == ACCESS_TOKEN_EXPIRE_MINUTES
+        assert ACCESS_TOKEN_EXPIRE_MINUTES == 60 * 24
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_empty_string_returns_24h_default(self, db_session: AsyncSession):
+        await _set_session_max_hours(db_session, "")
+        assert await resolve_session_max_minutes(db_session) == ACCESS_TOKEN_EXPIRE_MINUTES
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_unparseable_value_returns_24h_default(self, db_session: AsyncSession):
+        await _set_session_max_hours(db_session, "not-a-number")
+        assert await resolve_session_max_minutes(db_session) == ACCESS_TOKEN_EXPIRE_MINUTES
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_zero_or_negative_returns_24h_default(self, db_session: AsyncSession):
+        await _set_session_max_hours(db_session, "0")
+        assert await resolve_session_max_minutes(db_session) == ACCESS_TOKEN_EXPIRE_MINUTES
+        await _set_session_max_hours(db_session, "-5")
+        assert await resolve_session_max_minutes(db_session) == ACCESS_TOKEN_EXPIRE_MINUTES
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_one_hour_minimum(self, db_session: AsyncSession):
+        await _set_session_max_hours(db_session, "1")
+        assert await resolve_session_max_minutes(db_session) == 60
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_seven_days_passes_through(self, db_session: AsyncSession):
+        await _set_session_max_hours(db_session, "168")
+        assert await resolve_session_max_minutes(db_session) == 168 * 60
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_thirty_days_passes_through(self, db_session: AsyncSession):
+        await _set_session_max_hours(db_session, str(SESSION_MAX_HOURS_HARD_CEILING))
+        assert await resolve_session_max_minutes(db_session) == SESSION_MAX_HOURS_HARD_CEILING * 60
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_above_ceiling_is_clamped_to_30_days(self, db_session: AsyncSession):
+        """Defense-in-depth: a tampered settings row above 720h must be clamped."""
+        await _set_session_max_hours(db_session, "99999")
+        assert await resolve_session_max_minutes(db_session) == SESSION_MAX_HOURS_HARD_CEILING * 60
+
+
+class TestLoginRespectsSessionPolicy:
+    """The /auth/login route must honour the resolved ceiling."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_login_uses_default_24h_when_unset(self, async_client: AsyncClient, db_session: AsyncSession):
+        await async_client.post(
+            "/api/v1/auth/setup",
+            json={
+                "auth_enabled": True,
+                "admin_username": "sessiontest1",
+                "admin_password": "SessionPass1!",
+            },
+        )
+        await _set_session_max_hours(db_session, None)
+
+        before = int(time.time())
+        response = await async_client.post(
+            "/api/v1/auth/login",
+            json={"username": "sessiontest1", "password": "SessionPass1!"},
+        )
+        after = int(time.time())
+
+        assert response.status_code == 200
+        token = response.json()["access_token"]
+        decoded = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
+        # exp should be ~24h ahead. Allow generous bounds for clock drift.
+        expected_min = before + 24 * 3600 - 60
+        expected_max = after + 24 * 3600 + 60
+        assert expected_min <= decoded["exp"] <= expected_max
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_login_uses_configured_7d_ceiling(self, async_client: AsyncClient, db_session: AsyncSession):
+        await async_client.post(
+            "/api/v1/auth/setup",
+            json={
+                "auth_enabled": True,
+                "admin_username": "sessiontest2",
+                "admin_password": "SessionPass2!",
+            },
+        )
+        await _set_session_max_hours(db_session, "168")  # 7 days
+
+        before = int(time.time())
+        response = await async_client.post(
+            "/api/v1/auth/login",
+            json={"username": "sessiontest2", "password": "SessionPass2!"},
+        )
+        after = int(time.time())
+
+        assert response.status_code == 200
+        token = response.json()["access_token"]
+        decoded = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
+        expected_min = before + 168 * 3600 - 60
+        expected_max = after + 168 * 3600 + 60
+        assert expected_min <= decoded["exp"] <= expected_max
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_login_clamps_above_ceiling(self, async_client: AsyncClient, db_session: AsyncSession):
+        """A settings row above the 720h ceiling must be clamped at login time."""
+        await async_client.post(
+            "/api/v1/auth/setup",
+            json={
+                "auth_enabled": True,
+                "admin_username": "sessiontest3",
+                "admin_password": "SessionPass3!",
+            },
+        )
+        await _set_session_max_hours(db_session, "5000")  # would be ~208 days
+
+        before = int(time.time())
+        response = await async_client.post(
+            "/api/v1/auth/login",
+            json={"username": "sessiontest3", "password": "SessionPass3!"},
+        )
+        after = int(time.time())
+
+        assert response.status_code == 200
+        token = response.json()["access_token"]
+        decoded = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
+        # Clamped to 30 days, not 5000 hours.
+        expected_min = before + SESSION_MAX_HOURS_HARD_CEILING * 3600 - 60
+        expected_max = after + SESSION_MAX_HOURS_HARD_CEILING * 3600 + 60
+        assert expected_min <= decoded["exp"] <= expected_max
+
+
+class TestSettingsAPIExposesSessionMaxHours:
+    """The /settings API must round-trip session_max_hours as an int."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_default_is_24(self, async_client: AsyncClient, db_session: AsyncSession):
+        await _set_session_max_hours(db_session, None)
+        response = await async_client.get("/api/v1/settings/")
+        assert response.status_code == 200
+        assert response.json()["session_max_hours"] == 24
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_accepts_valid_value(self, async_client: AsyncClient, db_session: AsyncSession):
+        response = await async_client.patch(
+            "/api/v1/settings/",
+            json={"session_max_hours": 168},
+        )
+        assert response.status_code == 200
+        assert response.json()["session_max_hours"] == 168
+        # Persisted as the int's string form so the resolver round-trips.
+        result = await db_session.execute(select(Settings).where(Settings.key == "session_max_hours"))
+        row = result.scalar_one()
+        assert row.value == "168"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_rejects_zero(self, async_client: AsyncClient):
+        response = await async_client.patch(
+            "/api/v1/settings/",
+            json={"session_max_hours": 0},
+        )
+        assert response.status_code == 422
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_rejects_above_ceiling(self, async_client: AsyncClient):
+        response = await async_client.patch(
+            "/api/v1/settings/",
+            json={"session_max_hours": SESSION_MAX_HOURS_HARD_CEILING + 1},
+        )
+        assert response.status_code == 422

+ 2 - 0
frontend/src/api/client.ts

@@ -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 - 0
frontend/src/i18n/locales/de.ts

@@ -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 - 0
frontend/src/i18n/locales/en.ts

@@ -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 - 0
frontend/src/i18n/locales/es.ts

@@ -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 - 0
frontend/src/i18n/locales/fr.ts

@@ -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 - 0
frontend/src/i18n/locales/it.ts

@@ -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 - 0
frontend/src/i18n/locales/ja.ts

@@ -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 - 0
frontend/src/i18n/locales/ko.ts

@@ -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 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -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 - 0
frontend/src/i18n/locales/tr.ts

@@ -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 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -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 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -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
frontend/src/pages/SettingsPage.tsx

@@ -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>

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-DznM9swC.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-lB37rzBj.js"></script>
+    <script type="module" crossorigin src="/assets/index-DznM9swC.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-JNXvMxhG.css">
   </head>
   <body>

Некоторые файлы не были показаны из-за большого количества измененных файлов