通过 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.properties
或application.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_hint
和 post_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/
【讨论】:
【参考方案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 在我的例子中,APIauth/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_id
和refresh_token
就够了。
@SwissNavy:当你登录用户时你会得到刷新令牌(这意味着你可能已经向 localhost:8080/auth/realms/ "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>
您可以在该响应中找到刷新令牌。跨度>
我在 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?