本项目是基于 RuoYi 框架 集成 api-security-spring-boot-starter 安全组件的示例工程,旨在为 RuoYi 框架的接口提供全方位的安全防护能力。通过注解式配置,快速实现接口数据加密解密、签名验证、防重放攻击及超时控制等功能,无需侵入业务代码即可提升接口安全性,有效抵御数据泄露、请求篡改、重复提交等常见风险。
基于 api-security-spring-boot-starter 组件,本 Demo 为 RuoYi 框架的接口添加了以下安全增强:
| 功能 | 说明 |
|---|---|
| 数据加密解密 | 自动对请求参数解密、响应数据加密,保护敏感信息(如用户信息、订单数据)传输安全 |
| 签名验证 | 支持 MD5、RSA 算法验证请求签名,确保请求来源合法且未被篡改 |
| 防重放攻击 | 基于内存 / Redis 实现请求去重,防止重复提交(如重复下单、重复支付) |
| 接口超时控制 | 验证请求时间戳,拒绝超时请求,降低恶意请求风险 |
| 响应签名生成 | 自动为响应数据生成签名,供客户端验证响应完整性 |
- JDK 1.8+
- Spring Boot 2.7.x(RuoYi 框架默认版本兼容)
- RuoYi 框架(任意基于 Spring Boot 2.7.x 的版本)
- Maven 3.6+
在 RuoYi 项目的 pom.xml 中添加 api-security-spring-boot-starter 依赖:
<dependency>
<groupId>cn.coderxiaoc</groupId>
<artifactId>api-security-spring-boot-starter</artifactId>
<version>1.0.0</version>
</dependency>在 Spring Boot 启动类上添加注解,启用加密解密和签名功能:
@SpringBootApplication(exclude = { DataSourceAutoConfiguration.class })
@EnableCipher()
@EnableSignature(mode = DefaultSignatureMode.MD5)
public class RuoYiApplication
{
public static void main(String[] args)
{
// System.setProperty("spring.devtools.restart.enabled", "false");
ConfigurableApplicationContext run = SpringApplication.run(RuoYiApplication.class, args);
System.out.println("(♥◠‿◠)ノ゙ 若依启动成功 ლ(´ڡ`ლ)゙ \n" +
" .-------. ____ __ \n" +
" | _ _ \\ \\ \\ / / \n" +
" | ( ' ) | \\ _. / ' \n" +
" |(_ o _) / _( )_ .' \n" +
" | (_,_).' __ ___(_ o _)' \n" +
" | |\\ \\ | || |(_,_)' \n" +
" | | \\ `' /| `-' / \n" +
" | | \\ / \\ / \n" +
" ''-' `'-' `-..-' ");
}
}在 application.yml 中添加安全配置(以 AES 加密和 RSA 签名为例):
web-security:
cipher:
aes:
secret-key: coderxiaoc812728
signature:
md5:
secret-key: coderxiaoc812728在 RuoYi 的 Controller 接口上添加注解,实现完整安全防护:
package com.ruoyi.web.controller.system;
import java.util.List;
import java.util.Set;
import cn.coderxiaoc.annotation.Decrypt;
import cn.coderxiaoc.annotation.Encrypt;
import cn.coderxiaoc.annotation.Signature;
import cn.coderxiaoc.annotation.Verification;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import com.ruoyi.common.constant.Constants;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.domain.entity.SysMenu;
import com.ruoyi.common.core.domain.entity.SysUser;
import com.ruoyi.common.core.domain.model.LoginBody;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.framework.web.service.SysLoginService;
import com.ruoyi.framework.web.service.SysPermissionService;
import com.ruoyi.system.service.ISysMenuService;
/**
* 登录验证
*
* @author ruoyi
*/
@RestController
@Encrypt
public class SysLoginController
{
@Autowired
private SysLoginService loginService;
@Autowired
private ISysMenuService menuService;
@Autowired
private SysPermissionService permissionService;
/**
* 登录方法
*
* @param loginBody 登录信息
* @return 结果
*/
@Decrypt()
@PostMapping("/login")
@Verification(value = "#params.header('x-s-nonce')&#params.header('x-s-timestamp')&#params.body('data')", signatureField = "x-s-sing")
public AjaxResult login(@RequestBody LoginBody loginBody)
{
AjaxResult ajax = AjaxResult.success();
// 生成令牌
String token = loginService.login(loginBody.getUsername(), loginBody.getPassword(), loginBody.getCode(),
loginBody.getUuid());
ajax.put(Constants.TOKEN, token);
return ajax;
}
/**
* 获取用户信息
*
* @return 用户信息
*/
@GetMapping("getInfo")
public AjaxResult getInfo()
{
SysUser user = SecurityUtils.getLoginUser().getUser();
// 角色集合
Set<String> roles = permissionService.getRolePermission(user);
// 权限集合
Set<String> permissions = permissionService.getMenuPermission(user);
AjaxResult ajax = AjaxResult.success();
ajax.put("user", user);
ajax.put("roles", roles);
ajax.put("permissions", permissions);
return ajax;
}
/**
* 获取路由信息
*
* @return 路由信息
*/
@GetMapping("getRouters")
public AjaxResult getRouters()
{
Long userId = SecurityUtils.getUserId();
List<SysMenu> menus = menuService.selectMenuTreeByUserId(userId);
return AjaxResult.success(menuService.buildMenus(menus));
}
}通过 @Verification 注解的默认配置,结合 application.yml 中的参数,自动实现:
- 检查请求头中的
x-s-nonce(唯一标识),确保 30 秒内不重复 - 检查请求头中的
x-s-timestamp(时间戳),拒绝超过 60 秒的请求
- 密钥管理:生产环境中需使用安全的密钥管理方式(如配置中心),避免硬编码
- 密钥长度:AES 密钥建议 16/24/32 位,RSA 密钥建议 2048 位及以上
- 性能影响:加密解密和签名验证会增加接口耗时,建议仅对敏感接口启用
- 兼容性:与 RuoYi 框架的权限拦截器(如
TokenInterceptor)兼容,需注意执行顺序
import axios from 'axios'
import { Notification, MessageBox, Message, Loading } from 'element-ui'
import store from '@/store'
import { getToken } from '@/utils/auth'
import errorCode from '@/utils/errorCode'
import { tansParams, blobValidate } from "@/utils/ruoyi";
import cache from '@/plugins/cache'
import { saveAs } from 'file-saver'
import { md5 } from 'js-md5'
import CryptoJS from 'crypto-js'
const keyStr = "coderxiaoc812728";
const keyBytes = CryptoJS.enc.Utf8.parse(keyStr);
let downloadLoadingInstance;
// 是否显示重新登录
export let isRelogin = { show: false };
axios.defaults.headers['Content-Type'] = 'application/json;charset=utf-8'
// 创建axios实例
const service = axios.create({
// axios中请求配置有baseURL选项,表示请求URL公共部分
baseURL: process.env.VUE_APP_BASE_API,
// 超时
timeout: 10000
})
// request拦截器
service.interceptors.request.use(config => {
//数据加密
const dataBytes = CryptoJS.enc.Utf8.parse(JSON.stringify(config.data));
const encrypted = CryptoJS.AES.encrypt(dataBytes, keyBytes, {
mode: CryptoJS.mode.ECB,
padding: CryptoJS.pad.Pkcs7
});
config.data = encrypted.ciphertext.toString(CryptoJS.enc.Base64);
// 是否需要设置 token
const isToken = (config.headers || {}).isToken === false
// 是否需要防止数据重复提交
const isRepeatSubmit = (config.headers || {}).repeatSubmit === false
const timestamp = (new Date().getTime()).toString()
const nonce = "nonce"
config.headers['x-s-nonce'] = nonce
config.headers['x-s-timestamp'] = timestamp
if (getToken() && !isToken) {
config.headers['Authorization'] = 'Bearer ' + getToken()
config.headers['x-s-token'] = getToken();
config.headers['x-s-sing'] = md5(`${getToken()}|${nonce}|${timestamp}|${config.data}|coderxiaoc81278`)
} else {
config.headers['x-s-sing'] = md5(`${nonce}|${timestamp}|${config.data}|coderxiaoc812728`)
}
// get请求映射params参数
if (config.method === 'get' && config.params) {
let url = config.url + '?' + tansParams(config.params);
url = url.slice(0, -1);
config.params = {};
config.url = url;
}
if (!isRepeatSubmit && (config.method === 'post' || config.method === 'put')) {
const requestObj = {
url: config.url,
data: typeof config.data === 'object' ? JSON.stringify(config.data) : config.data,
time: new Date().getTime()
}
const requestSize = Object.keys(JSON.stringify(requestObj)).length; // 请求数据大小
const limitSize = 5 * 1024 * 1024; // 限制存放数据5M
if (requestSize >= limitSize) {
console.warn(`[${config.url}]: ` + '请求数据大小超出允许的5M限制,无法进行防重复提交验证。')
return config;
}
const sessionObj = cache.session.getJSON('sessionObj')
if (sessionObj === undefined || sessionObj === null || sessionObj === '') {
cache.session.setJSON('sessionObj', requestObj)
} else {
const s_url = sessionObj.url; // 请求地址
const s_data = sessionObj.data; // 请求数据
const s_time = sessionObj.time; // 请求时间
const interval = 1000; // 间隔时间(ms),小于此时间视为重复提交
if (s_data === requestObj.data && requestObj.time - s_time < interval && s_url === requestObj.url) {
const message = '数据正在处理,请勿重复提交';
console.warn(`[${s_url}]: ` + message)
return Promise.reject(new Error(message))
} else {
cache.session.setJSON('sessionObj', requestObj)
}
}
}
return config
}, error => {
console.log(error)
Promise.reject(error)
})
// 响应拦截器
service.interceptors.response.use(res => {
const decrypted = CryptoJS.AES.decrypt(res.data, keyBytes, {
mode: CryptoJS.mode.ECB,
padding: CryptoJS.pad.Pkcs7
});
const resultStr= decrypted.toString(CryptoJS.enc.Utf8)
let result = res.data
if (resultStr !== undefined && resultStr !== null&& resultStr.length > 0) {
result = JSON.parse(resultStr)
}
// 未设置状态码则默认成功状态
const code = result.code || 200;
// 获取错误信息
const msg = errorCode[code] || result.msg || errorCode['default']
// 二进制数据则直接返回
if (res.request.responseType === 'blob' || res.request.responseType === 'arraybuffer') {
return result
}
if (code === 401) {
if (!isRelogin.show) {
isRelogin.show = true;
MessageBox.confirm('登录状态已过期,您可以继续留在该页面,或者重新登录', '系统提示', { confirmButtonText: '重新登录', cancelButtonText: '取消', type: 'warning' }).then(() => {
isRelogin.show = false;
store.dispatch('LogOut').then(() => {
location.href = '/index';
})
}).catch(() => {
isRelogin.show = false;
});
}
return Promise.reject('无效的会话,或者会话已过期,请重新登录。')
} else if (code === 500) {
Message({ message: msg, type: 'error' })
return Promise.reject(new Error(msg))
} else if (code === 601) {
Message({ message: msg, type: 'warning' })
return Promise.reject('error')
} else if (code !== 200) {
Notification.error({ title: msg })
return Promise.reject('error')
} else {
return result
}
},
error => {
console.log('err' + error)
let { message } = error;
if (message == "Network Error") {
message = "后端接口连接异常";
} else if (message.includes("timeout")) {
message = "系统接口请求超时";
} else if (message.includes("Request failed with status code")) {
message = "系统接口" + message.substr(message.length - 3) + "异常";
}
Message({ message: message, type: 'error', duration: 5 * 1000 })
return Promise.reject(error)
}
)
// 通用下载方法
export function download(url, params, filename, config) {
downloadLoadingInstance = Loading.service({ text: "正在下载数据,请稍候", spinner: "el-icon-loading", background: "rgba(0, 0, 0, 0.7)", })
return service.post(url, params, {
transformRequest: [(params) => { return tansParams(params) }],
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
responseType: 'blob',
...config
}).then(async (data) => {
const isBlob = blobValidate(data);
if (isBlob) {
const blob = new Blob([data])
saveAs(blob, filename)
} else {
const resText = await data.text();
const rspObj = JSON.parse(resText);
const errMsg = errorCode[rspObj.code] || rspObj.msg || errorCode['default']
Message.error(errMsg);
}
downloadLoadingInstance.close();
}).catch((r) => {
console.error(r)
Message.error('下载文件出现错误,请联系管理员!')
downloadLoadingInstance.close();
})
}
export default service



