Skip to content

Commit

Permalink
Merge pull request #247 from 777GE90/add_obo_groups_lookup
Browse files Browse the repository at this point in the history
Added ability to obtain groups from MS Graph when there are too many
  • Loading branch information
JonasKs committed Aug 23, 2022
2 parents 5067f8f + 1852042 commit ec598f5
Show file tree
Hide file tree
Showing 10 changed files with 503 additions and 33 deletions.
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

0 comments on commit ec598f5

Please sign in to comment.