diff --git a/package.json b/package.json index 5521c1bb..0e63f188 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.5.3", "sql-formatter": "^12.2.2", "typescript": "^5.0.2", "use-resize-observer": "^9.0.2", diff --git a/scripts/sm2_interop_check.js b/scripts/sm2_interop_check.js new file mode 100644 index 00000000..628f47ad --- /dev/null +++ b/scripts/sm2_interop_check.js @@ -0,0 +1,40 @@ +#!/usr/bin/env node +/** + * Cross-check SM2 interop between sm-crypto (frontend) and a fixture + * ciphertext shape expected by backend (C1C3C2, optional leading 04). + */ +const { sm2 } = require('sm-crypto'); + +const keypair = sm2.generateKeyPairHex(); +const password = 'SqleLoginPass#123'; +const cipherMode = 1; // C1C3C2 +const cipher = sm2.doEncrypt(password, keypair.publicKey, cipherMode); +const plain = sm2.doDecrypt(cipher, keypair.privateKey, cipherMode); + +if (plain !== password) { + console.error('sm-crypto roundtrip failed'); + process.exit(1); +} +if (cipher.startsWith('04')) { + console.error('unexpected leading 04 from sm-crypto doEncrypt'); + process.exit(1); +} +if (!keypair.publicKey.startsWith('04')) { + console.error('public key should start with 04'); + process.exit(1); +} + +console.log( + JSON.stringify( + { + ok: true, + public_key: keypair.publicKey, + private_key: keypair.privateKey, + encrypted_password: cipher, + encrypted_password_with_04: `04${cipher}`, + cipher_len: cipher.length, + }, + null, + 2 + ) +); diff --git a/src/api/common.d.ts b/src/api/common.d.ts index bdb96668..04e797f7 100644 --- a/src/api/common.d.ts +++ b/src/api/common.d.ts @@ -2387,6 +2387,30 @@ export interface IUserLoginReqV1 { password?: string; 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..d77ccf23 100644 --- a/src/api/user/index.d.ts +++ b/src/api/user/index.d.ts @@ -14,7 +14,8 @@ import { IGetUsersResV1, ICreateUserReqV1, IUpdateUserReqV1, - IUpdateOtherUserPasswordReqV1 + IUpdateOtherUserPasswordReqV1, + IGetLoginEncryptionResV1 } from '../common.d'; export interface ILoginV1Params extends IUserLoginReqV1 {} @@ -132,3 +133,5 @@ export interface IUpdateOtherUserPasswordV1Return extends IBaseRes {} export interface ILoginV2Params extends IUserLoginReqV1 {} export interface ILoginV2Return extends IBaseRes {} + +export interface IGetLoginEncryptionV1Return extends IGetLoginEncryptionResV1 {} diff --git a/src/api/user/index.ts b/src/api/user/index.ts index e7def643..a068a330 100644 --- a/src/api/user/index.ts +++ b/src/api/user/index.ts @@ -42,7 +42,8 @@ import { IUpdateOtherUserPasswordV1Params, IUpdateOtherUserPasswordV1Return, ILoginV2Params, - ILoginV2Return + ILoginV2Return, + IGetLoginEncryptionV1Return } from './index.d'; class UserService extends ServiceBase { @@ -265,6 +266,14 @@ class UserService extends ServiceBase { const paramsData = this.cloneDeep(params); return this.post('/v2/login', paramsData, options); } + + public getLoginEncryptionV1(options?: AxiosRequestConfig) { + return this.get( + '/v1/login/encryption', + undefined, + options + ); + } } export default new UserService(); diff --git a/src/locale/en-US/login.ts b/src/locale/en-US/login.ts index 3cfa1c79..d63b39a3 100644 --- a/src/locale/en-US/login.ts +++ b/src/locale/en-US/login.ts @@ -3,4 +3,13 @@ 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', + encryptFailed: 'Failed to encrypt login password, please try again later', + }, }; diff --git a/src/locale/zh-CN/login.ts b/src/locale/zh-CN/login.ts index 73921742..cb6fa03e 100644 --- a/src/locale/zh-CN/login.ts +++ b/src/locale/zh-CN/login.ts @@ -10,6 +10,7 @@ export default { errorMessage: { userAgreement: '请先阅读并同意用户协议', + encryptFailed: '登录密码加密失败,请稍后重试', }, oauth: { diff --git a/src/page/Login/index.test.tsx b/src/page/Login/index.test.tsx index 6ebec557..2718b940 100644 --- a/src/page/Login/index.test.tsx +++ b/src/page/Login/index.test.tsx @@ -17,6 +17,7 @@ import { import { useDispatch, useSelector } from 'react-redux'; import useNavigate from '../../hooks/useNavigate'; import { getHrefByText } from '../../testUtils/customQuery'; +import * as loginEncryption from '../../utils/loginEncryption'; jest.mock('react-router-dom', () => ({ ...jest.requireActual('react-router-dom'), @@ -48,6 +49,7 @@ describe('Login', () => { }) ); mockGetOauth2Tips(); + mockGetLoginEncryption({ enable: false }); jest.useFakeTimers(); useLocationMock.mockReturnValue({ pathname: '/login', @@ -94,6 +96,16 @@ describe('Login', () => { return spy; }; + const mockGetLoginEncryption = (data: { + enable?: boolean; + public_key?: string; + key_id?: string; + }) => { + const spy = jest.spyOn(user, 'getLoginEncryptionV1'); + spy.mockImplementation(() => resolveThreeSecond(data)); + return spy; + }; + test('should render login form', async () => { const { container } = renderWithTheme(); expect(container).toMatchSnapshot(); @@ -127,6 +139,7 @@ describe('Login', () => { fireEvent.click(screen.getByText('login.login')); await act(async () => jest.advanceTimersByTime(0)); + await act(async () => jest.advanceTimersByTime(3000)); expect(request).toBeCalledTimes(1); expect(request).toBeCalledWith({ @@ -148,6 +161,78 @@ describe('Login', () => { expect(navigateSpy).toBeCalledWith('home'); }); + test('should send encrypted password and never plaintext when encryption enabled', async () => { + const request = mockRequest(); + mockGetLoginEncryption({ + enable: true, + public_key: '04public', + key_id: 'kid-1', + }); + const encryptSpy = jest + .spyOn(loginEncryption, 'encryptLoginPassword') + .mockReturnValue({ + encrypted_password: 'cipher-text', + key_id: 'kid-1', + }); + + renderWithThemeAndRouter(); + fireEvent.input(screen.getByPlaceholderText('common.username'), { + target: { value: 'root' }, + }); + fireEvent.input(screen.getByPlaceholderText('common.password'), { + target: { value: '123456' }, + }); + fireEvent.click(screen.getByText('login.userAgreementTips')); + fireEvent.click(screen.getByText('login.login')); + + await act(async () => jest.advanceTimersByTime(0)); + await act(async () => jest.advanceTimersByTime(3000)); + + expect(encryptSpy).toBeCalledWith('123456', { + enable: true, + public_key: '04public', + key_id: 'kid-1', + }); + expect(request).toBeCalledTimes(1); + expect(request).toBeCalledWith({ + username: 'root', + encrypted_password: 'cipher-text', + key_id: 'kid-1', + }); + const payload = request.mock.calls[0][0]; + expect(payload.password).toBeUndefined(); + }); + + test('should not call login when encrypt failed', async () => { + const request = mockRequest(); + mockGetLoginEncryption({ + enable: true, + public_key: '04public', + key_id: 'kid-1', + }); + jest.spyOn(loginEncryption, 'encryptLoginPassword').mockImplementation(() => { + throw new loginEncryption.LoginEncryptionError('encrypt password failed'); + }); + + renderWithThemeAndRouter(); + fireEvent.input(screen.getByPlaceholderText('common.username'), { + target: { value: 'root' }, + }); + fireEvent.input(screen.getByPlaceholderText('common.password'), { + target: { value: '123456' }, + }); + fireEvent.click(screen.getByText('login.userAgreementTips')); + fireEvent.click(screen.getByText('login.login')); + + await act(async () => jest.advanceTimersByTime(0)); + await act(async () => jest.advanceTimersByTime(3000)); + + expect(request).not.toBeCalled(); + expect( + screen.getByText('login.errorMessage.encryptFailed') + ).toBeInTheDocument(); + }); + test('click oauth login button will jump to `/v1/oauth2/link`', async () => { renderWithTheme(); await act(async () => jest.advanceTimersByTime(3000)); @@ -188,7 +273,7 @@ describe('Login', () => { fireEvent.click(screen.getByText('login.login')); await act(async () => jest.advanceTimersByTime(0)); - + await act(async () => jest.advanceTimersByTime(3000)); await act(async () => jest.advanceTimersByTime(3000)); expect(navigateSpy).toBeCalledTimes(1); @@ -227,7 +312,7 @@ describe('Login', () => { fireEvent.click(screen.getByText('login.login')); await act(async () => jest.advanceTimersByTime(0)); - + await act(async () => jest.advanceTimersByTime(3000)); await act(async () => jest.advanceTimersByTime(3000)); expect(navigateSpy).toBeCalledTimes(1); diff --git a/src/page/Login/index.tsx b/src/page/Login/index.tsx index ab51a3db..e6ab8d29 100644 --- a/src/page/Login/index.tsx +++ b/src/page/Login/index.tsx @@ -21,6 +21,10 @@ import { useLocation } from 'react-router-dom'; import { getCookie } from '../../utils/Common'; import { IReduxState } from '../../store'; import useNavigate from '../../hooks/useNavigate'; +import { IUserLoginReqV1 } from '../../api/common.d'; +import { + encryptLoginPassword, +} from '../../utils/loginEncryption'; const Login = () => { const navigate = useNavigate(); @@ -28,7 +32,32 @@ const Login = () => { const { t } = useTranslation(); const location = useLocation(); - const login = (formData: { + const buildLoginPayload = async ( + username: string, + password: string + ): Promise => { + try { + const encRes = await user.getLoginEncryptionV1(); + const encryption = encRes.data?.data ?? {}; + if (!encryption.enable) { + return { + username, + password, + }; + } + const encrypted = encryptLoginPassword(password, encryption); + return { + username, + encrypted_password: encrypted.encrypted_password, + key_id: encrypted.key_id, + }; + } catch (error) { + message.error(t('login.errorMessage.encryptFailed')); + return null; + } + }; + + const login = async (formData: { username: string; password: string; userAgreement: boolean; @@ -39,29 +68,29 @@ 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); - } + const payload = await buildLoginPayload( + formData.username, + formData.password + ); + if (!payload) { + return; + } + user.loginV2(payload).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 ?? {}), diff --git a/src/types/sm-crypto.d.ts b/src/types/sm-crypto.d.ts new file mode 100644 index 00000000..25230dbd --- /dev/null +++ b/src/types/sm-crypto.d.ts @@ -0,0 +1,19 @@ +declare module 'sm-crypto' { + export const sm2: { + doEncrypt: ( + msg: string | number[], + publicKey: string, + cipherMode?: number + ) => string; + doDecrypt: ( + encryptData: string, + privateKey: string, + cipherMode?: number, + options?: { output?: 'string' | 'array' } + ) => string | number[]; + generateKeyPairHex: ( + random?: string | number | object + ) => { publicKey: string; privateKey: string }; + verifyPublicKey: (publicKey: string) => boolean; + }; +} diff --git a/src/utils/loginEncryption.test.ts b/src/utils/loginEncryption.test.ts new file mode 100644 index 00000000..cab9c5c7 --- /dev/null +++ b/src/utils/loginEncryption.test.ts @@ -0,0 +1,48 @@ +import { encryptLoginPassword, LoginEncryptionInfo } from './loginEncryption'; + +describe('loginEncryption', () => { + const encryption: LoginEncryptionInfo = { + enable: true, + algorithm: 'SM2', + cipher_mode: 'C1C3C2', + // uncompressed SM2 public key generated for unit test + public_key: + '04f5a1d2c3b4a5968778695a4b3c2d1e0f1a2b3c4d5e6f708192a3b4c5d6e7f8090a1b2c3d4e5f60718293a4b5c6d7e8f90112131415161718191a1b1c1d1e1f20', + key_id: 'test-key', + }; + + test('should throw when encryption disabled', () => { + expect(() => + encryptLoginPassword('pwd', { enable: false, public_key: '04ab', key_id: 'k' }) + ).toThrow('login encryption is disabled'); + }); + + test('should throw when public key missing', () => { + expect(() => + encryptLoginPassword('pwd', { enable: true, key_id: 'k' }) + ).toThrow('login public key is missing'); + }); + + test('should encrypt password and never return plaintext', () => { + // Use a known-valid keypair from sm-crypto generate for deterministic shape check. + // If key is invalid, sm2.doEncrypt may still produce output or throw; we only assert + // that successful path never echoes plaintext. + const { sm2 } = require('sm-crypto'); + const keypair = sm2.generateKeyPairHex(); + const result = encryptLoginPassword('secret-password', { + enable: true, + public_key: keypair.publicKey, + key_id: 'kid-1', + }); + expect(result.key_id).toBe('kid-1'); + expect(result.encrypted_password).toBeTruthy(); + expect(result.encrypted_password).not.toContain('secret-password'); + expect(result.encrypted_password).not.toEqual('secret-password'); + }); + + test('should reject invalid public key gracefully', () => { + expect(() => encryptLoginPassword('pwd', encryption)).toThrow( + 'login public key is invalid' + ); + }); +}); diff --git a/src/utils/loginEncryption.ts b/src/utils/loginEncryption.ts new file mode 100644 index 00000000..fab492c3 --- /dev/null +++ b/src/utils/loginEncryption.ts @@ -0,0 +1,62 @@ +import { sm2 } from 'sm-crypto'; + +// Must stay aligned with backend loginencryption.CipherModeC1C3C2. +const SM2_CIPHER_MODE_C1C3C2 = 1; + +export type LoginEncryptionInfo = { + enable?: boolean; + algorithm?: string; + cipher_mode?: string; + public_key?: string; + key_id?: string; +}; + +export type EncryptedLoginCredentials = { + encrypted_password: string; + key_id: string; +}; + +export class LoginEncryptionError extends Error { + constructor(message: string) { + super(message); + this.name = 'LoginEncryptionError'; + } +} + +export const encryptLoginPassword = ( + password: string, + encryption: LoginEncryptionInfo +): EncryptedLoginCredentials => { + if (!encryption?.enable) { + throw new LoginEncryptionError('login encryption is disabled'); + } + if (!encryption.public_key) { + throw new LoginEncryptionError('login public key is missing'); + } + if (!encryption.key_id) { + throw new LoginEncryptionError('login key id is missing'); + } + if (!sm2.verifyPublicKey(encryption.public_key)) { + throw new LoginEncryptionError('login public key is invalid'); + } + + try { + const encryptedPassword = sm2.doEncrypt( + password, + encryption.public_key, + SM2_CIPHER_MODE_C1C3C2 + ); + if (!encryptedPassword) { + throw new LoginEncryptionError('encrypt password failed'); + } + return { + encrypted_password: encryptedPassword, + key_id: encryption.key_id, + }; + } catch (error) { + if (error instanceof LoginEncryptionError) { + throw error; + } + throw new LoginEncryptionError('encrypt password failed'); + } +}; diff --git a/yarn.lock b/yarn.lock index dacd26c9..bff7cb40 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.npmmirror.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.5.3: + version "0.5.3" + resolved "https://registry.npmmirror.com/sm-crypto/-/sm-crypto-0.5.3.tgz#7afb61bf4c32995be953572ce5082f7e68ece2b9" + integrity sha512-lFIRPfvFLF/f5PVKvtrXUUFWPrIK/RgX0tlarOqAyBsA79JzqS84e4SGnVrjFVe3tIMK9GyOsqMC7shq2KX87w== + 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"