feat: add cookie sessions and backend role authorization

This commit is contained in:
2026-10-16 07:51:59 +08:00
parent 411eadb431
commit 3f25ffab83
28 changed files with 1186 additions and 31 deletions
+6 -3
View File
@@ -1,6 +1,6 @@
# 那拉提文旅智能问答
Vue 3 + TypeScript + Vite 前端;FastAPI + Pydantic + SQLAlchemy 后端;PostgreSQL + pgvector。当前后端提供知识管理、来源、文档、人工审核和历史记录,问答仍为占位接口。
Vue 3 + TypeScript + Vite 前端;FastAPI + Pydantic + SQLAlchemy 后端;PostgreSQL + pgvector。当前后端提供知识管理、来源、文档、人工审核、关键词检索、历史记录及认证/角色权限,问答仍为占位接口。
## 启动数据库
需要可用的 Docker Engine(Windows 可使用 Docker Desktop),在项目根目录运行:
@@ -34,6 +34,8 @@ uvicorn app.main:app --reload --host 127.0.0.1
配置始终从 `backend/.env` 读取,环境变量优先。必须先迁移再启动;应用启动只检查数据库版本,不再执行 `create_all`。旧版 `knowledge_items` 会被非破坏性接管;首次迁移前仍须备份。迁移会保留原条目及导入快照,不推测缺失的采集时间。旧版 `verified` 条目因缺少完整审核记录,会转为 `pending`,原状态保存在导入日志中。
认证迁移为 `0004_auth_sessions`。知识管理、来源、文档及检索历史现要求管理员登录;迁移不创建默认账号。运行 `python -m app.manage create-admin --username local_admin`,通过隐藏输入设置独立密码。默认 Cookie 要求 HTTPS;本机 HTTP 联调须在未入库的 `.env` 显式设置 `AUTH_COOKIE_SECURE=false`(仅 development/test 允许)。完整来源、CSRF、字段和权限说明见 [认证接口契约](docs/AUTH_API.md)。
## 启动前端(另开终端)
```powershell
cd frontend
@@ -43,6 +45,7 @@ npm run dev
Vite 通常使用 http://localhost:5173。
## 已有接口
- 注册/登录/退出、当前会话、资料修改、改密与管理员用户列表,详见 [认证接口契约](docs/AUTH_API.md)
- GET `/api/health`
- GET `/api/ready`:检查数据库连接
- POST `/api/chat`:占位接口,尚未接入检索和 LLM
@@ -102,7 +105,7 @@ Vite 通常使用 http://localhost:5173。
前端包括问答、知识库管理、来源与更新、实验评估四个页面。实验评估页面没有虚构数据;所有页面明确标记当前功能状态。
注意:当前没有实现 Embedding、RAG、网络搜索、后台更新任务或用户认证。审核人是本机开发环境输入字段,不是认证身份。服务保持仅本机访问;正式部署能力留到后续阶段。`.env` 不要提交到 Git。
注意:当前没有实现 Embedding、RAG、网络搜索或后台更新任务。管理接口已有认证与角色校验;审核表单的核验人仍是人工输入字段。服务保持仅本机访问,完整生产部署及前端登录联调尚未验收。`.env` 不要提交到 Git。
### 后端测试和迁移回滚
@@ -115,7 +118,7 @@ pytest -q
测试需要真实 PostgreSQL + pgvector:设置 `TEST_DATABASE_URL`(`postgresql+psycopg://...`),或另行 `pip install pgserver` 使用可选的本机临时实例。`pgserver` 仅用于测试,不是应用运行依赖;外部测试数据库账号需有建 Schema 权限且已启用 vector 扩展。每个测试使用独立 Schema,不会清空业务表。测试使用的占位正文和审核人不代表真实资料。
迁移命令为 `alembic upgrade head`,可重复执行。仅撤回关键词检索的数据库变更时,备份后执行 `alembic downgrade 0002_catalog`,只删除检索历史表。需要撤回阶段 2 时,先备份,再执行 `alembic downgrade 0001_baseline`;它保留基础知识字段,但会删除新增来源、文档、审核字段与所有历史记录,软删除的基础条目也会重新可见。降级须配合对应旧版后端,当前应用会拒绝旧 Schema。恢复完整数据应使用备份。不要在有业务数据的数据库执行 `downgrade base`。
迁移命令为 `alembic upgrade head`,可重复执行。仅撤回认证时,备份后执行 `alembic downgrade 0003_retrieval_runs`,删除用户/会话/预算表,保留知识和检索历史。继续执行 `alembic downgrade 0002_catalog` 还会删除检索历史。需要撤回阶段 2 时,先备份,再执行 `alembic downgrade 0001_baseline`;它保留基础知识字段,但会删除新增来源、文档、审核字段与所有历史记录,软删除的基础条目也会重新可见。降级须配合对应旧版后端,当前应用会拒绝旧 Schema。恢复完整数据应使用备份。不要在有业务数据的数据库执行 `downgrade base`。
## 团队协作文档
+4
View File
@@ -3,3 +3,7 @@ APP_ENV=development
API_PREFIX=/api
DATABASE_URL=postgresql+psycopg://nalati:change-this-password@127.0.0.1:5432/nalati_rag
CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
AUTH_ALLOWED_ORIGINS=http://localhost:5173,http://127.0.0.1:5173,http://localhost:8000,http://127.0.0.1:8000
# 默认要求 HTTPS;仅本机 HTTP 联调可显式改为 false。
AUTH_COOKIE_SECURE=true
AUTH_SESSION_SECONDS=28800
+122
View File
@@ -0,0 +1,122 @@
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
from sqlalchemy import delete, select
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.models.auth import AuthSession, User
from app.schemas.auth import (
LoginInput,
PasswordChangeInput,
ProfileInput,
RegisterInput,
SessionRead,
UserRead,
)
from app.services.auth import (
PASSWORD_HASHER,
CurrentSession,
check_password,
clear_cookie,
dummy_hash,
issue_session,
limit_auth_requests,
require_admin,
require_origin,
require_session,
)
router = APIRouter(tags=["auth"])
@router.post("/auth/register", response_model=UserRead, status_code=201)
def register(
payload: RegisterInput,
request: Request,
db: Session = Depends(get_db),
origin=Depends(require_origin),
):
"""Registration never accepts role, ID, password hash or credential version fields."""
limit_auth_requests(db, request)
user = User(
username=payload.username,
password_hash=PASSWORD_HASHER.hash(payload.password.get_secret_value()),
nickname=payload.nickname,
email=str(payload.email) if payload.email else None,
role="user",
)
db.add(user)
db.commit()
return user
@router.post("/auth/login", response_model=SessionRead)
def login(
payload: LoginInput,
request: Request,
response: Response,
db: Session = Depends(get_db),
origin=Depends(require_origin),
):
limit_auth_requests(db, request, payload.username)
user = db.scalar(select(User).where(User.username == payload.username).with_for_update())
encoded = user.password_hash if user else dummy_hash()
if not check_password(encoded, payload.password.get_secret_value()) or user is None:
raise HTTPException(401, "用户名或密码错误")
if PASSWORD_HASHER.check_needs_rehash(user.password_hash):
user.password_hash = PASSWORD_HASHER.hash(payload.password.get_secret_value())
return issue_session(db, user, request, response)
@router.get("/auth/me", response_model=SessionRead)
def me(current: CurrentSession = Depends(require_session)):
return current.response()
@router.post("/auth/logout", status_code=204)
def logout(
response: Response,
current: CurrentSession = Depends(require_session),
db: Session = Depends(get_db),
):
db.delete(current.session)
db.commit()
clear_cookie(response)
@router.patch("/users/me", response_model=UserRead)
def update_profile(
payload: ProfileInput,
current: CurrentSession = Depends(require_session),
db: Session = Depends(get_db),
):
if not payload.model_fields_set:
raise HTTPException(422, "请至少填写一个可修改字段")
for key, value in payload.model_dump(exclude_unset=True, mode="json").items():
setattr(current.user, key, value)
db.commit()
return current.user
@router.post("/auth/change-password", status_code=204)
def change_password(
payload: PasswordChangeInput,
response: Response,
current: CurrentSession = Depends(require_session),
db: Session = Depends(get_db),
):
if not check_password(current.user.password_hash, payload.current_password.get_secret_value()):
raise HTTPException(400, "当前密码不正确")
current.user.password_hash = PASSWORD_HASHER.hash(payload.new_password.get_secret_value())
current.user.credential_version += 1
db.execute(delete(AuthSession).where(AuthSession.user_id == current.user.id))
db.commit()
clear_cookie(response)
@router.get("/admin/users", response_model=list[UserRead], dependencies=[Depends(require_admin)])
def list_users(
limit: int = Query(100, ge=1, le=100),
offset: int = Query(0, ge=0),
db: Session = Depends(get_db),
):
return list(db.scalars(select(User).order_by(User.id).offset(offset).limit(limit)))
+2 -1
View File
@@ -5,6 +5,7 @@ from sqlalchemy.orm import Session
from app.db.session import get_db
from app.models import Document, KnowledgeItem, Source
from app.schemas.catalog import AuditRead, DocumentCreate, DocumentRead, DocumentUpdate, Page
from app.services.auth import require_admin
from app.services.catalog import (
ensure_version,
get_item,
@@ -15,7 +16,7 @@ from app.services.catalog import (
update_values,
)
router = APIRouter(prefix="/documents", tags=["documents"])
router = APIRouter(prefix="/documents", tags=["documents"], dependencies=[Depends(require_admin)])
@router.get("", response_model=Page[DocumentRead])
+2 -1
View File
@@ -7,6 +7,7 @@ from app.models import KnowledgeItem
from app.models.catalog import utc_now
from app.schemas.catalog import AuditRead, Page, Status
from app.schemas.knowledge import KnowledgeCreate, KnowledgeRead, KnowledgeReview, KnowledgeUpdate
from app.services.auth import require_admin
from app.services.catalog import (
ensure_version,
get_item,
@@ -18,7 +19,7 @@ from app.services.catalog import (
update_values,
)
router = APIRouter(prefix="/knowledge", tags=["knowledge"])
router = APIRouter(prefix="/knowledge", tags=["knowledge"], dependencies=[Depends(require_admin)])
def filtered_query(q="", category=None, verification_status=None, source_id=None, document_id=None):
+5 -2
View File
@@ -8,6 +8,7 @@ from app.db.session import get_db
from app.models import RetrievalRun
from app.schemas.catalog import Page
from app.schemas.retrieval import RetrievalRequest, RetrievalResponse
from app.services.auth import require_admin
from app.services.retrieval import retrieve
router = APIRouter(prefix="/retrieval", tags=["retrieval"])
@@ -19,7 +20,7 @@ def keyword_retrieval(payload: RetrievalRequest, db: Session = Depends(get_db)):
return retrieve(db, payload)
@router.get("/runs", response_model=Page[RetrievalResponse])
@router.get("/runs", response_model=Page[RetrievalResponse], dependencies=[Depends(require_admin)])
def list_runs(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
@@ -39,7 +40,9 @@ def list_runs(
}
@router.get("/runs/{run_id}", response_model=RetrievalResponse)
@router.get(
"/runs/{run_id}", response_model=RetrievalResponse, dependencies=[Depends(require_admin)]
)
def read_run(run_id: UUID, db: Session = Depends(get_db)):
row = db.get(RetrievalRun, str(run_id))
if row is None:
+2 -1
View File
@@ -5,6 +5,7 @@ from sqlalchemy.orm import Session
from app.db.session import get_db
from app.models import Document, KnowledgeItem, Source
from app.schemas.catalog import AuditRead, Page, SourceCreate, SourceRead, SourceUpdate
from app.services.auth import require_admin
from app.services.catalog import (
ensure_version,
get_item,
@@ -15,7 +16,7 @@ from app.services.catalog import (
update_values,
)
router = APIRouter(prefix="/sources", tags=["sources"])
router = APIRouter(prefix="/sources", tags=["sources"], dependencies=[Depends(require_admin)])
@router.get("", response_model=Page[SourceRead])
+32
View File
@@ -1,5 +1,7 @@
from pathlib import Path
from urllib.parse import urlsplit
from pydantic import Field, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -9,6 +11,11 @@ class Settings(BaseSettings):
api_prefix: str = "/api"
database_url: str = "postgresql+psycopg://nalati:change-this-password@127.0.0.1:5432/nalati_rag"
cors_origins: str = "http://localhost:5173,http://127.0.0.1:5173"
auth_allowed_origins: str = (
"http://localhost:5173,http://127.0.0.1:5173,http://localhost:8000,http://127.0.0.1:8000"
)
auth_cookie_secure: bool = True
auth_session_seconds: int = Field(28800, ge=300, le=604800)
model_config = SettingsConfigDict(
env_file=Path(__file__).resolve().parents[2] / ".env", extra="ignore"
)
@@ -17,5 +24,30 @@ class Settings(BaseSettings):
def cors_origin_list(self) -> list[str]:
return [x.strip() for x in self.cors_origins.split(",") if x.strip()]
@property
def auth_origin_list(self) -> list[str]:
return [x.strip() for x in self.auth_allowed_origins.split(",") if x.strip()]
@model_validator(mode="after")
def validate_auth_settings(self):
if not self.auth_cookie_secure and self.app_env not in {"development", "test"}:
raise ValueError("非开发/测试环境必须使用 Secure Cookie 与 HTTPS")
if not self.auth_origin_list:
raise ValueError("必须显式设置认证允许的来源")
for origin in [*self.auth_origin_list, *self.cors_origin_list]:
parsed = urlsplit(origin)
if (
parsed.scheme not in {"http", "https"}
or not parsed.hostname
or parsed.netloc != parsed.hostname + (f":{parsed.port}" if parsed.port else "")
or parsed.path
or parsed.query
or parsed.fragment
or "*" in origin
or (self.app_env not in {"development", "test"} and parsed.scheme != "https")
):
raise ValueError("认证和跨域来源须为明确的 HTTP(S) Origin,不允许通配符或路径")
return self
settings = Settings()
+27 -3
View File
@@ -1,15 +1,17 @@
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from sqlalchemy import text
from sqlalchemy.exc import IntegrityError, OperationalError, ProgrammingError
from sqlalchemy.orm.exc import StaleDataError
from app.api.routes import chat, documents, health, knowledge, retrieval, sources
from app.api.routes import auth, chat, documents, health, knowledge, retrieval, sources
from app.core.config import settings
from app.db.session import engine
from app.services.auth import dummy_hash
from app.services.retrieval import tokenizer
@@ -19,7 +21,7 @@ def create_app(db_engine=engine) -> FastAPI:
try:
with db_engine.connect() as connection:
revision = connection.scalar(text("SELECT version_num FROM alembic_version"))
if revision != "0003_retrieval_runs":
if revision != "0004_auth_sessions":
raise RuntimeError(
"数据库版本不匹配,请先在 backend 目录执行 alembic upgrade head"
)
@@ -28,12 +30,13 @@ def create_app(db_engine=engine) -> FastAPI:
"数据库尚未初始化,请先在 backend 目录执行 alembic upgrade head"
) from exc
tokenizer()
dummy_hash()
try:
yield
finally:
db_engine.dispose()
app = FastAPI(title=settings.app_name, version="0.3.0", lifespan=lifespan)
app = FastAPI(title=settings.app_name, version="0.4.0", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origin_list,
@@ -43,6 +46,7 @@ def create_app(db_engine=engine) -> FastAPI:
)
for router in (
health.router,
auth.router,
chat.router,
knowledge.router,
sources.router,
@@ -51,6 +55,26 @@ def create_app(db_engine=engine) -> FastAPI:
):
app.include_router(router, prefix=settings.api_prefix)
@app.middleware("http")
async def no_cache_api(request, call_next):
response = await call_next(request)
if request.url.path.startswith(settings.api_prefix + "/"):
response.headers["Cache-Control"] = "no-store"
return response
@app.exception_handler(RequestValidationError)
async def validation_handler(request, exc):
# FastAPI's default includes raw input (including passwords) in error responses.
return JSONResponse(
status_code=422,
content={
"detail": [
{"loc": list(error["loc"]), "msg": error["msg"], "type": error["type"]}
for error in exc.errors()
]
},
)
@app.exception_handler(StaleDataError)
async def stale_data_handler(request, exc):
return JSONResponse(status_code=409, content={"detail": "记录已更新,请刷新后重试"})
+47
View File
@@ -0,0 +1,47 @@
"""Local-only administrator provisioning. Passwords are read from the terminal, never argv."""
import argparse
import getpass
from pydantic import ValidationError
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from app.db.session import SessionLocal
from app.models.auth import User
from app.schemas.auth import RegisterInput
from app.services.auth import PASSWORD_HASHER
def main():
parser = argparse.ArgumentParser(description="本机管理员初始化(须先完成数据库迁移)")
parser.add_argument("command", choices=["create-admin"])
parser.add_argument("--username", required=True)
args = parser.parse_args()
password = getpass.getpass("管理员密码(至少 15 字符):")
if password != getpass.getpass("再次输入密码:"):
parser.exit(1, "两次密码不一致\n")
try:
payload = RegisterInput(username=args.username, password=password)
except ValidationError:
parser.exit(1, "用户名须为 3–32 位字母开头的小写字母/数字/下划线;密码须为 15–128 字符\n")
with SessionLocal() as db:
if db.scalar(select(User.id).where(User.username == payload.username)) is not None:
parser.exit(1, "用户已存在,未修改原有用户或权限\n")
db.add(
User(
username=payload.username,
password_hash=PASSWORD_HASHER.hash(password),
role="admin",
)
)
try:
db.commit()
except IntegrityError:
db.rollback()
parser.exit(1, "用户已存在,未修改原有用户或权限\n")
print("管理员已创建;请通过登录接口建立会话。")
if __name__ == "__main__":
main()
+11 -1
View File
@@ -2,4 +2,14 @@ from app.models.catalog import AuditEvent, Document, Source
from app.models.knowledge import KnowledgeItem
from app.models.retrieval import RetrievalRun
__all__ = ["AuditEvent", "Document", "KnowledgeItem", "RetrievalRun", "Source"]
__all__ = [
"AuditEvent",
"AuthRateLimit",
"AuthSession",
"Document",
"KnowledgeItem",
"RetrievalRun",
"Source",
"User",
]
from app.models.auth import AuthRateLimit, AuthSession, User
+42
View File
@@ -0,0 +1,42 @@
"""Users, opaque sessions and shared authentication request budgets."""
from datetime import datetime
from sqlalchemy import CheckConstraint, DateTime, ForeignKey, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column
from app.db.session import Base
class User(Base):
__tablename__ = "users"
__table_args__ = (CheckConstraint("role IN ('user', 'admin')", name="ck_users_role"),)
id: Mapped[int] = mapped_column(primary_key=True)
username: Mapped[str] = mapped_column(String(32), unique=True, nullable=False)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
nickname: Mapped[str | None] = mapped_column(String(80))
email: Mapped[str | None] = mapped_column(String(254))
role: Mapped[str] = mapped_column(String(10), default="user", nullable=False)
credential_version: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class AuthSession(Base):
__tablename__ = "auth_sessions"
token_hash: Mapped[str] = mapped_column(String(64), primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
credential_version: Mapped[int] = mapped_column(Integer, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
expires_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, index=True
)
class AuthRateLimit(Base):
__tablename__ = "auth_rate_limits"
key: Mapped[str] = mapped_column(String(64), primary_key=True)
window_start: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
attempts: Mapped[int] = mapped_column(Integer, nullable=False)
+67
View File
@@ -0,0 +1,67 @@
from datetime import datetime
from typing import Annotated, Literal
from pydantic import BaseModel, ConfigDict, EmailStr, Field, SecretStr, field_validator
Password = Annotated[SecretStr, Field(min_length=15, max_length=128)]
class AuthInput(BaseModel):
model_config = ConfigDict(extra="forbid")
class ProfileInput(AuthInput):
nickname: str | None = Field(None, min_length=1, max_length=80)
email: EmailStr | None = Field(None, max_length=254)
@field_validator("nickname", "email", mode="before")
@classmethod
def optional_text(cls, value):
return (value.strip() or None) if isinstance(value, str) else value
@field_validator("nickname")
@classmethod
def no_control_characters(cls, value):
if value and any(ord(char) < 32 or ord(char) == 127 for char in value):
raise ValueError("昵称不能包含控制字符")
return value
class RegisterInput(ProfileInput):
username: str = Field(min_length=3, max_length=32, pattern=r"^[a-z][a-z0-9_]{2,31}$")
password: Password
@field_validator("username", mode="before")
@classmethod
def canonical_username(cls, value):
return value.strip().lower() if isinstance(value, str) else value
class LoginInput(AuthInput):
username: str = Field(min_length=3, max_length=32, pattern=r"^[a-z][a-z0-9_]{2,31}$")
password: SecretStr = Field(min_length=1, max_length=128)
@field_validator("username", mode="before")
@classmethod
def canonical_username(cls, value):
return value.strip().lower() if isinstance(value, str) else value
class PasswordChangeInput(AuthInput):
current_password: SecretStr = Field(min_length=1, max_length=128)
new_password: Password
class UserRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
username: str
nickname: str | None
email: str | None
role: Literal["user", "admin"]
created_at: datetime
class SessionRead(UserRead):
csrf_token: str
+161
View File
@@ -0,0 +1,161 @@
"""Database-backed sessions; no bearer secrets are stored in the database."""
import hashlib
import hmac
import re
import secrets
from dataclasses import dataclass
from datetime import timedelta
from functools import lru_cache
from argon2 import PasswordHasher
from argon2.exceptions import InvalidHashError, VerificationError
from fastapi import Depends, HTTPException, Request, Response
from sqlalchemy import case, delete, select
from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.orm import Session
from app.core.config import settings
from app.db.session import get_db
from app.models.auth import AuthRateLimit, AuthSession, User
from app.models.catalog import utc_now
from app.schemas.auth import SessionRead, UserRead
COOKIE_NAME = "nalati_session"
UNSAFE_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
PASSWORD_HASHER = PasswordHasher(time_cost=2, memory_cost=19456, parallelism=1)
@lru_cache(maxsize=1)
def dummy_hash():
return PASSWORD_HASHER.hash(secrets.token_urlsafe(32))
def check_password(encoded: str, password: str) -> bool:
try:
return PASSWORD_HASHER.verify(encoded, password)
except (VerificationError, InvalidHashError):
return False
def token_hash(raw: str) -> str:
return hashlib.sha256(raw.encode()).hexdigest()
def csrf_token(raw: str) -> str:
# Domain-separated derivative; knowing the CSRF token does not reveal the cookie.
return hmac.new(raw.encode(), b"nalati-csrf-v1", hashlib.sha256).hexdigest()
def require_origin(request: Request):
if request.headers.get("origin") not in settings.auth_origin_list:
raise HTTPException(403, "请求来源不受信任")
def limit_auth_requests(db: Session, request: Request, username: str | None = None):
"""Atomic, shared across workers and restarts; never trust forwarded client headers here."""
now = utc_now()
window = timedelta(minutes=5)
buckets = [("ip:" + (request.client.host if request.client else "unknown"), 30)]
if username is not None:
buckets.append(("username:" + username, 10))
blocked = False
for identity, maximum in buckets:
statement = insert(AuthRateLimit).values(
key=token_hash(identity), window_start=now, attempts=1
)
expired = AuthRateLimit.window_start <= now - window
statement = statement.on_conflict_do_update(
index_elements=[AuthRateLimit.key],
set_={
"window_start": case((expired, now), else_=AuthRateLimit.window_start),
"attempts": case((expired, 1), else_=AuthRateLimit.attempts + 1),
},
).returning(AuthRateLimit.attempts)
blocked |= db.scalar(statement) > maximum
db.execute(delete(AuthRateLimit).where(AuthRateLimit.window_start < now - timedelta(days=1)))
# Persist even failed authentication; no password or raw address is logged.
db.commit()
if blocked:
raise HTTPException(429, "认证请求过于频繁,请稍后重试", headers={"Retry-After": "300"})
@dataclass
class CurrentSession:
user: User
session: AuthSession
raw_token: str
def response(self):
return SessionRead(
**UserRead.model_validate(self.user).model_dump(),
csrf_token=csrf_token(self.raw_token),
)
def require_session(request: Request, db: Session = Depends(get_db)) -> CurrentSession:
raw = request.cookies.get(COOKIE_NAME, "")
if not re.fullmatch(r"[A-Za-z0-9_-]{43}", raw):
raise HTTPException(401, "请先登录")
session = db.get(AuthSession, token_hash(raw))
if session is None:
raise HTTPException(401, "登录已失效,请重新登录")
# Lock the user before checking the current session again, serializing password changes/login.
user = db.scalar(select(User).where(User.id == session.user_id).with_for_update())
session = db.scalar(
select(AuthSession)
.where(AuthSession.token_hash == token_hash(raw))
.execution_options(populate_existing=True)
)
if (
user is None
or session is None
or session.expires_at <= utc_now()
or session.credential_version != user.credential_version
):
raise HTTPException(401, "登录已失效,请重新登录")
if request.method in UNSAFE_METHODS:
require_origin(request)
submitted = request.headers.get("x-csrf-token", "")
if not hmac.compare_digest(submitted.encode(), csrf_token(raw).encode()):
raise HTTPException(403, "CSRF 校验失败,请刷新登录状态后重试")
return CurrentSession(user, session, raw)
def require_admin(current: CurrentSession = Depends(require_session)):
if current.user.role != "admin":
raise HTTPException(403, "需要管理员权限")
return current
def issue_session(db: Session, user: User, request: Request, response: Response):
now = utc_now()
old = request.cookies.get(COOKIE_NAME, "")
db.execute(delete(AuthSession).where(AuthSession.expires_at <= now))
if old:
db.execute(delete(AuthSession).where(AuthSession.token_hash == token_hash(old)))
raw = secrets.token_urlsafe(32)
session = AuthSession(
token_hash=token_hash(raw),
user_id=user.id,
credential_version=user.credential_version,
expires_at=now + timedelta(seconds=settings.auth_session_seconds),
)
db.add(session)
db.commit()
response.set_cookie(
COOKIE_NAME,
raw,
max_age=settings.auth_session_seconds,
httponly=True,
secure=settings.auth_cookie_secure,
samesite="lax",
path="/",
)
return CurrentSession(user, session, raw).response()
def clear_cookie(response: Response):
response.delete_cookie(
COOKIE_NAME, path="/", httponly=True, secure=settings.auth_cookie_secure, samesite="lax"
)
@@ -0,0 +1,52 @@
"""Add users, revocable opaque sessions and shared authentication budgets."""
import sqlalchemy as sa
from alembic import op
revision = "0004_auth_sessions"
down_revision = "0003_retrieval_runs"
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
"users",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("username", sa.String(32), nullable=False, unique=True),
sa.Column("password_hash", sa.String(255), nullable=False),
sa.Column("nickname", sa.String(80)),
sa.Column("email", sa.String(254)),
sa.Column("role", sa.String(10), nullable=False),
sa.Column("credential_version", sa.Integer(), nullable=False),
sa.Column(
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
sa.CheckConstraint("role IN ('user', 'admin')", name="ck_users_role"),
)
op.create_table(
"auth_sessions",
sa.Column("token_hash", sa.String(64), primary_key=True),
sa.Column(
"user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False
),
sa.Column("credential_version", sa.Integer(), nullable=False),
sa.Column(
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
)
op.create_index("ix_auth_sessions_user_id", "auth_sessions", ["user_id"])
op.create_index("ix_auth_sessions_expires_at", "auth_sessions", ["expires_at"])
op.create_table(
"auth_rate_limits",
sa.Column("key", sa.String(64), primary_key=True),
sa.Column("window_start", sa.DateTime(timezone=True), nullable=False),
sa.Column("attempts", sa.Integer(), nullable=False),
)
def downgrade():
op.drop_table("auth_rate_limits")
op.drop_table("auth_sessions")
op.drop_table("users")
+2
View File
@@ -7,3 +7,5 @@ psycopg[binary]>=3.2,<4.0
pgvector>=0.3,<1.0
alembic>=1.13,<2.0
jieba==0.42.1
argon2-cffi>=25.1,<26
email-validator>=2.2,<3
+24 -2
View File
@@ -14,6 +14,8 @@ from sqlalchemy.orm import sessionmaker
from app.db.session import get_db
from app.main import create_app
from app.models.auth import User
from app.services.auth import PASSWORD_HASHER
BACKEND = Path(__file__).resolve().parents[1]
@@ -70,7 +72,7 @@ def migrated_database(database):
@pytest.fixture
def client(migrated_database):
def anonymous_client(migrated_database):
app = create_app(migrated_database)
factory = sessionmaker(bind=migrated_database)
@@ -83,5 +85,25 @@ def client(migrated_database):
raise
app.dependency_overrides[get_db] = test_session
with TestClient(app) as client:
with TestClient(app, base_url="https://testserver") as client:
client.headers["Origin"] = "http://localhost:5173"
yield client
@pytest.fixture
def client(anonymous_client, migrated_database):
with sessionmaker(bind=migrated_database)() as db:
db.add(
User(
username="test_admin",
password_hash=PASSWORD_HASHER.hash("test-password-123"),
role="admin",
)
)
db.commit()
response = anonymous_client.post(
"/api/auth/login", json={"username": "test_admin", "password": "test-password-123"}
)
assert response.status_code == 200, response.text
anonymous_client.headers["X-CSRF-Token"] = response.json()["csrf_token"]
return anonymous_client
+342
View File
@@ -0,0 +1,342 @@
"""Authentication, authorization and CSRF boundaries over real PostgreSQL."""
import json
from datetime import timedelta
import pytest
from pydantic import ValidationError
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.core.config import Settings
from app.models.auth import AuthSession, User
from app.models.catalog import utc_now
from app.services.auth import COOKIE_NAME, check_password, token_hash
PASSWORD = "test-password-123"
ORIGIN = "http://localhost:5173"
def register(client, username="test_user", **extra):
return client.post(
"/api/auth/register", json={"username": username, "password": PASSWORD, **extra}
)
def login(client, username="test_user", password=PASSWORD):
response = client.post("/api/auth/login", json={"username": username, "password": password})
if response.status_code == 200:
client.headers["X-CSRF-Token"] = response.json()["csrf_token"]
return response
def test_registration_hashes_password_and_cannot_select_privilege(
anonymous_client, migrated_database
):
c = anonymous_client
for extra in [{"role": "admin"}, {"id": 1}, {"password_hash": "injected"}]:
assert register(c, **extra).status_code == 422
result = register(c, username=" Test_User ", email="user@example.com", nickname="占位用户")
assert result.status_code == 201
assert result.json()["username"] == "test_user" and result.json()["role"] == "user"
assert c.get("/api/auth/me").status_code == 401
assert register(c).status_code == 409
with Session(migrated_database) as db:
user = db.scalar(select(User))
assert user.password_hash.startswith("$argon2id$")
assert PASSWORD not in user.password_hash and check_password(user.password_hash, PASSWORD)
assert db.scalar(select(func.count()).select_from(AuthSession)) == 0
assert "password" not in result.text and "credential_version" not in result.text
@pytest.mark.parametrize(
"data",
[
{"password": "short"},
{"password": "x" * 129},
{"username": "a"},
{"username": "a b"},
{"email": "invalid"},
{"nickname": "x" * 81},
{"nickname": "bad\u0000name"},
],
)
def test_validation_does_not_echo_credentials(anonymous_client, data):
submitted = {"username": "test_user", "password": PASSWORD, **data}
response = anonymous_client.post("/api/auth/register", json=submitted)
assert response.status_code == 422
assert '"input"' not in response.text
assert json.dumps(submitted["password"]) not in response.text
assert response.headers["cache-control"] == "no-store"
@pytest.mark.parametrize("origin", [None, "null", "https://evil.example", ORIGIN + ".evil.example"])
def test_login_and_registration_reject_untrusted_origin(
anonymous_client, migrated_database, origin
):
c = anonymous_client
c.headers.pop("Origin", None)
headers = {"Origin": origin} if origin else {}
for path in ["register", "login"]:
response = c.post(
f"/api/auth/{path}",
json={"username": "test_user", "password": PASSWORD},
headers=headers,
)
assert response.status_code == 403
with Session(migrated_database) as db:
assert db.scalar(select(func.count()).select_from(User)) == 0
def test_login_cookie_rotation_logout_and_opaque_storage(anonymous_client, migrated_database):
c = anonymous_client
register(c)
response = login(c)
assert response.status_code == 200
assert all(
value in response.headers["set-cookie"]
for value in ["HttpOnly", "Secure", "SameSite=lax", "Path=/"]
)
first = c.cookies.get(COOKIE_NAME)
csrf = response.json()["csrf_token"]
assert c.get("/api/auth/me").json()["csrf_token"] == csrf
with Session(migrated_database) as db:
stored = db.scalar(select(AuthSession))
assert stored.token_hash == token_hash(first) and first not in stored.token_hash
assert login(c).status_code == 200
second = c.cookies.get(COOKIE_NAME)
assert first != second
c.cookies.clear()
c.cookies.set(COOKIE_NAME, first)
assert c.get("/api/auth/me").status_code == 401
c.cookies.clear()
c.cookies.set(COOKIE_NAME, second)
response = c.post("/api/auth/logout")
assert response.status_code == 204 and "Max-Age=0" in response.headers["set-cookie"]
c.cookies.clear()
c.cookies.set(COOKIE_NAME, second)
assert c.get("/api/auth/me").status_code == 401
def test_login_failure_is_generic_and_throttled_in_database(anonymous_client):
c = anonymous_client
register(c)
absent = login(c, "absent_user", "wrong").json()
wrong = login(c, password="wrong")
assert wrong.status_code == 401 and wrong.json() == absent
for _ in range(9):
assert login(c, password="wrong").status_code == 401
blocked = login(c)
assert blocked.status_code == 429 and blocked.headers["retry-after"] == "300"
@pytest.mark.parametrize("username", ["bad\u0000name", "invalid name", "../user"])
def test_login_rejects_invalid_username_before_database_lookup(anonymous_client, username):
assert login(anonymous_client, username=username).status_code == 422
def test_expired_forged_and_credential_version_sessions_are_rejected(
anonymous_client, migrated_database
):
c = anonymous_client
register(c)
login(c)
with Session(migrated_database) as db:
session = db.scalar(select(AuthSession))
session.expires_at = utc_now() - timedelta(seconds=1)
db.commit()
assert c.get("/api/auth/me").status_code == 401
login(c)
with Session(migrated_database) as db:
db.scalar(select(User)).credential_version += 1
db.commit()
assert c.get("/api/auth/me").status_code == 401
c.cookies.clear()
c.cookies.set(COOKIE_NAME, "x" * 43)
assert c.get("/api/auth/me").status_code == 401
c.cookies.clear()
c.cookies.set(COOKIE_NAME, "invalid")
assert c.get("/api/auth/me").status_code == 401
def test_csrf_is_bound_to_session_and_required_for_all_authenticated_writes(anonymous_client):
c = anonymous_client
register(c)
login(c)
first = c.headers["X-CSRF-Token"]
login(c)
for token in ["", "invalid", first]:
assert (
c.patch(
"/api/users/me", json={"nickname": "占位"}, headers={"X-CSRF-Token": token}
).status_code
== 403
)
assert c.post("/api/auth/logout", headers={"X-CSRF-Token": token}).status_code == 403
assert (
c.patch(
"/api/users/me", json={"nickname": "占位"}, headers={"Origin": "https://evil.example"}
).status_code
== 403
)
c.headers.pop("Origin")
assert c.patch("/api/users/me", json={"nickname": "占位"}).status_code == 403
c.headers["Origin"] = ORIGIN
assert c.patch("/api/users/me", json={"nickname": " 占位 "}).json()["nickname"] == "占位"
def test_profile_updates_only_own_allowed_fields(anonymous_client, migrated_database):
c = anonymous_client
first = register(c).json()
register(c, "other_user")
login(c)
for payload in [
{"id": 2},
{"role": "admin"},
{"username": "other_user"},
{"password": "changed"},
{},
]:
assert c.patch("/api/users/me", json=payload).status_code == 422
assert (
c.patch("/api/users/me", json={"email": "changed@example.com", "nickname": "示例"}).json()[
"id"
]
== first["id"]
)
assert c.patch("/api/users/me", json={"email": "", "nickname": None}).json()["email"] is None
with Session(migrated_database) as db:
assert db.scalar(select(User).where(User.username == "other_user")).email is None
assert db.scalar(select(User).where(User.id == first["id"])).role == "user"
def test_password_change_revokes_all_sessions(anonymous_client):
c = anonymous_client
register(c)
login(c)
first = c.cookies.get(COOKIE_NAME)
# Independent second browser: existing first session must also be invalidated.
c.cookies.clear()
login(c)
assert (
c.post(
"/api/auth/change-password",
json={"current_password": "wrong", "new_password": "new-password-123"},
).status_code
== 400
)
response = c.post(
"/api/auth/change-password",
json={"current_password": PASSWORD, "new_password": "new-password-123"},
)
assert response.status_code == 204
assert c.get("/api/auth/me").status_code == 401
c.cookies.set(COOKIE_NAME, first)
assert c.get("/api/auth/me").status_code == 401
assert login(c).status_code == 401
assert login(c, password="new-password-123").status_code == 200
def test_all_management_routes_require_admin_and_history_is_private(anonymous_client):
c = anonymous_client
paths = [
("GET", "/api/admin/users"),
("GET", "/api/knowledge"),
("GET", "/api/knowledge/page"),
("GET", "/api/knowledge/1"),
("POST", "/api/knowledge"),
("PATCH", "/api/knowledge/1"),
("DELETE", "/api/knowledge/1?expected_version=1"),
("POST", "/api/knowledge/1/review"),
("GET", "/api/knowledge/1/history"),
("GET", "/api/sources"),
("POST", "/api/sources"),
("GET", "/api/sources/1"),
("PATCH", "/api/sources/1"),
("DELETE", "/api/sources/1?expected_version=1"),
("GET", "/api/sources/1/history"),
("GET", "/api/documents"),
("POST", "/api/documents"),
("GET", "/api/documents/1"),
("PATCH", "/api/documents/1"),
("DELETE", "/api/documents/1?expected_version=1"),
("GET", "/api/documents/1/history"),
("GET", "/api/retrieval/runs"),
("GET", "/api/retrieval/runs/00000000-0000-0000-0000-000000000000"),
]
for method, path in paths:
assert c.request(method, path, json={}).status_code == 401, path
register(c)
login(c)
for method, path in paths:
assert c.request(method, path, json={}).status_code == 403, path
assert c.post("/api/retrieval", json={"query": "占位"}).json()["status"] == "no_evidence"
assert c.post("/api/chat", json={"question": "占位"}).json()["status"] == "stub"
def test_admin_access_live_role_and_no_csrf_bypass(client, migrated_database):
c = client
users = c.get("/api/admin/users").json()
assert users[0]["role"] == "admin" and "password_hash" not in str(users)
assert c.get("/api/admin/users?limit=101").status_code == 422
assert (
c.post(
"/api/knowledge",
json={"title": "示例", "content": "示例"},
headers={"X-CSRF-Token": ""},
).status_code
== 403
)
assert c.post("/api/knowledge", json={"title": "示例", "content": "示例"}).status_code == 201
with Session(migrated_database) as db:
db.scalar(select(User)).role = "user"
db.commit()
assert c.get("/api/admin/users").status_code == 403
assert c.get("/api/knowledge").status_code == 403
def test_auth_settings_require_explicit_origins_and_secure_production():
with pytest.raises(ValidationError):
Settings(_env_file=None, app_env="production", auth_cookie_secure=False)
for origin in [
"*",
"null",
"https://example.com/path",
"https://user@example.com",
"https://*.example.com",
]:
with pytest.raises(ValidationError):
Settings(_env_file=None, auth_allowed_origins=origin)
def test_local_admin_provisioning_has_no_default_account(migrated_database, monkeypatch, capsys):
from sqlalchemy.orm import sessionmaker
from app import manage
monkeypatch.setattr(manage, "SessionLocal", sessionmaker(bind=migrated_database))
monkeypatch.setattr("sys.argv", ["manage", "create-admin", "--username", "local_admin"])
monkeypatch.setattr(manage.getpass, "getpass", lambda _: PASSWORD)
manage.main()
with Session(migrated_database) as db:
user = db.scalar(select(User))
assert user.role == "admin" and check_password(user.password_hash, PASSWORD)
with pytest.raises(SystemExit) as error:
manage.main()
assert error.value.code == 1
assert PASSWORD not in capsys.readouterr().out
def test_client_budget_is_shared_across_names_and_registration(anonymous_client, migrated_database):
c = anonymous_client
for index in range(30):
assert login(c, username=f"absent_{index}").status_code == 401
assert register(c).status_code == 429
# Expire the persisted budget instead of waiting in a test.
from app.models.auth import AuthRateLimit
with Session(migrated_database) as db:
for bucket in db.scalars(select(AuthRateLimit)):
bucket.window_start = utc_now() - timedelta(minutes=6)
db.commit()
assert register(c).status_code == 201
+26 -2
View File
@@ -16,7 +16,7 @@ def test_startup_requires_migration(database):
def test_startup_rejects_previous_schema(database):
migrate(database, "0002_catalog")
migrate(database, "0003_retrieval_runs")
with pytest.raises(RuntimeError, match="alembic upgrade head"):
with TestClient(create_app(database)):
pass
@@ -42,10 +42,34 @@ def test_upgrade_preserves_original_rows_and_repeated_upgrade(database):
assert connection.scalar(text("SELECT count(*) FROM audit_events")) == 2
assert (
connection.scalar(text("SELECT version_num FROM alembic_version"))
== "0003_retrieval_runs"
== "0004_auth_sessions"
)
assert compare_metadata(MigrationContext.configure(connection), Base.metadata) == []
migrate(database, "0001_baseline", downgrade=True)
with database.connect() as connection:
assert connection.scalar(text("SELECT content FROM knowledge_items")) == "原始正文"
migrate(database)
def test_auth_upgrade_and_downgrade_preserve_knowledge_and_retrieval(database):
migrate(database, "0003_retrieval_runs")
with database.begin() as c:
c.execute(
text("""INSERT INTO knowledge_items
(title, category, content, verification_status, version, updated_at, is_deleted)
VALUES ('占位', '其他', '保留的占位正文', 'pending', 1, now(), false)""")
)
c.execute(
text("""INSERT INTO retrieval_runs
(id, query, mode, corpus_fingerprint, response)
VALUES ('00000000-0000-0000-0000-000000000001', '占位', 'keyword', 'placeholder', '{}')""")
)
migrate(database)
with database.connect() as c:
assert c.scalar(text("SELECT count(*) FROM users")) == 0
assert c.scalar(text("SELECT count(*) FROM auth_sessions")) == 0
migrate(database, "0003_retrieval_runs", downgrade=True)
with database.connect() as c:
assert c.scalar(text("SELECT content FROM knowledge_items")) == "保留的占位正文"
assert c.scalar(text("SELECT count(*) FROM retrieval_runs")) == 1
migrate(database)
+49 -2
View File
@@ -9,14 +9,35 @@ from contextlib import contextmanager
from pathlib import Path
import httpx
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models.auth import User
from app.services.auth import PASSWORD_HASHER
@contextmanager
def server(engine):
def server(engine, *, authenticate=True):
with socket.socket() as socket_:
socket_.bind(("127.0.0.1", 0))
port = socket_.getsockname()[1]
environment = {**os.environ, "DATABASE_URL": engine.url.render_as_string(hide_password=False)}
environment = {
**os.environ,
"DATABASE_URL": engine.url.render_as_string(hide_password=False),
"APP_ENV": "test",
"AUTH_COOKIE_SECURE": "false",
}
if authenticate:
with Session(engine) as db:
if db.scalar(select(User.id).where(User.username == "http_admin")) is None:
db.add(
User(
username="http_admin",
password_hash=PASSWORD_HASHER.hash("test-password-123"),
role="admin",
)
)
db.commit()
backend = Path(__file__).resolve().parents[1]
process = subprocess.Popen(
[
@@ -38,6 +59,7 @@ def server(engine):
)
try:
with httpx.Client(base_url=f"http://127.0.0.1:{port}", timeout=2) as client:
client.headers["Origin"] = "http://localhost:5173"
deadline = time.monotonic() + 15
while time.monotonic() < deadline:
if process.poll() is not None:
@@ -50,6 +72,13 @@ def server(engine):
time.sleep(0.05)
else:
raise AssertionError("默认后端未在时限内就绪")
if authenticate:
login = client.post(
"/api/auth/login",
json={"username": "http_admin", "password": "test-password-123"},
)
assert login.status_code == 200, login.text
client.headers["X-CSRF-Token"] = login.json()["csrf_token"]
yield client
finally:
process.terminate()
@@ -107,3 +136,21 @@ def test_retrieval_run_persists_across_restart(migrated_database):
assert run["results"][0]["knowledge_id"] == item["id"]
with server(migrated_database) as client:
assert client.get(f"/api/retrieval/runs/{run['run_id']}").json() == run
def test_session_and_logout_survive_http_restart(migrated_database):
from app.services.auth import COOKIE_NAME
with server(migrated_database, authenticate=False) as client:
payload = {"username": "http_user", "password": "test-password-123"}
assert client.post("/api/auth/register", json=payload).status_code == 201
response = client.post("/api/auth/login", json=payload)
cookie = client.cookies.get(COOKIE_NAME)
token = response.json()["csrf_token"]
with server(migrated_database, authenticate=False) as client:
client.cookies.set(COOKIE_NAME, cookie)
assert client.get("/api/auth/me").json()["username"] == "http_user"
assert client.post("/api/auth/logout", headers={"X-CSRF-Token": token}).status_code == 204
with server(migrated_database, authenticate=False) as client:
client.cookies.set(COOKIE_NAME, cookie)
assert client.get("/api/auth/me").status_code == 401
+4 -2
View File
@@ -14,7 +14,7 @@ FastAPI + Pydantic
|-- 来源获取 -> 人工核验 -> 文本切分 -> 生成向量 -> 发布版本
```
当前后端实现健康/数据库就绪检查、来源与文档管理、知识增删改查、分页筛选、人工审核、历史快照及独立关键词检索;问答仍为占位接口。数据库由 Alembic 显式迁移并启用 pgvector,尚无向量字段、向量索引和 RAG。前端来源登记/查询已接入,实验页仍为静态原型,文档、审核、完整分页与检索页面需继续联调。
当前后端实现健康/数据库就绪检查、来源与文档管理、知识增删改查、分页筛选、人工审核、历史快照、认证会话/角色权限及独立关键词检索;问答仍为占位接口。数据库由 Alembic 显式迁移并启用 pgvector,尚无向量字段、向量索引和 RAG。前端来源登记/查询已接入,实验页仍为静态原型,文档、审核、完整分页与检索页面需继续联调。
## 阶段 2 数据边界
@@ -27,7 +27,9 @@ FastAPI + Pydantic
- 旧库首次升级自动接管基础表,保留条目与导入快照。没有完整审核记录的旧 `verified` 状态回到待审核。启动不自动迁移,缺失或不匹配的版本会明确报错。
## 安全边界
当前无认证与权限控制,仅供本机开发;正式部署前须补充登录鉴权、接口限流、数据库迁移、密钥管理、日志脱敏、CORS 收敛和 HTTPS。
新增 `users`、`auth_sessions` 与 `auth_rate_limits`。Argon2id 保存密码哈希,随机 Cookie 会话只保存摘要,角色逐次由数据库校验。来源白名单与会话 CSRF 保护写入;改密撤销所有会话。管理资源和检索历史均限管理员,公开检索只返回已核验资料。具体字段、来源、Cookie、预算和迁移见 [认证接口契约](AUTH_API.md)。
当前仍供本机开发与联调;完整生产部署、网关限流、访问审计、邮箱验证、账号恢复和前端权限页面仍待后续工程验收。
## 关键词检索基线(T06)
+67
View File
@@ -0,0 +1,67 @@
# 认证与角色权限接口(Issue #5)
本分支实现 [Issue #5](https://gitea.kronecker.cc/saint/border-red-post-nalati/issues/5) 的七个接口。认证分支 `feat/backend-auth-sessions` 基于 PR #6 的已验证后端提交,迁移依次为 `0002_catalog → 0003_retrieval_runs → 0004_auth_sessions`;PR #6 仍须独立审阅,本分支没有自行合并它。
## 启动与管理员初始化
在 `backend/` 安装更新依赖并执行 `alembic upgrade head`,再运行:
```bash
python -m app.manage create-admin --username local_admin
```
命令通过终端隐藏输入和确认密码,不接受密码参数,不创建默认账号;已有用户名不会被升级为管理员或覆盖密码。生产密码请使用独立长口令,不把账号、密码、Cookie 或响应保存到仓库。迁移只新增表,不预置任何用户。
默认 `AUTH_COOKIE_SECURE=true`,必须通过 HTTPS 使用会话。本机 HTTP 联调时可在未入库的 `backend/.env` 显式设置 `APP_ENV=development` 和 `AUTH_COOKIE_SECURE=false`,保持服务只绑定回环地址。其他环境拒绝关闭 Secure;非开发/测试环境的认证与 CORS Origin 必须均为 HTTPS。不要把本机例外带到部署环境。
`AUTH_ALLOWED_ORIGINS` 独立控制写请求的来源,默认允许 `localhost/127.0.0.1` 的 5173、8000 端口;`CORS_ORIGINS` 控制前端跨域读取。两者均须填写完整 `scheme://host:port`,不含路径/尾斜杠/通配符。部署时按实际地址同时收敛这两项。代理层仅信任受控代理,不能任意接受外部转发 IP 头。
## 接口契约
| 接口 | 请求 | 成功响应与权限 |
|---|---|---|
| POST `/api/auth/register` | `username/password`,可选 `nickname/email` | 201 用户资料;仅创建 `user`,不自动登录 |
| POST `/api/auth/login` | `username/password` | 200 用户资料及 `csrf_token`;设置会话 Cookie |
| POST `/api/auth/logout` | 无正文 | 204;要求当前会话和 CSRF,作废该会话并清除 Cookie |
| GET `/api/auth/me` | 无 | 200 用户资料及当前 `csrf_token`;要求有效会话 |
| PATCH `/api/users/me` | `nickname` 和/或 `email` | 200 当前用户资料;会话与 CSRF 必需 |
| POST `/api/auth/change-password` | `current_password/new_password` | 204;验证当前密码后撤销该用户全部会话并清除 Cookie,须重新登录 |
| GET `/api/admin/users` | `limit` 默认/最大 100,`offset` 默认 0 | 200 用户数组,按 ID 升序;仅 `admin` |
所有输入拒绝未知字段。注册和资料修改均不接受 `role/id/password_hash`,不能修改他人资料或用户名。用户名去首尾空白并转小写,须为字母开头的 3–32 位 ASCII 字母、数字或下划线。新密码为 15–128 字符,不修剪、不截断;旧密码只作校验。昵称最长 80 字符且不能含控制字符;邮箱须为合法地址,最长 254 字符。昵称/邮箱的空字符串或 `null` 用于清空;邮箱尚未验证,不能用于登录、找回密码或唯一身份判定。
用户资料为 `{id, username, nickname, email, role, created_at}`;登录和 `/me` 额外返回 `csrf_token`。响应不包含密码哈希、会话 Cookie 值或内部凭据版本。示例中的 ID、时间和名字均为占位:
```json
{
"id": 1,
"username": "example_user",
"nickname": null,
"email": null,
"role": "user",
"created_at": "2026-10-16T00:00:00Z",
"csrf_token": "<由服务端返回的当前会话 CSRF 值>"
}
```
错误使用 `detail`:参数 422、用户名冲突 409、登录错误/会话失效 401、权限/来源/CSRF 错误 403、当前密码错误 400、认证请求过多 429(`Retry-After: 300`)。参数错误不回显输入值,避免原样返回密码。认证及其他 `/api/` 响应均使用 `Cache-Control: no-store`。
## Cookie、CSRF 与授权
- Cookie 名 `nalati_session`,HttpOnly、Secure(默认)、SameSite=Lax、Path=/、不设置 Domain。服务端生成 256 位随机会话标识;数据库仅保存其 SHA-256 摘要、用户、凭据版本及到期时间。固定有效期 `AUTH_SESSION_SECONDS` 默认 28800 秒,可配置 300–604800 秒,不自动续期。
- 登录轮换本浏览器旧会话;退出仅撤销当前会话;改密通过用户行锁、凭据版本递增与删除全部会话使旧凭据立即失效。授权每次从数据库读取当前角色,不信任前端角色或 Cookie 内的角色声明。
- 登录/注册即检查精确匹配的 `Origin`,缺失或不可信均拒绝,避免登录 CSRF。已认证的 POST/PATCH/DELETE 等写请求还须带 `X-CSRF-Token`,值来自登录或 `/me`。该值由当前随机 Cookie 经独立 HMAC 派生,不能跨会话复用。后端不从请求正文、URL 或客户端角色获取权限。
- `knowledge`、`sources`、`documents` 的全部读写及历史、管理员用户列表、检索历史列表/详情均要求 `admin`。因此旧匿名知识管理调用现在返回 401,普通用户返回 403。健康检查、公开的已核验关键词检索和当前占位问答保持公开;正式问答、实验接口接入时须重新明确各自授权。
- 登录及注册共用每客户端地址 30 次/5 分钟预算;登录另有规范用户名 10 次/5 分钟预算,成功/失败都计数。计数通过 PostgreSQL 原子更新,跨进程/重启有效;仅保存带类型前缀的摘要,旧预算在下一次认证请求时清理。此处为基础防暴力尝试,不代替部署网关限流与监控。
前端 `src/services/auth.ts` 和 `src/services/session.ts` 提供真实 Cookie 请求封装:CSRF 只存内存,刷新后通过 `/me` 恢复;知识写入也使用同一封装。登录入口、路由守卫和权限页面交互仍由前端接入;远程 main 注册原型的最低密码提示须同步为 15 字符。当前 `dev` 页面没有完整登录流程,不能把 API 完成视作浏览器验收完成。
## 验证与回滚
在 `backend/` 执行 `ruff check .`、`ruff format --check .`、`pytest -q`;在 `frontend/` 执行 `npm run build`。测试使用隔离 PostgreSQL 和占位账号,覆盖权限拒绝、CSRF、输入越权、哈希、轮换/撤销/过期、改密、重启后会话恢复与退出持久化、预算、迁移及既有检索回归。测试库不代表 Docker 或浏览器验收。
备份后可配合旧后端执行 `alembic downgrade 0003_retrieval_runs`,删除用户、会话与认证预算表,保留知识和检索历史;账号与哈希恢复依赖备份。降级会撤除授权边界,旧服务只能按其原开发范围使用,不应用于公开部署。继续降至 `0002_catalog` 还会删除检索历史。
尚未实现邮箱验证、找回密码、MFA、角色管理 API、会话管理页面、访问审计和完整生产部署;没有把这些功能标记为完成。
实现依据:[argon2-cffi API](https://argon2-cffi.readthedocs.io/en/stable/api.html)、[OWASP 密码存储](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html)、[OWASP 认证](https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html)、[OWASP CSRF 防护](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html)。本项目使用 Argon2id(19 MiB、2 次迭代、并行度 1);会话与来源策略以本接口契约及代码为准。
+7
View File
@@ -28,3 +28,10 @@
状态:分支实现,待审阅。采用固定 Jieba 搜索分词及关闭 HMM 的词典模式,加入项目名称词,并使用固定停用词表。选择标题重复权重 2、正文权重 1 的 BM25 正值平滑 IDF 变体,不称为 BM25F;查询词去重,同分按知识 ID 排序。参数、分词标识、实际语料指纹及出处版本随每次结果保存,方便冻结后续实验配置。分词接口参考 [Jieba 官方说明](https://github.com/fxsjy/jieba),BM25 基础参考 [Stanford IR 教材](https://nlp.stanford.edu/IR-book/html/htmledition/okapi-bm25-a-non-binary-model-1.html);具体平滑公式与权重以本仓库实现和手算测试为准。
当前检索单元为已审核知识条目,在设置的容量内按请求计算,不提前实现向量切分/索引更新;超过容量明确失败。`retrieval_runs` 仅记录当时结果,不代表该条目之后仍然获得审核批准。网络搜索、模型选择和真实论文实验保持原阶段边界。
## ADR-009 服务端会话与最小角色边界
状态:Issue #5 分支实现,待审阅。使用 Argon2id 密码哈希、数据库保存摘要的随机 Cookie 会话,以及独立 CSRF 请求头与显式 Origin 白名单;不把角色或会话凭据放入浏览器持久存储。普通注册只能创建 user,管理员仅由本机隐藏密码输入命令创建。知识/来源/文档管理及检索历史限 admin,公开检索继续只使用已核验依据。完整契约与实现依据见 [AUTH_API.md](AUTH_API.md)。
为改密和会话撤销使用用户行锁与凭据版本,认证预算使用数据库原子计数;不依赖单进程内存限流。现有前端写请求同步 Cookie/CSRF 封装,页面和路由联调仍待前端完成。本分支叠加 PR #6,后续审阅需关注迁移顺序及匿名管理调用变为 401 的行为变化。
+9
View File
@@ -52,3 +52,12 @@
- 当前主机 Docker 服务仍未运行;本轮继续使用隔离 PostgreSQL 做接口与迁移测试,不把容器或浏览器端联调标记为完成。独立任务 Issue 仍须补齐。
- 新增检索接口、中文分词和 BM25 基线、有效审核筛选、原文片段、来源与版本、语料指纹及检索历史;前端调用层同步类型与接口,页面接入由前端继续联调。
- 分支验证:`ruff check .`、`ruff format --check .`、`pytest -q`;本轮 42 项 PostgreSQL/单元/真实 HTTP 测试通过,覆盖手算分数、标题权重、排序复现、审核边界、来源失效、容量拒绝、迁移兼容和服务重启后的检索历史。前端 `npm run build` 通过,包含新增检索调用类型的检查。全部使用占位资料,不是论文实验结果。
## 2026-10-16 Issue #5:认证与角色权限
- 按继续开发的授权,在 `feat/backend-auth-sessions` 推进 T10 的认证部分;叠加 PR #6(`8ac9dca`),不等待其合并,不自行批准或合并。PR #6 与本分支分别保持可追踪记录。
- 实现注册、登录、退出、当前会话、本人资料修改、改密及管理员用户列表;注册拒绝角色字段,知识/来源/文档管理及检索历史均由服务端要求 admin。
- 新增 `0004_auth_sessions`,保存 Argon2id 密码哈希、会话摘要与到期/凭据版本、共享认证请求预算;不预置用户,提供本机隐藏密码输入的管理员初始化命令。迁移和回滚保留知识及检索记录。
- 默认 Secure/HttpOnly/SameSite Cookie;写操作校验明确 Origin 及当前会话 CSRF。改密使所有旧会话失效,退出持久化撤销;参数错误不回显密码。字段、来源、CSRF 和前端交接见 [认证接口契约](AUTH_API.md)。
- 实际验证:`ruff check .`、`ruff format --check .` 通过;`pytest -q` **70 passed**,包含认证、授权、CSRF、输入越权、过期/轮换/撤销、限额、迁移及既有知识/检索回归;真实 HTTP 服务重启验证会话恢复和退出后不再有效。前端 `npm run build` 通过。
- 当前仍使用隔离 PostgreSQL 做自动化验证;Docker 容器、前端登录/角色页面及真实浏览器端流程仍待验收。远程 main 的注册原型最低密码提示需要按本契约同步到 15 字符,并接入带 Cookie/CSRF 的调用层。没有宣称已完成前端集成或生产部署。
+12 -11
View File
@@ -1,17 +1,18 @@
export async function getHealth(){const r=await fetch('/api/health');if(!r.ok)throw new Error('health failed');return r.json()}
export async function askQuestion(question:string){const r=await fetch('/api/chat',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({question})});if(!r.ok)throw new Error('chat failed');return r.json() as Promise<{answer:string;status:string;sources:{title:string;source_url?:string|null}[]}>}
export async function getKnowledge(){const r=await fetch('/api/knowledge');if(!r.ok)throw new Error('knowledge failed');return r.json() as Promise<any[]>}
export async function createKnowledge(payload:{title:string;category:string;content:string;source_url?:string}){const r=await fetch('/api/knowledge',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});if(!r.ok)throw new Error('保存失败,请检查网址格式及后端状态');return r.json()}
import { apiRequest } from './services/session'
export const getHealth = () => apiRequest<{status:string}>('/api/health')
export const askQuestion = (question:string) => apiRequest<{answer:string;status:string;sources:{title:string;source_url?:string|null}[]}>('/api/chat',{method:'POST',body:JSON.stringify({question})})
export const getKnowledge = () => apiRequest<{id:number;title:string;category:string;content:string;source_url:string|null;verification_status:string;version:number}[]>('/api/knowledge')
export const createKnowledge = (payload:{title:string;category:string;content:string;source_url?:string}) => apiRequest('/api/knowledge',{method:'POST',body:JSON.stringify(payload)},true)
export type VerificationStatus='pending'|'verified'|'rejected'
export type SourceItem={id:number;version:number;name:string;source_url:string|null;notes:string;created_at:string}
async function catalogRequest<T>(path:string,init?:RequestInit):Promise<T>{const r=await fetch(path,init);if(!r.ok){const detail=await r.json().catch(()=>null);throw new Error(detail?.detail||'请求失败,请检查后端服务和填写内容。')}return r.json() as Promise<T>}
export function getSources(q=''){return catalogRequest<{items:SourceItem[];total:number}>(`/api/sources?q=${encodeURIComponent(q)}`)}
export function createSource(payload:{name:string;source_url?:string;notes:string}){return catalogRequest<SourceItem>('/api/sources',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)})}
export function reviewKnowledge(id:number,payload:{expected_version:number;verification_status:VerificationStatus;reviewer:string;verification_notes:string}){return catalogRequest(`/api/knowledge/${id}/review`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)})}
export function getSources(q=''){return apiRequest<{items:SourceItem[];total:number}>(`/api/sources?q=${encodeURIComponent(q)}`)}
export function createSource(payload:{name:string;source_url?:string;notes:string}){return apiRequest<SourceItem>('/api/sources',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)},true)}
export function reviewKnowledge(id:number,payload:{expected_version:number;verification_status:VerificationStatus;reviewer:string;verification_notes:string}){return apiRequest(`/api/knowledge/${id}/review`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)},true)}
export type RetrievalHit={rank:number;knowledge_id:number;knowledge_version:number;document_id:number|null;document_version:number|null;source_id:number|null;source_version:number|null;source_name:string|null;source_url:string;title:string;category:string;snippet:string;snippet_start:number;snippet_end:number;score:number;matched_terms:string[]}
export type RetrievalResult={run_id:string;query:string;mode:'keyword';status:'ok'|'no_evidence';top_k:number;category:string|null;query_terms:string[];corpus_size:number;corpus_fingerprint:string;total_hits:number;results:RetrievalHit[];elapsed_ms:number;created_at:string;config:{tokenizer:string;scorer:string;k1:number;b:number;title_weight:number;stopwords_version:string}}
export function retrieveKnowledge(payload:{query:string;mode?:'keyword';top_k?:number;category?:string}){return catalogRequest<RetrievalResult>('/api/retrieval',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)})}
export function getRetrievalRun(runId:string){return catalogRequest<RetrievalResult>(`/api/retrieval/runs/${encodeURIComponent(runId)}`)}
export function getRetrievalRuns(page=1,pageSize=20){return catalogRequest<{items:RetrievalResult[];total:number;page:number;page_size:number}>(`/api/retrieval/runs?page=${page}&page_size=${pageSize}`)}
export function retrieveKnowledge(payload:{query:string;mode?:'keyword';top_k?:number;category?:string}){return apiRequest<RetrievalResult>('/api/retrieval',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)})}
export function getRetrievalRun(runId:string){return apiRequest<RetrievalResult>(`/api/retrieval/runs/${encodeURIComponent(runId)}`)}
export function getRetrievalRuns(page=1,pageSize=20){return apiRequest<{items:RetrievalResult[];total:number;page:number;page_size:number}>(`/api/retrieval/runs?page=${page}&page_size=${pageSize}`)}
+20
View File
@@ -0,0 +1,20 @@
import type { ChangePasswordPayload, CurrentUser, LoginPayload, RegisterPayload, SessionUser, UpdateProfilePayload } from '../types/auth'
import { apiRequest, ApiError, clearSessionToken } from './session'
export { ApiError as AuthApiUnavailableError }
export const register = (payload: RegisterPayload) => apiRequest<CurrentUser>('/api/auth/register', { method: 'POST', body: JSON.stringify(payload) })
export const login = async (payload: LoginPayload) => {
clearSessionToken()
return apiRequest<SessionUser>('/api/auth/login', { method: 'POST', body: JSON.stringify(payload) })
}
export const getCurrentUser = () => apiRequest<SessionUser>('/api/auth/me')
export const updateMyProfile = (payload: UpdateProfilePayload) => apiRequest<CurrentUser>('/api/users/me', { method: 'PATCH', body: JSON.stringify(payload) }, true)
export const logout = async () => {
await apiRequest<void>('/api/auth/logout', { method: 'POST' }, true)
clearSessionToken()
}
export const changePassword = async (payload: ChangePasswordPayload) => {
await apiRequest<void>('/api/auth/change-password', { method: 'POST', body: JSON.stringify(payload) }, true)
clearSessionToken()
}
export const getUsers = (limit = 100, offset = 0) => apiRequest<CurrentUser[]>(`/api/admin/users?limit=${limit}&offset=${offset}`)
+28
View File
@@ -0,0 +1,28 @@
let csrfToken: string | null = null
export class ApiError extends Error {
constructor(public readonly status: number, message: string) { super(message) }
}
export function clearSessionToken() { csrfToken = null }
export async function apiRequest<T>(path: string, init: RequestInit = {}, protect = false): Promise<T> {
if (protect && !csrfToken) await apiRequest('/api/auth/me')
const headers = new Headers(init.headers)
headers.set('Accept', 'application/json')
if (init.body) headers.set('Content-Type', 'application/json')
if (protect && csrfToken) headers.set('X-CSRF-Token', csrfToken)
const response = await fetch(path, { ...init, credentials: 'include', headers })
if (!response.ok) {
if (response.status === 401) clearSessionToken()
const body: unknown = await response.json().catch(() => null)
const detail = body && typeof body === 'object' && 'detail' in body ? body.detail : null
throw new ApiError(response.status, typeof detail === 'string' ? detail : `请求失败(${response.status}),请检查登录状态或填写内容。`)
}
if (response.status === 204) return undefined as T
const data = await response.json()
if (path === '/api/auth/login' || path === '/api/auth/me') {
csrfToken = typeof data.csrf_token === 'string' ? data.csrf_token : null
}
return data as T
}
+14
View File
@@ -0,0 +1,14 @@
export type UserRole = 'user' | 'admin'
export type CurrentUser = {
id: number
username: string
nickname: string | null
email: string | null
role: UserRole
created_at: string
}
export type SessionUser = CurrentUser & { csrf_token: string }
export type RegisterPayload = { username: string; password: string; email?: string; nickname?: string }
export type LoginPayload = { username: string; password: string }
export type UpdateProfilePayload = { nickname?: string | null; email?: string | null }
export type ChangePasswordPayload = { current_password: string; new_password: string }