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