ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

支付宝小程序Python后端认证:解决pycrypto安装失败与手写OAuth模块实践

支付宝小程序Python后端认证:解决pycrypto安装失败与手写OAuth模块实践 踩了整整两天alipay-sdk-python的坑之后我决定把这段经历完整写下来。事情起因很简单我给支付宝小程序写 Python 后端需要在用户授权后用前端拿到的code换取user_id / open_id。正常思路是直接装官方 SDK结果pip install alipay-sdk-python时依赖的pycrypto死活装不上从 Windows 报 Visual C 14.0 required到 Linux 上报fatal error: openssl/md2.h file not found整个环境搭建阶段就卡了大半天。这篇文章我会把认证链路的来龙去脉、pycrypto 安装失败的根因、两种可用解法以及最终我手写认证模块的完整代码全部讲清楚适合正在做支付宝小程序 Python 后端、又不想被老旧的加密库折腾到怀疑人生的开发者参考。1. 项目与故障背景小程序用户认证到底在认证什么1.1 从源头梳理认证链路支付宝小程序的前后端认证链路本质上是一套“授权码兑换身份标识”的流程并不神秘。小程序端调用my.getAuthCode拿到一个临时授权码code然后把这个code通过业务接口传给后端。后端拿到code后调用开放平台的alipay.system.oauth.token接口用grant_typeauthorization_code去换用户的user_id、open_id、access_token和refresh_token。整个过程中后端扮演的是一个“受托人”的角色真正校验用户身份的是支付宝开放平台。这里最容易被新手忽略的一点是my.getAuthCode拿到的code有效期非常短通常几分钟内就会失效而且只能使用一次。所以后端拿到code后必须立刻去换 token不能把这个code存到数据库里打算以后用。我在联调时就遇到过一次前端把code打印在日志里过了十分钟才发给后端结果接口直接返回isv.code-invalid。1.2 我在安装 alipay-sdk-python 时看到的报错我当时的环境是 Python 3.10Windows 11项目用 virtualenv 管理依赖。执行pip install alipay-sdk-python日志走到一半pip 开始尝试构建pycrypto然后就抛出了error: Microsoft Visual C 14.0 is required. Get it with Build Tools for Visual Studio同事在 macOS 上的报错更干脆build/temp.macosx-12.0-arm64/.../src/MD2.c:31:10: fatal error: openssl/md2.h file not found换到一台 CentOS 7 上尝试报的是gcc: error: unrecognized command-line option -stdc11。三种平台三种死法但根因指向同一个目标pycrypto这个库实在过于古老已经不适合在当前技术栈里继续使用了。1.3 官方 SDK 为什么会绑死 pycryptoalipay-sdk-python是支付宝官方维护的 Python SDK但它的依赖策略明显滞后。它的底层加密工具模块引入了Crypto.Cipher.AES、Crypto.PublicKey.RSA等来自pycrypto的库。pycrypto的 PyPI 页面显示它最后更新于 2013 年前后项目实际上早就停止维护了。官方 SDK 没有及时把依赖切换到同为Crypto命名空间的pycryptodome导致所有新环境在安装阶段都会撞上这个坑。这个问题的恶心之处在于它不一定每次都报错。如果你恰好用的是 Python 3.6 或 3.7并且系统里已经预装了老版本的 OpenSSLpycrypto可能侥幸编译通过。但只要你升级到 Python 3.9、3.10、3.11 这些版本或者系统 OpenSSL 版本更新编译失败的概率几乎是百分百。2. “pycrypto 无法安装”的根因拆解2.1 Python 版本演进与 ABI 兼容性要搞懂为什么pycrypto装不上得先理解 Python 的 ABIApplication Binary Interface概念。第三方库如果是 C 扩展编译出来的.so或.pyd文件是和特定 Python 版本绑定的。pycrypto最后一次发布时Python 还停留在 2.7 / 3.4 时代它的 C 源码里大量使用了早已废弃的 API。到 Python 3.10 之后解释器内部结构发生了变化旧的 C 扩展写法无法通过编译。即使你运气好在某些旧镜像上能找到预编译的 wheel 包pycrypto包本身也没有提供 Python 3.10 的 wheelpip 只能选择源码编译。一旦走上编译这条路就需要完整的工具链Windows 上要 Visual C Build ToolsLinux 上要python3-dev、gcc、libssl-dev。更麻烦的是即使装齐了编译工具pycrypto的源码里还引用了 OpenSSL 1.0 时代才有的头文件openssl/md2.h现代发行版大多已经用 OpenSSL 3.x这个头文件早就被移除了。2.2 旧版 C 扩展在现代环境里的通病pycrypto的问题并不是个例。类似年代的M2Crypto、pycurl也经常遇到同样的情况源码里写死了旧的 API编译器一升级就报错。这类库本质上已经不兼容 2024 年的技术栈了继续在项目里硬顶只是给自己埋坑。还有一点要说清楚pycrypto和pycryptodome是两个不同的项目但它们在 Python 里暴露的模块名都叫Crypto。pycryptodome是一个活跃维护的替代品API 保持了高度兼容绝大多数代码只需要改依赖名不需要改导入语句。正是因为这个特性我们才能用“偷梁换柱”的方式把官方 SDK 救活。3. 方案上手指南切换到 pycryptodome 拯救官方 SDK3.1 快速全局替换依赖如果你的项目已经安装了alipay-sdk-python现在因为pycrypto缺失导致整个环境不可用最直接的方式是pip uninstall pycrypto -y pip install pycryptodome安装完成之后验证一下是否可以正常导入python -c from Crypto.PublicKey import RSA; print(Crypto OK)如果之前pycrypto安装失败导致alipay-sdk-python也没有装上可以先手动安装pycryptodome再安装alipay-sdk-pythonpip install pycryptodome pip install alipay-sdk-python由于整个包命名空间都是Crypto官方 SDK 源码里的from Crypto.PublicKey import RSA会自动命中pycryptodome不需要逐文件修改导入语句。这也是我推荐先安装pycryptodome再装 SDK 的原因。3.2 验证替换方式是否生效替换完成后跑一个简单的初始化用例确认 SDK 本身能正常导入并完成签名方法绑定from alipay.aop_api import AlipayApi from alipay import AlipayConfig config AlipayConfig() config.app_id 2021000000000000 config.private_key open(./app_private_key.pem, r).read() config.alipay_public_key open(./alipay_public_key.pem, r).read() config.sign_type RSA2 api AlipayApi(config) print(SDK init ok)这一步能过说明 SDK 已经可以正常初始化。不过我要提前打个预防针alipay-sdk-python的类名、方法名在不同版本里差别很大有的版本入口是AlipayClient有的版本是DefaultAlipayClient还有的版本是AlipayApi。务必以你安装的那个版本源码为准不要直接照抄网上的旧文章。可以先在 Python 里执行pip show alipay-sdk-python python -c import alipay; print(dir(alipay))看看当前安装的版本对外暴露了哪些类。3.3 官方 SDK 的实用入口初始化示例以我自己项目里使用的版本为例初始化配置如下from alipay import AlipayConfig from alipay.aop_api import AlipayApi config AlipayConfig() config.app_id 2021000000xxxx config.private_key open(./keys/app_private_key.pem).read() config.alipay_public_key open(./keys/alipay_public_key.pem).read() config.sign_type RSA2 config.gateway_host openapi.alipay.com api AlipayApi(config)然后调用 token 兑换接口resp api.call_alipay_system_oauth_token( grant_typeauthorization_code, codexxxxxxxx ) print(resp)如果签名、密钥、授权码都正确响应里会带上user_id、open_id、access_token等字段。如果出现异常SDK 通常会抛出AlipayApiException异常信息里会包含支付宝返回的错误码和错误描述。不过我必须承认折腾完这一整套替换之后我仍然对官方 SDK 心存疑虑。原因很简单这个 SDK 的更新节奏太不稳定API 变动频繁出了问题网上能查到的资料往往对应的是别的版本。与其在它的坑里继续挣扎不如直接手写一个极简认证模块只依赖requests和cryptography两个稳定的库。4. 更稳定的替代手写认证调用4.1 签名前的参数处理和请求字符串构造支付宝开放平台的签名规则本质上并不复杂只是细节多。核心流程是把所有请求参数公共参数 业务参数放在同一个字典里剔除sign字段本身和值为空的参数然后按参数名的 ASCII 码升序排序拼成key1value1key2value2的字符串用应用私钥对这个字符串做SHA256withRSA签名得到 Base64 编码的sign参数。我见过很多人在签名阶段踩坑问题往往出在“要不要对参数值做 URL 编码”上。根据支付宝官方文档和长期实践签名用的明文字符串直接使用参数原始值拼接不需要调用quote_plus。发送请求时requests库会对data参数自动进行表单编码这个环节不需要你手动干预前提是你的参数值里没有中文或特殊符号。alipay.system.oauth.token接口的请求参数很简单基本不会出现非 ASCII 字符所以用原始值拼接是安全的。下面是我在项目里实际使用的参数构造方式def build_sign_string(params: dict) - str: filtered {k: v for k, v in params.items() if v not in (, None) and k ! sign} sorted_keys sorted(filtered.keys()) return .join(f{k}{filtered[k]} for k in sorted_keys)4.2 私钥加载、RSA2 签名和请求发送签名部分我直接使用cryptography库它比pycrypto更现代安装时有预编译的 wheel不会出现编译失败的问题。加载私钥时需要注意支付宝开放平台生成的应用私钥通常是 PKCS#8 格式的 PEM 文件但如果你在控制台复制的是一段包含-----BEGIN RSA PRIVATE KEY-----的 PKCS#1 格式文本cryptography也是支持的。最稳妥的办法是把私钥保存为单独的文件用load_pem_private_key读取避免把私钥直接写在代码里。from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.asymmetric import padding import base64 def sign_rsa2(private_key: str, sign_string: str) - str: key serialization.load_pem_private_key( private_key.encode(utf-8) if isinstance(private_key, str) else private_key, passwordNone, ) signature key.sign( sign_string.encode(utf-8), padding.PKCS1v15(), hashes.SHA256(), ) return base64.b64encode(signature).decode(utf-8)有了签名函数就可以封装一个通用的请求方法import requests from datetime import datetime GATEWAY https://openapi.alipay.com/gateway.do class AlipayOauthClient: def __init__(self, app_id, private_key, alipay_public_key): self.app_id app_id self.private_key private_key self.alipay_public_key alipay_public_key def build_common_params(self, method): return { app_id: self.app_id, method: method, format: JSON, charset: utf-8, sign_type: RSA2, timestamp: datetime.now().strftime(%Y-%m-%d %H:%M:%S), version: 1.0, } def execute(self, method, biz_params): params self.build_common_params(method) params.update(biz_params) sign_string build_sign_string(params) params[sign] sign_rsa2(self.private_key, sign_string) resp requests.post(GATEWAY, dataparams, timeout5) return resp.json()实际调用client AlipayOauthClient( app_id2021000000xxxx, private_keyopen(./app_private_key.pem).read(), alipay_public_keyopen(./alipay_public_key.pem).read(), ) result client.execute( methodalipay.system.oauth.token, biz_params{ grant_type: authorization_code, code: xxxxxxxx, }, ) print(result)4.3 如何自己校验支付宝响应签名请求发出去之后不能只拿到user_id就认为万事大吉。严谨的做法是校验响应签名确认真的是支付宝返回的而不是中间人伪造的。响应体通常长这样{ alipay_system_oauth_token_response: { access_token: ..., user_id: 2088..., open_id: ..., expires_in: 31536000, refresh_token: ... }, sign: ... }校验签名的时候需要提取alipay_system_oauth_token_response的值把它序列化成一个 JSON 字符串然后作为唯一的待签名内容和响应里的sign字段一起做 RSA2 验签。这里有一个容易踩的坑支付宝返回的响应体里alipay_system_oauth_token_response字段的值本质上是字符串形式的 JSON很多场景下它的字段顺序是固定的。最靠谱的方式是用json.dumps(response_payload, separators(,, :))重新序列化确保拼出来的字符串和支付宝签名时用的完全一致。from cryptography.hazmat.primitives.asymmetric import padding from cryptography.hazmat.primitives import hashes import json, base64 def verify_response(resp_json: dict) - bool: sign resp_json.get(sign) # 找到响应体里除了 sign 之外的那个业务字段 business_field None for key in resp_json: if key ! sign: business_field key break if not business_field: return False content json.dumps(resp_json[business_field], separators(,, :), ensure_asciiFalse) alipay_pub serialization.load_pem_public_key(self.alipay_public_key.encode()) try: alipay_pub.verify( base64.b64decode(sign), content.encode(utf-8), padding.PKCS1v15(), hashes.SHA256(), ) return True except Exception: return False这里还要强调一个细节ensure_asciiFalse很关键。如果响应里有中文昵称等信息支付宝签名时用的是原始 UTF-8 字符而json.dumps默认会把非 ASCII 字符转成\uXXXX。不关掉这个开关验签永远失败。我第一次就是这个原因排查了整整一个晚上。5. 联调中最容易踩的 5 个认证细节问题5.1 授权码时效与 scope 权限前面提到过code有效期很短这里再补充一个容易被忽视的点my.getAuthCode在支付宝小程序端可以传入scopes参数常见取值是auth_base和auth_user。如果只做登录态需要的用户身份标识用auth_base就够了不需要申请额外的用户信息权限。如果你需要获取用户的昵称、头像等资料就必须使用auth_user或者单独调用my.getOpenUserInfo并且在小程序后台申请对应的权限包。权限没开通的时候后端调接口会返回isv.auth-permission-error前端代码写得再正确也没用。5.2 timestamp 格式为什么要精确到秒支付宝网关对timestamp的格式要求是yyyy-MM-dd HH:mm:ss精确到秒且必须是北京时间。很多新手喜欢用时间戳数字比如1700000000或者带毫秒的 ISO 格式结果签名校验不过去。更隐蔽的问题是服务器时区设置不对。如果你的服务器时区不是Asia/Shanghaidatetime.now()生成的时间是 UTC8 之外的时间支付宝网关比对时间戳时可能直接判定非法。建议在项目设置里显式指定时区from datetime import datetime import pytz bj_tz pytz.timezone(Asia/Shanghai) timestamp datetime.now(bj_tz).strftime(%Y-%m-%d %H:%M:%S)5.3 open_id 与 user_id 的区别支付宝在逐步推进open_id的普及它和小程序 AppID 维度绑定同一个用户在不同小程序下得到的open_id不同。user_id是支付宝账号维度的唯一标识形如2088开头的 16 位数字。如果你的业务数据结构里需要区分不同小程序下的同一用户建议优先使用open_id作为主键把user_id作为辅助字段。在我目前接手的项目里新版授权接口返回的open_id已经成为主推荐字段部分场景甚至不再返回user_id所以后端表结构不要写死只认user_id。5.4 证书模式和公钥模式的配置不可混用支付宝开放平台有两种密钥配置模式。一种是我在上面示例里用的公钥模式需要配置“应用公钥”和“支付宝公钥”请求时只带sign字段。另一种是证书模式需要配置应用证书、支付宝公钥证书、支付宝根证书请求时要额外带上app_cert_sn、alipay_root_cert_sn。这两种模式不能混用如果你在控制台上传了证书后端却用公钥模式拼接参数网关会直接报isv.invalid-app-cert-sn之类的错误。在alipay-sdk-python的较新版本里初始化配置时会有app_cert和alipay_root_cert的可选参数。建议先确认控制台里的密钥配置再去决定代码走哪条分支。5.5 响应验签时小心 HTTP 库的自动编码最后一个坑和requests库的行为有关。调用resp.json()之后你拿到的已经是解析好的字典但如果你需要验签最好保留原始响应文本。原因在于requests在发出请求时会对参数做quote_plus编码而支付宝返回的响应体根据请求头的charset决定编码方式。绝大多数情况下resp.text能正确还原 UTF-8 内容但如果遇到编码异常解析出来的字符串和支付宝签名时用的字符串不一致验签就会失败。我习惯这样处理resp requests.post(GATEWAY, dataparams, timeout5) raw_text resp.text resp_json resp.json()验签时直接基于raw_text取出业务字段而不是用resp.json()的结果重新序列化。代码上虽然多了一步但排查编码问题的时间和成本会少很多。写在最后的个人体会这次踩坑给我最大的启发是老牌官方 SDK 不一定是你项目里最可靠的选择尤其是它依赖了一个十年未维护的加密库时。pycrypto装不上这个问题看起来只是个环境问题实际上反映的是整个 Python 技术栈向前演进带来的兼容性阵痛。如果你时间充裕可以按我第三节的方式用pycryptodome把官方 SDK 救活如果你想长期维护一个稳定的认证模块我更推荐直接手写因为alipay.system.oauth.token这个接口的复杂度真的不高核心逻辑就是一次签名、一次 POST、一次验签。我后来把这段认证逻辑封装成了独立的AlipayOauthClient类放在项目的common模块里接口调用方只关心传入code、返回用户标识完全不感知底下用的是官方 SDK 还是自研实现。后续如果再遇到某个 Python 版本升级导致兼容性问题我也只需要修改一个类文件而不是在整个项目里搜索alipay的调用点。这种把不可控依赖隔离在单个模块里的做法可能就是这次踩坑经历最大的收获。
返回列表