本文正在参加「Java主题月 - Java 开发实战」,详情查看 活动链接
前言
对于JWT(JSON Web Token),可以先自己思考如下几个问题来测试一下对JWT的了解情况:
1、JWT功能实现的原理是什么?
2、使用JWT带来的优缺点有哪些?
3、项目中如果使用JWT,它各个部分的必要性是否能够认知?
如果有不清楚的,可以带着问题往下看。
最后也会结合Spring Boot做一个JWT实例
1、 JWT原理探究
什么是JWT?官方的定义是
JSON Web 令牌 (JWT) 是一个开放标准[(RFC 7519](https://tools.ietf.org/html/rfc7519)),它定义了一种紧凑且自包含的方式,用于将信息作为 JSON 对象安全地在各方之间传输信息。
去除各种费脑子的描述之后就是:客户端和服务器端安全传输信息的一个标准。
1.1、JWT的结构组成
JWT由三部分组成,中间使用句点"."连接,即header.payload.signature,这三个部分都是由base64编码的,这么做的目的是为了保证url中安全的传输。
第一部分header中是由两部分信息组成,即声明类型jwt和声明加密的算法(例如SHA256),所以说header在base64URL编码之前是如下的JSON
{
'typ':'jwt',
'alg':'SHA256'
}
base64URL编码之后为:
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9
第二部分payload是存放想要传递的信息,例如经常用到的用户ID和过时时间exp,所以说payload在base64URL编码之前是如下的JSON
{
'id':'10',
'exp':'2301597433'
}
base64URL编码之后为:
ewoJJ2lkJzonMTAnLAoJJ2V4cCc6JzIzMDE1OTc0MzMnCn0=
第三部分signature最是关键,它的组成原理是:1、将header和payload分别base64URL编码之后组合到一起(通过"."连接);2、添加一个只有服务器知道的签名字符串;3、再使用header中的签名算法SHA256加密步骤1、2。可以看成下面的公式:
signature = SHA256(base64encode(header) + '.' + base64encode(payload), 'SEVER_SECRET_KEY')
最终可以得到签名信息为
05dd35b4d20c95430cd1b63406f861de7e4c1476f9dbffa25f30fe08baf8f530
为啥第三部分是关键呢?因为第三部分只能由服务器生成,而只能由服务器生成的根本原因就是没有人知道签名字符串--SEVER_SECRET_KEY,如果有人只篡改了第一和第二部分,服务器能够正常解析里面的内容,但是作为验证的第三部分显然是不匹配的;如果有人篡改了所有部分,服务器是没法解析第三部分的,因为SEVER_SECRET_KEY一定不一样。
以上三个部分组合在一起就构成了完成的JWT了,如下所示:
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.ewoJJ2lkJzonMTAnLAoJJ2V4cCc6JzIzMDE1OTc0MzMnCn0=.05dd35b4d20c95430cd1b63406f861de7e4c1476f9dbffa25f30fe08baf8f530
1.2、JWT缺点:
1、安全性:从以上的描述中我们可以看到,payload中的信息只是进行了base64URL编码,并没有加密,所以不能存放敏感信息;同时,
JWT如果泄露会被人冒用身份,为防止盗用,JWT应尽量使用https协议传输。
2、性能:JWT为了做到安全性,导致其本身很长,即http请求开销增加。
1.3、JWT优点:
1、扩展性:应用程序分布式部署的情况下,客户端的一个令牌可以访问所有的服务器的同时,也避免了服务器之间做session id的多机数据共享。
2、性能:JWT中信息的存储,可以有效的减少服务器查询数据库的次数。
2、Spring boot + JWT实现
创建Spring boot项目以及数据库配置等等细节直接跳过,下图是接下来项目中会用到的包的结构及其说明:
bean:存放实体对象
common:存放拦截器等公共模块
controller:控制层
dto:控制层接收前端属性时定义的对象
mapper:数据层
service:业务层
utils:工具类
此时可以完全不管层级结构,因为接下来的实例中会有具体的应用场景,更容易理解。
做好了前期的准备:
第一步:写一个生成以及验证 JWT 的工具类 JWTUtils(显然应该放到utils包底下):
@Component
public class JWTUtils {
/**
* 生成 token
*/
public static String getToken(Map<String,String> map) {
Date date = new Date(System.currentTimeMillis() + 86400*1000);
Algorithm algorithm = Algorithm.HMAC256("Xinmachong666");
//创建jwt builder
JWTCreator.Builder builder = JWT.create();
//payload
map.forEach((k,v)->{
builder.withClaim(k,v);
});
// 附带username信息
return builder
//到期时间
.withExpiresAt(date)
//创建一个新的JWT,并使用给定的算法进行标记
.sign(algorithm);
}
/**
* 校验 token 是否正确
*/
public static DecodedJWT verify(String token) {
return JWT.require(Algorithm.HMAC256("Xinmachong666")).build().verify(token);
}
其中 86400*1000 是设定JWT有效期为1天,“Xinmachong666”就是前面所说的非常隐秘的签名字符串。
第二步:写一个 JWT 的拦截器,从而对所有的 JWT 进行验证合法性:
public class JWTInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//获取请求头中令牌
ApiResponse apiResponse = null;
String token = request.getHeader("token");
try {
JWTUtils.verify(token);
return true;
}catch (SignatureVerificationException e){
apiResponse = new ApiResponse(505,"无效签名",false);
}catch (TokenExpiredException e){
apiResponse = new ApiResponse(505,"token过期",false);
}catch (AlgorithmMismatchException e){
apiResponse = new ApiResponse(505,"token算法不一致",false);
}catch (Exception e){
apiResponse = new ApiResponse(505,"token无效",false);
}
String json = new ObjectMapper().writeValueAsString(apiResponse);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().println(json);
return false;
}
}
该类实现了HandlerInterceptor,并且重写了预处理的实现部分。
第三步:虽然实现了 JWT 验证的预处理,但是现在服务器不知道怎么以及何时拦截下 JWT 。举个栗子:JWT 验证的预处理就好比收费站的小姐姐,但是现在没有收费站,你让她咋收费呢?拿着黑李逵的斧子去收过路费?那所有车辆都要收费?显然消防车不用吧?所以说第三步就是写一个配置类对配置的路由进行选择性拦截,如下所示:
@Configuration
public class InterceptorConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new JWTInterceptor())
.addPathPatterns("/**")
.excludePathPatterns("/v1/user/login");
}
}
其中 addPathPatterns("/**") 表示拦截所有路由(要想过此路,留下买路财),excludePathPatterns("/v1/user/login") 表示可以放行的路由(消防员:救火也要收费?小姐姐:失误失误)。
第四部分:写一个登陆接口进行测试一下,首先写控制层代码(显然 UserController 类放到controller包中):
@RestController
@RequestMapping("/v1/user")
@Validated
@CrossOrigin
public class UserController {
@Resource
private UserService userService;
@PostMapping("/login")
public ApiResponse login(@RequestBody @Validated LoginDTO loginDTO) {
String salt = this.userService.getSaltByAccount(loginDTO);
String password = new SimpleHash("MD5",loginDTO.getPassword(),salt,1).toHex();
String realPassword = this.userService.getPasswordByAccount(loginDTO);
if(StringUtils.isEmpty(realPassword)){
return new ApiResponse(400,"用户名错误",0);
} else if (!realPassword.equals(password)) {
return new ApiResponse(400, "密码错误", 0);
} else {
LoginVO loginVO = this.userService.getUserMsg(loginDTO);
return new ApiResponse(200,"success",loginVO);
}
}
@GetMapping("/test")
public ApiResponse test(HttpServletRequest request) {
String username = this.userService.getUsernameByJWT(request);
return new ApiResponse(200,"success",username);
}
}
其中 LoginDTO 是控制层将要接收的对象:
@Data
@NoArgsConstructor
public class LoginDTO {
@NotBlank(message = "用户名不能为空")
private String account;
@NotBlank(message = "密码不能为空")
private String password;
}
ApiResponse是自定义的返回值格式:
@Data
public class ApiResponse {
private int code;
private String msg;
private Object data;
public ApiResponse(int code, String msg, Object data) {
this.code = code;
this.msg = msg;
this.data = data;
}
}
而 getSaltByAccount()、getPasswordByAccount() 和 getUserMsg() 方法的具体实现在业务层,如下所示(显然 UserService 类放到service包中):
@Service
public class UserService {
@Resource
private UserMapper userMapper;
@Resource
private GetInformationFromJWT getInformationFromJWT;
public String getSaltByAccount(LoginDTO loginDTO) {
QueryWrapper<MeyerUser> wrapper = new QueryWrapper<>();
wrapper.lambda().eq(MeyerUser::getAccount,loginDTO.getAccount());
return this.userMapper.selectOne(wrapper).getSalt();
}
public String getPasswordByAccount(LoginDTO loginDTO) {
QueryWrapper<MeyerUser> wrapper = new QueryWrapper<>();
wrapper.lambda().eq(MeyerUser::getAccount,loginDTO.getAccount());
return this.userMapper.selectOne(wrapper).getPassword();
}
public LoginVO getUserMsg(LoginDTO loginDTO) {
QueryWrapper<MeyerUser> wrapper = new QueryWrapper<>();
wrapper.lambda().eq(MeyerUser::getAccount,loginDTO.getAccount());
MeyerUser meyerUser = this.userMapper.selectOne(wrapper);
String token = this.getToken(meyerUser);
LoginVO loginVO = new LoginVO();
loginVO.setToken(token);
BeanUtils.copyProperties(meyerUser,loginVO);
return loginVO;
}
public String getToken(MeyerUser meyerUser) {
Map<String,String> payload = new HashMap<>();
payload.put("userId", meyerUser.getId()+"");
payload.put("username", meyerUser.getUsername());
payload.put("account", meyerUser.getAccount());
return JWTUtils.getToken(payload);
}
public String getUsernameByJWT(HttpServletRequest request) {
return this.getInformationFromJWT.getUsernameByJWT(request);
}
}
因为项目中经常会获取 JWT 中存储的信息,所以我提取了该功能到新的类中:
@Component
public class GetInformationFromJWT {
public int getUserIdByJWT(HttpServletRequest request){
String token = request.getHeader("token");
DecodedJWT verify = JWTUtils.verify(token);
return Integer.parseInt(verify.getClaim("userId").asString());
}
public int getRoleIdByJWT(HttpServletRequest request){
String token = request.getHeader("token");
DecodedJWT verify = JWTUtils.verify(token);
return Integer.parseInt(verify.getClaim("roleId").asString());
}
public String getUsernameByJWT(HttpServletRequest request){
String token = request.getHeader("token");
DecodedJWT verify = JWTUtils.verify(token);
return verify.getClaim("username").asString();
}
}
接下来是UserMapper和User实体:
@Repository
public interface UserMapper extends BaseMapper<MeyerUser> {
}
@Data
@Accessors(chain = true)
public class User {
@TableId(type = IdType.AUTO)
private int id;
private String username;
private String account;
private String password;
private String salt;
private String staffNo;
private String nickname;
private String avatar;
@JsonIgnore
private Date createTime;
@JsonIgnore
private Date updateTime;
@JsonIgnore
@TableLogic
private Date deleteTime;
}