跳到主要内容UniApp 集成鸿蒙华为一键登录 Account Kit 实战 | 极客日志TypeScriptNode.js大前端
UniApp 集成鸿蒙华为一键登录 Account Kit 实战
在 UniApp 项目中集成鸿蒙华为一键登录(Account Kit)的完整流程。文章涵盖了从华为开发者平台配置、插件结构设计、UTS 插件层核心实现(JS API 与原生按钮组件)、客户端 Vue 页面编写到后端 Express 验证示例的全链路开发指南。重点讲解了 authorizationCode 的安全流转机制、state 防 CSRF 措施、条件编译处理以及 OpenID 与 UnionID 的区别。通过封装通用插件,实现了用户无需手动输入即可快速获取手机号并完成登录,显著提升了鸿蒙生态下的用户体验与转化率。
DevOpsTeam6 浏览 什么是华为一键登录
华为 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
两个文件各司其职:index.uts 提供函数式调用,适合你想自定义按钮样式的场景;login.ets 封装了华为官方的登录按钮组件,通过 embed 标签嵌入 Vue 页面,样式合规且用户体验更好。
核心实现:UTS 插件层
JS API 方式 (index.uts)
这个文件导出两个核心函数:huaweiLogin(获取用户资料)和 getPhoneNumber(获取手机号,一键登录场景)。
import './login.ets'
import { authentication } from '@kit.AccountKit'
import { hilog }
{ util }
{ }
{ common }
=
{
:
:
:
:
:
}
{
:
:
}
{
:
:
:
?:
?:
}
{
:
:
}
(): {
{
loginRequest = authentication.().()
loginRequest. =
loginRequest. = []
loginRequest. = []
loginRequest. = util.()
loginRequest. = util.()
loginRequest. = authentication..
context = () common.
controller = authentication.(context)
controller.(loginRequest, {
(error) {
hilog.(, , )
options. && options.()
}
response = data authentication.
(response. && loginRequest. !== response.) {
hilog.(, , )
options. && options.()
}
credential = response?.
: = {
: credential?. ?? ,
: credential?. ?? ,
: credential?. ?? ,
: credential?. ?? ,
: credential?. ??
}
options. && options.(userInfo)
})
} (error) {
hilog.(, , )
options. && options.()
}
}
(): {
{
loginRequest = authentication.().()
loginRequest. =
loginRequest. = []
loginRequest. = []
loginRequest. = util.()
context = () common.
controller = authentication.(context)
controller.(loginRequest, {
(error) {
hilog.(, , )
options. && options.()
}
response = data authentication.
(response. && loginRequest. !== response.) {
options. && options.()
}
credential = response?.
: = {
: credential?. ?? ,
: credential?. ?? ,
: credential?. ?? ,
: credential?.?. | ,
: credential?.?. |
}
options. && options.(phoneInfo)
})
} (error) {
hilog.(, , )
options. && options.()
}
}
微信扫一扫,关注极客日志
微信公众号「极客日志」,在微信中扫描左侧二维码关注。展示文案:极客日志 zeeklog
相关免费在线工具
- Base64 字符串编码/解码
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
- Base64 文件转换器
将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
- Markdown转HTML
将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML转Markdown 互为补充。 在线工具,Markdown转HTML在线工具,online
- HTML转Markdown
将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML转Markdown在线工具,online
- JSON 压缩
通过删除不必要的空白来缩小和压缩JSON。 在线工具,JSON 压缩在线工具,online
- JSON美化和格式化
将JSON字符串修饰为友好的可读格式。 在线工具,JSON美化和格式化在线工具,online
from
'@kit.PerformanceAnalysisKit'
import
from
'@kit.ArkTS'
import
BusinessError
from
'@kit.BasicServicesKit'
import
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
new
HuaweiIDProvider
createAuthorizationWithHuaweiIDRequest
forceAuthorization
true
scopes
'profile'
permissions
'serviceauthcode'
state
generateRandomUUID
nonce
generateRandomUUID
idTokenSignAlgorithm
IdTokenSignAlgorithm
PS256
const
getContext
as
UIAbilityContext
const
new
AuthenticationController
executeRequest
(error: BusinessError<Object>, data) =>
if
error
0x0000
TAG
`Login failed: ${JSON.stringify(error)}`
fail
fail
`登录失败:${error.message || '未知错误'}`
return
const
as
AuthorizationWithHuaweiIDResponse
if
state
state
state
error
0x0000
TAG
`State mismatch`
fail
fail
'状态验证失败'
return
const
data
const
userInfo
HuaweiUserInfo
authorizationCode
authorizationCode
''
openID
openID
''
unionID
unionID
''
nickName
nickName
''
avatarUri
avatarUri
''
success
success
catch
error
0x0000
TAG
`Exception: ${error}`
fail
fail
`登录异常:${error}`
export
function
getPhoneNumber
options: GetPhoneOptions
void
try
const
new
HuaweiIDProvider
createAuthorizationWithHuaweiIDRequest
forceAuthorization
true
scopes
'quickLoginAnonymousPhone'
permissions
'serviceauthcode'
state
generateRandomUUID
const
getContext
as
UIAbilityContext
const
new
AuthenticationController
executeRequest
(error: BusinessError<Object>, data) =>
if
error
0x0000
TAG
`Get phone failed: ${JSON.stringify(error)}`
fail
fail
`获取手机号失败:${error.message}`
return
const
as
AuthorizationWithHuaweiIDResponse
if
state
state
state
fail
fail
'状态验证失败'
return
const
data
const
phoneInfo
PhoneInfo
authorizationCode
authorizationCode
''
openID
openID
''
unionID
unionID
''
anonymousPhone
extraInfo
quickLoginAnonymousPhone
as
string
undefined
isLocalPhone
extraInfo
localNumberConsistency
as
boolean
undefined
success
success
catch
error
0x0000
TAG
`Exception: ${error}`
fail
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)
}
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())
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'
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: '缺少必需参数' })
}
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: '华为账号服务异常' })
}
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 })
}
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 是一次性的,后端用它换完手机号之后就失效了。如果你的后端接口因为网络问题重试,第二次调用华为 API 会失败。建议在后端做好幂等处理。
所有涉及 @kit.AccountKit 的代码都只能在鸿蒙平台运行。Vue 页面里的 import 和模板都要用 #ifdef APP-HARMONY 包裹,否则在其他平台编译会报错。
OpenID 是同一个应用下唯一的用户标识,不同应用拿到的 OpenID 不同。UnionID 是同一个开发者账号下唯一的,如果你有多个 App 需要打通用户体系,应该用 UnionID 作为用户关联的主键。
总结
华为一键登录的接入并不复杂,核心就是理解 authorizationCode 的流转:客户端获取 → 传给后端 → 后端去华为换手机号。该方案提供了 JS API 和原生按钮两种方式,基本覆盖了常见的登录场景。