权限认证
采用 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 信息基本上是由 alg 和 typ 两个字段组成, alg 是加密方式, 可选为 HMAC SHA256 或 RSA, 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方式差不多储存登录记录, 但是不适合多机负载情况因为启动应用内存是没办法跨进程判断的.