第05章 · SSO 与微信扫码登录(正式版)

2022-12-30T14:11:02+08:00 | 72分钟阅读 | 更新于 2022-12-30T14:11:02+08:00

@

写在最前面:这一版和普通讲义有什么不同

这是《内容社区》课程第 05 章。它假设你已经跟着前四章走完了 Gin / GORM / JWT / Redis / 测试,但完全没接触过 OAuth2——这是本章最大的新概念,也是难度峰值。

这一版做了三件事:

  1. OAuth2 从零讲起。原文默认你懂授权码模式,但你可能第一次听说。本章用"酒店房卡"类比 + 一张完整时序图,把"为什么要多一个 code"讲透。
  2. 术语不跳过。凡是出现你没见过的词(OAuth2、SSO、CSRF、配置中心、结构化日志),当场用「补课胶囊」补上,不会让你自己去搜。
  3. 结尾有面试问答。12 道题,按"基础 → 进阶 → 追问"三档排列,每题告诉你一句话结论、展开怎么答、面试官想听什么词、新手最容易答错在哪。

如果你想要"高密度速查版",可以看原版第 05 章 ;两版知识点完全一致,只是讲法不同。


一、本章导读

前四章我们一直在做一件事:让用户在我们自己这里注册账号、用 JWT 登录。但真实产品里还有一类非常常见的诉求——用户不想再记一套账号密码,希望"用微信扫一下就登录"。这一章就要把登录从"自己签发凭证"推进到"借助第三方身份完成登录"。

我们将以微信扫码登录为载体,系统学习 SSO(单点登录) 思想与 OAuth2 授权码流程,并解决它带来的一连串工程问题:长短 token 的设计与刷新、JWT 无状态机制下如何实现退出登录。登录之外,本章还会补齐两块工程基础设施——用 viper 管理配置、用 zap 记录日志。学完本章,你将能够独立设计并实现一套生产可用的第三方登录与认证会话体系。

开读前先看一眼全局:微信扫码登录的完整旅程就这七步——记住"code 换 token、token 拿用户、用户映射本站账号"这条主线,正文所有细节(state 防 CSRF、长短 token、退出登录)都是在加固它。

flowchart LR
    A["用户点微信扫码"] --> B["跳转微信授权页
带 state 防 CSRF"] B --> C["用户确认授权"] C --> D["微信回调携带 code"] D --> E["用 code 换 access_token"] --> F["拉取微信用户信息"] F --> G["查找 / 注册本站账号"] --> H["签发 JWT 长短 token"] H --> I["后续用 refresh token
静默续期"]

这张图怎么读:这是用户视角的单向旅程——用户点微信扫码后,浏览器先被跳转到微信授权页(B,带 state 防 CSRF),用户在微信域名下确认授权(C),微信再带着 code 回调我们(D)。此后数据流分两段:服务端拿 code 换 access_token(E)、再用 token 拉取微信用户信息(F),然后用 union_id 查找或注册本站账号(G),签发我们自己的长短 token(H);之后的访问靠短 token,过期后由 I 用 refresh token 静默续期。整条主线就是"code 换 token → token 拿用户 → 用户映射本站账号",正文里的 state 校验、长短 token、退出登录都是在给这条链路加锁。


二、本章知识点大纲

先把这张地图看一遍,知道这一章要往哪走、走到哪算够。

必会(学完必须能独立写出来)

  1. 不看资料独立画出 OAuth2 授权码流程的时序图,说清 code 与 access_token 各自走哪条通道。
  2. 给微信回调的 Callback 加上 state 校验,并写出一个"攻击者用别人 code 绑账号"的测试证明它能拦住。
  3. 实现长短 token:登录返回 access_token + refresh_token,提供刷新接口,明白为什么刷新必须由前端触发。
  4. 用 ssid + Redis 实现退出登录,并故意停掉 Redis 验证降级策略下已登录用户仍能访问。
  5. 用 viper 读取本地 YAML 配置、用 etcd 远程配置;用 zap 打结构化日志并抽象自己的 Logger 接口。

理解(能讲清原理和取舍,不要求背代码)

  1. OAuth2 授权码模式四步流程:跳转授权 → 用户同意 → 拿 code → 换 token,以及每一步为什么这么设计。
  2. 为什么需要两个 token:核心矛盾是"越长寿越易用,越长寿越危险",长短 token 是折中。
  3. JWT 退出登录的 ssid 方案:黑名单 vs 白名单、废长 token 还是两个都废、降级策略为什么只在高频路径生效。
  4. 配置的来源与优先级、两次加载的必要性。
  5. 结构化日志相比 log.Println 的优势,以及"宁滥勿缺"“优先 AOP"的原则。

了解(知道有这回事,面试能说出名字和一句话定位)

  1. CSRF 攻击:state 参数防的是什么,跨站请求伪造的本质。
  2. 布隆过滤器:作为 ssid 黑名单的替代方案,有假阳性、要 Redis 兜底。
  3. 适配器模式 vs 装饰器模式:日志 API 抽象用到了适配器。
  4. 配置中心 etcd 的 Raft 一致性、watch 机制(简历可写)。

前置依赖与补课清单

本章用到前四章的知识,同时引入五个你大概率第一次接触的概念。前四章的铺垫本章直接复用,新概念一律就地补「补课胶囊」。

需要你已掌握在哪章本章会不会补补在哪一节
Gin 路由、Handler 写法第 02 章❌ 不补,这是真前提—
GORM、面向接口编程、依赖注入第 03、04 章❌ 不补—
JWT 三段结构、middleware 校验第 03 章❌ 不补—
Redis 基本操作第 03 章❌ 不补—
OAuth2 / 授权码模式全新✅ 补3.2 / 3.3 节
SSO 单点登录全新✅ 补3.1 节
CSRF 攻击全新✅ 补3.7 节
配置中心是什么全新✅ 补3.11 节
结构化日志是什么全新✅ 补3.12 节

不需要你提前懂的:OAuth2 的细节、etcd 原理、zap 配置——本章逐个铺台阶。

预计时长与学习成果

  • 预计时长:4 小时(精读 + 跟着敲全部代码);如果只求主干,2 小时可过一遍。

  • 学完你能跑出来的东西(验收标准,做到才算过关):

    在 webook 项目里跑通:访问 /oauth2/wechat/authurl 拿到微信授权 URL → 在浏览器走完扫码 → 回调自动注册/登录 → 后续请求带长短 token → 调 /refresh 刷新 → 调 /logout 退出后旧 token 失效(Redis 挂掉时仍能降级放行)。同时配置从 config.yaml 加载,关键路径日志用 zap 结构化输出。

flowchart LR
    A["3.1 SSO 是什么"] --> B["3.2 OAuth2 是什么"]
    B --> C["3.3 授权码四步流程"]
    C --> D["3.4 为什么需要 code"]
    D --> E["3.5 微信扫码 API"]
    E --> F["3.6 设计与实现"]
    F --> G["3.7 state 防 CSRF"]
    G --> H["3.9 长短 token"]
    H --> I["3.10 退出登录 ssid"]
    I --> J["3.11 配置模块"]
    J --> K["3.12 日志模块"]
    K --> L["第四节 跟着写
扫码登录 + 配置 + 日志"] L --> M["第七节 面试问答"]

这张图怎么读:路线前半是登录主线——3.1 到 3.4 依次铺好 SSO、OAuth2、授权码四步、“为什么需要 code"的概念台阶,3.5 对齐微信 API 参数,3.6 落地实现,3.7 用 state 防 CSRF 给回调加固。后半是登录态治理——3.9 用长短 token 化解"越长寿越易用、越长寿越危险"的矛盾,3.10 用 ssid 解决 JWT 退出登录;最后 3.11、3.12 补上配置与日志两块基础设施,汇入第四节"跟着写:扫码登录+配置+日志”,第七节面试问答收口。3.1-3.4 概念节可快读,3.6-3.10 建议对照代码精读。

SSO 与扫码登录登录流程登出与 Token配置与日志SSO 单点登录概念OAuth2 授权码四步state 防 CSRF微信 API 三步走长短 token 设计前端触发刷新ssid + Redis 黑名单降级 / 布隆过滤器viper 本地 + etcd 远程两次加载 / 优先级zap 结构化日志Logger 接口抽象
复习提示:怎么读这一章:每一节都按同一个节奏推进——先抛问题 → 给个直觉(类比或图)→ 最小可运行代码 → 逐行讲解 → 常见坑 → 小收口。如果你只想快速过一遍,可以只读每节的"抛问题"和"小收口";如果要真学会,就把每个代码块都敲一遍。遇到蓝色「补课胶囊」是给你补前置概念的,别跳过。

三、正文

3.1 SSO 是什么

先抛问题

我们项目现在用的是"自己签发 JWT"的登录方式。但你想想自己手机里的 App:微信、抖音、美团……你是真的在每个 App 里都注册了账号吗?还是大多数时候都是"用微信一键登录”?

这就引出一个问题:能不能让用户用第三方的身份登录我们的系统,而不用在我们这里再注册一遍? 这就是 SSO 要解决的问题。

给个直觉:公司的一卡通

补课胶囊 · SSO(单点登录) 一句话:在多个互信系统里,登录一次就能处处通行,不用每个系统都登录一遍。 类比:公司发的"一卡通"——你在前台刷脸领了一张门禁卡,之后进大楼、进食堂、进健身房都不用再证明身份。 最小示例:你用微信登录了"腾讯文档",再打开"腾讯会议"发现已经自动登录了——因为它们同属腾讯、互相信任,这就是 SSO 在起作用。 回到主线:微信扫码登录,本质就是让"微信"作为那个统一的登录入口,我们的 webook 是其中一个信任微信的应用。

SSO(Single Sign-On,单点登录):用户在一个登录入口登录后,访问同一公司下所有互信的应用都不需要再次登录。

SSO 的常见实现有:

  • OAuth2:授权码模式(最常用,本文重点)
  • CAS:中央认证服务,多用于传统企业内网
  • JWT + 共享:把 token 通过 Cookie 共享给同根域下的子应用

这三种方案没有绝对的高下,选型取决于应用之间的信任关系与部署形态。本书只展开第一种——它是互联网产品接入第三方登录的事实标准,也是本章后续所有内容的基础。

微信扫码登录本质就是一个 OAuth2 授权码流程:你作为用户,授权 webook 这个第三方应用拿到了 access_token,webook 就认为你登录了。

小收口

SSO 是"一次登录处处通行"的思想,OAuth2 授权码模式是它在互联网产品里最主流的落地方式。下一节我们就从零拆开 OAuth2 到底是什么。


3.2 OAuth2 是什么

先抛问题

“用微信扫码登录"听起来很自然,但技术上有个矛盾:你不想把家门钥匙(微信账号密码)交给 webook,可 webook 又必须能代表你去微信那里证明"这人确实是微信用户”。怎么办?

如果让用户把微信账号密码直接告诉 webook,后果很可怕:webook 不仅能登录,还能改你密码、看你聊天记录、用你身份干任何事,而且你改了密码 webook 就挂了。所以绝对不能把账号密码交给第三方。

给个直觉:酒店房卡

补课胶囊 · OAuth2 一句话:OAuth2 是一套"授权第三方有限访问"的协议,核心是"给房卡不给钥匙"。 类比:你出差住酒店。你不想把家门钥匙(账号密码)交给服务员,但你希望他能进你房间打扫。于是前台给你一张"房卡"——它有时限(退房失效)、权限受限(只能开你这间房,不能开保险箱,不能去别的楼层)。你把房卡给服务员,他就能干活,却拿不到你家钥匙。 最小示例:微信扫码登录 = 你授权 webook 一张"房卡"(access_token),webook 凭卡只能读取你的基本信息,改不了你微信密码,也碰不到你聊天记录。 回到主线:这张"房卡"在 OAuth2 里叫 access_token,而"发房卡"的过程就是"授权码模式"。

OAuth2 是一个授权(Authorization)框架,注意是"授权"不是"认证(Authentication)"——它解决的是"我允许第三方以我的名义做某件事",而不是"证明我是我"。(顺带一提,证明你是谁通常由 OpenID Connect 在 OAuth2 之上做,本章不展开。)

在授权码模式里,有两个核心凭证,先记住它们的名字,后面会反复出现:

凭证是什么生活类比
code(授权码)一次性的临时号码,用来"换房卡"前台给你的"临时排队号"
access_token(访问令牌)真正的房卡,用来访问资源能开你房门的房卡

小收口

OAuth2 的核心思想是"给房卡不给钥匙"——用户授权第三方一个有时限、有限权的 token,而不是账号密码。下节我们把"发房卡"的整个过程,用时序图一步步走完。


3.3 授权码模式四步流程

先抛问题

上一节我们知道了要发"房卡"(access_token)。但 OAuth2 的授权码模式不是一步到位把房卡塞给你,而是分了好几步。为什么这么绕?每一步到底在防什么?这一节我们把整条流程走一遍。

给个直觉:四步领卡

授权码模式可以概括成四步:

  1. 跳转授权:webook 把用户浏览器重定向到微信授权页(“去前台领号”)
  2. 用户同意:用户在微信页面扫码、点"确认授权"(“你亲自按了确认”)
  3. 拿 code:微信重定向回 webook,地址栏带一个 code(“前台给你一个临时排队号”)
  4. 换 token:webook 后端拿 code + secret 去微信换 access_token(“你拿号去后台换真正的房卡”)

先看完整的时序图,重点盯住两个凭证各自的去向——code 出现在第 4 步,access_token 出现在第 6 步:

sequenceDiagram
    participant U as 用户
    participant W as webook(客户端)
    participant X as 微信(授权+资源服务器)
    U->>W: 1. 点击登录
    W->>X: 2. 重定向到微信授权页(带 client_id+redirect_uri+state)
    U->>X: 3. 扫码确认
    X->>W: 4. 重定向回 webook(带 code+state)
    W->>X: 5. 用 code 换 access_token
    X->>W: 6. 返回 access_token
    W->>X: 7. 用 token 拿用户信息
    X->>W: 8. 返回用户信息
    W->>U: 9. 登录成功

逐行讲解(四步为什么这么设计)

步骤动作为什么这么设计
1-2 跳转授权浏览器被重定向到微信扫码动作必须发生在微信的域名下,用户才敢放心扫码。如果扫码页面是 webook 自己画的,用户凭什么相信你不是钓鱼?
3 用户同意用户在微信页点"确认"这是"用户亲手授权"的关键一步,意味着授权是用户真实意愿,而不是程序偷偷做的
4 拿 code微信带着 code 跳回 webookcode 是一次性临时号,走浏览器通道。它不能直接当房卡用,只能用来换房卡
5-6 换 tokenwebook 后端 code+secret 换 access_token这一步走服务端到服务端的安全通道,secret 不暴露给浏览器,拿到真正的房卡

常见坑

坑 1 · 把"授权"和"资源"混为一谈。有人以为微信扫码只是为了"登录",其实授权码流程拿到 access_token 之后,webook 还能继续用这个 token 去微信拉用户的昵称、头像。授权(你能做什么)和资源(你的数据)是分开的,但共用同一个 token。

坑 2 · 认为第 4 步的 code 就是登录凭证。code 只是"换房卡的号",本身不能访问任何资源,而且只能用一次、几分钟就过期。真正能用的凭证是第 6 步换来的 access_token。

小收口

四步流程的骨架是:用户同意 → 发 code → 后端拿 code 换 token。现在你可能会问:第 4 步拿到了 code,第 6 步才拿到真正能用的 token,为什么不全在浏览器一步给完? 这就是下一节要讲的关键设计。


3.4 为什么需要 code 这一步(而不是直接给 token)

先抛问题

既然最终要的是 access_token,为什么不直接在第 4 步通过浏览器把 token 发给 webook?中间多一个 code 不是脱裤子放屁吗?——这是 OAuth2 面试最高频的追问,也是理解整个协议安全边界的钥匙。

给个直觉:token 不能出现在地址栏

核心原因:access_token 是敏感的,如果直接通过浏览器 URL 传递,会被泄露。

为什么 URL 传递会泄露?因为浏览器访问一个 URL 时,这个 URL 会出现在好几处不安全的地方:

  • 浏览器地址栏:旁边人一眼就能看到
  • Referer 请求头:你跳转到下一个页面时,上一个 URL(含 token)会被带过去
  • 浏览器历史记录、代理服务器日志:都会被记下来

所以 OAuth2 的设计是:先用浏览器传一个临时 code(短效、只能用一次),后端再用 code + secret 换 access_token,全程走服务端到服务端的安全通道。code 泄露了危害也极小,access_token 则从不经过浏览器。

flowchart LR
    subgraph 不安全通道[浏览器通道 不安全]
        A["用户浏览器 ↔ 微信
传递 code + state"] end subgraph 安全通道[服务端通道 安全] B["webook后端 ↔ 微信
传递 code+secret → access_token"] end A -->|"微信把 code 跳回"| B

这就引出下一个自然的问题:如果 code(临时授权码)被黑客拿到了,会发生什么? 这也是面试常被追问的点。

基本无事可做。code 有三重保护:

  1. 短效:code 的有效期极短(一般几十秒到几分钟),黑客拿到时大概率已经过期
  2. 一次性:code 用一次就作废。就算黑客抢在你前面兑换,你自己兑换时会失败,反而能发现异常
  3. 兑换需要 secret:用 code 换 access_token 必须带上 appid + secret(可以理解为"人码合一"),secret 只存在你的后端,黑客手里只有 code 是兑换不了的

所以 OAuth2 的安全边界划得很清楚:code 可以走浏览器这条不安全通道,access_token 和 secret 只走服务端这条安全通道。

与之相关的另一个问题是:为什么微信回调地址的域名必须与注册时填写的一致?

redirect_uri 必须是你注册应用时填写的域名,微信会严格校验。如果没有这个限制,攻击者可以把 redirect_uri 改成自己的服务器——用户扫码授权后,code 就被发到攻击者手里,整个授权流程被劫持。域名白名单是 OAuth2 防御"授权码劫持"的第一道闸门。

常见坑

⚠️ 新手必踩的坑:误以为 code 和 token 一样敏感。记住分工:code 走浏览器、短效、一次性、兑换需 secret;token 走服务端、长效、敏感。安全设计的分水岭就在"token 绝不进浏览器"。

小收口

到这里,OAuth2 授权码模式的"为什么"已经讲透:code 是给浏览器用的临时号,token 只走服务端。带着这个认知,下一节我们正式进入微信扫码登录的具体 API。

3.5 微信扫码登录 API 详解

先抛问题

概念清楚了,但"扫码登录"具体落地要跟微信开放平台打哪些交道?每一步微信要什么参数、返回什么数据?这一节可以先当"接口速查表"来读,后面写代码时会逐个对上。

给个直觉:把登录拆成三步

微信扫码登录整体拆成三步,就像"去前台领号 → 前台喊你回来 → 拿号换房卡":

3.5.1 第一步:构造跳转到微信的 URL

第一步是构造跳转到微信授权页的 URL。它由固定格式拼接而成,需要带上这些参数:

参数说明
appid微信开放平台注册的 ID
redirect_uri微信跳回来的地址(必须与注册时填的域名一致)
response_type固定为 code
scope固定为 snsapi_login(登录权限)
state随机数,防 CSRF

表中 appid、redirect_uri 在微信开放平台注册时就确定了,response_type 和 scope 是固定值,真正需要我们动态生成并校验的只有 state——它正是 3.7 节防 CSRF 的主角。

3.5.2 第二步:微信跳回来,带 code

微信扫码确认后,会重定向到 redirect_uri?code=xxx&state=xxx。这一步浏览器自动完成,你的回调接口只需要读这两个参数。

3.5.3 第三步:用 code 换 access_token

后端用 appid + secret + code 调用微信接口,换取真正的 access_token。这步走服务端到服务端,secret 不出后端。

3.5.4 微信返回的字段

第三步兑换成功后,微信返回的字段可以分成两组:一组是"凭证",用于继续调用微信的 API;一组是"身份",用于识别用户——

授权码部分:

  • access_token:后续访问微信 API 的凭证(短 token)
  • expires_in:access_token 的有效期
  • refresh_token:access_token 过期后用它换新的

ID 部分:

  • open_id:用户在你这个应用下的唯一 ID
  • union_id:用户在你这个公司下的唯一 ID

3.5.5 open_id 与 union_id 的区别

假设你们公司在微信注册了 A、B 两个产品。对用户张伟:

  • 张伟在 A 上有一个 open_id
  • 张伟在 B 上有另一个不同的 open_id
  • 但张伟在 A 和 B 上的 union_id 是同一个

所以跨产品识别同一个用户,要用 union_id。

flowchart TD
    subgraph A[产品A]
        OA["open_id: aaa"]
    end
    subgraph B[产品B]
        OB["open_id: bbb"]
    end
    OA --> U["union_id: 同一个"]
    OB --> U

小收口

微信 API 三步本质上是"拼 URL → 收 code → code 换 token",背后就是 3.3 的四步流程。下一步我们把它落成代码,先定义接口再写实现。


3.6 设计与实现

先抛问题

流程和 API 都清楚了,现在动手写代码。整体思路是对外暴露两个接口:一个把用户送去微信(返回授权 URL),一个接住微信的回调(完成登录)。我们延续第 04 章的习惯,先定义抽象、再写实现。

3.6.1 提供两个接口

先看 Handler 的骨架。它依赖三个协作对象——微信 OAuth2 服务、用户服务、JWT 处理,分别对应"换凭证、找用户、发 token"三件事:

// web/wechat.go
type OAuth2WechatHandler struct {
	svc     wechat.Service       // 微信 OAuth2 服务
	userSvc service.UserService  // 用户服务
	jwt     jwt.Handler          // JWT 处理
}

func (h *OAuth2WechatHandler) RegisterRoutes(server *gin.Engine) {
	g := server.Group("/oauth2/wechat")
	g.GET("/authurl", h.AuthURL)    // 1. 获取微信授权 URL
	g.GET("/callback", h.Callback)  // 2. 处理微信回调
}

3.6.2 wechat.Service:构造授权 URL

按照面向接口的习惯,先把微信服务抽象成 Service 接口——只暴露"构造授权 URL"和"用 code 换用户信息"两个方法,再由 WechatService 给出真实实现:

// service/wechat/service.go
package wechat

import (
	"context"
	"fmt"
	"net/url"
)

// Service 微信 OAuth2 服务抽象
type Service interface {
	// AuthURL 生成跳转到微信的授权 URL
	AuthURL(ctx context.Context, state string) (string, error)
	// Verify 用 code 换 access_token 和用户信息
	Verify(ctx context.Context, code string) (Result, error)
}

type Result struct {
	OpenId       string
	UnionId      string
	AccessToken  string
	RefreshToken string
	ExpiresIn    int64
}

// 腾讯实现(构造 URL)
type WechatService struct {
	appId       string
	appSecret   string
	redirectURL string
}

func NewWechatService(appId, appSecret, redirectURL string) *WechatService {
	return &WechatService{appId: appId, appSecret: appSecret, redirectURL: redirectURL}
}

func (s *WechatService) AuthURL(ctx context.Context, state string) (string, error) {
	// 步骤 1:拼接微信授权 URL(appid + 回调 + 固定 response_type/scope + state)
	return fmt.Sprintf(
		"https://open.weixin.qq.com/connect/qrconnect?appid=%s&redirect_uri=%s"+
			"&response_type=code&scope=snsapi_login&state=%s",
		s.appId, url.QueryEscape(s.redirectURL), state), nil
}

3.6.3 AuthURL Handler

这个接口只做三件事:生成 state、把 state 写进 Cookie、返回授权 URL。注意"生成"和"留存"必须在这里同步完成,这正是后面防 CSRF 时"存一份"的动作:

func (h *OAuth2WechatHandler) AuthURL(ctx *gin.Context) {
	// 步骤 1:生成 state,用 UUID 防止被猜
	state := uuid.New().String()
	// 步骤 2:把 state 写入 Cookie(JWT 形式),等回调时校验
	val := h.jwt.SetStateToken(ctx, state)
	ctx.SetCookie("jwt-state", val, 600, "/", "", false, true)

	// 步骤 3:构造微信授权 URL 返回给前端
	url, err := h.svc.AuthURL(ctx, state)
	if err != nil {
		ctx.JSON(http.StatusOK, web.Result{Code: 5, Msg: "系统错误"})
		return
	}
	ctx.JSON(http.StatusOK, web.Result{Data: url})
}

3.6.4 Callback Handler

回调是整个流程的收口。它按顺序做四件事:校验 state、用 code 换用户信息、FindOrCreate 用户、设置登录态:

func (h *OAuth2WechatHandler) Callback(ctx *gin.Context) {
	// 步骤 1:校验 state(防 CSRF)
	err := h.jwt.CheckState(ctx, ctx.Query("state"))
	if err != nil {
		ctx.JSON(http.StatusOK, web.Result{Code: 4, Msg: "非法请求"})
		return
	}
	// 步骤 2:用 code 换微信用户信息
	wechatRes, err := h.svc.Verify(ctx, ctx.Query("code"))
	if err != nil {
		ctx.JSON(http.StatusOK, web.Result{Code: 5, Msg: "系统错误"})
		return
	}
	// 步骤 3:FindOrCreate:新用户直接注册,老用户直接登录
	u, err := h.userSvc.FindOrCreate(ctx, wechatRes.UnionId)
	if err != nil {
		ctx.JSON(http.StatusOK, web.Result{Code: 5, Msg: "系统错误"})
		return
	}
	// 步骤 4:设置登录态(长短 token)
	if err = h.jwt.SetLoginToken(ctx, u.Id); err != nil {
		ctx.JSON(http.StatusOK, web.Result{Code: 5, Msg: "系统错误"})
		return
	}
	ctx.JSON(http.StatusOK, web.Result{Msg: "登录成功"})
}

有两个细节值得注意。一是 FindOrCreate 的设计:扫码登录没有独立的"注册"步骤,用户第一次扫码时系统直接以 UnionId 创建账号,之后再扫码就命中已有用户——登录与注册被合并成了一次查询。二是 SetLoginToken 内部会生成一个 ssid,它的用途要到 3.10 节讲退出登录时才揭晓,这里先留个印象。

小收口

两个接口串起了整个 OAuth2 流程:AuthURL 负责"发 state + 给跳转链接",Callback 负责"验 state + 换 token + 找用户 + 发登录态"。但 state 到底防的是什么攻击?下一节专门讲清。


3.7 state 参数:防 CSRF 攻击

先抛问题

3.6.3 里我们费劲生成了一个 state,又把它写进 Cookie,回调时还要比对。这个 state 到底在防什么?不校验会出什么大事?

给个直觉:攻击者用他的号绑你的账号

补课胶囊 · CSRF(跨站请求伪造) 一句话:攻击者诱导你(带着你的登录身份)去发起一个你没想做的请求。 类比:你戴着工牌进了公司,攻击者趁你不注意,把你的工牌借去刷开了不该开的门——门禁系统只认工牌,不认"是不是你本人想开"。 最小示例:你登录着网银,又点开了攻击者的网页;那个网页悄悄向银行发了"转账"请求,浏览器自动带上了你的 Cookie,银行以为是本人操作。 回到主线:微信登录里,攻击者不抢你的账号,而是诱导你"带着你的微信登录态,去用攻击者的 code 绑定账号"——state 就是用来戳穿这出戏的。

3.7.1 攻击场景

理解 state 的核心是抓住"攻击者让你用他的临时授权码来绑定账号":

  1. 攻击者弄到一个绑定微信的临时授权码 A
  2. 正常用户登录成功
  3. 攻击者伪造页面诱导用户点击,攻击者带着正常用户的 Cookie + 攻击者的 code 去请求
  4. 结果:攻击者通过微信扫码登录,看到正常用户的数据
sequenceDiagram
    participant A as 攻击者
    participant V as 正常用户
    participant W as webook
    A->>V: 诱导点击(带着攻击者的 code)
    V->>W: 正常用户的 Cookie + 攻击者的 code
    Note over W: 若没校验 state, 把攻击者的微信绑到正常用户账号
    W-->>A: 攻击者用自己微信登录, 看到正常用户数据

3.7.2 state 如何解决

看清攻击手法之后,防御思路就自然浮现:攻击者能伪造 code,但伪造不出"我们事先发给正常用户的那份随机数"。于是方案分两步——

  • 生成 AuthURL 时,生成 state 并存起来(JWT 或 Session)
  • 微信跳回来时,把回调的 state 和我们存的 state 比较

如果不一致,说明有人伪造请求。这与 Web 开发里防 CSRF 的经典做法同源:用一个攻击者拿不到的随机值,把"发起登录的人"和"完成登录的人"绑定成同一个人。

3.7.3 用 JWT 记录 state

用 JWT 记录 state 的好处:不用额外存 Redis,直接用 JWT 自带的 state 比较。注意要放在 Cookie 里,因为微信回调直接到后端,不经过前端。如果放 Header,前端代码需要主动加,但这里没法让前端加。

// 检查 state
func (h *JWTHandler) CheckState(ctx *gin.Context, state string) error {
	// 步骤 1:从 Cookie 取出 state token
	ck, err := ctx.Cookie("jwt-state")
	if err != nil {
		return fmt.Errorf("找不到 state cookie")
	}
	// 步骤 2:解析 JWT
	var claims StateClaims
	_, err = jwt.ParseWithClaims(ck, &claims, func(t *jwt.Token) (interface{}, error) {
		return h.stateKey, nil
	})
	if err != nil {
		return err
	}
	// 步骤 3:比对 state 是否一致
	if claims.State != state {
		return fmt.Errorf("state 不匹配")
	}
	return nil
}

如果你们用的是 Session 呢? 也可以把 state 存到 Session(比如基于 Redis 的 Session)里,回调时从 Session 取出来比对。唯一的注意点是:Session 要在用户点击"微信登录"的那一刻就创建好——因为回调请求直接打到后端,到时候再创建就来不及了。

深入讨论:如果存 state 的 Cookie 泄露了呢? 答案是攻击者依然得逞不了。构造跨域攻击时,攻击者伪造的页面只能带上他自己知道的 state(他自己的 Cookie 里没有你的 state);要拿到你的 state Cookie,得先让你的电脑中病毒才行,单纯的跨域攻击做不到。所以"state 藏在 Cookie 里"这个方案,安全性是够用的。

复习提示:现实情况:大部分公司都没处理 state。如果想把这一点讲成面试里的亮点,就要能解释清楚:什么情况下不处理 state 会出问题(CORS 场景),以及如何解决。
复习提示:⚠️ 新手必踩的坑:state 必须"存一份 + 比一次"。常见错误是只在 URL 里生成 state 却没在服务端留存,回调时无法比对——等于没防。正确做法是 AuthURL 时把 state 写进 Cookie(或 Session/Redis),Callback 时取出来和微信回传的 state 严格相等才放行。

小收口

state 是 OAuth2 防 CSRF 的标配:服务端存一份随机数,回调比对。记住口诀"存一份 + 比一次"。


3.8 需求分析方法:参考竞品与扩展性预留

先抛问题

拿到"微信扫码登录"这个需求,新手最容易一头扎进 API 文档。但更聪明的入手方式是什么?以及,要不要"一步到位"把钉钉、Gmail 登录都做了?

3.8.1 参考竞品

拿到"微信扫码登录"这个需求,最直接的入手方式不是翻 API 文档,而是找一个已经做好这个功能的产品,把流程亲自走一遍。比如打开极客时间的登录页:

  1. 勾选同意协议、点击"微信登录",浏览器跳转到微信的页面——注意:扫码这个动作发生在微信的域名下,不是极客时间的页面
  2. 在微信页面上扫码、点击确认登录
  3. 微信跳回极客时间的回调地址,再跳到首页——打开浏览器网络面板能看到"第一跳"的地址和参数

把这三步的每个 URL、每个参数都看清楚,你就知道要实现什么了。竞品就是最好的需求文档。

3.8.2 需求扩展点:要不要提前支持钉钉、Gmail?

往后想一步:将来有没有可能用别的扫码登录(钉钉扫码)?有可能。有没有不是扫码的第三方授权登录(Gmail)?也有可能。

那这次需求要做到哪一步?答案是:只考虑微信,等新需求来了再说。不过我们在结构上留了余地——wechat.Service 是一个接口,将来接钉钉时再定义一个 dingtalk.Service,Handler 层稍加改造就能复用。这叫"预留扩展点,但不过度设计"。

复习提示:类比:装修新房时预留好网线接口(扩展点),但没必要现在就把每个房间的路由器都买好(过度设计)。判断标准是:抽象的成本很低(一个接口),而收益可能很高(将来接新登录方式不用大改)。

3.8.3 深入讨论:为什么不让前端自己拼接微信 URL?

跳转到微信的 URL 就是固定格式的字符串拼接,前端完全可以自己拼,为什么还要后端提供 /authurl 接口?两个原因:

  1. 能后端解决的就不麻烦前端:后端在你自己的控制之下,要改参数(换 appid、加新参数)只改一处就发版;URL 散在前端代码里,改起来要看前端的发布节奏
  2. state 必须由后端生成和留存:state 要"存一份 + 比一次",“存"这个动作只能发生在后端。如果前端自己拼 URL,它还是得先找后端要 state——绕了一圈,不如整个 URL 都由后端给

小收口

需求分析的三个要点:学竞品走流程、只做当前需求但留接口扩展点、URL 拼接交给后端。下面进入本章最难也最面试高频的部分:长短 token。


3.9 长短 token 与登出

先抛问题

扫码登录跑通之后,一个更普遍的问题浮出水面:无论用户用什么方式登录,登录态最终都要落到 token 上,而 token 的寿命怎么定都两头不讨好——设太长怕泄露,设太短用户老要登录。怎么办?

3.9.1 长短 token 的设计

在微信扫码登录里你已经看到:

  • access_token:短 token,用于访问资源,频繁使用,容易泄露
  • refresh_token:长 token,access_token 过期后用它换新的,使用频率低,不容易泄露

这组概念虽然是从微信的返回值里引出来的,但并不只服务于微信登录——我们自己签发的 JWT 登录态同样可以套用这套设计。

3.9.2 为什么需要两个 token

核心矛盾:

  • token 越长寿,用户体验越好(不用频繁登录)
  • token 越长寿,泄露后危害越大

长短 token 的折中方案:

  • 短 token 寿命短(如 30 分钟),即使泄露损失也小
  • 长 token 寿命长(如 7 天),但只在登录和刷新时用,不直接访问资源,泄露风险小
flowchart TD
    U[用户] -->|携带 access_token 访问资源| R[业务资源]
    R -. access_token 过期 .-> F[返回 401]
    F --> RF[前端用 refresh_token 换新的 access_token]
    RF --> U

3.9.3 改造已有代码

方案定了,落实到代码就是前后端各几处改动。先看后端要做什么:

后端改造点:

  1. 登录成功时返回两个 token:access_token(短)+ refresh_token(长)
  2. 提供新的刷新 token 接口 refresh_token
  3. 去掉 JWT middleware 中的"自动刷新过期时间"机制

前端改造点:

  1. 收到 401 响应时,调用 refresh_token 拿新 token
  2. 用新 token 重试原请求

后端改造的核心是 SetLoginToken:登录成功时一次性生成短、长两个 token,通过不同的响应 Header 分别下发——

// 设置长短 token
func (h *JWTHandler) SetLoginToken(ctx *gin.Context, uid int64) error {
	// 步骤 1:生成 ssid,用于退出登录
	ssid := uuid.New().String()

	// 步骤 2:生成短 token(access_token,30 分钟)
	accessToken, err := h.genToken(uid, ssid, h.accessKey, 30*time.Minute)
	if err != nil {
		return err
	}
	ctx.Header("x-jwt-token", accessToken)

	// 步骤 3:生成长 token(refresh_token,7 天)
	refreshToken, err := h.genToken(uid, ssid, h.refreshKey, 7*24*time.Hour)
	if err != nil {
		return err
	}
	ctx.Header("x-refresh-token", refreshToken)
	return nil
}
复习提示:⚠️ 新手必踩的坑:跨域场景忘了放行自定义 Header。长短 token 是通过 x-jwt-token / x-refresh-token 这种自定义 Header 返回的。同源部署没问题;一旦前后端分离跨域部署,CORS 配置里就必须显式允许这些 Header(Access-Control-Allow-Headers),否则浏览器在预检请求(OPTIONS)就把你拦下,前端怎么调试都拿不到 token。

长短 token 的过期时间怎么设? 没有标准答案,给出常见取值和思路:

token常见寿命设置思路
access_token30 分钟 ~ 2 小时按"一次连续使用时长"设:用户一次浏览会话大概多久,就设多久
refresh_token7 天 ~ 30 天按"可接受的重新登录频率"设:希望用户多久至少重登一次,就设对应的值

核心权衡:access_token 越短越安全(泄露损失小)但刷新越频繁(体验略降);refresh_token 越长体验越好但泄露后危害越大。安全要求高的系统(支付类)两个都短,内容社区可以放宽。

3.9.4 refresh_token 接口

刷新接口的流程是:从 Header 取出长 token,验签并确认未过期,再确认这个会话没有被登出(ssid 校验),全部通过后颁发一对新 token:

func (h *UserHandler) RefreshToken(ctx *gin.Context) {
	// 步骤 1:从 Header 拿长 token
	tokenStr := ctx.GetHeader("x-refresh-token")
	if tokenStr == "" {
		ctx.AbortWithStatus(http.StatusUnauthorized)
		return
	}
	// 步骤 2:解析长 token(验签用的 refreshKey 内聚在 jwt.Handler 实现里,不外泄给业务层)
	claims, err := h.jwt.ParseRefreshToken(tokenStr)
	if err != nil || claims.ExpiresAt.Before(time.Now()) {
		ctx.AbortWithStatus(http.StatusUnauthorized)
		return
	}
	// 步骤 3:校验 ssid(用户是否已退出登录)
	err = h.jwt.CheckSession(ctx, claims.Ssid)
	if err != nil {
		ctx.AbortWithStatus(http.StatusUnauthorized)
		return
	}
	// 步骤 4:颁发新的短 token 和长 token
	err = h.jwt.SetLoginToken(ctx, claims.Uid)
	if err != nil {
		ctx.JSON(http.StatusOK, web.Result{Code: 5, Msg: "系统错误"})
		return
	}
	ctx.JSON(http.StatusOK, web.Result{Msg: "OK"})
}

3.9.5 为什么必须前端触发刷新?

复习提示:面试常问:能不能在 JWT 中间件里发现短 token 过期就自动刷新?

不能。如果后端自动刷新,就和原来的"自动续期"机制一样了,违背了长短 token 的初衷。长短 token 的核心假设是:短 token 频繁使用容易泄露,所以寿命要短;长 token 只在登录和刷新时用,不容易泄露。如果中间件自动刷新长 token,长 token 也会变成"频繁使用”,泄露风险上升。

3.9.6 长 token 泄露了怎么办?

之所以说"能做的事已经不多",是因为 token 一旦签发出去,到期之前它本身就是合法凭证,服务端没有主动收回的手段——这个困境和下一节的"退出登录"同根同源。剩下能做的只有抬高攻击者利用的门槛:

  1. 在长 token 里编码登录时的信息(如 User-Agent),校验时比对
  2. 长 token 只能用一次:每次刷新时颁发新的长 token,旧的作废(缓解但不能根治)

注意这两个手段都只是缓解而非根治。安全设计里常见的现实就是:堵住所有泄露路径不可能,能做的是让攻击的成本高于收益。

小收口

长短 token 用"短 token 干活、长 token 换短 token"的折中,化解了"易用 vs 安全"的矛盾;刷新必须前端触发,否则长 token 也会变"高频"。但还有一个更硬的问题:JWT 是无状态的,用户点了"退出登录"之后,那个还活着的 token 怎么办?


3.10 JWT 退出登录

先抛问题

我们第 03 章学了 JWT 是无状态的——签发后服务端不存任何东西,靠验签验证。这很爽,但带来一个尴尬:用户点了"退出登录",旧的 token 明明还没过期,服务端却"拦不住它"。怎么破?

3.10.1 Session vs JWT 退出

Session 退出登录很简单:删 Cookie + 删 Redis 里的 Session 数据。因为 Session 本身就是服务端存的状态,删了下次请求一来校验就失败。

JWT 退出登录很麻烦:JWT 是无状态的,签发后无法主动作废(除非生成新 token)。多实例部署下,退出登录的请求落到实例 0,后续请求落到实例 1,实例 1 必须能感知到。

解决方案:用 Redis 记录"已退出登录的会话"。

动手之前先想清楚两个设计决策。

3.10.2 决策一:白名单还是黑名单?

用 Redis 记录有两种思路:

  • 白名单:登录时写入 token,校验时查"在不在名单里",退出时删除
  • 黑名单:退出时写入 token,校验时查"在不在名单里",命中则拒绝

应该用黑名单。因为退出登录是比登录低频得多的操作——正常用户很少主动退出,绝大多数会话从头到尾都不会进黑名单,Redis 里只需存极少量数据;用白名单则每个在线会话都要占一个 key,内存开销大得多。

3.10.3 决策二:废掉长 token,还是两个都废掉?

采用了长短 token 之后,让会话失效有两种做法:

  • 只废长 token:那登录校验时就得检测长 token 有没有效——可校验的明明是短 token,凭什么去查长 token?
  • 两个都废:那校验时必须同时拿到长短 token,前端得把两个都传上来,传来传去很烦

既然麻烦的根源是"token 传来传去",那就换个思路:用一个东西标识"这一次登录",长短 token 里都带上它,最后只检测这个标识。这就是 ssid 的由来——它不是拍脑袋发明的,是被设计问题一步步逼出来的。

3.10.4 ssid 方案

思路:每次登录生成一个 ssid(Session ID),写入长短 token。退出登录时把 ssid 加入 Redis 黑名单。校验时检查 ssid 是否在黑名单中。

// 登录时生成 ssid(在 SetLoginToken 内部):
//   ssid := uuid.New().String()
// 写入 access_token 和 refresh_token 的 claims

// 退出登录时,把 ssid 加入 Redis
func (h *JWTHandler) Logout(ctx *gin.Context, ssid string) error {
	// 步骤 1:标记 ssid 不可用,过期时间和长 token 一致(7 天)
	return h.cmd.Set(ctx, fmt.Sprintf("users:ssid:%s", ssid),
		1, 7*24*time.Hour).Err()
}

// 校验时检查
func (h *JWTHandler) CheckSession(ctx *gin.Context, ssid string) error {
	// 步骤 1:查 Redis 黑名单
	cnt, err := h.cmd.Exists(ctx, fmt.Sprintf("users:ssid:%s", ssid)).Result()
	if err != nil {
		// 降级策略:Redis 挂了就不严格校验,保证登录用户可用
		return nil
	}
	// 步骤 2:在黑名单里则拒绝
	if cnt > 0 {
		return errors.New("会话已失效")
	}
	return nil
}
flowchart TD
    L[登录] --> G[生成 ssid 写入 token]
    O[退出登录] --> B[ssid 加入 Redis 黑名单]
    V[后续请求校验] --> C{Redis 里 ssid 存在?}
    C -- 存在 --> R[拒绝: 会话失效]
    C -- 不存在 --> OK[放行]
    C -- Redis 挂了 --> OK
复习提示:⚠️ 新手必踩的坑:退出登录要让客户端也清掉 token。只把 ssid 写进 Redis 黑名单还不够——如果客户端仍带着旧 token 且没走到 Redis 校验(比如某些静态资源或中间件顺序问题),可能绕过。标准做法同时:① 服务端把 ssid 加入黑名单(让后续 JWT 校验失败);② 告诉客户端把长短 token 置为非法值。两层一起上才稳。

3.10.5 降级策略(面试加分点)

复习提示:Redis 崩溃时,要不要严格执行 ssid 校验?

不严格执行。理由:

  • 相比退出登录这种少数情况,绝大多数已登录用户是正常的
  • 如果严格执行,Redis 一挂,所有登录用户全部无法通过校验,业务雪崩
  • 降级策略:Redis 挂了就跳过 ssid 校验,保证大多数用户可用

补充一个对比:refresh_token 接口里也可以做同样的降级,但收益小得多。因为 refresh_token 是低频接口(短 token 半小时才过期一次),Redis 挂了影响的只是"恰好这一刻要刷新 token 的少量用户",就算严格执行校验、直接拒绝,伤害面也很小。而登录校验 middleware 是每个请求都过的高频路径,Redis 一挂还严格执行就是全员 401,这才是降级策略真正要保护的地方。记住:降级策略要用在"高频 + 故障影响面大"的路径上。

3.10.6 退出登录后的清理

退出登录时:

  1. 把 ssid 加入 Redis 黑名单
  2. 把客户端 Cookie/Header 里的长短 token 设为非法值,让客户端在到达 Redis 之前就因 JWT 校验失败而返回 401

3.10.7 重构:抽出 JwtHandler

随着功能增加,JWT 相关代码散落在 UserHandler、WechatHandler、JWTLoginMiddlewareBuilder 各处,屎山味道。重构方案:

// internal/jwt/handler.go
package jwt

type Handler interface {
	// 设置登录态 token(写入响应头)
	SetLoginToken(ctx context.Context, uid int64) error
	// 生成短 token
	GenerateToken(uid int64, ssid string) (string, error)
	// 校验短 token
	ParseToken(tokenStr string) (*Claims, error)
	// 校验长 token(refresh_token),验签 key 内聚在实现里
	ParseRefreshToken(tokenStr string) (*RefreshClaims, error)
	// 设置/校验 state(用于 OAuth2)
	SetStateToken(ctx context.Context, state string) string
	CheckState(ctx context.Context, state string) error
	// 退出登录
	Logout(ctx context.Context, ssid string) error
	CheckSession(ctx context.Context, ssid string) error
}

// Redis 实现
type RedisJWTHandler struct {
	cmd        redis.Cmdable
	accessKey  []byte
	refreshKey []byte
	stateKey   []byte
}

关键点:

  • 把所有 JWT 操作内聚到 jwt.Handler
  • 接口统一用 context.Context,与第四节「跟着写」工程版一致——*gin.Context 由上层经 context.Value 传入(见第四节步骤 2),jwt 包因此不依赖 gin,将来换 Web 框架也不用改
  • UserHandler、WechatHandler、JWTLoginMiddlewareBuilder 都依赖 jwt.Handler 接口
  • jwt.Handler 不直接写响应,让调用者根据返回值自己写(保持职责单一)

先看一眼重构后的架构:三类调用方只依赖接口,长短 token 的验签 key、Redis 黑名单这些细节全部封进 internal/jwt 包——这就是"依赖接口而非实现"在认证模块的落地形状。

flowchart TB
    subgraph CALLERS["调用方:只依赖接口"]
        UH["UserHandler
登录 / 刷新 token / 登出"] WH["WechatHandler
扫码 OAuth2 回调"] MW["JWTLoginMiddlewareBuilder
每个请求验签 + ssid 校验"] end subgraph JWT["internal/jwt 包"] IF["jwt.Handler 接口
SetLoginToken · ParseToken · ParseRefreshToken
SetStateToken · CheckState · Logout · CheckSession"] IMPL["RedisJWTHandler 实现
accessKey / refreshKey / stateKey"] end R[("Redis
users:ssid:* 黑名单
OAuth2 一次性 state")] UH --> IF WH --> IF MW --> IF IF -.->|"编译期只看得到接口"| IMPL IMPL -->|"读写 ssid 黑名单 / state"| R MW -.->|"Redis 挂了:跳过 ssid 校验(降级放行)"| R

这张图怎么读:自上而下看依赖方向——UserHandler(登录/刷新 token/登出)、WechatHandler(扫码 OAuth2 回调)、JWTLoginMiddlewareBuilder(每个请求验签+ssid 校验)三类调用方各自只用实线指向 jwt.Handler 接口,彼此互不知晓;接口与 RedisJWTHandler 实现之间是虚线,表示"编译期只看得到接口",accessKey/refreshKey/stateKey 三把验签密钥全封在实现内部;实现(IMPL)再向下读写 Redis 中的 ssid 黑名单与一次性 state。最下面 MW 直连 Redis 的虚线是关键失败分支:Redis 挂掉时 middleware 跳过 ssid 校验、降级放行,保证登录用户不被存储故障拖垮。

3.10.8 为什么不用 user id 代替 ssid?

复习提示:面试衍生:能不能用 user id 标识"已退出登录"?

可以,但要注意:

  1. 登录时要删除 user id 的记录
  2. 要处理"一边登录一边退出登录"的并发问题(用户登录会清掉记录,使退出登录失效)

所以不建议用 user id,建议用 UUID 生成的 ssid。每次登录生成新的 ssid,互不影响。

3.10.9 布隆过滤器方案(面试方案)

另一种"记录已退出登录会话"的方案是布隆过滤器:

  • 退出登录时把 ssid 哈希到比特数组的 N 个位置置 1
  • 校验时检查这些位置是否都为 1

缺点:

  • 布隆过滤器有假阳性(说存在但实际不存在),所以还是要 Redis 兜底
  • 布隆过滤器本身也要用 Redis 实现,至少访问一次 Redis

所以改进有限,主要作为面试亮点。

布隆过滤器原理:选取 N 个哈希函数,计算业务值的哈希,将比特数组对应位置置 1。查询时:

  • 如果任一位置为 0 → 一定不存在
  • 如果所有位置都为 1 → 可能存在(假阳性)
flowchart LR
    S[ssid] --> H1[hash1]
    S --> H2[hash2]
    S --> H3[hash3]
    H1 --> B1[bit 3]
    H2 --> B2[bit 7]
    H3 --> B3[bit 12]

3.10.10 深入讨论:这不又回到 Session 了吗?

你可能会疑惑:JWT 号称"无状态",结果退出登录又要引入 Redis 记录 ssid——兜兜转转,这不是回到 Session 了吗?有什么意义?

答案是:如果你坚持认为"JWT 在退出登录后必须严格不能使用",那你就需要类似的 Session 机制,绕不开。因为 JWT 生成之后,它自己并不记录自己"可用还是不可用"——这个状态只能存在 JWT 外面。

那这套方案相比纯 Session 的差别在哪?校验的主路径不同:

  • 纯 Session:每个请求都要查一次 Redis(重依赖远程存储)
  • JWT + ssid 黑名单:每个请求先本地验签(纯 CPU 计算,不碰网络),只有退出登录的会话才需要 Redis 拦截;Redis 挂了还能降级放行

所以这套方案可以理解为:用极小的有状态代价,换取无状态校验的主体性能。

3.10.11 升职加薪:为 Gin 设计通用的长短 token 机制

长短 token 是一个非常常见的通用方案,完全可以把它做成一个 Gin 的开源中间件/插件:

  • 形态一:单独的长短 token 插件——抽象出"生成 token / 校验 token / 刷新 token"的方法,用户自己传签名密钥和过期时间
  • 形态二:结合 JWT 的长短 token 插件——直接封装 JWT 的签发与校验,再给用户一个选项:是否开启 session 机制(也就是本章的 ssid 黑名单)

做完这个插件,相当于为用户提供了"JWT 结合 Session"的完整登录会话解决方案:接入方几行代码就解决登录和会话管理。对你自己来说,就是把 jwt.Handler 和 JWTLoginMiddlewareBuilder 合并到一起。面试时把这个项目讲出来,就是"熟知登录全过程"的最强佐证。

小收口

退出登录的 ssid 方案,本质是用"极小的有状态代价换无状态校验的性能"。黑名单优于白名单、Redis 挂了要降级、长 token 泄露只能缓解——这些都是面试高频。下面我们补齐两块工程基础设施:配置与日志。


3.11 配置模块

先抛问题

到目前为止,webook 的配置都是直接写死在代码里的,靠编译标签(build tag)区分环境。改一个数据库地址要重新编译发版,不同环境要维护多份代码文件。有没有更优雅的做法?

补课胶囊 · 配置中心 一句话:把"会变的东西"(数据库连接、密钥、开关)从代码里挪出去,集中管理。 类比:你家的电器插在墙上插座(配置)而不是焊死在电线里——想换电压、换房间,拔插就行,不用重新造电器。 最小示例:数据库地址写在 config.yaml 里,代码启动时读文件;换环境只改文件,不重新编译。 回到主线:配置中心是"配置文件"的升级版——配置存到远程(如 etcd),改了不用发版,还能实时生效。

3.11.1 配置的为什么

//go:build !k8s
// 满足"非 k8s"条件时,才会编译这个文件里的配置

这种做法的问题很明显:改一个数据库地址要重新编译发版;不同环境要维护多份代码文件,靠人肉保证它们不漂移。把"会变的东西"从代码里挪出去,交给配置模块统一管理,才是正路。

3.11.2 配置来源

来源适用场景
启动参数命令行工具,如 mockgen -source=xxx
环境变量和实例有关的参数,如权重、分组
配置文件当前环境的通用配置,如数据库连接
远程配置中心除启动最少配置外的所有配置

个人建议:

  • 少用启动参数(新人门槛高)
  • 少用环境变量(要登录机器才知道值)
  • 优先配置文件
  • 大规模微服务集群再引入远程配置中心

3.11.3 来源的优先级

如果多个来源都有同一配置项,用哪个?取决于哪个更"权威"。个人实践:

命令行 > 环境变量 > 配置文件 > 远程配置中心

理由:命令行是我主动输入的;环境变量是我自己设置的;配置文件可能是同事写的;远程配置中心是公共的。

3.11.4 两次加载

使用远程配置中心时一般有两次加载:

第一次:加载最基本的配置

  • 远程配置的连接信息(连上配置中心)
  • 日志相关配置(确保后续能输出日志)

第二次:完全加载

  • 读取所有依赖配置(数据库、Redis 等)
  • 用于初始化各种组件
  • 如果远程配置中心能覆盖第一次的配置,则覆盖并重新初始化(如重连日志平台)
flowchart TD
    S[启动] --> L1[第一次加载: 连配置中心+日志]
    L1 --> L2[第二次加载: 全量配置]
    L2 --> I[初始化 DB/Redis/...]
    I --> R[若远程覆盖, 重连日志平台]

3.11.5 viper:读取本地配置

viper 是 Go 里 star 数最高的配置库:

go get github.com/spf13/viper

基本用法:

viper.SetConfigName("config")  // 文件名(不含扩展名)
viper.SetConfigType("yaml")    // 文件类型
viper.AddConfigPath("./config") // 查找路径
viper.AddConfigPath(".")       // 也可以多个路径
err := viper.ReadInConfig()
if err != nil {
	panic(err)
}

注意 working directory:viper 从 Go 的 working directory 开始定位。如果在 GoLand 中跑,要确认 working directory 设置正确(一般是项目根目录)。

推荐写法:UnmarshalKey 到结构体:

type DBConfig struct {
	DSN string `mapstructure:"dsn"`  // 直接用 DSN,不拆账号密码
}

func InitDB() *gorm.DB {
	// 步骤 1:定义目标结构体
	var cfg DBConfig
	// 步骤 2:按 key 反序列化
	err := viper.UnmarshalKey("db", &cfg)
	if err != nil {
		panic(err)
	}
	// 步骤 3:用 DSN 连库
	db, err := gorm.Open(mysql.Open(cfg.DSN), &gorm.Config{})
	if err != nil {
		panic(err)
	}
	return db
}

为什么不直接 viper.GetString("db.dsn")?因为:把相关配置收到一个结构体里更清晰;后续扩展字段方便;类型安全。

默认值:

// 方式一:viper.SetDefault(在初始化函数里调用,不能在包级别直接写语句)
func InitConfig() {
	viper.SetDefault("db.dsn", "localhost:3306")
}

// 方式二(推荐):结构体字段设默认值
type DBConfig struct {
	DSN string `mapstructure:"dsn"`
}
func InitDB() *gorm.DB {
	cfg := DBConfig{DSN: "localhost:3306"}  // 默认值
	_ = viper.UnmarshalKey("db", &cfg)
	// 如果配置文件没有 db.dsn,cfg.DSN 仍是 localhost:3306
}

推荐方式二,因为默认值放在业务相应的地方。

直接传 io.Reader:

viper.SetConfigType("yaml")
viper.ReadConfig(strings.NewReader(`
db:
  dsn: "root:root@tcp(localhost:3306)/webook"
`))

测试或本地调试时方便,不用写配置文件。

3.11.6 不同环境加载不同配置

公司一般有几个环境:

  • 开发环境(本地)
  • 测试环境(测试人员用)
  • 线上环境

viper 不内置这种支持。常见做法是启动时传入配置文件路径:

// 启动时:go run . --config=./config/dev.yaml
func InitConfig() {
	configPath := flag.String("config", "config/dev.yaml", "配置文件路径")
	flag.Parse()
	viper.SetConfigFile(*configPath)
	if err := viper.ReadInConfig(); err != nil {
		panic(err)
	}
}

3.11.7 远程配置中心:etcd

为什么需要:配置文件的缺点——不够灵活,难以接入加密解密、权限控制、实时更新。所以可以用远程配置中心。

用 Docker Compose 启动 etcd:

# docker-compose.yaml
services:
  etcd:
    image: bitnami/etcd:latest
    environment:
      - ALLOW_NONE_AUTHENTICATION=yes  # 开发环境不设密码
    ports:
      - "2379:2379"

用 viper 接入 etcd:

import (
	"github.com/spf13/viper"
	_ "github.com/spf13/viper/remote"  // 别忘了匿名引入 remote 包
)

func InitConfig() {
	viper.SetConfigType("yaml")
	err := viper.AddRemoteProvider("etcd", "localhost:2379", "/webook")
	if err != nil {
		panic(err)
	}
	err = viper.ReadRemoteConfig()
	if err != nil {
		panic(err)
	}
}

在 etcd 中写入配置:

# v3 语法:把 yaml 文件内容整体作为 value 写入
# (etcdctl v3 没有 v2 的 "-- < 文件" 写法,多行内容用引号包裹即可)
etcdctl put /webook "$(cat config/dev.yaml)"

监听配置变更:

viper.WatchConfig()              // 监听文件变更
viper.OnConfigChange(func(e fsnotify.Event) {
	fmt.Println("配置变更:", e.Name)
})

// 监听远程配置
viper.WatchRemoteConfig()
复习提示:⚠️ 新手必踩的坑:WatchConfig 的调用时机。它必须在所有 SetConfigFile、AddConfigPath 这类"定位配置文件"的调用之后才能调用——文件路径都还没定好,监听谁去?顺序写反了不会报错,但监听永远不会生效,你以为开了热更新,其实只是个摆设。

应用场景:用配置做功能开关。新功能上线时开启,出问题立刻关闭,不用重新部署。

3.11.8 较好的实践

使用抽象的配置 API:不要在业务代码里直接操作 etcd API,否则应用和 etcd 耦合。借 viper 的 API 屏蔽底层实现差异(文件、etcd、Nacos)。如果担心 viper 不够好,可以照 viper API 抄一个公司内部统一的配置 API。

配置操作限定在初始化过程中:把配置操作放在 IoC 和 main 函数中。这样从 viper 换到别的框架时,只改初始化过程,别的代码不动。

复习提示:缺点:如果要在 Service 层监听配置项变更,就违背了这个原则。

小收口

配置模块的核心三件事:来源要分清、优先级要定好、操作限定在初始化。viper 统一屏蔽本地与远程差异。最后一块基础设施是日志。


3.12 日志模块

先抛问题

到现在我们都没用日志模块,全靠 fmt.Println 或 log.Println。这在小 demo 里没问题,但生产环境会有大麻烦:出问题时不知道系统发生了什么、无法按级别筛选、日志没法被收集分析。

补课胶囊 · 结构化日志 一句话:把日志写成"带字段的机器可读结构",而不是一行自由文本。 类比:log.Println("user login") 像在便利贴上随手写句话;logger.Info("用户登录", zap.Int64("uid", 1)) 像填了一张表格——哪一列是 uid、哪一列是动作,清清楚楚,机器能直接检索。 最小示例:zap.L().Info("用户登录", zap.String("biz", "login"), zap.Int64("uid", 1)) 输出 JSON {"level":"info","msg":"用户登录","biz":"login","uid":1}。 回到主线:结构化日志是"可观测性"的第一步,本章用 zap 接入。

3.12.1 为什么需要日志

到现在我们都没用日志模块,缺点:

  • 无法确认系统状态,出问题都不知道
  • 出现问题时难以定位

3.12.2 日志级别

级别含义线上是否打印
DEBUG辅助排查一般不打印
INFO中性描述发生了什么打印
WARN不好的事,但可容忍打印
ERROR需要关注的事打印

注意:除非刷 KPI,否则不要引入很多级别。每多一级别,多一份学习成本。

有些公司还会引入额外的级别,举两个典型的"反面教材":

  • DATA:专门用来记录请求和响应。其实用 INFO 记录就行,单独加一级纯属多余
  • FATAL:表达"非常严重、需要立刻人工介入"的错误。听起来合理,但大部分人根本分不清什么时候该用 ERROR、什么时候该用 FATAL,最后就是乱用

四个级别(DEBUG/INFO/WARN/ERROR)对绝大多数团队已经够用了。

3.12.3 什么时候打日志?打什么级别?

一个原则:如果你怀疑这个地方要不要打日志,那就打上。

个人习惯:

  • 用 AOP 机制记录跟第三方交互的请求/响应(数据库、缓存、RPC),用 DEBUG
  • 用 AOP 记录系统收到的请求和自己返回的响应,用 DEBUG
  • 开发阶段用 DEBUG 记录关键中间结果
  • 怀疑可能有问题但不严重的地方,记 WARN(频繁 WARN 才去看)
  • 不可能出问题或有人攻击的地方,记 ERROR

宁滥勿缺。

3.12.4 接入 zap

go get -u go.uber.org/zap
// 初始化全局 Logger
logger, err := zap.NewDevelopment()
if err != nil {
	panic(err)
}
defer logger.Sync()
zap.ReplaceGlobals(logger)

基本打印:

// ERROR:传 error
zap.L().Error("发送短信失败",
	zap.Error(err),
	zap.String("biz", "login"))

// WARN:短信发送太频繁
zap.L().Warn("短信发送太频繁", zap.Error(err))

// INFO:用户首次登录
zap.L().Info("新用户注册", zap.Int64("uid", u.Id))

关键细节:

  • 绝对不会把 error 暴露给前端
  • 敏感信息(手机号、密码)不能打日志
  • 跟第三方交互的 req/resp 含手机号,只有线上不开 DEBUG 时才可打

3.12.5 不使用 zap 包变量(保持依赖注入)

zap.L() 是包变量,大部分中小型应用没问题。但一旦同一项目要用多个 Logger(如 user 相关日志用更安全的存储),就得全改一遍。

// 推荐写法:依赖注入
type UserService struct {
	logger *zap.Logger
}
func NewUserService(repo repository.UserRepository, logger *zap.Logger) *UserService {
	return &UserService{repo: repo, logger: logger}
}

简化版(注了但没完全注):

type UserService struct {
	logger *zap.Logger
}
func NewUserService(repo repository.UserRepository) *UserService {
	return &UserService{
		repo:   repo,
		logger: zap.L(),  // 仍用全局,但封装在 New 里,将来改只改这里
	}
}

还有一种常见的公司实践:在业务系统里自己定义一个 Logger 包变量,业务代码直接用它。这和直接用 zap.L() 没有本质区别——如果说直接用 zap.L() 是 60 分,这种做法就是 61 分:耦合对象只是从"zap 这个库"换成了"你们自己的包",强耦合、缺扩展性的缺陷一模一样。

3.12.6 抽象日志 API

直接用 zap 会强耦合,将来想换就换不动。所以可以抽象自己的日志 API。

风格一:经典 printf 风格

type Logger interface {
	Debug(msg string, args ...any)
	Info(msg string, args ...any)
	Warn(msg string, args ...any)
	Error(msg string, args ...any)
}

要求用户在 msg 里留占位符。

风格二:zap 风格(参数有名字)

type LoggerV1 interface {
	Debug(msg string, fields ...Field)
	Info(msg string, fields ...Field)
	Warn(msg string, fields ...Field)
	Error(msg string, fields ...Field)
}
type Field struct {
	Key   string
	Value any
}
// 提供辅助方法 zap.String、zap.Int64 等

对日志分析更友好。

风格三:参数必须是偶数

// args 必须是偶数,假设参数都有名字
type LoggerV2 interface {
	Debug(msg string, args ...any)
	Info(msg string, args ...any)
	Warn(msg string, args ...any)
	Error(msg string, args ...any)
}

折中方案,但没编译器检查,用户不按套路出牌没办法。

选用哪种:

  • 兼容性最好:Logger
  • 认同参数要有名字:LoggerV1
  • 有完善 code review:LoggerV2(否则不建议)

用 LoggerV1 封装 zap(适配器模式):

type ZapLogger struct {
	l *zap.Logger
}

func NewZapLogger(l *zap.Logger) *ZapLogger {
	return &ZapLogger{l: l}
}

func (z *ZapLogger) Debug(msg string, fields ...Field) {
	// 步骤 1:把自定义 Field 转成 zap.Field
	z.l.Debug(msg, z.toZapFields(fields)...)
}

func (z *ZapLogger) toZapFields(fields []Field) []zap.Field {
	// 步骤 2:逐个转换
	res := make([]zap.Field, 0, len(fields))
	for _, f := range fields {
		res = append(res, zap.Any(f.Key, f.Value))
	}
	return res
}
复习提示:缺点:参数转换有额外内存分配和 CPU 消耗。这就是适配器模式。

3.12.7 适配器模式 vs 装饰器模式

模式接口用途
装饰器同一接口加新特性,不改变接口
适配器不同接口把 A 接口适配到 B 接口

展开讲讲适配器模式(Adapter):把一个类型适配到另外一个接口。注意这里说的是"类型"——被适配的 A 可以是一个接口,也可以是一个具体类型(本章就是把 zap.Logger 这个具体类型适配到 LoggerV1 接口)。典型使用场景有两种:

  1. 两个用途类似、但细节不同的接口,把一个适配到另一个
  2. 版本升级出现了两套接口,把老接口适配到新接口(或反过来,取决于谁在用)

区分两个模式只需记一条:装饰器从头到尾都是同一个接口(加功能不改接口);适配器必然涉及两个不同的接口(转接头)。

3.12.8 在系统出入口记录日志

  • 入口:系统收到请求、返回响应
  • 出口:调用第三方

利用 Gin middleware 打日志:

type AccessLog struct {
	Method string
	URL    string
	Body   string
	Resp   string
}

func AccessLogger(logFn func(l AccessLog)) gin.HandlerFunc {
	return func(ctx *gin.Context) {
		// 步骤 1:读取请求 body(并还原,避免后续读不到)
		body, _ := io.ReadAll(ctx.Request.Body)
		ctx.Request.Body = io.NopCloser(bytes.NewReader(body))

		// 步骤 2:包装 ResponseWriter,记录响应
		respWriter := &responseWriter{ResponseWriter: ctx.Writer}
		ctx.Writer = respWriter

		// 步骤 3:执行下一个
		ctx.Next()

		// 步骤 4:打日志
		logFn(AccessLog{
			Method: ctx.Request.Method,
			URL:    ctx.Request.URL.String(),
			Body:   string(body),
			Resp:   respWriter.data.String(),
		})
	}
}

漏洞:没控制住 AccessLog 大小,攻击者可传巨长 URL 或巨大请求。

复习提示:⚠️ 新手必踩的坑:记录请求/响应要防巨量 body。上面中间件直接把整个 body 和 resp 写进日志,攻击者传一个 100MB 的 POST body 就能把磁盘打满。生产要截断(如只保留前 1KB)并限制单条日志大小。

GORM 打日志:

gormLoggerFunc := glogger.NewFunc(func(sql string, args ...any) {
	zap.L().Debug("SQL", zap.String("sql", sql), zap.Any("args", args))
}, glogger.Config{...})

db, _ := gorm.Open(mysql.Open(dsn), &gorm.Config{
	Logger: gormLoggerFunc,
})

其他第三方:腾讯云短信 SDK(没暴露 AOP 接口,手动打)、redis.Cmdable(可以通过封装接口打,但有上百个方法)、http.Client(没暴露 AOP 接口,手动打)。

复习提示:没有 AOP 机制且没有接口 = 垃圾。

3.12.9 打印日志技巧总结

  • 宁滥勿缺,宁多打不少打
  • 优先用 AOP 机制打
  • 没 AOP 用装饰器
  • 百万 QPS 之前,不要考虑打印日志的开销问题。打,狠狠打,往死里打!

3.12.10 深入讨论:为什么直接用 viper,却要抽象 Logger?

你可能注意到了一个"双标":配置模块我们直接依赖 viper,日志模块却抽象了 LoggerV1 接口再封装 zap。为什么?

答案是:这里只是借这两个库,给你演示两种依赖策略:

  • 直接依赖开源库(viper 的用法):中小型公司完全够用,简单直接
  • 抽象接口 + 适配(Logger 的用法):中大型公司有价值——日志库的迁移历史上真实发生过(logrus → zap → slog),而且公司可能对"某些业务的日志要单独存储、脱敏"有定制需求,有抽象层才改得动

两种策略没有绝对优劣,判断依据是:你对"将来要换/要定制"的预期有多强。预期强就抽象,预期弱就直连,别为了抽象而抽象。

3.12.11 动手优化:Handler 里重复的 err 日志

观察 Web 层的 Handler,你会发现大量这样的分支:

if err != nil {
	zap.L().Error("系统错误", zap.Error(err))
	ctx.JSON(http.StatusOK, web.Result{Code: 5, Msg: "系统错误"})
	return
}

每个 err != nil 都要手写一遍"打日志 + 返回响应",又啰嗦又容易漏。优化思路:

  1. 错误统一处理:让 Handler 只返回 error(或包装过的业务错误),由一个集中的地方(Gin 的自定义错误处理 middleware)统一打日志、统一转成响应
  2. 只在错误最初产生的地方打日志:往上抛的是带上下文的包装错误(fmt.Errorf("保存帖子失败: %w", err)),避免同一个错误在每一层都打一遍、日志刷屏

小收口

日志是可观测性的第一步:四大级别、宁滥勿缺、优先 AOP、抽象 LoggerV1 用适配器封装 zap。至此正文四大块(登录流程、长短 token、配置、日志)全部讲完。下面进入动手环节。


3.13 加餐:UML 时序图绘制指南

先抛问题

本章学习目标里有一条:“能独立画出 OAuth2 授权码流程的时序图”。为什么课程这么强调画图?因为登录、支付、OAuth2 这类功能,难点恰恰在于多个系统之间一来一回的顺序——谁先发起、参数走哪条通道、哪步同步等返回。文字描述三句就没人读得懂,一张图能钉死。

给个直觉:几根泳道之间飞来飞去的纸条

补课胶囊 · 时序图(Sequence Diagram) 一句话:用时序图描述"多个角色(参与者)之间按时间顺序的交互"。 类比:时序图像几根泳道(每个参与者一根),消息是泳道之间飞来飞去的纸条;时间从上往下流,纸条从左到右飞。 最小示例:画"用户点登录 → 后端跳微信 → 微信回 code → 后端换 token",一张图胜过三页文字。 回到主线:微信扫码登录的完整流程,最适合用时序图表达,面试时直接默写开讲。

3.13.1 为什么要画时序图

文字描述并发交互有多乏力,写过需求文档的人都知道:“用户点击登录,然后系统 A 调用系统 B,B 返回之后 A 再调用 C……“三句话之后没人读得懂。而登录、支付这类功能难点在"多个系统之间一来一回的顺序”。时序图用"时间从上往下流、消息从左往右飞"的方式,把这些问题钉在一张图上。画图对你的价值:

  1. 理清思路:画不出来的流程,说明你没想清楚——画图是最好的自检
  2. 面试硬通货:面试官问"讲讲微信扫码登录”,你拿笔直接画图开讲,和背文字的候选人立刻拉开差距
  3. 团队协作:评审一个跨系统的设计,一张时序图胜过三页文档

3.13.2 时序图的五种基本元素

元素是什么白话理解
参与者(Participant/Actor)系统里的一个角色流程里"说话的人":用户、浏览器、你的后端、微信服务器
生命线(Lifeline)参与者下方的竖直虚线每个"人"的时间轴,从上往下就是时间流逝
激活条(Activation)生命线上的窄矩形这个人"正在干活"的时间段——从被调用到返回之间的处理期
同步消息(Sync Message)实线实心箭头打电话:我调你,等你干完返回我才能继续
异步消息(Async Message)实线开放箭头发微信:我发完就走,不等你回复

外加一个返回消息(Return Message,虚线箭头):表示"调用结束后,结果沿原路回来"。注意它和同步消息的区别:同步消息是"请求过去",返回消息是"结果回来",一来一回才构成一次完整调用。

复习提示:类比:把时序图想象成几根泳道,消息是泳道之间飞来飞去的纸条。同步消息是"打电话过去等对方说话";异步消息是"发完微信就放下手机";返回消息是对方把写好答案的纸条递回来。

对应的 mermaid 语法长这样:

sequenceDiagram
    participant A as 服务A
    participant B as 服务B
    activate A
    A->>B: 同步消息(实线箭头, 等待返回)
    activate B
    B-->>A: 返回消息(虚线箭头)
    deactivate B
    A-)B: 异步消息(发完就走)
    deactivate A

3.13.3 分支与循环怎么画:alt / opt / loop

真实流程不会是一条直线,会有"如果……则……“和"重复直到……"。UML 用组合片段(Combined Fragment)——画在时序图上的一个方框——来表达这些控制逻辑,最常用的三种:

片段含义对应代码
alt多选一分支(可以有多个 else 区块)if / else if / else
opt可选执行(条件满足才执行一次)if(没有 else)
loop循环(框上标注条件,如 [还有数据])for / while
sequenceDiagram
    participant U as 用户
    participant S as 服务器
    U->>S: 请求列表
    activate S
    alt 缓存命中
        S-->>U: 返回缓存数据
    else 缓存未命中
        S->>S: 查数据库并回写缓存
        S-->>U: 返回数据库数据
    end
    deactivate S
    loop [还有下一页]
        U->>S: 请求下一页
        S-->>U: 返回数据
    end

记忆技巧:alt = alternatively(多选一)、opt = optionally(可有可无)、loop = 循环。

3.13.4 完整示例:微信扫码登录时序图

把本章 3.3 的流程补全细节,画成一张"面试可以直接默写"的时序图:

sequenceDiagram
    participant U as 用户浏览器
    participant W as webook后端
    participant X as 微信开放平台

    U->>W: 1. 点击微信登录
    activate W
    W->>W: 2. 生成state并写入Cookie
    W-->>U: 3. 返回授权URL(appid+redirect_uri+state)
    deactivate W

    U->>X: 4. 重定向到微信扫码页
    activate X
    X-->>U: 5. 展示二维码
    deactivate X
    U->>X: 6. 扫码并确认授权
    activate X
    X-->>U: 7. 重定向回redirect_uri(带code+state)
    deactivate X

    U->>W: 8. 回调请求(code+state)
    activate W
    W->>W: 9. 校验state与Cookie中一致(防CSRF)
    alt state校验失败
        W-->>U: 返回非法请求
    else state校验通过
        W->>X: 10. 用code+secret换access_token(服务端调用)
        activate X
        X-->>W: 11. 返回access_token+open_id+union_id
        deactivate X
        W->>W: 12. FindOrCreate用户+设置长短token
        W-->>U: 13. 登录成功
    end
    deactivate W

对着这张图,把本章的关键点各就各位过一遍:

  • 步骤 2~3:state 的"存一份"发生在跳转之前
  • 步骤 4~7:浏览器和微信之间的交互不经过你的后端——这就是为什么 state 要放 Cookie(回调时浏览器会自动带上)
  • 步骤 9:state 的"比一次”
  • 步骤 10~11:code 换 token 走服务端到服务端通道,secret 不暴露给浏览器
  • 步骤 12:登录与注册合并(FindOrCreate)

3.13.5 给新手的几条画图建议

  1. 参与者别太多:控制在 3~5 个。参与者一多,说明系统边界切得太碎,先合并再画
  2. 消息写"动词 + 宾语",带上关键参数:用code换token 比 请求 有用一百倍;把 code、state、access_token 这些关键数据标在箭头上
  3. 先画主干再补分支:先把"幸福路径"(happy path,一切正常的主流程)从上到下画通,再用 alt/opt 补异常分支,最后用 loop 补循环。一上来就抠细节必然画乱
  4. 异常分支只画关键的:state 校验失败、token 过期这种"讲安全时是重点"的才画;每个 if 都画只会淹没主干
  5. 返回消息别省:很多人只画请求不画返回,读到一半就断片。返回值(code、access_token、用户信息)恰恰是流程里流动的关键数据
  6. 能默写才算会:本章开头的动手练习要求"不看书画出 OAuth2 时序图"。画不出来说明流程还没长在脑子里,回去重读 3.3 和 3.7
复习提示:⚠️ 新手必踩的坑:把时序图当流程图画。流程图(flowchart)描述的是"单个角色内部的逻辑分支",时序图描述的是"多个角色之间的交互顺序"。判断标准:你的关注点如果是"谁在什么时刻调了谁、传了什么",用时序图;如果是"一个请求在系统内部经过哪些处理步骤",用流程图。两者经常配合使用,别混为一谈。

小收口

时序图是后端工程师最该掌握的一种 UML 图:五个基本元素 + alt/opt/loop 三种组合片段,就能表达任意多系统交互。面试讲 OAuth2,先画这张图再开口,说服力翻倍。至此正文四大块 + 加餐全部讲完,下面进入动手环节。


四、跟着写:分步实现

本章动手主线是:在现有 webook 项目里,把"微信扫码登录 + 长短 token + ssid 退出 + viper 配置 + zap 日志"串起来跑通。每一步结束项目都能编译、能跑、能验证。

前置:你的项目此时已有第 03、04 章的 JWT 签发、GORM 用户表、Redis 客户端。下面只写"新增 / 改造"的部分。

步骤 1 · 引入依赖并抽 jwt.Handler 接口

目标:把散落的 JWT 逻辑内聚成一个 jwt.Handler 接口,统一后续调用。

// internal/jwt/handler.go
package jwt

import (
	"context"

	"github.com/redis/go-redis/v9"
)

// Handler 所有 JWT 相关操作的抽象
type Handler interface {
	SetLoginToken(ctx context.Context, uid int64) error
	GenerateToken(uid int64, ssid string) (string, error)
	ParseToken(tokenStr string) (*Claims, error)
	ParseRefreshToken(tokenStr string) (*RefreshClaims, error)
	SetStateToken(ctx context.Context, state string) string
	CheckState(ctx context.Context, state string) error
	Logout(ctx context.Context, ssid string) error
	CheckSession(ctx context.Context, ssid string) error
}

// RedisJWTHandler 基于 Redis 的实现
type RedisJWTHandler struct {
	cmd        redis.Cmdable
	accessKey  []byte
	refreshKey []byte
	stateKey   []byte
}

验证命令:go build ./... 应当编译通过。

预期输出:无报错(若缺 redis 依赖,先 go get github.com/redis/go-redis/v9)。

步骤 2 · 实现长短 token 与 ssid

目标:让 SetLoginToken 同时下发短 token(30 分钟)和长 token(7 天),并写入 ssid。

// internal/jwt/handler.go(节选)
func (h *RedisJWTHandler) SetLoginToken(ctx context.Context, uid int64) error {
	ssid := uuid.New().String()
	accessToken, err := h.genToken(uid, ssid, h.accessKey, 30*time.Minute)
	if err != nil {
		return err
	}
	ctx.Value("ginContext").(*gin.Context).Header("x-jwt-token", accessToken)

	refreshToken, err := h.genToken(uid, ssid, h.refreshKey, 7*24*time.Hour)
	if err != nil {
		return err
	}
	ctx.Value("ginContext").(*gin.Context).Header("x-refresh-token", refreshToken)
	return nil
}

说明:真实代码里 ctx 直接是 *gin.Context,这里为了聚焦只展示 token 生成核心。完整实现见 3.9.3。

验证命令:在登录接口返回后,用浏览器开发者工具看响应头,应有 x-jwt-token 与 x-refresh-token 两个自定义头。

步骤 3 · 实现 ssid 退出登录与降级

目标:Logout 把 ssid 写进 Redis 黑名单,CheckSession 校验;Redis 异常时降级放行。

// internal/jwt/handler.go(节选)
func (h *RedisJWTHandler) Logout(ctx context.Context, ssid string) error {
	return h.cmd.Set(ctx, fmt.Sprintf("users:ssid:%s", ssid),
		1, 7*24*time.Hour).Err()
}

func (h *RedisJWTHandler) CheckSession(ctx context.Context, ssid string) error {
	cnt, err := h.cmd.Exists(ctx, fmt.Sprintf("users:ssid:%s", ssid)).Result()
	if err != nil {
		return nil // 降级:Redis 挂了也放行
	}
	if cnt > 0 {
		return errors.New("会话已失效")
	}
	return nil
}

验证命令:

# 1) 登录拿到 token(假设已实现登录接口)
# 2) 调用 /logout,带 ssid
# 3) 再用旧 token 访问受保护接口,应返回 401

预期输出:退出后旧 token 被拒(会话已失效)。再 redis-cli shutdown 模拟 Redis 宕机,已登录用户仍能访问(降级生效)。

步骤 4 · 实现微信 OAuth2 Handler 与 state 校验

目标:新增 /oauth2/wechat/authurl 与 /callback,AuthURL 写 state 到 Cookie,Callback 校验 state。

// web/wechat.go(节选,完整见 3.6 节)
func (h *OAuth2WechatHandler) AuthURL(ctx *gin.Context) {
	state := uuid.New().String()
	val := h.jwt.SetStateToken(ctx, state)
	ctx.SetCookie("jwt-state", val, 600, "/", "", false, true)
	url, err := h.svc.AuthURL(ctx, state)
	if err != nil {
		ctx.JSON(http.StatusOK, web.Result{Code: 5, Msg: "系统错误"})
		return
	}
	ctx.JSON(http.StatusOK, web.Result{Data: url})
}

func (h *OAuth2WechatHandler) Callback(ctx *gin.Context) {
	if err := h.jwt.CheckState(ctx, ctx.Query("state")); err != nil {
		ctx.JSON(http.StatusOK, web.Result{Code: 4, Msg: "非法请求"})
		return
	}
	wechatRes, err := h.svc.Verify(ctx, ctx.Query("code"))
	if err != nil {
		ctx.JSON(http.StatusOK, web.Result{Code: 5, Msg: "系统错误"})
		return
	}
	u, err := h.userSvc.FindOrCreate(ctx, wechatRes.UnionId)
	if err != nil {
		ctx.JSON(http.StatusOK, web.Result{Code: 5, Msg: "系统错误"})
		return
	}
	if err = h.jwt.SetLoginToken(ctx, u.Id); err != nil {
		ctx.JSON(http.StatusOK, web.Result{Code: 5, Msg: "系统错误"})
		return
	}
	ctx.JSON(http.StatusOK, web.Result{Msg: "登录成功"})
}

验证命令:访问 /oauth2/wechat/authurl,拿到 URL;浏览器打开该 URL 完成扫码;回调成功应看到 {"msg":"登录成功"}。

步骤 5 · 引入 viper 配置

目标:把数据库连接等配置从硬编码迁到 config/dev.yaml。

# config/dev.yaml
db:
  dsn: "root:root@tcp(localhost:3306)/webook"
// internal/config/config.go
type DBConfig struct {
	DSN string `mapstructure:"dsn"`
}

func InitDB() *gorm.DB {
	cfg := DBConfig{}
	_ = viper.UnmarshalKey("db", &cfg)
	db, err := gorm.Open(mysql.Open(cfg.DSN), &gorm.Config{})
	if err != nil {
		panic(err)
	}
	return db
}

验证命令:go run . --config=./config/dev.yaml,应用应能连上数据库启动成功。

步骤 6 · 接入 zap 结构化日志

目标:用 zap 替换零散的 log.Println,并抽象 LoggerV1 接口适配。

// internal/logger/logger.go
type Field struct {
	Key   string
	Value any
}

type LoggerV1 interface {
	Debug(msg string, fields ...Field)
	Info(msg string, fields ...Field)
	Warn(msg string, fields ...Field)
	Error(msg string, fields ...Field)
}

type ZapLogger struct{ l *zap.Logger }

func (z *ZapLogger) Info(msg string, fields ...Field) {
	z.l.Info(msg, toZap(fields)...)
}
// Debug/Warn/Error 类似

func toZap(fields []Field) []zap.Field {
	res := make([]zap.Field, 0, len(fields))
	for _, f := range fields {
		res = append(res, zap.Any(f.Key, f.Value))
	}
	return res
}

验证命令:触发一次登录,控制台应出现结构化日志,如 {"level":"info","msg":"用户登录","uid":1}。

步骤 7 · 验证清单

做到下面每条,本章动手主线算完成:

  • go build ./... 通过,无编译错误
  • 访问 /oauth2/wechat/authurl 能拿到微信授权 URL
  • 完成扫码回调后,响应头带 x-jwt-token 与 x-refresh-token
  • 调用 /refresh 用长 token 换到新短 token(前端触发)
  • 调用 /logout 后,旧 token 访问受保护接口返回 401
  • 手动 redis-cli shutdown 后,已登录用户仍能访问(降级)
  • 数据库地址改到 config/dev.yaml,启动正常
  • 关键路径出现 zap 结构化日志

本章完成后的项目结构(节选):

webook/
├── config/
│   └── dev.yaml              # viper 读取的本地配置
├── internal/
│   ├── jwt/
│   │   └── handler.go        # jwt.Handler 接口 + Redis 实现(长短 token/ssid)
│   ├── logger/
│   │   └── logger.go         # LoggerV1 接口 + ZapLogger 适配器
│   └── config/
│       └── config.go         # viper 初始化
├── service/wechat/
│   └── service.go            # wechat.Service 接口 + 实现
└── web/
    └── wechat.go             # OAuth2WechatHandler(authurl/callback)

五、工程实践要点

这一节把生产环境里最容易出事的注意事项集中列出来,多是血泪教训。

5.1 安全相关

  • OAuth2 流程必须校验 state,否则有 CSRF 风险——攻击者能用他的微信绑你的账号。
  • 长 token 应该有保护:User-Agent 绑定 / 一次性使用,泄露了也能抬高攻击门槛。
  • 退出登录必须用 ssid + Redis 黑名单,且服务端写黑名单 + 客户端清 token 两层一起上。
  • 敏感信息(手机号、密码)绝对不能打日志;第三方 req/resp 含手机号,只有线上不开 DEBUG 时才可打。

5.2 降级策略

  • Redis 挂了不严格校验 ssid(保证大多数用户可用),但只降级高频的登录校验路径,刷新接口这种低频路径不必降级。
  • 配置中心挂了用本地配置兜底(如果有)。

5.3 配置管理

  • 优先配置文件,少用启动参数和环境变量;大规模集群再上远程配置中心。
  • 配置操作限定在初始化过程中(IoC / main),换框架时只改初始化。
  • 用 viper API 屏蔽底层差异(文件 / etcd / Nacos),业务代码不直接碰 etcd。

5.4 日志

  • 宁滥勿缺:怀疑要不要打就打上。
  • 优先 AOP / 装饰器打,百万 QPS 之前不要纠结开销。
  • 记录请求/响应要防巨量 body(截断到 1KB),避免磁盘被打满。
  • 抽象自己的 Logger 接口再封装 zap,便于将来迁移或定制(注意这是"适配器"不是"装饰器")。

5.5 接口与扩展

  • wechat.Service 用接口抽象,将来接钉钉 / Gmail 时只加新实现,Handler 改动极小——“预留扩展点,但不过度设计”。
  • 跨产品识别同一用户要用 union_id,不是 open_id。

六、自测题与动手练习

自测题(合上书能答出来,才算懂):

  1. OAuth2 授权码流程里,为什么浏览器先拿到的是 code 而不是直接拿 access_token?code 和 access_token 分别走什么通道?
  2. open_id 和 union_id 的区别是什么?跨产品(A、B 两个 App)识别同一用户该用哪个?
  3. 不校验 state 会出什么安全问题?服务端要怎么"存 + 比"才能防住 CSRF?
  4. 为什么长短 token 方案里,刷新必须由前端触发、而不能在 middleware 里自动续期?
  5. JWT 是无状态的,怎么实现"退出登录"?ssid + Redis 黑名单 方案的降级策略是什么、为什么必须降级?
  6. 退出登录为什么用"黑名单"而不是"白名单"?为什么要在长短 token 里都塞同一个 ssid?
  7. 配置的优先级怎么排?为什么"命令行 > 环境变量 > 配置文件 > 远程配置中心"?
  8. 结构化日志相比 log.Println 好在哪?日志级别有哪四个?什么原则叫"宁滥勿缺"?

动手练习(建议真做一遍):

  • 练习 1 · 画 + 讲 OAuth2:不看任何资料,独立画出授权码流程时序图,并讲清每一步是谁调用谁、参数怎么传。能默写才算会。
  • 练习 2 · 实现并验证 state 校验:给 Callback 接上 CheckState;写一个测试,模拟"攻击者带别人 code + 正常用户 Cookie"请求,确认返回"非法请求"。
  • 练习 3 · ssid 退出演练:本地起 Redis,实现 Logout/CheckSession;退出登录后确认后续请求被拒;再手动 redis-cli shutdown,确认降级下已登录用户仍可用。
  • 练习 4 · 配置与日志迁移:把项目里一处硬编码的数据库连接迁到 config/dev.yaml,用 viper 读取;再把一处 log.Println 改成 zap 结构化日志,带上 uid 字段。

七、模拟面试问答

这一节模拟真实面试场景。每题按基础 / 进阶 / 追问三档,固定五段:一句话结论 → 展开 → 面试官想听的关键词 → 常见失分点(外加用于背诵的定调句已并入结论)。

面试官
【基础】 OAuth2 授权码流程里,为什么浏览器先拿到的是 code 而不是直接拿 access_token?code 和 access_token 分别走什么通道?
候选人

一句话结论:access_token 是敏感凭证,若直接通过浏览器 URL 传递会被 Referer / 历史记录泄露,所以先用浏览器只传短效一次性的 code,后端再用 code + secret 走服务端到服务端安全通道换 access_token。

展开:code 出现在第 4 步(微信重定向回 webook 的地址栏里),access_token 出现在第 6 步(后端用 code 换 token,全程服务端到服务端)。code 有三重保护——短效、一次性、兑换需要 secret——即使泄露危害也极小;而 access_token 一旦泄露,攻击者可长期以用户身份访问资源。所以安全边界划在:code 走浏览器(前端↔授权服务器),access_token 与 secret 只走服务端(客户端后端↔授权服务器)。

面试官想听的关键词:token 不进地址栏、Referer 泄露、服务端到服务端通道、code 短效+一次性+需 secret、安全边界。

常见失分点:只说"为了安全"却讲不清具体泄露途径(答不出 Referer / 浏览器历史);或者认为 code 和 token 一样敏感,没区分两者分工。

面试官
【基础】 open_id 和 union_id 的区别是什么?跨产品(A、B 两个 App)识别同一用户该用哪个?
候选人

一句话结论:open_id 是用户在某一个应用下的唯一 ID(同用户在 A、B 各有一个不同的),union_id 是用户在某一个公司下的唯一 ID(跨产品相同);跨产品识别同一用户必须用 union_id。

展开:假设公司在微信注册了 A、B 两个产品,用户张伟在 A 上有一个 open_id、在 B 上有另一个不同的 open_id,但他在 A 和 B 上的 union_id 是同一个。这是因为微信的 open_id 绑定"应用",union_id 绑定"开发者主体(公司)"。如果你做跨 App 的用户打通(比如用一个账号体系贯通 A 和 B),拿 open_id 去匹配会误判为两个不同的人,必须用 union_id。

面试官想听的关键词:open_id 绑定应用、union_id 绑定公司主体、跨产品打通、唯一性层级。

常见失分点:把 open_id 当成"全局唯一"(实际只在单个应用内唯一);跨产品识别时选错成 open_id。

面试官
【基础】 不校验 state 会出什么安全问题?服务端要怎么"存 + 比"才能防住 CSRF?
候选人

一句话结论:不校验 state 时,攻击者可诱导正常用户带着"攻击者的 code"来请求,把攻击者的微信绑到正常用户账号,攻击者用自己的微信登录就能看到正常用户的数据;正确做法是 AuthURL 时生成 state 并留存、Callback 时取出微信回传的 state 与留存值严格相等才放行。

展开:CSRF 的本质是"攻击者让你带着自己的登录身份,去发起一个你没想做的请求"。在微信登录里,攻击者弄到一个授权码 A,诱导已登录的正常用户带着自己 Cookie + 攻击者的 code 去请求,若没校验 state,webook 会把攻击者的微信绑到正常用户账号。防御是"存一份 + 比一次":生成 AuthURL 时生成随机 state 存入 Cookie(或 Session/Redis),微信跳回时把回传的 state 和留存的比对,不一致说明伪造。state 放 Cookie 是因为微信回调直接到后端不经过前端。

面试官想听的关键词:CSRF、存一份+比一次、state 存 Cookie、攻击者绑账号、随机值防伪。

常见失分点:只在 URL 生成 state 却没在服务端留存,回调无法比对等于没防;或者把 state 放 Header 而非 Cookie,导致微信回调时前端带不上。

面试官
【基础】 为什么长短 token 方案里,刷新必须由前端触发、而不能在 middleware 里自动续期?
候选人

一句话结论:长短 token 的核心假设是"短 token 频繁使用易泄露所以要短命,长 token 只在登录和刷新时用、不易泄露",若 middleware 自动续期,长 token 也会变"频繁使用"、泄露风险上升,违背初衷。

展开:短 token(access_token)高频访问资源,所以寿命短(30 分钟);长 token(refresh_token)只在登录和刷新时用,寿命长(7 天)。自动续期会把长 token 也拉成高频使用——每次短 token 过期都在用长 token,攻击者截获长 token 的机会大增。因此规定:短 token 过期后,由前端收到 401 主动调 /refresh,后端才换发新 token。这是"前端触发"而非"后端自动"的分水岭。

面试官想听的关键词:核心假设、短高频长低频、自动续期破坏假设、401 触发刷新、泄露面。

常见失分点:答"为了安全"却说不出"长 token 变高频"这个关键推理;或反过来认为自动续期也无所谓。

面试官
【进阶】 JWT 是无状态的,怎么实现"退出登录"?ssid + Redis 黑名单 方案的降级策略是什么、为什么必须降级?
候选人

一句话结论:JWT 签发后无法主动作废,引入 ssid——登录时生成 ssid 写入长短 token,退出登录时把 ssid 加入 Redis 黑名单,校验时查 ssid 是否在黑名单;降级策略是 Redis 挂了就跳过校验直接放行,因为退出是少数情况、严格执行会全员 401 造成雪崩。

展开:黑名单优于白名单——退出远低频于登录,黑名单里只需存极少量数据。校验主路径先本地验签(纯 CPU,不碰网络),只有命中黑名单的会话才查 Redis,所以主体是"无状态性能 + 极小有状态代价"。降级只用在"高频 + 故障影响面大"的登录校验 middleware 上;刷新接口这种低频路径没必要降级(Redis 挂了影响的只是恰好此刻要刷新的少量用户)。

面试官想听的关键词:黑名单优于白名单、ssid 标识一次登录、本地验签主路径、降级只保高频、Redis 雪崩。

常见失分点:说"用白名单"且讲不清内存开销差异;或不知道降级只针对高频路径、误以为所有路径都要降级。

面试官
【进阶】 退出登录的 ssid 方案里,“这不又回到 Session 了吗?"——它和纯 Session 到底有什么区别?
候选人

一句话结论:如果你坚持"JWT 退出后必须严格不可用",确实绕不开类似 Session 的状态存储;但 ssid 方案与纯 Session 的校验主路径不同——前者先本地验签再按需查 Redis,后者每个请求都查 Redis。

展开:纯 Session 每个请求都要查一次 Redis(重依赖远程存储),性能随 Redis 压力走;JWT + ssid 黑名单每个请求先本地验签(纯 CPU,无网络),只有退出登录的会话才需要 Redis 拦截,且 Redis 挂了能降级放行。所以 ssid 是用"极小的有状态代价,换取无状态校验的主体性能"。代价是:你确实引入了一点状态,但比纯 Session 轻得多。

面试官想听的关键词:校验主路径差异、本地验签 vs 每次查 Redis、有状态代价极小、降级放行。

常见失分点:被问住后承认"就是 Session",却说不清性能差异;或声称"完全无状态"而忽略了 ssid 本身引入的状态。

面试官
【进阶】 长 token 泄露了怎么办?为什么说"能做的事已经不多"?
候选人

一句话结论:token 一旦签发出去、到期前本身就是合法凭证,服务端没有主动收回的手段,只能"抬高攻击门槛"而难以根治——在长 token 里编码登录时的 User-Agent 并校验、或让长 token 一次性使用(刷新即作废)。

展开:这与"退出登录"同根同源——JWT 无状态意味着服务端不记录"这个 token 还活不活",所以泄露后只能缓解:① 把登录时的 UA 编码进 token,校验时比对,异地新设备用不了;② 每次刷新颁发新长 token、旧作废,这样泄露的那张很快失效。但②只能缓解(攻击者若在你刷新前就用,仍有效),且要处理并发刷新问题。安全设计的现实是:堵住所有泄露路径不可能,让攻击成本高于收益即可。

面试官想听的关键词:token 即合法凭证、UA 绑定、长 token 一次性、缓解非根治、成本高于收益。

常见失分点:答"立刻让 token 失效"(JWT 做不到,除非引入黑名单,但那又回到有状态);或声称有根治手段。

面试官
【进阶】 配置模块为什么用 viper,而日志模块却要抽象 LoggerV1 接口再封装 zap?这不是双标吗?
候选人

一句话结论:这不是双标,而是借两个库演示两种依赖策略——配置直接依赖 viper(中小公司够用、简单),日志抽象接口再适配 zap(大公司可能迁移/定制,抽象层才改得动),判断依据是"对将来要换/要定制的预期有多强"。

展开:viper 直接依赖没毛病,中小型项目配置库换不换无所谓,直连省事。日志则不同:日志库迁移历史上真实发生过(logrus → zap → slog),且公司可能对"某些业务日志要单独存储、脱敏"有定制需求,有 LoggerV1 抽象层,换底层或加定制只改适配器。两种策略无绝对优劣,别为了抽象而抽象——预期强就抽象,弱就直连。

面试官想听的关键词:两种依赖策略、抽象代价/收益、日志库迁移史、预期驱动、不过度设计。

常见失分点:硬说"viper 也该抽象"或"zap 也不该抽象",没看到判断依据是"预期",陷入教条。

面试官
【追问】 退出登录为什么用"黑名单"而不是"白名单"?能不能用 user id 代替 ssid?
候选人

一句话结论:用黑名单是因为退出远低频于登录,黑名单只存极少数据;白名单要给每个在线会话占一个 key,内存开销大得多。不建议用 user id 代替 ssid,因为登录时要删 user id 记录,会和"一边登录一边退出"产生并发冲突使退出失效,而每次登录生成新 UUID 的 ssid 互不影响。

展开:黑名单里只有"已退出"的 ssid,绝大多数会话从头到尾不会进黑名单,Redis 内存极小。若用 user id 标识退出,则每次登录都要删掉该 user id 的"已退出"记录,但万一用户退出后又立刻登录、或并发登录,新登录会清掉退出记录、让退出失效。ssid 每次登录独立生成,彻底隔离。布隆过滤器可作为黑名单的替代,但有假阳性且仍要 Redis 兜底,改进有限,主要当面试亮点。

面试官想听的关键词:黑名单省内存、白名单开销大、user id 并发冲突、ssid 隔离、布隆过滤器假阳性。

常见失分点:选白名单却讲不清内存差异;想用 user id 但答不出并发失效问题。

面试官
【追问】 降级策略应该用在哪些路径?为什么 refresh_token 接口不一定要降级?
候选人

一句话结论:降级策略要用在"高频 + 故障影响面大"的路径上(如每个请求都过的登录校验 middleware);refresh_token 接口是低频路径(短 token 半小时才过期一次),Redis 挂了只影响恰好此刻刷新的少量用户,严格执行伤害面很小,所以不必降级。

展开:登录校验 middleware 是高频路径,每个请求都过,若 Redis 挂了还严格查 ssid 黑名单,会全员 401、业务雪崩,所以必须降级放行。而 refresh 接口只在 token 过期时由前端触发,频率低,Redis 故障时拒绝的只是极少数正在刷新的用户,影响面可控。原则一句话:降级保护的是"挂了会全员崩"的高频路径,不是"挂了只影响少数人"的低频路径。

面试官想听的关键词:高频+大影响面、全员 401 雪崩、refresh 低频、伤害面可控、降级取舍。

常见失分点:认为"只要用 Redis 的路径都要降级",没区分高频低频;或反过来所有路径都不降级。

面试官
【追问】 适配器模式 vs 装饰器模式,日志 API 抽象用的是哪个?怎么一眼区分?
候选人

一句话结论:日志 API 抽象用的是适配器模式——把 zap.Logger 这个具体类型适配到我们自己的 LoggerV1 接口(两个不同接口之间的"转接头");装饰器模式是同一个接口上加功能、不改变接口。

展开:适配器(Adapter)把一个类型适配到另一个接口,被适配的可以是接口也可以是具体类型(本章就是具体类型 zap.Logger → LoggerV1)。装饰器(Decorator)从头到尾是同一个接口,只是叠加新特性(如给响应加缓存、加日志但不改方法签名)。一句话区分:装饰器同接口加功能,适配器跨接口做转接。日志这里 LoggerV1 和 zap.Logger 方法签名不同,是典型适配器。

面试官想听的关键词:适配器跨接口、装饰器同接口、转接头、签名不同、zap→LoggerV1。

常见失分点:把"封装 zap"说成装饰器(错,因为接口变了);或说不清两者最本质的"接口是否相同"判据。

面试官
【追问】 微信回调地址 redirect_uri 为什么必须和注册时填的域名一致?不校验会怎样?
候选人

一句话结论:redirect_uri 必须和注册时填的域名一致,是 OAuth2 防御"授权码劫持"的第一道闸门——若不校验,攻击者把回调改成自己的服务器,用户扫码授权后 code 就被发到攻击者手里,整个授权流程被劫持。

展开:授权码流程里 code 通过浏览器重定向传递。如果微信不校验 redirect_uri 白名单,攻击者构造一个 redirect_uri=https://attacker.com/cb 的授权链接诱骗用户扫码,用户同意后 code 会跳到 attacker.com,攻击者拿着 code + 自己的 secret(不行,secret 在用户侧)——但注意攻击者若同时控制客户端,就能用 code 换 token。域名白名单确保 code 只能回到你注册的合法域名。这也是为什么"code 走浏览器"必须配"redirect_uri 白名单"才安全。

面试官想听的关键词:授权码劫持、域名白名单、第一道闸门、code 走浏览器风险、配合防御。

常见失分点:只说"为了安全"答不出劫持场景;或以为靠 code 短效一次性就够了、忽略 redirect_uri 这道独立防线。


八、本章小结

学完这一章,下面这些应该能从脑子里调出来。建议逐条打勾,打不上勾的回去看对应小节。

复习提示:

可勾选清单(面试前 5 分钟扫一遍)

  • OAuth2 是什么:授权框架,“给房卡不给钥匙”;微信扫码登录本质就是 OAuth2 授权码流程。
  • 授权码四步:跳转授权 → 用户同意 → 拿 code → 换 token;code 走浏览器(短效/一次性/需 secret),access_token 走服务端。
  • 为什么多一个 code:token 不能进浏览器地址栏(Referer 泄露),故先用 code 换 token;redirect_uri 必须域名白名单。
  • state 防 CSRF:“存一份 + 比一次”,state 存 Cookie(回调直接到后端);不校验会让攻击者用他的微信绑你的账号。
  • 长短 token:短 token 高频易泄露要短命,长 token 只在登录/刷新用要长命;刷新必须前端 401 触发,不能 middleware 自动续期。
  • JWT 退出登录:无状态无法主动作废,用 ssid + Redis 黑名单;黑名单优于白名单;Redis 挂了降级放行(只保高频路径)。
  • 长 token 泄露:只能缓解(UA 绑定 / 一次性),难根治;本质同"无法主动收回"。
  • ssid vs Session:校验主路径先本地验签再按需查 Redis,比纯 Session 每次查 Redis 轻;不是完全无状态,但代价极小。
  • 配置模块:来源(参数/环境变量/文件/远程),优先级命令行>环境变量>文件>远程,两次加载(基础+全量);viper 读本地、etcd 远程。
  • 日志模块:四级 DEBUG/INFO/WARN/ERROR,宁滥勿缺,优先 AOP;抽象 LoggerV1 用适配器封装 zap;记录 req/resp 要防巨量 body。
  • 面试加分点:降级策略只用于高频路径;布隆过滤器作黑名单替代(有假阳性、要 Redis 兜底);适配器 vs 装饰器一眼区分。

下一章我们将进入发帖功能与缓存(第 06 章),把内容生产链路和缓存设计讲透。本章的"登录态 + 配置 + 日志"三块基础设施,正是下一章能直接复用的底座。

复习提示:给下一章留个伏笔:本章我们解决了"怎么登录、怎么退出、配置从哪来、日志怎么打"。下一章要做"发帖"这种真实业务时,会立刻遇到两个新问题——读多写少怎么优化(缓存),以及热点帖子怎么扛量。带着"登录态已经稳了"的底气进入第 06 章,会学得更顺。
About Me

没什么想介绍的,一个很大众的码农…

喜欢代码,车,马,真的是 🐎

讨厌别人让我给自己的代码写注释 最厌烦别人的程序没有写注释

目标

学AI,加油!加油!