【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 字符串、对象和调试事件,这对于快速接通不同触发器很方便;但“能解析”只是入口第一层,后面仍需建立安全边界。

二、先明确:这是原型,不是生产认证闭环

当前文件有以下事实:

项目 当前状态 生产要求
短信凭据 占位字符串 密钥托管/环境变量/轮换
签名与模板 示例占位 审核通过的实际配置
验证码生成 客户端传入 服务端安全随机生成
手机号校验 只判断存在 格式、国家码、长度白名单
频率控制 多维限流与冷却
验证码存储 哈希、过期、尝试次数
防重放 一次性消费与幂等
日志脱敏 请求字段白名单与掩码
供应商错误 原文返回 稳定业务错误码

所以这份函数可以证明“项目准备过短信供应商调用”,不能证明“手机号登录已经安全上线”。文章和项目说明应使用“接入原型”“待生产化”这类准确措辞。

三、最大边界问题:验证码不应由客户端决定 ⚠️

当前客户端请求同时提供 identifierverifyCode,云函数直接把这个 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 或触发器事件类型;
  • 只接受对象,不接受数组和原始值;
  • 只提取允许字段,忽略未知字段;
  • identifierpurposerequestId 的类型和长度;
  • 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;
}

生命周期规则:

  1. 使用安全随机源生成固定长度验证码;
  2. 不保存明文验证码,不写日志;
  3. 设置有效期,过期后不可延长使用;
  4. 每次错误校验原子扣减次数;
  5. 达到上限立即失效;
  6. 成功校验后原子写入 consumedAt
  7. 同一 challengeId 不能二次成功;
  8. 发送新验证码时,按业务规则废弃旧挑战。

不能用 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 云函数 短信验证码 应用安全 限流 身份认证

img

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐