Files
bakery-ia/services/auth/app/services/auth_service.py
2025-08-02 23:42:59 +02:00

394 lines
15 KiB
Python

# services/auth/app/services/auth_service.py - UPDATED WITH NEW REGISTRATION METHOD
"""
Authentication Service - Updated to support registration with direct token issuance
"""
import hashlib
import uuid
from datetime import datetime, timedelta, timezone
from typing import Dict, Any, Optional
from fastapi import HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update
from sqlalchemy.exc import IntegrityError
import structlog
from app.models.users import User, RefreshToken
from app.schemas.auth import UserRegistration, UserLogin
from app.core.security import SecurityManager
from app.services.messaging import publish_user_registered, publish_user_login
logger = structlog.get_logger()
class AuthService:
"""Enhanced Authentication service with unified token response"""
@staticmethod
async def register_user(user_data: UserRegistration, db: AsyncSession) -> Dict[str, Any]:
"""Register a new user with FIXED token generation"""
try:
# Check if user already exists
existing_user = await db.execute(
select(User).where(User.email == user_data.email)
)
if existing_user.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="User with this email already exists"
)
user_role = user_data.role if user_data.role else "user"
# Create new user
hashed_password = SecurityManager.hash_password(user_data.password)
new_user = User(
id=uuid.uuid4(),
email=user_data.email,
full_name=user_data.full_name,
hashed_password=hashed_password,
is_active=True,
is_verified=False,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
role=user_role
)
db.add(new_user)
await db.flush() # Get user ID without committing
logger.debug(f"User created with role: {new_user.role} for {user_data.email}")
# ✅ FIX 1: Create SEPARATE access and refresh tokens with different payloads
access_token_data = {
"user_id": str(new_user.id),
"email": new_user.email,
"full_name": new_user.full_name,
"is_verified": new_user.is_verified,
"is_active": new_user.is_active,
"role": new_user.role,
"type": "access" # ✅ Explicitly mark as access token
}
refresh_token_data = {
"user_id": str(new_user.id),
"email": new_user.email,
"type": "refresh" # ✅ Explicitly mark as refresh token
}
logger.debug(f"Creating tokens for registration: {user_data.email}")
# ✅ FIX 2: Generate tokens with different payloads
access_token = SecurityManager.create_access_token(user_data=access_token_data)
refresh_token_value = SecurityManager.create_refresh_token(user_data=refresh_token_data)
logger.debug(f"Tokens created successfully for {user_data.email}")
# ✅ FIX 3: Store ONLY the refresh token in database (not access token)
refresh_token = RefreshToken(
id=uuid.uuid4(),
user_id=new_user.id,
token=refresh_token_value, # Store the actual refresh token
expires_at=datetime.now(timezone.utc) + timedelta(days=30),
is_revoked=False,
created_at=datetime.now(timezone.utc)
)
db.add(refresh_token)
await db.commit()
# Publish registration event (non-blocking)
try:
await publish_user_registered({
"user_id": str(new_user.id),
"email": new_user.email,
"full_name": new_user.full_name,
"role": new_user.role,
"registered_at": datetime.now(timezone.utc).isoformat()
})
except Exception as e:
logger.warning(f"Failed to publish registration event: {e}")
logger.info(f"User registered successfully: {user_data.email}")
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(),
"role": new_user.role
}
}
except HTTPException:
await db.rollback()
raise
except IntegrityError as e:
await db.rollback()
logger.error(f"Registration failed for {user_data.email}: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Registration failed"
)
except Exception as e:
await db.rollback()
logger.error(f"Registration failed for {user_data.email}: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Registration failed"
)
@staticmethod
async def login_user(login_data: UserLogin, db: AsyncSession) -> Dict[str, Any]:
"""Login user with FIXED token generation and SQLAlchemy syntax"""
try:
# Find user
result = await db.execute(
select(User).where(User.email == login_data.email)
)
user = result.scalar_one_or_none()
if not user or not SecurityManager.verify_password(login_data.password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid email or password"
)
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Account is deactivated"
)
# ✅ FIX 4: Revoke existing refresh tokens using proper SQLAlchemy ORM syntax
logger.debug(f"Revoking existing refresh tokens for user: {user.id}")
# Using SQLAlchemy ORM update (more reliable than raw SQL)
stmt = update(RefreshToken).where(
RefreshToken.user_id == user.id,
RefreshToken.is_revoked == False
).values(
is_revoked=True,
revoked_at=datetime.now(timezone.utc)
)
result = await db.execute(stmt)
revoked_count = result.rowcount
logger.debug(f"Revoked {revoked_count} existing refresh tokens for user: {user.id}")
# ✅ FIX 5: Create DIFFERENT token payloads
access_token_data = {
"user_id": str(user.id),
"email": user.email,
"full_name": user.full_name,
"is_verified": user.is_verified,
"is_active": user.is_active,
"role": user.role,
"type": "access" # ✅ Explicitly mark as access token
}
refresh_token_data = {
"user_id": str(user.id),
"email": user.email,
"type": "refresh", # ✅ Explicitly mark as refresh token
"jti": str(uuid.uuid4()) # ✅ Add unique identifier for each refresh token
}
logger.debug(f"Creating access token for login with data: {list(access_token_data.keys())}")
logger.debug(f"Creating refresh token for login with data: {list(refresh_token_data.keys())}")
# ✅ FIX 6: Generate tokens with different payloads and expiration
access_token = SecurityManager.create_access_token(user_data=access_token_data)
refresh_token_value = SecurityManager.create_refresh_token(user_data=refresh_token_data)
logger.debug(f"Access token created successfully for user {login_data.email}")
logger.debug(f"Refresh token created successfully for user {str(user.id)}")
# ✅ FIX 7: Store ONLY refresh token in database with unique constraint handling
refresh_token = RefreshToken(
id=uuid.uuid4(),
user_id=user.id,
token=refresh_token_value, # This should be the refresh token, not access token
expires_at=datetime.now(timezone.utc) + timedelta(days=30),
is_revoked=False,
created_at=datetime.now(timezone.utc)
)
db.add(refresh_token)
# Update last login
user.last_login = datetime.now(timezone.utc)
await db.commit()
# Publish login event (non-blocking)
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: {login_data.email}")
return {
"access_token": access_token,
"refresh_token": refresh_token_value,
"token_type": "bearer",
"expires_in": 1800, # 30 minutes
"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(),
"role": user.role
}
}
except HTTPException:
await db.rollback()
raise
except IntegrityError as e:
await db.rollback()
logger.error(f"Login failed for {login_data.email}: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Login failed"
)
except Exception as e:
await db.rollback()
logger.error(f"Login failed for {login_data.email}: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Login failed"
)
@staticmethod
async def logout_user(user_id: str, refresh_token: str, db: AsyncSession) -> bool:
"""Logout user by revoking refresh token"""
try:
# Revoke the specific refresh token using ORM
stmt = update(RefreshToken).where(
RefreshToken.user_id == user_id,
RefreshToken.token == refresh_token,
RefreshToken.is_revoked == False
).values(
is_revoked=True,
revoked_at=datetime.now(timezone.utc)
)
result = await db.execute(stmt)
if result.rowcount > 0:
await db.commit()
logger.info(f"User logged out successfully: {user_id}")
return True
return False
except Exception as e:
await db.rollback()
logger.error(f"Logout failed for user {user_id}: {e}")
return False
@staticmethod
async def refresh_access_token(refresh_token: str, db: AsyncSession) -> Dict[str, Any]:
"""Refresh access token using refresh token"""
try:
# Verify refresh token
payload = SecurityManager.decode_token(refresh_token)
user_id = payload.get("user_id")
if not user_id:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid refresh token"
)
# Check if refresh token exists and is valid using ORM
result = await db.execute(
select(RefreshToken).where(
RefreshToken.user_id == user_id,
RefreshToken.token == refresh_token,
RefreshToken.is_revoked == False,
RefreshToken.expires_at > datetime.now(timezone.utc)
)
)
stored_token = result.scalar_one_or_none()
if not stored_token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired refresh token"
)
# Get user
user_result = await db.execute(
select(User).where(User.id == user_id)
)
user = user_result.scalar_one_or_none()
if not user or not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found or inactive"
)
# Create new access token
access_token_data = {
"user_id": str(user.id),
"email": user.email,
"full_name": user.full_name,
"is_verified": user.is_verified,
"is_active": user.is_active,
"role": user.role,
"type": "access"
}
new_access_token = SecurityManager.create_access_token(user_data=access_token_data)
return {
"access_token": new_access_token,
"token_type": "bearer",
"expires_in": 1800
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Token refresh failed: {e}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token refresh failed"
)
@staticmethod
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:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token"
)
return payload
except Exception as e:
logger.error(f"Token verification error: {e}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token"
)