解決反向代理后的.Net Core網(wǎng)站進(jìn)行第三方登錄的Bug
注:我會(huì)把開(kāi)發(fā)過(guò)程中的一些技術(shù)經(jīng)驗(yàn)分享出來(lái)。其實(shí)一直很猶豫是不是要把它們發(fā)表到B站,因?yàn)閾?dān)心很多人看不懂,因?yàn)橛械姆劢z不是學(xué)編程的,或者沒(méi)有涉及到我分享的技術(shù)領(lǐng)域。但是最終還是決定分享出來(lái),畢竟哪怕只幫到一個(gè)人,也是有價(jià)值的。感覺(jué)我寫(xiě)的是天書(shū)的朋友們直接忽略即可。
正文:
我最近在開(kāi)發(fā)youzack的背單詞模塊,在開(kāi)發(fā)第三方登錄(外部登錄)的時(shí)候遇到了一些問(wèn)題。網(wǎng)站提供了QQ登錄、微軟賬號(hào)等方式,在開(kāi)發(fā)環(huán)境沒(méi)問(wèn)題,但是部署到生產(chǎn)環(huán)境的時(shí)候,這些外部登錄功能就工作不正常了。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.”仔細(xì)觀(guān)察重定向到外部登錄平臺(tái)的地址參數(shù),我發(fā)現(xiàn)redirect_uri參數(shù)(代表外部登錄成功后,返回我們網(wǎng)站的回調(diào)地址)中的網(wǎng)址是http://開(kāi)頭,而不是我們網(wǎng)站的https://,但是在這些外部登錄平臺(tái)中登記的回調(diào)地址是https://開(kāi)頭的,這樣就造成了redirect_uri校驗(yàn)不一致的問(wèn)題。
?

因?yàn)槲覀兊木W(wǎng)站啟用了阿里云的SLB,也就是負(fù)載均衡、反向代理服務(wù)器。我們把ssl證書(shū)配置到了SLB上,為了提升性能,SLB到我們的Web服務(wù)器用的是http通訊。用戶(hù)訪(fǎng)問(wèn)我們的網(wǎng)站的時(shí)候,其實(shí)是訪(fǎng)問(wèn)的SLB服務(wù)器,SLB服務(wù)器再把請(qǐng)求轉(zhuǎn)發(fā)給我們的Web服務(wù)器,因此對(duì)于Web服務(wù)器看來(lái),Web請(qǐng)求是來(lái)自于SLB服務(wù)器的http請(qǐng)求,因此應(yīng)用在構(gòu)造redirect_uri的時(shí)候識(shí)別的Request.Scheme時(shí)是http而非https。解決這個(gè)問(wèn)題很簡(jiǎn)單,.Net Core提供了很好的支持,只要在SLB反向代理上配置向Web服務(wù)器轉(zhuǎn)發(fā)X-Forwarded-Proto(原始請(qǐng)求的協(xié)議)即可,這樣反向代理服務(wù)器就會(huì)把原始的請(qǐng)求協(xié)議通過(guò)X-Forwarded-Proto這個(gè)報(bào)文頭轉(zhuǎn)發(fā)給Web服務(wù)器,Web服務(wù)器讀取它就可以知道原始的協(xié)議是什么了。只要在Startup.cs的app.UseForwardedHeaders();即可,代碼如下:
?ForwardedHeadersOptions options = new ForwardedHeadersOptions();
options.ForwardedHeaders =
????????????? ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
options.KnownNetworks.Clear();
options.KnownProxies.Clear();
app.UseForwardedHeaders(options);
?
ForwardedHeaders中間件會(huì)自動(dòng)把反向代理服務(wù)器轉(zhuǎn)發(fā)過(guò)來(lái)的X-Forwarded-For(客戶(hù)端真實(shí)IP)以及X-Forwarded-Proto(客戶(hù)端請(qǐng)求的協(xié)議)自動(dòng)填充到HttpContext.Connection.RemoteIPAddress和HttpContext.Request.Scheme中,這樣應(yīng)用代碼中讀取到的就是真實(shí)的IP和真實(shí)的協(xié)議了,不需要應(yīng)用做特殊處理。
哪怕你用到的反向代理服務(wù)器不支持轉(zhuǎn)發(fā)X-Forwarded-Proto,那么也可以是自己編寫(xiě)中間件代碼強(qiáng)制修改請(qǐng)求的Scheme的,代碼如下:
?app.Use((context, next) =>
{
?????? context.Request.Scheme = "https";
?????? context.Request.IsHttps = true;
?????? return next();
});
?把代碼部署上去之后,跳轉(zhuǎn)到外部登錄網(wǎng)站沒(méi)有問(wèn)題了。如果問(wèn)題就這樣解決了,那世界也太美好了。我發(fā)現(xiàn)了一個(gè)新問(wèn)題:
在PC上QQ登錄沒(méi)問(wèn)題,但是在手機(jī)上QQ登錄會(huì)在登錄完成回調(diào)到/signin-QQ的時(shí)候報(bào)錯(cuò)請(qǐng)求解析錯(cuò)誤。
微軟登錄也是在回調(diào)到/signin-Microsoft的時(shí)候報(bào)錯(cuò):AADSTS50011: The reply URL specified in the request does not match the reply URLs configured for the application。
?
這個(gè)問(wèn)題困擾了我好半天,因?yàn)橛^(guān)察瀏覽器中傳來(lái)傳去的redirect_uri地址,都已經(jīng)是https了。我突然想到,在OAuth中,有時(shí)候,我們的網(wǎng)站會(huì)在拿到外部網(wǎng)站返回的code之后,會(huì)由我們的網(wǎng)站服務(wù)器拿著code直接在后臺(tái)(非瀏覽器端跳轉(zhuǎn))服務(wù)器向外部網(wǎng)站服務(wù)器去請(qǐng)求獲得token,會(huì)不會(huì)在這里出現(xiàn)的問(wèn)題呢。因此只能觀(guān)察我們網(wǎng)站向外部網(wǎng)站發(fā)送的Http請(qǐng)求了,不過(guò)無(wú)論怎么調(diào)Logging配置,都無(wú)法把OAuth通過(guò)HttpClient向外部網(wǎng)站發(fā)送請(qǐng)求的Http日志打印出來(lái),而且即使能打印出來(lái),默認(rèn)的日志也只是打印請(qǐng)求的URL,而報(bào)文頭、報(bào)文體這些是看不到的。因此我決定改為直接通過(guò)代碼來(lái)攔截請(qǐng)求報(bào)文。經(jīng)過(guò)研究代碼,我發(fā)現(xiàn),.Net Core中所有外部登錄的配置參數(shù)基類(lèi)RemoteAuthenticationOptions中有Backchannel、BackchannelHttpHandler兩個(gè)屬性,是OAuth用來(lái)向外部服務(wù)器發(fā)送“code換token”請(qǐng)求的HttpClient相關(guān)的屬性,而且都是可讀可寫(xiě)的屬性,因此我們只要用我們自己的類(lèi)對(duì)象去賦值,就能攔截請(qǐng)求了。
編寫(xiě)如下繼承自HttpClientHandler的類(lèi):
??? 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);
??????? }
}
上面的代碼把請(qǐng)求的URL、報(bào)文頭、報(bào)文體等內(nèi)容都記錄到了日志中。
然后通過(guò)下面的代碼讓它生效:
.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就通過(guò)我們的LoggingHttpHandler發(fā)送Http請(qǐng)求了。
運(yùn)行代碼,查看日志,發(fā)現(xiàn)了如下向https://login.microsoftonline.com/common/oauth2/v2.0/token發(fā)送的請(qǐng)求體:client_id=32e66666-fdb8-41ff-ac12-cb4aceabcde&redirect_uri=http%3A%2F%2Fbdc.youzack.com%2Fsignin-microsoft&client_secret=
注意看其中的redirect_uri的值是http://開(kāi)頭的,而非https://開(kāi)頭的。
好奇怪,我們的網(wǎng)站中拿到的scheme已經(jīng)是https了,而且重定向到外部網(wǎng)站的請(qǐng)求中的redirect_uri中也是https了,怎么這個(gè)/signin-Microsoft回調(diào)中的請(qǐng)求拿到的還是http呢?
編寫(xiě)一個(gè)Action,打印scheme也是正確的https。
難道是UseForwardedHeaders有時(shí)候起作用,有時(shí)候不起作用?微軟不會(huì)有這樣低級(jí)的Bug吧?
只有從正常訪(fǎng)問(wèn)網(wǎng)頁(yè)和/signin-Microsoft回調(diào)請(qǐng)求中找不同了,他們最大的不同就是/signin-Microsoft是Authentication中間件攔截的請(qǐng)求地址,難道是在請(qǐng)求/signin-Microsoft的時(shí)候UseForwardedHeaders中間件請(qǐng)求不起作用?
仔細(xì)檢查Startup中的代碼,我發(fā)現(xiàn)我犯了一個(gè)愚蠢的錯(cuò)誤,就是把UseForwardedHeaders寫(xiě)到了UseAuthentication的后面。我們知道,.Net Core中的中間件是按照Use的順序從前往后執(zhí)行的,前面的中間件可以中斷執(zhí)行,這樣后面的中間件就不會(huì)得到執(zhí)行了。/signin-Microsoft這個(gè)回調(diào)是UseAuthentication中間件處理的,我把UseForwardedHeaders放到了它后面,當(dāng)然執(zhí)行/signin-Microsoft的時(shí)候拿到的就是原始的請(qǐng)求協(xié)議http,而不是由UseForwardedHeaders讀取X-Forwarded-Proto修改后的https。調(diào)整順序之后一切搞定!
?在ConfigureServices中注冊(cè)服務(wù)的時(shí)候,一般情況下是不需要注意注冊(cè)順序的,但是在Configure中注冊(cè)中間件的時(shí)候一定要注意中間件的執(zhí)行順序。
經(jīng)過(guò)研究這個(gè),我也發(fā)現(xiàn)了一個(gè)額外的收獲。我的網(wǎng)站準(zhǔn)備提供Facebook、Google等國(guó)外網(wǎng)站的外部登錄功能,方便海外用戶(hù)訪(fǎng)問(wèn)。但是我的服務(wù)器是放到中國(guó)國(guó)內(nèi)的。我們知道,中國(guó)國(guó)內(nèi)的網(wǎng)絡(luò)是無(wú)法訪(fǎng)問(wèn)Facebook、Google的服務(wù)器的,因此在“用code換token”這一步會(huì)失敗。既然我們可以定制OAuth的Backchannel、BackchannelHttpHandler,那么就可以對(duì)于Facebook、Google的OAuth配置中,對(duì)于他們的Backchannel、BackchannelHttpHandler啟用代理設(shè)置,把請(qǐng)求轉(zhuǎn)發(fā)給一個(gè)能訪(fǎng)問(wèn)海外服務(wù)器的中轉(zhuǎn)服務(wù)器,這樣就可以完美解決問(wèn)題了。