【OpenHarmony/HarmonyOS】短信验证码云函数设计:参数校验、限流、安全与客户端边界
【OpenHarmony/HarmonyOS】短信验证码云函数设计:参数校验、限流、安全与客户端边界
短信验证码看起来只有“接收手机号 → 调用供应商 → 返回成功”三步,实际上它同时连接身份认证、付费资源、隐私数据和攻击入口。仓库中的
cloud_functions/send-sms/handler.js已经搭好了阿里云短信 SDK 调用骨架,但配置仍是占位值,验证码由客户端传入,且缺少严格校验、限流、过期与一次性消费。它适合作为集成原型,不应直接描述为已部署的生产登录系统。本文在保留现有思路的基础上,给出一条完整的生产化演进路线。📱
一、当前云函数做了什么?
函数当前流程可以概括为:
flowchart LR
A["接收 event"] --> B["解析 event.body 或 event"]
B --> C["读取 identifier、verifyCode"]
C --> D["移除 +86/86 前缀"]
D --> E["调用短信供应商 SDK"]
E --> F["返回 code/message"]
关键入口如下:
let myHandler = async function(event, context) {
console.log("Receive event: " + JSON.stringify(event));
try {
let bodyObj;
if (event.body) {
bodyObj = typeof event.body === 'string'
? JSON.parse(event.body)
: event.body;
} else {
bodyObj = event;
}
const phoneNumber = bodyObj.identifier;
const verifyCode = bodyObj.verifyCode;
// ...构建供应商请求并发送
} catch (error) {
return {
code: 500,
message: "Send SMS Failed: " + error.message
};
}
};
兼容 event.body 字符串、对象和调试事件,这对于快速接通不同触发器很方便;但“能解析”只是入口第一层,后面仍需建立安全边界。
二、先明确:这是原型,不是生产认证闭环
当前文件有以下事实:
| 项目 | 当前状态 | 生产要求 |
|---|---|---|
| 短信凭据 | 占位字符串 | 密钥托管/环境变量/轮换 |
| 签名与模板 | 示例占位 | 审核通过的实际配置 |
| 验证码生成 | 客户端传入 | 服务端安全随机生成 |
| 手机号校验 | 只判断存在 | 格式、国家码、长度白名单 |
| 频率控制 | 无 | 多维限流与冷却 |
| 验证码存储 | 无 | 哈希、过期、尝试次数 |
| 防重放 | 无 | 一次性消费与幂等 |
| 日志脱敏 | 无 | 请求字段白名单与掩码 |
| 供应商错误 | 原文返回 | 稳定业务错误码 |
所以这份函数可以证明“项目准备过短信供应商调用”,不能证明“手机号登录已经安全上线”。文章和项目说明应使用“接入原型”“待生产化”这类准确措辞。
三、最大边界问题:验证码不应由客户端决定 ⚠️
当前客户端请求同时提供 identifier 与 verifyCode,云函数直接把这个 code 放进模板参数。攻击者可以自行选择验证码,认证强度就取决于后续验证接口是否相信该值;如果验证端没有独立的服务端记录,验证码形同虚设。
正确职责应拆成两个接口:
sequenceDiagram
participant U as "用户"
participant C as "HarmonyOS 客户端"
participant F as "云函数/认证服务"
participant S as "短信供应商"
participant K as "限流与验证码存储"
U->>C: 输入手机号并请求验证码
C->>F: requestCode(phone, purpose, requestId)
F->>F: 校验参数、风控、生成安全随机码
F->>K: 保存 codeHash、expiresAt、attempts
F->>S: 发送验证码
S-->>F: 供应商结果
F-->>C: 返回通用结果与 cooldown
U->>C: 输入收到的验证码
C->>F: verifyCode(challengeId, code)
F->>K: 原子校验并一次性消费
F-->>C: 返回认证结果
客户端只提交用户输入的手机号和用途,不提交“将要发送的验证码”。验证码由服务端使用密码学安全随机源生成;服务端只保存验证码的带盐哈希或等价安全表示,并设置很短的有效期。
四、事件解析必须限制输入形状和大小
当前实现直接 JSON.parse(event.body),然后假设 bodyObj.identifier 可用。生产入口至少要限制:
- Body 最大字节数,避免大请求消耗内存;
- Content-Type 或触发器事件类型;
- 只接受对象,不接受数组和原始值;
- 只提取允许字段,忽略未知字段;
identifier、purpose、requestId的类型和长度;- JSON 解析失败返回 400 类业务错误,而不是统一 500。
function parseRequest(event) {
const raw = event && event.body !== undefined
? event.body
: event;
const body = typeof raw === 'string' ? JSON.parse(raw) : raw;
if (!body || typeof body !== 'object' || Array.isArray(body)) {
throw new AppError('INVALID_REQUEST');
}
return {
identifier: String(body.identifier || '').trim(),
purpose: String(body.purpose || 'login'),
requestId: String(body.requestId || '')
};
}
这里是演进示例。String(...) 只是归一化的一部分,后面仍必须校验长度和格式,不能把任意对象强转后视为合法输入。
五、手机号归一化不能只删除“86”
当前逻辑:以 +86 开头就截 3 位,以 86 开头就截 2 位。这存在边界歧义:如果支持多个国家或地区,不能把所有号码都当成中国大陆手机号;如果用户输入空格、连字符或异常前缀,也没有明确处理。
若产品当前只支持一个地区,可以写成明确策略:
function normalizeMainlandPhone(input) {
const compact = input.replace(/[\s-]/g, '');
const local = compact.startsWith('+86')
? compact.slice(3)
: compact;
if (!/^1[3-9]\d{9}$/.test(local)) {
throw new AppError('INVALID_PHONE');
}
return local;
}
若要支持国际短信,应使用成熟的电话号码解析库,以 E.164 等标准格式存储,配置国家/地区白名单与各地区模板;不要继续堆 startsWith 分支。
手机号校验不是为了判断号码真实存在,而是拒绝明显非法输入并形成唯一归一化键。真实归属仍要靠短信挑战本身确认。
六、凭据必须离开源码
当前配置中的 AccessKey、签名和模板都是占位值,没有真实凭据,这一点是安全的;但注释要求“替换为自己的 AccessKey”容易诱导开发者把正式密钥直接写进仓库。
生产配置应来自环境或秘密管理服务:
const SMS_CONFIG = {
accessKeyId: process.env.SMS_ACCESS_KEY_ID,
accessKeySecret: process.env.SMS_ACCESS_KEY_SECRET,
endpoint: process.env.SMS_ENDPOINT,
signName: process.env.SMS_SIGN_NAME,
templateCode: process.env.SMS_TEMPLATE_CODE
};
function validateConfig(config) {
const required = [
config.accessKeyId,
config.accessKeySecret,
config.signName,
config.templateCode
];
if (required.some((item) => !item)) {
throw new Error('SMS_CONFIG_INCOMPLETE');
}
}
文档中的 ${SMS_ACCESS_KEY_ID} 仅表示占位名称,不是实际值。还应做到:最小权限、区分测试和生产账户、定期轮换、泄露后可立即吊销,以及禁止把凭据输出到日志或异常响应。
七、Client 在模块加载时创建:优点与边界
当前代码在模块加载阶段执行:
let client = createClient();
云函数容器复用时,这可以减少每次请求的初始化成本。但要处理几个问题:
- 配置缺失会在模块加载时失败,是否能得到清晰健康检查;
- 密钥轮换后,长生命周期实例是否及时刷新;
- SDK client 是否适合并发复用;
- 测试时如何注入 fake client;
- endpoint 与超时是否明确配置。
可以保留懒加载单例,同时把创建函数变成可注入依赖:
let smsClient;
function getSmsClient() {
if (!smsClient) {
validateConfig(SMS_CONFIG);
smsClient = createClient(SMS_CONFIG);
}
return smsClient;
}
这不是强制模板。真正选择取决于云函数运行时和 SDK 文档,但必须明确冷启动、复用和密钥轮换策略。
八、限流要同时看手机号、来源和业务用途
只按手机号限流会被攻击者轮换号码;只按 IP 限流会误伤共享网络用户。推荐组合多个维度:
| 维度 | 示例规则 | 防御目标 |
|---|---|---|
| phone + purpose | 短冷却,例如倒计时后再发 | 连续轰炸同一用户 |
| phone | 小时/日上限 | 单号码滥用 |
| sourceHash/IP 段 | 滑动窗口 | 批量请求 |
| device/session | 单设备上限 | 自动化脚本 |
| account | 风险分层 | 已登录场景滥用 |
| 全局 | 供应商预算阈值 | 费用失控 |
限流状态必须以原子方式更新,不能“先读取次数,再分别写回”,否则并发请求会同时通过。对于分布式云函数,应使用支持原子自增/条件写的存储。
返回时不要告诉攻击者某个手机号是否已注册。发送接口可统一回复“若号码可用,验证码将发送”,同时返回冷却时间和请求追踪 ID。
九、验证码生命周期:生成、保存、校验、消费
一个验证码挑战至少包含:
interface SmsChallenge {
challengeId: string;
phoneHash: string;
purpose: 'login' | 'bind' | 'reset';
codeHash: string;
expiresAt: number;
attemptsRemaining: number;
consumedAt?: number;
createdAt: number;
}
生命周期规则:
- 使用安全随机源生成固定长度验证码;
- 不保存明文验证码,不写日志;
- 设置有效期,过期后不可延长使用;
- 每次错误校验原子扣减次数;
- 达到上限立即失效;
- 成功校验后原子写入
consumedAt; - 同一
challengeId不能二次成功; - 发送新验证码时,按业务规则废弃旧挑战。
不能用 Math.random() 生成认证验证码,也不能只在客户端倒计时:客户端时间和界面都可修改,真正的过期判断必须在服务端。
十、发送成功与认证成功是两回事
供应商返回 OK,只说明供应商接受了发送请求,不等于:
- 手机一定收到短信;
- 手机号属于当前用户;
- 验证码已经通过;
- 用户已经登录;
- 可以发放游戏资产。
应把状态拆开:
REQUEST_ACCEPTED → PROVIDER_ACCEPTED → CODE_VERIFIED → AUTH_ISSUED
↘ PROVIDER_FAILED
CODE_VERIFIED → CONSUMED(不可回退)
客户端收到 REQUEST_ACCEPTED 后展示倒计时和输入框;只有 verifyCode 成功并由认证服务签发会话后,才能切换登录态。游戏页面不能因为“短信发送成功”就直接信任某个用户标识。
十一、日志必须脱敏,当前实现尤其需要改
当前函数会记录完整 event、清洗后的手机号、验证码和供应商完整响应。这几类信息都不应原样进入生产日志。
推荐记录:
console.info(JSON.stringify({
event: 'sms_send_result',
requestId,
phoneHash: hashForAudit(normalizedPhone),
purpose,
providerCode: safeProviderCode,
durationMs,
success
}));
禁止记录:
- 明文手机号;
- 验证码或验证码哈希;
- AccessKey、Token、签名凭据;
- 完整请求事件;
- 供应商响应中的潜在个人数据;
- 用户输入的任意原文。
phoneHash 也不是绝对匿名。手机号空间有限,普通无盐哈希可能被枚举;审计标识应使用带服务端秘密的 HMAC 或受控映射,并设置日志访问权限与保留周期。
十二、不要把供应商错误原文返回客户端
当前 catch 返回:
return {
code: 500,
message: "Send SMS Failed: " + error.message
};
供应商错误可能包含内部配置、请求标识或诊断细节,而且前端会被迫依赖不稳定文案。应建立稳定业务错误码:
| 对外错误码 | 用户语义 | 内部处理 |
|---|---|---|
INVALID_REQUEST |
请求格式不正确 | 不重试 |
TOO_MANY_REQUESTS |
操作频繁,请稍后 | 返回 cooldown |
SMS_TEMPORARILY_UNAVAILABLE |
服务暂不可用 | 可有限重试 |
CODE_INVALID |
验证码不正确 | 扣减尝试次数 |
CODE_EXPIRED |
验证码已过期 | 重新获取 |
CODE_ALREADY_USED |
验证已失效 | 阻止重放 |
内部日志可以保存白名单化 providerCode,并通过 requestId 关联;外部响应不返回堆栈、SDK 消息或配置状态。
十三、幂等和超时:防止“点一次发两条”
客户端可能因弱网重试、用户连点或页面恢复重复请求。发送接口应接受一次性 requestId,服务端在短时间内对相同 phoneHash + purpose + requestId 返回同一挑战结果,而不是再次扣费发送。
调用供应商还应配置:
- 连接/请求超时;
- 明确的有限重试,只重试可恢复错误;
- 供应商请求幂等能力(若支持);
- 熔断和全局预算保护;
- 发送状态对账,避免未知结果无限重发。
不能在云函数超时后盲目重试,因为供应商可能已经接收请求,只是响应丢失。此时幂等键和供应商侧查询能力决定能否安全重放。
十四、HarmonyOS 客户端应该承担什么?
客户端负责交互,不负责安全真相:
interface RequestCodeResponse {
requestId: string;
cooldownSeconds: number;
}
async function requestSmsCode(phone: string): Promise<void> {
this.isSending = true;
try {
const result = await authApi.requestCode({
identifier: phone,
purpose: 'login',
requestId: createRequestId()
});
this.startCountdown(result.cooldownSeconds);
} catch (error) {
this.showFriendlyError(mapAuthError(error));
} finally {
this.isSending = false;
}
}
客户端可以做基础格式提示、禁用连点按钮、展示倒计时和处理错误码;但服务端仍要重复所有安全校验。不要把验证码、限流剩余次数或“是否已注册”保存在可篡改的本地状态中作为权威依据。
十五、测试矩阵 🧪
| 测试场景 | 预期行为 |
|---|---|
| body 为非法 JSON | 返回 INVALID_REQUEST,无内部堆栈 |
| identifier 缺失/超长 | 拒绝,不调用供应商 |
| 国家码不支持 | 返回稳定格式错误 |
| 同号码快速重复 | 原子限流,仅首个请求可发送 |
| 同 requestId 重放 | 返回同一结果,不重复发送 |
| 供应商超时 | 进入未知/失败状态,不无限重试 |
| 错误验证码多次 | 尝试次数递减并最终锁定 |
| 正确验证码重放 | 第二次返回已消费 |
| 过期验证码 | 即使值正确也拒绝 |
| 日志扫描 | 不出现手机号、验证码、密钥 |
| 配置缺失 | 冷启动快速失败并触发告警 |
| 并发验证 | 只能有一个请求成功消费 |
还应对费用保护做演练:当全局日预算或错误率达到阈值时,系统暂停发送、告警运维,并给客户端返回通用服务不可用,而不是继续消耗付费额度。
十六、生产化检查清单 ✅
- 验证码由服务端安全随机生成;
- 凭据来自秘密管理或环境配置,源码只保留占位名称;
- 请求体有类型、长度和字段白名单;
- 手机号按支持地区标准化;
- 手机号、来源、设备、全局预算多维限流;
- 验证码哈希存储、短时过期、限制尝试次数;
- 成功校验原子消费,禁止重放;
- 发送与验证接口分离;
- 写操作具备 requestId 幂等;
- 日志不含明文手机号、验证码和完整事件;
- 对外只返回稳定错误码;
- 客户端发送成功不等同于登录成功;
- 测试环境与生产环境凭据、模板、预算完全隔离;
- 有监控、告警、熔断和供应商故障预案。
十七、总结 ✨
仓库中的短信函数已经展示了事件解析、手机号前缀处理、阿里云 SDK 请求和 AGC 云函数导出形式,是一个可读的集成起点;但它目前使用占位配置、接受客户端传入的验证码、记录完整敏感数据,并缺少校验、限流、过期、一次性消费与错误隔离。因此它应被明确标注为“接入原型”。
生产验证码系统的核心不在发送 API,而在服务端边界:安全随机生成、哈希存储、多维限流、短期有效、尝试次数、原子消费、幂等请求、日志脱敏和稳定错误码。HarmonyOS 客户端负责良好交互,云函数负责不信任任何客户端输入,认证服务负责签发最终可信身份。只有这三层职责都闭环,短信才能从“发出去”升级为“可用于认证”。🔐
推荐标签: OpenHarmony HarmonyOS ArkTS 云函数 短信验证码 应用安全 限流 身份认证

更多推荐


所有评论(0)