feat: add CI/CD workflow and sync latest workspace changes

This commit is contained in:
chengkai3
2026-04-12 00:03:30 +08:00
parent add7517a1d
commit 0eb656aaf2
42 changed files with 2055 additions and 96 deletions
+15
View File
@@ -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
+210
View File
@@ -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 <<ENV
API_IMAGE=${API_IMAGE}:${IMAGE_TAG}
WEB_IMAGE=${WEB_IMAGE}:${IMAGE_TAG}
NEXT_PUBLIC_API_BASE_URL=${NEXT_PUBLIC_API_BASE_URL}
ENV
echo "${GHCR_TOKEN}" | docker login ghcr.io -u "${GHCR_USERNAME}" --password-stdin
COMPOSE_CMD="docker compose"
if ! docker compose version >/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
+1
View File
@@ -19,6 +19,7 @@ venv/
__pycache__/
*.py[cod]
*.egg-info/
.db
.pytest_cache/
.mypy_cache/
+24 -5
View File
@@ -1,13 +1,13 @@
# fquiz
基于 Next.js + PythonFastAPI)的全栈 Monorepo 初始化工程
基于 Next.js + PythonFastAPI)的全栈 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 Token15m+ Refresh SessionHttpOnly Cookie, 轮换
- 权限:RBACroles / 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)。
+9
View File
@@ -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
+2 -1
View File
@@ -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
+15 -2
View File
@@ -1,6 +1,13 @@
# API Service
Python FastAPI 后端服务。
FastAPI 后端服务,包含用户认证和 RBAC 权限控制
## 核心能力
- JWT Access Token(默认 15 分钟)
- Refresh SessionHttpOnly 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`
+1
View File
@@ -0,0 +1 @@
"""API router package."""
+13
View File
@@ -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"}
+1
View File
@@ -0,0 +1 @@
"""Versioned API routes."""
+128
View File
@@ -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)
+76
View File
@@ -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
+2 -29
View File
@@ -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"]
+1
View File
@@ -0,0 +1 @@
"""Core infrastructure for config, database, and security."""
+56
View File
@@ -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()
+47
View File
@@ -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)
+73
View File
@@ -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
+81
View File
@@ -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()
+14 -8
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
"""Database models."""
+29
View File
@@ -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")
+37
View File
@@ -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")
+5
View File
@@ -0,0 +1,5 @@
from datetime import datetime, timezone
def utcnow() -> datetime:
return datetime.now(timezone.utc)
+74
View File
@@ -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,
)
+57
View File
@@ -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",
)
+1
View File
@@ -0,0 +1 @@
"""Pydantic schemas."""
+25
View File
@@ -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
+29
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
"""Business services."""
+238
View File
@@ -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)
+90
View File
@@ -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)
+98
View File
@@ -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,
)
+5
View File
@@ -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",
]
+5
View File
@@ -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
+41
View File
@@ -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:
+5 -3
View File
@@ -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
+118
View File
@@ -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<UserPublic[]>([]);
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 (
<main className="mx-auto flex min-h-screen w-full max-w-5xl items-center justify-center px-6 py-20">
<p className="text-sm text-zinc-500">Loading users...</p>
</main>
);
}
if (!user) {
return (
<main className="mx-auto flex min-h-screen w-full max-w-4xl flex-col justify-center gap-4 px-6 py-20">
<p className="text-sm text-zinc-600 dark:text-zinc-300">
访
</p>
<Link href="/" className="text-sm underline">
</Link>
</main>
);
}
if (!hasPermission("user.manage")) {
return (
<main className="mx-auto flex min-h-screen w-full max-w-4xl flex-col justify-center gap-4 px-6 py-20">
<p className="text-sm text-zinc-600 dark:text-zinc-300">
访 `user.manage`
</p>
<Link href="/" className="text-sm underline">
</Link>
</main>
);
}
return (
<main className="mx-auto flex min-h-screen w-full max-w-5xl flex-col gap-6 px-6 py-10">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-semibold tracking-tight"></h1>
<Link href="/" className="text-sm underline">
</Link>
</div>
{error && (
<pre className="overflow-auto rounded-xl border border-red-500/30 bg-red-50 p-4 text-sm text-red-700 dark:border-red-500/30 dark:bg-red-950/30 dark:text-red-300">
{error}
</pre>
)}
<div className="overflow-x-auto rounded-xl border border-black/10 dark:border-white/15">
<table className="min-w-full divide-y divide-black/10 text-left text-sm dark:divide-white/15">
<thead className="bg-black/[0.03] dark:bg-white/[0.06]">
<tr>
<th className="px-4 py-3 font-medium">ID</th>
<th className="px-4 py-3 font-medium">Email</th>
<th className="px-4 py-3 font-medium">Username</th>
<th className="px-4 py-3 font-medium">Status</th>
<th className="px-4 py-3 font-medium">Roles</th>
<th className="px-4 py-3 font-medium">Permissions</th>
</tr>
</thead>
<tbody className="divide-y divide-black/10 dark:divide-white/15">
{users.map((item) => (
<tr key={item.id}>
<td className="whitespace-nowrap px-4 py-3 font-mono text-xs">
{item.id}
</td>
<td className="whitespace-nowrap px-4 py-3">{item.email}</td>
<td className="whitespace-nowrap px-4 py-3">{item.username}</td>
<td className="whitespace-nowrap px-4 py-3">{item.status}</td>
<td className="px-4 py-3">{item.role_codes.join(", ") || "-"}</td>
<td className="px-4 py-3">
{item.permission_codes.join(", ") || "-"}
</td>
</tr>
))}
</tbody>
</table>
</div>
</main>
);
}
+6 -1
View File
@@ -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`}
>
<body className="min-h-full flex flex-col">{children}</body>
<body className="min-h-full flex flex-col">
<AuthProvider>{children}</AuthProvider>
</body>
</html>
);
}
+164 -47
View File
@@ -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<Mode>("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<PingResponse | null>(null);
const [error, setError] = useState<string>("");
const [loading, setLoading] = useState(false);
const handlePing = async () => {
setLoading(true);
const title = useMemo(
() => (mode === "login" ? "登录账号" : "注册新账号"),
[mode],
);
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
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 (
<main className="mx-auto flex min-h-screen w-full max-w-3xl items-center justify-center px-6 py-20">
<p className="text-sm text-zinc-500">Initializing session...</p>
</main>
);
}
return (
<div className="mx-auto flex min-h-screen w-full max-w-3xl flex-col justify-center gap-6 px-6 py-20 sm:px-10">
<main className="mx-auto flex min-h-screen w-full max-w-3xl flex-col justify-center gap-6 px-6 py-20 sm:px-10">
<h1 className="text-3xl font-semibold tracking-tight">fquiz</h1>
<p className="text-base text-zinc-600 dark:text-zinc-300">
Next.js + FastAPI full-stack starter is ready.
JWT + Refresh Session + RBAC
</p>
<div className="rounded-xl border border-black/10 bg-white p-5 shadow-sm dark:border-white/15 dark:bg-black">
<section className="rounded-xl border border-black/10 bg-white p-5 shadow-sm dark:border-white/15 dark:bg-black">
<p className="text-sm text-zinc-500 dark:text-zinc-400">API Base URL</p>
<p className="mt-1 font-mono text-sm">{apiBaseUrl}</p>
</div>
<p className="mt-1 font-mono text-sm">{API_BASE_URL}</p>
</section>
<div className="flex items-center gap-3">
<button
className="rounded-md bg-black px-4 py-2 text-sm font-medium text-white transition hover:bg-zinc-800 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-white dark:text-black dark:hover:bg-zinc-200"
onClick={handlePing}
disabled={loading}
type="button"
>
{loading ? "Pinging..." : "Ping Backend"}
</button>
</div>
{user ? (
<section className="rounded-xl border border-black/10 bg-white p-5 shadow-sm dark:border-white/15 dark:bg-black">
<p className="text-lg font-medium">{user.username}</p>
<p className="mt-1 text-sm text-zinc-500">{user.email}</p>
<p className="mt-2 text-xs text-zinc-500">
Roles: {user.role_codes.join(", ") || "-"}
</p>
<p className="mt-1 text-xs text-zinc-500">
Permissions: {user.permission_codes.join(", ") || "-"}
</p>
<div className="mt-4 flex flex-wrap items-center gap-3">
<button
className="rounded-md bg-black px-4 py-2 text-sm font-medium text-white transition hover:bg-zinc-800 dark:bg-white dark:text-black dark:hover:bg-zinc-200"
onClick={handlePing}
type="button"
>
Ping Backend
</button>
{hasPermission("user.manage") && (
<Link
href="/admin/users"
className="rounded-md border border-black/15 px-4 py-2 text-sm font-medium transition hover:bg-black/5 dark:border-white/20 dark:hover:bg-white/10"
>
</Link>
)}
<button
className="rounded-md border border-black/15 px-4 py-2 text-sm font-medium transition hover:bg-black/5 dark:border-white/20 dark:hover:bg-white/10"
onClick={() => void logout()}
type="button"
>
退
</button>
</div>
</section>
) : (
<section className="rounded-xl border border-black/10 bg-white p-5 shadow-sm dark:border-white/15 dark:bg-black">
<div className="mb-4 flex gap-2">
<button
className={`rounded-md px-3 py-1 text-sm ${
mode === "login"
? "bg-black text-white dark:bg-white dark:text-black"
: "border border-black/15 dark:border-white/20"
}`}
onClick={() => setMode("login")}
type="button"
>
</button>
<button
className={`rounded-md px-3 py-1 text-sm ${
mode === "register"
? "bg-black text-white dark:bg-white dark:text-black"
: "border border-black/15 dark:border-white/20"
}`}
onClick={() => setMode("register")}
type="button"
>
</button>
</div>
<form className="space-y-3" onSubmit={handleSubmit}>
<h2 className="text-base font-medium">{title}</h2>
<input
className="w-full rounded-md border border-black/15 bg-transparent px-3 py-2 text-sm outline-none focus:border-black/40 dark:border-white/20 dark:focus:border-white/40"
placeholder="Email"
type="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
required
/>
{mode === "register" && (
<input
className="w-full rounded-md border border-black/15 bg-transparent px-3 py-2 text-sm outline-none focus:border-black/40 dark:border-white/20 dark:focus:border-white/40"
placeholder="Username"
type="text"
value={username}
onChange={(event) => setUsername(event.target.value)}
minLength={3}
maxLength={64}
required
/>
)}
<input
className="w-full rounded-md border border-black/15 bg-transparent px-3 py-2 text-sm outline-none focus:border-black/40 dark:border-white/20 dark:focus:border-white/40"
placeholder="Password (>= 8 chars)"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
minLength={8}
maxLength={128}
required
/>
<button
className="rounded-md bg-black px-4 py-2 text-sm font-medium text-white transition hover:bg-zinc-800 disabled:opacity-60 dark:bg-white dark:text-black dark:hover:bg-zinc-200"
disabled={busy}
type="submit"
>
{busy ? "Submitting..." : mode === "login" ? "登录" : "注册并登录"}
</button>
</form>
</section>
)}
{pingResult && (
<pre className="overflow-auto rounded-xl border border-emerald-500/30 bg-emerald-50 p-4 text-sm text-emerald-800 dark:border-emerald-500/30 dark:bg-emerald-950/30 dark:text-emerald-300">
@@ -78,6 +195,6 @@ export default function Home() {
{error}
</pre>
)}
</div>
</main>
);
}
+224
View File
@@ -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<void>;
register: (email: string, username: string, password: string) => Promise<void>;
logout: () => Promise<void>;
refreshAccessToken: () => Promise<boolean>;
fetchWithAuth: (
path: string,
init?: RequestInit,
retryOnUnauthorized?: boolean,
) => Promise<Response>;
hasRole: (roleCode: string) => boolean;
hasPermission: (permissionCode: string) => boolean;
};
const AuthContext = createContext<AuthContextValue | undefined>(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<UserPublic | null>(null);
const [accessToken, setAccessToken] = useState<string | null>(null);
const [initializing, setInitializing] = useState(true);
const accessTokenRef = useRef<string | null>(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<boolean> => {
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<Response> => {
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<AuthContextValue>(
() => ({
user,
accessToken,
initializing,
login,
register,
logout,
refreshAccessToken,
fetchWithAuth,
hasRole,
hasPermission,
}),
[
user,
accessToken,
initializing,
login,
register,
logout,
refreshAccessToken,
fetchWithAuth,
hasRole,
hasPermission,
],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth(): AuthContextValue {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth must be used inside AuthProvider");
}
return context;
}
+11
View File
@@ -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<string> {
try {
const data = (await response.json()) as { detail?: string };
return data.detail ?? `HTTP ${response.status}`;
} catch {
return `HTTP ${response.status}`;
}
}
+22
View File
@@ -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;
};