浅析认证授权的三种方式(session、token、JWT)

4,759 阅读7分钟

基本概念

认证

认证(authentication),指的是验证访问者的身份。

常用的认证方式有:

  • 用户名密码认证
  • 短信验证码认证
  • 邮件验证码认证
  • 扫码认证
  • 人脸识别/指纹识别等生物因素认证

授权

授权(authorization),指的是用户通过身份认证之后,能够访问指定的某些资源。

常见的授权模型有3种:

  1. ACL(Access Control List):ACL中包含用户、资源、资源操作三个关键要素。通过将资源以及资源操作授权给用户而使得用户具有操作某资源的权限。
  2. RBAC(Role-Based Access Control ):对用户进行角色分类,通过角色来判断用户是否有对某资源的操作权限。
  3. ABAC(Attribute Base Access Control):基于计算一个或一组属性是否满足某条件来判断用户是否有操作权限。

鉴权凭证

在我们首次用用户名密码登录了某系统之后,服务端会颁发一个凭证,用于表示用户已经认证通过。之后的请求用户只需要携带上这个凭证来表明自己的身份即可。

所以,一般流程就是:

  1. 用户通过用户名密码(或者其他认证方式)请求服务端,认证通过之后得到凭证。
  2. 用户在之后的请求中均携带上这个凭证,表明自己的身份。
  3. 服务端根据用户的身份,给用户相应的操作权限。例如每个用户只能访问自己聊天记录,管理员有权把用户移除某群聊等。

鉴权凭证

本文中,根据鉴权凭证的存储方式,将其分成3类

在不同的文章中,可能分类与定义并不相同,边界比较模糊。例如有些文章中的token是通过data + signature组成的,认为JWT是token的一种实现方式;有些文章提到可以将session存储在数据库中,以实现分布式。但不论如何,大体上都分成3种方案,单机的、集中存储的和无需存储的。无需存储的方案,并非只有JWT,我们也可以模仿JWT的方式,自己设计鉴权凭证的结构。

一、单机的session方案

session是一种记录客户端状态的机制。可以简单地将服务端存储理解为一个Map:Key是sessionID,Value是一个session。而一个session也是一个Map,我们可以往里面存想存的东西。

  1. 客户端带上账号与密码进行认证
  2. 服务端生成一个session用于保存用户当前的状态,并返回一个 sessionID
  3. 服务端下次请求时带上这个sessionID,服务端用这个sessionID去查对应的session,判断当前用户的状态
  4. 这个凭证的有效期是“会话期间”,所以在关闭浏览器之后,就会自动失效

在仅用session机制进行用户认证的情况下,携带这个凭证在请求服务端时,必须使用HTTPS协议,避免凭证被黑客窃取而冒充用户身份

服务端代码示例

@Controller
public class Hello {

    @ResponseBody
    @RequestMapping(value = "login", method = RequestMethod.POST)
    public String login(HttpServletRequest request, @RequestBody JSONObject requestBody) {
        String username = requestBody.getString("u");
        String password = requestBody.getString("p");

        if ("user".equals(username) && "123456".equals(password)) {
            // 若认证通过则置 pass = true
            HttpSession session = request.getSession();
            session.setAttribute("pass", true);
            return "Login success!";
        } else {
            return "Login failed!";
        }
    }

    @ResponseBody
    @RequestMapping("visit")
    public String index(HttpServletRequest request) {
        HttpSession session = request.getSession();
        Boolean pass = (Boolean) session.getAttribute("pass");
        if (pass != null && pass) {
            // 只有认证通过,才记录访问次数
            long time;
            if (session.getAttribute("time") == null) {
                time = 0;
                session.setAttribute("time", 1);
                System.out.println("[start]");
            } else {
                time = Integer.parseInt(session.getAttribute("time").toString());
                session.setAttribute("time", time + 1);
                System.out.println(time);
            }
            return String.format("No.%d!", time + 1);
        } else {
            // 认证不通过,返回没有权限
            return "No Auth!";
        }
    }
}

客户端访问示例

  1. 客户端先登录,并得到 sessionID

\

  1. 客户端访问 /visit 接口,服务端通过查询 session 可以知道该会话的状态

二、需要存储的token方案

上面的session方案有一个弊端,就是session是存储在内存当中的。在分布式场景之下,数据无法共享。这种情况下,使用分布式缓存(如Redis)或者数据库(如MySQL)来存储Token就非常实用。这种做法的思路很简单,就是在分布式系统中,集中存储鉴权凭证

  1. 客户端带上用户名密码认证
  2. 认证通过后,服务端生成token字符串,存储于集中式存储中,并返回token给客户端
  3. 之后的客户端访问,携带上uid与颁发的token
  4. 服务端通过uid去集中式存储中查询token与create_time,用于确认token是否有效、是否过期

三、无需存储的JWT方案

在服务访问量逐步增大时,我们从单机服务扩展至分布式服务。所以鉴权凭证方案从session发展至需要存储的token方案。但随着访问量的进一步提升,缓存与数据库由于容量限制与读写速度限制,已经无法满足了。随后JWT的方案被提出。

JSON Web Token(简称 JWT)是目前最流行的跨域认证解决方案。官网介绍JSON Web Token 入门教程

他的主要思路就是,在凭证中直接写明用户的信息,并使用服务端秘钥对用户信息进行加密,生成一个签名。客户端请求时,同时发送用户信息和秘钥,如果服务端验证发送的用户信息加密的结果等于发送的秘钥的话,说明是认证通过的。

生成JWT

package com.bin;

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;

import org.apache.commons.codec.binary.Base64;

public class JWT {
    public static void main(String[] args) throws NoSuchAlgorithmException, InvalidKeyException {
        // 1. 生成 header
        String header = "{"alg":"HS256","typ":"JWT"}";
        String JWT_header = Base64.encodeBase64URLSafeString(header.getBytes());
        System.out.println("[JWT_header] " + JWT_header);

        // 2. 生成 payload
        String payload = "{"uid":"12345","name":"ImZero","iat":1643891024}";
        String JWT_payload = Base64.encodeBase64URLSafeString(payload.getBytes());
        System.out.println("[JWT_payload] " + JWT_payload);

        // 3. 生成秘钥
        String hp = JWT_header + "." + JWT_payload;
        String secret = "ImSecret";
        Mac sha256 = Mac.getInstance("HmacSha256");
        SecretKeySpec key = new SecretKeySpec(secret.getBytes(), "HmacSha256");
        sha256.init(key);
        String JWT_sign = Base64.encodeBase64URLSafeString(sha256.doFinal(hp.getBytes()));

        System.out.println("[JWT_sign] " + JWT_sign);

        String JWT = JWT_header + "." + JWT_payload + "." + JWT_sign;
        System.out.println("[FINAL] " + JWT);


        // 4. 与官网生成的JWT作对比,看看是否正确
        String resFromWeb = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9." +
                "eyJ1aWQiOiIxMjM0NSIsIm5hbWUiOiJJbVplcm8iLCJpYXQiOjE2NDM4OTEwMjR9." +
                "fkT1D1G_P9dB7y2JJc84HdiqLEfVfZ6WxF19sFC1HR0";

        System.out.println("对比结果:" + resFromWeb.equals(JWT));
    }
}

主要步骤如下:

  1. 在 header中约定加密的算法
  2. 在 payload中放入用户的信息
  3. 对 header + payload进行加密,形成 signature
  4. 拼凑 header + payload + signature的base64URL编码,发送给客户端

这样一来,服务端不需要存储任何鉴权凭证。可以直接通过客户端发过来的JWT,来获取用户身份与鉴权信息。而通过秘钥加密header与payload,可以防止用户信息被篡改,例如把name改成ImNotZero。

验证JWT

package com.bin;

import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import org.apache.commons.codec.binary.Base64;

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.security.Timestamp;


public class CheckJWT {

    public static void main(String[] args) throws NoSuchAlgorithmException, InvalidKeyException {
        String jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1aWQiOiIxMjM0NSIsIm5hbWUiOiJJbVplcm8iLCJpYXQiOjE2NDM4OTEwMjR9" +
                ".fkT1D1G_P9dB7y2JJc84HdiqLEfVfZ6WxF19sFC1HR0";

        String[] partition = jwt.split("\.");

        // 1. 得到 header
        String header = new String(Base64.decodeBase64(partition[0]));
        System.out.println("header " + header);
        JSONObject h = JSONObject.parseObject(header);
        String algorithm = h.get("alg").toString();
        System.out.println("algorithm " + algorithm);

        // 2. 得到 payload
        String payload = new String(Base64.decodeBase64(partition[1]));
        System.out.println("payload " + payload);


        JSONObject p = JSONObject.parseObject(payload);
        long create_time = Long.parseLong(p.get("iat").toString());

        // 3. 根据header 和 payload,计算签名
        String calculateSign = "";
        if ("HS256".equals(algorithm)) {
            String head_payload = partition[0] + "." + partition[1];

            String secret = "ImSecret";
            Mac sha256 = Mac.getInstance("HmacSha256");
            SecretKeySpec key = new SecretKeySpec(secret.getBytes(), "HmacSha256");
            sha256.init(key);
            calculateSign = Base64.encodeBase64URLSafeString(sha256.doFinal(head_payload.getBytes()));
        }
        
        long cur = System.currentTimeMillis() / 1000; // 换算成秒
        if (calculateSign.equals(partition[2]) &&  cur - create_time <= 60 * 60 * 24) { // 有效期是 24h
            System.out.println("验证通过!");
        } else {
            System.out.println("验证不通过!");
        }
    }
}

与sessionID和token一样,JWT是不能被泄漏的。泄漏之后,黑客可以冒充用户身份进行操作。

方案比较

image.png

其他

Cookie

在很多地方,会对 Cookie 和 session 进行比较,但在我看来其实两者关系并不是非常大。

Cookie实际上是一小段的文本信息。在客户端访问服务端时,服务端可以通过 Set-Cookie 向客户端设置Cookie(上面的流程图中有画出来),客户端将其保存起来。当客户端之后请求该网站时,会带上这个Cookie去请求。例如session方案中,JSESSIONID是存在Cookie中的,我们的token和JWT既可以存在Cookie中,也可以存在Local Storage中。

Cookie在客户端是由浏览器来管理的,用户可以自己设置是否启用浏览器的Cookie功能;浏览器来保证百度的Cookie不会被携带着去访问淘宝;浏览器来保证用户的Cookie不会被泄漏(某些安全攻击除外)。

参考

傻傻分不清之 Cookie、Session、Token、JWT
前端鉴权的兄弟们:cookie、session、token、jwt、单点登录


你的点赞和关注是对我最大的鼓励!
我的往期文章:
浅析如何保证数据库与缓存一致性