Procházet zdrojové kódy

fix(auth): preserve original URL across login + OIDC round-trip (#1750)

      ProtectedRoute and PermissionRoute now pass the requested location as
      router state when redirecting to /login. LoginPage stashes it in
      sessionStorage before the OIDC provider redirect (since window.location
      kills React state) and consumes it on all three post-login navigations
      (credentials, 2FA, OIDC token exchange). Targets are sanitized to
      same-origin internal paths only — protocol-relative and /login itself
      are rejected to prevent open-redirect.

      QR labels (https://host/inventory?spool=N) now land on the scanned
      spool instead of the printer page after authentik / any OIDC SSO login.
maziggy před 2 měsíci
rodič
revize
ed1683fe3e

+ 5 - 3
frontend/src/App.tsx

@@ -1,5 +1,5 @@
 import { Component, type ReactNode, type ErrorInfo } from 'react';
 import { Component, type ReactNode, type ErrorInfo } from 'react';
-import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
+import { BrowserRouter, Routes, Route, Navigate, useLocation } from 'react-router-dom';
 import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
 import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
 import { Layout } from './components/Layout';
 import { Layout } from './components/Layout';
 import { PrintersPage } from './pages/PrintersPage';
 import { PrintersPage } from './pages/PrintersPage';
@@ -93,13 +93,14 @@ function WebSocketProvider({ children }: { children: React.ReactNode }) {
 
 
 function ProtectedRoute({ children }: { children: React.ReactNode }) {
 function ProtectedRoute({ children }: { children: React.ReactNode }) {
   const { authEnabled, loading, user } = useAuth();
   const { authEnabled, loading, user } = useAuth();
+  const location = useLocation();
 
 
   if (loading) {
   if (loading) {
     return <div className="min-h-screen flex items-center justify-center">Loading...</div>;
     return <div className="min-h-screen flex items-center justify-center">Loading...</div>;
   }
   }
 
 
   if (authEnabled && !user) {
   if (authEnabled && !user) {
-    return <Navigate to="/login" replace />;
+    return <Navigate to="/login" replace state={{ from: location }} />;
   }
   }
 
 
   return <>{children}</>;
   return <>{children}</>;
@@ -112,6 +113,7 @@ function PermissionRoute({ permission, children }: { permission: string; childre
   // (e.g. settings:read grants read-only access to Settings; specific tabs
   // (e.g. settings:read grants read-only access to Settings; specific tabs
   // require their own permissions like users:read, groups:update, etc.).
   // require their own permissions like users:read, groups:update, etc.).
   const { authEnabled, loading, user, hasPermission } = useAuth();
   const { authEnabled, loading, user, hasPermission } = useAuth();
+  const location = useLocation();
 
 
   if (loading) {
   if (loading) {
     return <div className="min-h-screen flex items-center justify-center">Loading...</div>;
     return <div className="min-h-screen flex items-center justify-center">Loading...</div>;
@@ -123,7 +125,7 @@ function PermissionRoute({ permission, children }: { permission: string; childre
   }
   }
 
 
   if (!user) {
   if (!user) {
-    return <Navigate to="/login" replace />;
+    return <Navigate to="/login" replace state={{ from: location }} />;
   }
   }
 
 
   if (!hasPermission(permission as Parameters<typeof hasPermission>[0])) {
   if (!hasPermission(permission as Parameters<typeof hasPermission>[0])) {

+ 59 - 4
frontend/src/pages/LoginPage.tsx

@@ -1,5 +1,5 @@
 import { useEffect, useRef, useState } from 'react';
 import { useEffect, useRef, useState } from 'react';
-import { useNavigate, useSearchParams } from 'react-router-dom';
+import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
 import { useMutation, useQuery } from '@tanstack/react-query';
 import { useMutation, useQuery } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import { useAuth } from '../contexts/AuthContext';
 import { useAuth } from '../contexts/AuthContext';
@@ -16,6 +16,7 @@ type LoginStep = 'credentials' | '2fa' | 'reset-password';
 // Read + remove in one try so all branches in the OIDC useEffect see the same
 // Read + remove in one try so all branches in the OIDC useEffect see the same
 // value and a subsequent page load does not replay the flag.
 // value and a subsequent page load does not replay the flag.
 const REMEMBER_ME_KEY = 'auth_remember_me';
 const REMEMBER_ME_KEY = 'auth_remember_me';
+const POST_LOGIN_REDIRECT_KEY = 'auth_post_login_redirect';
 
 
 function toPersistence(remember: boolean): TokenPersistence {
 function toPersistence(remember: boolean): TokenPersistence {
   return remember ? 'persistent' : 'session';
   return remember ? 'persistent' : 'session';
@@ -32,6 +33,38 @@ function consumeSavedRememberMe(): boolean {
   }
   }
 }
 }
 
 
+// Only accept same-origin internal paths. Rejects protocol-relative (`//evil.com`),
+// absolute URLs, and the login page itself (would loop). Anything else falls
+// back to `/` so a tampered sessionStorage entry can't open-redirect.
+function sanitizeRedirectTarget(target: string | null | undefined): string | null {
+  if (!target) return null;
+  if (!target.startsWith('/')) return null;
+  if (target.startsWith('//')) return null;
+  if (target.startsWith('/login')) return null;
+  return target;
+}
+
+function stashPostLoginRedirect(target: string): void {
+  const safe = sanitizeRedirectTarget(target);
+  if (!safe) return;
+  try {
+    sessionStorage.setItem(POST_LOGIN_REDIRECT_KEY, safe);
+  } catch (err) {
+    console.warn('stashPostLoginRedirect: sessionStorage unavailable, post-login target will be lost across OIDC redirect', err);
+  }
+}
+
+function consumePostLoginRedirect(): string | null {
+  try {
+    const saved = sessionStorage.getItem(POST_LOGIN_REDIRECT_KEY);
+    sessionStorage.removeItem(POST_LOGIN_REDIRECT_KEY);
+    return sanitizeRedirectTarget(saved);
+  } catch (err) {
+    console.warn('consumePostLoginRedirect: sessionStorage unavailable', err);
+    return null;
+  }
+}
+
 /**
 /**
  * Single OIDC-provider login button. Extracted from the `.map()` body
  * Single OIDC-provider login button. Extracted from the `.map()` body
  * because hooks can't be used inside a loop callback — the `iconFailed`
  * because hooks can't be used inside a loop callback — the `iconFailed`
@@ -77,12 +110,27 @@ function OIDCProviderButton({
 
 
 export function LoginPage() {
 export function LoginPage() {
   const navigate = useNavigate();
   const navigate = useNavigate();
+  const location = useLocation();
   const [searchParams] = useSearchParams();
   const [searchParams] = useSearchParams();
   const { t } = useTranslation();
   const { t } = useTranslation();
   const { login, loginWithToken } = useAuth();
   const { login, loginWithToken } = useAuth();
   const { showToast } = useToast();
   const { showToast } = useToast();
   const { mode } = useTheme();
   const { mode } = useTheme();
 
 
+  // Resolve the post-login destination, preferring router state (set by
+  // ProtectedRoute when it redirects an unauthed visit) over the sessionStorage
+  // stash (used to survive the OIDC provider round-trip, which kills React
+  // state). Falls back to `/` and rejects unsafe targets via sanitize.
+  function resolvePostLoginRedirect(): string {
+    const fromState = (location.state as { from?: { pathname?: string; search?: string } } | null)?.from;
+    if (fromState?.pathname) {
+      const target = `${fromState.pathname}${fromState.search ?? ''}`;
+      const safe = sanitizeRedirectTarget(target);
+      if (safe) return safe;
+    }
+    return consumePostLoginRedirect() ?? '/';
+  }
+
   // Credentials step state
   // Credentials step state
   const [username, setUsername] = useState('');
   const [username, setUsername] = useState('');
   const [password, setPassword] = useState('');
   const [password, setPassword] = useState('');
@@ -190,7 +238,7 @@ export function LoginPage() {
         } else if (resp.access_token && resp.user) {
         } else if (resp.access_token && resp.user) {
           loginWithToken(resp.access_token, resp.user, toPersistence(savedRememberMe));
           loginWithToken(resp.access_token, resp.user, toPersistence(savedRememberMe));
           showToast(t('login.loginSuccess'));
           showToast(t('login.loginSuccess'));
-          navigate('/', { replace: true });
+          navigate(resolvePostLoginRedirect(), { replace: true });
         } else {
         } else {
           showToast(t('login.oidcLoginFailed'), 'error');
           showToast(t('login.oidcLoginFailed'), 'error');
           navigate('/login', { replace: true });
           navigate('/login', { replace: true });
@@ -219,7 +267,7 @@ export function LoginPage() {
         setStep('2fa');
         setStep('2fa');
       } else if (resp.access_token && resp.user) {
       } else if (resp.access_token && resp.user) {
         showToast(t('login.loginSuccess'));
         showToast(t('login.loginSuccess'));
-        navigate('/');
+        navigate(resolvePostLoginRedirect(), { replace: true });
       }
       }
     },
     },
     onError: (error: Error) => {
     onError: (error: Error) => {
@@ -275,7 +323,7 @@ export function LoginPage() {
       if (resp.access_token && resp.user) {
       if (resp.access_token && resp.user) {
         loginWithToken(resp.access_token, resp.user, toPersistence(rememberMe));
         loginWithToken(resp.access_token, resp.user, toPersistence(rememberMe));
         showToast(t('login.loginSuccess'));
         showToast(t('login.loginSuccess'));
-        navigate('/');
+        navigate(resolvePostLoginRedirect(), { replace: true });
       } else {
       } else {
         console.error('2FA verify: unexpected response shape', resp);
         console.error('2FA verify: unexpected response shape', resp);
         showToast(t('login.loginFailed'), 'error');
         showToast(t('login.loginFailed'), 'error');
@@ -298,6 +346,13 @@ export function LoginPage() {
           console.warn('setItem auth_remember_me failed, Remember Me will not carry through OIDC redirect', err);
           console.warn('setItem auth_remember_me failed, Remember Me will not carry through OIDC redirect', err);
         }
         }
       }
       }
+      // Stash the post-login destination from router state so it survives the
+      // provider round-trip (window.location.href kills React state). If the
+      // user landed on /login directly, fromState is absent and we don't stash.
+      const fromState = (location.state as { from?: { pathname?: string; search?: string } } | null)?.from;
+      if (fromState?.pathname) {
+        stashPostLoginRedirect(`${fromState.pathname}${fromState.search ?? ''}`);
+      }
       window.location.href = data.auth_url;
       window.location.href = data.auth_url;
     },
     },
     onError: (error: Error) => {
     onError: (error: Error) => {

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/index-DiPrOrl_.js


+ 1 - 1
static/index.html

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

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů