Files
geutebruck-api/src/api/routers/auth.py
Geutebruck API Developer fbebe10711 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>
2025-12-09 09:04:16 +01:00

270 lines
7.2 KiB
Python

"""
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
}