Add authentication features for admin access

- Introduce a new `auth` module with login, logout, and user verification endpoints for a single admin user.
- Update backend routes to require admin authentication for sensitive operations, enhancing security.
- Modify frontend components to include an authentication provider and gate, ensuring only authorized users can access the application.
- Implement a login page for admin access, improving user experience and security management.
- Update API request handling to redirect unauthorized users to the login page, ensuring proper access control.
This commit is contained in:
Xin Wang
2026-07-09 23:27:50 +08:00
parent d857401ef3
commit 590cb33cbd
20 changed files with 534 additions and 29 deletions

View File

@@ -22,6 +22,7 @@ from fastapi.middleware.cors import CORSMiddleware
from routes import (
assistants,
auth,
health,
knowledge_bases,
model_registry,
@@ -48,6 +49,7 @@ app.add_middleware(
)
app.include_router(health.router)
app.include_router(auth.router)
app.include_router(assistants.router)
app.include_router(knowledge_bases.router)
app.include_router(model_registry.router)

View File

@@ -6,12 +6,17 @@ from db.models import Assistant, AssistantModelBinding, ModelResource
from db.session import get_session
from fastapi import APIRouter, Depends, HTTPException
from schemas import AssistantOut, AssistantUpsert
from services.auth import require_admin
from services.masking import mask, resolve_incoming_key
from services.node_specs import validate_graph
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
router = APIRouter(prefix="/api/assistants", tags=["assistants"])
router = APIRouter(
prefix="/api/assistants",
tags=["assistants"],
dependencies=[Depends(require_admin)],
)
CAPABILITIES = ("LLM", "ASR", "TTS", "Realtime", "Embedding", "Agent")

56
backend/routes/auth.py Normal file
View File

@@ -0,0 +1,56 @@
"""Single-admin login endpoints."""
from fastapi import APIRouter, Depends, HTTPException, Response, status
from pydantic import BaseModel
from services.auth import (
AdminUser,
authenticate_admin,
clear_auth_cookie,
create_admin_token,
require_admin,
set_auth_cookie,
)
router = APIRouter(prefix="/api/auth", tags=["auth"])
class LoginIn(BaseModel):
username: str
password: str
class AdminUserOut(BaseModel):
username: str
displayName: str
role: str
def _to_out(user: AdminUser) -> AdminUserOut:
return AdminUserOut(
username=user.username,
displayName=user.display_name,
role=user.role,
)
@router.post("/login", response_model=AdminUserOut)
async def login(body: LoginIn, response: Response):
user = authenticate_admin(body.username, body.password)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="用户名或密码错误",
)
set_auth_cookie(response, create_admin_token())
return _to_out(user)
@router.post("/logout")
async def logout(response: Response):
clear_auth_cookie(response)
return {"ok": True}
@router.get("/me", response_model=AdminUserOut)
async def me(user: AdminUser = Depends(require_admin)):
return _to_out(user)

View File

@@ -10,11 +10,16 @@ from db.models import KnowledgeBase, ModelResource
from db.session import get_session
from fastapi import APIRouter, Depends, HTTPException
from schemas import KnowledgeBaseOut, KnowledgeBaseUpsert
from services.auth import require_admin
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
router = APIRouter(prefix="/api/knowledge-bases", tags=["knowledge-bases"])
router = APIRouter(
prefix="/api/knowledge-bases",
tags=["knowledge-bases"],
dependencies=[Depends(require_admin)],
)
async def _validate_embedding_resource(

View File

@@ -16,13 +16,18 @@ from schemas import (
ModelResourceTestResult,
ModelResourceUpsert,
)
from services.auth import require_admin
from services.interface_catalog import validate_fields
from services.masking import mask_secrets, merge_secrets
from services.model_resource_tester import test_model_resource
from sqlalchemy import delete, select, update
from sqlalchemy.ext.asyncio import AsyncSession
router = APIRouter(prefix="/api", tags=["model-registry"])
router = APIRouter(
prefix="/api",
tags=["model-registry"],
dependencies=[Depends(require_admin)],
)
def _definition_dict(row: InterfaceDefinition) -> dict:

View File

@@ -3,10 +3,15 @@
规格是只读的、与代码同生命周期(改了要重启后端 + 前端刷新),所以无需鉴权与缓存层。
"""
from fastapi import APIRouter
from fastapi import APIRouter, Depends
from services.auth import require_admin
from services.node_specs import node_types_response
router = APIRouter(prefix="/api/node-types", tags=["workflow"])
router = APIRouter(
prefix="/api/node-types",
tags=["workflow"],
dependencies=[Depends(require_admin)],
)
@router.get("")

View File

@@ -11,9 +11,10 @@
import asyncio
from db.session import SessionLocal
from fastapi import APIRouter, WebSocket
from fastapi import APIRouter, Depends, WebSocket
from loguru import logger
from models import AssistantConfig, SignalingOffer
from services.auth import require_admin, require_admin_websocket
from services.config_resolver import resolve_runtime_config
from starlette.websockets import WebSocketDisconnect, WebSocketState
@@ -24,7 +25,7 @@ from services.webrtc_ice import aiortc_ice_servers, client_ice_servers
router = APIRouter(tags=["voice"])
@router.get("/api/webrtc/ice-servers")
@router.get("/api/webrtc/ice-servers", dependencies=[Depends(require_admin)])
async def ice_servers():
"""Browser fetches STUN/TURN config (with ephemeral TURN creds when configured)."""
return {"iceServers": client_ice_servers()}
@@ -32,6 +33,8 @@ async def ice_servers():
@router.websocket("/ws/voice")
async def voice_signaling(websocket: WebSocket):
if not await require_admin_websocket(websocket):
return
await websocket.accept()
peers: dict = {}
try:

View File

@@ -15,6 +15,7 @@ from db.session import SessionLocal
from fastapi import APIRouter, WebSocket
from loguru import logger
from models import AssistantConfig
from services.auth import require_admin_websocket
from services.config_resolver import resolve_runtime_config
from starlette.websockets import WebSocketDisconnect
@@ -38,6 +39,8 @@ async def voice_stream(websocket: WebSocket):
from services.pipecat.pipeline import run_pipeline
from services.pipecat.transports import build_ws_transport
if not await require_admin_websocket(websocket):
return
await websocket.accept()
try:
cfg = await _resolve_start_config(await websocket.receive_text())

125
backend/services/auth.py Normal file
View File

@@ -0,0 +1,125 @@
"""Single-admin authentication helpers.
The project currently needs one deployment-level super admin, configured by
environment variables. Tokens are signed with HMAC and stored in an HttpOnly
cookie so the frontend does not handle bearer tokens directly.
"""
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import time
from dataclasses import dataclass
from typing import Any
import settings
from fastapi import HTTPException, Request, Response, WebSocket, status
@dataclass(frozen=True)
class AdminUser:
username: str
display_name: str = "超级管理员"
role: str = "super_admin"
def _b64encode(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
def _b64decode(data: str) -> bytes:
padding = "=" * (-len(data) % 4)
return base64.urlsafe_b64decode(f"{data}{padding}".encode("ascii"))
def _sign(payload: str) -> str:
digest = hmac.new(
settings.AUTH_SECRET_KEY.encode("utf-8"),
payload.encode("ascii"),
hashlib.sha256,
).digest()
return _b64encode(digest)
def _admin_user() -> AdminUser:
return AdminUser(username=settings.ADMIN_USERNAME)
def create_admin_token() -> str:
now = int(time.time())
payload = {
"sub": settings.ADMIN_USERNAME,
"role": "super_admin",
"iat": now,
"exp": now + settings.AUTH_TOKEN_EXPIRE_MINUTES * 60,
}
encoded_payload = _b64encode(
json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
)
return f"{encoded_payload}.{_sign(encoded_payload)}"
def verify_admin_token(token: str | None) -> AdminUser | None:
if not token or "." not in token:
return None
encoded_payload, signature = token.rsplit(".", 1)
if not hmac.compare_digest(_sign(encoded_payload), signature):
return None
try:
payload: dict[str, Any] = json.loads(_b64decode(encoded_payload))
except (ValueError, json.JSONDecodeError):
return None
if payload.get("sub") != settings.ADMIN_USERNAME:
return None
if int(payload.get("exp", 0)) < int(time.time()):
return None
return _admin_user()
def authenticate_admin(username: str, password: str) -> AdminUser | None:
username_ok = hmac.compare_digest(username, settings.ADMIN_USERNAME)
password_ok = hmac.compare_digest(password, settings.ADMIN_PASSWORD)
return _admin_user() if username_ok and password_ok else None
def set_auth_cookie(response: Response, token: str) -> None:
response.set_cookie(
settings.AUTH_COOKIE_NAME,
token,
max_age=settings.AUTH_TOKEN_EXPIRE_MINUTES * 60,
httponly=True,
secure=settings.AUTH_COOKIE_SECURE,
samesite=settings.AUTH_COOKIE_SAMESITE,
path="/",
)
def clear_auth_cookie(response: Response) -> None:
response.delete_cookie(
settings.AUTH_COOKIE_NAME,
httponly=True,
secure=settings.AUTH_COOKIE_SECURE,
samesite=settings.AUTH_COOKIE_SAMESITE,
path="/",
)
async def require_admin(request: Request) -> AdminUser:
user = verify_admin_token(request.cookies.get(settings.AUTH_COOKIE_NAME))
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="未登录或登录已过期",
)
return user
async def require_admin_websocket(websocket: WebSocket) -> AdminUser | None:
user = verify_admin_token(websocket.cookies.get(settings.AUTH_COOKIE_NAME))
if not user:
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
return None
return user

View File

@@ -29,6 +29,15 @@ CORS_ORIGINS = _split(
os.getenv("CORS_ORIGINS", "http://localhost:3000,http://127.0.0.1:3000")
)
# ---- Admin auth ----
ADMIN_USERNAME = os.getenv("ADMIN_USERNAME", "admin")
ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "znjj189981$")
AUTH_SECRET_KEY = os.getenv("AUTH_SECRET_KEY", "dev-secret-change-me")
AUTH_COOKIE_NAME = os.getenv("AUTH_COOKIE_NAME", "ai_video_admin_token")
AUTH_TOKEN_EXPIRE_MINUTES = int(os.getenv("AUTH_TOKEN_EXPIRE_MINUTES", "1440"))
AUTH_COOKIE_SECURE = os.getenv("AUTH_COOKIE_SECURE", "false").lower() == "true"
AUTH_COOKIE_SAMESITE = os.getenv("AUTH_COOKIE_SAMESITE", "lax")
# ---- WebRTC TURN ----
# TURN_URLS example:
# turn:182.92.86.220:3478?transport=udp,turn:182.92.86.220:3478?transport=tcp