JSON Web Token(缩写 JWT)是目前最流行的跨域认证解决方案,本文介绍它的原理和用法。
一、跨域认证的问题
互联网服务离不开用户认证
一般流程是:
1、用户向服务器发送用户名和密码
2、服务器验证通过后,在当前对话(session)里面保存相关数据,比如用户角色、登录时间
3、服务器向用户返回一个 session_id,写入用户的 Cookie
4、用户随后的每一次请求,都会通过 Cookie,将 session_id 传回服务器
5、服务器收到 session_id,找到前期保存的数据,由此得知用户的身份
这种模式的问题在于,扩展性(scaling)不好。
单机当然没有问题,如果是服务器集群,或者是跨域的服务导向架构,就要求 session 数据共享,每台服务器都能够读取 session。
举例来说,A 网站和 B 网站是同一家公司的关联服务。现在要求,用户只要在其中一个网站登录,再访问另一个网站就会自动登录,请问怎么实现?
一种解决方案是 session 数据持久化,写入数据库或别的持久层。各种服务收到请求后,都向持久层请求数据。这种方案的优点是架构清晰,缺点是工程量比较大。另外,持久层万一挂了,就会单点失败。
另一种方案是服务器索性不保存 session 数据了,所有数据都保存在客户端,每次请求都发回服务器。JWT 就是这种方案的一个代表。
总结:
什么是JWT?
JSON Web Token(JWT)是一个非常轻巧的规范,这个规范允许我们使用JWT在用户和服务器之间传递安全可靠的信息。
二、JWT 的原理
JWT 的原理是,服务器认证以后,生成一个 JSON 对象,发回给用户,如:
{
“姓名”: “张三”,
“角色”: “管理员”,
“到期时间”: “2018年7月1日0点0分”
}
用户与服务端通信的时候,都要发回这个 JSON 对象。服务器完全只靠这个对象认定用户身份。为了防止用户篡改数据,服务器在生成这个对象的时候,会加上签名。
服务器就不保存任何 session 数据了,服务器变成无状态,从而比较容易实现扩展。
三、JWT 的数据结构
实际的 JWT 大概就像下面这样。
它是一个很长的字符串,中间用点(.
)分隔成三个部分。注意,JWT 内部是没有换行的,这里只是为了便于展示,将它写成了几行。
JWT 的三个部分依次如下。
-
Header(头部) 明文
-
Payload(负载) 明文
-
Signature(签名)
写成一行,就是下面的样子。
Header.Payload.Signature
下面依次介绍这三个部分:
3.1 Header(头部)
Header 部分是一个 JSON 对象,描述 JWT 的元数据,通常是下面的样子。
{ "alg": "HS256", "typ": "JWT"}
alg
属性表示签名的算法(algorithm),默认是 HMAC SHA256(写成 HS256);
typ
属性表示这个令牌(token)的类型(type),JWT 令牌统一写为JWT
。
最后,将上面的 JSON 对象使用 Base64URL 算法转成字符串。
在头部指明了签名算法是HS256算法。 我们进行BASE64编码https://base64.us/,https://www.zxgj.cn/g/base64编码后的字符串如下:
eyAgImFsZyI6ICJIUzI1NiIsICAidHlwIjogIkpXVCJ9
Base64是一种基于64个可打印字符来表示二进制数据的表示方法
由于2的6次方等于64,所以每6个比特为一个单元,对应某个可打印字符;三个字节有24个比特,对应于4个Base64单元,即3个字节需要用4个可打印字符来表示
JDK 中提供了非常方便的 BASE64Encoder 和 BASE64Decoder,用它们可以非常方便的完成基于 BASE64 的编码和解码
3.2 Payload (载荷)
Payload 部分也是一个 JSON 对象,用来存放实际需要传递的数据。JWT 规定了7个官方字段,供选用。
iss (issuer):签发人
exp (expiration time):过期时间 这个过期时间必须要大于签发时间
sub (subject):主题 ==》jwt所面向的用户
aud (audience):受众 ==》接收jwt的一方
nbf (Not Before):生效时间 ==》(定义在什么时间之前,该jwt都是不可用的.)
iat (Issued At):签发时间
jti (JWT ID):编号 ==》(jwt的唯一身份标识,主要用来作为一次性token,从而回避重放攻击。)
除了官方字段,你还可以在这个部分定义私有字段,下面就是一个例子。
{"sub":"1234567890","name":"John Doe","admin":true}
JWT 默认是不加密的,任何人都可以读到,所以不要把秘密信息放在这个部分。
这个 JSON 对象也要使用 Base64URL 算法转成字符串,得到Jwt的第二部分。
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9
3.3 Signature (签证)
jwt的第三部分是一个签证信息,这个签证信息由三部分组成:
-
header (base64后的)
-
payload (base64后的)
-
secret
签名,利用“加密算法”对JWT进行签名,保证没有被篡改过,值得注意的是,这里的数据都是明文的,算法实际上执行的是最后的数据签名功能,只能保证“不被篡改”,而不是保证“不被解密”,所以后面看到“加密、解密”,其实都是为签名服务的。
首先,需要指定一个密钥(secret)。这个密钥只有服务器才知道,不能泄露给用户。然后,使用 Header 里面指定的签名算法(默认是 HMAC SHA256),按照下面的公式产生签名。
HMACSHA256(base64UrlEncode(header) + "." +base64UrlEncode(payload), secret)
算出签名以后,把 Header、Payload、Signature 三个部分拼成一个字符串,每个部分之间用"点"(.
)分隔,就可以返回给用户。
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ
3.4 Base64URL
Header 和 Payload 串型化的算法是 Base64URL。这个算法跟 Base64 算法基本类似,但有一些小的不同。
JWT 作为一个令牌(token),有些场合可能会放到 URL(比如 api.example.com/?token=xxx)。Base64 有三个字符+
、/
和=
,在 URL 里面有特殊含义,所以要被替换掉:=
被省略、+
替换成-
,/
替换成_
。这就是 Base64URL 算法。
3.5 Jwt官网验证
为了验证上述生成的 jwt 是否合法,我们可以登录 JWT 官网,官网界面提供 JWT 解密功能,将生成好的 JWT 复制到如下图中进行解密
四、小小实验室
通过上述的介绍,我们已经了解到什么是 JWT 以及 JWT 生成的规则,现在我们通过代码方式来生成 JWT。
JWT 官网提供了通过不同编程语言来创建 JWT 的工具类/库
java-jwt
4.1 对称签名
首先引入依赖
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>3.10.3</version>
</dependency>
生成JWT的token
@Test
public void testGenerateToken(){
// 指定token过期时间为10秒
Calendar calendar = Calendar.getInstance();
calendar.add(Calendar.SECOND, 60);
String token = JWT.create()
.withHeader(new HashMap<>()) // Header
.withClaim("userId", 21) // Payload
.withClaim("userName", "baobao")
.withExpiresAt(calendar.getTime()) // 过期时间
.sign(Algorithm.HMAC256("!34ADAS")); // 签名用的secret
System.out.println(token);
}
注意多次运行方法生成的token字符串内容是不一样的,尽管我们的payload信息没有变动。因为JWT中携带了超时时间,所以每次生成的token会不一样,我们利用base64解密工具可以发现payload确实携带了超时时间https://www.zxgj.cn/g/base64
解析JWT字符串
@Test
public void testResolveToken(){
// 创建解析对象,使用的算法和secret要与创建token时保持一致
JWTVerifier jwtVerifier = JWT.require(Algorithm.HMAC256("!34ADAS")).build();
// 解析指定的token
DecodedJWT decodedJWT = jwtVerifier.verify(
"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyTmFtZSI6ImJhb2JhbyIsImV4cCI6MTY4MTkxNzI2OSwidXNlcklkIjoyMX0.v07T_9QQ3b5nWHaO-oLWthekMyZXu7W9R8IOz2w9bk8"
);
// 获取解析后的token中的payload信息
Claim userId = decodedJWT.getClaim("userId");
Claim userName = decodedJWT.getClaim("userName");
System.out.println(userId.asInt());
System.out.println(userName.asString());
// 输出超时时间
System.out.println(decodedJWT.getExpiresAt());
}
运行后发现报异常,原因是之前生成的token已经过期
再运行一次生成token的方法,改一下时间然后在过期时间60秒之内将生成的字符串拷贝到解析方法中,运行解析方法即可成功
4.2 封装工具类
可以将上述方法封装成工具类
public class JwtUtils {
// 签名密钥
private static final String SECRET = "!DAR$";
/**
* 生成token
* @param payload token携带的信息
* @return token字符串
*/
public static String getToken(Map<String,String> payload){
// 指定token过期时间为7天
Calendar calendar = Calendar.getInstance();
calendar.add(Calendar.DATE, 7);
JWTCreator.Builder builder = JWT.create();
// 构建payload
payload.forEach((k,v) -> builder.withClaim(k,v));
// 指定过期时间和签名算法
String token = builder.withExpiresAt(calendar.getTime()).sign(Algorithm.HMAC256(SECRET));
return token;
}
/**
* 解析token
* @param token token字符串
* @return 解析后的token
*/
public static DecodedJWT decode(String token){
JWTVerifier jwtVerifier = JWT.require(Algorithm.HMAC256(SECRET)).build();
DecodedJWT decodedJWT = jwtVerifier.verify(token);
return decodedJWT;
}
}
jjwt-root
4.3 对称签名
引入依赖
<!-- https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
整合使用:
1、创建mapper文件
@Mapper
public interface UserMapper {
Admin findByUserNameAndPassword(
@Param("username") String username,
@Param("password") String password);
}
2、构建Jwt工具类
@Component
public class JwtUtil {
@Value("${jwt.secretKey}")
private String secretKey;
public String createJWT(String id, String subject, long ttlMillis, Map<String, Object> map) throws Exception {
JwtBuilder builder = Jwts.builder()
.setId(id)
.setSubject(subject) // 发行者
.setIssuedAt(new Date()) // 发行时间
// HS256算法实际上就是MD5加盐值,此时secretKey就代表盐值
.signWith(SignatureAlgorithm.HS256, secretKey) // 签名类型 与 密钥
.compressWith(CompressionCodecs.DEFLATE);// 对载荷进行压缩
if (!CollectionUtils.isEmpty(map)) {
builder.setClaims(map);
}
if (ttlMillis > 0) {
builder.setExpiration(new Date(System.currentTimeMillis() + ttlMillis));
}
return builder.compact();
}
//获取有效的Jwttoken
public Claims parseJWT(String jwtString) {
return Jwts.parser().setSigningKey(secretKey)
.parseClaimsJws(jwtString)
.getBody();
}
}
3、yml中加入:
jwt:
secretKey: ak47
mapper-locations: classpath:mapper.*Mapper.xml
4、构建mapper.xml
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.by.mapper.UserMapper">
<select id="findByUserNameAndPassword" resultType="com.by.pojo.Admin">
select * from admin where username = #{username} and password = #{password}
</select>
</mapper>
5、创建service接口及实现类
public interface UserService {
public String login(String username, String password) throws Exception;
}
//实现类
@Service
public class UserServiceImpl implements UserService {
@Autowired
private JwtUtil jwtUtil;
@Resource
private UserMapper userMapper;
@Override
public String login(String username, String password) throws Exception {
//登录验证
Admin user = userMapper.findByUserNameAndPassword(username, password);
if (user == null) {
return null;
}
//如果能查出,则表示账号密码正确,生成jwt返回
String uuid = UUID.randomUUID().toString().replace("-", "");
HashMap<String, Object> map = new HashMap<>();
map.put("username", user.getUsername());
map.put("age", user.getAge());
return jwtUtil.createJWT(uuid, "login subject", 0L, map);
}
}
6、创建controller类
@RestController
@RequestMapping("/user")
public class UserController {
@Autowired
private UserService userService;
@RequestMapping(value = "/login", method = RequestMethod.POST)
public JwtResponse login(@RequestParam("username") String username,
@RequestParam("password") String password){
String jwt = "";
try {
jwt = userService.login(username, password);
return JwtResponse.success(jwt);
} catch (Exception e) {
e.printStackTrace();
return JwtResponse.fail(jwt);
}
}
}
7、postman测试:
注意:
- jjwt在0.10版本以后发生了较大变化,pom依赖要引入多个
4.4 首先引入Maven依赖
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.10.7</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.10.7</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.10.7</version>
<scope>runtime</scope>
</dependency>
标准规范中对各种加密算法的secretKey的长度有如下要求:
-
HS256:要求至少 256 bits (32 bytes)
-
HS384:要求至少384 bits (48 bytes)
-
HS512:要求至少512 bits (64 bytes)
-
RS256 and PS256:至少2048 bits
-
RS384 and PS384:至少3072 bits
-
RS512 and PS512:至少4096 bits
-
ES256:至少256 bits (32 bytes)
-
ES384:至少384 bits (48 bytes)
-
ES512:至少512 bits (64 bytes)
在jjwt0.10版本之前,没有强制要求,secretKey长度不满足要求时也可以签名成功。但是0.10版本后强制要求secretKey满足规范中的长度要求,否则生成jws时会抛出异常
4.5 创建测试类
@Test
public void testJWT() {
Key key = Keys.secretKeyFor(SignatureAlgorithm.HS256);
System.out.println("=============创建 JWT===========");
Date now = new Date();
JwtBuilder builder= Jwts.builder()
.setId(UUID.randomUUID().toString()) // 载荷-标准中注册的声明
.setSubject("admin") // 载荷-标准中注册的声明
.setIssuedAt(now) // 载荷-标准中注册的声明,表示签发时间
.claim("id", "123456") // 载荷-公共的声明
.claim("name", "MoonlightL") // 载荷-公共的声明
.claim("sex", "male") // 载荷-公共的声明
.signWith(key); // 签证
String jwt = builder.compact();
System.out.println("生成的 jwt :" +jwt);
System.out.println("=============解析 JWT===========");
try {
Jws<Claims> result = Jwts.parser().setSigningKey(key).parseClaimsJws(jwt);
// 以下步骤随实际情况而定,只要上一行代码执行不抛异常就证明 jwt 是有效的、合法的
Claims body = result.getBody();
System.out.println("载荷-标准中注册的声明 id:" + body.getId());
System.out.println("载荷-标准中注册的声明 subject:" + body.getSubject());
System.out.println("载荷-标准中注册的声明 issueAt:" + body.getIssuedAt());
System.out.println("载荷-公共的声明的 id:" + result.getBody().get("id"));
System.out.println("载荷-公共的声明的 name:" + result.getBody().get("name"));
System.out.println("载荷-公共的声明的 sex:" + result.getBody().get("sex"));
} catch (JwtException ex) { // jwt 不合法或过期都会抛异常
ex.printStackTrace();
}
}
}
-
setIssuedAt用于设置签发时间
-
signWith用于设置签名秘钥
测试运行,输出如下:
=============创建 JWT===========
生成的 jwt :eyJhbGciOiJIUzI1NiJ9.eyJqdGkiOiJhZjZlNjcwMS1kMjhjLTRmOTQtOWU3OS02OGNkNjIyZGQ0ZGEiLCJzdWIiOiJhZG1pbiIsImlhdCI6MTY4MTg4MjEwOCwiaWQiOiIxMjM0NTYiLCJuYW1lIjoiTW9vbmxpZ2h0TCIsInNleCI6Im1hbGUifQ.bxbx_dgrBa1XsebalUOoQ4daBvP0lMysaVAxFvyPzAY
=============解析 JWT===========
载荷-标准中注册的声明 id:af6e6701-d28c-4f94-9e79-68cd622dd4da
载荷-标准中注册的声明 subject:admin
载荷-标准中注册的声明 issueAt:Wed Apr 19 13:28:28 CST 2023
载荷-公共的声明的 id:123456
载荷-公共的声明的 name:MoonlightL
载荷-公共的声明的 sex:male
再次运行,会发现每次运行的结果是不一样的,因为我们的载荷中包含了时间。
4.6 10版本封装工具类
public class JwtUtils {
// token时效:24小时
public static final long EXPIRE = 1000 * 60 * 60 * 24;
// 签名哈希的密钥,对于不同的加密算法来说含义不同
public static final String APP_SECRET = "ukc8BDbRigUDaY6pZFfWus2jZWLPHOsdadasdasfdssfeweee";
/**
* 根据用户id和昵称生成token
* @param id 用户id
* @param nickname 用户昵称
* @return JWT规则生成的token
*/
public static String getJwtToken(String id, String nickname){
String JwtToken = Jwts.builder()
.setSubject("baobao-user")
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + EXPIRE))
.claim("id", id)
.claim("nickname", nickname)
// 传入Key对象
.signWith(Keys.hmacShaKeyFor(APP_SECRET.getBytes(StandardCharsets.UTF_8)), SignatureAlgorithm.HS256)
.compact();
return JwtToken;
}
/**
* 判断token是否存在与有效
* @param jwtToken token字符串
* @return 如果token有效返回true,否则返回false
*/
public static Jws<Claims> decode(String jwtToken) {
// 传入Key对象
Jws<Claims> claimsJws = Jwts.parserBuilder().setSigningKey(Keys.hmacShaKeyFor(APP_SECRET.getBytes(StandardCharsets.UTF_8))).build().parseClaimsJws(jwtToken);
return claimsJws;
}
}
五、JWT 的几个特点
(1)JWT 默认是不加密,但也是可以加密的。生成原始 Token 以后,可以用密钥再加密一次。
(2)JWT 不加密的情况下,不能将秘密数据写入 JWT。
(3)JW 不仅可以用于认证,也可以用于交换信息。有效使用 JWT,可以降低服务器查询数据库的次数。
(4)JWT 的最大缺点是,由于服务器不保存 session 状态,因此无法在使用过程中废止某个 token,或者更改 token 的权限。也就是说,一旦 JWT 签发了,在到期之前就会始终有效,除非服务器部署额外的逻辑。
(5)JWT 本身包含了认证信息,一旦泄露,任何人都可以获得该令牌的所有权限。为了减少盗用,JWT 的有效期应该设置得比较短。对于一些比较重要的权限,使用时应该再次对用户进行认证.
(6)为了减少盗用,JWT 不应该使用 HTTP 协议明码传输,要使用 HTTPS 协议传输。
六 、实际开发中的应用
在实际的SpringBoot项目中,一般我们可以用如下流程做登录:
-
在登录验证通过后,给用户生成一个对应的随机token(注意这个token不是指jwt,可以用uuid等算法生成),然后将这个token作为key的一部分,用户信息作为value存入Redis,并设置过期时间,这个过期时间就是登录失效的时间
-
将第1步中生成的随机token作为JWT的payload生成JWT字符串返回给前端
-
前端之后每次请求都在请求头中的Authorization字段中携带JWT字符串
-
后端定义一个拦截器,每次收到前端请求时,都先从请求头中的Authorization字段中取出JWT字符串并进行验证,验证通过后解析出payload中的随机token,然后再用这个随机token得到key,从Redis中获取用户信息,如果能获取到就说明用户已经登录
6.1 拦截器定义:
public class JWTInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String JWT = request.getHeader("Authorization");
try {
// 1.校验JWT字符串
DecodedJWT decodedJWT = JWTUtils.decode(JWT);
// 2.取出JWT字符串载荷中的随机token,从Redis中获取用户信息
...
return true;
}catch (SignatureVerificationException e){
System.out.println("无效签名");
e.printStackTrace();
}catch (TokenExpiredException e){
System.out.println("token已经过期");
e.printStackTrace();
}catch (AlgorithmMismatchException e){
System.out.println("算法不一致");
e.printStackTrace();
}catch (Exception e){
System.out.println("token无效");
e.printStackTrace();
}
return false;
}
}