使用 Azure Active Directory B2C 在你自己的 Python Web 应用程序中启用身份验证

本文将介绍如何在你自己的 Python Web 应用程序中添加 Azure Active Directory B2C (Azure AD B2C) 身份验证。 你需要允许用户使用 Azure AD B2C 用户流登录、注销、更新个人资料和重置密码。 本文使用适用于 Python 的 Microsoft 身份验证库 (MSAL) 来简化向 Python Web 应用程序添加身份验证的过程。

本文的目的是将你在使用 Azure AD B2C 在示例 Python Web 应用程序中配置身份验证中使用的示例应用程序替换为你自己的 Python 应用程序。

本文使用 Python 3.9+Flask 2.1 创建一个基本的 Web 应用。 应用程序的视图使用 Jinja2 模板

先决条件

步骤 1:创建 Python 项目

  1. 在文件系统上,为本教程创建一个项目文件夹,例如 my-python-web-app

  2. 在终端中,将目录切换到 Python 应用文件夹,例如 cd my-python-web-app

  3. 运行以下命令,以根据当前解释器创建并激活名为 .venv 的虚拟环境。

    sudo apt-get install python3-venv  # If needed
    python3 -m venv .venv
    source .venv/bin/activate
    
  4. 在终端中运行以下命令以更新虚拟环境中的 pip:

    python -m pip install --upgrade pip
    
  5. 若要启用 Flask 调试功能,请将 Flask 开发环境切换到 development 模式。 有关调试 Flask 应用的详细信息,请查看 Flask 文档

    export FLASK_ENV=development
    
  6. 通过在 VS Code 中运行 code . 命令,或者打开 VS Code 并选择“文件”>“打开文件夹”来打开项目文件夹。

步骤 2:安装应用依赖项

在Web 应用根文件夹下,创建 requirements.txt 文件。 requirements 文件列出了要使用 pip install 安装的。 将以下内容添加到 requirements.txt 文件中:

Flask>=2
werkzeug>=2

flask-session>=0.3.2,<0.5
requests>=2,<3
msal>=1.7,<2

在终端中,运行以下命令来安装依赖项:

python -m pip install -r requirements.txt

步骤 3:生成应用 UI 组件

Flask 是一种轻量级 Web 应用程序 Python 框架,为 URL 路由和页面呈现提供基础知识。 它利用 Jinja2 作为模板引擎来呈现应用的内容。 有关详细信息,请查看模板设计器文档。 在本部分,你将添加所需的模板来提供 Web 应用的基本功能。

步骤 3.1:创建基础模板

Flask 中的基础页面模板包含一系列页面的所有共享部分,包括对 CSS 文件、脚本文件等的引用。 基础模板还定义一个或多个块标记,扩展基础模板的其他模板预期会替代这些标记。 在基础模板和扩展的模板中,块标记都由 {% block <name> %}{% endblock %} 描述。

在 Web 应用的根文件夹中,创建 templates 文件夹。 在模板文件夹中,创建名为 base.html 的文件并添加以下内容:

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    {% block metadata %}{% endblock %}

    <title>{% block title %}{% endblock %}</title>
    <!-- Bootstrap CSS file reference -->
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.0-beta1/dist/css/bootstrap.min.css" rel="stylesheet"
        integrity="sha384-0evHe/X+R7YkIZDRvuzKMRqM+OrBnVFBL6DOitfPri4tjfHxaWutUpFmBp4vmVor" crossorigin="anonymous">
</head>

<body>
    <nav class="navbar navbar-expand-lg navbar-dark bg-dark">
        <div class="container-fluid">
            <a class="navbar-brand" href="{{ url_for('index')}}">Python Flask demo</a>
            <button class="navbar-toggler" type="button" data-bs-toggle="collapse"
                data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false"
                aria-label="Toggle navigation">
                <span class="navbar-toggler-icon"></span>
            </button>
            <div class="collapse navbar-collapse" id="navbarSupportedContent">
                <ul class="navbar-nav me-auto mb-2 mb-lg-0">
                    <li class="nav-item">
                        <a class="nav-link active" aria-current="page" href="{{ url_for('index')}}">Home</a>
                    </li>
                    <li class="nav-item">
                        <a class="nav-link" href="{{ url_for('graphcall')}}">Graph API</a>
                    </li>
                </ul>
            </div>
        </div>
    </nav>

    <div class="container body-content">
        <br />
        {% block content %}
        {% endblock %}

        <hr />
        <footer>
            <p>Powered by MSAL Python {{ version }}</p>
        </footer>
    </div>
</body>

</html>

步骤 3.2:创建 Web 应用模板

在模板文件夹下添加以下模板。 这些模板扩展 base.html 模板:

  • index.html:Web 应用的主页。 模板使用以下逻辑:如果用户未登录,则呈现“登录”按钮。 如果用户正在登录,则呈现访问令牌的声明、指向编辑个人资料的链接,并调用图形 API。

    {% extends "base.html" %}
    {% block title %}Home{% endblock %}
    {% block content %}
    
    <h1>Microsoft Identity Python Web App</h1>
    
    {% if user %}
    <h2>Claims:</h2>
    <pre>{{ user |tojson(indent=4) }}</pre>
    
    
    {% if config.get("ENDPOINT") %}
    <li><a href='/graphcall'>Call Microsoft Graph API</a></li>
    {% endif %}
    
    {% if config.get("B2C_PROFILE_AUTHORITY") %}
    <li><a href='{{_build_auth_code_flow(authority=config["B2C_PROFILE_AUTHORITY"])["auth_uri"]}}'>Edit Profile</a></li>
    {% endif %}
    
    <li><a href="/logout">Logout</a></li>
    
    {% else %}
    <li><a href='{{ auth_url }}'>Sign In</a></li>
    {% endif %}
    
    {% endblock %}
    
  • graph.html:演示如何调用 REST API。

    {% extends "base.html" %}
    {% block title %}Graph API{% endblock %}
    {% block content %}
    <a href="javascript:window.history.go(-1)">Back</a>
    <!-- Displayed on top of a potentially large JSON response, so it will remain visible -->
    <h1>Graph API Call Result</h1>
    <pre>{{ result |tojson(indent=4) }}</pre> <!-- Just a generic json viewer -->
    {% endblock %}
    
  • auth_error.html:处理身份验证错误。

    {% extends "base.html" %}
    {% block title%}Error{% endblock%}
    
    {% block metadata %}
    {% if config.get("B2C_RESET_PASSWORD_AUTHORITY") and "AADB2C90118" in result.get("error_description") %}
    <!-- See also https://learn.microsoft.com/azure/active-directory-b2c/active-directory-b2c-reference-policies#linking-user-flows -->
    <meta http-equiv="refresh"
      content='0;{{_build_auth_code_flow(authority=config["B2C_RESET_PASSWORD_AUTHORITY"])["auth_uri"]}}'>
    {% endif %}
    {% endblock %}
    
    {% block content %}
    <h2>Login Failure</h2>
    <dl>
      <dt>{{ result.get("error") }}</dt>
      <dd>{{ result.get("error_description") }}</dd>
    </dl>
    
    <a href="{{ url_for('index') }}">Homepage</a>
    {% endblock %}
    

步骤 4:配置 Web 应用

在 Web 应用的根文件夹中,创建名为 app_config.py 的文件。 此文件包含有关 Azure AD B2C 标识提供者的信息。 Web 应用使用此信息与 Azure AD B2C 建立信任关系、允许用户登录和注销、获取令牌和验证令牌。 将以下内容添加到该文件中:

import os

b2c_tenant = "fabrikamb2c"
signupsignin_user_flow = "B2C_1_signupsignin1"
editprofile_user_flow = "B2C_1_profileediting1"

resetpassword_user_flow = "B2C_1_passwordreset1"  # Note: Legacy setting.

authority_template = "https://{tenant}.b2clogin.cn/{tenant}.partner.onmschina.cn/{user_flow}"

CLIENT_ID = "Enter_the_Application_Id_here" # Application (client) ID of app registration

CLIENT_SECRET = "Enter_the_Client_Secret_Here" # Application secret.

AUTHORITY = authority_template.format(
    tenant=b2c_tenant, user_flow=signupsignin_user_flow)
B2C_PROFILE_AUTHORITY = authority_template.format(
    tenant=b2c_tenant, user_flow=editprofile_user_flow)

B2C_RESET_PASSWORD_AUTHORITY = authority_template.format(
    tenant=b2c_tenant, user_flow=resetpassword_user_flow)

REDIRECT_PATH = "/getAToken"

# This is the API resource endpoint
ENDPOINT = '' # Application ID URI of app registration in Azure portal

# These are the scopes you've exposed in the web API app registration in the Azure portal
SCOPE = []  # Example with two exposed scopes: ["demo.read", "demo.write"]

SESSION_TYPE = "filesystem"  # Specifies the token cache should be stored in server-side session

按照在示例 Python Web 应用中配置身份验证一文的配置示例 Web 应用部分中所述,使用 Azure AD B2C 环境设置更新上面的代码。

步骤 5:添加 Web 应用代码

在本部分,你将添加 Flask 视图函数和 MSAL 库身份验证方法。 在项目的根文件夹下添加名为 app.py 的文件,其中包含以下代码:

import uuid
import requests
from flask import Flask, render_template, session, request, redirect, url_for
from flask_session import Session  # https://pythonhosted.org/Flask-Session
import msal
import app_config


app = Flask(__name__)
app.config.from_object(app_config)
Session(app)

# This section is needed for url_for("foo", _external=True) to automatically
# generate http scheme when this sample is running on localhost,
# and to generate https scheme when it is deployed behind reversed proxy.
# See also https://flask.palletsprojects.com/en/1.0.x/deploying/wsgi-standalone/#proxy-setups
from werkzeug.middleware.proxy_fix import ProxyFix
app.wsgi_app = ProxyFix(app.wsgi_app, x_proto=1, x_host=1)


@app.route("/anonymous")
def anonymous():
    return "anonymous page"

@app.route("/")
def index():
    #if not session.get("user"):
    #    return redirect(url_for("login"))

    if not session.get("user"):
        session["flow"] = _build_auth_code_flow(scopes=app_config.SCOPE)
        return render_template('index.html', auth_url=session["flow"]["auth_uri"], version=msal.__version__)
    else:
        return render_template('index.html', user=session["user"], version=msal.__version__)

@app.route("/login")
def login():
    # Technically we could use empty list [] as scopes to do just sign in,
    # here we choose to also collect end user consent upfront
    session["flow"] = _build_auth_code_flow(scopes=app_config.SCOPE)
    return render_template("login.html", auth_url=session["flow"]["auth_uri"], version=msal.__version__)

@app.route(app_config.REDIRECT_PATH)  # Its absolute URL must match your app's redirect_uri set in AAD
def authorized():
    try:
        cache = _load_cache()
        result = _build_msal_app(cache=cache).acquire_token_by_auth_code_flow(
            session.get("flow", {}), request.args)
        if "error" in result:
            return render_template("auth_error.html", result=result)
        session["user"] = result.get("id_token_claims")
        _save_cache(cache)
    except ValueError:  # Usually caused by CSRF
        pass  # Simply ignore them
    return redirect(url_for("index"))

@app.route("/logout")
def logout():
    session.clear()  # Wipe out user and its token cache from session
    return redirect(  # Also logout from your tenant's web session
        app_config.AUTHORITY + "/oauth2/v2.0/logout" +
        "?post_logout_redirect_uri=" + url_for("index", _external=True))

@app.route("/graphcall")
def graphcall():
    token = _get_token_from_cache(app_config.SCOPE)
    if not token:
        return redirect(url_for("login"))
    graph_data = requests.get(  # Use token to call downstream service
        app_config.ENDPOINT,
        headers={'Authorization': 'Bearer ' + token['access_token']},
        ).json()
    return render_template('graph.html', result=graph_data)


def _load_cache():
    cache = msal.SerializableTokenCache()
    if session.get("token_cache"):
        cache.deserialize(session["token_cache"])
    return cache

def _save_cache(cache):
    if cache.has_state_changed:
        session["token_cache"] = cache.serialize()

def _build_msal_app(cache=None, authority=None):
    return msal.ConfidentialClientApplication(
        app_config.CLIENT_ID, authority=authority or app_config.AUTHORITY,
        client_credential=app_config.CLIENT_SECRET, token_cache=cache)

def _build_auth_code_flow(authority=None, scopes=None):
    return _build_msal_app(authority=authority).initiate_auth_code_flow(
        scopes or [],
        redirect_uri=url_for("authorized", _external=True))

def _get_token_from_cache(scope=None):
    cache = _load_cache()  # This web app maintains one cache per session
    cca = _build_msal_app(cache=cache)
    accounts = cca.get_accounts()
    if accounts:  # So all account(s) belong to the current signed-in user
        result = cca.acquire_token_silent(scope, account=accounts[0])
        _save_cache(cache)
        return result

app.jinja_env.globals.update(_build_auth_code_flow=_build_auth_code_flow)  # Used in template

if __name__ == "__main__":
    app.run()

步骤 6:运行 Web 应用

在终端中,通过输入以下命令来运行应用,这会运行 Flask 开发服务器。 默认情况下,开发服务器会查找 app.py。 然后,打开浏览器并导航到 Web 应用 URL:http://localhost:5000

python -m flask run --host localhost --port 5000

[可选] 调试应用

使用调试功能可以在特定的代码行上暂停正在运行的程序。 暂停程序时,可以检查变量、在“调试控制台”面板中运行代码,或者利用调试中所述的功能。 若要使用 Visual Studio Code 调试器,请查看 VS Code 文档

若要更改主机名和/或端口号,请使用 launch.json 文件的 args 数组。 以下示例演示如何将主机名配置为 localhost 并将端口号配置为 5001。 请注意,如果更改主机名或端口号,则必须更新重定向 URI 或应用程序。 有关详细信息,请查看注册 Web 应用程序步骤。

{
    // Use IntelliSense to learn about possible attributes.
    // Hover to view descriptions of existing attributes.
    // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
    "version": "0.2.0",
    "configurations": [
        {
            "name": "Python: Flask",
            "type": "python",
            "request": "launch",
            "module": "flask",
            "env": {
                "FLASK_APP": "app.py",
                "FLASK_ENV": "development"
            },
            "args": [
                "run",
                "--host=localhost",
                "--port=5001"
            ],
            "jinja": true,
            "justMyCode": true
        }
    ]
}