解决反向代理后的.Net Core网站进行第三方登录的Bug
注:我会把开发过程中的一些技术经验分享出来。其实一直很犹豫是不是要把它们发表到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启用代理设置,把请求转发给一个能访问海外服务器的中转服务器,这样就可以完美解决问题了。

