ASP.NET Core 提供了許多身分驗證的 Middleware,內建的 AuthenticationMiddleware (app.UseAuthentication) 需要搭配 AuthenticationHandler,這裡我將介紹如何使用自訂的身分驗證跟 AuthenticationMiddleware 的串接,驗證成功後替使用者建立身分識別
開發環境
- .NET 6
- Rider 2022.1.2
自訂驗證的步驟
使用 Authentication Middleware
在 Startup.cs 加入 Authentication Middleware
app.UseAuthentication();
加入 AuthenticationScheme 的 DI Container
在 Startup.cs 加入 Authentication 的 DI Container 配置
services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = "Basic";
options.DefaultChallengeScheme = "Basic";
})
.AddCustomAuthenticationBuilder(o => { });
AddCustomAuthenticationBuilder 擴充方法,調用 AddScheme <CustomAuthenticationHandler,CustomAuthenticationOptions>,這代表要採用的驗證方案
public static class BasicAuthenticationExtensions
{
public static AuthenticationBuilder AddBasicAuthenticationBuilder(this AuthenticationBuilder builder,
Action<BasicAuthenticationOptions> configureOptions)
{
return builder.AddScheme<BasicAuthenticationOptions, BasicAuthenticationHandler>("Basic",
"Basic", configureOptions);
}
}
實作 AuthenticationSchemeOptions
存放驗證的設定
public class BasicAuthenticationOptions : AuthenticationSchemeOptions
{
public BasicAuthenticationOptions()
{
}
}
實作 AuthenticationHandler<AuthenticationSchemeOptions>
主要的驗證邏輯就放在這裡了,驗證成功之後會回傳 AuthenticateResult
internal class BasicAuthenticationHandler : AuthenticationHandler<CustomAuthenticationOptions>
{
public BasicAuthenticationHandler(IOptionsMonitor<CustomAuthenticationOptions> options,
ILoggerFactory logger,
UrlEncoder encoder, ISystemClock clock) :
base(options, logger, encoder, clock)
{
// store custom services here...
}
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
// build the claims and put them in "Context"; you need to import the Microsoft.AspNetCore.Authentication package
return AuthenticateResult.NoResult();
}
}
AuthenticateResult 它具有以下有用的靜態方法,您可以使用它們來構造結果:
- AuthenticateResult.result():表示沒有結果
- AuthenticateResult.Fail("Invalid username or password"):表示錯誤
- AuthenticateResult.Success(ticket):表示認證成功,參數為 AuthenticationTicket
HandleChallengeAsync and HandleForbiddenAsync
這兩個用來回應調用端的錯誤訊息,
- Challenge:代表驗證失敗,返回 401
- Forbidden:代表授權失敗,返回 403
protected override Task HandleChallengeAsync(AuthenticationProperties properties)
{
return base.HandleChallengeAsync(properties);
}
AuthorizeAttribute
最後,在 Endpoint 掛上 [Authorize]
[HttpGet]
[Authorize]
public async Task<IActionResult> Get(){}
理解使用步驟之後,接下來我將針對上述的步驟進行開發,開始之前可以先看一下 Baisc Authentication HTTP基本认证 - 维基百科,自由的百科全书 (wikipedia.org)
實作
接下來我想要實作的授權流程如下
- 請求受保護的端點
- 通過身分驗證
- 沒有授權回傳 HttpStatus 401
- 有授權回傳 HttpStatus 200
實作 BasicAuthenticationProvider
BasicAuthenticationProvider,比對帳號密碼,資料來源可以是其他的 IO Storage,這裡我用記憶體來演示
public class BasicAuthenticationProvider : IBasicAuthenticationProvider
{
private readonly Dictionary<string, string> _clientIdentities = new(StringComparer.InvariantCultureIgnoreCase)
{
{ "yao", "9527" }
};
public Task<bool> IsValidateAsync(string user, string password, CancellationToken cancel = default)
{
if (this._clientIdentities.TryGetValue(user, out var secret) == false)
{
return Task.FromResult(false);
}
if (password != secret)
{
return Task.FromResult(false);
}
return Task.FromResult(true);
}
}
為了開發授權功能,你可能還需要假裝驗證成功
public class DefaultBasicAuthenticationProvider : IBasicAuthenticationProvider
{
public Task<bool> IsValidateAsync(string user, string password, CancellationToken cancel = default)
{
return Task.FromResult(true);
}
}
實作 BasicAuthenticationOptions
public class BasicAuthenticationOptions : AuthenticationSchemeOptions
{
public string Realm { get; set; } = "Demo Site";
}
實作 BasicAuthenticationHandler
首先新增一個 ASP.NET Core 的 Web API 專案,新增 BasicAuthenticationHandler
排除端點有使用 [AllowAnonymous]
if (endpoint?.Metadata?.GetMetadata<IAllowAnonymous>() != null)
{
return AuthenticateResult.NoResult();
}
處理 Authorization Header,如下
this._authenticationProvider.IsValidateAsync:驗證身分,_authenticationProvider 由外部注入
if (!this.Request.Headers.ContainsKey(AuthorizationHeaderName))
{
this._failReason = "Invalid basic authentication header";
return AuthenticateResult.Fail(this._failReason);
}
if (!AuthenticationHeaderValue.TryParse(this.Request.Headers[AuthorizationHeaderName],
out var authHeaderValue))
{
this._failReason = "Invalid authorization Header";
return AuthenticateResult.Fail(this._failReason);
}
if (authHeaderValue.Scheme.StartsWith(schemeName, StringComparison.InvariantCultureIgnoreCase) == false)
{
this._failReason = "Invalid authorization scheme name";
return AuthenticateResult.Fail("Invalid authorization scheme name");
}
var credentialBytes = Convert.FromBase64String(authHeaderValue.Parameter);
var userAndPassword = Encoding.UTF8.GetString(credentialBytes);
var credentials = userAndPassword.Split(':');
if (credentials.Length != 2)
{
this._failReason = "Invalid basic authentication header";
return AuthenticateResult.Fail(this._failReason);
}
var user = credentials[0];
var password = credentials[1];
var isValidate = await this._authenticationProvider.IsValidateAsync(user, password, CancellationToken.None);
if (!isValidate)
{
this._failReason = "Invalid username or password";
return AuthenticateResult.Fail(this._failReason);
}
驗證成功後,呼叫 AuthenticateResult.Success(ticket),代碼如下
protected override async Task HandleChallengeAsync(AuthenticationProperties properties)
{
// 寫入詳細的失敗原因,排除敏感性資料
this.Logger.LogInformation("{FailureReason}", new
{
Code = "InvalidAuthentication",
Message = this._failReason
});
this.Response.StatusCode = 401;
this.Response.HttpContext.Features.Get<IHttpResponseFeature>().ReasonPhrase = this._failReason;
this.Response.Headers["WWW-Authenticate"] = $"Basic realm=\"{this.Options.Realm}\", charset=\"UTF-8\"";
// 回應粗糙的內容,這不是標準的 Basic Authentication 失敗的回傳,僅是為了示意
this.Response.WriteAsJsonAsync(new
{
Code = "InvalidAuthentication",
Message = "Please contact your administrator"
});
await Task.CompletedTask;
}
跟資安有關的回應調用端的時候記得要使用粗糙的內容
驗證失敗,回應(Response) 回傳 StatusCode = 401
protected override async Task HandleChallengeAsync(AuthenticationProperties properties)
{
this.Response.StatusCode = 401;
this.Response.HttpContext.Features.Get<IHttpResponseFeature>().ReasonPhrase = this._failReason;
this.Response.Headers["WWW-Authenticate"] = $"Basic realm=\"{this.Options.Realm}\", charset=\"UTF-8\"";
await Task.CompletedTask;
}
完整代碼如下
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
var schemeName = this.Scheme.Name; //由外部注入
var endpoint = this.Context.GetEndpoint();
if (endpoint?.Metadata?.GetMetadata<IAllowAnonymous>() != null)
{
return AuthenticateResult.NoResult();
}
if (!this.Request.Headers.ContainsKey(HeaderNames.Authorization))
{
this._failReason = "Invalid basic authentication header";
return AuthenticateResult.Fail(this._failReason);
}
if (!AuthenticationHeaderValue.TryParse(this.Request.Headers[HeaderNames.Authorization],
out var authHeaderValue))
{
this._failReason = "Invalid authorization Header";
return AuthenticateResult.Fail(this._failReason);
}
if (authHeaderValue.Scheme.StartsWith(schemeName, StringComparison.InvariantCultureIgnoreCase) == false)
{
this._failReason = "Invalid authorization scheme name";
return AuthenticateResult.Fail("Invalid authorization scheme name");
}
var credentialBytes = Convert.FromBase64String(authHeaderValue.Parameter);
var userAndPassword = Encoding.UTF8.GetString(credentialBytes);
var credentials = userAndPassword.Split(':');
if (credentials.Length != 2)
{
this._failReason = "Invalid basic authentication header";
return AuthenticateResult.Fail(this._failReason);
}
var user = credentials[0];
var password = credentials[1];
var isValidate = await this._authenticationProvider.IsValidateAsync(user, password, CancellationToken.None);
if (!isValidate)
{
this._failReason = "Invalid username or password";
return AuthenticateResult.Fail(this._failReason);
}
return this.SignIn(user);
}
完整的流程已經確定,開發了 IBasicAuthenticationProvider 由外部注入,可以根據需求抽換它
設定驗證 DI Container
增加 AddBasicAuthentication 擴充方法,把配置 Authentication 的動作收攏起來
public static class BasicAuthenticationExtensions
{
public static AuthenticationBuilder AddBasic(this AuthenticationBuilder builder,
Action<BasicAuthenticationOptions> configureOptions)
{
return builder.AddScheme<BasicAuthenticationOptions, BasicAuthenticationHandler>(
BasicAuthenticationDefaults.AuthenticationScheme,
BasicAuthenticationDefaults.AuthenticationScheme, configureOptions);
}
public static AuthenticationBuilder AddBasicAuthentication(this IServiceCollection services,
Action<BasicAuthenticationOptions> configureOptions)
{
return services.AddAuthentication(o =>
{
o.DefaultAuthenticateScheme = BasicAuthenticationDefaults.AuthenticationScheme;
o.DefaultChallengeScheme = BasicAuthenticationDefaults.AuthenticationScheme;
})
.AddBasic(configureOptions);
}
}
DI Container 的配置,我的 BasicAuthenticationProvider很明確不會保存狀態,所以使用了 Singleton lifecycle,如果你不是很確定可以使用 Scope
services.AddSingleton<IBasicAuthenticationProvider, BasicAuthenticationProvider>();
services.AddBasicAuthentication(_ => { });
services.AddAuthorization();
AuthenticationMiddleware 的單元測試
新增一個測試專案,並加入以下套件
dotnet add package Microsoft.AspNetCore.TestHost --version 6.0.6
有關 Middleware 的單元測試可以參考 上篇,這裡我使用 Host.GetTestServer()
[TestMethod]
public async Task 驗證成功()
{
using var server = await CreateTestServer();
var httpContext = await server.SendAsync(config =>
{
config.Request.Headers.Authorization = CreateBasicAuthenticationValue("yao", "9527");
});
var userPrincipal = httpContext.User;
Assert.AreEqual(true, userPrincipal.Identity.IsAuthenticated);
}
private static string CreateBasicAuthenticationValue(string userId, string password)
{
var certificate = $"{userId}:{password}";
var base64Encode = Convert.ToBase64String(Encoding.ASCII.GetBytes(certificate));
return $"Basic {base64Encode}";
}
private static async Task<TestServer> CreateTestServer()
{
var host = await new HostBuilder()
.ConfigureWebHost(webBuilder =>
{
webBuilder.UseTestServer()
.ConfigureServices(
services =>
{
services.AddSingleton<IBasicAuthenticationProvider, BasicAuthenticationProvider>();
services.AddBasicAuthentication(_ => { });
services.AddAuthorization();
})
.Configure(app =>
{
app.UseAuthentication();
app.UseAuthorization();
});
})
.StartAsync();
var server = host.GetTestServer();
server.BaseAddress = new Uri("https://我真的是假的/不要打我的臉/");
return server;
}
這裡我碰到一個問題,當 BasicAuthenticationHandler.HandleAuthenticateAsync 得到 AuthenticateResult.Fail 之後並沒有觸發BasicAuthenticationHandler.HandleChallengeAsync,我推測是 Host.GetTestServer 本身的限制,目前還沒有明確的證據
[TestMethod]
public async Task 驗證失敗()
{
using var server = await CreateTestServer();
var httpContext = await server.SendAsync(config =>
{
config.Request.Headers.Authorization = CreateBasicAuthenticationValue("yao", "9527xxxx");
});
// 驗證失敗沒有觸發 BasicAuthenticationHandler.HandleChallengeAsync
var userPrincipal = httpContext.User;
Assert.AreEqual(false, userPrincipal.Identity.IsAuthenticated);
}
BasicAuthenticationHandler 的單元測試
換另外一條路,測試目標改成 BasicAuthenticationHandler,為了簡化 BasicAuthenticationHandler 實例化建立步驟,我從 DI Container(testHost.Services) 取出BasicAuthenticationHandler 實例
[TestMethod]
public async Task 驗證失敗後回應錯誤()
{
var context = new DefaultHttpContext
{
Response = { Body = new MemoryStream() }
};
var authorizationHeader = new StringValues(CreateBasicAuthenticationValue("yao", "9527"));
context.Request.Headers.Add(HeaderNames.Authorization, authorizationHeader);
using var testHost = await CreateTestHost();
var handler = testHost.Services.GetService<BasicAuthenticationHandler>();
await handler.InitializeAsync(new AuthenticationScheme("basic",
"basic",
typeof(BasicAuthenticationHandler)),
context);
var authenticateResult = await handler.AuthenticateAsync();
await handler.ChallengeAsync(authenticateResult.Properties);
var response = context.Response;
Assert.IsFalse(authenticateResult.Succeeded);
var expected = "Basic realm=\"Demo Site\", charset=\"UTF-8\"";
Assert.AreEqual(expected, response.Headers.WWWAuthenticate.ToString());
}
private static string CreateBasicAuthenticationValue(string userId, string password)
{
var certificate = $"{userId}:{password}";
var base64Encode = Convert.ToBase64String(Encoding.ASCII.GetBytes(certificate));
return $"Basic {base64Encode}";
}
private static async Task<IHost> CreateTestHost()
{
var host = await new HostBuilder()
.ConfigureWebHost(webBuilder =>
{
webBuilder.UseTestServer()
.ConfigureServices(
services =>
{
services.AddSingleton<IBasicAuthenticationProvider, BasicAuthenticationProvider>();
services.AddBasicAuthentication(_ => { });
services.AddAuthorization();
})
.Configure(app =>
{
app.UseAuthentication();
app.UseAuthorization();
});
})
.StartAsync();
return host;
}
AuthenticationMiddleware 的整合測試
有關整合測試可以參考以下
[ASP.NET Core 5] 利用 WebApplicationFactory 進行 Web API 整合測試 | 余小章 @ 大內殿堂 - 點部落 (dotblogs.com.tw)
[ASP.NET Core 3] 利用 TestServer 進行 Web API 整合測試 | 余小章 @ 大內殿堂 - 點部落 (dotblogs.com.tw)
開啟一個新的測試專案,安裝以下套件
dotnet add package Microsoft.AspNetCore.Mvc.Testing --version 6.0.6
在測試專案增加 TestController
[ApiController]
[Route("[controller]")]
public class TestController : ControllerBase
{
private readonly ILogger<TestController> _logger;
public TestController(ILogger<TestController> logger)
{
this._logger = logger;
}
[AllowAnonymous]
[HttpGet]
public async Task<IActionResult> Get()
{
return this.Ok("好");
}
[AllowAnonymous]
[HttpPost]
public async Task<IActionResult> Post(User user)
{
return this.Ok("好");
}
}
新增 TestServer 實作 WebApplicationFactory<Program>
public class TestServer : WebApplicationFactory<Program>
{
private void ConfigureServices(IServiceCollection services)
{
services.AddControllers()
.AddApplicationPart(typeof(TestController).Assembly);
}
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureServices(this.ConfigureServices)
.UseSetting("https_port", "9527")
// .UseUrls("https://localhost:9527")
;
}
}
使用整合測試時,當 BasicAuthenticationHandler.HandleAuthenticateAsync 得到 AuthenticateResult.Fail 之後會觸發BasicAuthenticationHandler.HandleChallengeAsync
[TestMethod]
public void 訪問受保護的服務_驗證失敗()
{
var server = new TestServer();
var httpClient = server.CreateClient();
var url = "protect";
var clientId = "YAO1234";
var clientSecret = "9527";
var request = new HttpRequestMessage(HttpMethod.Get, url)
{
Headers = { Authorization = CreateAuthenticationHeaderValue(clientId, clientSecret) }
};
var response = httpClient.SendAsync(request).Result;
response.Headers.TryGetValues("WWW-Authenticate", out var values);
Console.WriteLine($"驗證失敗:{values.First()}");
Assert.AreEqual(HttpStatusCode.Unauthorized, response.StatusCode);
}
private static AuthenticationHeaderValue CreateAuthenticationHeaderValue(string clientId, string clientSecret)
{
var authenticationString = $"{clientId}:{clientSecret}";
var base64Encoded = Convert.ToBase64String(Encoding.ASCII.GetBytes(authenticationString));
return new AuthenticationHeaderValue("basic", base64Encoded);
}
範例位置
若有謬誤,煩請告知,新手發帖請多包涵
Microsoft MVP Award 2010~2017 C# 第四季
Microsoft MVP Award 2018~2022 .NET