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