教學課程:在 Node.js 和 Express Web 應用程式中登入使用者並取得 Microsoft Graph 的令牌

在本教學課程中,您會建置 Web 應用程式來登入使用者,並取得呼叫 Microsoft Graph 的存取令牌。 您建置的 Web 應用程式會使用適用於 NodeMicrosoft 驗證連結庫 (MSAL)。

請遵循本教學課程中的步驟來:

  • 在 Azure 入口網站 註冊應用程式
  • 建立 Express Web 應用程式專案
  • 安裝驗證連結庫套件
  • 新增應用程式註冊詳細數據
  • 新增使用者登入的程序代碼
  • 測試應用程式

如需詳細資訊,請參閱 示範如何使用 MSAL Node 登入、註銷及取得受保護資源的存取令牌範例程式代碼 ,例如 Microsoft Graph。

必要條件

註冊應用程式

首先,完成向 Microsoft 身分識別平台 註冊應用程式中的步驟,以註冊您的應用程式。

針對您的應用程式註冊使用下列設定:

  • 名稱: ExpressWebApp (建議)
  • 支援的帳戶類型: 僅限此組織目錄中的帳戶
  • 平台類型: Web
  • 重新導向 URI: http://localhost:3000/auth/redirect
  • 用戶端密碼: ********* (記錄此值以供後續步驟使用 - 只會顯示一次)

建立專案

使用 Express 應用程式產生器工具來建立應用程式基本架構。

  1. 首先,安裝 express-generator 套件:
    npm install -g express-generator
  1. 然後,建立應用程式基本架構,如下所示:
    express --view=hbs /ExpressWebApp && cd /ExpressWebApp
    npm install

您現在有簡單的 Express Web 應用程式。 項目的檔案和資料夾結構看起來應該類似下列資料夾結構:

ExpressWebApp/
├── bin/
|    └── wwww
├── public/
|    ├── images/
|    ├── javascript/
|    └── stylesheets/
|        └── style.css
├── routes/
|    ├── index.js
|    └── users.js
├── views/
|    ├── error.hbs
|    ├── index.hbs
|    └── layout.hbs
├── app.js
└── package.json

安裝驗證連結庫

在終端機中找出項目目錄的根目錄,並透過 npm 安裝 MSAL 節點套件。

    npm install --save @azure/msal-node

安裝其他相依性

本教學課程中的 Web 應用程式範例會 使用 Express-session 套件進行會話 管理、 dotenv 套件用於在開發期間讀取環境參數,以及 axios 對 Microsoft Graph API 進行網路呼叫。 透過 npm 安裝這些專案:

    npm install --save express-session dotenv axios

新增應用程式註冊詳細數據

  1. 在項目資料夾的根目錄中建立 .env.dev 檔案。 然後,新增下列程式碼:
CLOUD_INSTANCE="Enter_the_Cloud_Instance_Id_Here" # cloud instance string should end with a trailing slash
TENANT_ID="Enter_the_Tenant_Info_Here"
CLIENT_ID="Enter_the_Application_Id_Here"
CLIENT_SECRET="Enter_the_Client_Secret_Here"

REDIRECT_URI="http://localhost:3000/auth/redirect"
POST_LOGOUT_REDIRECT_URI="http://localhost:3000"

GRAPH_API_ENDPOINT="Enter_the_Graph_Endpoint_Here" # graph api endpoint string should end with a trailing slash

EXPRESS_SESSION_SECRET="Enter_the_Express_Session_Secret_Here"

以您從 Azure 應用程式註冊入口網站取得的值填入這些詳細資料:

  • Enter_the_Cloud_Instance_Id_Here:註冊應用程式的 Azure 雲端實例。
    • 針對主要(或 全域)Azure 雲端,輸入 https://login.microsoftonline.com/ (包括尾端正斜線)。
    • 針對國家雲端(例如中國),您可以在國家雲端中找到適當的值。
  • Enter_the_Tenant_Info_here 應該是下列其中一個參數:
    • 如果您的應用程式支援 此組織目錄中的帳戶,請將此值取代為 租用戶標識碼租用戶名稱。 例如: contoso.microsoft.com
    • 如果您的應用程式支援 任何組織目錄中的帳戶,請將此值取代為 organizations
    • 如果您的應用程式支援 任何組織目錄和個人 Microsoft 帳戶中的帳戶,請將此值取代為 common
    • 若要限制僅對個人 Microsoft 帳戶的支援,請將此值取代為 consumers
  • Enter_the_Application_Id_Here:您註冊之應用程式的應用程式(用戶端)標識碼
  • Enter_the_Client_secret:將此值取代為您稍早建立的客戶端密碼。 若要產生新的金鑰,請在 Azure 入口網站 的應用程式註冊設定中使用憑證和秘密

警告

原始程式碼中的任何純文字秘密都會造成更高的安全性風險。 本文僅使用純文本客戶端密碼來簡化。 在 機密用戶端應用程式中使用憑證認證 ,而不是客戶端密碼,特別是您想要部署到生產環境的應用程式。

  • Enter_the_Graph_Endpoint_Here:您的應用程式將呼叫的 Microsoft Graph API 雲端實例。 針對主要 (全域) Microsoft Graph API 服務,輸入 https://graph.microsoft.com/ (包括尾端正斜線)。
  • Enter_the_Express_Session_Secret_Here 用來簽署 Express 會話 Cookie 的秘密。 選擇隨機字元字串,以取代此字串,例如您的客戶端密碼。
  1. 接下來,在專案的根目錄中建立名為 authConfig.js 的檔案,以讀取這些參數。 建立之後,請在該處新增下列程序代碼:
/*
 * Copyright (c) Microsoft Corporation. All rights reserved.
 * Licensed under the MIT License.
 */

require('dotenv').config({ path: '.env.dev' });

/**
 * Configuration object to be passed to MSAL instance on creation.
 * For a full list of MSAL Node configuration parameters, visit:
 * https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/lib/msal-node/docs/configuration.md
 */
const msalConfig = {
    auth: {
        clientId: process.env.CLIENT_ID, // 'Application (client) ID' of app registration in Azure portal - this value is a GUID
        authority: process.env.CLOUD_INSTANCE + process.env.TENANT_ID, // Full directory URL, in the form of https://login.microsoftonline.com/<tenant>
        clientSecret: process.env.CLIENT_SECRET // Client secret generated from the app registration in Azure portal
    },
    system: {
        loggerOptions: {
            loggerCallback(loglevel, message, containsPii) {
                console.log(message);
            },
            piiLoggingEnabled: false,
            logLevel: 3,
        }
    }
}

const REDIRECT_URI = process.env.REDIRECT_URI;
const POST_LOGOUT_REDIRECT_URI = process.env.POST_LOGOUT_REDIRECT_URI;
const GRAPH_ME_ENDPOINT = process.env.GRAPH_API_ENDPOINT + "v1.0/me";

module.exports = {
    msalConfig,
    REDIRECT_URI,
    POST_LOGOUT_REDIRECT_URI,
    GRAPH_ME_ENDPOINT
};

新增使用者登入和令牌擷取的程序代碼

  1. 建立名為 auth 的新資料夾,並在其中新增名為 AuthProvider.js 的新檔案。 這將包含 AuthProvider 類別,它會使用 MSAL 節點封裝必要的驗證邏輯。 在那裡新增下列程式代碼:
const msal = require('@azure/msal-node');
const axios = require('axios');

const { msalConfig } = require('../authConfig');

class AuthProvider {
    msalConfig;
    cryptoProvider;

    constructor(msalConfig) {
        this.msalConfig = msalConfig
        this.cryptoProvider = new msal.CryptoProvider();
    };

    login(options = {}) {
        return async (req, res, next) => {

            /**
             * MSAL Node library allows you to pass your custom state as state parameter in the Request object.
             * The state parameter can also be used to encode information of the app's state before redirect.
             * You can pass the user's state in the app, such as the page or view they were on, as input to this parameter.
             */
            const state = this.cryptoProvider.base64Encode(
                JSON.stringify({
                    successRedirect: options.successRedirect || '/',
                })
            );

            const authCodeUrlRequestParams = {
                state: state,

                /**
                 * By default, MSAL Node will add OIDC scopes to the auth code url request. For more information, visit:
                 * https://docs.microsoft.com/azure/active-directory/develop/v2-permissions-and-consent#openid-connect-scopes
                 */
                scopes: options.scopes || [],
                redirectUri: options.redirectUri,
            };

            const authCodeRequestParams = {
                state: state,

                /**
                 * By default, MSAL Node will add OIDC scopes to the auth code request. For more information, visit:
                 * https://docs.microsoft.com/azure/active-directory/develop/v2-permissions-and-consent#openid-connect-scopes
                 */
                scopes: options.scopes || [],
                redirectUri: options.redirectUri,
            };

            /**
             * If the current msal configuration does not have cloudDiscoveryMetadata or authorityMetadata, we will 
             * make a request to the relevant endpoints to retrieve the metadata. This allows MSAL to avoid making 
             * metadata discovery calls, thereby improving performance of token acquisition process. For more, see:
             * https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/lib/msal-node/docs/performance.md
             */
            if (!this.msalConfig.auth.cloudDiscoveryMetadata || !this.msalConfig.auth.authorityMetadata) {

                const [cloudDiscoveryMetadata, authorityMetadata] = await Promise.all([
                    this.getCloudDiscoveryMetadata(this.msalConfig.auth.authority),
                    this.getAuthorityMetadata(this.msalConfig.auth.authority)
                ]);

                this.msalConfig.auth.cloudDiscoveryMetadata = JSON.stringify(cloudDiscoveryMetadata);
                this.msalConfig.auth.authorityMetadata = JSON.stringify(authorityMetadata);
            }

            const msalInstance = this.getMsalInstance(this.msalConfig);

            // trigger the first leg of auth code flow
            return this.redirectToAuthCodeUrl(
                authCodeUrlRequestParams,
                authCodeRequestParams,
                msalInstance
            )(req, res, next);
        };
    }

    acquireToken(options = {}) {
        return async (req, res, next) => {
            try {
                const msalInstance = this.getMsalInstance(this.msalConfig);

                /**
                 * If a token cache exists in the session, deserialize it and set it as the 
                 * cache for the new MSAL CCA instance. For more, see: 
                 * https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/lib/msal-node/docs/caching.md
                 */
                if (req.session.tokenCache) {
                    msalInstance.getTokenCache().deserialize(req.session.tokenCache);
                }

                const tokenResponse = await msalInstance.acquireTokenSilent({
                    account: req.session.account,
                    scopes: options.scopes || [],
                });

                /**
                 * On successful token acquisition, write the updated token 
                 * cache back to the session. For more, see: 
                 * https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/lib/msal-node/docs/caching.md
                 */
                req.session.tokenCache = msalInstance.getTokenCache().serialize();
                req.session.accessToken = tokenResponse.accessToken;
                req.session.idToken = tokenResponse.idToken;
                req.session.account = tokenResponse.account;

                res.redirect(options.successRedirect);
            } catch (error) {
                if (error instanceof msal.InteractionRequiredAuthError) {
                    return this.login({
                        scopes: options.scopes || [],
                        redirectUri: options.redirectUri,
                        successRedirect: options.successRedirect || '/',
                    })(req, res, next);
                }

                next(error);
            }
        };
    }

    handleRedirect(options = {}) {
        return async (req, res, next) => {
            if (!req.body || !req.body.state) {
                return next(new Error('Error: response not found'));
            }

            const authCodeRequest = {
                ...req.session.authCodeRequest,
                code: req.body.code,
                codeVerifier: req.session.pkceCodes.verifier,
            };

            try {
                const msalInstance = this.getMsalInstance(this.msalConfig);

                if (req.session.tokenCache) {
                    msalInstance.getTokenCache().deserialize(req.session.tokenCache);
                }

                const tokenResponse = await msalInstance.acquireTokenByCode(authCodeRequest, req.body);

                req.session.tokenCache = msalInstance.getTokenCache().serialize();
                req.session.idToken = tokenResponse.idToken;
                req.session.account = tokenResponse.account;
                req.session.isAuthenticated = true;

                const state = JSON.parse(this.cryptoProvider.base64Decode(req.body.state));
                res.redirect(state.successRedirect);
            } catch (error) {
                next(error);
            }
        }
    }

    logout(options = {}) {
        return (req, res, next) => {

            /**
             * Construct a logout URI and redirect the user to end the
             * session with Azure AD. For more information, visit:
             * https://docs.microsoft.com/azure/active-directory/develop/v2-protocols-oidc#send-a-sign-out-request
             */
            let logoutUri = `${this.msalConfig.auth.authority}/oauth2/v2.0/`;

            if (options.postLogoutRedirectUri) {
                logoutUri += `logout?post_logout_redirect_uri=${options.postLogoutRedirectUri}`;
            }

            req.session.destroy(() => {
                res.redirect(logoutUri);
            });
        }
    }

    /**
     * Instantiates a new MSAL ConfidentialClientApplication object
     * @param msalConfig: MSAL Node Configuration object 
     * @returns 
     */
    getMsalInstance(msalConfig) {
        return new msal.ConfidentialClientApplication(msalConfig);
    }


    /**
     * Prepares the auth code request parameters and initiates the first leg of auth code flow
     * @param req: Express request object
     * @param res: Express response object
     * @param next: Express next function
     * @param authCodeUrlRequestParams: parameters for requesting an auth code url
     * @param authCodeRequestParams: parameters for requesting tokens using auth code
     */
    redirectToAuthCodeUrl(authCodeUrlRequestParams, authCodeRequestParams, msalInstance) {
        return async (req, res, next) => {
            // Generate PKCE Codes before starting the authorization flow
            const { verifier, challenge } = await this.cryptoProvider.generatePkceCodes();

            // Set generated PKCE codes and method as session vars
            req.session.pkceCodes = {
                challengeMethod: 'S256',
                verifier: verifier,
                challenge: challenge,
            };

            /**
             * By manipulating the request objects below before each request, we can obtain
             * auth artifacts with desired claims. For more information, visit:
             * https://azuread.github.io/microsoft-authentication-library-for-js/ref/modules/_azure_msal_node.html#authorizationurlrequest
             * https://azuread.github.io/microsoft-authentication-library-for-js/ref/modules/_azure_msal_node.html#authorizationcoderequest
             **/
            req.session.authCodeUrlRequest = {
                ...authCodeUrlRequestParams,
                responseMode: msal.ResponseMode.FORM_POST, // recommended for confidential clients
                codeChallenge: req.session.pkceCodes.challenge,
                codeChallengeMethod: req.session.pkceCodes.challengeMethod,
            };

            req.session.authCodeRequest = {
                ...authCodeRequestParams,
                code: '',
            };

            try {
                const authCodeUrlResponse = await msalInstance.getAuthCodeUrl(req.session.authCodeUrlRequest);
                res.redirect(authCodeUrlResponse);
            } catch (error) {
                next(error);
            }
        };
    }

    /**
     * Retrieves cloud discovery metadata from the /discovery/instance endpoint
     * @returns 
     */
    async getCloudDiscoveryMetadata(authority) {
        const endpoint = 'https://login.microsoftonline.com/common/discovery/instance';

        try {
            const response = await axios.get(endpoint, {
                params: {
                    'api-version': '1.1',
                    'authorization_endpoint': `${authority}/oauth2/v2.0/authorize`
                }
            });

            return await response.data;
        } catch (error) {
            throw error;
        }
    }

    /**
     * Retrieves oidc metadata from the openid endpoint
     * @returns
     */
    async getAuthorityMetadata(authority) {
        const endpoint = `${authority}/v2.0/.well-known/openid-configuration`;

        try {
            const response = await axios.get(endpoint);
            return await response.data;
        } catch (error) {
            console.log(error);
        }
    }
}

const authProvider = new AuthProvider(msalConfig);

module.exports = authProvider;
  1. 接下來,在 routes 資料夾下建立名為 auth.js 的新檔案,並在該處新增下列程式代碼:
/*
 * Copyright (c) Microsoft Corporation. All rights reserved.
 * Licensed under the MIT License.
 */

var express = require('express');

const authProvider = require('../auth/AuthProvider');
const { REDIRECT_URI, POST_LOGOUT_REDIRECT_URI } = require('../authConfig');

const router = express.Router();

router.get('/signin', authProvider.login({
    scopes: [],
    redirectUri: REDIRECT_URI,
    successRedirect: '/'
}));

router.get('/acquireToken', authProvider.acquireToken({
    scopes: ['User.Read'],
    redirectUri: REDIRECT_URI,
    successRedirect: '/users/profile'
}));

router.post('/redirect', authProvider.handleRedirect());

router.get('/signout', authProvider.logout({
    postLogoutRedirectUri: POST_LOGOUT_REDIRECT_URI
}));

module.exports = router;
  1. 將現有的程式代碼取代為下列代碼段,以更新index.js路由:
/*
 * Copyright (c) Microsoft Corporation. All rights reserved.
 * Licensed under the MIT License.
 */

var express = require('express');
var router = express.Router();

router.get('/', function (req, res, next) {
    res.render('index', {
        title: 'MSAL Node & Express Web App',
        isAuthenticated: req.session.isAuthenticated,
        username: req.session.account?.username,
    });
});

module.exports = router;
  1. 最後,使用下列代碼段取代現有的程式代碼,以更新 users.js 路由:
/*
 * Copyright (c) Microsoft Corporation. All rights reserved.
 * Licensed under the MIT License.
 */

var express = require('express');
var router = express.Router();

var fetch = require('../fetch');

var { GRAPH_ME_ENDPOINT } = require('../authConfig');

// custom middleware to check auth state
function isAuthenticated(req, res, next) {
    if (!req.session.isAuthenticated) {
        return res.redirect('/auth/signin'); // redirect to sign-in route
    }

    next();
};

router.get('/id',
    isAuthenticated, // check if user is authenticated
    async function (req, res, next) {
        res.render('id', { idTokenClaims: req.session.account.idTokenClaims });
    }
);

router.get('/profile',
    isAuthenticated, // check if user is authenticated
    async function (req, res, next) {
        try {
            const graphResponse = await fetch(GRAPH_ME_ENDPOINT, req.session.accessToken);
            res.render('profile', { profile: graphResponse });
        } catch (error) {
            next(error);
        }
    }
);

module.exports = router;

新增程式代碼以呼叫 Microsoft Graph API

在專案的根目錄中建立名為 fetch.js 的檔案,並新增下列程式代碼:

/*
 * Copyright (c) Microsoft Corporation. All rights reserved.
 * Licensed under the MIT License.
 */

var axios = require('axios');

/**
 * Attaches a given access token to a MS Graph API call
 * @param endpoint: REST API endpoint to call
 * @param accessToken: raw access token string
 */
async function fetch(endpoint, accessToken) {
    const options = {
        headers: {
            Authorization: `Bearer ${accessToken}`
        }
    };

    console.log(`request made to ${endpoint} at: ` + new Date().toString());

    try {
        const response = await axios.get(endpoint, options);
        return await response.data;
    } catch (error) {
        throw new Error(error);
    }
}

module.exports = fetch;

新增用於顯示數據的檢視

  1. views 資料夾中,將現有的程式代碼取代為下列程式代碼,以更新 index.hbs 檔案:
<h1>{{title}}</h1>
{{#if isAuthenticated }}
<p>Hi {{username}}!</p>
<a href="/users/id">View ID token claims</a>
<br>
<a href="/auth/acquireToken">Acquire a token to call the Microsoft Graph API</a>
<br>
<a href="/auth/signout">Sign out</a>
{{else}}
<p>Welcome to {{title}}</p>
<a href="/auth/signin">Sign in</a>
{{/if}}
  1. 仍在相同的資料夾中,建立另一個名為id.hbs的檔案,以顯示使用者標識元令牌的內容:
<h1>Azure AD</h1>
<h3>ID Token</h3>
<table>
    <tbody>
        {{#each idTokenClaims}}
        <tr>
            <td>{{@key}}</td>
            <td>{{this}}</td>
        </tr>
        {{/each}}
    </tbody>
</table>
<br>
<a href="https://aka.ms/id-tokens" target="_blank">Learn about claims in this ID token</a>
<br>
<a href="/">Go back</a>
  1. 最後,建立另一個名為 profile.hbs 的檔案,以顯示對 Microsoft Graph 呼叫的結果:
<h1>Microsoft Graph API</h1>
<h3>/me endpoint response</h3>
<table>
    <tbody>
        {{#each profile}}
        <tr>
            <td>{{@key}}</td>
            <td>{{this}}</td>
        </tr>
        {{/each}}
    </tbody>
</table>
<br>
<a href="/">Go back</a>

註冊路由器並新增狀態管理

專案資料夾根目錄中的app.js 檔案中,註冊您稍早建立的路由,並使用快速會話套件新增追蹤驗證狀態的 會話 支援。 將現有的程式代碼取代為下列代碼段:

/*
 * Copyright (c) Microsoft Corporation. All rights reserved.
 * Licensed under the MIT License.
 */

require('dotenv').config();

var path = require('path');
var express = require('express');
var session = require('express-session');
var createError = require('http-errors');
var cookieParser = require('cookie-parser');
var logger = require('morgan');

var indexRouter = require('./routes/index');
var usersRouter = require('./routes/users');
var authRouter = require('./routes/auth');

// initialize express
var app = express();

/**
 * Using express-session middleware for persistent user session. Be sure to
 * familiarize yourself with available options. Visit: https://www.npmjs.com/package/express-session
 */
 app.use(session({
    secret: process.env.EXPRESS_SESSION_SECRET,
    resave: false,
    saveUninitialized: false,
    cookie: {
        httpOnly: true,
        secure: false, // set this to true on production
    }
}));

// view engine setup
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'hbs');

app.use(logger('dev'));
app.use(express.json());
app.use(cookieParser());
app.use(express.urlencoded({ extended: false }));
app.use(express.static(path.join(__dirname, 'public')));

app.use('/', indexRouter);
app.use('/users', usersRouter);
app.use('/auth', authRouter);

// catch 404 and forward to error handler
app.use(function (req, res, next) {
    next(createError(404));
});

// error handler
app.use(function (err, req, res, next) {
    // set locals, only providing error in development
    res.locals.message = err.message;
    res.locals.error = req.app.get('env') === 'development' ? err : {};

    // render the error page
    res.status(err.status || 500);
    res.render('error');
});

module.exports = app;

測試登入並呼叫 Microsoft Graph

您已完成應用程式的建立,現在已準備好測試應用程式的功能。

  1. 從項目資料夾的根目錄中執行下列命令,以啟動Node.js控制台應用程式:
   npm start
  1. 開啟瀏覽器視窗並瀏覽至 http://localhost:3000。 您應該會看到歡迎頁面:

Web app welcome page displaying

  1. 選取 [ 登入 ] 連結。 您應該會看到 Microsoft Entra 登入畫面:

Microsoft Entra sign-in screen displaying

  1. 輸入認證之後,您應該會看到同意畫面,要求您核准應用程式的許可權。

Microsoft Entra consent screen displaying

  1. 同意之後,您應該重新導向回應用程式首頁。

Web app welcome page after sign-in displaying

  1. 選取 [ 檢視標識元令牌 ] 連結,以顯示已登入使用者的標識碼令牌內容。

User ID token screen displaying

  1. 返回首頁,然後選取 [取得存取令牌] 並呼叫 Microsoft Graph API 連結。 完成後,您應該會看到來自已登入使用者的 Microsoft Graph /me 端點回應。

Graph call screen displaying

  1. 返回首頁,然後選取 [註銷 ] 連結。 您應該會看到 Microsoft Entra 註銷畫面。

Microsoft Entra sign-out screen displaying

應用程式的運作方式

在本教學課程中,您會將 MSAL Node ConfidentialClientApplication 物件具現化,方法是傳遞組態物件 (msalConfig),其中包含在 Azure 入口網站 上從 Microsoft Entra 應用程式註冊取得的參數。 您建立的 Web 應用程式會使用 OpenID 連線 通訊協定來登入使用者和 OAuth 2.0 授權碼流程,以取得存取令牌。

下一步

如果您想要深入瞭解 Microsoft 身分識別平台 上的 Node.js 和 Express Web 應用程式開發,請參閱我們的多部分案例系列: