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:
269
src/api/routers/auth.py
Normal file
269
src/api/routers/auth.py
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user