Merge pull request 'feat: implement authentication and backend role permissions' (#7) from kronecker/feat/backend-auth-sessions into dev
This commit is contained in:
@@ -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`。
|
||||
|
||||
## 团队协作文档
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)))
|
||||
@@ -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])
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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
@@ -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": "记录已更新,请刷新后重试"})
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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);会话与来源策略以本接口契约及代码为准。
|
||||
@@ -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 的行为变化。
|
||||
|
||||
@@ -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
@@ -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}`)}
|
||||
|
||||
@@ -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}`)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
Reference in New Issue
Block a user