Skip to content
Open
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
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,9 @@ AZURE_AUTH = {
"EXTRA_FIELDS": [], # Optional, extra AAD user profile attributes you want to make available in the user mapping function
"USER_MAPPING_FN": "azure_auth.tests.misc.user_mapping_fn", # Optional, path to the function used to map the AAD to Django attributes
"GRAPH_USER_ENDPOINT": "https://graph.microsoft.com/v1.0/me", # Optional, URL to the Graph endpoint that returns user info
"USE_LOGIN_URL": False, # When set to "True" use the configured LOGIN_URL as redirect target. (See "Compatibility with other Authentication Backends" below)
}
LOGIN_URL = "/azure_auth/login"
LOGIN_URL = "/my/own/login/page/"
LOGIN_REDIRECT_URL = "/" # Or any other endpoint
```

Expand Down Expand Up @@ -212,6 +213,12 @@ will be removed from the Django group.

During logout, if the ID token includes only the default claims, Active Directory will present the user with a page prompting them to select the account to log out. To disable this, simply enable the `login_hint` optional claim in your client application in Azure, as described in https://learn.microsoft.com/en-us/entra/identity-platform/v2-protocols-oidc#send-a-sign-out-request.

### Compatibility with other Authentication Backends

When using multiple Authentication Backends you will need to provide your own login page where your user can select the authentication backend to
use (e. g. local Django users). To redirect to your own login page set `USE_LOGIN_URL` to `True` and configure the URL in `LOGIN_URL` to point to
your login page. The Django default is `/accounts/login/` (https://docs.djangoproject.com/en/5.1/ref/settings/#std-setting-LOGIN_URL).

## Credits

This app is heavily inspired by and builds on functionality in
Expand Down
21 changes: 21 additions & 0 deletions azure_auth/decorators.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,39 @@
import functools
from urllib.parse import urlparse

from django.conf import settings
from django.contrib.auth import BACKEND_SESSION_KEY, decorators
from django.shortcuts import redirect
from django.urls import reverse

from .handlers import AuthHandler


def _dummy_login_not_required(view_func):
return view_func


# The login_not_required decorator was added in Django 5.1
# For earlier version we use a dummy decorator
login_not_required = getattr(
decorators, "login_not_required", _dummy_login_not_required
)


def azure_auth_required(func):
@functools.wraps(func)
def _wrapper(request, *args, **kwargs):
active_auth_backend = request.session.get(BACKEND_SESSION_KEY, "")

# If the token is valid (or a new valid one can be generated)
if AuthHandler(request).user_is_authenticated:
return func(request, *args, **kwargs)
elif active_auth_backend != "azure_auth.auth_backends.AzureBackend":
# User is handled by another backend
return func(request, *args, **kwargs)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as for in the middleware - shouldn't there be a check on self.request.user.is_authenticated before returning?

if settings.AZURE_AUTH.get("USE_LOGIN_URL", False):
return redirect(f"{settings.LOGIN_URL}?next={urlparse(request.path).path}")

return redirect(
f"{reverse('azure_auth:login')}?next={urlparse(request.path).path}"
)
Expand Down
9 changes: 9 additions & 0 deletions azure_auth/middleware.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from urllib.parse import urlparse

from django.conf import settings
from django.contrib.auth import BACKEND_SESSION_KEY
from django.http import HttpRequest
from django.shortcuts import redirect
from django.urls import reverse
Expand All @@ -19,6 +20,8 @@ def __init__(self, get_response):
) # added to resolve paths

def __call__(self, request: HttpRequest):
active_auth_backend = request.session.get(BACKEND_SESSION_KEY, "")

if request.path_info in self.public_urls:
return self.get_response(request)

Expand All @@ -29,6 +32,12 @@ def __call__(self, request: HttpRequest):

if AuthHandler(request).user_is_authenticated:
return self.get_response(request)
elif active_auth_backend != "azure_auth.auth_backends.AzureBackend":
# User is handled by another backend
return self.get_response(request)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't there be a check on self.request.user.is_authenticated before returning?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure. In our case we have a second middleware together with a second authentication backend. So each middleware is only checking the accounts that belong to it's backend. But checking self.request.user.is_authenticated here is probably equally valid.


if settings.AZURE_AUTH.get("USE_LOGIN_URL", False):
return redirect(f"{settings.LOGIN_URL}?next={urlparse(request.path).path}")

return redirect(
f"{reverse('azure_auth:login')}?next={urlparse(request.path).path}"
Expand Down
2 changes: 1 addition & 1 deletion azure_auth/tests/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,5 +144,5 @@
"EXTRA_FIELDS": [],
"USER_MAPPING_FN": "azure_auth.tests.misc.user_mapping_fn",
}
LOGIN_URL = "/azure_auth/login"
LOGIN_URL = "/my/own/login/page/"
LOGIN_REDIRECT_URL = "/"
22 changes: 21 additions & 1 deletion azure_auth/tests/test_decorators.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,42 @@
from collections import ChainMap
from http import HTTPStatus
from unittest.mock import patch

import msal
import pytest
from django.test import TransactionTestCase
from django.conf import settings
from django.contrib.auth import BACKEND_SESSION_KEY
from django.test import TransactionTestCase, override_settings
from django.urls import reverse


@pytest.mark.django_db
@pytest.mark.usefixtures("token")
@patch.object(msal, "ConfidentialClientApplication")
class TestAzureAuthDecorator(TransactionTestCase):
def setUp(self):
s = self.client.session
s.update({BACKEND_SESSION_KEY: "azure_auth.auth_backends.AzureBackend"})
s.save()

def test_invalid_token(self, mocked_msal_app):
mocked_msal_app.return_value.acquire_token_silent.return_value = None
resp = self.client.get(reverse("decorator_protected"))
assert resp.status_code == HTTPStatus.FOUND
assert resp.url == f"{reverse('azure_auth:login')}?next=/decorator_protected/" # type: ignore

@override_settings(
AZURE_AUTH=ChainMap(
{"USE_LOGIN_URL": True},
settings.AZURE_AUTH,
)
)
def test_invalid_token_with_use_login_url(self, mocked_msal_app):
mocked_msal_app.return_value.acquire_token_silent.return_value = None
resp = self.client.get(reverse("decorator_protected"))
assert resp.status_code == HTTPStatus.FOUND
assert resp.url == f"{settings.LOGIN_URL}?next=/decorator_protected/" # type: ignore

def test_valid_token_unauthenticated_user(self, mocked_msal_app):
# Not sure how this situation could arise but test anyway...

Expand Down
22 changes: 21 additions & 1 deletion azure_auth/tests/test_middleware.py
Original file line number Diff line number Diff line change
@@ -1,23 +1,43 @@
import datetime
from collections import ChainMap
from http import HTTPStatus
from unittest.mock import patch

import msal
import pytest
from django.test import TransactionTestCase
from django.conf import settings
from django.contrib.auth import BACKEND_SESSION_KEY
from django.test import TransactionTestCase, override_settings
from django.urls import reverse


@pytest.mark.django_db
@pytest.mark.usefixtures("token")
@patch.object(msal, "ConfidentialClientApplication")
class TestAzureAuthMiddleware(TransactionTestCase):
def setUp(self):
s = self.client.session
s.update({BACKEND_SESSION_KEY: "azure_auth.auth_backends.AzureBackend"})
s.save()

def test_invalid_token(self, mocked_msal_app):
mocked_msal_app.return_value.acquire_token_silent.return_value = None
resp = self.client.get(reverse("middleware_protected"))
assert resp.status_code == HTTPStatus.FOUND
assert resp.url == f"{reverse('azure_auth:login')}?next=/middleware_protected/" # type: ignore

@override_settings(
AZURE_AUTH=ChainMap(
{"USE_LOGIN_URL": True},
settings.AZURE_AUTH,
)
)
def test_invalid_token_with_use_login_url(self, mocked_msal_app):
mocked_msal_app.return_value.acquire_token_silent.return_value = None
resp = self.client.get(reverse("middleware_protected"))
assert resp.status_code == HTTPStatus.FOUND
assert resp.url == f"{settings.LOGIN_URL}?next=/middleware_protected/" # type: ignore

def test_valid_token_unauthenticated_user(self, mocked_msal_app):
# Not sure how this situation could arise but test anyway...

Expand Down
4 changes: 4 additions & 0 deletions azure_auth/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@

from azure_auth.utils import EntraStateSerializer

from .decorators import login_not_required
from .handlers import AuthHandler

serializer = EntraStateSerializer()


@login_not_required
def azure_auth_login(request: HttpRequest):
return HttpResponseRedirect(
AuthHandler(request).get_auth_uri(
Expand All @@ -20,6 +22,7 @@ def azure_auth_login(request: HttpRequest):
)


@login_not_required
def azure_auth_logout(request: HttpRequest):
# Auth handler has to be initialized before `logout()` to load the claims from the session
auth_handler = AuthHandler(request)
Expand All @@ -28,6 +31,7 @@ def azure_auth_logout(request: HttpRequest):
return HttpResponseRedirect(auth_handler.get_logout_uri())


@login_not_required
def azure_auth_callback(request: HttpRequest):
token = AuthHandler(request).get_token_from_flow()
user = authenticate(request, token=token)
Expand Down