Files
sdb2-backend/app/services/auth.py

275 lines
9.2 KiB
Python

"""Authentication service."""
import hashlib
from datetime import UTC, datetime, timedelta
from fastapi import HTTPException, status
from sqlmodel.ext.asyncio.session import AsyncSession
from app.core.config import settings
from app.core.logging import get_logger
from app.models.user import User
from app.repositories.user import UserRepository
from app.schemas.auth import (
AuthResponse,
TokenResponse,
UserLoginRequest,
UserRegisterRequest,
UserResponse,
)
from app.utils.auth import JWTUtils, PasswordUtils
logger = get_logger(__name__)
class AuthService:
"""Service for authentication operations."""
def __init__(self, session: AsyncSession) -> None:
"""Initialize the auth service."""
self.session = session
self.user_repo = UserRepository(session)
async def register(self, request: UserRegisterRequest) -> AuthResponse:
"""Register a new user."""
logger.info("Attempting to register user with email: %s", request.email)
# Check if email already exists
if await self.user_repo.email_exists(request.email):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email address is already registered",
)
# Hash the password
hashed_password = PasswordUtils.hash_password(request.password)
# Create user data
user_data = {
"email": request.email,
"name": request.name,
"password_hash": hashed_password,
"role": "user",
"is_active": True,
}
# Create the user
user = await self.user_repo.create(user_data)
# Generate access token
token = self._create_access_token(user)
# Create response
user_response = await self.create_user_response(user)
logger.info("Successfully registered user: %s", user.email)
return AuthResponse(user=user_response, token=token)
async def login(self, request: UserLoginRequest) -> AuthResponse:
"""Authenticate a user login."""
logger.info("Attempting to login user with email: %s", request.email)
# Get user by email
user = await self.user_repo.get_by_email(request.email)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid email or password",
)
# Check if user is active
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Account is deactivated",
)
# Verify password
if not user.password_hash or not PasswordUtils.verify_password(
request.password,
user.password_hash,
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid email or password",
)
# Generate access token
token = self._create_access_token(user)
# Create response
user_response = await self.create_user_response(user)
logger.info("Successfully authenticated user: %s", user.email)
return AuthResponse(user=user_response, token=token)
async def get_current_user(self, user_id: int) -> User:
"""Get the current authenticated user."""
user = await self.user_repo.get_by_id(user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Account is deactivated",
)
return user
def _create_access_token(self, user: User) -> TokenResponse:
"""Create an access token for a user."""
access_token_expires = timedelta(
minutes=settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES,
)
token_data = {
"sub": str(user.id),
"email": user.email,
"role": user.role,
}
access_token = JWTUtils.create_access_token(
data=token_data,
expires_delta=access_token_expires,
)
return TokenResponse(
access_token=access_token,
token_type="bearer", # noqa: S106 # This is OAuth2 standard, not a password
expires_in=settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES * 60,
)
async def create_and_store_refresh_token(self, user: User) -> str:
"""Create and store a refresh token for a user."""
refresh_token_expires = timedelta(days=settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS)
token_data = {
"sub": str(user.id),
"email": user.email,
}
refresh_token = JWTUtils.create_refresh_token(
data=token_data,
expires_delta=refresh_token_expires,
)
# Hash the refresh token for storage
refresh_token_hash = hashlib.sha256(refresh_token.encode()).hexdigest()
# Store hash and expiration in database
user.refresh_token_hash = refresh_token_hash
user.refresh_token_expires_at = datetime.now(UTC) + refresh_token_expires
self.session.add(user)
await self.session.commit()
return refresh_token
async def refresh_access_token(self, refresh_token: str) -> TokenResponse:
"""Create a new access token using a refresh token."""
try:
# Decode the refresh token
payload = JWTUtils.decode_refresh_token(refresh_token)
user_id_str = payload.get("sub")
if not user_id_str:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid refresh token",
)
user_id = int(user_id_str)
# Get the user
user = await self.user_repo.get_by_id(user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid refresh token",
)
# Check if refresh token hash matches stored hash
refresh_token_hash = hashlib.sha256(refresh_token.encode()).hexdigest()
if (
not user.refresh_token_hash
or user.refresh_token_hash != refresh_token_hash
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid refresh token",
)
# Check if refresh token is expired
if user.refresh_token_expires_at and datetime.now(
UTC
) > user.refresh_token_expires_at.replace(tzinfo=UTC):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Refresh token has expired",
)
# Check if user is active
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Account is deactivated",
)
# Create new access token
return self._create_access_token(user)
except HTTPException:
raise
except Exception as e:
logger.exception("Failed to refresh access token")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid refresh token",
) from e
async def revoke_refresh_token(self, user: User) -> None:
"""Revoke a user's refresh token."""
try:
# Use the repository to update the user to ensure proper session handling
update_data = {
"refresh_token_hash": None,
"refresh_token_expires_at": None,
}
await self.user_repo.update(user, update_data)
logger.info("Refresh token revoked for user: %s", user.email)
except Exception:
logger.exception("Failed to revoke refresh token for user: %s", user.email)
raise
async def create_user_response(self, user: User) -> UserResponse:
"""Create a user response from a user model."""
# Always refresh to ensure the plan relationship is loaded
await self.session.refresh(user, ["plan"])
# Ensure user has an ID (should always be true for persisted users)
if user.id is None:
msg = "User must have an ID to create response"
raise ValueError(msg)
return UserResponse(
id=user.id,
email=user.email,
name=user.name,
picture=user.picture,
role=user.role,
credits=user.credits,
is_active=user.is_active,
plan={
"id": user.plan.id,
"code": user.plan.code,
"name": user.plan.name,
"description": user.plan.description,
"credits": user.plan.credits,
"max_credits": user.plan.max_credits,
},
created_at=user.created_at,
updated_at=user.updated_at,
)