架构版本
Spring Boot 3.1
Spring Authorization Server 1.1.1
spring-cloud 2022.0.3
spring-cloud-alibaba 2022.0.0.0
完整代码👉watermelon-cloud
JWK 密钥生成
为什么JWK密钥对要固定生成?
目前代码中每次重启授权服务都会重新生成密钥对,Spring Authorization Server 默认的token加密就是 jwt,所以要做这个事情,如果不用jwt生成的token,自定义去扩展,要实现很多的东西,涉及到到服务也非常多,后面再单独讲讲不用jwt,自己扩展一个玩玩。
@Bean public JWKSource<SecurityContext> jwkSource() throws NoSuchAlgorithmException, IOException, InvalidKeySpecException { RSAKey rsaKey = JwtKeyUtil.generateRsa(); JWKSet jwkSet = new JWKSet(rsaKey); return (jwkSelector, securityContext) -> jwkSelector.select(jwkSet); }每次重启服务这个bean就重新实例化了,
JwtKeyUtil.generateRsa()是每次重新生成的,并不是读取指定的密钥对文件。
有这样一种场景:当我们客户端、资源服务都没有重启的情况下,授权服务器重启了,密钥对重新生成了,这个时候客户端或资源服务器到授权服务器访问/oauth2/jwks,token还是之前的token,那么密钥对都改变了,那肯定是解密失败的,这个时候就需要重新授权(生成token才行了)。
如何解决?
让它不变不就行了?
能想到有以下方案
1.将
JwtKeyUtil.generateRsa()生成的RSAKey持久化(redis)
2.生成密钥对文件,然后读取。
选择方案2
原因是jwk密钥对这样的存在就不是需要变动的,直接选择文件,简单又好维护。
生成密钥对文件
win上打开小黑窗口
生成私钥:openssl genpkey -algorithm RSA -out private_key.pem -pkeyopt rsa_keygen_bits:2048
一定是2048 ,少于2048 Spring Authorization Server 是不允许的会抛异常。
提取公钥:openssl rsa -pubout -in private_key.pem -out public_key.pem
你的小黑窗口在哪个目录下打开的,就会生成在哪个目录下,我选择的实在resource的static目录下生成的。
读取密钥对
private_key.pem里面长这样,我们只要中间的数据,将它转换为PrivateKey就可以了-----BEGIN PRIVATE KEY----- MIIEwAIBADANBgkqhkiG9w0BAQEFAASCBKowggSmAgEAAoIBAQDr2aZI9TFmeS9F 3W9OqaGoG3iw+A97BKgEaQOn/4X6PQ3UCd1VN7F5lJ242Q8RM5I8sz8Ho70k+BUi 3JwL5ADbeer25OBK2PGVFUVm8mNd6z3Qy8EEYd1OcI5SI/lfKe1findUFG/0zrby jDqYu10L/mAyPoPXAahOPJr1K4jpoaxDkb09rtuG7wkN7NnzRAbodemjNVxBybU1 BUv7MloT/jGXly+7YXA6FU8FuKLwWNrQ7dGn7V12gSQ6wBsyraYm1b8dZS2StjC6 8Lec7klEJCVjEhTGakG3pfltl4S4fyOA2t9i6pcovKJ7vx3vW5a1gYIjZQSZ41gA 9IXYAKthAgMBAAECggEBAI98L5UNTsuYCHGJwRDrVIUQiYGouMpPz+Q2+1l2tEzE XihVBAm3Q0rDZp0xuN/vLxWsuzjrncPjBgDalDkLspXT+2XPYsFGcNsRQNLbviZC Wq4vd7Mx0tDI210Ps8P4nwhUFjrZ4C7goB65v2ByBK6qSF3o+I6S3JEUf/WOUdJI NZRfW4K00jw/CSgohUEP+wvgy6b1222e+PZPsNdXeVzgRKO3tua3GtR0t9gOZ2pB 8/i/+EtD6PrtSaR4A1zBXJ6S36G0l+O4Rw+2siytUwyt3ppnu4zMaDl0po668mJm Ba5OpI69//2hSmea5IXkPQ3a70hoAY8CZyq/H5KBI0ECgYEA+3XR6ekzDuht/eWI y/zp3emejTFYm8i4u5Op7uMU8ef2Fp/hH/QMEJpzcEXLaXq0UWK1g/jTKLDHdBUo jm/GNofT8x8ewopz4mbf3EjCjWH+70udBp0RqmIurM01+NVRyVTebInmYe5HzxI7 gTmPI4Bc5QkSOW9vm6yGnSkak4kCgYEA8BuvK8MSW9u3pIcn94fyViwnN+zkgxGR gEbxVmj4A75UcBSiWj5FR5zprw0+Q3pfVuN+mNmFzYJZ5NYzwP7KsCfze9il9ffJ 8Dn/1GrvejMUMdHxzA6SbcEVk3n+rFkpVRWiMZDgT7xDEKw8dqZLTeVxX1ISku1A CpqKpV1OaxkCgYEAgALbyPt5jaZPkEhQmp/3IoxytaggVrYZLQygHselevy+L4hW n+CqX61xBP/S7LCVqTTZ+QQr4vQTpYm76r8GJe6BvKvkCd9X3TLH1amIuVbg5EsW 9i3xt05iOoABcNqP1zGIRbLyAHrAPa8nccKulsEbCVHT4D9VjueGY+1v5RkCgYEA rgeSzohELToyf9jKehoZ5qV4A4v7EJjSOgSxdaz9XlE8iEQcbIZH1qD/qzZRE72F jsezAXxgA9Vf7IHo3xCNvmImk3QyzfW8cxbGu6KKUqrlDzsZI4rITS6uwcahdS/m ylm0xnI4cvKENXhxFppvaFVN+AXXmpDFYyoiJbtcVDkCgYEArSjHW98MmSMXdEz0 XMs8TXZRVDnmm9RnQ+3d0SeXC5zZz4OZOLcx8GR9dwiTkJiK92h1rd7uvRVRD17L VcLRPomXai6ny+ExKGPkwow6MfLDmORbUX8yIMwbgy7eI8t10lc4xwo1e4NSE5/K sLHtHH4yXEOekXfHNBOUeQOUmfY= -----END PRIVATE KEY-----我也不知道怎么转,直接问chatgpt吧,或者科大讯飞的讯飞星火也可以,工具类方法这事就找他们代劳了。
public static PrivateKey getPrivateKey() throws IOException, NoSuchAlgorithmException, >InvalidKeySpecException { ClassPathResource resource = new ClassPathResource("static/private_key.pem"); byte[] privateKeyBytes = FileCopyUtils.copyToByteArray(resource.getInputStream()); String key = new String(privateKeyBytes, StandardCharsets.UTF_8); String privateStr = key .replace("-----BEGIN PRIVATE KEY-----", "") .replaceAll(System.lineSeparator(), "") .replace("-----END PRIVATE KEY-----", ""); byte[] privateKeyDecodedBytes = Base64.getDecoder().decode(privateStr); PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(privateKeyDecodedBytes); KeyFactory keyFactory = KeyFactory.getInstance("RSA"); PrivateKey privateKey = keyFactory.generatePrivate(keySpec); return privateKey; }
public_key.pem也同样找gpt代工。public static RSAPublicKey getPublicKey() throws IOException, NoSuchAlgorithmException, InvalidKeySpecException { ClassPathResource resource = new ClassPathResource("static/public_key.pem"); byte[] publicKeyBytes = FileCopyUtils.copyToByteArray(resource.getInputStream()); String key = new String(publicKeyBytes, StandardCharsets.UTF_8); String publicStr = key .replace("-----BEGIN PUBLIC KEY-----", "") .replaceAll(System.lineSeparator(), "") .replace("-----END PUBLIC KEY-----", ""); KeyFactory keyFactory = KeyFactory.getInstance("RSA"); byte[] publicKeyBase64Bytes = Base64.getDecoder().decode(publicStr); X509EncodedKeySpec publicKeySpec = new X509EncodedKeySpec(publicKeyBase64Bytes); PublicKey rsaPublicKey = keyFactory.generatePublic(publicKeySpec); return (RSAPublicKey) rsaPublicKey; }最后参考已有的封装一个 生成
RSAKey的工具方法,这样也就完成了public static RSAKey generateRsa() throws IOException, NoSuchAlgorithmException, InvalidKeySpecException { // @formatter:off return new RSAKey.Builder(getPublicKey()) .privateKey(getPrivateKey()) .build(); // @formatter:on }
替换工具类方法就完事了😁
JWKSource<SecurityContext> 最后是用在哪里的呢?
这个我们就要从token生成这里找入口了,因为token的加密是和它有关系的。
SmsAuthenticationProvider 种这样一段代码
/ ----- Access token ----- OAuth2TokenContext tokenContext = tokenContextBuilder.tokenType(OAuth2TokenType.ACCESS_TOKEN).build(); OAuth2Token generatedAccessToken = this.tokenGenerator.generate(tokenContext);
tokenGenerator.generate就是生成token的,我们跟进去能找到对应实现就是JwtGeneratorpublic final class JwtGenerator implements OAuth2TokenGenerator<Jwt> { private final JwtEncoder jwtEncoder; private OAuth2TokenCustomizer<JwtEncodingContext> jwtCustomizer; @Nullable @Override public Jwt generate(OAuth2TokenContext context) { if (this.jwtCustomizer != null) { JwtEncodingContext jwtContext = jwtContextBuilder.build(); this.jwtCustomizer.customize(jwtContext); } JwsHeader jwsHeader = jwsHeaderBuilder.build(); JwtClaimsSet claims = claimsBuilder.build(); Jwt jwt = this.jwtEncoder.encode(JwtEncoderParameters.from(jwsHeader, claims)); return jwt; } }
jwtEncoder的实现继续往下 只有一个默认实现NimbusJwtEncoderpublic final class NimbusJwtEncoder implements JwtEncoder { private final JWKSource<SecurityContext> jwkSource; /** * Constructs a {@code NimbusJwtEncoder} using the provided parameters. * @param jwkSource the {@code com.nimbusds.jose.jwk.source.JWKSource} */ public NimbusJwtEncoder(JWKSource<SecurityContext> jwkSource) { Assert.notNull(jwkSource, "jwkSource cannot be null"); this.jwkSource = jwkSource; } @Override public Jwt encode(JwtEncoderParameters parameters) throws JwtEncodingException { Assert.notNull(parameters, "parameters cannot be null"); JwsHeader headers = parameters.getJwsHeader(); if (headers == null) { headers = DEFAULT_JWS_HEADER; } JwtClaimsSet claims = parameters.getClaims(); JWK jwk = selectJwk(headers); headers = addKeyIdentifierHeadersIfNecessary(headers, jwk); String jws = serialize(headers, claims, jwk); return new Jwt(jws, claims.getIssuedAt(), claims.getExpiresAt(), headers.getHeaders(), claims.getClaims()); } private JWK selectJwk(JwsHeader headers) { List<JWK> jwks; try { JWKSelector jwkSelector = new JWKSelector(createJwkMatcher(headers)); jwks = this.jwkSource.get(jwkSelector, null); } catch (Exception ex) { throw new JwtEncodingException(String.format(ENCODING_ERROR_MESSAGE_TEMPLATE, "Failed to select a JWK signing key -> " + ex.getMessage()), ex); } if (jwks.size() > 1) { throw new JwtEncodingException(String.format(ENCODING_ERROR_MESSAGE_TEMPLATE, "Found multiple JWK signing keys for algorithm '" + headers.getAlgorithm().getName() + "'")); } if (jwks.isEmpty()) { throw new JwtEncodingException( String.format(ENCODING_ERROR_MESSAGE_TEMPLATE, "Failed to select a JWK signing key")); } return jwks.get(0); } }
NimbusJwtEncoder就用到了JWKSource<SecurityContext>,jwt生成token也是在这里完成的 ,后面如果想扩展,可以参考jwt的token生成流程。
JWT信息扩展
我们先来看看,默认的jwt中有哪些信息
{ "access_token": "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiIxODY4MjY3ODk5NSIsImF1ZCI6Im1lc3NhZ2luZy1jbGllbnQiLCJuYmYiOjE3MDA0Njg2MzksImlzcyI6Imh0dHA6Ly8xOTIuMTY4LjU2LjE6OTAwMCIsImV4cCI6MTcwMDQ2ODkzOSwiaWF0IjoxNzAwNDY4NjM5fQ.hTGxT9cmH6M3RGnYjKylu-hrdmInWQe4xpb07Ap4QNNt39LUOqA5Nz38e_txB292n-tJTFUylDuToCetMechfuHsEncBfP8Q5C3rClSuN_eSz1_IbpuOuHjlAd_P1te-a-YytbDiBp63ljntolQpCiDSA2v2VPBw-c9WjMULrM2CC3NCxXNr7VwNBkcIlEaD8xjF3lFlgKuX8uEI9Y154CCv1V6o2TAxv0UQ-tS4EJw99vj-KOQqVdFrIUEXkseRa-C2YSumL23L7LiqQb0rbAsFVmHxvctORqqdA-kAV_ORO46m8vfnNp1V9SE1CVGfSh2H6iJ2ChqqIcKuo0fHsg", "refresh_token": "3k17g-FjXQTGODkbIFQCkDzaRl7FENky4N0Nrz_Z53vuOcoN-Vet38FFas3ydpTqaTSKPBpKvN5T6M-rQ4uOH3sgZN-hBHepopDXPNqrr0nVt8mtTgLfF98sie8L8Okn", "token_type": "Bearer", "expires_in": 300 }用jwt解密工具将 access_token 解密出来看看
这点信息是不是有点少,如果我们想扩展呢,怎么玩呢?
我们默认的token生成,能找到是 JwtGenerator生成的 ,然后直接上代码吧
public class ExtOAuth2TokenJwtCustomizer implements OAuth2TokenCustomizer<JwtEncodingContext> { @Override public void customize(JwtEncodingContext context) { JwtClaimsSet.Builder claims = context.getClaims(); String tokenType = context.getTokenType().getValue(); Object principal = context.getPrincipal().getPrincipal(); if (principal instanceof DefaultOAuth2User defaultOAuth2User) { //todo 看后续是否需要根据第三方平台类型的map中属性不同再进行分别的一个转换 claims.claim(AuthorizationServerConfigurationConsent.USER_INFO_PARAMETER, defaultOAuth2User.getAttributes()); return; } // 这个 Object principal 对应的是 provider 中 DefaultOAuth2TokenContext.Builder.principal(usernamePasswordAuthenticationToken) 这个地方的对象 可以根据需要自行扩展属性 claims.claim(AuthorizationServerConfigurationConsent.USER_INFO_PARAMETER, principal); } }然后注入到容器里面即可
//jwt 信息扩展 @Bean public OAuth2TokenCustomizer<JwtEncodingContext> idTokenCustomizer() { return new ExtOAuth2TokenJwtCustomizer(); }最后来看看