JWT 全称 JSON Web
Token,是一种用于通信双方之间传递安全信息的简洁的、URL安全的表述性声明规范,经常用在跨域身份验证。本期内容与
Shiro 无关,不过在下一期我们就会整合 Shiro 和 JWT。
什么是 JWT
JWT
是一种可以携带信息的加密串,加密时可以将各种信息,如用户、作者、过期时间等,并设定签名(密钥)。
解密时,只要提供签名(密钥),token
就可以被解析得到信息,从而实现一种相对安全的前后端交互方式。
Session 的缺陷
传统的认证采用 Session 的形式,用户登录成功后,就将用户信息以 Session
形式存入服务器内存,并为用户发送 Cookie
保存登录信息。下次用户登录时,通过检验 Cookie 和 Session
信息,判断认证是否有效。
- 浪费资源,Session 保存在服务器内存中,开销很大
- 因为是基于 Cookie 来进行用户识别的, Cookie
如果被截获,用户就会很容易受到跨站请求伪造的攻击
- 由于 Session
存在内存中,如果是分布式应用,服务器之间共享内存,不利于应用扩展
JWT 的结构
一个生成的 token
如下:eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJrb29yeWUiLCJzdWIiOiJzaGlyb19kZW1vIiwiYXVkIjoia29vcnllIiwiaWF0IjoxNTk1NjYwNTQxLCJleHAiOjE1OTU2NjQxNDF9.EMq7pVog37X3Un0FVgx2qP8sULpd5haXvdU1qvzKZYo
token 分为3部分,用 .
分割:
- 第一部分 头部信息
- 第二部分 载荷信息
- 第三部分 签名信息
JWT
官网提供了清晰的例子,头部包含加密算法等信息,中间包含我们传入的信息,尾部则包含密钥、用于验证:
在这里插入图片描述
JWT 的生成和解析
一个标准的 JWT 应该具有的信息:
- iss: jwt 签发者
- sub: jwt 所面向的用户
- aud: 接收 jwt 的一方
- exp: jwt 的过期时间,这个过期时间必须要大于签发时间
- nbf: 定义在什么时间之前,该 jwt 都是不可用的
- iat: jwt 的签发时间
- jti: jwt 的唯一身份标识,主要用来作为一次性
token,从而回避重放攻击
导入依赖
导入 jjwt 包:
1 2 3 4 5 6
| <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt</artifactId> <version>0.9.1</version> </dependency>
|
编写工具类
笔者此处将一些固定的信息,如签发者、主题、签名等以常量形式存储。
生成 token 时,需要调用 Jwts.builder 存入信息,然后使用 compact 得到
token 字符串。
解析token 时,调用 Jwts.parser,传入签名即可。
例:
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
| package org.koorye.util;
import io.jsonwebtoken.*;
import java.util.Date;
public class JwtUtil { public static final String ISSUER = "koorye"; public static final String SUBJECT = "shiro_demo"; public static final String SIGN = "koorye_love_jwt";
public static String getToken(String username, int expireTime) { Date currentTime = new Date();
JwtBuilder jwtBuilder = Jwts.builder() .setIssuer(ISSUER) .setSubject(SUBJECT) .setAudience(username) .setIssuedAt(currentTime) .setExpiration(new Date(System.currentTimeMillis() + expireTime * 1000)) .signWith(SignatureAlgorithm.HS256, SIGN);
return jwtBuilder.compact(); }
public static Claims parseToken(String token) { return Jwts.parser().setSigningKey(SIGN).parseClaimsJws(token).getBody(); } }
|
测试
第二个参数是过期时间(单位秒):
1 2 3 4 5 6 7 8 9
| @Test public void testToken() { String token = JwtUtil.getToken("koorye", 60 * 60); System.out.println(token); System.out.println("======================="); Claims claims = JwtUtil.parseToken(token); System.out.println("Audience: " + claims.getAudience()); System.out.println("Subject: " + claims.getSubject()); }
|
运行:
1 2 3 4 5
| eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJrb29yeWUiLCJzdWIiOiJzaGlyb19kZW1vIiwiYXVkIjoia29vcnllIiwiaWF0IjoxNTk1NjYwNTQxLCJleHAiOjE1OTU2NjQxNDF9.EMq7pVog37X3Un0FVgx2qP8sULpd5haXvdU1qvzKZYo ======================= Audience: koorye Subject: shiro_demo
|
基于 JWT 的用户认证
编写拦截器
使用原生 JWT 完成认证,我们可以自定义拦截器来实现:
- returnJson 用于返回信息
- preHandle 重写,定义拦截规则
在拦截器中,我们检测 Header 中是否包含 access_token
信息,如果不包含,说明未登录。
如果包含:
- 如果 token 解析失败,说明 token 错误
- 如果 token 解析成功,但是过期异常,说明 token 过期
都通过则说明 token 可用,返回 true:
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 52 53 54
| @Component public class JwtInterceptor extends HandlerInterceptorAdapter {
private void returnJson(HttpServletResponse response, String json) throws Exception { response.setCharacterEncoding("UTF-8"); response.setContentType("text/html; charset=utf-8"); try (PrintWriter writer = response.getWriter()) { writer.print(json); } catch (IOException e) { e.printStackTrace(); } }
@Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token = request.getHeader("access_token"); if (token == null) { System.out.println("[ ERROR ] Token is NULL.");
Map<String, String> map = new HashMap<>(); map.put("ret_code", "401"); map.put("err_msg", "Token is NULL."); response.setStatus(401);
returnJson(response, JSON.toJSONString(map)); return false; } else { try { Claims claims = JwtUtil.parseToken(token); } catch (MalformedJwtException e) { System.out.println("[ ERROR ] Token is ERROR.");
Map<String, String> map = new HashMap<>(); map.put("ret_code", "402"); map.put("err_msg", "Token is ERROR."); response.setStatus(403); returnJson(response, JSON.toJSONString(map));
return false; } catch (ExpiredJwtException e) { System.out.println("[ ERROR ] Token is EXPIRED.");
Map<String, String> map = new HashMap<>(); map.put("ret_code", "403"); map.put("err_msg", "Token is EXPIRED."); response.setStatus(403); returnJson(response, JSON.toJSONString(map));
return false; } } return true; } }
|
配置拦截规则
在 Spring Boot 中,我们可以使用配置类的形式实现。
除了登录和注册,其他请求都需要拦截:
1 2 3 4 5 6 7 8 9 10 11 12 13
| @Configuration public class InterceptorConfig extends WebMvcConfigurationSupport { @Autowired private JwtInterceptor jwtInterceptor;
@Override protected void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(jwtInterceptor) .addPathPatterns("/api/**") .excludePathPatterns("/api/login") .excludePathPatterns("/api/register"); } }
|
编写控制器
主要关注认证成功的部分
isAuthenticated
,认证成功时,生成一个 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
| @RequestMapping("/api/login") public ResponseEntity<Map<String, String>> login(String username, String password) { Subject subject = SecurityUtils.getSubject(); UsernamePasswordToken token = new UsernamePasswordToken(username, password); try { subject.login(token); } catch (UnknownAccountException e) { Map<String, String> map = new HashMap<>(); map.put("ret_code", "401"); map.put("err_msg", "Username is not EXISTED."); return new ResponseEntity<>(map, HttpStatus.BAD_REQUEST); } catch (AuthenticationException e) { Map<String, String> map = new HashMap<>(); map.put("ret_code", "402"); map.put("err_msg", "Password is ERROR."); return new ResponseEntity<>(map, HttpStatus.BAD_REQUEST); }
if (subject.isAuthenticated()) { String access_token = JwtUtil.getToken(username, 30 * 60); Map<String, String> map = new HashMap<>(); map.put("ret_code", "201"); map.put("access_token", access_token); return new ResponseEntity<>(map, HttpStatus.OK); } else { Map<String, String> map = new HashMap<>(); map.put("ret_code", "403"); map.put("err_msg", "Login failed."); return new ResponseEntity<>(map, HttpStatus.BAD_REQUEST); } }
|
测试
我们使用 postman 测试一下。
模拟登录:
在这里插入图片描述
返回体:
1 2 3 4
| { "access_token": "eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJrb29yeWUiLCJzdWIiOiJzaGlyb19kZW1vIiwiYXVkIjoia29vcnllIiwiaWF0IjoxNTk1NjYxNzgyLCJleHAiOjE1OTU2NjM1ODJ9.AqhHVRw7FiOMv3y79XAelkVLgfeQzrmCmqYYPg1ouOY", "ret_code": "201" }
|
模拟访问页面,访问时提供 access_token:
在这里插入图片描述
返回体:
如果 token 被修改:
1
| {"err_msg":"Token is ERROR.","ret_code":"402"}
|
如果 token 过期:
1
| {"err_msg":"Token is EXPIRED.","ret_code":"403"}
|
主流的双 token 认证方案
目前的主流方案是使用双 token 认证。
用户登录成功后,服务端给用户传递两个 token:
- access_token 认证用 token,存储主体信息,过期时间较短(如 30
分钟)
- refresh_token 刷新用 token,过期时间较长(如一星期)
用户登录时,首先发送 access_token,如果请求成功,则放行。
如果请求失败,提供 access_token 过期,则发送 refresh_token:
- 如果 refresh_token 没过期,则给用户传递一个新的 access_token 和
refresh_token ``
主流的双 token 认证方案
目前的主流方案是使用双 token 认证。
用户登录成功后,服务端给用户传递两个 token:
- access_token 认证用 token,存储主体信息,过期时间较短(如 30
分钟)
- refresh_token 刷新用 token,过期时间较长(如一星期)
用户登录时,首先发送 access_token,如果请求成功,则放行。
如果请求失败,提供 access_token 过期,则发送 refresh_token:
- 如果 refresh_token 没过期,则给用户传递一个新的 access_token 和
refresh_token
- 如果 refresh_token 过期,则不放行