admin 管理员组

文章数量: 887016

概述

华为账号一键登录是基于OAuth 2.0协议标准和OpenID Connect协议标准构建的OAuth2.0 授权登录系统,应用可以通过华为账号一键登录能力方便地获取华为账号用户的身份标识和手机号,快速建立应用内的用户体系。

优势:

  • 利用系统账号的安全性和便利性,用户无需输入账号名和密码,无需复杂的安全验证,简化登录步骤,提高用户转化率。
  • 提供系统验证过的手机号,关联应用已有用户。
  • 实现手机、平板、2in1一致的登录体验。

场景介绍

若应用需同时获取手机号和UnionID完成用户登录,可使用Account Kit提供的华为账号一键登录按钮同时获取手机号和UnionID。应用可以将华为账号一键登录按钮嵌入自有的登录页,使用登录按钮获取手机号和UnionID,实现用户登录。设备登录华为账号(该账号已绑定手机号)后,一键登录获取手机号可不依赖设备插SIM卡。华为账号一键登录详细接入体验可参考Account Kit提供的SampleCode示例工程。

说明

手机号验证机制说明:

Account Kit调用系统能力获取华为账号登录设备上的手机号码,与华为账号绑定的手机号进行校验(有网络即可,无需使用SIM卡移动数据)。用户点击一键登录按钮后,结合华为账号使用过程中的短信验证记录,90天内有验证通过的记录,则正常返回手机号;若90天内没有验证通过的记录,则触发Account Kit默认提供的短信验证流程(Account Kit提供的验证页,暂不可自定义),确保返回的手机号经过正确验证。

约束与限制

1、应用满足《常见类型移动互联网应用程序必要个人信息范围规定》中使用个人信息的必要业务场景。

2、使用华为账号一键登录功能用户必须同意《华为账号用户认证协议》,当用户点击《华为账号用户认证协议》,应用需跳转到如下链接。

3、应用在用户同意后获取到手机号,需要根据自身业务场景判断使用的方式,必要时增加其他安全验证手段,比如对二次放号的判断。

4、华为账号一键登录服务当前仅限中国大陆用户可用。

5、应用服务器获取华为账号绑定号码时,该服务器必须部署在中国大陆境内。

用户体验设计

图1

图2

登录页面UX设计规范

一键登录按钮的用户体验和UX设计需符合【华为账号一键登录】按钮规范,用户体验设计图2中的华为标志按钮可参考华为账号登录视觉规范中的样式三。不符合规范的UX设计可能会对应用上架和用户体验带来影响。一键登录按钮的样式设计具体可以参考华为账号登录按钮类型。

用户场景设计

用户使用华为账号一键登录能力,注册/登录应用时,可能存在多种场景,应用可参考如下流程,根据自身业务场景进行设计。

用户已经使用华为账号登录过该应用后,再次使用华为账号一键登录的场景体验(对应上述流程图中的“展示UnionID、OpenID已关联的应用账号,由用户选择是否使用华为账号登录该应用”)。以下示例图可供参考。

图3 非首次登录一键登录示例图

说明

将UnionID/OpenID和手机号同时与应用账号建立关联,可以为用户带来更多便利的功能。如:实现静默登录、获取华为账号用户信息等。

实现免用户操作登录,获得安全快捷地应用登录体验。

业务流程

流程说明:

  1. 用户打开应用后,应用传对应的scope调用AuthorizationWithHuaweiIDRequest请求获取AuthorizationWithHuaweiIDResponse响应结果。
  2. 通过响应结果判断系统华为账号是否已登录,如未登录,则应用需要展示其他登录方式;如已登录,则从响应结果中解析出UnionID、OpenID和匿名手机号。
  3. 应用查询UnionID、OpenID是否已关联用户。如已关联,结合风控、安全因素及自身业务场景判断,可展示已关联的账号,由用户选择是否使用华为账号登录应用,或免用户操作,静默登录应用;如未关联,则参考下面步骤继续开发。
  4. 判断匿名手机号是否为空,如为空,则应用需要展示其他登录方式;不为空,则设置相关参数调用LoginWithHuaweiIDButton组件拉起一键登录页面。
  5. 用户同意协议后,点击华为账号一键登录按钮,应用可以获取到Authorization Code等数据。
  6. 将获取的Authorization Code数据传给应用服务器,应用服务器通过调用获取用户级凭证接口和获取用户信息接口获取用户完整手机号和UnionID、OpenID。
  7. 应用通过用户手机号和UnionID、OpenID完成用户关联。

接口说明

华为账号一键登录按钮关键接口如下表所示,具体API说明详见API参考。

接口名

描述

createAuthorizationWithHuaweiIDRequest(): AuthorizationWithHuaweiIDRequest

获取授权接口,通过AuthorizationWithHuaweiIDRequest传入一键登录的scope:quickLoginAnonymousPhone,即可在授权结果中获取到用户UnionID、OpenID、匿名化手机号。

constructor(context?: common.Context)

创建授权请求Controller。

executeRequest(request: AuthenticationRequest): Promise<AuthenticationResponse>

通过Promise方式执行授权操作。

LoginWithHuaweiIDButton

华为账号Button登录组件。

该组件仅纯文本样式支持华为账号一键登录功能。开发者可以通过调整按钮的大小、圆角等参数以适配HarmonyOS应用登录界面。如果仍然不能满足开发者的诉求,可以使用BUTTON_CUSTOM类型定义按钮的文字颜色和背景色。

onClickLoginWithHuaweiIDButton(callback: AsyncCallback<HuaweiIDCredential>): LoginWithHuaweiIDButtonController

注册华为账号一键登录按钮的结果回调。

setAgreementStatus(agreementStatus: AgreementStatus): LoginWithHuaweiIDButtonController

设置协议状态方法。用户未同意协议前设置协议状态为NOT_ACCEPTED,用户同意协议后设置协议状态为ACCEPTED,才可以完成华为账号登录。

onClickEvent(callback: AsyncCallback<ClickEvent>): LoginWithHuaweiIDButtonController

注册华为账号一键登录按钮的点击事件回调。

continueLogin(callback: AsyncCallback<void>): LoginWithHuaweiIDButtonController

用户点击协议弹框的同意并登录按钮结果回调。

注意

上述接口需在页面或自定义组件生命周期内调用。

开发前提

  1. 在进行代码开发前,请先确认已完成开发准备工作。
  2. 应用使用华为账号一键登录功能之前,需要完成quickLoginMobilePhone(华为账号一键登录)的scope权限申请,详情参见配置scope权限。scope权限申请审批未完成或未通过,将报错1001502014 应用未申请scopes或permissions权限。

    细分场景

    对应scope

    权限名称

    权限描述

    权限是否需要申请

    华为账号一键登录

    quickLoginAnonymousPhone

    quickLoginMobilePhone

    华为账号一键登录,包含获取完整手机号

客户端开发

自行开发

  1. 导入Account Kit的authentication模块及相关公共模块。
     
      
    1. import { authentication } from '@kit.AccountKit';
    2. import { util } from '@kit.ArkTS';
    3. import { hilog } from '@kit.PerformanceAnalysisKit';
    4. import { BusinessError } from '@kit.BasicServicesKit';
  2. 调用authentication模块的AuthorizationWithHuaweiIDRequest请求获取华为账号用户的UnionID、OpenID、匿名手机号。匿名手机号用于登录页面展示。

    注意

    该场景下forceAuthorization参数需设置为false。

    根据获取的响应结果判断,可能存在以下场景:
    • 已正确获取到用户身份标识UnionID、OpenID,应用可通过用户身份标识查询该用户是否已关联。

      1)如已关联,结合风控、安全因素及自身业务场景判断,可展示已关联的账号,由用户选择是否使用华为账号登录应用,或免用户操作,静默登录应用,客户端开发结束。

      2)如未关联,再判断是否存在下面的异常场景,如无,则参考下面步骤3继续开发。

    • 存在如下返回ArkTS错误码的异常场景:

      1)返回1001502001 用户未登录华为账号错误码,说明华为账号未登录。

      2)返回1001500003 不支持该scopes或permissions错误码,说明华为账号用户注册地非中国大陆。

      3)获取到的匿名手机号为空,说明华为账号没有绑定手机号、权限未申请或未生效、登录的华为账号是儿童账号。

      上述异常场景应用需要展示其他登录方式。
       
          
      1. getQuickLoginAnonymousPhone() {
      2. // 创建授权请求,并设置参数
      3. const authRequest = new authentication.HuaweiIDProvider().createAuthorizationWithHuaweiIDRequest();
      4. // 获取匿名手机号需传quickLoginAnonymousPhone这个scope,传参之前需要先申请“华为账号一键登录”权限
      5. authRequest.scopes = ['quickLoginAnonymousPhone'];
      6. // 用于防跨站点请求伪造
      7. authRequest.state = util.generateRandomUUID();
      8. // 一键登录场景该参数只能设置为false
      9. authRequest.forceAuthorization = false;
      10. const controller = new authentication.AuthenticationController();
      11. try {
      12. controller.executeRequest(authRequest).then((response: authentication.AuthorizationWithHuaweiIDResponse) => {
      13. // 获取到UnionID、OpenID、匿名手机号
      14. const unionID = response.data?.unionID;
      15. const openID = response.data?.openID;
      16. const anonymousPhone = response.data?.extraInfo?.quickLoginAnonymousPhone as string;
      17. if (anonymousPhone) {
      18. hilog.info(0x0000, 'testTag', 'Succeeded in authentication.');
      19. const quickLoginAnonymousPhone: string = anonymousPhone;
      20. return;
      21. }
      22. hilog.info(0x0000, 'testTag', 'Succeeded in authentication. AnonymousPhone is empty.');
      23. // 未获取到匿名手机号需要跳转到应用自定义的登录页面
      24. }).catch((error: BusinessError) => {
      25. this.dealAllError(error);
      26. })
      27. } catch (error) {
      28. this.dealAllError(error);
      29. }
      30. }
      31. // 错误处理
      32. dealAllError(error: BusinessError): void {
      33. hilog.error(0x0000, 'testTag',
      34. `Failed to login, errorCode is ${error.code}, errorMessage is ${error.message}`);
      35. }
  3. 将获取到的匿名手机号设置给下面QuickLoginButtonComponent组件示例代码中的quickLoginAnonymousPhone变量,调用LoginWithHuaweiIDButton组件,实现应用自己的登录页面,并展示华为账号一键登录按钮和华为账号用户认证协议(Account Kit提供跳转链接,应用需实现协议跳转,参见约束与限制第2点),用户同意协议并点击一键登录按钮后,可获取到Authorization Code,将该值传给应用服务器用于获取用户信息(完整手机号、UnionID、OpenID)。
     
      
    1. import { loginComponentManager, LoginWithHuaweiIDButton } from '@kit.AccountKit';
    2. import { hilog } from '@kit.PerformanceAnalysisKit';
    3. import { BusinessError } from '@kit.BasicServicesKit';
    4. import { promptAction, router } from '@kit.ArkUI';
    5. import { connection } from '@kit.NetworkKit';
    6. @Component
    7. struct QuickLoginButtonComponent {
    8. logTag: string = 'QuickLoginButtonComponent';
    9. domainId: number = 0x0000;
    10. // 第二步获取的匿名化手机号传到此处
    11. @State quickLoginAnonymousPhone: string = '';
    12. // 是否勾选协议
    13. @State isSelected: boolean = false;
    14. // 华为账号用户认证协议链接,此处仅为示例,实际开发过程中,域名不建议硬编码在本地
    15. private static USER_AUTHENTICATION_PROTOCOL: string =
    16. 'https://privacy.consumer.huawei/legal/id/authentication-terms.htm?code=CN&language=zh-CN';
    17. private static USER_SERVICE_TAG = '用户服务协议';
    18. private static PRIVACY_TAG = '隐私协议';
    19. private static USER_AUTHENTICATION_TAG = '华为账号用户认证协议';
    20. // 定义LoginWithHuaweiIDButton展示的隐私文本,展示应用的用户服务协议、隐私协议和华为账号用户认证协议
    21. privacyText: loginComponentManager.PrivacyText[] = [{
    22. text: '已阅读并同意',
    23. type: loginComponentManager.TextType.PLAIN_TEXT
    24. }, {
    25. text: '《用户服务协议》',
    26. tag: QuickLoginButtonComponent.USER_SERVICE_TAG,
    27. type: loginComponentManager.TextType.RICH_TEXT
    28. }, {
    29. text: '《隐私协议》',
    30. tag: QuickLoginButtonComponent.PRIVACY_TAG,
    31. type: loginComponentManager.TextType.RICH_TEXT
    32. }, {
    33. text: '和',
    34. type: loginComponentManager.TextType.PLAIN_TEXT
    35. }, {
    36. text: '《华为账号用户认证协议》',
    37. tag: QuickLoginButtonComponent.USER_AUTHENTICATION_TAG,
    38. type: loginComponentManager.TextType.RICH_TEXT
    39. }, {
    40. text: '。',
    41. type: loginComponentManager.TextType.PLAIN_TEXT
    42. }];
    43. // 构造LoginWithHuaweiIDButton组件的控制器
    44. controller: loginComponentManager.LoginWithHuaweiIDButtonController =
    45. new loginComponentManager.LoginWithHuaweiIDButtonController()
    46. /**
    47. * 当应用使用自定义的登录页时,如果用户未同意协议,需要设置协议状态为NOT_ACCEPTED,当用户同意协议后再设置
    48. * 协议状态为ACCEPTED,才可以使用华为账号一键登录功能
    49. */
    50. .setAgreementStatus(loginComponentManager.AgreementStatus.NOT_ACCEPTED)
    51. .onClickLoginWithHuaweiIDButton((error: BusinessError | undefined,
    52. response: loginComponentManager.HuaweiIDCredential) => {
    53. this.handleLoginWithHuaweiIDButton(error, response);
    54. })
    55. .onClickEvent((error: BusinessError, clickEvent: loginComponentManager.ClickEvent) => {
    56. if (error) {
    57. this.dealAllError(error);
    58. return;
    59. }
    60. hilog.info(this.domainId, this.logTag, `onClickEvent clickEvent: ${clickEvent}`);
    61. });
    62. agreementDialog: CustomDialogController = new CustomDialogController({
    63. builder: AgreementDialog({
    64. privacyText: this.privacyText,
    65. cancel: () => {
    66. this.agreementDialog.close();
    67. this.controller.setAgreementStatus(loginComponentManager.AgreementStatus.NOT_ACCEPTED);
    68. },
    69. confirm: () => {
    70. this.agreementDialog.close();
    71. this.isSelected = true;
    72. this.controller.setAgreementStatus(loginComponentManager.AgreementStatus.ACCEPTED);
    73. // 调用此方法,同意协议与登录一并完成,无需再次点击登录按钮
    74. this.controller.continueLogin((error: BusinessError) => {
    75. if (error) {
    76. hilog.error(this.domainId, this.logTag,
    77. `Failed to click agreementDialog continueLogin. errCode is ${error.code}, errMessage is ${error.message}`);
    78. } else {
    79. hilog.info(this.domainId, this.logTag,
    80. 'Succeeded in clicking agreementDialog continueLogin.');
    81. }
    82. });
    83. },
    84. clickHyperlinkText: () => {
    85. this.agreementDialog.close();
    86. this.jumpToPrivacyWebView();
    87. }
    88. }),
    89. autoCancel: false,
    90. alignment: DialogAlignment.Center,
    91. });
    92. // 传递页面渲染所需的数据,如匿名手机号等
    93. aboutToAppear(): void {
    94. }
    95. // Toast提示
    96. showToast(resource: string) {
    97. try {
    98. promptAction.showToast({
    99. message: resource,
    100. duration: 2000
    101. });
    102. } catch (error) {
    103. const message = (error as BusinessError).message
    104. const code = (error as BusinessError).code
    105. hilog.error(this.domainId, this.logTag, `showToast args errCode is ${code}, errMessage is ${message}`);
    106. }
    107. }
    108. // 跳转华为账号用户认证协议页,该页面需在工程main_pages.json文件配置
    109. jumpToPrivacyWebView() {
    110. try {
    111. // 需在module.json5中配置“ohos.permission.GET_NETWORK_INFO”权限
    112. const checkNetConn = connection.hasDefaultNetSync();
    113. if (!checkNetConn) {
    114. this.showToast('服务或网络异常,请稍后重试');
    115. return;
    116. }
    117. } catch (error) {
    118. const message = error.message as string;
    119. const code = error.code as string;
    120. hilog.error(0x0000, 'testTag', `Failed to hasDefaultNetSync, errCode is ${code}, errMessage is ${message}`);
    121. }
    122. router.pushUrl({
    123. // 需在module.json5配置“ohos.permission.INTERNET”网络权限
    124. url: 'pages/WebPage',
    125. params: {
    126. isFromDialog: true,
    127. url: QuickLoginButtonComponent.USER_AUTHENTICATION_PROTOCOL,
    128. }
    129. }, (err) => {
    130. if (err) {
    131. hilog.error(this.domainId, this.logTag,
    132. `Failed to jumpToPrivacyWebView, errCode is ${err.code}, errMessage is ${err.message}`);
    133. }
    134. });
    135. }
    136. handleLoginWithHuaweiIDButton(error: BusinessError | undefined,
    137. response: loginComponentManager.HuaweiIDCredential) {
    138. if (error) {
    139. hilog.error(this.domainId, this.logTag,
    140. `Failed to click LoginWithHuaweiIDButton. errCode is ${error.code}, errMessage is ${error.message}`);
    141. if (error.code === ErrorCode.ERROR_CODE_NETWORK_ERROR) {
    142. AlertDialog.show(
    143. {
    144. message: "网络未连接,请检查网络设置。",
    145. offset: { dx: 0, dy: -12 },
    146. alignment: DialogAlignment.Bottom,
    147. autoCancel: false,
    148. confirm: {
    149. value: "知道了",
    150. action: () => {
    151. }
    152. }
    153. }
    154. );
    155. } else if (error.code === ErrorCode.ERROR_CODE_AGREEMENT_STATUS_NOT_ACCEPTED) {
    156. // 未同意协议,弹出协议弹框,推荐使用该回调方式
    157. this.agreementDialog.open();
    158. } else if (error.code === ErrorCode.ERROR_CODE_LOGIN_OUT) {
    159. // 华为账号未登录提示
    160. this.showToast("华为账号未登录,请重试");
    161. } else if (error.code === ErrorCode.ERROR_CODE_NOT_SUPPORTED) {
    162. // 不支持该scopes或permissions提示
    163. this.showToast("该scopes或permissions不支持");
    164. } else {
    165. // 其他提示系统或服务异常
    166. this.showToast('服务或网络异常,请稍后重试');
    167. }
    168. return;
    169. }
    170. try {
    171. if (this.isSelected) {
    172. if (response) {
    173. hilog.info(this.domainId, this.logTag, 'Succeeded in clicking LoginWithHuaweiIDButton.');
    174. // 开发者根据实际业务情况使用以下信息
    175. const authCode = response.authorizationCode;
    176. const openID = response.openID;
    177. const unionID = response.unionID;
    178. const idToken = response.idToken;
    179. }
    180. } else {
    181. this.agreementDialog.open();
    182. }
    183. } catch (err) {
    184. hilog.error(this.domainId, this.logTag,
    185. `Failed to LoginWithHuaweiIDButton, errCode: ${err.code}, errMessage: ${err.message}`);
    186. AlertDialog.show(
    187. {
    188. message: '服务或网络异常,请稍后重试',
    189. offset: { dx: 0, dy: -12 },
    190. alignment: DialogAlignment.Bottom,
    191. autoCancel: false,
    192. confirm: {
    193. value: '知道了',
    194. action: () => {
    195. }
    196. }
    197. }
    198. );
    199. }
    200. }
    201. // 错误处理
    202. dealAllError(error: BusinessError): void {
    203. hilog.error(this.domainId, this.logTag,
    204. `Failed to login, errorCode is ${error.code}, errorMessage is ${error.message}`);
    205. }
    206. build() {
    207. Scroll() {
    208. Column() {
    209. Column() {
    210. Column() {
    211. Image($r('app.media.app_icon'))
    212. .width(48)
    213. .height(48)
    214. .draggable(false)
    215. .copyOption(CopyOptions.None)
    216. .onComplete(() => {
    217. hilog.info(this.domainId, this.logTag, 'appIcon loading success.');
    218. })
    219. .onError(() => {
    220. hilog.error(this.domainId, this.logTag, 'appIcon loading fail.');
    221. })
    222. Text($r('app.string.app_name'))
    223. .fontFamily($r('sys.string.ohos_id_text_font_family_medium'))
    224. .fontWeight(FontWeight.Medium)
    225. .fontWeight(FontWeight.Bold)
    226. .maxFontSize($r('sys.float.ohos_id_text_size_headline8'))
    227. .minFontSize($r('sys.float.ohos_id_text_size_body1'))
    228. .maxLines(1)
    229. .fontColor($r('sys.color.ohos_id_color_text_primary'))
    230. .constraintSize({ maxWidth: '100%' })
    231. .margin({
    232. top: 12,
    233. })
    234. Text('应用描述')
    235. .fontSize($r('sys.float.ohos_id_text_size_body2'))
    236. .fontColor($r('sys.color.ohos_id_color_text_secondary'))
    237. .fontFamily($r('sys.string.ohos_id_text_font_family_regular'))
    238. .fontWeight(FontWeight.Regular)
    239. .constraintSize({ maxWidth: '100%' })
    240. .margin({
    241. top: 8,
    242. })
    243. }.margin({
    244. top: 100
    245. })
    246. Column() {
    247. Text(this.quickLoginAnonymousPhone)
    248. .fontSize(36)
    249. .fontColor($r('sys.color.ohos_id_color_text_primary'))
    250. .fontFamily($r('sys.string.ohos_id_text_font_family_medium'))
    251. .fontWeight(FontWeight.Bold)
    252. .lineHeight(48)
    253. .textAlign(TextAlign.Center)
    254. .maxLines(1)
    255. .constraintSize({ maxWidth: '100%', minHeight: 48 })
    256. Text('华为账号绑定号码')
    257. .fontSize($r('sys.float.ohos_id_text_size_body2'))
    258. .fontColor($r('sys.color.ohos_id_color_text_secondary'))
    259. .fontFamily($r('sys.string.ohos_id_text_font_family_regular'))
    260. .fontWeight(FontWeight.Regular)
    261. .lineHeight(19)
    262. .textAlign(TextAlign.Center)
    263. .maxLines(1)
    264. .constraintSize({ maxWidth: '100%' })
    265. .margin({
    266. top: 8
    267. })
    268. }.margin({
    269. top: 64
    270. })
    271. Column() {
    272. LoginWithHuaweiIDButton({
    273. params: {
    274. // LoginWithHuaweiIDButton支持的样式
    275. style: loginComponentManager.Style.BUTTON_RED,
    276. // 账号登录按钮在登录过程中展示加载态
    277. extraStyle: {
    278. buttonStyle: new loginComponentManager.ButtonStyle().loadingStyle({
    279. show: true
    280. })
    281. },
    282. // LoginWithHuaweiIDButton的边框圆角半径
    283. borderRadius: 24,
    284. // LoginWithHuaweiIDButton支持的登录类型
    285. loginType: loginComponentManager.LoginType.QUICK_LOGIN,
    286. // LoginWithHuaweiIDButton支持按钮的样式跟随系统深浅色模式切换
    287. supportDarkMode: true,
    288. // verifyPhoneNumber:如果华为账号用户在过去90天内未进行短信验证,是否拉起Account Kit提供的短信验证码页面
    289. verifyPhoneNumber: true
    290. },
    291. controller: this.controller
    292. })
    293. }
    294. .height(40)
    295. .margin({
    296. top: 56
    297. })
    298. Column() {
    299. Button({
    300. type: ButtonType.Capsule,
    301. stateEffect: true
    302. }) {
    303. Text('其他方式登录')
    304. .fontColor($r('sys.color.ohos_id_color_text_primary_activated'))
    305. .fontFamily($r('sys.string.ohos_id_text_font_family_medium'))
    306. .fontWeight(FontWeight.Medium)
    307. .fontSize($r('sys.float.ohos_id_text_size_button1'))
    308. .focusable(true)
    309. .focusOnTouch(true)
    310. .textOverflow({ overflow: TextOverflow.Ellipsis })
    311. .maxLines(1)
    312. .padding({ left: 8, right: 8 })
    313. }
    314. .fontColor($r('sys.color.ohos_id_color_text_primary_activated'))
    315. .fontFamily($r('sys.string.ohos_id_text_font_family_medium'))
    316. .fontWeight(FontWeight.Medium)
    317. .backgroundColor($r('sys.color.ohos_id_color_button_normal'))
    318. .focusable(true)
    319. .focusOnTouch(true)
    320. .constraintSize({ minHeight: 40 })
    321. .width('100%')
    322. .onClick(() => {
    323. hilog.info(this.domainId, this.logTag, 'click optionalLoginButton.');
    324. })
    325. }.margin({ top: 16 })
    326. }.width('100%')
    327. Row() {
    328. Row() {
    329. Checkbox({ name: 'privacyCheckbox', group: 'privacyCheckboxGroup' })
    330. .width(24)
    331. .height(24)
    332. .focusable(true)
    333. .focusOnTouch(true)
    334. .margin({ top: 0 })
    335. .select(this.isSelected)
    336. .onChange((value: boolean) => {
    337. if (value) {
    338. this.isSelected = true;
    339. this.controller.setAgreementStatus(loginComponentManager.AgreementStatus.ACCEPTED);
    340. } else {
    341. this.isSelected = false;
    342. this.controller.setAgreementStatus(loginComponentManager.AgreementStatus.NOT_ACCEPTED);
    343. }
    344. hilog.info(this.domainId, this.logTag, `agreementChecked: ${value}`);
    345. })
    346. }
    347. Row() {
    348. Text() {
    349. ForEach(this.privacyText, (item: loginComponentManager.PrivacyText) => {
    350. if (item?.type == loginComponentManager.TextType.PLAIN_TEXT && item?.text) {
    351. Span(item?.text)
    352. .fontColor($r('sys.color.ohos_id_color_text_secondary'))
    353. .fontFamily($r('sys.string.ohos_id_text_font_family_regular'))
    354. .fontWeight(FontWeight.Regular)
    355. .fontSize($r('sys.float.ohos_id_text_size_body3'))
    356. } else if (item?.type == loginComponentManager.TextType.RICH_TEXT && item?.text) {
    357. Span(item?.text)
    358. .fontColor($r('sys.color.ohos_id_color_text_primary_activated'))
    359. .fontFamily($r('sys.string.ohos_id_text_font_family_medium'))
    360. .fontWeight(FontWeight.Medium)
    361. .fontSize($r('sys.float.ohos_id_text_size_body3'))
    362. .focusable(true)
    363. .focusOnTouch(true)
    364. .onClick(() => {
    365. // 应用需要根据item.tag实现协议页面的跳转逻辑
    366. hilog.info(this.domainId, this.logTag, `click privacy text tag: ${item.tag}`);
    367. // 华为账号用户认证协议
    368. if (item.tag === QuickLoginButtonComponent.USER_AUTHENTICATION_TAG) {
    369. this.jumpToPrivacyWebView();
    370. }
    371. })
    372. }
    373. }, (item: loginComponentManager.PrivacyText, index: number) => `${item.text}_${index}}`)
    374. }
    375. .width('100%')
    376. }
    377. .margin({ left: 12 })
    378. .layoutWeight(1)
    379. .constraintSize({ minHeight: 24 })
    380. }
    381. .alignItems(VerticalAlign.Top)
    382. .margin({
    383. bottom: 16
    384. })
    385. }
    386. .justifyContent(FlexAlign.SpaceBetween)
    387. .constraintSize({ minHeight: '100%' })
    388. .margin({
    389. left: 16,
    390. right: 16
    391. })
    392. }
    393. .width('100%')
    394. .height('100%')
    395. }
    396. }
    397. @CustomDialog
    398. export struct AgreementDialog {
    399. logTag: string = 'AgreementDialog';
    400. domainId: number = 0x0000;
    401. dialogController?: CustomDialogController;
    402. cancel: () => void = () => {
    403. };
    404. confirm: () => void = () => {
    405. };
    406. clickHyperlinkText: () => void = () => {
    407. };
    408. privacyText: loginComponentManager.PrivacyText[] = [];
    409. private static USER_AUTHENTICATION_TAG = '华为账号用户认证协议';
    410. build() {
    411. Column() {
    412. Row() {
    413. Text('用户协议与隐私条款')
    414. .id('loginPanel_agreement_dialog_privacy_title')
    415. .maxFontSize($r('sys.float.ohos_id_text_size_headline8'))
    416. .minFontSize($r('sys.float.ohos_id_text_size_body1'))
    417. .fontColor($r('sys.color.ohos_id_color_text_primary'))
    418. .fontFamily($r('sys.string.ohos_id_text_font_family_medium'))
    419. .fontWeight(FontWeight.Bold)
    420. .textAlign(TextAlign.Center)
    421. .textOverflow({ overflow: TextOverflow.Ellipsis })
    422. .maxLines(2)
    423. }
    424. .alignItems(VerticalAlign.Center)
    425. .constraintSize({ minHeight: 56, maxWidth: 400 })
    426. .margin({
    427. left: $r('sys.float.ohos_id_max_padding_start'),
    428. right: $r('sys.float.ohos_id_max_padding_start')
    429. })
    430. Row() {
    431. Text() {
    432. ForEach(this.privacyText, (item: loginComponentManager.PrivacyText) => {
    433. if (item?.type == loginComponentManager.TextType.PLAIN_TEXT && item?.text) {
    434. Span(item?.text)
    435. .fontSize($r('sys.float.ohos_id_text_size_body1'))
    436. .fontColor($r('sys.color.ohos_id_color_text_primary'))
    437. .fontFamily($r('sys.string.ohos_id_text_font_family_regular'))
    438. .fontWeight(FontWeight.Regular)
    439. } else if (item?.type == loginComponentManager.TextType.RICH_TEXT && item?.text) {
    440. Span(item?.text)
    441. .fontSize($r('sys.float.ohos_id_text_size_body1'))
    442. .fontColor('#CE0E2D')
    443. .fontFamily($r('sys.string.ohos_id_text_font_family_medium'))
    444. .fontWeight(FontWeight.Medium)
    445. .focusable(true)
    446. .focusOnTouch(true)
    447. .onClick(() => {
    448. // 应用需要根据item.tag实现协议页面的跳转逻辑
    449. hilog.info(this.domainId, this.logTag, `click privacy text tag: ${item.tag}`);
    450. // 华为账号用户认证协议
    451. if (item.tag === AgreementDialog.USER_AUTHENTICATION_TAG) {
    452. hilog.info(this.domainId, this.logTag, 'AgreementDialog click.');
    453. this.clickHyperlinkText();
    454. }
    455. })
    456. }
    457. }, (item: loginComponentManager.PrivacyText, index: number) => `${item.text}_${index}}`)
    458. }
    459. .width('100%')
    460. .textOverflow({ overflow: TextOverflow.Ellipsis })
    461. .maxLines(10)
    462. .textAlign(TextAlign.Start)
    463. .focusable(true)
    464. .focusOnTouch(true)
    465. .padding({ left: 24, right: 24 })
    466. }.width('100%')
    467. Flex({
    468. direction: FlexDirection.Row
    469. }) {
    470. Button('取消',
    471. { type: ButtonType.Capsule, stateEffect: true })
    472. .id('loginPanel_agreement_cancel_btn')
    473. .fontColor($r('sys.color.ohos_id_color_text_primary'))
    474. .fontSize($r('sys.float.ohos_id_text_size_button1'))
    475. .fontFamily($r('sys.string.ohos_id_text_font_family_medium'))
    476. .backgroundColor(Color.Transparent)
    477. .fontWeight(FontWeight.Medium)
    478. .focusable(true)
    479. .focusOnTouch(true)
    480. .constraintSize({ minHeight: 40, maxWidth: 400 })
    481. .width('50%')
    482. .onClick(() => {
    483. hilog.info(this.domainId, this.logTag, 'AgreementDialog cancel.');
    484. this.cancel();
    485. })
    486. Button('同意并登录',
    487. { type: ButtonType.Capsule, stateEffect: true })
    488. .id('loginPanel_agreement_dialog_huawei_id_login_btn')
    489. .fontColor(Color.White)
    490. .backgroundColor('#CE0E2D')
    491. .fontSize($r('sys.float.ohos_id_text_size_button1'))
    492. .fontFamily($r('sys.string.ohos_id_text_font_family_medium'))
    493. .fontWeight(FontWeight.Medium)
    494. .focusable(true)
    495. .focusOnTouch(true)
    496. .constraintSize({ minHeight: 40, maxWidth: 400 })
    497. .width('50%')
    498. .onClick(() => {
    499. hilog.info(this.domainId, this.logTag, 'AgreementDialog confirm.');
    500. this.confirm();
    501. })
    502. }
    503. .margin({
    504. top: 8,
    505. left: $r('sys.float.ohos_id_elements_margin_horizontal_l'),
    506. right: $r('sys.float.ohos_id_elements_margin_horizontal_l'),
    507. bottom: 16
    508. })
    509. }.backgroundColor($r('sys.color.ohos_id_color_dialog_default_bg'))
    510. .padding({
    511. left: 16,
    512. right: 16
    513. })
    514. }
    515. }
    516. export enum ErrorCode {
    517. // 账号未登录
    518. ERROR_CODE_LOGIN_OUT = 1001502001,
    519. // 该账号不支持一键登录,如海外账号
    520. ERROR_CODE_NOT_SUPPORTED = 1001500003,
    521. // 网络错误
    522. ERROR_CODE_NETWORK_ERROR = 1001502005,
    523. // 用户未同意用户协议
    524. ERROR_CODE_AGREEMENT_STATUS_NOT_ACCEPTED = 1005300001
    525. }

    以下是华为账号用户认证协议展示页示例代码:

     
      
    1. import { webview } from '@kit.ArkWeb';
    2. import { hilog } from '@kit.PerformanceAnalysisKit';
    3. import { router } from '@kit.ArkUI';
    4. // 华为账号用户认证协议展示页
    5. @Entry
    6. @Component
    7. struct WebPage {
    8. @State webUrl?: string = '';
    9. @State progress: number = 0;
    10. logTag: string = 'WebPage';
    11. domainId: number = 0x0000;
    12. controller: webview.WebviewController = new webview.WebviewController();
    13. build() {
    14. Column() {
    15. Column() {
    16. Button({ type: ButtonType.Normal }) {
    17. Image($r('sys.media.ohos_ic_compnent_titlebar_back'))
    18. .backgroundColor(Color.Transparent)
    19. .borderRadius(20)
    20. .width(24)
    21. .height(24)
    22. .draggable(false)
    23. .autoResize(false)
    24. .focusable(true)
    25. .fillColor($r('sys.color.ohos_id_color_titlebar_icon'))
    26. .matchTextDirection(true)
    27. }
    28. .alignSelf(ItemAlign.Start)
    29. .backgroundColor($r('sys.color.ohos_id_color_button_normal'))
    30. .borderRadius(20)
    31. .width(40)
    32. .height(40)
    33. .onClick(() => {
    34. router.back();
    35. })
    36. }
    37. .height(56)
    38. .width('100%')
    39. .justifyContent(FlexAlign.Center)
    40. .margin({
    41. top: 36,
    42. left: 16
    43. })
    44. Progress({ value: this.progress, type: ProgressType.Linear })
    45. .width('100%')
    46. .visibility(this.progress <= 99 ? Visibility.Visible : Visibility.None)
    47. Web({ src: this.webUrl ?? '', controller: this.controller })
    48. .backgroundColor(Color.Transparent)
    49. .margin({ bottom: 60 })
    50. .onProgressChange((event) => {
    51. hilog.info(this.domainId, this.logTag,
    52. 'onProgressChange: ', (event !== undefined ? event.newProgress : -1));
    53. this.progress = event !== undefined ? event.newProgress : 0;
    54. })
    55. .darkMode(WebDarkMode.Auto)
    56. .forceDarkAccess(true)
    57. .onLoadIntercept((event) => {
    58. hilog.info(this.domainId, this.logTag, 'onLoadIntercept');
    59. return false;
    60. })
    61. .onErrorReceive((event) => {
    62. if (event) {
    63. hilog.error(this.domainId, this.logTag, `onErrorReceive,errorInfo: ${event?.error?.getErrorInfo()}`);
    64. }
    65. })
    66. }
    67. .alignItems(HorizontalAlign.Start)
    68. .padding({ left: 12, right: 12, bottom: 60 })
    69. .width('100%')
    70. .height('100%')
    71. }
    72. aboutToAppear(): void {
    73. hilog.info(0x0000, 'testTag', 'aboutToAppear');
    74. const params = router.getParams() as Record<string, string>;
    75. this.webUrl = params.url ?? '';
    76. hilog.info(0x0000, 'testTag', `webUrl: ${this.webUrl}`);
    77. }
    78. aboutToDisappear(): void {
    79. hilog.info(0x0000, 'testTag', 'aboutToDisappear');
    80. if (this.webUrl) {
    81. this.controller.stop();
    82. }
    83. }
    84. }

借助DevEco Studio辅助开发(可选)

  1. 打开需要提供一键登录功能的页面,在页面的build()中创建一个容器(如Column)。
  2. 在DevEco Studio菜单栏点击View > Tool Windows > Kit Assistant,或使用快捷键Alt + K,进入Kit Assistant页面。
  3. 在左侧目录中点击选中AccountKit > QuickLoginButton,并拖拽至新创建的容器中。即可在当前位置插入相应的代码片段。

    若代码片段插入失败,可查询快速插入的说明排查原因。

  4. 在自动生成的代码段的getQuickLoginAnonymousPhone函数中,执行executeRequest函数可获取响应结果。根据获取的响应结果判断,可能存在以下场景:
    • 已正确获取到用户身份标识UnionID、OpenID,应用可通过用户身份标识查询该用户是否已关联。

      1)如已关联,结合风控、安全因素及自身业务场景判断,可展示已关联的账号,由用户选择是否使用华为账号登录应用,或免用户操作,静默登录应用,客户端开发结束。

      2)如未关联,再判断是否存在下面的异常场景,如无,则参考下面步骤5继续开发。

    • 存在如下返回ArkTS错误码的异常场景:

      1)返回1001502001 用户未登录华为账号错误码,说明华为账号未登录。

      2)返回1001500003 不支持该scopes或permissions错误码,说明华为账号用户注册地非中国大陆或登录的华为账号是儿童账号。

      3)获取到的匿名手机号为空,说明华为账号没有绑定手机号、权限未申请或未生效。

      上述异常场景应用需要展示其他登录方式。

  5. 根据上述代码实现应用的登录页面,并展示华为账号一键登录按钮和华为账号用户认证协议(Account Kit提供跳转链接,应用需实现协议跳转,参见约束与限制第2点),用户同意协议并点击一键登录按钮后,可获取到Authorization Code,将该值传给应用服务器用于获取用户信息(完整手机号、UnionID、OpenID)。

服务端开发

  1. 应用服务器使用Client ID、Client Secret、Authorization Code调用获取用户级凭证的接口向华为账号服务器请求获取Access Token、Refresh Token。
  2. 使用Access Token调用获取用户信息接口获取用户信息,从用户信息中获取用户绑定的完整手机号和华为账号用户标识UnionID。

    Access Token过期处理

    由于Access Token的有效期仅为60分钟,当Access Token失效或者即将失效时(可通过REST API错误码判断),可以使用Refresh Token(有效期180天)通过刷新凭证向华为账号服务器请求获取新的Access Token。

    说明

    1. 当Access Token失效时,若应用不使用Refresh Token向华为账号服务器请求获取新的Access Token,账号的授权信息将会失效,导致使用Access Token的功能都会失败。
    2. 当Access Token非正常失效(如修改密码、退出账号、删除设备)时,应用可重新登录授权获取Authorization Code,向华为账号服务器请求获取新的Access Token。

    Refresh Token过期处理

    由于Refresh Token的有效期为180天,当Refresh Token失效后(可通过REST API错误码判断),应用服务器需要通知客户端,重新调用授权接口,请求用户重新授权。

  3. 应用通过获取到的完整手机号或UnionID查询该用户是否已关联应用系统数据库。如已关联,则绑定获取的UnionID与手机号到已有用户上(如已绑定,则可忽略),完成用户登录;如未关联,则创建新用户并绑定手机号与UnionID到该用户上。

客户端与服务端交互开发

应用客户端到应用服务端的开发

业务流程:

  • 准备:
  1. 请先完成应用客户端一键登录的相关开发,相关开发指导参考客户端开发;
  2. 参考基于RCP的网络请求开发实践完成客户端到服务端的接口请求;
    • 开发步骤:

    在应用客户端,根据实现基础的网络请求的使用说明进行网络请求;

    1. 在应用客户端调用服务端提供的接口,将Authorization Code传输给应用的服务端;

      注意

      应用客户端与应用服务端的交互安全需要应用自行保证。

       
          
      1. import { rcp } from '@kit.RemoteCommunicationKit';
      2. import { hilog } from '@kit.PerformanceAnalysisKit';
      3. import { util } from '@kit.ArkTS';
      4. import { BusinessError } from '@kit.BasicServicesKit';
      5. // 客户端请求接口示例代码
      6. export function rcpRequest(authCode: string) {
      7. // 定义请求头
      8. const headers: rcp.RequestHeaders = {
      9. 'accept': 'application/json'
      10. };
      11. // 定义要传递的参数
      12. const postMessage: Record<string, string> = {
      13. 'authorizationCode': authCode
      14. };
      15. const securityConfig: rcp.SecurityConfiguration = {
      16. tlsOptions: {
      17. tlsVersion: 'TlsV1.3'
      18. }
      19. };
      20. // 假设"http://localhost:6687"为应用服务器地址
      21. const baseUrl = 'http://localhost:6687/account/login';
      22. // 定义请求对象
      23. const req = new rcp.Request(baseUrl, 'POST', headers, postMessage);
      24. // 创建通信会话对象
      25. const session = rcp.createSession({ requestConfiguration: { security: securityConfig } });
      26. // 发起请求
      27. session.fetch(req).then((response) => {
      28. hilog.info(0x0000, 'getRcpResult', 'Succeeded in getting result from server.');
      29. if (response.body) {
      30. const decoder = util.TextDecoder.create('utf-8');
      31. const result = JSON.parse(decoder.decodeToString(new Uint8Array(response.body))) as Record<string, Object>;
      32. const phoneNumber: string = JSON.stringify(result['phone'] ?? '');
      33. if (phoneNumber) {
      34. // 应用处理相关逻辑
      35. }
      36. } else {
      37. hilog.error(0x0000, 'getRcpResult', 'Failed to get response body.');
      38. }
      39. }).catch((err: BusinessError) => {
      40. hilog.error(0x0000, 'getRcpResult', `err: err code is ${err.code}, err message is ${JSON.stringify(err)}`);
      41. });
      42. }
    2. 应用服务端提供接口用于接收应用客户端获取到的Authorization Code;
       
          
      1. // 服务端接口示例代码(Java)
      2. @PostMapping("/login")
      3. public Response login(@RequestBody LoginRequesBody requestBody) throws IOException {
      4. UserInfo userInfo = loginService.loginWithHuawei(requestBody.getAuthorizationCode());
      5. return new Response(userInfo, 200, "login success!");
      6. }
    3. 应用服务端获取到Authorization Code之后,对接华为账号服务器,参考服务端开发,获取Access Token和Refresh Token,并调用获取用户信息接口获取用户信息;
    4. 根据获取的UnionID、OpenID、完整手机号,判断登录用户是否为新用户、是否已关联等等(根据实际业务开发);
    5. 保存或更新用户信息到应用服务器,并可根据登录设计方案选择是否保存Refresh Token;完成处理后,返回登录用户的信息至应用客户端;
       
          
      1. /*
      2. * 服务端接口示例代码(Java)
      3. * 服务端使用Authorization Code获取Access Token,获取用户信息的示例代码
      4. */
      5. public UserInfo loginWithHuawei(String authorizationCode) {
      6. AccessTokenRequestBody requestBody = new AccessTokenRequestBody();
      7. requestBody.setClient_id(agcProperties.getClientId());
      8. requestBody.setClient_secret(agcProperties.getClientSecret());
      9. requestBody.setGrant_type("authorization_code");
      10. requestBody.setCode(authorizationCode);
      11. /*
      12. * 若使用refresh_token获取Access Token,则传refresh_code、grant_type设为"refresh_token"。
      13. * requestBody.setRefresh_token("******");
      14. */
      15. log.info("account rest api requestBody is : " + requestBody.toString());
      16. // 使用authCode获取Access Token
      17. TokenEntity response = httpService.callHttpPost("https://oauth-login.cloud.huawei/oauth2/v3/token", requestBody, TokenEntity.class).getBody();
      18. log.info("the output of oauth2/v3/token Access_token is: " + response.toString());
      19. // 使用Access Token获取用户信息
      20. AccountInfoRequestBody accountInfoBody = new AccountInfoRequestBody();
      21. accountInfoBody.setAccess_token(response.getAccess_token());
      22. accountInfoBody.setGetNickName("1");
      23. AccountInfoEntity accountInfo = httpService.callHttpPost("https://account.cloud.huawei/rest.php?nsp_svc=GOpen.User.getInfo", accountInfoBody, AccountInfoEntity.class).getBody();
      24. log.info("the output of rest.php?nsp_svc=GOpen.User.getInfo is: " + accountInfo.toString());
      25. // 组装用户信息,用于接口返回数据到客户端
      26. UserInfo userInfo = new UserInfo();
      27. userInfo.setPhone(accountInfo.getLoginMobileNumber());
      28. /**
      29. * 根据业务设计流程,在数据库中查询用户信息,比如:
      30. * 1、使用UnionID查询用户,匹配到了则返回用户信息;
      31. * 2、未匹配到则使用手机号查询用户,查到了则将华为账号UnionID关联到该用户,返回用户信息;
      32. * 3、UnionID和手机号均没有匹配到,则进入注册流程
      33. *
      34. * (可选)保存Refresh Token
      35. * 由于Access Token的有效期仅为60分钟,当Access Token失效或者即将失效时(可通过REST API错误码判断),
      36. * 可以使用Refresh Token(有效期180天)通过获取凭证Access Token向华为账号服务器请求获取新的Access Token。
      37. */
      38. return userInfo;
      39. }

客户端与服务端联调

前提:根据应用登录方案设计及实现,完成客户端和服务端开发,开发指导参见客户端开发、服务端开发和应用客户端到应用服务端的开发。

  1. 在客户端获取到Authorization Code之后,传送给服务端接口;在服务端使用Authorization Code获取Access Token,再用Access Token获取华为账号绑定的手机号、UnionID、OpenID。

  2. 根据应用登录方案使用华为账号绑定的手机号、UnionID、OpenID登录成功后,应用服务器返回用户信息给应用客户端,应用客户端可根据需要进行本地持久化存储,例如:登录状态、用户账号名、手机号、用户唯一标识等。
  3. 在应用客户端首页或个人信息页等位置,对当前登录用户信息进行展示,举例如下图:

开发后验证

集成华为账号一键登录能力应用用户体验质量建议

应用完成开发后,可参照以下标准检查集成华为账号一键登录后的用户体验是否符合预期:

标准编号

标准项名称

类型

标准详细描述

1

满足华为账号提供登录设计规范

规则

需满足华为账号开放登录中【华为账号一键登录】按钮规范,保障HarmonyOS应用拥有简单易用、高效一致、快速安全的登录体验;

2

用户交互体验原则

建议

(1)登录页面的用户协议与隐私协议、华为账号用户认证协议可展示、可点击;

(2)当用户点击协议后,回退页面,须回到点击前的页面;

(3)只有用户勾选并同意所有协议后,才可继续进行登录操作,若用户未勾选协议时直接点击华为账号登录按钮,须有明确的同意协议提醒;

(4)点击登录按钮须直接完成登录流程,可出现头像、昵称授权页,但取消场景须不影响登录流程;若出现处理异常,须及时终止页面,不应出现应用卡死无法操作;

3

登录页面内容用户体验原则

建议

(1)若未提供其他登录方式,不应显示“其他登录方式”的入口;

(2)若使用华为账号一键登录,页面匿名手机号须展示从华为账号侧获取的匿名手机号,不应展示其他来源的手机号;

(3)用户协议中,必须包含《华为账号用户认证协议》,且协议必须可点击、可加载,加载后支持回退页面,且回到点击前的页面;

4

异常处理用户体验原则

建议

登录页面需进行异常处理保证:

(1)若登录异常(如网络异常、海外账号不支持等情况),勿将错误码等原始信息直接透传给用户;

(2)若登录时触发了华为侧的短信验证码校验,则在校验成功之后,应用不应再展示额外的验证码验证页面;

5

应用生命周期变化的华为账号用户体验原则

建议

应用更新后,其登录状态须与更新前一致;

本文标签: 华为 账号 一键 能力 快速