问题

在《身份验证哪家强?IdentityServer 初体验 - Jeff Tian的文章 - 知乎 》中,我详细叙述了如何在 dotnet web 应用里增加企业微信登录。不知道你有没有注意到一个细节,就是在企业微信里创建的应用,只能配置一个授权回调域。

1711618050523 93d8f4cb 53e9 441f b0f9 f56efb3e2f59

如果你有过对接其他身份认证系统或者开放平台的经验,你应该见过很多身份认证系统(比如 Keycloak)或者开放平台,都支持配置多个授权回调域,以便开发者在不同的环境中进行联调,比如 Keycloak 里,就允许配置多个不同的回调地址,这样就可以在不同的环境里(不同的回调 URL)共用同一个应用。

1711618147400 bb6421b8 3344 40eb 91fd 7606314f2648

但是企业微信里的应用,只允许配置一个授权回调域,导致同样的应用,但是有多套环境,比如:开发环境、测试环境、验收环境等,就无法共用同一个应用了。当然,这里是指不同的环境有着不同的域名,如果都在同一个域名之下,使用不同的路径来区分不同的环境,那么这个问题也不存在。

重述一下这个问题:同一个应用,具有多个分身(域名),但是在企业微信里,一个应用只能配置一个授权回调域,即只有一个分身能够正常工作(获取到用户的授权码)。但是希望所有的分身都能获取到用户的授权码,怎么办?

如果直接在分身应用里尝试企业微信登录,会在授权阶段跳转到企业微信的页面时,得不到二维码,而是一个错误信息: r<font style=color:rgb(23, 43, 77);>edirect_uri 与配置的授权完成回调域名不一致。

可能的解决方案

土豪版解决方案

为每个环境申请一套企业微信,并分别注册企业,这样授权回调域分别在不同环境中的企业微信中指定,互不影响。之所以是土豪版,是因为成本很高。这不仅要多维护几个企业微信,还需要为每套环境维护使用人群,而实际上这些人群都是同一批,不仅管理成本翻了多倍,而且用户在手机上需要在不同的企业之间来回切换,所以成本太高,只有土豪能负担得起。

中产版解决方案

同一套企业微信,但是为不同环境的相同应用,申请不同的企业微信应用。这是一个非常可行的方案,只是一次性地配置要重复弄几次,但是好在一劳永逸。

赤贫版解决方案

如果不想申请多个企业微信,或者也不想在同一个企业微信里创建多个应用,那就只能靠这个办法了!

<font style=color:rgb(119, 119, 119);> 计算机科学领域的任何问题都可以通过增加一个间接的中间层来解决。

<font style=color:rgb(119, 119, 119);>尽管我们现在的问题谈不上是计算机科学,但不妨借鉴一下这个思想,通过添加一个中间层来尝试解决这个问题。

<font style=color:rgb(119, 119, 119);>效果

完美解决了这个问题,只是非指定的授权回调域,会需要多一个额外的跳转,即将指定好的授权回调,做为了一个中间商,企业微信跳回这个指定的授权回调域之后,授权回调域将 code 和 state 再二次转发给目标域。一个简化的时序图如下(授权回调域是我们配置好的第一个环境里的应用,而现在我们将它部署到了第二个环境,在目标回调域上):

1711624120870 94bdd823 a114 43e4 96aa e5e7a71f7f50

风险与规避

这样做会带来一些风险,比如恶意站点也利用这个中间商(即授权回调域)的二次跳转链接,来获取用户的信息,甚至可以以该用户的身份来做一些更多的操作。

所以,要使用这个方案,就需要授权回调域对目标回调域做校验,看看是不是自己的分身。只对自己的分身做二次跳转。

当然,即使校验不严格,问题也不大。因为 code 被盗用,但是盗用者没有 appid 、app secret 的话,也获取不到用户的令牌。

代码分析

要实现这个方案,不需要任务配置,但是需要写一点代码。我们就以 dotnet web 应用程序为例,先来看看已有代码,即在 dotnet web 应用里如何对接企业微信登录。如《身份验证哪家强?IdentityServer 初体验 - Jeff Tian的文章 - 知乎 》所写的,关键代码如下:

csharp private static void AddExternalIdentityProviders(this WebApplicationBuilder builder) { builder.Services.AddAuthentication() ... .AddWorkWeixin(wecom, 企业微信, options => { options.SignInScheme = IdentityServerConstants.ExternalCookieAuthenticationScheme; options.ClientId = 替换成你自己的; options.ClientSecret = 替换成你自己的; options.AgentId = 替换成你自己的; }); }

我们要做的改动,就在于可以在 options 里添加一些钩子。从上面的时序图里可以看出,这个钩子就是在构建授权链接的时刻,要对 redirectUri 做一些替换的工作。

替换完成后,企业微信服务器会将 code 和 state 发送到授权回调域的一个路径上,不妨叫它二次跳转路径。所以我们还需要实现一下这个二次跳转路径(不实现的话,企业微信服务器发回 code 和 state 后,页面会停在那里不动,显示一个 404,这时可以手动跳转到目标回调域,也能正常工作)。

具体的端点说明

在实际改动前不妨使用开发者工具,观察一下通过授权回调域进行企业微信登录的具体端点流程:

  1. 首先,任何应用在构建完企业微信授权链接之后,都会跳转到这样的 URL 地址:<font style=color:rgb(23, 43, 77);>https://open.work.weixin.qq.com/wwopen/sso/qrConnect?appid=你的企业微信应用 ID&agentid=你的企业微信应用的 agentID&redirect_uri=https%3A%2F%2F授权.回调.域%2Fsignin-workweixin&state=授权回调域生成的state

  2. 这时浏览器展示了企业微信的网页,即一个二维码。同时,浏览器开始发送 AJAX 请求,查询用户的扫码状态。这个请求是这样的: https://open.work.weixin.qq.com/wwopen/sso/l/qrConnect?callback=jsonpCallback&key=faada3dc7c1064d5&redirect_uri=https%3A%2F%2F授权.回调.域%2Fsignin-workweixin&appid=<font style=color:rgb(23, 43, 77);>你的企业微信应用 ID&_=1711421384447

  3. 在用户没扫码时,以上请求会得到这样的响应:<font style=color:rgb(23, 43, 77);>jsonpCallback({status:QRCODE_SCAN_ING,auth_code:})

  4. 一旦扫码并确认登录,企业微信服务器会返回用户的授权码信息给到浏览器,浏览器会将该授权码通过跳转传给授权回调域,即重定向到 https://授权.回调.域/signin-workweixin?code=OdXR0mK6T_CJpti-pCe-nFGV3d96h9bfLvDVJu0NHHg&state=<font style=color:rgb(23, 43, 77);>授权回调域生成的state

  5. 以我们举的这个 dotnet web 应用为例,以上地址会再次 302 重定向至自己的 /ExternalLogin/Callback 路由

  6. 这时浏览器来到了: https://授权.回调.域/ExternalLogin/Callback 这个页面对应的逻辑会完成用户登录的过程。

实现帮助函数

由于在钩子环节,我们要进行 redirectUri 的替换,所以先实现一个能做这件事情的帮助函数。为此,我们先将期待的工作方式用测试代码表达出来:

csharp using FluentAssertions; using IdentityPlatform.Extensions.ExternalLogins; using JetBrains.Annotations; using Xunit;

namespace XXX.Tests.Extensions.ExternalLogins;

[TestSubject(typeof(WeComHelper))] public class WeComHelperTest { [Fact] public void Should_ReplaceRedirectUriTo授权回调域_For目标回调域() { // arrange const string targetRedirectUri = https://open.work.weixin.qq.com/wwopen/sso/qrConnect?appid=你的企业微信应用ID&agentid=你的企业微信应用的agentID&redirect_uri=https%3A%2F%2F目标.授权.域%2Fsignin-workweixin&state=授权回调域生成的state;

    // act
    var result = WeComHelper.ReplaceWeComRedirectUri(targetRedirectUri);

    // assert
    result.Should()
        .Be(
            https://open.work.weixin.qq.com/wwopen/sso/qrConnect?appid=你的企业微信应用ID&agentid=你的企业微信应用的agentID&redirect_uri=https%3a%2f%2f授权.回调.域%2fapi%2fv1%2fRedirect%3fredirectUri%3dhttps%3a%2f%2f目标.回调.域%2fsignin-workweixin&state=授权回调域生成的state);
}

}

要让以上测试通过,需要有这样的实现代码:

csharp using System.Web;

namespace XXX.Extensions.ExternalLogins;

public class WeComHelper { public static string ReplaceWeComRedirectUri(string redirectUri) { var uri = new Uri(redirectUri);

    var parsedQuery = HttpUtility.ParseQueryString(uri.Query);

    var originalRedirectTarget = parsedQuery.Get(redirect_uri);

    if (originalRedirectTarget.StartsWith(https://目标.回调.域))
    {
        var newRedirectTarget = https://授权.回调.域/api/v1/Redirect?redirectUri= +
                                originalRedirectTarget;

        parsedQuery.Set(redirect_uri, newRedirectTarget);
    }

    return uri.Scheme + :// + uri.Host + uri.AbsolutePath + ? + parsedQuery;
}

}

实现授权钩子

然后,我们来添加一个钩子:

csharp options.Events = new OAuthEvents { OnRedirectToAuthorizationEndpoint = context => { var currentHost = context.Request.Host.Value;

    if (currentHost.Contains(授权.回调.域))
    {
        // 对于授权回调域自己来说,就啥也不改,原样跳转
        context.Response.Redirect(context.RedirectUri);
    }
    else
    {
        // 否则,我们就将 redirectUri 替换成授权回调域
        context.Response.Redirect(WeComHelper.ReplaceWeComRedirectUri(context.RedirectUri));
    }

    return Task.CompletedTask;
}

};

实现二次跳转路由

在实现帮助函数时,你大概注意到了 /api/v1/Redirect?redirectUri 这个路由。这个路由本不存在,所以在实现钩子后,需要手工跳转,我们现在将手工的这一步自动化。

这个二次跳转路由要做的事情就是将 code 和 state 中继给目标回调域,同样地,我们用测试代码来表达这个意图:

csharp using System.Net; using FluentAssertions; using Microsoft.AspNetCore.Mvc.Testing; using Xunit;

namespace XXX.IntegrationTests.Features.Redirect;

public class RedirectApiTests : BaseTest { [Fact] public async Task Should_Return302() { // Act var response = await Client.GetAsync(api/v1/Redirect?redirectUri=https://www.google.com);

    // Assert
    response.StatusCode.Should().Be(HttpStatusCode.Redirect);
}

[Fact]
public async Task Should_Return302WithQueryString_WhenQueryStringIsPassed()
{
    // Act
    var response = await Client.GetAsync(api/v1/Redirect?redirectUri=https://www.google.com&code=123&state=456);

    // Assert
    response.StatusCode.Should().Be(HttpStatusCode.Redirect);
    response.Headers.Location.Should().Be(https://www.google.com?code=123&state=456);
}

public RedirectApiTests(WebApplicationFactory<Program> factory) : base(factory)
{
}

}

using Microsoft.AspNetCore.Mvc.Testing; using Xunit;

namespace XXX.IntegrationTests;

public class BaseTest : IClassFixture<WebApplicationFactory>, IAsyncDisposable { protected readonly HttpClient Client;

public BaseTest(WebApplicationFactory<Program> factory)
{
    Client ??= factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false, });
}

public async ValueTask DisposeAsync()
{
    Client.Dispose();
}

}

要让以上测试通过,需要有如下的实现代码:

csharp using Microsoft.AspNetCore.Mvc;

namespace XXX.Controllers.v1;

[Route(api/v1/[controller])] [ApiController] public class RedirectController : ControllerBase { [HttpGet] public IActionResult RedirectTo([FromQuery] string redirectUri, [FromQuery] string code, [FromQuery] string state) { if (!string.IsNullOrEmpty(code) && !string.IsNullOrEmpty(state)) { return Redirect(${redirectUri}?code={code}&state={state}); }

    return Redirect(redirectUri);
}

}

至此,我们对授权回调域的改造就完成了!

总结

通过写一点点代码,省去了很多配置和管理上的麻烦,还是值得的。从此,一个应用,多个分身,都可以通过同一个企业微信应用客户端接入企业微信登录了。