@liujitcn/kratos-admin-core 0.0.29 → 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.
- package/README.md +7 -6
- package/build/auto-imports.json +73 -0
- package/build/plugins.ts +2 -58
- package/dist/package/declarations/src/api/base/mfa.d.ts +26 -0
- package/dist/package/declarations/src/components/Dialog/FormDialog.vue.d.ts +3 -3
- package/dist/package/declarations/src/components/Dialog/PasswordVerifyDialog.vue.d.ts +65 -0
- package/dist/package/declarations/src/components/Dialog/ProDialog.vue.d.ts +1 -1
- package/dist/package/declarations/src/components/Grid/components/GridItem.vue.d.ts +1 -1
- package/dist/package/declarations/src/components/Mfa/MfaRecoveryCodesDialog.vue.d.ts +19 -0
- package/dist/package/declarations/src/components/Mfa/MfaSetupPanel.vue.d.ts +8 -0
- package/dist/package/declarations/src/components/ProForm/components/ProFormItem.vue.d.ts +4 -4
- package/dist/package/declarations/src/components/ProForm/index.vue.d.ts +1 -1
- package/dist/package/declarations/src/components/ProForm/interface/index.d.ts +4 -1
- package/dist/package/declarations/src/components/RichTextPreview/index.vue.d.ts +8 -0
- package/dist/package/declarations/src/components/Upload/Img.vue.d.ts +1 -1
- package/dist/package/declarations/src/components/Upload/Imgs.vue.d.ts +1 -1
- package/dist/package/declarations/src/layouts/components/LockScreen.vue.d.ts +3 -0
- package/dist/package/declarations/src/locales/generated.d.ts +168 -0
- package/dist/package/declarations/src/locales/index.d.ts +2 -0
- package/dist/package/declarations/src/rpc/base/v1/login.d.ts +33 -3
- package/dist/package/declarations/src/rpc/base/v1/mfa.d.ts +152 -0
- package/dist/package/declarations/src/rpc/base/v1/oauth.d.ts +25 -0
- package/dist/package/declarations/src/rpc/base/v1/oauth_client.d.ts +23 -0
- package/dist/package/declarations/src/rpc/system/admin/v1/base_i18n.d.ts +10 -6
- package/dist/package/declarations/src/security.d.ts +2 -0
- package/dist/package/declarations/src/stores/interface/index.d.ts +13 -0
- package/dist/package/declarations/src/stores/modules/lockScreen.d.ts +20 -0
- package/dist/package/declarations/src/stores/modules/user.d.ts +5 -2
- package/dist/package/declarations/src/stores/runtime.d.ts +1 -0
- package/dist/package/declarations/src/utils/clipboard.d.ts +2 -0
- package/dist/package/declarations/src/utils/webauthn.d.ts +4 -0
- package/dist/package/declarations/vite.config.d.ts +2 -0
- package/dist/package/src/api/base/mfa.ts +114 -0
- package/dist/package/src/components/Dialog/PasswordVerifyDialog.vue +176 -0
- package/dist/package/src/components/Loading/fullScreen.ts +2 -1
- package/dist/package/src/components/MarkdownPreview/index.vue +21 -1
- package/dist/package/src/components/Mfa/MfaRecoveryCodesDialog.vue +91 -0
- package/dist/package/src/components/Mfa/MfaSetupPanel.vue +81 -0
- package/dist/package/src/components/ProForm/components/ProFormItem.vue +16 -1
- package/dist/package/src/components/ProForm/interface/index.ts +4 -0
- package/dist/package/src/components/ProTable/components/ColSetting.vue +1 -1
- package/dist/package/src/components/ProTable/index.vue +6 -2
- package/dist/package/src/components/RichTextPreview/index.vue +45 -0
- package/dist/package/src/config/index.ts +1 -1
- package/dist/package/src/layouts/LayoutClassic/index.vue +3 -1
- package/dist/package/src/layouts/LayoutColumns/index.vue +3 -1
- package/dist/package/src/layouts/LayoutTransverse/index.vue +3 -1
- package/dist/package/src/layouts/LayoutVertical/index.vue +9 -1
- package/dist/package/src/layouts/components/Header/components/Avatar.vue +23 -4
- package/dist/package/src/layouts/components/Header/components/InfoDialog.vue +1 -1
- package/dist/package/src/layouts/components/Header/components/PasswordDialog.vue +1 -1
- package/dist/package/src/layouts/components/LockScreen.vue +450 -0
- package/dist/package/src/layouts/index.vue +2 -0
- package/dist/package/src/layouts/indexAsync.vue +2 -0
- package/dist/package/src/locales/README.md +2 -1
- package/dist/package/src/locales/en-US.json +43 -1
- package/dist/package/src/locales/index.ts +16 -12
- package/dist/package/src/locales/ja-JP.json +43 -1
- package/dist/package/src/locales/zh-CN.json +43 -1
- package/dist/package/src/locales/zh-TW.json +43 -1
- package/dist/package/src/rpc/base/v1/login.ts +33 -2
- package/dist/package/src/rpc/base/v1/mfa.ts +180 -0
- package/dist/package/src/rpc/base/v1/oauth.ts +25 -0
- package/dist/package/src/rpc/base/v1/oauth_client.ts +33 -0
- package/dist/package/src/rpc/system/admin/v1/base_i18n.ts +10 -6
- package/dist/package/src/security.ts +2 -0
- package/dist/package/src/stores/interface/index.ts +14 -0
- package/dist/package/src/stores/modules/lockScreen.ts +78 -0
- package/dist/package/src/stores/modules/user.ts +16 -3
- package/dist/package/src/stores/runtime.ts +1 -0
- package/dist/package/src/utils/clipboard.ts +22 -0
- package/dist/package/src/utils/request.ts +26 -10
- package/dist/package/src/utils/webauthn.ts +90 -0
- package/dist/package/src/views/login/components/LoginForm.vue +237 -16
- package/dist/package/src/views/login/index.vue +3 -1
- package/dist/package/types/generated/auto-imports.d.ts +132 -129
- package/dist/package/types/generated/components.d.ts +4 -72
- package/package.json +22 -1
- package/types/generated/auto-imports.d.ts +132 -129
- package/types/generated/components.d.ts +4 -72
- package/vite.config.ts +3 -1
|
@@ -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
|
|
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 || "
|
|
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
|
+
}
|
|
@@ -59,15 +59,18 @@
|
|
|
59
59
|
</el-form-item>
|
|
60
60
|
</el-form>
|
|
61
61
|
<div class="login-btn">
|
|
62
|
-
<el-button
|
|
62
|
+
<el-button round size="large" @click="resetForm(loginFormRef)">
|
|
63
|
+
<el-icon><CircleClose /></el-icon>
|
|
64
|
+
{{ t("common.action.reset") }}
|
|
65
|
+
</el-button>
|
|
63
66
|
<el-button
|
|
64
|
-
:icon="UserFilled"
|
|
65
67
|
round
|
|
66
68
|
size="large"
|
|
67
69
|
type="primary"
|
|
68
70
|
:loading="loading || oauthTicketLoading"
|
|
69
71
|
@click="handleLogin(loginFormRef)"
|
|
70
72
|
>
|
|
73
|
+
<el-icon><UserFilled /></el-icon>
|
|
71
74
|
{{ t("common.action.login") }}
|
|
72
75
|
</el-button>
|
|
73
76
|
</div>
|
|
@@ -128,6 +131,48 @@
|
|
|
128
131
|
/>
|
|
129
132
|
</div>
|
|
130
133
|
</el-dialog>
|
|
134
|
+
<ProDialog v-model="mfaDialogVisible" :title="t('core.login.mfa_title')" width="360px" :close-on-click-modal="false">
|
|
135
|
+
<ProForm
|
|
136
|
+
ref="mfaLoginFormRef"
|
|
137
|
+
:model="mfaLoginForm"
|
|
138
|
+
:fields="mfaLoginFormFields"
|
|
139
|
+
:rules="mfaLoginFormRules"
|
|
140
|
+
size="default"
|
|
141
|
+
label-width="auto"
|
|
142
|
+
@keyup.enter.prevent="verifyMfaLogin"
|
|
143
|
+
/>
|
|
144
|
+
<template #footer>
|
|
145
|
+
<el-button @click="mfaDialogVisible = false">{{ t("common.action.cancel") }}</el-button>
|
|
146
|
+
<el-button type="primary" :loading="mfaLoading" @click="verifyMfaLogin">
|
|
147
|
+
{{ mfaMethod === "webauthn" ? t("core.login.mfa_webauthn_action") : t("common.action.confirm") }}
|
|
148
|
+
</el-button>
|
|
149
|
+
</template>
|
|
150
|
+
</ProDialog>
|
|
151
|
+
<ProDialog v-model="mfaSetupDialogVisible" :title="t('core.login.mfa_setup_title')" width="520px" :close-on-click-modal="false">
|
|
152
|
+
<template v-if="mfaSetupMethod !== 'webauthn'">
|
|
153
|
+
<MfaSetupPanel :uri="mfaSetupUri" />
|
|
154
|
+
<ProForm
|
|
155
|
+
ref="mfaSetupFormRef"
|
|
156
|
+
:model="mfaSetupForm"
|
|
157
|
+
:fields="mfaSetupFormFields"
|
|
158
|
+
:rules="mfaSetupFormRules"
|
|
159
|
+
size="default"
|
|
160
|
+
label-width="auto"
|
|
161
|
+
@keyup.enter.prevent="confirmMfaSetup"
|
|
162
|
+
/>
|
|
163
|
+
</template>
|
|
164
|
+
<template #footer>
|
|
165
|
+
<el-button :disabled="mfaLoading" @click="mfaSetupDialogVisible = false">{{ t("common.action.cancel") }}</el-button>
|
|
166
|
+
<el-button type="primary" :loading="mfaLoading" @click="confirmMfaSetup">
|
|
167
|
+
{{ mfaSetupMethod === "webauthn" ? t("core.login.mfa_webauthn_action") : t("common.action.confirm") }}
|
|
168
|
+
</el-button>
|
|
169
|
+
</template>
|
|
170
|
+
</ProDialog>
|
|
171
|
+
<MfaRecoveryCodesDialog
|
|
172
|
+
v-model="recoveryCodesDialogVisible"
|
|
173
|
+
:codes="recoveryCodes"
|
|
174
|
+
@confirm="finishMfaEnrollment"
|
|
175
|
+
/>
|
|
131
176
|
</template>
|
|
132
177
|
|
|
133
178
|
<script setup lang="ts">
|
|
@@ -136,9 +181,15 @@ import { useRoute, useRouter } from "vue-router";
|
|
|
136
181
|
import { HOME_URL } from "../../../config";
|
|
137
182
|
import { getTimeState } from "../../../utils";
|
|
138
183
|
import { defLoginService } from "../../../api/base/login";
|
|
184
|
+
import { defMfaService } from "../../../api/base/mfa";
|
|
139
185
|
import { defOauthService } from "../../../api/base/oauth";
|
|
140
|
-
import {
|
|
141
|
-
import
|
|
186
|
+
import { createWebAuthnCredential, getWebAuthnAssertion } from "../../../security";
|
|
187
|
+
import ProDialog from "../../../components/Dialog/ProDialog.vue";
|
|
188
|
+
import ProForm from "../../../components/ProForm/index.vue";
|
|
189
|
+
import MfaSetupPanel from "../../../components/Mfa/MfaSetupPanel.vue";
|
|
190
|
+
import MfaRecoveryCodesDialog from "../../../components/Mfa/MfaRecoveryCodesDialog.vue";
|
|
191
|
+
import type { ProFormField, ProFormInstance } from "../../../components/ProForm/interface";
|
|
192
|
+
import { LoginStatus, type LoginRequest, type LoginResponse } from "../../../rpc/base/v1/login";
|
|
142
193
|
import type { OauthProvider } from "../../../rpc/base/v1/oauth";
|
|
143
194
|
import { getOauthProviderIcon, withOauthProviderDisplay, type OauthProviderDisplay } from "../../../utils/oauthProvider";
|
|
144
195
|
import { useUserStore } from "../../../stores/modules/user";
|
|
@@ -147,8 +198,7 @@ import { useTabsStore } from "../../../stores/modules/tabs";
|
|
|
147
198
|
import { useKeepAliveStore } from "../../../stores/modules/keepAlive";
|
|
148
199
|
import { initDynamicRouter } from "../../../routers/modules/dynamicRouter";
|
|
149
200
|
import { isUnmatchedRoute, navigateTo, resolveFrontendRouteURL } from "../../../utils/router";
|
|
150
|
-
import {
|
|
151
|
-
import type { ElForm } from "element-plus";
|
|
201
|
+
import type { ElForm, FormRules } from "element-plus";
|
|
152
202
|
import { PASSWORD_CRYPTO_SCENE, encryptPassword } from "../../../utils/passwordCrypto";
|
|
153
203
|
import { useConfigStore } from "../../../stores/modules/config";
|
|
154
204
|
import { Click as GoCaptchaClick, Rotate as GoCaptchaRotate, Slide as GoCaptchaSlide } from "go-captcha-vue";
|
|
@@ -182,6 +232,84 @@ const loginRules = computed(() => ({
|
|
|
182
232
|
const loading = ref(false);
|
|
183
233
|
const oauthLoadingProvider = ref("");
|
|
184
234
|
const oauthTicketLoading = ref(false);
|
|
235
|
+
const mfaDialogVisible = ref(false);
|
|
236
|
+
const mfaLoading = ref(false);
|
|
237
|
+
const mfaChallengeId = ref("");
|
|
238
|
+
const mfaLoginFormRef = ref<ProFormInstance>();
|
|
239
|
+
const mfaLoginForm = reactive({ code: "", recoveryCode: "" });
|
|
240
|
+
const mfaMethod = ref("totp");
|
|
241
|
+
const mfaWebAuthnOptionsJson = ref("");
|
|
242
|
+
const mfaSetupDialogVisible = ref(false);
|
|
243
|
+
const mfaSetupTicket = ref("");
|
|
244
|
+
const mfaSetupUri = ref("");
|
|
245
|
+
const mfaSetupFormRef = ref<ProFormInstance>();
|
|
246
|
+
const mfaSetupForm = reactive({ code: "" });
|
|
247
|
+
const recoveryCodesDialogVisible = ref(false);
|
|
248
|
+
const recoveryCodes = ref<string[]>([]);
|
|
249
|
+
const mfaSetupMethod = ref("totp");
|
|
250
|
+
const mfaSetupWebAuthnOptionsJson = ref("");
|
|
251
|
+
|
|
252
|
+
/** 登录阶段 MFA 输入字段,验证码和恢复码至少填写其一。 */
|
|
253
|
+
const mfaLoginFormFields = computed<ProFormField[]>(() => {
|
|
254
|
+
const recoveryCodeField: ProFormField = {
|
|
255
|
+
prop: "recoveryCode",
|
|
256
|
+
label: t("core.login.mfa_recovery_code"),
|
|
257
|
+
component: "input",
|
|
258
|
+
props: {
|
|
259
|
+
autocomplete: "one-time-code",
|
|
260
|
+
placeholder: t("core.login.mfa_recovery_code")
|
|
261
|
+
}
|
|
262
|
+
};
|
|
263
|
+
if (mfaMethod.value === "webauthn") return [recoveryCodeField];
|
|
264
|
+
return [
|
|
265
|
+
{
|
|
266
|
+
prop: "code",
|
|
267
|
+
label: t("core.login.mfa_code"),
|
|
268
|
+
component: "input",
|
|
269
|
+
props: {
|
|
270
|
+
autocomplete: "one-time-code",
|
|
271
|
+
inputmode: "numeric",
|
|
272
|
+
maxlength: 8,
|
|
273
|
+
placeholder: t("core.login.mfa_code")
|
|
274
|
+
}
|
|
275
|
+
},
|
|
276
|
+
recoveryCodeField
|
|
277
|
+
];
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
const mfaLoginFormRules = computed<FormRules>(() => {
|
|
281
|
+
if (mfaMethod.value === "webauthn") return {};
|
|
282
|
+
const factorRule = {
|
|
283
|
+
validator: (_rule: unknown, value: string, callback: (error?: Error) => void) => {
|
|
284
|
+
if (value || mfaLoginForm.code || mfaLoginForm.recoveryCode) {
|
|
285
|
+
callback();
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
callback(new Error(t("core.login.mfa_code")));
|
|
289
|
+
},
|
|
290
|
+
trigger: "blur"
|
|
291
|
+
};
|
|
292
|
+
return { code: [factorRule], recoveryCode: [factorRule] };
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
/** 强制绑定 MFA 的 TOTP 输入字段。 */
|
|
296
|
+
const mfaSetupFormFields = computed<ProFormField[]>(() => [
|
|
297
|
+
{
|
|
298
|
+
prop: "code",
|
|
299
|
+
label: t("core.login.mfa_code"),
|
|
300
|
+
component: "input",
|
|
301
|
+
props: {
|
|
302
|
+
autocomplete: "one-time-code",
|
|
303
|
+
inputmode: "numeric",
|
|
304
|
+
maxlength: 8,
|
|
305
|
+
placeholder: t("core.login.mfa_code")
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
]);
|
|
309
|
+
|
|
310
|
+
const mfaSetupFormRules = computed<FormRules>(() => ({
|
|
311
|
+
code: [{ required: true, message: t("core.login.mfa_code"), trigger: "blur" }]
|
|
312
|
+
}));
|
|
185
313
|
|
|
186
314
|
/** 登录页三方登录展示项。 */
|
|
187
315
|
type LoginOauthProvider = OauthProvider & OauthProviderDisplay;
|
|
@@ -412,12 +540,14 @@ const handleOauthLogin = async (provider: LoginOauthProvider) => {
|
|
|
412
540
|
};
|
|
413
541
|
|
|
414
542
|
/** 完成登录后的用户信息、字典与动态路由初始化。 */
|
|
415
|
-
const finishLogin = async () => {
|
|
543
|
+
const finishLogin = async (mustChangePassword = false) => {
|
|
416
544
|
// 1.获取用户信息
|
|
417
545
|
await userStore.getUserInfo();
|
|
418
546
|
|
|
419
|
-
|
|
420
|
-
|
|
547
|
+
if (!mustChangePassword) {
|
|
548
|
+
// 2.预加载字典缓存,避免页面首次渲染时字典值为空
|
|
549
|
+
await dictStore.loadDictionaries();
|
|
550
|
+
}
|
|
421
551
|
|
|
422
552
|
// 3.添加动态路由
|
|
423
553
|
await initDynamicRouter();
|
|
@@ -428,7 +558,11 @@ const finishLogin = async () => {
|
|
|
428
558
|
|
|
429
559
|
// 5.优先跳回登录失效前页面,没有记录时再进入首个可访问页面。
|
|
430
560
|
// 统一走动态路由感知跳转,避免首次登录后目标页面尚未完成挂载时直接进入 404。
|
|
431
|
-
|
|
561
|
+
if (mustChangePassword) {
|
|
562
|
+
await navigateTo(router, "/profile", { tab: "password" });
|
|
563
|
+
} else {
|
|
564
|
+
await navigateTo(router, getLoginRedirectPath());
|
|
565
|
+
}
|
|
432
566
|
ElNotification({
|
|
433
567
|
title: getTimeState(),
|
|
434
568
|
message: t("core.login.welcome"),
|
|
@@ -437,6 +571,95 @@ const finishLogin = async () => {
|
|
|
437
571
|
});
|
|
438
572
|
};
|
|
439
573
|
|
|
574
|
+
/** 处理密码或 OAuth 返回的 MFA 登录状态。 */
|
|
575
|
+
const handleLoginResponse = async (result: LoginResponse) => {
|
|
576
|
+
if (result.status === LoginStatus.LOGIN_STATUS_MFA_REQUIRED) {
|
|
577
|
+
mfaChallengeId.value = result.mfa_challenge_id;
|
|
578
|
+
mfaMethod.value = result.mfa_method || "totp";
|
|
579
|
+
mfaWebAuthnOptionsJson.value = result.mfa_webauthn_options_json || "";
|
|
580
|
+
mfaLoginForm.code = "";
|
|
581
|
+
mfaLoginForm.recoveryCode = "";
|
|
582
|
+
mfaDialogVisible.value = true;
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
if (result.status === LoginStatus.LOGIN_STATUS_MFA_ENROLLMENT_REQUIRED) {
|
|
586
|
+
mfaSetupTicket.value = result.mfa_setup_ticket;
|
|
587
|
+
mfaSetupMethod.value = result.mfa_method || "totp";
|
|
588
|
+
await beginMfaSetup(result.mfa_setup_ticket);
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
userStore.updateTokenAuth(result.access_token, result.refresh_token ?? "", result.token_type ?? "", result.expires_in);
|
|
592
|
+
await finishLogin(result.status === LoginStatus.LOGIN_STATUS_PASSWORD_CHANGE_REQUIRED);
|
|
593
|
+
};
|
|
594
|
+
|
|
595
|
+
/** 校验登录阶段 MFA 并完成登录。 */
|
|
596
|
+
const verifyMfaLogin = async () => {
|
|
597
|
+
if (mfaLoading.value || !mfaChallengeId.value) return;
|
|
598
|
+
if (mfaMethod.value !== "webauthn" && !(await mfaLoginFormRef.value?.validate())) return;
|
|
599
|
+
mfaLoading.value = true;
|
|
600
|
+
try {
|
|
601
|
+
const useRecoveryCode = Boolean(mfaLoginForm.recoveryCode);
|
|
602
|
+
const webauthnResponseJson =
|
|
603
|
+
mfaMethod.value === "webauthn" && !useRecoveryCode ? await getWebAuthnAssertion(mfaWebAuthnOptionsJson.value) : "";
|
|
604
|
+
if (mfaMethod.value !== "webauthn" && !mfaLoginForm.code && !mfaLoginForm.recoveryCode) return;
|
|
605
|
+
const result = await defMfaService.VerifyMfa({
|
|
606
|
+
challenge_id: mfaChallengeId.value,
|
|
607
|
+
code: useRecoveryCode ? "" : mfaLoginForm.code,
|
|
608
|
+
recovery_code: mfaLoginForm.recoveryCode,
|
|
609
|
+
webauthn_response_json: webauthnResponseJson
|
|
610
|
+
});
|
|
611
|
+
mfaDialogVisible.value = false;
|
|
612
|
+
await handleLoginResponse(result);
|
|
613
|
+
} finally {
|
|
614
|
+
mfaLoading.value = false;
|
|
615
|
+
}
|
|
616
|
+
};
|
|
617
|
+
|
|
618
|
+
/** 开始强制绑定 MFA。 */
|
|
619
|
+
const beginMfaSetup = async (setupTicket: string) => {
|
|
620
|
+
mfaLoading.value = true;
|
|
621
|
+
try {
|
|
622
|
+
const result = await defMfaService.BeginMfaEnrollment({ setup_ticket: setupTicket });
|
|
623
|
+
mfaSetupTicket.value = result.setup_ticket;
|
|
624
|
+
mfaSetupUri.value = result.otpauth_uri;
|
|
625
|
+
mfaSetupMethod.value = result.method || "totp";
|
|
626
|
+
mfaSetupWebAuthnOptionsJson.value = result.webauthn_options_json || "";
|
|
627
|
+
mfaSetupForm.code = "";
|
|
628
|
+
mfaSetupDialogVisible.value = true;
|
|
629
|
+
} finally {
|
|
630
|
+
mfaLoading.value = false;
|
|
631
|
+
}
|
|
632
|
+
};
|
|
633
|
+
|
|
634
|
+
/** 关闭恢复码弹窗并结束强制绑定登录流程。 */
|
|
635
|
+
const finishMfaEnrollment = async () => {
|
|
636
|
+
if (!recoveryCodes.value.length) return;
|
|
637
|
+
recoveryCodes.value = [];
|
|
638
|
+
ElMessage.success(t("core.login.mfa_setup_success"));
|
|
639
|
+
await loadPageCaptcha();
|
|
640
|
+
};
|
|
641
|
+
|
|
642
|
+
/** 确认强制绑定 MFA 并要求用户重新登录。 */
|
|
643
|
+
const confirmMfaSetup = async () => {
|
|
644
|
+
if (mfaLoading.value || !mfaSetupTicket.value) return;
|
|
645
|
+
if (mfaSetupMethod.value !== "webauthn" && !(await mfaSetupFormRef.value?.validate())) return;
|
|
646
|
+
mfaLoading.value = true;
|
|
647
|
+
try {
|
|
648
|
+
const webauthnResponseJson =
|
|
649
|
+
mfaSetupMethod.value === "webauthn" ? await createWebAuthnCredential(mfaSetupWebAuthnOptionsJson.value) : "";
|
|
650
|
+
const result = await defMfaService.ConfirmMfaEnrollment({
|
|
651
|
+
setup_ticket: mfaSetupTicket.value,
|
|
652
|
+
code: mfaSetupForm.code,
|
|
653
|
+
webauthn_response_json: webauthnResponseJson
|
|
654
|
+
});
|
|
655
|
+
mfaSetupDialogVisible.value = false;
|
|
656
|
+
recoveryCodes.value = result.recovery_codes;
|
|
657
|
+
recoveryCodesDialogVisible.value = true;
|
|
658
|
+
} finally {
|
|
659
|
+
mfaLoading.value = false;
|
|
660
|
+
}
|
|
661
|
+
};
|
|
662
|
+
|
|
440
663
|
/** 消费 OAuth 回调携带的一次性票据。 */
|
|
441
664
|
const consumeOauthTicket = async () => {
|
|
442
665
|
const oauthError = route.query.oauth_error;
|
|
@@ -452,8 +675,7 @@ const consumeOauthTicket = async () => {
|
|
|
452
675
|
oauthTicketLoading.value = true;
|
|
453
676
|
try {
|
|
454
677
|
const result = await defOauthService.ExchangeOauthTicket({ ticket });
|
|
455
|
-
|
|
456
|
-
await finishLogin();
|
|
678
|
+
await handleLoginResponse(result);
|
|
457
679
|
} finally {
|
|
458
680
|
oauthTicketLoading.value = false;
|
|
459
681
|
}
|
|
@@ -537,10 +759,9 @@ const submitLogin = async (captchaToken: string) => {
|
|
|
537
759
|
captcha_code: captchaToken,
|
|
538
760
|
captcha_id: loginForm.captcha_id
|
|
539
761
|
};
|
|
540
|
-
// 1
|
|
541
|
-
await userStore.login(loginRequest);
|
|
542
|
-
|
|
543
|
-
await finishLogin();
|
|
762
|
+
// 1.执行登录接口,挑战态不保存令牌,等待 MFA 完成。
|
|
763
|
+
const result = await userStore.login(loginRequest);
|
|
764
|
+
await handleLoginResponse(result);
|
|
544
765
|
} catch (_error) {
|
|
545
766
|
await loadPageCaptcha();
|
|
546
767
|
} finally {
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
<SwitchDark class="dark" />
|
|
5
5
|
<LocaleSwitch class="locale" />
|
|
6
6
|
<div class="login-left">
|
|
7
|
-
<img class="login-left-img" :src="backgroundUrl" alt="login" />
|
|
7
|
+
<img class="login-left-img" :src="backgroundUrl" :alt="t('core.login.background_alt')" />
|
|
8
8
|
</div>
|
|
9
9
|
<div class="login-form">
|
|
10
10
|
<div ref="loginLogoRef" class="login-logo">
|
|
@@ -23,8 +23,10 @@ import LoginForm from "./components/LoginForm.vue";
|
|
|
23
23
|
import SwitchDark from "../../components/SwitchDark/index.vue";
|
|
24
24
|
import LocaleSwitch from "../../components/LocaleSwitch/index.vue";
|
|
25
25
|
import { useConfigStore } from "../../stores/modules/config";
|
|
26
|
+
import { useLocaleStore } from "../../locales";
|
|
26
27
|
|
|
27
28
|
const configStore = useConfigStore();
|
|
29
|
+
const { t } = useLocaleStore();
|
|
28
30
|
const projectName = computed(() => configStore.display.sysName || import.meta.env.VITE_GLOB_APP_TITLE);
|
|
29
31
|
const logoUrl = computed(() => configStore.display.adminLogo);
|
|
30
32
|
const backgroundUrl = computed(() => configStore.display.background);
|