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

Posted

技术标签:

【中文标题】通过 Keycloak REST API 注销用户不起作用【英文标题】:Logout user via Keycloak REST API doesn't work 【发布时间】: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.* 系列的解决方案吗? 【参考方案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 存储库来重现该问题?这对我或其他人来说会更容易,并且可能对此有所暗示。 我想知道这是否应该是一个全新的堆栈溢出票。我应该创建一个新的吗?

以上是关于通过 Keycloak REST API 注销用户不起作用的主要内容,如果未能解决你的问题,请参考以下文章