需求
为了增强用户体验,实现“记住我”功能;
为了提高csrf攻击门槛,增加图形验证码功能;
记住我
场景类比:
- 从用户体验来讲:
在用户登录系统一次访问首页后,在有效期内可以免登录访问首页;中间可以包括不小心电脑关机,关闭浏览器等场景… - 从技术的角度来讲:
- 即便session过期,remember me还在有效期内也可以访问;
- 即便系统重启,remember me还在有效期内也可以访问。
验证码
对于csrf攻击,大家可自行百度详细内容,我在这里简单说一下:
要完成一次CSRF攻击,受害者必须依次完成两个步骤:
- 登录受信任网站A,并在本地生成Cookie。
- 在不登出A的情况下,访问危险网站B(B直达A的链接)。
如果在表单中增加一个随机的数字或字母验证码,通过强制用户和应用进行交互,来有效地遏制CSRF攻击。
其实还有一种方式放csrf攻击的方式,通过token校验或者referer
本文代码已上传至GitHub
注:本篇并不打算讲解如何通过代码去搭建完成的springboot整合security工程(一方面因为网上已经有很多优秀的案例代码,另一方面我感觉如果用大量的代码去讲解的话,很多人看着看着就绕进去了,或者看到一半迷茫了,从而去纠结为什么这么做,这么做的目的是什么),如有需要或疑惑,请到github下载源码,内部含有详细注释。
记住我功能
思考
在引入正文之前,我们可以先思考以下这两个问题:
- rememberMe的原理是什么?
- 已经有session了,为什么不把会话时间调大一点?为什么还要引入rememberMe?
至于第一个问题,其实可以这么讲:
如果我们撇开security来讲,其实rememberMe功能的本质就是利用Cookie。
在登录页面,如果用户勾选了“记住我”,那么服务端在响应中加上Remember-Me的cookie,在这里我们还可以设置cookie的时长,比如设置3天。在3天内,用户可以不用登录,反之则需要重新登录。
而在security中他是将这个“cookie”给持久化了,从数据库去拿到这个“cookie”值,从而进行访问。
那么第二个问题是问什么呢?
在security框架中,session是可以设置时长的。你设置3天,意味着一个HttpSession将会由Tomcat保留3天。这样就加大了服务器的开销。试想以下如果用户基数大的情况下,性能急剧下降。
security中的remrember me
为了防止代码看着看着就迷路,我们先来看一段流程图:
上图是针对与代码层次的,也就是说用户在security认证成功后,会问一下remrember me小弟,“兄弟,我这里保存了用户信息,你要不要也保存一份”,然后剩下的就靠这个小弟自己权衡了。
另 :强调一下:PersistentTokenBasedRememberMeServices在这里做了两件事:
- 向数据库添加token;
- 另一个是添加cookie
注意:以上图例是在用户第一次访问时候的流程,也就是说刚勾选上“记住我”这个功能,然后访问的过程。
那么当服务器重启或者清除浏览器缓存时,他的流程是这样的:
其中涉及到的类:
- 方法有RememberMeAuthenticationFilter的doFilter方法,判断session是否存在;
- 不存在则调取rememberMeServices的autoLogin方法,会去到cookie中取是否存在Token,遍历cookie获取到RememberMe的Token;
- 最后又通过PersistentTokenBasedRememberMeServices的processAutoLoginCookie方法去数据库查,然后根据Token获取用户信息返回调用的URL信息。
以上就是Spring Security实现RemeberMe功能的原理;
最后再来一张总图:
在实际使用中,自己曾经遇到过CookieTheftException问题,俗称cookie欺骗,(主要原因是拿到的cookie令牌没有跟用户信息匹配上)大致总结一下该问题的原因:
- 成功登录后:将使用一些随机哈希为用户创建一个永久令牌。将为用户创建一个cookie,上面带有令牌详细信息。为用户创建一个会话。只要用户仍具有活动会话,就不会在身份验证时调用我的功能。
- 用户会话到期后: “记住我”功能将启动并使用cookie从数据库中获取持久性令牌。如果持久令牌与cookie中的令牌匹配,则每个人都对用户进行身份验证感到满意,将生成一个新的随机哈希,并使用持久令牌进行更新,并为后续请求更新用户的cookie。
- 但是,如果Cookie中的令牌与持久令牌中的令牌不匹配,则会收到CookieTheftException。令牌不匹配的最常见原因是快速连续触发了两个或更多请求,第一个请求将通过,并为随后的请求生成新的哈希,但第二个请求仍将旧令牌打开它并因此导致异常。
核心代码如下:
security 配置类:config
@Bean
public PersistentTokenBasedRememberMeServices persistentTokenBasedRememberMeServices() {
PersistentTokenBasedRememberMeServices services = new PersistentTokenBasedRememberMeServices("remember-me"
, myUserDetailService, rememberMeTokenService);
services.setTokenValiditySeconds(3600);
services.setParameter("rememberMe");
return services;
}
注入remember me实现类:
@Component
public class LoginAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
private static final String SECURITY_LOGIN_URL = "/authentication/form";
@Autowired
private AuthenticationSuccessHandler authenticationSuccessHandler;
@Autowired
private AuthenticationFailedHandler authenticationFailedHandler;
@Autowired
private PersistentTokenBasedRememberMeServices persistentTokenBasedRememberMeServices;
@PostConstruct
public void init() {
setRequiresAuthenticationRequestMatcher(new AntPathRequestMatcher(SECURITY_LOGIN_URL, "POST"));
setAuthenticationSuccessHandler(authenticationSuccessHandler);
setAuthenticationFailureHandler(authenticationFailedHandler);
setRememberMeServices(persistentTokenBasedRememberMeServices);
}
在这里提一下遇到的坑:
在security源码中,校验token是否过期的逻辑是这样的:
if (token.getDate().getTime() + getTokenValiditySeconds() * 1000L < System
.currentTimeMillis()) {
throw new RememberMeAuthenticationException("Remember-me login has expired");
}
注意这里的getDate(),我dao层使用的是jdbcTemplate,在获取时间的代码我是这么写的:
public RememberMeToken getRememberMeToken( String seriesId){
String sql ="select login_name,series,token,last_used from t_remember_token where series=?";
RememberMeToken rememberMeToken = null;
try {
rememberMeToken = jdbcTemplate.queryForObject(sql,new Object[]{seriesId},(rs, rowNum) -> {
RememberMeToken token = new RememberMeToken();
token.setSeries(rs.getString("SERIES"));
token.setToken(rs.getString("TOKEN"));
token.setLastUsed(rs.getDate("LAST_USED"));
token.setLoginName(rs.getString("LOGIN_NAME"));
return token;
});
} catch (Exception e) {
return null;
}
return rememberMeToken;
}
注意获取"LAST_USED"的时候我用的是rs.getDate()。。。而不是时间戳,这就会导致你的token一直过期。原因如下:
比如你是“2020-05-26 12:12:12”存储的token,但rs.getDate()只会获取“2020-05-26”,从而导致security源码中认为是“2020-05-26 00:00:00”,如果你token存储时间短的话,则会一直过期!!!!
图形验证码功能
在SpringBoot集成SpringSecurity(二) 登录认证流程解析一文中,我们清楚的了解到了security各个过滤链的顺序,那我们来做一下遐想:
第一,我们在未进入security认证流程时,就开始验证图形验证码,也就是说在UsernamePasswordAuthenticationFilter 未将用户信息转换为Authentication类;
第二,我们在用户已经认证完成后,在进行图形验证码校验;
毫无疑问,肯定是第一种情况友好;
以下为核心代码:
@Component
public class LoginAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
private static final String SECURITY_LOGIN_URL = "/authentication/form";
@Autowired
private AuthenticationSuccessHandler authenticationSuccessHandler;
@Autowired
private AuthenticationFailedHandler authenticationFailedHandler;
@Autowired
private PersistentTokenBasedRememberMeServices persistentTokenBasedRememberMeServices;
@PostConstruct
public void init() {
setRequiresAuthenticationRequestMatcher(new AntPathRequestMatcher(SECURITY_LOGIN_URL, "POST"));
setAuthenticationSuccessHandler(authenticationSuccessHandler);
setAuthenticationFailureHandler(authenticationFailedHandler);
setRememberMeServices(persistentTokenBasedRememberMeServices);
}
@SneakyThrows
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
String requestBody = IOUtils.toString(request.getReader());
LoginRequest loginRequest = JSON.parseObject(requestBody,LoginRequest.class);
if(loginRequest == null || loginRequest.isInvalid()){
throw new InsufficientAuthenticationException("身份验证失败");
}
//校验验证码
verificationCaptcha(loginRequest,request,response);
UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(loginRequest.getUsername(),loginRequest.getPassword());
return this.getAuthenticationManager().authenticate(token);
}
其中,校验验证码的逻辑如下:
private void verificationCaptcha(LoginRequest loginRequest,HttpServletRequest request,HttpServletResponse response) throws IOException {
HttpSession session = request.getSession();
String captcha = loginRequest.getCaptcha();
String verificationCode = (String)session.getAttribute("captcha");
if(!StringUtils.isEmpty(verificationCode)) {
//清除验证码,不管是或成功
session.removeAttribute("captcha");
if (!StringUtils.isEmpty(verificationCode) && !captcha.equals(verificationCode)) {
throw new AuthenticationServiceException("验证码错误!");
}
}
}
security配置类,config如下:
@Override
protected void configure(HttpSecurity http) throws Exception {
http.addFilterBefore(loginAuthenticationFilter,UsernamePasswordAuthenticationFilter.class);
http.formLogin()
.loginPage("/login")//登录页
.and().logout().logoutUrl("/logout")//定义退出页
.and().authorizeRequests()
.antMatchers("/r/r1").hasAuthority("p1")//拥有p1权限的人才能访问r1资源
.antMatchers("/r/r2").hasAuthority("p2")//拥有p2权限的人才能访问r2资源
.antMatchers("/r/**").authenticated()//对r/**的资源需要认证
.antMatchers(dynamicDcUrl)
.permitAll()
.and()
.csrf().disable();
}
注:本图形验证码采用的插件为kaptcha;
本文代码已上传至GitHub
Reference