@liujitcn/kratos-admin-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.
Files changed (84) hide show
  1. package/README.md +7 -6
  2. package/build/auto-imports.json +73 -0
  3. package/build/plugins.ts +2 -58
  4. package/dist/package/declarations/src/api/base/mfa.d.ts +26 -0
  5. package/dist/package/declarations/src/components/Dialog/FormDialog.vue.d.ts +3 -3
  6. package/dist/package/declarations/src/components/Dialog/PasswordVerifyDialog.vue.d.ts +65 -0
  7. package/dist/package/declarations/src/components/Dialog/ProDialog.vue.d.ts +1 -1
  8. package/dist/package/declarations/src/components/Grid/components/GridItem.vue.d.ts +1 -1
  9. package/dist/package/declarations/src/components/Mfa/MfaRecoveryCodesDialog.vue.d.ts +19 -0
  10. package/dist/package/declarations/src/components/Mfa/MfaSetupPanel.vue.d.ts +8 -0
  11. package/dist/package/declarations/src/components/ProForm/components/ProFormItem.vue.d.ts +4 -4
  12. package/dist/package/declarations/src/components/ProForm/index.vue.d.ts +1 -1
  13. package/dist/package/declarations/src/components/ProForm/interface/index.d.ts +4 -1
  14. package/dist/package/declarations/src/components/RichTextPreview/index.vue.d.ts +8 -0
  15. package/dist/package/declarations/src/components/Upload/Img.vue.d.ts +1 -1
  16. package/dist/package/declarations/src/components/Upload/Imgs.vue.d.ts +1 -1
  17. package/dist/package/declarations/src/layouts/components/LockScreen.vue.d.ts +3 -0
  18. package/dist/package/declarations/src/locales/generated.d.ts +168 -0
  19. package/dist/package/declarations/src/locales/index.d.ts +2 -0
  20. package/dist/package/declarations/src/rpc/base/v1/login.d.ts +33 -3
  21. package/dist/package/declarations/src/rpc/base/v1/mfa.d.ts +152 -0
  22. package/dist/package/declarations/src/rpc/base/v1/oauth.d.ts +25 -0
  23. package/dist/package/declarations/src/rpc/base/v1/oauth_client.d.ts +23 -0
  24. package/dist/package/declarations/src/rpc/system/admin/v1/base_dict.d.ts +4 -4
  25. package/dist/package/declarations/src/rpc/system/admin/v1/base_i18n.d.ts +27 -23
  26. package/dist/package/declarations/src/security.d.ts +2 -0
  27. package/dist/package/declarations/src/stores/interface/index.d.ts +14 -1
  28. package/dist/package/declarations/src/stores/modules/lockScreen.d.ts +20 -0
  29. package/dist/package/declarations/src/stores/modules/user.d.ts +5 -2
  30. package/dist/package/declarations/src/stores/runtime.d.ts +1 -0
  31. package/dist/package/declarations/src/utils/clipboard.d.ts +2 -0
  32. package/dist/package/declarations/src/utils/webauthn.d.ts +4 -0
  33. package/dist/package/declarations/vite.config.d.ts +2 -0
  34. package/dist/package/src/api/base/mfa.ts +114 -0
  35. package/dist/package/src/components/Dialog/PasswordVerifyDialog.vue +176 -0
  36. package/dist/package/src/components/Loading/fullScreen.ts +2 -1
  37. package/dist/package/src/components/MarkdownPreview/index.vue +21 -1
  38. package/dist/package/src/components/Mfa/MfaRecoveryCodesDialog.vue +91 -0
  39. package/dist/package/src/components/Mfa/MfaSetupPanel.vue +81 -0
  40. package/dist/package/src/components/ProForm/components/ProFormItem.vue +16 -1
  41. package/dist/package/src/components/ProForm/interface/index.ts +4 -0
  42. package/dist/package/src/components/ProTable/components/ColSetting.vue +1 -1
  43. package/dist/package/src/components/ProTable/index.vue +6 -2
  44. package/dist/package/src/components/RichTextPreview/index.vue +45 -0
  45. package/dist/package/src/config/index.ts +1 -1
  46. package/dist/package/src/layouts/LayoutClassic/index.vue +3 -1
  47. package/dist/package/src/layouts/LayoutColumns/index.vue +3 -1
  48. package/dist/package/src/layouts/LayoutTransverse/index.vue +3 -1
  49. package/dist/package/src/layouts/LayoutVertical/index.vue +9 -1
  50. package/dist/package/src/layouts/components/Header/components/Avatar.vue +23 -4
  51. package/dist/package/src/layouts/components/Header/components/InfoDialog.vue +1 -1
  52. package/dist/package/src/layouts/components/Header/components/PasswordDialog.vue +1 -1
  53. package/dist/package/src/layouts/components/LockScreen.vue +450 -0
  54. package/dist/package/src/layouts/index.vue +2 -0
  55. package/dist/package/src/layouts/indexAsync.vue +2 -0
  56. package/dist/package/src/locales/README.md +2 -1
  57. package/dist/package/src/locales/en-US.json +43 -1
  58. package/dist/package/src/locales/index.ts +16 -12
  59. package/dist/package/src/locales/ja-JP.json +43 -1
  60. package/dist/package/src/locales/zh-CN.json +43 -1
  61. package/dist/package/src/locales/zh-TW.json +43 -1
  62. package/dist/package/src/rpc/base/v1/login.ts +33 -2
  63. package/dist/package/src/rpc/base/v1/mfa.ts +180 -0
  64. package/dist/package/src/rpc/base/v1/oauth.ts +25 -0
  65. package/dist/package/src/rpc/base/v1/oauth_client.ts +33 -0
  66. package/dist/package/src/rpc/system/admin/v1/base_dict.ts +4 -4
  67. package/dist/package/src/rpc/system/admin/v1/base_i18n.ts +27 -23
  68. package/dist/package/src/security.ts +2 -0
  69. package/dist/package/src/stores/interface/index.ts +15 -1
  70. package/dist/package/src/stores/modules/config.ts +4 -4
  71. package/dist/package/src/stores/modules/lockScreen.ts +78 -0
  72. package/dist/package/src/stores/modules/user.ts +16 -3
  73. package/dist/package/src/stores/runtime.ts +1 -0
  74. package/dist/package/src/utils/clipboard.ts +22 -0
  75. package/dist/package/src/utils/request.ts +26 -10
  76. package/dist/package/src/utils/webauthn.ts +90 -0
  77. package/dist/package/src/views/login/components/LoginForm.vue +237 -16
  78. package/dist/package/src/views/login/index.vue +3 -1
  79. package/dist/package/types/generated/auto-imports.d.ts +132 -129
  80. package/dist/package/types/generated/components.d.ts +4 -72
  81. package/package.json +22 -1
  82. package/types/generated/auto-imports.d.ts +132 -129
  83. package/types/generated/components.d.ts +4 -72
  84. package/vite.config.ts +3 -1
@@ -59,6 +59,20 @@ export interface UserState {
59
59
  userInfo: UserInfoForm;
60
60
  }
61
61
 
62
+ /** 当前管理端锁屏状态。 */
63
+ export interface LockScreenState {
64
+ /** 是否处于锁屏状态。 */
65
+ isLocked: boolean;
66
+ /** 当前锁屏密码的摘要,仅在锁屏期间持久化。 */
67
+ passwordHash: string;
68
+ /** 是否显示设置锁屏密码界面。 */
69
+ setupVisible: boolean;
70
+ /** 是否显示解锁输入界面。 */
71
+ unlockVisible: boolean;
72
+ /** 最近一次解锁是否失败。 */
73
+ unlockError: boolean;
74
+ }
75
+
62
76
  /** 标签页菜单项状态。 */
63
77
  export interface TabsMenuProps {
64
78
  icon: string;
@@ -100,5 +114,5 @@ export interface SiteConfigState {
100
114
  /** 当前登录验证码配置 */
101
115
  captcha: LoginCaptchaConfig;
102
116
  /** 是否允许显式生成机器翻译草稿。 */
103
- translationDraftEnabled: boolean;
117
+ i18nDraftEnabled: boolean;
104
118
  }
@@ -6,7 +6,7 @@ import defaultLogoUrl from "../../assets/images/logo.svg";
6
6
  import defaultBackgroundUrl from "../../assets/images/login_left.png";
7
7
 
8
8
  const CAPTCHA_TYPE_KEY = "captchaType";
9
- const TRANSLATION_DRAFT_ENABLED_KEY = "i18n.translation_draft.enabled";
9
+ const I18N_DRAFT_ENABLED_KEY = "i18n.draft.enabled";
10
10
 
11
11
  const DEFAULT_SITE_DISPLAY_CONFIG: SiteDisplayConfig = {
12
12
  sysName: "Admin",
@@ -78,7 +78,7 @@ export const useConfigStore = defineStore("admin-config", {
78
78
  state: (): SiteConfigState => ({
79
79
  display: { ...DEFAULT_SITE_DISPLAY_CONFIG },
80
80
  captcha: { ...DEFAULT_LOGIN_CAPTCHA_CONFIG },
81
- translationDraftEnabled: false
81
+ i18nDraftEnabled: false
82
82
  }),
83
83
  getters: {},
84
84
  actions: {
@@ -103,7 +103,7 @@ export const useConfigStore = defineStore("admin-config", {
103
103
  resetDisplayConfig() {
104
104
  this.display = { ...DEFAULT_SITE_DISPLAY_CONFIG };
105
105
  this.captcha = { ...DEFAULT_LOGIN_CAPTCHA_CONFIG };
106
- this.translationDraftEnabled = false;
106
+ this.i18nDraftEnabled = false;
107
107
  },
108
108
  /**
109
109
  * 加载管理端站点配置,并以服务端返回值覆盖本地默认值。
@@ -116,7 +116,7 @@ export const useConfigStore = defineStore("admin-config", {
116
116
 
117
117
  this.setDisplayConfig(normalizeSiteDisplayConfig(configMap));
118
118
  this.setLoginCaptchaConfig(normalizeLoginCaptchaConfig(configMap));
119
- this.translationDraftEnabled = configMap[TRANSLATION_DRAFT_ENABLED_KEY] === "true";
119
+ this.i18nDraftEnabled = configMap[I18N_DRAFT_ENABLED_KEY] === "true";
120
120
  return this.display;
121
121
  }
122
122
  }
@@ -0,0 +1,78 @@
1
+ import { defineStore } from "pinia";
2
+ import type { LockScreenState } from "../../stores/interface";
3
+ import piniaPersistConfig from "../../stores/helper/persist";
4
+
5
+ /** 将锁屏密码转换为不可逆摘要,避免在本地持久化明文密码。 */
6
+ async function hashPassword(password: string): Promise<string> {
7
+ const data = new TextEncoder().encode(password);
8
+ const digest = await crypto.subtle.digest("SHA-256", data);
9
+ return Array.from(new Uint8Array(digest), byte => byte.toString(16).padStart(2, "0")).join("");
10
+ }
11
+
12
+ /** 管理端锁屏状态仓库。 */
13
+ export const useLockScreenStore = defineStore("admin-lock-screen", {
14
+ state: (): LockScreenState => ({
15
+ isLocked: false,
16
+ passwordHash: "",
17
+ setupVisible: false,
18
+ unlockVisible: false,
19
+ unlockError: false
20
+ }),
21
+ getters: {
22
+ /** 当前是否已配置可用于解锁的密码。 */
23
+ isConfigured: state => Boolean(state.passwordHash)
24
+ },
25
+ actions: {
26
+ /** 打开锁屏密码设置界面。 */
27
+ openSetup() {
28
+ this.setupVisible = true;
29
+ this.unlockVisible = false;
30
+ this.unlockError = false;
31
+ },
32
+ /** 关闭锁屏密码设置界面。 */
33
+ closeSetup() {
34
+ this.setupVisible = false;
35
+ },
36
+ /** 设置锁屏密码并立即进入锁屏状态。 */
37
+ async lock(password: string) {
38
+ this.passwordHash = await hashPassword(password);
39
+ this.isLocked = true;
40
+ this.setupVisible = false;
41
+ this.unlockVisible = false;
42
+ this.unlockError = false;
43
+ },
44
+ /** 打开锁屏解锁输入界面。 */
45
+ openUnlock() {
46
+ this.unlockVisible = true;
47
+ this.unlockError = false;
48
+ },
49
+ /** 返回锁屏首页。 */
50
+ closeUnlock() {
51
+ this.unlockVisible = false;
52
+ this.unlockError = false;
53
+ },
54
+ /** 校验锁屏密码,成功后解除当前锁定。 */
55
+ async unlock(password: string): Promise<boolean> {
56
+ const passwordHash = await hashPassword(password);
57
+ if (!this.passwordHash || passwordHash !== this.passwordHash) {
58
+ this.unlockError = true;
59
+ return false;
60
+ }
61
+
62
+ this.isLocked = false;
63
+ this.passwordHash = "";
64
+ this.unlockVisible = false;
65
+ this.unlockError = false;
66
+ return true;
67
+ },
68
+ /** 清理锁屏状态,供退出登录时使用。 */
69
+ clearLock() {
70
+ this.isLocked = false;
71
+ this.passwordHash = "";
72
+ this.setupVisible = false;
73
+ this.unlockVisible = false;
74
+ this.unlockError = false;
75
+ }
76
+ },
77
+ persist: piniaPersistConfig("admin-lock-screen", ["isLocked", "passwordHash"])
78
+ });
@@ -1,11 +1,14 @@
1
1
  import { defineStore } from "pinia";
2
2
  import { defAuthService } from "../../api/system/auth";
3
3
  import { defLoginService } from "../../api/base/login";
4
- import type { LoginRequest } from "../../rpc/base/v1/login";
4
+ import { defMfaService } from "../../api/base/mfa";
5
+ import type { LoginRequest, LoginResponse } from "../../rpc/base/v1/login";
6
+ import type { VerifyMfaRequest } from "../../rpc/base/v1/mfa";
5
7
  import type { UserInfoForm } from "../../rpc/system/admin/v1/auth";
6
8
  import { UserState } from "../../stores/interface";
7
9
  import piniaPersistConfig from "../../stores/helper/persist";
8
10
  import { useDictStoreHook } from "../../stores/modules/dict";
11
+ import { useLockScreenStore } from "../../stores/modules/lockScreen";
9
12
  import { t } from "../../locales";
10
13
 
11
14
  const defaultUserInfo: UserInfoForm = {
@@ -65,9 +68,18 @@ export const useUserStore = defineStore("admin-user", {
65
68
  return Boolean(this.token.trim() && this.tokenExpiresAt > Date.now());
66
69
  },
67
70
  /** 登录 */
68
- async login(loginRequest: LoginRequest) {
71
+ async login(loginRequest: LoginRequest): Promise<LoginResponse> {
69
72
  const data = await defLoginService.Login(loginRequest);
73
+ if (data.status === 1 || data.status === 0 || data.status === 4) {
74
+ this.updateTokenAuth(data.access_token, data.refresh_token ?? "", data.token_type ?? "", data.expires_in);
75
+ }
76
+ return data;
77
+ },
78
+ /** 校验登录阶段的多因素认证并保存正式令牌。 */
79
+ async verifyMfa(request: VerifyMfaRequest): Promise<LoginResponse> {
80
+ const data = await defMfaService.VerifyMfa(request);
70
81
  this.updateTokenAuth(data.access_token, data.refresh_token ?? "", data.token_type ?? "", data.expires_in);
82
+ return data;
71
83
  },
72
84
  /** 刷新认证令牌 */
73
85
  async refreshAccessToken() {
@@ -89,6 +101,7 @@ export const useUserStore = defineStore("admin-user", {
89
101
  clearAuthData() {
90
102
  // 清理登录态时同步清空字典缓存,避免切换账号后读到旧字典。
91
103
  useDictStoreHook().clearDictionaryCache();
104
+ useLockScreenStore().clearLock();
92
105
  this.setToken("");
93
106
  this.setRefreshToken("");
94
107
  this.setTokenType("");
@@ -106,5 +119,5 @@ export const useUserStore = defineStore("admin-user", {
106
119
  }
107
120
  }
108
121
  },
109
- persist: piniaPersistConfig("admin-user")
122
+ persist: piniaPersistConfig("admin-user", ["token", "tokenType", "tokenExpiresAt", "userInfo"])
110
123
  });
@@ -1,5 +1,6 @@
1
1
  export { useAuthStore } from "./modules/auth";
2
2
  export { useConfigStore } from "./modules/config";
3
3
  export { useGlobalStore } from "./modules/global";
4
+ export { useLockScreenStore } from "./modules/lockScreen";
4
5
  export { useTabsStore } from "./modules/tabs";
5
6
  export { useUserStore } from "./modules/user";
@@ -0,0 +1,22 @@
1
+ /** 将文本复制到系统剪贴板,并兼容非安全上下文的管理端开发地址。 */
2
+ export async function copyText(content: string): Promise<void> {
3
+ if (typeof navigator !== "undefined" && navigator.clipboard && typeof window !== "undefined" && window.isSecureContext) {
4
+ try {
5
+ await navigator.clipboard.writeText(content);
6
+ return;
7
+ } catch {
8
+ // Clipboard API 受权限限制时继续尝试传统 DOM 方案。
9
+ }
10
+ }
11
+ if (typeof document === "undefined") throw new Error("copy failed");
12
+ const textarea = document.createElement("textarea");
13
+ textarea.value = content;
14
+ textarea.style.position = "fixed";
15
+ textarea.style.opacity = "0";
16
+ document.body.appendChild(textarea);
17
+ textarea.focus();
18
+ textarea.select();
19
+ const copied = document.execCommand("copy");
20
+ document.body.removeChild(textarea);
21
+ if (!copied) throw new Error("copy failed");
22
+ }
@@ -1,6 +1,5 @@
1
1
  import axios, { type InternalAxiosRequestConfig, type AxiosResponse, type AxiosError } from "axios";
2
2
  import qs from "qs";
3
- import { ElMessage, ElMessageBox } from "element-plus";
4
3
  import router from "../routers";
5
4
  import { LOGIN_URL } from "../config";
6
5
  import pinia from "../stores";
@@ -12,6 +11,7 @@ const apiTargetUrl = import.meta.env.VITE_API_URL || import.meta.env.VITE_APP_AP
12
11
  export const requestBaseURL = `${apiTargetUrl}${apiBasePath}`;
13
12
  const SESSION_URL = "/v1/base/session";
14
13
  const TOKEN_URL = "/v1/base/token";
14
+ const REFRESH_EXPIRY_COOKIE = "kratos_refresh_exp";
15
15
  const CAPTCHA_URL = "/v1/base/captcha";
16
16
  const CONFIG_URL = "/v1/base/config";
17
17
  const LANGUAGE_URL = "/v1/base/language";
@@ -19,6 +19,9 @@ const PASSWORD_PUBLIC_KEY_URL = "/v1/base/password-public-key";
19
19
  const OAUTH_PROVIDER_URL = "/v1/base/oauth/provider";
20
20
  const OAUTH_AUTHORIZATION_URL = "/v1/base/oauth/authorization";
21
21
  const OAUTH_TICKET_URL = "/v1/base/oauth/ticket";
22
+ const MFA_VERIFY_URL = "/v1/base/mfa/verify";
23
+ const MFA_ENROLLMENT_URL = "/v1/base/mfa/enrollment";
24
+ const MFA_ENROLLMENT_CONFIRM_URL = "/v1/base/mfa/enrollment/confirm";
22
25
  const LEGACY_AUTH_URL = "/auth";
23
26
  const LEGACY_REFRESH_TOKEN_URL = `${LEGACY_AUTH_URL}/token`;
24
27
  const LEGACY_CAPTCHA_URL = "/login/captcha";
@@ -33,6 +36,9 @@ const NO_AUTH_URL_SET = new Set([
33
36
  OAUTH_PROVIDER_URL,
34
37
  OAUTH_AUTHORIZATION_URL,
35
38
  OAUTH_TICKET_URL,
39
+ MFA_VERIFY_URL,
40
+ MFA_ENROLLMENT_URL,
41
+ MFA_ENROLLMENT_CONFIRM_URL,
36
42
  LEGACY_AUTH_URL,
37
43
  LEGACY_CAPTCHA_URL,
38
44
  LEGACY_REFRESH_TOKEN_URL
@@ -47,6 +53,9 @@ const AUTH_EXPIRED_EXCLUDED_URL_SET = new Set([
47
53
  OAUTH_PROVIDER_URL,
48
54
  OAUTH_AUTHORIZATION_URL,
49
55
  OAUTH_TICKET_URL,
56
+ MFA_VERIFY_URL,
57
+ MFA_ENROLLMENT_URL,
58
+ MFA_ENROLLMENT_CONFIRM_URL,
50
59
  LEGACY_AUTH_URL,
51
60
  LEGACY_REFRESH_TOKEN_URL,
52
61
  LEGACY_CAPTCHA_URL
@@ -70,7 +79,8 @@ const service = axios.create({
70
79
  const refreshService = axios.create({
71
80
  baseURL: requestBaseURL,
72
81
  timeout: 50000,
73
- headers: { "Content-Type": "application/json;charset=utf-8" }
82
+ headers: { "Content-Type": "application/json;charset=utf-8" },
83
+ withCredentials: true
74
84
  });
75
85
 
76
86
  /** 获取用户状态仓库 */
@@ -108,6 +118,15 @@ function getTokenExpiresAt() {
108
118
  return getUserStore().tokenExpiresAt;
109
119
  }
110
120
 
121
+ /** 判断浏览器是否存在非敏感的刷新令牌过期提示。 */
122
+ function hasRefreshCookieHint() {
123
+ if (typeof document === "undefined") return false;
124
+ const item = document.cookie.split(";").find(value => value.trim().startsWith(`${REFRESH_EXPIRY_COOKIE}=`));
125
+ if (!item) return false;
126
+ const expiresAt = Number(item.split("=")[1]);
127
+ return Number.isFinite(expiresAt) && expiresAt * 1000 > Date.now();
128
+ }
129
+
111
130
  /** 判断当前访问令牌是否仍在有效期内。 */
112
131
  export function hasValidAccessToken() {
113
132
  const userStore = getUserStore();
@@ -117,7 +136,7 @@ export function hasValidAccessToken() {
117
136
  /** 读取最新可用访问令牌,必要时先串行刷新,供 axios、fetch 与 SSE 共用。 */
118
137
  export async function getRequestAccessToken(): Promise<string> {
119
138
  const userStore = getUserStore();
120
- if (!userStore.token && userStore.refreshToken) {
139
+ if (!userStore.token && (userStore.refreshToken || hasRefreshCookieHint())) {
121
140
  await handleTokenRefresh(false);
122
141
  }
123
142
 
@@ -137,7 +156,7 @@ export async function ensureAccessToken() {
137
156
  }
138
157
 
139
158
  const userStore = getUserStore();
140
- if (!userStore.refreshToken) {
159
+ if (!userStore.refreshToken && !hasRefreshCookieHint()) {
141
160
  return false;
142
161
  }
143
162
 
@@ -213,7 +232,7 @@ service.interceptors.response.use(
213
232
  }
214
233
 
215
234
  ElMessage.error(message || t("common.message.system_error"));
216
- return Promise.reject(new Error(message || "Error"));
235
+ return Promise.reject(new Error(message || t("common.message.system_error")));
217
236
  },
218
237
  async (error: AxiosError) => {
219
238
  const status = error.response?.status;
@@ -224,7 +243,7 @@ service.interceptors.response.use(
224
243
 
225
244
  // 业务请求仅在 401 时尝试刷新并重放,403 直接展示后端权限错误。
226
245
  if ((status === 401 || code === 401) && !shouldSkipAuthExpiredPrompt(requestConfig)) {
227
- if (requestConfig && !requestConfig._authRetried && getUserStore().refreshToken) {
246
+ if (requestConfig && !requestConfig._authRetried && (getUserStore().refreshToken || hasRefreshCookieHint())) {
228
247
  requestConfig._authRetried = true;
229
248
  try {
230
249
  await handleTokenRefresh(false);
@@ -282,13 +301,10 @@ async function handleTokenRefresh(promptOnFailure = true) {
282
301
  /** 调用刷新令牌接口并回写最新认证信息 */
283
302
  async function refreshAccessToken() {
284
303
  const userStore = getUserStore();
285
- if (!userStore.refreshToken) {
286
- return Promise.reject(new Error(t("core.auth.refresh_token_missing")));
287
- }
288
304
 
289
305
  const response = await refreshService.post(
290
306
  TOKEN_URL,
291
- { refresh_token: userStore.refreshToken },
307
+ { refresh_token: userStore.refreshToken || undefined },
292
308
  { headers: { Authorization: "no-auth", ...getLocaleRequestHeaders() } }
293
309
  );
294
310
  const data = response.data;
@@ -0,0 +1,90 @@
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) payload.authenticatorAttachment = credential.authenticatorAttachment;
73
+ return JSON.stringify(payload);
74
+ }
75
+
76
+ /** 执行 WebAuthn 注册 ceremony。 */
77
+ export async function createWebAuthnCredential(optionsJson: string): Promise<string> {
78
+ if (!navigator.credentials?.create) throw new Error(t("core.login.mfa_webauthn_setup_unsupported"));
79
+ const credential = await navigator.credentials.create({ publicKey: parseCreationOptions(optionsJson) });
80
+ if (!(credential instanceof PublicKeyCredential)) throw new Error(t("core.login.mfa_webauthn_setup_invalid"));
81
+ return serializeCredential(credential);
82
+ }
83
+
84
+ /** 执行 WebAuthn 登录 ceremony。 */
85
+ export async function getWebAuthnAssertion(optionsJson: string): Promise<string> {
86
+ if (!navigator.credentials?.get) throw new Error(t("core.login.mfa_webauthn_login_unsupported"));
87
+ const credential = await navigator.credentials.get({ publicKey: parseRequestOptions(optionsJson) });
88
+ if (!(credential instanceof PublicKeyCredential)) throw new Error(t("core.login.mfa_webauthn_login_invalid"));
89
+ return serializeCredential(credential);
90
+ }