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,17 @@
|
||||
"""HTTP API layer for the auth plugin."""
|
||||
|
||||
from app.plugins.auth.api.router import (
|
||||
ChangePasswordRequest,
|
||||
LoginResponse,
|
||||
MessageResponse,
|
||||
RegisterRequest,
|
||||
router,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ChangePasswordRequest",
|
||||
"LoginResponse",
|
||||
"MessageResponse",
|
||||
"RegisterRequest",
|
||||
"router",
|
||||
]
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Authentication endpoints for the auth plugin."""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
|
||||
from app.plugins.auth.api.schemas import (
|
||||
ChangePasswordRequest,
|
||||
InitializeAdminRequest,
|
||||
LoginResponse,
|
||||
MessageResponse,
|
||||
RegisterRequest,
|
||||
_check_rate_limit,
|
||||
_get_client_ip,
|
||||
_login_attempts,
|
||||
_record_login_failure,
|
||||
_record_login_success,
|
||||
)
|
||||
from app.plugins.auth.domain.errors import AuthErrorResponse
|
||||
from app.plugins.auth.domain.jwt import create_access_token
|
||||
from app.plugins.auth.domain.models import UserResponse
|
||||
from app.plugins.auth.domain.service import AuthServiceError
|
||||
from app.plugins.auth.runtime.config_state import get_auth_config
|
||||
from app.plugins.auth.security.csrf import is_secure_request
|
||||
from app.plugins.auth.security.dependencies import CurrentAuthService, get_current_user_from_request
|
||||
|
||||
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
|
||||
|
||||
|
||||
def _set_session_cookie(response: Response, token: str, request: Request) -> None:
|
||||
config = get_auth_config()
|
||||
is_https = is_secure_request(request)
|
||||
response.set_cookie(
|
||||
key="access_token",
|
||||
value=token,
|
||||
httponly=True,
|
||||
secure=is_https,
|
||||
samesite="lax",
|
||||
max_age=config.token_expiry_days * 24 * 3600 if is_https else None,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/login/local", response_model=LoginResponse)
|
||||
async def login_local(
|
||||
request: Request,
|
||||
response: Response,
|
||||
auth_service: CurrentAuthService,
|
||||
form_data: OAuth2PasswordRequestForm = Depends(),
|
||||
):
|
||||
client_ip = _get_client_ip(request)
|
||||
_check_rate_limit(client_ip)
|
||||
try:
|
||||
user = await auth_service.login_local(form_data.username, form_data.password)
|
||||
except AuthServiceError as exc:
|
||||
_record_login_failure(client_ip)
|
||||
raise HTTPException(
|
||||
status_code=exc.status_code,
|
||||
detail=AuthErrorResponse(code=exc.code, message=exc.message).model_dump(),
|
||||
) from exc
|
||||
|
||||
_record_login_success(client_ip)
|
||||
token = create_access_token(str(user.id), token_version=user.token_version)
|
||||
_set_session_cookie(response, token, request)
|
||||
return LoginResponse(
|
||||
expires_in=get_auth_config().token_expiry_days * 24 * 3600,
|
||||
needs_setup=user.needs_setup,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def register(request: Request, response: Response, body: RegisterRequest, auth_service: CurrentAuthService):
|
||||
try:
|
||||
user = await auth_service.register(body.email, body.password)
|
||||
except AuthServiceError as exc:
|
||||
raise HTTPException(
|
||||
status_code=exc.status_code,
|
||||
detail=AuthErrorResponse(code=exc.code, message=exc.message).model_dump(),
|
||||
) from exc
|
||||
|
||||
token = create_access_token(str(user.id), token_version=user.token_version)
|
||||
_set_session_cookie(response, token, request)
|
||||
return UserResponse(id=str(user.id), email=user.email, system_role=user.system_role)
|
||||
|
||||
|
||||
@router.post("/logout", response_model=MessageResponse)
|
||||
async def logout(request: Request, response: Response):
|
||||
response.delete_cookie(key="access_token", secure=is_secure_request(request), samesite="lax")
|
||||
return MessageResponse(message="Successfully logged out")
|
||||
|
||||
|
||||
@router.post("/change-password", response_model=MessageResponse)
|
||||
async def change_password(
|
||||
request: Request,
|
||||
response: Response,
|
||||
body: ChangePasswordRequest,
|
||||
auth_service: CurrentAuthService,
|
||||
):
|
||||
user = await get_current_user_from_request(request)
|
||||
try:
|
||||
user = await auth_service.change_password(
|
||||
user,
|
||||
current_password=body.current_password,
|
||||
new_password=body.new_password,
|
||||
new_email=body.new_email,
|
||||
)
|
||||
except AuthServiceError as exc:
|
||||
raise HTTPException(
|
||||
status_code=exc.status_code,
|
||||
detail=AuthErrorResponse(code=exc.code, message=exc.message).model_dump(),
|
||||
) from exc
|
||||
|
||||
token = create_access_token(str(user.id), token_version=user.token_version)
|
||||
_set_session_cookie(response, token, request)
|
||||
return MessageResponse(message="Password changed successfully")
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
async def get_me(request: Request):
|
||||
user = await get_current_user_from_request(request)
|
||||
return UserResponse(id=str(user.id), email=user.email, system_role=user.system_role, needs_setup=user.needs_setup)
|
||||
|
||||
|
||||
@router.get("/setup-status")
|
||||
async def setup_status(auth_service: CurrentAuthService):
|
||||
return {"needs_setup": await auth_service.get_setup_status()}
|
||||
|
||||
|
||||
@router.post("/initialize", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def initialize_admin(
|
||||
request: Request,
|
||||
response: Response,
|
||||
body: InitializeAdminRequest,
|
||||
auth_service: CurrentAuthService,
|
||||
):
|
||||
try:
|
||||
user = await auth_service.initialize_admin(body.email, body.password)
|
||||
except AuthServiceError as exc:
|
||||
raise HTTPException(
|
||||
status_code=exc.status_code,
|
||||
detail=AuthErrorResponse(code=exc.code, message=exc.message).model_dump(),
|
||||
) from exc
|
||||
|
||||
token = create_access_token(str(user.id), token_version=user.token_version)
|
||||
_set_session_cookie(response, token, request)
|
||||
return UserResponse(id=str(user.id), email=user.email, system_role=user.system_role)
|
||||
|
||||
|
||||
@router.get("/oauth/{provider}")
|
||||
async def oauth_login(provider: str):
|
||||
if provider not in ["github", "google"]:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"Unsupported OAuth provider: {provider}")
|
||||
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail="OAuth login not yet implemented")
|
||||
|
||||
|
||||
@router.get("/callback/{provider}")
|
||||
async def oauth_callback(provider: str, code: str, state: str):
|
||||
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail="OAuth callback not yet implemented")
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ChangePasswordRequest",
|
||||
"InitializeAdminRequest",
|
||||
"LoginResponse",
|
||||
"MessageResponse",
|
||||
"RegisterRequest",
|
||||
"_check_rate_limit",
|
||||
"_get_client_ip",
|
||||
"_login_attempts",
|
||||
"_record_login_failure",
|
||||
"_record_login_success",
|
||||
"router",
|
||||
]
|
||||
@@ -0,0 +1,176 @@
|
||||
"""HTTP schemas and request helpers for the auth plugin API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
from ipaddress import ip_address, ip_network
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
from pydantic import BaseModel, EmailStr, Field, field_validator
|
||||
|
||||
_COMMON_PASSWORDS: frozenset[str] = frozenset(
|
||||
{
|
||||
"password",
|
||||
"password1",
|
||||
"password12",
|
||||
"password123",
|
||||
"password1234",
|
||||
"12345678",
|
||||
"123456789",
|
||||
"1234567890",
|
||||
"qwerty12",
|
||||
"qwertyui",
|
||||
"qwerty123",
|
||||
"abc12345",
|
||||
"abcd1234",
|
||||
"iloveyou",
|
||||
"letmein1",
|
||||
"welcome1",
|
||||
"welcome123",
|
||||
"admin123",
|
||||
"administrator",
|
||||
"passw0rd",
|
||||
"p@ssw0rd",
|
||||
"monkey12",
|
||||
"trustno1",
|
||||
"sunshine",
|
||||
"princess",
|
||||
"football",
|
||||
"baseball",
|
||||
"superman",
|
||||
"batman123",
|
||||
"starwars",
|
||||
"dragon123",
|
||||
"master123",
|
||||
"shadow12",
|
||||
"michael1",
|
||||
"jennifer",
|
||||
"computer",
|
||||
}
|
||||
)
|
||||
_MAX_LOGIN_ATTEMPTS = 5
|
||||
_LOCKOUT_SECONDS = 300
|
||||
_MAX_TRACKED_IPS = 10000
|
||||
_login_attempts: dict[str, tuple[int, float]] = {}
|
||||
|
||||
|
||||
class LoginResponse(BaseModel):
|
||||
expires_in: int
|
||||
needs_setup: bool = False
|
||||
|
||||
|
||||
class RegisterRequest(BaseModel):
|
||||
email: EmailStr
|
||||
password: str = Field(..., min_length=8)
|
||||
|
||||
_strong_password = field_validator("password")(classmethod(lambda cls, v: _validate_strong_password(v)))
|
||||
|
||||
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
current_password: str
|
||||
new_password: str = Field(..., min_length=8)
|
||||
new_email: EmailStr | None = None
|
||||
|
||||
_strong_password = field_validator("new_password")(classmethod(lambda cls, v: _validate_strong_password(v)))
|
||||
|
||||
|
||||
class MessageResponse(BaseModel):
|
||||
message: str
|
||||
|
||||
|
||||
class InitializeAdminRequest(BaseModel):
|
||||
email: EmailStr
|
||||
password: str = Field(..., min_length=8)
|
||||
|
||||
_strong_password = field_validator("password")(classmethod(lambda cls, v: _validate_strong_password(v)))
|
||||
|
||||
|
||||
def _password_is_common(password: str) -> bool:
|
||||
return password.lower() in _COMMON_PASSWORDS
|
||||
|
||||
|
||||
def _validate_strong_password(value: str) -> str:
|
||||
if _password_is_common(value):
|
||||
raise ValueError("Password is too common; choose a stronger password.")
|
||||
return value
|
||||
|
||||
|
||||
def _trusted_proxies() -> list:
|
||||
raw = os.getenv("AUTH_TRUSTED_PROXIES", "").strip()
|
||||
if not raw:
|
||||
return []
|
||||
nets = []
|
||||
for entry in raw.split(","):
|
||||
entry = entry.strip()
|
||||
if not entry:
|
||||
continue
|
||||
try:
|
||||
nets.append(ip_network(entry, strict=False))
|
||||
except ValueError:
|
||||
pass
|
||||
return nets
|
||||
|
||||
|
||||
def _get_client_ip(request: Request) -> str:
|
||||
peer_host = request.client.host if request.client else None
|
||||
trusted = _trusted_proxies()
|
||||
if trusted and peer_host:
|
||||
try:
|
||||
peer_ip = ip_address(peer_host)
|
||||
if any(peer_ip in net for net in trusted):
|
||||
real_ip = request.headers.get("x-real-ip", "").strip()
|
||||
if real_ip:
|
||||
return real_ip
|
||||
except ValueError:
|
||||
pass
|
||||
return peer_host or "unknown"
|
||||
|
||||
|
||||
def _check_rate_limit(ip: str) -> None:
|
||||
record = _login_attempts.get(ip)
|
||||
if record is None:
|
||||
return
|
||||
fail_count, lock_until = record
|
||||
if fail_count >= _MAX_LOGIN_ATTEMPTS:
|
||||
if time.time() < lock_until:
|
||||
raise HTTPException(status_code=429, detail="Too many login attempts. Try again later.")
|
||||
del _login_attempts[ip]
|
||||
|
||||
|
||||
def _record_login_failure(ip: str) -> None:
|
||||
if len(_login_attempts) >= _MAX_TRACKED_IPS:
|
||||
now = time.time()
|
||||
expired = [k for k, (c, t) in _login_attempts.items() if c >= _MAX_LOGIN_ATTEMPTS and now >= t]
|
||||
for key in expired:
|
||||
del _login_attempts[key]
|
||||
if len(_login_attempts) >= _MAX_TRACKED_IPS:
|
||||
by_time = sorted(_login_attempts.items(), key=lambda kv: kv[1][1])
|
||||
for key, _ in by_time[: len(by_time) // 2]:
|
||||
del _login_attempts[key]
|
||||
|
||||
record = _login_attempts.get(ip)
|
||||
if record is None:
|
||||
_login_attempts[ip] = (1, 0.0)
|
||||
else:
|
||||
new_count = record[0] + 1
|
||||
lock_until = time.time() + _LOCKOUT_SECONDS if new_count >= _MAX_LOGIN_ATTEMPTS else 0.0
|
||||
_login_attempts[ip] = (new_count, lock_until)
|
||||
|
||||
|
||||
def _record_login_success(ip: str) -> None:
|
||||
_login_attempts.pop(ip, None)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ChangePasswordRequest",
|
||||
"InitializeAdminRequest",
|
||||
"LoginResponse",
|
||||
"MessageResponse",
|
||||
"RegisterRequest",
|
||||
"_check_rate_limit",
|
||||
"_get_client_ip",
|
||||
"_login_attempts",
|
||||
"_record_login_failure",
|
||||
"_record_login_success",
|
||||
]
|
||||
Reference in New Issue
Block a user