浏览代码

feat: encrypt the privacy data when it is persisted (#6056)

* 对私密数据持久化时执行加密
* 将锁屏密码合并到accessStore中进行加密
Netfan 2 周之前
父节点
当前提交
aa27a2f7a1

+ 3 - 0
apps/web-antd/.env

@@ -3,3 +3,6 @@ VITE_APP_TITLE=Vben Admin Antd
 
 # 应用命名空间,用于缓存、store等功能的前缀,确保隔离
 VITE_APP_NAMESPACE=vben-web-antd
+
+# 对store进行加密的密钥,在将store持久化到localStorage时会使用该密钥进行加密
+VITE_APP_STORE_SECURE_KEY=please-replace-me-with-your-own-key

+ 3 - 0
apps/web-ele/.env

@@ -3,3 +3,6 @@ VITE_APP_TITLE=Vben Admin Ele
 
 # 应用命名空间,用于缓存、store等功能的前缀,确保隔离
 VITE_APP_NAMESPACE=vben-web-ele
+
+# 对store进行加密的密钥,在将store持久化到localStorage时会使用该密钥进行加密
+VITE_APP_STORE_SECURE_KEY=please-replace-me-with-your-own-key

+ 3 - 0
apps/web-naive/.env

@@ -3,3 +3,6 @@ VITE_APP_TITLE=Vben Admin Naive
 
 # 应用命名空间,用于缓存、store等功能的前缀,确保隔离
 VITE_APP_NAMESPACE=vben-web-naive
+
+# 对store进行加密的密钥,在将store持久化到localStorage时会使用该密钥进行加密
+VITE_APP_STORE_SECURE_KEY=please-replace-me-with-your-own-key

+ 3 - 3
packages/effects/layouts/src/basic/layout.vue

@@ -12,7 +12,7 @@ import {
   updatePreferences,
   usePreferences,
 } from '@vben/preferences';
-import { useLockStore } from '@vben/stores';
+import { useAccessStore } from '@vben/stores';
 import { cloneDeep, mapTree } from '@vben/utils';
 
 import { VbenAdminLayout } from '@vben-core/layout-ui';
@@ -49,7 +49,7 @@ const {
   sidebarCollapsed,
   theme,
 } = usePreferences();
-const lockStore = useLockStore();
+const accessStore = useAccessStore();
 const { refresh } = useRefresh();
 
 const sidebarTheme = computed(() => {
@@ -356,7 +356,7 @@ const headerSlots = computed(() => {
       />
 
       <Transition v-if="preferences.widget.lockScreen" name="slide-up">
-        <slot v-if="lockStore.isLockScreen" name="lock-screen"></slot>
+        <slot v-if="accessStore.isLockScreen" name="lock-screen"></slot>
       </Transition>
 
       <template v-if="preferencesButtonPosition.fixed">

+ 4 - 4
packages/effects/layouts/src/widgets/lock-screen/lock-screen.vue

@@ -3,7 +3,7 @@ import { computed, reactive, ref } from 'vue';
 
 import { LockKeyhole } from '@vben/icons';
 import { $t, useI18n } from '@vben/locales';
-import { storeToRefs, useLockStore } from '@vben/stores';
+import { storeToRefs, useAccessStore } from '@vben/stores';
 
 import { useScrollLock } from '@vben-core/composables';
 import { useVbenForm, z } from '@vben-core/form-ui';
@@ -26,7 +26,7 @@ withDefaults(defineProps<Props>(), {
 defineEmits<{ toLogin: [] }>();
 
 const { locale } = useI18n();
-const lockStore = useLockStore();
+const accessStore = useAccessStore();
 
 const now = useNow();
 const meridiem = useDateFormat(now, 'A');
@@ -35,7 +35,7 @@ const minute = useDateFormat(now, 'mm');
 const date = useDateFormat(now, 'YYYY-MM-DD dddd', { locales: locale.value });
 
 const showUnlockForm = ref(false);
-const { lockScreenPassword } = storeToRefs(lockStore);
+const { lockScreenPassword } = storeToRefs(accessStore);
 
 const [Form, { form, validate }] = useVbenForm(
   reactive({
@@ -66,7 +66,7 @@ async function handleSubmit() {
   const { valid } = await validate();
   if (valid) {
     if (validPass.value) {
-      lockStore.unlockScreen();
+      accessStore.unlockScreen();
     } else {
       form.setFieldError('password', $t('authentication.passwordErrorTip'));
     }

+ 3 - 3
packages/effects/layouts/src/widgets/user-dropdown/user-dropdown.vue

@@ -9,7 +9,7 @@ import { useHoverToggle } from '@vben/hooks';
 import { LockKeyhole, LogOut } from '@vben/icons';
 import { $t } from '@vben/locales';
 import { preferences, usePreferences } from '@vben/preferences';
-import { useLockStore } from '@vben/stores';
+import { useAccessStore } from '@vben/stores';
 import { isWindowsOs } from '@vben/utils';
 
 import { useVbenModal } from '@vben-core/popup-ui';
@@ -82,7 +82,7 @@ const emit = defineEmits<{ logout: [] }>();
 
 const { globalLockScreenShortcutKey, globalLogoutShortcutKey } =
   usePreferences();
-const lockStore = useLockStore();
+const accessStore = useAccessStore();
 const [LockModal, lockModalApi] = useVbenModal({
   connectedComponent: LockScreenModal,
 });
@@ -133,7 +133,7 @@ function handleOpenLock() {
 
 function handleSubmitLock(lockScreenPassword: string) {
   lockModalApi.close();
-  lockStore.lockScreen(lockScreenPassword);
+  accessStore.lockScreen(lockScreenPassword);
 }
 
 function handleLogout() {

+ 1 - 0
packages/stores/package.json

@@ -25,6 +25,7 @@
     "@vben-core/typings": "workspace:*",
     "pinia": "catalog:",
     "pinia-plugin-persistedstate": "catalog:",
+    "secure-ls": "catalog:",
     "vue": "catalog:",
     "vue-router": "catalog:"
   }

+ 25 - 1
packages/stores/src/modules/access.ts

@@ -27,6 +27,14 @@ interface AccessState {
    * 是否已经检查过权限
    */
   isAccessChecked: boolean;
+  /**
+   * 是否锁屏状态
+   */
+  isLockScreen: boolean;
+  /**
+   * 锁屏密码
+   */
+  lockScreenPassword?: string;
   /**
    * 登录是否过期
    */
@@ -61,6 +69,10 @@ export const useAccessStore = defineStore('core-access', {
       }
       return findMenu(this.accessMenus, path);
     },
+    lockScreen(password: string) {
+      this.isLockScreen = true;
+      this.lockScreenPassword = password;
+    },
     setAccessCodes(codes: string[]) {
       this.accessCodes = codes;
     },
@@ -82,10 +94,20 @@ export const useAccessStore = defineStore('core-access', {
     setRefreshToken(token: AccessToken) {
       this.refreshToken = token;
     },
+    unlockScreen() {
+      this.isLockScreen = false;
+      this.lockScreenPassword = undefined;
+    },
   },
   persist: {
     // 持久化
-    pick: ['accessToken', 'refreshToken', 'accessCodes'],
+    pick: [
+      'accessToken',
+      'refreshToken',
+      'accessCodes',
+      'isLockScreen',
+      'lockScreenPassword',
+    ],
   },
   state: (): AccessState => ({
     accessCodes: [],
@@ -93,6 +115,8 @@ export const useAccessStore = defineStore('core-access', {
     accessRoutes: [],
     accessToken: null,
     isAccessChecked: false,
+    isLockScreen: false,
+    lockScreenPassword: undefined,
     loginExpired: false,
     refreshToken: null,
   }),

+ 0 - 1
packages/stores/src/modules/index.ts

@@ -1,4 +1,3 @@
 export * from './access';
-export * from './lock';
 export * from './tabbar';
 export * from './user';

+ 0 - 31
packages/stores/src/modules/lock.test.ts

@@ -1,31 +0,0 @@
-import { createPinia, setActivePinia } from 'pinia';
-import { beforeEach, describe, expect, it } from 'vitest';
-
-import { useLockStore } from './lock';
-
-describe('useLockStore', () => {
-  beforeEach(() => {
-    setActivePinia(createPinia());
-  });
-
-  it('should initialize with correct default state', () => {
-    const store = useLockStore();
-    expect(store.isLockScreen).toBe(false);
-    expect(store.lockScreenPassword).toBeUndefined();
-  });
-
-  it('should lock screen with a password', () => {
-    const store = useLockStore();
-    store.lockScreen('1234');
-    expect(store.isLockScreen).toBe(true);
-    expect(store.lockScreenPassword).toBe('1234');
-  });
-
-  it('should unlock screen and clear password', () => {
-    const store = useLockStore();
-    store.lockScreen('1234');
-    store.unlockScreen();
-    expect(store.isLockScreen).toBe(false);
-    expect(store.lockScreenPassword).toBeUndefined();
-  });
-});

+ 0 - 33
packages/stores/src/modules/lock.ts

@@ -1,33 +0,0 @@
-import { defineStore } from 'pinia';
-
-interface AppState {
-  /**
-   * 是否锁屏状态
-   */
-  isLockScreen: boolean;
-  /**
-   * 锁屏密码
-   */
-  lockScreenPassword?: string;
-}
-
-export const useLockStore = defineStore('core-lock', {
-  actions: {
-    lockScreen(password: string) {
-      this.isLockScreen = true;
-      this.lockScreenPassword = password;
-    },
-
-    unlockScreen() {
-      this.isLockScreen = false;
-      this.lockScreenPassword = undefined;
-    },
-  },
-  persist: {
-    pick: ['isLockScreen', 'lockScreenPassword'],
-  },
-  state: (): AppState => ({
-    isLockScreen: false,
-    lockScreenPassword: undefined,
-  }),
-});

+ 18 - 1
packages/stores/src/setup.ts

@@ -3,6 +3,7 @@ import type { Pinia } from 'pinia';
 import type { App } from 'vue';
 
 import { createPinia } from 'pinia';
+import SecureLS from 'secure-ls';
 
 let pinia: Pinia;
 
@@ -20,11 +21,27 @@ export async function initStores(app: App, options: InitStoreOptions) {
   const { createPersistedState } = await import('pinia-plugin-persistedstate');
   pinia = createPinia();
   const { namespace } = options;
+  const ls = new SecureLS({
+    encodingType: 'aes',
+    encryptionSecret: import.meta.env.VITE_APP_STORE_SECURE_KEY,
+    isCompression: true,
+    // @ts-ignore secure-ls does not have a type definition for this
+    metaKey: `${namespace}-secure-meta`,
+  });
   pinia.use(
     createPersistedState({
       // key $appName-$store.id
       key: (storeKey) => `${namespace}-${storeKey}`,
-      storage: localStorage,
+      storage: import.meta.env.DEV
+        ? localStorage
+        : {
+            getItem(key) {
+              return ls.get(key);
+            },
+            setItem(key, value) {
+              ls.set(key, value);
+            },
+          },
     }),
   );
   app.use(pinia);

+ 3 - 0
playground/.env

@@ -3,3 +3,6 @@ VITE_APP_TITLE=Vben Admin
 
 # 应用命名空间,用于缓存、store等功能的前缀,确保隔离
 VITE_APP_NAMESPACE=vben-web-play
+
+# 对store进行加密的密钥,在将store持久化到localStorage时会使用该密钥进行加密
+VITE_APP_STORE_SECURE_KEY=please-replace-me-with-your-own-key

+ 26 - 0
pnpm-lock.yaml

@@ -393,6 +393,9 @@ catalogs:
     sass:
       specifier: ^1.86.3
       version: 1.86.3
+    secure-ls:
+      specifier: ^2.0.0
+      version: 2.0.0
     sortablejs:
       specifier: ^1.15.6
       version: 1.15.6
@@ -1778,6 +1781,9 @@ importers:
       pinia-plugin-persistedstate:
         specifier: 'catalog:'
         version: 4.2.0(magicast@0.3.5)(pinia@2.3.1(typescript@5.8.3)(vue@3.5.13(typescript@5.8.3)))
+      secure-ls:
+        specifier: 'catalog:'
+        version: 2.0.0
       vue:
         specifier: ^3.5.13
         version: 3.5.13(typescript@5.8.3)
@@ -5666,6 +5672,9 @@ packages:
   crossws@0.3.4:
     resolution: {integrity: sha512-uj0O1ETYX1Bh6uSgktfPvwDiPYGQ3aI4qVsaC/LWpkIzGj1nUYm5FK3K+t11oOlpN01lGbprFCH4wBlKdJjVgw==}
 
+  crypto-js@4.2.0:
+    resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==}
+
   crypto-random-string@2.0.0:
     resolution: {integrity: sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==}
     engines: {node: '>=8'}
@@ -7666,6 +7675,10 @@ packages:
     peerDependencies:
       vue: ^3.5.13
 
+  lz-string@1.5.0:
+    resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
+    hasBin: true
+
   magic-string@0.25.9:
     resolution: {integrity: sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==}
 
@@ -9231,6 +9244,10 @@ packages:
     resolution: {integrity: sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==}
     engines: {node: '>=4'}
 
+  secure-ls@2.0.0:
+    resolution: {integrity: sha512-Wgtnw0QSm0v7gVKv11nOoeyGS65EThGXnBB7jfd4IhZd2eq3B4AMPcXAL5qJ1h55+Qolun7TONTwX7H5m6e2pQ==}
+    engines: {node: '>=8.0'}
+
   seemly@0.3.10:
     resolution: {integrity: sha512-2+SMxtG1PcsL0uyhkumlOU6Qo9TAQ/WyH7tthnPIOQB05/12jz9naq6GZ6iZ6ApVsO3rr2gsnTf3++OV63kE1Q==}
 
@@ -14938,6 +14955,8 @@ snapshots:
     dependencies:
       uncrypto: 0.1.3
 
+  crypto-js@4.2.0: {}
+
   crypto-random-string@2.0.0: {}
 
   cspell-config-lib@8.18.1:
@@ -17132,6 +17151,8 @@ snapshots:
     dependencies:
       vue: 3.5.13(typescript@5.8.3)
 
+  lz-string@1.5.0: {}
+
   magic-string@0.25.9:
     dependencies:
       sourcemap-codec: 1.4.8
@@ -18814,6 +18835,11 @@ snapshots:
       extend-shallow: 2.0.1
       kind-of: 6.0.3
 
+  secure-ls@2.0.0:
+    dependencies:
+      crypto-js: 4.2.0
+      lz-string: 1.5.0
+
   seemly@0.3.10: {}
 
   select@1.1.2: {}

+ 1 - 0
pnpm-workspace.yaml

@@ -147,6 +147,7 @@ catalog:
   rollup: ^4.39.0
   rollup-plugin-visualizer: ^5.14.0
   sass: ^1.86.3
+  secure-ls: ^2.0.0
   sortablejs: ^1.15.6
   stylelint: ^16.18.0
   stylelint-config-recess-order: ^5.1.1