基于SpringBoot 3.0.1 WebFlux的CLI工具OAuth2 SSO实现咨询
Spring Boot CLI 集成Keycloak OAuth2 公开客户端实现浏览器SSO
针对你的需求,完全不需要Selenium,Spring Security OAuth2客户端模块已经提供了支撑授权码流程(带PKCE)的能力,结合临时Web服务器接收回调,就能实现浏览器触发SSO、CLI获取令牌的流程。以下是具体实现方案:
核心思路
- 采用OAuth2授权码流程+PKCE:因为是公开客户端(CLI无法安全存储客户端密钥),PKCE是必须的,防止授权码被拦截。
- 临时Web服务器:CLI启动后若未认证,启动一个临时WebFlux服务器,接收Keycloak的认证回调。
- 令牌本地存储:将获取的访问令牌、刷新令牌存储在本地文件,下次启动时优先校验令牌有效性,无需重复认证。
- 令牌自动刷新:令牌过期时用刷新令牌获取新令牌,失败则重新触发浏览器认证。
步骤1:Keycloak客户端配置
在Keycloak后台创建公开客户端:
- 客户端类型:
公开 - 授权流程:启用
授权码流程 - 重定向URI:配置为
http://localhost:8081/login/oauth2/code/keycloak(可自定义端口,需和CLI代码一致) - 高级设置:启用
PKCE
步骤2:依赖配置(pom.xml)
<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-oauth2-client</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-webflux</artifactId> </dependency> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> </dependency> </dependencies>
步骤3:应用配置(application.yml)
spring: security: oauth2: client: registration: keycloak: client-id: your-cli-client-id authorization-grant-type: authorization_code scope: openid,profile,email provider: keycloak: issuer-uri: https://your-keycloak-domain/auth/realms/your-realm
步骤4:核心代码实现
令牌存储工具类
负责本地文件的令牌读写与有效性校验:
@Component public class TokenStorage { private static final String TOKEN_DIR = System.getProperty("user.home") + "/.sampleclitool"; private static final String TOKEN_FILE = TOKEN_DIR + "/tokens.json"; private final ObjectMapper objectMapper = new ObjectMapper(); public void saveTokens(OAuth2AccessTokenResponse tokenResponse) { File dir = new File(TOKEN_DIR); if (!dir.exists()) dir.mkdirs(); try (FileWriter writer = new FileWriter(TOKEN_FILE)) { objectMapper.writeValue(writer, tokenResponse); } catch (IOException e) { throw new RuntimeException("令牌存储失败", e); } } public OAuth2AccessTokenResponse loadTokens() { File file = new File(TOKEN_FILE); if (!file.exists()) return null; try (FileReader reader = new FileReader(file)) { return objectMapper.readValue(reader, OAuth2AccessTokenResponse.class); } catch (IOException e) { return null; } } public boolean isTokenValid(OAuth2AccessTokenResponse tokenResponse) { if (tokenResponse == null) return false; Instant expiresAt = tokenResponse.getAccessToken().getExpiresAt(); return expiresAt != null && expiresAt.isAfter(Instant.now().minusSeconds(60)); } }
OAuth2认证服务类
处理认证流程、令牌刷新、临时服务器启动:
@Component public class OAuth2AuthService { private final OAuth2AuthorizedClientManager authorizedClientManager; private final TokenStorage tokenStorage; public OAuth2AuthService(OAuth2AuthorizedClientManager authorizedClientManager, TokenStorage tokenStorage) { this.authorizedClientManager = authorizedClientManager; this.tokenStorage = tokenStorage; } public OAuth2AccessToken getValidToken() { OAuth2AccessTokenResponse storedTokens = tokenStorage.loadTokens(); // 校验令牌有效性 if (tokenStorage.isTokenValid(storedTokens)) { return storedTokens.getAccessToken(); } // 尝试刷新令牌 if (storedTokens != null && storedTokens.getRefreshToken() != null) { try { OAuth2RefreshTokenGrantRequest refreshReq = new OAuth2RefreshTokenGrantRequest( OAuth2ClientCredentialsClientRegistrationId.of("keycloak"), new OAuth2AuthorizedClient( ClientRegistration.withRegistrationId("keycloak").build(), null, storedTokens.getAccessToken(), storedTokens.getRefreshToken() ) ); OAuth2AuthorizedClient authorizedClient = authorizedClientManager.authorize(refreshReq); if (authorizedClient != null) { saveNewTokens(authorizedClient); return authorizedClient.getAccessToken(); } } catch (OAuth2AuthorizationException ignored) { // 刷新失败,走完整认证流程 } } // 启动授权码流程 return startAuthorizationCodeFlow(); } private OAuth2AccessToken startAuthorizationCodeFlow() { // 生成PKCE挑战 PkceGenerator pkce = new PkceGenerator(); String codeVerifier = pkce.generateCodeVerifier(); String codeChallenge = pkce.generateCodeChallenge(codeVerifier); // 获取客户端配置,构造授权URL ClientRegistration client = authorizedClientManager.getClientRegistrationRepository().findByRegistrationId("keycloak"); String authUri = UriComponentsBuilder.fromUriString(client.getProviderDetails().getAuthorizationUri()) .queryParam("client_id", client.getClientId()) .queryParam("response_type", "code") .queryParam("scope", String.join(" ", client.getScopes())) .queryParam("redirect_uri", "http://localhost:8081/login/oauth2/code/keycloak") .queryParam("code_challenge", codeChallenge) .queryParam("code_challenge_method", "S256") .toUriString(); // 打开默认浏览器 try { Desktop.getDesktop().browse(URI.create(authUri)); } catch (IOException e) { throw new RuntimeException("无法打开浏览器", e); } // 启动临时WebFlux服务器接收回调 CountDownLatch latch = new CountDownLatch(1); AtomicReference<String> authCode = new AtomicReference<>(); RouterFunction<ServerResponse> router = route() .GET("/login/oauth2/code/keycloak", req -> { authCode.set(req.queryParam("code").orElseThrow()); latch.countDown(); return ServerResponse.ok().bodyValue("认证成功,可关闭此页面"); }) .build(); HttpServer.create().host("localhost").port(8081).handle(router).bindNow(); try { if (!latch.await(5, TimeUnit.MINUTES)) { throw new RuntimeException("认证超时"); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new RuntimeException("认证被中断"); } // 交换授权码为令牌 OAuth2AuthorizationCodeGrantRequest tokenReq = new OAuth2AuthorizationCodeGrantRequest( client, new OAuth2AuthorizationExchange( OAuth2AuthorizationRequest.from(client).redirectUri("http://localhost:8081/login/oauth2/code/keycloak").build(), OAuth2AuthorizationResponse.success(authCode.get()).build() ), codeVerifier ); OAuth2AuthorizedClient authorizedClient = authorizedClientManager.authorize(tokenReq); if (authorizedClient == null) { throw new RuntimeException("获取令牌失败"); } saveNewTokens(authorizedClient); return authorizedClient.getAccessToken(); } private void saveNewTokens(OAuth2AuthorizedClient client) { OAuth2AccessTokenResponse tokenResponse = OAuth2AccessTokenResponse.withToken(client.getAccessToken().getTokenValue()) .tokenType(client.getAccessToken().getTokenType()) .expiresIn(client.getAccessToken().getExpiresAt().getEpochSecond() - Instant.now().getEpochSecond()) .refreshToken(client.getRefreshToken().getTokenValue()) .build(); tokenStorage.saveTokens(tokenResponse); } // PKCE生成工具 private static class PkceGenerator { public String generateCodeVerifier() { SecureRandom random = new SecureRandom(); byte[] bytes = new byte[32]; random.nextBytes(bytes); return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); } public String generateCodeChallenge(String verifier) { try { MessageDigest digest = MessageDigest.getInstance("SHA-256"); byte[] hash = digest.digest(verifier.getBytes(StandardCharsets.UTF_8)); return Base64.getUrlEncoder().withoutPadding().encodeToString(hash); } catch (NoSuchAlgorithmException e) { throw new RuntimeException(e); } } } }
CLI主逻辑(CommandLineRunner)
处理命令输入、认证触发、微服务调用:
@Component public class SampleCliRunner implements CommandLineRunner { private final OAuth2AuthService authService; private final WebClient.Builder webClientBuilder; public SampleCliRunner(OAuth2AuthService authService, WebClient.Builder webClientBuilder) { this.authService = authService; this.webClientBuilder = webClientBuilder; } @Override public void run(String... args) { if (args.length == 0 || !"dosomething".equals(args[0])) { System.out.println("使用方式:sampleclitool dosomething"); return; } System.out.println("... 重定向至SSO... 等待访问令牌..."); OAuth2AccessToken token = authService.getValidToken(); String userName = extractUserName(token); System.out.printf("... %s已登录。\n", userName); System.out.println("... 执行操作!"); callProtectedMicroservice(token); } private String extractUserName(OAuth2AccessToken token) { JwtDecoder decoder = JwtDecoder.withIssuerLocation("https://your-keycloak-domain/auth/realms/your-realm").build(); Jwt jwt = decoder.decode(token.getTokenValue()); return jwt.getClaimAsString("preferred_username"); } private void callProtectedMicroservice(OAuth2AccessToken token) { webClientBuilder.build() .get() .uri("https://your-microservice/api/protected") .header(HttpHeaders.AUTHORIZATION, "Bearer " + token.getTokenValue()) .retrieve() .bodyToMono(String.class) .doOnSuccess(res -> System.out.println("微服务响应:" + res)) .doOnError(err -> System.err.println("调用失败:" + err.getMessage())) .block(); } }
关键注意事项
- 动态端口优化:可以用
ServerSocket(0)获取空闲端口,动态构造重定向URI,避免端口冲突。 - 令牌加密存储:示例中明文存储令牌,生产环境需加密(比如用Jasypt或自定义加密逻辑)。
- 异常处理:补充用户取消认证、网络异常、Keycloak配置错误等场景的异常捕获与提示。
- Spring Security版本兼容:Spring Boot 3.0.x对应Spring Security 6.0.x,API与旧版本有差异,需注意类的导入路径。
内容的提问来源于stack exchange,提问作者Pezetter
相关产品推荐
相关产品推荐

