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

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 2 месяцев назад
Родитель
Сommit
5fdea009fc
4 измененных файлов с 65 добавлено и 8 удалено
  1. 5 3
      frontend/src/App.tsx
  2. 59 4
      frontend/src/pages/LoginPage.tsx
  3. 0 0
      static/assets/index-DiPrOrl_.js
  4. 1 1
      static/index.html

+ 5 - 3
frontend/src/App.tsx

@@ -1,5 +1,5 @@
 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 { Layout } from './components/Layout';
 import { PrintersPage } from './pages/PrintersPage';
@@ -93,13 +93,14 @@ function WebSocketProvider({ children }: { children: React.ReactNode }) {
 
 function ProtectedRoute({ children }: { children: React.ReactNode }) {
   const { authEnabled, loading, user } = useAuth();
+  const location = useLocation();
 
   if (loading) {
     return <div className="min-h-screen flex items-center justify-center">Loading...</div>;
   }
 
   if (authEnabled && !user) {
-    return <Navigate to="/login" replace />;
+    return <Navigate to="/login" replace state={{ from: location }} />;
   }
 
   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
   // require their own permissions like users:read, groups:update, etc.).
   const { authEnabled, loading, user, hasPermission } = useAuth();
+  const location = useLocation();
 
   if (loading) {
     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) {
-    return <Navigate to="/login" replace />;
+    return <Navigate to="/login" replace state={{ from: location }} />;
   }
 
   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 { useNavigate, useSearchParams } from 'react-router-dom';
+import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
 import { useMutation, useQuery } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
 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
 // value and a subsequent page load does not replay the flag.
 const REMEMBER_ME_KEY = 'auth_remember_me';
+const POST_LOGIN_REDIRECT_KEY = 'auth_post_login_redirect';
 
 function toPersistence(remember: boolean): TokenPersistence {
   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
  * because hooks can't be used inside a loop callback — the `iconFailed`
@@ -77,12 +110,27 @@ function OIDCProviderButton({
 
 export function LoginPage() {
   const navigate = useNavigate();
+  const location = useLocation();
   const [searchParams] = useSearchParams();
   const { t } = useTranslation();
   const { login, loginWithToken } = useAuth();
   const { showToast } = useToast();
   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
   const [username, setUsername] = useState('');
   const [password, setPassword] = useState('');
@@ -190,7 +238,7 @@ export function LoginPage() {
         } else if (resp.access_token && resp.user) {
           loginWithToken(resp.access_token, resp.user, toPersistence(savedRememberMe));
           showToast(t('login.loginSuccess'));
-          navigate('/', { replace: true });
+          navigate(resolvePostLoginRedirect(), { replace: true });
         } else {
           showToast(t('login.oidcLoginFailed'), 'error');
           navigate('/login', { replace: true });
@@ -219,7 +267,7 @@ export function LoginPage() {
         setStep('2fa');
       } else if (resp.access_token && resp.user) {
         showToast(t('login.loginSuccess'));
-        navigate('/');
+        navigate(resolvePostLoginRedirect(), { replace: true });
       }
     },
     onError: (error: Error) => {
@@ -275,7 +323,7 @@ export function LoginPage() {
       if (resp.access_token && resp.user) {
         loginWithToken(resp.access_token, resp.user, toPersistence(rememberMe));
         showToast(t('login.loginSuccess'));
-        navigate('/');
+        navigate(resolvePostLoginRedirect(), { replace: true });
       } else {
         console.error('2FA verify: unexpected response shape', resp);
         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);
         }
       }
+      // 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;
     },
     onError: (error: Error) => {

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-DiPrOrl_.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-D0SWpeco.js"></script>
+    <script type="module" crossorigin src="/assets/index-DiPrOrl_.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-45eedLWT.css">
   </head>
   <body>

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