Руководство. Вход пользователей и вызов API Microsoft Graph в классическом приложении Electron

В этом руководстве описано, как создать классическое приложение Electron, которое поддерживает вход пользователей и вызовы Microsoft Graph с использованием потока кода авторизации с PKCE. Создаваемое классическое приложение использует библиотеку проверки подлинности Майкрософт (MSAL) для Node.js.

Выполните шаги из этого руководства, чтобы:

  • регистрация приложения на портале Azure;
  • создать проект классического приложения Electron;
  • Добавление логики аутентификации в приложение
  • добавить метод для вызова веб-API;
  • Добавление сведений о регистрации приложения
  • Тестирование приложения

Необходимые компоненты

Регистрация приложения

Сначала выполните действия, описанные в статье Краткое руководство. Регистрация приложения с помощью платформы удостоверений Майкрософт, чтобы зарегистрировать приложение.

Используйте следующие параметры для регистрации приложения:

  • Имя: 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, который будет использоваться в качестве пользовательского интерфейса. Добавьте в него следующий код:

    <!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 для обработки входа, выхода и получения маркера, а также вызываем API Microsoft Graph.

  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 отрисовщика для предоставления управляемому процессу отрисовщика доступа к некоторым Node APIs путем применения каналов IPC, настроенных для взаимодействия между основными и отрисовщиками.

  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. После выполнения остальных инструкций в этом учебнике файл и структура папок проекта должны выглядеть следующим образом:

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 Node. Добавьте в него следующий код:

/*
 * 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;

В приведенном выше фрагменте кода мы сначала инициализировали класс PublicClientApplication MSAL Node, передав объект конфигурации (msalConfig). Затем мы предоставили методы login, logout и getToken для вызова модулем main (main.js). В login и getToken, мы получаем маркеры идентификатора и доступа с помощью общедоступного API MSAL Node acquireTokenInteractive .

Добавление пакета SDK Для Microsoft Graph

Создайте файл с именем graph.js. Файл graph.js будет содержать экземпляр клиента пакета SDK Microsoft Graph для упрощения доступа к данным в API Microsoft Graph с помощью маркера доступа, полученного MSAL Node:

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;

Добавление сведений о регистрации приложения

Создайте файл среды для хранения сведений о регистрации приложения, которые будут использоваться при получении маркеров. Для этого создайте файл authConfig.js в корневой папке примера (ElectronDesktopApp) и добавьте следующий код:

/*
 * 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.
    • Если приложение поддерживает учетные записи в любом каталоге организации и личные учетные записи Майкрософт, замените это значение на 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 экземпляр API Microsoft Graph, с которым должно взаимодействовать приложение.
    • Для глобальной конечной точки API Microsoft Graph замените оба экземпляра этой строки на https://graph.microsoft.com/.
    • Дополнительные сведения о конечных точках в национальных облачных развертываниях см. в статье Национальные облачные развертывания в документации по Microsoft Graph.

Тестирование приложения

Вы завершили создание приложения и теперь готовы запустить классическое приложение Electron и протестировать его функциональность.

  1. Запустите приложение, выполнив следующую команду из корневой папки проекта:
electron App/main.js
  1. В главном окне приложения отобразится содержимое файла index.html и кнопка Sign In (Войти).

Проверка входа и выхода

После загрузки файла index.html выберите Sign In (Войти). Вам будет предложено войти с помощью платформы удостоверений Майкрософт:

sign-in prompt

Если вы согласны предоставить запрошенные разрешения, веб-приложения выводят имя пользователя, что означает успешный вход.

successful sign-in

Проверка вызова веб-API

После входа выберите "Просмотреть профиль" , чтобы просмотреть сведения профиля пользователя, возвращенные в ответе на вызов API Microsoft Graph. После согласия вы увидите сведения о профиле, возвращенные в ответе:

profile information from Microsoft Graph

Принцип работы приложения

Когда пользователь впервые выбирает кнопку входа , acquireTokenInteractive метод MSAL Node. Этот метод перенаправляет пользователя на вход с помощью конечной точки платформа удостоверений Майкрософт и проверяет учетные данные пользователя, получает код авторизации, а затем обменивается этим кодом для маркера идентификатора, маркера доступа и маркера обновления. Узел MSAL также кэширует эти маркеры для дальнейшего использования.

Маркер идентификатора содержит основные сведения о пользователе, например отображаемое имя. Срок действия маркера доступа ограничен и истекает через 24 часа. Если вы планируете использовать эти маркеры для доступа к защищенному ресурсу, внутренний сервер должен его проверить и подтвердить, что маркер выдан допустимому пользователю для вашего приложения.

Классическое приложение, созданное при работе с этим руководством, выполняет вызов REST к API Microsoft Graph, используя маркер доступа в качестве маркера носителя в заголовке запроса (RFC 6750).

Для чтения профиля пользователя API Microsoft Graph требуется область user.read. По умолчанию эта область автоматически добавляется в каждое приложение, зарегистрированное на портале Azure. Для других API Microsoft Graph, а также для пользовательских API вашего внутреннего сервера могут потребоваться дополнительные области. Например, для отображения сообщений электронной почты пользователя API Microsoft Graph требуется область Mail.Read.

При добавлении областей приложение может запросить у пользователей дополнительное согласие на использование таких добавленных областей.

Справка и поддержка

Если вам нужна помощь, если вы хотите сообщить о проблеме или узнать о доступных вариантах поддержки, воспользуйтесь статьей Возможности получения поддержки и справки для разработчиков.

Следующие шаги

Если вы хотите узнать больше о Node.js и разработке классических приложений Electron на платформе удостоверений Майкрософт, см. серию статей: