diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/audit.py b/backend/app/api/audit.py new file mode 100644 index 0000000..f406f93 --- /dev/null +++ b/backend/app/api/audit.py @@ -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() diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py new file mode 100644 index 0000000..d83426f --- /dev/null +++ b/backend/app/api/auth.py @@ -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"} diff --git a/backend/app/api/users.py b/backend/app/api/users.py new file mode 100644 index 0000000..b9b1ce9 --- /dev/null +++ b/backend/app/api/users.py @@ -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 diff --git a/backend/app/main.py b/backend/app/main.py index 4f8c78f..fa3945f 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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") diff --git a/backend/app/middleware/__init__.py b/backend/app/middleware/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/middleware/audit.py b/backend/app/middleware/audit.py new file mode 100644 index 0000000..d2b13e4 --- /dev/null +++ b/backend/app/middleware/audit.py @@ -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 diff --git a/backend/app/middleware/auth.py b/backend/app/middleware/auth.py new file mode 100644 index 0000000..60bccec --- /dev/null +++ b/backend/app/middleware/auth.py @@ -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 diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py new file mode 100644 index 0000000..8faec28 --- /dev/null +++ b/backend/app/schemas/auth.py @@ -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 diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py new file mode 100644 index 0000000..77b8d87 --- /dev/null +++ b/backend/app/schemas/user.py @@ -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 diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/services/auth_service.py b/backend/app/services/auth_service.py new file mode 100644 index 0000000..ed1667b --- /dev/null +++ b/backend/app/services/auth_service.py @@ -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 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..35eb04a --- /dev/null +++ b/backend/tests/conftest.py @@ -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() diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py new file mode 100644 index 0000000..143299e --- /dev/null +++ b/backend/tests/test_auth.py @@ -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