release: v0.5.0 - Authentication, API Keys & Advanced Features
E2E Tests / Run E2E Tests (push) Has been cancelled
E2E Tests / Visual Regression Tests (push) Has been cancelled
E2E Tests / Smoke Tests (push) Has been cancelled

Complete v0.5.0 implementation:

Database (@db-engineer):
- 3 migrations: users, api_keys, report_schedules tables
- Foreign keys, indexes, constraints, enums

Backend (@backend-dev):
- JWT authentication service with bcrypt (cost=12)
- Auth endpoints: /register, /login, /refresh, /me
- API Keys service with hash storage and prefix validation
- API Keys endpoints: CRUD + rotate
- Security module with JWT HS256

Frontend (@frontend-dev):
- Login/Register pages with validation
- AuthContext with localStorage persistence
- Protected routes implementation
- API Keys management UI (create, revoke, rotate)
- Header with user dropdown

DevOps (@devops-engineer):
- .env.example and .env.production.example
- docker-compose.scheduler.yml
- scripts/setup-secrets.sh
- INFRASTRUCTURE_SETUP.md

QA (@qa-engineer):
- 85 E2E tests: auth.spec.ts, apikeys.spec.ts, scenarios.spec.ts, regression-v050.spec.ts
- auth-helpers.ts with 20+ utility functions
- Test plans and documentation

Architecture (@spec-architect):
- SECURITY.md with best practices
- SECURITY-CHECKLIST.md pre-deployment
- Updated architecture.md with auth flows
- Updated README.md with v0.5.0 features

Documentation:
- Updated todo.md with v0.5.0 status
- Added docs/README.md index
- Complete setup instructions

Dependencies added:
- bcrypt, python-jose, passlib, email-validator

Tested: JWT auth flow, API keys CRUD, protected routes, 85 E2E tests ready

Closes: v0.5.0 milestone
This commit is contained in:
Luca Sacchi Ricciardi
2026-04-07 19:22:47 +02:00
parent 9b9297b7dc
commit cc60ba17ea
49 changed files with 9847 additions and 176 deletions
+10
View File
@@ -24,9 +24,19 @@ class Settings(BaseSettings):
reports_cleanup_days: int = 30
reports_rate_limit_per_minute: int = 10
# JWT Configuration
jwt_secret_key: str = "super-secret-change-in-production"
jwt_algorithm: str = "HS256"
access_token_expire_minutes: int = 30
refresh_token_expire_days: int = 7
# Security
bcrypt_rounds: int = 12
class Config:
env_file = ".env"
case_sensitive = False
extra = "ignore"
@lru_cache()
+207
View File
@@ -0,0 +1,207 @@
"""Security utilities - JWT and password hashing."""
from datetime import datetime, timedelta, timezone
from typing import Optional
import secrets
import base64
import bcrypt
from jose import JWTError, jwt
from pydantic import EmailStr
from src.core.config import settings
# JWT Configuration
JWT_SECRET_KEY = getattr(
settings, "jwt_secret_key", "super-secret-change-in-production"
)
JWT_ALGORITHM = getattr(settings, "jwt_algorithm", "HS256")
ACCESS_TOKEN_EXPIRE_MINUTES = getattr(settings, "access_token_expire_minutes", 30)
REFRESH_TOKEN_EXPIRE_DAYS = getattr(settings, "refresh_token_expire_days", 7)
# Password hashing
BCRYPT_ROUNDS = getattr(settings, "bcrypt_rounds", 12)
def hash_password(password: str) -> str:
"""Hash a password using bcrypt.
Args:
password: Plain text password
Returns:
Hashed password string
"""
password_bytes = password.encode("utf-8")
salt = bcrypt.gensalt(rounds=BCRYPT_ROUNDS)
hashed = bcrypt.hashpw(password_bytes, salt)
return hashed.decode("utf-8")
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Verify a password against a hash.
Args:
plain_password: Plain text password
hashed_password: Hashed password string
Returns:
True if password matches, False otherwise
"""
password_bytes = plain_password.encode("utf-8")
hashed_bytes = hashed_password.encode("utf-8")
return bcrypt.checkpw(password_bytes, hashed_bytes)
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
"""Create a JWT access token.
Args:
data: Data to encode in the token
expires_delta: Optional custom expiration time
Returns:
JWT token string
"""
to_encode = data.copy()
if expires_delta:
expire = datetime.now(timezone.utc) + expires_delta
else:
expire = datetime.now(timezone.utc) + timedelta(
minutes=ACCESS_TOKEN_EXPIRE_MINUTES
)
to_encode.update({"exp": expire, "type": "access"})
encoded_jwt = jwt.encode(to_encode, JWT_SECRET_KEY, algorithm=JWT_ALGORITHM)
return encoded_jwt
def create_refresh_token(data: dict) -> str:
"""Create a JWT refresh token.
Args:
data: Data to encode in the token
Returns:
JWT refresh token string
"""
to_encode = data.copy()
expire = datetime.now(timezone.utc) + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
to_encode.update({"exp": expire, "type": "refresh"})
encoded_jwt = jwt.encode(to_encode, JWT_SECRET_KEY, algorithm=JWT_ALGORITHM)
return encoded_jwt
def verify_token(token: str) -> Optional[dict]:
"""Verify and decode a JWT token.
Args:
token: JWT token string
Returns:
Decoded payload dict or None if invalid
"""
try:
payload = jwt.decode(token, JWT_SECRET_KEY, algorithms=[JWT_ALGORITHM])
return payload
except JWTError:
return None
def verify_access_token(token: str) -> Optional[dict]:
"""Verify an access token specifically.
Args:
token: JWT access token string
Returns:
Decoded payload dict or None if invalid
"""
payload = verify_token(token)
if payload and payload.get("type") == "access":
return payload
return None
def verify_refresh_token(token: str) -> Optional[dict]:
"""Verify a refresh token specifically.
Args:
token: JWT refresh token string
Returns:
Decoded payload dict or None if invalid
"""
payload = verify_token(token)
if payload and payload.get("type") == "refresh":
return payload
return None
def generate_api_key() -> tuple[str, str]:
"""Generate a new API key and its hash.
Returns:
Tuple of (full_key, key_hash)
- full_key: The complete API key to show once (mk_ + base64)
- key_hash: SHA-256 hash to store in database
"""
# Generate 32 random bytes
random_bytes = secrets.token_bytes(32)
# Encode to base64 (URL-safe)
key_part = base64.urlsafe_b64encode(random_bytes).decode("utf-8").rstrip("=")
# Full key with prefix
full_key = f"mk_{key_part}"
# Create hash for storage (using bcrypt for security)
key_hash = bcrypt.hashpw(
full_key.encode("utf-8"), bcrypt.gensalt(rounds=12)
).decode("utf-8")
# Prefix for identification (first 8 chars after mk_)
return full_key, key_hash
def get_key_prefix(key: str) -> str:
"""Extract prefix from API key for identification.
Args:
key: Full API key
Returns:
First 8 characters of the key part (after mk_)
"""
if key.startswith("mk_"):
key_part = key[3:] # Remove "mk_" prefix
return key_part[:8]
return key[:8]
def verify_api_key(key: str, key_hash: str) -> bool:
"""Verify an API key against its stored hash.
Args:
key: Full API key
key_hash: Stored bcrypt hash
Returns:
True if key matches, False otherwise
"""
return bcrypt.checkpw(key.encode("utf-8"), key_hash.encode("utf-8"))
def validate_email_format(email: str) -> bool:
"""Validate email format.
Args:
email: Email string to validate
Returns:
True if valid email format, False otherwise
"""
try:
EmailStr._validate(email)
return True
except Exception:
return False