diff --git a/.env.example b/.env.example index 5ddd394..9298355 100644 --- a/.env.example +++ b/.env.example @@ -2,3 +2,18 @@ NEXT_PUBLIC_API_BASE_URL=http://127.0.0.1:8000 API_HOST=0.0.0.0 API_PORT=8000 API_CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000 +DATABASE_URL=postgresql+psycopg://fquiz:fquiz@db:5432/fquiz +JWT_SECRET_KEY=change-this-in-production +ACCESS_TOKEN_EXPIRE_MINUTES=15 +REFRESH_TOKEN_EXPIRE_DAYS=30 +REFRESH_COOKIE_SECURE=false +REFRESH_COOKIE_SAMESITE=lax +INITIAL_ADMIN_EMAIL=admin@example.com +INITIAL_ADMIN_USERNAME=admin +INITIAL_ADMIN_PASSWORD=change-me-strong-password +POSTGRES_DB=fquiz +POSTGRES_USER=fquiz +POSTGRES_PASSWORD=fquiz +POSTGRES_IMAGE=docker.m.daocloud.io/library/postgres:16-alpine +PYTHON_BASE_IMAGE=docker.m.daocloud.io/library/python:3.11-slim +NODE_BASE_IMAGE=docker.m.daocloud.io/library/node:22-alpine diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..a70da2b --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,210 @@ +name: fquiz 镜像构建与部署 + +on: + push: + branches: + - main + workflow_dispatch: + +concurrency: + group: fquiz-build-and-deploy + cancel-in-progress: true + +permissions: + contents: read + packages: write + +env: + REGISTRY: ghcr.io + +jobs: + build-and-push: + runs-on: ubuntu-latest + outputs: + api_image: ${{ steps.vars.outputs.api_image }} + web_image: ${{ steps.vars.outputs.web_image }} + image_tag: ${{ steps.vars.outputs.image_tag }} + steps: + - name: 拉取代码 + uses: actions/checkout@v4 + + - name: 生成镜像变量 + id: vars + shell: bash + run: | + OWNER_LC="${GITHUB_REPOSITORY_OWNER,,}" + echo "api_image=${{ env.REGISTRY }}/${OWNER_LC}/fquiz-api" >> "$GITHUB_OUTPUT" + echo "web_image=${{ env.REGISTRY }}/${OWNER_LC}/fquiz-web" >> "$GITHUB_OUTPUT" + echo "image_tag=${GITHUB_SHA}" >> "$GITHUB_OUTPUT" + + - name: 安装 Buildx + uses: docker/setup-buildx-action@v3 + + - name: 登录 GHCR + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: 构建并推送 API 镜像 + uses: docker/build-push-action@v6 + with: + context: ./api + file: ./api/Dockerfile + push: true + tags: | + ${{ steps.vars.outputs.api_image }}:${{ steps.vars.outputs.image_tag }} + ${{ steps.vars.outputs.api_image }}:latest + cache-from: type=gha,scope=fquiz-api + cache-to: type=gha,mode=max,scope=fquiz-api + + - name: 构建并推送 Web 镜像 + uses: docker/build-push-action@v6 + with: + context: ./web + file: ./web/Dockerfile + push: true + build-args: | + NEXT_PUBLIC_API_BASE_URL=${{ vars.NEXT_PUBLIC_API_BASE_URL || 'http://127.0.0.1:8000' }} + tags: | + ${{ steps.vars.outputs.web_image }}:${{ steps.vars.outputs.image_tag }} + ${{ steps.vars.outputs.web_image }}:latest + cache-from: type=gha,scope=fquiz-web + cache-to: type=gha,mode=max,scope=fquiz-web + + deploy: + runs-on: ubuntu-latest + needs: build-and-push + if: github.ref == 'refs/heads/main' + steps: + - name: 通过 SSH 拉取并更新容器 + uses: appleboy/ssh-action@v1.2.0 + env: + DEPLOY_PATH: ${{ secrets.DEPLOY_PATH }} + API_IMAGE: ${{ needs.build-and-push.outputs.api_image }} + WEB_IMAGE: ${{ needs.build-and-push.outputs.web_image }} + IMAGE_TAG: ${{ needs.build-and-push.outputs.image_tag }} + NEXT_PUBLIC_API_BASE_URL: ${{ vars.NEXT_PUBLIC_API_BASE_URL || 'http://127.0.0.1:8000' }} + GHCR_USERNAME: ${{ github.actor }} + GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + host: ${{ secrets.SERVER_HOST }} + username: ${{ secrets.SERVER_USER }} + port: 22 + key: ${{ secrets.SERVER_SSH_KEY }} + password: ${{ secrets.SERVER_PASSWORD }} + script_stop: true + envs: DEPLOY_PATH,API_IMAGE,WEB_IMAGE,IMAGE_TAG,NEXT_PUBLIC_API_BASE_URL,GHCR_USERNAME,GHCR_TOKEN + script: | + set -euo pipefail + + DEPLOY_DIR="${DEPLOY_PATH:-/opt/fquiz}" + mkdir -p "${DEPLOY_DIR}" + cd "${DEPLOY_DIR}" + + cat > docker-compose.prod.yml <<'YAML' + services: + db: + image: postgres:16-alpine + container_name: fquiz-db + environment: + POSTGRES_DB: ${POSTGRES_DB:-fquiz} + POSTGRES_USER: ${POSTGRES_USER:-fquiz} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-fquiz} + ports: + - "5432:5432" + volumes: + - fquiz_db_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-fquiz} -d ${POSTGRES_DB:-fquiz}"] + interval: 10s + timeout: 5s + retries: 8 + start_period: 10s + restart: unless-stopped + + api: + image: ${API_IMAGE} + container_name: fquiz-api + depends_on: + db: + condition: service_healthy + environment: + API_HOST: ${API_HOST:-0.0.0.0} + API_PORT: ${API_PORT:-8000} + API_CORS_ORIGINS: ${API_CORS_ORIGINS:-http://localhost:3000,http://127.0.0.1:3000} + DATABASE_URL: ${DATABASE_URL:-postgresql+psycopg://fquiz:fquiz@db:5432/fquiz} + JWT_SECRET_KEY: ${JWT_SECRET_KEY:-change-this-in-production} + ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-15} + REFRESH_TOKEN_EXPIRE_DAYS: ${REFRESH_TOKEN_EXPIRE_DAYS:-30} + REFRESH_COOKIE_SECURE: ${REFRESH_COOKIE_SECURE:-false} + REFRESH_COOKIE_SAMESITE: ${REFRESH_COOKIE_SAMESITE:-lax} + INITIAL_ADMIN_EMAIL: ${INITIAL_ADMIN_EMAIL:-admin@example.com} + INITIAL_ADMIN_USERNAME: ${INITIAL_ADMIN_USERNAME:-admin} + INITIAL_ADMIN_PASSWORD: ${INITIAL_ADMIN_PASSWORD:-change-me-strong-password} + ports: + - "${API_PORT:-8000}:8000" + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=2).read()"] + interval: 10s + timeout: 3s + retries: 5 + start_period: 10s + restart: unless-stopped + + web: + image: ${WEB_IMAGE} + container_name: fquiz-web + depends_on: + api: + condition: service_healthy + environment: + NEXT_PUBLIC_API_BASE_URL: ${NEXT_PUBLIC_API_BASE_URL:-http://localhost:8000} + NODE_ENV: production + ports: + - "3000:3000" + restart: unless-stopped + + volumes: + fquiz_db_data: + YAML + + if [ ! -f .env ]; then + cat > .env <<'ENV' + NEXT_PUBLIC_API_BASE_URL=http://127.0.0.1:8000 + API_HOST=0.0.0.0 + API_PORT=8000 + API_CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000 + DATABASE_URL=postgresql+psycopg://fquiz:fquiz@db:5432/fquiz + JWT_SECRET_KEY=change-this-in-production + ACCESS_TOKEN_EXPIRE_MINUTES=15 + REFRESH_TOKEN_EXPIRE_DAYS=30 + REFRESH_COOKIE_SECURE=false + REFRESH_COOKIE_SAMESITE=lax + INITIAL_ADMIN_EMAIL=admin@example.com + INITIAL_ADMIN_USERNAME=admin + INITIAL_ADMIN_PASSWORD=change-me-strong-password + POSTGRES_DB=fquiz + POSTGRES_USER=fquiz + POSTGRES_PASSWORD=fquiz + ENV + echo "[warn] .env 不存在,已写入默认模板,请尽快改成生产配置。" + fi + + cat > .images.env </dev/null 2>&1; then + COMPOSE_CMD="docker-compose" + fi + + ${COMPOSE_CMD} --env-file .env --env-file .images.env -f docker-compose.prod.yml pull + ${COMPOSE_CMD} --env-file .env --env-file .images.env -f docker-compose.prod.yml up -d --remove-orphans + ${COMPOSE_CMD} --env-file .env --env-file .images.env -f docker-compose.prod.yml ps diff --git a/.gitignore b/.gitignore index 243802c..22ceec6 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ venv/ __pycache__/ *.py[cod] *.egg-info/ +.db .pytest_cache/ .mypy_cache/ diff --git a/README.md b/README.md index 21c3cff..7ef0096 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,13 @@ # fquiz -基于 Next.js + Python(FastAPI)的全栈 Monorepo 初始化工程。 +基于 Next.js + Python(FastAPI)的全栈 Monorepo,内置用户管理与登录认证。 ## 目录结构 ```text . -├── web/ # Next.js 16 + TypeScript + App Router -├── api/ # FastAPI 服务 +├── web/ # Next.js 16 + TypeScript + App Router(登录态与用户管理页) +├── api/ # FastAPI 服务(JWT + Refresh Session + RBAC) ├── scripts/dev.sh # 前后端一键并行启动脚本 ├── .env.example # 根环境变量模板 └── package.json # Monorepo 根脚本 @@ -16,8 +16,9 @@ ## 技术栈 - 前端:Next.js 16、React 19、TypeScript -- 后端:FastAPI、Uvicorn、Pydantic Settings -- 协议:REST(默认 `/health`、`/api/v1/ping`) +- 后端:FastAPI、SQLAlchemy、PostgreSQL/SQLite、Pydantic Settings +- 认证:JWT Access Token(15m)+ Refresh Session(HttpOnly Cookie, 轮换) +- 权限:RBAC(roles / permissions / user_roles / role_permissions) ## 环境要求 @@ -71,6 +72,22 @@ npm run build:web npm run lint:web ``` +## 认证接口 + +- `POST /api/v1/auth/register` +- `POST /api/v1/auth/login` +- `POST /api/v1/auth/refresh` +- `POST /api/v1/auth/logout` +- `GET /api/v1/auth/me` +- `GET /api/v1/users`(需要 `user.manage`) +- `GET /api/v1/users/{id}`(本人或 `user.manage`) +- `PATCH /api/v1/users/{id}`(需要 `user.manage`) +- `POST /api/v1/users/{id}/roles`(需要 `user.manage`) + +初始化管理员(可选): +- 在 `.env` 设置 `INITIAL_ADMIN_EMAIL`、`INITIAL_ADMIN_USERNAME`、`INITIAL_ADMIN_PASSWORD` +- API 启动时会自动创建并赋予 `admin` 角色 + ## Docker Compose 部署 1. 准备环境变量: @@ -96,6 +113,7 @@ npm run lint:web - 前端:`http://localhost:3000` - 后端:`http://localhost:8000/health` +- PostgreSQL:`localhost:5432` 5. 停止并清理: @@ -105,3 +123,4 @@ npm run lint:web 说明: - `NEXT_PUBLIC_API_BASE_URL` 在 Next.js 中是构建期注入;如果修改该值,需要重新执行 `docker compose up --build`。 +- 若使用 Docker Compose,默认 `DATABASE_URL` 指向容器内 `db` 服务(PostgreSQL)。 diff --git a/api/.env.example b/api/.env.example index 2952319..eda67be 100644 --- a/api/.env.example +++ b/api/.env.example @@ -1,3 +1,12 @@ API_HOST=0.0.0.0 API_PORT=8000 API_CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000 +DATABASE_URL=sqlite:///./fquiz.db +JWT_SECRET_KEY=change-this-in-production +ACCESS_TOKEN_EXPIRE_MINUTES=15 +REFRESH_TOKEN_EXPIRE_DAYS=30 +REFRESH_COOKIE_SECURE=false +REFRESH_COOKIE_SAMESITE=lax +INITIAL_ADMIN_EMAIL=admin@example.com +INITIAL_ADMIN_USERNAME=admin +INITIAL_ADMIN_PASSWORD=change-me-strong-password diff --git a/api/Dockerfile b/api/Dockerfile index b477d7c..da03919 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -1,4 +1,5 @@ -FROM python:3.11-slim +ARG PYTHON_BASE_IMAGE=docker.m.daocloud.io/library/python:3.11-slim +FROM ${PYTHON_BASE_IMAGE} WORKDIR /app diff --git a/api/README.md b/api/README.md index 6aa5a87..301ecba 100644 --- a/api/README.md +++ b/api/README.md @@ -1,6 +1,13 @@ # API Service -Python FastAPI 后端服务。 +FastAPI 后端服务,包含用户认证和 RBAC 权限控制。 + +## 核心能力 + +- JWT Access Token(默认 15 分钟) +- Refresh Session(HttpOnly Cookie,默认 30 天,刷新轮换) +- RBAC(用户-角色-权限) +- 用户管理接口(需 `user.manage`) ## 本地开发 @@ -11,7 +18,13 @@ python -m pip install -r api/requirements.txt python -m uvicorn api.app.main:app --reload --host 0.0.0.0 --port 8000 ``` -## 默认接口 +## 主要接口 - `GET /health` - `GET /api/v1/ping` +- `POST /api/v1/auth/register` +- `POST /api/v1/auth/login` +- `POST /api/v1/auth/refresh` +- `POST /api/v1/auth/logout` +- `GET /api/v1/auth/me` +- `GET /api/v1/users` diff --git a/api/app/api/__init__.py b/api/app/api/__init__.py new file mode 100644 index 0000000..8e61617 --- /dev/null +++ b/api/app/api/__init__.py @@ -0,0 +1 @@ +"""API router package.""" diff --git a/api/app/api/router.py b/api/app/api/router.py new file mode 100644 index 0000000..e15652b --- /dev/null +++ b/api/app/api/router.py @@ -0,0 +1,13 @@ +from fastapi import APIRouter + +from .v1.auth import router as auth_router +from .v1.users import router as users_router + +api_router = APIRouter(prefix="/api/v1") +api_router.include_router(auth_router) +api_router.include_router(users_router) + + +@api_router.get("/ping") +def ping() -> dict[str, str]: + return {"message": "pong"} diff --git a/api/app/api/v1/__init__.py b/api/app/api/v1/__init__.py new file mode 100644 index 0000000..be2f397 --- /dev/null +++ b/api/app/api/v1/__init__.py @@ -0,0 +1 @@ +"""Versioned API routes.""" diff --git a/api/app/api/v1/auth.py b/api/app/api/v1/auth.py new file mode 100644 index 0000000..dd705fe --- /dev/null +++ b/api/app/api/v1/auth.py @@ -0,0 +1,128 @@ +from fastapi import APIRouter, Depends, Request, Response +from sqlalchemy.orm import Session + +from ...core.config import get_settings +from ...core.database import get_db +from ...core.dependencies import CurrentUser, get_current_user +from ...schemas.auth import AuthTokenResponse, LoginRequest, MessageResponse, RegisterRequest +from ...schemas.user import UserPublic +from ...services.auth_service import ( + AuthResult, + login_user, + logout_user_session, + refresh_user_session, + register_user, +) +from ...services.user_service import serialize_user + +settings = get_settings() +router = APIRouter(prefix="/auth", tags=["auth"]) + + +def _client_ip(request: Request) -> str | None: + forwarded = request.headers.get("x-forwarded-for") + if forwarded: + return forwarded.split(",")[0].strip() + if request.client: + return request.client.host + return None + + +def _set_refresh_cookie(response: Response, token: str) -> None: + response.set_cookie( + key=settings.refresh_cookie_name, + value=token, + httponly=True, + secure=settings.refresh_cookie_secure, + samesite=settings.refresh_cookie_samesite, + max_age=settings.refresh_token_expire_days * 24 * 60 * 60, + path="/api/v1/auth", + ) + + +def _clear_refresh_cookie(response: Response) -> None: + response.delete_cookie( + key=settings.refresh_cookie_name, + path="/api/v1/auth", + httponly=True, + secure=settings.refresh_cookie_secure, + samesite=settings.refresh_cookie_samesite, + ) + + +def _to_auth_response(result: AuthResult) -> AuthTokenResponse: + return AuthTokenResponse( + access_token=result.access_token, + expires_in=result.expires_in, + user=serialize_user(result.user), + ) + + +@router.post("/register", response_model=AuthTokenResponse) +def register( + payload: RegisterRequest, + request: Request, + response: Response, + db: Session = Depends(get_db), +) -> AuthTokenResponse: + result = register_user( + db, + payload, + user_agent=request.headers.get("user-agent"), + ip_address=_client_ip(request), + ) + _set_refresh_cookie(response, result.refresh_token) + return _to_auth_response(result) + + +@router.post("/login", response_model=AuthTokenResponse) +def login( + payload: LoginRequest, + request: Request, + response: Response, + db: Session = Depends(get_db), +) -> AuthTokenResponse: + result = login_user( + db, + payload, + user_agent=request.headers.get("user-agent"), + ip_address=_client_ip(request), + ) + _set_refresh_cookie(response, result.refresh_token) + return _to_auth_response(result) + + +@router.post("/refresh", response_model=AuthTokenResponse) +def refresh( + request: Request, + response: Response, + db: Session = Depends(get_db), +) -> AuthTokenResponse: + result = refresh_user_session( + db, + request.cookies.get(settings.refresh_cookie_name), + user_agent=request.headers.get("user-agent"), + ip_address=_client_ip(request), + ) + _set_refresh_cookie(response, result.refresh_token) + return _to_auth_response(result) + + +@router.post("/logout", response_model=MessageResponse) +def logout( + request: Request, + response: Response, + db: Session = Depends(get_db), +) -> MessageResponse: + logout_user_session( + db, + request.cookies.get(settings.refresh_cookie_name), + user_id=None, + ) + _clear_refresh_cookie(response) + return MessageResponse(message="Logged out") + + +@router.get("/me", response_model=UserPublic) +def me(current_user: CurrentUser = Depends(get_current_user)) -> UserPublic: + return serialize_user(current_user.user) diff --git a/api/app/api/v1/users.py b/api/app/api/v1/users.py new file mode 100644 index 0000000..0a36f72 --- /dev/null +++ b/api/app/api/v1/users.py @@ -0,0 +1,76 @@ +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.orm import Session + +from ...core.database import get_db +from ...core.dependencies import CurrentUser, get_current_user, require_permission +from ...schemas.user import UserListResponse, UserPublic, UserRoleUpdateRequest, UserUpdateRequest +from ...services.user_service import ( + get_user_by_id, + list_users, + serialize_user, + set_user_roles, + update_user, +) + +router = APIRouter(prefix="/users", tags=["users"]) + + +@router.get("", response_model=UserListResponse) +def list_all_users( + limit: int = Query(default=50, ge=1, le=200), + offset: int = Query(default=0, ge=0), + _: CurrentUser = Depends(require_permission("user.manage")), + db: Session = Depends(get_db), +) -> UserListResponse: + return list_users(db, limit=limit, offset=offset) + + +@router.get("/{user_id}", response_model=UserPublic) +def get_user_detail( + user_id: str, + current_user: CurrentUser = Depends(get_current_user), + db: Session = Depends(get_db), +) -> UserPublic: + can_manage = "admin" in current_user.role_codes or "user.manage" in current_user.permission_codes + if current_user.user.id != user_id and not can_manage: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Insufficient permissions", + ) + + user = get_user_by_id(db, user_id) + if not user: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") + return serialize_user(user) + + +@router.patch("/{user_id}", response_model=UserPublic) +def update_user_profile( + user_id: str, + payload: UserUpdateRequest, + _: CurrentUser = Depends(require_permission("user.manage")), + db: Session = Depends(get_db), +) -> UserPublic: + updated = update_user(db, user_id, payload) + if not updated: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found or username exists", + ) + return updated + + +@router.post("/{user_id}/roles", response_model=UserPublic) +def assign_roles( + user_id: str, + payload: UserRoleUpdateRequest, + _: CurrentUser = Depends(require_permission("user.manage")), + db: Session = Depends(get_db), +) -> UserPublic: + updated = set_user_roles(db, user_id, payload) + if not updated: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found or invalid roles", + ) + return updated diff --git a/api/app/config.py b/api/app/config.py index 2014d07..0a726b6 100644 --- a/api/app/config.py +++ b/api/app/config.py @@ -1,30 +1,3 @@ -from functools import lru_cache +from .core.config import Settings, get_settings -from pydantic_settings import BaseSettings, SettingsConfigDict - - -class Settings(BaseSettings): - api_name: str = "fquiz-api" - api_version: str = "0.1.0" - api_host: str = "0.0.0.0" - api_port: int = 8000 - api_cors_origins: str = "http://localhost:3000,http://127.0.0.1:3000" - - model_config = SettingsConfigDict( - env_file=".env", - env_file_encoding="utf-8", - case_sensitive=False, - ) - - @property - def cors_origins(self) -> list[str]: - return [ - origin.strip() - for origin in self.api_cors_origins.split(",") - if origin.strip() - ] - - -@lru_cache -def get_settings() -> Settings: - return Settings() +__all__ = ["Settings", "get_settings"] diff --git a/api/app/core/__init__.py b/api/app/core/__init__.py new file mode 100644 index 0000000..893aa89 --- /dev/null +++ b/api/app/core/__init__.py @@ -0,0 +1 @@ +"""Core infrastructure for config, database, and security.""" diff --git a/api/app/core/config.py b/api/app/core/config.py new file mode 100644 index 0000000..01ad07e --- /dev/null +++ b/api/app/core/config.py @@ -0,0 +1,56 @@ +from functools import lru_cache +from typing import Literal + +from pydantic import field_validator +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + api_name: str = "fquiz-api" + api_version: str = "0.1.0" + api_host: str = "0.0.0.0" + api_port: int = 8000 + api_cors_origins: str = "http://localhost:3000,http://127.0.0.1:3000" + + database_url: str = "sqlite:///./fquiz.db" + + jwt_secret_key: str = "change-this-in-production" + jwt_algorithm: str = "HS256" + access_token_expire_minutes: int = 15 + refresh_token_expire_days: int = 30 + + refresh_cookie_name: str = "refresh_token" + refresh_cookie_secure: bool = False + refresh_cookie_samesite: Literal["lax", "strict", "none"] = "lax" + + initial_admin_email: str | None = None + initial_admin_username: str = "admin" + initial_admin_password: str | None = None + + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + case_sensitive=False, + extra="ignore", + ) + + @field_validator("access_token_expire_minutes", "refresh_token_expire_days") + @classmethod + def validate_positive_numbers(cls, value: int) -> int: + if value <= 0: + msg = "Value must be greater than 0" + raise ValueError(msg) + return value + + @property + def cors_origins(self) -> list[str]: + return [ + origin.strip() + for origin in self.api_cors_origins.split(",") + if origin.strip() + ] + + +@lru_cache +def get_settings() -> Settings: + return Settings() diff --git a/api/app/core/database.py b/api/app/core/database.py new file mode 100644 index 0000000..8167ea1 --- /dev/null +++ b/api/app/core/database.py @@ -0,0 +1,47 @@ +from collections.abc import Generator + +from sqlalchemy import create_engine +from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker + +from .config import get_settings + +settings = get_settings() + +connect_args: dict[str, bool] = {} +if settings.database_url.startswith("sqlite"): + connect_args["check_same_thread"] = False + +engine = create_engine( + settings.database_url, + pool_pre_ping=True, + connect_args=connect_args, +) + +SessionLocal = sessionmaker( + bind=engine, + autocommit=False, + autoflush=False, + expire_on_commit=False, +) + + +class Base(DeclarativeBase): + pass + + +def get_db() -> Generator[Session, None, None]: + db = SessionLocal() + try: + yield db + finally: + db.close() + + +def init_db() -> None: + # Import models so metadata includes every table before create_all. + from ..models import audit_log, auth_session, rbac, user # noqa: F401 + from ..services.seed_service import seed_defaults + + Base.metadata.create_all(bind=engine) + with SessionLocal() as db: + seed_defaults(db) diff --git a/api/app/core/dependencies.py b/api/app/core/dependencies.py new file mode 100644 index 0000000..4f4a463 --- /dev/null +++ b/api/app/core/dependencies.py @@ -0,0 +1,73 @@ +from dataclasses import dataclass + +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer +from sqlalchemy import select +from sqlalchemy.orm import Session, joinedload + +from ..models.rbac import Role +from ..models.user import User +from .database import get_db +from .security import decode_access_token + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login") + + +@dataclass +class CurrentUser: + user: User + role_codes: set[str] + permission_codes: set[str] + + +def _load_user_with_rbac(db: Session, user_id: str) -> User | None: + stmt = ( + select(User) + .options(joinedload(User.roles).joinedload(Role.permissions)) + .where(User.id == user_id) + ) + return db.execute(stmt).unique().scalar_one_or_none() + + +def _get_user_permissions(user: User) -> set[str]: + return {permission.code for role in user.roles for permission in role.permissions} + + +def get_current_user( + db: Session = Depends(get_db), + token: str = Depends(oauth2_scheme), +) -> CurrentUser: + payload = decode_access_token(token) + user_id = str(payload["sub"]) + user = _load_user_with_rbac(db, user_id) + + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User not found", + ) + if user.status != "active": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="User is disabled", + ) + + return CurrentUser( + user=user, + role_codes={role.code for role in user.roles}, + permission_codes=_get_user_permissions(user), + ) + + +def require_permission(permission_code: str): + def dependency(current_user: CurrentUser = Depends(get_current_user)) -> CurrentUser: + if "admin" in current_user.role_codes: + return current_user + if permission_code not in current_user.permission_codes: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Missing permission: {permission_code}", + ) + return current_user + + return dependency diff --git a/api/app/core/security.py b/api/app/core/security.py new file mode 100644 index 0000000..3e45c44 --- /dev/null +++ b/api/app/core/security.py @@ -0,0 +1,81 @@ +import hashlib +import secrets +from datetime import datetime, timedelta, timezone +from typing import Any + +import jwt +from argon2 import PasswordHasher +from argon2.exceptions import InvalidHash, VerifyMismatchError +from fastapi import HTTPException, status + +from .config import get_settings + +password_hasher = PasswordHasher() + + +def hash_password(password: str) -> str: + return password_hasher.hash(password) + + +def verify_password(password: str, password_hash: str) -> bool: + try: + return password_hasher.verify(password_hash, password) + except (VerifyMismatchError, InvalidHash): + return False + + +def create_access_token( + *, + user_id: str, + role_codes: list[str], + permission_codes: list[str], + expires_minutes: int | None = None, +) -> tuple[str, int]: + settings = get_settings() + now = datetime.now(timezone.utc) + minutes = expires_minutes or settings.access_token_expire_minutes + expires_at = now + timedelta(minutes=minutes) + + payload = { + "sub": user_id, + "roles": role_codes, + "permissions": permission_codes, + "iat": int(now.timestamp()), + "exp": int(expires_at.timestamp()), + } + token = jwt.encode( + payload, + settings.jwt_secret_key, + algorithm=settings.jwt_algorithm, + ) + return token, int((expires_at - now).total_seconds()) + + +def decode_access_token(token: str) -> dict[str, Any]: + settings = get_settings() + try: + payload = jwt.decode( + token, + settings.jwt_secret_key, + algorithms=[settings.jwt_algorithm], + ) + except jwt.PyJWTError as exc: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or expired access token", + ) from exc + + if not payload.get("sub"): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid access token payload", + ) + return payload + + +def create_refresh_token() -> str: + return secrets.token_urlsafe(48) + + +def hash_token(token: str) -> str: + return hashlib.sha256(token.encode("utf-8")).hexdigest() diff --git a/api/app/main.py b/api/app/main.py index 21b2551..d58d499 100644 --- a/api/app/main.py +++ b/api/app/main.py @@ -1,13 +1,25 @@ +from contextlib import asynccontextmanager + from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from .config import get_settings +from .api.router import api_router +from .core.config import get_settings +from .core.database import init_db settings = get_settings() + +@asynccontextmanager +async def lifespan(_: FastAPI): + init_db() + yield + + app = FastAPI( title=settings.api_name, version=settings.api_version, + lifespan=lifespan, ) app.add_middleware( @@ -27,10 +39,4 @@ def health() -> dict[str, str]: "version": settings.api_version, } - -@app.get("/api/v1/ping") -def ping() -> dict[str, str]: - return { - "message": "pong", - "service": settings.api_name, - } +app.include_router(api_router) diff --git a/api/app/models/__init__.py b/api/app/models/__init__.py new file mode 100644 index 0000000..7bb09cc --- /dev/null +++ b/api/app/models/__init__.py @@ -0,0 +1 @@ +"""Database models.""" diff --git a/api/app/models/audit_log.py b/api/app/models/audit_log.py new file mode 100644 index 0000000..e9b80c4 --- /dev/null +++ b/api/app/models/audit_log.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from datetime import datetime +from typing import TYPE_CHECKING + +from sqlalchemy import DateTime, ForeignKey, String, Text +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from ..core.database import Base +from .base import utcnow + +if TYPE_CHECKING: + from .user import User + + +class AuditLog(Base): + __tablename__ = "audit_logs" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + user_id: Mapped[str | None] = mapped_column( + String(36), + ForeignKey("users.id", ondelete="SET NULL"), + index=True, + ) + action: Mapped[str] = mapped_column(String(128), index=True) + detail: Mapped[str | None] = mapped_column(Text()) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) + + user: Mapped[User | None] = relationship("User", back_populates="audit_logs") diff --git a/api/app/models/auth_session.py b/api/app/models/auth_session.py new file mode 100644 index 0000000..10a5d8d --- /dev/null +++ b/api/app/models/auth_session.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from datetime import datetime +from typing import TYPE_CHECKING +from uuid import uuid4 + +from sqlalchemy import DateTime, ForeignKey, String +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from ..core.database import Base +from .base import utcnow + +if TYPE_CHECKING: + from .user import User + + +class AuthSession(Base): + __tablename__ = "auth_sessions" + + id: Mapped[str] = mapped_column( + String(36), + primary_key=True, + default=lambda: str(uuid4()), + ) + user_id: Mapped[str] = mapped_column( + String(36), + ForeignKey("users.id", ondelete="CASCADE"), + index=True, + ) + refresh_token_hash: Mapped[str] = mapped_column(String(128), unique=True, index=True) + user_agent: Mapped[str | None] = mapped_column(String(512)) + ip_address: Mapped[str | None] = mapped_column(String(64)) + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True) + revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) + + user: Mapped[User] = relationship("User", back_populates="sessions") diff --git a/api/app/models/base.py b/api/app/models/base.py new file mode 100644 index 0000000..2313d48 --- /dev/null +++ b/api/app/models/base.py @@ -0,0 +1,5 @@ +from datetime import datetime, timezone + + +def utcnow() -> datetime: + return datetime.now(timezone.utc) diff --git a/api/app/models/rbac.py b/api/app/models/rbac.py new file mode 100644 index 0000000..9874380 --- /dev/null +++ b/api/app/models/rbac.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from sqlalchemy import ForeignKey, String +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from ..core.database import Base + +if TYPE_CHECKING: + from .user import User + + +class Role(Base): + __tablename__ = "roles" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + code: Mapped[str] = mapped_column(String(64), unique=True, index=True) + name: Mapped[str] = mapped_column(String(128)) + + users: Mapped[list[User]] = relationship( + "User", + secondary="user_roles", + back_populates="roles", + lazy="selectin", + ) + permissions: Mapped[list[Permission]] = relationship( + "Permission", + secondary="role_permissions", + back_populates="roles", + lazy="selectin", + ) + + +class Permission(Base): + __tablename__ = "permissions" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + code: Mapped[str] = mapped_column(String(64), unique=True, index=True) + name: Mapped[str] = mapped_column(String(128)) + + roles: Mapped[list[Role]] = relationship( + "Role", + secondary="role_permissions", + back_populates="permissions", + lazy="selectin", + ) + + +class UserRole(Base): + __tablename__ = "user_roles" + + user_id: Mapped[str] = mapped_column( + String(36), + ForeignKey("users.id", ondelete="CASCADE"), + primary_key=True, + ) + role_id: Mapped[int] = mapped_column( + ForeignKey("roles.id", ondelete="CASCADE"), + primary_key=True, + ) + + +class RolePermission(Base): + __tablename__ = "role_permissions" + + role_id: Mapped[int] = mapped_column( + ForeignKey("roles.id", ondelete="CASCADE"), + primary_key=True, + ) + permission_id: Mapped[int] = mapped_column( + ForeignKey("permissions.id", ondelete="CASCADE"), + primary_key=True, + ) diff --git a/api/app/models/user.py b/api/app/models/user.py new file mode 100644 index 0000000..5e1f36d --- /dev/null +++ b/api/app/models/user.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +from datetime import datetime +from typing import TYPE_CHECKING +from uuid import uuid4 + +from sqlalchemy import DateTime, String +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from ..core.database import Base +from .base import utcnow + +if TYPE_CHECKING: + from .auth_session import AuthSession + from .audit_log import AuditLog + from .rbac import Role + + +class User(Base): + __tablename__ = "users" + + id: Mapped[str] = mapped_column( + String(36), + primary_key=True, + default=lambda: str(uuid4()), + ) + email: Mapped[str] = mapped_column(String(255), unique=True, index=True) + username: Mapped[str] = mapped_column(String(64), unique=True, index=True) + password_hash: Mapped[str] = mapped_column(String(255)) + status: Mapped[str] = mapped_column(String(32), default="active", index=True) + last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=utcnow, + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=utcnow, + onupdate=utcnow, + ) + + roles: Mapped[list[Role]] = relationship( + "Role", + secondary="user_roles", + back_populates="users", + lazy="selectin", + ) + sessions: Mapped[list[AuthSession]] = relationship( + "AuthSession", + back_populates="user", + lazy="selectin", + ) + audit_logs: Mapped[list[AuditLog]] = relationship( + "AuditLog", + back_populates="user", + lazy="selectin", + ) diff --git a/api/app/schemas/__init__.py b/api/app/schemas/__init__.py new file mode 100644 index 0000000..f391682 --- /dev/null +++ b/api/app/schemas/__init__.py @@ -0,0 +1 @@ +"""Pydantic schemas.""" diff --git a/api/app/schemas/auth.py b/api/app/schemas/auth.py new file mode 100644 index 0000000..aa7cebe --- /dev/null +++ b/api/app/schemas/auth.py @@ -0,0 +1,25 @@ +from pydantic import BaseModel, EmailStr, Field + +from .user import UserPublic + + +class RegisterRequest(BaseModel): + email: EmailStr + username: str = Field(min_length=3, max_length=64) + password: str = Field(min_length=8, max_length=128) + + +class LoginRequest(BaseModel): + email: EmailStr + password: str = Field(min_length=8, max_length=128) + + +class AuthTokenResponse(BaseModel): + access_token: str + token_type: str = "bearer" + expires_in: int + user: UserPublic + + +class MessageResponse(BaseModel): + message: str diff --git a/api/app/schemas/user.py b/api/app/schemas/user.py new file mode 100644 index 0000000..3315980 --- /dev/null +++ b/api/app/schemas/user.py @@ -0,0 +1,29 @@ +from datetime import datetime +from typing import Literal + +from pydantic import BaseModel, EmailStr, Field + + +class UserPublic(BaseModel): + id: str + email: EmailStr + username: str + status: str + role_codes: list[str] + permission_codes: list[str] + created_at: datetime + last_login_at: datetime | None = None + + +class UserListResponse(BaseModel): + items: list[UserPublic] + total: int + + +class UserUpdateRequest(BaseModel): + username: str | None = Field(default=None, min_length=3, max_length=64) + status: Literal["active", "disabled"] | None = None + + +class UserRoleUpdateRequest(BaseModel): + role_codes: list[str] = Field(min_length=1) diff --git a/api/app/services/__init__.py b/api/app/services/__init__.py new file mode 100644 index 0000000..b021e21 --- /dev/null +++ b/api/app/services/__init__.py @@ -0,0 +1 @@ +"""Business services.""" diff --git a/api/app/services/auth_service.py b/api/app/services/auth_service.py new file mode 100644 index 0000000..fc33598 --- /dev/null +++ b/api/app/services/auth_service.py @@ -0,0 +1,238 @@ +from dataclasses import dataclass +from datetime import timedelta + +from fastapi import HTTPException, status +from sqlalchemy import and_, or_, select +from sqlalchemy.orm import Session + +from ..models.audit_log import AuditLog +from ..models.auth_session import AuthSession +from ..models.base import utcnow +from ..models.rbac import Role +from ..models.user import User +from ..schemas.auth import LoginRequest, RegisterRequest +from .user_service import get_user_by_email +from ..core.config import get_settings +from ..core.security import ( + create_access_token, + create_refresh_token, + hash_password, + hash_token, + verify_password, +) + +settings = get_settings() + + +@dataclass +class AuthResult: + access_token: str + expires_in: int + refresh_token: str + user: User + + +def register_user( + db: Session, + payload: RegisterRequest, + *, + user_agent: str | None, + ip_address: str | None, +) -> AuthResult: + email = payload.email.lower() + + duplicate = db.scalar( + select(User.id).where(or_(User.email == email, User.username == payload.username)) + ) + if duplicate: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Email or username already exists", + ) + + role = db.scalar(select(Role).where(Role.code == "user")) + if not role: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Default role not initialized", + ) + + user = User( + email=email, + username=payload.username, + password_hash=hash_password(payload.password), + status="active", + ) + user.roles.append(role) + db.add(user) + db.commit() + + db.add(AuditLog(user_id=user.id, action="auth.register", detail="User registered")) + db.commit() + + return issue_auth_result_for_user( + db, + user_id=user.id, + user_agent=user_agent, + ip_address=ip_address, + action="auth.login_after_register", + ) + + +def login_user( + db: Session, + payload: LoginRequest, + *, + user_agent: str | None, + ip_address: str | None, +) -> AuthResult: + user = get_user_by_email(db, payload.email.lower()) + if not user or not verify_password(payload.password, user.password_hash): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid email or password", + ) + + if user.status != "active": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="User is disabled", + ) + + return issue_auth_result_for_user( + db, + user_id=user.id, + user_agent=user_agent, + ip_address=ip_address, + action="auth.login", + ) + + +def refresh_user_session( + db: Session, + refresh_token: str | None, + *, + user_agent: str | None, + ip_address: str | None, +) -> AuthResult: + if not refresh_token: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Missing refresh token", + ) + + now = utcnow() + token_hash = hash_token(refresh_token) + session = db.scalar( + select(AuthSession).where( + and_( + AuthSession.refresh_token_hash == token_hash, + AuthSession.revoked_at.is_(None), + AuthSession.expires_at > now, + ) + ) + ) + if not session: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid refresh session", + ) + + session.revoked_at = now + db.add(AuditLog(user_id=session.user_id, action="auth.refresh", detail="Session rotated")) + db.commit() + + return issue_auth_result_for_user( + db, + user_id=session.user_id, + user_agent=user_agent, + ip_address=ip_address, + action="auth.refresh_issued", + ) + + +def logout_user_session(db: Session, refresh_token: str | None, *, user_id: str | None) -> None: + if not refresh_token: + return + + token_hash = hash_token(refresh_token) + now = utcnow() + session = db.scalar( + select(AuthSession).where( + and_( + AuthSession.refresh_token_hash == token_hash, + AuthSession.revoked_at.is_(None), + ) + ) + ) + if not session: + return + + if user_id and session.user_id != user_id: + return + + session.revoked_at = now + db.add(AuditLog(user_id=session.user_id, action="auth.logout", detail="Session revoked")) + db.commit() + + +def issue_auth_result_for_user( + db: Session, + *, + user_id: str, + user_agent: str | None, + ip_address: str | None, + action: str, +) -> AuthResult: + user = get_user_by_id_with_rbac(db, user_id) + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found", + ) + if user.status != "active": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="User is disabled", + ) + + refresh_token = create_refresh_token() + refresh_expires_at = utcnow() + timedelta(days=settings.refresh_token_expire_days) + + db.add( + AuthSession( + user_id=user.id, + refresh_token_hash=hash_token(refresh_token), + user_agent=user_agent, + ip_address=ip_address, + expires_at=refresh_expires_at, + ) + ) + + user.last_login_at = utcnow() + db.add(AuditLog(user_id=user.id, action=action, detail="Access token issued")) + db.commit() + + user = get_user_by_id_with_rbac(db, user_id) + role_codes = sorted({role.code for role in user.roles}) + permission_codes = sorted( + {permission.code for role in user.roles for permission in role.permissions} + ) + access_token, expires_in = create_access_token( + user_id=user.id, + role_codes=role_codes, + permission_codes=permission_codes, + ) + + return AuthResult( + access_token=access_token, + expires_in=expires_in, + refresh_token=refresh_token, + user=user, + ) + + +def get_user_by_id_with_rbac(db: Session, user_id: str) -> User | None: + from .user_service import get_user_by_id + + return get_user_by_id(db, user_id) diff --git a/api/app/services/seed_service.py b/api/app/services/seed_service.py new file mode 100644 index 0000000..1d6968e --- /dev/null +++ b/api/app/services/seed_service.py @@ -0,0 +1,90 @@ +from sqlalchemy import select +from sqlalchemy.orm import Session + +from ..core.config import get_settings +from ..core.security import hash_password +from ..models.rbac import Permission, Role +from ..models.user import User + +settings = get_settings() + +DEFAULT_PERMISSIONS: dict[str, str] = { + "user.read": "Read user profile", + "user.write": "Update user profile", + "user.manage": "Manage all users and roles", +} + +DEFAULT_ROLES: dict[str, dict[str, object]] = { + "admin": { + "name": "Administrator", + "permissions": ["user.read", "user.write", "user.manage"], + }, + "user": { + "name": "User", + "permissions": ["user.read"], + }, +} + + +def seed_defaults(db: Session) -> None: + permissions = _seed_permissions(db) + _seed_roles(db, permissions) + _seed_initial_admin(db) + db.commit() + + +def _seed_permissions(db: Session) -> dict[str, Permission]: + permission_map: dict[str, Permission] = {} + for code, name in DEFAULT_PERMISSIONS.items(): + permission = db.scalar(select(Permission).where(Permission.code == code)) + if not permission: + permission = Permission(code=code, name=name) + db.add(permission) + permission_map[code] = permission + + db.flush() + # Refresh map with persisted entities. + for code in DEFAULT_PERMISSIONS: + permission = db.scalar(select(Permission).where(Permission.code == code)) + if not permission: + msg = f"Permission not found after seeding: {code}" + raise RuntimeError(msg) + permission_map[code] = permission + return permission_map + + +def _seed_roles(db: Session, permission_map: dict[str, Permission]) -> None: + for code, role_info in DEFAULT_ROLES.items(): + role = db.scalar(select(Role).where(Role.code == code)) + if not role: + role = Role(code=code, name=str(role_info["name"])) + db.add(role) + db.flush() + + role.permissions = [permission_map[p] for p in role_info["permissions"]] + db.flush() + + +def _seed_initial_admin(db: Session) -> None: + if not settings.initial_admin_email or not settings.initial_admin_password: + return + + admin_role = db.scalar(select(Role).where(Role.code == "admin")) + if not admin_role: + return + + admin_email = settings.initial_admin_email.lower() + user = db.scalar(select(User).where(User.email == admin_email)) + if not user: + user = User( + email=admin_email, + username=settings.initial_admin_username, + password_hash=hash_password(settings.initial_admin_password), + status="active", + ) + db.add(user) + db.flush() + + role_codes = {role.code for role in user.roles} + if "admin" not in role_codes: + user.roles.append(admin_role) diff --git a/api/app/services/user_service.py b/api/app/services/user_service.py new file mode 100644 index 0000000..c828c7c --- /dev/null +++ b/api/app/services/user_service.py @@ -0,0 +1,98 @@ +from sqlalchemy import func, select +from sqlalchemy.orm import Session, joinedload + +from ..models.rbac import Role +from ..models.user import User +from ..schemas.user import UserListResponse, UserPublic, UserRoleUpdateRequest, UserUpdateRequest + + +def _user_with_rbac_stmt(): + return select(User).options(joinedload(User.roles).joinedload(Role.permissions)) + + +def list_users(db: Session, *, limit: int, offset: int) -> UserListResponse: + total = db.scalar(select(func.count()).select_from(User)) or 0 + stmt = ( + _user_with_rbac_stmt() + .order_by(User.created_at.desc()) + .offset(offset) + .limit(limit) + ) + users = db.execute(stmt).unique().scalars().all() + return UserListResponse(items=[serialize_user(user) for user in users], total=total) + + +def get_user_by_id(db: Session, user_id: str) -> User | None: + stmt = _user_with_rbac_stmt().where(User.id == user_id) + return db.execute(stmt).unique().scalar_one_or_none() + + +def get_user_by_email(db: Session, email: str) -> User | None: + stmt = _user_with_rbac_stmt().where(User.email == email) + return db.execute(stmt).unique().scalar_one_or_none() + + +def update_user( + db: Session, + user_id: str, + payload: UserUpdateRequest, +) -> UserPublic | None: + user = get_user_by_id(db, user_id) + if not user: + return None + + if payload.username and payload.username != user.username: + duplicate = db.scalar( + select(User.id).where(User.username == payload.username, User.id != user.id) + ) + if duplicate: + return None + user.username = payload.username + + if payload.status: + user.status = payload.status + + db.commit() + updated = get_user_by_id(db, user_id) + return serialize_user(updated) if updated else None + + +def set_user_roles( + db: Session, + user_id: str, + payload: UserRoleUpdateRequest, +) -> UserPublic | None: + user = get_user_by_id(db, user_id) + if not user: + return None + + role_codes = sorted(set(payload.role_codes)) + roles = db.execute(select(Role).where(Role.code.in_(role_codes))).scalars().all() + if len(roles) != len(role_codes): + return None + + user.roles = roles + db.commit() + updated = get_user_by_id(db, user_id) + return serialize_user(updated) + + +def serialize_user(user: User | None) -> UserPublic: + if user is None: + msg = "User is required" + raise ValueError(msg) + + role_codes = sorted({role.code for role in user.roles}) + permission_codes = sorted( + {permission.code for role in user.roles for permission in role.permissions} + ) + return UserPublic( + id=user.id, + email=user.email, + username=user.username, + status=user.status, + role_codes=role_codes, + permission_codes=permission_codes, + created_at=user.created_at, + last_login_at=user.last_login_at, + ) diff --git a/api/pyproject.toml b/api/pyproject.toml index b63669d..e1e43f4 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -7,4 +7,9 @@ dependencies = [ "fastapi>=0.116.0,<1.0.0", "uvicorn[standard]>=0.35.0,<1.0.0", "pydantic-settings>=2.10.0,<3.0.0", + "sqlalchemy>=2.0.36,<3.0.0", + "psycopg[binary]>=3.2.3,<4.0.0", + "PyJWT>=2.10.1,<3.0.0", + "argon2-cffi>=23.1.0,<24.0.0", + "email-validator>=2.2.0,<3.0.0", ] diff --git a/api/requirements.txt b/api/requirements.txt index 1c5ca92..5a88bbd 100644 --- a/api/requirements.txt +++ b/api/requirements.txt @@ -1,3 +1,8 @@ fastapi>=0.116.0,<1.0.0 uvicorn[standard]>=0.35.0,<1.0.0 pydantic-settings>=2.10.0,<3.0.0 +sqlalchemy>=2.0.36,<3.0.0 +psycopg[binary]>=3.2.3,<4.0.0 +PyJWT>=2.10.1,<3.0.0 +argon2-cffi>=23.1.0,<24.0.0 +email-validator>=2.2.0,<3.0.0 diff --git a/docker-compose.yml b/docker-compose.yml index 6fca9f4..2497d49 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,13 +1,50 @@ services: + db: + image: ${POSTGRES_IMAGE:-docker.m.daocloud.io/library/postgres:16-alpine} + container_name: fquiz-db + environment: + POSTGRES_DB: ${POSTGRES_DB:-fquiz} + POSTGRES_USER: ${POSTGRES_USER:-fquiz} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-fquiz} + ports: + - "5432:5432" + volumes: + - fquiz_db_data:/var/lib/postgresql/data + healthcheck: + test: + [ + "CMD-SHELL", + "pg_isready -U ${POSTGRES_USER:-fquiz} -d ${POSTGRES_DB:-fquiz}", + ] + interval: 10s + timeout: 5s + retries: 8 + start_period: 10s + restart: unless-stopped + api: build: context: ./api dockerfile: Dockerfile + args: + PYTHON_BASE_IMAGE: ${PYTHON_BASE_IMAGE:-docker.m.daocloud.io/library/python:3.11-slim} container_name: fquiz-api + depends_on: + db: + condition: service_healthy environment: API_HOST: ${API_HOST:-0.0.0.0} API_PORT: ${API_PORT:-8000} API_CORS_ORIGINS: ${API_CORS_ORIGINS:-http://localhost:3000,http://127.0.0.1:3000} + DATABASE_URL: ${DATABASE_URL:-postgresql+psycopg://fquiz:fquiz@db:5432/fquiz} + JWT_SECRET_KEY: ${JWT_SECRET_KEY:-change-this-in-production} + ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-15} + REFRESH_TOKEN_EXPIRE_DAYS: ${REFRESH_TOKEN_EXPIRE_DAYS:-30} + REFRESH_COOKIE_SECURE: ${REFRESH_COOKIE_SECURE:-false} + REFRESH_COOKIE_SAMESITE: ${REFRESH_COOKIE_SAMESITE:-lax} + INITIAL_ADMIN_EMAIL: ${INITIAL_ADMIN_EMAIL:-admin@example.com} + INITIAL_ADMIN_USERNAME: ${INITIAL_ADMIN_USERNAME:-admin} + INITIAL_ADMIN_PASSWORD: ${INITIAL_ADMIN_PASSWORD:-change-me-strong-password} ports: - "${API_PORT:-8000}:8000" healthcheck: @@ -29,6 +66,7 @@ services: context: ./web dockerfile: Dockerfile args: + NODE_BASE_IMAGE: ${NODE_BASE_IMAGE:-docker.m.daocloud.io/library/node:22-alpine} NEXT_PUBLIC_API_BASE_URL: ${NEXT_PUBLIC_API_BASE_URL:-http://localhost:8000} container_name: fquiz-web depends_on: @@ -40,3 +78,6 @@ services: ports: - "3000:3000" restart: unless-stopped + +volumes: + fquiz_db_data: diff --git a/web/Dockerfile b/web/Dockerfile index dda05b1..4a3c83e 100644 --- a/web/Dockerfile +++ b/web/Dockerfile @@ -1,10 +1,12 @@ -FROM node:22-alpine AS deps +ARG NODE_BASE_IMAGE=docker.m.daocloud.io/library/node:22-alpine + +FROM ${NODE_BASE_IMAGE} AS deps WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci -FROM node:22-alpine AS builder +FROM ${NODE_BASE_IMAGE} AS builder WORKDIR /app ARG NEXT_PUBLIC_API_BASE_URL=http://localhost:8000 @@ -14,7 +16,7 @@ COPY --from=deps /app/node_modules ./node_modules COPY . . RUN npm run build -FROM node:22-alpine AS runner +FROM ${NODE_BASE_IMAGE} AS runner WORKDIR /app ENV NODE_ENV=production diff --git a/web/src/app/admin/users/page.tsx b/web/src/app/admin/users/page.tsx new file mode 100644 index 0000000..d9c7450 --- /dev/null +++ b/web/src/app/admin/users/page.tsx @@ -0,0 +1,118 @@ +"use client"; + +import Link from "next/link"; +import { useEffect, useState } from "react"; + +import { useAuth } from "@/components/auth-provider"; +import { readApiError } from "@/lib/api"; +import type { UserListResponse, UserPublic } from "@/types/auth"; + +export default function AdminUsersPage() { + const { user, initializing, fetchWithAuth, hasPermission } = useAuth(); + const [users, setUsers] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + + useEffect(() => { + const load = async () => { + if (!user || !hasPermission("user.manage")) { + setLoading(false); + return; + } + + const response = await fetchWithAuth("/api/v1/users?limit=200&offset=0"); + if (!response.ok) { + setError(await readApiError(response)); + setLoading(false); + return; + } + + const payload = (await response.json()) as UserListResponse; + setUsers(payload.items); + setLoading(false); + }; + void load(); + }, [fetchWithAuth, hasPermission, user]); + + if (initializing || loading) { + return ( +
+

Loading users...

+
+ ); + } + + if (!user) { + return ( +
+

+ 请先登录后再访问用户管理页面。 +

+ + 返回首页 + +
+ ); + } + + if (!hasPermission("user.manage")) { + return ( +
+

+ 你没有访问该页面的权限(需要 `user.manage`)。 +

+ + 返回首页 + +
+ ); + } + + return ( +
+
+

用户管理

+ + 返回首页 + +
+ + {error && ( +
+          {error}
+        
+ )} + +
+ + + + + + + + + + + + + {users.map((item) => ( + + + + + + + + + ))} + +
IDEmailUsernameStatusRolesPermissions
+ {item.id} + {item.email}{item.username}{item.status}{item.role_codes.join(", ") || "-"} + {item.permission_codes.join(", ") || "-"} +
+
+
+ ); +} diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx index c837e3b..38d3fe5 100644 --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -1,5 +1,8 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; + +import { AuthProvider } from "@/components/auth-provider"; + import "./globals.css"; const geistSans = Geist({ @@ -27,7 +30,9 @@ export default function RootLayout({ lang="en" className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`} > - {children} + + {children} + ); } diff --git a/web/src/app/page.tsx b/web/src/app/page.tsx index fbe168f..0069259 100644 --- a/web/src/app/page.tsx +++ b/web/src/app/page.tsx @@ -1,71 +1,188 @@ "use client"; -import { useMemo, useState } from "react"; +import Link from "next/link"; +import { FormEvent, useMemo, useState } from "react"; -type PingResponse = { - message: string; - service: string; -}; +import { useAuth } from "@/components/auth-provider"; +import { API_BASE_URL, readApiError } from "@/lib/api"; -const FALLBACK_API_BASE_URL = "http://127.0.0.1:8000"; +type Mode = "login" | "register"; +type PingResponse = { message: string }; export default function Home() { - const apiBaseUrl = useMemo( - () => process.env.NEXT_PUBLIC_API_BASE_URL ?? FALLBACK_API_BASE_URL, - [], - ); + const { user, initializing, login, register, logout, hasPermission } = useAuth(); + + const [mode, setMode] = useState("login"); + const [email, setEmail] = useState(""); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); const [pingResult, setPingResult] = useState(null); - const [error, setError] = useState(""); - const [loading, setLoading] = useState(false); - const handlePing = async () => { - setLoading(true); + const title = useMemo( + () => (mode === "login" ? "登录账号" : "注册新账号"), + [mode], + ); + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault(); setError(""); - setPingResult(null); - + setBusy(true); try { - const response = await fetch(`${apiBaseUrl}/api/v1/ping`, { - method: "GET", - }); - - if (!response.ok) { - throw new Error(`HTTP ${response.status}`); + if (mode === "login") { + await login(email, password); + } else { + await register(email, username, password); } - - const data = (await response.json()) as PingResponse; - setPingResult(data); - } catch (err) { - const message = err instanceof Error ? err.message : "Unknown error"; - setError( - `Failed to connect API at ${apiBaseUrl}/api/v1/ping (${message})`, - ); + setPassword(""); + } catch (submitError) { + const message = + submitError instanceof Error ? submitError.message : "Unknown error"; + setError(message); } finally { - setLoading(false); + setBusy(false); } }; + const handlePing = async () => { + setError(""); + const response = await fetch(`${API_BASE_URL}/api/v1/ping`, { + method: "GET", + credentials: "include", + }); + if (!response.ok) { + setError(await readApiError(response)); + return; + } + setPingResult((await response.json()) as PingResponse); + }; + + if (initializing) { + return ( +
+

Initializing session...

+
+ ); + } + return ( -
+

fquiz

- Next.js + FastAPI full-stack starter is ready. + 用户管理与登录认证已就绪(JWT + Refresh Session + RBAC)。

-
+

API Base URL

-

{apiBaseUrl}

-
+

{API_BASE_URL}

+ -
- -
+ {user ? ( +
+

欢迎,{user.username}

+

{user.email}

+

+ Roles: {user.role_codes.join(", ") || "-"} +

+

+ Permissions: {user.permission_codes.join(", ") || "-"} +

+ +
+ + {hasPermission("user.manage") && ( + + 管理用户 + + )} + +
+
+ ) : ( +
+
+ + +
+ +
+

{title}

+ setEmail(event.target.value)} + required + /> + {mode === "register" && ( + setUsername(event.target.value)} + minLength={3} + maxLength={64} + required + /> + )} + setPassword(event.target.value)} + minLength={8} + maxLength={128} + required + /> + +
+
+ )} {pingResult && (
@@ -78,6 +195,6 @@ export default function Home() {
           {error}
         
)} -
+ ); } diff --git a/web/src/components/auth-provider.tsx b/web/src/components/auth-provider.tsx new file mode 100644 index 0000000..83e7a78 --- /dev/null +++ b/web/src/components/auth-provider.tsx @@ -0,0 +1,224 @@ +"use client"; + +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react"; + +import { API_BASE_URL, readApiError } from "@/lib/api"; +import type { AuthTokenResponse, UserPublic } from "@/types/auth"; + +type AuthContextValue = { + user: UserPublic | null; + accessToken: string | null; + initializing: boolean; + login: (email: string, password: string) => Promise; + register: (email: string, username: string, password: string) => Promise; + logout: () => Promise; + refreshAccessToken: () => Promise; + fetchWithAuth: ( + path: string, + init?: RequestInit, + retryOnUnauthorized?: boolean, + ) => Promise; + hasRole: (roleCode: string) => boolean; + hasPermission: (permissionCode: string) => boolean; +}; + +const AuthContext = createContext(undefined); + +function withApiPath(path: string): string { + if (path.startsWith("http://") || path.startsWith("https://")) { + return path; + } + return `${API_BASE_URL}${path}`; +} + +export function AuthProvider({ children }: { children: React.ReactNode }) { + const [user, setUser] = useState(null); + const [accessToken, setAccessToken] = useState(null); + const [initializing, setInitializing] = useState(true); + const accessTokenRef = useRef(null); + + const applyToken = useCallback((token: string | null) => { + accessTokenRef.current = token; + setAccessToken(token); + }, []); + + const clearAuth = useCallback(() => { + setUser(null); + applyToken(null); + }, [applyToken]); + + const applyAuthPayload = useCallback( + (payload: AuthTokenResponse) => { + setUser(payload.user); + applyToken(payload.access_token); + }, + [applyToken], + ); + + const refreshAccessToken = useCallback(async (): Promise => { + const response = await fetch(withApiPath("/api/v1/auth/refresh"), { + method: "POST", + credentials: "include", + }); + if (!response.ok) { + clearAuth(); + return false; + } + + const payload = (await response.json()) as AuthTokenResponse; + applyAuthPayload(payload); + return true; + }, [applyAuthPayload, clearAuth]); + + const fetchWithAuth = useCallback( + async ( + path: string, + init: RequestInit = {}, + retryOnUnauthorized = true, + ): Promise => { + const headers = new Headers(init.headers); + const token = accessTokenRef.current; + if (token) { + headers.set("Authorization", `Bearer ${token}`); + } + + const response = await fetch(withApiPath(path), { + ...init, + headers, + credentials: "include", + }); + + if (response.status !== 401 || !retryOnUnauthorized) { + return response; + } + + const refreshed = await refreshAccessToken(); + if (!refreshed) { + return response; + } + + const retryHeaders = new Headers(init.headers); + const nextToken = accessTokenRef.current; + if (nextToken) { + retryHeaders.set("Authorization", `Bearer ${nextToken}`); + } + + return fetch(withApiPath(path), { + ...init, + headers: retryHeaders, + credentials: "include", + }); + }, + [refreshAccessToken], + ); + + const login = useCallback( + async (email: string, password: string) => { + const response = await fetch(withApiPath("/api/v1/auth/login"), { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ email, password }), + }); + if (!response.ok) { + throw new Error(await readApiError(response)); + } + + const payload = (await response.json()) as AuthTokenResponse; + applyAuthPayload(payload); + }, + [applyAuthPayload], + ); + + const register = useCallback( + async (email: string, username: string, password: string) => { + const response = await fetch(withApiPath("/api/v1/auth/register"), { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ email, username, password }), + }); + if (!response.ok) { + throw new Error(await readApiError(response)); + } + + const payload = (await response.json()) as AuthTokenResponse; + applyAuthPayload(payload); + }, + [applyAuthPayload], + ); + + const logout = useCallback(async () => { + await fetch(withApiPath("/api/v1/auth/logout"), { + method: "POST", + credentials: "include", + }); + clearAuth(); + }, [clearAuth]); + + useEffect(() => { + const bootstrap = async () => { + try { + await refreshAccessToken(); + } finally { + setInitializing(false); + } + }; + void bootstrap(); + }, [refreshAccessToken]); + + const hasRole = useCallback( + (roleCode: string) => (user ? user.role_codes.includes(roleCode) : false), + [user], + ); + const hasPermission = useCallback( + (permissionCode: string) => + user ? user.permission_codes.includes(permissionCode) : false, + [user], + ); + + const value = useMemo( + () => ({ + user, + accessToken, + initializing, + login, + register, + logout, + refreshAccessToken, + fetchWithAuth, + hasRole, + hasPermission, + }), + [ + user, + accessToken, + initializing, + login, + register, + logout, + refreshAccessToken, + fetchWithAuth, + hasRole, + hasPermission, + ], + ); + + return {children}; +} + +export function useAuth(): AuthContextValue { + const context = useContext(AuthContext); + if (!context) { + throw new Error("useAuth must be used inside AuthProvider"); + } + return context; +} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts new file mode 100644 index 0000000..020967b --- /dev/null +++ b/web/src/lib/api.ts @@ -0,0 +1,11 @@ +export const API_BASE_URL = + process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://127.0.0.1:8000"; + +export async function readApiError(response: Response): Promise { + try { + const data = (await response.json()) as { detail?: string }; + return data.detail ?? `HTTP ${response.status}`; + } catch { + return `HTTP ${response.status}`; + } +} diff --git a/web/src/types/auth.ts b/web/src/types/auth.ts new file mode 100644 index 0000000..bb88ff9 --- /dev/null +++ b/web/src/types/auth.ts @@ -0,0 +1,22 @@ +export type UserPublic = { + id: string; + email: string; + username: string; + status: string; + role_codes: string[]; + permission_codes: string[]; + created_at: string; + last_login_at: string | null; +}; + +export type AuthTokenResponse = { + access_token: string; + token_type: string; + expires_in: number; + user: UserPublic; +}; + +export type UserListResponse = { + items: UserPublic[]; + total: number; +};