1. 项目简介

这个小项目预期结果是让 Directus 支持使用钉钉账号来登录。 在了解OAuth2协议后(参见上一篇blog,参考资料1),已经有足够知识储备来实施。 Directus 原生支持使用GitHub登录, 所以,解决思路是先从GitHub入手。按下面步骤进行:

  • 配置Directus使用GitHub账号登录,熟悉Directus对OAuth的标准支持功能
  • 配置Directus使用钉钉账号登录,由于钉钉的协议实现和RFC6749/GitHub有不同,这里有可能需要见招拆招
  • 上线Directus到服务器环境,在钉钉的PC版和手机版验证

2. 环境配置

在本地用ngrok暴漏出一个服务,来接受OAuth服务器的redirect。

ngrok http 8055

得到 https://445a-240e-47c-30b0-3b10-600e-ea25-cde5-2334.ngrok.io/ 作为外网域名来访问本机8055端口的directus。

3. Directus 使用 GitHub账号登录

按参考资料2中配置参数。以下配置中,对每一个新的GitHub授权用户,Directus在登录过程中会使用用户email自动创建一个Directus用户,并且将其角色赋值为AUTH_GITHUB_DEFAULT_ROLE_ID。

A A A A A A A A A A # # U U U U U U U U U U T T T T T T T T T T A A H H H H H H H H H H U U _ _ _ _ _ _ _ _ _ _ T T P G G G G G G G G G H H R I I I I I I I I I _ _ O T T T T T T T T T G G V H H H H H H H H H I I I U U U U U U U U U T T D B B B B B B B B B H H E _ _ _ _ _ _ _ _ _ U U R D C C A A P A D I B B S R L L U C R L E C _ _ = I I I T C O L F O E I " V E E H E F O A N M D g E N N O S I W U = A E i R T T R S L _ L " I N t = _ _ I _ E P T g L T h " I S Z U _ U _ i _ I u o D E E R U B R t K F b a = C _ L R L O h E I " u " R U = L I L u Y E t 7 E R " = C E b = R h e T L h " _ _ " " _ 2 . = = t h R I e K " . " " t t E D m E . d h p t G = a Y . 5 t s p I " i = a . t : s S 0 l " e . p / : T f " e " . s / / R 5 m . : g / A f a . i a T 1 i . t p I b l . g h i O 5 " d i u . N a 9 t b g = - " h . i " 1 u c t t 0 b o h r 6 . m u u f c b e - l . " 4 m c e g o c l i m 7 o n - g u a i o s 8 n a e b / u r 8 o t " - a h 6 u / f t a 1 h c 1 / c 4 a e 8 u s 2 t s a h _ 0 o t 6 r o 0 i k f z e " e n " "

重启Directus让配置生效后,可以看到登录界面的GitHub选项。 picture 1

选择授权后,成功登录Directus。 检查Directus中新生成的用户和权限正常。 picture 2

4. Directus 使用钉钉账号登录的尝试

先照猫画虎配置下。

A A A A A A A A A A # # U U U U U U U U U U A A T T T T T T T T T T U U H H H H H H H H H H T T _ _ _ _ _ _ _ _ _ _ H H P D D D D D D D D D _ _ R I I I I I I I I I D D O N N N N N N N N N I I V G G G G G G G G G N N I T T T T T T T T T G G D A A A A A A A A A T T E L L L L L L L L L A A R K K K K K K K K K L L S _ _ _ _ _ _ _ _ _ K K = D C C A A P A D I _ _ " R L L U C R L E C E I g I I I T C O L F O M D i V E E H E F O A N A E t E N N O S I W U = I N h R T T R S L _ L " L T u = _ _ I _ E P T a _ I b " I S Z U _ U _ l K F , o D E E R U B R i E I a = C _ L R L O p Y E d u " R U = L I L a = R i t d E R " = C E y " _ n h i T L h " _ _ " e K g 2 n = = t h R I m E t " g " " t t E D a Y a . c h p t G = i = l . 6 t s p I " l " k . r t : s S 0 " e " t C p / : T f m x T s / / R 5 a t . : a / A f i " . p a T 1 l . / i p I b " h l . i O 5 4 o d . N a o g i d = - h i n i " 1 K n g n t 0 l . t g r 6 q d a t u f 5 i l a e - o n k l " 4 z g . k e " t c . c a o c 7 l m - k m a . v / 8 c 1 v b o . 1 8 m 0 . - / 0 6 o / f a a c 1 u u o 1 t t n 4 h h t 8 2 2 a 2 / / c a a u t 0 u s / 6 t e u 0 h r s f " A e " c r c s e / s m s e T " o k e n "

picture 3

点击Log In with Dingtalk可以正常授权, 但授权后被redirect到了

/ a d m i n / l o g i n ? r e a s o n = I N V A L I D _ U S E R

怀疑是钉钉重定向回来的链接没有code参数(参见上一篇协议解析,钉钉是用的authCode参数),第一时间先在社区开个issue看看有没有其他人碰到过。

同时,对oauth2的driver做了一个临时补丁, 当有authCode时候, 就把authCode赋值给code。

    try {
        res.clearCookie(`oauth2.${providerName}`);

        if ( req.query.authCode) {
            req.query.code = req.query.authCode
        }

        if (!req.query.code || !req.query.state) {
            logger.warn(`[OAuth2] Couldn't extract OAuth2 code or state from query: ${JSON.stringify(req.query)}`);
        }

        authResponse = await authenticationService.login(providerName, {
            code: req.query.code,
            codeVerifier: verifier,
            state: req.query.state,
        });
    } catch (error: any) {
        ...

再次重启directus后, 补丁似乎生效了, 这次被重定向到了。

/ a d m i n / l o g i n ? r e a s o n = S E R V I C E _ U N A V A I L A B L E

OAuth2协议的第一步获取code已经通过了。 SERVICE_UNAVAILABLE 是获取token出问题,还是取profile出问题了?

注意到钉钉获取token的请求中,参数名称是clientId,clientSecret。 而GitHub是client_id,client_secret. 另外钉钉还需要一个额外的grantType.

{ } " " " " c c c g l l o r i i d a e e e n n n " t t t T I S : y d e p " c " e r 6 " : e b t 4 : " " 2 d 7 " i : e a n 8 u g " b t y f h y o a o o u b r u r 8 i r 3 z s e a i e 9 t d c 3 i " r b o , e e n t d _ " d c , 1 o 3 d f e 1 " 6 a 4 3 0 7 0 2 " ,

把clientId,clientSecret和grantType作为参数配置到directus请求中。

A U T H _ D I N G T A L K _ P A R A M S = " { \ " c l i e n t I d \ " : \ " d i n . . . t x t \ " , \ " c l i e n t S e c r e t \ " : \ " d 5 6 . . . . 5 8 b d 9 \ " , \ " g r a n t T y p e \ " : \ " a u t h o r i z a t i o n _ c o d e \ " } "

仍然是SERVICE_UNAVAILABLE。 检查driver,发现问题出在下面:

    try {
        tokenSet = await this.client.oauthCallback(
            this.redirectUrl,
            { code: payload.code, state: payload.state },
            { code_verifier: payload.codeVerifier, state: generators.codeChallenge(payload.codeVerifier) }
        );
        userInfo = await this.client.userinfo(tokenSet.access_token!);
    } catch (e) {
        throw handleError(e);
    }

上面代码抛出异常了,原因是HTTP请求得到的响应是400. 应该是钉钉OAuth服务器不识别Directus发过去的消息。

上面代码执行背景是:

  • 在oauth2 driver中,配置了express路由处理钉钉redirect过来的code,在处理过程中,需要认证用户(认证成功会完成登录,发放JWT token);
  • 用户认证和driver无关,用一个通用的AuthenticationService.login服务处理, 在服务中,又调用driver的getUserID方法来获取userId;
  • 对于oauth2 driver来说,网页上没传过来用户名密码,其唯一输入就是钉钉redirect过来的code,需要通过OAuth接口,把code转换成token,然后读取用户信息,才能知道userID。

OAuth2 Driver使用了openid-client 和服务器通信。其client也在driver中初始化:

    const issuer = new Issuer({
        authorization_endpoint: authorizeUrl,
        token_endpoint: accessUrl,
        userinfo_endpoint: profileUrl,
        issuer: additionalConfig.provider,
    });

    this.client = new issuer.Client({
        client_id: clientId,
        client_secret: clientSecret,
        redirect_uris: [this.redirectUrl],
        response_types: ['code'],
    });

所以问题细化成了 openid-client 和钉钉的兼容性。再具体一些,是如何用oauthCallback函数来从钉钉处获取token。

看了下openid-client的实现,其和OAuth服务器交互时候,POST的表单数据是按照RFC6749中定义的参数名称硬编码的。 必然和钉钉的要求不匹配。 使用openid-client没有办法兼容钉钉。 将调研结果和directus OAuth Driver的作者在Integrating Dingtalk as OAuth2 server 做了详细的探讨。

5. 结论

原定计划无法达成。 原因是钉钉的OAuth实现和标准不兼容。而Directus使用了第三方的OAuth库来和OAuth服务器通信。 基于标准的openid-client和说方言的钉钉OAuth服务器无法沟通。

考虑两种方案:

  1. 从directus标准oauth2 driver中继承,实现一个钉钉方言版本的oauth2-dingtalk driver, 或者
  2. 实现一个proxy,来做钉钉的OAuth方言和标准OAuth2协议的翻译

倾向于方案2, 相当于给钉钉做一个协议封装层,按照标准转换下参数格式。这样后续有其他系统需要集成钉钉登录,也可以用的上。

后续完成后再补记。

6. 补记

参考apiproxy 使用上述方案2实现了钉钉免密登录。

7. 参考资料