Spring Security 实战干货:使用 JWT 认证访问接口

1. 前言

欢迎阅读Spring Security 实战干货系列。之前我讲解了如何编写一个自己的 Jwt 生成器以及如何在用户认证通过后返回 Json Web Token 。今天我们来看看如何在请求中使用 Jwt 访问鉴权。DEMO 获取方法在文末。

2. 常用的 Http 认证方式

我们要在 Http 请求中使用 Jwt 我们就必须了解 常见的 Http 认证方式。

2.1 HTTP Basic Authentication

HTTP Basic Authentication 又叫基础认证,它简单地使用 Base64 算法对用户名、密码进行加密,并将加密后的信息放在请求头 Header 中,本质上还是明文传输用户名、密码,并不安全,所以最好在 Https 环境下使用。其认证流程如下:

basic.png

客户端发起 GET 请求 服务端响应返回 401 Unauthorizedwww-Authenticate 指定认证算法,realm 指定安全域。然后客户端一般会弹窗提示输入用户名称和密码,输入用户名密码后放入 Header 再次请求,服务端认证成功后以 200 状态码响应客户端。

2.2 HTTP Digest Authentication

为弥补 BASIC 认证存在的弱点就有了 HTTP Digest Authentication 。它又叫摘要认证。它使用随机数加上 MD5 算法来对用户名、密码进行摘要编码,流程类似 Http Basic Authentication ,但是更加复杂一些:

步骤1:跟基础认证一样,只不过返回带 WWW-Authenticate 首部字段的响应。该字段内包含质问响应方式认证所需要的临时咨询码(随机数,nonce)。 首部字段WWW-Authenticate 内必须包含 realmnonce 这两个字段的信息。客户端就是依靠向服务器回送这两个值进行认证的。nonce 是一种每次随返回的 401 响应生成的任意随机字符串。该字符串通常推荐由 Base64 编码的十六进制数的组成形式,但实际内容依赖服务器的具体实现

步骤2:接收到 401 状态码的客户端,返回的响应中包含 DIGEST 认证必须的首部字段 Authorization 信息。首部字段 Authorization 内必须包含username、realm、nonce、uriresponse 的字段信息,其中,realmnonce 就是之前从服务器接收到的响应中的字段。

步骤3:接收到包含首部字段 Authorization 请求的服务器,会确认认证信息的正确性。认证通过后则会返回包含 Request-URI 资源的响应。

并且这时会在首部字段 Authorization-Info 写入一些认证成功的相关信息。

2.3 SSL 客户端认证

SSL 客户端认证就是通常我们说的 HTTPS 。安全级别较高,但需要承担 CA 证书费用。SSL 认证过程中涉及到一些重要的概念,数字证书机构的公钥、证书的私钥和公钥、非对称算法(配合证书的私钥和公钥使用)、对称密钥、对称算法(配合对称密钥使用)。相对复杂一些这里不过多讲述。

2.4 Form 表单认证

Form 表单的认证方式并不是HTTP规范。所以实现方式也呈现多样化,其实我们平常的扫码登录,手机验证码登录都属于表单登录的范畴。表单认证一般都会配合 CookieSession 的使用,现在很多 Web 站点都使用此认证方式。用户在登录页中填写用户名和密码,服务端认证通过后会将 sessionId 返回给浏览器端,浏览器会保存 sessionId 到浏览器的 Cookie 中。因为 HTTP 是无状态的,所以浏览器使用 Cookie 来保存 sessionId。下次客户端会在发送的请求中会携带 sessionId 值,服务端发现 sessionId 存在并以此为索引获取用户存在服务端的认证信息进行认证操作。认证过则会提供资源访问。

我们在Spring Security 实战干货:登录后返回 JWT Token 一文其实也是通过 Form 提交来获取 Jwt 其实 JwtsessionId 同样的作用,只不过 Jwt 天然携带了用户的一些信息,而 sessionId 需要去进一步获取用户信息。

2.5 Json Web Token 的认证方式 Bearer Authentication

我们通过表单认证获取 Json Web Token ,那么如何使用它呢? 通常我们会把 Jwt 作为令牌使用 Bearer Authentication 方式使用。Bearer Authentication 是一种基于令牌的 HTTP 身份验证方案,用户向服务器请求访问受限资源时,会携带一个 Token 作为凭证,检验通过则可以访问特定的资源。最初是在 RFC 6750 中作为 OAuth 2.0 的一部分,但有时也可以单独使用。
我们在使用 Bear Token 的方法是在请求头的 Authorization 字段中放入 Bearer <token> 的格式的加密串(Json Web Token)。请注意 Bearer 前缀与 Token 之间有一个空字符位,与基本身份验证类似,Bearer Authentication 只能在HTTPS(SSL)上使用。

3. Spring Security 中实现接口 Jwt 认证

接下来我们是我们该系列的重头戏 ———— 接口的 Jwt 认证。

3.1 定义 Json Web Token 过滤器

无论上面提到的哪种认证方式,我们都可以使用 Spring Security 中的 Filter 来处理。 Spring Security 默认的基础配置没有提供对 Bearer Authentication 处理的过滤器, 但是提供了处理 Basic Authentication 的过滤器:

org.springframework.security.web.authentication.www.BasicAuthenticationFilter

BasicAuthenticationFilter 继承了 OncePerRequestFilter 。所以我们也模仿 BasicAuthenticationFilter 来实现自己的 JwtAuthenticationFilter 。 完整代码如下:

 package cn.felord.spring.security.filter;
 
 import cn.felord.spring.security.exception.SimpleAuthenticationEntryPoint;
 import cn.felord.spring.security.jwt.JwtTokenGenerator;
 import cn.felord.spring.security.jwt.JwtTokenPair;
 import cn.felord.spring.security.jwt.JwtTokenStorage;
 import cn.hutool.json.JSONArray;
 import cn.hutool.json.JSONObject;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.http.HttpHeaders;
 import org.springframework.security.authentication.AuthenticationCredentialsNotFoundException;
 import org.springframework.security.authentication.BadCredentialsException;
 import org.springframework.security.authentication.CredentialsExpiredException;
 import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
 import org.springframework.security.core.AuthenticationException;
 import org.springframework.security.core.GrantedAuthority;
 import org.springframework.security.core.authority.AuthorityUtils;
 import org.springframework.security.core.context.SecurityContextHolder;
 import org.springframework.security.core.userdetails.User;
 import org.springframework.security.web.AuthenticationEntryPoint;
 import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
 import org.springframework.util.StringUtils;
 import org.springframework.web.filter.OncePerRequestFilter;
 
 import javax.servlet.FilterChain;
 import javax.servlet.ServletException;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
 import java.io.IOException;
 import java.util.List;
 import java.util.Objects;
 
 /**
  * jwt 认证拦截器 用于拦截 请求 提取jwt 认证
  *
  * @author dax
  * @since 2019/11/7 23:02
  */
 @Slf4j
 public class JwtAuthenticationFilter extends OncePerRequestFilter {
     private static final String AUTHENTICATION_PREFIX = "Bearer ";
     /**
      * 认证如果失败由该端点进行响应
      */
     private AuthenticationEntryPoint authenticationEntryPoint = new SimpleAuthenticationEntryPoint();
     private JwtTokenGenerator jwtTokenGenerator;
     private JwtTokenStorage jwtTokenStorage;
 
 
     public JwtAuthenticationFilter(JwtTokenGenerator jwtTokenGenerator, JwtTokenStorage jwtTokenStorage) {
         this.jwtTokenGenerator = jwtTokenGenerator;
         this.jwtTokenStorage = jwtTokenStorage;
     }
 
 
     @Override
     protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
         // 如果已经通过认证
         if (SecurityContextHolder.getContext().getAuthentication() != null) {
             chain.doFilter(request, response);
             return;
         }
         // 获取 header 解析出 jwt 并进行认证 无token 直接进入下一个过滤器  因为  SecurityContext 的缘故 如果无权限并不会放行
         String header = request.getHeader(HttpHeaders.AUTHORIZATION);
         if (StringUtils.hasText(header) && header.startsWith(AUTHENTICATION_PREFIX)) {
             String jwtToken = header.replace(AUTHENTICATION_PREFIX, "");
 
 
             if (StringUtils.hasText(jwtToken)) {
                 try {
                     authenticationTokenHandle(jwtToken, request);
                 } catch (AuthenticationException e) {
                     authenticationEntryPoint.commence(request, response, e);
                 }
             } else {
                 // 带安全头 没有带token
                 authenticationEntryPoint.commence(request, response, new AuthenticationCredentialsNotFoundException("token is not found"));
             }
 
         }
         chain.doFilter(request, response);
     }
 
     /**
      * 具体的认证方法  匿名访问不要携带token
      * 有些逻辑自己补充 这里只做基本功能的实现
      *
      * @param jwtToken jwt token
      * @param request  request
      */
     private void authenticationTokenHandle(String jwtToken, HttpServletRequest request) throws AuthenticationException {
 
         // 根据我的实现 有效token才会被解析出来
         JSONObject jsonObject = jwtTokenGenerator.decodeAndVerify(jwtToken);
 
         if (Objects.nonNull(jsonObject)) {
             String username = jsonObject.getStr("aud");
 
             // 从缓存获取 token
             JwtTokenPair jwtTokenPair = jwtTokenStorage.get(username);
             if (Objects.isNull(jwtTokenPair)) {
                 if (log.isDebugEnabled()) {
                     log.debug("token : {}  is  not in cache", jwtToken);
                 }
                 // 缓存中不存在就算 失败了
                 throw new CredentialsExpiredException("token is not in cache");
             }
             String accessToken = jwtTokenPair.getAccessToken();
 
             if (jwtToken.equals(accessToken)) {
                   // 解析 权限集合  这里
                 JSONArray jsonArray = jsonObject.getJSONArray("roles");
 
                 String roles = jsonArray.toString();
 
                 List<GrantedAuthority> authorities = AuthorityUtils.commaSeparatedStringToAuthorityList(roles);
                 User user = new User(username, "[PROTECTED]", authorities);
                 // 构建用户认证token
                 UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(user, null, authorities);
                 usernamePasswordAuthenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                 // 放入安全上下文中
                 SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);
             } else {
                 // token 不匹配
                 if (log.isDebugEnabled()){
                     log.debug("token : {}  is  not in matched", jwtToken);
                 }
 
                 throw new BadCredentialsException("token is not matched");
             }
         } else {
             if (log.isDebugEnabled()) {
                 log.debug("token : {}  is  invalid", jwtToken);
             }
             throw new BadCredentialsException("token is invalid");
         }
     }
 }

具体看代码注释部分,逻辑有些地方根据你业务进行调整。匿名访问必然是不能带 Token 的!

3.2 配置 JwtAuthenticationFilter

首先将过滤器 JwtAuthenticationFilter 注入 Spring IoC 容器 ,然后一定要将 JwtAuthenticationFilter 顺序置于 UsernamePasswordAuthenticationFilter 之前:

        @Override
         protected void configure(HttpSecurity http) throws Exception {
             http.csrf().disable()
                     .cors()
                     .and()
                     // session 生成策略用无状态策略
                     .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                     .and()
                     .exceptionHandling().accessDeniedHandler(new SimpleAccessDeniedHandler()).authenticationEntryPoint(new SimpleAuthenticationEntryPoint())
                     .and()
                     .authorizeRequests().anyRequest().authenticated()
                     .and()
                     .addFilterBefore(preLoginFilter, UsernamePasswordAuthenticationFilter.class)
                     // jwt 必须配置于 UsernamePasswordAuthenticationFilter 之前
                     .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
                     // 登录  成功后返回jwt token  失败后返回 错误信息
                     .formLogin().loginProcessingUrl(LOGIN_PROCESSING_URL).successHandler(authenticationSuccessHandler).failureHandler(authenticationFailureHandler)
                     .and().logout().addLogoutHandler(new CustomLogoutHandler()).logoutSuccessHandler(new CustomLogoutSuccessHandler());
 
         }

4. 使用 Jwt 进行请求验证

编写一个受限接口 ,我们这里是 http://localhost:8080/foo/test 。直接请求会被 401 。 我们通过下图方式获取 Token :

然后在 Postman 中使用 Jwt :

最终会认证成功并访问到资源。

5. 刷新 Jwt Token

我们在 Spring Security 实战干货:手把手教你实现JWT Token 中已经实现了 Json Web Token 都是成对出现的逻辑。accessToken 用来接口请求, refreshToken 用来刷新 accessToken 。我们可以同样定义一个 Filter 可参照 上面的 JwtAuthenticationFilter 。只不过 这次请求携带的是 refreshToken,我们在过滤器中拦截 URI跟我们定义的刷新端点进行匹配。同样验证 Token ,通过后像登录成功一样返回 Token 对即可。这里不再进行代码演示。

6. 总结

这是系列原创文章,总有不仔细看的同学抓不着头脑颇有微词。饭需要一口一口的吃,没有现成的可以吃,都是这么过来的,急什么。原创不易,关注才是动力。Spring Security 实战干货系列 每一篇都有不同的知识点,而且它们都是相互有联系的。有不懂的地方多回头看。Spring Security 并不难学,关键是你找对思路了没有。本次 DEMO 可通过关注公众号:Felordcn 回复 ss08 获取。


欢迎关注:码农小胖哥