楼层: 首页/ 软件技术/ Python Web 开发/ 认证授权:Session / JWT / OIDC
10

认证授权:Session / JWT / OIDC

Authentication & Authorization in Depth

"用户登录了没有"(认证,"你是谁")和"用户能不能干这件事"(授权,"你能做什么")是两件事,很多人一直混着做。这一章从 Django 自带的 Session 讲到 DRF 的 Token、SimpleJWT,再到企业里更常见的 OAuth2 / OIDC 第三方登录,最后把"JWT 到底该不该存 localStorage"这类争议问题说清楚。

先分清认证与授权,再谈技术选型

是什么:认证(Authentication)解决"你是谁",产出的是一个身份凭证;授权(Authorization)解决"你能做什么",产出的是允许/拒绝的判断。

为什么必须先分清:因为线上绝大多数"权限问题"其实是把两件事搅在一起了。比如"会员才能看这篇文章"——认证负责识别出"这是用户张三",授权负责判断"张三的会员还没到期"。如果权限判断写在了认证层(比如把"是否会员"塞进 token),一旦运营在后台改了用户等级,用户手里的 token 还是旧状态,就会出"已退款用户仍能看会员内容"的事故。

维度认证授权
回答你是谁你能做什么
时机每个请求开头一次每次访问具体资源时
失败响应401 Unauthorized403 Forbidden
Django 里的对应Session / Token / JWT 认证类Permission / permission_classes
可否缓存到客户端可以(凭证)不可以(必须实时查)
401 和 403 别混用

401 表示"你没有有效凭证,去登录";403 表示"我知道你是谁,但你没权限"。把权限不足返回成 401,前端会跳转登录页——用户刚登录完又被弹去登录,非常迷惑。另外一个常见问题:只在前端隐藏按钮,后端不校验。用户改一下请求照样能删别人的数据——"前端隐藏"永远不是权限控制。

Django 自带 auth:Session + CSRF 是怎么配合的

是什么:Django 的默认方案是基于 Session 的有状态认证。用户 login() 后,服务端把 {user_id: 1} 之类的数据写进 session 表(或缓存),只把一个随机 sessionid 发到浏览器 cookie 里;后续请求靠这个 cookie 找回用户。

为什么它依然是 Web 应用的默认选择:三个好处——① 可撤销:用户点"退出所有设备"就删掉 session 记录,立即失效;② 无敏感信息下发:cookie 里只有随机串,泄露了也只能用一会儿(服务端可随时删);③ CSRF 有现成防护:Django 中间件自动给表单加 token。

# 登录 / 登出 from django.contrib.auth import login, logout, authenticate from django.shortcuts import redirect, render def login_view(request): if request.method == "POST": user = authenticate( request, username=request.POST["email"], password=request.POST["password"], ) if user is None: return render(request, "login.html", {"error": "邮箱或密码错误"}) login(request, user) # ← 触发 session 轮换,防会话固定攻击 return redirect("dashboard") return render(request, "login.html") def logout_view(request): logout(request) # 删掉服务端 session return redirect("login")
# settings.py 里和 session 有关的关键项 SESSION_ENGINE = "django.contrib.sessions.backends.cache" # 存 Redis,别用默认的表 SESSION_COOKIE_AGE = 60 * 60 * 24 * 14 # 两周过期 SESSION_COOKIE_HTTPONLY = True # 禁止 JS 读取 cookie,防 XSS 偷 session SESSION_COOKIE_SAMESITE = "Lax" # 防 CSRF 的第一道闸 SESSION_COOKIE_SECURE = True # 只在 HTTPS 上发送 SESSION_SAVE_EVERY_REQUEST = False # True 会让每个请求都写 session,压力大
# 前端提交要带 CSRF token(Django 模板里) <form method="post"> {% csrf_token %} <input name="email"> <input name="password" type="password"> <button>登录</button> </form> # 前后端分离时,Django 用 cookie 下发 token,前端取出来放到请求头 # 取 Cookie 里的 csrftoken → 放进 X-CSRFToken 头 # 视图上加 @ensure_csrf_cookie 保证 cookie 一定被种下

论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 豁免要谨慎,别整个视图类一刀切

@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 / 前后端分离
OIDCIdP 持有,你的服务只验签名靠 IdP 的 token 撤销企业 SSO、第三方登录
$ pip install djangorestframework-simplejwt
# settings.py REST_FRAMEWORK = { "DEFAULT_AUTHENTICATION_CLASSES": [ "rest_framework_simplejwt.authentication.JWTAuthentication", ], "DEFAULT_PERMISSION_CLASSES": [ "rest_framework.permissions.IsAuthenticated", # 默认要登录!白名单式开放 ], } from datetime import timedelta SIMPLE_JWT = { "ACCESS_TOKEN_LIFETIME": timedelta(minutes=15), # 短:泄露的窗口小 "REFRESH_TOKEN_LIFETIME": timedelta(days=7), "ROTATE_REFRESH_TOKENS": True, # 每次刷新都换一张新的 refresh "BLACKLIST_AFTER_ROTATION": True, # 旧 refresh 立刻拉黑,防重放 "UPDATE_LAST_LOGIN": True, "SIGNING_KEY": os.environ["JWT_SIGNING_KEY"], # 别用 SECRET_KEY 一把钥匙开所有锁 "ALGORITHM": "HS256", } INSTALLED_APPS += ["rest_framework_simplejwt.token_blacklist"] # 启用黑名单需要 migrate
# urls.py —— 拿到 token、刷新 token from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView urlpatterns = [ path("api/token/", TokenObtainPairView.as_view()), # 换 access + refresh path("api/token/refresh/", TokenRefreshView.as_view()), # access 过期时换新的 ] # 请求时带:Authorization: Bearer <access_token>
# 登出:把 refresh token 拉黑(不拉黑的话"登出"只是个前端动作) from rest_framework_simplejwt.tokens import RefreshToken from rest_framework.views import APIView from rest_framework.response import Response class LogoutView(APIView): def post(self, request): try: token = RefreshToken(request.data["refresh"]) token.blacklist() # 写进 blacklist 表 except Exception: pass # 已失效的 token 也当作登出成功 return Response({"detail": "已登出"})

论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 唯一的优势:无状态验签,适合多服务横向扩展。

JWT 放 localStorage 是 XSS 的放大器

把 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 给的凭证"。

$ pip install authlib # 或者 social-auth-app-django(Django 生态更省事) $ pip install social-auth-app-django
# settings.py —— social-auth-app-django 接 Google INSTALLED_APPS += [ "social_django", "django.contrib.auth", "django.contrib.contenttypes", ] AUTHENTICATION_BACKENDS = [ "social_core.backends.google.GoogleOAuth2", "django.contrib.auth.backends.ModelBackend", # 保留本地密码登录 ] SOCIAL_AUTH_GOOGLE_OAUTH2_KEY = os.environ["GOOGLE_CLIENT_ID"] SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET = os.environ["GOOGLE_CLIENT_SECRET"] SOCIAL_AUTH_GOOGLE_OAUTH2_SCOPE = ["openid", "email", "profile"] # openid 走 OIDC LOGIN_REDIRECT_URL = "/dashboard/" SOCIAL_AUTH_PIPELINE = ( "social_core.pipeline.social_auth.social_details", "social_core.pipeline.social_auth.social_uid", "social_core.pipeline.social_auth.auth_allowed", "social_core.pipeline.social_auth.social_user", "social_core.pipeline.user.get_username", "social_core.pipeline.user.create_user", # 首次登录自动建本地用户 "social_core.pipeline.social_auth.associate_user", "social_core.pipeline.social_auth.load_extra_data", )
# urls.py —— 两行接完第三方登录 urlpatterns += [ path("", include("social_django.urls", namespace="social")), ] # 前端放个链接:/login/google-oauth2/ 就会跳到 Google 授权页
授权模式流程要点用在哪
授权码 + PKCE前端生成 code_verifier / challenge,换 code 再换 tokenSPA、移动 App(当前推荐默认)
授权码 + client_secret后端持密钥换 token有后端的传统 Web 应用
隐式模式 implicittoken 直接出现在 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 参数千万别省,回调地址必须白名单

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,写进 sessionstate 是防 CSRF 的,回调必须校验
3后端 → 浏览器302 跳转到 Google 授权页,带上 client_id、redirect_uri、scope、state、challengeredirect_uri 是白名单里的那个
4Google展示授权页面,用户同意用户在这里登录 Google、选择授权范围
5Google → 浏览器 → 后端重定向回 redirect_uri?code=xxx&state=yyy先校验 state 是否与 session 里一致
6你的后端 → Google用 code + code_verifier + client_secret 换 token(服务端直连)这一步是后端对后端,浏览器看不到
7Google → 你的后端返回 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 的声明
# 手写 authlib 的核心片段(理解流程用,生产建议用现成库) from authlib.integrations.django_client import OAuth oauth = OAuth() def login(request): redirect_uri = request.build_absolute_uri("/callback") # 第 2-3 步:生成 state 与 PKCE,跳去授权页 return oauth.google.authorize_redirect( request, redirect_uri, code_challenge_method="S256" ) def callback(request): # 第 5-7 步:code 换 token(authlib 会同时校验 state) token = oauth.google.authorize_access_token(request) # 第 8 步:authlib 已校验签名/iss/aud/exp,这里取身份 info = token["userinfo"] # OIDC 时来自 id_token # 第 9 步:按 (provider, sub) 找本地用户,没有就建 from django.contrib.auth import get_user_model, login from django.contrib.auth.models import User as _ # 仅示意,实际用 get_user_model() User = get_user_model() SocialAccount = ... # 自己建一张 (provider, uid, user) 表,uid 存 sub # 第 10 步:建立本地会话 user, _created = User.objects.get_or_create( email=info["email"], defaults={"username": info["email"]} ) login(request, user) return redirect("/dashboard/")

论为什么绑定键要用 (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(视图级:这个接口谁能访问);第三层是对象级权限(行级:能不能改这一条订单)。

# 第一层:模型级权限(Django 自带 add/change/delete/view 四种) from django.contrib.auth.models import Group, Permission editors = Group.objects.get_or_create(name="编辑")[0] editors.permissions.add( *Permission.objects.filter(codename__in=["change_order", "view_order"]) ) user.groups.add(editors) user.has_perm("orders.change_order") # 代码里判断
# 第二层:DRF 视图级权限 from rest_framework.permissions import BasePermission, IsAuthenticated from rest_framework.viewsets import ModelViewSet class IsOwnerOrReadOnly(BasePermission): """只读开放给所有人,写操作要求是作者本人""" def has_object_permission(self, request, view, obj): if request.method in ("GET", "HEAD", "OPTIONS"): return True return obj.author_id == request.user.id class ArticleViewSet(ModelViewSet): permission_classes = [IsAuthenticated, IsOwnerOrReadOnly] # 注意:has_object_permission 只对"单条对象"生效 # 列表接口要自己在 get_queryset 里过滤,否则会返回别人的数据 def get_queryset(self): qs = Article.objects.select_related("author") if self.request.user.is_staff: return qs return qs.filter(author=self.request.user)
层级粒度写法典型场景
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)共享文档、协作场景
has_object_permission 不会保护列表接口

这是 DRF 最常见的权限漏洞之一。has_object_permission 只在 get_object() 被调用时执行(详情、更新、删除),而 list 动作根本不会调用它——于是一个"只能改自己帖子"的接口,列表却能列出所有人的帖子。同理,filter 参数里如果能传别人的 id,也能越权。记住两条:① 对象级权限的地基是 get_queryset 的数据隔离,"看不见"比"拿不到"更根本;② 写完之后一定用自己的小号实测一遍列表接口和详情接口。

认证授权常见坑合辑

把前面几节散落的坑集中成一张排查表。上线前遮住右列自测一遍。

现象原因与修法
用 session 登录后调 API 报 CSRF FailedDRF 的 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)),并对草稿状态再滤一层;再用小号实测列表接口。