This is an automated email from the ASF dual-hosted git repository.
dengliming pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/shenyu-dashboard.git
The following commit(s) were added to refs/heads/master by this push:
new 95d05bf9 fix(login): wait for secret initialization and guard failures
(#670)
95d05bf9 is described below
commit 95d05bf9229347256c3920898cf5a5691a781659
Author: Efe <[email protected]>
AuthorDate: Sun Sep 27 07:47:04 2026 +0300
fix(login): wait for secret initialization and guard failures (#670)
* fix(login): wait for secret initialization
Signed-off-by: Efe Gökdemir <[email protected]>
* fix(login): allow empty admin secrets
Signed-off-by: Efe Gökdemir <[email protected]>
---------
Signed-off-by: Efe Gökdemir <[email protected]>
---
src/models/login.js | 17 ++++++++++---
src/models/login.test.js | 45 +++++++++++++++++++++++++++++++++
src/routes/User/Login.js | 48 +++++++++++------------------------
src/routes/User/Login.test.js | 34 +++++++++++++++++++++++++
src/routes/User/loginSecret.js | 50 +++++++++++++++++++++++++++++++++++++
src/routes/User/loginSecret.test.js | 43 +++++++++++++++++++++++++++++++
6 files changed, 199 insertions(+), 38 deletions(-)
diff --git a/src/models/login.js b/src/models/login.js
index e5e5b846..00f40e04 100644
--- a/src/models/login.js
+++ b/src/models/login.js
@@ -31,12 +31,19 @@ export default {
effects: {
*login({ payload }, { call, put, select }) {
const { callback } = payload;
- const response = yield call(queryLogin, payload);
+ let response;
+ let requestError;
+ try {
+ response = yield call(queryLogin, payload);
+ } catch (error) {
+ requestError = error;
+ response = error.response?.data ?? error.response;
+ }
yield call(callback, response);
const namespaces = yield select((state) => state.global.namespaces);
// Login successfully
- if (response.data) {
+ if (response?.data) {
yield put({
type: "changeLoginStatus",
payload: {
@@ -76,10 +83,12 @@ export default {
yield put(routerRedux.push("/home"));
} else {
message.destroy();
- if (response.code === 404) {
+ if (response?.code === 404) {
message.error("Incorrect user name or password");
} else {
- message.error(response.message);
+ message.error(
+ response?.message || requestError?.message || "Login failed",
+ );
}
}
},
diff --git a/src/models/login.test.js b/src/models/login.test.js
new file mode 100644
index 00000000..af04b926
--- /dev/null
+++ b/src/models/login.test.js
@@ -0,0 +1,45 @@
+import { runSaga, effects } from "dva/saga";
+import { message } from "antd";
+import model from "./login";
+import { queryLogin } from "../services/api";
+
+jest.mock("../services/api", () => ({
+ queryLogin: jest.fn(),
+}));
+jest.mock("antd", () => ({
+ message: { destroy: jest.fn(), error: jest.fn() },
+}));
+jest.mock("../components/_utils/utils", () => ({
+ defaultNamespaceId: "default",
+}));
+
+it("does not dereference an absent login response", async () => {
+ queryLogin.mockResolvedValueOnce(undefined);
+ const callback = jest.fn();
+
+ await runSaga(
+ { dispatch: jest.fn(), getState: () => ({ global: { namespaces: [] } }) },
+ model.effects.login,
+ { payload: { callback } },
+ effects,
+ ).done;
+
+ expect(callback).toHaveBeenCalledWith(undefined);
+ expect(message.error).toHaveBeenCalledWith("Login failed");
+});
+
+it("handles rejected login requests without dereferencing a response", async
() => {
+ const error = new Error("request failed");
+ queryLogin.mockRejectedValueOnce(error);
+ const callback = jest.fn();
+
+ await runSaga(
+ { dispatch: jest.fn(), getState: () => ({ global: { namespaces: [] } }) },
+ model.effects.login,
+ { payload: { callback } },
+ effects,
+ ).done;
+
+ expect(callback).toHaveBeenCalledWith(undefined);
+ expect(message.error).toHaveBeenCalledWith("request failed");
+});
diff --git a/src/routes/User/Login.js b/src/routes/User/Login.js
index 12e2da99..e1386867 100644
--- a/src/routes/User/Login.js
+++ b/src/routes/User/Login.js
@@ -22,35 +22,9 @@ import { Alert } from "antd";
import UUID from "uuid";
import Login from "components/Login";
import styles from "./Login.less";
-import { querySecretInfo } from "../../services/api";
+import { ensureSecret, getSecret } from "./loginSecret";
const { UserName, Password, Submit, VerifyCode, LoginCode } = Login;
-
-let secretKey = "";
-let secretIv = "";
-async function initSecret() {
- try {
- let promise = await querySecretInfo();
- if (typeof promise !== "undefined") {
- if (promise.status === 200) {
- let body = await promise.json();
- let secret = JSON.parse(atob(body.data));
- if (
- secret.key != null &&
- secret.key !== "" &&
- secret.iv != null &&
- secret.iv !== ""
- ) {
- secretKey = secret.key;
- secretIv = secret.iv;
- }
- }
- }
- } catch (e) {
- // ignore error
- }
-}
-initSecret().then(() => {});
@connect(({ login, loading }) => ({
login,
submitting: loading.effects["login/login"],
@@ -70,7 +44,7 @@ export default class LoginPage extends Component {
this.ChildRef.current?.handleChange();
}
- handleSubmit = (err, values) => {
+ handleSubmit = async (err, values) => {
const { dispatch } = this.props;
const { needCode } = this.state;
if (!err) {
@@ -79,7 +53,11 @@ export default class LoginPage extends Component {
this.ChildRef.current.handleChange();
return;
}
- if (secretKey !== "" && secretIv !== "") {
+ if (!(await ensureSecret())) {
+ return;
+ }
+ const { key: secretKey, iv: secretIv } = getSecret();
+ if (secretKey && secretIv) {
const keyByte = CryptoJS.enc.Utf8.parse(secretKey);
const ivByte = CryptoJS.enc.Utf8.parse(secretIv);
const encryptedPassword = CryptoJS.AES.encrypt(
@@ -99,16 +77,18 @@ export default class LoginPage extends Component {
payload: {
...values,
clientId: UUID.v4().replaceAll("-", ""),
- callback: (res) => {
- if (res.code === 500) {
- this.setState({ needCode: true });
- }
- },
+ callback: this.handleLoginResponse,
},
});
}
};
+ handleLoginResponse = (res) => {
+ if (res?.code === 500) {
+ this.setState({ needCode: true });
+ }
+ };
+
// eslint-disable-next-line react/no-unused-class-component-methods
renderMessage = (content) => {
return (
diff --git a/src/routes/User/Login.test.js b/src/routes/User/Login.test.js
new file mode 100644
index 00000000..f50d27ad
--- /dev/null
+++ b/src/routes/User/Login.test.js
@@ -0,0 +1,34 @@
+import LoginPage from "./Login";
+
+jest.mock("dva", () => ({
+ connect: () => (Component) => Component,
+}));
+jest.mock("components/Login", () => {
+ const Login = () => null;
+ Login.UserName = () => null;
+ Login.Password = () => null;
+ Login.Submit = () => null;
+ Login.VerifyCode = () => null;
+ Login.LoginCode = () => null;
+ return Login;
+});
+jest.mock("./loginSecret", () => ({
+ ensureSecret: jest.fn().mockResolvedValue(true),
+ getSecret: () => ({ key: "", iv: "" }),
+}));
+jest.mock("crypto-js", () => ({
+ enc: { Utf8: { parse: jest.fn() } },
+ AES: { encrypt: jest.fn() },
+ mode: { CBC: {} },
+ pad: { Pkcs7: {} },
+}));
+jest.mock("uuid", () => ({ v4: () => "client-id" }));
+
+it("does not throw when the login callback receives no response", async () => {
+ const page = new LoginPage({ dispatch: jest.fn() });
+ page.setState = jest.fn();
+
+ expect(() => page.handleLoginResponse(undefined)).not.toThrow();
+ expect(() => page.handleLoginResponse(null)).not.toThrow();
+ expect(page.setState).not.toHaveBeenCalled();
+});
diff --git a/src/routes/User/loginSecret.js b/src/routes/User/loginSecret.js
new file mode 100644
index 00000000..b7094d28
--- /dev/null
+++ b/src/routes/User/loginSecret.js
@@ -0,0 +1,50 @@
+import { querySecretInfo } from "../../services/api";
+
+let secretKey = "";
+let secretIv = "";
+let secretInitialized = false;
+let secretPromise;
+
+async function loadSecret() {
+ try {
+ const response = await querySecretInfo();
+ if (response?.status !== 200) {
+ return false;
+ }
+
+ const body = await response.json();
+ const secret = JSON.parse(atob(body.data));
+ const hasKey = Boolean(secret.key);
+ const hasIv = Boolean(secret.iv);
+ if (hasKey !== hasIv) {
+ return false;
+ }
+ secretKey = secret.key || "";
+ secretIv = secret.iv || "";
+ secretInitialized = true;
+ return true;
+ } catch (error) {
+ // The submit path retries once when the initial request is still
unavailable.
+ }
+ return false;
+}
+
+export async function ensureSecret() {
+ if (secretInitialized) {
+ return true;
+ }
+
+ if (!secretPromise) {
+ secretPromise = loadSecret();
+ }
+ const loaded = await secretPromise;
+ if (!loaded && !secretInitialized) {
+ secretPromise = loadSecret();
+ await secretPromise;
+ }
+ return secretInitialized;
+}
+
+export function getSecret() {
+ return { key: secretKey, iv: secretIv };
+}
diff --git a/src/routes/User/loginSecret.test.js
b/src/routes/User/loginSecret.test.js
new file mode 100644
index 00000000..75e773c6
--- /dev/null
+++ b/src/routes/User/loginSecret.test.js
@@ -0,0 +1,43 @@
+import { ensureSecret, getSecret } from "./loginSecret";
+import { querySecretInfo } from "../../services/api";
+
+jest.mock("../../services/api", () => ({
+ querySecretInfo: jest.fn(),
+}));
+
+const response = (key, iv) => ({
+ status: 200,
+ json: async () => ({ data: btoa(JSON.stringify({ key, iv })) }),
+});
+
+it("retries secret initialization when the eager request fails", async () => {
+ querySecretInfo.mockRejectedValueOnce(new Error("not ready"));
+ querySecretInfo.mockResolvedValueOnce(response("key", "iv"));
+
+ await expect(ensureSecret()).resolves.toBe(true);
+ expect(getSecret()).toEqual({ key: "key", iv: "iv" });
+ expect(querySecretInfo).toHaveBeenCalledTimes(2);
+});
+
+it("accepts an initialized Admin response without client-side secrets", async
() => {
+ jest.resetModules();
+ let isolatedQuerySecretInfo;
+ jest.doMock("../../services/api", () => ({
+ querySecretInfo: (() => {
+ isolatedQuerySecretInfo = jest.fn();
+ return isolatedQuerySecretInfo;
+ })(),
+ }));
+ let ensureInitializedSecret;
+ let getInitializedSecret;
+ jest.isolateModules(() => {
+ ({
+ ensureSecret: ensureInitializedSecret,
+ getSecret: getInitializedSecret,
+ } = require("./loginSecret"));
+ });
+ isolatedQuerySecretInfo.mockResolvedValueOnce(response("", ""));
+
+ await expect(ensureInitializedSecret()).resolves.toBe(true);
+ expect(getInitializedSecret()).toEqual({ key: "", iv: "" });
+});