feat: JWT auth, user management, audit logging system

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
enzo
2026-07-22 16:52:09 +08:00
co-authored by Claude
parent 7441546804
commit e13be8eb1c
15 changed files with 562 additions and 0 deletions
View File
+27
View File
@@ -0,0 +1,27 @@
"""Audit API: list audit logs — only owner."""
from fastapi import APIRouter, Depends, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.middleware.auth import require_role
from app.models.audit import AuditLog
from app.models.user import User
router = APIRouter(prefix="/api/v1/audit", tags=["audit"])
@router.get("")
async def list_audit(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
action: str | None = Query(None),
user: User = Depends(require_role("owner")),
db: AsyncSession = Depends(get_db),
):
query = select(AuditLog).order_by(AuditLog.created_at.desc())
if action:
query = query.where(AuditLog.action.ilike(f"%{action}%"))
query = query.offset((page - 1) * page_size).limit(page_size)
result = await db.execute(query)
return result.scalars().all()
+74
View File
@@ -0,0 +1,74 @@
"""Auth API: register, login, refresh, change-password."""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database import get_db
from app.middleware.auth import require_auth
from app.models.user import User
from app.schemas.auth import (
ChangePasswordRequest,
LoginRequest,
RegisterRequest,
TokenResponse,
)
from app.schemas.user import UserResponse
from app.services.auth_service import (
authenticate_user,
create_access_token,
create_user,
decode_access_token,
get_user_by_username,
hash_password,
verify_password,
)
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
@router.post("/register", status_code=201, response_model=UserResponse)
async def register(body: RegisterRequest, db: AsyncSession = Depends(get_db)):
existing = await get_user_by_username(db, body.username)
if existing:
raise HTTPException(status_code=409, detail="Username already exists")
result = await db.execute(select(User).where(User.email == body.email))
if result.scalar_one_or_none():
raise HTTPException(status_code=409, detail="Email already exists")
user = await create_user(db, body.username, body.email, body.password)
return user
@router.post("/login", response_model=TokenResponse)
async def login(body: LoginRequest, db: AsyncSession = Depends(get_db)):
user = await authenticate_user(db, body.username, body.password)
token = create_access_token(user.id, user.role.value)
return TokenResponse(
access_token=token,
expires_in=settings.jwt_expire_minutes * 60,
)
@router.post("/refresh", response_model=TokenResponse)
async def refresh(user: User = Depends(require_auth)):
token = create_access_token(user.id, user.role.value)
return TokenResponse(
access_token=token,
expires_in=settings.jwt_expire_minutes * 60,
)
@router.post("/change-password")
async def change_password(
body: ChangePasswordRequest,
user: User = Depends(require_auth),
db: AsyncSession = Depends(get_db),
):
if not verify_password(body.old_password, user.hashed_password):
raise HTTPException(status_code=400, detail="Old password is incorrect")
user.hashed_password = hash_password(body.new_password)
await db.commit()
return {"detail": "Password changed successfully"}
+78
View File
@@ -0,0 +1,78 @@
"""User management API: list, detail, update, approve — only owner."""
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.middleware.auth import require_role
from app.models.user import User
from app.schemas.user import UserResponse, UserUpdateRequest
router = APIRouter(prefix="/api/v1/users", tags=["users"])
@router.get("", response_model=list[UserResponse])
async def list_users(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
user: User = Depends(require_role("owner")),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(User).offset((page - 1) * page_size).limit(page_size)
)
return result.scalars().all()
@router.get("/{user_id}", response_model=UserResponse)
async def get_user(
user_id: str,
user: User = Depends(require_role("owner")),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(User).where(User.id == user_id))
target = result.scalar_one_or_none()
if not target:
raise HTTPException(status_code=404, detail="User not found")
return target
@router.patch("/{user_id}", response_model=UserResponse)
async def update_user(
user_id: str,
body: UserUpdateRequest,
user: User = Depends(require_role("owner")),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(User).where(User.id == user_id))
target = result.scalar_one_or_none()
if not target:
raise HTTPException(status_code=404, detail="User not found")
if body.role is not None:
target.role = body.role
if body.is_active is not None:
target.is_active = body.is_active
await db.commit()
await db.refresh(target)
return target
@router.post("/{user_id}/approve", response_model=UserResponse)
async def approve_user(
user_id: str,
user: User = Depends(require_role("owner")),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(User).where(User.id == user_id))
target = result.scalar_one_or_none()
if not target:
raise HTTPException(status_code=404, detail="User not found")
if target.is_approved:
raise HTTPException(status_code=400, detail="User already approved")
target.is_approved = True
await db.commit()
await db.refresh(target)
return target
+7
View File
@@ -4,6 +4,8 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.database import engine, Base
from app.api import auth, users, audit
from app.middleware.audit import AuditMiddleware
@asynccontextmanager
@@ -27,6 +29,11 @@ app.add_middleware(
allow_methods=["*"],
allow_headers=["*"],
)
app.add_middleware(AuditMiddleware)
app.include_router(auth.router)
app.include_router(users.router)
app.include_router(audit.router)
@app.get("/api/v1/health")
View File
+63
View File
@@ -0,0 +1,63 @@
"""Audit middleware: log all mutating requests to AuditLog."""
import json
from fastapi import Request
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import Response
from app.database import async_session
from app.models.audit import AuditLog
from app.services.auth_service import decode_access_token, get_user_by_id
class AuditMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next) -> Response:
if request.method in ("POST", "PUT", "PATCH", "DELETE"):
response = await call_next(request)
await self._log(request, response)
return response
return await call_next(request)
async def _log(self, request: Request, response: Response):
try:
user_id = "anonymous"
username = "anonymous"
auth_header = request.headers.get("authorization")
if auth_header and auth_header.startswith("Bearer "):
try:
payload = decode_access_token(auth_header[7:])
async with async_session() as db:
user = await get_user_by_id(db, payload["sub"])
if user:
user_id = user.id
username = user.username
except Exception:
pass
path = request.url.path
action = f"{request.method} {path}"
detail = {}
if response.status_code >= 400:
body = b""
try:
body = await request.body()
if body:
detail["request_body"] = json.loads(body)
except Exception:
pass
async with async_session() as db:
log = AuditLog(
user_id=user_id,
username=username,
action=action,
target=path,
detail=json.dumps(detail) if detail else None,
ip_address=request.client.host if request.client else None,
)
db.add(log)
await db.commit()
except Exception:
pass
+27
View File
@@ -0,0 +1,27 @@
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.services.auth_service import decode_access_token, get_user_by_id
security = HTTPBearer()
async def require_auth(
credentials: HTTPAuthorizationCredentials = Depends(security),
db: AsyncSession = Depends(get_db),
):
payload = decode_access_token(credentials.credentials)
user = await get_user_by_id(db, payload["sub"])
if not user or not user.is_active:
raise HTTPException(status_code=401, detail="Invalid or inactive user")
return user
def require_role(*roles: str):
async def role_check(user=Depends(require_auth)):
if user.role.value not in roles:
raise HTTPException(status_code=403, detail="Insufficient permissions")
return user
return role_check
View File
+23
View File
@@ -0,0 +1,23 @@
from pydantic import BaseModel
class LoginRequest(BaseModel):
username: str
password: str
class RegisterRequest(BaseModel):
username: str
email: str # 先用 str,后续升级 EmailStr
password: str
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
expires_in: int = 0
class ChangePasswordRequest(BaseModel):
old_password: str
new_password: str
+19
View File
@@ -0,0 +1,19 @@
from pydantic import BaseModel, ConfigDict
from datetime import datetime
class UserResponse(BaseModel):
id: str
username: str
email: str
role: str
is_active: bool
is_approved: bool
created_at: datetime
model_config = ConfigDict(from_attributes=True)
class UserUpdateRequest(BaseModel):
role: str | None = None
is_active: bool | None = None
View File
+96
View File
@@ -0,0 +1,96 @@
"""Auth service: password hashing, JWT, user queries."""
from datetime import datetime, timedelta, timezone
from fastapi import HTTPException, status
from jose import JWTError, ExpiredSignatureError, jwt
from passlib.context import CryptContext
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.models.user import User, UserRole
_pwd = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(password: str) -> str:
return _pwd.hash(password)
def verify_password(plain: str, hashed: str) -> bool:
return _pwd.verify(plain, hashed)
def create_access_token(user_id: str, role: str) -> str:
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.jwt_expire_minutes)
payload = {"sub": user_id, "role": role, "exp": expire}
return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
def decode_access_token(token: str) -> dict:
try:
return jwt.decode(token, settings.jwt_secret, algorithms=[settings.jwt_algorithm])
except ExpiredSignatureError:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired")
except JWTError:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
async def get_user_by_id(db: AsyncSession, user_id: str) -> User | None:
result = await db.execute(select(User).where(User.id == user_id))
return result.scalar_one_or_none()
async def get_user_by_username(db: AsyncSession, username: str) -> User | None:
result = await db.execute(select(User).where(User.username == username))
return result.scalar_one_or_none()
async def authenticate_user(db: AsyncSession, username: str, password: str) -> User:
user = await get_user_by_username(db, username)
if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
# Check lock
if user.locked_until and user.locked_until > datetime.utcnow():
raise HTTPException(
status_code=status.HTTP_423_LOCKED,
detail=f"Account locked until {user.locked_until.isoformat()}",
)
if not verify_password(password, user.hashed_password):
user.login_attempts += 1
if user.login_attempts >= 5:
user.locked_until = datetime.utcnow() + timedelta(minutes=15)
await db.commit()
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
# Success — reset
user.login_attempts = 0
user.locked_until = None
await db.commit()
return user
async def create_user(db: AsyncSession, username: str, email: str, password: str) -> User:
result = await db.execute(select(User))
existing_count = len(result.scalars().all())
if existing_count == 0:
role = UserRole.OWNER
is_approved = True
else:
role = UserRole.VIEWER
is_approved = False
user = User(
username=username,
email=email,
hashed_password=hash_password(password),
role=role,
is_approved=is_approved,
)
db.add(user)
await db.commit()
await db.refresh(user)
return user
+53
View File
@@ -0,0 +1,53 @@
"""Test fixtures: async client + in-memory SQLite database."""
import asyncio
from typing import AsyncGenerator
import pytest
import pytest_asyncio
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.database import Base, get_db
from app.main import app
TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"
@pytest.fixture(scope="session")
def event_loop():
loop = asyncio.new_event_loop()
yield loop
loop.close()
@pytest_asyncio.fixture(scope="session")
async def engine():
engine = create_async_engine(TEST_DATABASE_URL, echo=False)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield engine
await engine.dispose()
@pytest_asyncio.fixture
async def db(engine) -> AsyncGenerator[AsyncSession, None]:
session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async with session_factory() as session:
yield session
@pytest_asyncio.fixture
async def client(engine) -> AsyncGenerator[AsyncClient, None]:
session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async def override_get_db():
async with session_factory() as session:
yield session
app.dependency_overrides[get_db] = override_get_db
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac
app.dependency_overrides.clear()
+95
View File
@@ -0,0 +1,95 @@
"""Auth API tests."""
import pytest
from httpx import AsyncClient
class TestRegister:
@pytest.mark.asyncio
async def test_register_success(self, client: AsyncClient):
resp = await client.post("/api/v1/auth/register", json={
"username": "owner1",
"email": "owner@test.local",
"password": "Pass1234",
})
assert resp.status_code == 201
data = resp.json()
assert data["username"] == "owner1"
assert data["role"] == "owner"
assert data["is_approved"] is True
@pytest.mark.asyncio
async def test_register_duplicate(self, client: AsyncClient):
resp = await client.post("/api/v1/auth/register", json={
"username": "owner1",
"email": "another@test.local",
"password": "Pass1234",
})
assert resp.status_code == 409
assert "Username" in resp.json()["detail"]
@pytest.mark.asyncio
async def test_register_second_user_is_viewer(self, client: AsyncClient):
resp = await client.post("/api/v1/auth/register", json={
"username": "viewer1",
"email": "viewer@test.local",
"password": "Pass1234",
})
assert resp.status_code == 201
data = resp.json()
assert data["role"] == "viewer"
assert data["is_approved"] is False
class TestLogin:
@pytest.mark.asyncio
async def test_login_success(self, client: AsyncClient):
resp = await client.post("/api/v1/auth/login", json={
"username": "owner1",
"password": "Pass1234",
})
assert resp.status_code == 200
data = resp.json()
assert "access_token" in data
assert data["token_type"] == "bearer"
@pytest.mark.asyncio
async def test_login_wrong_password(self, client: AsyncClient):
resp = await client.post("/api/v1/auth/login", json={
"username": "owner1",
"password": "wrongpass",
})
assert resp.status_code == 401
@pytest.mark.asyncio
async def test_login_locked(self, client: AsyncClient):
# Fail 4 more times (already failed once above) → total 5 failures
for _ in range(4):
await client.post("/api/v1/auth/login", json={
"username": "owner1",
"password": "wrongpass",
})
resp = await client.post("/api/v1/auth/login", json={
"username": "owner1",
"password": "wrongpass",
})
assert resp.status_code == 423
class TestAuthGuard:
@pytest.mark.asyncio
async def test_require_auth_no_token(self, client: AsyncClient):
resp = await client.post("/api/v1/auth/refresh")
assert resp.status_code == 401
@pytest.mark.asyncio
async def test_viewer_cannot_manage_users(self, client: AsyncClient):
# Login as viewer1 → token
login = await client.post("/api/v1/auth/login", json={
"username": "viewer1",
"password": "Pass1234",
})
token = login.json()["access_token"]
headers = {"Authorization": f"Bearer {token}"}
resp = await client.get("/api/v1/users", headers=headers)
assert resp.status_code == 403