Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
APP_NAME = "ms-graph-user"
INVALID_USER_CHARS_REGEX = re.compile(r"[%&*+/=?`{|}]")
API_VERSION: str = "v1.0"
MFA_APP_ID = '981f26a1-7f43-403b-a875-f8b09b8cd720' # MFA app ID (not a secret, same app ID for all azure tenants)


def camel_case_to_readable(text):
Expand Down Expand Up @@ -325,6 +326,207 @@
"""
url_suffix = f"users/{quote(user_id)}/authentication/temporaryAccessPassMethods/{quote(policy_id)}"
self.ms_client.http_request(method="DELETE", url_suffix=url_suffix, resp_type="text")


def request_mfa_app_secret(self) -> str:
"""
The function utilizes the MFA application ID (981f26a1-7f43-403b-a875-f8b09b8cd720) to retrieve the service principal ID.
Which then uses the service principal ID to retrieve the client secret.

Check failure on line 334 in Packs/MicrosoftGraphUser/Integrations/MicrosoftGraphUser/MicrosoftGraphUser.py

View workflow job for this annotation

GitHub Actions / pre-commit / pre-commit

Ruff (E501)

Packs/MicrosoftGraphUser/Integrations/MicrosoftGraphUser/MicrosoftGraphUser.py:334:131: E501 Line too long (153 > 130)
The client secret has an expiration of 2 years and therefore the generation will occur only once and then retrieved from the integration context.
TODO: add a mechanism for renewal of the client secret if expired.

Args:
None.

Returns:
(str): the MFA application client secret.
"""
# attempt to retrieve the client secret from the integration context if exist.
ctx = get_integration_context()
if client_secret := ctx.get('mfa_app_client_secret'):
return client_secret

# if not exist, generate a new client secret.
# Search for the service principal with the MFA application ID.
sp_endpoint_url_suffix = f'servicePrincipals?$filter=appId eq \'{MFA_APP_ID}\'&$select=id'
demisto.debug(f"Searching for Service Principal with appId: {MFA_APP_ID}...")
sp_data = self.ms_client.http_request(method="GET", url_suffix=sp_endpoint_url_suffix)

if not sp_data.get('value'):
raise DemistoException(f"Error: Service Principal with appId {MFA_APP_ID} not found.")

# Extract the Service Principal Object ID
service_principal_id = sp_data['value'][0]['id']
demisto.debug(f"Service Principal ID (Object ID) found: {service_principal_id}")

# Send request for new client secret.
SECRET_DISPLAY_NAME = "MFA App Secret"

# Endpoint to add a password credential to the service principal
secret_genertion_endpoint_url_suffix = f'servicePrincipals/{service_principal_id}/addPassword'

# Request body for adding the secret
secret_body = {
"passwordCredential": {
"displayName": SECRET_DISPLAY_NAME # The display name for the secret, not mandatory
}
}

demisto.debug(f"Adding new client secret with display name '{SECRET_DISPLAY_NAME}'")
secret_result = self.ms_client.http_request(method="POST",
url_suffix=secret_genertion_endpoint_url_suffix,
data=json.dumps(secret_body))

# The 'secretText' is the actual secret value, which is only returned *once* on creation.
new_secret_value = secret_result.get('secretText')

demisto.debug(f"A new client secret with the name {secret_result.get('displayName')} was created successfully. the"
f"secret is valid until {secret_result.get('endDateTime')}")

# Update the integration context with the new client secret.
set_integration_context({
'mfa_app_client_secret': new_secret_value
})

return new_secret_value

def push_mfa_notification(self, client_secret: str, user_principal_name: str):
"""
Attempt to generate access token the MFA app using the given client secret.
Then send MFA push notification to the user with the given UPN.
If user Approve - return None, otherwise raise an error.
Args:
client_secret (str): The client secret of the MFA app.
user_principal_name (str): The user principal name of the user to send the MFA notification to.

Returns:
None.
"""
import requests
import xml.etree.ElementTree as ET
import uuid

# Hardcoded endpoints
RESOURCE = 'https://adnotifications.windowsazure.com/StrongAuthenticationService.svc/Connector'
AUTH_ENDPOINT = f'https://login.microsoftonline.com/{self.ms_client.tenant_id}/oauth2/token'
MFA_SERVICE_URI = 'https://strongauthenticationservice.auth.microsoft.com/StrongAuthenticationService.svc/Connector//BeginTwoWayAuthentication'

# Generating MFA app access token.
# acces token is valid for 1 day. TODO: implement a mechanism to create new one only when needed.
demisto.debug("Getting MFA Client Access Token...")

token_body = {
'resource': RESOURCE,
'client_id': MFA_APP_ID,
'client_secret': client_secret,
'grant_type': "client_credentials",
'scope': "openid"
}


try:
token_response = requests.post(AUTH_ENDPOINT, data=token_body)
token_response.raise_for_status()
mfa_client_token = token_response.json().get('access_token')
demisto.debug("Access token obtained successfully.")

except requests.exceptions.HTTPError as e:
raise DemistoException(f"Error obtaining access token: {e}\nResponse: {token_response.text}")

demisto.debug("\nSending MFA challenge to the user...")

# Generate a unique GUID for ContextId
context_id = str(uuid.uuid4())

# Define the XML payload
# The ContextId is dynamically replaced with a new GUID
# The UPN is the user principal name of the user to send the MFA notification to.
xml_payload = f"""
<BeginTwoWayAuthenticationRequest>
<Version>1.0</Version>
<UserPrincipalName>{user_principal_name}</UserPrincipalName>
<Lcid>en-us</Lcid>
<AuthenticationMethodProperties xmlns:a="http://schemas.microsoft.com/2003/10/Serialization/Arrays">
<a:KeyValueOfstringstring>
<a:Key>OverrideVoiceOtp</a:Key>
<a:Value>false</a:Value>
</a:KeyValueOfstringstring>
</AuthenticationMethodProperties>
<ContextId>{context_id}</ContextId>
<SyncCall>true</SyncCall>
<RequireUserMatch>true</RequireUserMatch>
<CallerName>radius</CallerName>
<CallerIP>UNKNOWN:</CallerIP>
</BeginTwoWayAuthenticationRequest>
"""

headers = {
"Authorization": f"Bearer {mfa_client_token}",
"Content-Type": "application/xml"
}

try:
# Send the request to the strong authentication service
mfa_result = requests.post(
MFA_SERVICE_URI,
headers=headers,
data=xml_payload.strip().encode('utf-8')
)
mfa_result.raise_for_status()

demisto.debug("MFA Challenge Request Sent. Waiting for response...")

# PARSE THE XML RESPONSE AND OUTPUT RESULTS

# 1. Parse the XML string into an ElementTree root object
root = ET.fromstring(mfa_result.text)

# 2. Extract crucial nodes directly from the root element
result_node = root.find('./Result')
auth_result_node = root.find('./AuthenticationResult')

# Ensure the core nodes exist before accessing their text
if result_node is not None and auth_result_node is not None:

# Get the core values
result_value = result_node.find('./Value').text
# AuthenticationResult is a string "true" or "false"
mfa_challenge_received = auth_result_node.text.lower() == 'true'

# Get message, correctly handling the XML schema 'nil' attribute
message_node = result_node.find('./Message')
is_nil = message_node is not None and message_node.get('{http://www.w3.org/2001/XMLSchema-instance}nil') == 'true'
result_message = message_node.text if message_node is not None and not is_nil else "No specific message"

# Determine final status
mfa_challenge_approved = (result_value == "Success")
mfa_challenge_denied = (result_value == "PhoneAppDenied")
mfa_challenge_timeout = (result_value == "PhoneAppNoResponse")

demisto.debug("--- MFA CHALLENGE RESULT ---")
demisto.debug(f"Raw Result Value: {result_value}")
demisto.debug(f"Message: {result_message}")

if mfa_challenge_approved and mfa_challenge_received:
return "Status: User Approved MFA Request 🎉"
elif mfa_challenge_denied:
raise DemistoException("Status: User Denied Request ❌")

Check failure on line 513 in Packs/MicrosoftGraphUser/Integrations/MicrosoftGraphUser/MicrosoftGraphUser.py

View workflow job for this annotation

GitHub Actions / pre-commit / pre-commit

Ruff (E501)

Packs/MicrosoftGraphUser/Integrations/MicrosoftGraphUser/MicrosoftGraphUser.py:513:131: E501 Line too long (135 > 130)
elif mfa_challenge_timeout:
raise DemistoException("Status: MFA Request Timed Out ⏳")
else:
# Covers "NoDefaultAuthenticationMethodIsConfigured" or other failures
raise DemistoException("Status: MFA Request Failed. Check user setup or raw XML for details.")

else:
# Fallback if XML structure is unexpected or empty
raise DemistoException(f"Error: Could not find core <Result> or <AuthenticationResult> elements in XML.\nRaw Response:\n{mfa_result.text}")

except requests.exceptions.HTTPError as e:
raise DemistoException(f"Error sending MFA challenge: {e}\n Response: {mfa_result.text}")
except ET.ParseError as e:
raise DemistoException(f"FATAL XML PARSE ERROR (Structure): {e}\nRaw Response: {mfa_result.text}")
except Exception as e:
raise DemistoException(f"An unexpected error occurred: {e}")


def suppress_errors_with_404_code(func):
Expand Down Expand Up @@ -730,6 +932,27 @@
return CommandResults(readable_output=human_readable)


def request_mfa_command(client: MsGraphClient, args: dict) -> None:
"""
Pops a request to MFA for the given user.

Args:
client (MsGraphClient): The Microsoft Graph client used to make the API request.
args (dict): A dictionary of arguments, which must include:
- user_mail (str): The mail of the user to pop the MFA to.

Returns:
CommandResults: Pops a request to MFA for the given user.
"""
user_mail = args.get("user_mail")
try:
mfa_app_secret = client.request_mfa_app_secret()
client.push_mfa_notification(mfa_app_secret, user_mail)
except Exception as e:
raise DemistoException(f"Failed to pop MFA request for user {user_mail}: {e}")

return CommandResults(readable_output=f"MFA request was successfully popped for user {user_mail}")

def create_zip_with_password(generated_tap_password: str, zip_password: str):
"""
Creates a password-protected zip file containing the TAP policy password.
Expand Down Expand Up @@ -833,6 +1056,7 @@
"msgraph-user-tap-policy-list": list_tap_policy_command,
"msgraph-user-tap-policy-create": create_tap_policy_command,
"msgraph-user-tap-policy-delete": delete_tap_policy_command,
"msgraph-user-request-mfa": request_mfa_command,
}
command = demisto.command()
LOG(f"Command being called is {command}")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -761,6 +761,12 @@ script:
- description: Run this command if for some reason you need to rerun the authentication process.
name: msgraph-user-auth-reset
arguments: []
- arguments:
- description: The user mail to pop the MFA to.
name: user_mail
required: true
description: pops a request to MFA for the given user.
name: msgraph-user-request-mfa
dockerimage: demisto/crypto:1.0.0.4578119
runonce: false
script: '-'
Expand Down
Loading