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

一、起源
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"));
   }
}
View Code

 

 

三、符合 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

posted @ 2022-12-19 16:23  明媚下雨天  阅读(157)  评论(0)    收藏  举报