需求

为了增强用户体验,实现“记住我”功能;

为了提高csrf攻击门槛,增加图形验证码功能;

记住我

场景类比:

  • 从用户体验来讲:
    在用户登录系统一次访问首页后,在有效期内可以免登录访问首页;中间可以包括不小心电脑关机,关闭浏览器等场景…
  • 从技术的角度来讲:
  1. 即便session过期,remember me还在有效期内也可以访问;
  2. 即便系统重启,remember me还在有效期内也可以访问。

验证码

对于csrf攻击,大家可自行百度详细内容,我在这里简单说一下:

要完成一次CSRF攻击,受害者必须依次完成两个步骤:

  1. 登录受信任网站A,并在本地生成Cookie。
  2. 在不登出A的情况下,访问危险网站B(B直达A的链接)。

如果在表单中增加一个随机的数字或字母验证码,通过强制用户和应用进行交互,来有效地遏制CSRF攻击。

其实还有一种方式放csrf攻击的方式,通过token校验或者referer

本文代码已上传至GitHub

代码参考:https://github.com/wanglongsxr/springsecurity.git

注:本篇并不打算讲解如何通过代码去搭建完成的springboot整合security工程(一方面因为网上已经有很多优秀的案例代码,另一方面我感觉如果用大量的代码去讲解的话,很多人看着看着就绕进去了,或者看到一半迷茫了,从而去纠结为什么这么做,这么做的目的是什么),如有需要或疑惑,请到github下载源码,内部含有详细注释。

记住我功能

思考

在引入正文之前,我们可以先思考以下这两个问题:

  1. rememberMe的原理是什么?
  2. 已经有session了,为什么不把会话时间调大一点?为什么还要引入rememberMe?

至于第一个问题,其实可以这么讲:

如果我们撇开security来讲,其实rememberMe功能的本质就是利用Cookie。

在登录页面,如果用户勾选了“记住我”,那么服务端在响应中加上Remember-Me的cookie,在这里我们还可以设置cookie的时长,比如设置3天。在3天内,用户可以不用登录,反之则需要重新登录。

而在security中他是将这个“cookie”给持久化了,从数据库去拿到这个“cookie”值,从而进行访问。

那么第二个问题是问什么呢?

在security框架中,session是可以设置时长的。你设置3天,意味着一个HttpSession将会由Tomcat保留3天。这样就加大了服务器的开销。试想以下如果用户基数大的情况下,性能急剧下降。

security中的remrember me

为了防止代码看着看着就迷路,我们先来看一段流程图:

springboot登录时间配置 springboot记住登录_security

上图是针对与代码层次的,也就是说用户在security认证成功后,会问一下remrember me小弟,“兄弟,我这里保存了用户信息,你要不要也保存一份”,然后剩下的就靠这个小弟自己权衡了。

另 :强调一下:PersistentTokenBasedRememberMeServices在这里做了两件事:

  1. 向数据库添加token;
  2. 另一个是添加cookie

注意:以上图例是在用户第一次访问时候的流程,也就是说刚勾选上“记住我”这个功能,然后访问的过程。

那么当服务器重启或者清除浏览器缓存时,他的流程是这样的:

springboot登录时间配置 springboot记住登录_security_02

其中涉及到的类:

  1. 方法有RememberMeAuthenticationFilter的doFilter方法,判断session是否存在;
  2. 不存在则调取rememberMeServices的autoLogin方法,会去到cookie中取是否存在Token,遍历cookie获取到RememberMe的Token;
  3. 最后又通过PersistentTokenBasedRememberMeServices的processAutoLoginCookie方法去数据库查,然后根据Token获取用户信息返回调用的URL信息。

以上就是Spring Security实现RemeberMe功能的原理;

最后再来一张总图:

springboot登录时间配置 springboot记住登录_remember me_03

在实际使用中,自己曾经遇到过CookieTheftException问题,俗称cookie欺骗,(主要原因是拿到的cookie令牌没有跟用户信息匹配上)大致总结一下该问题的原因:

  1. 成功登录后:将使用一些随机哈希为用户创建一个永久令牌。将为用户创建一个cookie,上面带有令牌详细信息。为用户创建一个会话。只要用户仍具有活动会话,就不会在身份验证时调用我的功能。
  2. 用户会话到期后: “记住我”功能将启动并使用cookie从数据库中获取持久性令牌。如果持久令牌与cookie中的令牌匹配,则每个人都对用户进行身份验证感到满意,将生成一个新的随机哈希,并使用持久令牌进行更新,并为后续请求更新用户的cookie。
  3. 但是,如果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

代码参考:https://github.com/wanglongsxr/springsecurity.git

Reference