当前位置:   article > 正文

SpringSecurity如何使用JWT认证?_springsecurity jwt

springsecurity jwt

theme: Chinese-red

本章内容

  1. 介绍什么是JWT?
  2. Spring Security怎么使用JWT?

Spring Boot 版本 2.7.1+

什么是JWT?

JWT全名叫JSON Web Token, 是一个开放标准(RFC 7519),它定义了一种紧凑的、自包含的方式,用于作为JSON对象在各方之间安全地传输信息。该信息可以被验证和信任,因为它是数字签名的。

JWT的使用场景

下列场景中使用JSON Web Token是很有用的:

  • Authorization (授权) : 这是使用JWT的最常见场景。一旦用户登录,后续每个请求都将包含JWT,允许用户访问该令牌允许的路由、服务和资源。单点登录是现在广泛使用的JWT的一个特性,因为它的开销很小,并且可以轻松地跨域使用。
  • Information Exchange (信息交换) : 对于安全的在各方之间传输信息而言,JSON Web Tokens无疑是一种很好的方式。因为JWT可以被签名,例如,用公钥/私钥对,你可以确定发送人就是它们所说的那个人。另外,由于签名是使用头和有效负载计算的,您还可以验证内容没有被篡改。

JSON Web Token的结构是什么样的

JSON Web Token由三部分组成,它们之间用圆点(.)连接。这三部分分别是:

  • Header
  • Payload
  • Signature

因此,一个典型的JWT看起来是这个样子的:

xxxxx.yyyyy.zzzzz

image-20230203132007274

Header

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

例如:

{
  "alg": "HS256",
  "typ": "JWT"
}
  • 1
  • 2
  • 3
  • 4

上面代码中,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):编号

除了官方字段,你还可以在这个部分定义私有字段,下面就是一个例子。

{
  "sub": "1234567890",
  "name": "John Doe",
  "admin": true
}
  • 1
  • 2
  • 3
  • 4
  • 5

注意,JWT 默认是不加密的,任何人都可以读到,所以不要把秘密信息放在这个部分。

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

Signature

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

首先,需要指定一个密钥(secret)。这个密钥只有服务器才知道,不能泄露给用户。然后,使用 Header 里面指定的签名算法(默认是 HMAC SHA256),按照下面的公式产生签名。

HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
secret)
  • 1
  • 2
  • 3
  • 4

算出签名以后,把 HeaderPayloadSignature 三个部分拼成一个字符串,每个部分之间用"点"(.)分隔,就可以返回给用户。

image-20230203105221641

Base64URL

前面提到,HeaderPayload 串型化的算法是 Base64URL。这个算法跟 Base64 算法基本类似,但有一些小的不同。

JWT 作为一个令牌(token),有些场合可能会放到 URL(比如 api.example.com/?token=xxx)。Base64 有三个字符+/=,在 URL 里面有特殊含义,所以要被替换掉:=被省略、+替换成-/替换成_ 。这就是 Base64URL 算法。

JWT在实际应用中怎么用?

JWT一般是由服务端颁发, 由客户端存储的, 一般存储在 localStorage

每次客户端(也就是前端)发出请求时在请求头信息Authorization字段里面填写jwt, 发送给后端, 后端验证jwt字符串是否过期, 合规等

这里不推荐存储到cookie中, 且不说cookie存在大量安全问题, 存储在cookie中不支持跨域

JWT 的几个特点

(1)JWT 默认是不加密,但也是可以加密的。生成原始 Token 以后,可以用密钥再加密一次。

(2)JWT 不加密的情况下,不能将秘密数据写入 JWT。

(3)JWT 不仅可以用于认证,也可以用于交换信息。有效使用 JWT,可以降低服务器查询数据库的次数。

(4)JWT 的最大缺点是,由于服务器不保存 session 状态,因此无法在使用过程中废止某个 token,或者更改 token 的权限。也就是说,一旦 JWT 签发了,在到期之前就会始终有效,除非服务器部署额外的逻辑。

(5)JWT 本身包含了认证信息,一旦泄露,任何人都可以获得该令牌的所有权限。为了减少盗用,JWT 的有效期应该设置得比较短。对于一些比较重要的权限,使用时应该再次对用户进行认证。

(6)为了减少盗用,JWT 不应该使用 HTTP 协议明码传输,要使用 HTTPS 协议传输。

上面部分转载自JSON Web Token 入门教程 - 阮一峰的网络日志 (ruanyifeng.com),

部分翻译至JSON Web Tokens - jwt.io

总结

jwt总体结构还是比较简单的, 我们需要注意的点在于jwt的缺点:

  1. jwt 比较容易暴露, 由于存放在客户端, 所以我们需要提供 https 协议进行保护, 同时 jwt 的过期时间不宜过长

  2. jwt由于存放在客户端中, 服务端是无状态的, 所以jwt无法主动过期, 一旦颁发只有等待 jwt 自己过期, 当然我们还可以通过 黑名单方式, 将 jwt 拉黑

    1. 黑名单通常借助redis实现, 一般配置黑名单过期时间为半小时, 因为不管何时配置的token, 半小时都会过期(不过这里需要保证服务端的secret不暴露出去, 否则jwt的第三部分将被破译, 那么黑客就可以自己颁发合法的token了)

  3. jwt的内部基本上等同于明文, 所以敏感信息绝不能保存在jwt中

  4. jwt的续约比较麻烦, 需要程序员自己实现, 什么refresh token之类不太安全, 虽然它通常比token的过期时间长, 具体情况可能需要后端提供 url, 让前端程序员进行无感续约

    1. 因为 token 过期时间比较短, 大概半小时过期, 所以无感续约还是需要的, 而所谓的续约其实就是颁发新的token给客户端

说完缺点, 还有优点:

  1. jwt对于服务端来说是无状态的, 所以对于集群来说比较方便, 因为信息存放在各个客户端中了, 也就不需要配置什么redis session之类的功能

Spring Security怎么使用JWT?

本章完整源码: jwt-spring-security - 码云 - 开源中国 (gitee.com)

服务端编码思路

从上面的介绍中, 我们发现服务端需要做的事情有:

  • 颁发jwt
  • 每次请求时, 验证jwt
  • 添加刷新token url

颁发jwt

首先颁发jwt在spring security中应该怎么做呢?

我们知道 spring security存在验证 usernamepassword 的过程, 所以这部分我们顶多修改成前后端分离方式就可以了

public class MyUsernamePasswordAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
   
   @Resource
   private ObjectMapper objectMapper;
   
   @SneakyThrows
   @Override
   public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
         throws AuthenticationException {
      if (!"POST".equals(request.getMethod())) {
         throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
      }
      String contentType = request.getContentType();
      if (MediaType.APPLICATION_JSON_VALUE.equalsIgnoreCase(contentType)
            || MediaType.APPLICATION_JSON_UTF8_VALUE.equalsIgnoreCase(contentType)) {
         Map map = objectMapper.readValue(request.getInputStream(), Map.class);
         
         String username = (String) map.get(this.getUsernameParameter());
         username = (username != null) ? username.trim() : "";
         String password = (String) map.get(this.getPasswordParameter());
         password = (password != null) ? password : "";
         UsernamePasswordAuthenticationToken authRequest = UsernamePasswordAuthenticationToken.unauthenticated(username,
               password);
         // Allow subclasses to set the "details" property
         setDetails(request, authRequest);
         return this.getAuthenticationManager().authenticate(authRequest);
      }
      return super.attemptAuthentication(request, response);
   }
   
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31

然后将该过滤器加入到Spring Security中运行

http.addFilterBefore(usernamePasswordAuthenticationFilter(http), UsernamePasswordAuthenticationFilter.class);
  • 1

接着我们知道, spring security在认证成功之后将会调用AuthenticationSuccessHandler接口的实现类

也就是

image-20230203141242630

这两个函数, 不过这里我们不能够在上面这个地方配置, 因为我们定义了UsernamePasswordAuthenticationFilter

@Bean
public PasswordEncoder passwordEncoder() {
    return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}

@Bean
public UserDetailsService userDetailsService() throws Exception {
    return new UserService();
}

// @Bean
// public JwtAuthenticationFilter jwtAuthenticationFilter() throws Exception {
//    return new JwtAuthenticationFilter();
// }

@Bean
public MyAuthenticationSuccessHandler authenticationSuccessHandler() throws Exception {
    return new MyAuthenticationSuccessHandler();
}

@Bean
public MyAuthenticationFailureHandler authenticationFailureHandler() throws Exception {
    return new MyAuthenticationFailureHandler();
}

// @Bean
// public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
//    return authenticationConfiguration.getAuthenticationManager();
// }

@Bean
AuthenticationManager authenticationManager(HttpSecurity httpSecurity) throws Exception {
    return httpSecurity.getSharedObject(AuthenticationManagerBuilder.class)
        .userDetailsService(userDetailsService())
        .passwordEncoder(passwordEncoder())
        .and()
        .parentAuthenticationManager(null) // 在实际运行中出现 parent 无限代理栈溢出的情况, 这里只能把 parent 部分设置成 null 了
        .build();
}

@Bean
public MyUsernamePasswordAuthenticationFilter usernamePasswordAuthenticationFilter(HttpSecurity httpSecurity) throws Exception {
    MyUsernamePasswordAuthenticationFilter authenticationFilter = new MyUsernamePasswordAuthenticationFilter();
    authenticationFilter.setAuthenticationManager(authenticationManager(httpSecurity));
    authenticationFilter.setAuthenticationSuccessHandler(authenticationSuccessHandler());
    authenticationFilter.setAuthenticationFailureHandler(authenticationFailureHandler());
    return authenticationFilter;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48

这里需要注意这段代码parentAuthenticationManager(null), 我也不知道什么原因(主要是懒), 这里将会出现无限 parent 的情况导致 栈溢出 错误

小白: “等等, 我们不是说颁发么? 怎么还没开始颁发?”

小黑: “颁发代码就在前面的这里”

@Bean
public MyAuthenticationSuccessHandler authenticationSuccessHandler() throws Exception {
    return new MyAuthenticationSuccessHandler();
}
  • 1
  • 2
  • 3
  • 4
public class MyAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
	
	@Resource
	private ObjectMapper objectMapper;
	@Resource
	private JwtTokenUtil jwtTokenUtil;
	
	@Override
	public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
		User user = (User) authentication.getPrincipal();
		// 根据 User 对象生成对应的 token
		// 说白了, 底层就是调用了 user.getUsername() 方法而已, 所以只要能拿到 当前用户 的 用户名 就可以了
		String token = jwtTokenUtil.generateToken(user);
		response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
		PrintWriter out = response.getWriter();
		HashMap<String, Object> mapJson = new HashMap<>();
		mapJson.put("msg", "登录成功");
		mapJson.put("data", objectMapper.writeValueAsString(user));
		mapJson.put("code", HttpStatus.OK);
		// 添加到 map 中, 返回给前端
		mapJson.put("token", token);
		out.write(objectMapper.writeValueAsString(mapJson));
		out.flush();
		out.close();
	}
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26

颁发jwt 的过程结束

验证jwt

验证这里我和别人的入手点不同, 很多朋友喜欢借助OncePerRequestFilter类实现, 每次访问请求是验证 token 是否有效

@Slf4j
@Deprecated
public class JwtAuthenticationFilter extends OncePerRequestFilter {
	
	@Resource
	private JwtTokenUtil jwtTokenUtil;
	
	@Resource
	private UserService userService;
	
	@Override
	protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
		// TODO wuya 我觉得这里需要改造, 原本 spring security是从 SecurityContextPersistenceFilter/SecurityContextHolderFilter
		//  中的Session读取并设置到SecurityContextHolder.getContext()中的
		String jwt = request.getHeader(jwtTokenUtil.getHeader());
		if (StrUtil.isNotBlank(jwt)) {
			//根据jwt获取用户名
			String username = jwtTokenUtil.getUserNameFromToken(jwt);
			log.info("校验username:{}", username);
			//如果可以正确从JWT中提取用户信息,并且该用户未被授权
			if (!StrUtil.isEmpty(username) && SecurityContextHolder.getContext().getAuthentication() == null) {
				UserDetails userDetails = this.userService.loadUserByUsername(username);
				if (jwtTokenUtil.validateToken(jwt, userDetails)) {
					//给使用该JWT令牌的用户进行授权
					UsernamePasswordAuthenticationToken authenticationToken
							= new UsernamePasswordAuthenticationToken(userDetails, null,
							userDetails.getAuthorities());
					authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
					SecurityContextHolder.getContext().setAuthentication(authenticationToken);
				}
			}
		}
		filterChain.doFilter(request, response);
	}
	
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36

但是我们思路是这样

学过spring security应该知道, 我们的SecurityContextHolder的数据是从那里写入/读取出来的?

除了UsernamePasswordAuthenticationFilter 认证流程的时候做了

SecurityContextHolder.getContext().setAuthentication(authentication)

还有一个地方不知道你们有没有注意到?

那就是SecurityContextHolder存储在ThreadLocal中, 那么每次从哪里读取出来的?

session

如果研究过Spring Security过滤器的流程, 我们将会看到这么一个过滤器SecurityContextPersistenceFilter

或者说该过滤器底层的一个属性

image-20230203142748582

repo属性

这里我的思路就是借助SecurityContextRepository接口实现的验证jwt的功能

public class MySecurityContextRepository implements SecurityContextRepository {
	
	//	protected final Log logger = LogFactory.getLog(this.getClass());
	@Resource
	private JwtTokenUtil jwtTokenUtil;
	@Resource
	private UserService userService;
	
	@Override
	public SecurityContext loadContext(HttpRequestResponseHolder requestResponseHolder) {
		HttpServletRequest request = requestResponseHolder.getRequest();
		SecurityContext securityContext = SecurityContextHolder.createEmptyContext();
		// 从请求头中读取token
		String token = request.getHeader(HttpHeaders.AUTHORIZATION);
		if (StrUtil.isBlank(token)) {
			return securityContext;
		}
		// 拿到 用户名
		String username = jwtTokenUtil.getUserNameFromToken(token);
		// 查询用户
		UserDetails user = userService.loadUserByUsername(username);
		if (user == null) {
			return securityContext;
		}
		// 验证 token
		if (!jwtTokenUtil.validateToken(token, user)) {
			return securityContext;
		}
		// 构建SecurityContext
		String password = StrUtil.subAfter(user.getPassword(), '}', false);
		UsernamePasswordAuthenticationToken authentication = UsernamePasswordAuthenticationToken.unauthenticated(user.getUsername(), password);
		authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
		securityContext.setAuthentication(authentication);
		// 返回
		return securityContext;
	}
	
	@Override
	public void saveContext(SecurityContext context, HttpServletRequest request, HttpServletResponse response) {
		// 不需要保存了, 只要前端持续发送token便好
	}
	
	@Override
	public boolean containsContext(HttpServletRequest request) {
		String token = request.getHeader(HttpHeaders.AUTHORIZATION);
		if (jwtTokenUtil.isTokenExpired(token)) {
			return false;
		}
		return StrUtil.isNotBlank(token);
	}
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51

然后添加到 Spring Security中执行

@Bean
public MySecurityContextRepository securityContextRepository() throws Exception {
    return new MySecurityContextRepository();
}
  • 1
  • 2
  • 3
  • 4
http.securityContext()
      .securityContextRepository(securityContextRepository());
  • 1
  • 2

token续约

记住, 旧的token如果过期, 那么就无法进行 token 续约了, 这一点需要跟前端程序员说下

@RestController
@Slf4j
public class IndexController {
	
	@Resource
	private JwtTokenUtil jwtTokenUtil;
	@Value("${jwt.header}")
	private String tokenHeader;
	
	@PostMapping("/refreshToken")
	public ResultUtils refreshToken(@RequestHeader(HttpHeaders.AUTHORIZATION) String oldToken) {
		String token;
		// 如果 token 已经过期, 那么就不支持续约新的 token 了
		if (!jwtTokenUtil.isTokenExpired(oldToken)) {
			token = jwtTokenUtil.refreshHeadToken(oldToken);
			HashMap<String, Object> map = new HashMap<>();
			map.put("token", token);
			map.put("tokenHeader", tokenHeader);
			return ResultUtils.success(map);
		}
		return ResultUtils.fail("token已过期");
	}
	
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24

黑名单token

黑名单机制我就不写了, 思路很简单, 使用 redis , 将注销功能的 token 填写到 redis 中, 下次只要我们验证token时, 先读取 redistoken, 如果能够读取出 token 则表示是黑名单 token , 直接验证 token 失败就好

注意 redis 中存储的 黑名单 token 只需要保存半小时就好了(也就是我们token的过期时间, 一般是半小时, 当然你可以自定义过期时间, 我们黑名单跟着改就好)

无感续约token

我在网上看到过, 可以直接参考他们的文章

主要思路就是在token即将过期前, 请求后端刷新tokenurl, 替换 localStorage 保存的旧token

如果上面的"即将过期前刷新"不太好, 因为你无法保证服务器的时间和客户端的时间一定是相同的

  1. 可以考虑所谓的长短 token 方案, 也就是 access_tokenrefresh_token方案, 这里的长短方案说白了就是过期时间的长短, 一个过期时间半小时, 另一个过期时间一周等等
  2. 可以增加过期时间, 比如30分钟过期, 后端实际给出45分钟过期, 前端定时30分钟过期
  3. 直接修改JwtTokenUtil, 不过这里不推荐, 因为token过期表示token已经失效了, 失效的token怎么可以继续使用于刷新token呢? 这明显不符合逻辑

思路还有很多, 就不一一举例了

主要还是看你和前端工程师之间如何讨论咯!!!

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/我家自动化/article/detail/638463
推荐阅读
相关标签
  

闽ICP备14008679号