diff --git a/README.md b/README.md index e72305c..93fc0cc 100644 --- a/README.md +++ b/README.md @@ -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`。 ## 团队协作文档 diff --git a/backend/.env.example b/backend/.env.example index aa41b80..9358bb9 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -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 diff --git a/backend/app/api/routes/auth.py b/backend/app/api/routes/auth.py new file mode 100644 index 0000000..8047763 --- /dev/null +++ b/backend/app/api/routes/auth.py @@ -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))) diff --git a/backend/app/api/routes/documents.py b/backend/app/api/routes/documents.py index a525cfd..66c5b95 100644 --- a/backend/app/api/routes/documents.py +++ b/backend/app/api/routes/documents.py @@ -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]) diff --git a/backend/app/api/routes/knowledge.py b/backend/app/api/routes/knowledge.py index a967c7e..ea453df 100644 --- a/backend/app/api/routes/knowledge.py +++ b/backend/app/api/routes/knowledge.py @@ -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): diff --git a/backend/app/api/routes/retrieval.py b/backend/app/api/routes/retrieval.py index 8ddbc15..0e3b870 100644 --- a/backend/app/api/routes/retrieval.py +++ b/backend/app/api/routes/retrieval.py @@ -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: diff --git a/backend/app/api/routes/sources.py b/backend/app/api/routes/sources.py index ba95e1b..d7198f5 100644 --- a/backend/app/api/routes/sources.py +++ b/backend/app/api/routes/sources.py @@ -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]) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 068adb0..7475f97 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -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() diff --git a/backend/app/main.py b/backend/app/main.py index 8a7fed0..15d4f6a 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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": "记录已更新,请刷新后重试"}) diff --git a/backend/app/manage.py b/backend/app/manage.py new file mode 100644 index 0000000..018cc88 --- /dev/null +++ b/backend/app/manage.py @@ -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() diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 89e1a73..e8ed958 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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 diff --git a/backend/app/models/auth.py b/backend/app/models/auth.py new file mode 100644 index 0000000..f166e6b --- /dev/null +++ b/backend/app/models/auth.py @@ -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) diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py new file mode 100644 index 0000000..7fcd8ab --- /dev/null +++ b/backend/app/schemas/auth.py @@ -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 diff --git a/backend/app/services/auth.py b/backend/app/services/auth.py new file mode 100644 index 0000000..a0083c5 --- /dev/null +++ b/backend/app/services/auth.py @@ -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" + ) diff --git a/backend/migrations/versions/0004_auth_sessions.py b/backend/migrations/versions/0004_auth_sessions.py new file mode 100644 index 0000000..adf620f --- /dev/null +++ b/backend/migrations/versions/0004_auth_sessions.py @@ -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") diff --git a/backend/requirements.txt b/backend/requirements.txt index 73bf48b..facd5b8 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -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 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 3b7fe5a..4d2ede1 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -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 diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py new file mode 100644 index 0000000..e3cf403 --- /dev/null +++ b/backend/tests/test_auth.py @@ -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 diff --git a/backend/tests/test_migrations.py b/backend/tests/test_migrations.py index de0ec81..53e856a 100644 --- a/backend/tests/test_migrations.py +++ b/backend/tests/test_migrations.py @@ -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) diff --git a/backend/tests/test_runtime.py b/backend/tests/test_runtime.py index 63f20cc..af14b8a 100644 --- a/backend/tests/test_runtime.py +++ b/backend/tests/test_runtime.py @@ -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 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 32e196c..da47960 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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) diff --git a/docs/AUTH_API.md b/docs/AUTH_API.md new file mode 100644 index 0000000..3ab832a --- /dev/null +++ b/docs/AUTH_API.md @@ -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);会话与来源策略以本接口契约及代码为准。 diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index adb116b..cdece9b 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -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 的行为变化。 diff --git a/docs/TASKS.md b/docs/TASKS.md index b7fd832..b6b2e63 100644 --- a/docs/TASKS.md +++ b/docs/TASKS.md @@ -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 的调用层。没有宣称已完成前端集成或生产部署。 diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 7b189eb..9d7c9bc 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -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} -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(path:string,init?:RequestInit):Promise{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} -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('/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('/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('/api/retrieval',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)})} -export function getRetrievalRun(runId:string){return catalogRequest(`/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('/api/retrieval',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)})} +export function getRetrievalRun(runId:string){return apiRequest(`/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}`)} diff --git a/frontend/src/services/auth.ts b/frontend/src/services/auth.ts new file mode 100644 index 0000000..26031cd --- /dev/null +++ b/frontend/src/services/auth.ts @@ -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('/api/auth/register', { method: 'POST', body: JSON.stringify(payload) }) +export const login = async (payload: LoginPayload) => { + clearSessionToken() + return apiRequest('/api/auth/login', { method: 'POST', body: JSON.stringify(payload) }) +} +export const getCurrentUser = () => apiRequest('/api/auth/me') +export const updateMyProfile = (payload: UpdateProfilePayload) => apiRequest('/api/users/me', { method: 'PATCH', body: JSON.stringify(payload) }, true) +export const logout = async () => { + await apiRequest('/api/auth/logout', { method: 'POST' }, true) + clearSessionToken() +} +export const changePassword = async (payload: ChangePasswordPayload) => { + await apiRequest('/api/auth/change-password', { method: 'POST', body: JSON.stringify(payload) }, true) + clearSessionToken() +} +export const getUsers = (limit = 100, offset = 0) => apiRequest(`/api/admin/users?limit=${limit}&offset=${offset}`) diff --git a/frontend/src/services/session.ts b/frontend/src/services/session.ts new file mode 100644 index 0000000..10d5753 --- /dev/null +++ b/frontend/src/services/session.ts @@ -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(path: string, init: RequestInit = {}, protect = false): Promise { + 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 +} diff --git a/frontend/src/types/auth.ts b/frontend/src/types/auth.ts new file mode 100644 index 0000000..ef4eb5b --- /dev/null +++ b/frontend/src/types/auth.ts @@ -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 }