Spring Security5整合JWT认证和授权

JWT介绍

JWT原理

JWT是JSON Web Token的缩写,是目前最流行的跨域认证解决方法。

互联网服务认证的一般流程是:

  1. 用户向服务器发送账号、密码
  2. 服务器验证通过后,将用户的角色、登录时间等信息保存到当前会话中
  3. 同时,服务器向用户返回一个session_id(一般保存在cookie里)
  4. 用户再次发送请求时,把含有session_id的cookie发送给服务器
  5. 服务器收到session_id,查找session,提取用户信息

上面的认证模式,存在以下缺点:

  • cookie不允许跨域
  • 因为每台服务器都必须保存session对象,所以扩展性不好

JWT认证原理是:

  1. 用户向服务器发送账号、密码
  2. 服务器验证通过后,生成token令牌返回给客户端(token可以包含用户信息)
  3. 用户再次请求时,把token放到请求头Authorization
  4. 服务器收到请求,验证token合法后放行请求

JWT token令牌可以包含用户身份、登录时间等信息,这样登录状态保持者由服务器端变为客户端,服务器变成无状态了;token放到请求头,实现了跨域

JWT数据结构

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

JWT由三部分组成:

  • Header(头部)
  • Payload(负载)
  • Signature(签名)

表现形式为:Header.Payload.Signature

Header 部分是一个 JSON 对象,描述 JWT 的元数据,通常是下面的样子:

{
  "alg": "HS256",
  "typ": "JWT"
}

上面代码中,alg属性表示签名的算法(algorithm),默认是 HMAC SHA256(写成 HS256);typ属性表示这个令牌(token)的类型(type),JWT 令牌统一写为JWT

上面的 JSON 对象使用 Base64URL 算法转成字符串

Payload

Payload 部分也是一个 JSON 对象,用来存放实际需要传递的数据。JWT 规定了7个官方字段:

  • iss (issuer):签发人

  • exp (expiration time):过期时间

  • sub (subject):主题

  • aud (audience):受众

  • nbf (Not Before):生效时间

  • iat (Issued At):签发时间

  • jti (JWT ID):编号

当然,用户也可以定义私有字段。

这个 JSON 对象也要使用 Base64URL 算法转成字符串

Signature

Signature 部分是对前两部分的签名,防止数据篡改

签名算法如下:

HMACSHA256(
  base64UrlEncode(header) + "." +
  base64UrlEncode(payload),
  your-256-bit-secret
)

算出签名以后,把 Header、Payload、Signature 三个部分拼成一个字符串,每个部分之间用"."分隔

JWT认证和授权

Security是基于AOP和Servlet过滤器的安全框架,为了实现JWT要重写那些方法、自定义那些过滤器需要首先了解security自带的过滤器。security默认过滤器链如下:

  1. org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter
  2. org.springframework.security.web.context.SecurityContextPersistenceFilter
  3. org.springframework.security.web.header.HeaderWriterFilter
  4. org.springframework.security.web.authentication.logout.LogoutFilter
  5. org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter
  6. org.springframework.security.web.savedrequest.RequestCacheAwareFilter
  7. org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter
  8. org.springframework.security.web.authentication.AnonymousAuthenticationFilter
  9. org.springframework.security.web.session.SessionManagementFilter
  10. org.springframework.security.web.access.ExceptionTranslationFilter
  11. org.springframework.security.web.access.intercept.FilterSecurityInterceptor

SecurityContextPersistenceFilter

这个过滤器有两个作用:

  • 用户发送请求时,从session对象提取用户信息,保存到SecurityContextHolder的securitycontext中
  • 当前请求响应结束时,把SecurityContextHolder的securitycontext保存的用户信息放到session,便于下次请求时共享数据;同时将SecurityContextHolder的securitycontext清空

由于禁用session功能,所以该过滤器只剩一个作用即把SecurityContextHolder的securitycontext清空。举例来说明为何要清空securitycontext:用户1发送一个请求,由线程M处理,当响应完成线程M放回线程池;用户2发送一个请求,本次请求同样由线程M处理,由于securitycontext没有清空,理应储存用户2的信息但此时储存的是用户1的信息,造成用户信息不符

UsernamePasswordAuthenticationFilter

UsernamePasswordAuthenticationFilter继承自AbstractAuthenticationProcessingFilter,处理逻辑在doFilter方法中:

  1. 当请求被UsernamePasswordAuthenticationFilter拦截时,判断请求路径是否匹配登录URL,若不匹配继续执行下个过滤器;否则,执行步骤2
  2. 调用attemptAuthentication方法进行认证。UsernamePasswordAuthenticationFilter重写了attemptAuthentication方法,负责读取表单登录参数,委托AuthenticationManager进行认证,返回一个认证过的token(null表示认证失败)
  3. 判断token是否为null,非null表示认证成功,null表示认证失败
  4. 若认证成功,调用successfulAuthentication。该方法把认证过的token放入securitycontext供后续请求授权,同时该方法预留一个扩展点(AuthenticationSuccessHandler.onAuthenticationSuccess方法),进行认证成功后的处理
  5. 若认证失败,同样可以扩展uthenticationFailureHandler.onAuthenticationFailure进行认证失败后的处理
  6. 只要当前请求路径匹配登录URL,那么无论认证成功还是失败,当前请求都会响应完成,不再执行过滤器链

UsernamePasswordAuthenticationFilterattemptAuthentication方法,执行逻辑如下:

  1. 从请求中获取表单参数。因为使用HttpServletRequest.getParameter方法获取参数,它只能处理Content-Type为application/x-www-form-urlencoded或multipart/form-data的请求,若是application/json则无法获取值
  2. 把步骤1获取的账号、密码封装成UsernamePasswordAuthenticationToken对象,创建未认证的token。UsernamePasswordAuthenticationToken有两个重载的构造方法,其中public UsernamePasswordAuthenticationToken(Object principal, Object credentials)创建未经认证的token,public UsernamePasswordAuthenticationToken(Object principal, Object credentials, Collection<? extends GrantedAuthority> authorities)创建已认证的token
  3. 获取认证管理器AuthenticationManager,其缺省实现为ProviderManager,调用其authenticate进行认证
  4. ProviderManagerauthenticate是个模板方法,它遍历所有AuthenticationProvider,直至找到支持认证某类型token的AuthenticationProvider,调用AuthenticationProvider.authenticate方法认证,AuthenticationProvider.authenticate加载正确的账号、密码进行比较验证
  5. AuthenticationManager.authenticate方法返回一个已认证的token

AnonymousAuthenticationFilter

AnonymousAuthenticationFilter负责创建匿名token:

public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {
        if (SecurityContextHolder.getContext().getAuthentication() == null) {
            SecurityContextHolder.getContext().setAuthentication(this.createAuthentication((HttpServletRequest)req));
            if (this.logger.isTraceEnabled()) {
                this.logger.trace(LogMessage.of(() -> {
                    return "Set SecurityContextHolder to " + SecurityContextHolder.getContext().getAuthentication();
                }));
            } else {
                this.logger.debug("Set SecurityContextHolder to anonymous SecurityContext");
            }
        } else if (this.logger.isTraceEnabled()) {
            this.logger.trace(LogMessage.of(() -> {
                return "Did not set SecurityContextHolder since already authenticated " + SecurityContextHolder.getContext().getAuthentication();
            }));
        }

        chain.doFilter(req, res);
    }

如果当前用户没有认证,会创建一个匿名token,用户是否能读取资源交由FilterSecurityInterceptor过滤器委托给决策管理器判断是否有权限读取

实现思路

JWT认证思路:

  1. 利用Security原生的表单认证过滤器验证用户名、密码
  2. 验证通过后自定义AuthenticationSuccessHandler认证成功处理器,由该处理器生成token令牌

JWT授权思路:

  1. 使用JWT目的是让服务器变成无状态,不用session共享数据,所以要禁用security的session功能(http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS))
  2. token令牌数据结构设计时,payload部分要储存用户名、角色信息
  3. token令牌有两个作用:
    1. 认证, 用户发送的token合法即代表认证成功
    2. 授权,令牌验证成功后提取角色信息,构造认证过的token,将其放到securitycontext,具体权限判断交给security框架处理
  4. 自己实现一个过滤器,拦截用户请求,实现(3)中所说的功能

代码实现

创建JWT工具类

JWT的Java实现,利用开源的java-jwt

<dependency>
	<groupId>com.auth0</groupId>
	<artifactId>java-jwt</artifactId>
	<version>3.12.0</version>
 </dependency>

我们对java-jwt提供的API进行封装,便于创建、验证、提取claim

@Slf4j
public class JWTUtil {
    // 携带token的请求头名字
    public final static String TOKEN_HEADER = "Authorization";
    //token的前缀
    public final static String TOKEN_PREFIX = "Bearer ";
    // 默认密钥
    public final static String DEFAULT_SECRET = "mySecret";
    // 用户身份
    private final static String ROLES_CLAIM = "roles";
    // token有效期,单位分钟;
    private final static long EXPIRE_TIME = 5 * 60 * 1000;
    // 设置Remember-me功能后的token有效期
    private final static long EXPIRE_TIME_REMEMBER = 7 * 24 * 60 * 60 * 1000;

    // 创建token
    public static String createToken(String username, List role, String secret, boolean rememberMe) {

        Date expireDate = rememberMe ? new Date(System.currentTimeMillis() + EXPIRE_TIME_REMEMBER) : new Date(System.currentTimeMillis() + EXPIRE_TIME);
        try {
            // 创建签名的算法实例
            Algorithm algorithm = Algorithm.HMAC256(secret);
            String token = JWT.create()
                    .withExpiresAt(expireDate)
                    .withClaim("username", username)
                    .withClaim(ROLES_CLAIM, role)
                    .sign(algorithm);
            return token;
        } catch (JWTCreationException jwtCreationException) {
            log.warn("Token create failed");
            return null;
        }
    }

    // 验证token
    public static boolean verifyToken(String token, String secret) {
        try{
            Algorithm algorithm = Algorithm.HMAC256(secret);
            // 构建JWT验证器,token合法同时pyload必须含有私有字段username且值一致
            // token过期也会验证失败
            JWTVerifier verifier = JWT.require(algorithm)
                    .build();
            // 验证token
            DecodedJWT decodedJWT = verifier.verify(token);
            return true;
        } catch (JWTVerificationException jwtVerificationException) {
            log.warn("token验证失败");
            return false;
        }

    }

    // 获取username
    public static String getUsername(String token) {
        try {
            // 因此获取载荷信息不需要密钥
            DecodedJWT jwt = JWT.decode(token);
            return jwt.getClaim("username").asString();
        } catch (JWTDecodeException jwtDecodeException) {
            log.warn("提取用户姓名时,token解码失败");
            return null;
        }
    }

    public static List<String> getRole(String token) {
        try {
            // 因此获取载荷信息不需要密钥
            DecodedJWT jwt = JWT.decode(token);
            // asList方法需要指定容器元素的类型
            return jwt.getClaim(ROLES_CLAIM).asList(String.class);
        } catch (JWTDecodeException jwtDecodeException) {
            log.warn("提取身份时,token解码失败");
            return null;
        }
    }
}

认证

验证账号、密码交给UsernamePasswordAuthenticationFilter,不用修改代码

认证成功后,需要生成token返回给客户端,我们通过扩展AuthenticationSuccessHandler.onAuthenticationSuccess方法实现

@Component
public class JWTAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
    @Override
    public void onAuthenticationSuccess(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Authentication authentication) throws IOException, ServletException {
        ResponseData responseData = new ResponseData();
        responseData.setCode("200");
        responseData.setMessage("登录成功!");
		
        // 提取用户名,准备写入token
        String username = authentication.getName();
        // 提取角色,转为List<String>对象,写入token
        List<String> roles = new ArrayList<>();
        Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
        for (GrantedAuthority authority : authorities){
            roles.add(authority.getAuthority());
        }
		
        // 创建token
        String token = JWTUtil.createToken(username, roles, JWTUtil.DEFAULT_SECRET, true);
        httpServletResponse.setCharacterEncoding("utf-8");
        // 为了跨域,把token放到响应头WWW-Authenticate里
        httpServletResponse.setHeader("WWW-Authenticate", JWTUtil.TOKEN_PREFIX + token);
		// 写入响应里
        ObjectMapper mapper = new ObjectMapper();
        mapper.writeValue(httpServletResponse.getWriter(), responseData);
    }
}

为了统一返回值,我们封装了一个ResponseData对象

授权

自定义一个过滤器JWTAuthorizationFilter,验证token,token验证成功后认为认证成功

@Slf4j
public class JWTAuthorizationFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {

        String token = getTokenFromRequestHeader(request);
        Authentication verifyResult = verefyToken(token, JWTUtil.DEFAULT_SECRET);
        if (verifyResult == null) {
            // 即便验证失败,也继续调用过滤链,匿名过滤器生成匿名令牌
            chain.doFilter(request, response);
            return;
        } else {
            log.info("token令牌验证成功");
            SecurityContextHolder.getContext().setAuthentication(verifyResult);
            chain.doFilter(request, response);
        }
    }
	
    // 从请求头获取token
    private String getTokenFromRequestHeader(HttpServletRequest request) {
        String header = request.getHeader(JWTUtil.TOKEN_HEADER);
        if (header == null || !header.startsWith(JWTUtil.TOKEN_PREFIX)) {
            log.info("请求头不含JWT token, 调用下个过滤器");
            return null;
        }

        String token = header.split(" ")[1].trim();
        return token;
    }
	
    // 验证token,并生成认证后的token
    private UsernamePasswordAuthenticationToken verefyToken(String token, String secret) {
        if (token == null) {
            return null;
        }
		
        // 认证失败,返回null
        if (!JWTUtil.verifyToken(token, secret)) {
            return null;
        }

        // 提取用户名
        String username = JWTUtil.getUsername(token);
        // 定义权限列表
        List<GrantedAuthority> authorities = new ArrayList<>();
        // 从token提取角色
        List<String> roles = JWTUtil.getRole(token);
        for (String role : roles) {
            log.info("用户身份是:" + role);
            authorities.add(new SimpleGrantedAuthority(role));
        }
        // 构建认证过的token
        return new UsernamePasswordAuthenticationToken(username, null, authorities);
    }
}

OncePerRequestFilter保证当前请求中,此过滤器只被调用一次,执行逻辑在doFilterInternal

security配置

@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private AjaxAuthenticationEntryPoint ajaxAuthenticationEntryPoint;
    @Autowired
    private JWTAuthenticationSuccessHandler jwtAuthenticationSuccessHandler;

    @Autowired
    private AjaxAuthenticationFailureHandler ajaxAuthenticationFailureHandler;

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable()
                .authorizeRequests().anyRequest().authenticated()
                .and()
                .formLogin()
                .successHandler(jwtAuthenticationSuccessHandler)
                .failureHandler(ajaxAuthenticationFailureHandler)
                .permitAll()
                .and()
                .addFilterAfter(new JWTAuthorizationFilter(), UsernamePasswordAuthenticationFilter.class)
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .exceptionHandling().authenticationEntryPoint(ajaxAuthenticationEntryPoint);

    }
}

配置里取消了session功能,把我们定义的过滤器添加到过滤链中;同时,定义ajaxAuthenticationEntryPoint处理未认证用户访问未授权资源时抛出的异常

@Component
public class AjaxAuthenticationEntryPoint implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException, ServletException {
        ResponseData responseData = new ResponseData();
        responseData.setCode("401");
        responseData.setMessage("匿名用户,请先登录再访问!");

        httpServletResponse.setCharacterEncoding("utf-8");
        ObjectMapper mapper = new ObjectMapper();
        mapper.writeValue(httpServletResponse.getWriter(), responseData);
    }
}

参考

JSON Web Token 入门教程

Spring Security-5-认证流程梳理

Spring Security3源码分析(5)-SecurityContextPersistenceFilter分析

Spring Security addFilter() 顺序问题

前后端联调之Form Data与Request Payload,你真的了解吗?

Spring Boot 2 + Spring Security 5 + JWT 的单页应用 Restful 解决方案

SpringBoot实战派-第十章源码

原文地址:https://www.cnblogs.com/weixia-blog/p/14191109.html