【问题标题】:Logout user via Keycloak REST API doesn't work通过 Keycloak REST API 注销用户不起作用
【发布时间】:2018-03-23 04:14:41
【问题描述】:

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

its documentation 中所述支持此方案:

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

注销端点注销经过身份验证的用户。

用户代理可以重定向到端点,在这种情况下,活动用户会话被注销。之后用户代理被重定向回应用程序。

端点也可以由应用程序直接调用。要直接调用此端点,需要包含刷新令牌以及验证客户端所需的凭据

我的请求格式如下:

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 4.* 系列的解决方案吗?

标签: keycloak


【解决方案1】:

仅供参考:OIDC 规范和 Google 的实现有一个 token revocation endpoint

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

【讨论】:

    【解决方案2】:

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

    标题

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

    请求正文

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

    方法

    POST
    

    网址

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

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

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

    【讨论】:

      【解决方案3】:

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

      在某些情况下,end_session_endpoint 在与 Spring Security 配对时在大多数身份验证提供程序(Okta 除外)上默认不使用,我们需要手动将其注入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 Security 将查询参数 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 Security 的默认 GET /logout 端点。

      杂项:

      • Spring Boot 2.5
      • 春云2020.0.3
      • Java 11
      • Keycloak 服务器 13.0.1

      客户端设置:

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

      某人,某处可能会发现它有帮助。

      附:该应用程序及其属性文件用于学习

      【讨论】:

      • 谢谢。它帮助了我。需要补充的几点: 1. 我们需要在 Keycloak 中添加 "http://127.0.0.1:8005/" 作为另一个 有效重定向 URI 2. 我们可以使用 "{baseUrl}/" 代替硬编码的 "http://127.0.0.1:8005/",我们可以使用 "{baseUrl}/",例如 handler.setPostLogoutRedirectUri("{baseUrl}/");
      【解决方案4】:

      在 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);      
              
          }
      

      【讨论】:

        【解决方案5】:

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

        这就是我的 SpringBoot FX 应用程序的工作原理

        GET http://loccalhost:8080/auth/realms//protocol/openid-connect/logout?post_redirect_uri=your_encodedRedirectUri&id_token_hint=id_token

        【讨论】:

          【解决方案6】:

          适用于 Keycloak 6.0。

          为了清楚起见:我们确实会过期 refreshToken,但 accessToken 在“Access Token Lifespan”时间内仍然有效。下次用户尝试通过刷新令牌更新访问令牌时,Keycloak 返回 400 Bad request,应该捕获什么并作为 401 Unauthorized 响应发送。

          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 url = "/auth/realms/my-realm/protocol/openid-connect/logout";
          
              restTemplate.postForEntity(url, request, Object.class);
              // got response 204, no content
          }
          

          【讨论】:

          • 您是如何设法使 accessToken 也过期的?我担心的是,如果访问令牌在注销后仍然有效,那么就会存在安全风险。我该如何处理?
          • 通常 accessToken 的有效期很短,比如 15 分钟。怎么强制过期,不知道,在keycloak论坛提问keycloak.discourse.group
          • 在我的例子中,API auth/realms/my-realm/protocol/openid-connect/userinfo 给出了 401 和 access_token。我正在使用 keycloak 7.01
          【解决方案7】:

          在 3.4 版中,您需要 x-www-form-urlencoded 正文密钥 client_id、client_secret 和 refresh_token。

          【讨论】:

          • RFC 6749 不鼓励在正文中发送 client_id 和 client_secret,最好使用 HTTP Basic 身份验证。只有 refresh_token 需要在正文中发送,我检查了它是否适用于 Keycloak。
          【解决方案8】:

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

          如果客户端是公共客户端,则必须包含“client_id”表单参数。

          所以我缺少的是 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>
          

          会话应该被正确销毁。

          【讨论】:

          • 从这个答案中,我不明白您的 client_id 在 Spring Web 应用程序中映射到什么?我尝试了“IdToken.id”、“AccessTokenId.id”以及“context.tokenString” - 每次我收到错误消息“无效的客户端凭据”
          • 其实access token是不需要的,client_idrefresh_token就够了。
          • @SwissNavy:当你登录用户时你会得到刷新令牌(这意味着你可能已经向 localhost:8080/auth/realms//protocol/openid-connect/token 发送了一个请求) ,该请求的响应可能如下所示: { "access_token": "<access_token_value>", "expires_in": 299, "refresh_expires_in": 1799, "refresh_token": "<refresh_token_value>", "token_type" : "bearer", "not-before-policy": 0, "session_state": "<session_state_value>", "scope": "profile email" } </session_state_value></refresh_token_value></access_token_value> 您可以在该响应中找到刷新令牌。跨度>
          • @SwissNavy:这取决于您如何与 Keycloak 集成:哪个 OpenID 连接流(隐式流/身份验证流/资源所有者密码授予/客户端凭据授予),因为我认为并非所有这些流给你一个刷新令牌。您可能需要参考 OpenID Connect 协议的文档以获取更多信息。您使用的技术堆栈也很重要,因为某些库/框架可能以不同的方式存储刷新令牌。请问您正在研究什么流程+技术堆栈?
          • 我从未使用过 Django(因为我是 Java 人?),但我尝试查看您使用的框架(Social Core)来实现 Keycloak。我似乎没有在任何地方存储刷新令牌:github.com/python-social-auth/social-core/blob/…。最好向图书馆维护人员提出您的问题。
          【解决方案9】:

          我在 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*
          

          如您所见,我尝试了许多不同的主题,但我不断收到无效的用户身份验证。 哦耶。我将来自application.properties 的keycloak 凭据秘密注入到带有@Value 的对象实例字段中

          @Value("${keycloak.credentials.secret}")
          private String keycloakCredentialsSecret;
          

          Java Spring Security 经验丰富的工程师有什么想法吗?

          附录 我在 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");
          

          就是这样

          【讨论】:

          • 您的登录流程使用的是哪种grant_type?我使用的是grant_type=password,因为它是一个带有本机(iOS)登录表单的移动客户端。因此,我不得不 POST 到端点,但无法将用户重定向到 Keycloak 注销页面。您似乎正在开发一个 Web 应用程序,您是否尝试将用户重定向到 Keycloak 的注销页面:keycloak.org/docs/latest/securing_apps/index.html#logout
          • 我的授权类型是“public”,客户端协议是“openid-connect”,我还使用 Vaadin 10 来保护 Java Web 应用程序
          • 我刚刚在上面的答案中添加了 redirectURI 代码。与 Vaadin 8 相比,Vaadin 10 具有不同的注销机制。它使用新的 Flow API。见这里vaadin.com/docs/v10/flow/advanced/… 我可以确认没有 Keycloak 的注销工作,因为我测试了他们自己的 Vaadin Bakery Spring Security 应用程序。但是,这不会将用户从 Keycloak 中注销,因此我试图对 Keycloak 服务器进行 RESTful 调用以注销用户,然后关闭 Vaadin (Http) 会话。有道理? :-/
          • OpenID Connect(Keycloak 实现)使用与 OAuth2 相同的 grant_type,因此其值应为以下之一:oauth.net/2/grant-types。由于我还不知道为什么您的代码不起作用,您能否提供一个示例 Github 存储库来重现该问题?这对我或其他人来说会更容易,并且可能对此有所暗示。
          • 我想知道这是否应该是一个全新的堆栈溢出票。我应该创建一个新的吗?
          猜你喜欢
          • 1970-01-01
          • 1970-01-01
          • 2017-07-06
          • 2022-12-14
          • 1970-01-01
          • 1970-01-01
          • 2020-02-07
          • 2019-12-10
          • 2018-06-23
          相关资源
          最近更新 更多