在Asp.net WebAPI中,認證是通過AuthenticationFilter過濾器實現的,我們通常的做法是自定義AuthenticationFilter,實現認證邏輯,認證通過,繼續管道處理,認證失敗,直接返回認證失敗結果,類似如下:
public async Task AuthenticateAsync(HttpAuthenticationContext context, CancellationToken cancellationToken)
{
var principal = await this.AuthenticateAsync(context.Request);
if (principal == null)
{
context.Request.Headers.GetCookies().Clear();
context.ErrorResult = new AuthenticationFailureResult("未授權請求", context.Request);
}
else
{
context.Principal = principal;
}
}
但在.net core中,AuthenticationFilter已經不復存在,取而代之的是認證中間件。至於理由,我想應該是微軟覺得Authentication並非業務緊密相關的,放在管道中間件中更合適。那么,話說回來,在.net core中,我們應該怎么實現認證呢?如大家所願,微軟已經為我們提供了認證中間件。這里以CookieAuthenticationMiddleware中間件為例,來介紹認證的實現。
1、引用Microsoft.AspNetCore.Authentication.Cookies包。項目實踐中引用的是"Microsoft.AspNetCore.Authentication.Cookies": "1.1.0"。
2、Startup中注冊及配置認證、授權服務:
服務注冊:
services.AddMvc(options =>
{
//添加模型綁定過濾器
options.Filters.Add(typeof(ModelValidateActionFilter));
//添加授權過濾器,以便強制執行Authentication跳轉及屏蔽邏輯
//var policy = new AuthorizationPolicyBuilder().RequireAuthenticatedUser().Build();
var policy = new AuthorizationPolicyBuilder().AddRequirements(new AuthenticationRequirement()).Build();
options.Filters.Add(new AuthorizeFilter(policy));
});
//services.AddAuthorization(options =>
//{
// options.AddPolicy("RequireAuthentication", policy => policy.AddRequirements(new AuthenticationRequirement()));
//});
大家注意,上面代碼中有兩處注釋掉的地方。第一處注釋,RequireAuthenticatedUser()是.net core預定義的授權驗證,代表通過授權驗證的最低要求是提供經過認證的Identity。Demo中,我的要求也是這個,只要是經過基本認證的用戶即可,那為什么Demo中沒有使用呢?因為這里是個坑!實際實踐中,我發現,采用注釋中的做法,無論如何,調用總是返回401,迫不得已,download認證及授權源碼,發現該處邏輯是這樣的:
var user = context.User;
var userIsAnonymous =
user?.Identity == null ||
!user.Identities.Any(i => i.IsAuthenticated);
if (!userIsAnonymous)
{
context.Succeed(requirement);
}
加入斷點猛調,發現IsAuthenticated永遠是false!!!迫不得已,反編譯查看源碼,發現ClaimsIdentity的IsAuthenticated屬性是這樣定義的:

WTF!!!坑爹么這是!!!.net framework中, 記得 這里的邏輯是,只要Name非空,就返回true,到了.net core中成了這樣,你說坑不坑。。。
那怎么辦?總不能放棄吧?我想,大家第一想法應該是繼承ClaimsIdentity自定義一個Identity,尤其是看到屬性上那個virtual的時候,我也不例外。可繼承后, 發現認證框架那兒依然不認,還是一直返回false,可能是我哪里用的不對吧。所以,Startup中第一處注釋出現了。最終解決方案是自定義AuthenticationRequirement及處理器,實現要求的驗證,如下:
public class AuthenticationRequirement : AuthorizationHandler<AuthenticationRequirement>, IAuthorizationRequirement
{
protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, AuthenticationRequirement requirement)
{
var user = context.User;
var userIsAnonymous =
user?.Identity == null
|| string.IsNullOrWhiteSpace(user.Identity.Name);
if (!userIsAnonymous)
{
context.Succeed(requirement);
}
return TaskCache.CompletedTask;
}
}
上述代碼紅色的部分便是相對默認實現變化的部分。
startup中第二部分注釋,是注冊授權策略的,注冊方法也是官網文檔中給出的注冊方法。那為什么這里又沒有采用呢?因為,如果按注釋中的方法配置,我需要在每個希望認證的控制器或方法上都用Authorize標記,甚至還需要在特性上配置角色或策略,而這里我的預設是全局認證,所以,直接以全局過濾器的形式添加到了MVC處理管道中。讀到這里,細心的讀者應該有疑問了,你一個簡單的認證,跟授權毛線關系啊,注冊授權過濾器作甚!我也覺得沒關系啊,這是net core認證的第二個坑,那就是,在.net core或者微軟看來,認證僅僅提供Principal的生成、序列化、反序列化及重新生成Principal,它的職責確實也包括了返回401、403等各種認證失敗信息,但這部分不會主動觸發,必須有處理管道中其他邏輯去觸發。我仔細閱讀了官網文檔,得出的大致結論是,.net core大概認為,認證是個多樣化的過程,不光有我們目前看到的或需要的某一種認證,實際需求中很可能會多種認證並存,我們的API也可能會同時允許多種認證方式通過,所以某一種認證失敗就直接返回401或403是錯誤的。這是實踐當中第二個坑!那話說回來,添加了授權,就可以觸發這個過程,這個是看源碼發現的,具體流程就是,如果授權失敗,過濾器會返回一個challengeResult,這個Result最終會跑到認證中間件中的對應Challenge方法,在.net core源碼中表現如下:
public async Task ChallengeAsync(ChallengeContext context)
{
ChallengeCalled = true;
var handled = false;
if (ShouldHandleScheme(context.AuthenticationScheme, Options.AutomaticChallenge))
{
switch (context.Behavior)
{
case ChallengeBehavior.Automatic:
// If there is a principal already, invoke the forbidden code path
var result = await HandleAuthenticateOnceSafeAsync();
if (result?.Ticket?.Principal != null)
{
goto case ChallengeBehavior.Forbidden;
}
goto case ChallengeBehavior.Unauthorized;
case ChallengeBehavior.Unauthorized:
handled = await HandleUnauthorizedAsync(context);
Logger.AuthenticationSchemeChallenged(Options.AuthenticationScheme);
break;
case ChallengeBehavior.Forbidden:
handled = await HandleForbiddenAsync(context);
Logger.AuthenticationSchemeForbidden(Options.AuthenticationScheme);
break;
}
context.Accept();
}
if (!handled && PriorHandler != null)
{
await PriorHandler.ChallengeAsync(context);
}
}
以其中HandleForbiddenAsync為例,具體又如下:
/// <summary>
/// Override this method to deal with a challenge that is forbidden.
/// </summary>
/// <param name="context"></param>
protected virtual Task<bool> HandleForbiddenAsync(ChallengeContext context)
{
Response.StatusCode = 403;
return Task.FromResult(true);
}
這樣,經由授權流程觸發Challenge,Challenge返回相應驗證結果到API調用方。
注冊完了認證及授權所需相關服務,接下來注冊中間件,如下:
app.UseCookieAuthentication(new CookieAuthenticationOptions
{
AuthenticationScheme = "GuoKun",
AutomaticAuthenticate = true,
AutomaticChallenge = true,
DataProtectionProvider = DataProtectionProvider.Create(new DirectoryInfo(env.ContentRootPath))
});
app.UseMvc();
注意UseCookieAuthentication要放在UseMvc前面。大家注意其中紅色部分,這里為什么要自己手動創建DataProtectionProvider呢?因為這里是要做服務集群的,如果單機或單服務實例情況下,采用默認DataProtection機制就可以了。代碼中手動指定目錄創建,與默認實現的區別就是,默認實現會生成一個與當前機器及應用相關的key進行數據加解密,而手動指定目錄創建provider,會在指定的目錄下生成一個key的xml文件。這樣,服務集群部署時候,加解密key一樣,加解密得到的報文也是一致的。別問我怎么知道的,踩過坑,使勁兒調試,外加看官網文檔,淚流滿面。。。
3、添加控制器模擬登陸及認證授權
[Route("api/[controller]")]
public class AccountController : Controller
{
[AllowAnonymous]
[HttpPost("login")]
public async Task Login([FromBody]User user)
{
IEnumerable<Claim> claims = new List<Claim>()
{
new Claim(ClaimTypes.Name, user.UID)
};
await HttpContext.Authentication.SignInAsync("GuoKun",
new ClaimsPrincipal(new ClaimsIdentity(claims)));
}
[HttpGet("serverresponse")]
public ContentResult ServerResponse()
{
return this.Content($"來自{((Microsoft.AspNetCore.Server.Kestrel.Internal.Http.ConnectionContext)this.HttpContext.Features).LocalEndPoint.ToString()}的響應:{this.User.Identity.Name ?? "匿名"},您好");
}
}
因為授權現在是全局的,所以在登陸方法上用AllowAnonymous標記,跳過認證及授權。
在ServerResponse方法中,返回當前服務實例綁定的IP及端口號。由於本Demo是采用ANCM寄宿在IIS中的,所以具體服務實例綁定的端口是動態的。
4、部署。具體在IIS中的部署如下:

三個站點的端口分別為9001,9002,9003,具體運行時,ANCM會將IIS的請求代理到KestrlServer。
5、Nginx負載均衡配置:
upstream guokun {
server localhost:9001;
server localhost:9002;
server localhost:9003;
}
server {
listen 9000;
server_name localhost;
#charset koi8-r;
#access_log logs/host.access.log main;
location / {
root html;
index index.html index.htm;
proxy_pass http://guokun;
}
這個比較簡單,不廢話。
6、運行效果:
這里采用Postman模擬請求。當未調用登錄API,直接請求api/Account/serverresponse時,如下:

可以看到,直接401了,而且,響應標頭中,有個Location,這個是challenge中默認實現的,告訴我們需要去登錄認證,認證完了會跳轉到當前請求資源url(在MVC中尤其有用)。
接下來,登錄:


我們可以看到,登錄成功,而且,服務端返回了加密及序列化后的憑證。接下來,我們再請求api/Account/serverresponse:

看到沒,請求成功。那么多請求幾次,分別得到如下結果:


可以看見,請求已經被負載到了不同的服務實例。
有人會問,為什么不部署在多台不同服務器上啊,搞一台機器在那兒模擬。哥沒那么多錢整那么多台機器啊,而且,裝虛擬機,配置撐不了,望大神勿噴勿吐槽。
如此,一個簡易的基於asp.net core,帶認證,具有集群負載的后端,便實現了。
補充說明:
之前,由於網絡原因,ClaimsIdentity部分沒有下載源碼,而是直接反編譯的方式查看,導致得出ClaimsIdentity.IsAuthenticated總是返回false的結論,在此更正,並特別感謝Savorboard大神的特別指正。經過翻閱Github上源碼,該屬性是這樣定義的:
/// <summary>
/// Gets a value that indicates if the user has been authenticated.
/// </summary>
public virtual bool IsAuthenticated
{
get { return !string.IsNullOrEmpty(_authenticationType); }
}
之前一直返回false,則是由於登錄成功構建ClaimsIdentity時沒有指定AuthenticationType。弄清楚了這個,那么對應授權策略的注冊,就可以采用如下方式了:
services.AddMvc(options =>
{
//添加模型綁定過濾器
options.Filters.Add(typeof(ModelValidateActionFilter));
//添加授權過濾器,以便強制執行Authentication跳轉及屏蔽邏輯
var policy = new AuthorizationPolicyBuilder().RequireAuthenticatedUser().Build();
//var policy = new AuthorizationPolicyBuilder().AddRequirements(new AuthenticationRequirement()).Build();
options.Filters.Add(new AuthorizeFilter(policy));
});
相應地,在登錄成功后,構建ClaimsIdentity時指定其AuthenticationType:
await HttpContext.Authentication.SignInAsync("GuoKun",
new ClaimsPrincipal(new ClaimsIdentity(claims, "GuoKun")));

