likes
comments
collection
share

Spring Boot(十三):集成 JWT 设计 API 接口安全服务

作者站长头像
站长
· 阅读数 19

JSON Web Token(缩写 JWT)是目前最流行的跨域认证解决方案,本文来认识下 JWT 并通过实践将其集成到 Spring Boot 项目中完成 API 接口安全服务的设计。

认识 JWT

Spring Boot(十三):集成 JWT 设计 API 接口安全服务

跨域认证的问题

互联网服务离不开用户认证,而谈及用户认证就避免不了谈谈基于 Session 认证的方式了。

这种模式的问题在于,扩展性不好。单机当然没有问题,如果是服务器集群,或者是跨域的服务导向架构,就要求 session 数据共享,每台服务器都能够读取 session。

基于 session 认证所显露的问题

  • Session: 每个用户经过我们的应用认证之后,我们的应用都要在服务端做一次记录,以方便用户下次请求的鉴别,通常而言 session 都是保存在内存中,而随着认证用户的增多,服务端的开销会明显增大。
  • 扩展性: 用户认证之后,服务端做认证记录,如果认证的记录被保存在内存中的话,这意味着用户下次请求还必须要请求在这台服务器上,这样才能拿到授权的资源,这样在分布式的应用上,相应的限制了负载均衡器的能力。这也意味着限制了应用的扩展能力。
  • CSRF: 因为是基于 cookie 来进行用户识别的,cookie 如果被截获,用户就会很容易受到跨站请求伪造 (CSRF) 的攻击。

🎯单点登录(SSO)问题

举例来说,A 网站和 B 网站是同一家公司的关联服务。现在要求,用户只要在其中一个网站登录,再访问另一个网站就会自动登录,请问怎么实现?

一种解决方案是 session 数据持久化,写入数据库或别的持久层【例如使用 Redis 集群】。各种服务收到请求后,都向持久层请求数据。这种方案的优点是架构清晰,缺点是工程量比较大。另外,持久层万一挂了,就会单点失败。

Spring Boot(十三):集成 JWT 设计 API 接口安全服务

另一种方案是服务器索性不保存 session 数据了,所有数据都保存在客户端,每次请求都发回服务器。JWT 就是这种方案的一个代表。

Spring Boot(十三):集成 JWT 设计 API 接口安全服务

为什么需要 JWT 呢

要想了解为什么,就需要先了解 JWT 的应用场景 —— 基于 Web 开发领域的身份验证。

HTTP 是无状态协议

我们知道 HTTP 是无状态协议,所以我们如果想让服务器知道我们是谁,并且根据之前我的信息简化我本次的操作的话,那么就需要服务器和客户端进行配合来实现 “有状态”

如果不太理解,我们做一个类比。

我们去餐厅吃饭,哪怕我们每天都去,那边的服务员都无法记住我们昨天吃了什么,如果你跟他说 “服务员上菜,和昨天的一样,记到我帐上”,他做不到。他对任何人的服务态度、服务方式都是一样的,他既不会记得你曾经吃过什么,也不会知道你的账单是哪个,更不会去找你要账

这个场景用到我们 Web 开发领域就是 HTTP 协议,他只负责传输,既没有历史记录(你昨天吃了什么)也没有账户密码(你的账单),只要你访问它就根据你的 URL 进行处理,处理完返回结果。你再次访问,他就再次返回。这就是无状态。

基于 Session 的身份验证方式

如果我们想让它更智能就需要做一些额外的事情。

由于 HTTP 无法记录我们的任何状态,那就必须由服务器来记录了。

还是刚刚那个例子,如果服务员记性不好,我们就要在餐厅建立会员机制,餐厅给我们一个会员编号来区分不同的会员,餐厅根据这个编号记录每个会员卡的消费情况、账单情况。每次我们只需要给服务员会员编号他就可以获取到我们的消费信息了。

在 Web 开发领域,就是 CookieSession 的关系,在我首次访问站点的时候,我们的服务器发送给浏览器一个 Cookie,浏览器记录了一个 Cookie 存储我们的 sessionID,通过这个 sessionID 可以在服务器找到一个 Session,里面可以记录各种自定义信息。

Spring Boot(十三):集成 JWT 设计 API 接口安全服务

如图所示,Cookie 存储在浏览器,根据站点域名进行划分,不同域名的 Cookie 一般情况下是不会互相混用的(关于cookie的详细机制请自行百度)。

这种传统的 Session 方式就是用户保留会员编号,然后由餐厅记录个人信息的方式。

这里所言过程的就是经典的 Session 机制的身份验证。

基于 JWT 的身份验证方式

了解了上面的 Session 机制,我们再理解 JWT 就变的特别简单。

我们需要在服务端存储为登录的用户生成的 Session ,这些 Session 可能会存储在内存,磁盘,或者数据库里。我们还需要在服务端定期的去清理过期的 Session

用户有很多,服务器对每个用户都记录的话,对服务器的压力会比较大

JWT 机制的出现恰好就弥补了这个不足。

还是以刚才餐厅会员为例,这次餐厅不给我们会员编号,而是直接给了我们一张会员卡 —— 卡中可以记录用户的一些信息,当我们拿卡去餐厅的时候,服务员一刷卡就可以获取我们的信息。

回到 Web开发领域,就是 Cookie 里面记录的内容的变化,Cookie 里面直接记录我们的具体消费信息,服务器拿到 Cookie 直接可以获得我们的相应信息,不再需要自行记录,也不需要查询,只需要“解码”和“验证”。

下图我们对比 Session 机制和 JWT 机制中 Cookie 存储内容的不同:

Spring Boot(十三):集成 JWT 设计 API 接口安全服务

  1. 第一条记录是 Session 机制:Cookie 只记录了 session 的 id,服务器获取到 cookie 之后需要根据这个 cookie 获取到对应的 session,然后在 session 里面获取用户信息。
  2. 第二条记录是 JWT 机制:在 cookie 里面存储更多信息,直接记录我们的具体的消息(下文详细讲解 JWT 的结构),服务器获取到 Cookie 之后只要解码也就获取这些信息,而不需要去查询数据库

回到 JWT 机制,服务器为了防止用户篡改数据,服务器在生成这个对象的时候,会加上签名(Signature)。服务器就不保存任何 Session 数据了。这样就简化了服务器端架构的设计:

  1. 此时服务器变成无状态了,从而比较容易实现扩展。
  2. 将原本服务器的存储成本转移到客户端存储,从而缓解了数据存储、管理的压力

从整体来看,JWT 机制的引入,其实是 去中心化 的一种具体实现,将原本服务器的存储成本转移到客户端存储,从而简介了服务器的 Session 管理设计,也让处理效率变得高效。

JWT 的原理

JWT 的原理是:服务器认证以后,生成一个 JSON 对象,发回给用户,就像下面这样。

{
  "姓名": "张三",
  "角色": "管理员",
  "到期时间": "2022年1月10日12点10分"
}

以后,用户与服务端通信的时候,都要发回这个 JSON 对象。服务器完全只靠这个对象认定用户身份。为了防止用户篡改数据,服务器在生成这个对象的时候,会加上签名(详见后文)。

服务器就不保存任何 session 数据了,也就是说,服务器变成无状态了,从而比较容易实现扩展。

JWT 的数据结构

实际的 JWT 大概就像下面这样。

Spring Boot(十三):集成 JWT 设计 API 接口安全服务

它是一个很长的字符串,中间用点(.)分隔成三个部分。注意,JWT 内部是没有换行的,这里只是为了便于展示,将它写成了几行。

JWT 的三个部分依次如下。

  • Header(头部)
  • Payload(负载)
  • Signature(签名)

写成一行,就是下面的样子。

Header.Payload.Signature

Spring Boot(十三):集成 JWT 设计 API 接口安全服务

如果不太好理解的话,以日常生活中 开货车 为例来对比:

  • Header 相当于你货车的 车牌
  • Payload 相当于你货车所拉的 货物
  • Signature 就你驾驶员的 驾驶证/行驶证

这么一类比是否就清晰多了?下面依次详细介绍这三个部分。

Header

Header 是一个 JSON 对象,描述 JWT 的元数据,通常是如下例子:

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

其中:

  • typ:表示这个令牌(token)的类型(type),在 JWT 协议里没得选,只能是 JWT
  • alg:表示你后面你在 Signature 部分所使用的加密算法。

常用的算法有 HMAC SHA256RSA,完整的算法类型我从官方上截了个图:

Spring Boot(十三):集成 JWT 设计 API 接口安全服务

最后,将上面的 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):编号

除了官方字段,你还可以在这个部分定义私有字段(Private claims),下面就是一个例子。

{
  "sub": "1234567890",
  "name": "John Doe",
  "admin": true
}

注意,这部分默认是不加密的是不加密的是不加密的(重要的话多说几遍),任何人都可以读到,所以不要把你敏感信息明文放在这个部分(除非你把内容先自行加密过)。

这个 JSON 对象也要使用 Base64URL 算法转成字符串。

Signature

Signature 部分是对前两部分的签名,防止数据篡改。

首先,需要指定一个密钥(secret)。这个密钥只有服务器才知道,不能泄露给用户。然后,使用 Header 里面指定的签名算法(默认是 HMAC SHA256),按照下面的公式产生签名。

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

这个算出的签名就是上面所述的 Signature 字符串了。

算出签名以后,把 HeaderPayloadSignature 三个部分拼成一个字符串,每个部分之间用"点"(.)分隔,至此你就获得了一个 JWT —— 是不是简单到令你窒息?!

拓展: Base64URL

前面提到,HeaderPayload 串型化的算法是 Base64URL。这个算法跟 Base64 算法基本类似,但有一些小的不同。

JWT 作为一个令牌(token),有些场合可能会放到 URL(比如 api.example.com/?token=xxx)。Base64 有三个字符+/=,在 URL 里面有特殊含义,所以要被替换掉:=被省略、+替换成-/替换成_

这就是 Base64URL 算法。

⭐JWT 使用 Base64 编码,注意这不是加密,只是把 JWT 的 json 格式去除,变成更加紧凑的形式

如果觉得陌生的话,jwt.io 官网提供了实时的生成工具,可自行前往体验:jwt.io/#debugger-i…

Spring Boot(十三):集成 JWT 设计 API 接口安全服务

简要说明 JWT 的使用方式

客户端收到服务器返回的 JWT,可以储存在 Cookie 里面,也可以储存在 localStorage

此后,客户端每次与服务器通信,都要带上这个 JWT。你可以把它放在 Cookie 里面自动发送,但是这样不能跨域,所以更好的做法是放在 HTTP 请求的头信息Authorization字段里面。

Authorization: Bearer <token>

⭐另一种做法是,跨域的时候,JWT 就放在 POST 请求的数据体里面。

常用的 JWT 的身份验证架构

通常基于 Token 的身份验证方法,在服务端不需要存储用户的登录记录,常用身份验证的架构流程如下:

Spring Boot(十三):集成 JWT 设计 API 接口安全服务

如图所示,存在3个角色:authentication server (登录/授权服务器)user(用户)app server (应用服务器)

  1. 用户通过授权服务器的登录系统去登录,授权服务器把 JWT 传给用户;
  2. 用户客户端收到 Token 以后可以把它存储起来,比如放在 Cookie 里或者 localStorage 里
  3. 用户访问应用服务器的 API 时,带上 JWT;
  4. 服务端收到请求,然后去验证客户端请求里面带着的 Token,如果验证成功,就向客户端返回请求的数据。

在这个过程中,只有身份验证服务器和应用服务器知道秘钥是什么。如果身份验证服务器和应用服务器完全独立,则应用服务器的 JWT 校验工作也可以交由认证服务器完成(因此 JWT 也适合做单点登录功能)

可以看到,这是一套无状态的验证机制,不必在内存中保存用户状态。用户访问时自带 JWT,无需像传统应用使用 Session,应用可以做到更多的解耦和扩展。同时,JWT 还可以保存用户的数据,减少数据库访问

JWT 的优势与劣势

⭐优势

使用 JWT 保护应用安全,至少可以获得以下优势:

  1. 更少的数据库连接:因其基于算法来实现身份认证,在使用 JWT 时查询数据的次数更少(更少的数据连接不等于不连接数据库),降低服务器查询数据库的次数,可以获得更快的系统响应时间。
  2. 构建更简单:如果应用程序本身是无状态的,那么选择 JWT 可以加快系统构建过程。
  3. 跨服务调用:可以构建一个认证中心来处理用户身份认证和发放签名的工作,其他应用服务在后续的用户请求中 (理论上) 不需要再询问认证中心,可使用自有的公钥对用户签名进行验证。
  4. 无状态:不需要向传统的 Web 应用那样将用户状态保存于 Session 中。

⭐劣势

任何技术框架都有 自身的局限性,不可能一劳永逸,JWT 也不例外。它存在以下劣势:

  1. 严重依赖于秘钥JWT 的生成与解析过程都需要依赖于秘钥(Secret),且都以硬编码的方式存在于系统中(有放在外部配置文件中的)。如果秘钥不小心泄露,系统的安全性将受到威胁。
  2. JWT 的最大缺点是无法作废已颁布的令牌:由于服务器不保存 session 状态,因此无法在使用过程中废止某个 token,或者更改 token 的权限。也就是说,一旦 JWT 签发了,在到期之前就会始终有效,除非服务器部署额外的逻辑。
  3. 服务端无法主动推送消息:服务端由于是无状态的,将无法使用像 Session 那样的方式推送消息到客户端,例如过期时间将至,服务端无法主动为用户续约,需要客户端向服务端发起续约请求。
  4. 冗余的数据开销:一个 JWT 签名的大小要远比一个 SessionId 长很多,如果对有效载荷(payload)中的数据不做有效控制,其长度会成几何倍数增长,且在每一次请求时都需要负担额外的网络开销。如果放在 localStorage则可能受到 XSS 攻击

安全风险控制

考虑这样一个问题:如果客户端的 JWT 令牌泄露或者被盗取,会发生什么严重的后果?有什么补救措施?

首先我们看一下使用 JWT 可能带来的风险:

使用 JWT 带来的风险

  • 如果单纯依靠 JWT 解决用户认证的所有问题,那么系统的安全性将是脆弱的。
  • 由于 JWT 令牌存储于客户端中,一旦客户端存储的令牌发生泄露事件或者被攻击,攻击者就可以轻而易举的伪造用户身份去 修改/删除 系统资源。
  • 虽然 JWT 自带过期时间,但在过期之前,攻击者可以肆无忌惮的操作系统数据。通过算法来校验用户身份合法性是 JWT 的优势,也是最大的弊端 —— 太过于依赖算法。反观传统的用户认证措施,通常会包含多种组合,如手机验证码,人脸识别,语音识别,指纹锁等
  • 用户名和密码只做用户身份识别使用,当用户名和密码泄露后,在遇到敏感操作时(如新增,修改,删除,下载,上传),都会采用其他方式对用户的合法性进行验证(如发送验证码,邮箱验证码,指纹信息等)以确保数据安全。

总而言之,与传统的身份验证方式相比,JWT 过多的依赖于算法,缺乏灵活性,而且服务端往往是被动执行用户身份验证操作,无法及时对异常用户进行隔离(这是最为根本的特征,这是考试重点,可以做笔记了)。

其实不管是基于 Session 还是基于 JWT,一旦密令被盗取,都是一件棘手的事情。下面介绍 JWT 发生令牌泄露是该采取什么样的措施(包含但不局限于此)。

风险控制手段建议

为了防止用户 JWT 令牌泄露而威胁系统安全,可以在以下方面完善系统功能:

  • 清除已泄露的令牌:最直接也容易实现。将 JWT 令牌在服务端也存储一份,若发现有异常的令牌存在,则从服务端将此异常令牌清除。当用户发起请求时,强制用户重新进行身份验证,直至验证成功。服务端令牌的存储,可以借助 Redis 等缓存服务器进行管理,也可使用 Ehcache 将令牌信息存储在内存中。
  • 敏感操作保护:在涉及到诸如新增,修改,删除,上传,下载等敏感性操作时,定期 (30分钟,15分钟甚至更短) 检查用户身份,如手机验证码,扫描二维码等手段,确认操作者是用户本人。如果身份验证不通过,则终止请求,并要求重新验证用户身份信息。
  • 地域检查:通常用户会在一个相对固定的地理范围内访问应用程序,可以将地理位置信息作为辅助来甄别。如果发现用户 A 由经常所在的地区 1 变到了相对较远的地区 2,或者频繁在多个地区间切换,不管用户有没有可能在短时间内在多个地域活动 (一般不可能),都应当终止当前请求**,强制用户重新进行验证身份,颁发新的 JWT 令牌,并提醒 (或要求) 用户重置密码**。
  • 监控请求频率:如果 JWT 密令被盗取,攻击者或通过某些工具伪造用户身份,高频次的对系统发送请求,以套取用户数据。针对这种情况,可以监控用户在单位时间内的请求次数,当单位时间内的请求次数超出预定阈值值,则判定该用户密令是有问题的。例如 1 秒内连续超过 5 次请求,则视为用户身份非法,服务端终止请求并强制将该用户的 JWT 密令清除,然后回跳到认证中心对用户身份进行验证。
  • 客户端环境检查:对于一些移动端应用来说,可以将用户信息与设备 (手机,平板) 的机器码进行绑定,并存储于服务端中,当客户端发起请求时,可以先校验客户端的机器码与服务端的是否匹配,如果不匹配,则视为非法请求,并终止用户的后续请求。

最佳实践

当你充分了解了 JWT 的技术细节、处理的场景,那么获得一套关于 JWT 使用的最佳实践,也就水到渠成:

  1. 在使用 JWT 的时候一定要注意别携带敏感信息,令牌别暴露了。
  2. 在 Web 应用中,别把 JWT 当做 Session 使用!如果想要 Session,绝大多数情况下,传统的 Cookie - Session 机制工作得更好。
  3. JWT 适合一次性的命令认证,颁发一个有效期极短的 JWT,即使暴露了危险也很小,由于每次操作都会生成新的 JWT,因此也没必要保存 JWT,真正实现无状态。
  4. 为了减少盗用,JWT 不应该使用 HTTP 协议明码传输,要使用 HTTPS 协议传输。
  5. 当你建立一套基于 JWT 的用户验证的时候,一定要同时建立一套相对应的风控机制,确保风险发生时风险可控 & 及时止损。

Spring Boot 集成 JWT

导入依赖

<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt</artifactId>
    <version>0.9.0</version>
</dependency>

实现 Jwt 工具类

这一步需要自定义 JWT 的工具类,用于生成 token 和校验 token。

public class JwtUtil {
    // 设置 token 过期时间(单位:毫秒):目前为 60 秒
    private static final long ttl = 60 * 1000;
    // Signature 签名
    private static final String signature = "Wu-YiKun";

    public static String createToken(String username) {
        JwtBuilder jwtBuilder = Jwts.builder();

        return jwtBuilder
                .setHeaderParam("typ", "JWT")
                .setHeaderParam("alg", "HS256")
                .setId(UUID.randomUUID().toString())
                .setSubject("Access Token")
                .setExpiration(new Date(System.currentTimeMillis() + ttl))
                .setIssuer("Wu-Yikun")
                .setIssuedAt(new Date())
                .claim("userId", username)
                .claim("role", "user")
                .signWith(SignatureAlgorithm.HS256, signature)
                .compact();
    }

    public static boolean parseToken(String token) {
        if (token == null) {
            return false;
        }
        JwtParser jwtParser = Jwts.parser();
        try {
            jwtParser.setSigningKey(signature).parseClaimsJws(token);
        } catch (Exception e) {
            return false;
        }
        return true;
    }
}

业务校验并生成 token

@RestController
@RequestMapping("/user")
public class UserController {

    @Autowired
    private UserMapper userMapper;

    @PostMapping("/login")
    public String loginReturnToken(@RequestBody User user) {
        QueryWrapper<User> queryWrapper = new QueryWrapper<>();

        Map<String, String> map = new HashMap<>();
        map.put("username", user.getUsername());
        map.put("password", user.getPassword());
        queryWrapper.allEq(map);
        User selectOne = userMapper.selectOne(queryWrapper);

        if (selectOne != null) {
            // 登录验证通过、生成合法token!
            return JwtUtil.createToken(selectOne.getUsername());
        }
        return "";
    }
}

拦截器

public class AuthenticationInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        String token = request.getHeader("token");
        boolean result = JwtUtil.parseToken(token);
        return result;
    }

    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
    }
}

记得要配置拦截器:

@Configuration
public class WebMvcConfiguration implements WebMvcConfigurer {
    
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new AuthenticationInterceptor())
            .addPathPatterns("/**")
            .excludePathPatterns("/user/login");
    }
    
}

Postman 请求验证

登录获取 token

Spring Boot(十三):集成 JWT 设计 API 接口安全服务

携带 token 获取数据:

Spring Boot(十三):集成 JWT 设计 API 接口安全服务

可见携带着 token 的请求在未过期时间段内可以正常向后台发送请求获取数据。

参考文章

/ END / 生活原本沉闷,但跑起来就有风~