From 5f69824838ba57e045718ca1623a8ac7add36b42 Mon Sep 17 00:00:00 2001 From: actiontech-zihan Date: Mon, 3 Aug 2026 17:39:59 +0800 Subject: [PATCH 1/2] feat: encrypt login username and password with SM2 Fetch login encryption public key and submit dual ciphertext fields when enabled; keep plaintext path only for disabled mode. Co-authored-by: Cursor --- package.json | 1 + src/api/common.d.ts | 26 +++++++++ src/api/user/index.d.ts | 5 +- src/api/user/index.ts | 11 +++- src/locale/en-US/login.ts | 22 ++++++++ src/locale/zh-CN/login.ts | 1 + src/page/Login/index.tsx | 108 ++++++++++++++++++++++++++++++-------- src/scripts/version.ts | 2 +- src/types/sm-crypto.d.ts | 14 +++++ src/utils/sm2Encrypt.ts | 22 ++++++++ yarn.lock | 12 +++++ 11 files changed, 199 insertions(+), 25 deletions(-) create mode 100644 src/types/sm-crypto.d.ts create mode 100644 src/utils/sm2Encrypt.ts diff --git a/package.json b/package.json index 5521c1bb..03ee40ac 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "react-monaco-editor": "^0.43.0", "react-redux": "^8.0.5", "react-router-dom": "^6.3.0", + "sm-crypto": "0.3.13", "sql-formatter": "^12.2.2", "typescript": "^5.0.2", "use-resize-observer": "^9.0.2", diff --git a/src/api/common.d.ts b/src/api/common.d.ts index bdb96668..04aa8173 100644 --- a/src/api/common.d.ts +++ b/src/api/common.d.ts @@ -2387,6 +2387,32 @@ export interface IUserLoginReqV1 { password?: string; username?: string; + + encrypted_username?: string; + + encrypted_password?: string; + + key_id?: string; +} + +export interface ILoginEncryptionResDataV1 { + enable?: boolean; + + algorithm?: string; + + cipher_mode?: string; + + public_key?: string; + + key_id?: string; +} + +export interface IGetLoginEncryptionResV1 { + code?: number; + + message?: string; + + data?: ILoginEncryptionResDataV1; } export interface IUserLoginResV1 { diff --git a/src/api/user/index.d.ts b/src/api/user/index.d.ts index 0cd03bc9..9c9ffa2c 100644 --- a/src/api/user/index.d.ts +++ b/src/api/user/index.d.ts @@ -14,13 +14,16 @@ import { IGetUsersResV1, ICreateUserReqV1, IUpdateUserReqV1, - IUpdateOtherUserPasswordReqV1 + IUpdateOtherUserPasswordReqV1, + IGetLoginEncryptionResV1 } from '../common.d'; export interface ILoginV1Params extends IUserLoginReqV1 {} export interface ILoginV1Return extends IGetUserLoginResV1 {} +export interface IGetLoginEncryptionV1Return extends IGetLoginEncryptionResV1 {} + export interface ILogoutV1Return extends IBaseRes {} export interface IGetMemberTipListV1Params { diff --git a/src/api/user/index.ts b/src/api/user/index.ts index e7def643..04907b1f 100644 --- a/src/api/user/index.ts +++ b/src/api/user/index.ts @@ -42,10 +42,19 @@ import { IUpdateOtherUserPasswordV1Params, IUpdateOtherUserPasswordV1Return, ILoginV2Params, - ILoginV2Return + ILoginV2Return, + IGetLoginEncryptionV1Return } from './index.d'; class UserService extends ServiceBase { + public getLoginEncryptionV1(options?: AxiosRequestConfig) { + return this.get( + '/v1/login/encryption', + undefined, + options + ); + } + public loginV1(params: ILoginV1Params, options?: AxiosRequestConfig) { const paramsData = this.cloneDeep(params); return this.post('/v1/login', paramsData, options); diff --git a/src/locale/en-US/login.ts b/src/locale/en-US/login.ts index 3cfa1c79..328855cc 100644 --- a/src/locale/en-US/login.ts +++ b/src/locale/en-US/login.ts @@ -3,4 +3,26 @@ export default { powered: 'Action SQLe', pageTitle: 'SQL Audit Platform', login: 'Login', + otherMethod: 'Other login methods', + + userAgreementTips: 'I have read and agree to the', + userAgreement: 'User Agreement', + + errorMessage: { + userAgreement: 'Please read and agree to the user agreement first', + encryption: 'Login failed, please try again later', + }, + + oauth: { + title: 'User Binding', + form: { + username: 'Bound SQLE username', + }, + submitButton: 'Bind and login', + bindTips: 'If the username does not exist, it will be created automatically', + errorTitle: 'OAuth login error', + lostToken: 'Token not found, please return to the login page and retry', + lostOauth2Token: + 'OAuth token not found, please return to the login page and retry', + }, }; diff --git a/src/locale/zh-CN/login.ts b/src/locale/zh-CN/login.ts index 73921742..7693c376 100644 --- a/src/locale/zh-CN/login.ts +++ b/src/locale/zh-CN/login.ts @@ -10,6 +10,7 @@ export default { errorMessage: { userAgreement: '请先阅读并同意用户协议', + encryption: '登录失败,请稍后重试', }, oauth: { diff --git a/src/page/Login/index.tsx b/src/page/Login/index.tsx index ab51a3db..c8565f08 100644 --- a/src/page/Login/index.tsx +++ b/src/page/Login/index.tsx @@ -1,4 +1,4 @@ -import { useEffect } from 'react'; +import { useEffect, useRef } from 'react'; import './index.less'; import { Button, Checkbox, Form, Input, message, Typography } from 'antd'; @@ -21,14 +21,69 @@ import { useLocation } from 'react-router-dom'; import { getCookie } from '../../utils/Common'; import { IReduxState } from '../../store'; import useNavigate from '../../hooks/useNavigate'; +import { ILoginEncryptionResDataV1, IUserLoginReqV1 } from '../../api/common.d'; +import { sm2EncryptPassword } from '../../utils/sm2Encrypt'; const Login = () => { const navigate = useNavigate(); const dispatch = useDispatch(); const { t } = useTranslation(); const location = useLocation(); + const encryptionCacheRef = useRef(null); - const login = (formData: { + const fetchLoginEncryption = async (): Promise => { + const res = await user.getLoginEncryptionV1(); + if (res.data.code !== ResponseCode.SUCCESS || !res.data.data) { + throw new Error('get login encryption failed'); + } + encryptionCacheRef.current = res.data.data; + return res.data.data; + }; + + const buildLoginBody = async (formData: { + username: string; + password: string; + }): Promise => { + let info = encryptionCacheRef.current; + if (!info) { + info = await fetchLoginEncryption(); + } + + if (info.enable === true) { + if ( + info.algorithm !== 'SM2' || + info.cipher_mode !== 'C1C3C2' || + !info.public_key || + !info.key_id + ) { + throw new Error('invalid login encryption params'); + } + const encrypted_username = sm2EncryptPassword( + formData.username, + info.public_key + ); + const encrypted_password = sm2EncryptPassword( + formData.password, + info.public_key + ); + return { + encrypted_username, + encrypted_password, + key_id: info.key_id, + }; + } + + if (info.enable === false) { + return { + username: formData.username, + password: formData.password, + }; + } + + throw new Error('login encryption enable unknown'); + }; + + const login = async (formData: { username: string; password: string; userAgreement: boolean; @@ -39,29 +94,31 @@ const Login = () => { return; } /* FITRUE_isEE */ - user - .loginV2({ - username: formData.username, - password: formData.password, - }) - .then((res) => { - if (res.data.code === ResponseCode.SUCCESS) { - const params = new URLSearchParams(location.search); - dispatch( - updateToken({ token: getCookie(SQLE_COOKIE_TOKEN_KEY_NAME) }) - ); - const target = params.get(SQLE_REDIRECT_KEY_PARAMS_NAME); - if (target) { - if (target === '/sqlQuery') { - navigate(`sqlQuery?${OPEN_CLOUD_BEAVER_URL_PARAM_NAME}=true`); - } else { - navigate(target); - } + let body: IUserLoginReqV1; + try { + body = await buildLoginBody(formData); + } catch { + message.error(t('login.errorMessage.encryption')); + return; + } + user.loginV2(body).then((res) => { + if (res.data.code === ResponseCode.SUCCESS) { + const params = new URLSearchParams(location.search); + dispatch( + updateToken({ token: getCookie(SQLE_COOKIE_TOKEN_KEY_NAME) }) + ); + const target = params.get(SQLE_REDIRECT_KEY_PARAMS_NAME); + if (target) { + if (target === '/sqlQuery') { + navigate(`sqlQuery?${OPEN_CLOUD_BEAVER_URL_PARAM_NAME}=true`); } else { - navigate('home'); + navigate(target); } + } else { + navigate('home'); } - }); + } + }); }; const { run: getOauth2Tips, data: oauthConfig } = useRequest( () => configuration.getOauth2Tips().then((res) => res.data?.data ?? {}), @@ -75,6 +132,13 @@ const Login = () => { webLogoUrl: state.system.webLogoUrl, })); + useEffect(() => { + fetchLoginEncryption().catch(() => { + encryptionCacheRef.current = null; + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + /* IFTRUE_isEE */ useEffect(() => { getOauth2Tips(); diff --git a/src/scripts/version.ts b/src/scripts/version.ts index a559def1..1a034ff4 100644 --- a/src/scripts/version.ts +++ b/src/scripts/version.ts @@ -1 +1 @@ -export const UI_VERSION="feature/issue-110 72a0e52" \ No newline at end of file +export const UI_VERSION="feat/yumchina-login-sm2-encryption a855454" \ No newline at end of file diff --git a/src/types/sm-crypto.d.ts b/src/types/sm-crypto.d.ts new file mode 100644 index 00000000..50a01663 --- /dev/null +++ b/src/types/sm-crypto.d.ts @@ -0,0 +1,14 @@ +declare module 'sm-crypto' { + export const sm2: { + doEncrypt: ( + msg: string, + publicKey: string, + cipherMode?: number + ) => string; + doDecrypt: ( + encryptData: string, + privateKey: string, + cipherMode?: number + ) => string; + }; +} diff --git a/src/utils/sm2Encrypt.ts b/src/utils/sm2Encrypt.ts new file mode 100644 index 00000000..268ef8b3 --- /dev/null +++ b/src/utils/sm2Encrypt.ts @@ -0,0 +1,22 @@ +import { sm2 } from 'sm-crypto'; + +/** sm-crypto: 1 = C1C3C2(与后端 gmsm 一致) */ +const CIPHER_MODE_C1C3C2 = 1; + +/** + * 用 SM2 公钥对明文(用户名或口令)做 C1C3C2 加密,返回 hex(无前导 04;服务端会补齐)。 + * publicKeyHex 为未压缩点 hex;sm-crypto 要求带前导 04。 + */ +export function sm2EncryptPassword( + plaintext: string, + publicKeyHex: string +): string { + let key = (publicKeyHex || '').trim(); + if (!key) { + throw new Error('empty sm2 public key'); + } + if (!/^04/i.test(key)) { + key = `04${key}`; + } + return sm2.doEncrypt(plaintext, key, CIPHER_MODE_C1C3C2); +} diff --git a/yarn.lock b/yarn.lock index dacd26c9..7610e1c1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7425,6 +7425,11 @@ js-yaml@^4.1.0: dependencies: argparse "^2.0.1" +jsbn@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-1.1.0.tgz#b01307cb29b618a1ed26ec79e911f803c4da0040" + integrity sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A== + jsdom@^16.6.0: version "16.7.0" resolved "https://registry.npmmirror.com/jsdom/-/jsdom-16.7.0.tgz#918ae71965424b197c819f8183a754e18977b710" @@ -10519,6 +10524,13 @@ slash@^4.0.0: resolved "https://registry.npmmirror.com/slash/-/slash-4.0.0.tgz#2422372176c4c6c5addb5e2ada885af984b396a7" integrity sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew== +sm-crypto@0.3.13: + version "0.3.13" + resolved "https://registry.yarnpkg.com/sm-crypto/-/sm-crypto-0.3.13.tgz#9615d67f9f2280970c353122e5901ae87d64899a" + integrity sha512-ztNF+pZq6viCPMA1A6KKu3bgpkmYti5avykRHbcFIdSipFdkVmfUw2CnpM2kBJyppIalqvczLNM3wR8OQ0pT5w== + dependencies: + jsbn "^1.1.0" + sockjs@^0.3.24: version "0.3.24" resolved "https://registry.npmmirror.com/sockjs/-/sockjs-0.3.24.tgz#c9bc8995f33a111bea0395ec30aa3206bdb5ccce" From 025c3d653cc6b7a5a6a1f63289a4780ab1a12f4e Mon Sep 17 00:00:00 2001 From: actiontech-zihan Date: Tue, 4 Aug 2026 10:15:05 +0800 Subject: [PATCH 2/2] fix(test): mock login encryption and refresh Modal snapshot for CI Restore Login unit tests after SM2 encryption mount dependency, and align Header Modal snapshot with current UI_VERSION so PR checks pass. Co-authored-by: Cursor --- .../Header/Modal/__snapshots__/index.test.tsx.snap | 2 +- src/page/Login/index.test.tsx | 13 ++++++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/components/Nav/Header/Modal/__snapshots__/index.test.tsx.snap b/src/components/Nav/Header/Modal/__snapshots__/index.test.tsx.snap index dc7e2453..2eac74f9 100644 --- a/src/components/Nav/Header/Modal/__snapshots__/index.test.tsx.snap +++ b/src/components/Nav/Header/Modal/__snapshots__/index.test.tsx.snap @@ -93,7 +93,7 @@ exports[`test Nav/Header/Modal should match snapshot 1`] = ` class="ant-typography" > UI Version: - feature/issue-110 72a0e52 + feat/yumchina-login-sm2-encryption a855454
{ system: { webTitle: SQLE_DEFAULT_WEB_TITLE, webLogoUrl: '' }, }) ); + mockGetLoginEncryption(); mockGetOauth2Tips(); jest.useFakeTimers(); useLocationMock.mockReturnValue({ @@ -86,6 +90,13 @@ describe('Login', () => { return spy; }; + // plaintext path: keep existing loginV2 assertions; encryption API is a mount dep + const mockGetLoginEncryption = () => { + const spy = jest.spyOn(user, 'getLoginEncryptionV1'); + spy.mockImplementation(() => resolveImmediately({ enable: false })); + return spy; + }; + const mockGetOauth2Tips = () => { const spy = jest.spyOn(configuration, 'getOauth2Tips'); spy.mockImplementation(() =>