You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

基于SpringBoot 3.0.1 WebFlux的CLI工具OAuth2 SSO实现咨询

Spring Boot CLI 集成Keycloak OAuth2 公开客户端实现浏览器SSO

针对你的需求,完全不需要Selenium,Spring Security OAuth2客户端模块已经提供了支撑授权码流程(带PKCE)的能力,结合临时Web服务器接收回调,就能实现浏览器触发SSO、CLI获取令牌的流程。以下是具体实现方案:

核心思路

  1. 采用OAuth2授权码流程+PKCE:因为是公开客户端(CLI无法安全存储客户端密钥),PKCE是必须的,防止授权码被拦截。
  2. 临时Web服务器:CLI启动后若未认证,启动一个临时WebFlux服务器,接收Keycloak的认证回调。
  3. 令牌本地存储:将获取的访问令牌、刷新令牌存储在本地文件,下次启动时优先校验令牌有效性,无需重复认证。
  4. 令牌自动刷新:令牌过期时用刷新令牌获取新令牌,失败则重新触发浏览器认证。

步骤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();
    }
}

关键注意事项

  1. 动态端口优化:可以用ServerSocket(0)获取空闲端口,动态构造重定向URI,避免端口冲突。
  2. 令牌加密存储:示例中明文存储令牌,生产环境需加密(比如用Jasypt或自定义加密逻辑)。
  3. 异常处理:补充用户取消认证、网络异常、Keycloak配置错误等场景的异常捕获与提示。
  4. Spring Security版本兼容:Spring Boot 3.0.x对应Spring Security 6.0.x,API与旧版本有差异,需注意类的导入路径。

内容的提问来源于stack exchange,提问作者Pezetter

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.03 16:11:13