上篇中,我们初步观察了 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 框架认证相关的逻辑。本篇就到这里,比心,❤。