12k
All articles

如何为 Electron 应用添加身份验证

在 Electron 应用中添加身份验证:OAuth 2.0 PKCE、系统浏览器登录、深链接或 loopback 重定向,以及 safeStorage 安全保存令牌。

OpenReplay Team
OpenReplay Team
如何为 Electron 应用添加身份验证

如果你曾搜索过如何为 Electron 应用添加身份验证,大概会找到一些教程,它们通过打开 BrowserWindow 来显示登录表单,在 Electron 内部拦截 OAuth 回调,并以明文形式存储令牌。这种方式已经过时、存在安全隐患,且与当今 OAuth 2.0 原生应用规范的实际工作方式相冲突。

本文将介绍正确的架构方案:带 PKCE 的授权码流程、通过系统浏览器登录,以及使用深度链接或回环重定向将令牌返回给应用。

核心要点

  • 在嵌入式 BrowserWindow 中执行登录流程违反了 RFC 8252,会使用户面临凭据被拦截的风险。请改用系统浏览器。
  • PKCE(Proof Key for Code Exchange,代码交换证明密钥)取代了客户端密钥——Electron 应用无法安全地存储客户端密钥。
  • 通过自定义 URI 方案的深度链接或回环重定向,可将授权码返回给主进程。
  • 使用 safeStorage 存储刷新令牌,该机制利用操作系统级别的密钥管理(Keychain、DPAPI、libsecret)。
  • 通过设置 contextIsolation: true 隔离渲染进程,并仅通过 contextBridge 暴露有限的 IPC 方法。

为什么 Electron 的身份验证与 Web 身份验证不同

Electron 应用运行在用户可控的机器上。应用没有后端来隐藏客户端密钥,也没有浏览器沙箱将应用与操作系统隔离。这从根本上改变了安全威胁模型。

RFC 8252 是专门针对原生和桌面应用编写的 OAuth 2.0 标准,其中明确规定:不得在 OAuth 登录流程中使用嵌入式 Web 视图。嵌入式 BrowserWindow 可以静默拦截用户凭据,且用户无法验证自己是否正在与合法的身份提供商通信。请改用系统浏览器。

正确架构:带深度链接的 OAuth 2.0 PKCE

推荐的流程如下:

  1. 用户在 Electron 应用中点击”登录”。
  2. 应用使用授权 URL 打开系统浏览器。
  3. 用户通过身份提供商(Auth0、Clerk、Firebase Authentication、AWS Cognito 或任何兼容 OpenID Connect 的提供商)完成身份验证。
  4. 提供商重定向至自定义 URI 方案(如 myapp://auth/callback)或回环地址(如 http://127.0.0.1:PORT/callback)。
  5. Electron 主进程拦截该重定向,并使用授权码换取令牌。
  6. 使用 safeStorage 安全存储令牌。

由于 Electron 应用无法安全保存客户端密钥,PKCE(Proof Key for Code Exchange) 完全取代了密钥的作用。在流程开始之前,应用会生成一个随机的 code_verifier,将其哈希为 code_challenge,并在授权请求中携带该挑战值。在用授权码换取令牌时,再发送原始的 code_verifier。授权服务器验证两者是否匹配。整个过程中,密钥从不离开你的应用。

为 OAuth 回调配置深度链接

在主进程中注册自定义协议处理器:

// main.js
const { app } = require('electron');
const path = require('path');

if (process.defaultApp) {
  if (process.argv.length >= 2) {
    app.setAsDefaultProtocolClient('myapp', process.execPath, [
      path.resolve(process.argv[1]),
    ]);
  }
} else {
  app.setAsDefaultProtocolClient('myapp');
}

// macOS:处理深度链接回调
app.on('open-url', (event, callbackUrl) => {
  event.preventDefault();
  handleAuthCallback(callbackUrl); // 解析授权码,换取令牌
});

// Windows 和 Linux:深度链接通过 second-instance 事件传递
const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) {
  app.quit();
} else {
  app.on('second-instance', (event, argv) => {
    const callbackUrl = argv.find((arg) => arg.startsWith('myapp://'));
    if (callbackUrl) handleAuthCallback(callbackUrl);
  });
}

Windows 和 Linux 上,second-instance 事件以命令行参数的形式传递深度链接。在 macOS 上,应使用 open-url 事件。在打包配置方面,需在 electron-builder 中配置协议:

{
  "protocols": {
    "name": "myapp",
    "schemes": ["myapp"]
  }
}

注意: 在 macOS 和 Linux 上,深度链接通常只在打包后的应用中才能可靠运行,因为协议处理器必须向操作系统注册。请务必针对打包后的构建版本测试深度链接,而不仅仅使用 electron . 运行时测试。

使用 Electron safeStorage 安全存储令牌

切勿将令牌存储在 localStorage、明文文件或未加密的 electron-store 中。请使用 safeStorage,它通过操作系统级别的密钥管理对数据进行加密(macOS 使用 Keychain,Windows 使用 DPAPI,Linux 使用 libsecret):

const { app, safeStorage } = require('electron');
const fs = require('fs');
const path = require('path');

const TOKEN_PATH = path.join(app.getPath('userData'), 'refresh.enc');

async function saveRefreshToken(token) {
  if (!safeStorage.isEncryptionAvailable()) {
    throw new Error('Encryption is not available on this system');
  }

  const encrypted = await safeStorage.encryptStringAsync(token);
  fs.writeFileSync(TOKEN_PATH, encrypted);
}

async function loadRefreshToken() {
  if (!fs.existsSync(TOKEN_PATH)) return null;

  const encrypted = fs.readFileSync(TOKEN_PATH);
  return await safeStorage.decryptStringAsync(encrypted);
}

在加密之前,务必检查 safeStorage.isEncryptionAvailable(),在 Linux 上尤为重要,因为操作系统密钥环可能未配置。在某些 Linux 系统上,如果没有可用的安全密钥环,Electron 可能会回退到 basic_text 后端。你可以通过 safeStorage.getSelectedStorageBackend() 查看当前使用的后端,并在必要时向用户发出警告。

保持渲染进程隔离

渲染进程不应直接接触令牌。请使用 contextIsolation,将 nodeIntegration 设为 false,并仅通过 contextBridge 向渲染进程暴露必要的接口:

// preload.js
const { contextBridge, ipcRenderer } = require('electron');

contextBridge.exposeInMainWorld('auth', {
  login: () => ipcRenderer.invoke('auth:login'),
  logout: () => ipcRenderer.send('auth:logout'),
  getProfile: () => ipcRenderer.invoke('auth:get-profile'),
});

主进程负责处理所有令牌操作,渲染进程只调用具名的 IPC 方法。这样即使渲染进程遭到恶意内容入侵,也能将影响范围降到最低。

选择身份提供商

上述架构适用于任何兼容 OpenID Connect 的提供商。Electron 应用常用的选择包括 Auth0ClerkFirebase AuthenticationAWS Cognito。在所选提供商中,将应用注册为原生桌面应用类型,并将自定义 URI 方案配置为允许的重定向 URI。

应避免的模式

许多教程仍在展示以下模式:

// ❌ 请勿这样做
const authWindow = new BrowserWindow({ /* ... */ });
authWindow.loadURL(authorizationUrl);
authWindow.webContents.session.webRequest.onBeforeRequest(
  { urls: ['http://localhost/callback*'] },
  ({ url }) => { /* 拦截令牌 */ }
);

这种方式将登录流程嵌入 Electron 内部,违反了 RFC 8252,且剥夺了用户验证身份提供商的能力。无论有多少教程仍在推荐这种做法,都应坚决避免。

结论

为 Electron 应用添加身份验证的正确方式其实并不复杂:打开系统浏览器,使用带 PKCE 的 OAuth 2.0,通过深度链接或回环重定向处理回调,并使用 safeStorage 存储令牌。同时,保持渲染进程与所有令牌逻辑的隔离。这一架构正是 RFC 8252 所推荐的方案,也是 Auth0、Clerk 等现代提供商在注册原生应用时所期望的实现方式。

常见问题

我可以使用回环重定向代替自定义 URI 方案吗?

可以。回环重定向在 127.0.0.1 上启动一个使用随机端口的本地 HTTP 服务器来接收 OAuth 回调。这种方式无需进行操作系统级别的协议注册,在开发阶段非常实用。其代价是每次登录都需要启动和停止服务器,且某些提供商要求在控制台中显式配置回环 URI。

如果身份提供商提供了客户端密钥,我还需要使用 PKCE 吗?

需要。即使提供商签发了客户端密钥,也不应将其嵌入 Electron 二进制文件中,因为用户可以将其提取出来。PKCE 保护的是授权码交换过程,与是否存在密钥无关。请将应用注册为原生或公共客户端,以强制要求使用 PKCE,并避免使用客户端密钥。

在 Electron 应用中应如何刷新访问令牌?

仅使用 safeStorage 存储刷新令牌。当访问令牌过期时,主进程将刷新令牌发送至提供商的令牌端点,并获取新的访问令牌。访问令牌应仅保存在主进程的内存中,绝不存入渲染进程。如果刷新令牌被吊销或已过期,则提示用户通过系统浏览器重新登录。

为什么使用 electron . 运行应用时深度链接不起作用?

自定义协议处理器必须向操作系统注册,而这一注册过程通常在应用安装或打包时完成。直接运行 electron . 会启动 Electron 二进制文件,操作系统并不知道你的应用处理 myapp:// 方案。请务必针对由 electron-builder 或类似工具生成的打包构建版本测试深度链接。

DevTools for the frontend

Gain Debugging Superpowers

Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.