如何在ESP32 Arduino框架下生成适合和风天气的JWT
观前提示:我只是一个 ESP32 萌新,这些代码是我和 DeepSeek 一起写出来的,所以代码烂是正常的。
另外,我用的是 PlatformIO,所以本教程对于 Arduino IDE 有些方法可能不适用。
注:此章会使用上一章节的完整demo。如果没看,点我跳转,找到下方「完整demo」章节,但最好看完
完整源码在下方「jwt_ed25519.h 代码总览」和「main.cpp 代码总览」章节,建议先看完讲解再复制使用。
导言
何为JWT?
搬运原文
JWT (JSON Web Token) 是目前最流行的跨域认证解决方案,是一种基于 Token 的认证授权机制。 从 JWT 的全称可以看出,JWT 本身也是 Token,一种规范化之后的 JSON 结构的 Token。
JWT 自身包含了身份验证所需要的所有信息,因此,我们的服务器不需要存储 Session 信息。这显然增加了系统的可用性和伸缩性,大大减轻了服务端的压力。
可以看出,JWT 更符合设计 RESTful API 时的「Stateless(无状态)」原则 。
如果客户端把 JWT 作为 Bearer Token 显式放入 Authorization Header,浏览器不会像 Cookie 那样自动附带它,因此可以降低传统 CSRF 风险。不过,这取决于凭据的传输和存储方式,而不是 JWT 格式本身;如果把 JWT 放在 Cookie 中,仍然需要 CSRF 防护。
我在 JWT 优缺点分析这篇文章中有详细介绍到使用 JWT 做身份认证的优势和劣势。
下面是 RFC 7519 对 JWT 做的较为正式的定义。
JSON Web Token (JWT) is a compact, URL-safe means of representing claims to be transferred between two parties. The claims in a JWT are encoded as a JSON object that is used as the payload of a JSON Web Signature (JWS) structure or as the plaintext of a JSON Web Encryption (JWE) structure, enabling the claims to be digitally signed or integrity protected with a Message Authentication Code (MAC) and/or encrypted. ——JSON Web Token (JWT)
译文(机翻,仅供参考):
JSON Web Token(JWT)是一种紧凑且URL安全的表示声明的方式,用于在两方之间传输。JWT中的声明以JSON对象形式编码,该对象可作为JSON Web Signature(JWS)结构的有效载荷或JSON Web Encryption(JWE)结构的明文,从而实现声明的数字签名或通过消息认证码(MAC)进行完整性保护,以及加密处理。——JSON Web Token (JWT)
简单来说,JWT 对比传统 API KEY 更加安全。
为什么要使用 JWT 而不直接使用 API KEY?
首先,正如上文所说,JWT更加安全。其次,和风天气在 2024-10-30 发布公告,即将在 2027年2月1日 开始限制使用API KEY(包括基于API KEY的数字签名)的 每日 请求量。这一限制措施可以确保入侵者即使获取到了API KEY也无法在短时间内请求大量数据而对开发者的利益造成损失。
相关文档:对API KEY请求量的限制 (和风天气官方发文)
关于我自己的故事:我折腾了好几天,发现网上的教程要么是针对ESP-IDF框架,要么就是HS256加密(和风天气强制要求使用EdDSA),所以项目暂时停滞,因为不想让单片机“寄生”在我的电脑上,遂撰写此文。
相关文档:身份认证 (和风天气官方文档)
准备工作
- ESP32开发板一块(本文使用ESP32-WROOM-32D,其他型号理论上兼容)
- arduinolibs
- WiFiClientSecure(自带,不用管)
- 上篇文章的完整 demo
提示:不用去库管理器安装arduinolibs,因为压根没有
正式开始部署
注意:私钥切记不要上传或泄露,它只保存在你的 ESP32 代码中。
在网页部署请参见和风天气官方文档:
生成Ed25519密钥
上传公钥
Linux用户
首先,对于Linux用户,运行以下命令:
1 | |
对于command not found: git,运行以下命令:
| 发行版 | 安装命令 |
|---|---|
| Debian/Ubuntu | sudo apt install git |
| Fedora/RHEL | sudo dnf install git |
| Arch系 | sudo pacman -S git |
NixOS和FreeBSD的朋友,既然你们都知道怎么用这个系统了,git应该会装吧?
如果不知道自己的发行版是什么,在终端运行 cat /etc/os-release | grep "^ID" 查看自己的发行版。
Windows 用户
使用命令行配置
如果安装了git,请打开开始菜单,找到 Git Bash 并运行
在 Git Bash 里依次执行以下命令:
1 | |
对的没错,Git Bash支持Linux语法。
如果没安装,请下载安装 Git for Windows:点这里跳转官网
具体教程请移步搜索引擎,本文不再赘述。
不想碰命令行?
打开你的项目 src 文件夹,新建一个文件夹,重命名为lib;
点击绿色的 Code 按钮 → Download ZIP;
解压后,进入 arduinolibs-master/libraries/Crypto 文件夹;
把里面所有 .cpp、.h 文件以及 utility 文件夹,全部复制粘贴到你的项目 src 文件夹中的 lib 文件夹里,即可安装。
如果不知道项目的 src 目录在哪:用 VS Code 打开你的项目,左侧文件列表里的 src 文件夹就是。右键点击它,选择“复制路径”,然后替换命令中的 [你的项目src目录] 即可。(/lib 保留别动)
开干
jwt_ed25519.h 代码解析
既然安装好了,那就开干。不过跟上篇文章不同,这次我们要自己写一个.h头文件放在main.cpp的同级位置。
创建一个jwt_ed25519.h文件,放在 src 目录下,然后在 jwt_ed25519.h 中写下这几行:
1 | |
#ifndef JWT_ED25519_H和#define JWT_ED25519_H:防止重复包含导致编译失败。mbedtls/base64.h:是一个关于URLbase64编解码的头文件。lib/Ed25519.h:你看!前面安装的库就有用了,这就是EdDSA的签名要用到的玩意。由于我们安装库的时候放在了lib文件夹内,而我们写的这个头文件在src文件夹里,所以我们在前面要加一个 lib/ ,这样编译器才能正常链接文件。
接着我们写extractEd25519PrivateKey函数,用于从 PEM 提取 Ed25519 私钥:
1 | |
代码首先对输入的 PEM 字符串做预处理:移除标记行 -----BEGIN PRIVATE KEY----- 和 -----END PRIVATE KEY-----,以及换行符和回车符,保留纯 Base64 编码部分。
之后用 mbedtls_base64_decode(NULL, 0, ...) 进行第一次解码调用,目的是计算解码后的数据长度。如果长度小于 32 字节,说明数据无效,直接返回失败。
接着分配与解码长度相等的内存缓冲区,执行实际解码。解码后的数据是 ASN.1 DER 格式的私钥结构,Ed25519 私钥位于结构末尾的 32 字节。代码通过 memcpy(outPrivateKey, decoded + decodedLen - 32, 32) 提取最后 32 字节,存入输出参数。
最后释放内存并返回成功。
接着来写生成 Ed25519 JWT函数:
1 | |
函数接收 PEM 格式私钥、kid、sub、签发时间和过期时间,返回完整的 JWT 字符串。
执行流程分为六个阶段。第一阶段构建 JWT 头部,包含算法(EdDSA)和密钥 ID(kid),生成 JSON 格式字符串。第二阶段构建载荷,包含签发时间 iat、过期时间 exp 和主题 sub。第三阶段对头部和载荷分别进行 Base64URL 编码,并用点号拼接成待签名数据。
第四阶段调用 extractEd25519PrivateKey 从 PEM 私钥中提取 32 字节原始私钥。第五阶段调用 Ed25519::derivePublicKey 从私钥派生出 32 字节公钥。第六阶段调用 Ed25519::sign 对待签名数据进行签名,生成 64 字节签名。
最后阶段将签名进行 Base64URL 编码,拼接到待签名数据后面,用点号分隔,形成完整的 JWT 字符串并返回。
jwt_ed25519.h 代码总览
最后,写上#endif,jwt_ed25519.h就写完了。
代码总览:
1 | |
main.cpp 代码解析
把上一章的完整demo拷过来,由于篇幅问题,这里只列出重要部分,如果想看完整 JWT 实现,请见下方的 main.cpp 代码总览。
首先加入头文件、一些宏定义和一些变量
1 | |
其次把
1 | |
改为
1 | |
这一步是取消api key的调用方式。
然后添加这几个函数在 getGeoData 前:
1 | |
解析:syncTime() 函数是用来同步 NTP 时间的,用于生成JWT提供准确时间。getCurrentTimestamp() 函数用来同步本地时间。
重头戏: getJWT() 函数用来正式生成JWT。
然后改getGeoData() 函数:
1 | |
然后改setup()函数:
1 | |
loop函数不变,保持原样
main.cpp 代码总览
接着,我们把所有代码拼接好,下面是最终代码:
1 | |
效果展示
1 | |