@liujitcn/kratos-admin-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.
- package/README.md +15 -8
- package/build/auto-imports.json +73 -0
- package/build/plugins.ts +2 -58
- package/dist/package/declarations/src/api/base/{config.d.ts → v1/config.d.ts} +1 -1
- package/dist/package/declarations/src/api/base/{file.d.ts → v1/file.d.ts} +3 -1
- package/dist/package/declarations/src/api/base/{language.d.ts → v1/language.d.ts} +1 -1
- package/dist/package/declarations/src/api/base/{login.d.ts → v1/login.d.ts} +2 -2
- package/dist/package/declarations/src/api/base/v1/mfa.d.ts +26 -0
- package/dist/package/declarations/src/api/base/{oauth.d.ts → v1/oauth.d.ts} +1 -1
- package/dist/package/declarations/src/api/system/{auth.d.ts → admin/v1/auth.d.ts} +2 -2
- package/dist/package/declarations/src/api/system/{base_dict.d.ts → admin/v1/base_dict.d.ts} +1 -1
- package/dist/package/declarations/src/api/system/{base_tenant.d.ts → admin/v1/base_tenant.d.ts} +2 -2
- 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/ProTable/index.vue.d.ts +114 -0
- package/dist/package/declarations/src/components/RichTextPreview/index.vue.d.ts +8 -0
- package/dist/package/declarations/src/components/TreeFilter/index.vue.d.ts +84 -76
- package/dist/package/declarations/src/components/Upload/File.vue.d.ts +1 -1
- package/dist/package/declarations/src/components/Upload/Files.vue.d.ts +1 -1
- package/dist/package/declarations/src/components/Upload/Img.vue.d.ts +2 -2
- package/dist/package/declarations/src/components/Upload/Imgs.vue.d.ts +2 -2
- package/dist/package/declarations/src/components/WangEditor/index.vue.d.ts +2 -0
- package/dist/package/declarations/src/documentTitle.d.ts +2 -0
- package/dist/package/declarations/src/format.d.ts +1 -1
- package/dist/package/declarations/src/index.d.ts +2 -1
- package/dist/package/declarations/src/layouts/components/LockScreen.vue.d.ts +3 -0
- package/dist/package/declarations/src/locales/generated.d.ts +208 -0
- package/dist/package/declarations/src/locales/index.d.ts +2 -0
- package/dist/package/declarations/src/modules/index.d.ts +1 -3
- package/dist/package/declarations/src/request.d.ts +1 -1
- package/dist/package/declarations/src/routers/index.d.ts +1 -1
- package/dist/package/declarations/src/rpc/base/v1/login.d.ts +37 -5
- 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/auth.d.ts +13 -0
- package/dist/package/declarations/src/rpc/system/admin/v1/base_dict.d.ts +0 -101
- package/dist/package/declarations/src/rpc/system/admin/v1/base_i18n.d.ts +10 -6
- package/dist/package/declarations/src/rpc/system/common/v1/common.d.ts +26 -0
- package/dist/package/declarations/src/security.d.ts +2 -0
- package/dist/package/declarations/src/stores/interface/index.d.ts +19 -1
- package/dist/package/declarations/src/stores/modules/lockScreen.d.ts +20 -0
- package/dist/package/declarations/src/stores/modules/user.d.ts +7 -6
- 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/passwordStrength.d.ts +1 -12
- package/dist/package/declarations/src/utils/request.d.ts +13 -2
- package/dist/package/declarations/src/utils/utils.d.ts +4 -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/{config.ts → v1/config.ts} +2 -2
- package/dist/package/src/api/base/{file.ts → v1/file.ts} +20 -24
- package/dist/package/src/api/base/{language.ts → v1/language.ts} +2 -2
- package/dist/package/src/api/base/{login.ts → v1/login.ts} +3 -3
- package/dist/package/src/api/base/v1/mfa.ts +114 -0
- package/dist/package/src/api/base/{oauth.ts → v1/oauth.ts} +2 -2
- package/dist/package/src/api/system/{auth.ts → admin/v1/auth.ts} +3 -3
- package/dist/package/src/api/system/{base_dict.ts → admin/v1/base_dict.ts} +2 -2
- package/dist/package/src/api/system/{base_tenant.ts → admin/v1/base_tenant.ts} +3 -3
- package/dist/package/src/bootstrap.ts +6 -5
- package/dist/package/src/components/CronExpression/index.vue +4 -3
- package/dist/package/src/components/Dialog/PasswordVerifyDialog.vue +176 -0
- package/dist/package/src/components/Dialog/ProDialog.vue +1 -0
- package/dist/package/src/components/ImportExcel/index.vue +4 -2
- package/dist/package/src/components/Loading/fullScreen.ts +2 -1
- package/dist/package/src/components/MarkdownPreview/index.vue +22 -2
- 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/PasswordStrength/index.vue +1 -1
- 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/components/TableColumn.vue +14 -6
- package/dist/package/src/components/ProTable/index.vue +9 -5
- package/dist/package/src/components/RichTextPreview/index.vue +56 -0
- package/dist/package/src/components/SelectIcon/index.vue +3 -2
- package/dist/package/src/components/Upload/File.vue +2 -2
- package/dist/package/src/components/Upload/Files.vue +2 -2
- package/dist/package/src/components/Upload/Img.vue +7 -5
- package/dist/package/src/components/Upload/Imgs.vue +6 -5
- package/dist/package/src/components/WangEditor/index.vue +10 -7
- package/dist/package/src/config/index.ts +1 -1
- package/dist/package/src/documentTitle.ts +8 -0
- package/dist/package/src/format.ts +1 -1
- package/dist/package/src/index.ts +2 -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 +26 -6
- package/dist/package/src/layouts/components/Header/components/InfoDialog.vue +4 -3
- package/dist/package/src/layouts/components/Header/components/PasswordDialog.vue +4 -3
- package/dist/package/src/layouts/components/Header/components/SearchMenu.vue +16 -8
- package/dist/package/src/layouts/components/LockScreen.vue +573 -0
- package/dist/package/src/layouts/components/Tabs/index.vue +12 -3
- package/dist/package/src/layouts/components/ThemeDrawer/index.scss +4 -4
- 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 +51 -3
- package/dist/package/src/locales/index.ts +16 -12
- package/dist/package/src/locales/ja-JP.json +51 -3
- package/dist/package/src/locales/zh-CN.json +51 -3
- package/dist/package/src/locales/zh-TW.json +51 -3
- package/dist/package/src/modules/index.ts +2 -4
- package/dist/package/src/request.ts +3 -0
- package/dist/package/src/routers/index.ts +15 -14
- package/dist/package/src/rpc/base/v1/login.ts +37 -4
- 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/auth.ts +13 -0
- package/dist/package/src/rpc/system/admin/v1/base_dict.ts +0 -112
- package/dist/package/src/rpc/system/admin/v1/base_i18n.ts +10 -6
- package/dist/package/src/rpc/system/common/v1/common.ts +35 -0
- package/dist/package/src/security.ts +2 -0
- package/dist/package/src/stores/interface/index.ts +20 -1
- package/dist/package/src/stores/modules/auth.ts +1 -1
- package/dist/package/src/stores/modules/config.ts +2 -2
- package/dist/package/src/stores/modules/dict.ts +1 -1
- package/dist/package/src/stores/modules/lockScreen.ts +78 -0
- package/dist/package/src/stores/modules/user.ts +34 -22
- package/dist/package/src/stores/runtime.ts +1 -0
- package/dist/package/src/styles/element.scss +35 -4
- package/dist/package/src/utils/clipboard.ts +22 -0
- package/dist/package/src/utils/passwordCrypto.ts +1 -1
- package/dist/package/src/utils/passwordStrength.ts +7 -24
- package/dist/package/src/utils/request.ts +175 -40
- package/dist/package/src/utils/tenant.ts +1 -1
- package/dist/package/src/utils/utils.ts +25 -6
- package/dist/package/src/utils/webauthn.ts +90 -0
- package/dist/package/src/views/login/components/LoginForm.vue +257 -24
- package/dist/package/src/views/login/index.scss +2 -2
- 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 +4 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import axios, { type InternalAxiosRequestConfig, type AxiosResponse, type AxiosError } from "axios";
|
|
1
|
+
import axios, { type InternalAxiosRequestConfig, type AxiosResponse, type AxiosError, type AxiosRequestConfig } from "axios";
|
|
2
2
|
import qs from "qs";
|
|
3
|
-
import {
|
|
3
|
+
import { reactive } from "vue";
|
|
4
4
|
import router from "../routers";
|
|
5
5
|
import { LOGIN_URL } from "../config";
|
|
6
6
|
import pinia from "../stores";
|
|
@@ -12,6 +12,11 @@ const apiTargetUrl = import.meta.env.VITE_API_URL || import.meta.env.VITE_APP_AP
|
|
|
12
12
|
export const requestBaseURL = `${apiTargetUrl}${apiBasePath}`;
|
|
13
13
|
const SESSION_URL = "/v1/base/session";
|
|
14
14
|
const TOKEN_URL = "/v1/base/token";
|
|
15
|
+
const REFRESH_EXPIRY_COOKIE = "kratos_refresh_exp";
|
|
16
|
+
const REFRESH_TOKEN_TRANSPORT_HEADER = "X-Refresh-Token-Transport";
|
|
17
|
+
const REFRESH_TOKEN_TRANSPORT_COOKIE = "cookie";
|
|
18
|
+
const PASSWORD_CHANGE_REQUIRED_METADATA_KEY = "password_change_required";
|
|
19
|
+
const PASSWORD_CHANGE_STATE_STORAGE_KEY = "admin-password-change-required";
|
|
15
20
|
const CAPTCHA_URL = "/v1/base/captcha";
|
|
16
21
|
const CONFIG_URL = "/v1/base/config";
|
|
17
22
|
const LANGUAGE_URL = "/v1/base/language";
|
|
@@ -19,10 +24,10 @@ const PASSWORD_PUBLIC_KEY_URL = "/v1/base/password-public-key";
|
|
|
19
24
|
const OAUTH_PROVIDER_URL = "/v1/base/oauth/provider";
|
|
20
25
|
const OAUTH_AUTHORIZATION_URL = "/v1/base/oauth/authorization";
|
|
21
26
|
const OAUTH_TICKET_URL = "/v1/base/oauth/ticket";
|
|
22
|
-
const
|
|
23
|
-
const
|
|
24
|
-
const
|
|
25
|
-
//
|
|
27
|
+
const MFA_VERIFY_URL = "/v1/base/mfa/verify";
|
|
28
|
+
const MFA_ENROLLMENT_URL = "/v1/base/mfa/enrollment";
|
|
29
|
+
const MFA_ENROLLMENT_CONFIRM_URL = "/v1/base/mfa/enrollment/confirm";
|
|
30
|
+
// 认证公共接口不携带访问令牌。
|
|
26
31
|
const NO_AUTH_URL_SET = new Set([
|
|
27
32
|
SESSION_URL,
|
|
28
33
|
TOKEN_URL,
|
|
@@ -33,9 +38,9 @@ const NO_AUTH_URL_SET = new Set([
|
|
|
33
38
|
OAUTH_PROVIDER_URL,
|
|
34
39
|
OAUTH_AUTHORIZATION_URL,
|
|
35
40
|
OAUTH_TICKET_URL,
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
41
|
+
MFA_VERIFY_URL,
|
|
42
|
+
MFA_ENROLLMENT_URL,
|
|
43
|
+
MFA_ENROLLMENT_CONFIRM_URL
|
|
39
44
|
]);
|
|
40
45
|
const AUTH_EXPIRED_EXCLUDED_URL_SET = new Set([
|
|
41
46
|
SESSION_URL,
|
|
@@ -47,9 +52,9 @@ const AUTH_EXPIRED_EXCLUDED_URL_SET = new Set([
|
|
|
47
52
|
OAUTH_PROVIDER_URL,
|
|
48
53
|
OAUTH_AUTHORIZATION_URL,
|
|
49
54
|
OAUTH_TICKET_URL,
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
55
|
+
MFA_VERIFY_URL,
|
|
56
|
+
MFA_ENROLLMENT_URL,
|
|
57
|
+
MFA_ENROLLMENT_CONFIRM_URL
|
|
53
58
|
]);
|
|
54
59
|
|
|
55
60
|
/** 支持自动重试的 Axios 请求配置。 */
|
|
@@ -58,19 +63,60 @@ type RetryableRequestConfig = InternalAxiosRequestConfig & {
|
|
|
58
63
|
_authRetried?: boolean;
|
|
59
64
|
};
|
|
60
65
|
|
|
66
|
+
/** 统一的结构化错误响应。 */
|
|
67
|
+
type ErrorResponseData = {
|
|
68
|
+
code?: string | number;
|
|
69
|
+
message?: string;
|
|
70
|
+
reason?: string;
|
|
71
|
+
metadata?: Record<string, string>;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/** 本地持久化的强制改密状态,仅包含非敏感的显示信息。 */
|
|
75
|
+
interface PersistedPasswordChangeState {
|
|
76
|
+
/** 是否需要强制修改密码。 */
|
|
77
|
+
required: boolean;
|
|
78
|
+
/** 后端返回的提示文案。 */
|
|
79
|
+
message: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** 读取刷新前保存的强制改密状态。 */
|
|
83
|
+
function getPersistedPasswordChangeState(): PersistedPasswordChangeState {
|
|
84
|
+
const emptyState = { required: false, message: "" };
|
|
85
|
+
if (typeof window === "undefined") return emptyState;
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
const value = window.localStorage.getItem(PASSWORD_CHANGE_STATE_STORAGE_KEY);
|
|
89
|
+
if (!value) return emptyState;
|
|
90
|
+
const parsed = JSON.parse(value) as unknown;
|
|
91
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return emptyState;
|
|
92
|
+
const persisted = parsed as { message?: unknown };
|
|
93
|
+
return {
|
|
94
|
+
required: true,
|
|
95
|
+
message: typeof persisted.message === "string" ? persisted.message : ""
|
|
96
|
+
};
|
|
97
|
+
} catch {
|
|
98
|
+
return emptyState;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** 强制改密弹窗状态。 */
|
|
103
|
+
export const passwordChangeState = reactive(getPersistedPasswordChangeState());
|
|
104
|
+
|
|
61
105
|
// 创建 axios 实例
|
|
62
106
|
const service = axios.create({
|
|
63
107
|
baseURL: requestBaseURL,
|
|
64
108
|
timeout: 50000,
|
|
65
109
|
headers: { "Content-Type": "application/json;charset=utf-8" },
|
|
66
|
-
paramsSerializer: params => qs.stringify(params)
|
|
110
|
+
paramsSerializer: params => qs.stringify(params),
|
|
111
|
+
withCredentials: true
|
|
67
112
|
});
|
|
68
113
|
|
|
69
114
|
// 刷新令牌请求使用独立实例,避免与主请求拦截器互相递归。
|
|
70
115
|
const refreshService = axios.create({
|
|
71
116
|
baseURL: requestBaseURL,
|
|
72
117
|
timeout: 50000,
|
|
73
|
-
headers: { "Content-Type": "application/json;charset=utf-8" }
|
|
118
|
+
headers: { "Content-Type": "application/json;charset=utf-8" },
|
|
119
|
+
withCredentials: true
|
|
74
120
|
});
|
|
75
121
|
|
|
76
122
|
/** 获取用户状态仓库 */
|
|
@@ -83,6 +129,9 @@ function shouldSkipAuth(config: InternalAxiosRequestConfig) {
|
|
|
83
129
|
if (config.headers.Authorization === "no-auth") return true;
|
|
84
130
|
|
|
85
131
|
const requestUrl = config.url ?? "";
|
|
132
|
+
const requestMethod = String(config.method ?? "").toLowerCase();
|
|
133
|
+
// 登录请求会显式声明 no-auth,登出请求则必须带访问令牌让服务端清理会话和刷新令牌。
|
|
134
|
+
if (requestUrl === SESSION_URL && requestMethod === "delete") return false;
|
|
86
135
|
return NO_AUTH_URL_SET.has(requestUrl);
|
|
87
136
|
}
|
|
88
137
|
|
|
@@ -100,7 +149,57 @@ function shouldSkipErrorMessage(config?: InternalAxiosRequestConfig) {
|
|
|
100
149
|
|
|
101
150
|
const requestUrl = config.url ?? "";
|
|
102
151
|
const requestMethod = String(config.method ?? "").toLowerCase();
|
|
103
|
-
return
|
|
152
|
+
return requestUrl === SESSION_URL && requestMethod === "delete";
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** 判断响应是否要求用户先修改密码。 */
|
|
156
|
+
function isPasswordChangeRequired(data?: ErrorResponseData) {
|
|
157
|
+
return data?.metadata?.[PASSWORD_CHANGE_REQUIRED_METADATA_KEY] === "true";
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** 判断最近是否已经处理过强制改密响应。 */
|
|
161
|
+
function isPasswordChangeRequiredPromptActive() {
|
|
162
|
+
return passwordChangePromptUntil > Date.now();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** 处理强制改密响应,避免并发请求重复提示并打开全局改密弹窗。 */
|
|
166
|
+
export function handlePasswordChangeRequired(message: string) {
|
|
167
|
+
if (isPasswordChangeRequiredPromptActive()) return;
|
|
168
|
+
passwordChangePromptUntil = Date.now() + 10 * 1000;
|
|
169
|
+
passwordChangeState.required = true;
|
|
170
|
+
passwordChangeState.message = message;
|
|
171
|
+
persistPasswordChangeState();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** 清理强制改密弹窗状态。 */
|
|
175
|
+
export function clearPasswordChangeRequired() {
|
|
176
|
+
passwordChangeState.required = false;
|
|
177
|
+
passwordChangeState.message = "";
|
|
178
|
+
passwordChangePromptUntil = 0;
|
|
179
|
+
persistPasswordChangeState();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** 持久化或删除强制改密状态,兼容浏览器禁用本地存储的场景。 */
|
|
183
|
+
function persistPasswordChangeState() {
|
|
184
|
+
if (typeof window === "undefined") return;
|
|
185
|
+
|
|
186
|
+
try {
|
|
187
|
+
if (passwordChangeState.required) {
|
|
188
|
+
window.localStorage.setItem(
|
|
189
|
+
PASSWORD_CHANGE_STATE_STORAGE_KEY,
|
|
190
|
+
JSON.stringify({ message: passwordChangeState.message })
|
|
191
|
+
);
|
|
192
|
+
} else {
|
|
193
|
+
window.localStorage.removeItem(PASSWORD_CHANGE_STATE_STORAGE_KEY);
|
|
194
|
+
}
|
|
195
|
+
} catch {
|
|
196
|
+
// 本地存储不可用时仍保留当前页面内的响应式状态。
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** 统一展示请求错误,合并并发请求产生的相同提示。 */
|
|
201
|
+
function showRequestError(message: string) {
|
|
202
|
+
ElMessage.error({ message, grouping: true });
|
|
104
203
|
}
|
|
105
204
|
|
|
106
205
|
/** 读取访问令牌过期时间 */
|
|
@@ -108,6 +207,15 @@ function getTokenExpiresAt() {
|
|
|
108
207
|
return getUserStore().tokenExpiresAt;
|
|
109
208
|
}
|
|
110
209
|
|
|
210
|
+
/** 判断浏览器是否存在非敏感的刷新令牌过期提示。 */
|
|
211
|
+
function hasRefreshCookieHint() {
|
|
212
|
+
if (typeof document === "undefined") return false;
|
|
213
|
+
const item = document.cookie.split(";").find(value => value.trim().startsWith(`${REFRESH_EXPIRY_COOKIE}=`));
|
|
214
|
+
if (!item) return false;
|
|
215
|
+
const expiresAt = Number(item.split("=")[1]);
|
|
216
|
+
return Number.isFinite(expiresAt) && expiresAt * 1000 > Date.now();
|
|
217
|
+
}
|
|
218
|
+
|
|
111
219
|
/** 判断当前访问令牌是否仍在有效期内。 */
|
|
112
220
|
export function hasValidAccessToken() {
|
|
113
221
|
const userStore = getUserStore();
|
|
@@ -117,7 +225,13 @@ export function hasValidAccessToken() {
|
|
|
117
225
|
/** 读取最新可用访问令牌,必要时先串行刷新,供 axios、fetch 与 SSE 共用。 */
|
|
118
226
|
export async function getRequestAccessToken(): Promise<string> {
|
|
119
227
|
const userStore = getUserStore();
|
|
120
|
-
if (
|
|
228
|
+
if (userStore.isLoggingOut) {
|
|
229
|
+
return userStore.token.trim();
|
|
230
|
+
}
|
|
231
|
+
if (userStore.authInvalidated) {
|
|
232
|
+
return "";
|
|
233
|
+
}
|
|
234
|
+
if (!userStore.token && hasRefreshCookieHint()) {
|
|
121
235
|
await handleTokenRefresh(false);
|
|
122
236
|
}
|
|
123
237
|
|
|
@@ -132,12 +246,15 @@ export async function getRequestAccessToken(): Promise<string> {
|
|
|
132
246
|
|
|
133
247
|
/** 尝试通过刷新令牌恢复访问令牌,供路由守卫进入页面前调用。 */
|
|
134
248
|
export async function ensureAccessToken() {
|
|
249
|
+
const userStore = getUserStore();
|
|
250
|
+
if (userStore.isLoggingOut || userStore.authInvalidated) {
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
135
253
|
if (hasValidAccessToken()) {
|
|
136
254
|
return true;
|
|
137
255
|
}
|
|
138
256
|
|
|
139
|
-
|
|
140
|
-
if (!userStore.refreshToken) {
|
|
257
|
+
if (!hasRefreshCookieHint()) {
|
|
141
258
|
return false;
|
|
142
259
|
}
|
|
143
260
|
|
|
@@ -152,6 +269,7 @@ export async function ensureAccessToken() {
|
|
|
152
269
|
|
|
153
270
|
// 防止并发 401 重复弹出认证失效确认框。
|
|
154
271
|
let isHandlingAuthExpired = false;
|
|
272
|
+
let passwordChangePromptUntil = 0;
|
|
155
273
|
|
|
156
274
|
/** 统一处理认证失效 */
|
|
157
275
|
export function handleAuthExpired() {
|
|
@@ -171,6 +289,7 @@ export function handleAuthExpired() {
|
|
|
171
289
|
const userStore = getUserStore();
|
|
172
290
|
const currentRoute = router.currentRoute.value;
|
|
173
291
|
const redirect = currentRoute.path === LOGIN_URL ? undefined : currentRoute.fullPath;
|
|
292
|
+
clearPasswordChangeRequired();
|
|
174
293
|
userStore.clearAuthData();
|
|
175
294
|
return router.replace({
|
|
176
295
|
path: LOGIN_URL,
|
|
@@ -188,7 +307,8 @@ service.interceptors.request.use(
|
|
|
188
307
|
const skipAuth = shouldSkipAuth(config);
|
|
189
308
|
const accessToken = skipAuth ? "" : await getRequestAccessToken();
|
|
190
309
|
Object.assign(config.headers, getLocaleRequestHeaders());
|
|
191
|
-
|
|
310
|
+
config.headers[REFRESH_TOKEN_TRANSPORT_HEADER] = REFRESH_TOKEN_TRANSPORT_COOKIE;
|
|
311
|
+
// 登录、验证码、刷新令牌接口不携带访问令牌,避免请求头污染。
|
|
192
312
|
if (!skipAuth && accessToken) {
|
|
193
313
|
config.headers.Authorization = accessToken;
|
|
194
314
|
} else {
|
|
@@ -207,24 +327,30 @@ service.interceptors.response.use(
|
|
|
207
327
|
return response;
|
|
208
328
|
}
|
|
209
329
|
|
|
210
|
-
const
|
|
330
|
+
const responseData = response.data as ErrorResponseData;
|
|
331
|
+
const { code, message, reason, metadata } = responseData;
|
|
211
332
|
if (code === undefined || message === undefined || reason === undefined || metadata === undefined) {
|
|
212
333
|
return response.data;
|
|
213
334
|
}
|
|
214
335
|
|
|
215
|
-
|
|
216
|
-
|
|
336
|
+
if (isPasswordChangeRequired(responseData)) {
|
|
337
|
+
handlePasswordChangeRequired(message || t("common.message.system_error"));
|
|
338
|
+
} else {
|
|
339
|
+
showRequestError(message || t("common.message.system_error"));
|
|
340
|
+
}
|
|
341
|
+
return Promise.reject(new Error(message || t("common.message.system_error")));
|
|
217
342
|
},
|
|
218
343
|
async (error: AxiosError) => {
|
|
219
344
|
const status = error.response?.status;
|
|
220
|
-
const data = error.response?.data as
|
|
345
|
+
const data = error.response?.data as ErrorResponseData | undefined;
|
|
221
346
|
const code = data?.code;
|
|
222
347
|
const message = data?.message;
|
|
223
348
|
const requestConfig = error.config as RetryableRequestConfig | undefined;
|
|
224
349
|
|
|
225
350
|
// 业务请求仅在 401 时尝试刷新并重放,403 直接展示后端权限错误。
|
|
226
351
|
if ((status === 401 || code === 401) && !shouldSkipAuthExpiredPrompt(requestConfig)) {
|
|
227
|
-
|
|
352
|
+
const userStore = getUserStore();
|
|
353
|
+
if (requestConfig && !requestConfig._authRetried && !userStore.isLoggingOut && !userStore.authInvalidated && hasRefreshCookieHint()) {
|
|
228
354
|
requestConfig._authRetried = true;
|
|
229
355
|
try {
|
|
230
356
|
await handleTokenRefresh(false);
|
|
@@ -237,20 +363,27 @@ service.interceptors.response.use(
|
|
|
237
363
|
handleAuthExpired();
|
|
238
364
|
} else if (data) {
|
|
239
365
|
if (!shouldSkipErrorMessage(requestConfig)) {
|
|
240
|
-
if (
|
|
241
|
-
|
|
366
|
+
if (isPasswordChangeRequired(data)) {
|
|
367
|
+
handlePasswordChangeRequired(message || t("common.message.system_error"));
|
|
368
|
+
} else if (message) {
|
|
369
|
+
showRequestError(message);
|
|
242
370
|
} else {
|
|
243
|
-
|
|
371
|
+
showRequestError(t("common.message.system_error"));
|
|
244
372
|
}
|
|
245
373
|
}
|
|
246
374
|
} else if (!shouldSkipErrorMessage(requestConfig)) {
|
|
247
|
-
|
|
375
|
+
showRequestError(error.message || t("common.message.system_error"));
|
|
248
376
|
}
|
|
249
377
|
return Promise.reject(error.message);
|
|
250
378
|
}
|
|
251
379
|
);
|
|
252
380
|
|
|
253
|
-
|
|
381
|
+
/** 以请求体、响应体顺序提供带类型约束的 HTTP 请求函数。 */
|
|
382
|
+
function request<Request = unknown, Response = unknown>(config: AxiosRequestConfig<Request>): Promise<Response> {
|
|
383
|
+
return service<Response, Response, Request>(config) as Promise<Response>;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
export default request;
|
|
254
387
|
|
|
255
388
|
// 刷新 Token 的锁
|
|
256
389
|
let isRefreshing = false;
|
|
@@ -282,20 +415,22 @@ async function handleTokenRefresh(promptOnFailure = true) {
|
|
|
282
415
|
/** 调用刷新令牌接口并回写最新认证信息 */
|
|
283
416
|
async function refreshAccessToken() {
|
|
284
417
|
const userStore = getUserStore();
|
|
285
|
-
|
|
286
|
-
return Promise.reject(new Error(t("core.auth.refresh_token_missing")));
|
|
287
|
-
}
|
|
418
|
+
const authVersion = userStore.authVersion;
|
|
288
419
|
|
|
289
420
|
const response = await refreshService.post(
|
|
290
421
|
TOKEN_URL,
|
|
291
|
-
{
|
|
292
|
-
{
|
|
422
|
+
{},
|
|
423
|
+
{
|
|
424
|
+
headers: {
|
|
425
|
+
Authorization: "no-auth",
|
|
426
|
+
[REFRESH_TOKEN_TRANSPORT_HEADER]: REFRESH_TOKEN_TRANSPORT_COOKIE,
|
|
427
|
+
...getLocaleRequestHeaders()
|
|
428
|
+
}
|
|
429
|
+
}
|
|
293
430
|
);
|
|
294
431
|
const data = response.data;
|
|
295
|
-
userStore.
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
data.expires_in
|
|
300
|
-
);
|
|
432
|
+
if (userStore.authVersion !== authVersion || userStore.isLoggingOut || userStore.authInvalidated) {
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
userStore.updateTokenAuth(data.access_token, data.token_type ?? "", data.expires_in);
|
|
301
436
|
}
|
|
@@ -28,13 +28,32 @@ export function formatSrc(src: string) {
|
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
const configuredBase = String(import.meta.env.VITE_APP_STATIC_URL ?? "").trim();
|
|
31
|
-
if (value.startsWith("/admin/")) {
|
|
32
|
-
return new URL(value, `${window.location.origin}/`).toString();
|
|
33
|
-
}
|
|
34
|
-
|
|
35
31
|
const staticBase = configuredBase
|
|
36
32
|
? new URL(`${configuredBase.replace(/\/$/, "")}/`, `${window.location.origin}/`).toString()
|
|
37
|
-
: `${window.location.origin}
|
|
38
|
-
const normalizedPath = value.replace(/^\/+/, "")
|
|
33
|
+
: `${window.location.origin}/`;
|
|
34
|
+
const normalizedPath = normalizeStaticAssetPath(value).replace(/^\/+/, "");
|
|
39
35
|
return new URL(normalizedPath, staticBase).toString();
|
|
40
36
|
}
|
|
37
|
+
|
|
38
|
+
/** 将对象路径转换为统一的数据访问路径。 */
|
|
39
|
+
export function normalizeStaticAssetPath(src: string) {
|
|
40
|
+
const value = String(src ?? "");
|
|
41
|
+
if (value.startsWith("http://") || value.startsWith("https://")) {
|
|
42
|
+
return value;
|
|
43
|
+
}
|
|
44
|
+
if (value.startsWith("/data/") || value.startsWith("/admin/")) return value;
|
|
45
|
+
if (value.startsWith("data/")) return `/${value}`;
|
|
46
|
+
return value;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** 将富文本中的媒体路径转换为统一的数据访问路径。 */
|
|
50
|
+
export function normalizeRichTextMediaPaths(html: string) {
|
|
51
|
+
if (typeof DOMParser === "undefined") return html;
|
|
52
|
+
|
|
53
|
+
const document = new DOMParser().parseFromString(html, "text/html");
|
|
54
|
+
document.body.querySelectorAll<HTMLElement>("img[src], video[src]").forEach(element => {
|
|
55
|
+
const src = element.getAttribute("src");
|
|
56
|
+
if (src) element.setAttribute("src", normalizeStaticAssetPath(src));
|
|
57
|
+
});
|
|
58
|
+
return document.body.innerHTML;
|
|
59
|
+
}
|
|
@@ -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
|
+
}
|