MeteorCat / JWT权限验证

Created Tue, 05 Dec 2023 22:42:06 +0800 Modified Wed, 29 Oct 2025 23:25:00 +0800
2264 Words

权限认证

采用 SpringBoot3 拦截其权限认证, 来做到接口安全保密防护, 在日常授权当中是以类似 authorization: xxx 来做凭证提交, 以下类似:

  • Authorization: Basic xxx: 基本认证头
  • Authorization: Bearer xxx: JWT认证头
  • Authorization: Digest xxx: MD5 哈希的 http-basic 认证(目前已弃用)
  • Authorization: AWS4-HMAC-SHA256 xxx: 相对更加复杂的 HMAC-SHA256 加密方式

具体实现: Authorization

常见来说 Authorization: Basic 方式最简单和常见, 包括 Nginx 默认的 auth_basic 配置就是基于这种方式, 但是安全方面也是最差的.

注意: Authorization: Basic 的用户名和密码的值可以容易地编码和解码, 所以除非相对简单场景否则不要把敏感信息写入.

Authorization: Bearer 则是最近兴起必须基于 HTTPS 进行授权通信方式, 利用 HTTPS+JWT 来加密授权凭据, 目前这种方式相对采用比较多.

JWT

这是目前相对广泛采用的 Bearer 加密方案, 如果是 api 授权可以推荐用此方式进行返回 token 授权.

官方说明: JSON Web Tokens

JWT 的加密格式为 xxx(Header头部).yyy(Payload负载).zzz(Signature签名), 这三个部分都常规来说都需要 base64 处理最后加密:

Header头部

这里 Header 信息基本上是由 algtyp 两个字段组成, alg 是加密方式, 可选为 HMAC SHA256RSA, typ 则默认Token类型即可( JWT ):

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

注意这里的压缩数据去除换行空格紧凑之后通过 Base64Url 获取到 Header 的编码部分:

# 这个就是首个部分
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9

# 先得出一部分
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.yyy(Payload负载).zzz(Signature签名)

Base64URL 的算法和传统 Base64 有所差别, 为了保证有的参数可以被放置在 GET 推送的时候(如 api.com/?token=test, 内部 =+/ 等不能出现 ), 则需要处理下:

Base64URL中对他们做了替换:"="去掉, "+"用"-"替换, "/"用"_"替换, 这就是Base64URL算法

Payload负载

这里就是追加需要传输的格式字段, 官方内部有预定义某些字段, 这些字段并非强制而是推荐:

  • iss(issuer): 颁发机构
  • exp(expiration time): 到期时间
  • sub(subject): 主题内容
  • aud(audience): 授权主体

这里采用三个字母是为了保证 JWT 紧凑结构, 节约传输的数据内容, 但是日常规范可以按照自己需求定制处理:

{
  "uid": 10001,
  "username": "MeteorCat",
  "level": 0,
  "exp": 1234567890
}

这里按照 Base64Url 再次编码得出, 注意这里面是能够反解密出来的, 所以敏感信息前往别放置其中, uid字段如果敏感可以直接不加入只加入 username:

# 得出第二部分数据
eyJ1aWQiOjEwMDAxLCJ1c2VybmFtZSI6Ik1ldGVvckNhdCIsImxldmVsIjowLCJleHAiOjEyMzQ1Njc4OTB9

# 填充第二部分
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1aWQiOjEwMDAxLCJ1c2VybmFtZSI6Ik1ldGVvckNhdCIsImxldmVsIjowLCJleHAiOjEyMzQ1Njc4OTB9.zzz(Signature签名)

Signature签名

这里就是最后关键的加密签名, 主要加密方法如下:

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

按照之前生成数据签名得出最后的验证数据:

HMACSHA256(
    base64UrlEncode(eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9) + "." +
    base64UrlEncode(eyJ1aWQiOjEwMDAxLCJ1c2VybmFtZSI6Ik1ldGVvckNhdCIsImxldmVsIjowLCJleHAiOjEyMzQ1Njc4OTB9),
    meteorcat-secret
)

# 最后得出的签名
lPOjOi4UV4M8fjcHcqpBB-ipn6RztY9eU2pB0sEn_F4

# 最终的登录凭据
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1aWQiOjEwMDAxLCJ1c2VybmFtZSI6Ik1ldGVvckNhdCIsImxldmVsIjowLCJleHAiOjEyMzQ1Njc4OTB9.lPOjOi4UV4M8fjcHcqpBB-ipn6RztY9eU2pB0sEn_F4

这里的 HMACSHA256 哈希方法在不同语言都有不同实现:

# PHP
$hash = hash_hmac('sha256', $string, $secret);
$hash = encode($hash);


# Java
Mac sha256_HMAC = Mac.getInstance("HmacSHA256");
SecretKeySpec secret_key = new SecretKeySpec(key.getBytes("UTF-8"), "HmacSHA256");
sha256_HMAC.init(secret_key);
byte[] array = sha256_HMAC.doFinal(data.getBytes("UTF-8"));
StringBuilder sb = new StringBuilder();
for (byte item : array) {
   sb.append(Integer.toHexString((item & 0xFF) | 0x100).substring(1, 3));
}
sb.toString().toUpperCase();

具体各种语言算法库都有携带, 参阅下语言算法库就行了, 官方还提供了自主验签工具.

验签工具: 官方工具

工作过程

这里传输流程主要放置在 Authorization 字段值中:

Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1aWQiOjEwMDAxLCJ1c2VybmFtZSI6Ik1ldGVvckNhdCIsImxldmVsIjowLCJleHAiOjEyMzQ1Njc4OTB9.lPOjOi4UV4M8fjcHcqpBB-ipn6RztY9eU2pB0sEn_F4

也可以考虑客户端放置在 Cookie 或者 localStorage 之中当作单个玩家的用户凭据, 同时在服务端识别 exp 的超时时间.

SpringBoot实现

首先需要配置 security 组件依赖:

<dependencies>
    <!-- SpringBoot3需要额外引入, 因为内部改变实现 -->
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-api</artifactId>
        <version>0.11.5</version>
    </dependency>


    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-impl</artifactId>
        <version>0.11.5</version>
        <optional>true</optional>
        <scope>runtime</scope>
    </dependency>


    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-jackson</artifactId>
        <version>0.11.5</version>
        <optional>true</optional>
        <scope>runtime</scope>
    </dependency>
</dependencies>

jjwt 相对用的人比较多, 而且功能也比较简单定制也方便, 之后就是原生生成 token 方法:

package com.app.fox;

import io.jsonwebtoken.JwtBuilder;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.io.Encoders;
import io.jsonwebtoken.security.Keys;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;


import java.nio.charset.StandardCharsets;
import java.security.Key;
import java.util.Base64;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;

@SpringBootTest
class FoxApplicationTests {


    @Test
    void jwtEncode() {
        // 编码登录的数据信息
        Map<String, Object> claims = new HashMap<>();
        claims.put("client", "Android");
        claims.put("ip","127.0.0.1");

        // 构建 secret, 新版本要求 Key 由内部自己生成, 因为自定义生成可能有异常问题
        // 设置加密方式, 新版本推荐采用 HS512 加密
        Key key = Keys.secretKeyFor(SignatureAlgorithm.HS512);
        String secret = Encoders.BASE64.encode(key.getEncoded());
        System.out.printf("SecretKey: %s\r\n",secret);


        // 构建器生成
        JwtBuilder builder = Jwts
                .builder()
                // 合并数据集, 数据集方便扩展多出来的字段信息
                .setClaims(claims)
                // 颁发者,这个可有可无一般是直接 访问url 或者作者名写入
                .setIssuer("https://www.meteorcat.net")
                // 追加用户名和ID, 注意如果用户名是单一的可以考虑放置单独字段, 如果字段名是 username+platform 这种复合唯一则需要放置 Claims
                .setId(String.valueOf(100001))
                .setSubject("MeteorCat")
                // 设置签发时间
                .setIssuedAt(new Date())
                // 设置超时时间, 假设30天超时
                .setExpiration(new Date(System.currentTimeMillis() + 30 * 86400 * 1000L))
                .signWith(key);


        // 生成最后的token
        String token = builder.compact();
        System.out.printf("JWT: %s\r\n", token);
    }

}

注意: 新版本 JWT 规范当中主要怕用户自定义 secret 带有非法/异常字符等问题, 所以现在生成都是有内部定义处理生成.

最后得出的结果, 最后结果和官方工具对比验证是匹配的:

SecretKey: 0SjQdlEabIUUqm5xoNb60mcE08LsM3DTNL2+I4zM7+gLNq42wLztHJgrBVqC70P5N7TezjNlfULJ/Rdg5/vB7w==
JWT: eyJhbGciOiJIUzUxMiJ9.eyJpcCI6IjEyNy4wLjAuMSIsImNsaWVudCI6IkFuZHJvaWQiLCJpc3MiOiJodHRwczovL3d3dy5tZXRlb3JjYXQubmV0IiwianRpIjoiMTAwMDAxIiwic3ViIjoiTWV0ZW9yQ2F0IiwiaWF0IjoxNzAxNzY0MTk2LCJleHAiOjE3MDQzNTYxOTZ9.0TKj8kGQtMSyIn7S137ja7B4kUp5-ErhveeXU_31VoblL8IWxtD_Yityg3C7wnn1bTBlWUjTr10ZAfryhSpb2A

和官方验证完成之后就是在代码里面的实现, 颁发给授权用户之后就是用户提交授权验证处理:

package com.app.fox;

import io.jsonwebtoken.*;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.io.Encoders;
import io.jsonwebtoken.security.Keys;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;

import javax.crypto.spec.SecretKeySpec;
import java.security.Key;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;

@SpringBootTest
class FoxApplicationTests {


    @Test
    void jwtDecode() {
        String jwtToken = "eyJhbGciOiJIUzUxMiJ9.eyJpcCI6IjEyNy4wLjAuMSIsImNsaWVudCI6IkFuZHJvaWQiLCJpc3MiOiJodHRwczovL3d3dy5tZXRlb3JjYXQubmV0IiwianRpIjoiMTAwMDAxIiwic3ViIjoiTWV0ZW9yQ2F0IiwiaWF0IjoxNzAxNzY0MTk2LCJleHAiOjE3MDQzNTYxOTZ9.0TKj8kGQtMSyIn7S137ja7B4kUp5-ErhveeXU_31VoblL8IWxtD_Yityg3C7wnn1bTBlWUjTr10ZAfryhSpb2A";
        String secret = "0SjQdlEabIUUqm5xoNb60mcE08LsM3DTNL2+I4zM7+gLNq42wLztHJgrBVqC70P5N7TezjNlfULJ/Rdg5/vB7w==";
        Key key = new SecretKeySpec(Decoders.BASE64.decode(secret), SignatureAlgorithm.HS512.getJcaName());

        
        // 以下代码注意包裹异常, 如果异常就是说明直接验签不过直接不允许授权访问
        // 创建数据解析器, 注意这个解析器初始化会导致线程阻塞, 所以最好 @AutoConfiguration+@Bean 全局构建好处理
        JwtParser parser = Jwts.parserBuilder()
                // 传递 secret
                .setSigningKey(key)
                .build();

        // 解析token内容
        Jws<Claims> params = parser.parseClaimsJws(jwtToken);
        Claims data = params.getBody();
        System.out.printf("Claims: %s\r\n", data);

        // 验证如果没有异常代表验签完成, 可以直接读取信息
        String uid = data.getId();
        String username = data.getSubject();
        System.out.printf("UID:%s | Username: %s", uid, username);
    }
}

这里还有个知识点就是单点登录的实现, 有的后台登录都是允许被多个不同浏览器登录相同账号, 基础授权 Token 只负责验签功能而不做多余操作, 所以需要再次通过其他方案验证登录唯一性, 这里用以下方法来处理:

  • Redis缓存: 通过在 redis 追加 login:10001 = token 标识登录记录, 每次验签完成之后需要去查询 Redis 匹配的 uid 内部 token 是否一致, 不一致需要重新授权系统.
  • 应用缓存: 这里采用静态语言特性, 直接在本身应用生成 HashMap 全局对象, 和 Redis 方式差不多储存登录记录, 但是不适合多机负载情况因为启动应用内存是没办法跨进程判断的.