《SpringSecurity实战》读书笔记

2021-05-31 fishedee 后端

0 概述

《Spring Security实战》,这本书主要讲述授权与认证,以及相关的web安全知识,弥补了我这一部分知识的匮乏。

1 理论

授权与验证有一大堆的相关的安全知识

1.1 登录态

HTTP是无状态协议,无法有效地追踪用户的状态,无法有效地根据不同用户展示不同的页面。后来HTTP发展了Cookie和Session来解决这个问题。

1.1.1 Session

这是一个普通的表单登录

登录成功后,服务器返回一个Set-Cookie字段,关键在于JSESSIONID的这个Cookie

登录成功以后,之后的所有页面请求都会带有这个JSESSIONID的Cookie,服务器就能根据这个Cookie知道是哪个用户在访问当前页面了。注意,在上一次返回了JSESSIONID以后,下一次的页面就不再返回JSESSIONID了。

Set-Cookie: JSESSIONID=2C8346A1EBD87CB442F89DCD1E334BFA; Domain=test.com; Path=/; HttpOnly

注意,这个JSESSIONID的写法,是没有过期时间的。这种没有过期时间的Cookie称为Session,浏览器会在它退出的时候,自动删除这个Cookie。所以,我们下次登录的时候,服务器依然不知道我们是谁,就会要求我们再次输入账号与密码。

我们平时登录的时候,都会看到一个“记住我”的按钮。点击以后,下次退出浏览器以后再登录,服务器也会知道我们是谁,我们不需要再次输入账号与密码。

Set-Cookie: JSESSIONID=2C8346A1EBD87CB442F89DCD1E334BFA; Domain=test.com; Max-Age=3600; Expires=Wed, 02-Jun-2021 11:48:58 GMT; Path=/; HttpOnly

一个直观的做法,是直接给JSESSIONID赋予过期时间,这样退出浏览器以后再次登录,浏览器依然会发送这个JSESSIONID的值。

但是,这样做会有个问题。服务器无法区分,当前用户是输入账号密码授权进来的,还是退出浏览器以后重新进来的。两者的安全性明显不同,我们需要对不同授权方式进来的用户给与不同的权限。

1.1.3 RememberMe

SpringSecurity的做法是,做两个Cookie,JSESSIONID依然是每次浏览器范围内的一个Cookie,然后有一个长期的remember-me的Cookie。

Set-Cookie: remember-me=ZHBlYlB5cEEzQnJScXBZYmlPcnJydyUzRCUzRDpETHBQMFVLRE91TWREU2phcmNOQzV3JTNEJTNE; Max-Age=3600; Expires=Wed, 02-Jun-2021 11:48:58 GMT; Domain=test.com; Path=/; HttpOnly

注意,remember-me的Cookie是有过期时间的,在退出浏览器以后,remember-me的Cookie不会自动删除。

那么,下次退出浏览器重新进入以后,JSESSIONID就没有了,但是remember-me的Cookie依然会继续发送给服务器

服务器就在验证了remember-me的Cookie的合法性以后,会续期remember-me的Cookie,并且分配新的JSESSIONID。这个过程用户是没有感知的,他看到的是退出浏览器重新进入后依然保持了登录态。同时,服务器知道这个用户是从remember-me进来的,而不是通过输入账号密码进来的。

1.1.4 展开

其实JSESSIONID与remember-me的关系就像,AccessToken与RefreshToken的关系。AccessToken代表你的凭证,RefreshToken是可以后备保存来刷新凭证。AccessToken的有效期比较短,所以即使被盗也是影响有限,RefreshToken是存放在本地的Token,很少在网络上传输的,安全性会更强。

但是由于浏览器的关系,remember-me的Cookie是每次都会在网络上传输,它的安全性相比RefreshToken就差很多了。

1.2 会话锁定攻击

会话固定攻击,看这里

会话固定攻击常用于在Url中传递JSESSIONID。

  • 攻击者自己登录网站,记下自己的JSESSIONID
  • 然后构造一个新的url,url的参数上附带自己的JSESSIONID,把这个url发送给别人
  • 被攻击者点击url以后登录,登录态被记录在当前的JSESSIONID
  • 攻击者就能沿用同一个JSESSIONID窃取了别人的登录态

因此,我们的防御方式是:

  • 关闭透明Session,禁止在Url上附带JSESSIONID
  • 每次登录前后,JSESSIONID都需要重新生成
  • JSESSIONID需要以尽量随机的,不易被猜测的方式生成。

1.3 CSRF攻击与HttpOnly

1.3.1 原因

CSRF攻击,看这里

他的攻击在于,在A网站,可以发送到B网站的跨域请求,但是这个请求只能发送,不能获取结果的。在普通的浏览器上,跨域请求都会附带跨域的Cookie,例如A网站请求B网站的请求时,该请求附带B网站的Cookie,并且会被B网站的服务器处理。浏览器仅仅是不允许A网站读取该跨域请求的返回结果而已。

请求会到达B网站服务器

但是,A网站在请求完毕后,无法读取请求的返回结果,浏览器会报出statusCode为0的错误。

但是,尽管如此,该跨域请求确实附带了B网站的Cookie,也被B网站的服务器处理了,这样最终导致CSRF攻击。你想想你在一个A网站浏览的时候,发现自己登录过的其他银行网站被突然转账了是一件多可怕的事情。

1.3.2 Origin防御

一个简单的防御是,服务器B检查,请求的来源,Origin与Referer是不是自己的网站,不是的话就直接拒绝。但是这个方法比较少用,因为Referer在不同浏览器上的实现不同,而且,Referer涉及到用户隐私,用户可以在浏览器中设置请求中不提供Referer属性。

1.3.3 Session防御

CSRF攻击与普通请求的关键在于,CSRF攻击总是跨域的。那么跨域请求与普通请求的最大区别在于:

  • 跨域请求无法读取请求的返回值
  • 跨域请求无法在javascript脚本读取跨域的Cookie

因此,我们根据这两个不同的构造出CSRF的防御措施。

<form action="xxx">
    <input type="text" name="name"/>
    <input type="password" name="password"/>
    <input type="hidden" name="${_csrf.parameterName}" value="${_csrf.token}">
    <button type="submit">登录</button>
</form>

我们在获取登录页面,和任意的表单页面上。用session生成一个随机的csrfToken值,然后注入到表单的一个隐藏字段上。提交表单的时候,这个字段会自动提交到后端,然后校对与当前后端session的值是否一致,就知道csrf攻击有没有发生了。

当跨域csrf发生的时候,它无法获取表单的隐藏csrf字段,所以会被后端的服务器拦截,拒绝执行请求。

@Data
@AllArgsConstructor
public static class ResponseResult{
    @JsonIgnore
    private HttpStatus statusCode;

    private int code;

    private String msg;

    private Object data;

    private String csrfToken;

    private void init(HttpStatus httpStatus,int code,String message,Object data){
        this.code = code;
        this.msg = message;
        this.data = data;
        this.statusCode = httpStatus;

        RequestAttributes requestAttributes =  RequestContextHolder.currentRequestAttributes();
        HttpServletRequest request = ((ServletRequestAttributes)requestAttributes).getRequest();
        Enumeration<String> days = request.getAttributeNames();
        while( days.hasMoreElements()){
            log.info("attributes {}",days.nextElement());
        }

        CsrfToken token = (CsrfToken)request.getAttribute("org.springframework.security.web.csrf.CsrfToken");
        if( token != null ){
            this.csrfToken = token.getToken();
        }
    }

    public ResponseResult(int code,String message,Object data){

        init(HttpStatus.OK,code,message,data);
        //throw new RuntimeException("123");
    }

    public ResponseResult(HttpStatus httpStatus,int code,String message,Object data){
        init(httpStatus,code,message,data);
    }
}

另外一种方法,是在前后端分离的网站中,返回结构体增加一个csrfToken字段。前端收到这个字段后存放到本地变量,然后每次提交请求到附带这个字段就可以了。

1.3.4 Cookie防御

另外一种方法,是后端构造一个XSRF-Token值,写入到Cookie返回到前端。注意,因为POST请求需要XSRF-Token值,所以在调用POST请求之前,你需要保证用GET请求拿到页面的XSRF-Token值。

function getCookie(name){
    var strcookie = document.cookie;//获取cookie字符串
    var arrcookie = strcookie.split("; ");//分割
    //遍历匹配
    for ( var i = 0; i < arrcookie.length; i++) {
        var arr = arrcookie[i].split("=");
        if (arr[0] == name){
            return arr[1];
        }
    }
    return "";
}

 newOptions.headers = {
    'Content-Type': 'application/x-www-form-urlencoded',
    'X-XSRF-TOKEN':getCookie('XSRF-TOKEN'),
    ...newOptions.headers,
};

前端用js读取到这个XSRF-Token值并附带到Header里面提交。注意,这个方法里面,HttpOnly必须false,表示允许被js读取到这个Cookie。

由于跨域请求,无法跨域读取cookie,所以会被后端的服务器拦截,拒绝执行请求。我们在请求的时候,需要写入两个参数:

  • X-XSRF-TOKEN的header值
  • Cookie里面的JESSIONID和XSRF-Token值

1.4 请求跨域与CORS

我们在CSRF那一节了解过,普通跨域请求只能请求,不能获取返回数据。但是,如果我们的网址是www.abc.com,我们需要向后端的api.abc.com请求数据的话,这样就会出问题的。这样的API设计在当前的前后端分离项目中十分常见,怎么办?答案是使用CORS机制

1.4.1 前端CORS

const newOptions = { 
    ...xxx
    mode:"cors",  
};
let response = await fetch(url, newOptions);

前端用fetch发送CORS请求相当简单,只需要在原来的基础上加入mode:cors就可以了。

开启CORS请求以后,前端对API的请求,会先用OPTIONS方法调用检查跨域服务器是否允许这个请求,然后才会执行实际的POST方法来实际请求。

在OPTIONS预检请求中,服务器会发送Access-Control-Allow-Headers,Access-Control-Allow-Methods,Access-Control-Allow-Origin来响应允许这样的请求。

1.4.2 后端CORS

Access-Control-Allow-Origin: http://client.test.com

后端处理CORS,需要两步:

  • 拦截OPTIONS请求,然后在OPTIONS请求的返回中加入合适的Access-Control-Allow-Headers,Access-Control-Allow-Methods,Access-Control-Allow-Origin的Header。如果服务器检查了Origin字段,发现该请求是不合法的,那么就返回一个不含以上Header的回复,那么浏览器就会拒绝这个CORS请求。
  • 在正常的POST请求的返回中加入Access-Control-Allow-Origin的Header。

1.4.3 后端允许Cookie传送

CORS在默认情况下,是不会附带Cookie的信息。服务器需要在OPTIONS预检请求中加入Access-Control-Allow-Credentials:true的Header,那么在接下来的POST请求中才会附带Cookie信息。

**这里有个坑,如果服务器设置了Access-Control-Allow-Origin:*,那么就不能设置Access-Control-Allow-Credentials:true**。所以,你必须让Access-Control-Allow-Origin设置为一个特指的Host,而不能设置泛指的星号,才能设置Access-Control-Allow-Credentials:true。

1.5 Cookie跨域与SameSite

CORS解决了跨域请求的问题,然后我们加上Access-Control-Allow-Credentials:true也会让Cookie一起附带上进行跨域请求。但是在Chrome 80以上的版本,这样做依然不行,即使打开了Access-Control-Allow-Credentials:true,跨域请求依然没有附带上Cookie。这是因为Cookie新增了一个叫SameSite的字段。

SameSite字段的出发点是站在浏览器的角度来避免CSRF攻击,在Chrome新版本上,所有Cookie在没有设置SameSite字段的情况下,就会默认SameSite=Lax的值。SameSite的意义是,当用户处于A域名网站的时候,触发对B域名的跨域请求时,浏览器不会附带B域名的Cookie,毕竟CSRF攻击就是因为跨域请求时附带了Cookie导致的。

但是,在特殊的场景下,例如,我们在www.abc.com的网站,对api.abc.com的域名进行请求时,如果浏览器不对api.abc.com的域名附带Cookie,就会导致服务器无法区分请求者是哪个用户。换句话说,CORS仅仅是保证了我们可以在跨域的情况下获取请求的回复,但无法保证我们在跨域的情况需要附带Cookie的问题。

1.5.1 根域名同域

如果在client.test.com网站请求对server.test.com的域名,由于两者都是同一个根域名test.com。那么解决方法就可以很简单,让server.test.com的回复,让Cookie设置Domain为test.com。

那么虽然这个server.test.com的请求是跨域请求,但Cookie不是跨域的,依然会被附带在server.test.com的请求上,从而绕过了SameSite的限制。

1.5.2 根域名不同域

但是,如果我们在testclient.com网站请求对testserver.com的域名,两者是不属于同一个根域名的,这个Cookie就会因为默认的SameSite设置被拦截,无法在跨域请求中附带出去。所以,这个时候,我们只能对JESESSIONID进行特殊设置,将SameSite设置为None,并且Secure也要打开。这个时候,JESESSIONID就会在下次的跨域请求附带上了。

1.5.3 根域名不同域的CSRF防御

当然,JESSIONID没有了SameSite的保护,那么任何对testserver.com请求,即使不是CORS请求,也会附带上这个JESSIONID,这显然会导致CSRF攻击。

我们之前讨论的CSRF防御都是基于,网站域名与API域名都是同域的情况,而目前是不同域的情况,该如何防御CSRF攻击呢?

解决方法是让API请求从接口处返回CSRFToken

然后让这个CSRFToken附带在Header上来提交。我们模拟一下情况:

  • 对于可信网站,由于网站能通过CORS验证,所以,他能在HTTP回复获取到这个CSRFToken,因此请求成功。
  • 对于不可信网站,当它发出CORS请求时,由于它不能通过CORS验证,所以,他不能在HTTP回复获取到这个CSRFToken,因此请求失败。如果它直接发非CORS请求,它的Header没有CSRFToken,所以请求也会失败。

另外一种情况,我们用1.3.4的Cookie防御方式,模拟一下情况:

  • 对于可信网站,CSRFToken通过Cookie返回过来,但是这个Cookie是在testserver.com域名下的,前端js在testclient.com域名下的,无法直接读取到这个Cookie,更无法附带在请求的Header上,所以,请求失败。

1.6 单点登录

单点登录,注意与CORS的区分。CORS解决的是跨域请求的问题,Cookie是依然是跨域上。单点登录是,在A域名上登录,但是在B域名下可以感知到这个登录态。

其实,CAS单点登录的原理简单。它的方式是在A域名登录后,A域名跳转到B域名的固定网址上,并附带着一个特殊的code字段。B域名服务器根据这个code字段到A域名服务器查询登录态和用户信息,然后写入到自己的Cookie和Session上就可以完成自己域名下登录态的写入。

这个过程其实和OAuth是十分相似的。

2 表单登录

代码在这里

2.1 UserDetail

package spring_test.framework;

import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import spring_test.business.User;

import java.util.ArrayList;
import java.util.Collection;
import java.util.List;

/**
 * Created by fish on 2021/4/26.
 */
//UserDetail需要序列化的,要确保每个字段都可以被序列化.
public class MyUserDetail implements UserDetails {
    private static final long serialVersionUID = 4359709211352400087L;

    private String name;

    private String password;

    private Long userId;

    private String role;

    public MyUserDetail(User user){
        this.name = user.getName();
        this.password = user.getPassword();
        this.userId = user.getId();
        this.role = user.getRole().toString();
    }

    public Collection<? extends GrantedAuthority> getAuthorities(){
        List<GrantedAuthority> authorities = new ArrayList<>();
        authorities.add(new SimpleGrantedAuthority(this.role));
        return authorities;
    }

    public Long getUserId(){
        return this.userId;
    }

    public String getPassword(){
        return this.password;
    }

    public String getUsername(){
        return this.name;
    }

    public boolean isAccountNonExpired(){
        return true;
    }

    public boolean isAccountNonLocked(){
        return true;
    }

    public boolean isCredentialsNonExpired(){
        return true;
    }

    public boolean isEnabled(){
        return true;
    }

    @Override
    public boolean equals(Object obj){
        if( obj instanceof MyUserDetail ){
            return this.getUsername().equals(((MyUserDetail) obj).getUsername());
        }else{
            return false;
        }
    }

    @Override
    public int hashCode(){
        return this.getUsername().hashCode();
    }
}

首先定义一个UserDetail

package spring_test.framework;

import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Component;
import spring_test.business.User;
import spring_test.infrastructure.UserRepository;

/**
 * Created by fish on 2021/4/26.
 */
@Component
@Slf4j
public class MyUserDetailService implements UserDetailsService {
    @Autowired
    private UserRepository userRepository;

    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException{
        User user = userRepository.getByNameForRead(username);
        if( user ==  null){
            //这个异常是固定的,不能改其他的
            throw new UsernameNotFoundException("用户不存在");
        }
        return new MyUserDetail(user);
    }
}

然后定义一个UserDetailService

2.2 PasswordEncoder


/**
 * Created by fish on 2021/4/26.
 */
@Configuration
//这里可以打开debug模式
//可以看到请求过来的Request Cookie与Body
//在POSTMAN中,GET请求仅需要设置Cookie字段就可以访问了
//POST请求需要额外添加X-XSRF-TOKEN的header
@EnableWebSecurity(debug=true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    //密码编码器
    @Bean
    public PasswordEncoder passwordEncoder(){
        PasswordEncoder encoder = new BCryptPasswordEncoder(12);
        return encoder;
    }

    @Autowired
    private MyUserDetailService myUserDetailService;

    @Bean
    public UserDetailsService userDetailsService(){
        return myUserDetailService;
    }

    @Override
    public void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(myUserDetailService)
                .passwordEncoder(passwordEncoder());

    }
}

定义密码编码器,并且我们将UserDetailService与PasswordEncoder通过configure配置进去

2.3 登录与登出

public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl();
        jdbcTokenRepository.setDataSource(dataSource);

        http.
                //开启csrf
                csrf()
                .disable()
                .and()
                //设置认证异常与授权异常的处理
                .exceptionHandling()
                .authenticationEntryPoint(authenticationEntryPoint)
                .accessDeniedHandler(accessDeniedHandler)
                .and()
                //表单登录的处理
                //必须要用urlEncode的参数来传入
                .formLogin()
                .permitAll()
                .loginProcessingUrl("/login/login")
                .usernameParameter("user")//登录的用户名字段名称
                .passwordParameter("password")//登录的密码字段名称
                .successHandler(authSuccessHandler)
                .failureHandler(authFailureHandler)
                .and()
                //登出的处理
                .logout()
                .permitAll()
                .logoutUrl("/login/logout")
                .logoutSuccessHandler(logoutSuccessHandler);

        http.authorizeRequests()
                .antMatchers("/login/login").permitAll()
                .antMatchers("/login/islogin").permitAll()
                .anyRequest().authenticated();
    }
}

然后我们在configure(HttpSecurity http)配置表单登录的信息,包括登录的url,用户名字段,密码字段,以及登出字段。通过.successHandler,.failureHandler和.logoutSuccessHandler,我们可以重写登录和登出时成功的回复。

package spring_test.framework;

import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.security.core.AuthenticationException;
import java.io.IOException;
import java.io.PrintWriter;

//授权入口,发现用户未登陆,或者授权的权限不足的情况
@Component
public class HttpAuthenticationEntryPoint implements AuthenticationEntryPoint {
    @Autowired
    ObjectMapper mapper = new ObjectMapper();

    @Override
    public void commence(HttpServletRequest request,
                         HttpServletResponse response,
                         AuthenticationException authException) throws IOException, ServletException{
        response.setStatus(HttpServletResponse.SC_OK);
        response.setCharacterEncoding("UTF-8");
        response.setContentType("application/json");

        String result = mapper.writeValueAsString(new MyResponseBodyAdvice.ResponseResult(1,"未登录",null));

        PrintWriter writer = response.getWriter();
        writer.write(result);
        writer.flush();
    }
}

authenticationEntryPoint是用户未授权的情况时,被SpringSecurity阻止的逻辑

package spring_test.framework;

import org.apache.catalina.servlet4preview.http.HttpServletRequest;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

/**
 * Created by fish on 2021/4/26.
 */
@Component
public class MyAccessDeniedHandler implements AccessDeniedHandler {
    @Override
    public void handle(javax.servlet.http.HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException{
        response.setStatus(403);
        response.getWriter().write("Forbidden:" + accessDeniedException.getMessage());
    }
}

AccessDeniedHandler是用户已经授权,但是角色权限不足的情况时,被SpringSecurity阻止的逻辑。

3 记住我

3.1 配置

代码在这里

 @Override
protected void configure(HttpSecurity http) throws Exception {
    JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl();
    jdbcTokenRepository.setDataSource(dataSource);

    http.
            csrf()
            .disable()
            //设置认证异常与授权异常的处理
            .exceptionHandling()
            .authenticationEntryPoint(authenticationEntryPoint)
            .accessDeniedHandler(accessDeniedHandler)
            .and()
            //表单登录的处理
            //必须要用urlEncode的参数来传入
            .formLogin()
            .permitAll()
            .loginProcessingUrl("/login/login")
            .usernameParameter("user")//登录的用户名字段名称
            .passwordParameter("password")//登录的密码字段名称
            .successHandler(authSuccessHandler)
            .failureHandler(authFailureHandler)
            .and()
            //记住我,必须用check-box传入一个remeber-me的字段
            //使用记住我以后,maximumSessions为1是没有意义的,因为他能被自动登录
            .rememberMe()
            .userDetailsService(myUserDetailService)
            .tokenRepository(jdbcTokenRepository)
            .tokenValiditySeconds(60 * 60);
}

之前说过了,rememberMe就是相当于refreshToken的作用,可以避免退出浏览器重新进入后,需要重新输入账号密码登录的问题。我们可以在这里rememberMe的有效时间,以及rememberMe的存储位置。

3.2 散列加密与持久化令牌

rememberMe的构造有两种方式:

  • 散列加密,后台对当前用户ID,以及特殊的key值做hash,一起和用户ID合并起来的字符串,特殊的key值是为了防止前端自己伪造rememberMe的Token。具体看P60
  • 持久化令牌,在数据库存储用户ID,和随机的Token值的映射。服务器可以通过检查数据库来确定这个Token是哪个用户。具体看P64

散列加密的方案优点在不需要存储,但是有相对应的缺点:

  • 一个Token,可以放到多个PC上进行登录,这导致了无法限制登录数量的问题。
  • 一旦rememberMe的Token被盗取了,服务器没有办法去发现它。在Token的有效时间内,盗取者都可以用被盗的身份来获取合法的JESESSIONID。

持久化令牌的方案缺点是需要存储,但是每个Token都不会依赖key,纯粹的一个随机数而已,它能很好地避免了散列方案的问题。因为每个Token在换取一个新的JESESSIONID后就会变更,数据库中对于一个用户只能对应一条Token值。这使得:

  • 一个Token,只能在单个PC上登录。因为一个Token换取一个新的JESESSIONID后,就会在数据库中变成新的Token。旧的Token无法在另外一台PC上继续换取JESESSIONID。
  • 可以一定程度上的防盗。如果Token泄漏了,在盗取者机器A上换取了JESESSIONID。而原用户就会在不知情的情况下继续用旧Token换取JESESSIONID,SpringSecurity就会将该用户所有的JESESSIONID都删除掉,并将所有Token删除,以迫使该用户只能以账号密码的方式登录。但是,如果Token泄露了,原用户不登录的话,这种风险依然是存在的,后台是无法发现的。

这里的逻辑比较严谨巧妙,具体看P64

4 会话管理

4.1 会话过期

# session的默认保留时间, SpringBoot 1.x的配置
spring.session.timeout = 1h
# session的默认保留时间, SpringBoot 2.x的配置
server.servlet.session.timeout = 1h

我们可以在application.properties设置会话的保留时间,如果60秒以内会话没活动,就会自动失效。注意,这个值最小为60秒,即使设置少于60秒,SpringSecurity也会校正为60秒。P79

4.2 会话并发

代码在这里

 @Override
protected void configure(HttpSecurity http) throws Exception {
        JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl();
        jdbcTokenRepository.setDataSource(dataSource);

        http.
                //开启csrf
                csrf()
                //默认的headerName为"X-XSRF-TOKEN";
                //默认的cookieName为"XSRF-TOKEN";
                //默认的parameterName的"_csrf";
                //可以看一下CookieCsrfTokenRepository的源代码
                .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
                .and()
                //设置认证异常与授权异常的处理
                .exceptionHandling()
                .authenticationEntryPoint(authenticationEntryPoint)
                .accessDeniedHandler(accessDeniedHandler)
                .and()
                //表单登录的处理
                //必须要用urlEncode的参数来传入
                .formLogin()
                .permitAll()
                .loginProcessingUrl("/login/login")
                .usernameParameter("user")//登录的用户名字段名称
                .passwordParameter("password")//登录的密码字段名称
                .successHandler(authSuccessHandler)
                .failureHandler(authFailureHandler)
                .and()
                //记住我,必须用check-box传入一个remeber-me的字段
                //使用记住我以后,maximumSessions为1是没有意义的,因为他能被自动登录
                .rememberMe()
                .userDetailsService(myUserDetailService)
                .tokenRepository(jdbcTokenRepository)
                .tokenValiditySeconds(60 * 60)
                .and()
                //登出的处理
                .logout()
                .permitAll()
                .logoutUrl("/login/logout")
                .logoutSuccessHandler(logoutSuccessHandler)
                .and()
                //单个用户的最大可在线的会话数
                .sessionManagement()
                //.invalidSessionStrategy(invalidSessionStrategy)
                .maximumSessions(1)
                .expiredSessionStrategy(sessionInformationExpiredStrategy);

        http.authorizeRequests()
                .antMatchers("/login/login").permitAll()
                .antMatchers("/login/islogin").permitAll()
                .anyRequest().authenticated();
}

SpringSecurity还提供了每个用户的会话并发限制,就像上面的配置,就是每个用户同时在线的会话只能为1个。当会话到达上限后,就会踢掉该用户的旧会话下线。

  .sessionManagement()
        //.invalidSessionStrategy(invalidSessionStrategy)
        .maximumSessions(1)
        .maxSessionsPreventsLogin(true)
        .expiredSessionStrategy(sessionInformationExpiredStrategy);

我们也可以设置,当会话到达上限后,就会阻止该用户的新会话创建。P82

public class SessionRegistryImpl implements SessionRegistry{
    //存放用户以及对应的所有的sessionId的map
    private final ConcurrentMap<Object,Set<String>> principals;
    
    private final Map<String,SessionInformation> sessionIds;

    //对新会话创建时
    public void registerNewSession(String sessionId,Object principal){
        
        this.sessionIds.put(sessionId,new SessionInformation(principal,sessionId,new Date()));

        Set<String> sessionsUsedByPrincipal = (Set)this.principals.get(principal);

        xxxx

        sessionsUsedByPrincipal.add(sessionId);
    }
}

SessionRegistryImpl是会话并发控制的核心源码,在P90,它是由ConcurrentSessionControlAuthenticationStarategy的onAuthentication触发的,当新的会话产生时就触发SessionRegistryImpl的registerNewSession方法。它会在内存中记录对应的principals和sessionIds。

从代码中可以看出,principals的Map是以principal为Key的。而principal正正就是UserDetail。

package spring_test.framework;

import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import spring_test.business.User;

import java.util.ArrayList;
import java.util.Collection;
import java.util.List;

/**
 * Created by fish on 2021/4/26.
 */
//UserDetail需要序列化的,要确保每个字段都可以被序列化.
public class MyUserDetail implements UserDetails {
    private static final long serialVersionUID = 4359709211352400087L;

    private String name;

    private String password;

    private Long userId;

    private String role;

    public MyUserDetail(User user){
        this.name = user.getName();
        this.password = user.getPassword();
        this.userId = user.getId();
        this.role = user.getRole().toString();
    }

    public Collection<? extends GrantedAuthority> getAuthorities(){
        List<GrantedAuthority> authorities = new ArrayList<>();
        authorities.add(new SimpleGrantedAuthority(this.role));
        return authorities;
    }

    public Long getUserId(){
        return this.userId;
    }

    public String getPassword(){
        return this.password;
    }

    public String getUsername(){
        return this.name;
    }

    public boolean isAccountNonExpired(){
        return true;
    }

    public boolean isAccountNonLocked(){
        return true;
    }

    public boolean isCredentialsNonExpired(){
        return true;
    }

    public boolean isEnabled(){
        return true;
    }

    @Override
    public boolean equals(Object obj){
        if( obj instanceof MyUserDetail ){
            return this.getUsername().equals(((MyUserDetail) obj).getUsername());
        }else{
            return false;
        }
    }

    @Override
    public int hashCode(){
        return this.getUsername().hashCode();
    }
}

所以,要让会话并发控制有效,就需要对UserDetail重写equals与hashCode方法了。P92

package spring_test.framework;

import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler;
import org.springframework.security.web.session.SessionInformationExpiredEvent;
import org.springframework.security.web.session.SessionInformationExpiredStrategy;
import org.springframework.stereotype.Component;

import javax.servlet.ServletException;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;

/**
 * Created by fish on 2021/4/27.
 */
@Component
public class MySessionInformationExpiredStrategy implements SessionInformationExpiredStrategy {
    @Autowired
    ObjectMapper mapper = new ObjectMapper();

    @Override
    public void onExpiredSessionDetected(SessionInformationExpiredEvent event) throws IOException, ServletException{
        HttpServletResponse response = event.getResponse();
        HttpServletRequest request = event.getRequest();

        //清空remember-me的cookie
        Cookie[] cookies = request.getCookies();
        for (Cookie cookie : cookies) {
            if (cookie.getName().contains("remember-me")) {
                String cookieName = cookie.getName();
                Cookie newCookie = new Cookie(cookieName, null);
                newCookie.setPath("/");
                response.addCookie(newCookie);
                break;
            }
        }

        //返回登录过期了
        response.setStatus(HttpServletResponse.SC_OK);
        response.setCharacterEncoding("UTF-8");
        response.setContentType("application/json");
        String result = mapper.writeValueAsString(new MyResponseBodyAdvice.ResponseResult(20001,"你的账号在其他地方登录了",null));
        PrintWriter writer = response.getWriter();
        writer.write(result);
        writer.flush();
    }
}

最后,我们可以重写当踢掉旧会话上线时的处理逻辑,MySessionInformationExpiredStrategy。这里要注意删除remember-me的Cookie,这样才会让用户避免再次上线。如果不删除remember-me的Cookie,用户只要再刷新一次页面就能进入了。

4.3 会话存储

默认情况下,会话存放在内存中,当应用重新启动的时候,会话都会丢失。

但是,在IDEA下,直接restart应用,会话会自动保存重放,这样能避免开发环境下需要不断登录。同时,有时候IDEA这样做会给你带来困惑,让你怀疑会话不是存放在内存中的。具体看这里这里

4.4 集群会话

http.sessionManagement()
    .maximumSessions(1)
    .sessionRegistry(redisSessionRegistry)
    .expiredSessionStrategy(sessionInformationExpiredStrategy);

我们只需要设置sessionRegistry就可以了,这里参考书本的P97。

这个需要引用额外的Spring Session库,用来管理额外容器的Session。

5 验证码

代码在这里

package spring_test;

/**
 * Created by fish on 2021/6/3.
 */

import com.wf.captcha.SpecCaptcha;
import com.wf.captcha.base.Captcha;
import org.apache.catalina.servlet4preview.http.HttpServletRequest;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.RestController;

import javax.servlet.http.HttpServletResponse;

@RestController
public class CaptchaController {

    @ResponseBody
    @RequestMapping("/captcha")
    public String captcha(HttpServletRequest request, HttpServletResponse response) throws Exception {
        SpecCaptcha specCaptcha = new SpecCaptcha(130, 48, 5);
        specCaptcha.setCharType(Captcha.TYPE_NUM_AND_UPPER);
        String verCode = specCaptcha.text().toLowerCase();
        //将结果写入到session中
        request.getSession().setAttribute("captcha",verCode);
        return specCaptcha.toBase64();
    }
}

先建立一个captcha的接口,前端请求该接口以后,将captcha的答案写入session。

package spring_test.framework;

import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;

import org.springframework.security.core.AuthenticationException;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.io.IOException;

/**
 * Created by fish on 2021/6/3.
 */
@Component
@Slf4j
public class VerificationCodeFilter extends OncePerRequestFilter {

    @Autowired
    private AuthFailureHandler authFailureHandler;

    @Override
    protected void doFilterInternal(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, FilterChain filterChain) throws ServletException, IOException{
        if( !"/login/login".equals(httpServletRequest.getRequestURI())){
            filterChain.doFilter(httpServletRequest,httpServletResponse);
        }else{
            try {
                vertifyCode(httpServletRequest, httpServletResponse);
                filterChain.doFilter(httpServletRequest, httpServletResponse);
            }catch(AuthenticationException e ){
                authFailureHandler.onAuthenticationFailure(httpServletRequest,httpServletResponse,e);
            }
        }
    }

    public void vertifyCode(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse)throws AuthenticationException{
        String requestCode = httpServletRequest.getParameter("captcha");

        HttpSession session = httpServletRequest.getSession();
        String savedCode = (String)session.getAttribute("captcha");
        //清除验证码,只有一次尝试机会
        session.removeAttribute("captcha");

        if( StringUtils.isEmpty(requestCode) || StringUtils.isEmpty(savedCode) || ! requestCode.equals(savedCode)){
            throw new VertificationCodeException();
        }
    }
}

然后我们建立一个Filter,注意Filter是继承自OncePerRequestFilter。OncePerRequestFilter的意思是每个请求仅经过此Filter一次,这是Spring里面的推荐使用的Filter。该Filter的意图也简单,就是在login/login接口里面,检查传入captcha与实际的答案是否相符。注意,无论答对与否,captcha只能使用一次,每次都要清空答案。

@Override
protected void configure(HttpSecurity http) throws Exception {

    ....

    http.addFilterBefore(verificationCodeFilter, UsernamePasswordAuthenticationFilter.class);
}

最后,我们指定该filter在UsernamePasswordAuthenticationFilter之前触发。

@Controller
public class CaptchaController {
    @Autowired
    private RedisUtil redisUtil;
    
    @ResponseBody
    @RequestMapping("/captcha")
    public JsonResult captcha(HttpServletRequest request, HttpServletResponse response) throws Exception {
        SpecCaptcha specCaptcha = new SpecCaptcha(130, 48, 5);
        String verCode = specCaptcha.text().toLowerCase();
        String key = UUID.randomUUID().toString();
        // 存入redis并设置过期时间为30分钟
        redisUtil.setEx(key, verCode, 30, TimeUnit.MINUTES);
        // 将key和base64返回给前端
        return JsonResult.ok().put("key", key).put("image", specCaptcha.toBase64());
    }
    
    @ResponseBody
    @PostMapping("/login")
    public JsonResult login(String username,String password,String verCode,String verKey){
        // 获取redis中的验证码
        String redisCode = redisUtil.get(verKey);
        // 判断验证码
        if (verCode==null || !redisCode.equals(verCode.trim().toLowerCase())) {
            return JsonResult.error("验证码不正确");
        }
    }  
}

对于某些没有使用Session作为登录态的前后端分离项目,EasyCaptcha推荐使用Redis作为存储答案的方式,但是要注意,该代码忘了清空答案。

6 CSRF

代码在这里

@Override
protected void configure(HttpSecurity http) throws Exception {
    JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl();
    jdbcTokenRepository.setDataSource(dataSource);

    http.
            //开启csrf
            csrf()
            //默认的headerName为"X-XSRF-TOKEN";
            //默认的cookieName为"XSRF-TOKEN";
            //默认的parameterName的"_csrf";
            //可以看一下CookieCsrfTokenRepository的源代码
            .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
            .and()
            xxx
}

打开csrf配置,使用Cookie防御的方式。

function getCookie(name){
    var strcookie = document.cookie;//获取cookie字符串
    var arrcookie = strcookie.split("; ");//分割
    //遍历匹配
    for ( var i = 0; i < arrcookie.length; i++) {
        var arr = arrcookie[i].split("=");
        if (arr[0] == name){
            return arr[1];
        }
    }
    return "";
}

export default async function request(url, options) {

    const newOptions = { 
        ...defaultOptions, 
        ...options,
        headers:{
            'X-XSRF-TOKEN':getCookie('XSRF-TOKEN')
        },
        query:{
            _t:new Date().valueOf(),
            ...options.query,
        }
    };

    let response = await fetch(url, newOptions);
}

然后我们在Header的位置加入X-XSRF-TOKEN,这个值是来源于Cookie的。

7 同根域CORS

代码在这里

我们设计一个在client.test.com网站向server.test.com域名的跨域登录请求。

7.1 DNS

127.0.0.1       client.test.com
127.0.0.1       server.test.com

编辑/etc/hosts,加入这两条

7.2 nginx

server{
    listen 80;

    server_name server.test.com;

    location / {
        proxy_http_version 1.1;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header Connection "";
        proxy_pass http://localhost:9595;
    }
}

加入TestServer配置

server{
        listen 80;

        server_name client.test.com;

        location / {
                proxy_http_version 1.1;
                proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
                proxy_set_header Connection "";
                proxy_pass http://localhost:9596;
        }
}

加入TestClient配置

7.3 客户端CORS

function getCookie(name){
    var strcookie = document.cookie;//获取cookie字符串
    var arrcookie = strcookie.split("; ");//分割
    //遍历匹配
    for ( var i = 0; i < arrcookie.length; i++) {
        var arr = arrcookie[i].split("=");
        if (arr[0] == name){
            return arr[1];
        }
    }
    return "";
}

export default async function request(url, options) {

    const newOptions = { 
        ...defaultOptions, 
        ...options,
        mode:"cors",
        headers:{
            'X-XSRF-TOKEN':getCookie('XSRF-TOKEN')
        },
        query:{
            _t:new Date().valueOf(),
            ...options.query,
        }
    };
    xxxx
    url = "http://server.test.com"+url;

    let response = await fetch(url, newOptions);
}

前端文件中加入mode选项,CSRF防御依然使用Cookie防御的方式。

7.4 服务器CORS

package spring_test;

import org.apache.tomcat.util.http.Rfc6265CookieProcessor;
import org.apache.tomcat.util.http.SameSiteCookies;

import javax.servlet.http.HttpServletRequest;
import java.text.DateFormat;
import java.text.FieldPosition;
import java.util.Date;

/**
 * Created by fish on 2021/6/2.
 */
public class MyCookieProcessor extends Rfc6265CookieProcessor {
    public String generateHeader(javax.servlet.http.Cookie cookie, HttpServletRequest request) {
        cookie.setDomain("test.com");
        return super.generateHeader(cookie,request);
    }
}

先定义一个CookieProcessor,以让所有的Cookie都设置test.com的Domain

package spring_test;

import org.apache.tomcat.util.http.LegacyCookieProcessor;
import org.apache.tomcat.util.http.Rfc6265CookieProcessor;
import org.apache.tomcat.util.http.SameSiteCookies;
import org.springframework.boot.web.embedded.tomcat.TomcatContextCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

/**
 * Created by fish on 2021/6/1.
 */
@Configuration
public class MvcConfiguration implements WebMvcConfigurer {
    @Bean
    public TomcatContextCustomizer sameSiteCookiesConfig() {
        return context -> {
            final MyCookieProcessor cookieProcessor = new MyCookieProcessor();
            context.setCookieProcessor(cookieProcessor);
        };
    }
}

将MyCookieProcessor注入到MvcConfiguration中。

 @Override
protected void configure(HttpSecurity http) throws Exception {
    xxxxx


    http.authorizeRequests()
            .antMatchers("/login/login").permitAll()
            .antMatchers("/login/islogin").permitAll()
            .anyRequest().authenticated()
            .and()
            .cors();
}

@Bean
CorsConfigurationSource corsConfigurationSource(){
    CorsConfiguration configuration = new CorsConfiguration();
    configuration.setAllowedOrigins(Arrays.asList("https://client.test.com","http://client.test.com"));
    configuration.setAllowedMethods(Arrays.asList("*"));
    configuration.setAllowedHeaders(Arrays.asList("*"));
    configuration.setAllowCredentials(true);

    UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
    source.registerCorsConfiguration("/**",configuration);
    return source;
}

然后在WebSecurityConfigurerAdapter中加入以上配置即可。注意,setAllowedOrigins是不可以设置localhost域名,而且http协议还是https协议必须明确写出来,不能省略。

登录http://client.test.com/index.html即可

8 不同根域CORS

代码在这里

我们设计一个在testclient.com网站向testserver.com域名的跨域登录请求。两个是在不同根域下面的。

8.1 DNS

127.0.0.1   testclient.com
127.0.0.1   testserver.com

在/etc/hosts中加入以上dns

8.2 nginx

server{
    listen 443 ssl;

    server_name testclient.com;

    ssl_certificate /usr/local/etc/nginx/conf/server.crt;
    ssl_certificate_key /usr/local/etc/nginx/conf/server.key;
    ssl_session_timeout 5m;
    ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE:ECDH:AES:HIGH:!NULL:!aNULL:!MD5:!ADH:!RC4;
    ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
    ssl_prefer_server_ciphers on;

    location / {
        proxy_http_version 1.1;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header Connection "";
        proxy_pass http://localhost:9596;
    }
}

加入TestClientTLS的配置,注意,自己生成一个本地证书。

server{
    listen 443 ssl;

    server_name testserver.com;

    ssl_certificate /usr/local/etc/nginx/conf/server.crt;
    ssl_certificate_key /usr/local/etc/nginx/conf/server.key;
    ssl_session_timeout 5m;
    ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE:ECDH:AES:HIGH:!NULL:!aNULL:!MD5:!ADH:!RC4;
    ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
    ssl_prefer_server_ciphers on;

    location / {
        proxy_http_version 1.1;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header Connection "";
        proxy_pass http://localhost:9595;
    }
}

加入TestServerTLS的配置,注意,自己生成一个本地证书。

8.3 客户端cors


var globalCsrfToken = "";

function checkBody(response){
  if( response.code == 0 ){
    if( response.csrfToken ){
      globalCsrfToken = response.csrfToken;
    }
    return;
  }
  const error = new Error(response.msg);
  error.code = response.code;
  error.msg = response.msg;
  throw error;
}


export default async function request(url, options) {
  const defaultOptions = {
    credentials: "include",
  };
  const newOptions = { 
    ...defaultOptions, 
    ...options,
    mode:"cors",
    headers:{
        'X-CSRF-TOKEN':globalCsrfToken,
    }
    query:{
        _t:new Date().valueOf(),
        ...options.query,
    }
  };

  url = "https://testserver.com"+url;
  let response = await fetch(url, newOptions);
  if( newOptions.autoCheck ){
    checkStatus(response);
  }
  
  let data = await response.json();
  if( newOptions.autoCheck ){
    checkBody(data);
  }
}

客户端cors也是需要加入mode的选项,但是CSRF防御的方式使用Session防御,它会在接口中接收csrfToken字段,然后写入到本地的globalCsrfToken。再下次提交请求的时候,都会附带上这个Token到X-CSRF-TOKEN的header字段。

8.4 服务器cors

package spring_test;

import org.apache.tomcat.util.http.Rfc6265CookieProcessor;
import org.apache.tomcat.util.http.SameSiteCookies;

import javax.servlet.http.HttpServletRequest;
import java.text.DateFormat;
import java.text.FieldPosition;
import java.util.Date;

/**
 * Created by fish on 2021/6/2.
 */
public class MyCookieProcessor extends Rfc6265CookieProcessor {
    public String generateHeader(javax.servlet.http.Cookie cookie, HttpServletRequest request) {
        cookie.setSecure(true);
        return super.generateHeader(cookie,request);
    }
}

先定义一个CookieProcessor,将所有Cookie都打开Secure开关.

package spring_test;

import org.apache.tomcat.util.http.LegacyCookieProcessor;
import org.apache.tomcat.util.http.Rfc6265CookieProcessor;
import org.apache.tomcat.util.http.SameSiteCookies;
import org.springframework.boot.web.embedded.tomcat.TomcatContextCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

/**
 * Created by fish on 2021/6/1.
 */
@Configuration
public class MvcConfiguration implements WebMvcConfigurer {
    @Bean
    public TomcatContextCustomizer sameSiteCookiesConfig() {
        return context -> {
            final MyCookieProcessor cookieProcessor = new MyCookieProcessor();
            cookieProcessor.setSameSiteCookies(SameSiteCookies.NONE.getValue());
            context.setCookieProcessor(cookieProcessor);
        };
    }
}

在MvcConfiguration中打开SameSite为None的开关。

 @Override
protected void configure(HttpSecurity http) throws Exception {
    xxxxx
    HttpSessionCsrfTokenRepository csrfTokenRepository = new HttpSessionCsrfTokenRepository();

        http.
                //开启csrf
                csrf()
                .csrfTokenRepository(csrfTokenRepository)
                .and()
                .xxxx

    http.authorizeRequests()
            .antMatchers("/login/login").permitAll()
            .antMatchers("/login/islogin").permitAll()
            .anyRequest().authenticated()
            .and()
            .cors();
}

@Bean
CorsConfigurationSource corsConfigurationSource(){
    CorsConfiguration configuration = new CorsConfiguration();
    configuration.setAllowedOrigins(Arrays.asList("https://testclient.com"));
    configuration.setAllowedMethods(Arrays.asList("*"));
    configuration.setAllowedHeaders(Arrays.asList("*"));
    configuration.setAllowCredentials(true);

    UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
    source.registerCorsConfiguration("/**",configuration);
    return source;
}

WebSecurityConfigurerAdapter的配置倒是没什么变化,注意CSRF要使用HttpSessionCsrfTokenRepository的方式。

@Data
@AllArgsConstructor
public static class ResponseResult{
    @JsonIgnore
    private HttpStatus statusCode;

    private int code;

    private String msg;

    private Object data;

    private String csrfToken;

    private void init(HttpStatus httpStatus,int code,String message,Object data){
        this.code = code;
        this.msg = message;
        this.data = data;
        this.statusCode = httpStatus;

        RequestAttributes requestAttributes =  RequestContextHolder.currentRequestAttributes();
        HttpServletRequest request = ((ServletRequestAttributes)requestAttributes).getRequest();

        CsrfToken token = (CsrfToken)request.getAttribute("org.springframework.security.web.csrf.CsrfToken");
        if( token != null ){
            this.csrfToken = token.getToken();
        }
    }

    public ResponseResult(int code,String message,Object data){

        init(HttpStatus.OK,code,message,data);
    }

    public ResponseResult(HttpStatus httpStatus,int code,String message,Object data){
        init(httpStatus,code,message,data);
    }
}

最后设置一下ResponseResult,在返回的回复体都附带一个CsrfToken。

先登录https://testserver.com,强行打开这个没有根证书的域名。

然后打开https://testclient.com就可以登录了

9 OAuth

暂时没用到,就没看了。

11 切换用户

一个常见的场景是,管理员可以凭借自身权限,任意切换到其他的用户上。

@Bean
public SwitchUserFilter switchUserFilter(){
    SwitchUserFilter filter = new SwitchUserFilter();
    filter.setUserDetailsService(myUserDetailService);
    filter.setSwitchUserUrl("/login/impersonate");
    filter.setSuccessHandler(authSuccessHandler);
    filter.setFailureHandler(authFailureHandler);
    return filter;
}

加入切换用户的bean

@Override
protected void configure(HttpSecurity http) throws Exception {
    ...
    http.addFilterAfter(switchUserFilter(), FilterSecurityInterceptor.class);
}

将SwitchUserFilter加入到Filter链里面

 .authorizeRequests()
            .antMatchers("/login/impersonate*").hasRole("ADMIN")

指定url有对应的权限即可

const onLoginChange = async ()=>{
    await axios({
        method:'POST',
        url:'/login/impersonate',
        params:{
            username:data.name,
        }
    });
    window.location.reload();
}

切换的方法也比较简单,直接POST一个url,将name设置进去就可以了。

12 多租户

代码在这里

Spring Security要实现多租户需要支持:

  • 在表单登录和Remember-Me登录以后,需要将当前的租户ID写入Cookie和UserDetail,写入Cookie的目的是为了在所有API上自动带上租户ID,方便网关路由。写入UserDetail的目的是,Session的租户ID更加安全可靠,是与Cookie的租户ID相比较以确定无篡改。
  • 拉取和校验用户信息,以及Remember-me的持久化令牌,都需要设置租户ID,然后才去拉数据库
  • 在Session并发控制的时候,需要根据userName和tenantId的组合来确定当前有多少个session。而不是仅仅通过userName来判断。
  • Http的拦截器上,校验UserDetail上的tenantId,和cookie上的相比较,来确定是否合法

12.1 登录后写入Cookie

http  
    .formLogin()
    .successHandler(authSuccessHandler)
    .rememberMe()
    .authenticationSuccessHandler(new AuthenticationSuccessHandler(){
        @Override
        public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication var3) throws IOException, ServletException{
            if( myAuthSuccessHandler != null ){
                myAuthSuccessHandler.handle(false);
            }
        }
    })

设置successHandler的回调

package spring_test;

import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import spring_test.framework.MyAuthSuccessHandler;

import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.UnsupportedEncodingException;

@Component
@Slf4j
public class MyTenantAuthSuccessHandler implements MyAuthSuccessHandler {
    @Override
    public void handle(boolean isFormLogin){
        HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
        HttpServletResponse response = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getResponse();
        String tenantId = MyTenantHolder.getTenantIdByRequest();
        //写入cookie
        try{
            String tenantIdEncode = java.net.URLEncoder.encode(tenantId, "UTF-8");
            Cookie nameCookie = new Cookie("tenantId", tenantIdEncode);
            //设置Cookie的有效时间,单位为秒
            nameCookie.setMaxAge(7*3600*24);
            nameCookie.setPath("/");
            nameCookie.setHttpOnly(true);
            //通过response的addCookie()方法将此Cookie对象保存到客户端浏览器的Cookie中
            response.addCookie(nameCookie);
        }catch(UnsupportedEncodingException e){
            throw new RuntimeException(e);
        }
    }
}

在校验成功以后,写入cookie和session

12.2 登录后写入UserDetail

package spring_test;

import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import spring_test.business.User;

import java.util.ArrayList;
import java.util.Collection;
import java.util.List;

public class MyTenantUserDetails implements UserDetails {
    private static final long serialVersionUID = 4359709211352400087L;

    private String tenantId;

    private String name;

    private String password;

    private Long userId;

    private String role;

    public MyTenantUserDetails(String tenantId, User user){
        this.tenantId = tenantId;
        this.name = user.getName();
        this.password = user.getPassword();
        this.userId = user.getId();
        this.role = user.getRole().toString();
    }

    public Collection<? extends GrantedAuthority> getAuthorities(){
        List<GrantedAuthority> authorities = new ArrayList<>();
        authorities.add(new SimpleGrantedAuthority(this.role));
        return authorities;
    }

    public String getTenantId(){
        return this.tenantId;
    }

    public Long getUserId(){
        return this.userId;
    }

    public String getPassword(){
        return this.password;
    }

    public String getUsername(){
        return this.name;
    }

    public boolean isAccountNonExpired(){
        return true;
    }

    public boolean isAccountNonLocked(){
        return true;
    }

    public boolean isCredentialsNonExpired(){
        return true;
    }

    public boolean isEnabled(){
        return true;
    }

    @Override
    public boolean equals(Object obj){
        if( obj instanceof MyTenantUserDetails){
            MyTenantUserDetails right = (MyTenantUserDetails) obj;
            return this.getUsername().equals(right.getUsername()) &&
                    this.getTenantId().equals(right.getTenantId());
        }else{
            return false;
        }
    }

    @Override
    public int hashCode(){
        String link = this.getTenantId()+"#"+this.getUsername();
        return link.hashCode();
    }
}

自定义一个UserDetails,注意重写了hashCode和equals,要以name和tenantId的组合为匹配。

package spring_test;

import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Component;
import spring_test.business.User;
import spring_test.framework.MyUserDetail;
import spring_test.infrastructure.UserRepository;

@Component
@Slf4j
public class MyTenantUserDetailService implements UserDetailsService {
    private UserRepository userRepository;

    public MyTenantUserDetailService(UserRepository userRepository){
        this.userRepository = userRepository;
    }

    public MyTenantUserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        String tenantId = MyTenantHolder.getTenantIdByRequest();
        if( tenantId == null ){
            throw new UsernameNotFoundException("缺少租户参数");
        }
        MyTenantHolder.setTenantId(tenantId);

        User user = userRepository.getByNameForRead(username);
        if( user ==  null){
            //这个异常是固定的,不能改其他的
            throw new UsernameNotFoundException("用户不存在");
        }
        return new MyTenantUserDetails(tenantId,user);
    }
}

实现MyTenantUserDetailService,设置租户ID以后才读取数据库

package spring_test;

import org.springframework.security.web.authentication.rememberme.JdbcTokenRepositoryImpl;
import org.springframework.security.web.authentication.rememberme.PersistentRememberMeToken;

import javax.sql.DataSource;
import java.util.Date;

public class MyTenantPersistentTokenRepository extends JdbcTokenRepositoryImpl {

    public MyTenantPersistentTokenRepository(DataSource dataSource){
        this.setDataSource(dataSource);
    }

    @Override
    public void createNewToken(PersistentRememberMeToken token){
        String tenantId = MyTenantHolder.getTenantIdByRequest();
        if( tenantId == null ){
            throw new RuntimeException("缺少租户ID");
        }
        MyTenantHolder.setTenantId(tenantId);
        super.createNewToken(token);
    }

    @Override
    public void updateToken(String key, String value, Date date){
        String tenantId = MyTenantHolder.getTenantIdByRequest();
        if( tenantId == null ){
            throw new RuntimeException("缺少租户ID");
        }
        MyTenantHolder.setTenantId(tenantId);
        super.updateToken(key,value,date);
    }

    @Override
    public PersistentRememberMeToken getTokenForSeries(String key){
        String tenantId = MyTenantHolder.getTenantIdByRequest();
        if( tenantId == null ){
            return null;
        }
        MyTenantHolder.setTenantId(tenantId);
        return super.getTokenForSeries(key);
    }

    @Override
    public void removeUserTokens(String key){
        String tenantId = MyTenantHolder.getTenantIdByRequest();
        if( tenantId == null ){
            throw new RuntimeException("缺少租户ID");
        }
        MyTenantHolder.setTenantId(tenantId);
        super.removeUserTokens(key);
    }
}

Remember-me的持久化令牌中,每个接口都需要先设置租户ID,注意,getTokenForSeries没有租户ID的时候,直接返回null就可以了。

12.3 业务前的租户ID设置和校验

package spring_test;

import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.context.SecurityContextImpl;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import spring_test.framework.MyException;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

@Component
@Slf4j
public class MyTenantInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        String tenantId = MyTenantHolder.getTenantIdByRequest();
        MyTenantHolder.setTenantId(tenantId);
        //检查cookie与session是否一致
        SecurityContextImpl securityContextImpl = (SecurityContextImpl)request.getSession().getAttribute("SPRING_SECURITY_CONTEXT");
        if( securityContextImpl != null ){
            //FIXME,前端需要对以下错误进行处理,收到以下错误以后自动跳转到登录页面
            MyTenantUserDetails userDetail = (MyTenantUserDetails)securityContextImpl.getAuthentication().getPrincipal();

            log.info("tenantId {}, loginInfo {}",tenantId,userDetail);
            if( userDetail.getTenantId() == null ){
                throw new MyException(1,"缺少租户ID",null);
            }
            if( tenantId.equals(userDetail.getTenantId()) == false ){
                throw new MyException(1,"租户ID不一致",null);
            }
        }
        return true;
    }
}

当登录态为空的时候,无需校验租户ID的信息

13 Session

代码在这里

13.1 Session过期时间

# session的默认保留时间, SpringBoot 1.x的配置
spring.session.timeout = 1h
# session的默认保留时间, SpringBoot 2.x的配置
server.servlet.session.timeout = 1h

注意在不同版本的配置

package spring_test;

import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.web.embedded.tomcat.TomcatContextCustomizer;
import org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory;
import org.springframework.boot.web.servlet.server.ConfigurableServletWebServerFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
@Slf4j
public class MainConfig  {
    //两个bean,其中二选一
    @Bean
    public TomcatContextCustomizer sameSiteCookiesConfig() {
        return context -> {
            TOMCAT_CONTEXT = context;
            log.info("session timeout {} minutes", context.getSessionTimeout());
        };
    }


    public static org.apache.catalina.Context TOMCAT_CONTEXT;
}

启动以后,我们可以通过TomcatContextCustomizer来查看实际的配置

13.2 查看当前所有的Session

package spring_test;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.experimental.Accessors;
import lombok.extern.slf4j.Slf4j;
import org.apache.catalina.Session;
import org.apache.catalina.core.StandardContext;
import org.springframework.aop.framework.AopContext;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.authentication.RememberMeAuthenticationToken;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextImpl;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.context.request.RequestAttributes;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import spring_test.business.User;
import spring_test.framework.MyException;
import spring_test.framework.MyUserDetail;
import spring_test.infrastructure.UserRepository;

import javax.persistence.Access;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
import java.util.*;
import java.util.stream.Collectors;

/**
 * Created by fish on 2021/4/26.
 */
@RestController
@RequestMapping("/session")
@Slf4j
public class SessionController {

    @Data
    @AllArgsConstructor
    @NoArgsConstructor
    @Accessors(chain = true)
    public static class SessionInfo {
        private String id;

        private Date createTime;

        private Date lastActiveTime;

        private Integer maxIdleSecond;

        private Long currentIdleMilliSecond;

        private Map<String,String> attributes;
    }

    @GetMapping("/getAll")
    public List<SessionInfo> getAll(HttpServletRequest request){
        Session[] sessions = MainConfig.TOMCAT_CONTEXT.getManager().findSessions();
        return Arrays.stream(sessions).map(single->{
            Map<String,String> attributes = new HashMap<>();
            HttpSession s = single.getSession();
            Enumeration<String> names = s.getAttributeNames();
            while( names.hasMoreElements() ){
                String name = names.nextElement();
                String value = s.getAttribute(name).toString();
                attributes.put(name,value);
            }
            return new SessionInfo()
                    .setId(single.getId())
                    .setCreateTime(new Date(single.getCreationTime()))
                    .setLastActiveTime(new Date(single.getLastAccessedTime()))
                    .setMaxIdleSecond(single.getMaxInactiveInterval())
                    .setCurrentIdleMilliSecond(single.getIdleTimeInternal())
                    .setAttributes(attributes);
        }).collect(Collectors.toList());
    }
}

记录TomcatContextCustomizer的Context,然后getManager就能获取到所有的Session了,可以清晰看到登录用户或者未登录用户的session信息。

19 FAQ

19.1 Remember-me意外丢失

public class PersistentTokenBasedRememberMeServices{
    protected UserDetails processAutoLoginCookie(String[] cookieTokens, HttpServletRequest request, HttpServletResponse response) {
        if (cookieTokens.length != 2) {
            throw new InvalidCookieException("Cookie token did not contain 2 tokens, but contained '" + Arrays.asList(cookieTokens) + "'");
        } else {
            String presentedSeries = cookieTokens[0];
            String presentedToken = cookieTokens[1];
            PersistentRememberMeToken token = this.tokenRepository.getTokenForSeries(presentedSeries);
            if (token == null) {
                throw new RememberMeAuthenticationException("No persistent token found for series id: " + presentedSeries);
            } else if (!presentedToken.equals(token.getTokenValue())) {
                this.tokenRepository.removeUserTokens(token.getUsername());
                throw new CookieTheftException(this.messages.getMessage("PersistentTokenBasedRememberMeServices.cookieStolen", "Invalid remember-me token (Series/token) mismatch. Implies previous cookie theft attack."));
            } else if (token.getDate().getTime() + (long)this.getTokenValiditySeconds() * 1000L < System.currentTimeMillis()) {
                throw new RememberMeAuthenticationException("Remember-me login has expired");
            } else {
                this.logger.debug(LogMessage.format("Refreshing persistent login token for user '%s', series '%s'", token.getUsername(), token.getSeries()));
                PersistentRememberMeToken newToken = new PersistentRememberMeToken(token.getUsername(), token.getSeries(), this.generateTokenData(), new Date());

                try {
                    this.tokenRepository.updateToken(newToken.getSeries(), newToken.getTokenValue(), newToken.getDate());
                    this.addCookie(newToken, request, response);
                } catch (Exception var9) {
                    this.logger.error("Failed to update token: ", var9);
                    throw new RememberMeAuthenticationException("Autologin failed due to data access problem");
                }

                return this.getUserDetailsService().loadUserByUsername(token.getUsername());
            }
        }
    }
}

在processAutoLoginCookie的时候,如果发现presentedSeries对应的tokenValue不匹配,就会进行删除removeUserTokens的操作。这种设计的出发点是:

  • A用户登入,获取remember-me,然后他退出了。
  • B用户窃取了A用户的remember-me的token,登入了A的系统
  • A用户二次登录的时候,remember-me的token已经发生了变更

Spring Securitys发现同一个token被更新了多次,意味着token被发现,所以删除了该用户的所有token。

但是,这种设计,并没有考虑一个并发的现实问题。A用户登入的时候,携带的是一个过期的sessionId,以及有效的remember-me token。而且,它同时执行了两个请求。

  • A请求使用的(过期sessionId + 有效的remember-me token),这个时候,Spring Security刷新了后台的remember-me token,以及返回了一个新的sessionId
  • B请求使用的(过期sessionId + 有效的remember-me token),这个时候,Spring Security发现remember-me token已经刷新过了,就会触发CookieTheftException异常。

两个并发请求执行remember-me token刷新,就会触发CookieTheftException异常,导致所有的remember-me的Token都全部被删除了。

解决方法有两种:

  • 尽量避免过期sessionId去执行后端,例如可以是系统初始化的时候,每次都用remember-me的Token去获取新的sessionId,而不复用上次使用sessionId,这样的sessionId的使用时间是最长的。或者,仅允许用单线程周期性地使用remember-me的Token来获取新的sessionId,刷新的过程中,其他请求需要暂停发送。(客户端要求较高)
  • remember-me的token在删除的时候,使用value为random的方式来实现软删除。并发出现问题的时候,仅有B请求是失败的,并不会造成其他请求也崩掉的情况。(会丢掉窃取remember-me的token安全性)

20 总结

Spring Security总体而言还是比较复杂,这种复杂来源于:

  • 本身的Web安全理论就是复杂
  • 设计自身为了支持Http认证,表单认证,OAuth认证,以及为了扩展性支持不同的csrfTokenRepository,UserDetailService,sessionRegistry等等巨大扩展性引用的复杂性。

总体而言,使用复杂,架构设计也确实漂亮,能同时支持这么多的特性。

相关文章