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

# 常见又不常见的登录

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

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

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

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

login

我们再看请求体,如图:

body

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

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

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

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

code

code

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

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

login success

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

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

# 神秘的验证码

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

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

我们先找 /login API,如图:

login

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

package

真的是辣眼睛。

package

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

我们继续分析 /login

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

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

code

原文如下:

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

method

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

我们试验一下。

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

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

body

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

error

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

code

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

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

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

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

call

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

controller

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