@liujitcn/kratos-uni-app-core 0.0.28 → 0.0.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,101 @@
1
+ import { t } from '../locales'
2
+
3
+ /** WebAuthn 选项使用的 Base64URL 编解码。 */
4
+ function decodeBase64Url(value: string): ArrayBuffer {
5
+ const normalized = value.replace(/-/g, '+').replace(/_/g, '/')
6
+ const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=')
7
+ const binary = atob(padded)
8
+ const bytes = new Uint8Array(binary.length)
9
+ for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index)
10
+ return bytes.buffer
11
+ }
12
+
13
+ /** 将 WebAuthn 二进制响应编码为服务端协议使用的 Base64URL。 */
14
+ function encodeBase64Url(value: ArrayBuffer | Uint8Array): string {
15
+ const bytes = value instanceof Uint8Array ? value : new Uint8Array(value)
16
+ let binary = ''
17
+ bytes.forEach((byte) => {
18
+ binary += String.fromCharCode(byte)
19
+ })
20
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '')
21
+ }
22
+
23
+ /** 将服务端返回的 WebAuthn 创建选项转换为浏览器类型。 */
24
+ function parseCreationOptions(optionsJson: string): PublicKeyCredentialCreationOptions {
25
+ const options = JSON.parse(optionsJson) as { publicKey: PublicKeyCredentialCreationOptions }
26
+ const publicKey = options.publicKey
27
+ publicKey.challenge = decodeBase64Url(publicKey.challenge as unknown as string)
28
+ publicKey.user.id = decodeBase64Url(publicKey.user.id as unknown as string)
29
+ publicKey.excludeCredentials = publicKey.excludeCredentials?.map((item) => ({
30
+ ...item,
31
+ id: decodeBase64Url(item.id as unknown as string),
32
+ }))
33
+ return publicKey
34
+ }
35
+
36
+ /** 将服务端返回的 WebAuthn 断言选项转换为浏览器类型。 */
37
+ function parseRequestOptions(optionsJson: string): PublicKeyCredentialRequestOptions {
38
+ const options = JSON.parse(optionsJson) as { publicKey: PublicKeyCredentialRequestOptions }
39
+ const publicKey = options.publicKey
40
+ publicKey.challenge = decodeBase64Url(publicKey.challenge as unknown as string)
41
+ publicKey.allowCredentials = publicKey.allowCredentials?.map((item) => ({
42
+ ...item,
43
+ id: decodeBase64Url(item.id as unknown as string),
44
+ }))
45
+ return publicKey
46
+ }
47
+
48
+ /** 将 PublicKeyCredential 序列化为服务端协议使用的 JSON。 */
49
+ function serializeCredential(credential: PublicKeyCredential): string {
50
+ const response = credential.response
51
+ const serializedResponse: Record<string, unknown> = {
52
+ clientDataJSON: encodeBase64Url(response.clientDataJSON),
53
+ }
54
+ const payload: Record<string, unknown> = {
55
+ id: credential.id,
56
+ rawId: encodeBase64Url(credential.rawId),
57
+ type: credential.type,
58
+ response: serializedResponse,
59
+ clientExtensionResults: credential.getClientExtensionResults(),
60
+ }
61
+ if ('attestationObject' in response) {
62
+ const attestation = response as AuthenticatorAttestationResponse
63
+ serializedResponse.attestationObject = encodeBase64Url(attestation.attestationObject)
64
+ } else {
65
+ const assertion = response as AuthenticatorAssertionResponse
66
+ Object.assign(serializedResponse, {
67
+ authenticatorData: encodeBase64Url(assertion.authenticatorData),
68
+ signature: encodeBase64Url(assertion.signature),
69
+ userHandle: assertion.userHandle ? encodeBase64Url(assertion.userHandle) : null,
70
+ })
71
+ }
72
+ if (credential.authenticatorAttachment)
73
+ payload.authenticatorAttachment = credential.authenticatorAttachment
74
+ return JSON.stringify(payload)
75
+ }
76
+
77
+ /** 执行 WebAuthn 注册 ceremony。 */
78
+ export async function createWebAuthnCredential(optionsJson: string): Promise<string> {
79
+ if (typeof navigator === 'undefined' || !navigator.credentials?.create) {
80
+ throw new Error(t('core.login.mfa_webauthn_setup_unsupported'))
81
+ }
82
+ const credential = await navigator.credentials.create({
83
+ publicKey: parseCreationOptions(optionsJson),
84
+ })
85
+ if (!(credential instanceof PublicKeyCredential))
86
+ throw new Error(t('core.login.mfa_webauthn_setup_invalid'))
87
+ return serializeCredential(credential)
88
+ }
89
+
90
+ /** 执行 WebAuthn 登录 ceremony。 */
91
+ export async function getWebAuthnAssertion(optionsJson: string): Promise<string> {
92
+ if (typeof navigator === 'undefined' || !navigator.credentials?.get) {
93
+ throw new Error(t('core.login.mfa_webauthn_login_unsupported'))
94
+ }
95
+ const credential = await navigator.credentials.get({
96
+ publicKey: parseRequestOptions(optionsJson),
97
+ })
98
+ if (!(credential instanceof PublicKeyCredential))
99
+ throw new Error(t('core.login.mfa_webauthn_login_invalid'))
100
+ return serializeCredential(credential)
101
+ }
@@ -1,14 +1,19 @@
1
1
  <script setup lang="ts">
2
2
  import { useSettingStore, useUserStore } from '../../../stores'
3
3
  import type { LoginRequest } from '../../../rpc/base/v1/login'
4
+ import { LoginStatus, type LoginResponse } from '../../../rpc/base/v1/login'
4
5
  import { onLoad } from '@dcloudio/uni-app'
5
- import { computed, defineAsyncComponent, reactive, ref, watch } from 'vue'
6
+ import { computed, defineAsyncComponent, nextTick, reactive, ref, watch } from 'vue'
6
7
  import { defLoginService } from '../../../api/base/login'
8
+ import { defMfaService } from '../../../api/base/mfa'
7
9
  import defaultLogo from '../../../static/images/logo_icon.png'
8
10
  import { homeTabPage } from '../../../utils/navigation'
9
11
  import { PASSWORD_CRYPTO_SCENE, encryptPassword } from '../../../utils/passwordCrypto'
12
+ import { createWebAuthnCredential, getWebAuthnAssertion } from '../../../utils/webauthn'
10
13
  import { navigateAppRoute } from '../../../navigation'
11
14
  import { getLanguageOptions, useI18n, type SupportedLocale } from '../../../locales'
15
+ import MfaRecoveryCodesDialog from '../../../components/MfaRecoveryCodesDialog.vue'
16
+ import MfaSetupPanel from '../../../components/MfaSetupPanel.vue'
12
17
 
13
18
  // go-captcha-uni 仅在 H5 端使用,小程序端不支持 defineAsyncComponent,需按平台裁剪。
14
19
  // #ifdef H5
@@ -43,6 +48,21 @@ const showTenantCode = computed(() => settingStore.getData('showTenantCode') !==
43
48
  const isAgreePrivacy = ref(false)
44
49
  const isAgreePrivacyShakeY = ref(false)
45
50
  const loading = ref(false)
51
+ const mfaVisible = ref(false)
52
+ const mfaLoading = ref(false)
53
+ const mfaChallengeId = ref('')
54
+ const mfaCode = ref('')
55
+ const mfaRecoveryCode = ref('')
56
+ const mfaMethod = ref('totp')
57
+ const mfaWebAuthnOptionsJson = ref('')
58
+ const mfaSetupVisible = ref(false)
59
+ const mfaSetupTicket = ref('')
60
+ const mfaSetupUri = ref('')
61
+ const mfaSetupCode = ref('')
62
+ const mfaSetupMethod = ref('totp')
63
+ const mfaSetupWebAuthnOptionsJson = ref('')
64
+ const mfaRecoveryCodesVisible = ref(false)
65
+ const mfaRecoveryCodes = ref<string[]>([])
46
66
 
47
67
  watch(locale, () => void uni.setNavigationBarTitle({ title: t('common.action.login') }), {
48
68
  immediate: true,
@@ -77,6 +97,23 @@ const onOpenPrivacyContract = () => {
77
97
  navigateAppRoute('app/protocol/privacy')
78
98
  }
79
99
 
100
+ /** 提取请求失败时后端返回的可展示消息。 */
101
+ const getWechatErrorMessage = (error: unknown, fallback: string) => {
102
+ if (error instanceof Error && error.message) {
103
+ return error.message
104
+ }
105
+ if (!error || typeof error !== 'object') {
106
+ return fallback
107
+ }
108
+ const response = error as {
109
+ data?: {
110
+ message?: string
111
+ error?: { message?: string }
112
+ }
113
+ }
114
+ return response.data?.message || response.data?.error?.message || fallback
115
+ }
116
+
80
117
  // #ifdef MP-WEIXIN
81
118
  const miniBinding = ref(false)
82
119
  const miniCaptchaImage = ref('')
@@ -185,10 +222,13 @@ const wxLogin = async () => {
185
222
  void refreshMiniCaptcha()
186
223
  return
187
224
  }
188
- await loginSuccess()
225
+ if (await handleLoginResponse(session)) await loginSuccess()
189
226
  } catch (error) {
190
227
  if (!isWechatUnboundError(error)) {
191
- await uni.showToast({ icon: 'none', title: t('core.login.wechat_failed') })
228
+ await uni.showToast({
229
+ icon: 'none',
230
+ title: getWechatErrorMessage(error, t('core.login.wechat_failed')),
231
+ })
192
232
  return
193
233
  }
194
234
  miniBinding.value = true
@@ -221,7 +261,7 @@ const bindMiniAccount = async () => {
221
261
  PASSWORD_CRYPTO_SCENE.PASSWORD_CRYPTO_SCENE_LOGIN,
222
262
  )
223
263
  const code = (await wx.login()).code
224
- await userStore.bindOauthSession({
264
+ const response = await userStore.bindOauthSession({
225
265
  provider: wechatMiniProvider,
226
266
  code,
227
267
  tenant_code: showTenantCode.value ? miniForm.tenant_code : '0000',
@@ -230,18 +270,135 @@ const bindMiniAccount = async () => {
230
270
  captcha_code: miniForm.captcha_code,
231
271
  captcha_id: miniForm.captcha_id,
232
272
  })
233
- await loginSuccess()
273
+ if (await handleLoginResponse(response)) await loginSuccess()
234
274
  } catch (error) {
235
275
  await refreshMiniCaptcha()
236
- if (error instanceof Error && error.message) {
237
- await uni.showToast({ icon: 'none', title: error.message })
238
- }
276
+ await uni.showToast({
277
+ icon: 'none',
278
+ title: getWechatErrorMessage(error, t('core.login.login_failed')),
279
+ })
239
280
  } finally {
240
281
  loading.value = false
241
282
  }
242
283
  }
243
284
  // #endif
244
285
 
286
+ // 处理登录接口返回的 MFA 状态。
287
+ const handleLoginResponse = async (response: LoginResponse) => {
288
+ if (response.status === LoginStatus.LOGIN_STATUS_MFA_REQUIRED) {
289
+ mfaChallengeId.value = response.mfa_challenge_id
290
+ mfaCode.value = ''
291
+ mfaRecoveryCode.value = ''
292
+ mfaMethod.value = response.mfa_method || 'totp'
293
+ mfaWebAuthnOptionsJson.value = response.mfa_webauthn_options_json || ''
294
+ mfaVisible.value = true
295
+ return false
296
+ }
297
+ if (response.status === LoginStatus.LOGIN_STATUS_MFA_ENROLLMENT_REQUIRED) {
298
+ mfaSetupTicket.value = response.mfa_setup_ticket
299
+ mfaSetupMethod.value = response.mfa_method || 'totp'
300
+ await beginMfaSetup(response.mfa_setup_ticket)
301
+ return false
302
+ }
303
+ return true
304
+ }
305
+
306
+ // 校验登录阶段 MFA。
307
+ const verifyMfaLogin = async () => {
308
+ if (
309
+ mfaLoading.value ||
310
+ !mfaChallengeId.value ||
311
+ (mfaMethod.value !== 'webauthn' && !mfaCode.value && !mfaRecoveryCode.value)
312
+ )
313
+ return
314
+ mfaLoading.value = true
315
+ try {
316
+ const webauthnResponseJson =
317
+ mfaMethod.value === 'webauthn' && !mfaRecoveryCode.value
318
+ ? await getWebAuthnAssertion(mfaWebAuthnOptionsJson.value)
319
+ : ''
320
+ const response = await userStore.verifyMfa({
321
+ challenge_id: mfaChallengeId.value,
322
+ code: mfaRecoveryCode.value ? '' : mfaCode.value,
323
+ recovery_code: mfaRecoveryCode.value,
324
+ webauthn_response_json: webauthnResponseJson,
325
+ })
326
+ mfaVisible.value = false
327
+ if (await handleLoginResponse(response)) await loginSuccess()
328
+ } catch (error) {
329
+ await uni.showToast({
330
+ icon: 'none',
331
+ title: getWechatErrorMessage(error, t('core.login.login_failed')),
332
+ })
333
+ } finally {
334
+ mfaLoading.value = false
335
+ }
336
+ }
337
+
338
+ // 开始强制绑定 MFA。
339
+ const beginMfaSetup = async (ticket: string) => {
340
+ mfaLoading.value = true
341
+ try {
342
+ const response = await defMfaService.BeginMfaEnrollment({ setup_ticket: ticket })
343
+ mfaSetupTicket.value = response.setup_ticket
344
+ mfaSetupUri.value = response.otpauth_uri
345
+ mfaSetupCode.value = ''
346
+ mfaSetupMethod.value = response.method || 'totp'
347
+ mfaSetupWebAuthnOptionsJson.value = response.webauthn_options_json || ''
348
+ mfaSetupVisible.value = true
349
+ } finally {
350
+ mfaLoading.value = false
351
+ }
352
+ }
353
+
354
+ // 确认强制绑定 MFA。
355
+ const confirmMfaSetup = async () => {
356
+ if (
357
+ mfaLoading.value ||
358
+ !mfaSetupTicket.value ||
359
+ (mfaSetupMethod.value !== 'webauthn' && !mfaSetupCode.value)
360
+ )
361
+ return
362
+ mfaLoading.value = true
363
+ try {
364
+ const webauthnResponseJson =
365
+ mfaSetupMethod.value === 'webauthn'
366
+ ? await createWebAuthnCredential(mfaSetupWebAuthnOptionsJson.value)
367
+ : ''
368
+ const response = await defMfaService.ConfirmMfaEnrollment({
369
+ setup_ticket: mfaSetupTicket.value,
370
+ code: mfaSetupCode.value,
371
+ webauthn_response_json: webauthnResponseJson,
372
+ })
373
+ mfaSetupVisible.value = false
374
+ mfaRecoveryCodes.value = response.recovery_codes
375
+ mfaRecoveryCodesVisible.value = true
376
+ } catch (error) {
377
+ await uni.showToast({
378
+ icon: 'none',
379
+ title: getWechatErrorMessage(error, t('core.login.login_failed')),
380
+ })
381
+ } finally {
382
+ mfaLoading.value = false
383
+ }
384
+ }
385
+
386
+ /** 确认已保存恢复码,刷新验证码并重新进入登录流程。 */
387
+ const finishMfaEnrollment = async () => {
388
+ mfaRecoveryCodesVisible.value = false
389
+ await nextTick()
390
+ mfaRecoveryCodes.value = []
391
+ await uni.showToast({ icon: 'none', title: t('core.login.mfa_setup_success') })
392
+ // #ifdef H5
393
+ await loadPageCaptcha()
394
+ // #endif
395
+ // 小程序没有可复用的账号密码会话,绑定完成后重新走微信登录以进入 MFA 挑战。
396
+ // #ifdef MP-WEIXIN
397
+ miniBinding.value = false
398
+ await wxLogin()
399
+ // #endif
400
+ }
401
+
245
402
  // #ifdef H5
246
403
  const captcha_base64 = ref() // 验证码图片Base64字符串
247
404
  const defaultCaptchaImageWidth = 170
@@ -504,6 +661,7 @@ const verifyCaptchaToken = async (captchaCode: string) => {
504
661
  })
505
662
  return result.captcha_token
506
663
  }
664
+
507
665
  // 执行真正的账号登录流程。
508
666
  const submitLogin = async (captchaCode: string) => {
509
667
  const password = await encryptPassword(
@@ -540,8 +698,8 @@ const verifyBehaviorCaptcha = async (captchaCode: string, reset: () => void) =>
540
698
  try {
541
699
  const captchaToken = await verifyCaptchaToken(captchaCode)
542
700
  behaviorDialogVisible.value = false
543
- await submitLogin(captchaToken)
544
- await loginSuccess()
701
+ const response = await submitLogin(captchaToken)
702
+ if (await handleLoginResponse(response)) await loginSuccess()
545
703
  } catch {
546
704
  reset()
547
705
  await refreshCaptcha()
@@ -586,8 +744,8 @@ const onSubmit = async () => {
586
744
  return
587
745
  }
588
746
  try {
589
- await submitLogin(form.value.captcha_code)
590
- await loginSuccess()
747
+ const response = await submitLogin(form.value.captcha_code)
748
+ if (await handleLoginResponse(response)) await loginSuccess()
591
749
  } catch {
592
750
  form.value.captcha_code = ''
593
751
  await refreshCaptcha()
@@ -849,6 +1007,65 @@ onLoad(() => {
849
1007
  }}</text>
850
1008
  </view>
851
1009
  </view>
1010
+ <view v-if="mfaVisible" class="mfa-mask">
1011
+ <view class="mfa-panel">
1012
+ <text class="mfa-title">{{ t('core.login.mfa_title') }}</text>
1013
+ <input
1014
+ v-if="mfaMethod !== 'webauthn'"
1015
+ v-model="mfaCode"
1016
+ class="login-input"
1017
+ type="number"
1018
+ maxlength="8"
1019
+ :placeholder="t('core.login.mfa_code')"
1020
+ />
1021
+ <input
1022
+ v-model="mfaRecoveryCode"
1023
+ class="login-input"
1024
+ :placeholder="t('core.login.mfa_recovery_code')"
1025
+ />
1026
+ <button
1027
+ class="login-button login-button-primary"
1028
+ :loading="mfaLoading"
1029
+ @tap="verifyMfaLogin"
1030
+ >
1031
+ {{
1032
+ mfaMethod === 'webauthn' && !mfaRecoveryCode
1033
+ ? t('core.login.mfa_webauthn_action')
1034
+ : t('common.action.confirm')
1035
+ }}
1036
+ </button>
1037
+ </view>
1038
+ </view>
1039
+ <view v-if="mfaSetupVisible" class="mfa-mask">
1040
+ <view class="mfa-panel">
1041
+ <text class="mfa-title">{{ t('core.login.mfa_setup_title') }}</text>
1042
+ <MfaSetupPanel :uri="mfaSetupUri" />
1043
+ <input
1044
+ v-if="mfaSetupMethod !== 'webauthn'"
1045
+ v-model="mfaSetupCode"
1046
+ class="login-input"
1047
+ type="number"
1048
+ maxlength="8"
1049
+ :placeholder="t('core.login.mfa_code')"
1050
+ />
1051
+ <button
1052
+ class="login-button login-button-primary"
1053
+ :loading="mfaLoading"
1054
+ @tap="confirmMfaSetup"
1055
+ >
1056
+ {{
1057
+ mfaSetupMethod === 'webauthn'
1058
+ ? t('core.login.mfa_webauthn_action')
1059
+ : t('common.action.confirm')
1060
+ }}
1061
+ </button>
1062
+ </view>
1063
+ </view>
1064
+ <MfaRecoveryCodesDialog
1065
+ v-model="mfaRecoveryCodesVisible"
1066
+ :codes="mfaRecoveryCodes"
1067
+ @confirm="finishMfaEnrollment"
1068
+ />
852
1069
  </view>
853
1070
  </view>
854
1071
  </template>
@@ -1026,6 +1243,70 @@ onLoad(() => {
1026
1243
  }
1027
1244
  }
1028
1245
  }
1246
+ .mfa-mask {
1247
+ position: fixed;
1248
+ inset: 0;
1249
+ z-index: 20;
1250
+ display: flex;
1251
+ align-items: center;
1252
+ justify-content: center;
1253
+ padding: 40rpx;
1254
+ background: rgba(15, 23, 42, 0.46);
1255
+ }
1256
+ .mfa-panel {
1257
+ display: flex;
1258
+ flex-direction: column;
1259
+ width: 100%;
1260
+ max-width: 620rpx;
1261
+ padding: 36rpx;
1262
+ background: #fff;
1263
+ border-radius: 24rpx;
1264
+ box-sizing: border-box;
1265
+ }
1266
+ .mfa-title {
1267
+ margin-bottom: 22rpx;
1268
+ font-size: 34rpx;
1269
+ font-weight: 700;
1270
+ color: #172a35;
1271
+ }
1272
+
1273
+ .mfa-panel .login-input {
1274
+ width: 100%;
1275
+ height: 88rpx;
1276
+ margin-bottom: 20rpx;
1277
+ padding: 0 30rpx;
1278
+ color: #1f2937;
1279
+ font-size: 28rpx;
1280
+ background: #f9fbfa;
1281
+ border: 1px solid #e5e7eb;
1282
+ border-radius: 24rpx;
1283
+ box-sizing: border-box;
1284
+ }
1285
+
1286
+ .mfa-panel .login-button {
1287
+ display: flex;
1288
+ align-items: center;
1289
+ justify-content: center;
1290
+ width: 100%;
1291
+ height: 88rpx;
1292
+ margin-top: 12rpx;
1293
+ padding: 0;
1294
+ color: #fff;
1295
+ font-size: 30rpx;
1296
+ font-weight: 500;
1297
+ background: transparent;
1298
+ border: 0;
1299
+ border-radius: 24rpx;
1300
+ box-shadow: 0 18rpx 36rpx rgba(40, 187, 156, 0.24);
1301
+ }
1302
+
1303
+ .mfa-panel .login-button::after {
1304
+ border: 0;
1305
+ }
1306
+
1307
+ .mfa-panel .login-button-primary {
1308
+ background: linear-gradient(135deg, #34c8aa 0%, #28bb9c 100%);
1309
+ }
1029
1310
 
1030
1311
  .login-behavior-mask {
1031
1312
  position: fixed;