Add subcription feature 6

This commit is contained in:
Urtzi Alfaro
2026-01-16 15:19:34 +01:00
parent 6b43116efd
commit 4bafceed0d
35 changed files with 3826 additions and 1789 deletions

View File

@@ -454,28 +454,33 @@ class AuthService:
try:
logger.info(f"Validating user subscription, user_id={user.id}, email={user.email}")
# Check if user has a tenant_id (indicates they should have a subscription)
if user.tenant_id:
logger.info(f"User has tenant - subscription validation required, user_id={user.id}, tenant_id={user.tenant_id}")
# Since tenant relationships are managed by tenant service, we need to call tenant service
# to get the user's primary tenant and validate subscription status
# Get user's primary tenant from tenant service
primary_tenant = await self.tenant_client.get_user_primary_tenant(str(user.id))
if primary_tenant:
tenant_id = primary_tenant.get('tenant_id')
logger.info(f"User has primary tenant - subscription validation required, user_id={user.id}, tenant_id={tenant_id}")
# Call tenant service to validate subscription status
subscription_status = await self.tenant_client.get_subscription_status(user.tenant_id)
subscription_status = await self.tenant_client.get_subscription_status(tenant_id)
if subscription_status:
is_active = subscription_status.get('is_active', False)
status = subscription_status.get('status', 'unknown')
logger.info(f"Subscription status retrieved from tenant service, user_id={user.id}, tenant_id={user.tenant_id}, status={status}, is_active={is_active}")
logger.info(f"Subscription status retrieved from tenant service, user_id={user.id}, tenant_id={tenant_id}, status={status}")
# Consider subscription valid if it's active, trialing, or in grace period
valid_statuses = ['active', 'trialing', 'grace_period']
return is_active and status in valid_statuses
return status in valid_statuses
else:
logger.warning(f"No subscription status returned from tenant service, user_id={user.id}, tenant_id={user.tenant_id}")
logger.warning(f"No subscription status returned from tenant service, user_id={user.id}, tenant_id={tenant_id}")
return False
else:
# Users without tenant might be in registration flow
logger.info(f"User without tenant - no subscription validation, user_id={user.id}")
# Users without primary tenant might be in registration flow or using free tier
logger.info(f"User without primary tenant - no subscription validation required, user_id={user.id}")
return True
except Exception as e:
@@ -496,24 +501,32 @@ class AuthService:
try:
logger.info(f"Getting subscription details, user_id={user.id}")
if user.tenant_id:
# Get user's primary tenant from tenant service
primary_tenant = await self.tenant_client.get_user_primary_tenant(str(user.id))
if primary_tenant:
tenant_id = primary_tenant.get('tenant_id')
# Call tenant service to get subscription details
subscription = await self.tenant_client.get_subscription_details(user.tenant_id)
subscription = await self.tenant_client.get_subscription_details(tenant_id)
if subscription:
logger.info(f"Subscription details retrieved from tenant service, user_id={user.id}, tenant_id={user.tenant_id}, plan={subscription.get('plan')}, status={subscription.get('status')}")
logger.info(f"Subscription details retrieved from tenant service, user_id={user.id}, tenant_id={tenant_id}, plan={subscription.get('plan')}, status={subscription.get('status')}")
# Add tenant_id to subscription details for JWT
subscription['tenant_id'] = tenant_id
return subscription
else:
logger.warning(f"No subscription details returned from tenant service, user_id={user.id}, tenant_id={user.tenant_id}")
logger.warning(f"No subscription details returned from tenant service, user_id={user.id}, tenant_id={tenant_id}")
return {
"status": "no_subscription",
"plan": None,
"billing_cycle": None,
"current_period_end": None,
"trial_period_days": 0
"trial_period_days": 0,
"tenant_id": tenant_id
}
else:
logger.info(f"User without tenant - no subscription details available, user_id={user.id}")
logger.info(f"User without primary tenant - no subscription details available, user_id={user.id}")
return {
"status": "no_tenant",
"plan": None,
@@ -531,7 +544,35 @@ class AuthService:
"current_period_end": None,
"trial_period_days": 0
}
async def get_user_by_id(self, user_id: str) -> Optional[User]:
"""
Get user by ID
Args:
user_id: User ID to retrieve
Returns:
User object if found, None otherwise
"""
try:
logger.info(f"Getting user by ID, user_id={user_id}")
async with self.database_manager.get_session() as session:
user_repo = UserRepository(User, session)
user = await user_repo.get_by_id(user_id)
if user:
logger.info(f"User retrieved successfully, user_id={user_id}, email={user.email}")
else:
logger.warning(f"User not found, user_id={user_id}")
return user
except Exception as e:
logger.error(f"Get user by ID failed, error={str(e)}, user_id={user_id}", exc_info=True)
raise
async def _generate_auth_tokens(self, user: User) -> Dict[str, Any]:
"""
Generate authentication tokens for user
@@ -578,6 +619,9 @@ class AuthService:
# Generate refresh token using SecurityManager
refresh_token = SecurityManager.create_refresh_token(refresh_token_data)
# ✅ CRITICAL FIX: Store refresh token in Redis for later validation
await SecurityManager.store_refresh_token(str(user.id), refresh_token)
logger.info(f"Auth tokens generated successfully, user_id={user.id}, access_token_length={len(access_token)}, refresh_token_length={len(refresh_token)}")
return {
@@ -595,6 +639,323 @@ class AuthService:
detail=f"Token generation failed: {str(e)}"
) from e
async def refresh_auth_tokens(self, refresh_token: str) -> Dict[str, Any]:
"""
Refresh authentication tokens using a valid refresh token
Args:
refresh_token: Valid refresh token
Returns:
Dictionary with new access and refresh tokens
Raises:
HTTPException: 401 for invalid refresh tokens
"""
try:
logger.info("Refreshing auth tokens using refresh token")
# Import JWT dependencies
from jose import jwt, JWTError
from app.core.config import settings
from app.core.security import SecurityManager
# Decode refresh token to get user info
payload = jwt.decode(
refresh_token,
settings.JWT_SECRET_KEY,
algorithms=[settings.JWT_ALGORITHM]
)
# Verify this is a refresh token
if payload.get("type") != "refresh":
logger.warning("Invalid token type for refresh")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token type"
)
user_id = payload.get("user_id")
email = payload.get("email")
if not user_id or not email:
logger.warning("Invalid refresh token payload")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid refresh token"
)
# Check if refresh token is valid in Redis using SecurityManager
is_valid = await SecurityManager.is_refresh_token_valid(user_id, refresh_token)
if not is_valid:
logger.warning(f"Invalid or expired refresh token for user {user_id}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired refresh token"
)
logger.info(f"Refresh token validated for user {user_id}, email={email}")
# Get user from database
async with self.database_manager.get_session() as session:
user_repo = UserRepository(User, session)
user = await user_repo.get_by_id(user_id)
if not user:
logger.warning(f"User not found for refresh token, user_id={user_id}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found"
)
if not user.is_active:
logger.warning(f"Inactive user attempted token refresh, user_id={user_id}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Account is inactive"
)
# Generate new tokens using existing method
tokens = await self._generate_auth_tokens(user)
logger.info(f"Token refresh successful, user_id={user_id}, email={email}")
return tokens
except JWTError as e:
logger.warning(f"JWT decode error during refresh: {str(e)}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid refresh token"
)
except HTTPException:
# Re-raise HTTP exceptions
raise
except Exception as e:
logger.error(f"Token refresh failed: {str(e)}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Token refresh failed: {str(e)}"
) from e
async def verify_access_token(self, access_token: str) -> Dict[str, Any]:
"""
Verify the validity of an access token
Args:
access_token: Access token to verify
Returns:
Dictionary with token validation result and user info
Raises:
HTTPException: 401 for invalid access tokens
"""
try:
logger.info("Verifying access token")
# Import JWT dependencies
from jose import jwt, JWTError
from app.core.config import settings
# Decode and verify token
payload = jwt.decode(
access_token,
settings.JWT_SECRET_KEY,
algorithms=[settings.JWT_ALGORITHM]
)
# Verify this is an access token
if payload.get("type") != "access":
logger.warning("Invalid token type for verification")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token type"
)
user_id = payload.get("user_id")
email = payload.get("email")
if not user_id or not email:
logger.warning("Invalid access token payload")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid access token"
)
logger.info(f"Token verification successful, user_id={user_id}, email={email}")
return {
"valid": True,
"user_id": user_id,
"email": email
}
except JWTError as e:
logger.warning(f"JWT decode error during verification: {str(e)}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid access token"
)
except HTTPException:
# Re-raise HTTP exceptions
raise
except Exception as e:
logger.error(f"Token verification failed: {str(e)}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Token verification failed: {str(e)}"
) from e
async def revoke_refresh_token(self, refresh_token: str) -> bool:
"""
Revoke a refresh token
Args:
refresh_token: Refresh token to revoke
Returns:
True if revocation was successful or token didn't exist
Raises:
Exception: For unexpected errors
"""
try:
logger.info("Revoking refresh token")
# Try to decode token to get user_id
from jose import jwt, JWTError
from app.core.config import settings
user_id = "unknown"
try:
payload = jwt.decode(
refresh_token,
settings.JWT_SECRET_KEY,
algorithms=[settings.JWT_ALGORITHM]
)
user_id = payload.get("user_id") or "unknown"
except JWTError as e:
logger.warning(f"Could not decode refresh token during revocation: {str(e)}")
# Revoke the token using SecurityManager
await SecurityManager.revoke_refresh_token(user_id, refresh_token)
logger.info(f"Refresh token revoked successfully, user_id={user_id}")
return True
except Exception as e:
logger.error(f"Error revoking refresh token: {str(e)}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Token revocation failed: {str(e)}"
) from e
async def change_user_password(self, user_id: str, current_password: str, new_password: str) -> bool:
"""
Change user password
Args:
user_id: User ID
current_password: Current password for verification
new_password: New password to set
Returns:
True if password was changed successfully
Raises:
HTTPException: 401 for invalid current password, 404 for user not found
"""
try:
logger.info(f"Changing password for user {user_id}")
async with self.database_manager.get_session() as session:
user_repo = UserRepository(User, session)
# Get current user
user = await user_repo.get_by_id(user_id)
if not user:
logger.warning(f"User not found for password change, user_id={user_id}")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
# Verify current password
from app.core.security import verify_password
is_valid = verify_password(current_password, user.hashed_password)
if not is_valid:
logger.warning(f"Invalid current password for user {user_id}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Current password is incorrect"
)
# Update password
await user_repo.update_password(user_id, new_password)
logger.info(f"Password change successful, user_id={user_id}, email={user.email}")
return True
except HTTPException:
# Re-raise HTTP exceptions
raise
except Exception as e:
logger.error(f"Password change failed: {str(e)}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Password change failed: {str(e)}"
) from e
async def verify_user_email(self, email: str, verification_token: str) -> bool:
"""
Verify user email address
Args:
email: User email
verification_token: Email verification token
Returns:
True if email was verified successfully
Raises:
HTTPException: 404 for user not found
"""
try:
logger.info(f"Verifying email for {email}")
async with self.database_manager.get_session() as session:
user_repo = UserRepository(User, session)
# Find user by email
user = await user_repo.get_by_email(email)
if not user:
logger.warning(f"User not found for email verification, email={email}")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
# TODO: Implement actual email verification token logic
# For now, just mark as verified
await user_repo.update_user(user.id, {"is_verified": True})
logger.info(f"Email verification successful, user_id={user.id}, email={email}")
return True
except HTTPException:
# Re-raise HTTP exceptions
raise
except Exception as e:
logger.error(f"Email verification failed: {str(e)}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Email verification failed: {str(e)}"
) from e
async def request_password_reset(self, email: str) -> bool:
"""
Request a password reset for a user

View File

@@ -10,7 +10,8 @@ from fastapi import HTTPException, status
import structlog
from app.repositories import UserRepository, TokenRepository
from app.schemas.auth import UserResponse, UserUpdate
from app.schemas.auth import UserResponse
from app.schemas.users import UserUpdate
from app.models.users import User
from app.models.tokens import RefreshToken
from app.core.security import SecurityManager