JSON Web Token (JWT)是一个开放标准(RFC 7519) ,它定义了一种紧凑和自包含的方式,
用于作为 JSON 对象在各方之间安全地传输信息
。此信息可以进行验证和信任,因为它是经过数字签名的。JWT 可以使用机密(使用 HMAC 算法)或使用 RSA 或 ECDSA 的公钥/私钥对进行签名。
虽然可以对 JWT 进行加密,以便在各方之间提供保密性,但是我们将关注已签名的Token。签名Token可以验证其中包含的声明的完整性,而加密Token可以向其他方隐藏这些声明。当使用公钥/私钥对对令牌进行签名时,该签名还证明只有持有私钥的一方才是对其进行签名的一方(签名技术是保证传输的信息不被篡改,并不能保证信息传输的安全
)。
官网地址:https://jwt.io/introduction
在其紧凑的形式中,JWT由以点(.)分隔的三个部分组成,它们是:
类似于xxxx.xxxx.xxxx格式,真实情况如下:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
并且你可以通过官网https://jwt.io/#debugger-io解析出三部分表示的信息( 可使用 JWT.io Debugger 来解码、验证和生成 JWT
):
报头通常由两部分组成: Token的类型(即 JWT)和所使用的签名算法(如 HMAC SHA256或 RSA)。
例如:
{
"alg": "HS256",
"typ": "JWT"
}
最终这个 JSON 将由base64进行加密(该加密是可以对称解密的),用于构成 JWT 的第一部分,eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9就是base64加密后的结果。
Token的第二部分是有效负载,其中包含声明。声明是关于实体(通常是用户)和其他数据的语句。有三种类型的声明: registered claims, public claims, and private claims。
例如:
{
"sub": "1234567890",// 注册声明
"name": "John Doe",// 公共声明
"admin": true // 私有声明
}
这部分的声明也会通过base64进行加密,最终形成JWT的第二部分eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ
registered claims(注册声明)
这些是一组预定义的声明,它们
不是强制性的,而是推荐的
,以提供一组有用的、可互操作的声明
。
例如:
注意:声明名称只有三个字符,因为 JWT 意味着是紧凑的。
Public claims(公共的声明)
使用 JWT 的人可以随意定义这些声明(
可以自己声明一些有效信息如用户的id,name等,但是不要设置一些敏感信息,如密码
)。但是为了避免冲突,应该在 JWT注册表中定义它们,或者将它们定义为包含抗冲突名称空间的 URI。
Private claims(私人声明)
这些是创建用于在同意使用它们的各方之间共享信息的习惯声明,既不是注册声明,也不是公开声明(
私人声明是提供者和消费者所共同定义的声明
)。
注意:对于已签名的Token,这些信息虽然受到保护,不会被篡改,但任何人都可以阅读。除非加密,否则不要将机密信息放在 JWT 的有效负载或头元素中。
要创建Signature,您必须获取编码的标头(header)、编码的有效载荷(payload)、secret、标头中指定的算法,并对其进行签名。
例如,如果您想使用 HMAC SHA256算法,签名将按以下方式创建:
HMACSHA256(
base64UrlEncode(header) + "." +base64UrlEncode(payload),
secret
)
上面的JSON将会通过HMACSHA256算法结合secret进行加盐签名(私钥加密),其中header和payload将通过base64UrlEncode()方法进行base64加密然后通过字符串拼接 "."
生成新字符串,最终生成JWT的第三部分SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
注意:secret是保存在服务器端的,jwt的签发生成也是在服务器端的,secret就是用来进行jwt的签发和验证,所以,它就是你服务端的私钥,在任何场景都不应该流露出去。一旦客户端得知这个secret, 那就意味着客户端是可以自我签发jwt了
JWT输出是三个由点分隔的 Base64-URL 字符串,这些字符串可以在 HTML 和 HTTP 环境中轻松传递,同时与基于 XML 的标准(如 SAML)相比更加紧凑。
下面显示了一个 JWT,该 JWT 对前一个头和有效负载进行了编码,并使用一个 secret 进行签名。
真实情况,一般是在请求头里加入Authorization,并加上Bearer标注最后是JWT(格式:Authorization: Bearer
):
使用的是JJWT框架
)先导入JJWT的依赖(JJWT是JWT的框架)
<dependency>
<groupId>io.jsonwebtokengroupId>
<artifactId>jjwtartifactId>
<version>0.9.1version>
dependency>
测试代码如下:
public class JjwtTest {
@Test
public void generateToken() {
// JWT头部分信息【Header】
Map<String, Object> header = new HashMap<>();
header.put("alg", "HS256");
header.put("typ", "JWT");
// 载核【Payload】
Map<String, Object> payload = new HashMap<>();
payload.put("sub", "1234567890");
payload.put("name","John Doe");
payload.put("admin",true);
// 声明Token失效时间
Calendar instance = Calendar.getInstance();
instance.add(Calendar.SECOND,300);// 300s
// 生成Token
String token = Jwts.builder()
.setHeader(header)// 设置Header
.setClaims(payload) // 设置载核
.setExpiration(instance.getTime())// 设置生效时间
.signWith(SignatureAlgorithm.HS256,"secret") // 签名,这里采用私钥进行签名,不要泄露了自己的私钥信息
.compact(); // 压缩生成xxx.xxx.xxx
System.out.println(token);
}
}
运行结果:
使用的是JJWT框架
)测试代码如下:
@Test
public void getInfoByJwt() {
// 生成的token
String token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImV4cCI6MTY2MzI5NzQzMX0.Ju5EzKBpUnuIRhDG1SU0NwMGsd9Jl_8YBcMM6PB2C20";
// 解析head信息
JwsHeader jwsHeader = Jwts
.parser()
.setSigningKey("secret")
.parseClaimsJws(token)
.getHeader();
System.out.println(jwsHeader); // {typ=JWT, alg=HS256}
System.out.println("typ:"+jwsHeader.get("typ"));
// 解析Payload
Claims claims = Jwts
.parser()
.setSigningKey("secret")
.parseClaimsJws(token)
.getBody();
System.out.println(claims);// {sub=1234567890, name=John Doe, admin=true, exp=1663297431}
System.out.println("admin:"+claims.get("admin"));
// 解析Signature
String signature = Jwts
.parser()
.setSigningKey("secret")
.parseClaimsJws(token)
.getSignature();
System.out.println(signature); // Ju5EzKBpUnuIRhDG1SU0NwMGsd9Jl_8YBcMM6PB2C20
}
使用的是JJWT框架
)当然在实际项目中一般会将上面的操作封装成工具类来使用如下( 该项目是一个Spring Security+JWT的项目
):
应用配置文件application.yaml中加入如下配置:
jwt:
tokenHeader: Authorization #JWT存储的请求头
secret: mall-admin-secret #JWT加解密使用的密钥【私钥】
expiration: 604800 #JWT的超期限时间(60*60*24*7)
tokenHead: 'Bearer ' #JWT负载中拿到开头
工具类代码如下:
package com.dudu.mall.utils;
import cn.hutool.core.date.DateUtil;
import cn.hutool.core.util.StrUtil;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
/**
* JwtToken生成的工具类
* JWT token的格式:header.payload.signature
* header的格式(算法、token的类型):
* {"alg": "HS512","typ": "JWT"}
* payload的格式(用户名、创建时间、生成时间):
* {"sub":"wang","created":1489079981393,"exp":1489684781}
* signature的生成算法:
* HMACSHA512(base64UrlEncode(header) + "." +base64UrlEncode(payload),secret)
*/
public class JwtTokenUtil {
private static final Logger LOGGER = LoggerFactory.getLogger(JwtTokenUtil.class);
private static final String CLAIM_KEY_USERNAME = "sub";
private static final String CLAIM_KEY_CREATED = "created";
@Value("${jwt.secret}")
private String secret;
@Value("${jwt.expiration}")
private Long expiration;
@Value("${jwt.tokenHead}")
private String tokenHead;
/**
* 根据负责生成JWT的token
*/
private String generateToken(Map<String, Object> claims) {
return Jwts.builder()
.setClaims(claims)
.setExpiration(generateExpirationDate())
.signWith(SignatureAlgorithm.HS512, secret)
.compact();
}
/**
* 从token中获取JWT中的负载
*/
private Claims getClaimsFromToken(String token) {
Claims claims = null;
try {
claims = Jwts.parser()
.setSigningKey(secret)
.parseClaimsJws(token)
.getBody();
} catch (Exception e) {
LOGGER.info("JWT格式验证失败:{}", token);
}
return claims;
}
/**
* 生成token的过期时间
*/
private Date generateExpirationDate() {
return new Date(System.currentTimeMillis() + expiration * 1000);
}
/**
* 从token中获取登录用户名
*/
public String getUserNameFromToken(String token) {
String username;
try {
Claims claims = getClaimsFromToken(token);
username = claims.getSubject();
} catch (Exception e) {
username = null;
}
return username;
}
/**
* 验证token是否还有效
*
* @param token 客户端传入的token
* @param userDetails 从数据库中查询出来的用户信息
*/
public boolean validateToken(String token, UserDetails userDetails) {
String username = getUserNameFromToken(token);
return username.equals(userDetails.getUsername()) && !isTokenExpired(token);
}
/**
* 判断token是否已经失效
*/
private boolean isTokenExpired(String token) {
Date expiredDate = getExpiredDateFromToken(token);
return expiredDate.before(new Date());
}
/**
* 从token中获取过期时间
*/
private Date getExpiredDateFromToken(String token) {
Claims claims = getClaimsFromToken(token);
return claims.getExpiration();
}
/**
* 根据用户信息生成token
*/
public String generateToken(UserDetails userDetails) {
Map<String, Object> claims = new HashMap<>();
claims.put(CLAIM_KEY_USERNAME, userDetails.getUsername());
claims.put(CLAIM_KEY_CREATED, new Date());
return generateToken(claims);
}
/**
* 当原来的token没过期时是可以刷新的
*
* @param oldToken 带tokenHead的token
*/
public String refreshHeadToken(String oldToken) {
if(StrUtil.isEmpty(oldToken)){
return null;
}
String token = oldToken.substring(tokenHead.length());
if(StrUtil.isEmpty(token)){
return null;
}
//token校验不通过
Claims claims = getClaimsFromToken(token);
if(claims==null){
return null;
}
//如果token已经过期,不支持刷新
if(isTokenExpired(token)){
return null;
}
//如果token在30分钟之内刚刷新过,返回原token
if(tokenRefreshJustBefore(token,30*60)){
return token;
}else{
claims.put(CLAIM_KEY_CREATED, new Date());
return generateToken(claims);
}
}
/**
* 判断token在指定时间内是否刚刚刷新过
* @param token 原token
* @param time 指定时间(秒)
*/
private boolean tokenRefreshJustBefore(String token, int time) {
Claims claims = getClaimsFromToken(token);
Date created = claims.get(CLAIM_KEY_CREATED, Date.class);
Date refreshDate = new Date();
//刷新时间在创建时间的指定时间内
if(refreshDate.after(created)&&refreshDate.before(DateUtil.offsetSecond(created,time))){
return true;
}
return false;
}
}
下图显示了 JWT 是如何获得并用于访问 API 或资源的:
本项目JWT使用的是
JJWT框架
来实现,下图流程图来源于【编程不良人】JWT认证原理、流程整合springboot实战应用,前后端分离认证的解决方案!,项目采用SpringBoot+mybatis实现,业务逻辑非常简单,下面截取关键代码来了解整个流程。
业务流程图如下:
登入功能通过业务层提供的login(user)方法进行登入,登入成功返回token信息,如果token信息不为null就将token信息返回给前端系统
业务层login(User user)方法实现逻辑非常简单,就是通过查询数据库中是否存在该用户,存在表示登入成功,并通过Token工具类生成token
JWT工具类中generateToken(User user)虽然接受的是user对象,但是实际只使用了user的name作为载荷( 不要将敏感信息写入到Token中
)
Postman中模拟前端访问http://localhost:8086/user/login?name=test&password=123456:
执行成功后前端会接受到服务器端传递过来的Token信息
访问Api信息/资源,前端只需要将token信息保存到请求头中,发送请求到对应Api/资源即可:
Postman模拟前端访问:http://localhost:8086/user/info?id=1
至于Token的验证交给拦截器进行处理:
下面的内容借鉴博文:https://zhuanlan.zhihu.com/p/66037342,该博文详细的介绍了单点登入相关知识,借鉴博文什么是 JWT – JSON WEB TOKEN,该篇博文详细的介绍了JWT相关知识。
这是使用 JWT 最常见的场景。
一旦用户登录,每个后续请求都将包含 JWT,允许用户访问该Token所允许的路由、服务和资源
。单点登录
是目前广泛使用 JWT的一个特性,因为它的开销很小,而且能够很容易地跨不同域使用
。
单系统就是所有功能都在同一个系统上
为了合理利用资源和降低耦合性,于是把单系统拆分成多个子系统(
传统基于session认证的登入设计是无法实现多系统登入的,因为session无法跨域
)
关于什么是单点登入,简单概述一下就是,在多个系统中,用户只需一次登录,各个系统即可感知该用户已经登录,例如淘宝和天猫这二个不同的子系统,当登入淘宝的时候,天猫自然而然就登入了。
Http是一种无状态的协议,意味着服务器无法确认用户信息,所以W3C推出Cookie来确认用户的信息,Cookie就相当于一个用户凭证,服务器可以通过这个凭证(Cookie)知道是哪一个用户发起了请求,虽然我们可以通过Cookie知道发起请求是谁,但是我们怎么知道这个用户是否已经发送过Cookie了呢,意思就是我们怎么知道某个用户是否已经登入了,这个时候就需要通过一张表来记录用户的信息,这就是Session,相当于在服务器中建立了一份“客户明细表”,Session无法知道发起的用户是谁,所以服务器为了确定用户的信息,会向服务器发送一个名为JESSIONID的Cookie,它的值是Session的id值,来确认用户的身份。
单系统登入常见的操作:
业务代码如下:
/**
* 用户登陆
*/
@PostMapping(value = "/user/session", produces = {"application/json;charset=UTF-8"})
public Result login(String mobileNo, String password, String inputCaptcha, HttpSession session, HttpServletResponse response) {
//判断验证码是否正确
if (WebUtils.validateCaptcha(inputCaptcha, "captcha", session)) {
//判断有没有该用户
User user = userService.userLogin(mobileNo, password);
if (user != null) {
/*设置自动登陆,一个星期. 将token保存在数据库中*/
String loginToken = WebUtils.md5(new Date().toString() + session.getId());
user.setLoginToken(loginToken);
User user1 = userService.userUpload(user);
session.setAttribute("user", user1);
CookieUtil.addCookie(response,"loginToken",loginToken,604800);
return ResultUtil.success(user1);
} else {
return ResultUtil.error(ResultEnum.LOGIN_ERROR);
}
} else {
return ResultUtil.error(ResultEnum.CAPTCHA_ERROR);
}
}
/**
* 用户退出
*/
@DeleteMapping(value = "/session", produces = {"application/json;charset=UTF-8"})
public Result logout(HttpSession session,HttpServletRequest request,HttpServletResponse response ) {
//删除session和cookie
session.removeAttribute("user");
CookieUtil.clearCookie(request, response, "loginToken");
return ResultUtil.success();
}
/**
* @author ozc
* @version 1.0
*
* 拦截器;实现自动登陆功能
*/
public class UserInterceptor implements HandlerInterceptor {
@Autowired
private UserService userService;
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object o) throws Exception {
User sessionUser = (User) request.getSession().getAttribute("user");
// 已经登陆了,放行
if (sessionUser != null) {
return true;
} else {
//得到带过来cookie是否存在
String loginToken = CookieUtil.findCookieByName(request, "loginToken");
if (StringUtils.isNotBlank(loginToken)) {
//到数据库查询有没有该Cookie
User user = userService.findUserByLoginToken(loginToken);
if (user != null) {
request.getSession().setAttribute("user", user);
return true;
} else {
//没有该Cookie与之对应的用户(Cookie不匹配)
CookieUtil.clearCookie(request, response, "loginToken");
return false;
}
} else {
//没有cookie、也没有登陆。是index请求获取用户信息,可以放行
if (request.getRequestURI().contains("session")) {
return true;
}
//没有cookie凭证
response.sendRedirect("/login.html");
return false;
}
}
}
}
基于session认证的登入系统存在的问题:
每个用户经过我们的应用认证之后,我们的应用都要在服务端做一次记录,以方便用户下次请求的鉴别,通常而言session都是保存在内存中,而随着认证用户的增多,服务端的开销会明显增大。
用户认证之后,服务端做认证记录,如果认证的记录被保存在内存中的话,这意味着用户下次请求还必须要请求在这台服务器上,这样才能拿到授权的资源,这样在分布式的应用上,相应的限制了负载均衡器的能力。这也意味着限制了应用的扩展能力。
因为是基于cookie来进行用户识别的, cookie如果被截获,用户就会很容易受到跨站请求伪造的攻击。
基于token的鉴权机制,就没有这些问题:
基于token的鉴权机制类似于http协议也是无状态的,它不需要在服务端去保留用户的认证信息或者会话信息。这就意味着基于token认证机制的应用不需要去考虑用户在哪一台服务器登录了,这就为应用的扩展提供了便利。
实际流程如下( 对应项目demo前面已经简述了,这里不在叙述
):
JWT是在各方之间安全传输信息的好方法。因为可以对 JWT 进行签名(例如,使用公钥/私钥对) ,所以可以确保发件人就是他们所说的那个人。此外,由于签名是使用头和有效负载计算的,因此还可以验证内容是否被篡改。
数字签名就是发送方使用自己的私钥进行加密,接收方收到该签名后就可以使用发送方的公钥(公开的密钥,所有人都可以获取)进行解密,解密成功表示该消息没有被假冒且发送方也不能否认自己发送过。
下图就是JWT和SAML长度的比较: