这是一套基于 Cloudflare Serverless 生态的统一认证系统,前端(/src
)与后端(/functions
)使用 Cloudflare Pages 托管,数据库使用 Cloudflare D1,人机验证使用 Cloudflare Turnstile,发信使用 腾讯云 SES 服务 提供的 HTTP API
后端相关配置在 /functions/_configs
下,前端相关配置在 /src/configs
下;Cloudflare 相关服务配置在 wrangler.toml
;Cloudflare D1 数据库建表 SQL 在 schema.sql
- 运行
pnpm init-db:local
初始化本地数据库(如果没有初始化过) - 运行
pnpm dev:api
在3001
端口上启动 API 服务器 - 运行
pnpm dev:web
在3000
端口上启动 Vite,此时 API 将被反向代理到http://localhost:3000/api
推送到 main
分支即可自动部署
- 运行
pnpm build
构建前端 - 运行
pnpm init-db:remote
初始化远程数据库(如果没有初始化过) - 运行
pnpm run deploy
进行部署
-
登录 Cloudflare,编辑对应的 D1 数据库,创建 App
App Name
App 名字,kebab-case 格式App Secret
App 密钥,用于解密用户基本信息与授权 App 读写用户关联数据,32 位字母与数字Redirect URL
登陆成功后重定向到的 URL,加密用户基本信息sauce
会拼接在 URL 中返回Attributes
可选 App 属性,JSON 字符串,目前可用属性有displayName
(ePass 中的显示名称)、logoUrl
(ePass 中的 Logo 图片地址) -
业务前端把用户重定向到
https://<ePass 域名>/connect/<App Name>
,用户登陆后,携带加密用户基本信息sauce
返回业务前端 -
业务前端在
Redirect URL
接收sauce
,传递sauce
到业务后端,接收 JWT -
业务后端接到
sauce
,按照下述解密说明解密出用户基本信息,示例如下:{ "userId": 10001, // 用户 ID "username": "example-user", // 用户名 "displayName": "示例用户", // 用户展示名 "email": "[email protected]", // 用户邮箱 "issueDate": 1721441086, // `sauce` 签发时间戳 }
-
重要! 业务后端校验
sauce
签发时间,应该不早于当前时间 5 秒,否则视为过期(可以根据具体情况调整) -
业务后端签发 JWT,返回给前端
-
业务前端设置 JWT
-
业务前端通过
replace
的方式跳转到登陆后页面(防止浏览器历史记录留下sauce
)
sauce
是加密后的用户基本信息,为 Base64URL 编码的二进制数据。
首先需要对 sauce
进行 Base64URL 解码,Base64URL 指的是 +/
被替换为 -_
的 Base64 变体,如果使用的 Base64 解码库不支持 Base64URL 解码,可以手动把 -_
替换为 +/
,再进行 Base64 解码。
解码后的二进制数据前 12 字节为 IV
,其余部分为密文 Ciphertext
,使用 App Secret
、IV
对 Ciphertext
使用 AES-256-GCM 解密为 UTF-8 字符串,即为用户基本信息 JSON。
import base64
from Crypto.Cipher import AES
def decrypt_sauce(app_secret, sauce):
sauceBytes = base64.urlsafe_b64decode(sauce)
iv = sauceBytes[:12]
ciphertext = sauceBytes[12:]
cipher = AES.new(app_secret.encode('utf-8'), AES.MODE_GCM, nonce=iv)
# In AES-GCM, the actual ciphertext contains the authentication tag at the end (last 16 bytes)
tag_position = -16
plaintext = cipher.decrypt_and_verify(ciphertext[:tag_position], ciphertext[tag_position:])
return plaintext
import javax.crypto.Cipher;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
public class EpassSauceDecryption {
private static final int GCM_TAG_LENGTH = 16;
private static final int GCM_IV_LENGTH = 12;
public static String decrypt(String appSecret, String sauce) throws Exception {
// Base64URL 解码
byte[] sauceBytes = Base64.getUrlDecoder().decode(sauce);
// 前 12 字节为IV
byte[] iv = new byte[GCM_IV_LENGTH];
System.arraycopy(sauceBytes, 0, iv, 0, GCM_IV_LENGTH);
// 剩余部分为密文(包含认证标签)
int ciphertextLength = sauceBytes.length - GCM_IV_LENGTH;
byte[] ciphertextWithTag = new byte[ciphertextLength];
System.arraycopy(sauceBytes, GCM_IV_LENGTH, ciphertextWithTag, 0, ciphertextLength);
// 创建 AES 密钥
SecretKeySpec keySpec = new SecretKeySpec(appSecret.getBytes(StandardCharsets.UTF_8), "AES");
// 设置 GCM 参数
GCMParameterSpec gcmParameterSpec = new GCMParameterSpec(GCM_TAG_LENGTH * 8, iv);
// 初始化解密 Cipher
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.DECRYPT_MODE, keySpec, gcmParameterSpec);
// 解密(密文和认证标签都在 ciphertextWithTag 中)
byte[] decryptedText = cipher.doFinal(ciphertextWithTag);
// 返回解密后的字符串
return new String(decryptedText, StandardCharsets.UTF_8);
}
}
function base64UrlToUint8Array(base64Url: string) {
base64Url = base64Url.replace(/-/g, '+').replace(/_/g, '/')
const rawData = atob(base64Url)
const outputArray = new Uint8Array(rawData.length)
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i)
}
return outputArray
}
async function decryptSauce(sauce: string, key: string) {
const ciphertextArray = base64UrlToUint8Array(sauce)
const iv = ciphertextArray.slice(0, 12)
const actualCiphertext = ciphertextArray.slice(12)
const encoder = new TextEncoder()
const keyBuffer = encoder.encode(key)
const cryptoKey = await crypto.subtle.importKey(
'raw',
keyBuffer,
{ name: 'AES-GCM' },
false,
['decrypt'],
)
const decryptedBuffer = await crypto.subtle.decrypt(
{
name: 'AES-GCM',
iv: iv,
},
cryptoKey,
actualCiphertext,
)
const decoder = new TextDecoder()
const decryptedText = decoder.decode(decryptedBuffer)
return decryptedText
}
- 返回的用户基本信息中可以选择性取用;如果需要频繁调用接口,请优先考虑使用 Redis 缓存
- 修改密码或基本信息请引导用户到
https://<ePass 域名>/i
进行修改 - App Name 预留
i
,请不要使用,i
为“我的通行证”用户设置页面的 App Name
- 实现管理员界面
- 支持自定义重定向 URL,并对传入的重定向 URL 进行校验(正则表达式),防止滥用