赞
踩
Spring Boot 版本 2.7.1+
JWT全名叫JSON Web Token, 是一个开放标准(RFC 7519),它定义了一种紧凑的、自包含的方式,用于作为JSON对象在各方之间安全地传输信息。该信息可以被验证和信任,因为它是数字签名的。
下列场景中使用JSON Web Token是很有用的:
JSON Web Token由三部分组成,它们之间用圆点(.)连接。这三部分分别是:
因此,一个典型的JWT看起来是这个样子的:
xxxxx.yyyyy.zzzzz
Header
部分是一个 JSON
对象,描述 JWT
的元数据,通常是下面的样子。
例如:
{
"alg": "HS256",
"typ": "JWT"
}
上面代码中,alg
属性表示签名的算法(algorithm),默认是 HMAC SHA256
(写成 HS256
);typ
属性表示这个令牌(token
)的类型(type
),JWT
令牌统一写为JWT
。
最后,将上面的 JSON
对象使用 Base64URL
算法(详见后文)转成字符串。
Payload 部分也是一个 JSON 对象,用来存放实际需要传递的数据。JWT 规定了7个官方字段,供选用。
除了官方字段,你还可以在这个部分定义私有字段,下面就是一个例子。
{
"sub": "1234567890",
"name": "John Doe",
"admin": true
}
注意,JWT 默认是不加密的,任何人都可以读到,所以不要把秘密信息放在这个部分。
这个 JSON 对象也要使用 Base64URL 算法转成字符串。
Signature
部分是对前两部分的签名,防止数据篡改。
首先,需要指定一个密钥(secret
)。这个密钥只有服务器才知道,不能泄露给用户。然后,使用 Header
里面指定的签名算法(默认是 HMAC SHA256
),按照下面的公式产生签名。
HMACSHA256( base64UrlEncode(header) + "." + base64UrlEncode(payload), secret)
- 1
- 2
- 3
- 4
算出签名以后,把 Header
、Payload
、Signature
三个部分拼成一个字符串,每个部分之间用"点"(.
)分隔,就可以返回给用户。
Base64URL
前面提到,Header
和 Payload
串型化的算法是 Base64URL
。这个算法跟 Base64
算法基本类似,但有一些小的不同。
JWT 作为一个令牌(token
),有些场合可能会放到 URL(比如 api.example.com/?token=xxx
)。Base64
有三个字符+
、/
和=
,在 URL
里面有特殊含义,所以要被替换掉:=
被省略、+
替换成-
,/
替换成_
。这就是 Base64URL
算法。
JWT
一般是由服务端颁发, 由客户端存储的, 一般存储在 localStorage
中
每次客户端(也就是前端)发出请求时在请求头信息Authorization
字段里面填写jwt
, 发送给后端, 后端验证jwt
字符串是否过期, 合规等
这里不推荐存储到
cookie
中, 且不说cookie
存在大量安全问题, 存储在cookie
中不支持跨域
(1)JWT 默认是不加密,但也是可以加密的。生成原始 Token 以后,可以用密钥再加密一次。
(2)JWT 不加密的情况下,不能将秘密数据写入 JWT。
(3)JWT 不仅可以用于认证,也可以用于交换信息。有效使用 JWT,可以降低服务器查询数据库的次数。
(4)JWT 的最大缺点是,由于服务器不保存 session 状态,因此无法在使用过程中废止某个 token,或者更改 token 的权限。也就是说,一旦 JWT 签发了,在到期之前就会始终有效,除非服务器部署额外的逻辑。
(5)JWT 本身包含了认证信息,一旦泄露,任何人都可以获得该令牌的所有权限。为了减少盗用,JWT 的有效期应该设置得比较短。对于一些比较重要的权限,使用时应该再次对用户进行认证。
(6)为了减少盗用,JWT 不应该使用 HTTP 协议明码传输,要使用 HTTPS 协议传输。
jwt总体结构还是比较简单的, 我们需要注意的点在于jwt的缺点:
jwt 比较容易暴露, 由于存放在客户端, 所以我们需要提供 https 协议进行保护, 同时 jwt 的过期时间不宜过长
jwt由于存放在客户端中, 服务端是无状态的, 所以jwt无法主动过期, 一旦颁发只有等待 jwt 自己过期, 当然我们还可以通过 黑名单方式, 将 jwt 拉黑
黑名单通常借助
redis
实现, 一般配置黑名单过期时间为半小时, 因为不管何时配置的token, 半小时都会过期(不过这里需要保证服务端的secret
不暴露出去, 否则jwt的第三部分将被破译, 那么黑客就可以自己颁发合法的token了)
jwt的内部基本上等同于明文, 所以敏感信息绝不能保存在jwt中
jwt的续约比较麻烦, 需要程序员自己实现, 什么refresh token之类不太安全, 虽然它通常比token的过期时间长, 具体情况可能需要后端提供 url, 让前端程序员进行无感续约
因为 token 过期时间比较短, 大概半小时过期, 所以无感续约还是需要的, 而所谓的续约其实就是颁发新的token给客户端
说完缺点, 还有优点:
本章完整源码: jwt-spring-security - 码云 - 开源中国 (gitee.com)
从上面的介绍中, 我们发现服务端需要做的事情有:
首先颁发jwt在spring security中应该怎么做呢?
我们知道 spring security存在验证 username
和 password
的过程, 所以这部分我们顶多修改成前后端分离方式就可以了
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); } }
然后将该过滤器加入到Spring Security中运行
http.addFilterBefore(usernamePasswordAuthenticationFilter(http), UsernamePasswordAuthenticationFilter.class);
接着我们知道, spring security在认证成功之后将会调用AuthenticationSuccessHandler
接口的实现类
也就是
这两个函数, 不过这里我们不能够在上面这个地方配置, 因为我们定义了
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; }
这里需要注意这段代码
parentAuthenticationManager(null)
, 我也不知道什么原因(主要是懒), 这里将会出现无限 parent 的情况导致 栈溢出 错误
小白: “等等, 我们不是说颁发么? 怎么还没开始颁发?”
小黑: “颁发代码就在前面的这里”
@Bean
public MyAuthenticationSuccessHandler authenticationSuccessHandler() throws Exception {
return new MyAuthenticationSuccessHandler();
}
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(); } }
颁发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); } }
但是我们思路是这样
学过spring security应该知道, 我们的SecurityContextHolder
的数据是从那里写入/读取出来的?
除了UsernamePasswordAuthenticationFilter
认证流程的时候做了
SecurityContextHolder.getContext().setAuthentication(authentication)
还有一个地方不知道你们有没有注意到?
那就是SecurityContextHolder
存储在ThreadLocal
中, 那么每次从哪里读取出来的?
session
如果研究过Spring Security过滤器的流程, 我们将会看到这么一个过滤器SecurityContextPersistenceFilter
或者说该过滤器底层的一个属性
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); } }
然后添加到 Spring Security中执行
@Bean
public MySecurityContextRepository securityContextRepository() throws Exception {
return new MySecurityContextRepository();
}
http.securityContext()
.securityContextRepository(securityContextRepository());
记住, 旧的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已过期"); } }
黑名单机制我就不写了, 思路很简单, 使用 redis
, 将注销功能的 token
填写到 redis
中, 下次只要我们验证token时, 先读取 redis
的token
, 如果能够读取出 token
则表示是黑名单 token
, 直接验证 token
失败就好
注意
redis
中存储的 黑名单token
只需要保存半小时就好了(也就是我们token
的过期时间, 一般是半小时, 当然你可以自定义过期时间, 我们黑名单跟着改就好)
我在网上看到过, 可以直接参考他们的文章
主要思路就是在token
即将过期前, 请求后端刷新token
的url
, 替换 localStorage
保存的旧token
如果上面的"即将过期前刷新"不太好, 因为你无法保证服务器的时间和客户端的时间一定是相同的
- 可以考虑所谓的长短
token
方案, 也就是access_token
和refresh_token
方案, 这里的长短方案说白了就是过期时间的长短, 一个过期时间半小时, 另一个过期时间一周等等- 可以增加过期时间, 比如30分钟过期, 后端实际给出45分钟过期, 前端定时30分钟过期
- 直接修改
JwtTokenUtil
, 不过这里不推荐, 因为token过期表示token已经失效了, 失效的token怎么可以继续使用于刷新token呢? 这明显不符合逻辑
思路还有很多, 就不一一举例了
主要还是看你和前端工程师之间如何讨论咯!!!
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。