通过 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 注销用户不起作用的主要内容,如果未能解决你的问题,请参考以下文章

Keycloak :REST API 调用通过管理员用户名和密码获取用户的访问令牌

使用 JAVA 通过 REST API 集成 Keycloak 时在 toRepresentation() 处获取 404

如何从 REST API(PUT 方法)更新 Keycloak 密码?

如何配置 Keycloak 并通过 REST API 完成所有步骤并获得 JWT?

如何通过执行操作电子邮件通过 keycloak admin rest api 更新密码

如何使用 httppost/rest-api 从 keycloak 获取用户列表