Add deployment Docker Compose file, testing utilities, security helpers, and database initialization script

- Introduced `docker-compose.deploy.yml` for deployment scenarios with pre-built Docker images.
- Added `auth_test_utils.py` to simplify authentication testing in FastAPI.
- Implemented `security.py` for token-based operations like file uploads and password resets.
- Created `init_db.py` for database initialization and superuser creation during startup.
- Updated dependencies and tests to support optional authentication in FastAPI.
- Enhanced entrypoint script to handle database initialization.
This commit is contained in:
Felipe Cardoso
2025-10-29 22:30:43 +01:00
parent f87e0dd3b0
commit 6d34f81912
9 changed files with 859 additions and 2 deletions

View File

@@ -0,0 +1,129 @@
"""
Authentication utilities for testing.
This module provides tools to bypass FastAPI's authentication in tests.
"""
from typing import Callable, Dict, Optional
from fastapi import FastAPI
from fastapi.security import OAuth2PasswordBearer
from starlette.testclient import TestClient
from app.api.dependencies.auth import get_current_user, get_optional_current_user
from app.models.user import User
def create_test_auth_client(
app: FastAPI,
test_user: User,
extra_overrides: Optional[Dict[Callable, Callable]] = None
) -> TestClient:
"""
Create a test client with authentication pre-configured.
This bypasses the OAuth2 token validation and directly returns the test user.
Args:
app: The FastAPI app to test
test_user: The user object to use for authentication
extra_overrides: Additional dependency overrides to apply
Returns:
TestClient with authentication configured
"""
# First override the oauth2_scheme dependency to return a dummy token
# This prevents FastAPI from trying to extract a real bearer token from the request
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
app.dependency_overrides[oauth2_scheme] = lambda: "dummy_token_for_testing"
# Then override the get_current_user dependency to return our test user
app.dependency_overrides[get_current_user] = lambda: test_user
# Apply any extra overrides
if extra_overrides:
for dep, override in extra_overrides.items():
app.dependency_overrides[dep] = override
# Create and return the client
return TestClient(app)
def create_test_optional_auth_client(
app: FastAPI,
test_user: User
) -> TestClient:
"""
Create a test client with optional authentication pre-configured.
This is useful for testing endpoints that use get_optional_current_user.
Args:
app: The FastAPI app to test
test_user: The user object to use for authentication
Returns:
TestClient with optional authentication configured
"""
# Override the get_optional_current_user dependency
app.dependency_overrides[get_optional_current_user] = lambda: test_user
# Create and return the client
return TestClient(app)
def create_test_superuser_client(
app: FastAPI,
test_user: User
) -> TestClient:
"""
Create a test client with superuser authentication pre-configured.
Args:
app: The FastAPI app to test
test_user: The user object to use as superuser
Returns:
TestClient with superuser authentication
"""
# Make sure user is a superuser
test_user.is_superuser = True
# Use the auth client creation with superuser
return create_test_auth_client(app, test_user)
def create_test_unauthenticated_client(app: FastAPI) -> TestClient:
"""
Create a test client that will fail authentication checks.
This is useful for testing the unauthorized case of protected endpoints.
Args:
app: The FastAPI app to test
Returns:
TestClient without authentication
"""
# Any authentication attempts will fail
return TestClient(app)
def cleanup_test_client_auth(app: FastAPI) -> None:
"""
Clean up authentication overrides from the FastAPI app.
Call this after your tests to restore normal authentication behavior.
Args:
app: The FastAPI app to clean up
"""
# Get all auth dependencies
auth_deps = [
get_current_user,
get_optional_current_user,
OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
]
# Remove overrides
for dep in auth_deps:
if dep in app.dependency_overrides:
del app.dependency_overrides[dep]

View File

@@ -0,0 +1,110 @@
"""
Security utilities for token-based operations.
This module provides utilities for creating and verifying signed tokens,
useful for operations like file uploads, password resets, or any other
time-limited, single-use operations.
"""
import base64
import hashlib
import json
import secrets
import time
from typing import Dict, Any, Optional
from app.core.config import settings
def create_upload_token(file_path: str, content_type: str, expires_in: int = 300) -> str:
"""
Create a signed token for secure file uploads.
This generates a time-limited, single-use token that can be verified
to ensure the upload is authorized.
Args:
file_path: The destination path for the file
content_type: The expected content type (e.g., "image/jpeg")
expires_in: Expiration time in seconds (default: 300 = 5 minutes)
Returns:
A base64 encoded token string
Example:
>>> token = create_upload_token("/uploads/avatar.jpg", "image/jpeg")
>>> # Send token to client, client includes it in upload request
"""
# Create the payload
payload = {
"path": file_path,
"content_type": content_type,
"exp": int(time.time()) + expires_in,
"nonce": secrets.token_hex(8) # Add randomness to prevent token reuse
}
# Convert to JSON and encode
payload_bytes = json.dumps(payload).encode('utf-8')
# Create a signature using the secret key
signature = hashlib.sha256(
payload_bytes + settings.SECRET_KEY.encode('utf-8')
).hexdigest()
# Combine payload and signature
token_data = {
"payload": payload,
"signature": signature
}
# Encode the final token
token_json = json.dumps(token_data)
token = base64.urlsafe_b64encode(token_json.encode('utf-8')).decode('utf-8')
return token
def verify_upload_token(token: str) -> Optional[Dict[str, Any]]:
"""
Verify an upload token and return the payload if valid.
Args:
token: The token string to verify
Returns:
The payload dictionary if valid, None if invalid or expired
Example:
>>> payload = verify_upload_token(token_from_client)
>>> if payload:
... file_path = payload["path"]
... content_type = payload["content_type"]
... # Proceed with upload
... else:
... # Token invalid or expired
"""
try:
# Decode the token
token_json = base64.urlsafe_b64decode(token.encode('utf-8')).decode('utf-8')
token_data = json.loads(token_json)
# Extract payload and signature
payload = token_data["payload"]
signature = token_data["signature"]
# Verify signature
payload_bytes = json.dumps(payload).encode('utf-8')
expected_signature = hashlib.sha256(
payload_bytes + settings.SECRET_KEY.encode('utf-8')
).hexdigest()
if signature != expected_signature:
return None
# Check expiration
if payload["exp"] < int(time.time()):
return None
return payload
except (ValueError, KeyError, json.JSONDecodeError):
return None