Spring Security + JWT

Spring Security默认是基于session进行用户认证的,用户通过登录请求完成认证之后,认证信息在服务器端保存在session中,之后的请求发送上来后SecurityContextPersistenceFilter过滤器从session中获取认证信息、以便通过后续安全过滤器的安全检查。

今天的目标是替换Spring Security默认的session保存认证信息的机制为通过JWT的方式进行认证。

JWT(JSON WEB TOKEN)的相关内容就不做详细分析了,我们只需要知道以下几点:

  1. 用户登录认证(用户名、密码验证)通过之后,系统生成token并送给前端。
  2. token中包含用户id(或用户名)以及过期时间,包含通过加密机制生成的摘要,具有防篡改的能力。
  3. token信息不需要在服务器端保存,前端获取到token之后,每次请求都必须携带该token。
  4. 后台接收到请求之后,检查没有token、或者token验证不通过则不生成认证信息,否则,token验证通过则表示该用户通过认证。
  5. 后台接收到的token如果已过期,则根据应用的需求自动更新token或者要求前端重新登录。

与session方案对比一下,我们需要解决的问题如下:

  1. 需要停用掉Spring Security默认的session管理用户认证信息的方案。
  2. 用户登录后需要生成并返回给前端token。
  3. 前端请求上来之后,需要获取并验证token,验证通过后生成用户认证信息。

下面我们逐一解决上述三个问题。我们仍然使用上一篇文章中用过的demo,已经贴出过的代码就不再贴出了。

准备工作

我们需要准备一些与JWT相关的东西,比如引入JWT的生成token、token验证的模块。

我们引入java-jwt,在pom文件加入依赖即可:


            com.auth0
            java-jwt
            4.2.1
        

然后需要编写一个工具类,以便能够生成、验证token,我们暂时不考虑token过期等等细节问题的处理,只要能正确生成、验证token就可以:

public class JwtUtil {
    public final static String SECRET_KEY="This is secret key for JWT";
    public final static String JWTHeader_Leading_Str="Bearer ";
    public final static String JWTHeader_Name="Authorization";

    public static String generateToken(String userName){
        Calendar calendar = Calendar.getInstance();
        calendar.add(Calendar.SECOND,120);
        HashMap header = new HashMap<>();
        header.put("alg","HS256");
        header.put("Type","JWT");
        return JWT.create().withHeader(header)
                .withClaim("userName",userName)
                .withExpiresAt(calendar.getTime())
                .sign(Algorithm.HMAC256(SECRET_KEY));
    }

    public static String verify(String token){
        // 创建解析对象,使用的算法和secret要与创建token时保持一致
        JWTVerifier jwtVerifier = JWT.require(Algorithm.HMAC256(SECRET_KEY)).build();
        // 解析指定的token
        DecodedJWT decodedJWT = jwtVerifier.verify(token);
        return decodedJWT.getClaims().get("userName").asString();

    }

    public static String parseToken(HttpServletRequest request){
        String rawJwt = request.getHeader(JWTHeader_Name);
        if(rawJwt==null){
            return null;
        }
        if(!rawJwt.startsWith(JWTHeader_Leading_Str)){
            return null;
        }
        return rawJwt.substring(JWTHeader_Leading_Str.length()+1);
    }

    private void showToken(DecodedJWT decodedJWT){
        // 获取解析后的token中的信息
        String header = decodedJWT.getHeader();
        System.out.println("type:" + decodedJWT.getType());
        System.out.println("header:" + header);
        Map payloadMap = decodedJWT.getClaims();
        System.out.println("Payload:" + payloadMap);
        Date expires = decodedJWT.getExpiresAt();
        System.out.println("过期时间:" + expires);
        String signature = decodedJWT.getSignature();
        System.out.println("signature:" + signature);
    }


    public static void main(String[] args) {
        String token=JwtUtil.generateToken("Zhang Fu");
        System.out.println(token);
        String userName = JwtUtil.verify(token);
        System.out.println("userName:" +userName);
    }
}

OK,准备工作完成。

停用Spring Security的默认session方案

为了停用session,我们需要增加一项配置,所以我们要新建一个配置文件:

@Configuration
public class WebSecurityConfig {

    @Autowired
    MyRememberMeService myRememberMeService;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception
    {

        httpSecurity.authorizeRequests()
                //.antMatchers("/hello").permitAll()
                .anyRequest().authenticated().and()
                .httpBasic().and()
                .rememberMe().rememberMeServices(myRememberMeService)
                .and().sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .formLogin();
        //httpSecurity.addFilterBefore(new JwtSecurityFilter(), UsernamePasswordAuthenticationFilter.class);
        //httpSecurity.addFilterAfter(new JwtAfterUsernamePasswordFilter(),UsernamePasswordAuthenticationFilter.class);
        return httpSecurity.build();
    }
}

设置SessionCreationPolicy.STATELESS就可以达到目的。

原因可以在sessionManagementConfigure.java这个session配置器中找到,在他的init方法中:

@Override
    public void init(H http) {
        SecurityContextRepository securityContextRepository = http.getSharedObject(SecurityContextRepository.class);
        boolean stateless = isStateless();
        if (securityContextRepository == null) {
            if (stateless) {
                http.setSharedObject(SecurityContextRepository.class, new NullSecurityContextRepository());
            }

如果SessionCreationPolicy设置为stateless的话,那么他会创建NullSecurityContextRepository作为他的SecurityContextRepository。

这个NullSecurityContextRepository实际就是个假把式,啥也不干,我们知道用户认证通过后会调用他的saveContext方法存储认证信息,他是这么干的:

@Override
    public void saveContext(SecurityContext context, HttpServletRequest request, HttpServletResponse response) {
    }

所以,他就是个偷工减料的货,啥也没干。

所以第一个问题解决了。

用户登录后生成token并返回给前端

这个问题我尝试了好几个方案之后才成功。

我们知道用户登录是在安全过滤器UsernamePasswordAuthenticationFilter中完成的,登录成功后如果想要生成JWT的token,方案无非就是:

  1. UsernamePasswordAuthenticationFilter之后加一个我们自己的过滤器,与UsernamePasswordAuthenticationFilter一样只匹配登录请求,生成token。
  2. UsernamePasswordAuthenticationFilter过滤器认证通过后有没有调用过其他可以被我们客户化的东东,我们客户化这个东东完成我们的目标。
  3. 客户化UsernamePasswordAuthenticationFilter,登录成功后生成token。

这里必须交代一下,第3个方案只是从逻辑上来说应该能解决我们的问题,但是压根就没有考虑过这个方案,因为我觉得太麻烦。

先试了第一个方案,没成功,因为我们知道Spring Security还有一个RequestCacheAwareFilter过滤器,会导致如果你是在尚未获取授权之前访问了非登录页面,那么Spring Security会导航到登录页面、登录成功后在UsernamePasswordAuthenticationFilter中就会发生跳转,这样的话就跳过了我们后面加的这个过滤器,目标就无法实现或者说即使弯弯绕绕能实现,但是方案也不会太好。

所以,就努力研究第2个方案。

所以大概看了一下UsernamePasswordAuthenticationFilter在登录认证成功后的处理,发现了这个:

所以就大概去研究了一下RememberMeServices,读了一下他的doc,发现他是一个基于cokie的、确保前台请求即使在session过期之后发送上来都可以继续通过安全认证的“记住我”机制。

除了cokie之外,其他的与JWT的要求完全吻合。如果我们能自己实现一个基于JWT的RememberMeService,是不是就解决问题了?

所以就用他来尝试一下。

MyRememberMeService#loginSuccess

创建MyRememberMeService并通过配置加入到应用中来,前面停用session的时候的配置文件中已经加入了,返回去看一眼就行。

我们要实现他的loginSuccess方法,创建并返回token:

@Override
    public void loginSuccess(HttpServletRequest request, HttpServletResponse response,
                             Authentication successfulAuthentication) {
        String username = successfulAuthentication.getName();
        String token= JwtUtil.generateToken(username);
        token=JwtUtil.JWTHeader_Leading_Str+token;
        log.info("After login success:"+token);
        response.setHeader(JwtUtil.JWTHeader_Name,token);
    }

验证一下创建并返回token

启动项目,成功登录系统后,惊喜的发现他已经开始干活了:

好了,给了我们信心,撸起袖子加油干!

RememberMeAuthenticationFilter

RememberMeServices机制依赖RememberMeAuthenticationFilter实现,我们在上面的配置文件中已经启用了。

然后简单看一眼RememberMeAuthenticationFilter过滤器的doFilter方法,他首先去SecurityContextHolder获取认证信息,如果没有获取到的话,就调用RememberMeService的autoLogin方法,只是从doFilter的源码来看(代码就不贴出了),autoLogin方法返回的Authentication并未完成认证,因为返回之后还要调用authenticationManager进行认证。

这是与我们预期不符的地方,我们希望autoLogin之后就可以完成认证、并且可以将认证信息放置到SecurityContextHolder中(因为我们是通过JWT做验证的,token验证通过的话就相当于完成了认证)。

那我们是不是可以在autoLogin中完成这些操作,并且返回null骗一下RememberMeAuthenticationFilter的doFilter方法不再要求authenticationManager去再次认证呢?

我们试一下!

MyRememberMeService#autoLogin

创建RememberMeService并实现autoLogin方法,为了简化他的初始化过程,我们直接把他注入到Spring Ioc容器中。

如前所述,方法一定要返回null。

@Slf4j
@Component
public class MyRememberMeService implements RememberMeServices {
    @Autowired
    MyUserDetailsService myUserDetailsService;
    @Override
    public Authentication autoLogin(HttpServletRequest request, HttpServletResponse response) {
        log.info("autoLogin in MyRememberMeService: ");
        String username;
        String token = JwtUtil.parseToken(request);
        if(token==null){
            log.info("I dont get token from header");
            token=request.getParameter("token");
        }
        log.info("finally the token is :" + token);
        UsernamePasswordAuthenticationToken authenticationToken=null;
        if(token!=null) {
            String userName=JwtUtil.verify(token);
            UserDetails user = myUserDetailsService.loadUserByUsername(userName);
            authenticationToken = new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());
            authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
            SecurityContextHolder.getContext().setAuthentication(authenticationToken);
        }
        return null;
    }

    @Override
    public void loginFail(HttpServletRequest request, HttpServletResponse response) {

    }

}

测试一下autoLogin

上面的代码中已经看到了,我们只是为了测试、如果从请求头信息中拿不到token的话就从请求参数中获取。只是为了学习、测试偷个懒,正式项目实现的时候这个地方还是需要比较多的完善的。

启动项目,开始测试,第一步先通过login获取token,上面已经展示过了,然后用获取到的token发一个需要认证的请求,token加在请求参数后面:

如图,请求成功了!

上一篇Spring Security自定义用户认证过程(2)

你可能感兴趣的:(Spring Security + JWT)