hinbert-fastapi 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- app/__init__.py +8 -0
- app/api/__init__.py +1 -0
- app/api/deps/__init__.py +1 -0
- app/api/deps/auth.py +40 -0
- app/api/deps/pagination.py +8 -0
- app/api/v1/__init__.py +1 -0
- app/api/v1/endpoints/__init__.py +1 -0
- app/api/v1/endpoints/auth.py +255 -0
- app/api/v1/endpoints/dashboard.py +23 -0
- app/api/v1/endpoints/products.py +102 -0
- app/api/v1/endpoints/users.py +89 -0
- app/api/v1/routers/__init__.py +1 -0
- app/api/v1/routers/api_router.py +11 -0
- app/core/__init__.py +1 -0
- app/core/config/__init__.py +1 -0
- app/core/config/database.py +13 -0
- app/core/config/settings.py +83 -0
- app/core/exceptions/__init__.py +1 -0
- app/core/exceptions/base_exception.py +9 -0
- app/core/exceptions/custom_exceptions.py +17 -0
- app/core/middleware/__init__.py +1 -0
- app/core/middleware/cors.py +5 -0
- app/core/middleware/error_handler.py +23 -0
- app/core/middleware/logging.py +29 -0
- app/core/middleware/rate_limit.py +27 -0
- app/core/security/__init__.py +1 -0
- app/core/security/auth.py +8 -0
- app/core/security/jwt.py +33 -0
- app/core/security/oauth.py +8 -0
- app/core/security/password.py +18 -0
- app/core/security/totp.py +13 -0
- app/db/__init__.py +1 -0
- app/db/base.py +7 -0
- app/db/migrations/env.py +39 -0
- app/db/migrations/versions/97ed1bc05f4a_complete_product_and_totp_fields.py +49 -0
- app/db/migrations/versions/f8b632aa11da_initial_migration.py +113 -0
- app/db/session.py +11 -0
- app/main.py +50 -0
- app/models/__init__.py +10 -0
- app/models/domain/__init__.py +1 -0
- app/models/domain/base.py +20 -0
- app/models/domain/email_verification.py +24 -0
- app/models/domain/password_reset.py +24 -0
- app/models/domain/product.py +22 -0
- app/models/domain/refresh_token.py +24 -0
- app/models/domain/totp_secret.py +44 -0
- app/models/domain/user.py +34 -0
- app/models/schemas/__init__.py +1 -0
- app/models/schemas/auth.py +30 -0
- app/models/schemas/password.py +16 -0
- app/models/schemas/product.py +31 -0
- app/models/schemas/response.py +17 -0
- app/models/schemas/token.py +17 -0
- app/models/schemas/totp.py +16 -0
- app/models/schemas/user.py +37 -0
- app/repositories/__init__.py +1 -0
- app/repositories/base/__init__.py +1 -0
- app/repositories/base/base_repository.py +51 -0
- app/repositories/email_verification_repository.py +37 -0
- app/repositories/password_reset_repository.py +33 -0
- app/repositories/product_repository.py +40 -0
- app/repositories/refresh_token_repository.py +40 -0
- app/repositories/totp_secret_repository.py +23 -0
- app/repositories/user_repository.py +44 -0
- app/services/__init__.py +1 -0
- app/services/auth_service.py +32 -0
- app/services/base/__init__.py +1 -0
- app/services/base/base_service.py +21 -0
- app/services/email_service.py +32 -0
- app/services/product_service.py +24 -0
- app/services/social_auth_service.py +47 -0
- app/services/totp_service.py +13 -0
- app/services/user_service.py +27 -0
- app/tests/__init__.py +1 -0
- app/tests/conftest.py +36 -0
- app/tests/integration/test_auth_api.py +115 -0
- app/tests/integration/test_product_api.py +58 -0
- app/tests/integration/test_user_api.py +68 -0
- app/tests/unit/test_auth_service.py +48 -0
- app/tests/unit/test_product_service.py +33 -0
- app/tests/unit/test_user_service.py +32 -0
- app/utils/__init__.py +1 -0
- app/utils/date_utils.py +8 -0
- app/utils/file_utils.py +8 -0
- app/utils/logger.py +11 -0
- app/utils/validators.py +13 -0
- hinbert_fastapi-0.1.0.dist-info/METADATA +0 -0
- hinbert_fastapi-0.1.0.dist-info/RECORD +96 -0
- hinbert_fastapi-0.1.0.dist-info/WHEEL +5 -0
- hinbert_fastapi-0.1.0.dist-info/entry_points.txt +5 -0
- hinbert_fastapi-0.1.0.dist-info/licenses/LICENSE +21 -0
- hinbert_fastapi-0.1.0.dist-info/top_level.txt +2 -0
- scripts/__init__.py +1 -0
- scripts/create_admin.py +41 -0
- scripts/run_migrations.py +13 -0
- scripts/seed_data.py +44 -0
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Base exception carrying safe, client-facing error metadata."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class AppException(Exception):
|
|
5
|
+
"""Domain error with HTTP status and serializable validation details."""
|
|
6
|
+
|
|
7
|
+
def __init__(self, message: str, status_code: int = 400, errors: list[str] | None = None):
|
|
8
|
+
super().__init__(message)
|
|
9
|
+
self.message, self.status_code, self.errors = message, status_code, errors or []
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Frequently used domain exceptions with stable status codes."""
|
|
2
|
+
|
|
3
|
+
from app.core.exceptions.base_exception import AppException
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class NotFoundError(AppException):
|
|
7
|
+
"""Represent a requested resource that is absent."""
|
|
8
|
+
|
|
9
|
+
def __init__(self, message: str = "Resource not found"):
|
|
10
|
+
super().__init__(message, 404)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class UnauthorizedError(AppException):
|
|
14
|
+
"""Represent missing or invalid authentication."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, message: str = "Authentication required"):
|
|
17
|
+
super().__init__(message, 401)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""HTTP middleware and exception handling components."""
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Consistent JSON handling for domain exceptions and unexpected failures."""
|
|
2
|
+
|
|
3
|
+
from fastapi import FastAPI, Request
|
|
4
|
+
from fastapi.responses import JSONResponse
|
|
5
|
+
|
|
6
|
+
from app.core.exceptions.base_exception import AppException
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def register_exception_handlers(application: FastAPI) -> None:
|
|
10
|
+
"""Register handlers that preserve the public response contract."""
|
|
11
|
+
|
|
12
|
+
@application.exception_handler(AppException)
|
|
13
|
+
async def handle_app_exception(_: Request, exc: AppException) -> JSONResponse:
|
|
14
|
+
return JSONResponse(
|
|
15
|
+
status_code=exc.status_code,
|
|
16
|
+
content={
|
|
17
|
+
"success": False,
|
|
18
|
+
"message": exc.message,
|
|
19
|
+
"data": None,
|
|
20
|
+
"errors": exc.errors,
|
|
21
|
+
"status_code": exc.status_code,
|
|
22
|
+
},
|
|
23
|
+
)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Request timing middleware with correlation-friendly structured logging."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
|
|
5
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
6
|
+
from starlette.requests import Request
|
|
7
|
+
|
|
8
|
+
from app.utils.logger import logger
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class RequestLoggingMiddleware(BaseHTTPMiddleware):
|
|
12
|
+
"""Log method, path, status, and duration without logging request bodies."""
|
|
13
|
+
|
|
14
|
+
async def dispatch(self, request: Request, call_next):
|
|
15
|
+
"""Process one request and record an operational timing event."""
|
|
16
|
+
started = time.perf_counter()
|
|
17
|
+
response = await call_next(request)
|
|
18
|
+
response.headers["X-Content-Type-Options"] = "nosniff"
|
|
19
|
+
response.headers["X-Frame-Options"] = "DENY"
|
|
20
|
+
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
|
21
|
+
response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()"
|
|
22
|
+
logger.info(
|
|
23
|
+
"{method} {path} {status} {duration:.3f}s",
|
|
24
|
+
method=request.method,
|
|
25
|
+
path=request.url.path,
|
|
26
|
+
status=response.status_code,
|
|
27
|
+
duration=time.perf_counter() - started,
|
|
28
|
+
)
|
|
29
|
+
return response
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""SlowAPI limiter configuration and exception registration."""
|
|
2
|
+
|
|
3
|
+
from fastapi import FastAPI, Request
|
|
4
|
+
from fastapi.responses import JSONResponse
|
|
5
|
+
from slowapi import Limiter
|
|
6
|
+
from slowapi.errors import RateLimitExceeded
|
|
7
|
+
from slowapi.util import get_remote_address
|
|
8
|
+
|
|
9
|
+
limiter = Limiter(key_func=get_remote_address)
|
|
10
|
+
DEFAULT_LIMIT = "100/minute"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def register_rate_limit(application: FastAPI) -> None:
|
|
14
|
+
"""Register a uniform response for requests exceeding configured limits."""
|
|
15
|
+
|
|
16
|
+
@application.exception_handler(RateLimitExceeded)
|
|
17
|
+
async def handle_rate_limit(_: Request, __: RateLimitExceeded) -> JSONResponse:
|
|
18
|
+
return JSONResponse(
|
|
19
|
+
status_code=429,
|
|
20
|
+
content={
|
|
21
|
+
"success": False,
|
|
22
|
+
"message": "Rate limit exceeded",
|
|
23
|
+
"data": None,
|
|
24
|
+
"errors": [],
|
|
25
|
+
"status_code": 429,
|
|
26
|
+
},
|
|
27
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Authentication, authorization, and cryptographic helpers."""
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Small authentication primitives shared by services and dependencies."""
|
|
2
|
+
|
|
3
|
+
from app.core.security.password import verify_password
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def authenticate_password(password: str, password_hash: str) -> bool:
|
|
7
|
+
"""Return whether credentials match without exposing password details."""
|
|
8
|
+
return verify_password(password, password_hash)
|
app/core/security/jwt.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""JWT access-token creation and validation.
|
|
2
|
+
|
|
3
|
+
Access tokens are short-lived and carry only a subject and token type. Refresh
|
|
4
|
+
tokens are generated separately and must be hashed before database persistence.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from datetime import UTC, datetime, timedelta
|
|
8
|
+
from typing import Any
|
|
9
|
+
from uuid import UUID
|
|
10
|
+
|
|
11
|
+
from jose import JWTError, jwt
|
|
12
|
+
|
|
13
|
+
from app.core.config.settings import get_settings
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def create_token(subject: UUID | str, token_type: str, expires_delta: timedelta) -> str:
|
|
17
|
+
"""Create a signed JWT for a subject and explicit token type."""
|
|
18
|
+
now = datetime.now(UTC)
|
|
19
|
+
payload: dict[str, Any] = {"sub": str(subject), "type": token_type, "iat": now, "exp": now + expires_delta}
|
|
20
|
+
settings = get_settings()
|
|
21
|
+
return jwt.encode(payload, settings.jwt_secret_key.get_secret_value(), algorithm=settings.jwt_algorithm)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def decode_token(token: str, expected_type: str = "access") -> dict[str, Any]:
|
|
25
|
+
"""Decode and validate a JWT, raising ``ValueError`` for invalid tokens."""
|
|
26
|
+
settings = get_settings()
|
|
27
|
+
try:
|
|
28
|
+
payload = jwt.decode(token, settings.jwt_secret_key.get_secret_value(), algorithms=[settings.jwt_algorithm])
|
|
29
|
+
except JWTError as exc:
|
|
30
|
+
raise ValueError("Invalid or expired token") from exc
|
|
31
|
+
if payload.get("type") != expected_type or not payload.get("sub"):
|
|
32
|
+
raise ValueError("Invalid token type")
|
|
33
|
+
return payload
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""OAuth provider configuration boundary.
|
|
2
|
+
|
|
3
|
+
Provider exchange logic belongs in ``social_auth_service`` so credentials and
|
|
4
|
+
HTTP clients can be mocked in tests. Add provider-specific scopes and callback
|
|
5
|
+
URLs there without coupling the rest of the application to an SDK.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
SUPPORTED_PROVIDERS = ("google", "facebook")
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Password hashing using bcrypt through Passlib.
|
|
2
|
+
|
|
3
|
+
Only password hashes are persisted; plaintext passwords are never logged or
|
|
4
|
+
returned. Replace the scheme configuration here if an enterprise KMS policy
|
|
5
|
+
requires a different approved password verifier.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import bcrypt
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def hash_password(password: str) -> str:
|
|
12
|
+
"""Hash a password with bcrypt and return the encoded hash."""
|
|
13
|
+
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def verify_password(password: str, password_hash: str) -> bool:
|
|
17
|
+
"""Constant-time verify a candidate password against its stored hash."""
|
|
18
|
+
return bcrypt.checkpw(password.encode("utf-8"), password_hash.encode("utf-8"))
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""TOTP primitives for optional multi-factor authentication."""
|
|
2
|
+
|
|
3
|
+
import pyotp
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def new_secret() -> str:
|
|
7
|
+
"""Generate a cryptographically random Base32 TOTP secret."""
|
|
8
|
+
return pyotp.random_base32()
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def verify_code(secret: str, code: str) -> bool:
|
|
12
|
+
"""Verify a current TOTP code with a small clock-skew window."""
|
|
13
|
+
return bool(pyotp.TOTP(secret).verify(code, valid_window=1))
|
app/db/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Database session and migration support."""
|
app/db/base.py
ADDED
app/db/migrations/env.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Alembic environment configured for async SQLAlchemy metadata discovery."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
|
|
5
|
+
from alembic import context
|
|
6
|
+
from sqlalchemy import pool
|
|
7
|
+
from sqlalchemy.ext.asyncio import async_engine_from_config
|
|
8
|
+
|
|
9
|
+
import app.models # noqa: F401
|
|
10
|
+
from app.core.config.settings import get_settings
|
|
11
|
+
from app.db.base import Base
|
|
12
|
+
|
|
13
|
+
config = context.config
|
|
14
|
+
config.set_main_option("sqlalchemy.url", get_settings().database_url.replace("%", "%%"))
|
|
15
|
+
target_metadata = Base.metadata
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def run_migrations_offline() -> None:
|
|
19
|
+
"""Generate SQL without opening a database connection."""
|
|
20
|
+
context.configure(url=get_settings().database_url, target_metadata=target_metadata, literal_binds=True)
|
|
21
|
+
with context.begin_transaction():
|
|
22
|
+
context.run_migrations()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
async def run_migrations_online() -> None:
|
|
26
|
+
"""Run migrations through the async database driver."""
|
|
27
|
+
connectable = async_engine_from_config(
|
|
28
|
+
config.get_section(config.config_ini_section, {}), prefix="sqlalchemy.", poolclass=pool.NullPool
|
|
29
|
+
)
|
|
30
|
+
async with connectable.connect() as connection:
|
|
31
|
+
await connection.run_sync(lambda sync: context.configure(connection=sync, target_metadata=target_metadata))
|
|
32
|
+
await connection.run_sync(lambda _: context.run_migrations())
|
|
33
|
+
await connectable.dispose()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
if context.is_offline_mode():
|
|
37
|
+
run_migrations_offline()
|
|
38
|
+
else:
|
|
39
|
+
asyncio.run(run_migrations_online())
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""complete_product_and_totp_fields
|
|
2
|
+
|
|
3
|
+
Revision ID: 97ed1bc05f4a
|
|
4
|
+
Revises: f8b632aa11da
|
|
5
|
+
Create Date: 2026-09-04 03:49:08.642114
|
|
6
|
+
|
|
7
|
+
This generated migration is reviewed source code. Keep schema changes small,
|
|
8
|
+
reversible, and paired with application compatibility during rollout.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from collections.abc import Sequence
|
|
12
|
+
|
|
13
|
+
import sqlalchemy as sa
|
|
14
|
+
from alembic import op
|
|
15
|
+
|
|
16
|
+
revision: str = "97ed1bc05f4a"
|
|
17
|
+
down_revision: str | Sequence[str] | None = "f8b632aa11da"
|
|
18
|
+
branch_labels: str | Sequence[str] | None = None
|
|
19
|
+
depends_on: str | Sequence[str] | None = None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def upgrade() -> None:
|
|
23
|
+
"""Apply the generated schema change."""
|
|
24
|
+
# ### commands auto generated by Alembic - please adjust! ###
|
|
25
|
+
op.add_column("products", sa.Column("category", sa.String(length=100), nullable=False, server_default="general"))
|
|
26
|
+
op.add_column("products", sa.Column("owner_id", sa.Uuid(), nullable=True))
|
|
27
|
+
op.create_index(op.f("ix_products_category"), "products", ["category"], unique=False)
|
|
28
|
+
op.create_index(op.f("ix_products_owner_id"), "products", ["owner_id"], unique=False)
|
|
29
|
+
with op.batch_alter_table("products", recreate="always") as batch_op:
|
|
30
|
+
batch_op.create_foreign_key("fk_products_owner_id_users", "users", ["owner_id"], ["id"], ondelete="SET NULL")
|
|
31
|
+
with op.batch_alter_table("totp_secrets") as batch_op:
|
|
32
|
+
batch_op.add_column(sa.Column("encrypted_secret", sa.String(length=512), nullable=True))
|
|
33
|
+
batch_op.drop_column("secret")
|
|
34
|
+
# ### end Alembic commands ###
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def downgrade() -> None:
|
|
38
|
+
"""Reverse the generated schema change."""
|
|
39
|
+
# ### commands auto generated by Alembic - please adjust! ###
|
|
40
|
+
with op.batch_alter_table("totp_secrets") as batch_op:
|
|
41
|
+
batch_op.add_column(sa.Column("secret", sa.VARCHAR(length=128), nullable=True))
|
|
42
|
+
batch_op.drop_column("encrypted_secret")
|
|
43
|
+
with op.batch_alter_table("products") as batch_op:
|
|
44
|
+
batch_op.drop_constraint("fk_products_owner_id_users", type_="foreignkey")
|
|
45
|
+
batch_op.drop_index(op.f("ix_products_owner_id"))
|
|
46
|
+
batch_op.drop_index(op.f("ix_products_category"))
|
|
47
|
+
batch_op.drop_column("owner_id")
|
|
48
|
+
batch_op.drop_column("category")
|
|
49
|
+
# ### end Alembic commands ###
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""initial_migration
|
|
2
|
+
|
|
3
|
+
Revision ID: f8b632aa11da
|
|
4
|
+
Revises:
|
|
5
|
+
Create Date: 2026-09-04 03:32:35.906981
|
|
6
|
+
|
|
7
|
+
This generated migration is reviewed source code. Keep schema changes small,
|
|
8
|
+
reversible, and paired with application compatibility during rollout.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from collections.abc import Sequence
|
|
12
|
+
|
|
13
|
+
import sqlalchemy as sa
|
|
14
|
+
from alembic import op
|
|
15
|
+
|
|
16
|
+
revision: str = "f8b632aa11da"
|
|
17
|
+
down_revision: str | Sequence[str] | None = None
|
|
18
|
+
branch_labels: str | Sequence[str] | None = None
|
|
19
|
+
depends_on: str | Sequence[str] | None = None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def upgrade() -> None:
|
|
23
|
+
"""Apply the generated schema change."""
|
|
24
|
+
# ### commands auto generated by Alembic - please adjust! ###
|
|
25
|
+
op.create_table(
|
|
26
|
+
"products",
|
|
27
|
+
sa.Column("name", sa.String(length=200), nullable=False),
|
|
28
|
+
sa.Column("description", sa.Text(), nullable=False),
|
|
29
|
+
sa.Column("price", sa.Numeric(precision=12, scale=2), nullable=False),
|
|
30
|
+
sa.Column("id", sa.Uuid(), nullable=False),
|
|
31
|
+
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
|
32
|
+
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
|
33
|
+
sa.PrimaryKeyConstraint("id"),
|
|
34
|
+
)
|
|
35
|
+
op.create_index(op.f("ix_products_name"), "products", ["name"], unique=False)
|
|
36
|
+
op.create_table(
|
|
37
|
+
"users",
|
|
38
|
+
sa.Column("email", sa.String(length=320), nullable=False),
|
|
39
|
+
sa.Column("full_name", sa.String(length=200), nullable=False),
|
|
40
|
+
sa.Column("password_hash", sa.String(length=255), nullable=False),
|
|
41
|
+
sa.Column("is_active", sa.Boolean(), nullable=False),
|
|
42
|
+
sa.Column("is_admin", sa.Boolean(), nullable=False),
|
|
43
|
+
sa.Column("is_verified", sa.Boolean(), nullable=False),
|
|
44
|
+
sa.Column("id", sa.Uuid(), nullable=False),
|
|
45
|
+
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
|
46
|
+
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
|
47
|
+
sa.PrimaryKeyConstraint("id"),
|
|
48
|
+
)
|
|
49
|
+
op.create_index(op.f("ix_users_email"), "users", ["email"], unique=True)
|
|
50
|
+
op.create_table(
|
|
51
|
+
"email_verifications",
|
|
52
|
+
sa.Column("user_id", sa.Uuid(), nullable=False),
|
|
53
|
+
sa.Column("token_hash", sa.String(length=64), nullable=False),
|
|
54
|
+
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
|
55
|
+
sa.Column("used_at", sa.DateTime(timezone=True), nullable=True),
|
|
56
|
+
sa.Column("id", sa.Uuid(), nullable=False),
|
|
57
|
+
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
|
58
|
+
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
|
59
|
+
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
|
60
|
+
sa.PrimaryKeyConstraint("id"),
|
|
61
|
+
sa.UniqueConstraint("token_hash"),
|
|
62
|
+
)
|
|
63
|
+
op.create_table(
|
|
64
|
+
"password_resets",
|
|
65
|
+
sa.Column("user_id", sa.Uuid(), nullable=False),
|
|
66
|
+
sa.Column("token_hash", sa.String(length=64), nullable=False),
|
|
67
|
+
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
|
68
|
+
sa.Column("used_at", sa.DateTime(timezone=True), nullable=True),
|
|
69
|
+
sa.Column("id", sa.Uuid(), nullable=False),
|
|
70
|
+
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
|
71
|
+
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
|
72
|
+
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
|
73
|
+
sa.PrimaryKeyConstraint("id"),
|
|
74
|
+
sa.UniqueConstraint("token_hash"),
|
|
75
|
+
)
|
|
76
|
+
op.create_table(
|
|
77
|
+
"refresh_tokens",
|
|
78
|
+
sa.Column("user_id", sa.Uuid(), nullable=False),
|
|
79
|
+
sa.Column("token_hash", sa.String(length=64), nullable=False),
|
|
80
|
+
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
|
81
|
+
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
|
|
82
|
+
sa.Column("id", sa.Uuid(), nullable=False),
|
|
83
|
+
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
|
84
|
+
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
|
85
|
+
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
|
86
|
+
sa.PrimaryKeyConstraint("id"),
|
|
87
|
+
)
|
|
88
|
+
op.create_index(op.f("ix_refresh_tokens_token_hash"), "refresh_tokens", ["token_hash"], unique=True)
|
|
89
|
+
op.create_index(op.f("ix_refresh_tokens_user_id"), "refresh_tokens", ["user_id"], unique=False)
|
|
90
|
+
op.create_table(
|
|
91
|
+
"totp_secrets",
|
|
92
|
+
sa.Column("user_id", sa.Uuid(), nullable=False),
|
|
93
|
+
sa.Column("secret", sa.String(length=128), nullable=False),
|
|
94
|
+
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
|
95
|
+
sa.PrimaryKeyConstraint("user_id"),
|
|
96
|
+
)
|
|
97
|
+
# ### end Alembic commands ###
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def downgrade() -> None:
|
|
101
|
+
"""Reverse the generated schema change."""
|
|
102
|
+
# ### commands auto generated by Alembic - please adjust! ###
|
|
103
|
+
op.drop_table("totp_secrets")
|
|
104
|
+
op.drop_index(op.f("ix_refresh_tokens_user_id"), table_name="refresh_tokens")
|
|
105
|
+
op.drop_index(op.f("ix_refresh_tokens_token_hash"), table_name="refresh_tokens")
|
|
106
|
+
op.drop_table("refresh_tokens")
|
|
107
|
+
op.drop_table("password_resets")
|
|
108
|
+
op.drop_table("email_verifications")
|
|
109
|
+
op.drop_index(op.f("ix_users_email"), table_name="users")
|
|
110
|
+
op.drop_table("users")
|
|
111
|
+
op.drop_index(op.f("ix_products_name"), table_name="products")
|
|
112
|
+
op.drop_table("products")
|
|
113
|
+
# ### end Alembic commands ###
|
app/db/session.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""FastAPI dependency that yields and closes one async DB session."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import AsyncGenerator
|
|
4
|
+
|
|
5
|
+
from app.core.config.database import SessionLocal
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
async def get_db() -> AsyncGenerator:
|
|
9
|
+
"""Yield a request-scoped SQLAlchemy session and always close it."""
|
|
10
|
+
async with SessionLocal() as session:
|
|
11
|
+
yield session
|
app/main.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Create the FastAPI application and assemble cross-cutting concerns.
|
|
2
|
+
|
|
3
|
+
The factory pattern keeps imports side-effect-light for tests, workers, and CLI
|
|
4
|
+
scripts. Customize middleware and router registration here; business logic
|
|
5
|
+
belongs in services rather than in this composition module.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from fastapi import FastAPI, Request
|
|
9
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
10
|
+
from slowapi.middleware import SlowAPIMiddleware
|
|
11
|
+
|
|
12
|
+
from app.api.v1.routers.api_router import api_router
|
|
13
|
+
from app.core.config.settings import get_settings
|
|
14
|
+
from app.core.middleware.error_handler import register_exception_handlers
|
|
15
|
+
from app.core.middleware.logging import RequestLoggingMiddleware
|
|
16
|
+
from app.core.middleware.rate_limit import limiter, register_rate_limit
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def create_app() -> FastAPI:
|
|
20
|
+
"""Build and configure the application.
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
A configured FastAPI instance with OpenAPI documentation enabled.
|
|
24
|
+
"""
|
|
25
|
+
settings = get_settings()
|
|
26
|
+
application = FastAPI(title=settings.app_name, version="0.1.0")
|
|
27
|
+
application.state.limiter = limiter
|
|
28
|
+
application.add_middleware(SlowAPIMiddleware)
|
|
29
|
+
application.add_middleware(RequestLoggingMiddleware)
|
|
30
|
+
application.add_middleware(
|
|
31
|
+
CORSMiddleware,
|
|
32
|
+
allow_origins=settings.cors_origins,
|
|
33
|
+
allow_credentials=True,
|
|
34
|
+
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE"],
|
|
35
|
+
allow_headers=["Authorization", "Content-Type"],
|
|
36
|
+
)
|
|
37
|
+
application.include_router(api_router, prefix=settings.api_prefix)
|
|
38
|
+
register_exception_handlers(application)
|
|
39
|
+
register_rate_limit(application)
|
|
40
|
+
|
|
41
|
+
@application.get("/health", tags=["health"])
|
|
42
|
+
@limiter.limit("120/minute")
|
|
43
|
+
async def health(request: Request) -> dict[str, str]:
|
|
44
|
+
"""Return a lightweight liveness response for load balancers and probes."""
|
|
45
|
+
return {"status": "ok"}
|
|
46
|
+
|
|
47
|
+
return application
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
app = create_app()
|
app/models/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Public data-model namespace; import concrete models here for Alembic discovery."""
|
|
2
|
+
|
|
3
|
+
from app.models.domain.email_verification import EmailVerification
|
|
4
|
+
from app.models.domain.password_reset import PasswordReset
|
|
5
|
+
from app.models.domain.product import Product
|
|
6
|
+
from app.models.domain.refresh_token import RefreshToken
|
|
7
|
+
from app.models.domain.totp_secret import TotpSecret
|
|
8
|
+
from app.models.domain.user import User
|
|
9
|
+
|
|
10
|
+
__all__ = ["EmailVerification", "PasswordReset", "Product", "RefreshToken", "TotpSecret", "User"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""SQLAlchemy domain model namespace."""
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Reusable UUID primary key and UTC audit timestamps."""
|
|
2
|
+
|
|
3
|
+
from datetime import UTC, datetime
|
|
4
|
+
from uuid import UUID, uuid4
|
|
5
|
+
|
|
6
|
+
from sqlalchemy import DateTime
|
|
7
|
+
from sqlalchemy.orm import Mapped, mapped_column
|
|
8
|
+
|
|
9
|
+
from app.db.base import Base
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TimestampedModel(Base):
|
|
13
|
+
"""Abstract table mixin providing stable IDs and audit timestamps."""
|
|
14
|
+
|
|
15
|
+
__abstract__ = True
|
|
16
|
+
id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid4)
|
|
17
|
+
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
|
|
18
|
+
updated_at: Mapped[datetime] = mapped_column(
|
|
19
|
+
DateTime(timezone=True), default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC)
|
|
20
|
+
)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Hashed, expiring email verification tokens."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
from uuid import UUID
|
|
6
|
+
|
|
7
|
+
from sqlalchemy import DateTime, ForeignKey, String
|
|
8
|
+
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
9
|
+
|
|
10
|
+
from app.models.domain.base import TimestampedModel
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from app.models.domain.user import User
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class EmailVerification(TimestampedModel):
|
|
17
|
+
"""Single-use verification token linked to a user."""
|
|
18
|
+
|
|
19
|
+
__tablename__ = "email_verifications"
|
|
20
|
+
user_id: Mapped[UUID] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
|
|
21
|
+
token_hash: Mapped[str] = mapped_column(String(64), unique=True)
|
|
22
|
+
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
|
23
|
+
used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
24
|
+
user: Mapped["User"] = relationship(back_populates="email_verifications")
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Password reset token persistence boundary."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
from uuid import UUID
|
|
6
|
+
|
|
7
|
+
from sqlalchemy import DateTime, ForeignKey, String
|
|
8
|
+
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
9
|
+
|
|
10
|
+
from app.models.domain.base import TimestampedModel
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from app.models.domain.user import User
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class PasswordReset(TimestampedModel):
|
|
17
|
+
"""Document the reset-token contract; use a dedicated table in migrations."""
|
|
18
|
+
|
|
19
|
+
__tablename__ = "password_resets"
|
|
20
|
+
user_id: Mapped[UUID] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
|
|
21
|
+
token_hash: Mapped[str] = mapped_column(String(64), unique=True)
|
|
22
|
+
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
|
23
|
+
used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
24
|
+
user: Mapped["User"] = relationship(back_populates="password_resets")
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Product table used by the example CRUD surface."""
|
|
2
|
+
|
|
3
|
+
from decimal import Decimal
|
|
4
|
+
from uuid import UUID
|
|
5
|
+
|
|
6
|
+
from sqlalchemy import ForeignKey, Numeric, String, Text
|
|
7
|
+
from sqlalchemy.orm import Mapped, mapped_column
|
|
8
|
+
|
|
9
|
+
from app.models.domain.base import TimestampedModel
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Product(TimestampedModel):
|
|
13
|
+
"""Sellable product with indexed name and exact numeric price."""
|
|
14
|
+
|
|
15
|
+
__tablename__ = "products"
|
|
16
|
+
name: Mapped[str] = mapped_column(String(200), index=True)
|
|
17
|
+
description: Mapped[str] = mapped_column(Text, default="")
|
|
18
|
+
price: Mapped[Decimal] = mapped_column(Numeric(12, 2))
|
|
19
|
+
category: Mapped[str] = mapped_column(String(100), default="general", index=True)
|
|
20
|
+
owner_id: Mapped[UUID | None] = mapped_column(
|
|
21
|
+
ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True
|
|
22
|
+
)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Refresh-token allowlist storing only SHA-256 digests."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
from uuid import UUID
|
|
6
|
+
|
|
7
|
+
from sqlalchemy import DateTime, ForeignKey, String
|
|
8
|
+
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
9
|
+
|
|
10
|
+
from app.models.domain.base import TimestampedModel
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from app.models.domain.user import User
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class RefreshToken(TimestampedModel):
|
|
17
|
+
"""Revocable refresh session; raw bearer tokens never enter the database."""
|
|
18
|
+
|
|
19
|
+
__tablename__ = "refresh_tokens"
|
|
20
|
+
user_id: Mapped[UUID] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
|
21
|
+
token_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
|
22
|
+
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
|
23
|
+
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
24
|
+
user: Mapped["User"] = relationship(back_populates="refresh_tokens")
|