认证授权:Session / JWT / OIDC
"用户登录了没有"(认证,"你是谁")和"用户能不能干这件事"(授权,"你能做什么")是两件事,很多人一直混着做。这一章从 Django 自带的 Session 讲到 DRF 的 Token、SimpleJWT,再到企业里更常见的 OAuth2 / OIDC 第三方登录,最后把"JWT 到底该不该存 localStorage"这类争议问题说清楚。
先分清认证与授权,再谈技术选型
是什么:认证(Authentication)解决"你是谁",产出的是一个身份凭证;授权(Authorization)解决"你能做什么",产出的是允许/拒绝的判断。
为什么必须先分清:因为线上绝大多数"权限问题"其实是把两件事搅在一起了。比如"会员才能看这篇文章"——认证负责识别出"这是用户张三",授权负责判断"张三的会员还没到期"。如果权限判断写在了认证层(比如把"是否会员"塞进 token),一旦运营在后台改了用户等级,用户手里的 token 还是旧状态,就会出"已退款用户仍能看会员内容"的事故。
| 维度 | 认证 | 授权 |
|---|---|---|
| 回答 | 你是谁 | 你能做什么 |
| 时机 | 每个请求开头一次 | 每次访问具体资源时 |
| 失败响应 | 401 Unauthorized | 403 Forbidden |
| Django 里的对应 | Session / Token / JWT 认证类 | Permission / permission_classes |
| 可否缓存到客户端 | 可以(凭证) | 不可以(必须实时查) |
401 表示"你没有有效凭证,去登录";403 表示"我知道你是谁,但你没权限"。把权限不足返回成 401,前端会跳转登录页——用户刚登录完又被弹去登录,非常迷惑。另外一个常见问题:只在前端隐藏按钮,后端不校验。用户改一下请求照样能删别人的数据——"前端隐藏"永远不是权限控制。
Django 自带 auth:Session + CSRF 是怎么配合的
是什么:Django 的默认方案是基于 Session 的有状态认证。用户 login() 后,服务端把 {user_id: 1} 之类的数据写进 session 表(或缓存),只把一个随机 sessionid 发到浏览器 cookie 里;后续请求靠这个 cookie 找回用户。
为什么它依然是 Web 应用的默认选择:三个好处——① 可撤销:用户点"退出所有设备"就删掉 session 记录,立即失效;② 无敏感信息下发:cookie 里只有随机串,泄露了也只能用一会儿(服务端可随时删);③ CSRF 有现成防护:Django 中间件自动给表单加 token。
论CSRF 攻击是什么,为什么 Session 方案必须防它
① 攻击原理:你登录了 bank.com,浏览器存了 session cookie。这时你点开一个恶意页面,页面上有个隐藏表单自动提交到 bank.com/transfer?to=attacker&amount=1000。浏览器会自动带上 bank.com 的 cookie——服务端一看"cookie 有效",就执行了转账。关键在于:请求确实来自你的浏览器,但你本人并不知情。② 为什么 token 能防:CSRF token 是服务端种在 cookie 里、同时要求请求体/请求头也必须带上的一个随机值。恶意页面能借走 cookie,但读不到 token 的值(同源策略限制它读不到 bank.com 的 cookie 内容),于是伪造请求失败。③ 关键结论:CSRF 只对"靠浏览器自动携带凭证"的方案(cookie / session)成立。纯 JWT 放请求头时,浏览器不会自动带,所以严格说不怕 CSRF——但会引入新的问题(见第 3 节)。④ 额外防线:SameSite=Lax/Strict 让跨站请求默认不带 cookie,这是现代浏览器给出的第一道闸。
@csrf_exempt 很方便,但它把这道防线整个关掉了。常见错误是把整个 APIView 都加上 csrf_exempt,结果"用 session 登录的接口"也能被跨站伪造。正确做法:① DRF 的 SessionAuthentication 会强制 CSRF 校验(这是它和 TokenAuthentication 混用时最常出问题的点——用 session 登录后调 API 报 403 "CSRF Failed");② 真需要豁免的场景(如第三方回调、机器对机器)应该换用签名校验或 IP 白名单,而不是简单豁免;③ 前端分离项目记得设置 CSRF_TRUSTED_ORIGINS,否则 Cookie 方案在跨域下直接不工作。
DRF 认证体系与 SimpleJWT
是什么:Django REST Framework 用"认证类"(authentication class)统一处理凭证解析。内置四种:SessionAuthentication(复用 Django session,强制 CSRF)、BasicAuthentication(用户名密码 base64,仅调试用)、TokenAuthentication(一张永不过期的随机表)、JWTAuthentication(来自 djangorestframework-simplejwt)。
| 方案 | 状态存哪 | 能否即时撤销 | 适用 |
|---|---|---|---|
| Session | 服务端(DB / Redis) | 能,删记录即失效 | 浏览器端同域应用 |
| DRF Token | 服务端 auth_token 表 | 能(重新生成 token) | 简单内网服务、脚本调用 |
| SimpleJWT | 客户端持有,服务端不存 | 需黑名单机制才能 | 多端 App / 前后端分离 |
| OIDC | IdP 持有,你的服务只验签名 | 靠 IdP 的 token 撤销 | 企业 SSO、第三方登录 |
论JWT 里到底该放什么
① JWT 的结构:header.payload.signature,三部分都是 base64 明文(不是加密!)。谁拿到 token 谁就能读出 payload。② 所以不能放:密码、手机号、身份证号、内部字段名。payload 里放 user_id、exp、jti(token 唯一 id)、iss 就够。③ 特别不建议放权限字段:有人把 is_admin 或角色列表写进 token,为了"省一次数据库查询"。代价是——运营在后台撤了你的管理员权限,你的 token 还能用 15 分钟(甚至 7 天)。正确姿势:token 只承载身份(你是谁),权限每次实时查库(DRF 的权限类天然就是这么做的)。④ 为什么能做到"不查库校验":因为签名是用只有服务端知道密钥算出来的,篡改 payload 必然导致签名不匹配。这是 JWT 唯一的优势:无状态验签,适合多服务横向扩展。
把 access token 存进 localStorage 很常见,因为前后端分离写起来最省事。但 localStorage 能被同域的任何 JS 读到——一旦页面有一个 XSS 漏洞(哪怕来自第三方统计脚本、富文本渲染、老依赖),攻击者就能 localStorage.getItem("token") 把凭证带走,然后在他自己的机器上冒充你,而且服务端完全无法察觉(token 是合法签名的)。权衡方案:① 最稳的是 access token 只放内存(JS 变量),刷新时用 HttpOnly + Secure + SameSite 的 refresh cookie 换新 access——XSS 拿不到 refresh(HttpOnly 就是禁 JS 读),且刷新有次数限制;② 退而求其次用 HttpOnly cookie 存 token,但要补回 CSRF 防护(因为 cookie 会被自动携带);③ 无论哪种,都要配合 CSP、输出转义、依赖审计,把 XSS 从源头堵掉。
OAuth2 / OIDC:企业级第三方登录
是什么:OAuth2 是"授权协议"——让 A 应用在用户同意下拿到 B 平台的部分数据权限("允许 XX 读取你的微信昵称和头像")。OIDC(OpenID Connect)是 OAuth2 之上的身份层,在授权基础上多返回一个 id_token(一个 JWT,里面写着"这个人是谁")。做"用微信登录"这件事,你要的是 OIDC(或至少用 OAuth2 拿 userinfo)。
为什么不用自己写用户名密码:因为你不想存用户的微信密码;企业内也不想每个系统各维护一套账号。OIDC 让身份由 IdP(身份提供方,如 Okta、Keycloak、微信、Google)统一管理,你的应用只做"验证 IdP 给的凭证"。
| 授权模式 | 流程要点 | 用在哪 |
|---|---|---|
| 授权码 + PKCE | 前端生成 code_verifier / challenge,换 code 再换 token | SPA、移动 App(当前推荐默认) |
| 授权码 + client_secret | 后端持密钥换 token | 有后端的传统 Web 应用 |
| 隐式模式 implicit | token 直接出现在 URL fragment | 已废弃,不要再用 |
| 客户端凭证 client_credentials | 机器对机器,没有用户 | 服务间调用 |
| 密码模式 password | 应用直接拿用户密码换 token | 已废弃,等于自己存密码 |
论PKCE 解决的到底是什么问题
① 老流程的漏洞:SPA 和 App 无法安全保存 client_secret(反编译或看 JS 就拿到了)。于是有人建议用"隐式模式"直接把 token 放 URL——但 token 会进浏览器历史、Referer 头、日志,同样不安全。② PKCE 的思路:不靠静态密钥,靠"这一次请求的临时密钥"。客户端先生成随机串 code_verifier,算出它的哈希 code_challenge 一起发出去;换 token 时必须把原始 code_verifier 再发一次,授权服务器校验哈希是否匹配。③ 防的是什么:攻击者即使截获了 code(比如通过自定义 URL scheme 劫持),没有原始 code_verifier 也换不到 token。④ 一条实践结论:做移动端或 SPA 的第三方登录,必须用 Authorization Code + PKCE,别用隐式模式;并且 redirect_uri 必须白名单精确匹配,防开放重定向。
state 是发起授权时生成的一个随机串,存在自己的 session 里,回调时比对——它防的是 CSRF:攻击者诱导你的浏览器完成一次"绑定他人账号"的授权回调,把受害者账号绑到攻击者的第三方账号上。用 social-auth-app-django 时它自动处理 state;如果你手写 authlib 流程,务必自己校验。另外两件事:① redirect_uri 必须在 IdP 后台精确登记,否则你的登录入口会变成"任意跳转"漏洞的跳板;② 校验 id_token 时别只 base64 解一下就用——必须验签名(用 IdP 的 JWKS 公钥)、验 iss(签发方)、验 aud(是发给我的)、验 exp。少验一项,攻击者就能自己造一个"管理员"的 id_token 塞给你。
第三方登录完整时序(以"用 Google 登录"为例)
下面这张表把一次登录拆成 10 步。数字是执行顺序,方括号里是发生在哪一侧。
| # | 发生在 | 做什么 | 关键点 |
|---|---|---|---|
| 1 | 浏览器 | 用户点"用 Google 登录" | 链接指向你的 /login/google-oauth2/,不是直接跳 Google |
| 2 | 你的后端 | 生成 state 与 PKCE code_challenge,写进 session | state 是防 CSRF 的,回调必须校验 |
| 3 | 后端 → 浏览器 | 302 跳转到 Google 授权页,带上 client_id、redirect_uri、scope、state、challenge | redirect_uri 是白名单里的那个 |
| 4 | 展示授权页面,用户同意 | 用户在这里登录 Google、选择授权范围 | |
| 5 | Google → 浏览器 → 后端 | 重定向回 redirect_uri?code=xxx&state=yyy | 先校验 state 是否与 session 里一致 |
| 6 | 你的后端 → Google | 用 code + code_verifier + client_secret 换 token(服务端直连) | 这一步是后端对后端,浏览器看不到 |
| 7 | Google → 你的后端 | 返回 access_token + id_token(JWT) | id_token 里的 sub 是该用户的唯一标识 |
| 8 | 你的后端 | 验 id_token 签名 / iss / aud / exp,取出 sub 和 email | 用 IdP 的 JWKS 公钥验签,别自己解 base64 |
| 9 | 你的后端 | 按 sub 查本地用户:有就登录,没有就创建并绑定 | 用 (provider, sub) 做唯一键,别用 email(email 可能变) |
| 10 | 你的后端 → 浏览器 | 建立本地会话(session 或签发自己的 JWT)后跳回前台 | 业务权限用本地模型判断,不依赖 IdP 的声明 |
论为什么绑定键要用 (provider, sub),而不是 email
① email 会变:用户在公司改名、换了邮箱域名,IdP 返回的 email 就变了;如果你用 email 当绑定键,会凭空多出一个"新用户"并把老数据留在孤儿账号上。sub 是 IdP 保证"永不变、在本 IdP 内唯一"的标识,天生适合做外键。② 同一个 email 可能来自不同 IdP:用 Google 登录和用企业 SSO 登录,即使是同一个人,也是两套 sub。所以绑定表的主键必须是 (provider, sub) 组合,而不是单独的 sub。③ email 可能没返回:用户没授权 email scope 时,你连邮箱都拿不到——流程不能依赖它存在。④ 安全上还要注意:"已登录用户绑定第三方账号"的接口要单独做,并且要求他先用密码重新验证一次,否则会出现"把别人账号绑到自己账号上"的越权。
权限模型三层:Django Permission / Group、DRF permission_classes、对象级权限
是什么:三层的粒度从粗到细。第一层是 Django 自带的 Permission + Group(模型级:能不能"改订单");第二层是 DRF 的 permission_classes(视图级:这个接口谁能访问);第三层是对象级权限(行级:能不能改这一条订单)。
| 层级 | 粒度 | 写法 | 典型场景 |
|---|---|---|---|
| Permission / Group | 模型级 | user.has_perm("orders.change_order") | 后台功能开关 |
| DRF permission_classes | 视图 / 动作级 | [IsAuthenticated, IsAdminUser] | 接口准入 |
| has_object_permission | 单条对象 | 比较 obj.author_id | 改自己的帖子 |
| get_queryset 过滤 | 行级(列表) | qs.filter(owner=user) | 只能看到自己的订单 |
| django-guardian | 行级(细粒度) | assign_perm("view_article", user, article) | 共享文档、协作场景 |
这是 DRF 最常见的权限漏洞之一。has_object_permission 只在 get_object() 被调用时执行(详情、更新、删除),而 list 动作根本不会调用它——于是一个"只能改自己帖子"的接口,列表却能列出所有人的帖子。同理,filter 参数里如果能传别人的 id,也能越权。记住两条:① 对象级权限的地基是 get_queryset 的数据隔离,"看不见"比"拿不到"更根本;② 写完之后一定用自己的小号实测一遍列表接口和详情接口。
认证授权常见坑合辑
把前面几节散落的坑集中成一张排查表。上线前遮住右列自测一遍。
| 现象 | 原因与修法 |
|---|---|
用 session 登录后调 API 报 CSRF Failed | DRF 的 SessionAuthentication 强制 CSRF。前端要么带上 X-CSRFToken,要么该接口改用 JWTAuthentication |
| 登出后 token 还能用 | JWT 无状态,服务端没记录。启用 token_blacklist,登出时 RefreshToken(...).blacklist(),并把 access 寿命压到 15 分钟 |
| refresh token 被重复使用没人发现 | 开 ROTATE_REFRESH_TOKENS + BLACKLIST_AFTER_ROTATION:每次刷新换新并拉黑旧的,重放会失败并可作为被盗信号 |
| 权限改了但用户还能操作 | 权限被塞进了 token。改回"token 只放身份、权限每次查库" |
| 接口返回 401 却把用户踢到登录页 | 权限不足应该 403。检查 permission_classes 是不是把 IsAuthenticated 和自定义权限顺序写错 |
| 列表接口泄露他人数据 | has_object_permission 不覆盖 list。在 get_queryset 里做数据隔离 |
| 第三方登录回调被"绑定"到攻击者账号 | 没校验 state,或 redirect_uri 没白名单 |
| APP 里的 token 被逆向盗走 | 移动端别把长期 refresh 存在 SharedPreferences 明文里,用 Keychain / Keystore 或系统安全存储 |
论怎么选:一张决策表结束纠结
① 传统同域 Web(Django 模板渲染):直接用 Session + CSRF。不要为了"先进"硬上 JWT——你会丢掉可撤销性、还得自己处理刷新。② 前后端分离(SPA + 自己的 API):可以 Session(配 CSRF_TRUSTED_ORIGINS)或 JWT。若多端(Web + App + 小程序)都要用同一套接口,选 JWT。③ 移动 App 为主:JWT,access 短(15 分钟)、refresh 长(7 天)并存安全存储,服务端开黑名单。④ 企业内部系统:接公司统一的 OIDC SSO,个人不再维护密码,账号开了关了都在 IdP 一次操作。⑤ 微服务:网关统一认证,内部服务之间用 JWT 或 mTLS 传递身份。⑥ 一句话原则:能用有状态就别用无状态——可撤销、可审计、可强制下线,代价只是一次 Redis 查询;JWT 的"无状态"是给横向扩展和大规模多端准备的,小项目用它纯属给自己加难度。
不要自己写密码哈希(用 Django 的 make_password,它内部会选 PBKDF2/Argon2 并带盐)、不要自己写 JWT 签发与校验(用 simplejwt 或 authlib)、不要自己写 OAuth 流程(用成熟库)、不要自己写加密算法(用 cryptography 库的 AES-GCM)。密码存储唯一的正确姿势:永不解密,只做单向哈希比对;如果要"找回密码",走重置链接而不是"把密码发给你"。最后一条:别把 DEBUG=True 的错误页暴露出去——它会把 settings 里的密钥全部打印出来,等同直接把后门钥匙挂门口(第 8 章已经强调过一次,因为它真的每年都在出事)。
① 认证 ≠ 授权:401 是"去登录",403 是"你没权限";前端隐藏按钮不是权限控制。
② Session + CSRF 依然是同域 Web 的最优解:可撤销、不泄露凭证、有现成防护。CSRF 豁免要逐个接口审,别整个类一刀切。
③ JWT 的优势只有"无状态验签",代价是不可撤销。payload 只放身份(user_id / exp / jti),绝不放权限;放 localStorage 会放大 XSS 风险。
④ OIDC 第三方登录用 Authorization Code + PKCE,校验 state、白名单 redirect_uri、验 id_token 签名与 iss/aud;绑定键用 (provider, sub)。
⑤ 权限三层:Permission/Group(模型级)、permission_classes(视图级)、对象级(get_queryset 隔离 + has_object_permission)。记住 list 接口不被对象级权限保护。
1.(概念题)认证和授权分别回答什么问题?接口应该分别返回哪两个状态码?
查看答案
答案:认证回答"你是谁",失败返回 401(没有有效凭证,去登录);授权回答"你能做什么",失败返回 403(我知道你是谁,但你没权限)。混用会让前端跳转逻辑混乱。
2.(概念题)为什么基于 cookie/session 的方案必须防 CSRF,而 JWT 放请求头时通常不需要?
查看答案
答案:因为浏览器会自动携带同域 cookie,恶意页面能借走凭证;CSRF token 要求请求里额外带一个恶意页面读不到的值,所以能挡住。JWT 放 Authorization 头时必须由 JS 显式设置,跨站页面无法设置它,所以天然免疫 CSRF(代价是暴露在 XSS 风险下,因为 JS 能读)。
3.(概念题)为什么不该把 is_admin 或角色列表放进 JWT payload?
查看答案
答案:JWT 一旦签发就无法撤销(除非配黑名单),payload 是明文可读但被签名保护。把权限放进去,撤权后旧 token 在有效期内仍然有效,会出现"已降权用户仍能操作"的越权。正确做法是 token 只承载身份,权限每次实时查库。
4.(概念题)OIDC 的 PKCE 解决什么问题?为什么不能再用隐式模式?
查看答案
答案:SPA 和 App 无法安全保存 client_secret。PKCE 用每次请求生成的 code_verifier/code_challenge 代替静态密钥,攻击者即使截获授权码,没有原始 verifier 也换不到 token。隐式模式把 token 直接放在 URL fragment 里,会进浏览器历史、Referer 和日志,已被废弃。
5.(思考题)你的接口用 IsOwnerOrReadOnly 保护了"改自己的帖子",但上线后发现可以从 /api/articles/?author=5 看到别人的草稿。问题出在哪?
查看答案
答案:has_object_permission 只在获取单个对象时执行,list 接口不触发;而 author=5 这类过滤参数直接作用在 queryset 上,绕过了对象级权限。修法是在 get_queryset 里做数据隔离(非 staff 一律 filter(author=request.user)),并对草稿状态再滤一层;再用小号实测列表接口。