release: v0.5.0 - Authentication, API Keys & Advanced Features
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:
@@ -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()
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user