使用MSAL Node在Electron中实现Microsoft身份验证

Viewed 0

构建使用PKCE授权流的Electron桌面应用

本教程将指导你构建一个Electron桌面应用程序,该程序使用PKCE(Proof Key for Code Exchange)的授权代码流来实现用户登录并调用Microsoft Graph API。应用基于适用于Node.js的Microsoft身份验证库(MSAL Node),确保安全且高效的身份验证处理。

通过本教程,你将完成以下任务:

  • 在Azure门户中注册应用程序
  • 创建Electron桌面应用项目
  • 向应用添加身份验证逻辑
  • 添加方法以调用Web API
  • 配置应用注册详细信息
  • 测试应用程序功能

先决条件

在开始之前,请确保满足以下条件:

创建项目

本教程提供的Electron示例专为使用MSAL Node设计。请注意,Electron应用中不支持MSAL浏览器,因此请严格按照以下步骤设置项目。

首先,创建一个文件夹来托管应用程序,例如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
  1. 创建一个名为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>
    <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>
    <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>
    <script src="./renderer.js"></script>
</body>
</html>
  1. 创建main.js文件,添加以下代码以初始化Electron主窗口并处理事件:
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', () => {
    if (BrowserWindow.getAllWindows().length === 0) {
        createWindow();
    }
});

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);
});
  1. 创建renderer.js文件,用于处理UI事件和更新界面:
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);
});

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);
};
  1. 创建preload.js文件,暴露安全的API给渲染进程:
const { contextBridge, ipcRenderer } = require('electron');
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)),
});
  1. 创建constants.js文件,定义IPC消息常量:
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 };

完成以上步骤后,项目结构应如下所示:

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

向应用添加身份验证逻辑

App文件夹中创建AuthProvider.js文件,实现身份验证提供程序类,使用MSAL Node处理登录、注销和令牌获取。

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

class AuthProvider {
    constructor(msalConfig) {
        this.msalConfig = msalConfig;
        this.clientApplication = new PublicClientApplication(this.msalConfig);
        this.cache = this.clientApplication.getTokenCache();
        this.account = null;
    }

    async login() {
        const authResponse = await this.getToken({ scopes: [] });
        return this.handleResponse(authResponse);
    }

    async logout() {
        if (!this.account) return;
        try {
            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;
        }
    }

    async handleResponse(response) {
        if (response !== null) {
            this.account = response.account;
        } else {
            this.account = await this.getAccount();
        }
        return this.account;
    }

    async getAccount() {
        const currentAccounts = await this.cache.getAllAccounts();
        if (!currentAccounts) {
            console.log('No accounts detected');
            return null;
        }
        if (currentAccounts.length > 1) {
            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;

添加Microsoft Graph SDK

创建graph.js文件,初始化Microsoft Graph SDK客户端,以便使用访问令牌调用Graph API。

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

const getGraphClient = (accessToken) => {
    const graphClient = Client.init({
        authProvider: (done) => {
            done(null, accessToken);
        },
    });
    return graphClient;
};

module.exports = getGraphClient;

添加应用注册详细信息

在根文件夹中创建authConfig.js文件,配置MSAL Node所需的参数。

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

const AAD_ENDPOINT_HOST = "Enter_the_Cloud_Instance_Id_Here";
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,
        },
    },
};

const GRAPH_ENDPOINT_HOST = "Enter_the_Graph_Endpoint_Here";
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:租户ID或名称,例如contoso.microsoft.com
  • Enter_the_Application_Id_Here:应用程序的客户端ID。
  • Enter_the_Cloud_Instance_Id_Here:Azure云实例ID,对于国家云请参考相关文档。
  • Enter_the_Graph_Endpoint_Here:Microsoft Graph API端点,对于国家云请参考文档。

测试应用程序

完成以上步骤后,可以启动应用程序进行测试。

  1. 从项目根目录运行以下命令启动Electron应用:
electron App/main.js
  1. 应用主窗口将显示index.html内容,包括“登录”按钮。

测试登录和注销

点击“登录”按钮,系统将提示你使用Microsoft标识平台进行登录。成功登录后,界面会显示用户名,表示登录成功。

测试Web API调用

登录后,点击“查看个人资料”按钮,应用将调用Microsoft Graph API获取用户个人资料信息,并显示在界面上。

应用程序的工作原理

当用户首次点击“登录”时,应用使用MSAL Node的acquireTokenInteractive方法引导用户登录Microsoft标识平台,获取授权码并交换为ID令牌、访问令牌和刷新令牌。这些令牌被缓存以供后续使用。

ID令牌包含用户基本信息,访问令牌用于作为持有者令牌在请求头中调用受保护的API,如Microsoft Graph。本应用使用User.Read作用域来读取用户个人资料,其他API可能需要额外作用域。

帮助和支持

如果在开发过程中需要帮助或遇到问题,请参考面向开发人员的帮助和支持

后续步骤

想深入了解Microsoft标识平台上的Node.js和Electron桌面应用开发,请参阅相关多部分场景系列文档。

0 Answers