什么是华为一键登录
华为 Account Kit 提供的一键登录能力,本质上是这样一个流程:用户点击登录按钮 → 华为系统弹出授权面板,展示当前华为账号绑定的手机号(脱敏显示)→ 用户确认授权 → 客户端拿到一个 authorizationCode → 把这个 code 发给你的后端 → 后端拿 code 去华为服务器换取真实手机号 → 完成登录。
整个过程用户不需要手动输入任何信息,体验非常丝滑。
Account Kit 提供两种主要能力:
| 能力 | 说明 | 获取内容 |
|---|---|---|
| 华为账号授权登录 | 获取用户基本资料 | 昵称、头像、OpenID、UnionID |
| 一键登录(quickLoginAnonymousPhone) | 获取手机号 | 匿名手机号 + authorizationCode(后端换真实号码) |
整体架构
先看整体的技术架构,理解各层之间的关系:
- 服务端:Express 后端,处理 JWT Token 签发与手机号验证。
- 鸿蒙原生层:jack-hwilogin 插件,包含 index.uts (JS API) 和 login.ets (原生按钮组件)。
- 客户端:Vue 页面,通过 embed 标签或 JS API 调用。
- 华为授权服务:提供 AccountKit - AuthenticationController 与 LoginWithHuaweiIDButton。
关键点在于:客户端永远拿不到真实手机号,只能拿到匿名手机号和授权码。真实手机号的获取必须走服务端,这是华为出于安全考虑的设计。
前置准备
在写代码之前,需要先在华为开发者平台完成一些配置工作:
- 登录 AppGallery Connect 创建你的应用。
- 在「API 管理」中开通 Account Kit(帐号服务)。
- 记录下你的 Client ID 和 Client Secret,后端验证要用。
- 配置应用的签名证书指纹(SHA-256),这个很重要,指纹不匹配的话授权页面拉不起来。
重点说一下容易忽略的:签名证书指纹一定要和你实际打包用的证书一致,开发阶段用的调试证书和发布证书的指纹是不同的,别搞混了。
插件结构
插件目录结构很简洁:
uni_modules/jack-hwilogin/ └── utssdk/ └── app-harmony/ ├── index.uts # JS API(huaweiLogin / getPhoneNumber) └── login.ets # 原生登录按钮组件(embed 方式)
两个文件各司其职:index.uts 提供函数式调用,适合你想自定义按钮样式的场景;login.ets 封装了华为官方的登录按钮组件,通过 embed 标签嵌入 Vue 页面,样式合规且用户体验更好。
核心实现:UTS 插件层
JS API 方式 (index.uts)
这个文件导出两个核心函数:huaweiLogin(获取用户资料)和 getPhoneNumber(获取手机号,一键登录场景)。
import './login.ets'
import { authentication } from '@kit.AccountKit'
import { hilog } from '@kit.PerformanceAnalysisKit'
import { util } from '@kit.ArkTS'
import { BusinessError } from '@kit.BasicServicesKit'
import { common } from '@kit.AbilityKit'
const TAG = 'JackHWILogin'
// ========== 类型定义 ==========
interface HuaweiUserInfo {
authorizationCode: string
openID: string
unionID: string
nickName: string
avatarUri: string
}
interface HuaweiLoginOptions {
success: (data: HuaweiUserInfo) => void
fail: (err: string) => void
}
interface PhoneInfo {
authorizationCode: string
openID: string
unionID: string
anonymousPhone?: string
isLocalPhone?: boolean
}
interface GetPhoneOptions {
success: (data: PhoneInfo) => void
fail: (err: string) => void
}
/**
* 华为账号登录 —— 获取用户基本信息
*/
export function huaweiLogin(options: HuaweiLoginOptions): void {
try {
const loginRequest = new authentication.HuaweiIDProvider().createAuthorizationWithHuaweiIDRequest()
loginRequest.forceAuthorization = true
loginRequest.scopes = ['profile']
loginRequest.permissions = ['serviceauthcode']
loginRequest.state = util.generateRandomUUID()
loginRequest.nonce = util.generateRandomUUID()
loginRequest.idTokenSignAlgorithm = authentication.IdTokenSignAlgorithm.PS256
const context = getContext() as common.UIAbilityContext
const controller = new authentication.AuthenticationController(context)
controller.executeRequest(loginRequest, (error: BusinessError<Object>, data) => {
if (error) {
hilog.error(0x0000, TAG, `Login failed: ${JSON.stringify(error)}`)
options.fail && options.fail(`登录失败:${error.message || '未知错误'}`)
return
}
const response = data as authentication.AuthorizationWithHuaweiIDResponse
// 验证 state,防止 CSRF 攻击
if (response.state && loginRequest.state !== response.state) {
hilog.error(0x0000, TAG, `State mismatch`)
options.fail && options.fail('状态验证失败')
return
}
const credential = response?.data
const userInfo: HuaweiUserInfo = {
authorizationCode: credential?.authorizationCode ?? '',
openID: credential?.openID ?? '',
unionID: credential?.unionID ?? '',
nickName: credential?.nickName ?? '',
avatarUri: credential?.avatarUri ?? ''
}
options.success && options.success(userInfo)
})
} catch (error) {
hilog.error(0x0000, TAG, `Exception: ${error}`)
options.fail && options.fail(`登录异常:${error}`)
}
}
/**
* 获取手机号 —— 一键登录场景
*/
export function getPhoneNumber(options: GetPhoneOptions): void {
try {
const loginRequest = new authentication.HuaweiIDProvider().createAuthorizationWithHuaweiIDRequest()
loginRequest.forceAuthorization = true
loginRequest.scopes = ['quickLoginAnonymousPhone']
loginRequest.permissions = ['serviceauthcode']
loginRequest.state = util.generateRandomUUID()
const context = getContext() as common.UIAbilityContext
const controller = new authentication.AuthenticationController(context)
controller.executeRequest(loginRequest, (error: BusinessError<Object>, data) => {
if (error) {
hilog.error(0x0000, TAG, `Get phone failed: ${JSON.stringify(error)}`)
options.fail && options.fail(`获取手机号失败:${error.message}`)
return
}
const response = data as authentication.AuthorizationWithHuaweiIDResponse
if (response.state && loginRequest.state !== response.state) {
options.fail && options.fail('状态验证失败')
return
}
const credential = response?.data
const phoneInfo: PhoneInfo = {
authorizationCode: credential?.authorizationCode ?? '',
openID: credential?.openID ?? '',
unionID: credential?.unionID ?? '',
anonymousPhone: credential?.extraInfo?.quickLoginAnonymousPhone as string | undefined,
isLocalPhone: credential?.extraInfo?.localNumberConsistency as boolean | undefined
}
options.success && options.success(phoneInfo)
})
} catch (error) {
hilog.error(0x0000, TAG, `Exception: ${error}`)
options.fail && options.fail(`获取手机号异常:${error}`)
}
}
说几个关键设计点:
- scopes 的区别是两个函数最核心的差异。
huaweiLogin用的是['profile'],请求用户的昵称头像等基本资料;getPhoneNumber用的是['quickLoginAnonymousPhone'],请求手机号授权。两者返回的数据结构不同,所以我分成了两个独立函数,调用方按需选择。 - state 防 CSRF 是个容易被忽略但很重要的安全措施。每次请求我都用
util.generateRandomUUID()生成一个随机 state,回调时校验 response 里的 state 是否一致。如果不一致,说明响应可能被篡改,直接拒绝。 - forceAuthorization = true 表示每次都强制拉起授权页面,即使用户之前已经授权过。这样做的好处是每次都能拿到新的 authorizationCode,避免 code 过期的问题。
原生按钮组件 (login.ets)
除了 JS API,我还封装了华为官方的 LoginWithHuaweiIDButton 组件。这个组件的好处是样式由华为官方提供,符合品牌规范,而且用户看到熟悉的华为登录按钮会更有信任感。
import { defineNativeEmbed, NativeEmbedBuilderOptions } from '@dcloudio/uni-app-runtime'
import { loginComponentManager, LoginWithHuaweiIDButton } from '@kit.AccountKit'
import { hilog } from '@kit.PerformanceAnalysisKit'
import { BusinessError } from '@kit.BasicServicesKit'
const TAG = 'HuaweiLoginButton'
interface LoginButtonOptions extends NativeEmbedBuilderOptions {}
interface LoginSuccessData {
authorizationCode?: string
unionID?: string
openID?: string
success: boolean
err: string
}
interface LoginEvent {
type: string
detail: LoginSuccessData
}
@Component
struct HuaweiLoginButtonComponent {
onSuccess?: Function
onFail?: Function
private controller: loginComponentManager.LoginWithHuaweiIDButtonController = new loginComponentManager.LoginWithHuaweiIDButtonController()
.setAgreementStatus(loginComponentManager.AgreementStatus.ACCEPTED)
.onClickLoginWithHuaweiIDButton((error: BusinessError, response: loginComponentManager.HuaweiIDCredential) => {
if (error) {
hilog.error(0x0000, TAG, `Login failed: ${error.code}${error.message}`)
this.handleFail(`登录失败:${error.code}${error.message}`)
} else {
hilog.info(0x0000, TAG, `Login success`)
this.handleSuccess(response)
}
})
.onClickEvent((error: BusinessError, clickEvent: loginComponentManager.ClickEvent) => {
hilog.info(0x0000, TAG, `Button clicked: ${clickEvent}`)
})
private handleSuccess(response: loginComponentManager.HuaweiIDCredential): void {
if (this.onSuccess) {
const event: LoginEvent = {
type: 'success',
detail: {
authorizationCode: response.authorizationCode,
unionID: response.unionID,
openID: response.openID,
success: true,
err: 'ok'
}
}
this.onSuccess(event)
}
}
private handleFail(errorMsg: string): void {
if (this.onFail) {
const event: LoginEvent = {
type: 'fail',
detail: { success: false, err: errorMsg }
}
this.onFail(event)
}
}
build() {
LoginWithHuaweiIDButton({
params: {
style: loginComponentManager.Style.BUTTON_CUSTOM,
loginType: loginComponentManager.LoginType.QUICK_LOGIN,
supportDarkMode: true
},
controller: this.controller
}).width('100%').height('100%')
}
}
// 组件构建器
@Builder
function HuaweiLoginButtonBuilder(opts: LoginButtonOptions) {
HuaweiLoginButtonComponent({
onSuccess: opts?.on?.get('success'),
onFail: opts?.on?.get('fail')
}).width(opts.width).height(opts.height)
}
// 注册为原生嵌入组件,Vue 页面通过 <embed tag="hwilogin" /> 使用
defineNativeEmbed('hwilogin', { builder: HuaweiLoginButtonBuilder })
这里有个 uni-app 鸿蒙开发的知识点:defineNativeEmbed 是 uni-app 运行时提供的 API,用于将鸿蒙原生组件注册为可在 Vue 页面中通过 <embed> 标签使用的组件。tag 参数就是你在 Vue 模板里写的标签名,事件通过 opts.on.get('eventName') 获取。
loginType: LoginType.QUICK_LOGIN 表示使用快速登录模式,系统会自动展示当前华为账号的手机号。setAgreementStatus(ACCEPTED) 表示用户已同意隐私协议——这个状态需要你在业务层自己判断,我在 Vue 页面里做了隐私政策勾选的校验。
客户端完整页面
下面是我实际项目中的注册/登录页面完整代码,直接可用:
<template>
<view>
<!-- Logo 区域 -->
<view class="logo-section">
<image src="/static/logo.png" mode="aspectFit"></image>
<text class="app-name">示例 APP</text>
<text class="slogan">成长好帮手</text>
</view>
<!-- 登录表单卡片 -->
<view class="form-card">
<view class="form-title">用户注册</view>
<!-- 华为账号登录按钮(原生组件) -->
<!-- #ifdef APP-HARMONY -->
<view class="huawei-login-section">
<embed tag="hwilogin" :options="huaweiOptions" @success="handleHuaweiLoginSuccess" @fail="handleHuaweiLoginFail"></embed>
<text>请使用华为账号登录</text>
</view>
<!-- #endif -->
<!-- 登录链接 -->
<view class="login-link">
<text>已有账号?</text>
<text class="link-btn" @click="goToLogin">立即登录</text>
</view>
<!-- 隐私政策勾选 -->
<view class="privacy-check">
<view class="checkbox-wrapper" @click="agreePrivacy = !agreePrivacy">
<view class="checkbox" :class="{ checked: agreePrivacy }">
<text v-if="agreePrivacy">✓</text>
</view>
</view>
<text class="privacy-text">我已阅读并同意</text>
<text class="privacy-link" @click.stop="goToPrivacy">《隐私政策》</text>
</view>
</view>
</view>
</template>
<script>
import { huaweiLogin } from '@/utils/api.js'
import { setToken, setUserInfo } from '@/utils/auth.js'
// #ifdef APP-HARMONY
import '@/uni_modules/jack-hwilogin'
// #endif
export default {
name: 'Register',
data() {
return {
agreePrivacy: false,
huaweiOptions: {}
}
},
methods: {
// 华为登录成功回调
async handleHuaweiLoginSuccess({ detail }) {
console.log('华为登录成功:', detail)
// 必须先同意隐私政策
if (!this.agreePrivacy) {
uni.showToast({ title: '请先同意隐私政策', icon: 'none', duration: 2000 })
return
}
try {
uni.showLoading({ title: '登录中...' })
// 将 authorizationCode 发送到后端验证
const res = await huaweiLogin({
authorizationCode: detail.authorizationCode,
unionId: detail.unionID,
openId: detail.openID
})
if (res.success) {
setToken(res.token)
setUserInfo(res.user)
uni.hideLoading()
if (res.isNewUser) {
uni.showToast({ title: '注册成功', icon: 'success' })
} else {
uni.showToast({ title: '登录成功', icon: 'success' })
}
setTimeout(() => {
uni.switchTab({ url: '/pages/index/index' })
}, 1500)
}
} catch (err) {
uni.hideLoading()
console.error('华为登录失败:', err)
}
},
// 华为登录失败回调
handleHuaweiLoginFail(err) {
console.error('华为登录失败:', err)
uni.showToast({ title: '华为登录失败,请重试', icon: 'none' })
},
goToLogin() {
uni.navigateBack()
},
goToPrivacy() {
uni.navigateTo({ url: '/pages/privacy/index' })
}
}
}
</script>
<style scoped>
.register-page {
min-height: 100vh;
background: #f5f5f5;
position: relative;
padding-bottom: 120rpx;
}
.header-bg {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 500rpx;
background: linear-gradient(135deg, #FF9A9E, #FECFEF);
border-radius: 0 0 60rpx 60rpx;
}
.logo-section {
position: relative;
padding-top: 80rpx;
text-align: center;
}
.logo {
width: 140rpx;
height: 140rpx;
border-radius: 30rpx;
box-shadow: 0 8rpx 30rpx rgba(0, 0, 0, 0.15);
}
.app-name {
display: block;
margin-top: 20rpx;
font-size: 36rpx;
font-weight: bold;
color: #fff;
}
.slogan {
display: block;
margin-top: 8rpx;
font-size: 24rpx;
color: rgba(255, 255, 255, 0.8);
}
.form-card {
position: relative;
margin: 40rpx 40rpx 0;
padding: 40rpx;
background: #fff;
border-radius: 30rpx;
box-shadow: 0 8rpx 40rpx rgba(0, 0, 0, 0.1);
}
.form-title {
text-align: center;
font-size: 36rpx;
font-weight: bold;
color: #333;
margin-bottom: 40rpx;
}
.huawei-login-section {
margin-bottom: 30rpx;
}
.huawei-login-btn {
display: block;
width: 100%;
height: 96rpx;
margin-bottom: 20rpx;
}
.login-link {
margin-top: 30rpx;
text-align: center;
}
.link-text {
font-size: 28rpx;
color: #999;
}
.link-btn {
font-size: 28rpx;
color: #FF9A9E;
font-weight: bold;
}
.privacy-check {
display: flex;
align-items: center;
margin-top: 30rpx;
}
.checkbox-wrapper {
padding: 10rpx;
}
.checkbox {
width: 40rpx;
height: 40rpx;
border: 2rpx solid #ddd;
border-radius: 8rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 24rpx;
color: #fff;
}
.checkbox.checked {
background: linear-gradient(135deg, #FF9A9E, #FECFEF);
border-color: #FF9A9E;
}
.privacy-text {
font-size: 26rpx;
color: #666;
margin-left: 10rpx;
}
.privacy-link {
font-size: 26rpx;
color: #FF9A9E;
}
</style>
注意 import '@/uni_modules/jack-hwilogin' 这行,它的作用是触发 login.ets 中的 defineNativeEmbed 注册。如果不 import,<embed tag="hwilogin"> 就找不到对应的原生组件。另外整个 import 和 embed 标签都包在 #ifdef APP-HARMONY 条件编译里,这样在其他平台编译时不会报错。
登录时序
整个一键登录的完整时序如下:
- 用户勾选隐私政策。
- 点击华为登录按钮,embed 组件触发登录。
- 执行
executeRequest(quickLoginAnonymousPhone),弹出授权面板(显示脱敏手机号)。 - 用户确认授权,返回
authorizationCode + anonymousPhone + OpenID + UnionID。 @success回调,前端将authorizationCode发送给后端/api/auth/huawei-login。- 后端调用华为
oauth2/v6/quickLogin/getPhoneNumber接口换取真实手机号。 - 后端创建/更新用户记录,签发 JWT Token。
- 前端保存 Token,跳转首页。
后端验证示例
客户端拿到 authorizationCode 后,需要发给后端去华为服务器换取真实手机号。下面是一个简化的 Express 后端示例:
const express = require('express')
const axios = require('axios')
const jwt = require('jsonwebtoken')
const app = express()
app.use(express.json())
// 华为账号服务配置(从 AGC 后台获取)
const HUAWEI_CONFIG = {
clientId: 'your_client_id',
clientSecret: 'your_client_secret',
apiUrl: 'https://account-api.cloud.huawei.com/oauth2/v6/quickLogin/getPhoneNumber'
}
const JWT_SECRET = 'your_jwt_secret'
/**
* 华为一键登录接口
* 接收客户端传来的 authorizationCode,向华为服务器换取真实手机号
*/
app.post('/api/auth/huawei-login', async (req, res) => {
try {
const { authorizationCode, unionId, openId } = req.body
if (!authorizationCode || !unionId || !openId) {
return res.status(400).json({ success: false, message: '缺少必需参数' })
}
// 第一步:用 authorizationCode 向华为服务器换取真实手机号
let phoneNumber = null
try {
const response = await axios.post(HUAWEI_CONFIG.apiUrl, {
code: authorizationCode,
clientId: HUAWEI_CONFIG.clientId,
clientSecret: HUAWEI_CONFIG.clientSecret
}, {
headers: { 'Content-Type': 'application/json;charset=UTF-8' }
})
if (response.data && response.data.phoneNumber) {
phoneNumber = response.data.phoneNumber
} else {
return res.status(400).json({ success: false, message: '获取手机号失败' })
}
} catch (apiError) {
console.error('华为 API 调用失败:', apiError.message)
return res.status(500).json({ success: false, message: '华为账号服务异常' })
}
// 第二步:根据 unionId 查找或创建用户
// 这里用伪代码表示数据库操作,实际项目中替换为你的 ORM
let user = await db.findUserByUnionId(unionId)
let isNewUser = false
if (!user) {
// 新用户,创建记录
user = await db.createUser({
huaweiUnionId: unionId,
huaweiOpenId: openId,
phone: phoneNumber,
loginType: 'huawei'
})
isNewUser = true
} else {
// 老用户,更新手机号等信息
await db.updateUser(user.id, { huaweiOpenId: openId, phone: phoneNumber })
}
// 第三步:签发 JWT Token
const token = jwt.sign({ userId: user.id }, JWT_SECRET, { expiresIn: '7d' })
res.json({
success: true,
token,
isNewUser,
user: {
id: user.id,
phone: phoneNumber,
nickname: user.nickname
}
})
} catch (error) {
console.error('登录失败:', error)
res.status(500).json({ success: false, message: '服务器错误' })
}
})
app.listen(3000, () => console.log('Server running on port 3000'))
后端的核心逻辑就三步:拿 code 换手机号 → 查找/创建用户 → 签发 Token。华为的 getPhoneNumber 接口需要传 code(就是客户端给你的 authorizationCode)、clientId 和 clientSecret,返回的 phoneNumber 就是用户的真实手机号。
这里要注意 clientSecret 绝对不能暴露在客户端代码里,只能放在服务端。
踩坑与注意事项
授权页面拉不起来
大概率是签名证书指纹的问题。在 AGC 后台配置的 SHA-256 指纹必须和你打包用的证书完全一致。开发阶段换了调试证书记得同步更新。另外确认 Account Kit 服务已经在 AGC 后台开通。
authorizationCode 只能用一次
华为返回的 authorizationCode 是一次性的,后端用它换完手机号之后就失效了。如果你的后端接口因为网络问题重试,第二次调用华为 API 会失败。建议在后端做好幂等处理。
条件编译不能少
所有涉及 @kit.AccountKit 的代码都只能在鸿蒙平台运行。Vue 页面里的 import 和模板都要用 #ifdef APP-HARMONY 包裹,否则在其他平台编译会报错。
OpenID vs UnionID
OpenID 是同一个应用下唯一的用户标识,不同应用拿到的 OpenID 不同。UnionID 是同一个开发者账号下唯一的,如果你有多个 App 需要打通用户体系,应该用 UnionID 作为用户关联的主键。
总结
华为一键登录的接入并不复杂,核心就是理解 authorizationCode 的流转:客户端获取 → 传给后端 → 后端去华为换手机号。该方案提供了 JS API 和原生按钮两种方式,基本覆盖了常见的登录场景。

