6-Prevent Brute Force Authentication Attempts

防止暴力破解,这个话题在上一篇末尾说到,当用户有了你的用户名,如果一直尝试用密码去登录,如果你的密码强度比较高的情况下,“破解”这种难度会比较高,密码强度很大程度上是用户为了安全做出的努力,那我们开发者有没有一个可以贡献的地方呢?

显然是有的,我们的常用到的手机,有手势密码,有数字密码,有FaceID,如果我们尝试了很多次都一直错误,可能账号会被锁定。锁定就是暂时不让用户“尝试破解”密码,这时锁定就是不管用户输入什么密码,我们直接拒绝认证请求(登录操作),不进行后面的密码验证、用户鉴权等等操作。甚至我们在苹果手机上还会看到,如果用户一直尝试解锁失败,锁定的时间也会越来越长,这个方案也会带来新的挑战,如果“坏人”就是要锁定你的账号,不让你登录——很长时间的锁定,这时候我们可能会有新的一些安全措施,比如常用的登录设置,常用的登录IP地址(账号使用范围,比如安徽省,而不是美国加州),甚至有些时候我们可以人工客服解锁账号。这一些都是常用的防止暴力破解的安全方案及后备方案,我们今天探讨的是在Spring Security中怎么实现防止用户暴力破解。下面我们看看具体的操作。

1.添加一个认证失败事件监听器AuthenticationFailureEventListener

2.添加一个认证成功事件监听器AuthenticationSuccessEventListener

3.上面两个事件监听器都用到了LoginAttemptService

再看看它的具体实现类LoginAttemptServiceImpl

上面用到了Google guava框架中的LoadingCache,CacheBuilder,CacheLoader.其实具体的实现不是很困难,上面的方便之处就是可以在写入一天后过期,这一点在数据维护方面,投入的成本就比较低,比如前面我们用到的用户激活token,忘记密码token,这些数据中都有过期信息,那些过期数据其实没有太多数据价值的话,为了节约数据库成本是可以删除掉的,当然可以交给dba去做,还有就是可以使用java中的定时任务完成过期数据的删除,这个在后面的文章中我们会使用到。

上面使用的是内存保存数据,在服务重启的情况下,可能不是最好的选择,我们就自然想到Redis,数据既在内存中,同时还可以持久化数据到硬盘,并不会丢失。所以在实践中可以使用Redis来替代上面的 LoadingCache 。

还有一小点,MAX_ATTEMPT = 10,这个如果经常变更可以写成可配置的。

4.修改一下MyUserDetailsServiceImpl,优先检查用户有没有被阻塞。

5.CustomAuthenticationFailureHandler添加相应的错误信息(这个之前已经写好了)

UNUSUAL_LOCATION 这个我们后面也会使用到,先可以不用关心。

最后我们一起看下所有新增及改动的代码

我们启动项目验证一下,我故意把wys2317@hotmail.com的密码输错3次,我修改了MAX_ATTEMPT=3. 第四次再输入错误时就会出现下面的信息,然后你可以再尝试都会出现下面的信息。

最后我们补充两点:(1)上面的错误信息是在messages.properties中定义的。auth.message.blocked=This ip is blocked for 24 hours
说到这个i18n,我们如果想让错误信息更“动态”,其实 messages.properties 中可以使用占位符的,这样如果我们构建的“锁定”时间是一次比一次长的情况下,我们就可以给出具体的锁定时间,而不是每次都是24 hours。这个感兴趣地可以自己搜索有关资料。(2)看一下这个获取客户端ip的方法

在获取ip的时候,可能你会使用工具类,因为有时候需要一些封装,上面的封装可能还不够, 想获取真实的用户ip有时候的确需要付出一些额外的努力,代码封装上面的,甚至如果你的nginx也需要做一些配置。这个也是需要我们根据真实情况做一些调整。

具体的代码可以参见github.

5-Remember Me

在开始之前我们补充一下四个知识:(1) Whitelist IP Range,这个很容易理解,添加IP地址限制。我们可以在WebSecurityConfig中的configure()中添加

(2)Message Localization,这个就是我们之前使用过的i18n,我们可以先看一下登录页面:

没有错,这也是登录页面,只不过是从我们登录页面点了“Spanish”

这一块在对我们国际化项目时比较有用。

(3)登出的处理,如果我们不处理,在登录后进去点击右上角的 Logout链接,会出现

我们需要新增一个logout.html,同时修改一下WMvcConfig配置,添加一条新纪录

然后我们就可以正常退出了。

(4)自定义的AuthenticationSuccessHandler,这个有点像我们之前用到过的CustomAuthenticationFailureHandler,我们可以在认证成功之后统一做一些处理,而不是简简单单的页面跳转——我们可以在session中存一些信息,比如下面username.这个可以用于页面显示用户名。

我们还可以更精确地控制不同用户跳转地页面:常见的就是“Admin”页面

我们后面还有对这个处理器进行修改,所有在后面的时候我们再来具体看这个类。

好了,现在回到,“记住我”这个主题上来。

如果是自己的个人电脑,每次让我输入用户名和密码的确安全一些,但是每次操作起来却很麻烦。我们现在使用的APP也都有类似记住我的功能,不用每次登录的确是件用户体验良好的事。那么我们先从代码改造上来看看我们需要实现记住我,需要做那些工作。

1.修改WebSecurityConfig配置:

我看看到上面有一个CustomRememberMeServices,可以看一下全代码:

我们再具体看一下CustomRememberMeServices类体系结构

我们可以看到最上面是RememberMeServices接口

我们先把代码改动讲完,最后我们再看看spring-security 内部怎么实现这个记住我的功能,不然在此处展开内部实现实现细节,会打乱“代码改造”节奏。

2.修改login.html页面,添加记住我——remember-me的checkbox

效果如下:

我们正常登录后可以看到控制台:

至此,我们的代码改造完成了,可以来验证一下。首先我们可以看到数据库中多了一张表(需要jdbcTokenRepository.setCreateTableOnStartup(true);这行代码执行后我删除了)

WebSecurityConfig.java

成功登录里面也是有数据的

然后我们重启服务,来模拟触发session失效,然后刷新浏览器,注意此时我们浏览器的地址是
http://localhost:18080/homepage.html?user=wys2317@hotmail.com

如果没有添加记住我,刷新页面肯定是跳转到登录页面,因为这个页面需要用户登录后才能访问,由于我们使用“持久化的记住我”功能,我们不会再跳转到登录页面了,服务器认为我们已经登录过了——因为我们的浏览器中有remember-me的cookie.

现在已经完成了 “持久化的记住我”功能 ,我们再来看看它的内部实现。

Spring Security 过滤器中 AbstractAuthenticationProcessingFilterRememberMeAuthenticationFilter 会调用这个接口中的实现类中的方法。我们关心的是RememberMeAuthenticationFilter,再一起来看看它的源码中核心代码

其中 rememberMeServices 会调用自动登录方法来返回一个Authentication
Authentication rememberMeAuth = rememberMeServices.autoLogin(request,response);
我们再看看 autoLogin()的具体实现-->AbstractRememberMeServices

我们看到extractRememberMeCookie(request);它的具体实现

它就是从request中获取Cookie [],在前面我们看到浏览器控制台中cookie是一长串字符串
VjUlMkIwVFJnVlF6RnZ4SEN6JTJGaW92SEElM0QlM0Q6MFpWT1RyOFAzSFNaUGI5eiUyQjRlVU1BJTNEJTNE
然后我们数据库中保存是username,series,token,last_used。

显然返回给浏览器端的cookie是经过编码的,我们拿到浏览器端的cookie同样需要解码。String[] cookieTokens = decodeCookie(rememberMeCookie);

然后使用解码后的cookie完成自动登录
user = processAutoLoginCookie(cookieTokens, request, response);
userDetailsChecker.check(user);
return createSuccessfulAuthentication(request, user);

最后我们可以小结一下“记住我”完成自动登录的内部细节:

首先是PersistentTokenBasedRememberMeServices在用户成功登录后,会生成一个PersistentRememberMeToken,然后保存在数据库中,同时把cookie回写到request中,这样浏览器端就有了cookie.

假设过了一段时间但是cookie仍然没有过期的情况下访问需要登录的资源,RememberMeAuthenticationFilter会调用RememberMeServices中的autoLogin(),尝试使用请求的cookie来完成用户自动登陆过程,再返回用户请求的资源。

在我们的代码项目中还是有可以改进的地方,在cookie有效的情况下,如果访问/login还是会跳转到登录页面,一个比较好的方式是直接跳转到成功登录后的页面。

小结:“ 记住我 ”是一个用户体验友好的功能,对于用户而言没有“持久化”,这是后端需要完成的工作,它大大减少了用户手动登录的次数(因为这项操作比较麻烦),尽管牺牲了一些安全性,返回给浏览器了编码后的用户信息——cookie,从后面的源码中我们可以看到解码后可以拿到用户名和过期时间。所以后面就有了我们需要防止用户暴力破解密码的功能。这个留到下一篇。“记住我”这个功能除了完成自动登录,其实它还在“自动地”更新cookie的有效期,默认的token有效期我们可以在源码TokenBasedRememberMeServices中看到是14天。

具体的代码可以参见github.