feat(auth): implement auto guest mode and enhance token system

- Add role-based token system with metadata support
- Implement automatic guest mode for unconfigured authentication
- Create new /auth-status endpoint for authentication status checking
- Modify frontend to auto-detect auth status and bypass login when appropriate
- Add guest mode indicator in site header for better UX

This change allows users to automatically access the system without manual
login when authentication is not configured, while maintaining secure
authentication when credentials are properly set up.
This commit is contained in:
yangdx
2025-03-18 02:56:02 +08:00
parent 056b58af75
commit f8440c8f80
11 changed files with 460 additions and 45 deletions

View File

@@ -1,6 +1,8 @@
import { HashRouter as Router, Routes, Route, Navigate } from 'react-router-dom'
import { useEffect } from 'react'
import { useEffect, useState } from 'react'
import { useAuthStore } from '@/stores/state'
import { getAuthStatus } from '@/api/lightrag'
import { toast } from 'sonner'
import { Toaster } from 'sonner'
import App from './App'
import LoginPage from '@/features/LoginPage'
@@ -12,7 +14,58 @@ interface ProtectedRouteProps {
const ProtectedRoute = ({ children }: ProtectedRouteProps) => {
const { isAuthenticated } = useAuthStore()
const [isChecking, setIsChecking] = useState(true)
useEffect(() => {
let isMounted = true; // Flag to prevent state updates after unmount
// This effect will run when the component mounts
// and will check if authentication is required
const checkAuthStatus = async () => {
try {
// Skip check if already authenticated
if (isAuthenticated) {
if (isMounted) setIsChecking(false);
return;
}
const status = await getAuthStatus()
// Only proceed if component is still mounted
if (!isMounted) return;
if (!status.auth_configured && status.access_token) {
// If auth is not configured, use the guest token
useAuthStore.getState().login(status.access_token, true)
if (status.message) {
toast.info(status.message)
}
}
} catch (error) {
console.error('Failed to check auth status:', error)
} finally {
// Only update state if component is still mounted
if (isMounted) {
setIsChecking(false)
}
}
}
// Execute immediately
checkAuthStatus()
// Cleanup function to prevent state updates after unmount
return () => {
isMounted = false;
}
}, [isAuthenticated])
// Show nothing while checking auth status
if (isChecking) {
return null
}
// After checking, if still not authenticated, redirect to login
if (!isAuthenticated) {
return <Navigate to="/login" replace />
}
@@ -21,13 +74,65 @@ const ProtectedRoute = ({ children }: ProtectedRouteProps) => {
}
const AppRouter = () => {
// Check login at befor startup
const [initializing, setInitializing] = useState(true)
const { isAuthenticated } = useAuthStore()
// Check token validity and auth configuration on app initialization
useEffect(() => {
const token = localStorage.getItem('LIGHTRAG-API-TOKEN');
if (!token) {
useAuthStore.getState().logout();
let isMounted = true; // Flag to prevent state updates after unmount
const checkAuth = async () => {
try {
const token = localStorage.getItem('LIGHTRAG-API-TOKEN')
// If we have a token, we're already authenticated
if (token && isAuthenticated) {
if (isMounted) setInitializing(false);
return;
}
// If no token or not authenticated, check if auth is configured
const status = await getAuthStatus()
// Only proceed if component is still mounted
if (!isMounted) return;
if (!status.auth_configured && status.access_token) {
// If auth is not configured, use the guest token
useAuthStore.getState().login(status.access_token, true)
if (status.message) {
toast.info(status.message)
}
} else if (!token) {
// Only logout if we don't have a token
useAuthStore.getState().logout()
}
} catch (error) {
console.error('Auth initialization error:', error)
if (isMounted && !isAuthenticated) {
useAuthStore.getState().logout()
}
} finally {
// Only update state if component is still mounted
if (isMounted) {
setInitializing(false)
}
}
}
}, []);
// Execute immediately
checkAuth()
// Cleanup function to prevent state updates after unmount
return () => {
isMounted = false;
}
}, [isAuthenticated])
// Show nothing while initializing
if (initializing) {
return null
}
return (
<ThemeProvider>

View File

@@ -126,9 +126,19 @@ export type DocsStatusesResponse = {
statuses: Record<DocStatus, DocStatusResponse[]>
}
export type AuthStatusResponse = {
auth_configured: boolean
access_token?: string
token_type?: string
auth_mode?: 'enabled' | 'disabled'
message?: string
}
export type LoginResponse = {
access_token: string
token_type: string
auth_mode?: 'enabled' | 'disabled' // Authentication mode identifier
message?: string // Optional message
}
export const InvalidApiKeyError = 'Invalid API Key'
@@ -356,6 +366,63 @@ export const clearDocuments = async (): Promise<DocActionResponse> => {
return response.data
}
export const getAuthStatus = async (): Promise<AuthStatusResponse> => {
try {
// Add a timeout to the request to prevent hanging
const response = await axiosInstance.get('/auth-status', {
timeout: 5000, // 5 second timeout
headers: {
'Accept': 'application/json' // Explicitly request JSON
}
});
// Check if response is HTML (which indicates a redirect or wrong endpoint)
const contentType = response.headers['content-type'] || '';
if (contentType.includes('text/html')) {
console.warn('Received HTML response instead of JSON for auth-status endpoint');
return {
auth_configured: true,
auth_mode: 'enabled'
};
}
// Strict validation of the response data
if (response.data &&
typeof response.data === 'object' &&
'auth_configured' in response.data &&
typeof response.data.auth_configured === 'boolean') {
// For unconfigured auth, ensure we have an access token
if (!response.data.auth_configured) {
if (response.data.access_token && typeof response.data.access_token === 'string') {
return response.data;
} else {
console.warn('Auth not configured but no valid access token provided');
}
} else {
// For configured auth, just return the data
return response.data;
}
}
// If response data is invalid but we got a response, log it
console.warn('Received invalid auth status response:', response.data);
// Default to auth configured if response is invalid
return {
auth_configured: true,
auth_mode: 'enabled'
};
} catch (error) {
// If the request fails, assume authentication is configured
console.error('Failed to get auth status:', errorMessage(error));
return {
auth_configured: true,
auth_mode: 'enabled'
};
}
}
export const loginToServer = async (username: string, password: string): Promise<LoginResponse> => {
const formData = new FormData();
formData.append('username', username);

View File

@@ -1,7 +1,7 @@
import { useState } from 'react'
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuthStore } from '@/stores/state'
import { loginToServer } from '@/api/lightrag'
import { loginToServer, getAuthStatus } from '@/api/lightrag'
import { toast } from 'sonner'
import { useTranslation } from 'react-i18next'
@@ -13,11 +13,63 @@ import AppSettings from '@/components/AppSettings'
const LoginPage = () => {
const navigate = useNavigate()
const { login } = useAuthStore()
const { login, isAuthenticated } = useAuthStore()
const { t } = useTranslation()
const [loading, setLoading] = useState(false)
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [checkingAuth, setCheckingAuth] = useState(true)
// Check if authentication is configured
useEffect(() => {
let isMounted = true; // Flag to prevent state updates after unmount
const checkAuthConfig = async () => {
try {
// If already authenticated, redirect to home
if (isAuthenticated) {
navigate('/')
return
}
// Check auth status
const status = await getAuthStatus()
// Only proceed if component is still mounted
if (!isMounted) return;
if (!status.auth_configured && status.access_token) {
// If auth is not configured, use the guest token and redirect
login(status.access_token, true)
if (status.message) {
toast.info(status.message)
}
navigate('/')
return; // Exit early, no need to set checkingAuth to false
}
} catch (error) {
console.error('Failed to check auth configuration:', error)
} finally {
// Only update state if component is still mounted
if (isMounted) {
setCheckingAuth(false)
}
}
}
// Execute immediately
checkAuthConfig()
// Cleanup function to prevent state updates after unmount
return () => {
isMounted = false;
}
}, [isAuthenticated, login, navigate])
// Don't render anything while checking auth
if (checkingAuth) {
return null
}
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
@@ -29,9 +81,19 @@ const LoginPage = () => {
try {
setLoading(true)
const response = await loginToServer(username, password)
login(response.access_token)
// Check authentication mode
const isGuestMode = response.auth_mode === 'disabled'
login(response.access_token, isGuestMode)
if (isGuestMode) {
// Show authentication disabled notification
toast.info(response.message || t('login.authDisabled', 'Authentication is disabled. Using guest access.'))
} else {
toast.success(t('login.successMessage'))
}
navigate('/')
toast.success(t('login.successMessage'))
} catch (error) {
console.error('Login failed...', error)
toast.error(t('login.errorInvalidCredentials'))

View File

@@ -57,7 +57,7 @@ function TabsNavigation() {
export default function SiteHeader() {
const { t } = useTranslation()
const navigate = useNavigate()
const { logout } = useAuthStore()
const { logout, isGuestMode } = useAuthStore()
const handleLogout = () => {
logout()
@@ -74,6 +74,11 @@ export default function SiteHeader() {
<div className="flex h-10 flex-1 justify-center">
<TabsNavigation />
{isGuestMode && (
<div className="ml-2 self-center px-2 py-1 text-xs bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200 rounded-md">
{t('login.guestMode', 'Guest Mode')}
</div>
)}
</div>
<nav className="flex items-center">

View File

@@ -28,7 +28,9 @@
"loggingIn": "Logging in...",
"successMessage": "Login succeeded",
"errorEmptyFields": "Please enter your username and password",
"errorInvalidCredentials": "Login failed, please check username and password"
"errorInvalidCredentials": "Login failed, please check username and password",
"authDisabled": "Authentication is disabled. Using guest access.",
"guestMode": "Guest Mode"
},
"documentPanel": {
"clearDocuments": {

View File

@@ -28,7 +28,9 @@
"loggingIn": "登录中...",
"successMessage": "登录成功",
"errorEmptyFields": "请输入您的用户名和密码",
"errorInvalidCredentials": "登录失败,请检查用户名和密码"
"errorInvalidCredentials": "登录失败,请检查用户名和密码",
"authDisabled": "认证已禁用,使用访客访问模式。",
"guestMode": "访客模式"
},
"documentPanel": {
"clearDocuments": {

View File

@@ -19,7 +19,8 @@ interface BackendState {
interface AuthState {
isAuthenticated: boolean;
showLoginModal: boolean;
login: (token: string) => void;
isGuestMode: boolean; // Add guest mode flag
login: (token: string, isGuest?: boolean) => void;
logout: () => void;
setShowLoginModal: (show: boolean) => void;
}
@@ -66,16 +67,63 @@ const useBackendState = createSelectors(useBackendStateStoreBase)
export { useBackendState }
export const useAuthStore = create<AuthState>(set => ({
isAuthenticated: !!localStorage.getItem('LIGHTRAG-API-TOKEN'),
showLoginModal: false,
login: (token) => {
localStorage.setItem('LIGHTRAG-API-TOKEN', token);
set({ isAuthenticated: true, showLoginModal: false });
},
logout: () => {
localStorage.removeItem('LIGHTRAG-API-TOKEN');
set({ isAuthenticated: false });
},
setShowLoginModal: (show) => set({ showLoginModal: show })
}));
// Helper function to check if token is a guest token
const isGuestToken = (token: string): boolean => {
try {
// JWT tokens are in the format: header.payload.signature
const parts = token.split('.');
if (parts.length !== 3) return false;
// Decode the payload (second part)
const payload = JSON.parse(atob(parts[1]));
// Check if the token has a role field with value "guest"
return payload.role === 'guest';
} catch (e) {
console.error('Error parsing token:', e);
return false;
}
};
// Initialize auth state from localStorage
const initAuthState = (): { isAuthenticated: boolean; isGuestMode: boolean } => {
const token = localStorage.getItem('LIGHTRAG-API-TOKEN');
if (!token) {
return { isAuthenticated: false, isGuestMode: false };
}
return {
isAuthenticated: true,
isGuestMode: isGuestToken(token)
};
};
export const useAuthStore = create<AuthState>(set => {
// Get initial state from localStorage
const initialState = initAuthState();
return {
isAuthenticated: initialState.isAuthenticated,
showLoginModal: false,
isGuestMode: initialState.isGuestMode,
login: (token, isGuest = false) => {
localStorage.setItem('LIGHTRAG-API-TOKEN', token);
set({
isAuthenticated: true,
showLoginModal: false,
isGuestMode: isGuest
});
},
logout: () => {
localStorage.removeItem('LIGHTRAG-API-TOKEN');
set({
isAuthenticated: false,
isGuestMode: false
});
},
setShowLoginModal: (show) => set({ showLoginModal: show })
};
});