feat(app): add plugin system with auth plugin and static assets

Add new application structure:
- app/main.py - application entry point
- app/plugins/ - plugin system with auth plugin:
  - api/ - REST API endpoints and schemas
  - authorization/ - auth policies, providers, hooks
  - domain/ - business logic (service, models, jwt, password)
  - injection/ - route injection and guards
  - ops/ - operational utilities
  - runtime/ - runtime configuration
  - security/ - middleware, CSRF, dependencies
  - storage/ - user repositories and models
- app/static/ - static assets (scalar.js for API docs)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
rayhpeng
2026-04-22 11:31:42 +08:00
parent a0ab3a3dd4
commit 0f82f8a3a2
47 changed files with 5516 additions and 0 deletions
@@ -0,0 +1,28 @@
"""Domain layer for the auth plugin."""
from app.plugins.auth.domain.config import AuthConfig, load_auth_config_from_env
from app.plugins.auth.domain.errors import AuthErrorCode, AuthErrorResponse, TokenError, token_error_to_code
from app.plugins.auth.domain.jwt import TokenPayload, create_access_token, decode_token
from app.plugins.auth.domain.models import User, UserResponse
from app.plugins.auth.domain.password import hash_password, hash_password_async, verify_password, verify_password_async
from app.plugins.auth.domain.service import AuthService, AuthServiceError
__all__ = [
"AuthConfig",
"AuthErrorCode",
"AuthErrorResponse",
"AuthService",
"AuthServiceError",
"TokenError",
"TokenPayload",
"User",
"UserResponse",
"create_access_token",
"decode_token",
"hash_password",
"hash_password_async",
"load_auth_config_from_env",
"token_error_to_code",
"verify_password",
"verify_password_async",
]
+42
View File
@@ -0,0 +1,42 @@
"""Auth configuration schema and environment loader."""
from __future__ import annotations
import logging
import os
import secrets
from dotenv import load_dotenv
from pydantic import BaseModel, Field
load_dotenv()
logger = logging.getLogger(__name__)
class AuthConfig(BaseModel):
"""JWT and auth-related configuration."""
jwt_secret: str = Field(..., description="Secret key for JWT signing. MUST be set via AUTH_JWT_SECRET.")
token_expiry_days: int = Field(default=7, ge=1, le=30)
oauth_github_client_id: str | None = Field(default=None)
oauth_github_client_secret: str | None = Field(default=None)
def load_auth_config_from_env() -> AuthConfig:
"""Build an auth config from environment variables."""
jwt_secret = os.environ.get("AUTH_JWT_SECRET")
if not jwt_secret:
jwt_secret = secrets.token_urlsafe(32)
os.environ["AUTH_JWT_SECRET"] = jwt_secret
logger.warning(
"⚠ AUTH_JWT_SECRET is not set — using an auto-generated ephemeral secret. "
"Sessions will be invalidated on restart. "
"For production, add AUTH_JWT_SECRET to your .env file: "
'python -c "import secrets; print(secrets.token_urlsafe(32))"'
)
return AuthConfig(jwt_secret=jwt_secret)
__all__ = ["AuthConfig", "load_auth_config_from_env"]
+33
View File
@@ -0,0 +1,33 @@
"""Typed error definitions for auth plugin."""
from enum import StrEnum
from pydantic import BaseModel
class AuthErrorCode(StrEnum):
INVALID_CREDENTIALS = "invalid_credentials"
TOKEN_EXPIRED = "token_expired"
TOKEN_INVALID = "token_invalid"
USER_NOT_FOUND = "user_not_found"
EMAIL_ALREADY_EXISTS = "email_already_exists"
PROVIDER_NOT_FOUND = "provider_not_found"
NOT_AUTHENTICATED = "not_authenticated"
SYSTEM_ALREADY_INITIALIZED = "system_already_initialized"
class TokenError(StrEnum):
EXPIRED = "expired"
INVALID_SIGNATURE = "invalid_signature"
MALFORMED = "malformed"
class AuthErrorResponse(BaseModel):
code: AuthErrorCode
message: str
def token_error_to_code(err: TokenError) -> AuthErrorCode:
if err == TokenError.EXPIRED:
return AuthErrorCode.TOKEN_EXPIRED
return AuthErrorCode.TOKEN_INVALID
+37
View File
@@ -0,0 +1,37 @@
"""JWT token creation and verification."""
from datetime import UTC, datetime, timedelta
import jwt
from pydantic import BaseModel
from app.plugins.auth.domain.errors import TokenError
from app.plugins.auth.runtime.config_state import get_auth_config
class TokenPayload(BaseModel):
sub: str
exp: datetime
iat: datetime | None = None
ver: int = 0
def create_access_token(user_id: str, expires_delta: timedelta | None = None, token_version: int = 0) -> str:
config = get_auth_config()
expiry = expires_delta or timedelta(days=config.token_expiry_days)
now = datetime.now(UTC)
payload = {"sub": user_id, "exp": now + expiry, "iat": now, "ver": token_version}
return jwt.encode(payload, config.jwt_secret, algorithm="HS256")
def decode_token(token: str) -> TokenPayload | TokenError:
config = get_auth_config()
try:
payload = jwt.decode(token, config.jwt_secret, algorithms=["HS256"])
return TokenPayload(**payload)
except jwt.ExpiredSignatureError:
return TokenError.EXPIRED
except jwt.InvalidSignatureError:
return TokenError.INVALID_SIGNATURE
except jwt.PyJWTError:
return TokenError.MALFORMED
+32
View File
@@ -0,0 +1,32 @@
"""User Pydantic models for the auth plugin."""
from datetime import UTC, datetime
from typing import Literal
from uuid import UUID, uuid4
from pydantic import BaseModel, ConfigDict, EmailStr, Field
def _utc_now() -> datetime:
return datetime.now(UTC)
class User(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID = Field(default_factory=uuid4, description="Primary key")
email: EmailStr = Field(..., description="Unique email address")
password_hash: str | None = Field(None, description="bcrypt hash, nullable for OAuth users")
system_role: Literal["admin", "user"] = Field(default="user")
created_at: datetime = Field(default_factory=_utc_now)
oauth_provider: str | None = Field(None, description="e.g. 'github', 'google'")
oauth_id: str | None = Field(None, description="User ID from OAuth provider")
needs_setup: bool = Field(default=False, description="True for auto-created admin until setup completes")
token_version: int = Field(default=0, description="Incremented on password change to invalidate old JWTs")
class UserResponse(BaseModel):
id: str
email: str
system_role: Literal["admin", "user"]
needs_setup: bool = False
@@ -0,0 +1,21 @@
"""Password hashing utilities using bcrypt directly."""
import asyncio
import bcrypt
def hash_password(password: str) -> str:
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
def verify_password(plain_password: str, hashed_password: str) -> bool:
return bcrypt.checkpw(plain_password.encode("utf-8"), hashed_password.encode("utf-8"))
async def hash_password_async(password: str) -> str:
return await asyncio.to_thread(hash_password, password)
async def verify_password_async(plain_password: str, hashed_password: str) -> bool:
return await asyncio.to_thread(verify_password, plain_password, hashed_password)
+175
View File
@@ -0,0 +1,175 @@
from __future__ import annotations
from dataclasses import dataclass
from http import HTTPStatus
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from app.plugins.auth.domain.errors import AuthErrorCode
from app.plugins.auth.domain.models import User
from app.plugins.auth.domain.password import hash_password_async, verify_password_async
from app.plugins.auth.storage import DbUserRepository, UserCreate
from app.plugins.auth.storage.contracts import User as StoreUser
@dataclass(slots=True)
class AuthServiceError(Exception):
code: AuthErrorCode
message: str
status_code: int
def _to_auth_user(user: StoreUser) -> User:
return User(
id=UUID(user.id),
email=user.email,
password_hash=user.password_hash,
system_role=user.system_role, # type: ignore[arg-type]
created_at=user.created_time,
oauth_provider=user.oauth_provider,
oauth_id=user.oauth_id,
needs_setup=user.needs_setup,
token_version=user.token_version,
)
def _to_store_user(user: User) -> StoreUser:
return StoreUser(
id=str(user.id),
email=user.email,
password_hash=user.password_hash,
system_role=user.system_role,
oauth_provider=user.oauth_provider,
oauth_id=user.oauth_id,
needs_setup=user.needs_setup,
token_version=user.token_version,
created_time=user.created_at,
updated_time=None,
)
class AuthService:
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
self._session_factory = session_factory
async def login_local(self, email: str, password: str) -> User:
async with self._session_factory() as session:
repo = DbUserRepository(session)
user = await repo.get_user_by_email(email)
if user is None or user.password_hash is None:
raise AuthServiceError(
code=AuthErrorCode.INVALID_CREDENTIALS,
message="Incorrect email or password",
status_code=HTTPStatus.UNAUTHORIZED,
)
if not await verify_password_async(password, user.password_hash):
raise AuthServiceError(
code=AuthErrorCode.INVALID_CREDENTIALS,
message="Incorrect email or password",
status_code=HTTPStatus.UNAUTHORIZED,
)
return _to_auth_user(user)
async def register(self, email: str, password: str) -> User:
async with self._session_factory() as session:
repo = DbUserRepository(session)
try:
user = await repo.create_user(
UserCreate(
email=email,
password_hash=await hash_password_async(password),
system_role="user",
)
)
await session.commit()
except ValueError as exc:
await session.rollback()
raise AuthServiceError(
code=AuthErrorCode.EMAIL_ALREADY_EXISTS,
message="Email already registered",
status_code=HTTPStatus.BAD_REQUEST,
) from exc
return _to_auth_user(user)
async def change_password(
self,
user: User | StoreUser,
*,
current_password: str,
new_password: str,
new_email: str | None = None,
) -> User:
if user.password_hash is None:
raise AuthServiceError(
code=AuthErrorCode.INVALID_CREDENTIALS,
message="OAuth users cannot change password",
status_code=HTTPStatus.BAD_REQUEST,
)
if not await verify_password_async(current_password, user.password_hash):
raise AuthServiceError(
code=AuthErrorCode.INVALID_CREDENTIALS,
message="Current password is incorrect",
status_code=HTTPStatus.BAD_REQUEST,
)
async with self._session_factory() as session:
repo = DbUserRepository(session)
updated_email = user.email
if new_email is not None:
existing = await repo.get_user_by_email(new_email)
if existing and existing.id != str(user.id):
raise AuthServiceError(
code=AuthErrorCode.EMAIL_ALREADY_EXISTS,
message="Email already in use",
status_code=HTTPStatus.BAD_REQUEST,
)
updated_email = new_email
updated_user = user.model_copy(
update={
"email": updated_email,
"password_hash": await hash_password_async(new_password),
"token_version": user.token_version + 1,
"needs_setup": False if user.needs_setup and new_email is not None else user.needs_setup,
}
)
updated = await repo.update_user(_to_store_user(_to_auth_user(updated_user) if isinstance(updated_user, StoreUser) else updated_user))
await session.commit()
return _to_auth_user(updated)
async def get_setup_status(self) -> bool:
async with self._session_factory() as session:
repo = DbUserRepository(session)
admin_count = await repo.count_admin_users()
return admin_count == 0
async def initialize_admin(self, email: str, password: str) -> User:
async with self._session_factory() as session:
repo = DbUserRepository(session)
admin_count = await repo.count_admin_users()
if admin_count > 0:
raise AuthServiceError(
code=AuthErrorCode.SYSTEM_ALREADY_INITIALIZED,
message="System already initialized",
status_code=HTTPStatus.CONFLICT,
)
try:
user = await repo.create_user(
UserCreate(
email=email,
password_hash=await hash_password_async(password),
system_role="admin",
needs_setup=False,
)
)
await session.commit()
except ValueError as exc:
await session.rollback()
raise AuthServiceError(
code=AuthErrorCode.SYSTEM_ALREADY_INITIALIZED,
message="System already initialized",
status_code=HTTPStatus.CONFLICT,
) from exc
return _to_auth_user(user)