feat: add cookie sessions and backend role authorization

This commit is contained in:
Backend Contributors
2026-10-16 07:51:59 +08:00
parent 8ac9dcaacb
commit f9827feb1b
28 changed files with 1186 additions and 31 deletions
+4
View File
@@ -3,3 +3,7 @@ APP_ENV=development
API_PREFIX=/api
DATABASE_URL=postgresql+psycopg://nalati:change-this-password@127.0.0.1:5432/nalati_rag
CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
AUTH_ALLOWED_ORIGINS=http://localhost:5173,http://127.0.0.1:5173,http://localhost:8000,http://127.0.0.1:8000
# 默认要求 HTTPS;仅本机 HTTP 联调可显式改为 false。
AUTH_COOKIE_SECURE=true
AUTH_SESSION_SECONDS=28800
+122
View File
@@ -0,0 +1,122 @@
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
from sqlalchemy import delete, select
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.models.auth import AuthSession, User
from app.schemas.auth import (
LoginInput,
PasswordChangeInput,
ProfileInput,
RegisterInput,
SessionRead,
UserRead,
)
from app.services.auth import (
PASSWORD_HASHER,
CurrentSession,
check_password,
clear_cookie,
dummy_hash,
issue_session,
limit_auth_requests,
require_admin,
require_origin,
require_session,
)
router = APIRouter(tags=["auth"])
@router.post("/auth/register", response_model=UserRead, status_code=201)
def register(
payload: RegisterInput,
request: Request,
db: Session = Depends(get_db),
origin=Depends(require_origin),
):
"""Registration never accepts role, ID, password hash or credential version fields."""
limit_auth_requests(db, request)
user = User(
username=payload.username,
password_hash=PASSWORD_HASHER.hash(payload.password.get_secret_value()),
nickname=payload.nickname,
email=str(payload.email) if payload.email else None,
role="user",
)
db.add(user)
db.commit()
return user
@router.post("/auth/login", response_model=SessionRead)
def login(
payload: LoginInput,
request: Request,
response: Response,
db: Session = Depends(get_db),
origin=Depends(require_origin),
):
limit_auth_requests(db, request, payload.username)
user = db.scalar(select(User).where(User.username == payload.username).with_for_update())
encoded = user.password_hash if user else dummy_hash()
if not check_password(encoded, payload.password.get_secret_value()) or user is None:
raise HTTPException(401, "用户名或密码错误")
if PASSWORD_HASHER.check_needs_rehash(user.password_hash):
user.password_hash = PASSWORD_HASHER.hash(payload.password.get_secret_value())
return issue_session(db, user, request, response)
@router.get("/auth/me", response_model=SessionRead)
def me(current: CurrentSession = Depends(require_session)):
return current.response()
@router.post("/auth/logout", status_code=204)
def logout(
response: Response,
current: CurrentSession = Depends(require_session),
db: Session = Depends(get_db),
):
db.delete(current.session)
db.commit()
clear_cookie(response)
@router.patch("/users/me", response_model=UserRead)
def update_profile(
payload: ProfileInput,
current: CurrentSession = Depends(require_session),
db: Session = Depends(get_db),
):
if not payload.model_fields_set:
raise HTTPException(422, "请至少填写一个可修改字段")
for key, value in payload.model_dump(exclude_unset=True, mode="json").items():
setattr(current.user, key, value)
db.commit()
return current.user
@router.post("/auth/change-password", status_code=204)
def change_password(
payload: PasswordChangeInput,
response: Response,
current: CurrentSession = Depends(require_session),
db: Session = Depends(get_db),
):
if not check_password(current.user.password_hash, payload.current_password.get_secret_value()):
raise HTTPException(400, "当前密码不正确")
current.user.password_hash = PASSWORD_HASHER.hash(payload.new_password.get_secret_value())
current.user.credential_version += 1
db.execute(delete(AuthSession).where(AuthSession.user_id == current.user.id))
db.commit()
clear_cookie(response)
@router.get("/admin/users", response_model=list[UserRead], dependencies=[Depends(require_admin)])
def list_users(
limit: int = Query(100, ge=1, le=100),
offset: int = Query(0, ge=0),
db: Session = Depends(get_db),
):
return list(db.scalars(select(User).order_by(User.id).offset(offset).limit(limit)))
+2 -1
View File
@@ -5,6 +5,7 @@ from sqlalchemy.orm import Session
from app.db.session import get_db
from app.models import Document, KnowledgeItem, Source
from app.schemas.catalog import AuditRead, DocumentCreate, DocumentRead, DocumentUpdate, Page
from app.services.auth import require_admin
from app.services.catalog import (
ensure_version,
get_item,
@@ -15,7 +16,7 @@ from app.services.catalog import (
update_values,
)
router = APIRouter(prefix="/documents", tags=["documents"])
router = APIRouter(prefix="/documents", tags=["documents"], dependencies=[Depends(require_admin)])
@router.get("", response_model=Page[DocumentRead])
+2 -1
View File
@@ -7,6 +7,7 @@ from app.models import KnowledgeItem
from app.models.catalog import utc_now
from app.schemas.catalog import AuditRead, Page, Status
from app.schemas.knowledge import KnowledgeCreate, KnowledgeRead, KnowledgeReview, KnowledgeUpdate
from app.services.auth import require_admin
from app.services.catalog import (
ensure_version,
get_item,
@@ -18,7 +19,7 @@ from app.services.catalog import (
update_values,
)
router = APIRouter(prefix="/knowledge", tags=["knowledge"])
router = APIRouter(prefix="/knowledge", tags=["knowledge"], dependencies=[Depends(require_admin)])
def filtered_query(q="", category=None, verification_status=None, source_id=None, document_id=None):
+5 -2
View File
@@ -8,6 +8,7 @@ from app.db.session import get_db
from app.models import RetrievalRun
from app.schemas.catalog import Page
from app.schemas.retrieval import RetrievalRequest, RetrievalResponse
from app.services.auth import require_admin
from app.services.retrieval import retrieve
router = APIRouter(prefix="/retrieval", tags=["retrieval"])
@@ -19,7 +20,7 @@ def keyword_retrieval(payload: RetrievalRequest, db: Session = Depends(get_db)):
return retrieve(db, payload)
@router.get("/runs", response_model=Page[RetrievalResponse])
@router.get("/runs", response_model=Page[RetrievalResponse], dependencies=[Depends(require_admin)])
def list_runs(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
@@ -39,7 +40,9 @@ def list_runs(
}
@router.get("/runs/{run_id}", response_model=RetrievalResponse)
@router.get(
"/runs/{run_id}", response_model=RetrievalResponse, dependencies=[Depends(require_admin)]
)
def read_run(run_id: UUID, db: Session = Depends(get_db)):
row = db.get(RetrievalRun, str(run_id))
if row is None:
+2 -1
View File
@@ -5,6 +5,7 @@ from sqlalchemy.orm import Session
from app.db.session import get_db
from app.models import Document, KnowledgeItem, Source
from app.schemas.catalog import AuditRead, Page, SourceCreate, SourceRead, SourceUpdate
from app.services.auth import require_admin
from app.services.catalog import (
ensure_version,
get_item,
@@ -15,7 +16,7 @@ from app.services.catalog import (
update_values,
)
router = APIRouter(prefix="/sources", tags=["sources"])
router = APIRouter(prefix="/sources", tags=["sources"], dependencies=[Depends(require_admin)])
@router.get("", response_model=Page[SourceRead])
+32
View File
@@ -1,5 +1,7 @@
from pathlib import Path
from urllib.parse import urlsplit
from pydantic import Field, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -9,6 +11,11 @@ class Settings(BaseSettings):
api_prefix: str = "/api"
database_url: str = "postgresql+psycopg://nalati:change-this-password@127.0.0.1:5432/nalati_rag"
cors_origins: str = "http://localhost:5173,http://127.0.0.1:5173"
auth_allowed_origins: str = (
"http://localhost:5173,http://127.0.0.1:5173,http://localhost:8000,http://127.0.0.1:8000"
)
auth_cookie_secure: bool = True
auth_session_seconds: int = Field(28800, ge=300, le=604800)
model_config = SettingsConfigDict(
env_file=Path(__file__).resolve().parents[2] / ".env", extra="ignore"
)
@@ -17,5 +24,30 @@ class Settings(BaseSettings):
def cors_origin_list(self) -> list[str]:
return [x.strip() for x in self.cors_origins.split(",") if x.strip()]
@property
def auth_origin_list(self) -> list[str]:
return [x.strip() for x in self.auth_allowed_origins.split(",") if x.strip()]
@model_validator(mode="after")
def validate_auth_settings(self):
if not self.auth_cookie_secure and self.app_env not in {"development", "test"}:
raise ValueError("非开发/测试环境必须使用 Secure Cookie 与 HTTPS")
if not self.auth_origin_list:
raise ValueError("必须显式设置认证允许的来源")
for origin in [*self.auth_origin_list, *self.cors_origin_list]:
parsed = urlsplit(origin)
if (
parsed.scheme not in {"http", "https"}
or not parsed.hostname
or parsed.netloc != parsed.hostname + (f":{parsed.port}" if parsed.port else "")
or parsed.path
or parsed.query
or parsed.fragment
or "*" in origin
or (self.app_env not in {"development", "test"} and parsed.scheme != "https")
):
raise ValueError("认证和跨域来源须为明确的 HTTP(S) Origin,不允许通配符或路径")
return self
settings = Settings()
+27 -3
View File
@@ -1,15 +1,17 @@
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from sqlalchemy import text
from sqlalchemy.exc import IntegrityError, OperationalError, ProgrammingError
from sqlalchemy.orm.exc import StaleDataError
from app.api.routes import chat, documents, health, knowledge, retrieval, sources
from app.api.routes import auth, chat, documents, health, knowledge, retrieval, sources
from app.core.config import settings
from app.db.session import engine
from app.services.auth import dummy_hash
from app.services.retrieval import tokenizer
@@ -19,7 +21,7 @@ def create_app(db_engine=engine) -> FastAPI:
try:
with db_engine.connect() as connection:
revision = connection.scalar(text("SELECT version_num FROM alembic_version"))
if revision != "0003_retrieval_runs":
if revision != "0004_auth_sessions":
raise RuntimeError(
"数据库版本不匹配,请先在 backend 目录执行 alembic upgrade head"
)
@@ -28,12 +30,13 @@ def create_app(db_engine=engine) -> FastAPI:
"数据库尚未初始化,请先在 backend 目录执行 alembic upgrade head"
) from exc
tokenizer()
dummy_hash()
try:
yield
finally:
db_engine.dispose()
app = FastAPI(title=settings.app_name, version="0.3.0", lifespan=lifespan)
app = FastAPI(title=settings.app_name, version="0.4.0", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origin_list,
@@ -43,6 +46,7 @@ def create_app(db_engine=engine) -> FastAPI:
)
for router in (
health.router,
auth.router,
chat.router,
knowledge.router,
sources.router,
@@ -51,6 +55,26 @@ def create_app(db_engine=engine) -> FastAPI:
):
app.include_router(router, prefix=settings.api_prefix)
@app.middleware("http")
async def no_cache_api(request, call_next):
response = await call_next(request)
if request.url.path.startswith(settings.api_prefix + "/"):
response.headers["Cache-Control"] = "no-store"
return response
@app.exception_handler(RequestValidationError)
async def validation_handler(request, exc):
# FastAPI's default includes raw input (including passwords) in error responses.
return JSONResponse(
status_code=422,
content={
"detail": [
{"loc": list(error["loc"]), "msg": error["msg"], "type": error["type"]}
for error in exc.errors()
]
},
)
@app.exception_handler(StaleDataError)
async def stale_data_handler(request, exc):
return JSONResponse(status_code=409, content={"detail": "记录已更新,请刷新后重试"})
+47
View File
@@ -0,0 +1,47 @@
"""Local-only administrator provisioning. Passwords are read from the terminal, never argv."""
import argparse
import getpass
from pydantic import ValidationError
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from app.db.session import SessionLocal
from app.models.auth import User
from app.schemas.auth import RegisterInput
from app.services.auth import PASSWORD_HASHER
def main():
parser = argparse.ArgumentParser(description="本机管理员初始化(须先完成数据库迁移)")
parser.add_argument("command", choices=["create-admin"])
parser.add_argument("--username", required=True)
args = parser.parse_args()
password = getpass.getpass("管理员密码(至少 15 字符):")
if password != getpass.getpass("再次输入密码:"):
parser.exit(1, "两次密码不一致\n")
try:
payload = RegisterInput(username=args.username, password=password)
except ValidationError:
parser.exit(1, "用户名须为 3–32 位字母开头的小写字母/数字/下划线;密码须为 15–128 字符\n")
with SessionLocal() as db:
if db.scalar(select(User.id).where(User.username == payload.username)) is not None:
parser.exit(1, "用户已存在,未修改原有用户或权限\n")
db.add(
User(
username=payload.username,
password_hash=PASSWORD_HASHER.hash(password),
role="admin",
)
)
try:
db.commit()
except IntegrityError:
db.rollback()
parser.exit(1, "用户已存在,未修改原有用户或权限\n")
print("管理员已创建;请通过登录接口建立会话。")
if __name__ == "__main__":
main()
+11 -1
View File
@@ -2,4 +2,14 @@ from app.models.catalog import AuditEvent, Document, Source
from app.models.knowledge import KnowledgeItem
from app.models.retrieval import RetrievalRun
__all__ = ["AuditEvent", "Document", "KnowledgeItem", "RetrievalRun", "Source"]
__all__ = [
"AuditEvent",
"AuthRateLimit",
"AuthSession",
"Document",
"KnowledgeItem",
"RetrievalRun",
"Source",
"User",
]
from app.models.auth import AuthRateLimit, AuthSession, User
+42
View File
@@ -0,0 +1,42 @@
"""Users, opaque sessions and shared authentication request budgets."""
from datetime import datetime
from sqlalchemy import CheckConstraint, DateTime, ForeignKey, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column
from app.db.session import Base
class User(Base):
__tablename__ = "users"
__table_args__ = (CheckConstraint("role IN ('user', 'admin')", name="ck_users_role"),)
id: Mapped[int] = mapped_column(primary_key=True)
username: Mapped[str] = mapped_column(String(32), unique=True, nullable=False)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
nickname: Mapped[str | None] = mapped_column(String(80))
email: Mapped[str | None] = mapped_column(String(254))
role: Mapped[str] = mapped_column(String(10), default="user", nullable=False)
credential_version: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class AuthSession(Base):
__tablename__ = "auth_sessions"
token_hash: Mapped[str] = mapped_column(String(64), primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
credential_version: Mapped[int] = mapped_column(Integer, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
expires_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, index=True
)
class AuthRateLimit(Base):
__tablename__ = "auth_rate_limits"
key: Mapped[str] = mapped_column(String(64), primary_key=True)
window_start: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
attempts: Mapped[int] = mapped_column(Integer, nullable=False)
+67
View File
@@ -0,0 +1,67 @@
from datetime import datetime
from typing import Annotated, Literal
from pydantic import BaseModel, ConfigDict, EmailStr, Field, SecretStr, field_validator
Password = Annotated[SecretStr, Field(min_length=15, max_length=128)]
class AuthInput(BaseModel):
model_config = ConfigDict(extra="forbid")
class ProfileInput(AuthInput):
nickname: str | None = Field(None, min_length=1, max_length=80)
email: EmailStr | None = Field(None, max_length=254)
@field_validator("nickname", "email", mode="before")
@classmethod
def optional_text(cls, value):
return (value.strip() or None) if isinstance(value, str) else value
@field_validator("nickname")
@classmethod
def no_control_characters(cls, value):
if value and any(ord(char) < 32 or ord(char) == 127 for char in value):
raise ValueError("昵称不能包含控制字符")
return value
class RegisterInput(ProfileInput):
username: str = Field(min_length=3, max_length=32, pattern=r"^[a-z][a-z0-9_]{2,31}$")
password: Password
@field_validator("username", mode="before")
@classmethod
def canonical_username(cls, value):
return value.strip().lower() if isinstance(value, str) else value
class LoginInput(AuthInput):
username: str = Field(min_length=3, max_length=32, pattern=r"^[a-z][a-z0-9_]{2,31}$")
password: SecretStr = Field(min_length=1, max_length=128)
@field_validator("username", mode="before")
@classmethod
def canonical_username(cls, value):
return value.strip().lower() if isinstance(value, str) else value
class PasswordChangeInput(AuthInput):
current_password: SecretStr = Field(min_length=1, max_length=128)
new_password: Password
class UserRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
username: str
nickname: str | None
email: str | None
role: Literal["user", "admin"]
created_at: datetime
class SessionRead(UserRead):
csrf_token: str
+161
View File
@@ -0,0 +1,161 @@
"""Database-backed sessions; no bearer secrets are stored in the database."""
import hashlib
import hmac
import re
import secrets
from dataclasses import dataclass
from datetime import timedelta
from functools import lru_cache
from argon2 import PasswordHasher
from argon2.exceptions import InvalidHashError, VerificationError
from fastapi import Depends, HTTPException, Request, Response
from sqlalchemy import case, delete, select
from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.orm import Session
from app.core.config import settings
from app.db.session import get_db
from app.models.auth import AuthRateLimit, AuthSession, User
from app.models.catalog import utc_now
from app.schemas.auth import SessionRead, UserRead
COOKIE_NAME = "nalati_session"
UNSAFE_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
PASSWORD_HASHER = PasswordHasher(time_cost=2, memory_cost=19456, parallelism=1)
@lru_cache(maxsize=1)
def dummy_hash():
return PASSWORD_HASHER.hash(secrets.token_urlsafe(32))
def check_password(encoded: str, password: str) -> bool:
try:
return PASSWORD_HASHER.verify(encoded, password)
except (VerificationError, InvalidHashError):
return False
def token_hash(raw: str) -> str:
return hashlib.sha256(raw.encode()).hexdigest()
def csrf_token(raw: str) -> str:
# Domain-separated derivative; knowing the CSRF token does not reveal the cookie.
return hmac.new(raw.encode(), b"nalati-csrf-v1", hashlib.sha256).hexdigest()
def require_origin(request: Request):
if request.headers.get("origin") not in settings.auth_origin_list:
raise HTTPException(403, "请求来源不受信任")
def limit_auth_requests(db: Session, request: Request, username: str | None = None):
"""Atomic, shared across workers and restarts; never trust forwarded client headers here."""
now = utc_now()
window = timedelta(minutes=5)
buckets = [("ip:" + (request.client.host if request.client else "unknown"), 30)]
if username is not None:
buckets.append(("username:" + username, 10))
blocked = False
for identity, maximum in buckets:
statement = insert(AuthRateLimit).values(
key=token_hash(identity), window_start=now, attempts=1
)
expired = AuthRateLimit.window_start <= now - window
statement = statement.on_conflict_do_update(
index_elements=[AuthRateLimit.key],
set_={
"window_start": case((expired, now), else_=AuthRateLimit.window_start),
"attempts": case((expired, 1), else_=AuthRateLimit.attempts + 1),
},
).returning(AuthRateLimit.attempts)
blocked |= db.scalar(statement) > maximum
db.execute(delete(AuthRateLimit).where(AuthRateLimit.window_start < now - timedelta(days=1)))
# Persist even failed authentication; no password or raw address is logged.
db.commit()
if blocked:
raise HTTPException(429, "认证请求过于频繁,请稍后重试", headers={"Retry-After": "300"})
@dataclass
class CurrentSession:
user: User
session: AuthSession
raw_token: str
def response(self):
return SessionRead(
**UserRead.model_validate(self.user).model_dump(),
csrf_token=csrf_token(self.raw_token),
)
def require_session(request: Request, db: Session = Depends(get_db)) -> CurrentSession:
raw = request.cookies.get(COOKIE_NAME, "")
if not re.fullmatch(r"[A-Za-z0-9_-]{43}", raw):
raise HTTPException(401, "请先登录")
session = db.get(AuthSession, token_hash(raw))
if session is None:
raise HTTPException(401, "登录已失效,请重新登录")
# Lock the user before checking the current session again, serializing password changes/login.
user = db.scalar(select(User).where(User.id == session.user_id).with_for_update())
session = db.scalar(
select(AuthSession)
.where(AuthSession.token_hash == token_hash(raw))
.execution_options(populate_existing=True)
)
if (
user is None
or session is None
or session.expires_at <= utc_now()
or session.credential_version != user.credential_version
):
raise HTTPException(401, "登录已失效,请重新登录")
if request.method in UNSAFE_METHODS:
require_origin(request)
submitted = request.headers.get("x-csrf-token", "")
if not hmac.compare_digest(submitted.encode(), csrf_token(raw).encode()):
raise HTTPException(403, "CSRF 校验失败,请刷新登录状态后重试")
return CurrentSession(user, session, raw)
def require_admin(current: CurrentSession = Depends(require_session)):
if current.user.role != "admin":
raise HTTPException(403, "需要管理员权限")
return current
def issue_session(db: Session, user: User, request: Request, response: Response):
now = utc_now()
old = request.cookies.get(COOKIE_NAME, "")
db.execute(delete(AuthSession).where(AuthSession.expires_at <= now))
if old:
db.execute(delete(AuthSession).where(AuthSession.token_hash == token_hash(old)))
raw = secrets.token_urlsafe(32)
session = AuthSession(
token_hash=token_hash(raw),
user_id=user.id,
credential_version=user.credential_version,
expires_at=now + timedelta(seconds=settings.auth_session_seconds),
)
db.add(session)
db.commit()
response.set_cookie(
COOKIE_NAME,
raw,
max_age=settings.auth_session_seconds,
httponly=True,
secure=settings.auth_cookie_secure,
samesite="lax",
path="/",
)
return CurrentSession(user, session, raw).response()
def clear_cookie(response: Response):
response.delete_cookie(
COOKIE_NAME, path="/", httponly=True, secure=settings.auth_cookie_secure, samesite="lax"
)
@@ -0,0 +1,52 @@
"""Add users, revocable opaque sessions and shared authentication budgets."""
import sqlalchemy as sa
from alembic import op
revision = "0004_auth_sessions"
down_revision = "0003_retrieval_runs"
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
"users",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("username", sa.String(32), nullable=False, unique=True),
sa.Column("password_hash", sa.String(255), nullable=False),
sa.Column("nickname", sa.String(80)),
sa.Column("email", sa.String(254)),
sa.Column("role", sa.String(10), nullable=False),
sa.Column("credential_version", sa.Integer(), nullable=False),
sa.Column(
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
sa.CheckConstraint("role IN ('user', 'admin')", name="ck_users_role"),
)
op.create_table(
"auth_sessions",
sa.Column("token_hash", sa.String(64), primary_key=True),
sa.Column(
"user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False
),
sa.Column("credential_version", sa.Integer(), nullable=False),
sa.Column(
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
)
op.create_index("ix_auth_sessions_user_id", "auth_sessions", ["user_id"])
op.create_index("ix_auth_sessions_expires_at", "auth_sessions", ["expires_at"])
op.create_table(
"auth_rate_limits",
sa.Column("key", sa.String(64), primary_key=True),
sa.Column("window_start", sa.DateTime(timezone=True), nullable=False),
sa.Column("attempts", sa.Integer(), nullable=False),
)
def downgrade():
op.drop_table("auth_rate_limits")
op.drop_table("auth_sessions")
op.drop_table("users")
+2
View File
@@ -7,3 +7,5 @@ psycopg[binary]>=3.2,<4.0
pgvector>=0.3,<1.0
alembic>=1.13,<2.0
jieba==0.42.1
argon2-cffi>=25.1,<26
email-validator>=2.2,<3
+24 -2
View File
@@ -14,6 +14,8 @@ from sqlalchemy.orm import sessionmaker
from app.db.session import get_db
from app.main import create_app
from app.models.auth import User
from app.services.auth import PASSWORD_HASHER
BACKEND = Path(__file__).resolve().parents[1]
@@ -70,7 +72,7 @@ def migrated_database(database):
@pytest.fixture
def client(migrated_database):
def anonymous_client(migrated_database):
app = create_app(migrated_database)
factory = sessionmaker(bind=migrated_database)
@@ -83,5 +85,25 @@ def client(migrated_database):
raise
app.dependency_overrides[get_db] = test_session
with TestClient(app) as client:
with TestClient(app, base_url="https://testserver") as client:
client.headers["Origin"] = "http://localhost:5173"
yield client
@pytest.fixture
def client(anonymous_client, migrated_database):
with sessionmaker(bind=migrated_database)() as db:
db.add(
User(
username="test_admin",
password_hash=PASSWORD_HASHER.hash("test-password-123"),
role="admin",
)
)
db.commit()
response = anonymous_client.post(
"/api/auth/login", json={"username": "test_admin", "password": "test-password-123"}
)
assert response.status_code == 200, response.text
anonymous_client.headers["X-CSRF-Token"] = response.json()["csrf_token"]
return anonymous_client
+342
View File
@@ -0,0 +1,342 @@
"""Authentication, authorization and CSRF boundaries over real PostgreSQL."""
import json
from datetime import timedelta
import pytest
from pydantic import ValidationError
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.core.config import Settings
from app.models.auth import AuthSession, User
from app.models.catalog import utc_now
from app.services.auth import COOKIE_NAME, check_password, token_hash
PASSWORD = "test-password-123"
ORIGIN = "http://localhost:5173"
def register(client, username="test_user", **extra):
return client.post(
"/api/auth/register", json={"username": username, "password": PASSWORD, **extra}
)
def login(client, username="test_user", password=PASSWORD):
response = client.post("/api/auth/login", json={"username": username, "password": password})
if response.status_code == 200:
client.headers["X-CSRF-Token"] = response.json()["csrf_token"]
return response
def test_registration_hashes_password_and_cannot_select_privilege(
anonymous_client, migrated_database
):
c = anonymous_client
for extra in [{"role": "admin"}, {"id": 1}, {"password_hash": "injected"}]:
assert register(c, **extra).status_code == 422
result = register(c, username=" Test_User ", email="user@example.com", nickname="占位用户")
assert result.status_code == 201
assert result.json()["username"] == "test_user" and result.json()["role"] == "user"
assert c.get("/api/auth/me").status_code == 401
assert register(c).status_code == 409
with Session(migrated_database) as db:
user = db.scalar(select(User))
assert user.password_hash.startswith("$argon2id$")
assert PASSWORD not in user.password_hash and check_password(user.password_hash, PASSWORD)
assert db.scalar(select(func.count()).select_from(AuthSession)) == 0
assert "password" not in result.text and "credential_version" not in result.text
@pytest.mark.parametrize(
"data",
[
{"password": "short"},
{"password": "x" * 129},
{"username": "a"},
{"username": "a b"},
{"email": "invalid"},
{"nickname": "x" * 81},
{"nickname": "bad\u0000name"},
],
)
def test_validation_does_not_echo_credentials(anonymous_client, data):
submitted = {"username": "test_user", "password": PASSWORD, **data}
response = anonymous_client.post("/api/auth/register", json=submitted)
assert response.status_code == 422
assert '"input"' not in response.text
assert json.dumps(submitted["password"]) not in response.text
assert response.headers["cache-control"] == "no-store"
@pytest.mark.parametrize("origin", [None, "null", "https://evil.example", ORIGIN + ".evil.example"])
def test_login_and_registration_reject_untrusted_origin(
anonymous_client, migrated_database, origin
):
c = anonymous_client
c.headers.pop("Origin", None)
headers = {"Origin": origin} if origin else {}
for path in ["register", "login"]:
response = c.post(
f"/api/auth/{path}",
json={"username": "test_user", "password": PASSWORD},
headers=headers,
)
assert response.status_code == 403
with Session(migrated_database) as db:
assert db.scalar(select(func.count()).select_from(User)) == 0
def test_login_cookie_rotation_logout_and_opaque_storage(anonymous_client, migrated_database):
c = anonymous_client
register(c)
response = login(c)
assert response.status_code == 200
assert all(
value in response.headers["set-cookie"]
for value in ["HttpOnly", "Secure", "SameSite=lax", "Path=/"]
)
first = c.cookies.get(COOKIE_NAME)
csrf = response.json()["csrf_token"]
assert c.get("/api/auth/me").json()["csrf_token"] == csrf
with Session(migrated_database) as db:
stored = db.scalar(select(AuthSession))
assert stored.token_hash == token_hash(first) and first not in stored.token_hash
assert login(c).status_code == 200
second = c.cookies.get(COOKIE_NAME)
assert first != second
c.cookies.clear()
c.cookies.set(COOKIE_NAME, first)
assert c.get("/api/auth/me").status_code == 401
c.cookies.clear()
c.cookies.set(COOKIE_NAME, second)
response = c.post("/api/auth/logout")
assert response.status_code == 204 and "Max-Age=0" in response.headers["set-cookie"]
c.cookies.clear()
c.cookies.set(COOKIE_NAME, second)
assert c.get("/api/auth/me").status_code == 401
def test_login_failure_is_generic_and_throttled_in_database(anonymous_client):
c = anonymous_client
register(c)
absent = login(c, "absent_user", "wrong").json()
wrong = login(c, password="wrong")
assert wrong.status_code == 401 and wrong.json() == absent
for _ in range(9):
assert login(c, password="wrong").status_code == 401
blocked = login(c)
assert blocked.status_code == 429 and blocked.headers["retry-after"] == "300"
@pytest.mark.parametrize("username", ["bad\u0000name", "invalid name", "../user"])
def test_login_rejects_invalid_username_before_database_lookup(anonymous_client, username):
assert login(anonymous_client, username=username).status_code == 422
def test_expired_forged_and_credential_version_sessions_are_rejected(
anonymous_client, migrated_database
):
c = anonymous_client
register(c)
login(c)
with Session(migrated_database) as db:
session = db.scalar(select(AuthSession))
session.expires_at = utc_now() - timedelta(seconds=1)
db.commit()
assert c.get("/api/auth/me").status_code == 401
login(c)
with Session(migrated_database) as db:
db.scalar(select(User)).credential_version += 1
db.commit()
assert c.get("/api/auth/me").status_code == 401
c.cookies.clear()
c.cookies.set(COOKIE_NAME, "x" * 43)
assert c.get("/api/auth/me").status_code == 401
c.cookies.clear()
c.cookies.set(COOKIE_NAME, "invalid")
assert c.get("/api/auth/me").status_code == 401
def test_csrf_is_bound_to_session_and_required_for_all_authenticated_writes(anonymous_client):
c = anonymous_client
register(c)
login(c)
first = c.headers["X-CSRF-Token"]
login(c)
for token in ["", "invalid", first]:
assert (
c.patch(
"/api/users/me", json={"nickname": "占位"}, headers={"X-CSRF-Token": token}
).status_code
== 403
)
assert c.post("/api/auth/logout", headers={"X-CSRF-Token": token}).status_code == 403
assert (
c.patch(
"/api/users/me", json={"nickname": "占位"}, headers={"Origin": "https://evil.example"}
).status_code
== 403
)
c.headers.pop("Origin")
assert c.patch("/api/users/me", json={"nickname": "占位"}).status_code == 403
c.headers["Origin"] = ORIGIN
assert c.patch("/api/users/me", json={"nickname": " 占位 "}).json()["nickname"] == "占位"
def test_profile_updates_only_own_allowed_fields(anonymous_client, migrated_database):
c = anonymous_client
first = register(c).json()
register(c, "other_user")
login(c)
for payload in [
{"id": 2},
{"role": "admin"},
{"username": "other_user"},
{"password": "changed"},
{},
]:
assert c.patch("/api/users/me", json=payload).status_code == 422
assert (
c.patch("/api/users/me", json={"email": "changed@example.com", "nickname": "示例"}).json()[
"id"
]
== first["id"]
)
assert c.patch("/api/users/me", json={"email": "", "nickname": None}).json()["email"] is None
with Session(migrated_database) as db:
assert db.scalar(select(User).where(User.username == "other_user")).email is None
assert db.scalar(select(User).where(User.id == first["id"])).role == "user"
def test_password_change_revokes_all_sessions(anonymous_client):
c = anonymous_client
register(c)
login(c)
first = c.cookies.get(COOKIE_NAME)
# Independent second browser: existing first session must also be invalidated.
c.cookies.clear()
login(c)
assert (
c.post(
"/api/auth/change-password",
json={"current_password": "wrong", "new_password": "new-password-123"},
).status_code
== 400
)
response = c.post(
"/api/auth/change-password",
json={"current_password": PASSWORD, "new_password": "new-password-123"},
)
assert response.status_code == 204
assert c.get("/api/auth/me").status_code == 401
c.cookies.set(COOKIE_NAME, first)
assert c.get("/api/auth/me").status_code == 401
assert login(c).status_code == 401
assert login(c, password="new-password-123").status_code == 200
def test_all_management_routes_require_admin_and_history_is_private(anonymous_client):
c = anonymous_client
paths = [
("GET", "/api/admin/users"),
("GET", "/api/knowledge"),
("GET", "/api/knowledge/page"),
("GET", "/api/knowledge/1"),
("POST", "/api/knowledge"),
("PATCH", "/api/knowledge/1"),
("DELETE", "/api/knowledge/1?expected_version=1"),
("POST", "/api/knowledge/1/review"),
("GET", "/api/knowledge/1/history"),
("GET", "/api/sources"),
("POST", "/api/sources"),
("GET", "/api/sources/1"),
("PATCH", "/api/sources/1"),
("DELETE", "/api/sources/1?expected_version=1"),
("GET", "/api/sources/1/history"),
("GET", "/api/documents"),
("POST", "/api/documents"),
("GET", "/api/documents/1"),
("PATCH", "/api/documents/1"),
("DELETE", "/api/documents/1?expected_version=1"),
("GET", "/api/documents/1/history"),
("GET", "/api/retrieval/runs"),
("GET", "/api/retrieval/runs/00000000-0000-0000-0000-000000000000"),
]
for method, path in paths:
assert c.request(method, path, json={}).status_code == 401, path
register(c)
login(c)
for method, path in paths:
assert c.request(method, path, json={}).status_code == 403, path
assert c.post("/api/retrieval", json={"query": "占位"}).json()["status"] == "no_evidence"
assert c.post("/api/chat", json={"question": "占位"}).json()["status"] == "stub"
def test_admin_access_live_role_and_no_csrf_bypass(client, migrated_database):
c = client
users = c.get("/api/admin/users").json()
assert users[0]["role"] == "admin" and "password_hash" not in str(users)
assert c.get("/api/admin/users?limit=101").status_code == 422
assert (
c.post(
"/api/knowledge",
json={"title": "示例", "content": "示例"},
headers={"X-CSRF-Token": ""},
).status_code
== 403
)
assert c.post("/api/knowledge", json={"title": "示例", "content": "示例"}).status_code == 201
with Session(migrated_database) as db:
db.scalar(select(User)).role = "user"
db.commit()
assert c.get("/api/admin/users").status_code == 403
assert c.get("/api/knowledge").status_code == 403
def test_auth_settings_require_explicit_origins_and_secure_production():
with pytest.raises(ValidationError):
Settings(_env_file=None, app_env="production", auth_cookie_secure=False)
for origin in [
"*",
"null",
"https://example.com/path",
"https://user@example.com",
"https://*.example.com",
]:
with pytest.raises(ValidationError):
Settings(_env_file=None, auth_allowed_origins=origin)
def test_local_admin_provisioning_has_no_default_account(migrated_database, monkeypatch, capsys):
from sqlalchemy.orm import sessionmaker
from app import manage
monkeypatch.setattr(manage, "SessionLocal", sessionmaker(bind=migrated_database))
monkeypatch.setattr("sys.argv", ["manage", "create-admin", "--username", "local_admin"])
monkeypatch.setattr(manage.getpass, "getpass", lambda _: PASSWORD)
manage.main()
with Session(migrated_database) as db:
user = db.scalar(select(User))
assert user.role == "admin" and check_password(user.password_hash, PASSWORD)
with pytest.raises(SystemExit) as error:
manage.main()
assert error.value.code == 1
assert PASSWORD not in capsys.readouterr().out
def test_client_budget_is_shared_across_names_and_registration(anonymous_client, migrated_database):
c = anonymous_client
for index in range(30):
assert login(c, username=f"absent_{index}").status_code == 401
assert register(c).status_code == 429
# Expire the persisted budget instead of waiting in a test.
from app.models.auth import AuthRateLimit
with Session(migrated_database) as db:
for bucket in db.scalars(select(AuthRateLimit)):
bucket.window_start = utc_now() - timedelta(minutes=6)
db.commit()
assert register(c).status_code == 201
+26 -2
View File
@@ -16,7 +16,7 @@ def test_startup_requires_migration(database):
def test_startup_rejects_previous_schema(database):
migrate(database, "0002_catalog")
migrate(database, "0003_retrieval_runs")
with pytest.raises(RuntimeError, match="alembic upgrade head"):
with TestClient(create_app(database)):
pass
@@ -42,10 +42,34 @@ def test_upgrade_preserves_original_rows_and_repeated_upgrade(database):
assert connection.scalar(text("SELECT count(*) FROM audit_events")) == 2
assert (
connection.scalar(text("SELECT version_num FROM alembic_version"))
== "0003_retrieval_runs"
== "0004_auth_sessions"
)
assert compare_metadata(MigrationContext.configure(connection), Base.metadata) == []
migrate(database, "0001_baseline", downgrade=True)
with database.connect() as connection:
assert connection.scalar(text("SELECT content FROM knowledge_items")) == "原始正文"
migrate(database)
def test_auth_upgrade_and_downgrade_preserve_knowledge_and_retrieval(database):
migrate(database, "0003_retrieval_runs")
with database.begin() as c:
c.execute(
text("""INSERT INTO knowledge_items
(title, category, content, verification_status, version, updated_at, is_deleted)
VALUES ('占位', '其他', '保留的占位正文', 'pending', 1, now(), false)""")
)
c.execute(
text("""INSERT INTO retrieval_runs
(id, query, mode, corpus_fingerprint, response)
VALUES ('00000000-0000-0000-0000-000000000001', '占位', 'keyword', 'placeholder', '{}')""")
)
migrate(database)
with database.connect() as c:
assert c.scalar(text("SELECT count(*) FROM users")) == 0
assert c.scalar(text("SELECT count(*) FROM auth_sessions")) == 0
migrate(database, "0003_retrieval_runs", downgrade=True)
with database.connect() as c:
assert c.scalar(text("SELECT content FROM knowledge_items")) == "保留的占位正文"
assert c.scalar(text("SELECT count(*) FROM retrieval_runs")) == 1
migrate(database)
+49 -2
View File
@@ -9,14 +9,35 @@ from contextlib import contextmanager
from pathlib import Path
import httpx
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models.auth import User
from app.services.auth import PASSWORD_HASHER
@contextmanager
def server(engine):
def server(engine, *, authenticate=True):
with socket.socket() as socket_:
socket_.bind(("127.0.0.1", 0))
port = socket_.getsockname()[1]
environment = {**os.environ, "DATABASE_URL": engine.url.render_as_string(hide_password=False)}
environment = {
**os.environ,
"DATABASE_URL": engine.url.render_as_string(hide_password=False),
"APP_ENV": "test",
"AUTH_COOKIE_SECURE": "false",
}
if authenticate:
with Session(engine) as db:
if db.scalar(select(User.id).where(User.username == "http_admin")) is None:
db.add(
User(
username="http_admin",
password_hash=PASSWORD_HASHER.hash("test-password-123"),
role="admin",
)
)
db.commit()
backend = Path(__file__).resolve().parents[1]
process = subprocess.Popen(
[
@@ -38,6 +59,7 @@ def server(engine):
)
try:
with httpx.Client(base_url=f"http://127.0.0.1:{port}", timeout=2) as client:
client.headers["Origin"] = "http://localhost:5173"
deadline = time.monotonic() + 15
while time.monotonic() < deadline:
if process.poll() is not None:
@@ -50,6 +72,13 @@ def server(engine):
time.sleep(0.05)
else:
raise AssertionError("默认后端未在时限内就绪")
if authenticate:
login = client.post(
"/api/auth/login",
json={"username": "http_admin", "password": "test-password-123"},
)
assert login.status_code == 200, login.text
client.headers["X-CSRF-Token"] = login.json()["csrf_token"]
yield client
finally:
process.terminate()
@@ -107,3 +136,21 @@ def test_retrieval_run_persists_across_restart(migrated_database):
assert run["results"][0]["knowledge_id"] == item["id"]
with server(migrated_database) as client:
assert client.get(f"/api/retrieval/runs/{run['run_id']}").json() == run
def test_session_and_logout_survive_http_restart(migrated_database):
from app.services.auth import COOKIE_NAME
with server(migrated_database, authenticate=False) as client:
payload = {"username": "http_user", "password": "test-password-123"}
assert client.post("/api/auth/register", json=payload).status_code == 201
response = client.post("/api/auth/login", json=payload)
cookie = client.cookies.get(COOKIE_NAME)
token = response.json()["csrf_token"]
with server(migrated_database, authenticate=False) as client:
client.cookies.set(COOKIE_NAME, cookie)
assert client.get("/api/auth/me").json()["username"] == "http_user"
assert client.post("/api/auth/logout", headers={"X-CSRF-Token": token}).status_code == 204
with server(migrated_database, authenticate=False) as client:
client.cookies.set(COOKIE_NAME, cookie)
assert client.get("/api/auth/me").status_code == 401