关键词:session_state
keycloak是通过广播的形式实现单点登出(back channel)
实现流程
(以OIDC协议为例)
keycloak端:
在 oidc client 页面,配置 Admin url(SP URL ),登出时,会调用 ${admin url}/k_logout,会将token放到请求体内(没有参数名)
sp端:
1 获取 code 接口,记录 session_state 返回值
2 在使用 code 换取 token 时,设置form参数:client_session_state=${session_state}
3 配置 `k_logout`, `k_push_not_before` 端口,需要对token进行校验,demo(注意看注释及TODO部分):
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject;
import java.nio.charset.Charset;
import java.security.KeyFactory;
import java.security.PublicKey;
import java.security.spec.X509EncodedKeySpec;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.codec.binary.Base64;
import org.apache.commons.lang3.tuple.Triple;
import org.jose4j.jws.JsonWebSignature;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.util.StreamUtils;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@Slf4j
public class KeycloakController {
@Value("${keycloak.publicKey:MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvS4g5vSHD8X4nsK6ar60rqhZXnDwiDku5vAZJpTZvyFTY1ONh+UJqiBvy2bsBrhyOp11GsgLBfBhBvlAS0PmgjUHH1JOLNAmbt747ark9Sz1oZMiGgKtXTVvj/KFsINKvMTATi9nswaqy96Si/bk8C08GpI68sbxn6eOTaEMQmKRk62O3T0wxkUL45UYI9FrwNCJvOkcoKxc/NUh84rt/UV+meRiogSRqG7OFlLb9XDcgmL+Hh2rahimXQnjbC9YAqbHZOf8b6IGYEs7/0uaQBYwJEW0RVr3AXl/VIVENNtUBRRSR6CW3n+7B6ephv8YHX4Uoe2XNu91KhQKoMYWOwIDAQAB}")
String pubKeyStr;
/**
* 使用单点登出,需要先在 client 界面配置 admin_url
*
* 退出登录,由keycloak后端调用
*
* 处理成功需要返回 200 状态码
*
* 处理失败,返回 5xx 状态码
*/
@PostMapping({"/k_logout", "/k_push_not_before"})
public JSON logout(HttpServletRequest req, HttpServletResponse resp) {
try {
String token = StreamUtils.copyToString(req.getInputStream(), Charset.defaultCharset());
log.info(token);
Triple<Object, Boolean, JSONObject> verifyResult = verifyToken(token);
if (!verifyResult.getMiddle()) {
// 请求校验失败
throw new Exception("请求校验失败");
}
JSONObject action = verifyResult.getRight();
JSONArray sessionIds = action.getJSONArray("adapterSessionIds");
if (sessionIds != null) {
System.out.println(sessionIds);
for (Object sessionId : sessionIds) {
// TODO 清除这些 session id
System.out.println("delete " + sessionId);
}
} else {
// TODO 清除所有 session
System.out.println("delete all");
}
} catch (Exception e) {
log.error(e.getMessage(), e);
resp.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());
}
return new JSONObject();
}
private Triple<Object, Boolean, JSONObject> verifyToken(String token) throws Exception {
final JsonWebSignature jws = new JsonWebSignature();
jws.setCompactSerialization(token);
// TODO 根据实际配置key,比如这里,后台配置的算法是 RSA,从后台可以拿到 public/private key
// 还有另外一个方法判断用的是哪一种key,授权token的header部分,标记了alg:你使用的算法,以及kid:对应 keycloak 中 keys管理里的 kid
jws.setKey(genPubKey(pubKeyStr));
return Triple.of(null, jws.verifySignature(), JSONObject.parseObject(jws.getPayload()));
}
private static PublicKey genPubKey(String publicKey) throws Exception {
byte[] decoded = Base64.decodeBase64(publicKey);
return KeyFactory.getInstance("RSA").generatePublic(new X509EncodedKeySpec(decoded));
}
}
4. 访问IAM登出端口
如果你对接的时候使用的是:
- OIDC协议,对应的登出端口:http://{IAM Host}/auth/realms/{realm}/protocol/openid-connect/logout?id_token_hint={认证时获取到的 id_token}&post_logout_redirect_uri={登出之后,重定向回来的URL,一般设置为你的登出URL}
- 如何获取id_token,跳转授权码链接时,指定scope=openid,或者获取token接口指定
- CAS协议,对应的登出端口(必须是浏览器跳转):http://{IAM Host}/auth/realms/{realm}/protocol/cas/logout?service={登出之后,重定向回来的URL,一般设置为你的登出URL}
客户端在登录时,我们规定先访问IAM 的登出URL,在 回跳URL 填客户端登出URL
Final:如果没有上面流程,只能在keycloak端登出,其他SP仍保持登录状态
逻辑分析
keycloak逻辑
- org.keycloak.protocol.oidc.endpoints.TokenEndpoint#updateClientSession
TokenEndpoint 中,当请求参数中带有 client_session_state,会在 clientSession 中保存下来; - 调用 oidc/logout 登出接口时,keycloak有一个 backchannel机制,遍历当前用户关联的 authenticated client session ,然后调用对应的client的登出接口
- 入口:org.keycloak.protocol.oidc.endpoints.LogoutEndpoint,它包含多个logout接口,每个接口最终都会做 backchannel logout;
- org.keycloak.services.managers.AuthenticationManager#backchannelLogoutClientSession:根据当前session拿到你登录时用的什么协议(oidc、saml、docker auth),进行相应的登出操作
- org.keycloak.services.managers.ResourceAdminManager#logoutClientSessions:核心逻辑,从client session(客户端回话)中拿到 authenticated client session,然后通过调用client 的 k_login 接口,告诉客户端(SP)哪个session需要登出;
参考源码
客户端适配器如何登出
org.keycloak.adapters.springsecurity.authentication.KeycloakLogoutHandler#handleSingleSignOut
在本地服务登出之后,调用远程(keycloak)登录
keycloak如何广播client,进行单点登出
我们根据 k_logout 进行倒推逻辑
|
org.keycloak.protocol.oidc.OIDCLoginProtocol#backchannelLogout
|
org.keycloak.services.managers.AuthenticationManager#backchannelLogoutClientSession
…
参考
[[keycloak-user] Implementing central logout](https://lists.jboss.org/pipermail/keycloak-user/2015-October/003248.html)
[OpenID Connect会话管理](https://medium.com/@piraveenaparalogarajah/openid-connect-session-management-dc6a65040cc) OIDC session管理原理
[Support Keycloak’s OIDC backchannel logout](https://github.com/spring-projects/spring-security/issues/7770)
[OIDC backchannel 规范](https://openid.net/specs/openid-connect-backchannel-1_0.html)
[keycloak下基于CAS协议如何实现单点登录](https://github.com/keycloak-side/keycloak-protocol-cas) 基于上面分析的逻辑也实现了单点登出
[Session State Pattern会话状态模式](https://www.cnblogs.com/robyn/p/3528447.html)