分享方式:


教學課程:在 Node.js 和 Express Web 應用程式中登入使用者並取得 Microsoft Graph 的安全性權杖

在本教學課程中,您會組建 Web 應用程式來登入使用者,並取得用來呼叫 Microsoft Graph 的存取權杖。 您所建置的 Web 應用程式會使用適用於 Node 的 Microsoft 驗證程式庫 (MSAL)

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

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

如需詳細資訊,請參閱範例程式碼,其中示範如何使用 MSAL Node 登入、登出和取得受保護資源 (例如 Microsoft Graph) 的存取權杖。

必要條件

註冊應用程式

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

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

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

建立專案

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

  1. 首先,請安裝 Express 產生器套件:
    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 Node 套件。

    npm install --save @azure/msal-node

安裝其他相依性

本教學課程中的 Web 應用程式範例會使用 Express-session 套件進行工作階段管理、使用 dotenv 套件用於在開發期間讀取環境參數,以及使用 axios 對 Microsoft 圖形 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/ (包含尾端的正斜線)。
  • 用來簽署 Express 工作階段 Cookie 的密碼 Enter_the_Express_Session_Secret_Here。 選擇隨機字元字串來取代此字串,例如您的用戶端密碼。
  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 檔案中,註冊您稍早建立的路由,並使用 express-session 套件新增用於追蹤驗證狀態的工作階段支援。 使用下列程式碼片段取代該處現有的程式碼:

/*
 * 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 應用程式歡迎頁面顯示

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

顯示 Microsoft Entra 登入畫面

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

顯示 Microsoft Entra 同意畫面

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

登入後的 Web 應用程式歡迎頁面顯示

  1. 選取 [檢視識別碼權杖] 連結,以顯示已登入使用者識別碼權杖的內容。

使用者識別碼權杖畫面顯示

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

Graph 呼叫畫面顯示

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

顯示 Microsoft Entra 登出畫面

應用程式的運作方式

在本教學課程中,您已具現化 MSAL Node ConfidentialClientApplication 物件,方式是對其傳遞設定物件 (msalConfig),其中包含在 Azure 入口網站上從您的 Microsoft Entra 應用程式註冊中取得的參數。 您所建立的 Web 應用程式會使用 OpenID Connect 通訊協定來登入使用者,並使用 OAuth 2.0 授權碼流程取得存取權杖。

下一步

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