欢迎光临散文网 会员登陆 & 注册

解决反向代理后的.Net Core网站进行第三方登录的Bug

2020-05-11 11:25 作者:杨中科  | 我要投稿

注:我会把开发过程中的一些技术经验分享出来。其实一直很犹豫是不是要把它们发表到B站,因为担心很多人看不懂,因为有的粉丝不是学编程的,或者没有涉及到我分享的技术领域。但是最终还是决定分享出来,毕竟哪怕只帮到一个人,也是有价值的。感觉我写的是天书的朋友们直接忽略即可。

正文:

我最近在开发youzack的背单词模块,在开发第三方登录(外部登录)的时候遇到了一些问题。网站提供了QQ登录、微软账号等方式,在开发环境没问题,但是部署到生产环境的时候,这些外部登录功能就工作不正常了。QQ登录提示“redirect uri is illegal”,微软登录提示“invalid_request: The provided value for the input parameter 'redirect_uri' is not valid. The expected value is a URI which matches a redirect URI registered for this client application.”仔细观察重定向到外部登录平台的地址参数,我发现redirect_uri参数(代表外部登录成功后,返回我们网站的回调地址)中的网址是http://开头,而不是我们网站的https://,但是在这些外部登录平台中登记的回调地址是https://开头的,这样就造成了redirect_uri校验不一致的问题。

 

因为我们的网站启用了阿里云的SLB,也就是负载均衡、反向代理服务器。我们把ssl证书配置到了SLB上,为了提升性能,SLB到我们的Web服务器用的是http通讯。用户访问我们的网站的时候,其实是访问的SLB服务器,SLB服务器再把请求转发给我们的Web服务器,因此对于Web服务器看来,Web请求是来自于SLB服务器的http请求,因此应用在构造redirect_uri的时候识别的Request.Scheme时是http而非https。解决这个问题很简单,.Net Core提供了很好的支持,只要在SLB反向代理上配置向Web服务器转发X-Forwarded-Proto(原始请求的协议)即可,这样反向代理服务器就会把原始的请求协议通过X-Forwarded-Proto这个报文头转发给Web服务器,Web服务器读取它就可以知道原始的协议是什么了。只要在Startup.cs的app.UseForwardedHeaders();即可,代码如下:

 ForwardedHeadersOptions options = new ForwardedHeadersOptions();

options.ForwardedHeaders =

              ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;

options.KnownNetworks.Clear();

options.KnownProxies.Clear();

app.UseForwardedHeaders(options);

 

ForwardedHeaders中间件会自动把反向代理服务器转发过来的X-Forwarded-For(客户端真实IP)以及X-Forwarded-Proto(客户端请求的协议)自动填充到HttpContext.Connection.RemoteIPAddress和HttpContext.Request.Scheme中,这样应用代码中读取到的就是真实的IP和真实的协议了,不需要应用做特殊处理。

哪怕你用到的反向代理服务器不支持转发X-Forwarded-Proto,那么也可以是自己编写中间件代码强制修改请求的Scheme的,代码如下:

 app.Use((context, next) =>

{

       context.Request.Scheme = "https";

       context.Request.IsHttps = true;

       return next();

});

 把代码部署上去之后,跳转到外部登录网站没有问题了。如果问题就这样解决了,那世界也太美好了。我发现了一个新问题:

在PC上QQ登录没问题,但是在手机上QQ登录会在登录完成回调到/signin-QQ的时候报错请求解析错误。

微软登录也是在回调到/signin-Microsoft的时候报错:AADSTS50011: The reply URL specified in the request does not match the reply URLs configured for the application。

 

这个问题困扰了我好半天,因为观察浏览器中传来传去的redirect_uri地址,都已经是https了。我突然想到,在OAuth中,有时候,我们的网站会在拿到外部网站返回的code之后,会由我们的网站服务器拿着code直接在后台(非浏览器端跳转)服务器向外部网站服务器去请求获得token,会不会在这里出现的问题呢。因此只能观察我们网站向外部网站发送的Http请求了,不过无论怎么调Logging配置,都无法把OAuth通过HttpClient向外部网站发送请求的Http日志打印出来,而且即使能打印出来,默认的日志也只是打印请求的URL,而报文头、报文体这些是看不到的。因此我决定改为直接通过代码来拦截请求报文。经过研究代码,我发现,.Net Core中所有外部登录的配置参数基类RemoteAuthenticationOptions中有Backchannel、BackchannelHttpHandler两个属性,是OAuth用来向外部服务器发送“code换token”请求的HttpClient相关的属性,而且都是可读可写的属性,因此我们只要用我们自己的类对象去赋值,就能拦截请求了。

编写如下继承自HttpClientHandler的类:

    public class LoggingHttpHandler : HttpClientHandler

    {

        private readonly ILogger logger; 

        public LoggingHttpHandler(ILogger<LoggingHttpHandler> logger)

        {

            this.logger = logger;

        }

        protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)

        {

            var uri = request.RequestUri;

            StringBuilder sb = new StringBuilder();

            sb.AppendLine($"uri={uri}"); 

            var headers = string.Join("\r\n", request.Headers.Select(h=>h.Key+"="+string.Join(",", h.Value)));

            sb.AppendLine($"headers={headers}");

            if (request.Method==HttpMethod.Post)

            {

                string content = await request.Content.ReadAsStringAsync();

                sb.AppendLine($"content={content}");

            }

            logger.LogDebug(sb.ToString());

            return await base.SendAsync(request, cancellationToken);

        }

}

上面的代码把请求的URL、报文头、报文体等内容都记录到了日志中。

然后通过下面的代码让它生效:

.AddMicrosoftAccount(opt =>

       {

              Configuration.GetSection("Authentication:Microsoft").Bind(opt);

              using(var sp = services.BuildServiceProvider())

              {

                     var logger = sp.GetRequiredService<ILogger<LoggingHttpHandler>>();

                     opt.BackchannelHttpHandler = new LoggingHttpHandler(logger);

              }       

       }

)

这样OAuth就通过我们的LoggingHttpHandler发送Http请求了。

运行代码,查看日志,发现了如下向https://login.microsoftonline.com/common/oauth2/v2.0/token发送的请求体:client_id=32e66666-fdb8-41ff-ac12-cb4aceabcde&redirect_uri=http%3A%2F%2Fbdc.youzack.com%2Fsignin-microsoft&client_secret=

注意看其中的redirect_uri的值是http://开头的,而非https://开头的。

好奇怪,我们的网站中拿到的scheme已经是https了,而且重定向到外部网站的请求中的redirect_uri中也是https了,怎么这个/signin-Microsoft回调中的请求拿到的还是http呢?

编写一个Action,打印scheme也是正确的https。

难道是UseForwardedHeaders有时候起作用,有时候不起作用?微软不会有这样低级的Bug吧?

只有从正常访问网页和/signin-Microsoft回调请求中找不同了,他们最大的不同就是/signin-Microsoft是Authentication中间件拦截的请求地址,难道是在请求/signin-Microsoft的时候UseForwardedHeaders中间件请求不起作用?

仔细检查Startup中的代码,我发现我犯了一个愚蠢的错误,就是把UseForwardedHeaders写到了UseAuthentication的后面。我们知道,.Net Core中的中间件是按照Use的顺序从前往后执行的,前面的中间件可以中断执行,这样后面的中间件就不会得到执行了。/signin-Microsoft这个回调是UseAuthentication中间件处理的,我把UseForwardedHeaders放到了它后面,当然执行/signin-Microsoft的时候拿到的就是原始的请求协议http,而不是由UseForwardedHeaders读取X-Forwarded-Proto修改后的https。调整顺序之后一切搞定!

 在ConfigureServices中注册服务的时候,一般情况下是不需要注意注册顺序的,但是在Configure中注册中间件的时候一定要注意中间件的执行顺序。

经过研究这个,我也发现了一个额外的收获。我的网站准备提供Facebook、Google等国外网站的外部登录功能,方便海外用户访问。但是我的服务器是放到中国国内的。我们知道,中国国内的网络是无法访问Facebook、Google的服务器的,因此在“用code换token”这一步会失败。既然我们可以定制OAuth的Backchannel、BackchannelHttpHandler,那么就可以对于Facebook、Google的OAuth配置中,对于他们的Backchannel、BackchannelHttpHandler启用代理设置,把请求转发给一个能访问海外服务器的中转服务器,这样就可以完美解决问题了。

解决反向代理后的.Net Core网站进行第三方登录的Bug的评论 (共 条)

分享到微博请遵守国家法律