gateway/modules/interfaces/msftInterface.py
2025-05-22 00:48:56 +02:00

520 lines
No EOL
18 KiB
Python

"""
Microsoft interface for handling Microsoft authentication and Graph API operations.
"""
import logging
import json
import requests
import base64
import msal
from typing import Dict, Any, Optional, List, Tuple
from datetime import datetime, timedelta
import secrets
import os
from modules.shared.configuration import APP_CONFIG
from .msftModel import MsftToken, MsftUserInfo, MsftConfig
from modules.connectors.connectorDbJson import DatabaseConnector
from .msftAccess import MsftAccess
from modules.interfaces.gatewayInterface import getRootUser
logger = logging.getLogger(__name__)
# Singleton factory for MsftInterface instances per context
_msftInterfaces = {}
# Root interface instance
_rootMsftInterface = None
class MsftInterface:
"""Interface for Microsoft authentication and Graph API operations"""
def __init__(self, currentUser: Dict[str, Any] = None):
"""Initialize the Microsoft interface"""
# Initialize variables
self.currentUser = currentUser
self.mandateId = currentUser.get("mandateId") if currentUser else None
self.userId = currentUser.get("id") if currentUser else None
self.access = None # Will be set when user context is provided
# Initialize configuration
self.clientId = APP_CONFIG.get("Service_MSFT_CLIENT_ID")
self.clientSecret = APP_CONFIG.get("Service_MSFT_CLIENT_SECRET")
self.tenantId = APP_CONFIG.get("Service_MSFT_TENANT_ID", "common")
self.redirectUri = APP_CONFIG.get("Service_MSFT_REDIRECT_URI")
self.authority = f"https://login.microsoftonline.com/{self.tenantId}"
self.scopes = ["Mail.ReadWrite", "User.Read"]
# Initialize database
self._initializeDatabase()
# Initialize MSAL application
self.msal_app = msal.ConfidentialClientApplication(
self.clientId,
authority=self.authority,
client_credential=self.clientSecret
)
# Set user context if provided
if currentUser:
self.setUserContext(currentUser)
def _initializeDatabase(self):
"""Initializes the database connection."""
try:
# Get configuration values with defaults
dbHost = APP_CONFIG.get("DB_MSFT_HOST", "data")
dbDatabase = APP_CONFIG.get("DB_MSFT_DATABASE", "msft")
dbUser = APP_CONFIG.get("DB_MSFT_USER")
dbPassword = APP_CONFIG.get("DB_MSFT_PASSWORD_SECRET")
# Ensure the database directory exists
os.makedirs(dbHost, exist_ok=True)
self.db = DatabaseConnector(
dbHost=dbHost,
dbDatabase=dbDatabase,
dbUser=dbUser,
dbPassword=dbPassword,
mandateId=self.mandateId,
userId=self.userId
)
# Set context
self.db.updateContext(self.mandateId, self.userId)
logger.info("Database initialized successfully")
except Exception as e:
logger.error(f"Failed to initialize database: {str(e)}")
raise
def _uam(self, table: str, recordset: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""
Unified user access management function that filters data based on user privileges
and adds access control attributes.
Args:
table: Name of the table
recordset: Recordset to filter based on access rules
Returns:
Filtered recordset with access control attributes
"""
return self.access.uam(table, recordset)
def _canModify(self, table: str, recordId: Optional[str] = None) -> bool:
"""
Checks if the current user can modify (create/update/delete) records in a table.
Args:
table: Name of the table
recordId: Optional record ID for specific record check
Returns:
Boolean indicating permission
"""
return self.access.canModify(table, recordId)
def initiateLogin(self) -> str:
"""Initiate Microsoft login flow"""
try:
# Generate auth URL
auth_url = self.msal_app.get_authorization_request_url(
scopes=self.scopes,
redirect_uri=self.redirectUri,
state=self._generateState()
)
return auth_url
except Exception as e:
logger.error(f"Error initiating Microsoft login: {str(e)}")
return None
def handleAuthCallback(self, code: str) -> Optional[MsftToken]:
"""Handle Microsoft OAuth callback"""
try:
# Get token from code
token_response = self.msal_app.acquire_token_by_authorization_code(
code,
scopes=self.scopes,
redirect_uri=self.redirectUri
)
if "error" in token_response:
logger.error(f"Token acquisition failed: {token_response['error']}")
return None
# Get user info
user_info = self.getUserInfoFromToken(token_response["access_token"])
if not user_info:
return None
# Create token model
token = MsftToken(
access_token=token_response["access_token"],
refresh_token=token_response.get("refresh_token", ""),
expires_in=token_response.get("expires_in", 0),
token_type=token_response.get("token_type", "bearer"),
expires_at=datetime.now().timestamp() + token_response.get("expires_in", 0),
user_info=user_info.model_dump(),
mandateId=self.mandateId,
userId=self.userId
)
return token
except Exception as e:
logger.error(f"Error handling auth callback: {str(e)}")
return None
def verifyToken(self, token: str) -> bool:
"""Verify Microsoft token"""
try:
# Get user info from token
user_info = self.getUserInfoFromToken(token)
if not user_info:
return False
# Get current user's Microsoft connection
user = self.db.getRecordset("users", recordFilter={"id": self.userId})[0]
msft_connection = next((conn for conn in user.get("connections", [])
if conn.get("authority") == "microsoft"), None)
if not msft_connection:
return False
# Verify the token belongs to this user
return user_info.id == msft_connection.get("externalId")
except Exception as e:
logger.error(f"Error verifying Microsoft token: {str(e)}")
return False
def getUserInfoFromToken(self, token: str) -> Optional[MsftUserInfo]:
"""Get user info from Microsoft Graph"""
try:
# Call Microsoft Graph API
response = requests.get(
"https://graph.microsoft.com/v1.0/me",
headers={"Authorization": f"Bearer {token}"}
)
if response.status_code != 200:
logger.error(f"Failed to get user info: {response.text}")
return None
data = response.json()
# Create user info model
return MsftUserInfo(
id=data["id"],
email=data.get("mail") or data.get("userPrincipalName"),
name=data.get("displayName", ""),
picture=None # Microsoft Graph doesn't provide profile picture by default
)
except Exception as e:
logger.error(f"Error getting user info: {str(e)}")
return None
def refreshToken(self, refresh_token: str) -> Optional[MsftToken]:
"""Refresh Microsoft token"""
try:
# Refresh token
token_response = self.msal_app.acquire_token_by_refresh_token(
refresh_token,
scopes=self.scopes
)
if "error" in token_response:
logger.error(f"Token refresh failed: {token_response['error']}")
return None
# Get user info
user_info = self.getUserInfoFromToken(token_response["access_token"])
if not user_info:
return None
# Create token model
token = MsftToken(
access_token=token_response["access_token"],
refresh_token=token_response.get("refresh_token", refresh_token),
expires_in=token_response.get("expires_in", 0),
token_type=token_response.get("token_type", "bearer"),
expires_at=datetime.now().timestamp() + token_response.get("expires_in", 0),
user_info=user_info.model_dump(),
mandateId=self.mandateId,
userId=self.userId
)
return token
except Exception as e:
logger.error(f"Error refreshing token: {str(e)}")
return None
def _generateState(self) -> str:
"""Generate secure state token"""
return secrets.token_urlsafe(32)
def createDraftEmail(self, recipient: str, subject: str, body: str, attachments: List[Dict[str, Any]] = None) -> bool:
"""Create a draft email using Microsoft Graph API"""
try:
user_info, access_token = self.getCurrentUserToken()
if not user_info or not access_token:
return False
headers = {
'Authorization': f'Bearer {access_token}',
'Content-Type': 'application/json'
}
email_data = {
'subject': subject,
'body': {
'contentType': 'HTML',
'content': body
},
'toRecipients': [
{
'emailAddress': {
'address': recipient
}
}
]
}
if attachments:
email_data['attachments'] = []
for attachment in attachments:
doc = attachment.get('document', {})
file_name = attachment.get('name', 'attachment.file')
file_content = doc.get('data')
if not file_content:
continue
mime_type = doc.get('mimeType', 'application/octet-stream')
is_base64 = doc.get('base64Encoded', False)
try:
if is_base64:
content_bytes = file_content
else:
if isinstance(file_content, str):
content_bytes = base64.b64encode(file_content.encode('utf-8')).decode('utf-8')
elif isinstance(file_content, bytes):
content_bytes = base64.b64encode(file_content).decode('utf-8')
else:
continue
decoded_size = len(base64.b64decode(content_bytes))
attachment_data = {
'@odata.type': '#microsoft.graph.fileAttachment',
'name': file_name,
'contentType': mime_type,
'contentBytes': content_bytes,
'isInline': False,
'size': decoded_size
}
email_data['attachments'].append(attachment_data)
except Exception as e:
logger.error(f"Error processing attachment {file_name}: {str(e)}")
continue
response = requests.post(
'https://graph.microsoft.com/v1.0/me/messages',
headers=headers,
json=email_data
)
return response.status_code >= 200 and response.status_code < 300
except Exception as e:
logger.error(f"Error creating draft email: {str(e)}")
return False
def saveMsftToken(self, token_data: Dict[str, Any]) -> bool:
"""
Save Microsoft token data to the database.
Args:
token_data: Token data to save
Returns:
bool: True if successful, False otherwise
"""
try:
# Get existing token if any
existing_tokens = self.db.getRecordset(
"msftTokens",
recordFilter={
"mandateId": self.mandateId,
"userId": self.userId
}
)
if existing_tokens:
# Update existing token
token_id = existing_tokens[0]["id"]
success = self.db.updateRecord(
"msftTokens",
token_id,
token_data
)
else:
# Create new token record
success = self.db.createRecord(
"msftTokens",
token_data
)
return success
except Exception as e:
logger.error(f"Error saving Microsoft token: {str(e)}")
return False
def getMsftToken(self) -> Optional[Dict[str, Any]]:
"""
Get Microsoft token data for current user.
Returns:
Optional[Dict[str, Any]]: Token data if found, None otherwise
"""
try:
tokens = self.db.getRecordset(
"msftTokens",
recordFilter={
"mandateId": self.mandateId,
"userId": self.userId
}
)
if not tokens:
return None
return tokens[0]
except Exception as e:
logger.error(f"Error getting Microsoft token: {str(e)}")
return None
def getCurrentUserToken(self) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
"""
Get current user's Microsoft token and user info.
Returns:
Tuple[Optional[Dict[str, Any]], Optional[str]]: User info and access token
"""
try:
token_data = self.getMsftToken()
if not token_data:
return None, None
# Check if token needs refresh
if datetime.now().timestamp() >= token_data["expires_at"]:
if not token_data.get("refresh_token"):
return None, None
# Refresh token
new_token = self.refreshToken(token_data["refresh_token"])
if not new_token:
return None, None
# Save new token
self.saveMsftToken(new_token.model_dump())
token_data = new_token.model_dump()
return token_data["user_info"], token_data["access_token"]
except Exception as e:
logger.error(f"Error getting current user token: {str(e)}")
return None, None
def deleteMsftToken(self) -> bool:
"""
Delete Microsoft token for current user.
Returns:
bool: True if successful, False otherwise
"""
try:
# Get existing token
existing_tokens = self.db.getRecordset(
"msftTokens",
recordFilter={
"mandateId": self.mandateId,
"userId": self.userId
}
)
if not existing_tokens:
return True # No token to delete
# Delete token
success = self.db.deleteRecord(
"msftTokens",
existing_tokens[0]["id"]
)
return success
except Exception as e:
logger.error(f"Error deleting Microsoft token: {str(e)}")
return False
def setUserContext(self, currentUser: Dict[str, Any]):
"""Set user context for the interface"""
if not currentUser:
logger.info("Initializing interface without user context")
return
self.currentUser = currentUser
self.mandateId = currentUser.get("mandateId")
self.userId = currentUser.get("id")
if not self.mandateId or not self.userId:
raise ValueError("Invalid user context: mandateId and id are required")
# Initialize access control with user context
self.access = MsftAccess(self.currentUser, self.db)
# Update database context
self.db.updateContext(self.mandateId, self.userId)
logger.debug(f"User context set: userId={self.userId}")
def getRootInterface() -> MsftInterface:
"""
Returns a MsftInterface instance with root privileges.
This is used for initial setup and user creation.
"""
global _rootMsftInterface
if _rootMsftInterface is None:
# Get root user from gateway
rootUser = getRootUser()
_rootMsftInterface = MsftInterface(rootUser)
return _rootMsftInterface
def getInterface(currentUser: Dict[str, Any] = None) -> MsftInterface:
"""
Returns a MsftInterface instance.
If currentUser is provided, initializes with user context.
Otherwise, returns an instance with only database access.
"""
# Create new instance if not exists
if "default" not in _msftInterfaces:
_msftInterfaces["default"] = MsftInterface(currentUser or {})
interface = _msftInterfaces["default"]
if currentUser:
interface.setUserContext(currentUser)
else:
logger.info("Returning interface without user context")
return interface