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

@@ -1,7 +1,8 @@
from typing import Optional
from fastapi import Depends, HTTPException, status
from fastapi import Depends, HTTPException, status, Header
from fastapi.security import OAuth2PasswordBearer
from fastapi.security.utils import get_authorization_scheme_param
from sqlalchemy.orm import Session
from app.core.auth import get_token_data, TokenExpiredError, TokenInvalidError
@@ -109,9 +110,32 @@ def get_current_superuser(
return current_user
async def get_optional_token(authorization: str = Header(None)) -> Optional[str]:
"""
Get the token from the Authorization header without requiring it.
This is a custom dependency that doesn't raise an exception when no token is provided,
unlike the standard OAuth2PasswordBearer.
Args:
authorization: Authorization header value
Returns:
The token string if valid Bearer token is provided, None otherwise
"""
if not authorization:
return None
scheme, token = get_authorization_scheme_param(authorization)
if scheme.lower() != "bearer":
return None
return token
def get_optional_current_user(
db: Session = Depends(get_db),
token: Optional[str] = Depends(oauth2_scheme)
token: Optional[str] = Depends(get_optional_token)
) -> Optional[User]:
"""
Get the current user if authenticated, otherwise return None.

76
backend/app/init_db.py Normal file
View File

@@ -0,0 +1,76 @@
# app/init_db.py
import logging
from typing import Optional
from sqlalchemy.orm import Session
from app.core.config import settings
from app.crud.user import user as user_crud
from app.schemas.users import UserCreate
from app.core.database import engine
logger = logging.getLogger(__name__)
def init_db(db: Session) -> Optional[UserCreate]:
"""
Initialize database with first superuser if settings are configured and user doesn't exist.
Returns:
The created or existing superuser, or None if creation fails
"""
# Use default values if not set in environment variables
superuser_email = settings.FIRST_SUPERUSER_EMAIL or "admin@example.com"
superuser_password = settings.FIRST_SUPERUSER_PASSWORD or "admin123"
if not settings.FIRST_SUPERUSER_EMAIL or not settings.FIRST_SUPERUSER_PASSWORD:
logger.warning(
"First superuser credentials not configured in settings. "
f"Using defaults: {superuser_email}"
)
try:
# Check if superuser already exists
existing_user = user_crud.get_by_email(db, email=superuser_email)
if existing_user:
logger.info(f"Superuser already exists: {existing_user.email}")
return existing_user
# Create superuser if doesn't exist
user_in = UserCreate(
email=superuser_email,
password=superuser_password,
first_name="Admin",
last_name="User",
is_superuser=True
)
user = user_crud.create(db, obj_in=user_in)
logger.info(f"Created first superuser: {user.email}")
return user
except Exception as e:
logger.error(f"Error initializing database: {e}")
raise
if __name__ == "__main__":
# Configure logging to show info logs
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
with Session(engine) as session:
try:
user = init_db(session)
if user:
print(f"✓ Database initialized successfully")
print(f"✓ Superuser: {user.email}")
else:
print("✗ Failed to initialize database")
except Exception as e:
print(f"✗ Error initializing database: {e}")
raise
finally:
session.close()

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

View File

@@ -4,5 +4,9 @@ echo "Starting Backend"
# Apply database migrations
alembic upgrade head
# Initialize database (creates first superuser if needed)
python app/init_db.py
# Execute the command passed to docker run
exec "$@"

202
backend/migrate.py Executable file
View File

@@ -0,0 +1,202 @@
#!/usr/bin/env python
"""
Database migration helper script.
Provides convenient commands for generating and applying Alembic migrations.
"""
import argparse
import subprocess
import sys
from pathlib import Path
# Ensure the project root is in the Python path
project_root = Path(__file__).resolve().parent
if str(project_root) not in sys.path:
sys.path.append(str(project_root))
try:
# Import settings to check if configuration is working
from app.core.config import settings
print(f"Using database URL: {settings.database_url}")
except ImportError as e:
print(f"Error importing settings: {e}")
print("Make sure your Python path includes the project root.")
sys.exit(1)
def check_models():
"""Check if all models are properly imported"""
print("Checking model imports...")
try:
# Import all models through the models package
from app.models import __all__ as all_models
print(f"Found {len(all_models)} model(s):")
for model in all_models:
print(f" - {model}")
return True
except Exception as e:
print(f"Error checking models: {e}")
return False
def generate_migration(message):
"""Generate an Alembic migration with the given message"""
print(f"Generating migration: {message}")
cmd = ["alembic", "revision", "--autogenerate", "-m", message]
result = subprocess.run(cmd, capture_output=True, text=True)
print(result.stdout)
if result.returncode != 0:
print("Error generating migration:", file=sys.stderr)
print(result.stderr, file=sys.stderr)
return False
# Extract revision ID if possible
revision = None
for line in result.stdout.split("\n"):
if "Generating" in line and "..." in line:
try:
# Look for the revision ID, which is typically 12 hex characters
parts = line.split()
for part in parts:
if len(part) >= 12 and all(c in "0123456789abcdef" for c in part[:12]):
revision = part[:12]
break
except Exception:
pass
if revision:
print(f"Generated revision: {revision}")
else:
print("Generated migration (revision ID not identified)")
return revision or True
def apply_migration(revision=None):
"""Apply migrations up to the specified revision or head"""
target = revision or "head"
print(f"Applying migration(s) to: {target}")
cmd = ["alembic", "upgrade", target]
result = subprocess.run(cmd, capture_output=True, text=True)
print(result.stdout)
if result.returncode != 0:
print("Error applying migration:", file=sys.stderr)
print(result.stderr, file=sys.stderr)
return False
print("Migration(s) applied successfully")
return True
def show_current():
"""Show current revision"""
print("Current database revision:")
cmd = ["alembic", "current"]
result = subprocess.run(cmd, capture_output=True, text=True)
print(result.stdout)
if result.returncode != 0:
print("Error getting current revision:", file=sys.stderr)
print(result.stderr, file=sys.stderr)
return False
return True
def list_migrations():
"""List all migrations and their status"""
print("Listing migrations:")
cmd = ["alembic", "history", "--verbose"]
result = subprocess.run(cmd, capture_output=True, text=True)
print(result.stdout)
if result.returncode != 0:
print("Error listing migrations:", file=sys.stderr)
print(result.stderr, file=sys.stderr)
return False
return True
def check_database_connection():
"""Check if database is accessible"""
from sqlalchemy import create_engine
from sqlalchemy.exc import SQLAlchemyError
try:
engine = create_engine(settings.database_url)
with engine.connect() as conn:
print("✓ Database connection successful!")
return True
except SQLAlchemyError as e:
print(f"✗ Error connecting to database: {e}")
return False
def main():
"""Main function"""
parser = argparse.ArgumentParser(
description='Database migration helper for FastNext template'
)
subparsers = parser.add_subparsers(dest='command', help='Command to run')
# Generate command
generate_parser = subparsers.add_parser('generate', help='Generate a migration')
generate_parser.add_argument('message', help='Migration message')
# Apply command
apply_parser = subparsers.add_parser('apply', help='Apply migrations')
apply_parser.add_argument('--revision', help='Specific revision to apply to')
# List command
subparsers.add_parser('list', help='List migrations')
# Current command
subparsers.add_parser('current', help='Show current revision')
# Check command
subparsers.add_parser('check', help='Check database connection and models')
# Auto command (generate and apply)
auto_parser = subparsers.add_parser('auto', help='Generate and apply migration')
auto_parser.add_argument('message', help='Migration message')
args = parser.parse_args()
if args.command == 'generate':
check_models()
generate_migration(args.message)
elif args.command == 'apply':
apply_migration(args.revision)
elif args.command == 'list':
list_migrations()
elif args.command == 'current':
show_current()
elif args.command == 'check':
check_database_connection()
check_models()
elif args.command == 'auto':
check_models()
revision = generate_migration(args.message)
if revision:
proceed = input("\nPress Enter to apply migration or Ctrl+C to abort... ")
apply_migration()
else:
parser.print_help()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1 @@
# tests/api/dependencies/__init__.py

View File

@@ -0,0 +1,213 @@
# tests/api/dependencies/test_auth_dependencies.py
import pytest
import uuid
from unittest.mock import patch
from fastapi import HTTPException
from app.api.dependencies.auth import (
get_current_user,
get_current_active_user,
get_current_superuser,
get_optional_current_user
)
from app.core.auth import TokenExpiredError, TokenInvalidError
@pytest.fixture
def mock_token():
"""Fixture providing a mock JWT token"""
return "mock.jwt.token"
class TestGetCurrentUser:
"""Tests for get_current_user dependency"""
def test_get_current_user_success(self, db_session, mock_user, mock_token):
"""Test successfully getting the current user"""
# Mock get_token_data to return user_id that matches our mock_user
with patch('app.api.dependencies.auth.get_token_data') as mock_get_data:
mock_get_data.return_value.user_id = mock_user.id
# Call the dependency
user = get_current_user(db=db_session, token=mock_token)
# Verify the correct user was returned
assert user.id == mock_user.id
assert user.email == mock_user.email
def test_get_current_user_nonexistent(self, db_session, mock_token):
"""Test when the token contains a user ID that doesn't exist"""
# Mock get_token_data to return a non-existent user ID
nonexistent_id = uuid.UUID("11111111-1111-1111-1111-111111111111")
with patch('app.api.dependencies.auth.get_token_data') as mock_get_data:
mock_get_data.return_value.user_id = nonexistent_id
# Should raise HTTPException with 404 status
with pytest.raises(HTTPException) as exc_info:
get_current_user(db=db_session, token=mock_token)
assert exc_info.value.status_code == 404
assert "User not found" in exc_info.value.detail
def test_get_current_user_inactive(self, db_session, mock_user, mock_token):
"""Test when the user is inactive"""
# Make the user inactive
mock_user.is_active = False
db_session.commit()
# Mock get_token_data
with patch('app.api.dependencies.auth.get_token_data') as mock_get_data:
mock_get_data.return_value.user_id = mock_user.id
# Should raise HTTPException with 403 status
with pytest.raises(HTTPException) as exc_info:
get_current_user(db=db_session, token=mock_token)
assert exc_info.value.status_code == 403
assert "Inactive user" in exc_info.value.detail
def test_get_current_user_expired_token(self, db_session, mock_token):
"""Test with an expired token"""
# Mock get_token_data to raise TokenExpiredError
with patch('app.api.dependencies.auth.get_token_data') as mock_get_data:
mock_get_data.side_effect = TokenExpiredError("Token expired")
# Should raise HTTPException with 401 status
with pytest.raises(HTTPException) as exc_info:
get_current_user(db=db_session, token=mock_token)
assert exc_info.value.status_code == 401
assert "Token expired" in exc_info.value.detail
def test_get_current_user_invalid_token(self, db_session, mock_token):
"""Test with an invalid token"""
# Mock get_token_data to raise TokenInvalidError
with patch('app.api.dependencies.auth.get_token_data') as mock_get_data:
mock_get_data.side_effect = TokenInvalidError("Invalid token")
# Should raise HTTPException with 401 status
with pytest.raises(HTTPException) as exc_info:
get_current_user(db=db_session, token=mock_token)
assert exc_info.value.status_code == 401
assert "Could not validate credentials" in exc_info.value.detail
class TestGetCurrentActiveUser:
"""Tests for get_current_active_user dependency"""
def test_get_current_active_user(self, mock_user):
"""Test getting an active user"""
# Ensure user is active
mock_user.is_active = True
# Call the dependency with mocked current_user
user = get_current_active_user(current_user=mock_user)
# Should return the same user
assert user == mock_user
def test_get_current_inactive_user(self, mock_user):
"""Test getting an inactive user"""
# Make user inactive
mock_user.is_active = False
# Should raise HTTPException with 403 status
with pytest.raises(HTTPException) as exc_info:
get_current_active_user(current_user=mock_user)
assert exc_info.value.status_code == 403
assert "Inactive user" in exc_info.value.detail
class TestGetCurrentSuperuser:
"""Tests for get_current_superuser dependency"""
def test_get_current_superuser(self, mock_user):
"""Test getting a superuser"""
# Make user a superuser
mock_user.is_superuser = True
# Call the dependency with mocked current_user
user = get_current_superuser(current_user=mock_user)
# Should return the same user
assert user == mock_user
def test_get_current_non_superuser(self, mock_user):
"""Test getting a non-superuser"""
# Ensure user is not a superuser
mock_user.is_superuser = False
# Should raise HTTPException with 403 status
with pytest.raises(HTTPException) as exc_info:
get_current_superuser(current_user=mock_user)
assert exc_info.value.status_code == 403
assert "Not enough permissions" in exc_info.value.detail
class TestGetOptionalCurrentUser:
"""Tests for get_optional_current_user dependency"""
def test_get_optional_current_user_with_token(self, db_session, mock_user, mock_token):
"""Test getting optional user with a valid token"""
# Mock get_token_data
with patch('app.api.dependencies.auth.get_token_data') as mock_get_data:
mock_get_data.return_value.user_id = mock_user.id
# Call the dependency
user = get_optional_current_user(db=db_session, token=mock_token)
# Should return the correct user
assert user is not None
assert user.id == mock_user.id
def test_get_optional_current_user_no_token(self, db_session):
"""Test getting optional user with no token"""
# Call the dependency with no token
user = get_optional_current_user(db=db_session, token=None)
# Should return None
assert user is None
def test_get_optional_current_user_invalid_token(self, db_session, mock_token):
"""Test getting optional user with an invalid token"""
# Mock get_token_data to raise TokenInvalidError
with patch('app.api.dependencies.auth.get_token_data') as mock_get_data:
mock_get_data.side_effect = TokenInvalidError("Invalid token")
# Call the dependency
user = get_optional_current_user(db=db_session, token=mock_token)
# Should return None, not raise an exception
assert user is None
def test_get_optional_current_user_expired_token(self, db_session, mock_token):
"""Test getting optional user with an expired token"""
# Mock get_token_data to raise TokenExpiredError
with patch('app.api.dependencies.auth.get_token_data') as mock_get_data:
mock_get_data.side_effect = TokenExpiredError("Token expired")
# Call the dependency
user = get_optional_current_user(db=db_session, token=mock_token)
# Should return None, not raise an exception
assert user is None
def test_get_optional_current_user_inactive(self, db_session, mock_user, mock_token):
"""Test getting optional user when user is inactive"""
# Make the user inactive
mock_user.is_active = False
db_session.commit()
# Mock get_token_data
with patch('app.api.dependencies.auth.get_token_data') as mock_get_data:
mock_get_data.return_value.user_id = mock_user.id
# Call the dependency
user = get_optional_current_user(db=db_session, token=mock_token)
# Should return None for inactive users
assert user is None

View File

@@ -0,0 +1,98 @@
# Docker Compose configuration for DEPLOYMENT with pre-built images
#
# IMPORTANT: This configuration is designed for deployment scenarios where you have
# already built and pushed your Docker images to a container registry.
#
# Since this is a template project, you'll need to:
# 1. Build your images: docker-compose build
# 2. Tag them appropriately: docker tag <image> <your-registry>/<your-project>:<tag>
# 3. Push to your registry: docker push <your-registry>/<your-project>:<tag>
# 4. Update the image references below to point to your registry
#
# Example registry paths:
# - Docker Hub: username/project-backend:latest
# - GitHub Container Registry: ghcr.io/username/project-backend:latest
# - GitLab Registry: registry.gitlab.com/username/project/backend:latest
# - Private Registry: registry.example.com/project-backend:latest
services:
db:
image: postgres:17-alpine
volumes:
- postgres_data:/var/lib/postgresql/data/
environment:
- POSTGRES_USER=${POSTGRES_USER}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- POSTGRES_DB=${POSTGRES_DB}
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"]
interval: 5s
timeout: 5s
retries: 5
networks:
- app-network
restart: unless-stopped
backend:
# REPLACE THIS with your actual image from your container registry
# Examples:
# image: ghcr.io/your-username/your-project-backend:latest
# image: your-registry.com/your-project/backend:v1.0.0
# image: username/your-project-backend:latest
image: YOUR_REGISTRY/YOUR_PROJECT_BACKEND:latest
env_file:
- .env
environment:
- DATABASE_URL=${DATABASE_URL}
- SECRET_KEY=${SECRET_KEY}
- ENVIRONMENT=production
- DEBUG=false
- BACKEND_CORS_ORIGINS=${BACKEND_CORS_ORIGINS}
depends_on:
db:
condition: service_healthy
networks:
- app-network
restart: unless-stopped
# Uncomment if you need persistent data storage for uploads, etc.
# volumes:
# - ${HOST_DATA_FILES_DIR:-./data}:${DATA_FILES_DIR:-/app/data}
frontend:
# REPLACE THIS with your actual image from your container registry
# Examples:
# image: ghcr.io/your-username/your-project-frontend:latest
# image: your-registry.com/your-project/frontend:v1.0.0
# image: username/your-project-frontend:latest
image: YOUR_REGISTRY/YOUR_PROJECT_FRONTEND:latest
environment:
- NODE_ENV=production
- NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
depends_on:
- backend
networks:
- app-network
restart: unless-stopped
# Optional: Add a reverse proxy like nginx or traefik here
# nginx:
# image: nginx:alpine
# ports:
# - "80:80"
# - "443:443"
# volumes:
# - ./nginx.conf:/etc/nginx/nginx.conf:ro
# - ./ssl:/etc/nginx/ssl:ro
# depends_on:
# - frontend
# - backend
# networks:
# - app-network
# restart: unless-stopped
volumes:
postgres_data:
networks:
app-network:
driver: bridge