微信小程序的页面可以正常打开,并不代表接口已经安全。真正需要保护的是服务端的登录凭证、会话、业务权限和每一次有副作用的请求。客户端代码会随小程序包分发,任何写在客户端的密钥、权限判断或固定接口参数都不能作为安全边界。
本文沿着一次真实请求的生命周期,说明如何设计微信小程序接口安全:客户端取得登录凭证,服务端完成身份映射,再签发本站会话;接口根据服务端保存的身份和资源关系做权限判断;写操作使用幂等键和状态校验,日志只保留排障所需的最小信息。上一篇小程序开发平台怎么选解决的是平台路线,前一篇微信小程序开发流程实战介绍了从页面到发布的链路,本文专门补上上线后最容易被忽略的接口安全部分。
最重要的边界只有一句话:小程序负责发起请求,服务端负责相信谁、允许做什么,以及这次操作能不能再次执行。
一、先区分身份、会话和权限
这三个概念经常被混在一起,导致代码“能登录”却不能安全地访问业务数据。
| 概念 | 要回答的问题 | 推荐保存位置 |
|---|---|---|
| 平台身份 | 微信平台把这次登录对应到哪个用户主体? | 服务端账号表 |
| 应用会话 | 当前请求是否已经登录,凭证什么时候失效? | 服务端会话表或缓存;客户端只保存不可推导的会话凭证 |
| 业务权限 | 这个用户能否操作当前资源? | 服务端角色、组织和资源关系 |
openid 可以帮助服务端识别某个小程序中的用户,但它不是通用的授权令牌,也不能代替资源权限判断。客户端提交一个 openid、用户 ID 或角色名,服务端都不能直接照单全收;这些字段最多作为展示信息,真正的身份应从服务端验证过的会话中取得。
项目开始时先画出最小数据关系,例如 User、MiniProgramIdentity、Session、Role 和业务资源表。一个用户可能在不同平台拥有不同身份,一个账号也可能属于多个组织。把平台标识、应用标识和内部用户 ID 分开保存,后续增加其他端时不必重写授权逻辑。
二、微信登录的安全链路
微信小程序登录的安全链路可以拆成五步:
- 小程序调用
wx.login,取得一次性登录凭证code。 - 小程序通过 HTTPS 把
code发送给本站登录接口。 - 服务端使用自己的 AppID 和 AppSecret 调用微信
code2Session接口。 - 服务端根据返回的用户标识创建或查找内部账号,并生成本站会话。
- 客户端只保存本站会话凭证,后续请求通过 HTTPS 携带该凭证。
code 只能使用一次,客户端不应把它当成长久登录凭证;AppSecret 和微信返回的 session_key 也不能发回客户端。只有在确实需要微信侧数据解密时才考虑保存 session_key,并按密钥的敏感级别加密或限制访问。大多数业务只需要完成账号映射,不需要持久化它。
登录接口还要处理空 code、重复使用、微信接口超时、错误响应和本站账号状态异常。code2Session 超时后不要盲目重放同一个 code;应让客户端重新调用 wx.login 取得新 code,再发起一次新的登录请求。不要把微信原始错误、AppSecret、完整请求 URL 或 session_key 写进普通业务日志。用户能看到的提示应是“登录暂不可用,请稍后重试”这类可行动信息,详细原因留在受限的服务端日志中。
三、服务端兑换 code 的安全示例
下面示例使用 Python 标准库调用微信接口。生产项目应把请求封装在服务层,并加入超时、错误分类、指标和重试边界;重试不能让同一个 code 被无条件重复兑换。
import json
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode
from urllib.request import urlopen
class MiniProgramLoginError(Exception):
pass
def exchange_login_code(*, app_id, app_secret, code):
if not code or len(code) > 512:
raise MiniProgramLoginError("登录凭证无效")
query = urlencode({
"appid": app_id,
"secret": app_secret,
"js_code": code,
"grant_type": "authorization_code",
})
endpoint = "https://api.weixin.qq.com/sns/jscode2session?" + query
try:
with urlopen(endpoint, timeout=5) as response:
payload = json.loads(response.read().decode("utf-8"))
except (HTTPError, URLError, TimeoutError) as exc:
raise MiniProgramLoginError("登录服务暂不可用") from exc
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise MiniProgramLoginError("登录服务返回无效响应") from exc
if not isinstance(payload, dict):
raise MiniProgramLoginError("登录服务返回无效响应")
if payload.get("errcode") != 0 or not payload.get("openid"):
raise MiniProgramLoginError("登录凭证无效或已过期")
return {
"openid": payload["openid"],
"unionid": payload.get("unionid"),
"session_key": payload.get("session_key"),
}
示例明确要求 errcode == 0,而不是把缺少错误码的异常响应当成成功。session_key 只在服务端内存或受保护存储中短暂使用;如果业务不需要解密微信数据,可以直接丢弃。服务端兑换成功后应生成自己的随机会话凭证,而不是把 openid、session_key 或可预测的用户 ID 拼成 Token。
四、本站 Token 怎么选
小程序接口常见的两种会话方案是服务端状态会话和自包含 Token。两者都能工作,选择应由撤销、设备管理和运维要求决定。
优先考虑不可推导的随机会话
服务端生成足够长度的随机值,数据库或缓存只保存它的哈希、用户 ID、创建时间、过期时间、设备信息和撤销状态。客户端拿到原始值后,每次请求放在 Authorization: Bearer <token> 中。服务端对收到的值做哈希查询,过期或撤销后立即拒绝。
这种方式便于强制退出、单设备注销和风险会话清理;数据库泄露时也不会直接暴露可用 Token。不要把 Token 明文写进访问日志、错误追踪平台或截图。
什么时候使用 JWT
JWT 适合已经有多服务验证需求、能够管理密钥轮换和撤销策略的系统。它不是“加密后的登录态”,通常只提供签名完整性;令牌签发后,服务端很难在不维护黑名单或缩短有效期的情况下立即撤销。把用户角色直接写进 JWT 也不能替代服务端资源权限检查。
如果团队没有明确的多服务需求,使用数据库或缓存管理的短期随机会话通常更容易撤销、审计和排障。无论哪种方案,都应定义有效期、刷新方式、退出登录、异常登录和多设备策略,不要让一个永久 Token 解决所有问题。
五、Django 中实现会话校验
可以把认证结果放到请求上下文中,让业务视图只使用经过校验的用户对象。下面示例展示核心边界,实际项目还应使用 Django 的密码学安全工具、数据库索引和事务。
import hashlib
import secrets
from datetime import timedelta
from django.http import JsonResponse
from django.utils import timezone
def token_digest(raw_token):
return hashlib.sha256(raw_token.encode("utf-8")).hexdigest()
def create_session(user, *, request):
raw_token = secrets.token_urlsafe(32)
MiniSession.objects.create(
user=user,
token_hash=token_digest(raw_token),
expires_at=timezone.now() + timedelta(days=7),
user_agent=request.headers.get("User-Agent", "")[:300],
)
return raw_token
def require_session(request):
header = request.headers.get("Authorization", "")
scheme, _, raw_token = header.partition(" ")
if scheme.lower() != "bearer" or not raw_token:
return None
session = (MiniSession.objects
.select_related("user")
.filter(token_hash=token_digest(raw_token), revoked_at__isnull=True,
expires_at__gt=timezone.now())
.first())
return session.user if session else None
def private_profile(request):
user = require_session(request)
if user is None:
return JsonResponse({"code": "AUTH_REQUIRED", "message": "请先登录"}, status=401)
return JsonResponse({"id": user.id, "display_name": user.get_username()})
认证失败返回 401,而不是返回空数据让客户端误以为请求成功。数据库应为 token_hash、expires_at 和 revoked_at 建立合适索引;清理过期会话可以使用定时任务。退出登录时撤销当前会话,修改密码、账号冻结或发现异常设备时可以撤销该用户全部会话。
不要在每个视图里复制一份略有差异的 Token 解析代码。可以通过装饰器、中间件或统一的 API 基类完成认证,但最终必须把用户对象放进服务端上下文,不能由请求体里的 user_id 覆盖。
六、认证通过不等于有权操作
授权至少需要检查三层:用户是否处于可用状态,用户是否拥有该动作,用户是否能访问这条具体资源。
例如“编辑文章”接口不能只判断 request.user.is_authenticated,还要确认文章属于当前用户或当前组织,且当前角色拥有编辑权限:
def has_org_permission(user, organization_id, codename):
# 这里代表项目自己的组织成员与角色查询,必须包含组织范围。
return user.memberships.filter(
organization_id=organization_id,
permissions__codename=codename,
is_active=True,
).exists()
def can_edit_article(user, article):
if not user.is_active:
return False
if article.organization_id != user.organization_id:
return False
if article.owner_id == user.id:
return True
return has_org_permission(user, article.organization_id, "change_article")
def update_article(request, article_id):
user = require_session(request)
if user is None:
return JsonResponse({"code": "AUTH_REQUIRED"}, status=401)
# 先按当前组织收窄查询,避免跨组织对象的存在性泄露。
article = get_object_or_404(
Article.objects.filter(organization_id=user.organization_id),
pk=article_id,
)
if not can_edit_article(user, article):
return JsonResponse({"code": "FORBIDDEN"}, status=403)
# 通过白名单字段更新,并在事务中校验版本或状态。
...
401 表示没有通过身份认证,403 表示身份已知但没有权限。不要为了“方便前端处理”把所有错误都返回 200;稳定的 HTTP 状态码和业务错误码能帮助监控、客户端和安全审计区分问题。
资源 ID 也不能当作权限。攻击者可以把 /orders/1001/ 改成 /orders/1002/,如果服务端只做登录检查,就会出现越权读取。所有对象查询都应带上当前用户、组织或租户范围,并为管理员、普通成员和只读成员分别写测试。
七、写操作要防重复、越权和重放
登录、下单、支付、提交表单和上传等操作通常会产生副作用。网络重试、用户连续点击和攻击者重放同一个请求都可能重复执行。
使用幂等键
客户端为一次业务操作生成随机 Idempotency-Key,服务端按用户、接口和键保存请求摘要与结果。相同键、相同业务参数再次到达时返回第一次结果,而不是重新创建记录;如果同一个键对应的请求参数不同,应拒绝并返回 409,不能把第一次结果错误地复用给另一笔操作。幂等记录和业务写入应放在同一个数据库事务中,并设置合理的过期时间。
校验状态转移
不要允许客户端直接提交“已支付”“已审核”或“管理员”这样的最终状态。服务端根据当前状态、操作者和服务器时间决定允许的下一步,例如订单只能从“待支付”进入“支付处理中”,支付结果则由服务端回调和验签流程确认。
限制频率和请求大小
登录、验证码、搜索、上传和导出接口应分别设置频率、大小和并发限制。限制要基于账号、设备、IP 和接口风险综合判断,不能只依赖单一 IP;同时为正常用户保留明确的重试提示。上传文件还要检查大小、MIME、扩展名、存储路径和处理超时。
八、密钥、隐私和日志边界
AppSecret、数据库密码、第三方 API 密钥和会话签名密钥应来自服务端环境变量或受控密钥管理,不要提交到代码仓库、客户端配置、Markdown 示例或前端构建产物。密钥轮换要有旧密钥过渡和回滚方案,不能只在出事故后临时修改。
登录不等于用户同意收集所有个人信息。按功能需要列出手机号、头像、位置等数据的用途和保存期限,配置与实际功能一致的隐私指引;用户拒绝非必要授权时,仍提供不依赖该数据的基本路径。数据访问、导出和删除也要经过服务端权限判断。
日志建议记录请求 ID、接口路径、状态码、耗时、应用版本和内部错误分类,必要时记录经过脱敏的用户内部 ID。以下内容不应进入普通日志:
code、AppSecret、session_key、Token 和签名原文;- 手机号、精确位置、身份证号等不必要的个人信息;
- 完整请求头、完整 Cookie 和带参数的第三方接口 URL;
- 可以直接复现支付、删除或管理员操作的敏感请求体。
访问分析还应区分普通用户、搜索爬虫、AI 爬虫和扫描器,避免把异常请求误算成业务用户。站内SEO 访问日志分析实战和Django 访问分析后台设计介绍了分类与留存边界。
九、用测试证明接口边界
安全测试不应只验证“登录成功”。至少准备下面的矩阵,并在真实体验版环境复测一次:
| 测试场景 | 预期结果 |
|---|---|
| 缺少 Authorization | 返回 401,不泄露资源内容 |
| Token 过期或已撤销 | 返回 401,客户端进入重新登录流程 |
| 普通用户访问他人资源 | 返回 403 或统一的资源不可见结果 |
| 修改请求体中的 user_id | 服务端仍使用会话中的用户身份 |
| 重复提交同一个幂等键 | 只产生一次业务结果 |
| 并发更新同一资源 | 根据版本或状态校验拒绝冲突写入 |
| 篡改客户端角色字段 | 服务端权限结果不改变 |
| 微信 code 复用或错误 | 登录失败,不创建重复账号 |
| 微信接口超时或非 JSON 响应 | 返回可重试错误,不暴露第三方响应细节 |
| 日志检索 | 找得到请求链路,但搜不到密钥和完整个人信息 |
测试数据要使用专门的体验账号和非生产密钥。生产环境出现认证异常时,先根据请求 ID 查询受限日志,再决定是否撤销会话或切换功能开关;不要为了排障临时打开“跳过权限”的后门。
十、上线检查和故障处理
发布前按照下面顺序做一次检查:
- 确认体验版和正式版的 API 基址、合法域名和证书都指向预期环境。
- 用真实设备验证登录、退出、过期、拒绝授权、弱网和重复提交。
- 检查小程序包、构建日志和错误追踪平台中没有 AppSecret、Token 或个人信息。
- 验证普通成员、管理员和被禁用账号的资源边界。
- 记录小程序版本、后端版本、数据库迁移、密钥版本和回滚步骤。
- 发布后用体验账号完成一次线上冒烟,并观察 401、403、429、5xx 和登录失败率。
常见现象可以按层定位:体验版请求失败先看合法域名、HTTPS 和环境配置;只有部分资源泄露则优先看对象级查询范围;重复订单或重复提交优先检查幂等键和事务;登录偶发失败则检查 code 是否被复用、服务端超时和微信错误码。不要只在客户端增加重试次数,重试前必须确认请求是否有副作用。
十一、简明的安全验收清单
- [ ] AppSecret、数据库凭证和签名密钥只存在于服务端受控配置中。
- [ ]
wx.login得到的code只发送到 HTTPS 登录接口,并由服务端兑换。 - [ ] 服务端严格校验微信响应为对象且
errcode == 0,再创建或映射账号。 - [ ] 客户端只保存本站会话凭证,不保存或上传
session_key。 - [ ] Token 不可预测、有过期时间、可撤销,数据库只保存安全摘要。
- [ ] 每个受保护接口都做身份认证和资源级权限校验。
- [ ] 写操作有幂等策略、状态转移校验和重复提交测试。
- [ ] 登录、上传、导出等高风险接口有频率和大小限制。
- [ ] 日志包含请求 ID 和结果,但不记录密钥、Token 和不必要的个人信息。
- [ ] 体验版和正式版都验证弱网、过期会话、越权、重复提交与回滚步骤。
接口安全不是在登录页加一个 Token 就结束,而是把身份来源、会话生命周期、资源权限、写操作状态和日志边界统一起来。先用小范围体验版验证这些边界,再逐步开放支付、上传和管理员功能,通常比上线后依赖人工排查更可靠。具体字段、平台接口和隐私要求应以项目当前版本的官方文档为准。
评论 (0)
暂无评论,快来抢沙发吧!