Initial Claude implementation
This commit is contained in:
commit
e2d3ab9c2a
63 files changed
+3930
No files matched your search
Whitespace-only changes.
@@ -0,0 +1,52 @@
|
||||
"""Application configuration.
|
||||
|
||||
Settings are read from environment variables first, then from a `.env` file.
|
||||
Environment variables always take precedence over the file.
|
||||
"""
|
||||
from functools import lru_cache
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
extra="ignore",
|
||||
case_sensitive=False,
|
||||
)
|
||||
|
||||
# General
|
||||
app_name: str = "FastAPI Skeleton"
|
||||
debug: bool = False
|
||||
|
||||
# Security / JWT
|
||||
secret_key: str = "change-me-please-use-a-long-random-string"
|
||||
algorithm: str = "HS256"
|
||||
access_token_expire_minutes: int = 15
|
||||
refresh_token_expire_days: int = 7
|
||||
|
||||
# Database — single URL drives SQLite or Postgres.
|
||||
# SQLite: sqlite+aiosqlite:///./app.db
|
||||
# Postgres: postgresql+asyncpg://user:pass@host:5432/dbname
|
||||
database_url: str = "sqlite+aiosqlite:///./app.db"
|
||||
auto_create_tables: bool = True
|
||||
|
||||
# Web / cookies (the HTMX UI stores the access token in an httpOnly cookie)
|
||||
cookie_name: str = "access_token"
|
||||
cookie_secure: bool = False
|
||||
|
||||
# CORS
|
||||
cors_origins: list[str] = ["*"]
|
||||
|
||||
# Bootstrap superuser (used by scripts/seed.py)
|
||||
first_superuser_email: str = "admin@example.com"
|
||||
first_superuser_password: str = "changethis"
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
|
||||
|
||||
settings = get_settings()
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Async SQLAlchemy engine, session factory, and declarative base.
|
||||
|
||||
Works with SQLite (aiosqlite) or Postgres (asyncpg) depending on DATABASE_URL.
|
||||
"""
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncSession,
|
||||
async_sessionmaker,
|
||||
create_async_engine,
|
||||
)
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""Declarative base shared by all models."""
|
||||
|
||||
|
||||
engine = create_async_engine(settings.database_url, echo=settings.debug, future=True)
|
||||
|
||||
AsyncSessionLocal = async_sessionmaker(
|
||||
engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
autoflush=False,
|
||||
)
|
||||
|
||||
|
||||
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
"""FastAPI dependency yielding a request-scoped session."""
|
||||
async with AsyncSessionLocal() as session:
|
||||
yield session
|
||||
|
||||
|
||||
async def init_db() -> None:
|
||||
"""Create all tables. Convenient for dev/SQLite and tests.
|
||||
|
||||
In production prefer Alembic migrations (``alembic upgrade head``).
|
||||
"""
|
||||
# Import models so they register with Base.metadata before create_all.
|
||||
import app.models # noqa: F401
|
||||
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Password hashing and JWT token helpers."""
|
||||
import hashlib
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import bcrypt
|
||||
import jwt
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
ACCESS_TOKEN_TYPE = "access"
|
||||
REFRESH_TOKEN_TYPE = "refresh"
|
||||
|
||||
|
||||
# --- Passwords ---------------------------------------------------------------
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
|
||||
|
||||
|
||||
def verify_password(password: str, hashed: str) -> bool:
|
||||
try:
|
||||
return bcrypt.checkpw(password.encode(), hashed.encode())
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
# --- JWT ---------------------------------------------------------------------
|
||||
|
||||
def _create_token(
|
||||
subject: str | int,
|
||||
expires_delta: timedelta,
|
||||
token_type: str,
|
||||
) -> tuple[str, dict]:
|
||||
now = datetime.now(UTC)
|
||||
payload: dict = {
|
||||
"sub": str(subject),
|
||||
"iat": now,
|
||||
"exp": now + expires_delta,
|
||||
"type": token_type,
|
||||
"jti": uuid.uuid4().hex,
|
||||
}
|
||||
token = jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm)
|
||||
return token, payload
|
||||
|
||||
|
||||
def create_access_token(subject: str | int) -> str:
|
||||
token, _ = _create_token(
|
||||
subject,
|
||||
timedelta(minutes=settings.access_token_expire_minutes),
|
||||
ACCESS_TOKEN_TYPE,
|
||||
)
|
||||
return token
|
||||
|
||||
|
||||
def create_refresh_token(subject: str | int) -> tuple[str, dict]:
|
||||
"""Return ``(token, payload)`` so the caller can persist jti/exp for revocation."""
|
||||
return _create_token(
|
||||
subject,
|
||||
timedelta(days=settings.refresh_token_expire_days),
|
||||
REFRESH_TOKEN_TYPE,
|
||||
)
|
||||
|
||||
|
||||
def decode_token(token: str) -> dict:
|
||||
"""Decode and validate a JWT. Raises ``jwt.PyJWTError`` on failure."""
|
||||
return jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
|
||||
|
||||
|
||||
def hash_token(token: str) -> str:
|
||||
"""Stable hash used to store refresh tokens without keeping the raw value."""
|
||||
return hashlib.sha256(token.encode()).hexdigest()
|
||||
Reference in new issue
Block a user