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')} +
+ )}