feat: 加固本地缓存安全

This commit is contained in:
nap0o committed 2026-02-28 13:25:46 +08:00
1 parent 694714a11d
commit 06b418ec51
10 files changed
+146 -256

No files matched your search

+4 -3
View File
@@ -10,8 +10,9 @@
[**Demo演示**](https://2fa.nezha.pp.ua) https://2fa.nezha.pp.ua
- 设置任何用户都能登录,不受登录白名单限制,正常部署,只有设置的白名单用户才能登录
- 演示数据可以任意修改、删除;
- 演示站默认允许任何用户登录,区别于正常部署只有白名单用户才允许登录
- 演示站数据可以任意修改、删除
---
@@ -162,7 +163,7 @@ cd backend
npx wrangler d1 execute 2fauth-db-dev --local --env dev --file=schema.sql
# 4. 终端启动应用
# 终端 1:
```bash
cd backend && npm run dev
```
+3 -2
View File
@@ -48,7 +48,7 @@ auth.post('/callback/:provider', async (c) => {
const service = getService(c);
// 调用 Service 层处理登录
const { token, userInfo } = await service.handleOAuthCallback(providerName, body);
const { token, userInfo, deviceKey } = await service.handleOAuthCallback(providerName, body);
// 1. 设置 httpOnly 的鉴权 Cookie
setCookie(c, 'auth_token', token, {
@@ -71,7 +71,8 @@ auth.post('/callback/:provider', async (c) => {
return c.json({
success: true,
userInfo
userInfo,
deviceKey
});
});
+23 -3
View File
@@ -26,9 +26,9 @@ export class AuthService {
}
/**
* OAuth Callback 处理,进行鉴权并签发系统内部 JWT token
* OAuth Callback 处理,生成会话并返回附加参数
*/
async handleOAuthCallback(providerName: string, body: any): Promise<{ token: string, userInfo: OAuthUserInfo }> {
async handleOAuthCallback(providerName: string, body: any): Promise<{ token: string, userInfo: OAuthUserInfo, deviceKey: string }> {
const provider = getOAuthProvider(providerName, this.env);
let params: string | URLSearchParams;
@@ -53,7 +53,27 @@ export class AuthService {
// 签发 Token
const token = await this.generateSystemToken(userInfo);
return { token, userInfo };
// 附加客户端签名因子
const deviceKey = await this.generateDeviceKey(userInfo.id);
return { token, userInfo, deviceKey };
}
/**
* 生成客户端绑定标识
*/
private async generateDeviceKey(userId: string): Promise<string> {
const secret = this.env.JWT_SECRET;
const encoder = new TextEncoder();
const keyMaterial = await crypto.subtle.importKey(
'raw',
encoder.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
const signature = await crypto.subtle.sign('HMAC', keyMaterial, encoder.encode(userId + "device_salt_offline_key"));
return Array.from(new Uint8Array(signature)).map(b => b.toString(16).padStart(2, '0')).join('');
}
/**
+11 -4
View File
@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
import { ref } from 'vue'
import { request } from '@/shared/utils/request'
import { useVaultStore } from '@/features/vault/store/vaultStore'
import { removeIdbItem } from '@/shared/utils/idb'
export const useUserStore = defineStore('user', () => {
const getStoredUser = () => {
@@ -19,13 +20,19 @@ export const useUserStore = defineStore('user', () => {
localStorage.setItem('userInfo', JSON.stringify(info))
}
const clearUserInfo = () => {
const clearUserInfo = async () => {
userInfo.value = {}
localStorage.removeItem('userInfo')
localStorage.removeItem('secure_vault')
localStorage.removeItem('backup_providers_cache')
// 锁定保险箱
// 清理本地持久化运行配置
try {
await removeIdbItem('device_salt')
} catch (e) {
console.error('Failed to remove device_salt from IDB', e)
}
const vaultStore = useVaultStore()
vaultStore.lock()
}
@@ -36,7 +43,7 @@ export const useUserStore = defineStore('user', () => {
} catch (e) {
console.error('Logout request failed', e)
} finally {
clearUserInfo()
await clearUserInfo()
}
}
@@ -48,7 +55,7 @@ export const useUserStore = defineStore('user', () => {
return true
}
} catch (e) {
clearUserInfo()
await clearUserInfo()
return false
}
return false
@@ -31,6 +31,7 @@ import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Loading } from '@element-plus/icons-vue'
import { useUserStore } from '@/features/auth/store/userStore'
import { setIdbItem } from '@/shared/utils/idb'
const route = useRoute()
const router = useRouter()
@@ -125,6 +126,11 @@ onMounted(async () => {
if (data.success) {
// 登录成功!Token 已写入 httpOnly Cookie
// 持久化客户端参数
if (data.deviceKey) {
await setIdbItem('device_salt', data.deviceKey)
}
// 立即调用接口确认会话有效性,并更新用户信息
const userStore = useUserStore()
await userStore.fetchUserInfo()
@@ -228,15 +228,11 @@ const isRestoring = ref(false)
// --- API 交互 ---
const fetchProviders = async () => {
// 1. 离线优先:尝试加载加密缓存 (仅在保险箱已解锁时有效)
if (vaultStore.isUnlocked) {
const cachedEncrypted = await vaultStore.getEncryptedBackupProviders()
if (cachedEncrypted && Array.isArray(cachedEncrypted)) {
providers.value = cachedEncrypted
// 有缓存时静默更新,不转圈圈
} else {
isLoading.value = true
}
// 1. 离线优先:尝试加载加密缓存
const cachedEncrypted = await vaultStore.getEncryptedBackupProviders()
if (cachedEncrypted && Array.isArray(cachedEncrypted)) {
providers.value = cachedEncrypted
// 有缓存时静默更新,不转圈圈
} else {
isLoading.value = true
}
@@ -245,10 +241,8 @@ const fetchProviders = async () => {
const res = await request('/api/backups/providers')
if (res.success) {
providers.value = res.providers
// 2. 更新加密缓存 (如果保险箱已解锁)
if (vaultStore.isUnlocked) {
await vaultStore.saveEncryptedBackupProviders(res.providers)
}
// 2. 更新加密缓存
await vaultStore.saveEncryptedBackupProviders(res.providers)
}
} finally { isLoading.value = false }
}
@@ -1,12 +1,7 @@
<template>
<div class="vault-list-wrapper" style="min-height: 400px;">
<!-- 0. 保险箱锁定状态 -->
<div v-if="!vaultStore.isUnlocked" class="vault-lock-wrapper">
<VaultUnlock @unlocked="handleUnlocked" />
</div>
<!-- 1. 加载状态 (已解锁且本地缓存尚未挂载,或者正在首次/后台请求时但无数据) -->
<div v-else-if="(isLoading || isFetching) && vault.length === 0" class="loading-state" style="display: flex; flex-direction: column; justify-content: center; align-items: center; min-height: 400px; color: var(--el-text-color-secondary);">
<!-- 1. 加载状态 (本地缓存尚未挂载,或者正在首次/后台请求时但无数据) -->
<div v-if="(isLoading || isFetching) && vault.length === 0" class="loading-state" style="display: flex; flex-direction: column; justify-content: center; align-items: center; min-height: 400px; color: var(--el-text-color-secondary);">
<el-icon class="is-loading" :size="48" style="margin-bottom: 20px; color: var(--el-color-primary);"><Loading /></el-icon>
<p style="font-size: 16px; letter-spacing: 1px;">数据获取中, 请稍候...</p>
</div>
@@ -179,9 +174,6 @@ import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/vue-que
import { generateTOTP } from '@/shared/utils/totp'
import { copyToClipboard } from '@/shared/utils/common'
// 异步引入解锁组件,避免循环依赖
const VaultUnlock = defineAsyncComponent(() => import('@/features/vault/components/vaultUnlock.vue'))
const emit = defineEmits(['switch-tab'])
const queryClient = useQueryClient()
const vaultStore = useVaultStore()
@@ -528,10 +520,8 @@ const copySecret = async () => {
}
onMounted(() => {
// 如果已经解锁 (例如从其他 Tab 切回来),尝试加载缓存
if (vaultStore.isUnlocked) {
handleUnlocked()
}
// 组件加载即尝试读取本地离线数据
handleUnlocked()
globalTimer = setInterval(updateVaultStatus, 1000)
})
@@ -1,159 +0,0 @@
<template>
<div class="vault-unlock-container">
<el-card class="unlock-card" shadow="hover">
<div class="icon-wrapper">
<el-icon :size="48" color="#409EFF"><Lock /></el-icon>
</div>
<h2 class="title">{{ vaultStore.hasVault ? '解锁本地缓存' : '设置缓存密码' }}</h2>
<p class="subtitle" style="margin-bottom: 10px;">
{{ vaultStore.hasVault
? '请输入您的缓存密码以加载本地离线数据'
: '设置一个密码用于将 2FA 数据安全地缓存在本机'
}}
</p>
<div class="form-container">
<el-input
v-model="password"
type="password"
show-password
placeholder="请输入密码"
@keyup.enter="handleSubmit"
:prefix-icon="Key"
size="large"
/>
<div v-if="!vaultStore.hasVault" style="margin-top: 15px;">
<el-input
v-model="confirmPassword"
type="password"
show-password
placeholder="再次确认密码"
@keyup.enter="handleSubmit"
:prefix-icon="Key"
size="large"
/>
</div>
<div v-if="!vaultStore.hasVault" style="margin-top: 15px; text-align: left;">
<el-alert
title="缓存密码用途说明"
type="info"
:closable="false"
show-icon
>
<p style="margin: 5px 0 0 0; line-height: 1.5; font-size: 13px;">
此密码仅用于将 2FA 数据强加密保存到您的浏览器本地缓存中,防止<span style="color: var(--el-color-primary); font-weight: bold;">离线使用</span>时的明文泄露。<br/>
即使您意外遗忘了它,也可以随时<span style="color: var(--el-color-success); font-weight: bold;">清除并重置</span>,重新登录即可从云端恢复您的全部 2FA 数据。
</p>
</el-alert>
</div>
<div v-if="errorMsg" class="error-msg">
{{ errorMsg }}
</div>
<el-button
type="primary"
size="large"
class="submit-btn"
:loading="loading"
@click="handleSubmit"
>
{{ vaultStore.hasVault ? '验证并进入' : '开启缓存保护' }}
</el-button>
<div v-if="vaultStore.hasVault" class="reset-link">
<el-button link type="info" size="small" @click="handleResetPrompt">忘记密码?清除本地离线缓存</el-button>
</div>
</div>
</el-card>
</div>
</template>
<script setup>
import { ref, onMounted, h } from 'vue'
import { Lock, Key } from '@element-plus/icons-vue'
import { useVaultStore } from '@/features/vault/store/vaultStore'
import { ElMessage, ElMessageBox } from 'element-plus'
const vaultStore = useVaultStore()
const emit = defineEmits(['unlocked'])
const password = ref('')
const confirmPassword = ref('')
const loading = ref(false)
const errorMsg = ref('')
onMounted(() => {
vaultStore.init()
})
const handleSubmit = async () => {
if (!password.value) {
errorMsg.value = '密码不能为空'
return
}
loading.value = true
errorMsg.value = ''
try {
if (vaultStore.hasVault) {
// 解锁模式
await vaultStore.unlock(password.value)
ElMessage.success('解锁成功')
emit('unlocked')
} else {
// 创建模式
if (password.value.length < 6) {
throw new Error('密码长度至少需要 6 位')
}
if (password.value !== confirmPassword.value) {
throw new Error('两次输入的密码不一致')
}
await vaultStore.setup(password.value)
ElMessage.success('保险箱创建成功')
emit('unlocked')
}
} catch (e) {
errorMsg.value = e.message
// 震动反馈 (如果支持)
if (navigator.vibrate) navigator.vibrate(200)
} finally {
loading.value = false
}
}
const handleResetPrompt = async () => {
try {
await ElMessageBox.confirm(
h('div', null, [
h('p', { style: 'margin-bottom: 10px; font-weight: 500;' }, '密码找不到了?没关系!'),
h('p', { style: 'font-size: 13px; color: var(--el-text-color-secondary); line-height: 1.6;' },
'点击下方按钮,系统将为您强制清理当前浏览器的本地加密缓存。清理完成后,您只需像初次访问一样,重新设置一个新的缓存密码即可。您的云端原始数据不会受到任何影响。'
)
]),
'清除缓存锁',
{
confirmButtonText: '确定清除',
cancelButtonText: '点错了,返回',
type: 'warning',
center: true
}
)
// User confirmed
localStorage.removeItem('secure_vault')
vaultStore.init()
password.value = ''
confirmPassword.value = ''
errorMsg.value = ''
ElMessage.success('本地缓存已完全清除,请重新设置密码')
} catch (err) {
if (err !== 'cancel') console.error(err)
}
}
</script>
+34 -58
View File
@@ -1,91 +1,61 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { encryptDataWithPassword, decryptDataWithPassword } from '@/shared/utils/crypto'
import { getIdbItem } from '@/shared/utils/idb'
export const useVaultStore = defineStore('vault', () => {
const isUnlocked = ref(false)
const isUnlocked = ref(true) // 默认挂载状态
const hasVault = ref(false)
const isDirty = ref(false) // 标记缓存是否已因变更操作而过期
const _password = ref(null) // 仅保存在内存中,刷新页面即丢失
// 初始化检查:判断本地是否有加密数据,并尝试恢复会话
// 初始化检查
const init = async () => {
const encrypted = localStorage.getItem('secure_vault')
hasVault.value = !!encrypted
if (!hasVault.value) return
// 模式 A: 尝试从 sessionStorage 恢复会话级解锁状态
const sessionPassword = sessionStorage.getItem('vault_session_key')
if (sessionPassword) {
try {
// 验证密码是否仍然有效
await decryptDataWithPassword(encrypted, sessionPassword)
// 验证通过,自动解锁
_password.value = sessionPassword
isUnlocked.value = true
} catch (e) {
// 密码无效,清除会话并锁定
lock()
}
}
}
// 解锁保险箱
const unlock = async (password) => {
const encrypted = localStorage.getItem('secure_vault')
if (!encrypted) throw new Error('尚未创建保险箱')
// 获取本地运行时参数
const getDeviceKey = async () => {
try {
await decryptDataWithPassword(encrypted, password)
_password.value = password
isUnlocked.value = true
sessionStorage.setItem('vault_session_key', password)
return true
return await getIdbItem('device_salt')
} catch (e) {
throw new Error('密码错误')
return null
}
}
// 创建/重置保险箱
const setup = async (password, initialData = { vault: [] }) => {
const encrypted = await encryptDataWithPassword(initialData, password)
localStorage.setItem('secure_vault', encrypted)
_password.value = password
hasVault.value = true
isUnlocked.value = true
sessionStorage.setItem('vault_session_key', password)
}
// 锁定保险箱
const lock = () => {
_password.value = null
isUnlocked.value = false
sessionStorage.removeItem('vault_session_key')
}
// 获取数据 (使用内存密码解密)
// 加载数据
const getData = async () => {
if (!isUnlocked.value || !_password.value) return null
const key = await getDeviceKey()
if (!key) throw new Error('设备授权信息已失效,请重新登录')
const encrypted = localStorage.getItem('secure_vault')
if (!encrypted) return { vault: [] }
return await decryptDataWithPassword(encrypted, _password.value)
try {
return await decryptDataWithPassword(encrypted, key)
} catch (e) {
// 若解密失败(极少情况如果用户清库或密钥被篡改),强行回退给空库
return { vault: [] }
}
}
// 保存数据 (使用内存密码加密)
// 持久化数据
const saveData = async (data) => {
if (!isUnlocked.value || !_password.value) throw new Error('保险箱已锁定')
const encrypted = await encryptDataWithPassword(data, _password.value)
const key = await getDeviceKey()
if (!key) throw new Error('设备授权信息已失效,请重新登录')
const encrypted = await encryptDataWithPassword(data, key)
localStorage.setItem('secure_vault', encrypted)
hasVault.value = true
}
// --- 备份云配置的专属加密缓存 ---
const getEncryptedBackupProviders = async () => {
if (!isUnlocked.value || !_password.value) return null
const key = await getDeviceKey()
if (!key) return null
const encrypted = localStorage.getItem('backup_providers_cache')
if (!encrypted) return null
try {
const decrypted = await decryptDataWithPassword(encrypted, _password.value)
const decrypted = await decryptDataWithPassword(encrypted, key)
return decrypted.providers || null
} catch (e) {
return null
@@ -93,11 +63,17 @@ export const useVaultStore = defineStore('vault', () => {
}
const saveEncryptedBackupProviders = async (providers) => {
if (!isUnlocked.value || !_password.value) return
const encrypted = await encryptDataWithPassword({ providers }, _password.value)
const key = await getDeviceKey()
if (!key) return
const encrypted = await encryptDataWithPassword({ providers }, key)
localStorage.setItem('backup_providers_cache', encrypted)
}
// 残留供部分旧UI避免报错的空函数 (即将被清理)
const unlock = async () => { return true }
const setup = async () => { }
const lock = () => { }
return {
isUnlocked,
hasVault,
+54
View File
@@ -0,0 +1,54 @@
// frontend/src/shared/utils/idb.js
const DB_NAME = 'VaultAppDB'
const STORE_NAME = 'app_key_store'
const DB_VERSION = 1
function openDB() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION)
request.onerror = () => reject(request.error)
request.onsuccess = () => resolve(request.result)
request.onupgradeneeded = (event) => {
const db = event.target.result
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME)
}
}
})
}
export async function setIdbItem(key, value) {
const db = await openDB()
return new Promise((resolve, reject) => {
const transaction = db.transaction(STORE_NAME, 'readwrite')
const store = transaction.objectStore(STORE_NAME)
const request = store.put(value, key)
request.onsuccess = () => resolve()
request.onerror = () => reject(request.error)
})
}
export async function getIdbItem(key) {
const db = await openDB()
return new Promise((resolve, reject) => {
const transaction = db.transaction(STORE_NAME, 'readonly')
const store = transaction.objectStore(STORE_NAME)
const request = store.get(key)
request.onsuccess = () => resolve(request.result)
request.onerror = () => reject(request.error)
})
}
export async function removeIdbItem(key) {
const db = await openDB()
return new Promise((resolve, reject) => {
const transaction = db.transaction(STORE_NAME, 'readwrite')
const store = transaction.objectStore(STORE_NAME)
const request = store.delete(key)
request.onsuccess = () => resolve()
request.onerror = () => reject(request.error)
})
}