最火后台管理系统 RuoYi 项目探秘,之二
上篇中,我们开端查询了 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 request
、HttpServletResponse 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 username
、String 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
进行哈希,这点可以提升安全性,值得学习。
下一篇,我们会继续再打开一些关于 Shiro 结构认证相关的逻辑。本篇就到这儿,比心,❤。