release: v0.5.0 - Authentication, API Keys & Advanced Features
Some checks failed
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

View File

@@ -6,8 +6,12 @@ from src.api.v1.scenarios import router as scenarios_router
from src.api.v1.ingest import router as ingest_router
from src.api.v1.metrics import router as metrics_router
from src.api.v1.reports import scenario_reports_router, reports_router
from src.api.v1.auth import router as auth_router
from src.api.v1.apikeys import router as apikeys_router
api_router = APIRouter()
api_router.include_router(auth_router, tags=["authentication"])
api_router.include_router(apikeys_router, tags=["api-keys"])
api_router.include_router(scenarios_router, prefix="/scenarios", tags=["scenarios"])
api_router.include_router(ingest_router, tags=["ingest"])
api_router.include_router(metrics_router, prefix="/scenarios", tags=["metrics"])

223
src/api/v1/apikeys.py Normal file
View File

@@ -0,0 +1,223 @@
"""API Keys API endpoints."""
from typing import Annotated, Optional
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from src.core.database import get_db
from src.schemas.user import UserResponse
from src.schemas.api_key import (
APIKeyCreate,
APIKeyUpdate,
APIKeyResponse,
APIKeyCreateResponse,
APIKeyList,
)
from src.api.v1.auth import get_current_user
from src.services.apikey_service import (
create_api_key,
list_api_keys,
revoke_api_key,
rotate_api_key,
update_api_key,
APIKeyNotFoundError,
)
router = APIRouter(prefix="/api-keys", tags=["api-keys"])
@router.post(
"",
response_model=APIKeyCreateResponse,
status_code=status.HTTP_201_CREATED,
)
async def create_new_api_key(
key_data: APIKeyCreate,
current_user: Annotated[UserResponse, Depends(get_current_user)],
session: AsyncSession = Depends(get_db),
):
"""Create a new API key.
⚠️ WARNING: The full API key is shown ONLY at creation!
Make sure to copy and save it immediately.
Args:
key_data: API key creation data
current_user: Current authenticated user
session: Database session
Returns:
APIKeyCreateResponse with full key (shown only once)
"""
api_key, full_key = await create_api_key(
session=session,
user_id=current_user.id,
name=key_data.name,
scopes=key_data.scopes,
expires_days=key_data.expires_days,
)
return APIKeyCreateResponse(
id=api_key.id,
name=api_key.name,
key=full_key, # Full key shown ONLY ONCE!
key_prefix=api_key.key_prefix,
scopes=api_key.scopes,
is_active=api_key.is_active,
created_at=api_key.created_at,
expires_at=api_key.expires_at,
last_used_at=api_key.last_used_at,
)
@router.get(
"",
response_model=APIKeyList,
)
async def list_user_api_keys(
current_user: Annotated[UserResponse, Depends(get_current_user)],
session: AsyncSession = Depends(get_db),
):
"""List all API keys for the current user.
Args:
current_user: Current authenticated user
session: Database session
Returns:
APIKeyList with user's API keys (without key_hash)
"""
api_keys = await list_api_keys(session, current_user.id)
return APIKeyList(
items=[APIKeyResponse.model_validate(key) for key in api_keys],
total=len(api_keys),
)
@router.patch(
"/{key_id}",
response_model=APIKeyResponse,
)
async def update_api_key_endpoint(
key_id: UUID,
key_data: APIKeyUpdate,
current_user: Annotated[UserResponse, Depends(get_current_user)],
session: AsyncSession = Depends(get_db),
):
"""Update an API key (name only).
Args:
key_id: API key ID
key_data: Update data
current_user: Current authenticated user
session: Database session
Returns:
Updated APIKeyResponse
Raises:
HTTPException: If key not found
"""
try:
api_key = await update_api_key(
session=session,
api_key_id=key_id,
user_id=current_user.id,
name=key_data.name,
)
except APIKeyNotFoundError:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="API key not found",
)
return APIKeyResponse.model_validate(api_key)
@router.delete(
"/{key_id}",
status_code=status.HTTP_204_NO_CONTENT,
)
async def revoke_user_api_key(
key_id: UUID,
current_user: Annotated[UserResponse, Depends(get_current_user)],
session: AsyncSession = Depends(get_db),
):
"""Revoke (delete) an API key.
Args:
key_id: API key ID
current_user: Current authenticated user
session: Database session
Raises:
HTTPException: If key not found
"""
try:
await revoke_api_key(
session=session,
api_key_id=key_id,
user_id=current_user.id,
)
except APIKeyNotFoundError:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="API key not found",
)
return None
@router.post(
"/{key_id}/rotate",
response_model=APIKeyCreateResponse,
status_code=status.HTTP_201_CREATED,
)
async def rotate_user_api_key(
key_id: UUID,
current_user: Annotated[UserResponse, Depends(get_current_user)],
session: AsyncSession = Depends(get_db),
):
"""Rotate (regenerate) an API key.
The old key is revoked and a new key is created with the same settings.
⚠️ WARNING: The new full API key is shown ONLY at creation!
Args:
key_id: API key ID to rotate
current_user: Current authenticated user
session: Database session
Returns:
APIKeyCreateResponse with new full key
Raises:
HTTPException: If key not found
"""
try:
new_key, full_key = await rotate_api_key(
session=session,
api_key_id=key_id,
user_id=current_user.id,
)
except APIKeyNotFoundError:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="API key not found",
)
return APIKeyCreateResponse(
id=new_key.id,
name=new_key.name,
key=full_key, # New full key shown ONLY ONCE!
key_prefix=new_key.key_prefix,
scopes=new_key.scopes,
is_active=new_key.is_active,
created_at=new_key.created_at,
expires_at=new_key.expires_at,
last_used_at=new_key.last_used_at,
)

355
src/api/v1/auth.py Normal file
View File

@@ -0,0 +1,355 @@
"""Authentication API endpoints."""
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.ext.asyncio import AsyncSession
from src.core.database import get_db
from src.core.security import verify_access_token, verify_refresh_token
from src.schemas.user import (
UserCreate,
UserLogin,
UserResponse,
AuthResponse,
TokenRefresh,
TokenResponse,
PasswordChange,
PasswordResetRequest,
PasswordReset,
)
from src.services.auth_service import (
register_user,
authenticate_user,
change_password,
reset_password_request,
reset_password,
get_user_by_id,
create_tokens_for_user,
EmailAlreadyExistsError,
InvalidCredentialsError,
UserNotFoundError,
InvalidPasswordError,
InvalidTokenError,
)
router = APIRouter(prefix="/auth", tags=["authentication"])
security = HTTPBearer()
async def get_current_user(
credentials: Annotated[HTTPAuthorizationCredentials, Depends(security)],
session: AsyncSession = Depends(get_db),
) -> UserResponse:
"""Get current authenticated user from JWT token.
Args:
credentials: HTTP Authorization credentials with Bearer token
session: Database session
Returns:
UserResponse object
Raises:
HTTPException: If token is invalid or user not found
"""
token = credentials.credentials
payload = verify_access_token(token)
if not payload:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token",
headers={"WWW-Authenticate": "Bearer"},
)
user_id = payload.get("sub")
if not user_id:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token payload",
headers={"WWW-Authenticate": "Bearer"},
)
from uuid import UUID
user = await get_user_by_id(session, UUID(user_id))
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found",
headers={"WWW-Authenticate": "Bearer"},
)
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User account is disabled",
headers={"WWW-Authenticate": "Bearer"},
)
return UserResponse.model_validate(user)
@router.post(
"/register",
response_model=AuthResponse,
status_code=status.HTTP_201_CREATED,
)
async def register(
user_data: UserCreate,
session: AsyncSession = Depends(get_db),
):
"""Register a new user.
Args:
user_data: User registration data
session: Database session
Returns:
AuthResponse with user and tokens
Raises:
HTTPException: If email already exists or validation fails
"""
try:
user = await register_user(
session=session,
email=user_data.email,
password=user_data.password,
full_name=user_data.full_name,
)
except EmailAlreadyExistsError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered",
)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=str(e),
)
# Create tokens
access_token, refresh_token = create_tokens_for_user(user)
return AuthResponse(
user=UserResponse.model_validate(user),
access_token=access_token,
refresh_token=refresh_token,
)
@router.post(
"/login",
response_model=TokenResponse,
)
async def login(
credentials: UserLogin,
session: AsyncSession = Depends(get_db),
):
"""Login with email and password.
Args:
credentials: Login credentials
session: Database session
Returns:
TokenResponse with access and refresh tokens
Raises:
HTTPException: If credentials are invalid
"""
user = await authenticate_user(
session=session,
email=credentials.email,
password=credentials.password,
)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid email or password",
headers={"WWW-Authenticate": "Bearer"},
)
access_token, refresh_token = create_tokens_for_user(user)
return TokenResponse(
access_token=access_token,
refresh_token=refresh_token,
)
@router.post(
"/refresh",
response_model=TokenResponse,
)
async def refresh_token(
token_data: TokenRefresh,
session: AsyncSession = Depends(get_db),
):
"""Refresh access token using refresh token.
Args:
token_data: Refresh token data
session: Database session
Returns:
TokenResponse with new access and refresh tokens
Raises:
HTTPException: If refresh token is invalid
"""
payload = verify_refresh_token(token_data.refresh_token)
if not payload:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired refresh token",
headers={"WWW-Authenticate": "Bearer"},
)
from uuid import UUID
user_id = payload.get("sub")
user = await get_user_by_id(session, UUID(user_id))
if not user or not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found or inactive",
headers={"WWW-Authenticate": "Bearer"},
)
access_token, refresh_token = create_tokens_for_user(user)
return TokenResponse(
access_token=access_token,
refresh_token=refresh_token,
)
@router.get(
"/me",
response_model=UserResponse,
)
async def get_me(
current_user: Annotated[UserResponse, Depends(get_current_user)],
):
"""Get current user information.
Returns:
UserResponse with current user data
"""
return current_user
@router.post(
"/change-password",
status_code=status.HTTP_200_OK,
)
async def change_user_password(
password_data: PasswordChange,
current_user: Annotated[UserResponse, Depends(get_current_user)],
session: AsyncSession = Depends(get_db),
):
"""Change current user password.
Args:
password_data: Old and new password
current_user: Current authenticated user
session: Database session
Returns:
Success message
Raises:
HTTPException: If old password is incorrect
"""
from uuid import UUID
try:
await change_password(
session=session,
user_id=UUID(current_user.id),
old_password=password_data.old_password,
new_password=password_data.new_password,
)
except InvalidPasswordError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Current password is incorrect",
)
return {"message": "Password changed successfully"}
@router.post(
"/reset-password-request",
status_code=status.HTTP_200_OK,
)
async def request_password_reset(
request_data: PasswordResetRequest,
session: AsyncSession = Depends(get_db),
):
"""Request a password reset.
Args:
request_data: Email for password reset
session: Database session
Returns:
Success message (always returns success for security)
"""
# Always return success to prevent email enumeration
await reset_password_request(
session=session,
email=request_data.email,
)
return {
"message": "If the email exists, a password reset link has been sent",
}
@router.post(
"/reset-password",
status_code=status.HTTP_200_OK,
)
async def reset_user_password(
reset_data: PasswordReset,
session: AsyncSession = Depends(get_db),
):
"""Reset password using token.
Args:
reset_data: Token and new password
session: Database session
Returns:
Success message
Raises:
HTTPException: If token is invalid
"""
try:
await reset_password(
session=session,
token=reset_data.token,
new_password=reset_data.new_password,
)
except InvalidTokenError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid or expired token",
)
except UserNotFoundError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="User not found",
)
return {"message": "Password reset successfully"}