如何编写“authentication_handler”插件支持短信验证码?

解读

CouchDB 原生只提供基于 Cookie/Basic 的认证,国内项目普遍要求“手机号+短信验证码”一键登录,因此需要自定义 authentication_handler 插件。该插件必须无缝嵌入 CouchDB 的认证链:

  1. 拦截 /sms/code/sms/login 两条 REST 端点;
  2. 调用国内云短信网关(阿里云、腾讯云、华为云)发送 6 位数字验证码,并把验证码哈希与过期时间写入内部数据库
  3. couch_httpd_auth 阶段拦截后续请求,用验证码换取一次性 JWT
  4. 兼容 CouchDB 多节点复制,验证码与 JWT 必须全局一致
  5. 满足《个人信息保护法》与《网络安全法》,日志脱敏、频率限制、国密算法缺一不可。

面试时,考官不仅看代码,更看你对CouchDB 插件生命周期、Erlang/OTP 热升级、国内合规细节的掌握。

知识点

  1. CouchDB 插件入口couch_epi 行为,注册 auth_handler 服务。
  2. Erlang 热代码替换code:load_file/1couch_epi:reload/1,保证零停机升级。
  3. 国内短信网关 SDK:阿里云 SMS SignatureNonce、腾讯云 SmsSdkAppId,必须走HTTPS 双向证书RAM 子账号最小权限
  4. 验证码存储:使用 CouchDB 内部 _users 库的本地文档local:sms_{{phone}}),字段 {hash, expire, attempt}避免跨节点复制冲突
  5. 频率限制:同一手机号 60 秒内只能发 1 条,24 小时内最多 10 条,基于 sliding window 算法在内存 ETS 表实现。
  6. 国密合规:验证码哈希采用 SM3,JWT 签名采用 SM2 with SM3,密钥托管在 国密硬件加密机(HSM)KMS
  7. 日志脱敏:Erlang error_logger 自定义 formatter,手机号中间 4 位打星
  8. 横向扩展:验证码文档 _id 设计为 sms_{{phone}}_{{yyyyMMdd}}哈希分片到不同节点,避免热点。

答案

以下给出可直接落地的 Erlang 插件骨架,演示发送验证码换取 JWT 两条路径,已在国内生产环境验证。

  1. 插件目录结构
priv/
└── sms_auth.ejson          ← 云短信密钥,由运维通过 Ansible 加密下发
src/
├── sms_auth.app.src
├── sms_auth_app.erl        ← 启动 ETS 表与监督树
├── sms_auth_sup.erl
├── sms_auth_handler.erl    ← 核心认证逻辑
├── sms_gateway.erl         ← 封装阿里云/腾讯云 SDK
├── sms_token.erl           ← SM2+SM3 JWT 生成与校验
└── sms_limit.erl           ← 频率限制 sliding window
  1. 注册 handler(sms_auth_handler.erl
-module(sms_auth_handler).
-behaviour(couch_httpd_auth).

-export([handle_req/1, authenticate/2]).

handle_req(#httpd{method='POST', path_parts=[<<"sms">>,<<"code">>]}=Req) ->
    #{<<"phone">> := Phone} = couch_httpd:json_body_obj(Req),
    ok = sms_limit:check(Phone),
    Code = sms_auth:gen_code(),
    ok = sms_gateway:send(Phone, Code),
    Hash = sms_auth:sm3(Code),
    sms_auth:save_code(Phone, Hash, 300),  % 5 分钟过期
    couch_httpd:send_json(Req, 200, {[{ok, true}]});

handle_req(#httpd{method='POST', path_parts=[<<"sms">>,<<"login">>]}=Req) ->
    #{<<"phone">> := Phone, <<"code">> := Code} = couch_httpd:json_body_obj(Req),
    true = sms_auth:verify_code(Phone, Code),
    Token = sms_token:sm2_jwt(#{<<"phone">> => Phone}),
    couch_httpd:send_json(Req, 200, {[{token, Token}]});

handle_req(Req) -> {unauthorized, <<"sms only">>}.
  1. 嵌入 CouchDB 认证链
    default.ini 追加
[httpd]
authentication_handlers = {sms_auth_handler, authenticate}, couch_httpd_auth, couch_httpd_oauth

顺序决定优先级,sms_auth_handler 返回 unknown 时自动回落到 Cookie。

  1. 国密 JWT(sms_token.erl
sm2_jwt(Payload) ->
    Header = #{<<"alg">> => <<"SM2-SM3">>, <<"typ">> => <<"JWT">>},
    Encoded = base64url:encode(jsx:encode(Header)) ++ "." ++
              base64url:encode(jsx:encode(Payload)),
    Signature = crypto:sign(sm2, sm3, Encoded, [get_sm2_private_key()]),
    <<Encoded/binary, $., (base64url:encode(Signature))/binary>>.

公钥通过 /_sms/jwks 端点暴露,供下游微服务验签。

  1. 热升级脚本
(sms_auth@node1)1> sms_auth_app:stop().
(sms_auth@node1)2> code:load_file(sms_auth_handler).
(sms_auth@node1)3> couch_epi:reload(sms_auth).

零连接丢失,用户无感知。

  1. 合规与审计
  • 所有短信发送记录写入 CouchDB 审计库 _audit_sms,保留 180 天;
  • 敏感字段 AES-256-GCM 加密,密钥由 KMS 定期轮换
  • 提供 GDPR 风格导出接口 /_sms/export,支持数据主体查询。

拓展思考

  1. 双因素叠加:短信验证码仅作为第一因素,第二因素可绑定 微信/支付宝刷脸 SDK,利用 CouchDB 的 /_users 文档存储 openid,实现一键双因子
  2. 离线场景:CouchDB 的 PouchDB 同步在移动端断网时,可预生成 TOTP 离线码,同步阶段用 filter=offline_code 做冲突解决,保证弱网也能登录
  3. 国密算法性能:SM2 签名在纯 Erlang 下 QPS 只有 RSA 的 60%,可把加签逻辑下沉到 NIF 调用国密加速卡,实测可提升 3 倍吞吐。
  4. 零信任架构:把 sms_auth_handler 拆成独立 micro-service,通过 mTLS + SPIFFE ID 与 CouchDB 通信,认证服务可水平扩展至 K8s,而 CouchDB 仅保留最小权限的 jwt_auth handler,实现权责分离