所属专题:前端专题导航:HTML、CSS、JavaScript 与 UI 框架

微信小程序接口安全实战:登录态、Token 与权限校验怎么设计

微信小程序接口安全实战:登录态、Token 与权限校验怎么设计 - 暂无配图,技术文章默认封面

微信小程序的页面可以正常打开,并不代表接口已经安全。真正需要保护的是服务端的登录凭证、会话、业务权限和每一次有副作用的请求。客户端代码会随小程序包分发,任何写在客户端的密钥、权限判断或固定接口参数都不能作为安全边界。

本文沿着一次真实请求的生命周期,说明如何设计微信小程序接口安全:客户端取得登录凭证,服务端完成身份映射,再签发本站会话;接口根据服务端保存的身份和资源关系做权限判断;写操作使用幂等键和状态校验,日志只保留排障所需的最小信息。上一篇小程序开发平台怎么选解决的是平台路线,前一篇微信小程序开发流程实战介绍了从页面到发布的链路,本文专门补上上线后最容易被忽略的接口安全部分。

最重要的边界只有一句话:小程序负责发起请求,服务端负责相信谁、允许做什么,以及这次操作能不能再次执行。

一、先区分身份、会话和权限

这三个概念经常被混在一起,导致代码“能登录”却不能安全地访问业务数据。

概念 要回答的问题 推荐保存位置
平台身份 微信平台把这次登录对应到哪个用户主体? 服务端账号表
应用会话 当前请求是否已经登录,凭证什么时候失效? 服务端会话表或缓存;客户端只保存不可推导的会话凭证
业务权限 这个用户能否操作当前资源? 服务端角色、组织和资源关系

openid 可以帮助服务端识别某个小程序中的用户,但它不是通用的授权令牌,也不能代替资源权限判断。客户端提交一个 openid、用户 ID 或角色名,服务端都不能直接照单全收;这些字段最多作为展示信息,真正的身份应从服务端验证过的会话中取得。

项目开始时先画出最小数据关系,例如 User、MiniProgramIdentity、Session、Role 和业务资源表。一个用户可能在不同平台拥有不同身份,一个账号也可能属于多个组织。把平台标识、应用标识和内部用户 ID 分开保存,后续增加其他端时不必重写授权逻辑。

二、微信登录的安全链路

微信小程序登录的安全链路可以拆成五步:

  1. 小程序调用 wx.login,取得一次性登录凭证 code。
  2. 小程序通过 HTTPS 把 code 发送给本站登录接口。
  3. 服务端使用自己的 AppID 和 AppSecret 调用微信 code2Session 接口。
  4. 服务端根据返回的用户标识创建或查找内部账号,并生成本站会话。
  5. 客户端只保存本站会话凭证,后续请求通过 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 查询受限日志,再决定是否撤销会话或切换功能开关;不要为了排障临时打开“跳过权限”的后门。

十、上线检查和故障处理

发布前按照下面顺序做一次检查:

  1. 确认体验版和正式版的 API 基址、合法域名和证书都指向预期环境。
  2. 用真实设备验证登录、退出、过期、拒绝授权、弱网和重复提交。
  3. 检查小程序包、构建日志和错误追踪平台中没有 AppSecret、Token 或个人信息。
  4. 验证普通成员、管理员和被禁用账号的资源边界。
  5. 记录小程序版本、后端版本、数据库迁移、密钥版本和回滚步骤。
  6. 发布后用体验账号完成一次线上冒烟,并观察 401、403、429、5xx 和登录失败率。

常见现象可以按层定位:体验版请求失败先看合法域名、HTTPS 和环境配置;只有部分资源泄露则优先看对象级查询范围;重复订单或重复提交优先检查幂等键和事务;登录偶发失败则检查 code 是否被复用、服务端超时和微信错误码。不要只在客户端增加重试次数,重试前必须确认请求是否有副作用。

十一、简明的安全验收清单

  • [ ] AppSecret、数据库凭证和签名密钥只存在于服务端受控配置中。
  • [ ] wx.login 得到的 code 只发送到 HTTPS 登录接口,并由服务端兑换。
  • [ ] 服务端严格校验微信响应为对象且 errcode == 0,再创建或映射账号。
  • [ ] 客户端只保存本站会话凭证,不保存或上传 session_key。
  • [ ] Token 不可预测、有过期时间、可撤销,数据库只保存安全摘要。
  • [ ] 每个受保护接口都做身份认证和资源级权限校验。
  • [ ] 写操作有幂等策略、状态转移校验和重复提交测试。
  • [ ] 登录、上传、导出等高风险接口有频率和大小限制。
  • [ ] 日志包含请求 ID 和结果,但不记录密钥、Token 和不必要的个人信息。
  • [ ] 体验版和正式版都验证弱网、过期会话、越权、重复提交与回滚步骤。

接口安全不是在登录页加一个 Token 就结束,而是把身份来源、会话生命周期、资源权限、写操作状态和日志边界统一起来。先用小范围体验版验证这些边界,再逐步开放支付、上传和管理员功能,通常比上线后依赖人工排查更可靠。具体字段、平台接口和隐私要求应以项目当前版本的官方文档为准。

参考资料

分享这篇文章:

评论 (0)

请 登录 后发表评论, 还没有账户?立即注册

暂无评论,快来抢沙发吧!