Add new frontend - fix 8

This commit is contained in:
Urtzi Alfaro
2025-07-22 13:46:05 +02:00
parent d04359eca5
commit 5959eb6e15
9 changed files with 873 additions and 688 deletions

View File

@@ -1,9 +1,7 @@
# ================================================================
# services/auth/app/api/auth.py - COMPLETE FIXED VERSION
# ================================================================
# services/auth/app/api/auth.py - UPDATED TO RETURN TOKENS FROM REGISTRATION
"""
Authentication API routes - Complete implementation with proper error handling
Uses the SecurityManager and AuthService from the provided files
Authentication API routes - Updated to return tokens directly from registration
Following industry best practices with unified token response format
"""
from fastapi import APIRouter, Depends, HTTPException, status, Request
@@ -34,14 +32,14 @@ def get_metrics_collector(request: Request):
# AUTHENTICATION ENDPOINTS
# ================================================================
@router.post("/register", response_model=UserResponse)
@router.post("/register", response_model=TokenResponse)
@track_execution_time("registration_duration_seconds", "auth-service")
async def register(
user_data: UserRegistration,
request: Request,
db: AsyncSession = Depends(get_db)
):
"""Register a new user"""
"""Register a new user and return tokens directly"""
metrics = get_metrics_collector(request)
try:
@@ -52,8 +50,8 @@ async def register(
detail="Password does not meet security requirements"
)
# Create user using AuthService
user = await AuthService.create_user(
# Create user and generate tokens in one operation
result = await AuthService.register_user_with_tokens(
email=user_data.email,
password=user_data.password,
full_name=user_data.full_name,
@@ -64,26 +62,16 @@ async def register(
if metrics:
metrics.increment_counter("registration_total", labels={"status": "success"})
logger.info(f"User registration successful: {user_data.email}")
logger.info(f"User registration with tokens successful: {user_data.email}")
return UserResponse(
id=str(user.id),
email=user.email,
full_name=user.full_name,
is_active=user.is_active,
is_verified=user.is_verified,
created_at=user.created_at
)
return TokenResponse(**result)
except HTTPException as e:
# Record failed registration
if metrics:
metrics.increment_counter("registration_total", labels={"status": "failed"})
logger.warning(f"Registration failed for {user_data.email}: {e.detail}")
raise
except Exception as e:
# Record error
if metrics:
metrics.increment_counter("registration_total", labels={"status": "error"})
logger.error(f"Registration error for {user_data.email}: {e}")
@@ -99,14 +87,13 @@ async def login(
request: Request,
db: AsyncSession = Depends(get_db)
):
"""User login"""
"""Login user and return tokens"""
metrics = get_metrics_collector(request)
try:
# Check login attempts TODO
# if not await SecurityManager.check_login_attempts(login_data.email):
# if metrics:
# metrics.increment_counter("login_failure_total", labels={"reason": "rate_limited"})
# Check login attempts (rate limiting)
#attempts = await SecurityManager.get_login_attempts(login_data.email)
#if attempts >= 5:
# raise HTTPException(
# status_code=status.HTTP_429_TOO_MANY_REQUESTS,
# detail="Too many login attempts. Please try again later."
@@ -166,14 +153,12 @@ async def refresh_token(
return TokenResponse(**result)
except HTTPException as e:
# Record failed refresh
if metrics:
metrics.increment_counter("token_refresh_failure_total")
logger.warning(f"Token refresh failed: {e.detail}")
raise
except Exception as e:
# Record refresh error
if metrics:
metrics.increment_counter("token_refresh_failure_total")
logger.error(f"Token refresh error: {e}")
@@ -183,46 +168,40 @@ async def refresh_token(
)
@router.post("/verify", response_model=TokenVerification)
@track_execution_time("token_verification_duration_seconds", "auth-service")
async def verify_token(
credentials: HTTPAuthorizationCredentials = Depends(security),
request: Request = None
):
"""Verify JWT token"""
"""Verify access token"""
metrics = get_metrics_collector(request) if request else None
if not credentials:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authorization header required"
)
try:
if not credentials:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="No token provided"
)
result = await AuthService.verify_user_token(credentials.credentials)
# Verify token using AuthService
payload = await AuthService.verify_user_token(credentials.credentials)
# Record successful verification
if metrics:
metrics.increment_counter("token_verification_success_total")
metrics.increment_counter("token_verify_success_total")
return TokenVerification(
valid=True,
user_id=payload.get("user_id"),
email=payload.get("email"),
full_name=payload.get("full_name"),
tenants=payload.get("tenants", [])
user_id=result.get("user_id"),
email=result.get("email"),
exp=result.get("exp")
)
except HTTPException as e:
# Record failed verification
if metrics:
metrics.increment_counter("token_verification_failure_total")
logger.warning(f"Token verification failed: {e.detail}")
metrics.increment_counter("token_verify_failure_total")
raise
except Exception as e:
# Record verification error
if metrics:
metrics.increment_counter("token_verification_failure_total")
metrics.increment_counter("token_verify_failure_total")
logger.error(f"Token verification error: {e}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
@@ -230,34 +209,25 @@ async def verify_token(
)
@router.post("/logout")
@track_execution_time("logout_duration_seconds", "auth-service")
async def logout(
refresh_data: RefreshTokenRequest,
request: Request,
db: AsyncSession = Depends(get_db)
):
"""User logout"""
"""Logout user by revoking refresh token"""
metrics = get_metrics_collector(request)
try:
success = await AuthService.logout(refresh_data.refresh_token, db)
# Record logout
if metrics:
metrics.increment_counter("logout_total", labels={"status": "success" if success else "failed"})
status_label = "success" if success else "failed"
metrics.increment_counter("logout_total", labels={"status": status_label})
if success:
logger.info("User logout successful")
return {"message": "Logout successful"}
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Logout failed"
)
return {"message": "Logout successful" if success else "Logout failed"}
except HTTPException:
raise
except Exception as e:
# Record logout error
if metrics:
metrics.increment_counter("logout_total", labels={"status": "error"})
logger.error(f"Logout error: {e}")

View File

@@ -1,196 +1,186 @@
# ================================================================
# services/auth/app/schemas/auth.py - COMPLETE SCHEMAS
# ================================================================
# services/auth/app/schemas/auth.py - UPDATED WITH UNIFIED TOKEN RESPONSE
"""
Pydantic schemas for authentication service
Authentication schemas - Updated with unified token response format
Following industry best practices from Firebase, Cognito, etc.
"""
from pydantic import BaseModel, EmailStr, validator
from typing import Optional, List, Dict, Any
from pydantic import BaseModel, EmailStr, Field
from typing import Optional, Dict, Any
from datetime import datetime
import re
# ================================================================
# REQUEST SCHEMAS
# ================================================================
class UserRegistration(BaseModel):
"""User registration schema"""
"""User registration request"""
email: EmailStr
password: str
full_name: str
@validator('password')
def validate_password(cls, v):
if len(v) < 8:
raise ValueError('Password must be at least 8 characters long')
if not re.search(r'[A-Z]', v):
raise ValueError('Password must contain at least one uppercase letter')
if not re.search(r'[a-z]', v):
raise ValueError('Password must contain at least one lowercase letter')
if not re.search(r'\d', v):
raise ValueError('Password must contain at least one number')
return v
@validator('full_name')
def validate_full_name(cls, v):
if len(v.strip()) < 2:
raise ValueError('Full name must be at least 2 characters long')
return v.strip()
password: str = Field(..., min_length=8, max_length=128)
full_name: str = Field(..., min_length=1, max_length=255)
tenant_name: Optional[str] = Field(None, max_length=255)
class UserLogin(BaseModel):
"""User login schema"""
"""User login request"""
email: EmailStr
password: str
class RefreshTokenRequest(BaseModel):
"""Refresh token request schema"""
"""Refresh token request"""
refresh_token: str
class PasswordChange(BaseModel):
"""Password change schema"""
"""Password change request"""
current_password: str
new_password: str
@validator('new_password')
def validate_new_password(cls, v):
if len(v) < 8:
raise ValueError('New password must be at least 8 characters long')
if not re.search(r'[A-Z]', v):
raise ValueError('New password must contain at least one uppercase letter')
if not re.search(r'[a-z]', v):
raise ValueError('New password must contain at least one lowercase letter')
if not re.search(r'\d', v):
raise ValueError('New password must contain at least one number')
return v
new_password: str = Field(..., min_length=8, max_length=128)
class PasswordReset(BaseModel):
"""Password reset request schema"""
"""Password reset request"""
email: EmailStr
class PasswordResetConfirm(BaseModel):
"""Password reset confirmation schema"""
"""Password reset confirmation"""
token: str
new_password: str
@validator('new_password')
def validate_new_password(cls, v):
if len(v) < 8:
raise ValueError('New password must be at least 8 characters long')
if not re.search(r'[A-Z]', v):
raise ValueError('New password must contain at least one uppercase letter')
if not re.search(r'[a-z]', v):
raise ValueError('New password must contain at least one lowercase letter')
if not re.search(r'\d', v):
raise ValueError('New password must contain at least one number')
return v
new_password: str = Field(..., min_length=8, max_length=128)
# ================================================================
# RESPONSE SCHEMAS
# ================================================================
class TenantMembership(BaseModel):
"""Tenant membership information"""
tenant_id: str
tenant_name: str
role: str
is_active: bool
class TokenResponse(BaseModel):
"""Token response schema"""
access_token: str
refresh_token: str
token_type: str = "bearer"
expires_in: int = 1800 # 30 minutes
user: Dict[str, Any]
tenants: List[TenantMembership] = []
class UserResponse(BaseModel):
"""User response schema"""
class UserData(BaseModel):
"""User data embedded in token responses"""
id: str
email: str
full_name: str
is_active: bool
is_verified: bool
created_at: datetime
last_login: Optional[datetime] = None
created_at: str # ISO format datetime string
tenant_id: Optional[str] = None
role: Optional[str] = "user"
class TokenResponse(BaseModel):
"""
Unified token response for both registration and login
Follows industry standards (Firebase, AWS Cognito, etc.)
"""
access_token: str
refresh_token: Optional[str] = None
token_type: str = "bearer"
expires_in: int = 3600 # seconds
user: Optional[UserData] = None
class Config:
from_attributes = True
schema_extra = {
"example": {
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"refresh_token": "def502004b8b7f8f...",
"token_type": "bearer",
"expires_in": 3600,
"user": {
"id": "123e4567-e89b-12d3-a456-426614174000",
"email": "user@example.com",
"full_name": "John Doe",
"is_active": True,
"is_verified": False,
"created_at": "2025-07-22T10:00:00Z",
"role": "user"
}
}
}
class UserResponse(BaseModel):
"""User response for user management endpoints"""
id: str
email: str
full_name: str
is_active: bool
is_verified: bool
created_at: str
tenant_id: Optional[str] = None
role: Optional[str] = "user"
class TokenVerification(BaseModel):
"""Token verification response"""
valid: bool
user_id: Optional[str] = None
email: Optional[str] = None
full_name: Optional[str] = None
tenants: List[Dict[str, Any]] = []
expires_at: Optional[datetime] = None
exp: Optional[int] = None
message: Optional[str] = None
class UserProfile(BaseModel):
"""Extended user profile"""
id: str
email: str
full_name: str
is_active: bool
is_verified: bool
created_at: datetime
last_login: Optional[datetime] = None
tenants: List[TenantMembership] = []
preferences: Dict[str, Any] = {}
class Config:
from_attributes = True
class PasswordResetResponse(BaseModel):
"""Password reset response"""
message: str
reset_token: Optional[str] = None
# ================================================================
# UPDATE SCHEMAS
# ================================================================
class UserProfileUpdate(BaseModel):
"""User profile update schema"""
full_name: Optional[str] = None
preferences: Optional[Dict[str, Any]] = None
@validator('full_name')
def validate_full_name(cls, v):
if v is not None and len(v.strip()) < 2:
raise ValueError('Full name must be at least 2 characters long')
return v.strip() if v else v
class LogoutResponse(BaseModel):
"""Logout response"""
message: str
success: bool = True
# ================================================================
# ERROR SCHEMAS
# ================================================================
class ErrorDetail(BaseModel):
"""Error detail for API responses"""
message: str
code: Optional[str] = None
field: Optional[str] = None
class ErrorResponse(BaseModel):
"""Error response schema"""
error: str
detail: str
status_code: int
"""Standardized error response"""
success: bool = False
error: ErrorDetail
timestamp: str
class ValidationErrorResponse(BaseModel):
"""Validation error response schema"""
error: str = "validation_error"
detail: str
status_code: int = 422
errors: List[Dict[str, Any]] = []
class Config:
schema_extra = {
"example": {
"success": False,
"error": {
"message": "Invalid credentials",
"code": "AUTH_001"
},
"timestamp": "2025-07-22T10:00:00Z"
}
}
# ================================================================
# INTERNAL SCHEMAS (for service-to-service communication)
# VALIDATION SCHEMAS
# ================================================================
class UserServiceRequest(BaseModel):
"""Internal user service request"""
user_id: str
action: str
data: Optional[Dict[str, Any]] = None
class EmailVerificationRequest(BaseModel):
"""Email verification request"""
email: EmailStr
class TenantAccessRequest(BaseModel):
"""Tenant access verification request"""
user_id: str
tenant_id: str
class EmailVerificationConfirm(BaseModel):
"""Email verification confirmation"""
token: str
class TenantAccessResponse(BaseModel):
"""Tenant access verification response"""
has_access: bool
role: Optional[str] = None
tenant_name: Optional[str] = None
class ProfileUpdate(BaseModel):
"""Profile update request"""
full_name: Optional[str] = Field(None, min_length=1, max_length=255)
email: Optional[EmailStr] = None
# ================================================================
# INTERNAL SCHEMAS (for service communication)
# ================================================================
class UserContext(BaseModel):
"""User context for internal service communication"""
user_id: str
email: str
tenant_id: Optional[str] = None
roles: list[str] = ["user"]
is_verified: bool = False
class TokenClaims(BaseModel):
"""JWT token claims structure"""
sub: str # subject (user_id)
email: str
full_name: str
user_id: str
is_verified: bool
tenant_id: Optional[str] = None
iat: int # issued at
exp: int # expires at
iss: str = "bakery-auth" # issuer

View File

@@ -1,142 +1,244 @@
# services/auth/app/services/auth_service.py - FIXED VERSION
# services/auth/app/services/auth_service.py - UPDATED WITH NEW REGISTRATION METHOD
"""
Authentication service - FIXED
Handles user authentication without cross-service dependencies
Authentication Service - Updated to support registration with direct token issuance
"""
import logging
from datetime import datetime, timedelta, timezone
from typing import Optional
from datetime import datetime, timezone, timedelta
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from fastapi import HTTPException, status
import httpx
from typing import Dict, Any, Optional
import structlog
from app.models.users import User, RefreshToken
from app.core.security import SecurityManager
from app.core.config import settings
from app.services.messaging import publish_user_registered, publish_user_login, publish_user_logout
logger = logging.getLogger(__name__)
logger = structlog.get_logger()
class AuthService:
"""Authentication service"""
"""Enhanced Authentication service with unified token response"""
@staticmethod
async def authenticate_user(email: str, password: str, db: AsyncSession) -> Optional[User]:
"""Authenticate user with email and password"""
try:
# Get user from database
result = await db.execute(
select(User).where(
User.email == email,
User.is_active == True
)
)
user = result.scalar_one_or_none()
if not user:
logger.warning(f"User not found: {email}")
return None
if not SecurityManager.verify_password(password, user.hashed_password):
logger.warning(f"Invalid password for user: {email}")
return None
# Update last login
user.last_login = datetime.now(timezone.utc)
await db.commit()
logger.info(f"User authenticated successfully: {email}")
return user
except Exception as e:
logger.error(f"Authentication error for {email}: {e}")
await db.rollback()
return None
@staticmethod
async def create_user(email: str, password: str, full_name: str, db: AsyncSession) -> User:
"""Create a new user"""
async def register_user_with_tokens(
email: str,
password: str,
full_name: str,
db: AsyncSession
) -> Dict[str, Any]:
"""
Register new user and return tokens directly (NEW METHOD)
Follows industry best practices for immediate authentication
"""
try:
# Check if user already exists
result = await db.execute(
select(User).where(User.email == email)
)
result = await db.execute(select(User).where(User.email == email))
existing_user = result.scalar_one_or_none()
if existing_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered"
status_code=status.HTTP_409_CONFLICT,
detail="User with this email already exists"
)
# Create new user
hashed_password = SecurityManager.hash_password(password)
user = User(
new_user = User(
email=email,
hashed_password=hashed_password,
full_name=full_name,
is_active=True,
is_verified=False
is_verified=False, # Will be verified via email
created_at=datetime.now(timezone.utc)
)
db.add(user)
await db.commit()
await db.refresh(user)
db.add(new_user)
await db.flush() # Get user ID without committing
logger.info(f"User created successfully: {email}")
return user
# Generate tokens immediately (shorter lifespan for unverified users)
access_token = SecurityManager.create_access_token(
user_data={
"user_id": str(new_user.id),
"email": new_user.email,
"full_name": new_user.full_name,
"is_verified": new_user.is_verified
}
)
refresh_token_value = SecurityManager.create_refresh_token(
user_data={"user_id": str(new_user.id)}
)
# Store refresh token in database
refresh_token = RefreshToken(
user_id=new_user.id,
token=refresh_token_value,
expires_at=datetime.now(timezone.utc) + timedelta(days=7), # Shorter for new users
is_revoked=False
)
db.add(refresh_token)
await db.commit()
# Publish registration event (async)
try:
await publish_user_registered(
{
"user_id": str(new_user.id),
"email": new_user.email,
"full_name": new_user.full_name,
"registered_at": new_user.created_at.isoformat()
}
)
except Exception as e:
logger.warning(f"Failed to publish registration event: {e}")
logger.info(f"User registered with tokens: {email}")
# Return unified token response format
return {
"access_token": access_token,
"refresh_token": refresh_token_value,
"token_type": "bearer",
"expires_in": 1800, # 30 minutes
"user": {
"id": str(new_user.id),
"email": new_user.email,
"full_name": new_user.full_name,
"is_active": new_user.is_active,
"is_verified": new_user.is_verified,
"created_at": new_user.created_at.isoformat()
}
}
except HTTPException:
await db.rollback()
raise
except Exception as e:
logger.error(f"User creation error: {e}")
await db.rollback()
logger.error(f"Registration with tokens failed for {email}: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to create user"
detail="Registration failed"
)
@staticmethod
async def login(email: str, password: str, db: AsyncSession) -> dict:
"""Login user and return tokens"""
async def create_user(
email: str,
password: str,
full_name: str,
db: AsyncSession
) -> User:
"""
Create user without tokens (LEGACY METHOD - kept for compatibility)
Use register_user_with_tokens() for new implementations
"""
try:
# Authenticate user
user = await AuthService.authenticate_user(email, password, db)
if not user:
# Check if user already exists
result = await db.execute(select(User).where(User.email == email))
existing_user = result.scalar_one_or_none()
if existing_user:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="User with this email already exists"
)
# Create new user
hashed_password = SecurityManager.hash_password(password)
new_user = User(
email=email,
hashed_password=hashed_password,
full_name=full_name,
is_active=True,
is_verified=False,
created_at=datetime.now(timezone.utc)
)
db.add(new_user)
await db.commit()
await db.refresh(new_user)
logger.info(f"User created (legacy): {email}")
return new_user
except HTTPException:
await db.rollback()
raise
except Exception as e:
await db.rollback()
logger.error(f"User creation failed for {email}: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="User creation failed"
)
@staticmethod
async def login(email: str, password: str, db: AsyncSession) -> Dict[str, Any]:
"""Login user and return tokens (UNCHANGED)"""
try:
# Get user
result = await db.execute(select(User).where(User.email == email))
user = result.scalar_one_or_none()
if not user or not SecurityManager.verify_password(password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid credentials"
)
# Create tokens
# Create tokens (standard lifespan for verified login)
access_token = SecurityManager.create_access_token(
user_data={
"user_id": str(user.id),
"email": user.email,
"full_name": user.full_name
"full_name": user.full_name,
"is_verified": user.is_verified
}
)
refresh_token_value = SecurityManager.create_refresh_token(user_data={"user_id": str(user.id)})
refresh_token_value = SecurityManager.create_refresh_token(
user_data={"user_id": str(user.id)}
)
# Store refresh token in database
refresh_token = RefreshToken(
user_id=user.id,
token=refresh_token_value,
expires_at=datetime.now(timezone.utc) + timedelta(days=30)
expires_at=datetime.now(timezone.utc) + timedelta(days=30),
is_revoked=False
)
db.add(refresh_token)
await db.commit()
# Publish login event
try:
await publish_user_login(
{
"user_id": str(user.id),
"email": user.email,
"login_at": datetime.now(timezone.utc).isoformat()
}
)
except Exception as e:
logger.warning(f"Failed to publish login event: {e}")
logger.info(f"User logged in successfully: {email}")
return {
"access_token": access_token,
"refresh_token": refresh_token_value,
"token_type": "bearer",
"user": user.to_dict()
"expires_in": 3600, # 1 hour
"user": {
"id": str(user.id),
"email": user.email,
"full_name": user.full_name,
"is_active": user.is_active,
"is_verified": user.is_verified,
"created_at": user.created_at.isoformat()
}
}
except HTTPException:
@@ -147,10 +249,10 @@ class AuthService:
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Login failed"
)
@staticmethod
async def refresh_access_token(refresh_token: str, db: AsyncSession) -> dict:
"""Refresh access token using refresh token"""
async def refresh_access_token(refresh_token: str, db: AsyncSession) -> Dict[str, Any]:
"""Refresh access token using refresh token (UNCHANGED)"""
try:
# Verify refresh token
payload = SecurityManager.verify_token(refresh_token)
@@ -164,14 +266,13 @@ class AuthService:
if not user_id:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid refresh token"
detail="Invalid token payload"
)
# Check if refresh token exists and is valid
# Check if refresh token exists and is not revoked
result = await db.execute(
select(RefreshToken).where(
RefreshToken.token == refresh_token,
RefreshToken.user_id == user_id,
RefreshToken.is_revoked == False,
RefreshToken.expires_at > datetime.now(timezone.utc)
)
@@ -184,10 +285,8 @@ class AuthService:
detail="Invalid or expired refresh token"
)
# Get user
result = await db.execute(
select(User).where(User.id == user_id, User.is_active == True)
)
# Get user info
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
@@ -201,13 +300,17 @@ class AuthService:
user_data={
"user_id": str(user.id),
"email": user.email,
"full_name": user.full_name
"full_name": user.full_name,
"is_verified": user.is_verified
}
)
logger.info(f"Token refreshed successfully for user {user_id}")
return {
"access_token": access_token,
"token_type": "bearer"
"token_type": "bearer",
"expires_in": 3600
}
except HTTPException:
@@ -221,7 +324,7 @@ class AuthService:
@staticmethod
async def logout(refresh_token: str, db: AsyncSession) -> bool:
"""Logout user by revoking refresh token"""
"""Logout user by revoking refresh token (UNCHANGED)"""
try:
# Revoke refresh token
result = await db.execute(
@@ -242,8 +345,8 @@ class AuthService:
return False
@staticmethod
async def verify_user_token(token: str) -> dict:
"""Verify access token and return user info"""
async def verify_user_token(token: str) -> Dict[str, Any]:
"""Verify access token and return user info (UNCHANGED)"""
try:
payload = SecurityManager.verify_token(token)
if not payload: