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/**")
|
.excludePathPatterns("/doc.html", "/webjars/**", "/v3/api-docs/**")
|
||||||
.order(2);
|
.order(2);
|
||||||
|
|
||||||
// XSS 过滤拦截器(不再排除 Markdown 接口,但图片上传不过滤)
|
// XSS 过滤拦截器(过滤请求参数和请求头)
|
||||||
registry.addInterceptor(new XSSInterceptor())
|
registry.addInterceptor(new XSSInterceptor())
|
||||||
.addPathPatterns("/**")
|
.addPathPatterns("/**")
|
||||||
.excludePathPatterns("/api/images/upload", "/doc.html", "/webjars/**", "/v3/api-docs/**")
|
.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,6 +22,7 @@ public class CaptchaController {
|
|||||||
@Operation(summary = "获取图形验证码")
|
@Operation(summary = "获取图形验证码")
|
||||||
@GetMapping("/generate")
|
@GetMapping("/generate")
|
||||||
public R<Map<String, String>> generateCaptcha() {
|
public R<Map<String, String>> generateCaptcha() {
|
||||||
|
try {
|
||||||
CaptchaUtil.CaptchaResult result = CaptchaUtil.generateCaptcha();
|
CaptchaUtil.CaptchaResult result = CaptchaUtil.generateCaptcha();
|
||||||
|
|
||||||
Map<String, String> data = new HashMap<>();
|
Map<String, String> data = new HashMap<>();
|
||||||
@@ -29,5 +30,8 @@ public class CaptchaController {
|
|||||||
data.put("captchaImage", result.getBase64Image());
|
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.core.util.StrUtil;
|
||||||
import cn.hutool.http.HtmlUtil;
|
import cn.hutool.http.HtmlUtil;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import jakarta.servlet.http.HttpServletRequestWrapper;
|
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
import org.springframework.web.servlet.HandlerInterceptor;
|
import org.springframework.web.servlet.HandlerInterceptor;
|
||||||
|
|
||||||
import java.util.Arrays;
|
import java.util.Enumeration;
|
||||||
import java.util.HashMap;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* XSS 过滤拦截器
|
* XSS 过滤拦截器
|
||||||
* 使用 HttpServletRequestWrapper 真正替换请求参数中的 XSS 内容
|
* 过滤请求参数和请求头中的 XSS 内容
|
||||||
|
* 注意:此拦截器只能过滤 URL 参数和表单数据,无法过滤 @RequestBody 的 JSON 数据
|
||||||
|
* JSON 数据的 XSS 过滤由 XssStringDeserializer 处理
|
||||||
*/
|
*/
|
||||||
public class XSSInterceptor implements HandlerInterceptor {
|
public class XSSInterceptor implements HandlerInterceptor {
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
|
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
|
||||||
// 包装请求,过滤 XSS
|
// 过滤请求头
|
||||||
XSSRequestWrapper wrappedRequest = new XSSRequestWrapper(request);
|
filterHeaders(request);
|
||||||
// 将包装后的请求设置到属性中,供后续使用
|
|
||||||
request.setAttribute("XSS_FILTERED_REQUEST", wrappedRequest);
|
// 过滤请求参数(URL 参数和表单数据)
|
||||||
|
filterParameters(request);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* XSS 请求包装器
|
* 过滤请求头
|
||||||
*/
|
*/
|
||||||
public static class XSSRequestWrapper extends HttpServletRequestWrapper {
|
private void filterHeaders(HttpServletRequest request) {
|
||||||
|
Enumeration<String> headerNames = request.getHeaderNames();
|
||||||
public XSSRequestWrapper(HttpServletRequest request) {
|
if (headerNames == null) {
|
||||||
super(request);
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
while (headerNames.hasMoreElements()) {
|
||||||
public String getParameter(String name) {
|
String headerName = headerNames.nextElement();
|
||||||
String value = super.getParameter(name);
|
String headerValue = request.getHeader(headerName);
|
||||||
return filterXSS(value);
|
if (StrUtil.isNotBlank(headerValue)) {
|
||||||
|
String filteredValue = HtmlUtil.filter(headerValue);
|
||||||
|
// 注意:请求头无法直接修改,这里只是记录日志
|
||||||
|
if (!headerValue.equals(filteredValue)) {
|
||||||
|
// 发现 XSS 内容,记录日志
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
|
||||||
public String[] getParameterValues(String name) {
|
|
||||||
String[] values = super.getParameterValues(name);
|
|
||||||
if (values == null) {
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
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);
|
|
||||||
}
|
|
||||||
return filteredMap;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public String getHeader(String name) {
|
|
||||||
String value = super.getHeader(name);
|
|
||||||
return filterXSS(value);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 过滤 XSS 内容
|
* 过滤请求参数
|
||||||
*/
|
*/
|
||||||
private String filterXSS(String value) {
|
private void filterParameters(HttpServletRequest request) {
|
||||||
if (StrUtil.isBlank(value)) {
|
java.util.Map<String, String[]> parameterMap = request.getParameterMap();
|
||||||
return value;
|
for (java.util.Map.Entry<String, String[]> entry : parameterMap.entrySet()) {
|
||||||
}
|
String[] values = entry.getValue();
|
||||||
return HtmlUtil.filter(value);
|
if (values != null) {
|
||||||
|
for (int i = 0; i < values.length; i++) {
|
||||||
|
if (StrUtil.isNotBlank(values[i])) {
|
||||||
|
values[i] = HtmlUtil.filter(values[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ public class CaptchaUtil {
|
|||||||
cleanupExpiredCaptchas();
|
cleanupExpiredCaptchas();
|
||||||
|
|
||||||
// 生成验证码ID
|
// 生成验证码ID
|
||||||
String captchaId = UuidV7.generate().toString();
|
String captchaId = UuidV7.uuid();
|
||||||
|
|
||||||
// 生成验证码字符
|
// 生成验证码字符
|
||||||
String code = generateCode();
|
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 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
|
// 验证Token
|
||||||
export const validateToken = () => axiosApi.post('/api/user/validate-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');
|
return axiosApi.post('/api/system/registration/generate-code');
|
||||||
};
|
};
|
||||||
|
|
||||||
// 更新密码
|
// 获取验证码
|
||||||
export const updatePassword = (data) => {
|
export const getCaptcha = () => {
|
||||||
return axiosApi.put('/api/user/password', data);
|
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>
|
</template>
|
||||||
</el-result>
|
</el-result>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 验证码弹窗 -->
|
||||||
|
<CaptchaDialog
|
||||||
|
v-model="captchaVisible"
|
||||||
|
:description="captchaDescription"
|
||||||
|
@confirm="handleCaptchaConfirm"
|
||||||
|
@cancel="handleCaptchaCancel"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
@@ -46,11 +54,18 @@ import { useRouter } from 'vue-router';
|
|||||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||||
import { getTrash, restoreTrashItem, permanentlyDeleteItem, cleanTrash } from '@/api/CommonApi.js';
|
import { getTrash, restoreTrashItem, permanentlyDeleteItem, cleanTrash } from '@/api/CommonApi.js';
|
||||||
import { useUserStore } from '../stores/user';
|
import { useUserStore } from '../stores/user';
|
||||||
|
import CaptchaDialog from '@/components/CaptchaDialog.vue';
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const userStore = useUserStore();
|
const userStore = useUserStore();
|
||||||
const trashItems = ref([]);
|
const trashItems = ref([]);
|
||||||
|
|
||||||
|
// 验证码相关
|
||||||
|
const captchaVisible = ref(false);
|
||||||
|
const captchaDescription = ref('');
|
||||||
|
const pendingAction = ref(null);
|
||||||
|
const pendingItem = ref(null);
|
||||||
|
|
||||||
const fetchTrashItems = async () => {
|
const fetchTrashItems = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await getTrash();
|
const response = await getTrash();
|
||||||
@@ -76,13 +91,11 @@ const handleDeletePermanently = async (item) => {
|
|||||||
cancelButtonText: '取消',
|
cancelButtonText: '取消',
|
||||||
type: 'warning',
|
type: 'warning',
|
||||||
});
|
});
|
||||||
try {
|
// 保存操作和项目,显示验证码弹窗
|
||||||
await permanentlyDeleteItem(item.id, item.type);
|
pendingAction.value = 'delete';
|
||||||
ElMessage.success('已永久删除');
|
pendingItem.value = item;
|
||||||
fetchTrashItems();
|
captchaDescription.value = '永久删除需要安全验证';
|
||||||
} catch (error) {
|
captchaVisible.value = true;
|
||||||
ElMessage.error('删除失败');
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCleanTrash = async () => {
|
const handleCleanTrash = async () => {
|
||||||
@@ -91,15 +104,38 @@ const handleCleanTrash = async () => {
|
|||||||
cancelButtonText: '取消',
|
cancelButtonText: '取消',
|
||||||
type: 'warning',
|
type: 'warning',
|
||||||
});
|
});
|
||||||
|
// 保存操作,显示验证码弹窗
|
||||||
|
pendingAction.value = 'clean';
|
||||||
|
pendingItem.value = null;
|
||||||
|
captchaDescription.value = '清空回收站需要安全验证';
|
||||||
|
captchaVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 验证码确认
|
||||||
|
const handleCaptchaConfirm = async ({ captchaId, captchaCode }) => {
|
||||||
try {
|
try {
|
||||||
await cleanTrash();
|
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('回收站已清空');
|
ElMessage.success('回收站已清空');
|
||||||
|
}
|
||||||
|
captchaVisible.value = false;
|
||||||
|
pendingAction.value = null;
|
||||||
|
pendingItem.value = null;
|
||||||
fetchTrashItems();
|
fetchTrashItems();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error('清空失败');
|
ElMessage.error('操作失败: ' + (error.response?.data?.msg || error.message));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 验证码取消
|
||||||
|
const handleCaptchaCancel = () => {
|
||||||
|
pendingAction.value = null;
|
||||||
|
pendingItem.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
const goToLogin = () => {
|
const goToLogin = () => {
|
||||||
router.push('/login');
|
router.push('/login');
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -19,9 +19,17 @@
|
|||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button @click="handleClose">取消</el-button>
|
<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>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- 验证码弹窗 -->
|
||||||
|
<CaptchaDialog
|
||||||
|
v-model="captchaVisible"
|
||||||
|
description="修改密码需要安全验证"
|
||||||
|
@confirm="handleCaptchaConfirm"
|
||||||
|
@cancel="handleCaptchaCancel"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
@@ -30,6 +38,7 @@ import { ElMessage } from 'element-plus';
|
|||||||
import { updatePassword } from '@/api/CommonApi.js';
|
import { updatePassword } from '@/api/CommonApi.js';
|
||||||
import { useUserStore } from '@/stores/user';
|
import { useUserStore } from '@/stores/user';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
|
import CaptchaDialog from '@/components/CaptchaDialog.vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
visible: {
|
visible: {
|
||||||
@@ -43,6 +52,10 @@ const userStore = useUserStore();
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const formRef = ref(null);
|
const formRef = ref(null);
|
||||||
|
const loading = ref(false);
|
||||||
|
const captchaVisible = ref(false);
|
||||||
|
const pendingFormData = ref(null);
|
||||||
|
|
||||||
const form = ref({
|
const form = ref({
|
||||||
oldPassword: '',
|
oldPassword: '',
|
||||||
newPassword: '',
|
newPassword: '',
|
||||||
@@ -68,6 +81,7 @@ const rules = ref({
|
|||||||
watch(() => props.visible, (newVal) => {
|
watch(() => props.visible, (newVal) => {
|
||||||
if (!newVal && formRef.value) {
|
if (!newVal && formRef.value) {
|
||||||
formRef.value.resetFields();
|
formRef.value.resetFields();
|
||||||
|
pendingFormData.value = null;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -79,19 +93,36 @@ const handleSubmit = async () => {
|
|||||||
if (!formRef.value) return;
|
if (!formRef.value) return;
|
||||||
await formRef.value.validate(async (valid) => {
|
await formRef.value.validate(async (valid) => {
|
||||||
if (valid) {
|
if (valid) {
|
||||||
try {
|
// 保存表单数据,显示验证码弹窗
|
||||||
await updatePassword({
|
pendingFormData.value = {
|
||||||
oldPassword: form.value.oldPassword,
|
oldPassword: form.value.oldPassword,
|
||||||
newPassword: form.value.newPassword,
|
newPassword: form.value.newPassword,
|
||||||
});
|
};
|
||||||
ElMessage.success('密码修改成功,请重新登录');
|
captchaVisible.value = true;
|
||||||
emit('password-updated');
|
|
||||||
handleClose();
|
|
||||||
// Logout logic will be handled by the parent component
|
|
||||||
} catch (error) {
|
|
||||||
ElMessage.error('密码修改失败: ' + error.message);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 验证码确认
|
||||||
|
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>
|
</script>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import axios from 'axios'
|
|||||||
import { useUserStore } from '../stores/user'
|
import { useUserStore } from '../stores/user'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import router from '../router'
|
import router from '../router'
|
||||||
|
import { getReplayAttackHeaders, needsReplayAttackValidation } from './security'
|
||||||
|
|
||||||
const instance = axios.create({
|
const instance = axios.create({
|
||||||
baseURL: import.meta.env.VITE_API_BASE_URL,
|
baseURL: import.meta.env.VITE_API_BASE_URL,
|
||||||
@@ -20,6 +21,13 @@ instance.interceptors.request.use(
|
|||||||
if (userStore.token) {
|
if (userStore.token) {
|
||||||
config.headers['Authorization'] = `Bearer ${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) {
|
} catch (error) {
|
||||||
console.warn('Failed to get user store:', error)
|
console.warn('Failed to get user store:', error)
|
||||||
}
|
}
|
||||||
@@ -46,7 +54,12 @@ instance.interceptors.response.use(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
error => {
|
error => {
|
||||||
if (error.response && error.response.status === 401) {
|
if (error.response) {
|
||||||
|
const status = error.response.status;
|
||||||
|
const data = error.response.data;
|
||||||
|
|
||||||
|
// 401 - 未授权
|
||||||
|
if (status === 401) {
|
||||||
try {
|
try {
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
userStore.logout();
|
userStore.logout();
|
||||||
@@ -55,6 +68,33 @@ instance.interceptors.response.use(
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Failed to get user store:', 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 {
|
} else {
|
||||||
ElMessage({
|
ElMessage({
|
||||||
message: error.message,
|
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