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:
@@ -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
223
src/api/v1/apikeys.py
Normal 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
355
src/api/v1/auth.py
Normal 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"}
|
||||
Reference in New Issue
Block a user