文档

基于 Better Auth + Next.js 实现腾讯云短信验证:完整接入指南

前言

参考教程 https://cloud.tencent.com/document/product/382/43070

手机短信验证作为现代 Web 应用的重要身份验证方式,已经成为用户注册、登录和安全验证的标配功能。相比传统的邮箱验证,短信验证具有到达率高、验证速度快、用户体验好等优势。

最近,我在基于 Better AuthNext.js 的项目中集成了腾讯云短信服务,实现了完整的手机验证码登录功能。本文将从背景介绍、技术选型、实现流程到具体代码,为你提供一个完整的腾讯云短信服务接入指南。

背景:短信验证的重要性

为什么选择短信验证

  1. 高到达率:短信到达率通常在 95% 以上,远高于邮件
  2. 快速验证:用户无需切换应用,直接在短信中查看验证码
  3. 安全性高:手机号与用户身份强绑定,难以伪造
  4. 用户习惯:国内用户对短信验证接受度很高

技术选型:为什么选择腾讯云

在众多短信服务提供商中,我们选择腾讯云的原因:

优势

  • 价格优势:国内短信 0.045元/条,价格相对较低
  • 稳定可靠:腾讯云基础设施成熟,服务稳定性高
  • API 友好:SDK 完善,文档清晰,集成简单
  • 审核快速:模板审核通常 1-2 个工作日
  • 覆盖全面:支持国内外短信发送

注意事项

  • 需要企业认证(个人开发者可以申请个人认证)
  • 短信模板需要审核,建议提前准备
  • 有发送频率限制,需要合理设计防刷机制

前提条件

在开始集成之前,你需要准备以下条件:

1. 腾讯云账号准备

  • 注册腾讯云账号
  • 完成实名认证(务必是企业,请不要使用个人!个人大概率审核不通过)
  • 开通短信服务

2. 短信服务配置

  • 创建短信应用
  • 申请短信签名(如:【你的产品名】)
  • 创建短信模板(如:验证码为12分钟内有效)
  • 获取 SecretId 和 SecretKey

3. 开发环境

  • Node.js 项目(本文基于 Next.js)
  • Better Auth 已配置
  • 数据库支持(本文使用 Prisma)

实现流程概览

整个腾讯云短信服务集成分为以下几个步骤:

  1. 环境配置:配置腾讯云相关环境变量
  2. SMS 服务封装:创建腾讯云短信发送服务
  3. Better Auth 配置:集成 phoneNumber 插件
  4. 数据库更新:添加手机号相关字段
  5. 前端组件:创建手机验证码登录界面
  6. 测试验证:完整功能测试

具体实现步骤

1. 安装依赖

首先安装腾讯云 SDK:

bun add tencentcloud-sdk-nodejs

2. 环境变量配置

.env.local 文件中添加腾讯云短信相关配置:

# 腾讯云短信服务配置
TENCENT_SMS_SECRET_ID=your_secret_id
TENCENT_SMS_SECRET_KEY=your_secret_key
TENCENT_SMS_REGION=ap-guangzhou
TENCENT_SMS_SDK_APP_ID=your_sdk_app_id
TENCENT_SMS_SIGN_NAME=你的签名
TENCENT_SMS_TEMPLATE_ID=your_template_id

⚠️ 腾讯云短信服务的短信接口目前只开放在 ap-guangzhou 区域,填写其他区域会导致调用返回 “The action not support this region”。

3. 创建短信服务封装

创建 src/lib/sms/tencent-sms.ts 文件:

import { sms } from 'tencentcloud-sdk-nodejs';

const SmsClient = sms.v20210111.Client;

const client = new SmsClient({
  credential: {
    secretId: process.env.TENCENT_SMS_SECRET_ID!,
    secretKey: process.env.TENCENT_SMS_SECRET_KEY!,
  },
  region: process.env.TENCENT_SMS_REGION!,
});

export async function sendVerificationCodeSMS(
  phoneNumber: string,
  code: string
): Promise<{ success: boolean; error?: string }> {
  try {
    const params = {
      PhoneNumberSet: [phoneNumber],
      SmsSdkAppId: process.env.TENCENT_SMS_SDK_APP_ID!,
      SignName: process.env.TENCENT_SMS_SIGN_NAME!,
      TemplateId: process.env.TENCENT_SMS_TEMPLATE_ID!,
      TemplateParamSet: [code, '5'], // 验证码和有效期
    };

    const response = await client.SendSms(params);
    
    if (response.SendStatusSet?.[0]?.Code === 'Ok') {
      return { success: true };
    } else {
      return {
        success: false,
        error: response.SendStatusSet?.[0]?.Message || '发送失败',
      };
    }
  } catch (error) {
    console.error('腾讯云短信发送失败:', error);
    return {
      success: false,
      error: error instanceof Error ? error.message : '未知错误',
    };
  }
}

4. 配置 Better Auth

src/lib/auth/auth.ts 中添加 phoneNumber 插件:

import { betterAuth } from 'better-auth';
import { phoneNumber } from 'better-auth/plugins';
import { sendVerificationCodeSMS } from '@/lib/sms/tencent-sms';

export const auth = betterAuth({
  // ... 其他配置
  plugins: [
    phoneNumber({
      async sendOTP({ phoneNumber, otp }) {
        const result = await sendVerificationCodeSMS(phoneNumber, otp);
        if (!result.success) {
          throw new Error(result.error || '短信发送失败');
        }
      },
    }),
    // ... 其他插件
  ],
});

5. 更新数据库 Schema

prisma/schema.prisma 中添加手机号字段:

model User {
  id                   String    @id @default(cuid())
  email                String?   @unique
  phoneNumber          String?   @unique
  phoneNumberVerified  Boolean   @default(false)
  // ... 其他字段
}

运行数据库迁移:

bun run db:migrate

6. 配置客户端

src/lib/auth/client.ts 中添加 phoneNumber 客户端插件:

import { createAuthClient } from 'better-auth/react';
import { phoneNumberClient } from 'better-auth/client/plugins';

export const authClient = createAuthClient({
  baseURL: process.env.NEXT_PUBLIC_BETTER_AUTH_URL,
  plugins: [
    phoneNumberClient(),
    // ... 其他插件
  ],
});

7. 创建前端组件

创建 src/components/auth/phone-login.tsx

'use client';

import { useState } from 'react';
import { authClient } from '@/lib/auth/client';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';

export function PhoneLogin() {
  const [phoneNumber, setPhoneNumber] = useState('');
  const [otp, setOtp] = useState('');
  const [step, setStep] = useState<'phone' | 'otp'>('phone');
  const [loading, setLoading] = useState(false);
  const [countdown, setCountdown] = useState(0);

  const sendOTP = async () => {
    if (!phoneNumber) return;
    
    setLoading(true);
    try {
      await authClient.phoneNumber.sendOtp({ phoneNumber });
      setStep('otp');
      startCountdown();
    } catch (error) {
      console.error('发送验证码失败:', error);
    } finally {
      setLoading(false);
    }
  };

  const verifyOTP = async () => {
    if (!otp) return;
    
    setLoading(true);
    try {
      await authClient.phoneNumber.verify({
        phoneNumber,
        code: otp,
      });
      // 登录成功,跳转或刷新页面
      window.location.reload();
    } catch (error) {
      console.error('验证失败:', error);
    } finally {
      setLoading(false);
    }
  };

  const startCountdown = () => {
    setCountdown(60);
    const timer = setInterval(() => {
      setCountdown((prev) => {
        if (prev <= 1) {
          clearInterval(timer);
          return 0;
        }
        return prev - 1;
      });
    }, 1000);
  };

  if (step === 'phone') {
    return (
      <div className="space-y-4">
        <Input
          type="tel"
          placeholder="请输入手机号"
          value={phoneNumber}
          onChange={(e) => setPhoneNumber(e.target.value)}
        />
        <Button
          onClick={sendOTP}
          disabled={loading || !phoneNumber}
          className="w-full"
        >
          {loading ? '发送中...' : '发送验证码'}
        </Button>
      </div>
    );
  }

  return (
    <div className="space-y-4">
      <Input
        type="text"
        placeholder="请输入验证码"
        value={otp}
        onChange={(e) => setOtp(e.target.value)}
      />
      <div className="flex gap-2">
        <Button
          onClick={verifyOTP}
          disabled={loading || !otp}
          className="flex-1"
        >
          {loading ? '验证中...' : '验证登录'}
        </Button>
        <Button
          variant="outline"
          onClick={sendOTP}
          disabled={countdown > 0 || loading}
        >
          {countdown > 0 ? `${countdown}s` : '重发'}
        </Button>
      </div>
    </div>
  );
}

常见问题与解决方案

1. 短信发送失败

问题:收到错误 InvalidParameterValue.IncorrectPhoneNumber

解决方案

  • 确保手机号格式正确(如:+8613800138000)
  • 检查是否包含国家代码
  • 验证手机号是否在黑名单中

2. 模板参数错误

问题:收到错误 InvalidParameterValue.TemplateParameterFormatError

解决方案

  • 检查模板参数数量是否匹配
  • 确认参数顺序正确
  • 验证模板是否已审核通过

3. 签名问题

问题:收到错误 InvalidParameterValue.IncorrectSignature

解决方案

  • 确认签名已审核通过
  • 检查签名名称是否完全匹配(包括【】符号)
  • 验证签名是否与应用绑定

4. 频率限制

问题:短信发送过于频繁被限制

解决方案

  • 实现客户端倒计时限制
  • 添加服务端频率控制
  • 使用 Redis 缓存验证码状态

安全建议

1. 验证码安全

  • 验证码长度建议 4-6 位
  • 设置合理的过期时间(5-10分钟)
  • 验证后立即失效
  • 限制验证次数(如 3 次)

2. 防刷机制

  • 同一手机号限制发送频率
  • 同一 IP 限制发送数量
  • 添加图形验证码
  • 监控异常发送行为

3. 数据保护

  • 敏感信息加密存储
  • 定期清理过期验证码
  • 记录操作日志
  • 遵循数据保护法规

总结

通过本文的详细指南,你应该能够成功在 Better Auth + Next.js 项目中集成腾讯云短信服务。这套方案具有以下优势:

  • 成本可控:腾讯云短信价格合理,适合中小型项目
  • 集成简单:Better Auth 提供了完善的 phoneNumber 插件
  • 用户体验好:流程简洁,验证快速
  • 安全可靠:多重安全机制保障

在实际使用中,建议根据业务需求调整验证流程,添加必要的安全措施,确保服务的稳定性和安全性。

相关资源