Skip to content

codermyxiaoc/web-security-ruoyi

Repository files navigation

项目简介

本项目是基于 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+

快速集成步骤

1. 引入依赖

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

2. 启用安全功能

在 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" +
                " ''-'   `'-'    `-..-'              ");
    }
}

3. 配置安全参数

application.yml 中添加安全配置(以 AES 加密和 RSA 签名为例):

web-security:
  cipher:
    aes:
      secret-key: coderxiaoc812728
  signature:
    md5:
      secret-key: coderxiaoc812728

功能使用示例

1. 接口加密解密 + 签名验证(通用示例)

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

2. 防重放与超时控制

通过 @Verification 注解的默认配置,结合 application.yml 中的参数,自动实现:

  • 检查请求头中的 x-s-nonce(唯一标识),确保 30 秒内不重复
  • 检查请求头中的 x-s-timestamp(时间戳),拒绝超过 60 秒的请求

注意事项

  1. 密钥管理:生产环境中需使用安全的密钥管理方式(如配置中心),避免硬编码
  2. 密钥长度:AES 密钥建议 16/24/32 位,RSA 密钥建议 2048 位及以上
  3. 性能影响:加密解密和签名验证会增加接口耗时,建议仅对敏感接口启用
  4. 兼容性:与 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

请求截图

About

使用接口加密对ruoyi框架的大部分接口进行接口入参加密返回值加密

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published