diff --git a/backend/app.py b/backend/app.py index b5b84e1..a80b016 100644 --- a/backend/app.py +++ b/backend/app.py @@ -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) diff --git a/backend/routes/assistants.py b/backend/routes/assistants.py index 250d2de..db08229 100644 --- a/backend/routes/assistants.py +++ b/backend/routes/assistants.py @@ -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") diff --git a/backend/routes/auth.py b/backend/routes/auth.py new file mode 100644 index 0000000..b131f63 --- /dev/null +++ b/backend/routes/auth.py @@ -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) diff --git a/backend/routes/knowledge_bases.py b/backend/routes/knowledge_bases.py index c433034..e9cfbf0 100644 --- a/backend/routes/knowledge_bases.py +++ b/backend/routes/knowledge_bases.py @@ -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( diff --git a/backend/routes/model_registry.py b/backend/routes/model_registry.py index e7867b6..4c89026 100644 --- a/backend/routes/model_registry.py +++ b/backend/routes/model_registry.py @@ -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: diff --git a/backend/routes/node_types.py b/backend/routes/node_types.py index 4624b4d..24143f3 100644 --- a/backend/routes/node_types.py +++ b/backend/routes/node_types.py @@ -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("") diff --git a/backend/routes/voice_webrtc.py b/backend/routes/voice_webrtc.py index 61ee9b8..bf5c834 100644 --- a/backend/routes/voice_webrtc.py +++ b/backend/routes/voice_webrtc.py @@ -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: diff --git a/backend/routes/voice_ws.py b/backend/routes/voice_ws.py index 19a72e2..5ce5a22 100644 --- a/backend/routes/voice_ws.py +++ b/backend/routes/voice_ws.py @@ -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()) diff --git a/backend/services/auth.py b/backend/services/auth.py new file mode 100644 index 0000000..d1e553f --- /dev/null +++ b/backend/services/auth.py @@ -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 diff --git a/backend/settings.py b/backend/settings.py index 6e79a08..82c6537 100644 --- a/backend/settings.py +++ b/backend/settings.py @@ -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 diff --git a/deploy/README.md b/deploy/README.md index 52a7535..8a17203 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -94,12 +94,15 @@ uv run --with-requirements requirements.txt \ ### 4. 单独启动前端 -前端也监听 `0.0.0.0`。`NEXT_PUBLIC_API_BASE_URL` 要写浏览器最终访问 nginx 的 -https 地址,不要写 `:8000`: +前端也监听 `0.0.0.0`。`NEXT_PUBLIC_API_BASE_URL` 只有一个规则: +**写浏览器地址栏里访问 nginx 的同源地址**。如果地址栏带端口,这里也必须带端口; +如果地址栏不带端口,这里也不带端口。不要写后端直连端口 `:8000`。 ```bash cd frontend npm install + +# nginx 使用默认 443 时: NEXT_PUBLIC_API_BASE_URL=https://<远程机器IP或域名> \ npm run dev -- --hostname 0.0.0.0 ``` @@ -146,7 +149,7 @@ docker run --rm --name ai-video-nginx \ 访问: ```text -https://<远程机器IP或域名> +https://<远程机器IP或域名>:18189 ``` ### 6. 远程语音 / WebRTC 注意事项 @@ -174,11 +177,13 @@ TURN_SECRET=<同上> ## 前端怎么连后端 -前端读环境变量 `NEXT_PUBLIC_API_BASE_URL`(compose 里已设)。走 nginx 后, -让它指向**同源**即可,ws 地址由它推导: +前端读环境变量 `NEXT_PUBLIC_API_BASE_URL`。走 nginx 后,让它等于浏览器访问 +页面的 **origin** 即可。origin 包含协议、host 和可选端口: ``` -NEXT_PUBLIC_API_BASE_URL = https://<访问用的host> # 同源,不再写 :8000 +默认 443: NEXT_PUBLIC_API_BASE_URL=https://<访问用的host> +自定义端口: NEXT_PUBLIC_API_BASE_URL=https://<访问用的host>:18189 + wsUrl = NEXT_PUBLIC_API_BASE_URL.replace(/^http/, 'ws') + '/ws/voice' ``` diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index f61bfff..d1db827 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -2,7 +2,8 @@ import type { Metadata } from "next"; import { Geist_Mono, Inter, Cormorant_Garamond } from "next/font/google"; import "./globals.css"; import { cn } from "@/lib/utils"; -import { AppShell } from "@/components/layout/AppShell"; +import { AuthGate } from "@/components/auth/AuthGate"; +import { AuthProvider } from "@/components/auth/AuthProvider"; const inter = Inter({ subsets: ["latin"], variable: "--font-sans" }); @@ -50,7 +51,9 @@ export default function RootLayout({
-