上篇中,我们开端查询了 RuoYi 的项目结构,并在终究实践运转起了项目。我们也发现了作者不好的代码习气,作为反例,我们应该要养成杰出的编码习气。本篇开端,我们会按照 Web 界面逐个对具体子项目的完结的功用进行探秘。

常见又不常见的登录

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

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

而 Shiro 结构,说明我们对相关 RBAC 代码分析时,需求从 Shiro 结构的特征进行。

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

最火后台办理体系 RuoYi 项目探秘,之二

我们再看请求体,如图:

最火后台办理体系 RuoYi 项目探秘,之二

这儿存在一个非常严重的问题!!暗码明文传输,安全性很差!!假设我们要用 RuoYi 或许自己搭结构来做一些安全评级较高的项目,比方等保项目,很或许由于这个暗码问题就直接被否定了。

解决方法:暗码加密后传输,加密算法选用非对称的,如 RSA。进一步,传输协议调整为 https,趁便还对避免重放进犯。

别的,我们还发现这儿传输了验证码,那验证码怎么与其时登录绑定的?我们先验证一下是否可以进行验证码替换进犯。

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

最火后台办理体系 RuoYi 项目探秘,之二

最火后台办理体系 RuoYi 项目探秘,之二

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

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

最火后台办理体系 RuoYi 项目探秘,之二

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

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

奥妙的验证码

上面,我们现已知道登录进口 API 为 /login,那么只需找到该 API ,就可以找到进口深化 RuoYi。

Sping Boot 项目的话,举荐一个 IDEA 插件 RestfulToolkit,它可以很便当的查找 API,要是没有东西的话,我们就只能用全局查找法,来找我们想看的 API。

我们先找 /login API,如图:

最火后台办理体系 RuoYi 项目探秘,之二

一同,我们也趁便打开的 RuoYi 的包结构,如图:

最火后台办理体系 RuoYi 项目探秘,之二

真的是辣眼睛。

最火后台办理体系 RuoYi 项目探秘,之二

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

我们继续分析 /login

首要,我们看到这个 Controller 有比较多的 JavaDoc 注释和代码注释,这个很好,可以便当他人了解代码。可是 API 所对应的 Controller 函数却没有注释,直接懵逼,算了,我们接着分析代码。

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

最火后台办理体系 RuoYi 项目探秘,之二

原文如下:

/**
 * 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函数,如图:

最火后台办理体系 RuoYi 项目探秘,之二

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

我们试验一下。

第一步,我们先成心输错验证码登录,然后查询照应体,再通过照应体的要害数据查找代码中的完结。

验证码差错时,照应体如图:

最火后台办理体系 RuoYi 项目探秘,之二

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

最火后台办理体系 RuoYi 项目探秘,之二

然后我们查找对应的常量定义,找到运用处,如图:

最火后台办理体系 RuoYi 项目探秘,之二

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

if (ShiroConstants.CAPTCHA_ERROR.equals(ServletUtils.getRequest().getAttribute(ShiroConstants.CURRENT_CAPTCHA)))

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

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

最火后台办理体系 RuoYi 项目探秘,之二

证明晰实是登录才判别的验证码是否校验通过。那么 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,然后看到代码如图:

最火后台办理体系 RuoYi 项目探秘,之二

可以看到该函数对应的 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 结构认证相关的逻辑。本篇就到这儿,比心,❤。