Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added ability to obtain groups from MS Graph when there are too many #247

Merged
merged 5 commits into from
Aug 23, 2022
Merged
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
2 changes: 1 addition & 1 deletion django_auth_adfs/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,4 @@
Adding imports here will break setup.py
"""

__version__ = '1.10.0'
__version__ = '1.10.1'
140 changes: 118 additions & 22 deletions django_auth_adfs/backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,81 @@ def exchange_auth_code(self, authorization_code, request):
adfs_response = response.json()
return adfs_response

def get_obo_access_token(self, access_token):
"""
Gets an On Behalf Of (OBO) access token, which is required to make queries against MS Graph

Args:
access_token (str): Original authorization access token from the user

Returns:
obo_access_token (str): OBO access token that can be used with the MS Graph API
"""
logger.debug("Getting OBO access token: %s", provider_config.token_endpoint)
data = {
"grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer",
"client_id": settings.CLIENT_ID,
"client_secret": settings.CLIENT_SECRET,
"assertion": access_token,
"requested_token_use": "on_behalf_of",
}
if provider_config.token_endpoint.endswith("/v2.0/token"):
data["scope"] = 'GroupMember.Read.All'
else:
data["resource"] = 'https://graph.microsoft.com'

response = provider_config.session.get(provider_config.token_endpoint, data=data, timeout=settings.TIMEOUT)
# 200 = valid token received
# 400 = 'something' is wrong in our request
if response.status_code == 400:
logger.error("ADFS server returned an error: %s", response.json()["error_description"])
raise PermissionDenied

if response.status_code != 200:
logger.error("Unexpected ADFS response: %s", response.content.decode())
raise PermissionDenied

obo_access_token = response.json()["access_token"]
logger.debug("Received OBO access token: %s", obo_access_token)
return obo_access_token

def get_group_memberships_from_ms_graph(self, obo_access_token):
"""
Looks up a users group membership from the MS Graph API

Args:
obo_access_token (str): Access token obtained from the OBO authorization endpoint

Returns:
claim_groups (list): List of the users group memberships
"""
graph_url = "https://{}/v1.0/me/transitiveMemberOf/microsoft.graph.group".format(
provider_config.msgraph_endpoint
)
headers = {"Authorization": "Bearer {}".format(obo_access_token)}
response = provider_config.session.get(graph_url, headers=headers, timeout=settings.TIMEOUT)
# 200 = valid token received
# 400 = 'something' is wrong in our request
if response.status_code in [400, 401]:
logger.error("MS Graph server returned an error: %s", response.json()["message"])
raise PermissionDenied

if response.status_code != 200:
logger.error("Unexpected MS Graph response: %s", response.content.decode())
raise PermissionDenied

claim_groups = []
for group_data in response.json()["value"]:
if group_data["displayName"] is None:
logger.error(
"The application does not have the required permission to read user groups from "
"MS Graph (GroupMember.Read.All)"
)
raise PermissionDenied

claim_groups.append(group_data["displayName"])
return claim_groups

def validate_access_token(self, access_token):
for idx, key in enumerate(provider_config.signing_keys):
try:
Expand Down Expand Up @@ -100,10 +175,11 @@ def process_access_token(self, access_token, adfs_response=None):
if not claims:
raise PermissionDenied

groups = self.process_user_groups(claims, access_token)
user = self.create_user(claims)
self.update_user_attributes(user, claims)
self.update_user_groups(user, claims)
self.update_user_flags(user, claims)
self.update_user_groups(user, groups)
self.update_user_flags(user, claims, groups)

signals.post_authenticate.send(
sender=self,
Expand All @@ -116,6 +192,41 @@ def process_access_token(self, access_token, adfs_response=None):
user.save()
return user

def process_user_groups(self, claims, access_token):
"""
Checks the user groups are in the claim or pulls them from MS Graph if
applicable

Args:
claims (dict): claims from the access token
access_token (str): Used to make an OBO authentication request if
groups must be obtained from Microsoft Graph

Returns:
groups (list): Groups the user is a member of, taken from the access token or MS Graph
"""
groups = []
if settings.GROUPS_CLAIM is None:
logger.debug("No group claim has been configured")
return groups

if settings.GROUPS_CLAIM in claims:
groups = claims[settings.GROUPS_CLAIM]
if not isinstance(groups, list):
groups = [groups, ]
elif (
settings.TENANT_ID != "adfs"
and "_claim_names" in claims
and settings.GROUPS_CLAIM in claims["_claim_names"]
):
obo_access_token = self.get_obo_access_token(access_token)
groups = self.get_group_memberships_from_ms_graph(obo_access_token)
else:
logger.debug("The configured groups claim %s was not found in the access token",
settings.GROUPS_CLAIM)

return groups

def create_user(self, claims):
"""
Create the user if it doesn't exist yet
Expand Down Expand Up @@ -201,26 +312,18 @@ def update_user_attributes(self, user, claims, claim_mapping=None):
msg = "Model '{}' has no field named '{}'. Check ADFS claims mapping."
raise ImproperlyConfigured(msg.format(user._meta.model_name, field))

def update_user_groups(self, user, claims):
def update_user_groups(self, user, claim_groups):
"""
Updates user group memberships based on the GROUPS_CLAIM setting.

Args:
user (django.contrib.auth.models.User): User model instance
claims (dict): Claims from the access token
claim_groups (list): User groups from the access token / MS Graph
"""
if settings.GROUPS_CLAIM is not None:
# Update the user's group memberships
django_groups = [group.name for group in user.groups.all()]

if settings.GROUPS_CLAIM in claims:
claim_groups = claims[settings.GROUPS_CLAIM]
if not isinstance(claim_groups, list):
claim_groups = [claim_groups, ]
else:
logger.debug("The configured groups claim '%s' was not found in the access token",
settings.GROUPS_CLAIM)
claim_groups = []
if sorted(claim_groups) != sorted(django_groups):
existing_groups = list(Group.objects.filter(name__in=claim_groups).iterator())
existing_group_names = frozenset(group.name for group in existing_groups)
Expand All @@ -241,29 +344,22 @@ def update_user_groups(self, user, claims):
pass
user.groups.set(existing_groups + new_groups)

def update_user_flags(self, user, claims):
def update_user_flags(self, user, claims, claim_groups):
"""
Updates user boolean attributes based on the BOOLEAN_CLAIM_MAPPING setting.

Args:
user (django.contrib.auth.models.User): User model instance
claims (dict): Claims from the access token
claim_groups (list): User groups from the access token / MS Graph
"""
if settings.GROUPS_CLAIM is not None:
if settings.GROUPS_CLAIM in claims:
access_token_groups = claims[settings.GROUPS_CLAIM]
if not isinstance(access_token_groups, list):
access_token_groups = [access_token_groups, ]
else:
logger.debug("The configured group claim was not found in the access token")
access_token_groups = []

for flag, group in settings.GROUP_TO_FLAG_MAPPING.items():
if hasattr(user, flag):
if not isinstance(group, list):
group = [group]

if any(group_list_item in access_token_groups for group_list_item in group):
if any(group_list_item in claim_groups for group_list_item in group):
value = True
else:
value = False
Expand Down
5 changes: 5 additions & 0 deletions django_auth_adfs/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@ def __init__(self):
self.token_endpoint = None
self.end_session_endpoint = None
self.issuer = None
self.msgraph_endpoint = None

allowed_methods = frozenset([
'HEAD', 'GET', 'PUT', 'DELETE', 'OPTIONS', 'TRACE', 'POST'
Expand Down Expand Up @@ -229,6 +230,7 @@ def load_config(self):
logger.info("token endpoint: %s", self.token_endpoint)
logger.info("end session endpoint: %s", self.end_session_endpoint)
logger.info("issuer: %s", self.issuer)
logger.info("msgraph endpoint: %s", self.msgraph_endpoint)

def _load_openid_config(self):
if settings.VERSION != 'v1.0':
Expand Down Expand Up @@ -262,8 +264,10 @@ def _load_openid_config(self):
self.end_session_endpoint = openid_cfg["end_session_endpoint"]
if settings.TENANT_ID != 'adfs':
self.issuer = openid_cfg["issuer"]
self.msgraph_endpoint = openid_cfg["msgraph_host"]
else:
self.issuer = openid_cfg["access_token_issuer"]
self.msgraph_endpoint = "graph.microsoft.com"
except KeyError:
raise ConfigLoadError
return True
Expand Down Expand Up @@ -299,6 +303,7 @@ def _load_federation_metadata(self):
self.authorization_endpoint = base_url + "/oauth2/authorize"
self.token_endpoint = base_url + "/oauth2/token"
self.end_session_endpoint = base_url + "/ls/?wa=wsignout1.0"
self.msgraph_endpoint = "graph.microsoft.com"
return True

def _load_keys(self, certificates):
Expand Down
Binary file added docs/_static/AzureAD/20_add-permission-3.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
8 changes: 8 additions & 0 deletions docs/azure_ad_config_guide.rst
Original file line number Diff line number Diff line change
Expand Up @@ -225,3 +225,11 @@ Here we can give our frontend the permission scope we created earlier. Press **D

.. image:: _static/AzureAD/19_add-permission-2.PNG
:scale: 50 %

------------

Finally, sometimes the plugin will need to obtain the user groups claim from MS Graph (for example when the user has too many groups to fit in the access token), to ensure the plugin can do this successfully add the GroupMember.Read.All permission.


.. image:: _static/AzureAD/20_add-permission-3.PNG
:scale: 50 %
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = 'django-auth-adfs'
version = "1.10.0" # Remember to also change __init__.py version
version = "1.10.1" # Remember to also change __init__.py version
description = 'A Django authentication backend for Microsoft ADFS and AzureAD'
authors = ['Joris Beckers <joris.beckers@gmail.com>']
maintainers = ['Jonas Krüger Svensson <jonas-ks@hotmail.com>', 'Sondre Lillebø Gundersen <sondrelg@live.no>']
Expand Down
62 changes: 62 additions & 0 deletions tests/mock_files/azure-openid-configuration-v2.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
{
"authorization_endpoint": "https://login.microsoftonline.com/01234567-89ab-cdef-0123-456789abcdef/oauth2/v2.0/authorize",
"token_endpoint": "https://login.microsoftonline.com/01234567-89ab-cdef-0123-456789abcdef/oauth2/v2.0/token",
"token_endpoint_auth_methods_supported": [
"client_secret_post",
"private_key_jwt",
"client_secret_basic"
],
"jwks_uri": "https://login.microsoftonline.com/common/discovery/keys",
"response_modes_supported": [
"query",
"fragment",
"form_post"
],
"subject_types_supported": [
"pairwise"
],
"id_token_signing_alg_values_supported": [
"RS256"
],
"http_logout_supported": true,
"frontchannel_logout_supported": true,
"end_session_endpoint": "https://login.microsoftonline.com/01234567-89ab-cdef-0123-456789abcdef/oauth2/v2.0/logout",
"response_types_supported": [
"code",
"id_token",
"code id_token",
"token id_token",
"token"
],
"scopes_supported": [
"openid"
],
"issuer": "https://sts.windows.net/01234567-89ab-cdef-0123-456789abcdef/",
"claims_supported": [
"sub",
"iss",
"cloud_instance_name",
"cloud_instance_host_name",
"cloud_graph_host_name",
"msgraph_host",
"aud",
"exp",
"iat",
"auth_time",
"acr",
"amr",
"nonce",
"email",
"given_name",
"family_name",
"nickname"
],
"microsoft_multi_refresh_token": true,
"check_session_iframe": "https://login.microsoftonline.com/01234567-89ab-cdef-0123-456789abcdef/oauth2/v2.0/checksession",
"userinfo_endpoint": "https://login.microsoftonline.com/01234567-89ab-cdef-0123-456789abcdef/openid/userinfo",
"tenant_region_scope": "EU",
"cloud_instance_name": "microsoftonline.com",
"cloud_graph_host_name": "graph.windows.net",
"msgraph_host": "graph.microsoft.com",
"rbac_url": "https://pas.windows.net"
}
11 changes: 11 additions & 0 deletions tests/test_authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,17 @@ def test_group_claim(self):
self.assertEqual(user.email, "john.doe@example.com")
self.assertEqual(len(user.groups.all()), 0)

@mock_adfs("2016")
def test_no_group_claim(self):
backend = AdfsAuthCodeBackend()
with patch("django_auth_adfs.backend.settings.GROUPS_CLAIM", None):
user = backend.authenticate(self.request, authorization_code="dummycode")
self.assertIsInstance(user, User)
self.assertEqual(user.first_name, "John")
self.assertEqual(user.last_name, "Doe")
self.assertEqual(user.email, "john.doe@example.com")
self.assertEqual(len(user.groups.all()), 0)

@mock_adfs("2016", empty_keys=True)
def test_empty_keys(self):
backend = AdfsAuthCodeBackend()
Expand Down
Loading