漫谈JSON Web Token(JWT)
一、背景
传统的单体应用基于cookie-session的身份验证流程一般是这样的:
- 用户向服务器发送账户和密码。
- 服务器验证账号密码成功后,相关数据(用户角色、登录时间等)都保存到当前会话中。
- 服务器会生成一个sessionid返回浏览器,浏览器把这个sessionid存储到cookie当中。
- 以后每次发起请求都会在请求头cookie中带上这个sessionid信息,所以服务器就是根据这个sessionid作为索引获取到具体session。
但是这种模式存在如下几个问题:
- 没有分布式架构无法支持横向扩展,例如站点A和站点B提供同一公司的相关服务。现在要求用户只需要登录其中一个网站,然后它就会自动登录到另一个网站,满足不了这种需求。
- 如果用户很多,这些信息存储在服务器内存中会给服务器增加负担。
- 还有就是CSRF(跨站伪造请求攻击)攻击,session是基于cookie进行用户识别的, cookie如果被截获,用户就会很容易受到跨站请求伪造的攻击。
针对问题1,有两种解决方案,第一种就是使用session共享,比如使用redis实现,第二种解决方案就是服务器索性不保存 session 数据了,所有数据都保存在客户端,每次请求都发回服务器。JWT 就是这种方案的一个代表。
二、什么是JWT
JSON WEB TOKEN(以下称JWT),JWT也是一种token,是为了在网络应用环境间传递声明而执行的一种基于JSON的开放标准((RFC 7519)。该token被设计为紧凑且安全的,特别适用于分布式站点的单点登录(SSO)场景。
2.1JWT的特点
简洁(Compact): 可以通过URL,POST参数或者在HTTP header发送,因为数据量小,传输速度也很快。
自包含(Self-contained): 负载中包含了所有用户所需要的信息,避免了多次查询数据库或缓存。
2.2JWT的消息结构
实际的JWT数据结构就像这样。
它是一个很长的字符串,中间用点分隔成三个部分,红色是header,紫色部分是Payload,蓝色部分是Signature。即格式都是Header.Payload.Signature。
Header(头部)
Header 部分是一个 JSON 对象,描述 JWT 的元数据,通常是下面的样子。
{
"alg": "HS256",
"typ": "JWT"
}
alg属性表示签名的算法(algorithm),默认是 HMAC SHA256(写成 HS256);typ属性表示这个令牌(token)的类型(type),JWT 令牌统一写为JWT。
最后,将上面的JSON对象使用Base64URL算法转成字符串。
Payload(负载)
Payload 部分也是一个 JSON 对象,用来存放实际需要传递的数据。JWT 规定了7个官方字段,供选用。
iss (issuer):签发人
exp (expiration time):过期时间
sub (subject):主题
aud (audience):受众
nbf (Not Before):生效时间
iat (Issued At):签发时间
jti (JWT ID):编号
除了官方字段我们还可以自定义字段,比如:
{
"userId": 1234567890,
"name": "John Doe"
}
注意,JWT 默认是不加密的,任何人都可以读到,所以不要把秘密信息放在这个部分。然后这个JSON对象也要使用Base64URL算法转成字符串。
Signature(签名)
Signature是对前两个部分的签名,作用是防止数据被篡改。
首先,需要指定一个密钥(secret)。这个密钥只有服务器才知道,不能泄露给用户。然后,使用 Header 里面指定的签名算法(默认是 HMAC SHA256),按照下面的公式产生签名。
HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload),secret)
算出签名以后,把 Header、Payload、Signature 三个部分拼成一个字符串,每个部分之间用"点"(.)分隔,就可以返回给用户。
Base64URL算法
Base64URL算法与Base64算法有一些区别,作为令牌的JWT可以放在URL中(例如api.example/?token=xxx)。 Base64中用的三个字符是"+","/"和"=",由于在URL中有特殊含义,因此Base64URL中对他们做了替换:"="去掉,"+"用"-"替换,"/"用"_" 替换,这就是Base64URL算法。
三、JWT的使用方式
客户端收到服务器返回的 JWT,可以储存在 Cookie 里面,也可以储存在 localStorage。
此后,客户端每次与服务器通信都要带上JWT。你可以把它放在 Cookie 里面自动发送,但是这样不能跨域,所以更好的做法是放在 HTTP 请求的头信息Authorization字段里面。
还可以将JWT放在POST请求的数据体中,或者跟在URL后面。
所以使用JWT实现单点登录,也可以放在Cookie中或者Header中,基于Cookie的实现可以参考这篇文章《八幅漫画理解使用 JWT设计的单点登录系统》,之前公司的项目是基于Header的Authorization字段字段实现的。
四、JWT的缺点
当然,JWT并不是完美的,它也有一些缺点。
1.JWT 的最大缺点是,由于服务器不保存 session 状态,因此无法在使用过程中废止某个 token,或者更改 token 的权限。也就是说,一旦 JWT 签发了,在到期之前就会始终有效,除非服务器部署额外的逻辑。
2.JWT本身包含了认证信息,一旦泄露,任何人都可以获得该令牌的所有权限。所以为了避免盗用,可以将有效期设置的短一些,使用HTTPS协议加密传输。
《理解JWT的使用场景和优劣》这篇文章介绍的很详细,但是我不太认同他说的使用jwt做单点登录+会话管理没有传统的cookie-session 机制工作得更好。
五、代码示例
JWT一般是在网关服务配合Filter来实现认证的,考虑再弄个Zuul网关比较麻烦这里就简化了下。
JwtTestController
/**
* Created by 2YSP on 2019/8/26.
*/
@Slf4j
@RestController
@RequestMapping("/jwt")
public class JwtTestController {
@GetMapping("/login")
public String login(@RequestParam("username") String username,
@RequestParam("password") String password, HttpServletResponse response) throws Exception {
if (!checkUserNameAndPwd(username, password)) {
return "login error:invalid username or password";
}
// 过期时间
Long exp = System.currentTimeMillis() + 20 * 1000;
PayLoad payLoad = new PayLoad(1L, username, exp);
String token = JwtUtils.generateToken(payLoad);
Cookie cookie = new Cookie("token", token);
// HttpOnly属性来防止Cookie被JavaScript读取,从而避免跨站脚本攻击
cookie.setHttpOnly(true);
// 30秒
cookie.setMaxAge(30);
response.addCookie(cookie);
return token;
}
@GetMapping("/verify")
public Boolean verifyToken(HttpServletRequest request) {
String token = getTokenFromCookie(request);
if (StringUtils.isBlank(token)){
return false;
}
// 验证签名
if (!JwtUtils.checkSignature(token)){
return false;
}
PayLoad payLoad = JwtUtils.getPayLoad(token);
if (payLoad.getExp() < System.currentTimeMillis()){
// 已过期
return false;
}
Gson gson = new Gson();
log.info("verify successfully ,payLoad:{} ", gson.toJson(payLoad));
return true;
}
private String getTokenFromCookie(HttpServletRequest request){
Cookie[] cookies = request.getCookies();
for(Cookie cookie : cookies){
if (cookie.getName().equals("token")){
return cookie.getValue();
}
}
return null;
}
private boolean checkUserNameAndPwd(String username, String pwd) {
if (StringUtils.isBlank(username)) {
return false;
}
if (StringUtils.isBlank(pwd)) {
return false;
}
if (username.equals("admin") && pwd.equals("1234")) {
return true;
}
return false;
}
}
这里提供了两个接口,一个是模拟登录(登录一般是POST的这里方便测试就改为GET方式了),登录成功后返回一个token同时将token保存在cookie中。另一个是校验token的接口,依次进行验签、过期时间等校验。
PayLoad
@Data
@AllArgsConstructor
@NoArgsConstructor
public class PayLoad {
private Long userId;
private String name;
private Long exp;
}
保存一些用户信息和过期时间的实体类。
JwtUtils
- public class JwtUtils {
- /**
- * 加密算法
- */
- public static final String HEADER_ALG = "HS256";
- public static final String HEADER_TYP = "JWT";
- /**
- * 加密串
- */
- public static final String SECRET = "d5ec0a02";
- public static String generateToken(PayLoad payLoad) throws Exception {
- Gson gson = new Gson();
- Header header = new Header(HEADER_ALG, HEADER_TYP);
- String encodedHeader = Base64Utils.encodeToUrlSafeString(gson.toJson(header).getBytes(
- Charsets.UTF_8));
- String encodePayLoad = Base64Utils.encodeToUrlSafeString(gson.toJson(payLoad).getBytes(
- Charsets.UTF_8));
- String signature = HMACSHA256(encodedHeader + "." + encodePayLoad, SECRET);
- return encodedHeader + "." + encodePayLoad + "." + signature;
- }
- public static boolean checkSignature(String token) {
- String[] array = token.split("\\.");
- if (array.length != 3) {
- throw new IllegalArgumentException("token error");
- }
- try {
- String signature = HMACSHA256(array[0] + "." + array[1], SECRET);
- return signature.equals(array[2]);
- } catch (Exception e) {
- }
- return false;
- }
- public static PayLoad getPayLoad(String token) {
- String[] array = token.split("\\.");
- if (array.length != 3) {
- throw new IllegalArgumentException("token error");
- }
- String payLoad = new String(Base64Utils.decodeFromUrlSafeString(array[1]), Charsets.UTF_8);
- Gson gson = new Gson();
- return gson.fromJson(payLoad, PayLoad.class);
- }
- public static String HMACSHA256(String data, String key) throws Exception {
- Mac sha256_HMAC = Mac.getInstance("HmacSHA256");
- SecretKeySpec secret_key = new SecretKeySpec(key.getBytes("UTF-8"), "HmacSHA256");
- sha256_HMAC.init(secret_key);
- byte[] array = sha256_HMAC.doFinal(data.getBytes("UTF-8"));
- StringBuilder sb = new StringBuilder();
- for (byte item : array) {
- sb.append(Integer.toHexString((item & 0xFF) | 0x100).substring(1, 3));
- }
- return sb.toString().toUpperCase();
- }
- static class Header {
- private String alg;
- private String typ;
- }
- }
JWT的工具类,提供了一些生成token,验证签名等方法。代码地址。