阿里云 H5 一键登录接入实战:前后端完整实现

本文介绍在 React + Node(Koa) 项目中接入阿里云号码认证服务(H5 一键登录)的完整流程,包含前端网页端 SDK 与后端 OpenAPI 的对接,以及常见坑点与解决方案。适合有一定前后端基础、希望为 H5 页面增加「本机号一键登录」能力的开发者。


一、什么是 H5 一键登录?

一键登录是指用户无需输入手机号和验证码 ,在授权页确认本机号码并同意协议后,由运营商(移动/联通/电信)返回该手机号,完成登录或注册。

H5 网页 里实现时,需要用到:

  • 阿里云号码认证服务:提供 H5 能力与 OpenAPI。
  • 网页端 SDKaliyun_numberauthsdk_web,负责鉴权、拉起授权页、获取 spToken
  • 服务端 :调用阿里云 GetAuthTokenGetPhoneWithToken 两个接口,拿到鉴权 Token 和最终手机号,并完成自家业务的登录/注册。

整体流程可以概括为:

  1. 前端向自家后端要鉴权 Token(accessToken、jwtToken)。
  2. 后端调用阿里云 GetAuthToken,把 Token 返回给前端。
  3. 前端用 SDK checkLoginAvailable 鉴权,通过后 getLoginToken 拉起授权页。
  4. 用户输入手机号中间 4 位、勾选协议并点击登录,SDK 返回 spToken
  5. 前端把 spToken 交给后端,后端调用 GetPhoneWithToken 拿到手机号,再按业务做登录/注册,返回登录态。

下面按前置准备、后端、前端、注意事项四块说明,并给出本项目中的核心代码。


二、前置准备:阿里云控制台

  1. 开通 号码认证服务,并创建 H5 认证方案

  2. 在方案中配置:

    • 页面地址协议 + // + 域名 + /,如 http://yourdomain.com/
    • 请求来源(源地址)协议 + // + 域名,如 http://yourdomain.com(末尾不要 /
  3. 记录 方案 Code (SceneCode),以及 AccessKey ID / AccessKey Secret(RAM 子账号即可,需具备号码认证相关权限)。

注意 :中国移动侧能力需在创建方案后的第 2 个工作日才能使用;若遇 105113「AppID 非法或为空」,可先核对页面地址/源地址是否与控制台完全一致,或等待生效后再试。


三、后端实现(Node.js + Koa)

技术栈:Koa + @alicloud/dypnsapi20170525

需要完成三件事:加载环境变量封装阿里云 GetAuthToken / GetPhoneWithToken提供两个业务接口

3.1 依赖与环境变量

bash 复制代码
npm install @alicloud/dypnsapi20170525 dotenv --save

在项目根目录新建 .env(不要提交到 Git),例如:

env 复制代码
ALIYUN_ACCESS_KEY_ID=你的AccessKeyId
ALIYUN_ACCESS_KEY_SECRET=你的AccessKeySecret
PNVS_SCENE_CODE=FC000000012345678
PNVS_PAGE_URL=https://www.yourdomain.com/
PNVS_ORIGIN=https://www.yourdomain.com

在入口最先加载 dotenv(在 bin/wwwapp.js 最顶部):

javascript 复制代码
// bin/www 最顶部
require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') })

3.2 封装阿里云接口:numberAuth.js

services/numberAuth.js 中封装两个方法:getAuthTokengetPhoneWithToken

javascript 复制代码
/**
 * 阿里云号码认证服务(H5 一键登录)
 * 文档:https://help.aliyun.com/zh/pnvs/developer-reference/h5-client-access
 */
const DypnsapiClient = require('@alicloud/dypnsapi20170525').default
const GetAuthTokenRequest = require('@alicloud/dypnsapi20170525').GetAuthTokenRequest
const GetPhoneWithTokenRequest = require('@alicloud/dypnsapi20170525').GetPhoneWithTokenRequest

const accessKeyId = process.env.ALIYUN_ACCESS_KEY_ID || ''
const accessKeySecret = process.env.ALIYUN_ACCESS_KEY_SECRET || ''
const sceneCode = process.env.PNVS_SCENE_CODE || ''
const defaultUrl = process.env.PNVS_PAGE_URL || 'https://example.com/'
const defaultOrigin = process.env.PNVS_ORIGIN || 'https://example.com'

function createClient() {
  return new DypnsapiClient({
    accessKeyId,
    accessKeySecret,
    endpoint: 'dypnsapi.aliyuncs.com',
  })
}

/** 获取 H5 鉴权 Token,供前端 checkLoginAvailable 使用 */
async function getAuthToken(opts = {}) {
  if (!accessKeyId || !accessKeySecret || !sceneCode) {
    throw new Error('请配置 ALIYUN_ACCESS_KEY_ID、ALIYUN_ACCESS_KEY_SECRET、PNVS_SCENE_CODE')
  }
  const url = opts.url || defaultUrl
  const origin = opts.origin || defaultOrigin
  const client = createClient()
  const request = new GetAuthTokenRequest({
    url,
    origin,
    sceneCode,
    bizType: 1, // 1:一键登录
  })
  const response = await client.getAuthToken(request)
  const body = response.body
  if (body.code !== 'OK') {
    throw new Error(body.message || 'GetAuthToken 失败')
  }
  const tokenInfo = body.tokenInfo || {}
  return {
    accessToken: tokenInfo.accessToken || '',
    jwtToken: tokenInfo.jwtToken || '',
  }
}

/** 用 spToken 换取手机号(H5 一键登录取号) */
async function getPhoneWithToken(spToken) {
  if (!accessKeyId || !accessKeySecret) throw new Error('请配置阿里云 AccessKey')
  if (!spToken) throw new Error('spToken 不能为空')
  const client = createClient()
  const request = new GetPhoneWithTokenRequest({ spToken })
  const response = await client.getPhoneWithToken(request)
  const body = response.body
  if (body.code !== 'OK') {
    throw new Error(body.message || 'GetPhoneWithToken 失败')
  }
  const mobile = body.data && body.data.mobile
  if (!mobile) throw new Error('未获取到手机号')
  return mobile
}

module.exports = { getAuthToken, getPhoneWithToken }

3.3 业务接口:auth 路由

routes/auth.js 中增加两个接口。

1)获取鉴权 Token(给前端 SDK 用)

javascript 复制代码
const { getAuthToken, getPhoneWithToken } = require('../services/numberAuth')

// POST /api/auth/number-auth-token
router.post('/number-auth-token', async (ctx) => {
  const { url, origin } = ctx.request.body || {}
  try {
    const tokenInfo = await getAuthToken({ url, origin })
    ctx.body = { code: 0, message: 'ok', data: tokenInfo }
  } catch (e) {
    console.error('number-auth-token error:', e.message)
    ctx.status = 500
    ctx.body = { code: 500, message: e.message || '获取认证失败' }
  }
})

2)用 spToken 换手机号并登录/注册

javascript 复制代码
// POST /api/auth/number-login
router.post('/number-login', async (ctx) => {
  const { spToken } = ctx.request.body || {}
  if (!spToken) {
    ctx.status = 400
    ctx.body = { code: 400, message: '缺少 spToken' }
    return
  }
  try {
    const phone = await getPhoneWithToken(spToken)
    // 查库:已有用户则登录,否则自动注册(随机密码,后续可改密)
    const [rows] = await db.query(
      'SELECT id, phone, name, password FROM users WHERE phone = ? LIMIT 1',
      [String(phone).trim()]
    )
    let user = rows[0]
    if (!user) {
      const name = `用户${phone.slice(-4)}`
      const randomPassword = crypto.randomBytes(24).toString('base64')
      const hash = await bcrypt.hash(randomPassword, 10)
      const [insertResult] = await db.query(
        'INSERT INTO users (phone, password, name) VALUES (?, ?, ?)',
        [String(phone).trim(), hash, name]
      )
      user = {
        id: insertResult.insertId,
        phone: String(phone).trim(),
        name,
      }
    }
    const token = signToken(user.id)
    ctx.body = {
      code: 0,
      message: '登录成功',
      data: { token, user: { id: user.id, phone: user.phone, name: user.name } },
    }
  } catch (e) {
    console.error('number-login error:', e.message)
    ctx.status = 500
    ctx.body = { code: 500, message: e.message || '一键登录失败' }
  }
})

四、前端实现

4.1 安装依赖

bash 复制代码
npm install aliyun_numberauthsdk_web --save

4.2 登录页:一键登录流程(Login.jsx 核心逻辑)

jsx 复制代码
import { PhoneNumberServer } from 'aliyun_numberauthsdk_web'
import { authApi } from '../api/request'

// 1. 在组件内创建 SDK 实例(useRef,避免重复创建)
const phoneNumberServerRef = useRef(null)
useEffect(() => {
  phoneNumberServerRef.current = new PhoneNumberServer()
  return () => { phoneNumberServerRef.current = null }
}, [])

// 2. 一键登录点击处理
const handleOneClickLogin = async () => {
  const phoneNumberServer = phoneNumberServerRef.current
  if (!phoneNumberServer) return

  // H5 一键登录要求走移动数据,WiFi 下运营商无法可靠取号
  const netType = phoneNumberServer.getConnection?.()
  if (netType === 'wifi') {
    Toast.show({ content: '一键登录需使用移动数据网络,请关闭 Wi-Fi 后重试', icon: 'fail' })
    return
  }

  setOneClickLoading(true)
  try {
    // Step1:向自家后端要 accessToken、jwtToken
    const res = await authApi.getNumberAuthToken({
      url: `${window.location.origin}/`,
      origin: window.location.origin,
    })
    const { accessToken, jwtToken } = res.data || {}

    // Step2:SDK 鉴权
    phoneNumberServer.checkLoginAvailable({
      accessToken,
      jwtToken,
      success: (res) => {
        if (res.code !== 600000) {
          setOneClickLoading(false)
          Toast.show({ content: res.msg || '鉴权失败', icon: 'fail' })
          return
        }
        // Step3:拉起授权页(用户输入中间 4 位、勾选协议、点登录)
        phoneNumberServer.getLoginToken({
          authPageOption: {
            navText: '本机号码登录',
            btnText: '立即登录',
            privacyBefore: '我已阅读并同意',
            privacyOne: ['《用户协议》', '/about'],
            privacyTwo: ['《隐私政策》', '/about'],
            isDialog: true,
            manualClose: true,
          },
          success: async (tokenRes) => {
            if (tokenRes.code !== 600000) {
              setOneClickLoading(false)
              Toast.show({ content: tokenRes.msg || '获取登录态失败', icon: 'fail' })
              return
            }
            // Step4:把 spToken 交给后端,完成登录/注册
            try {
              const { data: loginData } = await authApi.loginWithSpToken(tokenRes.spToken)
              phoneNumberServer.closeLoginPage?.()
              localStorage.setItem('app_token', loginData.token)
              localStorage.setItem('app_user', JSON.stringify(loginData.user))
              setUser(loginData.user)
              setIsLoggedIn(true)
              Toast.show({ content: '登录成功', icon: 'success' })
              navigate('/')
            } catch (e) {
              Toast.show({ content: e.message || '登录失败', icon: 'fail' })
            } finally {
              setOneClickLoading(false)
            }
          },
          error: () => {
            setOneClickLoading(false)
            Toast.show({ content: '授权取消或失败', icon: 'fail' })
          },
        })
      },
      error: (err) => {
        setOneClickLoading(false)
        Toast.show({ content: err?.msg || '鉴权失败,请关闭 Wi-Fi 使用移动数据或使用账号密码登录', icon: 'fail' })
      },
    })
  } catch (e) {
    setOneClickLoading(false)
    Toast.show({ content: `获取token失败:${e.message}`, icon: 'fail' })
  }
}

// 页面上增加按钮
<Button block className={styles.btnOneClick} loading={oneClickLoading} onClick={handleOneClickLogin}>
  一键登录
</Button>

要点小结:

  • url / origin 必须与阿里云控制台里配置的页面地址、请求来源完全一致(含协议、域名、末尾斜杠)。
  • H5 场景下必须使用移动数据才能稳定取号,WiFi 下会提示用户关闭 Wi-Fi 或改用账号密码登录。
  • 授权页的「登录」按钮文案需包含「登录」等字样,符合运营商与阿里云规范。

六、参考文档

**

相关推荐
前端不太难15 小时前
在 HarmonyOS 上,游戏状态该怎么“死而复生”
游戏·状态模式·harmonyos
木斯佳1 天前
前端八股文面经大全:26届秋招滴滴校招前端一面面经-事件循环题解析
前端·状态模式
hepingfly1 天前
不再单打独斗!用 Agent Teams 让 7 个 Claude 同时帮你开发
状态模式
翼龙云_cloud1 天前
国际云代理商:2026年国际云注册风控升级实战指南 8 大平台无卡解决方案对比
服务器·阿里云·云计算
C澒1 天前
Remesh 框架详解:基于 CQRS 的前端领域驱动设计方案
前端·架构·前端框架·状态模式
前端不太难1 天前
HarmonyOS 游戏里,Ability 是如何被重建的
游戏·状态模式·harmonyos
阿里云大数据AI技术1 天前
全模态、多引擎、一体化,阿里云DLF3.0构建Data+AI驱动的智能湖仓平台
人工智能·阿里云·云计算
摇滚侠1 天前
阿里云安装的 Redis 在什么位置,如何找到 Redis 的安装位置
redis·阿里云·云计算
程序员agions2 天前
2026年,微前端终于“死“了
前端·状态模式