gateway/modules/routes/routeSecurityGoogle.py

544 lines
No EOL
22 KiB
Python

"""
Routes for Google authentication.
"""
from fastapi import APIRouter, HTTPException, Request, Response, status, Depends, Body, Query
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
import logging
import json
from typing import Dict, Any, Optional
from datetime import datetime, timedelta
from requests_oauthlib import OAuth2Session
import httpx
from modules.shared.configuration import APP_CONFIG
from modules.interfaces.interfaceAppObjects import getInterface, getRootInterface
from modules.interfaces.interfaceAppModel import AuthAuthority, User, Token, ConnectionStatus, UserConnection
from modules.security.auth import getCurrentUser, limiter
from modules.shared.attributeUtils import ModelMixin
from modules.shared.timezoneUtils import get_utc_now, create_expiration_timestamp, get_utc_timestamp
# Configure logger
logger = logging.getLogger(__name__)
# Create router
router = APIRouter(
prefix="/api/google",
tags=["Security Google"],
responses={
404: {"description": "Not found"},
400: {"description": "Bad request"},
401: {"description": "Unauthorized"},
403: {"description": "Forbidden"},
500: {"description": "Internal server error"}
}
)
# Google OAuth configuration
CLIENT_ID = APP_CONFIG.get("Service_GOOGLE_CLIENT_ID")
CLIENT_SECRET = APP_CONFIG.get("Service_GOOGLE_CLIENT_SECRET")
REDIRECT_URI = APP_CONFIG.get("Service_GOOGLE_REDIRECT_URI")
SCOPES = [
"https://www.googleapis.com/auth/gmail.readonly",
"https://www.googleapis.com/auth/userinfo.profile",
"https://www.googleapis.com/auth/userinfo.email",
"openid"
]
@router.get("/config")
async def get_config():
"""Debug endpoint to check Google OAuth configuration"""
return {
"client_id": CLIENT_ID,
"client_secret": "***" if CLIENT_SECRET else None,
"redirect_uri": REDIRECT_URI,
"scopes": SCOPES,
"config_loaded": bool(CLIENT_ID and CLIENT_SECRET and REDIRECT_URI),
"config_source": {
"client_id_from": "config.ini" if CLIENT_ID and "354925410565" in CLIENT_ID else "env file",
"redirect_uri_from": "config.ini" if REDIRECT_URI and "gateway-int.poweron-center.net" in REDIRECT_URI else "env file"
}
}
@router.get("/login")
@limiter.limit("5/minute")
async def login(
request: Request,
state: str = Query("login", description="State parameter to distinguish between login and connection flows"),
connectionId: Optional[str] = Query(None, description="Connection ID for connection flow")
) -> RedirectResponse:
"""Initiate Google login"""
try:
# Debug: Log configuration values
logger.info(f"Google OAuth Configuration - CLIENT_ID: {CLIENT_ID}, REDIRECT_URI: {REDIRECT_URI}")
# Validate required configuration
if not CLIENT_ID:
logger.error("Google OAuth CLIENT_ID is not configured")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Google OAuth CLIENT_ID is not configured"
)
if not CLIENT_SECRET:
logger.error("Google OAuth CLIENT_SECRET is not configured")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Google OAuth CLIENT_SECRET is not configured"
)
if not REDIRECT_URI:
logger.error("Google OAuth REDIRECT_URI is not configured")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Google OAuth REDIRECT_URI is not configured"
)
# Generate auth URL with state - use state as is if it's already JSON, otherwise create new state
try:
# Try to parse state as JSON to check if it's already encoded
json.loads(state)
state_param = state # Use state as is if it's valid JSON
except json.JSONDecodeError:
# If not JSON, create new state object
state_param = json.dumps({
"type": state,
"connectionId": connectionId
})
logger.info(f"Using state parameter: {state_param}")
# Use OAuth2Session directly - it works reliably
oauth = OAuth2Session(
client_id=CLIENT_ID,
redirect_uri=REDIRECT_URI,
scope=SCOPES
)
auth_url, state = oauth.authorization_url(
"https://accounts.google.com/o/oauth2/auth",
access_type="offline",
include_granted_scopes="true",
state=state_param,
prompt="select_account"
)
logger.info(f"Generated Google OAuth URL using OAuth2Session: {auth_url}")
return RedirectResponse(auth_url)
except Exception as e:
logger.error(f"Error initiating Google login: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to initiate Google login: {str(e)}"
)
@router.get("/auth/callback")
async def auth_callback(code: str, state: str, request: Request) -> HTMLResponse:
"""Handle Google OAuth callback"""
try:
# Parse state
state_data = json.loads(state)
state_type = state_data.get("type", "login")
connection_id = state_data.get("connectionId")
user_id = state_data.get("userId") # Get user ID from state
logger.info(f"Processing Google auth callback: state_type={state_type}, connection_id={connection_id}, user_id={user_id}")
# Use OAuth2Session directly for token exchange
oauth = OAuth2Session(
client_id=CLIENT_ID,
redirect_uri=REDIRECT_URI
)
# Get token using OAuth2Session
token_data = oauth.fetch_token(
"https://oauth2.googleapis.com/token",
client_secret=CLIENT_SECRET,
code=code,
include_client_id=True
)
token_response = {
"access_token": token_data.get("access_token"),
"refresh_token": token_data.get("refresh_token", ""),
"token_type": token_data.get("token_type", "bearer"),
"expires_in": token_data.get("expires_in", 0)
}
if not token_response.get("access_token"):
logger.error("Token acquisition failed: No access token received")
return HTMLResponse(
content="<html><body><h1>Authentication Failed</h1><p>Could not acquire token.</p></body></html>",
status_code=400
)
# Get user info using the access token
headers = {
'Authorization': f"Bearer {token_response['access_token']}",
'Content-Type': 'application/json'
}
async with httpx.AsyncClient() as client:
user_info_response = await client.get(
"https://www.googleapis.com/oauth2/v2/userinfo",
headers=headers
)
if user_info_response.status_code != 200:
logger.error(f"Failed to get user info: {user_info_response.text}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to get user info from Google"
)
user_info = user_info_response.json()
logger.info(f"Got user info from Google: {user_info.get('email')}")
if state_type == "login":
# Handle login flow
rootInterface = getRootInterface()
user = rootInterface.getUserByUsername(user_info.get("email"))
if not user:
# Create new user if doesn't exist
user = rootInterface.createUser(
username=user_info.get("email"),
email=user_info.get("email"),
fullName=user_info.get("name"),
authenticationAuthority=AuthAuthority.GOOGLE,
externalId=user_info.get("id"),
externalUsername=user_info.get("email"),
externalEmail=user_info.get("email")
)
# Create token
token = Token(
userId=user.id, # Use local user's ID
authority=AuthAuthority.GOOGLE,
tokenAccess=token_response["access_token"],
tokenRefresh=token_response.get("refresh_token", ""),
tokenType=token_response.get("token_type", "bearer"),
expiresAt=create_expiration_timestamp(token_response.get("expires_in", 0)),
createdAt=get_utc_timestamp()
)
# Save token
appInterface = getInterface(user)
appInterface.saveToken(token)
# Return success page with token data
return HTMLResponse(
content=f"""
<html>
<head><title>Authentication Successful</title></head>
<body>
<script>
if (window.opener) {{
window.opener.postMessage({{
type: 'google_auth_success',
access_token: {json.dumps(token_response["access_token"])},
token_data: {json.dumps(token.to_dict())}
}}, '*');
}}
setTimeout(() => window.close(), 1000);
</script>
</body>
</html>
"""
)
else:
# Handle connection flow
if not connection_id or not user_id:
logger.error("Connection ID or User ID is missing in connection flow")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Connection ID and User ID are required for connection flow"
)
# Get user directly by ID
rootInterface = getRootInterface()
user = rootInterface.getUser(user_id)
if not user:
logger.error(f"User {user_id} not found in database")
return HTMLResponse(
content=f"""
<html>
<head><title>Connection Failed</title></head>
<body>
<script>
if (window.opener) {{
window.opener.postMessage({{
type: 'google_connection_error',
error: 'User not found in database'
}}, '*');
// Wait for message to be sent before closing
setTimeout(() => window.close(), 1000);
}} else {{
window.close();
}}
</script>
</body>
</html>
""",
status_code=404
)
# Get the connection from the connections table
interface = getInterface(user)
connections = interface.getUserConnections(user_id)
connection = None
for conn in connections:
if conn.id == connection_id:
connection = conn
logger.info(f"Found existing connection for user {user.username}")
break
try:
if not connection:
logger.error(f"Connection {connection_id} not found in user's connections")
return HTMLResponse(
content=f"""
<html>
<head><title>Connection Failed</title></head>
<body>
<script>
if (window.opener) {{
window.opener.postMessage({{
type: 'google_connection_error',
error: 'Connection not found in user\'s connections'
}}, '*');
// Wait for message to be sent before closing
setTimeout(() => window.close(), 1000);
}} else {{
window.close();
}}
</script>
</body>
</html>
""",
status_code=404
)
logger.info(f"Updating connection {connection_id} for user {user.username}")
# Update connection with external service details
connection.status = ConnectionStatus.ACTIVE
connection.lastChecked = get_utc_timestamp()
connection.expiresAt = get_utc_timestamp() + token_response.get("expires_in", 0)
connection.externalId = user_info.get("id")
connection.externalUsername = user_info.get("email")
connection.externalEmail = user_info.get("email")
# Update connection record directly
rootInterface.db.recordModify("connections", connection_id, connection.to_dict())
# Clear cache to ensure fresh data
rootInterface.db.clearTableCache("connections")
# Save token
token = Token(
userId=user.id, # Use local user's ID
authority=AuthAuthority.GOOGLE,
connectionId=connection_id, # Link token to this specific connection
tokenAccess=token_response["access_token"],
tokenRefresh=token_response.get("refresh_token", ""),
tokenType=token_response.get("token_type", "bearer"),
expiresAt=create_expiration_timestamp(token_response.get("expires_in", 0)),
createdAt=get_utc_timestamp()
)
interface.saveToken(token)
# Return success page with connection data
return HTMLResponse(
content=f"""
<html>
<head><title>Connection Successful</title></head>
<body>
<script>
if (window.opener) {{
window.opener.postMessage({{
type: 'google_connection_success',
connection: {{
id: '{connection.id}',
status: 'connected',
type: 'google',
lastChecked: {get_utc_timestamp()},
expiresAt: {create_expiration_timestamp(token_response.get("expires_in", 0))}
}}
}}, '*');
// Wait for message to be sent before closing
setTimeout(() => window.close(), 1000);
}} else {{
window.close();
}}
</script>
</body>
</html>
"""
)
except Exception as e:
logger.error(f"Error updating connection: {str(e)}", exc_info=True)
return HTMLResponse(
content=f"""
<html>
<head><title>Connection Failed</title></head>
<body>
<script>
if (window.opener) {{
window.opener.postMessage({{
type: 'google_connection_error',
error: 'Failed to update connection: {str(e)}'
}}, '*');
// Wait for message to be sent before closing
setTimeout(() => window.close(), 1000);
}} else {{
window.close();
}}
</script>
</body>
</html>
""",
status_code=500
)
except Exception as e:
logger.error(f"Error in auth callback: {str(e)}", exc_info=True)
return HTMLResponse(
content=f"""
<html>
<head><title>Authentication Failed</title></head>
<body>
<script>
if (window.opener) {{
window.opener.postMessage({{
type: 'google_connection_error',
error: 'Authentication failed: {str(e)}'
}}, '*');
// Wait for message to be sent before closing
setTimeout(() => window.close(), 1000);
}} else {{
window.close();
}}
</script>
</body>
</html>
""",
status_code=500
)
@router.get("/me", response_model=User)
@limiter.limit("30/minute")
async def get_current_user(
request: Request,
currentUser: User = Depends(getCurrentUser)
) -> User:
"""Get current user information"""
try:
return currentUser
except Exception as e:
logger.error(f"Error getting current user: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=str(e)
)
@router.post("/logout")
@limiter.limit("10/minute")
async def logout(
request: Request,
currentUser: User = Depends(getCurrentUser)
) -> Dict[str, Any]:
"""Logout current user"""
try:
appInterface = getInterface(currentUser)
appInterface.logout()
return {"message": "Logged out successfully"}
except Exception as e:
logger.error(f"Error during logout: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to logout: {str(e)}"
)
@router.post("/refresh")
@limiter.limit("10/minute")
async def refresh_token(
request: Request,
currentUser: User = Depends(getCurrentUser)
) -> Dict[str, Any]:
"""Refresh Google OAuth token for current user"""
try:
appInterface = getInterface(currentUser)
# Find Google connection for this user
logger.debug(f"Looking for Google connection for user {currentUser.id}")
connections = appInterface.getUserConnections(currentUser.id)
google_connection = None
for conn in connections:
if conn.authority == AuthAuthority.GOOGLE:
google_connection = conn
break
if not google_connection:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No Google connection found for current user"
)
logger.debug(f"Found Google connection: {google_connection.id}, status={google_connection.status}")
# Get the token for this specific connection using the new method
current_token = appInterface.getTokenForConnection(google_connection.id, auto_refresh=False)
if not current_token:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No Google token found for this connection"
)
# Always attempt refresh (as per your requirement)
from modules.security.tokenManager import TokenManager
token_manager = TokenManager()
refreshed_token = token_manager.refresh_token(current_token)
if refreshed_token:
# Save the new token and delete the old one
appInterface.saveToken(refreshed_token)
appInterface.deleteTokenByConnectionId(google_connection.id)
# Update the connection's expiration time
google_connection.expiresAt = float(refreshed_token.expiresAt)
google_connection.lastChecked = get_utc_timestamp()
google_connection.status = ConnectionStatus.ACTIVE
# Save updated connection
appInterface.db.recordModify("connections", google_connection.id, google_connection.to_dict())
# Calculate time until expiration
current_time = get_utc_timestamp()
expires_in = int(refreshed_token.expiresAt - current_time)
return {
"message": "Token refreshed successfully",
"expires_at": refreshed_token.expiresAt,
"expires_in_seconds": expires_in
}
else:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to refresh token"
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error refreshing Google token: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to refresh token: {str(e)}"
)