我应该如何在 Django 应用程序中使用 AAD 实现用户 SSO(使用 Django Microsoft 身份验证后端模块)?

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

我正在开发一个安装了 Django Microsoft Auth 的 Django (2.2.3) 应用程序来处理 Azure AD 的 SSO。我已经能够按照快速入门文档来允许我使用我的 Microsoft 身份或我添加到 Django 用户 table 的标准用户名和密码登录 Django 管理面板。这一切都开箱即用,很好。

我的问题(真的)很简单 "What do I do next?"。从用户的角度来看,我希望他们:

  1. 导航到我的应用程序(example.com/ 或示例。com/content)- Django 将意识到它们未通过身份验证,或者
    • 自动将它们重定向到同一 window 中的 SSO 门户,或
    • 将他们重定向到 example.com/login,这要求他们单击将打开 SSO 的按钮 window 中的门户(这是默认管理案例中发生的情况)
  2. 允许他们登录并通过其 Microsoft 帐户使用 MFA
  3. 成功后将他们重定向到我的 @login_required 页面(示例。com/content)

目前,在我的导航根目录 (example.com/),我有这个:

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

我最初的想法是简单地将 redirect("/login") 更改为 redirect(authorization_url) - 这就是我的问题开始的地方..

据我所知,没有任何方法可以获取上下文处理器的当前实例(?)或 microsoft_auth 插件的后端来调用 authorization_url() 函数和将用户从 views.py.

重定向

好的...然后我想我只是实例化生成身份验证 URL 的 MicrosoftClient class。这没有用——不是 100% 确定为什么,但它认为这可能与 backend/context 处理器上实际 MicrosoftClient 实例使用的某些状态变量与我的不一致这一事实有关实例.

最后,我尝试模仿自动 /admin 页面的功能 - 呈现一个 SSO 按钮供用户单击,然后在单独的 window 中打开 Azure 门户。仔细研究后,我意识到我基本上有同样的问题——auth URL 作为内联 JS 传递到管理登录页面模板,稍后用于异步创建 Azure window客户端。

作为完整性检查,我尝试手动导航到管理员登录页面中显示的身份验证 URL,这确实有效(尽管重定向到 /content 没有).

在这一点上,考虑到我认为我为自己做这件事有多么困难,我觉得我正在以完全错误的方式处理这整件事。遗憾的是,我找不到任何关于如何完成这部分过程的文档。

所以,我做错了什么?!

又花了几天时间,我最终自己解决了这些问题,并且对 Django 的工作原理也有了更多了解。

我缺少的 link 是来自(第三方)Django 模块的 how/where 上下文处理器将它们的上下文传递到最终呈现的页面。我没有意识到 microsoft_auth 包中的变量(例如在其模板中使用的 authorisation_url)默认情况下也可以在我的任何模板中访问。知道了这一点,我能够实现管理面板使用的相同基于 JS 的登录过程的稍微简单的版本。

假设将来阅读本文的任何人都在经历我所经历的相同(学习)过程(特别是这个包),我也许能够猜出你接下来会遇到的几个问题.. .

第一个是“我已成功登录...我如何代表用户做任何事情?!”。人们会假设您将获得用户的访问令牌以用于将来的请求,但在编写此包时,默认情况下似乎并没有以任何明显的方式执行此操作。该软件包的文档仅能帮助您登录管理面板。

(在我看来,不是很明显)答案是您必须将 MICROSOFT_AUTH_AUTHENTICATE_HOOK 设置为可以在成功验证时调用的函数。它将传递给登录用户(模型)和他们的令牌 JSON 对象,供您随意处理。经过深思熟虑,我选择使用 AbstractUser 扩展我的用户模型,并将每个用户的令牌与他们的其他数据保持在一起。

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"

注意 MICROSOFT_AUTH_EXTRA_SCOPES 设置,这可能是您的 second/side 问题 - 包中的默认范围设置为 SCOPE_MICROSOFT = ["openid", "email", "profile"],以及如何添加更多范围并不明显。我至少需要添加 User.Read 。请记住,该设置需要一串 space 分隔范围,而不是列表。

获得访问令牌后,您就可以自由地向 Microsoft Graph 发出请求 API。他们的 Graph Explorer 在解决这个问题上非常有用。

所以我在Django中基于https://github.com/Azure-Samples/ms-identity-python-webapp做了这个自定义视图。 希望这会对某人有所帮助。

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}