通过 Keycloak REST API 注销用户不起作用

Logout user via Keycloak REST API doesn't work

我在从(移动)应用程序调用 Keycloak 的 logout 端点时遇到问题。

its documentation 所述支持此方案:

/realms/{realm-name}/protocol/openid-connect/logout

The logout endpoint logs out the authenticated user.

The user agent can be redirected to the endpoint, in which case the active user session is logged out. Afterward the user agent is redirected back to the application.

The endpoint can also be invoked directly by the application. To invoke this endpoint directly the refresh token needs to be included as well as the credentials required to authenticate the client.

我的请求格式如下:

POST http://localhost:8080/auth/realms/<my_realm>/protocol/openid-connect/logout
Authorization: Bearer <access_token>
Content-Type: application/x-www-form-urlencoded

refresh_token=<refresh_token>

但总是出现此错误:

HTTP/1.1 400 Bad Request
Connection: keep-alive
X-Powered-By: Undertow/1
Server: WildFly/10
Content-Type: application/json
Content-Length: 123
Date: Wed, 11 Oct 2017 12:47:08 GMT

{
  "error": "unauthorized_client",
  "error_description": "UNKNOWN_CLIENT: Client was not identified by any client authenticator"
}

如果我提供了 access_token,Keycloak 似乎无法检测到当前客户端的身份事件。我使用相同的 access_token 访问其他 Keycloak 的 API 没有任何问题,比如 userinfo (/auth/realms//protocol/openid-connect/userinfo)。

我的请求是基于此 Keycloak's issue。该问题的作者成功了,但我的情况并非如此。

我正在使用 Keycloak 3.2.1.Final

你有同样的问题吗?您知道如何解决吗?

最后,我通过查看 Keycloak 的源代码找到了解决方案:https://github.com/keycloak/keycloak/blob/9cbc335b68718443704854b1e758f8335b06c242/services/src/main/java/org/keycloak/protocol/oidc/endpoints/LogoutEndpoint.java#L169。它说:

If the client is a public client, then you must include a "client_id" form parameter.

所以我缺少的是 client_id 表单参数。我的要求应该是:

POST http://localhost:8080/auth/realms/<my_realm>/protocol/openid-connect/logout
Authorization: Bearer <access_token>
Content-Type: application/x-www-form-urlencoded

client_id=<my_client_id>&refresh_token=<refresh_token>

会话应该被正确销毁。

仅供参考:OIDC 规范和 Google 的实施具有 token revocation endpoint

它已在 Keycloak 10 中实现。有关详细信息,请参阅Keycloak JIRA

在版本 3.4 中,您需要作为 x-www-form-urlencoded 主体键 client_id、client_secret 和 refresh_token.

我用 Keycloak 4.4.0.Final 和 4.6.0.Final 试过了。我检查了 keycloak 服务器日志,在控制台输出中看到以下警告消息。

10:33:22,882 WARN  [org.keycloak.events] (default task-1) type=REFRESH_TOKEN_ERROR, realmId=master, clientId=security-admin-console, userId=null, ipAddress=127.0.0.1, error=invalid_token, grant_type=refresh_token, client_auth_method=client-secret
10:40:41,376 WARN  [org.keycloak.events] (default task-5) type=LOGOUT_ERROR, realmId=demo, clientId=eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJqYTBjX18xMHJXZi1KTEpYSGNqNEdSNWViczRmQlpGS3NpSHItbDlud2F3In0.eyJqdGkiOiI1ZTdhYzQ4Zi1mYjkyLTRkZTYtYjcxNC01MTRlMTZiMmJiNDYiLCJleHAiOjE1NDM0MDE2MDksIm5iZiI6MCwiaWF0IjoxNTQzNDAxMzA5LCJpc3MiOiJodHRwOi8vMTI3Lj, userId=null, ipAddress=127.0.0.1, error=invalid_client_credentials

那么HTTP请求是如何构建的呢?首先,我从 HttpSession 中检索了用户主体并转换为内部 Keycloak 实例类型:

KeycloakAuthenticationToken keycloakAuthenticationToken = (KeycloakAuthenticationToken) request.getUserPrincipal();
final KeycloakPrincipal keycloakPrincipal = (KeycloakPrincipal)keycloakAuthenticationToken.getPrincipal();
final RefreshableKeycloakSecurityContext context = (RefreshableKeycloakSecurityContext) keycloakPrincipal.getKeycloakSecurityContext();
final AccessToken accessToken = context.getToken();
final IDToken idToken = context.getIdToken();

其次,我创建了注销 URL,如顶部堆栈溢出答案(见上文):

final String logoutURI = idToken.getIssuer() +"/protocol/openid-connect/logout?"+
            "redirect_uri="+response.encodeRedirectURL(url.toString());

现在我会像这样构建其余的 HTTP 请求:

KeycloakRestTemplate keycloakRestTemplate = new KeycloakRestTemplate(keycloakClientRequestFactory);
HttpHeaders headers = new HttpHeaders();
headers.put("Authorization", Collections.singletonList("Bearer "+idToken.getId()));
headers.put("Content-Type", Collections.singletonList("application/x-www-form-urlencoded"));

同时构建正文内容字符串:

StringBuilder bodyContent = new StringBuilder();
bodyContent.append("client_id=").append(context.getTokenString())
            .append("&")
            .append("client_secret=").append(keycloakCredentialsSecret)
            .append("&")
            .append("user_name=").append(keycloakPrincipal.getName())
            .append("&")
            .append("user_id=").append(idToken.getId())
            .append("&")
            .append("refresh_token=").append(context.getRefreshToken())
            .append("&")
            .append("token=").append(accessToken.getId());
HttpEntity<String> entity = new HttpEntity<>(bodyContent.toString(), headers);
//   ...
ResponseEntity<String> forEntity = keycloakRestTemplate.exchange(logoutURI, HttpMethod.POST, entity, String.class); // *FAILURE*

如您所见,我尝试了多种主题变体,但我一直收到无效的用户身份验证。 哦耶。我使用 @Value

将来自 application.properties 的 keycloak 凭据秘密注入到对象实例字段中
@Value("${keycloak.credentials.secret}")
private String keycloakCredentialsSecret;

Java Spring 有安全经验的工程师有什么想法吗?

附录 我在 KC 中创建了一个名为 'demo' 的领域和一个名为 'web-portal' 的客户端 使用以下参数:

Client Protocol: openid-connect
Access Type: public
Standard Flow Enabled: On
Implicit Flow Enabled: Off
Direct Access Grants Enabled: On
Authorization Enabled: Off

这里是重建重定向 URI 的代码,我忘了把它包含在这里。

final String scheme = request.getScheme();             // http
final String serverName = request.getServerName();     // hostname.com
final int serverPort = request.getServerPort();        // 80
final String contextPath = request.getContextPath();   // /mywebapp

// Reconstruct original requesting URL
StringBuilder url = new StringBuilder();
url.append(scheme).append("://").append(serverName);

if (serverPort != 80 && serverPort != 443) {
    url.append(":").append(serverPort);
}

url.append(contextPath).append("/offline-page.html");

就这些了

适用于 Keycloak 6.0。

为了清楚起见:我们确实使 refreshToken 过期,但 accessToken 在“访问令牌生命周期”时间内仍然有效。下次用户尝试通过刷新令牌更新访问令牌时,Keycloak returns 400 错误请求,应该捕获并作为 401 未经授权的响应发送。

public void logout(String refreshToken) {
    try {
        MultiValueMap<String, String> requestParams = new LinkedMultiValueMap<>();
        requestParams.add("client_id", "my-client-id");
        requestParams.add("client_secret", "my-client-id-secret");
        requestParams.add("refresh_token", refreshToken);

        logoutUserSession(requestParams);

    } catch (Exception e) {
        log.info(e.getMessage(), e);
        throw e;
    }
}

private void logoutUserSession(MultiValueMap<String, String> requestParams) {
    HttpHeaders headers = new HttpHeaders();
    headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);

    HttpEntity<MultiValueMap<String, String>> request = new HttpEntity<>(requestParams, headers);
    String realmName = "your-realm-name";
    String url = "/auth/realms/" + realmName + "/protocol/openid-connect/logout";

    restTemplate.postForEntity(url, request, Object.class);
    // got response 204, no content
}

根据代码:https://github.com/keycloak/keycloak/blob/master/services/src/main/java/org/keycloak/protocol/oidc/endpoints/LogoutEndpoint.java#L106

这就是我的 SpringBoot FX 应用程序的工作方式

获取 http://localhost:8080/auth/realms//protocol/openid-connect/logout?post_redirect_uri=your_encodedRedirectUri&id_token_hint =id_token

在 JWT 中你有“session_state”

{
    "exp": 1616268254,
    "iat": 1616267954,
     ....
    "session_state": "c0e2cd7a-11ed-4537-b6a5-182db68eb00f",
    ...
}

之后

public void testDeconnexion() {
        
        String serverUrl = "http://localhost:8080/auth";
        String realm = "master";
        String clientId = "admin-cli";
        String clientSecret = "1d911233-bfb3-452b-8186-ebb7cceb426c";
        
        String sessionState = "c0e2cd7a-11ed-4537-b6a5-182db68eb00f";

        Keycloak keycloak = KeycloakBuilder.builder()
                .serverUrl(serverUrl)
                .realm(realm)
                .grantType(OAuth2Constants.CLIENT_CREDENTIALS)
                .clientId(clientId)
                .clientSecret(clientSecret) 
                .build();

        String realmApp = "MeineSuperApp";      

        RealmResource realmResource = keycloak.realm(realmApp);
        realmResource.deleteSession(sessionState);      
        
    }

此方法不需要任何手动端点触发器。它依赖于 LogoutSuccessHandler 尤其是 OidcClientInitiatedLogoutSuccessHandler 来检查 end_session_endpoint 是否存在于 ClientRegistration bean 上。

在某些情况下,当与 Spring 安全性配对时,大多数身份验证提供商(Okta 除外)默认不使用 end_session_endpoint,我们只能手动将其注入 ClientRegistration .最简单的方法是将其放在 InMemoryClientRegistrationRepository 初始化之前,就在 application.propertiesapplication.yaml 加载之后。

package com.tb.ws.cscommon.config;

import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientProperties;
import org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientPropertiesRegistrationAdapter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.oauth2.client.registration.ClientRegistration;
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository;

import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

@Configuration
public class ClientRegistrationConfig {

  @Bean
  @ConditionalOnMissingBean({ClientRegistrationRepository.class})
  InMemoryClientRegistrationRepository clientRegistrationRepository(
      OAuth2ClientProperties properties) {
    List<ClientRegistration> registrations =
        OAuth2ClientPropertiesRegistrationAdapter.getClientRegistrations(properties)
            .values()
            .stream()
            .map(
                o ->
                    ClientRegistration.withClientRegistration(o)
                        .providerConfigurationMetadata(
                            Map.of(
                                "end_session_endpoint",
                                "http://127.0.0.1:8080/auth/realms/OAuth2/protocol/openid-connect/logout"))
                        .build())
            .collect(Collectors.toList());

    return new InMemoryClientRegistrationRepository(registrations);
  }
}

并且在 WebSecurity 中:

package com.tb.ws.cscommon.config;

import lombok.extern.slf4j.Slf4j;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.oauth2.client.oidc.web.logout.OidcClientInitiatedLogoutSuccessHandler;
import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository;
import org.springframework.security.web.authentication.logout.LogoutSuccessHandler;

@Slf4j
@EnableWebSecurity
public class WebSecurity extends WebSecurityConfigurerAdapter {
  private final InMemoryClientRegistrationRepository registrationRepository;

  public WebSecurity(InMemoryClientRegistrationRepository registrationRepository) {
    this.registrationRepository = registrationRepository;
  }

  @Override
  protected void configure(HttpSecurity http) throws Exception {
    String[] permitAccess = new String[] {"/", "/styles/**"};

    http.authorizeRequests()
        .antMatchers(permitAccess)
        .permitAll()
        .anyRequest()
        .authenticated()
        .and()
        .oauth2Login()
        .and()
        .logout(
            logout -> {
              logout.logoutSuccessHandler(logoutSuccessHandler());
              logout.invalidateHttpSession(true);
              logout.clearAuthentication(true);
              logout.deleteCookies("JSESSIONID");
            });
  }

  private LogoutSuccessHandler logoutSuccessHandler() {
    OidcClientInitiatedLogoutSuccessHandler handler =
        new OidcClientInitiatedLogoutSuccessHandler(registrationRepository);
    handler.setPostLogoutRedirectUri("http://127.0.0.1:8005/");

    return handler;
  }
}

默认情况下,Spring 安全性将查询参数 id_token_hintpost_logout_redirect_uri 附加到 end_session_endpoint 上。这可以用 OidcClientInitiatedLogoutSuccessHandler handler 改变。这可以与社交提供者一起使用。只需为每个提供商提供相关的 end_session_endpoint

本例中使用的属性文件application.yaml

spring:
  application:
    name: cs-common
  main:
    banner-mode: off
  security:
    oauth2:
      client:
        registration:
          cs-common-1:
            client_id: cs-common
            client-secret: 03e2f8e1-f150-449c-853d-4d8f51f66a29
            scope: openid, profile, roles
            authorization-grant-type: authorization_code
            redirect_uri: http://127.0.0.1:8005/login/oauth2/code/cs-common-1
        provider:
          cs-common-1:
            authorization-uri: http://127.0.0.1:8080/auth/realms/OAuth2/protocol/openid-connect/auth
            token-uri: http://127.0.0.1:8080/auth/realms/OAuth2/protocol/openid-connect/token
            jwk-set-uri: http://127.0.0.1:8080/auth/realms/OAuth2/protocol/openid-connect/certs
            user-info-uri: http://127.0.0.1:8080/auth/realms/OAuth2/protocol/openid-connect/userinfo
            user-name-attribute: preferred_username
server:
  port: 8005
eureka:
  client:
    service-url:
      defaultZone: http://127.0.0.1:8004/eureka
  instance:
    instance-id: ${spring.application.name}:${instanceId:${random.value}}

为了测试我们只是从 UI.

踢 Spring 安全的默认 GET /logout 端点

杂项:

  • Spring 启动 2.5
  • Spring云2020.0.3
  • Java11
  • Keycloak 服务器 13.0.1

客户端设置:

  • 启用标准流程
  • 隐式流已禁用
  • 已启用直接访问授权

某人,某处可能会觉得有用。

P.S。该应用程序及其属性文件供学习使用

终于。它对我有用。我进行了如下所示的 REST 调用:

Headers:

{
 "Authorization" : "Bearer <access_token>",
 "Content-Type" : "application/x-www-form-urlencoded"
}

请求Body:

{
    "client_id" : "<client_id>",
    "client_secret" : "<client_secret>",
    "refresh_token" : "<refresh_token>"
}

方法:

POST

URL:

<scheme>://<host>:<port>/auth/realms/<realmName>/protocol/openid-connect/logout

我收到了 200 作为响应...如果您做错任何事,您将收到 401 或 400 错误。调试这个问题非常困难。顺便说一句,我的 keycloak 版本是 12.0.4

如果 post 不清楚或者您需要更多信息,请告诉我。