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

@@ -6,8 +6,10 @@ from pydantic import BaseModel
class TokenPayload(BaseModel): class TokenPayload(BaseModel):
sub: str sub: str # Username
exp: datetime exp: datetime # Expiration time
role: str = "user" # User role, default is regular user
metadata: dict = {} # Additional metadata
class AuthHandler: class AuthHandler:
@@ -15,13 +17,55 @@ class AuthHandler:
self.secret = os.getenv("TOKEN_SECRET", "4f85ds4f56dsf46") self.secret = os.getenv("TOKEN_SECRET", "4f85ds4f56dsf46")
self.algorithm = "HS256" self.algorithm = "HS256"
self.expire_hours = int(os.getenv("TOKEN_EXPIRE_HOURS", 4)) 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, 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 {}
)
def create_token(self, username: str) -> str:
expire = datetime.utcnow() + timedelta(hours=self.expire_hours)
payload = TokenPayload(sub=username, exp=expire)
return jwt.encode(payload.dict(), self.secret, algorithm=self.algorithm) 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: try:
payload = jwt.decode(token, self.secret, algorithms=[self.algorithm]) payload = jwt.decode(token, self.secret, algorithms=[self.algorithm])
expire_timestamp = payload["exp"] expire_timestamp = payload["exp"]
@@ -31,7 +75,14 @@ class AuthHandler:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired" 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: except jwt.PyJWTError:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token" status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token"

View File

@@ -341,25 +341,66 @@ def create_app(args):
ollama_api = OllamaAPI(rag, top_k=args.top_k) ollama_api = OllamaAPI(rag, top_k=args.top_k)
app.include_router(ollama_api.router, prefix="/api") 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)]) @app.post("/login", dependencies=[Depends(optional_api_key)])
async def login(form_data: OAuth2PasswordRequestForm = Depends()): async def login(form_data: OAuth2PasswordRequestForm = Depends()):
username = os.getenv("AUTH_USERNAME") username = os.getenv("AUTH_USERNAME")
password = os.getenv("AUTH_PASSWORD") password = os.getenv("AUTH_PASSWORD")
if not (username and password): if not (username and password):
raise HTTPException( # Authentication not configured, return guest token
status_code=status.HTTP_501_NOT_IMPLEMENTED, guest_token = auth_handler.create_token(
detail="Authentication not configured", 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: if form_data.username != username or form_data.password != password:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect credentials" 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 { return {
"access_token": auth_handler.create_token(username), "access_token": user_token,
"token_type": "bearer", "token_type": "bearer",
"auth_mode": "enabled"
} }
@app.get("/health", dependencies=[Depends(optional_api_key)]) @app.get("/health", dependencies=[Depends(optional_api_key)])

View File

@@ -9,7 +9,7 @@ import sys
import logging import logging
from ascii_colors import ASCIIColors from ascii_colors import ASCIIColors
from lightrag.api import __api_version__ 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 dotenv import load_dotenv
from fastapi.security import APIKeyHeader, OAuth2PasswordBearer from fastapi.security import APIKeyHeader, OAuth2PasswordBearer
from starlette.status import HTTP_403_FORBIDDEN from starlette.status import HTTP_403_FORBIDDEN
@@ -35,7 +35,8 @@ ollama_server_infos = OllamaServerInfos()
def get_auth_dependency(): 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( async def dependency(
request: Request, request: Request,
@@ -44,10 +45,41 @@ def get_auth_dependency():
if request.url.path in whitelist: if request.url.path in whitelist:
return 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 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 return dependency

View File

@@ -1,3 +1,3 @@
VITE_BACKEND_URL=http://localhost:9621 VITE_BACKEND_URL=http://localhost:9621
VITE_API_PROXY=true 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

View File

@@ -1,6 +1,8 @@
import { HashRouter as Router, Routes, Route, Navigate } from 'react-router-dom' 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 { useAuthStore } from '@/stores/state'
import { getAuthStatus } from '@/api/lightrag'
import { toast } from 'sonner'
import { Toaster } from 'sonner' import { Toaster } from 'sonner'
import App from './App' import App from './App'
import LoginPage from '@/features/LoginPage' import LoginPage from '@/features/LoginPage'
@@ -12,7 +14,58 @@ interface ProtectedRouteProps {
const ProtectedRoute = ({ children }: ProtectedRouteProps) => { const ProtectedRoute = ({ children }: ProtectedRouteProps) => {
const { isAuthenticated } = useAuthStore() 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) { if (!isAuthenticated) {
return <Navigate to="/login" replace /> return <Navigate to="/login" replace />
} }
@@ -21,13 +74,65 @@ const ProtectedRoute = ({ children }: ProtectedRouteProps) => {
} }
const AppRouter = () => { 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(() => { useEffect(() => {
const token = localStorage.getItem('LIGHTRAG-API-TOKEN'); let isMounted = true; // Flag to prevent state updates after unmount
if (!token) {
useAuthStore.getState().logout(); 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 ( return (
<ThemeProvider> <ThemeProvider>

View File

@@ -126,9 +126,19 @@ export type DocsStatusesResponse = {
statuses: Record<DocStatus, DocStatusResponse[]> 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 = { export type LoginResponse = {
access_token: string access_token: string
token_type: string token_type: string
auth_mode?: 'enabled' | 'disabled' // Authentication mode identifier
message?: string // Optional message
} }
export const InvalidApiKeyError = 'Invalid API Key' export const InvalidApiKeyError = 'Invalid API Key'
@@ -356,6 +366,63 @@ export const clearDocuments = async (): Promise<DocActionResponse> => {
return response.data 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> => { export const loginToServer = async (username: string, password: string): Promise<LoginResponse> => {
const formData = new FormData(); const formData = new FormData();
formData.append('username', username); 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 { useNavigate } from 'react-router-dom'
import { useAuthStore } from '@/stores/state' import { useAuthStore } from '@/stores/state'
import { loginToServer } from '@/api/lightrag' import { loginToServer, getAuthStatus } from '@/api/lightrag'
import { toast } from 'sonner' import { toast } from 'sonner'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@@ -13,11 +13,63 @@ import AppSettings from '@/components/AppSettings'
const LoginPage = () => { const LoginPage = () => {
const navigate = useNavigate() const navigate = useNavigate()
const { login } = useAuthStore() const { login, isAuthenticated } = useAuthStore()
const { t } = useTranslation() const { t } = useTranslation()
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [username, setUsername] = useState('') const [username, setUsername] = useState('')
const [password, setPassword] = 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>) => { const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault() e.preventDefault()
@@ -29,9 +81,19 @@ const LoginPage = () => {
try { try {
setLoading(true) setLoading(true)
const response = await loginToServer(username, password) const response = await loginToServer(username, password)
login(response.access_token)
navigate('/') // 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')) toast.success(t('login.successMessage'))
}
navigate('/')
} catch (error) { } catch (error) {
console.error('Login failed...', error) console.error('Login failed...', error)
toast.error(t('login.errorInvalidCredentials')) toast.error(t('login.errorInvalidCredentials'))

View File

@@ -57,7 +57,7 @@ function TabsNavigation() {
export default function SiteHeader() { export default function SiteHeader() {
const { t } = useTranslation() const { t } = useTranslation()
const navigate = useNavigate() const navigate = useNavigate()
const { logout } = useAuthStore() const { logout, isGuestMode } = useAuthStore()
const handleLogout = () => { const handleLogout = () => {
logout() logout()
@@ -74,6 +74,11 @@ export default function SiteHeader() {
<div className="flex h-10 flex-1 justify-center"> <div className="flex h-10 flex-1 justify-center">
<TabsNavigation /> <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> </div>
<nav className="flex items-center"> <nav className="flex items-center">

View File

@@ -28,7 +28,9 @@
"loggingIn": "Logging in...", "loggingIn": "Logging in...",
"successMessage": "Login succeeded", "successMessage": "Login succeeded",
"errorEmptyFields": "Please enter your username and password", "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": { "documentPanel": {
"clearDocuments": { "clearDocuments": {

View File

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

View File

@@ -19,7 +19,8 @@ interface BackendState {
interface AuthState { interface AuthState {
isAuthenticated: boolean; isAuthenticated: boolean;
showLoginModal: boolean; showLoginModal: boolean;
login: (token: string) => void; isGuestMode: boolean; // Add guest mode flag
login: (token: string, isGuest?: boolean) => void;
logout: () => void; logout: () => void;
setShowLoginModal: (show: boolean) => void; setShowLoginModal: (show: boolean) => void;
} }
@@ -66,16 +67,63 @@ const useBackendState = createSelectors(useBackendStateStoreBase)
export { useBackendState } export { useBackendState }
export const useAuthStore = create<AuthState>(set => ({ // Helper function to check if token is a guest token
isAuthenticated: !!localStorage.getItem('LIGHTRAG-API-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, showLoginModal: false,
login: (token) => { isGuestMode: initialState.isGuestMode,
login: (token, isGuest = false) => {
localStorage.setItem('LIGHTRAG-API-TOKEN', token); localStorage.setItem('LIGHTRAG-API-TOKEN', token);
set({ isAuthenticated: true, showLoginModal: false }); set({
isAuthenticated: true,
showLoginModal: false,
isGuestMode: isGuest
});
}, },
logout: () => { logout: () => {
localStorage.removeItem('LIGHTRAG-API-TOKEN'); localStorage.removeItem('LIGHTRAG-API-TOKEN');
set({ isAuthenticated: false }); set({
isAuthenticated: false,
isGuestMode: false
});
}, },
setShowLoginModal: (show) => set({ showLoginModal: show }) setShowLoginModal: (show) => set({ showLoginModal: show })
})); };
});