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

View File

@@ -0,0 +1,3 @@
"""
Business logic services
"""

View 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()