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:
3
src/api/services/__init__.py
Normal file
3
src/api/services/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
Business logic services
|
||||
"""
|
||||
318
src/api/services/auth_service.py
Normal file
318
src/api/services/auth_service.py
Normal file
@@ -0,0 +1,318 @@
|
||||
"""
|
||||
Authentication service for user login, logout, and token management
|
||||
"""
|
||||
from typing import Optional, Dict, Any
|
||||
from datetime import timedelta
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from passlib.hash import bcrypt
|
||||
import structlog
|
||||
|
||||
from models.user import User
|
||||
from models.audit_log import AuditLog
|
||||
from utils.jwt_utils import create_access_token, create_refresh_token, verify_token, decode_token
|
||||
from clients.redis_client import redis_client
|
||||
from config import settings
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
class AuthService:
|
||||
"""Service for authentication operations"""
|
||||
|
||||
def __init__(self, db_session: AsyncSession):
|
||||
self.db = db_session
|
||||
|
||||
async def login(
|
||||
self,
|
||||
username: str,
|
||||
password: str,
|
||||
ip_address: Optional[str] = None,
|
||||
user_agent: Optional[str] = None
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Authenticate user and generate tokens
|
||||
|
||||
Args:
|
||||
username: Username to authenticate
|
||||
password: Plain text password
|
||||
ip_address: Client IP address for audit logging
|
||||
user_agent: Client user agent for audit logging
|
||||
|
||||
Returns:
|
||||
Dictionary with tokens and user info, or None if authentication failed
|
||||
"""
|
||||
logger.info("login_attempt", username=username, ip_address=ip_address)
|
||||
|
||||
# Find user by username
|
||||
result = await self.db.execute(
|
||||
select(User).where(User.username == username)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
logger.warning("login_failed_user_not_found", username=username)
|
||||
# Create audit log for failed login
|
||||
await self._create_audit_log(
|
||||
action="auth.login",
|
||||
target=username,
|
||||
outcome="failure",
|
||||
details={"reason": "user_not_found"},
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent
|
||||
)
|
||||
return None
|
||||
|
||||
# Verify password
|
||||
if not await self.verify_password(password, user.password_hash):
|
||||
logger.warning("login_failed_invalid_password", username=username, user_id=str(user.id))
|
||||
# Create audit log for failed login
|
||||
await self._create_audit_log(
|
||||
action="auth.login",
|
||||
target=username,
|
||||
outcome="failure",
|
||||
details={"reason": "invalid_password"},
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
user_id=user.id
|
||||
)
|
||||
return None
|
||||
|
||||
# Generate tokens
|
||||
token_data = {
|
||||
"sub": str(user.id),
|
||||
"username": user.username,
|
||||
"role": user.role.value
|
||||
}
|
||||
|
||||
access_token = create_access_token(token_data)
|
||||
refresh_token = create_refresh_token(token_data)
|
||||
|
||||
logger.info("login_success", username=username, user_id=str(user.id), role=user.role.value)
|
||||
|
||||
# Create audit log for successful login
|
||||
await self._create_audit_log(
|
||||
action="auth.login",
|
||||
target=username,
|
||||
outcome="success",
|
||||
details={"role": user.role.value},
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
user_id=user.id
|
||||
)
|
||||
|
||||
# Return token response
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token,
|
||||
"token_type": "bearer",
|
||||
"expires_in": settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES * 60, # Convert to seconds
|
||||
"user": {
|
||||
"id": str(user.id),
|
||||
"username": user.username,
|
||||
"role": user.role.value,
|
||||
"created_at": user.created_at,
|
||||
"updated_at": user.updated_at
|
||||
}
|
||||
}
|
||||
|
||||
async def logout(
|
||||
self,
|
||||
token: str,
|
||||
ip_address: Optional[str] = None,
|
||||
user_agent: Optional[str] = None
|
||||
) -> bool:
|
||||
"""
|
||||
Logout user by blacklisting their token
|
||||
|
||||
Args:
|
||||
token: JWT access token to blacklist
|
||||
ip_address: Client IP address for audit logging
|
||||
user_agent: Client user agent for audit logging
|
||||
|
||||
Returns:
|
||||
True if logout successful, False otherwise
|
||||
"""
|
||||
# Decode and verify token
|
||||
payload = decode_token(token)
|
||||
if not payload:
|
||||
logger.warning("logout_failed_invalid_token")
|
||||
return False
|
||||
|
||||
user_id = payload.get("sub")
|
||||
username = payload.get("username")
|
||||
|
||||
# Calculate remaining TTL for token
|
||||
exp = payload.get("exp")
|
||||
if not exp:
|
||||
logger.warning("logout_failed_no_expiration", user_id=user_id)
|
||||
return False
|
||||
|
||||
# Blacklist token in Redis with TTL matching token expiration
|
||||
from datetime import datetime
|
||||
remaining_seconds = int(exp - datetime.utcnow().timestamp())
|
||||
|
||||
if remaining_seconds > 0:
|
||||
blacklist_key = f"blacklist:{token}"
|
||||
await redis_client.set(blacklist_key, "1", expire=remaining_seconds)
|
||||
logger.info("token_blacklisted", user_id=user_id, username=username, ttl=remaining_seconds)
|
||||
|
||||
# Create audit log for logout
|
||||
await self._create_audit_log(
|
||||
action="auth.logout",
|
||||
target=username,
|
||||
outcome="success",
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
user_id=user_id
|
||||
)
|
||||
|
||||
logger.info("logout_success", user_id=user_id, username=username)
|
||||
return True
|
||||
|
||||
async def validate_token(self, token: str) -> Optional[User]:
|
||||
"""
|
||||
Validate JWT token and return user if valid
|
||||
|
||||
Args:
|
||||
token: JWT access token
|
||||
|
||||
Returns:
|
||||
User object if token is valid, None otherwise
|
||||
"""
|
||||
# Verify token signature and expiration
|
||||
payload = verify_token(token, token_type="access")
|
||||
if not payload:
|
||||
return None
|
||||
|
||||
# Check if token is blacklisted
|
||||
blacklist_key = f"blacklist:{token}"
|
||||
is_blacklisted = await redis_client.get(blacklist_key)
|
||||
if is_blacklisted:
|
||||
logger.warning("token_blacklisted_validation_failed", user_id=payload.get("sub"))
|
||||
return None
|
||||
|
||||
# Get user from database
|
||||
user_id = payload.get("sub")
|
||||
if not user_id:
|
||||
return None
|
||||
|
||||
result = await self.db.execute(
|
||||
select(User).where(User.id == user_id)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
return user
|
||||
|
||||
async def refresh_access_token(
|
||||
self,
|
||||
refresh_token: str,
|
||||
ip_address: Optional[str] = None
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Generate new access token from refresh token
|
||||
|
||||
Args:
|
||||
refresh_token: JWT refresh token
|
||||
ip_address: Client IP address for audit logging
|
||||
|
||||
Returns:
|
||||
Dictionary with new access token, or None if refresh failed
|
||||
"""
|
||||
# Verify refresh token
|
||||
payload = verify_token(refresh_token, token_type="refresh")
|
||||
if not payload:
|
||||
logger.warning("refresh_failed_invalid_token")
|
||||
return None
|
||||
|
||||
# Check if refresh token is blacklisted
|
||||
blacklist_key = f"blacklist:{refresh_token}"
|
||||
is_blacklisted = await redis_client.get(blacklist_key)
|
||||
if is_blacklisted:
|
||||
logger.warning("refresh_failed_token_blacklisted", user_id=payload.get("sub"))
|
||||
return None
|
||||
|
||||
# Generate new access token
|
||||
token_data = {
|
||||
"sub": payload.get("sub"),
|
||||
"username": payload.get("username"),
|
||||
"role": payload.get("role")
|
||||
}
|
||||
|
||||
access_token = create_access_token(token_data)
|
||||
|
||||
logger.info("token_refreshed", user_id=payload.get("sub"), username=payload.get("username"))
|
||||
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"token_type": "bearer",
|
||||
"expires_in": settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES * 60
|
||||
}
|
||||
|
||||
async def hash_password(self, password: str) -> str:
|
||||
"""
|
||||
Hash password using bcrypt
|
||||
|
||||
Args:
|
||||
password: Plain text password
|
||||
|
||||
Returns:
|
||||
Bcrypt hashed password
|
||||
"""
|
||||
return bcrypt.hash(password)
|
||||
|
||||
async def verify_password(self, plain_password: str, hashed_password: str) -> bool:
|
||||
"""
|
||||
Verify password against hash
|
||||
|
||||
Args:
|
||||
plain_password: Plain text password
|
||||
hashed_password: Bcrypt hashed password
|
||||
|
||||
Returns:
|
||||
True if password matches, False otherwise
|
||||
"""
|
||||
try:
|
||||
return bcrypt.verify(plain_password, hashed_password)
|
||||
except Exception as e:
|
||||
logger.error("password_verification_error", error=str(e))
|
||||
return False
|
||||
|
||||
async def _create_audit_log(
|
||||
self,
|
||||
action: str,
|
||||
target: str,
|
||||
outcome: str,
|
||||
details: Optional[Dict[str, Any]] = None,
|
||||
ip_address: Optional[str] = None,
|
||||
user_agent: Optional[str] = None,
|
||||
user_id: Optional[str] = None
|
||||
) -> None:
|
||||
"""
|
||||
Create audit log entry
|
||||
|
||||
Args:
|
||||
action: Action name (e.g., "auth.login")
|
||||
target: Target of action (e.g., username)
|
||||
outcome: Outcome ("success", "failure", "error")
|
||||
details: Additional details as dictionary
|
||||
ip_address: Client IP address
|
||||
user_agent: Client user agent
|
||||
user_id: User UUID (if available)
|
||||
"""
|
||||
try:
|
||||
audit_log = AuditLog(
|
||||
user_id=user_id,
|
||||
action=action,
|
||||
target=target,
|
||||
outcome=outcome,
|
||||
details=details,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent
|
||||
)
|
||||
self.db.add(audit_log)
|
||||
await self.db.commit()
|
||||
except Exception as e:
|
||||
logger.error("audit_log_creation_failed", action=action, error=str(e))
|
||||
# Don't let audit log failure break the operation
|
||||
await self.db.rollback()
|
||||
Reference in New Issue
Block a user