<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>OAuth2.0 on kenji.blog</title><link>http://kenji.blog/zh-cn/tags/oauth2.0/</link><description>Recent content in OAuth2.0 on kenji.blog</description><generator>Hugo -- gohugo.io</generator><language>zh-cn</language><copyright>kenjinote</copyright><lastBuildDate>Sun, 13 Sep 2026 12:00:00 +0900</lastBuildDate><atom:link href="http://kenji.blog/zh-cn/tags/oauth2.0/index.xml" rel="self" type="application/rss+xml"/><item><title>图解 OAuth 2.0 机制：从 Slack App 集成实现中学习</title><link>http://kenji.blog/zh-cn/p/oauth2-architecture-slack-app-integration/</link><pubDate>Sun, 13 Sep 2026 12:00:00 +0900</pubDate><guid>http://kenji.blog/zh-cn/p/oauth2-architecture-slack-app-integration/</guid><description>&lt;img src="http://kenji.blog/p/oauth2-architecture-slack-app-integration/img/eyecatch.jpg" alt="Featured image of post 图解 OAuth 2.0 机制：从 Slack App 集成实现中学习" />&lt;h1 id="前言为什么要学习-oauth-20">前言：为什么要学习 OAuth 2.0？
&lt;/h1>&lt;p>在现代 Web 应用程序中，多个服务协同工作已经成为司空见惯的场景。例如，“使用 Google 账号登录”、“Trello 任务更新时向 Slack 发送通知”、“将 Zoom 会议链接自动添加到 Google 日历”等功能。在所有这些功能的背后发挥作用的，就是名为 &lt;strong>OAuth 2.0 (Open Authorization 2.0)&lt;/strong> 的授权框架。&lt;/p>
&lt;p>过去，在不同服务之间交换数据时，曾使用过让用户将自己的 ID 和密码直接交给关联服务的“基本认证”或“密码共享”等极其危险的方法。然而，使用这种方法，关联服务将掌握用户的全部权限，伴随着致命的安全风险。&lt;/p>
&lt;p>OAuth 2.0 作为一种标准协议（RFC 6749）应运而生，它旨在避免这种“密码共享”的情况，同时仅将“特定权限（作用域）”在“有限时间内”委托给第三方应用程序。&lt;/p>
&lt;p>本文将以作为商业通信工具事实标准的 &lt;strong>Slack (Slack API)&lt;/strong> 为对象，通过应用程序（Slack App）的实现，极其详细且富有实践性地解说 OAuth 2.0 的机制。这是一篇超过一万字的决定版解说，包含了使用 Node.js (Express) 的代码示例、图解协议流程的序列图，甚至深入探讨了安全上的重要概念 &lt;code>state&lt;/code> 参数以及 PKCE 的数学和密码学背景。&lt;/p>
&lt;hr>
&lt;h1 id="1-oauth-20-的基本概念4-个角色roles">1. OAuth 2.0 的基本概念：4 个角色（Roles）
&lt;/h1>&lt;p>理解 OAuth 2.0 的第一步是准确掌握其中的登场人物（Role）。在 RFC 6749 中，定义了以下 4 个角色。&lt;/p>
&lt;pre class="mermaid">
graph TD
RO[&amp;#34;Resource Owner (用户)&amp;#34;] -- 授予授权 --&amp;gt; C[&amp;#34;Client (Slack App)&amp;#34;]
C -- 请求授权 --&amp;gt; AS[&amp;#34;Authorization Server (Slack 授权服务器)&amp;#34;]
AS -- 颁发访问令牌 --&amp;gt; C
C -- 使用令牌进行访问 --&amp;gt; RS[&amp;#34;Resource Server (Slack API 服务器)&amp;#34;]
RO -- 使用 ID/密码登录 --&amp;gt; AS
&lt;/pre>
&lt;ol>
&lt;li>&lt;strong>Resource Owner（资源所有者）&lt;/strong>
&lt;ul>
&lt;li>拥有赋予资源访问权限的实体。通常指“最终用户（人类）”。在本次的例子中，就是“属于 Slack 工作区，拥有在频道中发布消息权限的你本人”。&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>&lt;strong>Client（客户端）&lt;/strong>
&lt;ul>
&lt;li>在获得资源所有者的许可后，尝试访问资源服务器的应用程序。在本次的例子中，是“你正在开发的 Node.js 应用程序（Slack App）”。虽然名叫“客户端”，但在 OAuth 的语境中，即使是在服务器端运行的 Web 应用程序也被称为“客户端”。&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>&lt;strong>Authorization Server（授权服务器）&lt;/strong>
&lt;ul>
&lt;li>对资源所有者进行身份验证，在获得资源所有者的授权后，向客户端颁发访问令牌的服务器。在本次的例子中，是提供 &lt;code>slack.com/oauth/v2/authorize&lt;/code> 的 Slack 身份验证基础设施。&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>&lt;strong>Resource Server（资源服务器）&lt;/strong>
&lt;ul>
&lt;li>托管受保护的资源，接收使用访问令牌发出的资源访问请求并予以响应的服务器。在本次的例子中，是提供 &lt;code>chat.postMessage&lt;/code> 等 API 的 &lt;code>slack.com/api/&lt;/code> 端点。&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ol>
&lt;p>一言以蔽之，OAuth 的流程就是 &lt;strong>“Client 在获得 Resource Owner 同意后，从 Authorization Server 接收访问令牌，并使用它从 Resource Server 获取和操作数据”&lt;/strong> 的一系列步骤。&lt;/p>
&lt;hr>
&lt;h1 id="2-完全解剖授权码授权authorization-code-grant">2. 完全解剖授权码授权（Authorization Code Grant）
&lt;/h1>&lt;p>OAuth 2.0 存在多种流程（授权类型），但在 Web 应用程序这种可以在服务器端安全保存私钥（Client Secret）的环境中，最被推荐且使用最广泛的是 &lt;strong>授权码授权（Authorization Code Grant）&lt;/strong>。&lt;/p>
&lt;p>授权码授权的最大特点在于明确分离了 &lt;strong>前台通道（通过浏览器的通信）&lt;/strong> 和 &lt;strong>后台通道（服务器之间的直接通信）&lt;/strong>。在前台通道中，只传递临时的“授权码（Authorization Code）”，而最终获取“访问令牌”的过程在后台通道进行，从而大幅降低了令牌泄露到浏览器历史记录或 Referer 中的风险。&lt;/p>
&lt;p>以下序列图展示了 Slack App 中授权码授权的全过程。&lt;/p>
&lt;pre class="mermaid">
sequenceDiagram
autonumber
participant U as &amp;#34;Resource Owner (Web 浏览器)&amp;#34;
participant C as &amp;#34;Client (Node.js 应用)&amp;#34;
participant AS as &amp;#34;Authorization Server (Slack 授权)&amp;#34;
participant RS as &amp;#34;Resource Server (Slack API)&amp;#34;
U-&amp;gt;&amp;gt;C: &amp;#34;点击安装应用的按钮 (GET /slack/install)&amp;#34;
Note over C: &amp;#34;生成 state 参数&amp;#34;
C--&amp;gt;&amp;gt;U: &amp;#34;重定向: 302 Found (Location: Slack 授权 URL)&amp;#34;
U-&amp;gt;&amp;gt;AS: &amp;#34;GET /oauth/v2/authorize?client_id=...&amp;amp;scope=...&amp;amp;state=...&amp;#34;
AS--&amp;gt;&amp;gt;U: &amp;#34;Slack 登录界面 &amp;amp; 权限批准界面 (Consent Screen)&amp;#34;
U-&amp;gt;&amp;gt;AS: &amp;#34;允许权限 (Allow)&amp;#34;
Note over AS: &amp;#34;生成授权码 (code)&amp;#34;
AS--&amp;gt;&amp;gt;U: &amp;#34;重定向: 302 Found (Location: Client Callback URL?code=...&amp;amp;state=...)&amp;#34;
U-&amp;gt;&amp;gt;C: &amp;#34;GET /slack/oauth_redirect?code=...&amp;amp;state=...&amp;#34;
Note over C: &amp;#34;验证 state 参数 (CSRF 防护)&amp;#34;
C-&amp;gt;&amp;gt;AS: &amp;#34;POST /api/oauth.v2.access (code, client_id, client_secret)&amp;#34;
Note over C,AS: &amp;#34;后台通道通信（不经过浏览器）&amp;#34;
AS--&amp;gt;&amp;gt;C: &amp;#34;200 OK (JSON: 颁发 access_token 等)&amp;#34;
C-&amp;gt;&amp;gt;RS: &amp;#34;POST /api/chat.postMessage (Authorization: Bearer &amp;lt;access_token&amp;gt;)&amp;#34;
RS--&amp;gt;&amp;gt;C: &amp;#34;200 OK (消息发布成功)&amp;#34;
&lt;/pre>
&lt;p>让我们通过具体的 Node.js (Express) 代码实现，来一步步解开这个流程。&lt;/p>
&lt;hr>
&lt;h1 id="3-准备实现slack-developer-console-中的设置">3. 准备实现：Slack Developer Console 中的设置
&lt;/h1>&lt;p>在编写代码之前，必须向 Slack 系统注册“存在一个新的客户端”。&lt;/p>
&lt;ol>
&lt;li>访问 &lt;a class="link" href="https://api.slack.com/apps" target="_blank" rel="noopener"
>Slack API: Applications&lt;/a>，点击“Create New App”。&lt;/li>
&lt;li>选择“From scratch”，指定应用名称（例：&lt;code>My First OAuth App&lt;/code>）以及要安装的工作区。&lt;/li>
&lt;li>在创建后的“Basic Information”界面，获取以下 2 个重要的凭证（资格信息）：
&lt;ul>
&lt;li>&lt;strong>Client ID&lt;/strong>: 公开、唯一标识你的应用的 ID。将其包含在经过浏览器的请求（前台通道）中也没有问题。&lt;/li>
&lt;li>&lt;strong>Client Secret&lt;/strong>: 只有你的应用知道的机密字符串。&lt;strong>绝对不能暴露在浏览器端，也不能提交到 GitHub 等地方。&lt;/strong>&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>移动到“OAuth &amp;amp; Permissions”界面，在“Redirect URLs”中注册回调目标的 URL。本次假设是本地开发，设置如下：
&lt;ul>
&lt;li>&lt;code>http://localhost:3000/slack/oauth_redirect&lt;/code>&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ol>
&lt;p>准备工作就此完成。接下来进入服务器的实现。&lt;/p>
&lt;hr>
&lt;h1 id="4-实现步骤-1slackinstall-与防御-csrf-的-state-参数">4. 实现步骤 1：&lt;code>/slack/install&lt;/code> 与防御 CSRF 的 &lt;code>state&lt;/code> 参数
&lt;/h1>&lt;p>创建一个初始端点，供用户开始使用应用（安装到工作区）。这里的最大责任是将用户重定向到 Slack 的授权服务器，但在安全性上极其重要的是 &lt;strong>生成和保存 &lt;code>state&lt;/code> 参数&lt;/strong>。&lt;/p>
&lt;h2 id="state-参数的必要性-防止-csrf-攻击">state 参数的必要性 (防止 CSRF 攻击)
&lt;/h2>&lt;p>如果不存在 &lt;code>state&lt;/code> 参数，恶意的攻击者可以利用自己的 Slack 账号发起授权流程，并将包含获取到的“授权码”的回调 URL（例：&lt;code>http://localhost:3000/slack/oauth_redirect?code=ATTACKER_CODE&lt;/code>）诱导受害者点击。如果受害者的浏览器执行了该 URL，就会在受害者的会话上完成与攻击者 Slack 账号的绑定，成为信息泄露或意外操作的原因（登录 CSRF）。&lt;/p>
&lt;p>为了防止这种情况，&lt;code>state&lt;/code> 是一串不可预测的随机字符串，用于验证发起请求的浏览器与接收回调的浏览器是否是同一个。&lt;/p>
&lt;h2 id="state-的熵数学背景">state 的熵（数学背景）
&lt;/h2>&lt;p>为了生成安全的 &lt;code>state&lt;/code>，需要具有足够“熵（信息量）”的随机数。熵 $E$ 依赖于生成的字符串种类 $N$，由以下公式表示。&lt;/p>
$$
E = \log_2(N) \quad (\text{单位: bits})
$$&lt;p>例如，生成 16 字节的密码学安全伪随机数（CSPRNG），并将其转换为十六进制（Hex）字符串时，可表示的状态数为 $2^{128}$。&lt;/p>
$$
E = \log_2(2^{128}) = 128 \text{ bits}
$$&lt;p>拥有 128 位熵的话，在现代计算机科学中，通过暴力破解（穷举攻击）找到碰撞在事实上是不可能的（天文数字般的概率）。通常，作为安全要求，建议 &lt;code>state&lt;/code> 至少具有 128 位以上的熵。&lt;/p>
&lt;h2 id="nodejs-实现">Node.js 实现
&lt;/h2>&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt"> 1
&lt;/span>&lt;span class="lnt"> 2
&lt;/span>&lt;span class="lnt"> 3
&lt;/span>&lt;span class="lnt"> 4
&lt;/span>&lt;span class="lnt"> 5
&lt;/span>&lt;span class="lnt"> 6
&lt;/span>&lt;span class="lnt"> 7
&lt;/span>&lt;span class="lnt"> 8
&lt;/span>&lt;span class="lnt"> 9
&lt;/span>&lt;span class="lnt">10
&lt;/span>&lt;span class="lnt">11
&lt;/span>&lt;span class="lnt">12
&lt;/span>&lt;span class="lnt">13
&lt;/span>&lt;span class="lnt">14
&lt;/span>&lt;span class="lnt">15
&lt;/span>&lt;span class="lnt">16
&lt;/span>&lt;span class="lnt">17
&lt;/span>&lt;span class="lnt">18
&lt;/span>&lt;span class="lnt">19
&lt;/span>&lt;span class="lnt">20
&lt;/span>&lt;span class="lnt">21
&lt;/span>&lt;span class="lnt">22
&lt;/span>&lt;span class="lnt">23
&lt;/span>&lt;span class="lnt">24
&lt;/span>&lt;span class="lnt">25
&lt;/span>&lt;span class="lnt">26
&lt;/span>&lt;span class="lnt">27
&lt;/span>&lt;span class="lnt">28
&lt;/span>&lt;span class="lnt">29
&lt;/span>&lt;span class="lnt">30
&lt;/span>&lt;span class="lnt">31
&lt;/span>&lt;span class="lnt">32
&lt;/span>&lt;span class="lnt">33
&lt;/span>&lt;span class="lnt">34
&lt;/span>&lt;span class="lnt">35
&lt;/span>&lt;span class="lnt">36
&lt;/span>&lt;span class="lnt">37
&lt;/span>&lt;span class="lnt">38
&lt;/span>&lt;span class="lnt">39
&lt;/span>&lt;span class="lnt">40
&lt;/span>&lt;span class="lnt">41
&lt;/span>&lt;span class="lnt">42
&lt;/span>&lt;span class="lnt">43
&lt;/span>&lt;span class="lnt">44
&lt;/span>&lt;span class="lnt">45
&lt;/span>&lt;span class="lnt">46
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-javascript" data-lang="javascript">&lt;span class="line">&lt;span class="cl">&lt;span class="c1">// app.js (节选)
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="kr">const&lt;/span> &lt;span class="nx">express&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">require&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;express&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kr">const&lt;/span> &lt;span class="nx">crypto&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">require&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;crypto&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kr">const&lt;/span> &lt;span class="nx">session&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">require&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;express-session&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kr">const&lt;/span> &lt;span class="nx">dotenv&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">require&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;dotenv&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nx">dotenv&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">config&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kr">const&lt;/span> &lt;span class="nx">app&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">express&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">// 会话中间件设置（用于保存 state）
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="nx">app&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">use&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">session&lt;/span>&lt;span class="p">({&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">secret&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="nx">process&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">env&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">SESSION_SECRET&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">resave&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="kc">false&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">saveUninitialized&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="kc">true&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">cookie&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="nx">secure&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="kc">false&lt;/span> &lt;span class="p">}&lt;/span> &lt;span class="c1">// 在生产环境中设为 true
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="p">}));&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kr">const&lt;/span> &lt;span class="nx">SLACK_CLIENT_ID&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">process&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">env&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">SLACK_CLIENT_ID&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kr">const&lt;/span> &lt;span class="nx">SLACK_AUTHORIZE_URL&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s1">&amp;#39;https://slack.com/oauth/v2/authorize&amp;#39;&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nx">app&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;/slack/install&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">req&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">res&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">=&amp;gt;&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1">// 生成 16 字节的强随机数，并转换为十六进制字符串 (熵: 128 bits)
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="kr">const&lt;/span> &lt;span class="nx">state&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">crypto&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">randomBytes&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">16&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nx">toString&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;hex&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1">// 保存到会话中以便在回调时进行验证
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="nx">req&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">session&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">oauth_state&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">state&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1">// 请求的作用域（权限）列表（逗号分隔）
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="c1">// chat:write = 在频道发送消息的权限
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="c1">// channels:read = 获取公共频道信息的权限
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="kr">const&lt;/span> &lt;span class="nx">scope&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s1">&amp;#39;chat:write,channels:read&amp;#39;&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1">// 构建面向 Slack 授权服务器的 URL 参数
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="kr">const&lt;/span> &lt;span class="nx">params&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">new&lt;/span> &lt;span class="nx">URLSearchParams&lt;/span>&lt;span class="p">({&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">client_id&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="nx">SLACK_CLIENT_ID&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">scope&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="nx">scope&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">state&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="nx">state&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">redirect_uri&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="s1">&amp;#39;http://localhost:3000/slack/oauth_redirect&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="kr">const&lt;/span> &lt;span class="nx">authUrl&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="sb">`&lt;/span>&lt;span class="si">${&lt;/span>&lt;span class="nx">SLACK_AUTHORIZE_URL&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="sb">?&lt;/span>&lt;span class="si">${&lt;/span>&lt;span class="nx">params&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">toString&lt;/span>&lt;span class="p">()&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="sb">`&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1">// 将用户重定向到 Slack 的授权界面（302 Found）
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="nx">res&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">redirect&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">authUrl&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>访问该端点时，HTTP 响应将如下所示。&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;span class="lnt">2
&lt;/span>&lt;span class="lnt">3
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-http" data-lang="http">&lt;span class="line">&lt;span class="cl">&lt;span class="kr">HTTP&lt;/span>&lt;span class="o">/&lt;/span>&lt;span class="m">1.1&lt;/span> &lt;span class="m">302&lt;/span> &lt;span class="ne">Found&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">Location&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="l">https://slack.com/oauth/v2/authorize?client_id=123.456&amp;amp;scope=chat%3Awrite%2Cchannels%3Aread&amp;amp;state=a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6&amp;amp;redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fslack%2Foauth_redirect&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">Set-Cookie&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="l">connect.sid=...; Path=/; HttpOnly&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>用户的浏览器将立即跳转到指定的 &lt;code>Location&lt;/code>，并显示 Slack 的界面（Consent Screen），出现大家熟悉的“My First OAuth App 正在请求访问您的工作区”的界面。&lt;/p>
&lt;hr>
&lt;h1 id="5-实现步骤-2接收回调并交换访问令牌">5. 实现步骤 2：接收回调并交换访问令牌
&lt;/h1>&lt;p>当用户在 Slack 的界面点击“允许 (Allow)”时，Slack 的服务器会将用户的浏览器重定向到事先设置的 &lt;code>redirect_uri&lt;/code>。同时，作为 URL 的查询参数，会附加 &lt;code>code&lt;/code>（授权码）和刚才发送的 &lt;code>state&lt;/code>。&lt;/p>
&lt;p>在后端执行以下处理。&lt;/p>
&lt;ol>
&lt;li>确认传来的 &lt;code>state&lt;/code> 与保存在会话中的 &lt;code>state&lt;/code> 是否完全一致。&lt;/li>
&lt;li>如果一致，使用接收到的 &lt;code>code&lt;/code>、自身的 &lt;code>client_id&lt;/code> 以及作为机密信息的 &lt;code>client_secret&lt;/code>，在后台通道向 Slack API 发起通信，请求访问令牌。&lt;/li>
&lt;/ol>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt"> 1
&lt;/span>&lt;span class="lnt"> 2
&lt;/span>&lt;span class="lnt"> 3
&lt;/span>&lt;span class="lnt"> 4
&lt;/span>&lt;span class="lnt"> 5
&lt;/span>&lt;span class="lnt"> 6
&lt;/span>&lt;span class="lnt"> 7
&lt;/span>&lt;span class="lnt"> 8
&lt;/span>&lt;span class="lnt"> 9
&lt;/span>&lt;span class="lnt">10
&lt;/span>&lt;span class="lnt">11
&lt;/span>&lt;span class="lnt">12
&lt;/span>&lt;span class="lnt">13
&lt;/span>&lt;span class="lnt">14
&lt;/span>&lt;span class="lnt">15
&lt;/span>&lt;span class="lnt">16
&lt;/span>&lt;span class="lnt">17
&lt;/span>&lt;span class="lnt">18
&lt;/span>&lt;span class="lnt">19
&lt;/span>&lt;span class="lnt">20
&lt;/span>&lt;span class="lnt">21
&lt;/span>&lt;span class="lnt">22
&lt;/span>&lt;span class="lnt">23
&lt;/span>&lt;span class="lnt">24
&lt;/span>&lt;span class="lnt">25
&lt;/span>&lt;span class="lnt">26
&lt;/span>&lt;span class="lnt">27
&lt;/span>&lt;span class="lnt">28
&lt;/span>&lt;span class="lnt">29
&lt;/span>&lt;span class="lnt">30
&lt;/span>&lt;span class="lnt">31
&lt;/span>&lt;span class="lnt">32
&lt;/span>&lt;span class="lnt">33
&lt;/span>&lt;span class="lnt">34
&lt;/span>&lt;span class="lnt">35
&lt;/span>&lt;span class="lnt">36
&lt;/span>&lt;span class="lnt">37
&lt;/span>&lt;span class="lnt">38
&lt;/span>&lt;span class="lnt">39
&lt;/span>&lt;span class="lnt">40
&lt;/span>&lt;span class="lnt">41
&lt;/span>&lt;span class="lnt">42
&lt;/span>&lt;span class="lnt">43
&lt;/span>&lt;span class="lnt">44
&lt;/span>&lt;span class="lnt">45
&lt;/span>&lt;span class="lnt">46
&lt;/span>&lt;span class="lnt">47
&lt;/span>&lt;span class="lnt">48
&lt;/span>&lt;span class="lnt">49
&lt;/span>&lt;span class="lnt">50
&lt;/span>&lt;span class="lnt">51
&lt;/span>&lt;span class="lnt">52
&lt;/span>&lt;span class="lnt">53
&lt;/span>&lt;span class="lnt">54
&lt;/span>&lt;span class="lnt">55
&lt;/span>&lt;span class="lnt">56
&lt;/span>&lt;span class="lnt">57
&lt;/span>&lt;span class="lnt">58
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-javascript" data-lang="javascript">&lt;span class="line">&lt;span class="cl">&lt;span class="kr">const&lt;/span> &lt;span class="nx">axios&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">require&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;axios&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kr">const&lt;/span> &lt;span class="nx">SLACK_CLIENT_SECRET&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">process&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">env&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">SLACK_CLIENT_SECRET&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kr">const&lt;/span> &lt;span class="nx">SLACK_ACCESS_TOKEN_URL&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s1">&amp;#39;https://slack.com/api/oauth.v2.access&amp;#39;&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nx">app&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;/slack/oauth_redirect&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kr">async&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">req&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">res&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">=&amp;gt;&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="kr">const&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="nx">code&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">state&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">error&lt;/span> &lt;span class="p">}&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">req&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">query&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1">// 处理用户拒绝授权的情况
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">error&lt;/span> &lt;span class="o">===&lt;/span> &lt;span class="s1">&amp;#39;access_denied&amp;#39;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">res&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">status&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">403&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nx">send&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;访问被拒绝。&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1">// 1. 验证 state（防范 CSRF）
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="kr">const&lt;/span> &lt;span class="nx">savedState&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">req&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">session&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">oauth_state&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="o">!&lt;/span>&lt;span class="nx">state&lt;/span> &lt;span class="o">||&lt;/span> &lt;span class="nx">state&lt;/span> &lt;span class="o">!==&lt;/span> &lt;span class="nx">savedState&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">res&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">status&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">400&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nx">send&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;Invalid State Parameter (CSRF Attack Detected)&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1">// 删去使用过的 state（防止重放攻击）
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="k">delete&lt;/span> &lt;span class="nx">req&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">session&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">oauth_state&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">try&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1">// 2. 将授权码交换为访问令牌（后台通道通信）
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="kr">const&lt;/span> &lt;span class="nx">tokenResponse&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kr">await&lt;/span> &lt;span class="nx">axios&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">post&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">SLACK_ACCESS_TOKEN_URL&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="k">new&lt;/span> &lt;span class="nx">URLSearchParams&lt;/span>&lt;span class="p">({&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">client_id&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="nx">SLACK_CLIENT_ID&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">client_secret&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="nx">SLACK_CLIENT_SECRET&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">code&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="nx">code&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">redirect_uri&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="s1">&amp;#39;http://localhost:3000/slack/oauth_redirect&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">}).&lt;/span>&lt;span class="nx">toString&lt;/span>&lt;span class="p">(),&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">headers&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s1">&amp;#39;Content-Type&amp;#39;&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="s1">&amp;#39;application/x-www-form-urlencoded&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="kr">const&lt;/span> &lt;span class="nx">data&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">tokenResponse&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">data&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="o">!&lt;/span>&lt;span class="nx">data&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ok&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">console&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">error&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;Token Exchange Error:&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">data&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">error&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">res&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">status&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">500&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nx">send&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="sb">`Slack API Error: &lt;/span>&lt;span class="si">${&lt;/span>&lt;span class="nx">data&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">error&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="sb">`&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1">// 成功！获取到访问令牌
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="kr">const&lt;/span> &lt;span class="nx">accessToken&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">data&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">access_token&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="kr">const&lt;/span> &lt;span class="nx">teamName&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">data&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">team&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">name&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="kr">const&lt;/span> &lt;span class="nx">botUserId&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">data&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">bot_user_id&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">console&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">log&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="sb">`Successfully installed to &lt;/span>&lt;span class="si">${&lt;/span>&lt;span class="nx">teamName&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="sb">. Access Token: &lt;/span>&lt;span class="si">${&lt;/span>&lt;span class="nx">accessToken&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="sb">`&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1">// 在实际应用中，这里应该将令牌加密后保存到数据库
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="c1">// saveToDatabase(data.team.id, encrypt(accessToken));
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">res&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">send&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="sb">`安装完成！工作区: &lt;/span>&lt;span class="si">${&lt;/span>&lt;span class="nx">teamName&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="sb">`&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">}&lt;/span> &lt;span class="k">catch&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">err&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">console&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">error&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;Network Error:&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">err&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">res&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">status&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">500&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nx">send&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;发生通信错误。&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>作为这个 &lt;code>/api/oauth.v2.access&lt;/code> 的响应，Slack 会返回类似下面的 JSON。&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt"> 1
&lt;/span>&lt;span class="lnt"> 2
&lt;/span>&lt;span class="lnt"> 3
&lt;/span>&lt;span class="lnt"> 4
&lt;/span>&lt;span class="lnt"> 5
&lt;/span>&lt;span class="lnt"> 6
&lt;/span>&lt;span class="lnt"> 7
&lt;/span>&lt;span class="lnt"> 8
&lt;/span>&lt;span class="lnt"> 9
&lt;/span>&lt;span class="lnt">10
&lt;/span>&lt;span class="lnt">11
&lt;/span>&lt;span class="lnt">12
&lt;/span>&lt;span class="lnt">13
&lt;/span>&lt;span class="lnt">14
&lt;/span>&lt;span class="lnt">15
&lt;/span>&lt;span class="lnt">16
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-json" data-lang="json">&lt;span class="line">&lt;span class="cl">&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;ok&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="kc">true&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;app_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;A12345678&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;authed_user&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;U12345678&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;scope&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;chat:write,channels:read&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;token_type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;bot&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;access_token&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;&amp;lt;YOUR_BOT_TOKEN_HERE&amp;gt;&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;bot_user_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;B12345678&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;team&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;T12345678&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;My Workspace&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;enterprise&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="kc">null&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>这串以 &lt;code>xoxb-&lt;/code> 开头的字符串，就是 Slack 中的 &lt;strong>Bot 访问令牌&lt;/strong>。此后，应用程序向 Slack API（Resource Server）发送请求时，只需在 HTTP 头信息中加上 &lt;code>Authorization: Bearer xoxb-...&lt;/code>，即可完成身份验证和权限证明。&lt;/p>
&lt;hr>
&lt;h1 id="6-令牌作用域与最小权限原则-principle-of-least-privilege">6. 令牌作用域与最小权限原则 (Principle of Least Privilege)
&lt;/h1>&lt;p>OAuth 2.0 中最重要的概念之一是“作用域（Scope）”。作用域是指与访问令牌绑定的权限范围。&lt;/p>
&lt;p>在 Slack 中，权限被划分得非常细致，主要分为 &lt;strong>Bot Token Scopes&lt;/strong> 和 &lt;strong>User Token Scopes&lt;/strong>。&lt;/p>
&lt;ul>
&lt;li>&lt;code>chat:write&lt;/code> (Bot): 作为应用（Bot）自身在频道中发布消息的权限。&lt;/li>
&lt;li>&lt;code>chat:write&lt;/code> (User): 作为安装了应用的用户的代理（使用该用户的名字和头像）发布消息的权限。&lt;/li>
&lt;li>&lt;code>channels:read&lt;/code>: 获取频道列表的权限。&lt;/li>
&lt;li>&lt;code>channels:history&lt;/code>: 读取频道历史消息记录的权限。&lt;/li>
&lt;/ul>
&lt;p>根据作为安全大原则的“最小权限原则（Principle of Least Privilege）”，&lt;strong>只请求对应用提供的功能而言真正不可或缺的作用域&lt;/strong> 是一项铁律。例如，如果是一个“仅发送通知”的应用，那么只应请求 &lt;code>chat:write&lt;/code>，绝不能请求 &lt;code>channels:history&lt;/code>（读取所有过去对话的权限）。这是为了在万一应用被黑客攻击、令牌泄露时，能将损失控制在最小范围。&lt;/p>
&lt;hr>
&lt;h1 id="7-更高级的安全性pkce-proof-key-for-code-exchange">7. 更高级的安全性：PKCE (Proof Key for Code Exchange)
&lt;/h1>&lt;p>近年来，作为进一步强化 OAuth 2.0 安全性的机制，&lt;strong>PKCE (Proof Key for Code Exchange, RFC 7636，发音为 &amp;ldquo;pixy&amp;rdquo;)&lt;/strong> 已成为标准并被广泛使用。&lt;/p>
&lt;p>原本 PKCE 是为原生应用（iOS/Android）或 SPA（Single Page Application）等无法安全保存 &lt;code>client_secret&lt;/code> 的“公共客户端（Public Client）”设计的。然而现在，在安全的最佳实践（OAuth 2.1 草案）中，即使是服务器端的“机密客户端（Confidential Client）”，也强烈推荐使用 PKCE。&lt;/p>
&lt;h2 id="pkce-的机制与数学背景">PKCE 的机制与数学背景
&lt;/h2>&lt;p>PKCE 在密码学上证明了“发起授权请求的人”与“发起令牌交换请求的人”是同一个人。&lt;/p>
&lt;ol>
&lt;li>客户端生成一段随机字符串 &lt;strong>&lt;code>code_verifier&lt;/code>&lt;/strong>（43〜128 个字符）。&lt;/li>
&lt;li>将其通过 &lt;strong>SHA-256&lt;/strong> 进行哈希处理，并进行 BASE64URL 编码，得到的结果作为 &lt;strong>&lt;code>code_challenge&lt;/code>&lt;/strong>。&lt;/li>
&lt;/ol>
&lt;p>用数学公式表示如下。&lt;/p>
$$
\text{code\_challenge} = \text{BASE64URL-ENCODE}( \text{SHA256}( \text{ASCII}(\text{code\_verifier}) ) )
$$&lt;ol start="3">
&lt;li>客户端在执行 &lt;code>/slack/install&lt;/code> 时，除了 &lt;code>state&lt;/code> 之外，还将 &lt;code>code_challenge&lt;/code> 和 &lt;code>code_challenge_method=S256&lt;/code> 发送给授权服务器（Slack）（Slack 会暂存这些信息）。&lt;/li>
&lt;li>回调后，在交换令牌（&lt;code>/api/oauth.v2.access&lt;/code>）时，发送进行哈希处理之前的原始 &lt;strong>&lt;code>code_verifier&lt;/code>&lt;/strong>。&lt;/li>
&lt;li>授权服务器（Slack）自己对收到的 &lt;code>code_verifier&lt;/code> 进行 SHA-256 哈希处理，并验证其是否与第 3 步中保存的 &lt;code>code_challenge&lt;/code> 完全一致。&lt;/li>
&lt;/ol>
&lt;pre class="mermaid">
sequenceDiagram
participant C as &amp;#34;Client&amp;#34;
participant AS as &amp;#34;Authorization Server&amp;#34;
Note over C: &amp;#34;code_verifier = 随机字符串&amp;#34;&amp;lt;br/&amp;gt;&amp;#34;code_challenge = SHA256(code_verifier)&amp;#34;
C-&amp;gt;&amp;gt;AS: &amp;#34;授权请求 (发送 code_challenge)&amp;#34;
Note over AS: &amp;#34;保存 code_challenge&amp;#34;
AS--&amp;gt;&amp;gt;C: &amp;#34;颁发授权码 (code)&amp;#34;
C-&amp;gt;&amp;gt;AS: &amp;#34;令牌请求 (发送 code + code_verifier)&amp;#34;
Note over AS: &amp;#34;SHA256(接收到的 verifier) == 保存的 challenge?&amp;#34;
AS--&amp;gt;&amp;gt;C: &amp;#34;验证成功: 颁发访问令牌&amp;#34;
&lt;/pre>
&lt;p>通过这一机制，即便因为恶意应用或通信路径窃听导致“授权码（code）”被盗，由于攻击者不知道原始的 &lt;code>code_verifier&lt;/code>（受不可逆的哈希函数 SHA-256 的性质限制，不可能从 challenge 反推回 verifier），因此无法获取到访问令牌。&lt;/p>
&lt;p>目前，Slack API 的一些新流程以及其他现代 SaaS API（Auth0, Okta, X/Twitter API v2 等）都在推进对 PKCE 的支持，这已经成为开发者应当积极采用的技术。&lt;/p>
&lt;hr>
&lt;h1 id="8-访问令牌的安全管理与运维">8. 访问令牌的安全管理与运维
&lt;/h1>&lt;p>最后是关于已获取的访问令牌保存方法的最佳实践。&lt;/p>
&lt;h2 id="1-存入数据库时必须加密">1. 存入数据库时必须加密
&lt;/h2>&lt;p>访问令牌（&lt;code>xoxb-...&lt;/code>）简直就是 Slack 工作区的“万能钥匙”。绝不能以明文（纯文本）形式将其保存在数据库（MySQL, PostgreSQL, MongoDB 等）中。万一发生 SQL 注入等导致数据库泄露的情况，将演变成所有客户的 Slack 被劫持的大惨剧。&lt;/p>
&lt;p>请务必在应用层使用 &lt;strong>AES-256-GCM&lt;/strong> 等强对称密钥加密算法进行加密后再存入 DB。用于加密/解密的主密钥，应使用 AWS KMS (Key Management Service) 或 GCP Cloud KMS 等安全的密钥管理服务进行严格管理。&lt;/p>
&lt;h2 id="2-令牌轮换token-rotation">2. 令牌轮换（Token Rotation）
&lt;/h2>&lt;p>持续使用长期有效的令牌伴随着风险。在最新的 OAuth 实现中，建议引入利用“刷新令牌（Refresh Token）”每隔数小时重新颁发新访问令牌的机制（Token Rotation）。在 Slack API 中，也可以通过可选设置启用令牌轮换。&lt;/p>
&lt;hr>
&lt;h1 id="总结">总结
&lt;/h1>&lt;p>本文结合 Slack App 集成的具体 Node.js 实现代码，详细解说了 OAuth 2.0 的授权码授权流程。&lt;/p>
&lt;ol>
&lt;li>通过意识到 &lt;strong>4 个角色（RO, Client, AS, RS）&lt;/strong>，能使整个系统的架构变得清晰。&lt;/li>
&lt;li>&lt;strong>授权码授权&lt;/strong> 通过巧妙地区分使用浏览器与服务器之间的通信路径（前台/后台通道）来确保安全性。&lt;/li>
&lt;li>深入了解利用 &lt;strong>&lt;code>state&lt;/code> 参数&lt;/strong> 防御 CSRF 攻击、利用 &lt;strong>PKCE&lt;/strong> 防止授权码拦截攻击等背后的密码学机制，是实现安全代码的捷径。&lt;/li>
&lt;li>基于 &lt;strong>最小权限原则&lt;/strong> 的作用域设计以及存入 DB 时的加密，是运维上绝对不可或缺的要素。&lt;/li>
&lt;/ol>
&lt;p>OAuth 2.0 非常深奥，单是 RFC 就有庞大的规范，但像这样以实际的平台（Slack）为目标，边动手边学习，应该就能切实感受到其洗练的设计理念与坚固的安全机制。如果在今后的应用开发和 API 集成实现中，本文的知识能派上用场，那将是我的荣幸。&lt;/p></description></item></channel></rss>