以前我在开发App时,后端给我们的权限字符串是一个token,这个token很简单,疑似一个固定字符串经过base64编码,大约32个字符,并不长。每次我们向后端请求接口,都要带着这个字符串。可能是由于那时候没有做分布式吧,这个token只是用来做权限甄别,并不携带其他信息。
最近两年我做java服务端,开始也是用这种方式。这种token在单例部署服务中是可以的,后端生成token,和用户绑定,收到前端的接口请求,把token对比一下就放行。简单起到了权限检查的作用,但是安全性很差。为什么?因为没有签名算法,很容易造假。另外,在分布式系统中,token签发和校验可能不在同一个服务中,这就造成了用户信息丢失的问题。我们在分布式系统中,mysql和redis也有可能是分开的,这就需要我们的前端在获取token的时候,一并带上自己的用户信息,就像身份证一样。于是jwt就起到了这个作用。
jwt的最终结果是生成个字符串,所以它只是一个算法工具。可以不依赖web程序进行测试。不过由于它也只有在web程序中才能体现自己的价值,所以我的demo还是以springboot为基础来搭建的。
看看程序的结构:
可以看出,要测试jwt,只需要很简单几个文件。除了web服务常见的controller、service和model之外,就只有TokenUtils是重点了,jwt的构建和解析都在其中。
一、添加pom依赖
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
<version>2.8.5</version>
</dependency>
<!-- java-jwt -->
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>3.4.0</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
二、配置yml,我习惯改个端口
server:
port: 6005
三、model文件User.java
这其中放置用户信息,假设是从数据库中查询得到的用户信息
package com.chris.jwt.model;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* @author chrischan
* create on 2019/6/24 9:50
* use for:
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class User {
private int id;
private String username;
private String password;
private String[] permissions;
public static User create(int id, String name, String password, String... permissions) {
return new User(id, name, password, permissions);
}
}
四、service层AccountService.java
package com.chris.jwt.service;
import com.chris.jwt.model.User;
import com.chris.jwt.utils.TokenUtils;
import org.springframework.stereotype.Service;
/**
* @author chrischan
* create on 2019/6/24 9:25
* use for:
*/
@Service
public class AccountService {
public Object login(String username, String password) {
User user = User.create(1, username, password, "ROLE_ADMIN", "ROLE_USER");
String sub = "jwt_test";
//构建的时候把用户信息中的用户名
return TokenUtils.build(user, sub, 600000, "username","permissions");
}
public String parseToken(String token, String field) {
return String.valueOf(TokenUtils.parse(token).get(field));
}
}
此次的目的是测试jwt,所以并没有涉及用户信息检验的逻辑,用户信息要加在jwt中,调用的时候传什么都视为合法,直接加进去。中间缺失的逻辑根据真实业务补充。
五、接口AccountController.java
package com.chris.jwt.api;
import com.chris.jwt.service.AccountService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* @author chrischan
* create on 2019/6/24 9:22
* use for:
*/
@RestController
@RequestMapping("/api")
public class AccountController {
@Autowired
AccountService accountService;
/**
* 登录 登陆成功则返回一个token
*
* @param username
* @param password
* @return
*/
@GetMapping("/login")
public ResponseEntity<?> login(String username, String password) {
return ResponseEntity.ok(accountService.login(username, password));
}
@PostMapping("/parseToken")
public ResponseEntity<?> parseToken(String token, String field) {
return ResponseEntity.ok(accountService.parseToken(token, field));
}
}
很简单的两个接口,一个用来模拟登陆,获得jwt,一个用来测试对token的解析。
六、最重要的部分,TokenUtils.java
jwt的构建和解析都在其中。
package com.chris.jwt.utils;
import com.chris.jwt.model.User;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtBuilder;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import java.lang.reflect.Field;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
/**
* @author chrischan
* create on 2019/6/24 9:48
* use for:
*/
public class TokenUtils {
/**
* 指纹 最关键的东西 不可丢失
*/
private static String secret = "ASFW56868UIIGNJ2356SKFH568DS856876FJK";
/**
* 工具初始化
*
* @param secret
*/
public static void init(String secret) {
//设置一个通用指纹 出入保持一致
TokenUtils.secret = secret;
}
/**
* 构建token 使用公用的指纹
*
* @param obj
* @param subject
* @param ttMillis
* @param fieldNames
* @param <T>
* @return
*/
public static <T> String build(T obj, String subject, long ttMillis, String... fieldNames) {
return build(obj, TokenUtils.secret, subject, ttMillis, fieldNames);
}
/**
* 构建token
*
* @param obj 需要添加到token中的用户对象
* @param secret 指纹
* @param subject 主题
* @param ttMillis 过期时间
* @param fieldNames 需要添加到token中的字段
* @param <T>
* @return
*/
public static <T> String build(T obj, String secret, String subject, long ttMillis, String... fieldNames) {
//签名算法
SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;
//生成token的时间
long nowMillis = System.currentTimeMillis();
Date now = new Date(nowMillis);
//私有生命 最有用的部分 可携带信息
Class<?> objClass = obj.getClass();
Map<String, Object> claims = new HashMap<>(16);
for (String fieldName : fieldNames) {
Field field = null;
try {
field = objClass.getDeclaredField(fieldName);
field.setAccessible(true);
Object value = field.get(obj);
field.setAccessible(false);
claims.put(fieldName, value);
} catch (Exception e) {
continue;
}
}
//构建
JwtBuilder builder = Jwts.builder()
.setClaims(claims)//刚发现这部分自定义的数据要首先设置,否则此前的设置都会消失
.setId(UUID.randomUUID().toString())
.setIssuer("chris") //签发人
.setIssuedAt(now) //签发时间
.setSubject(subject) //主题
.signWith(signatureAlgorithm, secret);
//添加过期时间
if (ttMillis > 0) {
long expMillis = nowMillis + ttMillis;
Date exp = new Date(expMillis);
builder.setExpiration(exp);
}
return builder.compact();
}
/**
* 解析token 使用通用指纹
*
* @param tokenJson
* @return
*/
public static Claims parse(String tokenJson) {
return parse(tokenJson, secret);
}
/**
* 解析token 使用自定义指纹
*
* @param tokenJson
* @param secret
* @return
*/
public static Claims parse(String tokenJson, String secret) {
Claims claims = Jwts.parser()
.setSigningKey(secret)
.parseClaimsJws(tokenJson).getBody();
return claims;
}
/**
* token是否有效检查
* 此处只对user做简单密码匹配校验
*
* @param tokenJson
* @param user
* @return
*/
public static boolean isEffective(String tokenJson, User user) {
String password = user.getPassword();
Claims claims = parse(tokenJson, secret);
//todo 检查过期
Object password1 = claims.get("password");
if (null != password1 && password.equals(password1)) {
return true;
}
return false;
}
}
jwt的构建,主要是操作JwtBuilder
//构建
JwtBuilder builder = Jwts.builder()
.setClaims(claims)
.setId(UUID.randomUUID().toString())
.setIssuer("chris") //签发人
.setIssuedAt(now) //签发时间
.setSubject(subject) //主题
.signWith(signatureAlgorithm, secret);
jwt的解析,主要是操作JwtParser
Claims claims = Jwts.parser()
.setSigningKey(secret)
.parseClaimsJws(tokenJson).getBody();
七、运行测试
http://localhost:6005/api/login?username=zhangsan&password=123456
得到结果:
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJqd3RfdGVzdCIsInBlcm1pc3Npb25zIjpbIlJPTEVfQURNSU4iLCJST0xFX1VTRVIiXSwiaXNzIjoiY2hyaXMiLCJleHAiOjE1NzA2NzkxNDIsImlhdCI6MTU3MDY3ODU0MiwianRpIjoiMjk1MzI2N2QtNTNiZS00N2Y2LWFjNTYtMjI4M2FjMDFkNWQ4IiwidXNlcm5hbWUiOiJ6aGFuZ3NhbiJ9.LT9ngTFW8BARFnBmK-enzz37fjRc39sELFPKUXjDw2g
关于jwt的结构,此处不再赘述。jwt分三段,头部指明了算法,中间部分是有效载荷,放置的是我们有用的信息。我们构建jwt的时候设置的大多数都是这部分,包括用户名和权限列表,签发人、主题和有效时间等等。最后一部分是吧前两部分合起来通过指纹算法生成的,不可逆,可以用来做校验。这也是最关键的,因为使用的是摘要算法,无法解析,也就不可能在中途做假,我们携带的信息就无法被篡改。
三部分之间都是用英文句点来连接的。
1. 现在我们来看一来看头部分,用base64解析一下。
左边是解析之后的部分,起哦门可以看到其中包含算法方式信息。这个信息在解析的时候会很有用,系统会根据这个信息使用相同的算法来验证签名。
2. 我们来看看有效载荷部分解析之后的样子
有效载荷就是第二部分,解析之后我们可以看到我们曾经添加的东西,主题、权限、用户名、签发人,有效时间都在里面。我们可以解析出用户名,在分布式服务中直接使用,权限列表也可以直接在security中进行控制。
3. 我们调用一下解析接口,看看是否成功解析到我们想要的数据。我们把刚才得到的token传进去,获取用户名
http://localhost:6005/api/parseToken?token=eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJqd3RfdGVzdCIsInBlcm1pc3Npb25zIjpbIlJPTEVfQURNSU4iLCJST0xFX1VTRVIiXSwiaXNzIjoiY2hyaXMiLCJleHAiOjE1NzA2NzkxNDIsImlhdCI6MTU3MDY3ODU0MiwianRpIjoiMjk1MzI2N2QtNTNiZS00N2Y2LWFjNTYtMjI4M2FjMDFkNWQ4IiwidXNlcm5hbWUiOiJ6aGFuZ3NhbiJ9.LT9ngTFW8BARFnBmK-enzz37fjRc39sELFPKUXjDw2g&field=username
猜猜结果是什么?
{
"timestamp": "2019-10-10T04:04:42.682+0000",
"status": 500,
"error": "Internal Server Error",
"message": "JWT expired at 2019-10-10T11:45:42Z. Current time: 2019-10-10T12:04:42Z, a difference of 1140679 milliseconds. Allowed clock skew: 0 milliseconds.",
"path": "/api/parseToken"
}
提示这个token已经过期失效了。因为我是边 测试边编辑,10分钟的有效时间已经过了。这也刚好检验了一下我们的jwt对过期检查的逻辑。我们重新请求一个token来检测解析。
http://localhost:6005/api/parseToken?token=eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJqd3RfdGVzdCIsInBlcm1pc3Npb25zIjpbIlJPTEVfQURNSU4iLCJST0xFX1VTRVIiXSwiaXNzIjoiY2hyaXMiLCJleHAiOjE1NzA2ODExMDYsImlhdCI6MTU3MDY4MDUwNiwianRpIjoiMTNiNDAxZTktZTNkMC00YTZjLWEyZTItYTc3YWQzMzQyYmIwIiwidXNlcm5hbWUiOiJ6aGFuZ3NhbiJ9.XqDfNclsygF4nxp5pHToIEGOAx5rQhcl8oBXFvvVQc4&field=username
看看PostMan请求的结果:
我们成功解析获取到我们的用户名: zhangsan。
八、说明
jwt自身携带用户信息,在分布式服务中可以直接验证和提取用户信息,省去了再到用户账号中心进行检验的麻烦。加上合理的设置有效时间,也能基本做到和用户信息的同步。不过由于解析的时候要用到构建时的签名,构建和解析时需要使用相同的指纹secret,而这个指纹也是jwt安全的关键所在,一旦丢失,jwt就可以在前端被伪造出来。所以一定要保密,或者要以非常安全的方式来管理secret。
本例中构建和解析都在一起。分布式中构建则存在于签发服务中,解析在业务服务中,是分开的。只需要把TokenUtils.java中关于解析和过期检验的逻辑搬过去即可,切记解析使用的类(如果有,如User)和指纹secret要一致。