背景

几年前,基于 Keycloak 实现了微信公众号关注即登录方案,详见《基于 keycloak 的关注公众号即登录功能的设计与实现 - Jeff Tian的文章 - 知乎 》。

该方案通过为 Keycloak 写了一个 Identity Provider 来实现,效果是在 Keycloak 的默认登录页面下面增加了微信登录按钮,这个流程可以通过 https://keycloak.jiwai.win/realms/Brickverse/account/ 进行实际体验:

image.png

然而,有些使用 Keycloak 的企业希望将关注微信公众号即登录的方式设置为首选,即在登录页直接展示二维码,而不是需要用户点击一次后再展示。反过来,如果用户希望使用用户名密码来登录,可以再点击一次后切换到用户名密码登录页面。

要实现将微信二维码做为登录页的首选,可以通过 Keycloak 的配置来实现。而要在微信二维码页面设置回到其他登录方式的按钮,就需要一点点编程了,并且得借助 Keycloak.js 来完成整个功能。

在线演示

可以通过 https://keycloak.jiwai.win/realms/hardway/account/#/ 体验,打开链接,点击“登录”按钮:

image.png

在打开的登录页面中,首先展示的是微信二维码,即首选微信扫码登录。

image.png

如果不想使用微信扫码,可以点击下面的按钮,选择其他的登录方式。

image.png

实现步骤

添加“weixin”到身份供应商

这里不详细说明了,因为在之前专门写过,参考:《【继续更新】尝试在 Keycloak 里打通整个微信生态 - Jeff Tian的文章 - 知乎 》。
image.png

通过配置将微信二维码页面做为首选登录方式

这一步非常简单,并且将是唯一需要做的一件事(后面会讲解代码,仅为有需要的同学们自己开发时做参考。因为我已经将代码写好了,所以可以直接使用)。通过领域的身份验证部分,找到 Identity Provider Redirector,然后点击设置:
image.png

接着,在弹出的对话框中,将别名和 Default Identity Provider 都设置为 weixin。

image.png

点击保存之后,你就能看到效果了。在登录页面上直接展示了二维码。二维码下面的按钮,需要一些开发,后面的步骤就是讲解它们的开发细节,尽管做为用户不需要了解,但为了照顾到其他开发同学,并不直接使用我写好的插件,而是想自己开发,那么不妨了解一下我是怎么开发的。

Keycloak.js

需要了解一件事情,就是 Keycloak 服务器上其实有一个 Keycloak js 文件,其路径是 /js/keycloak.js。比如 https://keycloak.jiwai.win/js/keycloak.js

我们先在二维码显示页面中引入该 js 文件。 html

接着我们就需要初始化 Keycloak 对象。这一步非常非常重要,因为要设置一些比如像 redirectUri,以及 PKCE 挑战码相关的算法等等。如果初始化不正确,就会导致按钮点击之后报各种错误。 html

再添加一个返回其他登录方式的按钮: html

注意 onclick 事件,就是调用 keycloak.login方法,但需要传入参数。 redirectUri 是指登录之后的回调页面地址,我这里设置为用户账号页面,比如: https://keycloak.jiwai.win/realms/hardway/account

另一个参数,idpHint,非常重要。因为我们在前面的步骤中设置了微信是默认的登录方式,所以这里的其他登录方式按钮,不能再回到这个登录方式,就需要通过 idpHint 来指向别的登录方式。这里我写了一个“username”,其实是一个不存在的身份供应商,这导致 Keycloak 渲染了用户名密码登录方式,正是我们需要的!

到这里,就完成了全部的开发,代码详见: https://github.com/Jeff-Tian/keycloak-services-social-weixin/blob/master/src/main/java/org/keycloak/social/weixin/resources/QrCodeResourceProvider.java java @GET @Path(mp-qr) @Produces(MediaType.TEXT_HTML) public Response mpQrUrl(@QueryParam(ticket) String ticket, @QueryParam(qr-code-url) String qrCodeUrl, @QueryParam(state) String state, @QueryParam(OAUTH2_PARAMETER_REDIRECT_URI) String redirectUri) { logger.info(展示一个 HTML 页面,该页面使用 React 展示一个组件,它调用一个后端 API,得到一个带参二维码 URL,并将该 URL 作为 img 的 src 属性值);

    var host = session.getContext().getUri().getBaseUri().toString();
    var realmName = session.getContext().getRealm().getName();
    var accountRedirectUri = host + /realms/ + realmName + /account;

    logger.info(String.format(host is %s, realmName is %s, host, realmName));

    var template = 
            <!DOCTYPE html>
            <html>
            <head>
                <title>QR Code Page</title>
            </head>
            <body>
                <p>请使用微信扫描下方二维码:</p>
                <div id=qrCodeContainer>
                    <img src=%s alt=%s>
            
                    <p></p>
                    <p><button id=login-by-username-password onclick=keycloak.login({ idpHint: username, redirectUri: %s }); type=button>使用密码登录</button></p>
                </div>
                <script type=text/javascript>
                    async function fetchQrScanStatus() {
                        const res = await fetch(mp-qr-scan-status?ticket=%s, {
                            headers: {
                                Content-Type: application/json
                            }
                        })
            
                        const {status, openid} = await res.json()
            
                        if (openid) {
                            window.location.href = %s?openid=${openid}&state=%s
                        } else {
                            setTimeout(fetchQrScanStatus, 1000)
                        }
                    }
            
                    fetchQrScanStatus()
                </script>
            
                <script src=/js/keycloak.js type=text/javascript></script>
                <script type=text/javascript>
                    const keycloak = new Keycloak({
                        url: %s,
                        realm: %s,
                        clientId: account-console,
                        redirectUri: %s
                    });
                    keycloak.init({onLoad: check-sso, pkceMethod: S256, promiseType: native});
                </script>
            </body>
            </html>
            ;

    String htmlContent = String.format(template, qrCodeUrl, ticket, accountRedirectUri, ticket, redirectUri, state, host, realmName, accountRedirectUri);

    // 返回包含HTML内容的响应
    return Response.ok(htmlContent, MediaType.TEXT_HTML_TYPE).build();
}

总结

通过设置 idpHint,可以指定 Keycloak 渲染不同的登录方式。我们先通过配置的方式,设置了微信登录为默认的登录方式。又通过设置 idpHint 为一个不存在的身份供应商,结合 keycloak.js 实现了在微信登录页面回到用户名密码登录方式的按钮。
出于演示,代码写得非常简单粗暴。首先,页面比较简陋,没有写 css 去美化,这是未来的改进方向。其次,在控制器方法里直接返回了一个 html 字符串实现了网页的渲染,而在拼接字符串时直接使用了 String.format 方法,这样做并不是好的工程实践,未来可以改为模板渲染方式。