JWT & 用户身份认证演变过程

2023-03-07,,

一、起源
0、HTTP无状态

HTTP是无状态的,服务端和客户端如何保持登录状态?

工程师在服务端搞了亿点事情, 就有了下面的解决方案。

1、session认证

(1)什么是session?

服务器为了保存用户状态而创建的一个特殊的对象。

当浏览器第一次登录时,服务器创建一个session对象(该对象有一个唯一的id,一般称之为sessionId),服务器会将sessionId以cookie的方式发送给浏览器。当浏览器再次访问服务器时,会将sessionId发送过来,服务器依据sessionId就可以找到对应的session对象。

主要针对Java Web(JSP)+ Tomcat

(2)Session的销毁

为了避免Session中存储的数据过大,Session需要销毁:

超时自动销毁。

从用户最后一次访问网站开始,超过一定时间后,服务器自动销毁Session,以及保存在Session中的数据。

Tomcat 服务器默认的Session超时时间是30分钟,可以利用web.xml设置超时时间单位是分钟,设置为0表示不销毁。

<session-config>
<session-timeout>30</session-timeout>
</session-config>

调用API方法,主动销毁Session

session.invalidate();

(3)缺点
session保存在Tomcat中,一定程度上会增大服务器压力
无法解决分布式共享用户登录状态的问题
2、Session共享

(1)基本原理

使用内存缓存系统(内存数据库),将Session存储到同一内存数据库(内存数据库集群)中,所有Tomcat从内存数据库中获取Session。

(2)使用内存数据库
MemCache
Redis(NoSQL)

NoSQL仅仅是一个概念,最常见的解释是“non-relational”, “Not Only SQL”也被很多人接受。下图是常见NoSQL数据库的分类及对比

(3)新问题
MemCache无法持久化,停机后失去所有用户的登录Session,无安全机制等,可以使用Redis代替
前后端分离怎么解,部署到不同服务器,移动端、小程序
3、Token认证

(1)基本原理

主要使用Redis(单节点/集群)存储用户的登录信息,基本原理类似于session,将token(sessionId)作为key,用于登录鉴权的用户信息作为value保存到Redis中,用户登录后所有请求携带token,与后端约定好,可以放到header中,也可以是cookies(允许请求携带cookies)。

后端在处理请求前(拦截器、过滤器)获取token,然后进行登录鉴权,鉴权通过后继续api请求,失败返回token失效信息提示用户登录。

(2)优点
实现前后端分离部署,移动端、小程序,登录认证
借助Redis的expire,可以设置登录有效时长、对用户登录状态延期,跟web端cookies类似
中心化,最大优点是主动让Token失效(删除,不能设置expire为0,最小为1)

// 保存
redisTemplate.opsForValue().set(key, value, expireTime, TimeUnit.SECONDS);
// 设置过期时间 > 0
redisTemplate.expire(key, expireTime, TimeUnit.SECONDS);
// 删除
redisTemplate.delete(key);
// 是否存在key
redisTemplate.hasKey(key);
// 根据key获取
redisTemplate.opsForValue().get(key);

(3)缺点
每个需要鉴权的请求都要读取redis,会增大redis的压力
如果是分布式Redis,会增大系统复杂性
4、JWT认证

请继续往下看

二、JWT
1、介绍

JSON Web Token(JWT)是一个轻量级的认证规范,这个规范允许我们使用JWT在用户和服务器之间传递安全可靠的信息。其本质是一个token,是一种紧凑的URL安全方法,用于在网络通信的双方之间传递。

2、结构

{header_urlbase64}.{payload_urlbase64}.{signature}

header,payload,signature三个部分的字符串通过 . 连接起来。

header

描述JWT的元数据

{
"alg": "HS256",
"typ": "JWT"
}

alg:表示前面算法,默认是 HMAC SHA256(写成 HS256)

typ:表示这个令牌(token)的类型(type),JWT 令牌统一写为 JWT

payload

存放实际的数据,JWT规定了7个官方字段

iss (issuer):签发人
exp (expiration time):过期时间
sub (subject):主题
aud (audience):受众
nbf (Not Before):生效时间
iat (Issued At):签发时间
jti (JWT ID):唯一id

除了上述官方字段,这里还可以存放自定义的数据,如:

{
"exp": 1664365790511,
"tenantId": "1",
"appId": "",
"userId": "131SG161E610001",
"serverToken": "7360dbb8-067d-4339-90a4-8955921c9e65",
"refreshToken": "d2b0083f-442d-42ea-a765-3c98d95119cb",
"expiredTime": 0,
"reloginVersion": 0,
"ip": "127.0.0.1"
}

signature

对前两个字符串的签名,防止数据被篡改。签名方法如下:

HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload), secret)

secret:需要传入salt(盐值),存放在服务端

Tips

什么是base64UrlEncode?
Base64有三个字符+、/和=,在URL里面有特殊含义,所以要被替换掉:=被省略、+替换成-,/替换成_ 。

3、优点
去中心化,便于分布式系统使用
基本信息可以直接放在token中。username,nickname,role
功能权限较少的话,可以直接放在token中。用bit位表示用户所具有的功能权限(类似于价税那种)
4、缺点
服务端不能主动让token失效,这里是一个很大的安全问题,失效时间越长,越不安全

如果将过期时间设置太短,会影响用户体验

jwt token无法续期
5、来个Demo

开个玩笑

<!-- JWT maven 依赖 -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>

package com.admin.api.utils;

import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import com.admin.api.constant.Constants;
import com.alibaba.fastjson.JSONObject;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import cn.hutool.core.date.DateField;
import cn.hutool.core.date.DateUtil;
import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.ObjectUtil;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.CompressionCodecs;
import io.jsonwebtoken.Jws;
import io.jsonwebtoken.JwtBuilder;
import io.jsonwebtoken.JwtParser;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
/**
* Jwt工具类
* @author wang_dgang
* @since 2018-10-23 10:28:40
*/
public class JwtUtil { // 日志
private static final Logger log = LoggerFactory.getLogger(JwtUtil.class); // JWT 加解密类型
private static final SignatureAlgorithm JWT_ALG = SignatureAlgorithm.HS512;
/**
* 生成JWT
* @param subjectJson JSON
* @param expire 超时时间,单位秒
* @return
*/
public static String buildJWT(String subjectJson, int expire) {
// 生成JWT的时间
Date startDate = DateUtil.date();
// 过期时间
Date endDate = DateUtil.offset(startDate, DateField.SECOND, expire);
// 为payload添加各种标准声明和私有声明
JwtBuilder builder = Jwts.builder() // new一个JwtBuilder,设置jwt的body
.setId(IdUtil.simpleUUID()) // JWT的唯一标识,回避重放攻击
.setExpiration(endDate) // 过期时间
.setIssuedAt(startDate) // 签发时间
.setSubject(subjectJson) // json格式的字符串
// .compressWith(CompressionCodecs.DEFLATE) // 压缩
.signWith(JWT_ALG, generalKey()); // 设置签名算法和密钥
return builder.compact();
}
/**
* 生成JWT
* @param claims Map
* @param expire 超时时间,单位秒
* @return
*/
public static String buildJWT(Map<String, Object> claims, int expire) {
// 生成JWT的时间
Date startDate = DateUtil.date();
// 过期时间
Date endDate = DateUtil.offset(startDate, DateField.SECOND, expire);
// 为payload添加各种标准声明和私有声明
JwtBuilder builder = Jwts.builder() // new一个JwtBuilder,设置jwt的body
.setClaims(claims) // 设置payload数据,需先进行设置,会覆盖其他属性
// .setId(IdUtil.simpleUUID()) // JWT的唯一标识,回避重放攻击
.setExpiration(endDate) // 过期时间,需设置在claims之后,否则会被覆盖
// .compressWith(CompressionCodecs.DEFLATE) // 压缩
.signWith(JWT_ALG, generalKey()); // 设置签名算法和密钥
return builder.compact();
}
/**
* Jwt验证(true:验证通过,false:验证失败)
* @param jwt 内容文本
* @return
*/
public static boolean checkJWT(String jwt) {
return ObjectUtil.isNotNull(getClaimsJws(jwt));
}
/**
* parseClaimsJws
* @param jwt 内容文本
* @return
*/
private static Jws<Claims> getClaimsJws(String jwt) {
Jws<Claims> parseClaimsJws = null;
try {
SecretKey generalKey = generalKey();
JwtParser parser = Jwts.parser().setSigningKey(generalKey);
parseClaimsJws = parser.parseClaimsJws(jwt);
} catch (Exception e) {
log.warn("JWT验证失败,原因:{}", e.getMessage());
}
return parseClaimsJws;
}
/**
* 生成加密key
* @return
*/
private static SecretKey generalKey() {
return JwtSecretKeyHolder.instance;
}
private static class JwtSecretKeyHolder {
// 服务端保存的jwt秘钥转为字节数组
static final byte[] encodedKey = Constants.JWT_SECRET.getBytes();
private static final SecretKey instance = new SecretKeySpec(encodedKey, JWT_ALG.getJcaName());
}
/**
* Jwt解析
* @param jwt 内容文本
* @return Subject中json串
*/
public static String parseJWT(String jwt) {
Jws<Claims> claimsJws = getClaimsJws(jwt);
Claims body = claimsJws.getBody();
// 获取加密的内容JSON并返回
String subjectJson = body.getSubject();
return subjectJson;
}
/**
* Jwt解析
* @param jwt
* @return Claims对象,直接get获取对应值
*/
public static Claims parseJWTMap(String jwt) {
Jws<Claims> claimsJws = getClaimsJws(jwt);
Claims body = claimsJws.getBody();
return body;
} // demo
public static void main(String[] args) throws InterruptedException, RuntimeException {
// 包装payload数据
JSONObject json = new JSONObject();
json.put("name", "77hub");
json.put("userId", "131SG161E610001");
json.put("serverToken", "7360dbb8-067d-4339-90a4-8955921c9e65");
// 生成jwt
String jwtWithExpire = buildJWT(json.toJSONString(), 2);
System.out.println(jwtWithExpire);
// Thread.sleep(3 * 1000);
// 解析,获取Subject中的json串
System.out.println(parseJWT(jwtWithExpire));
System.out.println();
System.out.println("---------------------------------");
System.out.println();
// 包装payload数据
Map<String, Object> claimsMap = new HashMap<>();
claimsMap.put("name", "77hub");
claimsMap.put("userId", "131SG161E610001");
claimsMap.put("serverToken", "7360dbb8-067d-4339-90a4-8955921c9e65");
// 生成jwt
String buildJWT = buildJWT(claimsMap, 2);
System.out.println(buildJWT);
// Thread.sleep(3 * 1000);
// 解析jwt
Claims claims = parseJWTMap(buildJWT);
System.out.println(claims.get("name"));
System.out.println(claims.get("serverToken"));
}
}

三、符合 OAuth 2.0 标准的 JWT 认证
1、什么是 OAuth 标准?

OAuth(Open Authorization)协议为用户资源的授权提供了一个安全的、开放而又简易的标准。此处省略好几个字,请移步链接查看。

2、什么又是 OAuth 2.0 标准?

有两个令牌 token , 分别是 access_token 和 refresh_token

(1)access_token

访问令牌, 它是一个用来访问受保护资源的凭证。

(2)refresh_token

刷新令牌, 它是一个用来获取 access_token 的凭证,OAuth 2.0 安全最佳实践中, 推荐 refresh_token 是一次性的,使用 refresh_token 获取 access_token 时,同时会返回一个 新的 refresh_token,之前的 refresh_token 就会失效,但是两个 refresh_token 的绝对过期时间是一样的,所以不会存在 refresh_token 快过期就获取一个新的,然后重复,永不过期的情况。

注意:确保 refresh_token 安全性,OAuth2.0 引入了 client_id、client_secret 机制。即每一个应用都会被分配到一个 client_id 和一个对应的 client_secret。应用必须把 client_secret 妥善保管在服务器上,决不能泄露。刷新 access_token 时,需要验证这个 client_secret。

sha256(client_id + refresh_token + client_secret)

3、认证流程

4、具体场景

假设有一个用户需要在后台管理界面上操作6个小时。

(1)颁发一个有效性很长的 access_token,比如 6 个小时,或者可以更长,这样用户只需要刚开始登录一次,access_token 可以一直使用,直到 access_token 过期,然后重复,这种是不安全的,access_token 的时效太长,也就失去了本身的意义。

(2)颁发一个1小时有效期的 access_token,过期后重新登录授权,这样用户需要登录 6 次,安全倒是有了,但是用户体验极差。

(3)颁发1小时有效期的 access_token 和6小时有效期的 refresh_token,当 access_token 过期后(或者快要过期的时候),使用 refresh_token 获取一个新的 access_token,直到 refresh_token 过期,用户重新登录,这样整个过程中,用户只需要登录一次,用户体验好。

access_token 泄露了怎么办? 没关系,它很快就会过期。

refresh_token 泄露了怎么办? 没关系,使用 refresh_token 是需要客户端秘钥 client_secret 的。

(4)用户登录后,在后台管理页面上操作1个小时后,离开了一段时间,然后 5个小时后,回到管理页面继续操作,此时 refresh_token 有效期6个小时,一直没有过期,也就可以换取新的 access_token,用户在这个过程中,可以不用重复登录。但是在一些安全要求较高的系统中,第二次操作是需要重新登录的,即使 refresh_token 没有过期,因为中间有几个小时,用户是没有操作的,系统猜测用户已离开,并关闭会话。

5、优缺点

优点:

access_token 有效期短,被盗损失更小,安全性更高
如果 refresh_token 被盗了,想刷新 access_token的话,也需要提供过期的access_token,盗取难度增加
同时 refresh_token 只有在第一次获取和刷新,access_token 时才会在网络中传输,因此被盗的风险远小于 access_token 从而在一定程度上更安全了一点
所谓的更安全就是让盗取信息者更不容易获得而已

缺点:

开发复杂度增加,这也是一个系统到一定规模必然的情况,可以借助一些认证框架(Spring Security等)
相对的增加了安全性
6、其他

{
"exp": 1664365790511,
"tenantId": "1",
"appId": "",
"userId": "131SG161E610001",
"serverToken": "7360dbb8-067d-4339-90a4-8955921c9e65",
"refreshToken": "d2b0083f-442d-42ea-a765-3c98d95119cb",
"expiredTime": 0,
"reloginVersion": 0,
"ip": "127.0.0.1"
}

讲到这,对公司系统当前使用的JWT里面 serverToken、refreshToken 就有了一些了解了,serverToken --> access_token,refreshToken --> refresh_token。

具体后端实现还是要使用 Redis,存储一些认证相关的信息。

关于 OAuth2.0 在实际系统中使用的介绍可参照《OAuth2.0 详解》。

接着看这个博客补充

https://sunshinehu.blog.csdn.net/article/details/127526921?spm=1001.2014.3001.5502

JWT & 用户身份认证演变过程的相关教程结束。

《JWT & 用户身份认证演变过程.doc》

下载本文的Word格式文档,以方便收藏与打印。