feat: 站点访问密码登录(SITE_PASSWORD)+ 退出登录按钮
- 后端:新增 /api/auth/login 与 /api/auth/status;HMAC 签名令牌 30 天有效、 服务重启不失效;设置 SITE_PASSWORD 后中间件拦截除登录/健康检查/CORS 预检 外的所有 /api/*,未启用密码时行为不变(本地开发默认开放) - 前端:AuthGate 登录层 + BookMate 风格全屏登录页(墨绿卡片、轨道环、书标金句); 令牌存 localStorage 随请求带 X-Access-Token 头,401 自动清令牌重新弹出登录页; 侧栏底部新增「退出登录」按钮(仅在启用密码时显示) - 认证通过后才挂载主应用,避免未登录先发起数据请求 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,10 @@ DATA_DIR=./data
|
||||
API_HOST=0.0.0.0
|
||||
API_PORT=8000
|
||||
|
||||
# 站点访问密码:留空 = 不启用登录(本地开发默认);
|
||||
# 设置后整个应用需要密码才能使用,前端会显示登录页(令牌 30 天有效)
|
||||
SITE_PASSWORD=
|
||||
|
||||
# ===== 模型 Provider(至少配一个;也可启动后在网页「管理模型」里添加)=====
|
||||
DEEPSEEK_API_KEY=
|
||||
DEEPSEEK_BASE_URL=https://api.deepseek.com/v1
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import hmac
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.security import LOGIN_TTL_S, make_token, verify_token
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
password: str
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
async def login(req: LoginRequest):
|
||||
"""校验站点访问密码,签发登录令牌(30 天有效,服务重启不失效)。"""
|
||||
if not settings.site_password:
|
||||
raise HTTPException(400, "未启用访问密码")
|
||||
if not hmac.compare_digest(req.password or "", settings.site_password):
|
||||
raise HTTPException(401, "访问密码错误")
|
||||
return {"token": make_token(settings.site_password), "expires_in": LOGIN_TTL_S}
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def auth_status(request: Request):
|
||||
"""探测:是否要求密码 + 当前令牌是否有效(本身不鉴权,供登录层判断)。"""
|
||||
required = bool(settings.site_password)
|
||||
token = request.headers.get("x-access-token", "")
|
||||
ok = required and bool(token) and verify_token(token, settings.site_password)
|
||||
return {"required": required, "ok": ok}
|
||||
@@ -10,6 +10,8 @@ class Settings(BaseSettings):
|
||||
data_dir: str = str(BASE_DIR / "data")
|
||||
api_host: str = "0.0.0.0"
|
||||
api_port: int = 8000
|
||||
# 站点访问密码:留空 = 完全开放(本地开发默认);设置后所有 /api/* 需登录
|
||||
site_password: str = ""
|
||||
|
||||
# ===== 向量检索 & 重排(OpenAI 兼容 API,如硅基流动 SiliconFlow)=====
|
||||
# embedding_base_url: 支持 OpenAI 兼容 /embeddings 的端点前缀
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
"""站点访问密码:HMAC 签名令牌(无状态,服务重启后仍有效)。
|
||||
|
||||
令牌 = base64url( 过期时间戳 + "." + HMAC-SHA256(过期时间戳) )
|
||||
校验签名 + 过期时间;密码本身不参与签名之外的任何存储。
|
||||
"""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import time
|
||||
|
||||
LOGIN_TTL_S = 30 * 24 * 3600 # 登录后 30 天内免输密码
|
||||
|
||||
|
||||
def make_token(site_password: str, now: float | None = None) -> str:
|
||||
exp = int(now if now is not None else time.time()) + LOGIN_TTL_S
|
||||
sig = hmac.new(site_password.encode(), str(exp).encode(), hashlib.sha256).hexdigest()
|
||||
raw = f"{exp}.{sig}".encode()
|
||||
return base64.urlsafe_b64encode(raw).decode().rstrip("=")
|
||||
|
||||
|
||||
def verify_token(token: str, site_password: str, now: float | None = None) -> bool:
|
||||
try:
|
||||
pad = "=" * ((4 - len(token) % 4) % 4)
|
||||
raw = base64.urlsafe_b64decode((token + pad).encode())
|
||||
exp_s, sig = raw.decode().split(".", 1)
|
||||
exp = int(exp_s)
|
||||
if exp <= int(now if now is not None else time.time()):
|
||||
return False
|
||||
expect = hmac.new(site_password.encode(), str(exp).encode(), hashlib.sha256).hexdigest()
|
||||
return hmac.compare_digest(sig, expect)
|
||||
except Exception:
|
||||
return False
|
||||
+24
-1
@@ -3,14 +3,18 @@ from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.database import init_db, Base
|
||||
from app.core.security import verify_token
|
||||
from app.api.books import router as books_router
|
||||
from app.api.chat import router as chat_router
|
||||
from app.api.models import router as models_router
|
||||
from app.api.mcp import router as mcp_router
|
||||
from app.api.auth import router as auth_router
|
||||
|
||||
from app.models.book import Book, Chapter, Segment
|
||||
from app.models.chat import Conversation, Message
|
||||
@@ -26,6 +30,7 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
app = FastAPI(title="BookMate", version="0.1.0", lifespan=lifespan)
|
||||
|
||||
# CORS 先加,站点访问密码中间件后加 → 密码中间件在最外层先跑
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
@@ -34,10 +39,28 @@ app.add_middleware(
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def site_auth_middleware(request: Request, call_next):
|
||||
"""站点访问密码门:设置 SITE_PASSWORD 后,除登录/健康检查/CORS 预检外全部拦截。"""
|
||||
if not settings.site_password:
|
||||
return await call_next(request)
|
||||
path = request.url.path
|
||||
method = request.method
|
||||
# CORS 预检、健康检查、登录相关端点放行
|
||||
if method == "OPTIONS" or path == "/api/health" or path.startswith("/api/auth/"):
|
||||
return await call_next(request)
|
||||
token = request.headers.get("x-access-token", "")
|
||||
if not token or not verify_token(token, settings.site_password):
|
||||
return JSONResponse(status_code=401, content={"detail": "需要访问密码"})
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
app.include_router(books_router, prefix="/api")
|
||||
app.include_router(chat_router, prefix="/api")
|
||||
app.include_router(models_router, prefix="/api")
|
||||
app.include_router(mcp_router, prefix="/api")
|
||||
app.include_router(auth_router, prefix="/api")
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
|
||||
Reference in New Issue
Block a user