为什么要使用 token?直接使用 cookie + session 不就完了吗?
我们来看看传统方式实现的接口鉴权,客户端调用登录接口登录成功,服务端将登录状态保存在 session 中,客户端调用后续接口,服务端根据 session 统一校验。
这样看起来是没问题的,但是服务端接口变成分布式以后,这种方式就有问题了,假如一个服务端署了2个实例(A,B),这时客户端请求A实例登录成功了,又发了一个订单请求跑到了B实例,那么这个订单请求肯定会鉴权失败,提示未登录,这样肯定不符合产品设计的,那么就需要将A,B实例的 session 进行共享,但是这种方式又会造成服务器网络IO的压力大,延迟太长等问题。
JWT就解决了上述的问题(还有这里没提到的避免 CSRF 攻击等),相当于将 session 保存在了客户端,解决了后台 session 复制的问题,客户端登录成功后,服务端响应一个 token,客户端在后续请求带上这个 token 随便到哪个实例都可以进行接口鉴权。
当然,正是由于 token 的无状态,也导致了一些缺点:比如当服务端在 token 有效期内废弃一个 token 或者更改它的权限的话,不会立即生效,一般需要等到有效期过后才可以。另外,当用户退出登录后,token 也不会失效。除非,我们在服务端增加额外的处理逻辑。
安装
详见github:https://github.com/jwtk/jjwt/
复制可得:
<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>
yml添加常用配置
jwt:
secret: gU1lF5jB1bN1dK2kE2lH1bH3fL1gQ1gU1lF5jB1bN1d #密钥
expiration: 30 #token有效期(S)
工具类
需要注意的是,生产token的signWith方法有更新,网上比较常见的signWith(SignatureAlgorithm.HS512, secret)方法在0.10.0及以上版本已提示过期,并提示将在1.0版本彻底删除;所以我们这里推荐使用Keys.hmacShaKeyFor(Decoders.BASE64.decode(secret))方法生成SecretKey,另外secret的安全规范也需要遵守,比如不得含有’$'特殊字符,长度不得小于43等
import com.fasterxml.jackson.databind.ObjectMapper;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
/**
* JwtToken操作工具类
*
* @author jason
*/
@Slf4j
@Component
public class JwtTokenUtil {
private static final String CLAIM_KEY_USERNAME = "sub";
private static final String CLAIM_KEY_CREATED = "created";
/**
* 密钥
*/
@Value("${jwt.secret}")
private String secret;
/**
* token有效期(S)
*/
@Value("${jwt.expiration}")
private Long expiration;
/**
* 根据用户信息生成token
*/
@SneakyThrows
public String generateToken(Object userInfo) {
Map<String, Object> claims = new HashMap<>();
claims.put(CLAIM_KEY_USERNAME, new ObjectMapper().writeValueAsString(userInfo));
claims.put(CLAIM_KEY_CREATED, new Date());
return generateToken(claims);
}
/**
* 从token中获取用户信息
*/
@SneakyThrows
public <T> T getUserInfoFromToken(String token, Class<T> valueType) {
Claims claims = getClaimsFromToken(token);
return new ObjectMapper().readValue(claims.getSubject(), valueType);
}
/**
* 判断token是否有效
*
* @return true 有效
*/
public boolean isTokenExpired(String token) {
Date expiredDate = getExpiredDateFromToken(token);
return expiredDate.after(new Date());
}
/**
* 刷新token
*/
public String refreshToken(String token) {
Claims claims = getClaimsFromToken(token);
claims.put(CLAIM_KEY_CREATED, new Date());
return generateToken(claims);
}
/**
* 生成token
*/
private String generateToken(Map<String, Object> claims) {
return Jwts.builder()
.setClaims(claims)
.setExpiration(generateExpirationDate())
// .signWith(SignatureAlgorithm.HS512, secret) // 已过期
.signWith(generateKeyByDecoders())
.compact();
}
/**
* 从token中获取
*/
private Claims getClaimsFromToken(String token) {
return Jwts.parserBuilder()
.setSigningKey(generateKeyByDecoders())
.build()
.parseClaimsJws(token)
.getBody();
}
/**
* 生成token的过期时间
*/
private Date generateExpirationDate() {
return new Date(System.currentTimeMillis() + expiration * 1000);
}
/**
* 生成自定义Key
*/
private SecretKey generateKeyByDecoders() {
return Keys.hmacShaKeyFor(Decoders.BASE64.decode(secret));
}
/**
* 从token中获取过期时间
*/
private Date getExpiredDateFromToken(String token) {
Claims claims = getClaimsFromToken(token);
return claims.getExpiration();
}
}
接口实现
这里写了2个测试接口,一个<登录>接口,一个<获取用户信息>接口,大致流程如下:
客户端调用<登录>接口提交账号密码到服务端,成功后服务端响应一个token,客户端带上token调用<获取用户信息>接口,服务端对token鉴权成功后响应用户信息。
其它接口同理。
token 鉴权常见异常:
ExpiredJwtException( token 过期)
SignatureException(签名编码有误)
MalformedJwtException(构造有误)
…
/**
* 登录
* -
* http://127.0.0.1:9090/user/login?username=jason&password=123456
*/
@GetMapping("login")
public String login(UserInfoVo userInfoVo) {
// TODO 参数校验...省略
// TODO 登录校验...省略
// 存放用户信息到token
UserInfoDto userInfoDto = new UserInfoDto();
userInfoDto.setId(20201203001L);
userInfoDto.setUsername("jason");
userInfoDto.setNickName("张三");
// 响应token
return jwtTokenUtil.generateToken(userInfoDto);
}
/**
* 通过token获取用户信息
* -
* http://127.0.0.1:9090/user/info
*/
@GetMapping("info")
public UserInfoDto info() {
try {
return getUserInfo();
} catch (ExpiredJwtException e) {
log.error("token已过期", e);
UserInfoDto userInfoDto = new UserInfoDto();
userInfoDto.setNickName("token已过期");
userInfoDto.setUsername("token已过期");
return userInfoDto;
}
}
测试
使用 postman 调用登录接口
带上 token 获取用户信息
因为我们 token 有效期设置的是30s,过期后再调用就会提示已过期。这里响应的就比较随意了,按规范来搞应该是封装统一异常+统一响应类