最火后台管理系统 RuoYi 项目探秘,之二

news2025/1/15 20:49:33

上篇中,我们初步观察了 RuoYi 的项目结构,并在最后实际运行起了项目。我们也发现了作者不好的代码习惯,作为反例,我们应该要养成良好的编码习惯。本篇开始,我们会按照 Web 界面逐一对具体子项目的实现的功能进行探秘。

常见又不常见的登录

在上一篇中,我们知道两个很重要的信息,一是 RuoYi 项目没有使用前后端分离,用的是 Thymeleaf 模板;二是权限框架选用的 Shiro。

没有前后端分离,说明登录以及其他业务的 API 响应,必定有一部分是针对 html 的响应,而非全部是 Restful API,落在具体的登录功能上,说明登录表单提交数据的 API,有可能响应的就直接一个 html 页面了。

而 Shiro 框架,说明我们对相关 RBAC 代码分析时,需要从 Shiro 框架的特点进行。

我们再次运行项目,并访问 http://localhost,进入登录界面后,按 F12 打开浏览器的开发人员工具,然后输入验证码,点击登录。接着我们观察工具的中的网络中的 XHR,然后发现调用了登录 API:http://localhost/login,如图:

我们再看请求体,如图:

这里存在一个非常严重的问题!!密码明文传输,安全性很差!!如果我们要用 RuoYi 或者自己搭框架来做一些安全评级较高的项目,比如等保项目,很可能由于这个密码问题就直接被否定了。

解决方法:密码加密后传输,加密算法选用非对称的,如 RSA。进一步,传输协议调整为 https,顺便还对防止重放攻击。

另外,我们还发现这里传输了验证码,那验证码如何与当前登录绑定的?我们先验证一下是否可以进行验证码替换攻击。

我们在浏览器同时打开两个登录界面,两个登录界面分别有两个验证码,如图:

我们将第二个验证码答案填入第一个界面,看能否登录。直接登录成功了。说明验证码与当前登录操作可能只有会话绑定关系,只要是同一会话,就认为是同一个操作。我们再用两个不同的浏览器验证一下,这次就无法替换验证码登录了,说明验证码确实是只与会话绑定,后面我们再从代码中确认一次。

然后我们看一下登录成功后的前端逻辑,如图:

可以看到 /login 接口响应 200 后,前端进入首页 index,说明前端是判定登录响应为 200 后,再次访问 index,由于后台会话已记录登录状态,所以鉴权通过,访问返回首页的 html 内容。

为了解答验证码的问题以及更深入了解 RuoYi 项目的登录实现,我们进入代码进行探秘。

神秘的验证码

上面,我们已经知道登录入口 API 为 /login,那么只要找到该 API ,就可以找到入口深入 RuoYi。

Sping Boot 项目的话,推荐一个 IDEA 插件 RestfulToolkit,它可以很方便的搜索 API,要是没有工具的话,我们就只能用全局搜索法,来找我们想看的 API。

我们先找 /login API,如图:

同时,我们也顺便展开的 RuoYi 的包结构,如图:

真的是辣眼睛。

一般工程实践中,我们会尽量用业务去划分包的结构,即同业务的在一个包里,更细的划分在用子包体现。而不会像 RuoYi 这样将一大堆可能业务相近的 Controller 扔同一个包里就完了,区分度再用类名去区分。这样其实很让人头疼。

我们继续分析 /login

首先,我们看到这个 Controller 有比较多的 JavaDoc 注释和代码注释,这个很好,能够方便别人理解代码。但是 API 所对应的 Controller 函数却没有注释,直接懵逼,算了,我们接着分析代码。

GET /login 所对应的函数参数有三个: HttpServletRequest requestHttpServletResponse response 和 ModelMap mmap,这个 mmap 是个啥玩意?我们点进他的定义 ModelMap 里,原来这个是用的 Spring Context。下载源码,看下官方类的说明写的啥,如图9:

原文如下:

/**
 * Implementation of {@link java.util.Map} for use when building model data for use
 * with UI tools. Supports chained calls and generation of model attribute names.
 *
 * <p>This class serves as generic model holder for Servlet MVC but is not tied to it.
 * Check out the {@link Model} interface for an interface variant.
 *
 * @author Rob Harrop
 * @author Juergen Hoeller
 * @since 2.0
 * @see Conventions#getVariableName
 * @see org.springframework.web.servlet.ModelAndView
 */
复制代码

可以看到,意思是 ModelMap 是标准库中 Map 的一个实现,用于使用 UI 工具构造 model 数据。也就是说这个是结合 html 网页的模型数据传输所使用的。

我们再看 POST /login函数,如图:

函数有三个参数 String usernameString password 和 Boolean rememberMe,分别对应的是:用户名、用户密码和是否记住我。并没有验证码相关的参数。是否说明验证码没有通过后台校验,或者它在哪里被校验的呢?

我们试验一下。

第一步,我们先故意输错验证码登录,然后观察响应体,再通过响应体的关键数据搜索代码中的实现。

验证码错误时,响应体如图:

我们可以看到关键报错信息为“验证码错误”,通过这几个汉字我们找到定义,如图:

然后我们搜索对应的常量定义,找到使用处,如图:

图中,我们就可以看到判断代码:

if (ShiroConstants.CAPTCHA_ERROR.equals(ServletUtils.getRequest().getAttribute(ShiroConstants.CURRENT_CAPTCHA)))
复制代码

这里判定的是如果 request 的 attribute 里 键值为 captcha(对应常量为 CURRENT_CAPTCHA)的值,是否为 captchaError(常量为 CAPTCHA_ERROR)。如果是,则说明验证码有问题。

看一下此方法被调用的地方,如图:

证明确实是登录才判断的验证码是否校验通过。那么 captcha 是在什么时候被设置的值呢?我们再搜索一下它的常量定义。

然后我们找到了类 CaptchaValidateFilter,然后看到了实现:

@Override
protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception
{
    request.setAttribute(ShiroConstants.CURRENT_CAPTCHA, ShiroConstants.CAPTCHA_ERROR);
    return true;
}
复制代码

再观察,发现此类是继承自 Shiro 的 AccessControlFilter,用于验证码校验的过滤器。

然后我们查找校验代码,发现如下:

public boolean validateResponse(HttpServletRequest request, String validateCode)
{
    Object obj = ShiroUtils.getSession().getAttribute(Constants.KAPTCHA_SESSION_KEY);
    String code = String.valueOf(obj != null ? obj : "");
    // 验证码清除,防止多次使用。
    request.getSession().removeAttribute(Constants.KAPTCHA_SESSION_KEY);
    if (StringUtils.isEmpty(validateCode) || !validateCode.equalsIgnoreCase(code))
    {
        return false;
    }
    return true;
}
复制代码

这里我们可以看到,对验证码的判断,原始数据是从 attribute 里获取的,键值对应的常量为 KAPTCHA_SESSION_KEY,我们再查找一下它生成的地方,找到类 SysCaptchaController,然后看到代码如图:

可以看到该函数对应的 API 为 /captchaImage,正好就是界面上生成验证码图片所调用的 API。我们观察一下此 API。哎……

代码中使用了大量明文字符串,而非常量字符串,非常……非常……难受。但凡有一点代码洁癖的,还是最好把这些常见的字符串定义为常量,或者使用工具组件中已定义好的枚举、常量。要不然别人会说你的代码写得好土。

我们可以看到此处验证码为数学模式式时,生成校验码的方法为 String capText = captchaProducerMath.createText();,然后后面的代码会将代码分割为公式部分和结果部分,公式部分生成图片响应前端,结果部分存储到 attribute 中用于判断是否正确。

以上代码分析后,整个验证码的生成和比较就比较清晰了。整理如下: 1.用户未登录状态,调用 /captchaImage,生成验证码图片,并将验证码正确结果放入 request 的 attribute 中,键值为 KAPTCHA_SESSION_KEY 2.用户点击登录,调用 /login 传递数据,包括用户名、密码、验证码、是否记住用户 3.拦截器 CaptchaValidateFilter 会先读取 /login 传入的验证码,并与 attribute 中的 KAPTCHA_SESSION_KEY 进行比较,如果相同,验证码正确,否则验证码错误,并同时清理已记录的 attribute KAPTCHA_SESSION_KEY;然后会在 attribute 中添加一个头 captcha,用于记录验证码校验结果 4./loign 对应的方法会调用登录代码 subject.login(token);,会触发 UserRealm 中的认证方法 doGetAuthenticationInfo(),会调用 SysLoginService 的方法 login(String username, String password) 进行用户认证,而其中就会先进行验证码的判定 5.从 attribute 里读取验证码校验结果的键值 captcha,如果对应的值为 captchaError,则说明验证码不对,就不会再进行用户名、密码的判断了

我们最开始有疑问的验证码校验,到此得到了答案。

奇怪的逻辑

从上面我们知道,其实最终对用户认证相关信息的判断都落在 UserRealm,这是 Shiro 框架中认证相关的类,暂不详细展开,在这个类里面,各种认证情况都以异常形式抛出,代码如下:

try
{
    user = loginService.login(username, password);
}
catch (CaptchaException e)
{
    throw new AuthenticationException(e.getMessage(), e);
}
catch (UserNotExistsException e)
{
    throw new UnknownAccountException(e.getMessage(), e);
}
catch (UserPasswordNotMatchException e)
{
    throw new IncorrectCredentialsException(e.getMessage(), e);
}
catch (UserPasswordRetryLimitExceedException e)
{
    throw new ExcessiveAttemptsException(e.getMessage(), e);
}
catch (UserBlockedException e)
{
    throw new LockedAccountException(e.getMessage(), e);
}
catch (RoleBlockedException e)
{
    throw new LockedAccountException(e.getMessage(), e);
}
catch (Exception e)
{
    log.info("对用户[" + username + "]进行登录验证..验证未通过{}", e.getMessage());
    throw new AuthenticationException(e.getMessage(), e);
}
复制代码

但是最奇怪的是,RuoYi 的作者在 login() 方法中针对各种认证失败的情况抛出了各种异常,然后又在 UserRealm 捕获这些异常后,再抛出其他的异常,最后又依据这些异常共同的父类来决定响应的错误数据。

这就像给一个孩子穿了件红色外套,然后走到门外,又给孩子加了件绿色外套,最后判断孩子的情况,又是通过外套是毛衣来判断。真让人摸不着头脑。

一般情况下,我们有两种实现策略。第一种,针对没的认证失败情况,以不同的异常抛出,那么就会有一个处理异常的顶层设计,通过不同的异常,返回不同的响应信息,在 Spring Boot 框架中,这个异常处理的顶层设计就是 @ControllerAdvice,所有异常都可以在这里处理。

第二种,我们直接在 Controller 中捕获异常,直接返回不同的响应信息即可。

像 RuoYi 作者这种,把以上两种情况结合起来,又在中间多包装一层异常的设计,就显得既臃肿又复杂,不可取。

原材料从哪里来

在验证码探秘的过程中,我们也基本理清了登录的逻辑,但我们还没有看到用户和密码是如何校验的,我们在上面逻辑中找一下相关逻辑。

我们先在 SysLoginService 中的 login() 中看到以下代码:

// 密码如果不在指定范围内 错误
if (password.length() < UserConstants.PASSWORD_MIN_LENGTH
    || password.length() > UserConstants.PASSWORD_MAX_LENGTH)
{
    AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message("user.password.not.match")));
    throw new UserPasswordNotMatchException();
}
// 用户名不在指定范围内 错误
if (username.length() < UserConstants.USERNAME_MIN_LENGTH
    || username.length() > UserConstants.USERNAME_MAX_LENGTH)
{
    AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message("user.password.not.match")));
    throw new UserPasswordNotMatchException();
}
复制代码

这段代码,判断了密码长度,不在长度范围内的密码,直接判定无效。但一般情况下,密码相关的复杂度判断,我们一般会采用密码策略的设计,提供一个用户可配置的策略,其中就会有密码长度的配置项。然后在这种判断密码长度时,读取密码长度配置项,再进行判断即可。用户名也类似的逻辑。

像作者这种直接将逻辑写死的情况,灵活性非常差,后期如果用户想自定义密码长度时,又需要修改代码,不建议像 RuoYi 作者这样实现。

接着,代码查询用户信息:

// 查询用户信息
SysUser user = userService.selectUserByLoginName(username);
复制代码

然后对用户的可用性状态判断,接着进行了密码校验,如下:

passwordService.validate(user, password);
复制代码

跟踪到这个实现里,忽略其他逻辑,发现密码校验函数为 matches(SysUser user, String newPassword),其实现为如下:

return user.getPassword().equals(encryptPassword(user.getLoginName(), newPassword, user.getSalt()));
复制代码

这段代码,可以明确的看出来就是将登录的数据,加盐后,再加密,然后与记录中的用户密码数据进行比较。我们再看一下 encryptPassword 的实现:

return new Md5Hash(loginName + password + salt).toHex();
复制代码

也就是存储的密码数据不是加密数据,而是用户名加上密码再加上盐,最后用 MD5 得到哈希值。并且我们也知道,用户创建时,存储的数据中,除了基本的用户名、用户密码等信息,还包括给用户的盐值。

我们大概找一下这个盐值的生成方式,代码如下:

/**
 * 生成随机盐
 */
public static String randomSalt()
{
    // 一个Byte占两个字节,此处生成的3字节,字符串长度为6
    SecureRandomNumberGenerator secureRandom = new SecureRandomNumberGenerator();
    String hex = secureRandom.nextBytes(3).toHex();
    return hex;
}
复制代码

小结

本篇分析了用户的基本登录流程,了解到 RuoYi 作者没有考虑密码传输的安全性,对异常的处理不是很清爽,也知道了作者没有设计密码策略相关配置,相关的配置非常不灵活。而且可以复用的的字符串也应该抽离为常量,这些在我们做项目时都应该尽量避免。

另外,我们也看到,作者对用户密码的存储,使用了比较安全的算法,不仅加了盐,还使用 MD5 进行哈希,这点可以提升安全性,值得学习。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/10484.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

Qt使用7z压缩和解压示例(支持文件夹递归、多文件不同位置)

1&#xff0c;简介 Qt自带的压缩处理类功能不太完善&#xff0c;也不支持中文路径。 这是我封装好的一个Qt调用7z处理压缩解压的工具类 ZipAPI&#xff0c;提供了几个简单易用的接口。 写压缩解压代码从此非常方便快捷&#xff01; 支持中文路径&#xff0c;支持常规的压缩解…

Cell:水平基因转移在昆虫中广泛存在,增强鳞翅目雄性昆虫求偶行为

期刊&#xff1a;Cell 影响因子&#xff1a;66.85 发表时间&#xff1a;2022年8月 一、研究背景 昆虫起源于约4.8亿年前&#xff0c;是地球上最繁盛的动物类群&#xff0c;已被描述种超过100万&#xff0c;占所有动物物种50%以上。这个古老的动物类群在…

插画、插图网站,免费(商用)

本期分享5个高质量插画网站&#xff0c;免费可商用&#xff0c;设计必备&#xff0c;建议收藏&#xff01;1、Undraw https://undraw.co/illustrationsUndraw是一个扁平风格插画图库&#xff0c;里面有大量的插画&#xff0c;可以支持在线更改配色&#xff0c;网站提供免费下载…

【JavaSE】类和对象(下)(访问限定符 包的概念 导入包中的类 自定义包 包的访问权限控制举例 常见的包 实例内部类 静态内部类 局部内部类 对象的打印)

文章目录六、 封装6.1 封装的概念6.2 访问限定符6.3 封装扩展之包6.3.1 包的概念6.3.2 导入包中的类6.3.3 自定义包6.3.4 包的访问权限控制举例6.3.5 常见的包七、内部类7.1 内部类7.1.1 实例内部类7.1.2 静态内部类7.2 局部内部类7.3 匿名内部类八、对象的打印六、 封装 6.1 …

人工智能-线性回归2--房价预测、欠拟合过拟合、正则化、模型保存加载

7&#xff0c;案例&#xff1a;波士顿房价预测 回归性能评估MSE from sklearn.datasets import load_boston from sklearn.model_selection import train_test_split from sklearn.linear_model import LinearRegression,SGDRegressor from sklearn.meyrics import mean_squa…

详解PHP解决swoole守护进程Redis假死 ,mysql断线重连问题

详解PHP解决swoole守护进程Redis假死 &#xff0c;mysql断线重连问题最近公司有个项目&#xff0c;要举办一个线上活动&#xff0c;我这边负责提供接口记录用户访问记录&#xff0c;与操作记录&#xff0c;由于活动参与人数可能比较多&#xff0c;为了不影响正常业务运行&#…

安全性归约(游戏)

文章目录基于游戏的安全性定义归约中的概率关系某事件发生某事件不发生互斥事件基于游戏的安全性定义 在将攻击 Γ\GammaΓ 的算法 A′AA′ 归约到攻击 Π\PiΠ 的算法 AAA 时&#xff0c; 让 A′AA′ 根据 ChΓCh_\GammaChΓ​ 提供的信息&#xff0c;为 AAA 模拟出同分布的 …

股票自动下单接口够接入多种股票数据源吗?

很多的股票交易接口在原先只能接入行情的CTP程序&#xff0c;那么股票自动下单接口现在可以通过openctp提供的CTPAPI&#xff0c;可以接入到多种多样的股票数据源&#xff01;但是目前由于大多的股票交易接口是受到监管限制的就很难接入实盘&#xff0c;那么股票自动下单接口通…

知心世界姐王瑞平:谷传民与大衣哥朱之文是沟通问题不是人品问题

大衣哥和谷传民的官司走到现在&#xff0c;互联网上面也出现了两大阵营对垒&#xff0c;一方面是大衣哥的粉丝&#xff0c;旗帜鲜明地支持自己的偶像&#xff0c;另一大阵营的人&#xff0c;则一心一意支持谷传民。虽然每个阵营都有自己的道理&#xff0c;但是毕竟都太过极端&a…

Virtual Data Augmentation: 虚拟数据扩增技术

听说过数据扩增&#xff08;Data Augmentation&#xff09;&#xff0c;也听说过虚拟对抗训练&#xff08;Virtual Adversarial Traning&#xff09;&#xff0c;但是我没想到会有人将其结合&#xff0c;谓之虚拟数据扩增&#xff08;Virtual Data Augmentation&#xff09;。这…

CANoe诊断测试

诊断协议那些事儿 本文为诊断协议那些事儿专栏文章&#xff0c;当我们在开发工程中越来越多的需要使用到总线测试工具&#xff0c;其中包括BUSMASTER、周立功、PCAN、CANOE等&#xff0c;本文将使用德国Vector公司的CANoe介绍诊断测试的基本环境。 文章目录诊断协议那些事儿一…

Python编程从入门到实践 第五章:if语句 练习答案记录

Python编程从入门到实践 第五章&#xff1a;if语句 练习答案记录 练习题导航Python编程从入门到实践 第五章&#xff1a;if语句 练习答案记录5.1 一个简单示例5.2 条件测试5.2.1 检查是否相等5.2.2 检查是否相等时忽略大小写5.2.3 检查是否不相等5.2.4 数值比较5.2.5 检查多个文…

运行yolov5 v6遇到的问题

1. Arial.ttf无法在运行时下载的问题 可以选择用浏览器下载&#xff0c;然后拷贝到docker或者ubuntu下&#xff0c;创建服务器的http访问方式。 具体为&#xff1a; 1.1 下载文件 Arial.ttf 并拷贝到docker或者ubuntu下 1.2 在ubuntu下创建http访问方式&#xff1a; # 安装…

有限元仿真分析误差来源之边界条件,约束和point mass

导读&#xff1a;前不久&#xff0c;我在这里分享了一篇《有限元仿真分析误差来源之材料参数设置&#xff0c;小心为妙》的文章&#xff0c;引发了同行们的关注和讨论。在此感谢仿真秀平台讲师们的批评和指正&#xff0c;一起认真交流技术和进步。今天我将继续带来关于边界条件…

spring data jpa在mysql分页中的实例(一次访问同时获取数据和总数)

一、原生sql语句 mysql中语句如下 select SQL_CALC_FOUND_ROWS sn,max(count) as active_count from sn_state_changed where sn_year zz group by sn limit 0,10; select FOUND_ROWS() as total; 解释&#xff1a; SQL_CALC_FOUND_ROWS 供后面的查询总数sql语句使…

(STM32)从零开始的RT-Thread之旅--SPI驱动ST7735(1)

上一篇&#xff1a; (STM32)从零开始的RT-Thread之旅--GPIO 我使用的开发板是WeAct的H743板子&#xff0c;板子带一个0.96的SPI驱动的LCD&#xff0c;给的有现成的测试用例&#xff0c;看源码应该是ST的工程师写的ST7735的驱动&#xff0c;打算把这个驱动直接拿到RTT工程里面使…

SolidWorks 入门笔记01:草图绘制

全文目录简介1. 草图的创建1.1 在基准面上新建一个二维草图多学一招&#xff1a;退出草图绘制模式&#xff0c;快捷键切换视图1.2 从已有的草图派生新的草图。1.3 在零件的平面上绘制草图多学一招&#xff1a;SolidWorks 中鼠标滚轮放大缩小功能反的解决办法 。2. 基本图形绘制…

【2022秋线上作业-第5次-第11-13周】判断题

1-1 一棵有124个结点的完全二叉树&#xff0c;其叶结点个数是确定的。T 解析&#xff1a; 一棵124个叶节点的完全二叉树&#xff0c;假设n0为叶子节点数&#xff0c;n1为度为1结点数&#xff0c;n2为度为2结点数&#xff0c;则有总结点数为n0n1n2&#xff1b;而n2n0-1123&#…

如何杜绝 spark history server ui 的未授权访问?

如何杜绝 spark history server ui 的未授权访问? 1 问题背景 默认状况下&#xff0c;Spark history Sever ui 是没有任何访问控制机制的&#xff0c;任何用户只要知道 shs 对应的 url&#xff0c;就可以访问链接查看 spark 作业的运行状况。 在证券基金银行等金融行业中&a…

Kotlin 开发Android app(六):Kotlin 中的空判断 问号和感叹号

如果有人对程序的崩溃原因做下统计的话&#xff0c;那么由于对象为空&#xff0c;但是又访问了对象的某个属性而导致的崩溃&#xff0c;也许会是程序崩溃的第一大原因了。 比如我们在使用字符串的时候&#xff0c;变量字符串为空的时候&#xff0c;我们去访问了这个字符串变量的…