feat: implement "Remember Me" functionality with AES-256 encryption for secure credential storage
This commit is contained in:
@@ -24,6 +24,7 @@ export const ACCESS_TOKEN = 'access_token';
|
||||
export const REFRESH_TOKEN = 'refresh_token';
|
||||
export const THEME_KEY = 'theme';
|
||||
export const TERMINAL_THEME_KEY = 'terminal_theme_key';
|
||||
export const REMEMBER_ME_KEY = 'smatec_remember_login';
|
||||
// Global Constants
|
||||
export const LIMIT_TREE_LEVEL = 5;
|
||||
export const DEFAULT_PAGE_SIZE = 5;
|
||||
|
||||
@@ -10,6 +10,12 @@ import {
|
||||
} from '@/services/master/AuthController';
|
||||
import { checkRefreshTokenExpired } from '@/utils/jwt';
|
||||
import { getDomainTitle, getLogoImage } from '@/utils/logo';
|
||||
import {
|
||||
clearCredentials,
|
||||
hasSavedCredentials,
|
||||
loadCredentials,
|
||||
saveCredentials,
|
||||
} from '@/utils/rememberMe';
|
||||
import {
|
||||
getBrowserId,
|
||||
getRefreshToken,
|
||||
@@ -21,8 +27,17 @@ import {
|
||||
} from '@/utils/storage';
|
||||
import { LoginFormPage } from '@ant-design/pro-components';
|
||||
import { FormattedMessage, history, useIntl, useModel } from '@umijs/max';
|
||||
import { Button, ConfigProvider, Flex, Image, message, theme } from 'antd';
|
||||
import { CSSProperties, useEffect, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
ConfigProvider,
|
||||
Flex,
|
||||
Form,
|
||||
Image,
|
||||
message,
|
||||
theme,
|
||||
} from 'antd';
|
||||
import { CSSProperties, useEffect, useRef, useState } from 'react';
|
||||
import { flushSync } from 'react-dom';
|
||||
import mobifontLogo from '../../../public/mobifont-logo.png';
|
||||
import ForgotPasswordForm from './components/ForgotPasswordForm';
|
||||
@@ -72,6 +87,12 @@ const LoginPage = () => {
|
||||
const { setInitialState } = useModel('@@initialState');
|
||||
const [loginType, setLoginType] = useState<LoginType>('login');
|
||||
const [pending2faToken, setPending2faToken] = useState<string>('');
|
||||
const [pendingCredentials, setPendingCredentials] = useState<{
|
||||
email: string;
|
||||
password: string;
|
||||
rememberMe: boolean;
|
||||
} | null>(null);
|
||||
const formRef = useRef<any>(null);
|
||||
|
||||
// Listen for theme changes from ThemeSwitcherAuth
|
||||
useEffect(() => {
|
||||
@@ -122,8 +143,32 @@ const LoginPage = () => {
|
||||
checkLogin();
|
||||
}, []);
|
||||
|
||||
// Load saved credentials on mount
|
||||
useEffect(() => {
|
||||
try {
|
||||
if (hasSavedCredentials()) {
|
||||
const saved = loadCredentials();
|
||||
if (saved && formRef.current) {
|
||||
// Use setTimeout to ensure form is mounted before setting values
|
||||
setTimeout(() => {
|
||||
if (formRef.current) {
|
||||
formRef.current.setFieldsValue({
|
||||
email: saved.email,
|
||||
password: saved.password,
|
||||
rememberMe: true,
|
||||
});
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error loading saved credentials:', e);
|
||||
clearCredentials();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleLogin = async (values: any) => {
|
||||
const { email, password } = values;
|
||||
const { email, password, rememberMe } = values;
|
||||
if (loginType === 'login') {
|
||||
try {
|
||||
const resp = await apiLogin({
|
||||
@@ -133,12 +178,20 @@ const LoginPage = () => {
|
||||
});
|
||||
// Check if 2FA is enabled
|
||||
if (resp?.enabled2fa && resp?.token) {
|
||||
// Save pending 2FA token and switch to OTP form
|
||||
// Save pending credentials and 2FA token and switch to OTP form
|
||||
setPendingCredentials({ email, password, rememberMe });
|
||||
setPending2faToken(resp.token);
|
||||
setLoginType('otp');
|
||||
return;
|
||||
}
|
||||
if (resp?.token) {
|
||||
// Handle remember me - save or clear credentials
|
||||
if (rememberMe) {
|
||||
saveCredentials(email, password);
|
||||
} else {
|
||||
clearCredentials();
|
||||
}
|
||||
|
||||
setAccessToken(resp.token);
|
||||
setRefreshToken(resp.refresh_token || '');
|
||||
const userInfo = await apiQueryProfile();
|
||||
@@ -167,6 +220,18 @@ const LoginPage = () => {
|
||||
otp: values.otp || '',
|
||||
});
|
||||
if (resp?.token) {
|
||||
// Handle remember me - save or clear credentials
|
||||
if (pendingCredentials) {
|
||||
if (pendingCredentials.rememberMe) {
|
||||
saveCredentials(
|
||||
pendingCredentials.email,
|
||||
pendingCredentials.password,
|
||||
);
|
||||
} else {
|
||||
clearCredentials();
|
||||
}
|
||||
}
|
||||
|
||||
setAccessToken(resp.token);
|
||||
setRefreshToken(resp.refresh_token || '');
|
||||
const userInfo = await apiQueryProfile();
|
||||
@@ -244,6 +309,7 @@ const LoginPage = () => {
|
||||
|
||||
const handleBackToLogin = () => {
|
||||
setPending2faToken('');
|
||||
setPendingCredentials(null);
|
||||
setLoginType('login');
|
||||
};
|
||||
|
||||
@@ -261,6 +327,7 @@ const LoginPage = () => {
|
||||
>
|
||||
{contextHolder}
|
||||
<LoginFormPage
|
||||
formRef={formRef}
|
||||
backgroundImageUrl="https://mdn.alipayobjects.com/huamei_gcee1x/afts/img/A*y0ZTS6WLwvgAAAAAAAAAAAAADml6AQ/fmt.webp"
|
||||
logo={getLogoImage()}
|
||||
backgroundVideoUrl="https://gw.alipayobjects.com/v/huamei_gcee1x/afts/video/jXRBRK_VAwoAAAAAAAAAAAAAK4eUAQBr"
|
||||
@@ -290,10 +357,25 @@ const LoginPage = () => {
|
||||
{loginType === 'otp' && <OtpForm />}
|
||||
</FormWrapper>
|
||||
<Flex
|
||||
justify="flex-end"
|
||||
align="flex-start"
|
||||
justify="space-between"
|
||||
align="center"
|
||||
style={{ marginBlockEnd: 16 }}
|
||||
>
|
||||
{loginType === 'login' && (
|
||||
<Form.Item
|
||||
name="rememberMe"
|
||||
valuePropName="checked"
|
||||
initialValue={false}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<Checkbox>
|
||||
<FormattedMessage
|
||||
id="master.auth.rememberMe"
|
||||
defaultMessage="Ghi nhớ mật khẩu"
|
||||
/>
|
||||
</Checkbox>
|
||||
</Form.Item>
|
||||
)}
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
|
||||
111
src/utils/rememberMe.ts
Normal file
111
src/utils/rememberMe.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Utility functions for managing "Remember me" password saving functionality
|
||||
* Uses AES-256 encryption for secure credential storage
|
||||
*/
|
||||
|
||||
import CryptoJS from 'crypto-js';
|
||||
import {
|
||||
getRememberMeData,
|
||||
removeRememberMeData,
|
||||
setRememberMeData,
|
||||
} from './storage';
|
||||
|
||||
const SECRET_KEY = 'smatec_secret_key_2024_secure_encryption';
|
||||
|
||||
export interface RememberedCredentials {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* AES-256 encryption using CryptoJS
|
||||
* Automatically generates random IV and encodes to Base64
|
||||
* @param data Data to encrypt
|
||||
* @returns Encrypted string (base64 encoded with IV)
|
||||
*/
|
||||
function encrypt(data: string): string {
|
||||
try {
|
||||
const encrypted = CryptoJS.AES.encrypt(data, SECRET_KEY).toString();
|
||||
return encrypted;
|
||||
} catch (error) {
|
||||
console.error('Encryption error:', error);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AES-256 decryption using CryptoJS
|
||||
* Automatically extracts IV and decrypts
|
||||
* @param encrypted Encrypted data (base64 encoded with IV)
|
||||
* @returns Decrypted string
|
||||
*/
|
||||
function decrypt(encrypted: string): string {
|
||||
try {
|
||||
const decrypted = CryptoJS.AES.decrypt(encrypted, SECRET_KEY);
|
||||
const result = decrypted.toString(CryptoJS.enc.Utf8);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Decryption error:', error);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear saved credentials from localStorage
|
||||
*/
|
||||
export function clearCredentials(): void {
|
||||
removeRememberMeData();
|
||||
}
|
||||
|
||||
/**
|
||||
* Save credentials to localStorage (encrypted with secret key)
|
||||
* @param email User's email
|
||||
* @param password User's password
|
||||
*/
|
||||
export function saveCredentials(email: string, password: string): void {
|
||||
try {
|
||||
const credentials: RememberedCredentials = { email, password };
|
||||
const json = JSON.stringify(credentials);
|
||||
const encrypted = encrypt(json);
|
||||
setRememberMeData(encrypted);
|
||||
} catch (error) {
|
||||
console.error('Error saving credentials:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load credentials from localStorage
|
||||
* @returns RememberedCredentials or null if not found or error occurs
|
||||
*/
|
||||
export function loadCredentials(): RememberedCredentials | null {
|
||||
try {
|
||||
const encrypted = getRememberMeData();
|
||||
if (!encrypted) {
|
||||
return null;
|
||||
}
|
||||
const decrypted = decrypt(encrypted);
|
||||
if (!decrypted) {
|
||||
clearCredentials();
|
||||
return null;
|
||||
}
|
||||
const credentials: RememberedCredentials = JSON.parse(decrypted);
|
||||
return credentials;
|
||||
} catch (error) {
|
||||
console.error('Error loading credentials:', error);
|
||||
clearCredentials();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there are saved credentials
|
||||
* @returns true if credentials exist, false otherwise
|
||||
*/
|
||||
export function hasSavedCredentials(): boolean {
|
||||
try {
|
||||
const encrypted = getRememberMeData();
|
||||
return encrypted !== null;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
ACCESS_TOKEN,
|
||||
REFRESH_TOKEN,
|
||||
REMEMBER_ME_KEY,
|
||||
TERMINAL_THEME_KEY,
|
||||
THEME_KEY,
|
||||
} from '@/constants';
|
||||
@@ -70,19 +71,62 @@ export function getBrowserId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Remember Me Storage Operations
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Get encrypted credentials from localStorage
|
||||
*/
|
||||
export function getRememberMeData(): string | null {
|
||||
try {
|
||||
return localStorage.getItem(REMEMBER_ME_KEY);
|
||||
} catch (error) {
|
||||
console.error('Error reading remember me data:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save encrypted credentials to localStorage
|
||||
*/
|
||||
export function setRememberMeData(encryptedData: string): void {
|
||||
try {
|
||||
localStorage.setItem(REMEMBER_ME_KEY, encryptedData);
|
||||
} catch (error) {
|
||||
console.error('Error saving remember me data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove encrypted credentials from localStorage
|
||||
*/
|
||||
export function removeRememberMeData(): void {
|
||||
try {
|
||||
localStorage.removeItem(REMEMBER_ME_KEY);
|
||||
} catch (error) {
|
||||
console.error('Error removing remember me data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all localStorage data except browserId, theme and terminal theme
|
||||
*/
|
||||
export function clearAllData() {
|
||||
const browserId = localStorage.getItem('sip-browserid');
|
||||
const rememberMe = getRememberMeData();
|
||||
const theme = getTheme();
|
||||
const terminalTheme = getTerminalTheme();
|
||||
localStorage.clear();
|
||||
// Khôi phục các giá trị cần thiết
|
||||
if (browserId) {
|
||||
localStorage.setItem('sip-browserid', browserId);
|
||||
}
|
||||
localStorage.setItem(THEME_KEY, theme);
|
||||
localStorage.setItem(TERMINAL_THEME_KEY, terminalTheme);
|
||||
if (rememberMe) {
|
||||
setRememberMeData(rememberMe);
|
||||
}
|
||||
setTheme(theme);
|
||||
setTerminalTheme(terminalTheme);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user