mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-05-20 15:11:09 +00:00
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:
@@ -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",
|
||||
]
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user