在前后端分离架构下,前端如何通过后端合规的安全接口全程安全地获取、使用和管理加密密钥,核心是确保密钥在“传输-存储-使用-销毁”全环节不泄露、不落地、不被滥用。以下是分阶段的完整实操方案,覆盖从“接口对接”到“密钥生命周期管理”的所有安全要点:
一、前置准备:确认后端接口合规(基础前提)
前端获取密钥前,必须先确认后端接口满足以下安全规范(否则前端再防护也无效):
- 接口协议:强制
HTTPS(TLS 1.2+),禁止HTTP; - 请求方法:仅支持
POST,密钥不出现在URL中; - 防重放机制:接口要求携带
timestamp(时间戳)、nonce(随机串)、sign(签名); - 密钥格式:返回的AES密钥为32字节、IV为16字节;
- 鉴权规则:刷新密钥接口需携带有效JWT令牌,且有频率限制。
二、核心步骤:前端安全获取&使用密钥(附完整代码)
步骤1:封装安全的请求工具(axios)
前端所有请求(含密钥相关)必须通过统一的安全请求工具,处理HTTPS强制、防重放参数生成、鉴权头、异常拦截:
// src/utils/request.js
import axios from 'axios';
import {
getAppSecret } from '@/utils/env'; // 仅存储前端appSecret(非加密密钥)
// 创建axios实例,强制HTTPS(生产环境)
const service = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL, // 后端HTTPS地址,如https://api.xxx.com
timeout: 10000,
headers: {
'Content-Type': 'application/json;charset=utf-8' }
});
// 生成防重放参数(timestamp + nonce + sign)
const generateReplayParams = () => {
const timestamp = Date.now(); // 毫秒级时间戳
const nonce = Math.random().toString(36).slice(2, 34); // 32位随机串
const appSecret = getAppSecret(); // 前端唯一标识(如环境变量中的固定值,非加密密钥)
// 签名:timestamp + nonce + appSecret 做MD5(和后端规则一致)
const sign = window.crypto.createHash('md5')
.update(`${
timestamp}${
nonce}${
appSecret}`)
.digest('hex');
return {
timestamp, nonce, sign };
};
// 请求拦截器:添加防重放参数+鉴权头
service.interceptors.request.use(
(config) => {
// 生产环境强制HTTPS
if (import.meta.env.PROD && window.location.protocol !== 'https:') {
throw new Error('生产环境必须使用HTTPS协议!');
}
// 1. 添加防重放参数(所有安全接口必带)
const replayParams = generateReplayParams();
config.data = {
...config.data, ...replayParams };
// 2. 添加JWT鉴权头(刷新密钥接口需带)
const token = localStorage.getItem('token'); // 仅token可落地,加密密钥不可
if (token) {
config.headers.Authorization = `Bearer ${
token}`;
}
return config;
},
(error) => Promise.reject(error)
);
// 响应拦截器:统一校验&错误处理
service.interceptors.response.use(
(res) => {
const {
code, msg, data, requestId } = res.data;
// 1. 校验业务状态码
if (code !== 200) {
throw new Error(`接口失败[${
requestId}]:${
msg}`);
}
// 2. 密钥接口额外校验格式(ck=密钥,iv=初始向量)
if (config.url.includes('login') || config.url.includes('refresh-crypto-key')) {
if (!data.ck || !data.iv || data.ck.length !== 32 || data.iv.length !== 16) {
throw new Error(`密钥格式错误[${
requestId}]:长度不符合规范`);
}
}
return res.data;
},
(error) => {
console.error('请求失败:', error.message);
return Promise.reject(error);
}
);
export default service;
步骤2:登录时首次获取密钥(核心入口)
登录接口是前端获取密钥的唯一合法入口,需先验证用户身份,再接收并存储密钥(仅内存):
<!-- src/views/Login.vue -->
<template>
<div>
<input v-model="username" placeholder="用户名" />
<input v-model="password" type="password" placeholder="密码" />
<button @click="handleLogin" :disabled="loading">登录</button>
</div>
</template>
<script setup>
import { ref } from 'vue';
import request from '@/utils/request';
import { updateCryptoKey, clearCryptoKey } from '@/utils/crypto';
import { useUserStore } from '@/store/user';
const username = ref('');
const password = ref('');
const loading = ref(false);
const userStore = useUserStore();
// 登录并安全获取密钥
const handleLogin = async () => {
loading.value = true;
try {
// 1. 密码前端哈希(避免明文传输,和后端校验规则一致)
const pwdHash = window.crypto.createHash('md5')
.update(password.value)
.digest('hex');
// 2. 调用登录接口(HTTPS + 防重放参数自动添加)
const res = await request.post('/api/v1/auth/login', {
username: username.value,
password: pwdHash // 哈希后传输
});
const { token, ck, iv, expireAt } = res.data;
// 3. 密钥仅存入内存(绝对禁止localStorage/sessionStorage)
updateCryptoKey(ck, iv);
// 4. 仅token可落地存储(用于后续刷新密钥鉴权)
localStorage.setItem('token', token);
// 5. 记录密钥过期时间(用于后续刷新)
localStorage.setItem('cryptoKeyExpire', expireAt);
// 更新用户状态(Pinia自动加密存储)
userStore.setUser({ name: username.value });
alert('登录成功,密钥已安全获取');
} catch (err) {
clearCryptoKey(); // 失败时清空内存密钥
alert(`获取密钥失败:${err.message}`);
} finally {
loading.value = false;
}
};
</script>
步骤3:密钥的安全存储与使用(仅内存)
密钥工具函数需确保密钥仅存在于内存,且使用前校验有效性:
// src/utils/crypto.js
import CryptoJS from 'crypto-js';
// 🔴 核心:密钥仅存于内存变量,初始为null(无硬编码)
let SECRET_KEY = null;
let IV = null;
// 动态更新密钥(仅接收合规密钥)
export const updateCryptoKey = (newKey, newIV) => {
// 双重校验:类型+长度
if (typeof newKey !== 'string' || typeof newIV !== 'string') {
throw new Error('密钥必须为字符串类型');
}
if (newKey.length !== 32 || newIV.length !== 16) {
throw new Error('密钥需32字节,IV需16字节');
}
SECRET_KEY = CryptoJS.enc.Utf8.parse(newKey);
IV = CryptoJS.enc.Utf8.parse(newIV);
};
// 清空内存密钥(登出/异常时调用)
export const clearCryptoKey = () => {
SECRET_KEY = null;
IV = null;
};
// 加密函数(使用前校验密钥有效性)
export const encrypt = (data) => {
if (!SECRET_KEY || !IV) {
throw new Error('密钥未初始化,请先登录');
}
try {
const dataStr = typeof data === 'string' ? data : JSON.stringify(data);
return CryptoJS.AES.encrypt(dataStr, SECRET_KEY, {
iv: IV,
mode: CryptoJS.mode.CBC,
padding: CryptoJS.pad.Pkcs7
}).toString();
} catch (err) {
console.error('加密失败:', err);
return '';
}
};
// 解密函数(同理)
export const decrypt = (encryptedStr) => {
if (!SECRET_KEY || !IV) throw new Error('密钥未初始化,请先登录');
if (!encryptedStr) return null;
try {
const decrypted = CryptoJS.AES.decrypt(encryptedStr, SECRET_KEY, {
iv: IV,
mode: CryptoJS.mode.CBC,
padding: CryptoJS.pad.Pkcs7
});
return decrypted.toString(CryptoJS.enc.Utf8) || null;
} catch (err) {
console.error('解密失败:', err);
return null;
}
};
步骤4:定期刷新密钥(降低泄露风险)
基于后端返回的密钥过期时间,提前调用刷新接口更新密钥:
// src/utils/keyRefresh.js
import request from '@/utils/request';
import {
updateCryptoKey, clearCryptoKey } from '@/utils/crypto';
// 刷新密钥(仅用户登录后调用)
export const refreshCryptoKey = async () => {
try {
// 调用刷新密钥接口(自动携带JWT鉴权头)
const res = await request.post('/api/v1/auth/refresh-crypto-key');
const {
ck, iv, expireAt } = res.data;
// 更新内存密钥(旧密钥失效)
updateCryptoKey(ck, iv);
// 更新过期时间
localStorage.setItem('cryptoKeyExpire', expireAt);
console.log('密钥已安全刷新');
return true;
} catch (err) {
clearCryptoKey(); // 刷新失败清空密钥,强制重新登录
console.error('刷新密钥失败:', err);
return false;
}
};
// 启动密钥刷新定时器(全局仅一次)
export const startKeyRefreshTimer = () => {
// 检查过期时间,提前5分钟刷新
const checkExpire = () => {
const expireAt = Number(localStorage.getItem('cryptoKeyExpire') || 0);
const now = Date.now();
if (expireAt - now < 5 * 60 * 1000 && expireAt > now) {
refreshCryptoKey();
}
// 过期则清空密钥,强制重新登录
if (expireAt < now) {
clearCryptoKey();
localStorage.removeItem('token');
alert('密钥已过期,请重新登录');
}
};
// 每分钟检查一次
checkExpire(); // 初始化立即检查
setInterval(checkExpire, 60 * 1000);
};
步骤5:登出/异常时销毁密钥(安全收尾)
用户登出或会话异常时,必须清空内存密钥,避免残留:
<!-- src/components/Header.vue -->
<script setup>
import { useUserStore } from '@/store/user';
import { clearCryptoKey } from '@/utils/crypto';
const userStore = useUserStore();
const handleLogout = () => {
// 1. 清空内存密钥(核心)
clearCryptoKey();
// 2. 清除落地的token和过期时间
localStorage.removeItem('token');
localStorage.removeItem('cryptoKeyExpire');
// 3. 重置Pinia状态
userStore.$reset();
// 4. 跳转登录页
window.location.href = '/login';
};
</script>
三、关键安全防护补充
- 禁止密钥落地:无论 localStorage/sessionStorage/Cookie,都绝对不能存储密钥,仅保留在内存变量中;
- 防XSS攻击:前端开启CSP(内容安全策略),避免恶意脚本窃取内存中的密钥;
- 请求失败处理:密钥相关接口请求失败时,立即清空内存密钥,防止使用无效/过期密钥;
- 避免密钥日志:前端控制台日志禁止打印密钥,生产环境关闭所有调试日志;
- 多标签页同步:通过
localStorage监听事件,同步多标签页的密钥状态(仅同步“密钥已失效”状态,不同步密钥本身)。
总结(核心安全要点)
- 传输安全:所有密钥相关请求强制HTTPS,请求体带防重放参数,密码哈希传输;
- 存储安全:密钥仅存于前端内存,登出/过期立即清空,禁止任何形式的本地落地;
- 使用安全:加解密前校验密钥有效性,格式不符合直接终止操作;
- 生命周期安全:登录时获取、定期刷新、异常/登出时销毁,全程管控密钥有效期。
通过这套方案,前端能完全合规、安全地使用后端提供的安全接口获取和管理密钥,从根本上避免密钥泄露风险,同时兼容前后端分离的架构特点。