JWT的简单了解与使用
一、JWT简介
JWT(Json Web Token)的作用和优势在网络上有大量的资料,这里不再赘述,只简单介绍JWT的形成和使用。
JWT是由三段信息构成的(以.
进行分割):
eyJhbGciOiJIUzI1NiJ9.
eyJzdWIiOiIxMjM0NTY3IiwiaWF0IjoxNTk4OTczNjUwLCJleHAiOjE1OTkyMzI4NTB9.
D2ZhxY0c6azChqiQ7m7I6wje7DIf6Cg2LnYZM8p9bMQ
-
header(头部)
- 声明类型:固定是JWT
- 声明加密算法:通常使用HMAC SHA256(HS256)
类似:{‘typ’: ‘JWT’, ‘alg’: ‘HS256’}
将头部单独Base64编码,得到例子中的eyJhbGciOiJIUzI1NiJ9
-
playload(载荷):存放有效信息的地方(JWT一般不存放敏感信息)
- 标准中注册的声明(常用)
- iss(issuer):表示JWT的签发者
- sub(subject):表示JWT的所有者
- aud(audience):表示JWT的接受者
- exp(expiration):一个时间戳,表示JWT的过期时间
- nbf(not before):一个时间戳,表示该JWT的生效时间,在这个时间之前验证JWT是会失败的
- iat(issued at):一个时间戳,表示这个JWT的签发时间
- jti(jwt id):JWT的唯一标识(也常用于redis的键)
- 公共的声明(可以存放任何信息,通常用Map保存键值对,然后再加入其中)
- 私有的声明(可以存放签发者和接收者共同定义的信息,不常用)
- 标准中注册的声明(常用)
类似:{“sub”: “1234567890”, “name”: “John Doe”, “admin”: true}
将载荷单独Base64编码,得到例子中的eyJzdWIiOiIxMjM0NTY3IiwiaWF0IjoxNTk4OTczNjUwLCJleHAiOjE1OTkyMzI4NTB9
-
signature(签证):这个部分需要上面两个Base64编码后的结果,用
.
连接组成字符串,然后通过header中声明的加密方式(HS256)进行加盐secret
组合加密(不可逆加密),然后就构成了jwt的第三部分。
加密得到:D2ZhxY0c6azChqiQ7m7I6wje7DIf6Cg2LnYZM8p9bMQ
secret是保存在服务器的,不能泄露,否则任何人都能够伪造合法的JWT。
JWT签发后发完客户端,客户端通常在请求头上加入Authorization字段,并加上Bearer(与JWT之间留有一个空格),并在之后的请求中,都带上此信息作为验证,如图:
二、Spring boot中的简单使用
在本文中,采用的是最新的Java JWT(jjwt)作为生成和处理JWT的工具类,GitHub官网。
网上大多资料都是其旧版本的,有些方法已经不适用。
1、解决依赖
首先,引入依赖:
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.2</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.2</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.11.2</version>
<scope>runtime</scope>
</dependency>
2、创建工具类JwtUtil
public class JwtUtil {
public static final long HOUR = 60 * 60 * 1000L;
/**
* jwt签证
*
* @param username 用户名(学号)
* @param secret **
* @param expiredTime 过期时间
* @return jwt
*/
public static String encode(String username, String secret, int expiredTime) {
long timeMillis = System.currentTimeMillis();
// 生成**
SecretKey key = new SecretKeySpec(secret.getBytes(), SignatureAlgorithm.HS256.getJcaName());
return Jwts.builder()
// 唯一用户名(学号),同时做redis的键
.setSubject(username)
// 签发时间
.setIssuedAt(new Date(timeMillis))
// 过期时间
.setExpiration(new Date(timeMillis + expiredTime * HOUR))
.signWith(key)
.compact();
}
/**
* 检验和解析jwt
*
* @param jwt jwt
* @param secret **
* @return 数据实体
* @throws JwtException token不合法
*/
public static Claims parse(String jwt, String secret) throws JwtException {
// 生成**
SecretKey key = new SecretKeySpec(secret.getBytes(), SignatureAlgorithm.HS256.getJcaName());
return Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(jwt)
.getBody();
}
/**
* 获取用户名(学号)
*/
public static String getSubject(String jwt, String secret) throws JwtException {
return parse(jwt, secret).getSubject();
}
}
在parse()方法中,如果JWT不合法,则会抛出JwtException,省去了JWT检验的方法,请看官方例子:
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(compactJws);
//OK, we can trust this JWT
} catch (JwtException e) {
//don't trust the JWT!
}
而在getSubject()方法中的getSubject()方法,如果JWT已过期,则抛出ExpiredJwtException(JwtException的子类),这样也不必检验JWT是否过期。
对于上述抛出的两个异常,我们可以在全局异常中心CenterExceptionHandler进行处理:
@RestControllerAdvice
public class CenterExceptionHandler {
/**
* 令牌过期异常
*/
@ExceptionHandler(ExpiredJwtException.class)
SkyResponse expiredJwtException(ExpiredJwtException e) {
return SkyResponse.fail(HttpStatus.INTERNAL_SERVER_ERROR, "令牌过期");
}
/**
* 令牌无效
*/
@ExceptionHandler(JwtException.class)
SkyResponse signatureException(JwtException e) {
return SkyResponse.fail(HttpStatus.INTERNAL_SERVER_ERROR, "令牌无效");
}
// 省略其他异常处理
// .....
}
3、定义JwtService
为了更规范的使用(自我感觉规范…),我增加了一层JwtService,进行其他业务操作:
@Component
public class JwtService {
@Value("${token.secret}")
private String secret;
@Value("${token.expiredTime}")
private int expiredTime;
@Resource
RedisUtils redisUtils;
DurianUserService userService;
@Autowired
public void setUserService(DurianUserService userService) {
this.userService = userService;
}
/**
* 生成token,并将相关的用户信息存入redis
*
* @param username username
* @return token
*/
public String createToken(String username) {
//检查redis中是否已存在该用户的登录信息,解决重复登陆的问题
if (redisUtils.getObject(username) == null) {
// 从数据库中得到用户
DurianUserDO loginUser = userService.getUserByUsername(username);
// 存入redis
redisUtils.setObject(loginUser.getUsername(), loginUser, expiredTime, TimeUnit.HOURS);
}
return JwtUtil.encode(username, secret, expiredTime);
}
/**
* 刷新token和redis中的过期时间
*
* @param token 旧token
* @return 新token
*/
public String refreshToken(String token) throws JwtException {
Claims claims = JwtUtil.parse(token, secret);
long expiration = claims.getExpiration().getTime();
long timeMillis = System.currentTimeMillis();
// 过期时间小于1小时则刷新
if (expiration - timeMillis < JwtUtil.HOUR) {
String username = claims.getSubject();
DurianUserDO user = redisUtils.getObject(username);
redisUtils.deleteObjects(username);
redisUtils.setObject(username, user, expiredTime, TimeUnit.HOURS);
return JwtUtil.encode(username, secret, expiredTime);
}
return token;
}
/**
* 刷新redis中的用户信息,并返回最新的token
*
* @param token token
* @param user 最新的用户信息
* @return 最新的token
*/
public String refreshUser(String token, DurianUserDO user) throws JwtException {
String username = JwtUtil.getSubject(token, secret);
redisUtils.deleteObjects(username);
redisUtils.setObject(username, user, expiredTime, TimeUnit.HOURS);
return JwtUtil.encode(username, secret, expiredTime);
}
/**
* 从redis中删除用户信息
*
* @param token token
*/
public void deleteUser(String token) throws JwtException {
String username = JwtUtil.getSubject(token, secret);
redisUtils.deleteObjects(username);
}
/**
* 从redis中获取token中用户的信息
*
* @param token token
* @return 用户实体
*/
public DurianUserDO getUserFromRedis(String token) throws JwtException {
String username = JwtUtil.getSubject(token, secret);
return redisUtils.getObject(username);
}
/**
* 从token中获取用户名
*
* @param token token
* @return 用户名
*/
public String getUsername(String token) throws JwtException {
return JwtUtil.getSubject(token, secret);
}
}
其中,将secret和expiredTime的值都放到yml配置文件中,后续也更便于更改
4、登陆接口LoginController
最终,是我们对外的登陆接口定义了:
@RestController
public class LoginController {
@Resource
private JwtService jwtService;
private DurianUserService userService;
@Autowired
public void setUserService(DurianUserService userService) {
this.userService = userService;
}
/**
* 登陆接口,登陆成功返回jwt
*/
@PostMapping("/login")
public SkyResponse login(String username, String password) {
DurianUserDO loginUser = userService.getUserByUsername(username);
if (loginUser == null) {
throw new RuntimeException("用户不存在,请重新输入");
}
if (!new BCryptPasswordEncoder().matches(password, loginUser.getPassword())) {
throw new RuntimeException("密码不正确,请重新输入");
}
String newToken = jwtService.createToken(username);
return SkyResponse.success("登陆成功", 1)
.put("token", newToken);
}
/**
* 注销登陆接口
*/
@PostMapping("/logout")
public SkyResponse logout(HttpServletRequest request) {
String token = TokenUtils.getToken(request);
if (StringUtils.isNotEmpty(token)) {
jwtService.deleteUser(token);
return SkyResponse.success("登出成功");
}
return SkyResponse.fail(HttpStatus.NOT_ACCEPTABLE, "当前还未登陆");
}
}
到这里,就完成了JWT签发和处理的所有流程了,后续加上Spring Security和单点登陆,就更完善了。