ASP.NET Core 6 實作自訂 Authentication 身份驗證,以 Basic Authentication 例

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 它具有以下有用的靜態方法,您可以使用它們來構造結果:

  1. AuthenticateResult.result():表示沒有結果
  2. AuthenticateResult.Fail("Invalid username or password"):表示錯誤
  3. 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 6 Top-level Statements 如何使用 WebApplicationFactory 進行整合測試 | 余小章 @ 大內殿堂 - 點部落 (dotblogs.com.tw)

[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);
}

範例位置

sample.dotblog/WebAPI/Security/Lab.AspNetCore.Security at master · yaochangyu/sample.dotblog (github.com)

若有謬誤,煩請告知,新手發帖請多包涵


Microsoft MVP Award 2010~2017 C# 第四季
Microsoft MVP Award 2018~2022 .NET

Image result for microsoft+mvp+logo