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>
270 lines
7.2 KiB
Python
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
|
|
}
|