feat(安全): 增加验证码和安全验证功能

refactor(XSS): 重构XSS过滤逻辑并添加JSON反序列化过滤

feat(防重放): 前端添加防重放攻击机制

fix(验证码): 优化验证码生成和异常处理

style: 格式化代码并修复部分警告
This commit is contained in:
ikmkj
2026-03-03 18:45:08 +08:00
parent 61aeba9c65
commit 07454a28d2
11 changed files with 485 additions and 102 deletions

View File

@@ -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/**")

View File

@@ -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);
}
}

View File

@@ -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());
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -110,7 +110,7 @@ public class CaptchaUtil {
cleanupExpiredCaptchas();
// 生成验证码ID
String captchaId = UuidV7.generate().toString();
String captchaId = UuidV7.uuid();
// 生成验证码字符
String code = generateCode();

View File

@@ -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
}
});
};

View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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,

View 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());
}