分享方式:


教學課程:在 Electron 桌面應用程式中登入使用者並呼叫 Microsoft Graph API

在本教學課程中,您會建置 Electron 桌面應用程式,以使用 PKCE 的授權碼流程登入使用者並呼叫 Microsoft Graph。 您所建置的桌面應用程式會使用適用於 Node.js 的 Microsoft 驗證程式庫 (MSAL)

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

  • 在 Azure 入口網站中註冊應用程式
  • 建立 Electron 桌面應用程式專案
  • 將驗證邏輯新增至應用程式中
  • 新增方法以呼叫 Web API
  • 新增應用程式註冊詳細資料
  • 測試應用程式

必要條件

註冊應用程式

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

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

  • 名稱:ElectronDesktopApp (建議)
  • 支援的帳戶類型:僅限我的組織目錄中的帳戶 (單一租用戶)
  • 平台類型:[行動應用程式與傳統型應用程式]
  • 重新導向 URI:http://localhost

建立專案

建立資料夾來裝載您的應用程式,例如 ElectronDesktopApp

  1. 首先,在終端機中變更至您的專案目錄,然後執行下列 npm 命令:

    npm init -y
    npm install --save @azure/msal-node @microsoft/microsoft-graph-client isomorphic-fetch bootstrap jquery popper.js
    npm install --save-dev electron@20.0.0
    
  2. 建立名為 App 的資料夾。 在此資料夾中,建立名為 index.html 的檔案做為 UI。 將下列程式碼新增至:

    <!DOCTYPE html>
    <html lang="en">
    
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no">
        <meta http-equiv="Content-Security-Policy" content="script-src 'self'" />
        <title>MSAL Node Electron Sample App</title>
    
        <!-- adding Bootstrap 4 for UI components  -->
        <link rel="stylesheet" href="../node_modules/bootstrap/dist/css/bootstrap.min.css">
    </head>
    
    <body>
        <nav class="navbar navbar-expand-lg navbar-dark bg-primary">
            <a class="navbar-brand">Microsoft identity platform</a>
            <div class="btn-group ml-auto dropleft">
                <button type="button" id="signIn" class="btn btn-secondary" aria-expanded="false">
                    Sign in
                </button>
                <button type="button" id="signOut" class="btn btn-success" hidden aria-expanded="false">
                    Sign out
                </button>
            </div>
        </nav>
        <br>
        <h5 class="card-header text-center">Electron sample app calling MS Graph API using MSAL Node</h5>
        <br>
        <div class="row" style="margin:auto">
            <div id="cardDiv" class="col-md-6" style="display:none; margin:auto">
                <div class="card text-center">
                    <div class="card-body">
                        <h5 class="card-title" id="WelcomeMessage">Please sign-in to see your profile and read your mails
                        </h5>
                        <div id="profileDiv"></div>
                        <br>
                        <br>
                        <button class="btn btn-primary" id="seeProfile">See Profile</button>
                    </div>
                </div>
            </div>
        </div>
    
        <!-- importing bootstrap.js and supporting js libraries -->
        <script src="../node_modules/jquery/dist/jquery.js"></script>
        <script src="../node_modules/popper.js/dist/umd/popper.js"></script>
        <script src="../node_modules/bootstrap/dist/js/bootstrap.js"></script>
    
        <!-- importing app scripts | load order is important -->
        <script src="./renderer.js"></script>
    
    </body>
    
    </html>
    
  3. 接下來,建立名為 main.js 的檔案,然後新增下列程式碼:

    /*
     * Copyright (c) Microsoft Corporation. All rights reserved.
     * Licensed under the MIT License.
     */
    
    const path = require("path");
    const { app, ipcMain, BrowserWindow } = require("electron");
    
    const AuthProvider = require("./AuthProvider");
    const { IPC_MESSAGES } = require("./constants");
    const { protectedResources, msalConfig } = require("./authConfig");
    const getGraphClient = require("./graph");
    
    let authProvider;
    let mainWindow;
    
    function createWindow() {
        mainWindow = new BrowserWindow({
            width: 800,
            height: 600,
            webPreferences: { preload: path.join(__dirname, "preload.js") },
        });
    
        authProvider = new AuthProvider(msalConfig);
    }
    
    app.on("ready", () => {
        createWindow();
        mainWindow.loadFile(path.join(__dirname, "./index.html"));
    });
    
    app.on("window-all-closed", () => {
        app.quit();
    });
    
    app.on('activate', () => {
        // On OS X it's common to re-create a window in the app when the
        // dock icon is clicked and there are no other windows open.
        if (BrowserWindow.getAllWindows().length === 0) {
            createWindow();
        }
    });
    
    
    // Event handlers
    ipcMain.on(IPC_MESSAGES.LOGIN, async () => {
        const account = await authProvider.login();
    
        await mainWindow.loadFile(path.join(__dirname, "./index.html"));
        
        mainWindow.webContents.send(IPC_MESSAGES.SHOW_WELCOME_MESSAGE, account);
    });
    
    ipcMain.on(IPC_MESSAGES.LOGOUT, async () => {
        await authProvider.logout();
    
        await mainWindow.loadFile(path.join(__dirname, "./index.html"));
    });
    
    ipcMain.on(IPC_MESSAGES.GET_PROFILE, async () => {
        const tokenRequest = {
            scopes: protectedResources.graphMe.scopes
        };
    
        const tokenResponse = await authProvider.getToken(tokenRequest);
        const account = authProvider.account;
    
        await mainWindow.loadFile(path.join(__dirname, "./index.html"));
    
        const graphResponse = await getGraphClient(tokenResponse.accessToken)
            .api(protectedResources.graphMe.endpoint).get();
    
        mainWindow.webContents.send(IPC_MESSAGES.SHOW_WELCOME_MESSAGE, account);
        mainWindow.webContents.send(IPC_MESSAGES.SET_PROFILE, graphResponse);
    });
    

在上述程式碼片段中,我們會初始化 Electron 主視窗物件,並建立一些事件處理常式,以便與 [Electron] 視窗進行互動。 我們也會匯入設定參數、具現化 authProvider 類別以處理登入、登出和權杖取得,以及呼叫 Microsoft Graph API。

  1. 在相同資料夾 (App) 中,建立另一個名為 renderer.js 的檔案,並新增下列程式碼:

    // Copyright (c) Microsoft Corporation. All rights reserved.
    // Licensed under the MIT License
    
    /**
     * The renderer API is exposed by the preload script found in the preload.ts
     * file in order to give the renderer access to the Node API in a secure and 
     * controlled way
     */
    const welcomeDiv = document.getElementById('WelcomeMessage');
    const signInButton = document.getElementById('signIn');
    const signOutButton = document.getElementById('signOut');
    const seeProfileButton = document.getElementById('seeProfile');
    const cardDiv = document.getElementById('cardDiv');
    const profileDiv = document.getElementById('profileDiv');
    
    window.renderer.showWelcomeMessage((event, account) => {
        if (!account) return;
    
        cardDiv.style.display = 'initial';
        welcomeDiv.innerHTML = `Welcome ${account.name}`;
        signInButton.hidden = true;
        signOutButton.hidden = false;
    });
    
    window.renderer.handleProfileData((event, graphResponse) => {
        if (!graphResponse) return;
    
        console.log(`Graph API responded at: ${new Date().toString()}`);
        setProfile(graphResponse);
    });
    
    // UI event handlers
    signInButton.addEventListener('click', () => {
        window.renderer.sendLoginMessage();
    });
    
    signOutButton.addEventListener('click', () => {
        window.renderer.sendSignoutMessage();
    });
    
    seeProfileButton.addEventListener('click', () => {
        window.renderer.sendSeeProfileMessage();
    });
    
    const setProfile = (data) => {
        if (!data) return;
        
        profileDiv.innerHTML = '';
    
        const title = document.createElement('p');
        const email = document.createElement('p');
        const phone = document.createElement('p');
        const address = document.createElement('p');
    
        title.innerHTML = '<strong>Title: </strong>' + data.jobTitle;
        email.innerHTML = '<strong>Mail: </strong>' + data.mail;
        phone.innerHTML = '<strong>Phone: </strong>' + data.businessPhones[0];
        address.innerHTML = '<strong>Location: </strong>' + data.officeLocation;
    
        profileDiv.appendChild(title);
        profileDiv.appendChild(email);
        profileDiv.appendChild(phone);
        profileDiv.appendChild(address);
    }
    

轉譯器方法會由在 preload.js 檔案中找到的預先載入指令碼公開,以安全且受控制的方式將 Node API 的存取權給予轉譯器

  1. 然後,建立新檔案 preload.js,並且新增下列程式碼:

    // Copyright (c) Microsoft Corporation. All rights reserved.
    // Licensed under the MIT License
    
    const { contextBridge, ipcRenderer } = require('electron');
    
    /**
     * This preload script exposes a "renderer" API to give
     * the Renderer process controlled access to some Node APIs
     * by leveraging IPC channels that have been configured for
     * communication between the Main and Renderer processes.
     */
    contextBridge.exposeInMainWorld('renderer', {
        sendLoginMessage: () => {
            ipcRenderer.send('LOGIN');
        },
        sendSignoutMessage: () => {
            ipcRenderer.send('LOGOUT');
        },
        sendSeeProfileMessage: () => {
            ipcRenderer.send('GET_PROFILE');
        },
        handleProfileData: (func) => {
            ipcRenderer.on('SET_PROFILE', (event, ...args) => func(event, ...args));
        },
        showWelcomeMessage: (func) => {
            ipcRenderer.on('SHOW_WELCOME_MESSAGE', (event, ...args) => func(event, ...args));
        },
    });
    

這個預先載入的指令碼會公開轉譯器 API,透過套用已針對主要和轉譯器處理序之間的通訊設定的 IPC 通道,為轉譯器處理序提供對某些 Node APIs 的控制存取。

  1. 最後,建立名為 constants.js 的檔案,該檔案會儲存字串常數以描述應用程式事件

    /*
     * Copyright (c) Microsoft Corporation. All rights reserved.
     * Licensed under the MIT License.
     */
    
    const IPC_MESSAGES = {
        SHOW_WELCOME_MESSAGE: 'SHOW_WELCOME_MESSAGE',
        LOGIN: 'LOGIN',
        LOGOUT: 'LOGOUT',
        GET_PROFILE: 'GET_PROFILE',
        SET_PROFILE: 'SET_PROFILE',
    }
    
    module.exports = {
        IPC_MESSAGES: IPC_MESSAGES,
    }
    

您現在已具有 Electron 應用程式的簡單 GUI 和互動。 完成本教學課程的其餘部分之後,您專案的檔案和資料夾結構看起來應該會如下所示:

ElectronDesktopApp/
├── App
│   ├── AuthProvider.js
│   ├── constants.js
│   ├── graph.js
│   ├── index.html
|   ├── main.js
|   ├── preload.js
|   ├── renderer.js
│   ├── authConfig.js
├── package.json

將驗證邏輯新增至應用程式中

App 資料夾中,建立名為 AuthProvider.js 的檔案。 AuthProvider.js 檔案會包含驗證提供者類別,其會使用 MSAL 節點來處理登入、登出、取得權杖、帳戶選取和相關的驗證工作。 將下列程式碼新增至:

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

const { PublicClientApplication, InteractionRequiredAuthError } = require('@azure/msal-node');
const { shell } = require('electron');

class AuthProvider {
    msalConfig
    clientApplication;
    account;
    cache;

    constructor(msalConfig) {
        /**
         * Initialize a public client application. For more information, visit:
         * https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/lib/msal-node/docs/initialize-public-client-application.md
         */
        this.msalConfig = msalConfig;
        this.clientApplication = new PublicClientApplication(this.msalConfig);
        this.cache = this.clientApplication.getTokenCache();
        this.account = null;
    }

    async login() {
        const authResponse = await this.getToken({
            // If there are scopes that you would like users to consent up front, add them below
            // by default, MSAL will add the OIDC scopes to every token request, so we omit those here
            scopes: [],
        });

        return this.handleResponse(authResponse);
    }

    async logout() {
        if (!this.account) return;

        try {
            /**
             * If you would like to end the session with AAD, use the logout endpoint. You'll need to enable
             * the optional token claim 'login_hint' for this to work as expected. For more information, visit:
             * https://learn.microsoft.com/azure/active-directory/develop/v2-protocols-oidc#send-a-sign-out-request
             */
            if (this.account.idTokenClaims.hasOwnProperty('login_hint')) {
                await shell.openExternal(`${this.msalConfig.auth.authority}/oauth2/v2.0/logout?logout_hint=${encodeURIComponent(this.account.idTokenClaims.login_hint)}`);
            }

            await this.cache.removeAccount(this.account);
            this.account = null;
        } catch (error) {
            console.log(error);
        }
    }

    async getToken(tokenRequest) {
        let authResponse;
        const account = this.account || (await this.getAccount());

        if (account) {
            tokenRequest.account = account;
            authResponse = await this.getTokenSilent(tokenRequest);
        } else {
            authResponse = await this.getTokenInteractive(tokenRequest);
        }

        return authResponse || null;
    }

    async getTokenSilent(tokenRequest) {
        try {
            return await this.clientApplication.acquireTokenSilent(tokenRequest);
        } catch (error) {
            if (error instanceof InteractionRequiredAuthError) {
                console.log('Silent token acquisition failed, acquiring token interactive');
                return await this.getTokenInteractive(tokenRequest);
            }

            console.log(error);
        }
    }

    async getTokenInteractive(tokenRequest) {
        try {
            const openBrowser = async (url) => {
                await shell.openExternal(url);
            };

            const authResponse = await this.clientApplication.acquireTokenInteractive({
                ...tokenRequest,
                openBrowser,
                successTemplate: '<h1>Successfully signed in!</h1> <p>You can close this window now.</p>',
                errorTemplate: '<h1>Oops! Something went wrong</h1> <p>Check the console for more information.</p>',
            });

            return authResponse;
        } catch (error) {
            throw error;
        }
    }

    /**
     * Handles the response from a popup or redirect. If response is null, will check if we have any accounts and attempt to sign in.
     * @param response
     */
    async handleResponse(response) {
        if (response !== null) {
            this.account = response.account;
        } else {
            this.account = await this.getAccount();
        }

        return this.account;
    }

    /**
     * Calls getAllAccounts and determines the correct account to sign into, currently defaults to first account found in cache.
     * https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/lib/msal-common/docs/Accounts.md
     */
    async getAccount() {
        const currentAccounts = await this.cache.getAllAccounts();

        if (!currentAccounts) {
            console.log('No accounts detected');
            return null;
        }

        if (currentAccounts.length > 1) {
            // Add choose account code here
            console.log('Multiple accounts detected, need to add choose account code.');
            return currentAccounts[0];
        } else if (currentAccounts.length === 1) {
            return currentAccounts[0];
        } else {
            return null;
        }
    }
}

module.exports = AuthProvider;

在上述程式碼片段中,我們先將設定物件 (msalConfig),藉以初始化 MSAL 節點 PublicClientApplication。 接著,我們會公開主要模組 (main.js) 所要呼叫的方法 loginlogout 以及 getToken。 在 logingetToken 中,使用 MSAL 節點 acquireTokenInteractive 公用 API 來取得識別碼和存取權杖。

新增 Microsoft Graph SDK

建立名為 graph.js 的檔案。 Graph.js 檔案將包含 Microsoft Graph SDK 用戶端的執行個體,使用 MSAL 節點所取得的存取權杖,來協助存取 Microsoft Graph API 上的資料:

const { Client } = require('@microsoft/microsoft-graph-client');
require('isomorphic-fetch');

/**
 * Creating a Graph client instance via options method. For more information, visit:
 * https://github.com/microsoftgraph/msgraph-sdk-javascript/blob/dev/docs/CreatingClientInstance.md#2-create-with-options
 * @param {String} accessToken
 * @returns
 */
const getGraphClient = (accessToken) => {
    // Initialize Graph client
    const graphClient = Client.init({
        // Use the provided access token to authenticate requests
        authProvider: (done) => {
            done(null, accessToken);
        },
    });

    return graphClient;
};

module.exports = getGraphClient;

新增應用程式註冊詳細資料

建立環境檔案,以儲存取得權杖時將使用的應用程式註冊詳細資料。 請使用下列步驟執行此作業,在範例 (ElectronDesktopApp) 的根資料夾內建立名為 authConfig.js 的檔案,並新增下列程式碼:

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

const { LogLevel } = require("@azure/msal-node");

/**
 * Configuration object to be passed to MSAL instance on creation.
 * For a full list of MSAL.js configuration parameters, visit:
 * https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/lib/msal-node/docs/configuration.md
 */
const AAD_ENDPOINT_HOST = "Enter_the_Cloud_Instance_Id_Here"; // include the trailing slash

const msalConfig = {
    auth: {
        clientId: "Enter_the_Application_Id_Here",
        authority: `${AAD_ENDPOINT_HOST}Enter_the_Tenant_Info_Here`,
    },
    system: {
        loggerOptions: {
            loggerCallback(loglevel, message, containsPii) {
                console.log(message);
            },
            piiLoggingEnabled: false,
            logLevel: LogLevel.Verbose,
        },
    },
};

/**
 * Add here the endpoints and scopes when obtaining an access token for protected web APIs. For more information, see:
 * https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/lib/msal-browser/docs/resources-and-scopes.md
 */
const GRAPH_ENDPOINT_HOST = "Enter_the_Graph_Endpoint_Here"; // include the trailing slash

const protectedResources = {
    graphMe: {
        endpoint: `${GRAPH_ENDPOINT_HOST}v1.0/me`,
        scopes: ["User.Read"],
    }
};


module.exports = {
    msalConfig: msalConfig,
    protectedResources: protectedResources,
};

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

  • Enter_the_Tenant_Id_here 應該是下列其中一項:
    • 如果您的應用程式支援 [此組織目錄中的帳戶],請將此值取代為 [租用戶識別碼] 或 [租用戶名稱]。 例如: contoso.microsoft.com
    • 如果您的應用程式支援 [任何組織目錄中的帳戶],請將此值取代為 organizations
    • 如果您的應用程式支援 [任何組織目錄中的帳戶及個人的 Microsoft 帳戶],請將此值取代為 common
    • 若要將支援範圍限制為 [僅限個人 Microsoft 帳戶],請將此值取代為 consumers
  • Enter_the_Application_Id_Here:是您所註冊應用程式的應用程式 (用戶端) 識別碼
  • Enter_the_Cloud_Instance_Id_Here:註冊應用程式所在的 Azure 雲端執行個體。
    • 針對主要 (或全域) Azure 雲端,請輸入 https://login.microsoftonline.com/
    • 針對國家雲端 (例如中國),您可以在國家雲端中找到適當的值。
  • Enter_the_Graph_Endpoint_Here 是應用程式所應通訊的 Microsoft Graph API 執行個體。
    • 針對全域 Microsoft Graph API 端點,請將此字串的兩個執行個體取代為 https://graph.microsoft.com/
    • 針對國家雲端部署中的端點,請參閱 Microsoft Graph 文件中的國家雲端部署

測試應用程式

您已完成應用程式的建立,現在已做好準備而可以啟動 Electron 桌面應用程式,並測試應用程式的功能了。

  1. 請從專案資料夾的根目錄內執行下列命令,以啟動應用程式:
electron App/main.js
  1. 在應用程式主視窗中,您應該會看到 index.html 檔案和 [登入] 按鈕。

測試登入和登出

載入 index.html 檔案之後,請選取 [登入]。 系統會提示您使用 Microsoft 身分識別平台登入:

登入提示

如果您同意要求的權限,Web 應用程式便會顯示您的使用者名稱,以表示您已成功登入:

成功登入

測試 Web API 呼叫

登入之後,選取 [查看設定檔],以檢視呼叫 Microsoft Graph API 時的回應中傳回的使用者設定檔資訊。 同意之後,您將檢視回應中傳回的設定檔資訊:

來自 Microsoft Graph 的設定檔資訊

應用程式的運作方式

當使用者首次選取 [登入] 按鈕時,MSAL 節點的 acquireTokenInteractive 方法。 此方法會將使用者重新導向以使用 Microsoft 身分識別平台端點登入,並驗證使用者的認證、取得授權碼,然後針對識別碼權杖、存取權杖及重新整理權杖交換該代碼。 MSAL 節點也會快取這些權杖以供日後使用。

識別碼權杖包含使用者的基本資訊,例如其顯示名稱。 存取權杖的存留期有限,24 小時後便會到期。 如果您計劃使用這些權杖存取受到保護的資源,則後端伺服器必須驗證資料,以保證所簽發權杖的適用對象是您應用程式的有效使用者。

您在本教學課程中建立的桌面應用程式,會使用存取權杖作為要求標頭 (RFC 6750) 中的持有人權杖對 Microsoft Graph API 進行 REST 呼叫。

Microsoft Graph API 需要 user.read 範圍才能讀取使用者的設定檔。 根據預設,在 Azure 入口網站中註冊的每個應用程式中,都會自動新增此範圍。 Microsoft Graph 的其他 API 與您後端伺服器的自訂 API 一樣,需要額外範圍。 例如,Microsoft Graph API 需要 Mail.Read 範圍才能列出使用者的電子郵件。

當您新增範圍時,系統可能會提示使用者針對新增的範圍另外提供同意。

說明與支援 

如果您需要協助、想要回報問題,或想要深入了解您的支援選項,請參閱 開發人員的協助與支援

下一步

如果您想要深入瞭解如何在 Microsoft 身分識別平台上開發 Electron 桌面應用程式,請參閱我們多部分的案例系列: