Keycloak 20.0.3 使用体验

💡
整体使用方式强烈的偏轻量化,并不会深度集成

最近有一些需求,评估之后想来想去,还是Keycloak比较合适。所以虽然个人很不喜欢,但还是使用了Keycloak。

本文主要分两块。第一块是关于Keycloak的评价性的描述,第二块才是一些功能的体验。


为什么要选择Keycloak

选择的理由

需求其实很简单,就是实现一个api gateway,让这个api gateway同时支持azure ad、google、github的认证,并保留增加更多认证的可能。
另外,特别的,需求要求不允许用户直接输入用户名密码登录。所以是必须要第三方登录才能登录。

之前虽然有集成Azure AD,但是是只集成了Azure AD,想扩展一个google其实并不容易。

Keycloak在这个场景确实具有非常强的优势。因为他可以将各个不同的认证源的用户集中到一个系统中,并给这些用户分配统一的user id、role或者group。其中最重要的就是user id,只要用户被唯一的识别了出来,那么多个用户源的问题就算解决了。
而且Keycloak已经默认支持了不少知名的认证源。因此不论是Azure AD,还是google,Keycloak配置都是非常轻松的。

反对的理由

选择的理由说到底其实就是一个点:Keycloak可以整合多个不同的源。这个确实是一个很不错的亮点。但是,除了这个亮点之外,其他不仅达不到平平无奇的水平,甚至全是坑。
当然,自古功过都是相伴而生。所以是坑还是亮点,见仁见智。后面的描述我会尽量同时说明这些坑如何解读为亮点。

首先,社区非常活跃。一个非常活跃的社区意味着有大量的开发人员不断的改善Keycloak的设计和功能。
但反过来说,短短两年时间,Keycloak从11.0直接更新到20.0,底层框架期间也直接换了个新的。而且各种功能在不同的版本之间存在较多的差异。这个可以从其超长的Release note中就能看出来了。
也就意味着如果要保持Keycloak更新,需要花费巨量的人工来学习每个版本更新到底干了什么。

其次,提供了丰富且全面的功能。意味着绝大多数功能都可以直接通过Keycloak配置实现。
但是反过来说,这些功能基本都是鸡肋,以client的authorization为例。其设计很强大,总的来看他的设计确实能解决95%的问题,看上去确实很美。但是如果稍微仔细一点看就会发现,80%的简单需求用Keycloak提供的功能反而会被其“完备”的设计拖累,而5%的复杂需求Keycloak提供的功能又无法实现(除非愿意在Keycloak上写代码)。于是换个视角就是:85%的问题用Keycloak解决都是不值得的。
所以个人认为,除非审慎考虑之后,发现场景恰好正好不多不少的用了Keycloak某个功能,否则大概率会亏(指选择别的方案会更好)。

最后,灵活的插件支持。意味着一些Keycloak本身没有的功能,可以通过插件实现。
但是这里的插件并不像Jenkins一样,鼠标点一点就能直接安装和更新。Keycloak的插件往往需要手动的放到某个特定的目录,或者执行特定的命令。这样虽然提供了很好的灵活性,但是却引入了复杂度。
再叠加前面两个buff,本身偏正面的插件支持,在极高的更新频率下,显出了极强的负面特性。(毕竟自己写个插件,过两天就不能用了,而且还没有预先告警,而且还得慢慢调试)

选不选Keycloak总结

如果我是一个赌徒,那么在我不考虑面对的具体场景具体问题的情况下,我会默认押宝说:不选。
也就是说,除非有什么非常明确的理由和目的,选择Keycloak我都认为是不明智的。

我目前所见到的,我认为算是有“明确理由”的场景:
1. 需要同时和大量的第三方登录做集成。
这种需求下,可以将Keycloak作为一个统一的认证中心。鉴权等其他一切其他功能可以扔别的地方。
总的来说,虽然Keycloak有很多问题,而且这些问题的成本不低。但是对比大量第三方集成的成本和整合用户信息的成本,Keycloak带来的成本相比之下还是较低。
2. 一个Keycloak给大量的团队公用。
这种需求下,Keycloak必然有一个独立团队维护。而且Keycloak的问题只需要有一个团队解决一次。规模化会摊平其成本。

总的来说,Keycloak成本极高,除非能通过某种方式规模化(而且这个规模不能太低)来摊平成本,否则很可能会付出比其他方案更高的代价。
或者说,Keycloak本身就是一个认证中心,除非真的你真的需要一个认证中心,而且用不了别的认证中心,否则选择认证中心就是不明智的。


Keycloak使用体验

登录流程

首先Keycloak 20的登录流程有了大的更新(至少相比15来说是大更新)。

旧版
新版

也就是说,新版的登录流程可以定制化了。也就是说,我可以禁用Cookie,或者添加一个额外的步骤让用户登陆后查看自己的profile等等。
同时,Keycloak提供了一个图像化的方式来展现Flow的具体流程。

同时,新的Flow明确的给出了Sub Flow的概念。上图中就有“Copy of browser forms”这样一个Sub Flow,里面只包含一个Step:Username Password Form。

最后新版的流程也支持Conditional Sub Flow,也就是只有在符合条件的情况下才会执行SubFlow里的Steps。

另外,可能是因为增强了可定制化,所以之前一些不合理的设计似乎被修正了。现在的Alternative就是真的Alternative。比如上面图里的完整流程的意思就是:Cookie、Kerberos、Identity provider Redirector或者Copy of browser Flow四个选一个就行。
也就是说,从Keycloak视角出发看访问Keycloak的流量,Keycloak会看他符合上面的哪一种。

这就会导致Identity Provider Redirector的意义更明确。现在我们很明确的知道,这个Redirector指的是:Keycloak接受并且会处理来自第三方登录跳转导致的访问。

Identity Provider Redirector的语义更明确本身是好事。但问题就在于Redirector既然只负责Redirect,那么怎么才能跳转到第三方就成了一个问题。两种方法:
1. 保留Username Password Form。这样,Form上就会显示所有登录选项。

2. 如果只有一个外部SSO(请反思为什么这种情况还会使用Keycloak)。也可以配置Identity Provider Redirector来指定一个默认的。这个默认的就会跳转指定的SSO。
另外,需要指出的是,Keycloak此处的设计其实破坏了Flow的整体观感。如前面所说,其他所有部分的都是考虑Keycloak接受外部流量后怎么做,而这个设置却是教Keycloak如何主动出击。

主题定制

我个人是不嫌弃Keycloak的UI的。只不过因为我的需求是多个外部SSO,而且我希望强制使用这些SSO。因此,我其实想要让Keycloak隐藏掉Username和Password的输入框。

首先Keycloak自带的Steps里面是没有这个支持的,只有Username Password Form一个选项。这个选项会同时包括用户名密码登录和第三方登录。

这里可以选择自定一个外部的独立于Keycloak的UI,比如React,然后这个UI主动跳转第三方SSO就好了。毕竟如前面所说,Identity Provider Redirector只看你是不是外部SSO跳回来,他并不管你怎么跳出去的。所以理论上这么做是可以的。
但是,一方面需要自己写一个页面,二方面不够骚。所以我没有选择这么做,而是选择定制主题。

思路就是,这个主题下,它本身就不会显示用户名密码的输入框,于是就只剩下第三方登陆的几个Button了。
具体代码实现我是基于一个别人写好的主题fork的。我的代码:https://github.com/xloypaypa/keywind

奇怪的是,主题的安装似乎不需要按照Keycloak官方文档说的那么麻烦。我其实就是直接下载了我编译好的代码,然后拷贝到theme文件夹下。并没有额外的安装和配置。
具体命令:sh -c 'rm -rf /opt/keycloak/themes/* && curl -sL https://github.com/xloypaypa/keywind/releases/download/2023-01-14/keywind-2023-01-14.tar.gz -o /keywind.tar.gz && tar -xvf /keywind.tar.gz && cp -r /keywind-2023-01-14/theme/keywind/ /opt/keycloak/themes/'
每次代码改动似乎需要跑npm run build更新dist文件夹里的文件。原库是提交了这两个文件到代码库,我只是follow其实践而已。
可能是因为我没有持久化keycloak的文件夹,导致Keycloak每次启动都会主动重新build一遍,所以才不需要如官网说的那样配置。我反正直接拷贝就行

启动之后,只需要在Client的配置里选择一下登录主题即可。

最后,效果图:

Token设置

Keycloak 20似乎默认的Token似乎不会包含用户被分配的realm role。这里参考了Stackoverflow的这个问答。似乎是说Keycloak里面各种role太多了,容易搞出事情。而且这个问题本身提问者也告诉了我们Keycloak的client的token在哪里配置规则的。

总结一下就是:client的里会配置Client Scope,Client Scope会规定用户信息的mapper。所以,我们只要保证client确实添加了对应的Client Scope,并且保证Client Scope会在生成Token时会被调用,并且保证用户信息的mapper会在生成Token时被调用即可。

落实到操作就是:
1. 去Client scope里找到一个叫“roles”的scope。这个是默认的,也可以建一个新的,然后命名成abc都可以。然后打开Include in token。

2. 去Scope的Mapper,删除其他的role,只保留realm role(根据前面StackOverflow的说法,这里是为了避免冲突。不删可不可以我不确定)
也是打开Token。

3. 检查一下Client的Client Scope是不是有#1的那个Scope。

Spring集成

我这的集成并没有采用keycloak的spring boot stater,而是直接用的spring security和oauth2-client。
主要是因为,keycloak自己的starter问题太多,而且我记忆中Keycloak的starter是不基于Spring security的。相比之下,我认为Spring Security更加可靠。
另一个原因是,我这里Keycloak的职责仅仅只是负责登录认证,最多管理user-role的mapping。至于后面的鉴权不是Keycloak需要操心的事情,所以只一个oauth就够了。而且如果我没记错,keycloak的库会大量调用Keycloak特有的API,非常难track。
最后一个原因就是,避免深度绑定。oauth2 client让我可以随时换一个“整合工具”,而不需要被Keycloak绑定。

集成的基础是参考了这一份教程,额外做了cors和csrf的配置以及从Keycloak的token拿role。

先上完整代码(示例代码,比较奔放,尤其是cors):

@Configuration
@EnableWebSecurity
class SecurityConfig {

    private final KeycloakLogoutHandler keycloakLogoutHandler;

    SecurityConfig(KeycloakLogoutHandler keycloakLogoutHandler) {
        this.keycloakLogoutHandler = keycloakLogoutHandler;
    }

    @Bean
    protected SessionAuthenticationStrategy sessionAuthenticationStrategy() {
        return new RegisterSessionAuthenticationStrategy(new SessionRegistryImpl());
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .antMatchers("/static/**").permitAll()
                .anyRequest().authenticated();
        http.oauth2Login().userInfoEndpoint().oidcUserService(getOidcUserService()).and()
                .and()
                .logout()
                .addLogoutHandler(keycloakLogoutHandler)
                .logoutSuccessUrl("/");

        http.cors().and().csrf().disable();
        return http.build();
    }
    
    private static OidcUserService getOidcUserService() {
        return new OidcUserService() {
            @Override
            public OidcUser loadUser(OidcUserRequest userRequest) throws OAuth2AuthenticationException {
                OidcUser oidcUser = super.loadUser(userRequest);
                JSONObject realmAccess = oidcUser.getAttribute("realm_access");
                if (realmAccess == null || !realmAccess.containsKey("roles")) {
                    return oidcUser;
                } else {
                    JSONArray roles = (JSONArray) realmAccess.get("roles");
                    Set<GrantedAuthority> authorities = new LinkedHashSet<>();
                    for (Object now : roles) {
                        authorities.add(new SimpleGrantedAuthority("ROLE_" + now.toString()));
                    }
                    return getNewUser(userRequest, oidcUser, authorities);
                }
            }

            @NotNull
            private DefaultOidcUser getNewUser(OidcUserRequest userRequest, OidcUser oidcUser, Set<GrantedAuthority> authorities) {
                ClientRegistration.ProviderDetails providerDetails = userRequest.getClientRegistration().getProviderDetails();
                String userNameAttributeName = providerDetails.getUserInfoEndpoint().getUserNameAttributeName();
                if (StringUtils.hasText(userNameAttributeName)) {
                    return new DefaultOidcUser(authorities, oidcUser.getIdToken(), oidcUser.getUserInfo(), userNameAttributeName);
                }
                return new DefaultOidcUser(authorities, oidcUser.getIdToken(), oidcUser.getUserInfo());
            }
        };
    }

    @Bean
    public CorsFilter corsFilter() {
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();

        CorsConfiguration corsConfiguration = new CorsConfiguration();
        corsConfiguration.addAllowedOriginPattern("*");
        corsConfiguration.addAllowedHeader("*");
        corsConfiguration.addAllowedMethod("*");
        corsConfiguration.setAllowCredentials(true);
        source.registerCorsConfiguration("/**", corsConfiguration);
        return new CorsFilter(source);
    }

}

其实本质就是通过.userInfoEndpoint().oidcUserService(getOidcUserService())指定了用户生成的方式。具体实现其实就是继承OidcUserServiceloadUser方法,基于他生成好的OidcUser从token里把role拿出来再塞进去。

至于后续是通过注释的方式管理权限,还是通过SecurityConfig直接集中管理权限,这个怎么玩都行,属于是spring security的范畴了。

登录限制

Keycloak有一个自带的功能可以限制用户同时多次登录了,具体我没试。但这个其实就是登录流程的一个扩展。就是添加一个step的事情。


总的来说,Keycloak还是一个非常heavy的工具,能不用尽量就不要用。

至于你问我怎么升级到20?
我只能说,不好意思,我这里就是从0搭建,升级的事情我不知道。不行就重建呗,反正你看我这Keycloak就只负责登录的话,重建也不是那么麻烦。
什么你是深度集成的重度用户?我祝您身体健康~

蜀ICP备19018968号