keycloak/单点登出(Single sign out)

关键词: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)

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注