How should I be implementing user SSO with AAD in a Django application (using the Django Microsoft Authentication Backend module)?
Asked Answered
R

2

15

I'm developing a Django (2.2.3) application with Django Microsoft Auth installed to handle SSO with Azure AD. I've been able to follow the quickstart documentation to allow me to log into the Django Admin panel by either using my Microsoft identity, or a standard username and password I've added to the Django user table. This all works out of the box and is fine.

My question put (really) simply is "What do I do next?". From a user's perspective, I'd like them to:

  1. Navigate to my application (example.com/ or example.com/content) - Django will realise they aren't authenticated, and either
    • automatically redirect them to the SSO portal in the same window, or
    • redirect them to example.com/login, which requires them to click a button that will open the SSO portal in a window (which is what happens in the default admin case)
  2. Allow them to sign in and use MFA with their Microsoft Account
  3. Once successful redirect them to my @login_required pages (example.com/content)

Currently, at the root of my navigation (example.com/), I have this:

    def index(request):
        if request.user.is_authenticated:
            return redirect("/content")
        else:
            return redirect("/login")

My original idea was to simply change the redirect("/login") to redirect(authorization_url) - and this is where my problems start..

As far as I can tell, there isn't any way to get the current instance(?) of the context processor or backend of the microsoft_auth plugin to call the authorization_url() function and redirect the user from views.py.

Ok... Then I thought I'd just instantiate the MicrosoftClient class that generates the auth URL. This didn't work - not 100% sure why, but it think it may have something to do with the fact that some state variable used by the actual MicrosoftClient instance on the backend/context processor is inconsistent with my instance.

Finally, I tried to mimic what the automatic /admin page does - present an SSO button for the user to click, and open the Azure portal in a separate window. After digging around a bit, I realise that I fundamentally have the same problem - the auth URL is passed into the admin login page template as inline JS, which is later used to create the Azure window asynchronously on the client side.

As a sanity check, I tried to manually navigate to the auth URL as it is presented in the admin login page, and that did work (though the redirect to /content didn't).

At this point, given how difficult I think I'm making it for myself, I'm feel like I'm going about this whole thing completely the wrong way. Sadly, I can't find any documentation on how to complete this part of the process.

So, what am I doing wrong?!

Rem answered 31/1, 2020 at 17:30 Comment(0)
R
11

A couple more days at this and I eventually worked out the issues myself, and learned a little more about how Django works too.

The link I was missing was how/where context processors from (third party) Django modules pass their context's through to the page that's eventually rendered. I didn't realise that variables from the microsoft_auth package (such as the authorisation_url used in its template) were accessible to me in any of my templates by default as well. Knowing this, I was able to implement a slightly simpler version of the same JS based login process that the admin panel uses.

Assuming that anyone reading this in the future is going through the same (learning) process I have (with this package in particular), I might be able to guess at the next couple of questions you'll have...

The first one was "I've logged in successfully...how do I do anything on behalf of the user?!". One would assume you'd be given the user's access token to use for future requests, but at the time of writing this package didn't seem to do it in any obvious way by default. The docs for the package only get you as far as logging into the admin panel.

The (in my opinion, not so obvious) answer is that you have to set MICROSOFT_AUTH_AUTHENTICATE_HOOK to a function that can be called on a successful authentication. It will be passed the logged in user (model) and their token JSON object for you to do with as you wish. After some deliberation, I opted to extend my user model using AbstractUser and just keep each user's token with their other data.

models.py

class User(AbstractUser):
    access_token = models.CharField(max_length=2048, blank=True, null=True)
    id_token = models.CharField(max_length=2048, blank=True, null=True)
    token_expires = models.DateTimeField(blank=True, null=True)

aad.py

from datetime import datetime
from django.utils.timezone import make_aware

def store_token(user, token):
    user.access_token = token["access_token"]
    user.id_token = token["id_token"]
    user.token_expires = make_aware(datetime.fromtimestamp(token["expires_at"]))
    user.save()

settings.py

MICROSOFT_AUTH_EXTRA_SCOPES = "User.Read"
MICROSOFT_AUTH_AUTHENTICATE_HOOK = "django_app.aad.store_token"

Note the MICROSOFT_AUTH_EXTRA_SCOPES setting, which might be your second/side question - The default scopes set in the package as SCOPE_MICROSOFT = ["openid", "email", "profile"], and how to add more isn't made obvious. I needed to add User.Read at the very least. Keep in mind that the setting expects a string of space separated scopes, not a list.

Once you have the access token, you're free to make requests to the Microsoft Graph API. Their Graph Explorer is extremely useful in helping out with this.

Rem answered 3/2, 2020 at 10:30 Comment(4)
Were you able to adapt the Login with Microsoft button on the admin login page to work on your app's main page? That is what I have been struggling with for days. I have the link on the page, but the login does not work. I get An invalid state variable was provided. If I look at the URL generated on the admin page and compare it to mine the state is indeed different. But I use the same code which gets the URL from {{ microsoft_authorization_url }}. I feel like I am missing one small thing.Pluton
@Larry Martell In a word, yes, that is what I was able to do. I saw the An invalid state variable was provided error a number of times for a few different reasons (from what I can remember), so I don't think I have enough context to give you any specific pointers.Rem
Sorry I know it's old, but I'm trying the following error: (invalid_grant) AADSTS70000: The provided value for the 'code' parameter is not valid. The code has expired. any suggestions? Basically I'm calling authorization_url in my template, initially everything looks fine but then I have this errorSac
@Sac Sorry, I didn't see this until now. Again, there's not much context for me to go on without seeing your Azure and Django settings. If you're still have issues ask a new question with all the details and I'll take a look if I can.Rem
K
3

So I made this custom view in Django based on https://github.com/Azure-Samples/ms-identity-python-webapp. Hopefully, this will help someone.

import logging
import uuid
from os import getenv

import msal
import requests
from django.http import JsonResponse
from django.shortcuts import redirect, render
from rest_framework.generics import ListAPIView

logging.getLogger("msal").setLevel(logging.WARN)

# Application (client) ID of app registration
CLIENT_ID = "<appid of client registered in AD>"

TENANT_ID = "<tenantid of AD>"

CLIENT_SECRET = getenv("CLIENT_SECRET")

AUTHORITY = "https://login.microsoftonline.com/" + TENANT_ID

# This resource requires no admin consent
GRAPH_ENDPOINT = 'https://graph.microsoft.com/v1.0/me'

SCOPE = ["User.Read"]

LOGIN_URI = "https://<your_domain>/login"

# This is registered as a redirect URI in app registrations in AD
REDIRECT_URI = "https://<your_domain>/authorize"


class Login(ListAPIView):
    '''initial login
    '''

    def get(self, request):
        session = request.session

        id_token_claims = get_token_from_cache(session, SCOPE)
        if id_token_claims:
            access_token = id_token_claims.get("access_token")

            if access_token:
                graph_response = microsoft_graph_call(access_token)

                if graph_response.get("error"):
                    resp = JsonResponse(graph_response, status=401)

                else:
                    resp = render(request, 'API_AUTH.html', graph_response)

            else:
                session["state"] = str(uuid.uuid4())
                auth_url = build_auth_url(scopes=SCOPE, state=session["state"])
                resp = redirect(auth_url)
        else:
            session["state"] = str(uuid.uuid4())
            auth_url = build_auth_url(scopes=SCOPE, state=session["state"])
            resp  = redirect(auth_url)

        return resp


class Authorize(ListAPIView):
    '''authorize after login
    '''

    def get(self, request):
        session = request.session

        # If states don't match login again
        if request.GET.get('state') != session.get("state"):
            return redirect(LOGIN_URI)

        # Authentication/Authorization failure
        if "error" in request.GET:
            return JsonResponse({"error":request.GET.get("error")})

        if request.GET.get('code'):
            cache = load_cache(session)
            result = build_msal_app(cache=cache).acquire_token_by_authorization_code(
                request.GET['code'],
                # Misspelled scope would cause an HTTP 400 error here
                scopes=SCOPE,
                redirect_uri=REDIRECT_URI
            )

            if "error" in result:
                resp = JsonResponse({"error":request.GET.get("error")})
            else:
                access_token = result["access_token"]

                session["user"] = result.get("id_token_claims")
                save_cache(session, cache)

                # Get user details using microsoft graph api call
                graph_response = microsoft_graph_call(access_token)

                resp = render(request, 'API_AUTH.html', graph_response)

        else:
            resp = JsonResponse({"login":"failed"}, status=401)

        return resp


def load_cache(session):
    '''loads from msal cache
    '''
    cache = msal.SerializableTokenCache()
    if session.get("token_cache"):
        cache.deserialize(session["token_cache"])
    return cache

def save_cache(session,cache):
    '''saves to msal cache
    '''
    if cache.has_state_changed:
        session["token_cache"] = cache.serialize()

def build_msal_app(cache=None, authority=None):
    '''builds msal cache
    '''
    return msal.ConfidentialClientApplication(
        CLIENT_ID, authority=authority or AUTHORITY,
        client_credential=CLIENT_SECRET, token_cache=cache)

def build_auth_url(authority=None, scopes=None, state=None):
    '''builds auth url per tenantid
    '''
    return build_msal_app(authority=authority).get_authorization_request_url(
        scopes or [],
        state=state or str(uuid.uuid4()),
        redirect_uri=REDIRECT_URI)

def get_token_from_cache(session, scope):
    '''get accesstoken from cache
    '''
    # This web app maintains one cache per session
    cache = load_cache(session)
    cca = build_msal_app(cache=cache)
    accounts = cca.get_accounts()
    # So all account(s) belong to the current signed-in user
    if accounts:
        result = cca.acquire_token_silent(scope, account=accounts[0])
        save_cache(session, cache)
        return result

def microsoft_graph_call(access_token):
    '''graph api to microsoft
    '''
    # Use token to call downstream service
    graph_data = requests.get(
        url=GRAPH_ENDPOINT,
        headers={'Authorization': 'Bearer ' + access_token},
        ).json()

    if "error" not in graph_data:
        return {
            "Login" : "success",
            "UserId" : graph_data.get("id"),
            "UserName" : graph_data.get("displayName"),
            "AccessToken" : access_token
            }

    else:
        return {"error" : graph_data}
Koto answered 23/2, 2021 at 17:4 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.