@liujitcn/kratos-uni-app-core 0.0.29 → 0.0.32

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.
Files changed (43) hide show
  1. package/README.md +30 -7
  2. package/dist/module.mjs +164 -16
  3. package/dist/vite.mjs +4 -5
  4. package/package.json +3 -1
  5. package/src/api/base/{config.ts → v1/config.ts} +6 -2
  6. package/src/api/base/{file.ts → v1/file.ts} +6 -2
  7. package/src/api/base/{language.ts → v1/language.ts} +2 -2
  8. package/src/api/base/{login.ts → v1/login.ts} +3 -3
  9. package/src/api/base/v1/mfa.ts +127 -0
  10. package/src/api/base/{oauth.ts → v1/oauth.ts} +3 -3
  11. package/src/api/system/{auth.ts → app/v1/auth.ts} +8 -15
  12. package/src/api/system/{base_area.ts → app/v1/base_area.ts} +2 -2
  13. package/src/api/system/app/v1/base_dict.ts +21 -0
  14. package/src/api/{base/menu.ts → system/app/v1/base_menu.ts} +2 -2
  15. package/src/bootstrap.ts +1 -1
  16. package/src/components/KratosSettingsPage.vue +292 -0
  17. package/src/components/KratosTabBar.vue +71 -1
  18. package/src/components/MfaManageDialog.vue +469 -0
  19. package/src/components/MfaRecoveryCodesDialog.vue +192 -0
  20. package/src/components/MfaSetupPanel.vue +160 -0
  21. package/src/components/PasswordVerifyDialog.vue +277 -0
  22. package/src/index.ts +2 -0
  23. package/src/locales/README.md +2 -1
  24. package/src/locales/en-US.json +41 -4
  25. package/src/locales/index.ts +10 -1
  26. package/src/locales/ja-JP.json +41 -4
  27. package/src/locales/zh-CN.json +41 -4
  28. package/src/locales/zh-TW.json +41 -4
  29. package/src/navigation.ts +115 -53
  30. package/src/rpc/base/v1/login.ts +37 -4
  31. package/src/rpc/base/v1/mfa.ts +180 -0
  32. package/src/rpc/base/v1/oauth.ts +25 -0
  33. package/src/rpc/system/app/v1/auth.ts +7 -0
  34. package/src/rpc/system/common/v1/common.ts +35 -0
  35. package/src/stores/modules/setting.ts +1 -1
  36. package/src/stores/modules/user.ts +29 -10
  37. package/src/utils/clipboard.ts +33 -0
  38. package/src/utils/http.ts +10 -13
  39. package/src/utils/passwordCrypto.ts +1 -1
  40. package/src/utils/webauthn.ts +101 -0
  41. package/src/views/pages/login/login.vue +299 -13
  42. package/src/vite.ts +4 -7
  43. package/src/api/system/base_dict.ts +0 -24
@@ -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 { defLoginService } from '../../../api/base/login'
6
+ import { computed, defineAsyncComponent, nextTick, reactive, ref, watch } from 'vue'
7
+ import { defLoginService } from '../../../api/base/v1/login'
8
+ import { defMfaService } from '../../../api/base/v1/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>
@@ -870,12 +1087,17 @@ onLoad(() => {
870
1087
  top: calc(24rpx + env(safe-area-inset-top));
871
1088
  right: 28rpx;
872
1089
  z-index: 2;
1090
+ max-width: calc(100% - 56rpx);
873
1091
  }
874
1092
 
875
1093
  .login-locale__value {
1094
+ max-width: 240rpx;
1095
+ overflow: hidden;
876
1096
  padding: 12rpx 16rpx;
877
1097
  color: #16806d;
878
1098
  font-size: 24rpx;
1099
+ text-overflow: ellipsis;
1100
+ white-space: nowrap;
879
1101
  }
880
1102
 
881
1103
  .login-hero {
@@ -1026,6 +1248,70 @@ onLoad(() => {
1026
1248
  }
1027
1249
  }
1028
1250
  }
1251
+ .mfa-mask {
1252
+ position: fixed;
1253
+ inset: 0;
1254
+ z-index: 20;
1255
+ display: flex;
1256
+ align-items: center;
1257
+ justify-content: center;
1258
+ padding: 40rpx;
1259
+ background: rgba(15, 23, 42, 0.46);
1260
+ }
1261
+ .mfa-panel {
1262
+ display: flex;
1263
+ flex-direction: column;
1264
+ width: 100%;
1265
+ max-width: 620rpx;
1266
+ padding: 36rpx;
1267
+ background: #fff;
1268
+ border-radius: 24rpx;
1269
+ box-sizing: border-box;
1270
+ }
1271
+ .mfa-title {
1272
+ margin-bottom: 22rpx;
1273
+ font-size: 34rpx;
1274
+ font-weight: 700;
1275
+ color: #172a35;
1276
+ }
1277
+
1278
+ .mfa-panel .login-input {
1279
+ width: 100%;
1280
+ height: 88rpx;
1281
+ margin-bottom: 20rpx;
1282
+ padding: 0 30rpx;
1283
+ color: #1f2937;
1284
+ font-size: 28rpx;
1285
+ background: #f9fbfa;
1286
+ border: 1px solid #e5e7eb;
1287
+ border-radius: 24rpx;
1288
+ box-sizing: border-box;
1289
+ }
1290
+
1291
+ .mfa-panel .login-button {
1292
+ display: flex;
1293
+ align-items: center;
1294
+ justify-content: center;
1295
+ width: 100%;
1296
+ height: 88rpx;
1297
+ margin-top: 12rpx;
1298
+ padding: 0;
1299
+ color: #fff;
1300
+ font-size: 30rpx;
1301
+ font-weight: 500;
1302
+ background: transparent;
1303
+ border: 0;
1304
+ border-radius: 24rpx;
1305
+ box-shadow: 0 18rpx 36rpx rgba(40, 187, 156, 0.24);
1306
+ }
1307
+
1308
+ .mfa-panel .login-button::after {
1309
+ border: 0;
1310
+ }
1311
+
1312
+ .mfa-panel .login-button-primary {
1313
+ background: linear-gradient(135deg, #34c8aa 0%, #28bb9c 100%);
1314
+ }
1029
1315
 
1030
1316
  .login-behavior-mask {
1031
1317
  position: fixed;
package/src/vite.ts CHANGED
@@ -5,7 +5,6 @@ import {
5
5
  readFileSync,
6
6
  readdirSync,
7
7
  rmdirSync,
8
- rmSync,
9
8
  statSync,
10
9
  unlinkSync,
11
10
  writeFileSync,
@@ -39,7 +38,6 @@ type BuildTransaction = {
39
38
  originalPages: string
40
39
  generatedFiles: string[]
41
40
  generatedStaticFiles: string[]
42
- generatedStatic?: boolean
43
41
  ownerPid?: number
44
42
  ready?: boolean
45
43
  }
@@ -368,13 +366,9 @@ function restoreBuildTransaction(transaction: BuildTransaction, transactionFile:
368
366
  removeEmptyParents(dirname(file), transaction.inputDir)
369
367
  }
370
368
  removeGeneratedStaticFiles(
371
- transaction.generatedStaticFiles ?? [],
369
+ transaction.generatedStaticFiles,
372
370
  resolve(transaction.inputDir, 'static'),
373
371
  )
374
- // 兼容旧事务:旧格式仅在 static 原本不存在时记录整个目录由插件生成。
375
- if (transaction.generatedStatic && transaction.generatedStaticFiles === undefined) {
376
- rmSync(resolve(transaction.inputDir, 'static'), { recursive: true, force: true })
377
- }
378
372
  writeFileSync(transaction.pagesFile, transaction.originalPages)
379
373
  if (existsSync(transactionFile)) unlinkSync(transactionFile)
380
374
  }
@@ -600,10 +594,13 @@ function appendPage(manifest: PagesManifest, page: ScannedPage): void {
600
594
  function createPageWrapper(page: ScannedPage): string {
601
595
  const source = page.source.replace(/\\/g, '/')
602
596
  return `<script setup lang="ts">
597
+ import { onShow } from '@dcloudio/uni-app'
598
+ import { setAppPageTitle } from '@liujitcn/kratos-uni-app-core'
603
599
  import KratosPage from ${JSON.stringify(source)}
604
600
  import KratosTabBar from '@liujitcn/kratos-uni-app-core/components/KratosTabBar.vue'
605
601
 
606
602
  defineOptions({ inheritAttrs: false })
603
+ onShow(() => setAppPageTitle(${JSON.stringify(page.route)}))
607
604
  </script>
608
605
 
609
606
  <template>
@@ -1,24 +0,0 @@
1
- import { http } from '../../utils/http'
2
- import type { BaseDictForm, BaseDictService } from '../../rpc/system/app/v1/base_dict'
3
-
4
- const BASE_DICT_URL = '/v1/app/base/dict'
5
-
6
- /** 字典查询请求兼容结构,支持 code 和旧版 value。 */
7
- type GetBaseDictRequestCompat = {
8
- code?: string
9
- value?: string
10
- }
11
-
12
- /** 字典服务 */
13
- export class BaseDictServiceImpl implements BaseDictService {
14
- /** 查询单个字典 */
15
- GetBaseDict(request: GetBaseDictRequestCompat): Promise<BaseDictForm> {
16
- const code = request.code ?? request.value ?? ''
17
- return http<BaseDictForm>({
18
- url: `${BASE_DICT_URL}/${code}`,
19
- method: 'GET',
20
- })
21
- }
22
- }
23
-
24
- export const defBaseDictService = new BaseDictServiceImpl()