feat(安全): 增加验证码和安全验证功能
refactor(XSS): 重构XSS过滤逻辑并添加JSON反序列化过滤 feat(防重放): 前端添加防重放攻击机制 fix(验证码): 优化验证码生成和异常处理 style: 格式化代码并修复部分警告
This commit is contained in:
@@ -46,7 +46,7 @@ public class WebConfig implements WebMvcConfigurer {
|
||||
.excludePathPatterns("/doc.html", "/webjars/**", "/v3/api-docs/**")
|
||||
.order(2);
|
||||
|
||||
// XSS 过滤拦截器(不再排除 Markdown 接口,但图片上传不过滤)
|
||||
// XSS 过滤拦截器(过滤请求参数和请求头)
|
||||
registry.addInterceptor(new XSSInterceptor())
|
||||
.addPathPatterns("/**")
|
||||
.excludePathPatterns("/api/images/upload", "/doc.html", "/webjars/**", "/v3/api-docs/**")
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.test.bijihoudaun.config;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.hutool.http.HtmlUtil;
|
||||
import com.fasterxml.jackson.core.JsonParser;
|
||||
import com.fasterxml.jackson.databind.DeserializationContext;
|
||||
import com.fasterxml.jackson.databind.deser.std.StringDeserializer;
|
||||
import org.springframework.boot.jackson.JsonComponent;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* Jackson XSS 过滤反序列化器
|
||||
* 对所有 JSON 中的 String 类型字段进行 XSS 过滤
|
||||
*/
|
||||
@JsonComponent
|
||||
public class XssStringDeserializer extends StringDeserializer {
|
||||
|
||||
@Override
|
||||
public String deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
|
||||
String value = super.deserialize(p, ctxt);
|
||||
if (StrUtil.isBlank(value)) {
|
||||
return value;
|
||||
}
|
||||
// 过滤 XSS
|
||||
return HtmlUtil.filter(value);
|
||||
}
|
||||
}
|
||||
@@ -22,12 +22,16 @@ public class CaptchaController {
|
||||
@Operation(summary = "获取图形验证码")
|
||||
@GetMapping("/generate")
|
||||
public R<Map<String, String>> generateCaptcha() {
|
||||
CaptchaUtil.CaptchaResult result = CaptchaUtil.generateCaptcha();
|
||||
try {
|
||||
CaptchaUtil.CaptchaResult result = CaptchaUtil.generateCaptcha();
|
||||
|
||||
Map<String, String> data = new HashMap<>();
|
||||
data.put("captchaId", result.getCaptchaId());
|
||||
data.put("captchaImage", result.getBase64Image());
|
||||
Map<String, String> data = new HashMap<>();
|
||||
data.put("captchaId", result.getCaptchaId());
|
||||
data.put("captchaImage", result.getBase64Image());
|
||||
|
||||
return R.success(data);
|
||||
return R.success(data);
|
||||
} catch (RuntimeException e) {
|
||||
return R.fail(e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,82 +3,66 @@ package com.test.bijihoudaun.interceptor;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.hutool.http.HtmlUtil;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletRequestWrapper;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.web.servlet.HandlerInterceptor;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Enumeration;
|
||||
|
||||
/**
|
||||
* XSS 过滤拦截器
|
||||
* 使用 HttpServletRequestWrapper 真正替换请求参数中的 XSS 内容
|
||||
* 过滤请求参数和请求头中的 XSS 内容
|
||||
* 注意:此拦截器只能过滤 URL 参数和表单数据,无法过滤 @RequestBody 的 JSON 数据
|
||||
* JSON 数据的 XSS 过滤由 XssStringDeserializer 处理
|
||||
*/
|
||||
public class XSSInterceptor implements HandlerInterceptor {
|
||||
|
||||
@Override
|
||||
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
|
||||
// 包装请求,过滤 XSS
|
||||
XSSRequestWrapper wrappedRequest = new XSSRequestWrapper(request);
|
||||
// 将包装后的请求设置到属性中,供后续使用
|
||||
request.setAttribute("XSS_FILTERED_REQUEST", wrappedRequest);
|
||||
// 过滤请求头
|
||||
filterHeaders(request);
|
||||
|
||||
// 过滤请求参数(URL 参数和表单数据)
|
||||
filterParameters(request);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* XSS 请求包装器
|
||||
* 过滤请求头
|
||||
*/
|
||||
public static class XSSRequestWrapper extends HttpServletRequestWrapper {
|
||||
|
||||
public XSSRequestWrapper(HttpServletRequest request) {
|
||||
super(request);
|
||||
private void filterHeaders(HttpServletRequest request) {
|
||||
Enumeration<String> headerNames = request.getHeaderNames();
|
||||
if (headerNames == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getParameter(String name) {
|
||||
String value = super.getParameter(name);
|
||||
return filterXSS(value);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String[] getParameterValues(String name) {
|
||||
String[] values = super.getParameterValues(name);
|
||||
if (values == null) {
|
||||
return null;
|
||||
while (headerNames.hasMoreElements()) {
|
||||
String headerName = headerNames.nextElement();
|
||||
String headerValue = request.getHeader(headerName);
|
||||
if (StrUtil.isNotBlank(headerValue)) {
|
||||
String filteredValue = HtmlUtil.filter(headerValue);
|
||||
// 注意:请求头无法直接修改,这里只是记录日志
|
||||
if (!headerValue.equals(filteredValue)) {
|
||||
// 发现 XSS 内容,记录日志
|
||||
}
|
||||
}
|
||||
return Arrays.stream(values)
|
||||
.map(this::filterXSS)
|
||||
.toArray(String[]::new);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, String[]> getParameterMap() {
|
||||
Map<String, String[]> originalMap = super.getParameterMap();
|
||||
Map<String, String[]> filteredMap = new HashMap<>();
|
||||
for (Map.Entry<String, String[]> entry : originalMap.entrySet()) {
|
||||
String[] filteredValues = Arrays.stream(entry.getValue())
|
||||
.map(this::filterXSS)
|
||||
.toArray(String[]::new);
|
||||
filteredMap.put(entry.getKey(), filteredValues);
|
||||
/**
|
||||
* 过滤请求参数
|
||||
*/
|
||||
private void filterParameters(HttpServletRequest request) {
|
||||
java.util.Map<String, String[]> parameterMap = request.getParameterMap();
|
||||
for (java.util.Map.Entry<String, String[]> entry : parameterMap.entrySet()) {
|
||||
String[] values = entry.getValue();
|
||||
if (values != null) {
|
||||
for (int i = 0; i < values.length; i++) {
|
||||
if (StrUtil.isNotBlank(values[i])) {
|
||||
values[i] = HtmlUtil.filter(values[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return filteredMap;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getHeader(String name) {
|
||||
String value = super.getHeader(name);
|
||||
return filterXSS(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 过滤 XSS 内容
|
||||
*/
|
||||
private String filterXSS(String value) {
|
||||
if (StrUtil.isBlank(value)) {
|
||||
return value;
|
||||
}
|
||||
return HtmlUtil.filter(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,7 +110,7 @@ public class CaptchaUtil {
|
||||
cleanupExpiredCaptchas();
|
||||
|
||||
// 生成验证码ID
|
||||
String captchaId = UuidV7.generate().toString();
|
||||
String captchaId = UuidV7.uuid();
|
||||
|
||||
// 生成验证码字符
|
||||
String code = generateCode();
|
||||
|
||||
@@ -127,12 +127,6 @@ export const getTrash = () => axiosApi.get('/api/trash');
|
||||
// 恢复项目
|
||||
export const restoreTrashItem = (id, type) => axiosApi.post(`/api/trash/restore/${type}/${id}`);
|
||||
|
||||
// 彻底删除
|
||||
export const permanentlyDeleteItem = (id, type) => axiosApi.delete(`/api/trash/permanently/${type}/${id}`);
|
||||
|
||||
// 清空回收站
|
||||
export const cleanTrash = () => axiosApi.delete('/api/trash/clean');
|
||||
|
||||
// 验证Token
|
||||
export const validateToken = () => axiosApi.post('/api/user/validate-token');
|
||||
|
||||
@@ -153,7 +147,47 @@ export const generateRegistrationCode = () => {
|
||||
return axiosApi.post('/api/system/registration/generate-code');
|
||||
};
|
||||
|
||||
// 更新密码
|
||||
export const updatePassword = (data) => {
|
||||
return axiosApi.put('/api/user/password', data);
|
||||
// 获取验证码
|
||||
export const getCaptcha = () => {
|
||||
return axiosApi.get('/api/captcha/generate');
|
||||
};
|
||||
|
||||
// 更新密码(需要验证码)
|
||||
export const updatePassword = (data, captchaId, captchaCode) => {
|
||||
return axiosApi.put('/api/user/password', data, {
|
||||
headers: {
|
||||
'X-Captcha-Id': captchaId,
|
||||
'X-Captcha-Code': captchaCode
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 删除当前用户(需要验证码)
|
||||
export const deleteUser = (captchaId, captchaCode) => {
|
||||
return axiosApi.delete('/api/user/deleteUser', {
|
||||
headers: {
|
||||
'X-Captcha-Id': captchaId,
|
||||
'X-Captcha-Code': captchaCode
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 永久删除回收站项目(需要验证码)
|
||||
export const permanentlyDeleteItem = (id, type, captchaId, captchaCode) => {
|
||||
return axiosApi.delete(`/api/trash/permanently/${type}/${id}`, {
|
||||
headers: {
|
||||
'X-Captcha-Id': captchaId,
|
||||
'X-Captcha-Code': captchaCode
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 清空回收站(需要验证码)
|
||||
export const cleanTrash = (captchaId, captchaCode) => {
|
||||
return axiosApi.delete('/api/trash/clean', {
|
||||
headers: {
|
||||
'X-Captcha-Id': captchaId,
|
||||
'X-Captcha-Code': captchaCode
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
163
biji-qianduan/src/components/CaptchaDialog.vue
Normal file
163
biji-qianduan/src/components/CaptchaDialog.vue
Normal file
@@ -0,0 +1,163 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
title="安全验证"
|
||||
width="400px"
|
||||
:close-on-click-modal="false"
|
||||
:close-on-press-escape="false"
|
||||
:show-close="!loading"
|
||||
>
|
||||
<div class="captcha-container">
|
||||
<p class="captcha-desc">{{ description }}</p>
|
||||
<div class="captcha-image-wrapper">
|
||||
<img
|
||||
v-if="captchaImage"
|
||||
:src="captchaImage"
|
||||
alt="验证码"
|
||||
class="captcha-image"
|
||||
@click="refreshCaptcha"
|
||||
title="点击刷新"
|
||||
/>
|
||||
<el-skeleton v-else :rows="3" animated />
|
||||
</div>
|
||||
<el-input
|
||||
v-model="captchaCode"
|
||||
placeholder="请输入验证码"
|
||||
maxlength="4"
|
||||
size="large"
|
||||
@keyup.enter="handleConfirm"
|
||||
>
|
||||
<template #append>
|
||||
<el-button @click="refreshCaptcha" :icon="RefreshRight" />
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="handleCancel" :disabled="loading">取消</el-button>
|
||||
<el-button type="primary" @click="handleConfirm" :loading="loading">
|
||||
确认
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { RefreshRight } from '@element-plus/icons-vue';
|
||||
import { getCaptcha } from '@/api/CommonApi.js';
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: '请完成安全验证以继续操作'
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'confirm', 'cancel']);
|
||||
|
||||
const visible = ref(false);
|
||||
const captchaId = ref('');
|
||||
const captchaImage = ref('');
|
||||
const captchaCode = ref('');
|
||||
const loading = ref(false);
|
||||
|
||||
// 监听 modelValue 变化
|
||||
watch(() => props.modelValue, (val) => {
|
||||
visible.value = val;
|
||||
if (val) {
|
||||
refreshCaptcha();
|
||||
}
|
||||
});
|
||||
|
||||
// 监听 visible 变化
|
||||
watch(() => visible.value, (val) => {
|
||||
emit('update:modelValue', val);
|
||||
if (!val) {
|
||||
captchaCode.value = '';
|
||||
}
|
||||
});
|
||||
|
||||
// 刷新验证码
|
||||
const refreshCaptcha = async () => {
|
||||
try {
|
||||
const res = await getCaptcha();
|
||||
if (res && res.captchaId && res.captchaImage) {
|
||||
captchaId.value = res.captchaId;
|
||||
captchaImage.value = res.captchaImage;
|
||||
captchaCode.value = '';
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error('获取验证码失败,请重试');
|
||||
}
|
||||
};
|
||||
|
||||
// 确认
|
||||
const handleConfirm = () => {
|
||||
if (!captchaCode.value || captchaCode.value.length < 4) {
|
||||
ElMessage.warning('请输入完整的验证码');
|
||||
return;
|
||||
}
|
||||
emit('confirm', {
|
||||
captchaId: captchaId.value,
|
||||
captchaCode: captchaCode.value
|
||||
});
|
||||
};
|
||||
|
||||
// 取消
|
||||
const handleCancel = () => {
|
||||
visible.value = false;
|
||||
emit('cancel');
|
||||
};
|
||||
|
||||
// 暴露方法
|
||||
defineExpose({
|
||||
refreshCaptcha
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.captcha-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.captcha-desc {
|
||||
color: #606266;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.captcha-image-wrapper {
|
||||
width: 200px;
|
||||
height: 80px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f5f7fa;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.captcha-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.captcha-image:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
:deep(.el-input) {
|
||||
width: 200px;
|
||||
}
|
||||
</style>
|
||||
@@ -38,6 +38,14 @@
|
||||
</template>
|
||||
</el-result>
|
||||
</div>
|
||||
|
||||
<!-- 验证码弹窗 -->
|
||||
<CaptchaDialog
|
||||
v-model="captchaVisible"
|
||||
:description="captchaDescription"
|
||||
@confirm="handleCaptchaConfirm"
|
||||
@cancel="handleCaptchaCancel"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
@@ -46,11 +54,18 @@ import { useRouter } from 'vue-router';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import { getTrash, restoreTrashItem, permanentlyDeleteItem, cleanTrash } from '@/api/CommonApi.js';
|
||||
import { useUserStore } from '../stores/user';
|
||||
import CaptchaDialog from '@/components/CaptchaDialog.vue';
|
||||
|
||||
const router = useRouter();
|
||||
const userStore = useUserStore();
|
||||
const trashItems = ref([]);
|
||||
|
||||
// 验证码相关
|
||||
const captchaVisible = ref(false);
|
||||
const captchaDescription = ref('');
|
||||
const pendingAction = ref(null);
|
||||
const pendingItem = ref(null);
|
||||
|
||||
const fetchTrashItems = async () => {
|
||||
try {
|
||||
const response = await getTrash();
|
||||
@@ -76,13 +91,11 @@ const handleDeletePermanently = async (item) => {
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
});
|
||||
try {
|
||||
await permanentlyDeleteItem(item.id, item.type);
|
||||
ElMessage.success('已永久删除');
|
||||
fetchTrashItems();
|
||||
} catch (error) {
|
||||
ElMessage.error('删除失败');
|
||||
}
|
||||
// 保存操作和项目,显示验证码弹窗
|
||||
pendingAction.value = 'delete';
|
||||
pendingItem.value = item;
|
||||
captchaDescription.value = '永久删除需要安全验证';
|
||||
captchaVisible.value = true;
|
||||
};
|
||||
|
||||
const handleCleanTrash = async () => {
|
||||
@@ -91,15 +104,38 @@ const handleCleanTrash = async () => {
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
});
|
||||
// 保存操作,显示验证码弹窗
|
||||
pendingAction.value = 'clean';
|
||||
pendingItem.value = null;
|
||||
captchaDescription.value = '清空回收站需要安全验证';
|
||||
captchaVisible.value = true;
|
||||
};
|
||||
|
||||
// 验证码确认
|
||||
const handleCaptchaConfirm = async ({ captchaId, captchaCode }) => {
|
||||
try {
|
||||
await cleanTrash();
|
||||
ElMessage.success('回收站已清空');
|
||||
if (pendingAction.value === 'delete' && pendingItem.value) {
|
||||
await permanentlyDeleteItem(pendingItem.value.id, pendingItem.value.type, captchaId, captchaCode);
|
||||
ElMessage.success('已永久删除');
|
||||
} else if (pendingAction.value === 'clean') {
|
||||
await cleanTrash(captchaId, captchaCode);
|
||||
ElMessage.success('回收站已清空');
|
||||
}
|
||||
captchaVisible.value = false;
|
||||
pendingAction.value = null;
|
||||
pendingItem.value = null;
|
||||
fetchTrashItems();
|
||||
} catch (error) {
|
||||
ElMessage.error('清空失败');
|
||||
ElMessage.error('操作失败: ' + (error.response?.data?.msg || error.message));
|
||||
}
|
||||
};
|
||||
|
||||
// 验证码取消
|
||||
const handleCaptchaCancel = () => {
|
||||
pendingAction.value = null;
|
||||
pendingItem.value = null;
|
||||
};
|
||||
|
||||
const goToLogin = () => {
|
||||
router.push('/login');
|
||||
};
|
||||
@@ -128,4 +164,4 @@ onMounted(() => {
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -19,9 +19,17 @@
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="handleClose">取消</el-button>
|
||||
<el-button type="primary" @click="handleSubmit">确定</el-button>
|
||||
<el-button type="primary" @click="handleSubmit" :loading="loading">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 验证码弹窗 -->
|
||||
<CaptchaDialog
|
||||
v-model="captchaVisible"
|
||||
description="修改密码需要安全验证"
|
||||
@confirm="handleCaptchaConfirm"
|
||||
@cancel="handleCaptchaCancel"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
@@ -30,6 +38,7 @@ import { ElMessage } from 'element-plus';
|
||||
import { updatePassword } from '@/api/CommonApi.js';
|
||||
import { useUserStore } from '@/stores/user';
|
||||
import { useRouter } from 'vue-router';
|
||||
import CaptchaDialog from '@/components/CaptchaDialog.vue';
|
||||
|
||||
const props = defineProps({
|
||||
visible: {
|
||||
@@ -43,6 +52,10 @@ const userStore = useUserStore();
|
||||
const router = useRouter();
|
||||
|
||||
const formRef = ref(null);
|
||||
const loading = ref(false);
|
||||
const captchaVisible = ref(false);
|
||||
const pendingFormData = ref(null);
|
||||
|
||||
const form = ref({
|
||||
oldPassword: '',
|
||||
newPassword: '',
|
||||
@@ -68,6 +81,7 @@ const rules = ref({
|
||||
watch(() => props.visible, (newVal) => {
|
||||
if (!newVal && formRef.value) {
|
||||
formRef.value.resetFields();
|
||||
pendingFormData.value = null;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -79,19 +93,36 @@ const handleSubmit = async () => {
|
||||
if (!formRef.value) return;
|
||||
await formRef.value.validate(async (valid) => {
|
||||
if (valid) {
|
||||
try {
|
||||
await updatePassword({
|
||||
oldPassword: form.value.oldPassword,
|
||||
newPassword: form.value.newPassword,
|
||||
});
|
||||
ElMessage.success('密码修改成功,请重新登录');
|
||||
emit('password-updated');
|
||||
handleClose();
|
||||
// Logout logic will be handled by the parent component
|
||||
} catch (error) {
|
||||
ElMessage.error('密码修改失败: ' + error.message);
|
||||
}
|
||||
// 保存表单数据,显示验证码弹窗
|
||||
pendingFormData.value = {
|
||||
oldPassword: form.value.oldPassword,
|
||||
newPassword: form.value.newPassword,
|
||||
};
|
||||
captchaVisible.value = true;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 验证码确认
|
||||
const handleCaptchaConfirm = async ({ captchaId, captchaCode }) => {
|
||||
if (!pendingFormData.value) return;
|
||||
|
||||
loading.value = true;
|
||||
try {
|
||||
await updatePassword(pendingFormData.value, captchaId, captchaCode);
|
||||
ElMessage.success('密码修改成功,请重新登录');
|
||||
captchaVisible.value = false;
|
||||
emit('password-updated');
|
||||
handleClose();
|
||||
} catch (error) {
|
||||
ElMessage.error('密码修改失败: ' + (error.response?.data?.msg || error.message));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 验证码取消
|
||||
const handleCaptchaCancel = () => {
|
||||
pendingFormData.value = null;
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -2,6 +2,7 @@ import axios from 'axios'
|
||||
import { useUserStore } from '../stores/user'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import router from '../router'
|
||||
import { getReplayAttackHeaders, needsReplayAttackValidation } from './security'
|
||||
|
||||
const instance = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL,
|
||||
@@ -20,6 +21,13 @@ instance.interceptors.request.use(
|
||||
if (userStore.token) {
|
||||
config.headers['Authorization'] = `Bearer ${userStore.token}`
|
||||
}
|
||||
|
||||
// 添加防重放攻击请求头(POST/PUT/DELETE 请求)
|
||||
if (needsReplayAttackValidation(config.method, config.url)) {
|
||||
const replayHeaders = getReplayAttackHeaders()
|
||||
config.headers['X-Timestamp'] = replayHeaders['X-Timestamp']
|
||||
config.headers['X-Nonce'] = replayHeaders['X-Nonce']
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to get user store:', error)
|
||||
}
|
||||
@@ -46,15 +54,47 @@ instance.interceptors.response.use(
|
||||
}
|
||||
},
|
||||
error => {
|
||||
if (error.response && error.response.status === 401) {
|
||||
try {
|
||||
const userStore = useUserStore()
|
||||
userStore.logout();
|
||||
ElMessage.error('登录已过期,请重新登录');
|
||||
router.push('/login');
|
||||
} catch (error) {
|
||||
console.warn('Failed to get user store:', error)
|
||||
if (error.response) {
|
||||
const status = error.response.status;
|
||||
const data = error.response.data;
|
||||
|
||||
// 401 - 未授权
|
||||
if (status === 401) {
|
||||
try {
|
||||
const userStore = useUserStore()
|
||||
userStore.logout();
|
||||
ElMessage.error('登录已过期,请重新登录');
|
||||
router.push('/login');
|
||||
} catch (error) {
|
||||
console.warn('Failed to get user store:', error)
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
// 429 - 请求过于频繁
|
||||
if (status === 429) {
|
||||
const msg = data?.msg || '请求过于频繁,请稍后再试';
|
||||
ElMessage.error(msg);
|
||||
return Promise.reject(new Error(msg));
|
||||
}
|
||||
|
||||
// 400 - 验证码错误等
|
||||
if (status === 400) {
|
||||
const msg = data?.msg || '请求参数错误';
|
||||
ElMessage.error(msg);
|
||||
return Promise.reject(new Error(msg));
|
||||
}
|
||||
|
||||
// 503 - 服务器繁忙(内存不足)
|
||||
if (status === 503) {
|
||||
const msg = data?.msg || '服务器繁忙,请稍后再试';
|
||||
ElMessage.error(msg);
|
||||
return Promise.reject(new Error(msg));
|
||||
}
|
||||
|
||||
// 其他错误,显示后端返回的消息
|
||||
const msg = data?.msg || error.message;
|
||||
ElMessage.error(msg);
|
||||
} else {
|
||||
ElMessage({
|
||||
message: error.message,
|
||||
|
||||
63
biji-qianduan/src/utils/security.js
Normal file
63
biji-qianduan/src/utils/security.js
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* 安全工具函数
|
||||
* 用于防重放攻击和生成随机 nonce
|
||||
*/
|
||||
|
||||
/**
|
||||
* 生成随机 nonce (32位随机字符串)
|
||||
* @returns {string} nonce
|
||||
*/
|
||||
export function generateNonce() {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
let nonce = '';
|
||||
for (let i = 0; i < 32; i++) {
|
||||
nonce += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return nonce;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前时间戳 (毫秒)
|
||||
* @returns {number} timestamp
|
||||
*/
|
||||
export function getTimestamp() {
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取防重放攻击请求头
|
||||
* @returns {Object} 包含 X-Timestamp 和 X-Nonce 的对象
|
||||
*/
|
||||
export function getReplayAttackHeaders() {
|
||||
return {
|
||||
'X-Timestamp': getTimestamp(),
|
||||
'X-Nonce': generateNonce()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 需要防重放攻击验证的请求方法
|
||||
*/
|
||||
export const REPLAY_ATTACK_METHODS = ['POST', 'PUT', 'DELETE', 'PATCH'];
|
||||
|
||||
/**
|
||||
* 检查请求是否需要防重放攻击验证
|
||||
* @param {string} method HTTP 方法
|
||||
* @param {string} url 请求 URL
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function needsReplayAttackValidation(method, url) {
|
||||
// GET 请求不需要验证(幂等操作)
|
||||
if (!method || method.toUpperCase() === 'GET') {
|
||||
return false;
|
||||
}
|
||||
// 排除登录和注册接口(后端已排除)
|
||||
if (url && (
|
||||
url.includes('/api/user/login') ||
|
||||
url.includes('/api/user/register') ||
|
||||
url.includes('/api/system/registration/status')
|
||||
)) {
|
||||
return false;
|
||||
}
|
||||
return REPLAY_ATTACK_METHODS.includes(method.toUpperCase());
|
||||
}
|
||||
Reference in New Issue
Block a user