Phase 4: Authentication System (T039-T048)

Implemented complete JWT-based authentication system with RBAC:

**Tests (TDD Approach):**
- Created contract tests for /api/v1/auth/login endpoint
- Created contract tests for /api/v1/auth/logout endpoint
- Created unit tests for AuthService (login, logout, validate_token, password hashing)
- Created pytest configuration and fixtures (test DB, test users, tokens)

**Schemas:**
- LoginRequest: username/password validation
- TokenResponse: access_token, refresh_token, user info
- LogoutResponse: logout confirmation
- RefreshTokenRequest: token refresh payload
- UserInfo: user data (excludes password_hash)

**Services:**
- AuthService: login(), logout(), validate_token(), hash_password(), verify_password()
- Integrated bcrypt password hashing
- JWT token generation (access + refresh tokens)
- Token blacklisting in Redis
- Audit logging for all auth operations

**Middleware:**
- Authentication middleware with JWT validation
- Role-based access control (RBAC) helpers
- require_role() dependency factory
- Convenience dependencies: require_viewer(), require_operator(), require_administrator()
- Client IP and User-Agent extraction

**Router:**
- POST /api/v1/auth/login - Authenticate and get tokens
- POST /api/v1/auth/logout - Blacklist token
- POST /api/v1/auth/refresh - Refresh access token
- GET /api/v1/auth/me - Get current user info

**Integration:**
- Registered auth router in main.py
- Updated startup event to initialize Redis and SDK Bridge clients
- Updated shutdown event to cleanup connections properly
- Fixed error translation utilities
- Added asyncpg dependency for PostgreSQL async driver

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Geutebruck API Developer
2025-12-09 09:04:16 +01:00
parent a4bde18d0f
commit fbebe10711
15 changed files with 1651 additions and 13 deletions

269
src/api/routers/auth.py Normal file
View File

@@ -0,0 +1,269 @@
"""
Authentication router for login, logout, and token management
"""
from fastapi import APIRouter, Depends, status, Request
from fastapi.responses import JSONResponse
from sqlalchemy.ext.asyncio import AsyncSession
import structlog
from models import get_db
from schemas.auth import (
LoginRequest,
TokenResponse,
LogoutResponse,
RefreshTokenRequest,
UserInfo
)
from services.auth_service import AuthService
from middleware.auth_middleware import (
get_current_user,
get_client_ip,
get_user_agent,
require_viewer
)
from models.user import User
logger = structlog.get_logger()
router = APIRouter(
prefix="/api/v1/auth",
tags=["authentication"]
)
@router.post(
"/login",
response_model=TokenResponse,
status_code=status.HTTP_200_OK,
summary="User login",
description="Authenticate with username and password to receive JWT tokens"
)
async def login(
request: Request,
credentials: LoginRequest,
db: AsyncSession = Depends(get_db)
):
"""
Authenticate user and return access and refresh tokens
**Request Body:**
- `username`: User's username
- `password`: User's password
**Response:**
- `access_token`: JWT access token (short-lived)
- `refresh_token`: JWT refresh token (long-lived)
- `token_type`: Token type (always "bearer")
- `expires_in`: Access token expiration in seconds
- `user`: Authenticated user information
**Audit Log:**
- Creates audit log entry for login attempt (success or failure)
"""
auth_service = AuthService(db)
# Get client IP and user agent for audit logging
ip_address = get_client_ip(request)
user_agent = get_user_agent(request)
# Attempt login
result = await auth_service.login(
username=credentials.username,
password=credentials.password,
ip_address=ip_address,
user_agent=user_agent
)
if not result:
logger.warning("login_endpoint_failed",
username=credentials.username,
ip=ip_address)
return JSONResponse(
status_code=status.HTTP_401_UNAUTHORIZED,
content={
"error": "Unauthorized",
"message": "Invalid username or password"
}
)
logger.info("login_endpoint_success",
username=credentials.username,
user_id=result["user"]["id"],
ip=ip_address)
return result
@router.post(
"/logout",
response_model=LogoutResponse,
status_code=status.HTTP_200_OK,
summary="User logout",
description="Logout by blacklisting the current access token",
dependencies=[Depends(require_viewer)] # Requires authentication
)
async def logout(
request: Request,
db: AsyncSession = Depends(get_db)
):
"""
Logout user by blacklisting their access token
**Authentication Required:**
- Must include valid JWT access token in Authorization header
**Response:**
- `message`: Logout confirmation message
**Audit Log:**
- Creates audit log entry for logout
"""
# Extract token from Authorization header
auth_header = request.headers.get("Authorization")
if not auth_header:
return JSONResponse(
status_code=status.HTTP_401_UNAUTHORIZED,
content={
"error": "Unauthorized",
"message": "Authentication required"
}
)
# Extract token (remove "Bearer " prefix)
token = auth_header.split()[1] if len(auth_header.split()) == 2 else None
if not token:
return JSONResponse(
status_code=status.HTTP_401_UNAUTHORIZED,
content={
"error": "Unauthorized",
"message": "Invalid authorization header"
}
)
auth_service = AuthService(db)
# Get client IP and user agent for audit logging
ip_address = get_client_ip(request)
user_agent = get_user_agent(request)
# Perform logout
success = await auth_service.logout(
token=token,
ip_address=ip_address,
user_agent=user_agent
)
if not success:
logger.warning("logout_endpoint_failed", ip=ip_address)
return JSONResponse(
status_code=status.HTTP_401_UNAUTHORIZED,
content={
"error": "Unauthorized",
"message": "Invalid or expired token"
}
)
user = get_current_user(request)
logger.info("logout_endpoint_success",
user_id=str(user.id) if user else None,
username=user.username if user else None,
ip=ip_address)
return {"message": "Successfully logged out"}
@router.post(
"/refresh",
status_code=status.HTTP_200_OK,
summary="Refresh access token",
description="Generate new access token using refresh token"
)
async def refresh_token(
request: Request,
refresh_request: RefreshTokenRequest,
db: AsyncSession = Depends(get_db)
):
"""
Generate new access token from refresh token
**Request Body:**
- `refresh_token`: Valid JWT refresh token
**Response:**
- `access_token`: New JWT access token
- `token_type`: Token type (always "bearer")
- `expires_in`: Access token expiration in seconds
**Note:**
- Refresh token is NOT rotated (same refresh token can be reused)
- For security, consider implementing refresh token rotation in production
"""
auth_service = AuthService(db)
# Get client IP for logging
ip_address = get_client_ip(request)
# Refresh token
result = await auth_service.refresh_access_token(
refresh_token=refresh_request.refresh_token,
ip_address=ip_address
)
if not result:
logger.warning("refresh_endpoint_failed", ip=ip_address)
return JSONResponse(
status_code=status.HTTP_401_UNAUTHORIZED,
content={
"error": "Unauthorized",
"message": "Invalid or expired refresh token"
}
)
logger.info("refresh_endpoint_success", ip=ip_address)
return result
@router.get(
"/me",
response_model=UserInfo,
status_code=status.HTTP_200_OK,
summary="Get current user",
description="Get information about the currently authenticated user",
dependencies=[Depends(require_viewer)] # Requires authentication
)
async def get_me(request: Request):
"""
Get current authenticated user information
**Authentication Required:**
- Must include valid JWT access token in Authorization header
**Response:**
- User information (id, username, role, created_at, updated_at)
**Note:**
- Password hash is NEVER included in response
"""
user = get_current_user(request)
if not user:
return JSONResponse(
status_code=status.HTTP_401_UNAUTHORIZED,
content={
"error": "Unauthorized",
"message": "Authentication required"
}
)
logger.info("get_me_endpoint",
user_id=str(user.id),
username=user.username)
return {
"id": str(user.id),
"username": user.username,
"role": user.role.value,
"created_at": user.created_at,
"updated_at": user.updated_at
}