From f8440c8f805fe4a9cedde92794ca29724b678a56 Mon Sep 17 00:00:00 2001 From: yangdx Date: Tue, 18 Mar 2025 02:56:02 +0800 Subject: [PATCH] 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. --- lightrag/api/auth.py | 65 ++++++++++-- lightrag/api/lightrag_server.py | 49 ++++++++- lightrag/api/utils_api.py | 42 +++++++- lightrag_webui/env.local.sample | 2 +- lightrag_webui/src/AppRouter.tsx | 117 +++++++++++++++++++-- lightrag_webui/src/api/lightrag.ts | 67 ++++++++++++ lightrag_webui/src/features/LoginPage.tsx | 72 ++++++++++++- lightrag_webui/src/features/SiteHeader.tsx | 7 +- lightrag_webui/src/locales/en.json | 4 +- lightrag_webui/src/locales/zh.json | 4 +- lightrag_webui/src/stores/state.ts | 76 ++++++++++--- 11 files changed, 460 insertions(+), 45 deletions(-) diff --git a/lightrag/api/auth.py b/lightrag/api/auth.py index 4d905de8..c0b22f2c 100644 --- a/lightrag/api/auth.py +++ b/lightrag/api/auth.py @@ -6,8 +6,10 @@ from pydantic import BaseModel class TokenPayload(BaseModel): - sub: str - exp: datetime + sub: str # Username + exp: datetime # Expiration time + role: str = "user" # User role, default is regular user + metadata: dict = {} # Additional metadata class AuthHandler: @@ -15,13 +17,55 @@ class AuthHandler: self.secret = os.getenv("TOKEN_SECRET", "4f85ds4f56dsf46") self.algorithm = "HS256" self.expire_hours = int(os.getenv("TOKEN_EXPIRE_HOURS", 4)) + self.guest_expire_hours = int(os.getenv("GUEST_TOKEN_EXPIRE_HOURS", 2)) # Guest token default expiration time - def create_token(self, username: str) -> str: - expire = datetime.utcnow() + timedelta(hours=self.expire_hours) - payload = TokenPayload(sub=username, exp=expire) + def create_token(self, username: str, role: str = "user", custom_expire_hours: int = None, metadata: dict = None) -> str: + """ + Create JWT token + + Args: + username: Username + role: User role, default is "user", guest is "guest" + custom_expire_hours: Custom expiration time (hours), if None use default value + metadata: Additional metadata + + Returns: + str: Encoded JWT token + """ + # Choose default expiration time based on role + if custom_expire_hours is None: + if role == "guest": + expire_hours = self.guest_expire_hours + else: + expire_hours = self.expire_hours + else: + expire_hours = custom_expire_hours + + expire = datetime.utcnow() + timedelta(hours=expire_hours) + + # Create payload + payload = TokenPayload( + sub=username, + exp=expire, + role=role, + metadata=metadata or {} + ) + return jwt.encode(payload.dict(), self.secret, algorithm=self.algorithm) - def validate_token(self, token: str) -> str: + def validate_token(self, token: str) -> dict: + """ + Validate JWT token + + Args: + token: JWT token + + Returns: + dict: Dictionary containing user information + + Raises: + HTTPException: If token is invalid or expired + """ try: payload = jwt.decode(token, self.secret, algorithms=[self.algorithm]) expire_timestamp = payload["exp"] @@ -31,7 +75,14 @@ class AuthHandler: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired" ) - return payload["sub"] + + # Return complete payload instead of just username + return { + "username": payload["sub"], + "role": payload.get("role", "user"), + "metadata": payload.get("metadata", {}), + "exp": expire_time + } except jwt.PyJWTError: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token" diff --git a/lightrag/api/lightrag_server.py b/lightrag/api/lightrag_server.py index 16f4a439..1fac9322 100644 --- a/lightrag/api/lightrag_server.py +++ b/lightrag/api/lightrag_server.py @@ -341,25 +341,66 @@ def create_app(args): ollama_api = OllamaAPI(rag, top_k=args.top_k) app.include_router(ollama_api.router, prefix="/api") + @app.get("/auth-status", dependencies=[Depends(optional_api_key)]) + async def get_auth_status(): + """Get authentication status and guest token if auth is not configured""" + username = os.getenv("AUTH_USERNAME") + password = os.getenv("AUTH_PASSWORD") + + if not (username and password): + # Authentication not configured, return guest token + guest_token = auth_handler.create_token( + username="guest", + role="guest", + metadata={"auth_mode": "disabled"} + ) + return { + "auth_configured": False, + "access_token": guest_token, + "token_type": "bearer", + "auth_mode": "disabled", + "message": "Authentication is disabled. Using guest access." + } + + return { + "auth_configured": True, + "auth_mode": "enabled" + } + @app.post("/login", dependencies=[Depends(optional_api_key)]) async def login(form_data: OAuth2PasswordRequestForm = Depends()): username = os.getenv("AUTH_USERNAME") password = os.getenv("AUTH_PASSWORD") if not (username and password): - raise HTTPException( - status_code=status.HTTP_501_NOT_IMPLEMENTED, - detail="Authentication not configured", + # Authentication not configured, return guest token + guest_token = auth_handler.create_token( + username="guest", + role="guest", + metadata={"auth_mode": "disabled"} ) + return { + "access_token": guest_token, + "token_type": "bearer", + "auth_mode": "disabled", + "message": "Authentication is disabled. Using guest access." + } if form_data.username != username or form_data.password != password: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect credentials" ) + # Regular user login + user_token = auth_handler.create_token( + username=username, + role="user", + metadata={"auth_mode": "enabled"} + ) return { - "access_token": auth_handler.create_token(username), + "access_token": user_token, "token_type": "bearer", + "auth_mode": "enabled" } @app.get("/health", dependencies=[Depends(optional_api_key)]) diff --git a/lightrag/api/utils_api.py b/lightrag/api/utils_api.py index 88a0132c..d5160acf 100644 --- a/lightrag/api/utils_api.py +++ b/lightrag/api/utils_api.py @@ -9,7 +9,7 @@ import sys import logging from ascii_colors import ASCIIColors from lightrag.api import __api_version__ -from fastapi import HTTPException, Security, Depends, Request +from fastapi import HTTPException, Security, Depends, Request, status from dotenv import load_dotenv from fastapi.security import APIKeyHeader, OAuth2PasswordBearer from starlette.status import HTTP_403_FORBIDDEN @@ -35,7 +35,8 @@ ollama_server_infos = OllamaServerInfos() def get_auth_dependency(): - whitelist = os.getenv("WHITELIST_PATHS", "").split(",") + # Set default whitelist paths + whitelist = os.getenv("WHITELIST_PATHS", "/login,/health").split(",") async def dependency( request: Request, @@ -44,10 +45,41 @@ def get_auth_dependency(): if request.url.path in whitelist: return - if not (os.getenv("AUTH_USERNAME") and os.getenv("AUTH_PASSWORD")): + # Check if authentication is configured + auth_configured = bool(os.getenv("AUTH_USERNAME") and os.getenv("AUTH_PASSWORD")) + + # If authentication is not configured, accept any token including guest tokens + if not auth_configured: + if token: # If token is provided, still validate it + try: + # Validate token but don't raise exception + token_info = auth_handler.validate_token(token) + # Check if it's a guest token + if token_info.get("role") != "guest": + # Non-guest tokens are not valid when auth is not configured + pass + except Exception as e: + # Ignore validation errors but log them + print(f"Token validation error (ignored): {str(e)}") return - - auth_handler.validate_token(token) + + # If authentication is configured, validate the token and reject guest tokens + if not token: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="Token required" + ) + + token_info = auth_handler.validate_token(token) + + # Reject guest tokens when authentication is configured + if token_info.get("role") == "guest": + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Authentication required. Guest access not allowed when authentication is configured." + ) + + # At this point, we have a valid non-guest token + return return dependency diff --git a/lightrag_webui/env.local.sample b/lightrag_webui/env.local.sample index 11fa8798..ed7c888e 100644 --- a/lightrag_webui/env.local.sample +++ b/lightrag_webui/env.local.sample @@ -1,3 +1,3 @@ VITE_BACKEND_URL=http://localhost:9621 VITE_API_PROXY=true -VITE_API_ENDPOINTS=/api,/documents,/graphs,/graph,/health,/query,/docs,/openapi.json,/login +VITE_API_ENDPOINTS=/api,/documents,/graphs,/graph,/health,/query,/docs,/openapi.json,/login,/auth-status diff --git a/lightrag_webui/src/AppRouter.tsx b/lightrag_webui/src/AppRouter.tsx index a88dc5ea..f7258393 100644 --- a/lightrag_webui/src/AppRouter.tsx +++ b/lightrag_webui/src/AppRouter.tsx @@ -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 } @@ -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 ( diff --git a/lightrag_webui/src/api/lightrag.ts b/lightrag_webui/src/api/lightrag.ts index 8a71ab9a..c681fa74 100644 --- a/lightrag_webui/src/api/lightrag.ts +++ b/lightrag_webui/src/api/lightrag.ts @@ -126,9 +126,19 @@ export type DocsStatusesResponse = { statuses: Record } +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 => { return response.data } +export const getAuthStatus = async (): Promise => { + 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 => { const formData = new FormData(); formData.append('username', username); diff --git a/lightrag_webui/src/features/LoginPage.tsx b/lightrag_webui/src/features/LoginPage.tsx index a79e225c..deda9818 100644 --- a/lightrag_webui/src/features/LoginPage.tsx +++ b/lightrag_webui/src/features/LoginPage.tsx @@ -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) => { 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')) diff --git a/lightrag_webui/src/features/SiteHeader.tsx b/lightrag_webui/src/features/SiteHeader.tsx index 1cdc7531..b25b833e 100644 --- a/lightrag_webui/src/features/SiteHeader.tsx +++ b/lightrag_webui/src/features/SiteHeader.tsx @@ -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() {
+ {isGuestMode && ( +
+ {t('login.guestMode', 'Guest Mode')} +
+ )}