394 lines
15 KiB
Python
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"
|
|
) |