一篇文章讲明白Cookie、session、token、JWT(持续更新)

209 阅读8分钟

Cookie、session、token

为什么要有Cookie、session、token?

http是无状态的:客户端与服务端会话完成后,服务端不会保存会话信息。
浏览器怎么知道是谁在登录他?通过认证,证明你是你自己。

互联网中的认证:

  1. 用户名密码登录
  2. 邮箱发送登录链接
  3. 手机号接收验证码
  4. 只要你能收到邮箱/验证码,就默认你是账号的主人

提到认证再说说授权

授权:用户授予第三方应用访问该用户某些资源的权限。
例如APP询问授予权限,小程序授予权限等。
实现认证和授权的前提就是需要一种证书标记访问者的身份,这就是凭证。

例如我们的居民身份证,能证明持有人的身份。
例如网站有游客模式和登录模式,游客模式可以游览,但是想发言的话只能登录,获得令牌,令牌就是一个凭证。

什么是cookie?

cookie存储在客户端,请求后,服务器发送回浏览器,浏览器在下次向同一服务器发送请求时,作为参数携带,并发送到服务器上。
cookie是一个具体的东西,指的是浏览器实现的一种数据存储功能。 cookie是不可跨域的,每个cookie绑定单一的域名。cookie是name=value键值对。

什么是session?

session是基于cookie实现的,session存储在服务器端,sessionID 会被存储到客户端的cookie中。
浏览器第二次访问服务器时,服务端从cookie中获取sessionID,再查找对应的session信息。如果找到说明还是此用户登录。
session把用户信息临时保存在服务器上,session的缺陷是:如果web服务器做了负载均衡,那么下次发起请求到另一台服务器时,session会丢失。

sessionID是连接cookie和session的一道桥梁。

cookie和session概念区别?

session 存储在服务器端,cookie 存储在客户端。
cookie只能存储字符串数据,其他类型数据都会被转换为字符串。session可以存储任意数据类型。
cookie可长时间保持,session失效时间较短。
单个cookie保存的数据不能超过4k,session远高于cookie。

什么是token?

token:访问资源接口(API)时所需要的资源凭证。
token代替的就是sessionID的位置。

token身份验证过程:

  1. 客户端通过用户名和密码发送请求。
  2. 服务端验证用户信息是否存在。
  3. 服务端将登录凭证(用户名、密码等)做数字签名得到token给客户端。
  4. 客户端储存token,用于再次发送请求放入请求头。
  5. 服务端验证token,进行解密和签名认证,并返回数据。

存储token的是cookie或localStorage,token被放到http的header里。
用解析token的时间换取session的存储空间,减轻服务器的压力,减少频繁查询数据库。

session和token概念区别?

session保存在服务端,token保存在客户端。 session使服务端有状态化,可以记录会话信息。token使服务端无状态化,不能记录会话信息。
作为身份认证的话,token比session好。(大量的sessionID存储比较困难)

如果你的用户数据可能需要和第三方共享,或者允许第三方调用 API 接口,用 token。
对于非浏览器的客户端或手机移动端不适用,因为session依赖于cookie,而移动端没有cookie。
前后端分离系统中不适用session,后端部署复杂,前端发送的请求往往经过多个中间件到达后端,cookie中关于session的信息会转发多次。

项目中实现token

使用JWT(Json Web Token)

JWT的优势是:JWT数据量小,传输速度很快。由于JWT是以JSON加密形式保存在客户端的,所以JWT可以跨语言。

JWT结构:标头(Header)、有效载荷(Payload)和签名(Signature)。在传输时会将JWT的三部分分别进行Base64编码后连接形成最终的字符串,它的算法是这样的:

JWTString=Base64(Header).Base64(Payload).HMACSHA256(base64UrlEncode(header)+"."+base64UrlEncode(payload),secret)

了解一下其组成参数

Header

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

alg表示签名使用的算法,默认为HMAC SHA256,typ表示令牌的类型,JWT令牌统一写为JWT。

Payload

{
  "userName": "zhangsan",
  "dept": "safeAI",
  "userId": 3
}

放入我们自定义的私有字段,比如用户信息数据。

需要注意一点:默认情况下JWT是未加密的,因为只是采用Base64算法,拿到JWT字符串后可以转换回原本的JSON数据,任何人都可以解读其内容,因此不要构建隐私信息字段,比如用户的密码一定不能保存到JWT中,以防止信息泄露。JWT只是适合在网络中传输一些非敏感的信息。

Signature 对上面两部分数据签名,需要使用Base64编码后的header和payload数据,通过指定算法生成哈希,保证数据不会被篡改。
首先需要指定一个密钥(secret),该密钥仅仅保存在服务器中,不能向用户公开。然后使用HMAC SHA256算法(Payload里指定)根据以下公式生成签名。

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

计算出签名哈希后,JWT头、有效载荷和签名哈希三个部分组成一个字符串,每个部分用.分隔,构成整个JWT对象。 例如:eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJkZXB0IjoiaWt1buWQjuaPtOS8miIsInVzZXJOYW1lIjoi5byg5LiJIiwiZXhwIjoxNjY1NjMwMjc1LCJ1c2VySWQiOiIzIn0.Oy82soyC8JGNFUzlZsZEC17Srxb6nokeBQHlonlxxkE

服务端收到JWT后,怎么处理呢?
Header和Payload可以直接用Base64解码出原文,可以从Header获取哈希签名算法,从Payload获取有效数据。
Signature使用的是SHA256这种不可逆的算法,无法解码成原码,它的作用是检验token有没有被篡改。
服务端获取header的加密算法后,利用该算法加上密钥secretKey对header、payload进行加密,比较加密后的数据和客户端发来的是否一致。secretKey对于MD5的摘要算法,代表的是盐值。

在Java中使用JWT

我们给JWT的加密与解密方法封装成工具类

先导入JWT的依赖

<dependency>
            <groupId>com.auth0</groupId>
            <artifactId>java-jwt</artifactId>
            <version>3.19.0</version>
</dependency>

写一个JWT的工具类,比如JWTHelper
然后就可以写数字签名生成token的静态方法了。

我们想把什么参数写进token,就传参进去,这就是JWT加密。

public static String sign(String userName, String dept, Integer userId, String secret, long time) {
    Date date = new Date(System.currentTimeMillis() + time);
    Algorithm algorithm = Algorithm.HMAC256(secret);
    String userIdString = null;
    if (userId != null) {
        userIdString = userId.toString();
    }
    return JWT.create().withClaim("userName", userName)
            .withClaim("dept", dept)
            .withClaim("userId", userIdString)
            .withExpiresAt(date).sign(algorithm);
}

比如这里,我们就把用户名、部门、用户id的信息,外加时间戳和数字签名通过JWT生成token。

下面是JWT解密。

我们创建一个UserInfoDTO类接收拿到的个人信息。

@Data
public class UserInfoDTO {

    private String userName;
    private String deptName;
    private Integer userId;
    
}
public static UserInfoDTO getInfo(String token) {
    try {
        DecodedJWT jwt = JWT.decode(token);
        UserInfoDTO userInfoDTO = new UserInfoDTO();
        userInfoDTO.setUserName(jwt.getClaim("userName").asString());
        userInfoDTO.setDeptName(jwt.getClaim("deptName").asString());
        String userId = jwt.getClaim("userId").asString();
        if (StringUtils.isNotBlank(userId)) {
            userInfoDTO.setUserId(Integer.valueOf(userId));
        }
        return userInfoDTO;
    } catch (JWTDecodeException e) {
        return null;
    }
}

当然,我们可以检验一下数字签名,看看token是否被人篡改了。

public static DecodedJWT decode(String token){
    JWTVerifier jwtVerifier = JWT.require(Algorithm.HMAC256(CommonContext.TOKEN_SECRET)).build();
    DecodedJWT decodedJWT = jwtVerifier.verify(token);
    return decodedJWT;
}

我们写一个测试方法

@Test
public void test02() {

//        加密
    String token = JWTHelper.sign("张三", "ikun后援会", 3, CommonContext.TOKEN_SECRET, 30000);
    System.out.println(token);

//        解密
    UserInfoDTO userInfoDTO = JWTHelper.getInfo(token);
    System.out.println(userInfoDTO.toString());

//        校验
    DecodedJWT decode = JWTHelper.decode(token);
    System.out.println(token.equals(decode.getToken()));
}

得到的结果是

eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJkZXB0TmFtZSI6ImlrdW7lkI7mj7TkvJoiLCJ1c2VyTmFtZSI6IuW8oOS4iSIsImV4cCI6MTY2NTY1MDY2NCwidXNlcklkIjoiMyJ9.ORNDmXHKhF7qQZaLuwlfVQ_kwgE5sivWd3d-5tibmYo
UserInfoDTO(userName=张三, deptName=ikun后援会, userId=3)
true

所以我们在之后,可以先判断一下token有无被篡改,如果无,再返回具体信息。

在实际开发中,可以使用如下流程做登录。

  1. 在登录验证通过后,给用户生成一个对应的随机token(这个token不是指jwt,可以用uuid等算法生成),然后将这个token作为key的一部分,用户信息作为value存入Redis,并设置过期时间,这个过期时间就是登录失效的时间。
  2. 将第1步中生成的随机token作为JWT的payload生成JWT字符串返回给前端。
  3. 前端之后每次请求都在请求头中的Authorization字段中携带JWT字符串。
  4. 后端定义一个拦截器,每次收到前端请求时,都先从请求头中的Authorization字段中取出JWT字符串并进行验证,验证通过后解析出payload中的随机token,然后再用这个随机token得到key,从Redis中获取用户信息,如果能获取到就说明用户已经登录。

如何保证JWT的安全性?

  1. 因为JWT是在请求头中传递的,所以为了避免网络劫持,推荐使用HTTPS来传输,更加安全。
  2. JWT的哈希签名的密钥是存放在服务端的,所以只要服务器不被攻破,理论上JWT是安全的。因此要保证服务器的安全。
  3. JWT可以使用暴力穷举来破解,所以为了应对这种破解方式,可以定期更换服务端的哈希签名密钥(相当于盐值)。这样可以保证等破解结果出来了,你的密钥也已经换了。