實戰:單點登錄的兩種實現方式,附源碼
最近工作有點忙,好久沒更新文章了,正好這兩天在整理單點登陸相關的文檔,今天趁著小孩睡著了??,趕緊碼一篇實戰文交差。
概念
單點登錄(Single Sign-On,SSO)是一種身份驗證服務,允許用戶使用單個標識來登錄多個應用程序或系統。如下圖所示,用戶只需要用戶名/密碼登陸一次就可以訪問系統A、系統B和系統C。
在傳統的登錄方式中,用戶必須為每個應用程序或系統提供不同的憑據和密碼。如下圖所示,用戶訪問系統A、系統B和系統C都必須用用戶名/密碼登陸。
這種方式既不方便也容易被攻擊者利用,而 SSO 解決了這個問題,使得用戶只需通過一次身份驗證就可以無縫地訪問多個應用程序或系統,從而提高了用戶體驗的便利性和安全性。
單點登陸的優點
- 用戶體驗改善:用戶只需要登錄一次,就可以訪問多個系統或應用程序,不需要重復輸入用戶名和密碼。這可以大大提高用戶的工作效率。
- 安全性增強:單點登陸可以提供更高級別的安全性,因為用戶只需要在一個系統中進行身份驗證,其他系統就可以共享這個身份驗證信息。這可以有效地防止黑客入侵多個系統。
- 管理更方便:單點登陸可以簡化管理員的工作,因為它可以集中管理用戶和權限。管理員可以在一個系統中管理多個系統的用戶和權限,這樣可以更方便地進行管理和維護。
單點登陸的實現方式
- 共享身份驗證:多個系統共享一個身份驗證系統,用戶只需要在一個系統中進行身份驗證,就可以訪問所有系統。這種方式需要建立一個共享的身份驗證系統,這樣可以保證用戶信息的安全性。
- 代理身份驗證:一個系統代表其他系統進行身份驗證,用戶在登錄時輸入用戶名和密碼,然后其他系統會代表用戶進行身份驗證。這種方式需要建立一個代理系統,這樣可以保證用戶信息的安全性。
- 基于令牌的身份驗證:用戶在登錄后,會獲得一個令牌,這個令牌可以在多個系統上進行身份驗證。這種方式需要建立一個令牌管理機制,這樣可以保證用戶信息的安全性。
實戰一
架構圖
- 用戶輸入用戶名/密碼登陸 ServiceA 系統;
- 用戶點擊 ServiceA 系統中的某個按鈕跳轉到 ServiceB 系統,在跳轉時需要帶上 ServiceA 系統頒發的 ticket 票據;
- ServiceB 系統拿 ServiceA 系統的 ticket 去獲取 ServiceA 系統的用戶信息;
- ServiceA 系統會校驗該 ticket 票據,然后將用戶信息返回給 ServiceB 系統;
- ServiceB 系統根據用戶信息生成 token 并附帶重定向地址返回給 ServiceA 系統;
- ServiceA 系統就可以拿著獲取的 token 去訪問 ServiceB 系統的資源信息了。
代碼實現
數據庫
首先是初始化數據庫,用戶、公司等表依據自己的具體業務而定,此處不再贅述。提供公共的單點登陸信息表。
CREATE TABLE `sso_client_detail` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '自增主鍵',
`platform_name` varchar(64) DEFAULT NULL COMMENT '應用名稱',
`platform_id` varchar(64) NOT NULL COMMENT '應用標識',
`platform_secret` varchar(64) NOT NULL COMMENT '應用秘鑰',
`encrypt_type` varchar(32) NOT NULL DEFAULT 'RSA' COMMENT '加密方式:AES或者RSA',
`public_key` varchar(1024) DEFAULT NULL COMMENT 'RSA加密的應用公鑰',
`sso_url` varchar(128) DEFAULT NULL COMMENT '單點登錄地址',
`remark` varchar(1024) DEFAULT NULL COMMENT '備注',
`create_date` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '創建時間',
`create_by` varchar(64) DEFAULT NULL COMMENT '創建人',
`update_date` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '更新時間',
`update_by` varchar(64) DEFAULT NULL COMMENT '更新人',
`del_flag` bit(1) NOT NULL DEFAULT b'0' COMMENT '刪除標志,0:正常;1:已刪除',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COMMENT='單點登陸信息表'
插入測試數據
INSERT INTO cheetah.sso_client_detail
(id, platform_name, platform_id, platform_secret, encrypt_type, public_key, sso_url, remark, create_date, create_by, update_date, update_by, del_flag)
VALUES(1, 'serviceA', 'A9mQUjun', 'Y6at4LexY5tguevJcKuaIioZ1vS3SDaULwOtXW63buBK4w2e1UEgrKmscjEq', 'RSA', NULL, 'http://127.0.0.1:8081/sso/url', NULL, '2023-05-23 16:55:26', 'system', '2023-05-30 13:16:16', NULL, 0);
- platform_id和platform_secret,阿Q是使用 apache 的 commons-lang3 包下的RandomStringUtils.randomAlphanumeric()方法生成的。
- sso_url就是上邊提到的 ServiceB 系統的地址。
- encrypt_type、public_key在此方式中未使用,可以忽略。
細心的阿Q還給大家準備了一個接口,只需要傳入 platformName 和 ssoUrl 就可以自動生成單點登陸信息。
接下來我們就進入真正的代碼部分了,回復“sso”即可獲取實戰源碼。
A跳轉B
/**
* com.itaq.cheetah.serviceA.controller.PortalController#jump
* title:跳轉 ServiceB
* <pre>
* 1. 前端點擊Jump鏈接觸發此接口調用
* 2. 此接口生成ticket并攜帶著請求 ServiceB
* 2.1 ServiceB拿著ticket請求我方服務獲取用戶信息
* 2.2 ServiceB獲取到我方用戶信息并進行數據同步
* 2.3 ServiceB返回一個鏈接,連接中帶 token
* 3. 重定向到返回的鏈接實現登錄
* </pre>
*
* @param req 單點跳轉請求體
* @return ServiceB單點登錄地址
*/
@PostMapping("/jumpB")
public WrapperResult<String> jump(@RequestBody @Validated SsoJumpReq req) {
log.debug("單點登錄:{}", req.getPlatformName());
//1、判斷該平臺名稱是否存在
SsoClientDetail one = iSsoClientDetailService.getOne(
new LambdaQueryWrapper<SsoClientDetail>().eq(SsoClientDetail::getPlatformName, req.getPlatformName())
);
if (Objects.isNull(one)) {
return WrapperResult.faild("不存在的app");
}
//2、校驗本系統的 token,并從中獲取用戶信息
/*
* 示例
* Result<Token> result = authorizationApi.checkToken(req.getToken());
*/
//3、生成ticket,并將用戶信息與其綁定存入redis
String ticket = UUID.randomUUID().toString().replaceAll("-", "");
UserInfo userInfo = new UserInfo();
userInfo.setId(1L);
userInfo.setUsername("阿Q");
redisTemplate.opsForValue().set(RedisConstants.TICKET_PREFIX + ticket, userInfo, 5, TimeUnit.MINUTES);
String ssoUrl = one.getSsoUrl();
Map<String, Object> data = new HashMap<>(1);
data.put("ticket", ticket);
//4、發送http請求,把ticket通過設置好的ssoUrl傳給ServiceB
WrapperResult<SsoRespDto> ssoRespDto = HttpRequest
.get(ssoUrl)
.queryMap(data)
.connectTimeout(Duration.ofSeconds(120))
.readTimeout(Duration.ofSeconds(120))
.execute()
.asValue(new TypeReference<WrapperResult<SsoRespDto>>() {
});
log.info("請求ServiceB 結果:{}", JsonUtils.toPrettyString(ssoRespDto));
return WrapperResult.success(ssoRespDto.getData().getRedirectUrl());
}
B獲取票據,并請求A獲取用戶信息
/**
* com.itaq.cheetah.serviceB.controller.SsoController#sso
* 獲取票據,并請求ServiceA 獲取用戶信息
* @param ticket 票據
* @return 返回地址供sso跳轉
* @throws JsonProcessingException 異常
*/
@GetMapping("/url")
public WrapperResult<SsoRespDto> sso(@RequestParam("ticket") String ticket) throws JsonProcessingException {
log.info("收到票據:{}", ticket);
//1.根據ticket換取ServiceA用戶信息
Map<String, Object> param = new HashMap<>(1);
param.put("ticket", ticket);
String ssoUrl = "http://localhost:8081/getUser";
String s = HttpRequest
.get(ssoUrl)
.queryMap(param)
.connectTimeout(Duration.ofSeconds(120))
.readTimeout(Duration.ofSeconds(120))
.execute()
.asString();
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
WrapperResult<SsoUserInfo> ssoUserInfoWrapperResult = objectMapper.readValue(s, new TypeReference<WrapperResult<SsoUserInfo>>() {
});
log.info("ticket登錄結果:{}", new ObjectMapper().writerWithDefaultPrettyPrinter().writeValueAsString(ssoUserInfoWrapperResult));
//2.獲取到用戶信息之后同步到本地數據庫
log.info("獲取用戶信息同步數據庫");
//3.生成token
log.info("生成token");
SsoRespDto respDto = new SsoRespDto();
//4、將ServiceA要跳轉的地址返給ServiceA并攜帶 ServiceB 的token
respDto.setRedirectUrl("http://localhost:8082/index?token=123456");
WrapperResult<SsoRespDto> success = WrapperResult.success(respDto);
log.info(new ObjectMapper().writeValueAsString(success));
return success;
}
A提供的獲取用戶信息接口
/**
* com.itaq.cheetah.serviceA.controller.PortalController#loginByTicket
* 根據票據獲取用戶信息
* @param ticket 票據信息
* @return 用戶信息
*/
@ApiOperation("根據ticket獲取用戶信息")
@GetMapping("/getUser")
public WrapperResult<SsoUserInfo> loginByTicket(@RequestParam("ticket") String ticket) {
log.info("收到票據:{}", ticket);
UserInfo userInfo = (UserInfo) redisTemplate.opsForValue().get(RedisConstants.TICKET_PREFIX + ticket);
if (Objects.isNull(userInfo)) {
return WrapperResult.faild("無法識別的票據信息");
}
//可能 userInfo 中只有少量的用戶信息,此處省略了根據用戶id查詢用戶和企業信息的過程,自行編寫邏輯代碼即可
SsoUserInfo ssoUserInfo = new SsoUserInfo();
BeanUtil.copyProperties(userInfo,ssoUserInfo);
return WrapperResult.success(ssoUserInfo);
}
測試結果
實戰二
架構圖
這次我們用 ServiceB 系統單點登陸 ServiceA 方式:
- 用戶輸入用戶名/密碼登陸 ServiceB 系統;
- 用戶點擊 ServiceB 系統中的某個按鈕跳轉到 ServiceA 系統,在跳轉時需要帶上 ServiceB 系統加密后的用戶信息;
- ServiceA 系統拿到 ServiceB 系統加密后的用戶信息后進行驗簽和解密操作;
- ServiceA 系統將用戶信息保存到本地并生成 token 返回給 ServiceB 系統;
- ServiceB 系統拿到 ServiceA 系統返回的 token 就可以訪問 ServiceA 系統的資源信息了;
代碼實現
此種方式就用到了上邊提到的數據庫中的encrypt_type、public_key字段,其中 public_key 是 ServiceA 給 ServiceB 提供的。為了演示方便直接在application.yml中進行配置。
B的配置
#本服務的appId和appSecret信息,該配置由serviceA提供
appId: A9mQUjun
appSecret: Y6at4LexY5tguevJcKuaIioZ1vS3SDaULwOtXW63buBK4w2e1UEgrKmscjEq
encrypt:
#加密方式 RSA | AES
type: RSA
#該配置是serviceA單點登陸serviceB用到的,此處是serviceB單點serviceA,所以用不到
#如果選擇非對稱加密,需要使用該配置;本服務的公私鑰信息,該配置由serviceB自己生成,并將publicKey給serviceA
rsa:
publicKey: MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0KLYE2Tv4qx/duxu8Qvq5ZN58yEjj/uwsxfs96pj+9iOOAUKLur8IIKjR/bi54GICUy0BHO6dzpWc0xqGK170F9NTv0bHe0qbh7jHgzq9MJrfcVD+XZAH17ho5tCGIo+z7CiC+rMWGTqmRopd/EQuzfx4Op4/85hoPlpKxdcxAfys0jpZ9tBMtROPsYKhCz01iDnHV2K95s4UwaQLbbx0VALVaXv1/4Yjw/PW4xK0syW/nqUtVqpfwPuX+fHf+bJ2s4kLnFBNwYAKFSU6znGmtJuq6aoxCunu2PbzI8xc7SYxHEfDqG8Zp29wtZcTJecWSDMBmywlaXjkXLzapvE7QIDAQAB
privateKey: MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDQotgTZO/irH927G7xC+rlk3nzISOP+7CzF+z3qmP72I44BQou6vwggqNH9uLngYgJTLQEc7p3OlZzTGoYrXvQX01O/Rsd7SpuHuMeDOr0wmt9xUP5dkAfXuGjm0IYij7PsKIL6sxYZOqZGil38RC7N/Hg6nj/zmGg+WkrF1zEB/KzSOln20Ey1E4+xgqELPTWIOcdXYr3mzhTBpAttvHRUAtVpe/X/hiPD89bjErSzJb+epS1Wql/A+5f58d/5snaziQucUE3BgAoVJTrOcaa0m6rpqjEK6e7Y9vMjzFztJjEcR8Oobxmnb3C1lxMl5xZIMwGbLCVpeORcvNqm8TtAgMBAAECggEBAKMhoQfRFYxMSkIHbluFcP5eyKylDbRoHOp726pvDUx/L/x3XFYBIHCfFOKRFSvk6SQ0WFFe176f27a9Wfu/sh7kVYNcflZw+YsvFXCKsy/70KZ/lr24izy8KHuPSyf6+E/WkW32Ah9fkNtzTFdfIzDv9m1hiIijq0x9l5C87KjNELnbvC0I6vwFOx0ak+JBbpaJ7IRjZxKZup7UIPvt9nbLzcbKelI83An2JUe8HNhrfWxH9UIyMOBoAY+bKCuAbUtHqSlImPiWyiCwE2/Fh7dmPSOAYYp9aZelnhd25jlR+eh4yaUoIID9ubmYVYbjcPW5SSNdfSZMfQ3oa79QeRUCgYEA6K4L+VLRiX8Dg7NCO1fM2+FTv2csTkPX6n7z/uu7kh0+wQDws+/C6Q906OtizvJBIJqFm2jPACNQCvnRixY1srgMJJlH/Rpeb4LtZGwdM1k0jAZIYQcBlGfaq3RaRI/+6+T0xdsh+7VF5A/smp/VXdK2xI3+JbLQ2wm9uN+3yZcCgYEA5Yvly7veDJYf2+8HIQkRhjWrWm1y5lCSe+HG+1ktfqnhN8YEOiPa71u0TXealL0T8EoKsqhWEjomxZ7n0jLigogz7OxxsGAE6HXAiKX0REINNYrq+1qNaqmkfLrhAJyg3JNgTSlb0xd56w7FSqOBttVL9INawGb1P98kYc5OzhsCgYBEfIY1urTGPcZxC2BhSzSXO7mEyv91ge6ZrQhwbj5lgYopEPfIXrgGFXCZ5j7NHu0ghZrx5WWYasxyjpmo0L65fgbE9wEDdLF7LRRmzJPDu2wGEwtW09MZNYBdmv++0ot8L4YEfr1/8xlBSZag5I7O8Oiu7gRyYDGtZy6are7QvQKBgQCaUZnUhOF7/rU+a4yUZf9VBeHD8k7LjaFdDWVzdvmB7P1PPJ185Lv8LN+jMORIWHD+GxjkEQ2ERXnpY7If+zuSW7Tk8/Reib7i9L7SXxc/iFRPCax9/NuTuKavgAdiHOp8P8v/M+3alS7OmuiCDDhZTT46DNDHBrCcFwzjgAo0vwKBgECBs6hEUVsYU74Uc64he8Zgkvj7wZ/yxnFlWmRRERprfBsuiY/y+DAf5ehezSRFpHXUrAkpeVXq2ydnr9BKTs6TV3AxlDMBNSndXsUYHENncR7tEHCSGRFTTu5jxdYA+k47R865Jh+2vQvPaPaXsEKSkDegvcFeUVR/yi5AsDub
#如果選擇非對稱加密,則需要使用該配置,該配置由serviceA提供
serviceA:
rsa:
publicKey: MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA2BF9EZCscKNXYADtulIDNHaMnoxV5Yu91jpv+LiWabW2EO51b8Sx8+Ei59EebM4r+SMal0k4L2Z+cNagQSP4Wvpss82/MkGO8bnAFSxS2SOKw+a+c2PxByWUxvHo4pbyYGFVWAGDXLiI+IqiO/fEFfpy6rYQzMLDnfgMFngdS4AZmRyTdMKbQs8mWqBE5nC0PoU39o/lFowfgelEjHE9vhjtTha67KhYY3n+ueuxsYdRQ40Mg7aQ0+Kt/qKoSn9yRWyx09DheFAkYl4ZCQfd0sMotLQ4BZtk0YWMNHOc1w+fL1bOumaj7AaJi6nM/VvwylLJyia2GjJIDrdTfHiOnwIDAQAB
A的配置
serviceA:
rsa:
privateKey: MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDYEX0RkKxwo1dgAO26UgM0doyejFXli73WOm/4uJZptbYQ7nVvxLHz4SLn0R5sziv5IxqXSTgvZn5w1qBBI/ha+myzzb8yQY7xucAVLFLZI4rD5r5zY/EHJZTG8ejilvJgYVVYAYNcuIj4iqI798QV+nLqthDMwsOd+AwWeB1LgBmZHJN0wptCzyZaoETmcLQ+hTf2j+UWjB+B6USMcT2+GO1OFrrsqFhjef6567Gxh1FDjQyDtpDT4q3+oqhKf3JFbLHT0OF4UCRiXhkJB93Swyi0tDgFm2TRhYw0c5zXD58vVs66ZqPsBomLqcz9W/DKUsnKJrYaMkgOt1N8eI6fAgMBAAECggEAA5f23o3rcEwnLd+WFJ08lGjMWe63lwPF+oQqTJa1Wbi9+HYe2ecJlqbN79EYknKzZIdi79U17APmYnYPYEX64Xh8yljHr0xL1lVijneYQShILI3v6PdmkNndKZnoZ6xfB59WzgnoZ2hiTs/vdtPeHQd3VdQFX4J1wnDXsp/4zMKi1fDPt7rhqWrP5W6PXcoGGKIkN9zBlqrd1RBdnKXcwfFoHcFf2ikk6g3Kn50YMRe324eiHMm8z7W34Y3iSvZYHcKBMgsDklFerw1WOGHTN61oMr+8/NTtCsy1AnCH4PrwX/ryO17mh5xNzo/ZSZRRezR92/hmwUIuOO+3FWIE4QKBgQD05wYMVlGKn1fm+sn4hn+ErC6NifXj3MkNdjs8oSHzLrYr6ea6xIvbxesZvqzqz1Fh68bHjpJPOBKwgFnl7+dLXYLNmKjry1iK0o/MMZTtrGUwMEnWHRrpmxXH6B0cnBecZUReuJ9XfKZIfd9ksHHsUY7IGv1CHcblVP/IhrpnxwKBgQDh2/n0cAh1jygGevlXGK/rxuRSlbVgtxJWLAtY8Yolf2BklSiTwmqtp7nzNn8sxRvgfQCZaLqpjC/o/wtC3Ba5b4StJQejoXkCNhVmRdLbIQ2tUxwAElPjFhWf3C5/4B6uBeLyC9izp4wTSYbNbPKxcUGkkfpPbWdHsFZOG4gSaQKBgA/me/cLF6o3ZD6j478WBGt5vmAEKAnOSONt3LS4BXtDeiJpwkg4AJiZRgVa4uEv6qm/5B0KvacVDemVu8B5DfxPqvFsSvNcNXh16U4pnfC8c6loSTL0ms21+vkKsfEslT/bN1ArDnVgq28jdQCVkB/2v51wWycSxdoX5a+AR9P7AoGAMvTwZefI4M0VmLCyBKZ7OlS7Oq6wJ0vmhS6WuNB1/JPKaacFaqDYdKl82JSZCL7H1VQeiH4KbypDvOud3M3PCrNQWcga+x35MTiGh3aFZg8FCO/RR2rbJkbbRh/lFdC420ZUt4tYrt/ESK20DjDgaIxG5RxSPw1N2ey87A5mGtECgYEAlA12yuxBb6qmG3OUSlacSfcKnxZIC3L1IMqxlXL8eG3MB4dI6QYesc3odmaxmy9csgHs+pTyLfM3yB9Ocl572OW5WcEnod5o1EIup9hxB4IG/xSECYVFHlGKfIgbd/JhWtqloYZrwx+kVX/Iw02z18R32DRqBtK4MQ3klOYH86s=
B跳轉A并加密用戶信息
/**
* com.itaq.cheetah.serviceB.controller.ToServiceAController#redirectToServiceA
* 跳轉 ServiceA 服務
*
* @return ServiceA返回的重定向鏈接
*/
@GetMapping
public WrapperResult<String> redirectToServiceA() {
//1、構建用戶信息
SsoUserInfo data = buildSsoUserInfo();
Long timestamp = System.currentTimeMillis();
String flowId = UUID.randomUUID().toString();
String businessId = "sso";
String dataEncrypt;
String encryptType = configProperties.getEncryptType();
//2、根據配置選擇哪種方式加密
switch (encryptType) {
case "AES":
AES aes = new AES(configProperties.getAppSecret().getBytes(StandardCharsets.UTF_8));
dataEncrypt = aes.encryptBase64(JsonUtils.toString(data), StandardCharsets.UTF_8);
break;
case "RSA":
RSA rsa = new RSA(AsymmetricAlgorithm.RSA_ECB_PKCS1.getValue(), null, configProperties.getServiceAPublicKey());
dataEncrypt = rsa.encryptBase64(JsonUtils.toString(data), StandardCharsets.UTF_8, KeyType.PublicKey);
break;
default:
return WrapperResult.faild("未配置加密方式");
}
//3、將以下信息進行簽名
SsoSignSource build = SsoSignSource.builder()
.platformId(configProperties.getAppId())
.platformSecret(configProperties.getAppSecret())
.businessId(businessId)
.data(dataEncrypt)
.flowId(flowId)
.timestamp(timestamp)
.build();
String sign = build.sign();
log.info("sign source={}", JsonUtils.toPrettyString(build));
//4、構建請求體
ToServiceAReq req = ToServiceAReq.builder()
.platformId(configProperties.getAppId())
.businessId("sso")
.flowId(flowId)
.timestamp(timestamp)
.sign(sign)
.data(dataEncrypt)
.build();
//5、跳轉A的操作
String s = HttpRequest.post("http://localhost:8081/serviceA")
.bodyString(JsonUtils.toString(req))
.execute()
.asString();
log.info("結果:{}", s);
return WrapperResult.success(s);
}
A獲取用戶信息后續操作
/**
* com.itaq.cheetah.serviceA.controller.ServiceAController#sso
*
* @return
*/
@PostMapping
public WrapperResult<SsoRespDto> sso(@VerifySign ToServiceAReq req) {
log.info("收到單點登錄ServiceA的請求:{}", JsonUtils.toPrettyString(req));
//同步用戶信息
//模擬登陸生成token
//返回拼接的url?token=xxx
//返回拼接的url?token=xxx
String url ="127.0.0.1:8081/index?token=xxx";
SsoRespDto ssoRespDto = new SsoRespDto();
ssoRespDto.setRedirectUrl(url);
return WrapperResult.success(ssoRespDto);
}
你可能會好奇,驗簽解密的邏輯去哪了?
此處我們通過注解的方式實現自動驗簽和解密的邏輯,至于具體的邏輯,大家可以回復“sso”獲取源碼自行解讀,當然后續阿Q還會推出新的文章進行詳細的講解,點擊關注【阿Q說代碼】進行預約吧!
測試
補充知識
本文中用到的 RSA 的密鑰是通過在線網站https://www.bchrt.com/tools/rsa/生成的,當然大家也可以使用 hutool 中的 RSA 類來生成,也可以使用 java 自帶的 security 來生成。