ASP.NET Core の API の認証・認可をカスタマイズする方法

Azure AD とか Azure AD B2C とか IdentityService4 とかを使うといい感じにはできます。
でも、例えば API Key での認証にしたいとか、なんらかの独自の認証の仕組みがあって、それに依存しないといけないとかいろんな事情が世の中にはあると思います。

## やってみよう

ASP.NET Core の API のプロジェクトを作成します。認証などはとりあえず無しで。

普段は Visual Studio 2019 を使うのですがファンが壊れていて発火したら怖いので VS Code on Macbook Pro でやりたいと思います

ということで `dotnet` コマンドで作成して Visual Studio Code で開きましょう。

```
$ dotnet new api -o MyApi
$ code MyApi
```

そうすると以下のようなものが作成されます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/70288/956f407f-e7e2-93d9-9df1-67510cbf0401.png)

VS Code に C# 拡張機能を入れていたら .vscode のフォルダーを生成するか？と聞いてくるので便利です。

では、早速 Startup.cs を編集して認証認可を有効にしましょう。

```csharp
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    if (env.IsDevelopment())
    {
        app.UseDeveloperExceptionPage();
    }

    app.UseHttpsRedirection();

    app.UseRouting();

    app.UseAuthentication(); // add
    app.UseAuthorization();

    app.UseEndpoints(endpoints =>
    {
        endpoints.MapControllers();
    });
}
```

そして、独自認証の処理を実装します。`AuthenticationHandler` を継承して作ります。サクッといきましょう。

継承してクイックフィックス(Ctrl + . か電球マークをクリック)で、コンストラクターと中小メソッドのインプリメントができるので以下のようなコードまでは、ほぼ打たなくても生成できます。

```csharp
class MyAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
    public MyAuthHandler(IOptionsMonitor<AuthenticationSchemeOptions> options, 
        ILoggerFactory logger, 
        UrlEncoder encoder, 
        ISystemClock clock) : base(options, logger, encoder, clock)
    {
    }

    protected override Task<AuthenticateResult> HandleAuthenticateAsync()
    {
        throw new NotImplementedException();
    }
}
```

HandleAuthenticateAsync を実装します。とりあえず今回は HTTP ヘッダーに API_KEY という名前で A か B が入ってたら OK というざるロジックでいきます。API_KEY をちゃんと実装するなら何処かに発行した API_KEY を保存しておきましょうね。

```csharp
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
    (bool ok, string name) tryGetApiKey(HttpContext context)
    {
        if (!context.Request.Headers.TryGetValue("API_KEY", out var apiKey))
        {
            return (false, "");
        }

        return apiKey.ToString() switch
        {
            "A" => (true, "a さん"),
            "B" => (true, "b さん"),
            _ => (false, ""),
        };
    }

    var (ok, name) = tryGetApiKey(Context);

    if (!ok)
    {
        return Task.FromResult(AuthenticateResult.Fail("Invalid API Key"));
    }

    var p = new ClaimsPrincipal(new ClaimsIdentity(new[]
    {
        new Claim(ClaimTypes.Name, name),
    }, "MyAuthType"));

    return Task.FromResult(AuthenticateResult.Success(new AuthenticationTicket(
        p, "Api"
    )));
}
```

そして、ConfigureServices で、このハンドラーを設定します。
デフォルトのスキーマの名前を Api にして、Api というスキーマで MyAuthHandler を使うようにしています。

```csharp
public void ConfigureServices(IServiceCollection services)
{
    services.AddControllers();
    services.AddAuthentication("Api")
        .AddScheme<AuthenticationSchemeOptions, MyAuthHandler>("Api", options => { });
}
```

そして、デフォルトで生成される WeatherForecastController.cs に Authorize 属性を追加して認証必須にして、返すデーターに認証されたユーザーの名前も入れるようにしました。

```csharp
[ApiController]
[Route("[controller]")]
[Authorize] // add
public class WeatherForecastController : ControllerBase
{
    // コンストラクタやフォールドなどは省略

    [HttpGet]
    public IEnumerable<WeatherForecast> Get()
    {
        var rng = new Random();
        return Enumerable.Range(1, 5).Select(index => new WeatherForecast
        {
            Date = DateTime.Now.AddDays(index),
            TemperatureC = rng.Next(-20, 55),
            Summary = $"{Summaries[rng.Next(Summaries.Length)]} for {HttpContext.User.Identity.Name}", // 名前も返すようにした
        })
        .ToArray();
    }
}
```

ここまでできたら `dotnet run` コマンドで実行しましょう

Visual Studio Code の REST API 拡張機能を使って叩いてみましょう。
まずは、ヘッダー無しの状態

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/70288/635836b1-5fd5-b7cc-85c5-5587665be5e3.png)

401 ですね。いい感じ。

API_KEY に A を設定して呼んでみましょう。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/70288/a2278f0d-1504-4a7b-d923-7e2f63c5bd3d.png)

ちゃんと 200 で、名前も a さんになってますね。B でやっても OK

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/70288/e8ba9f57-6f51-2356-b634-c8f7e243658e.png)

C にすると、401 にちゃんと戻ります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/70288/4af2530c-1f80-1ee0-6824-f2267b7835d1.png)

## 承認

認証してるのはわかった、じゃぁちゃんと API を呼んでいい人なのかどうかはどうするのか？というのをやってみようと思います。とりあえず Claim にロールを追加します。
API_KEY が A だと Admin で、B だと User というロールになるように AuthenticationHandler の HandleAuthenticateAsync を変えました。

```csharp
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
    (bool ok, string name, string role) tryGetApiKey(HttpContext context)
    {
        if (!context.Request.Headers.TryGetValue("API_KEY", out var apiKey))
        {
            return (false, "", "");
        }

        return apiKey.ToString() switch
        {
            "A" => (true, "a さん", "Admin"),
            "B" => (true, "b さん", "User"),
            _ => (false, "", ""),
        };
    }

    var (ok, name, role) = tryGetApiKey(Context);

    if (!ok)
    {
        return Task.FromResult(AuthenticateResult.Fail("Invalid API Key"));
    }

    var p = new ClaimsPrincipal(new ClaimsIdentity(new[]
    {
        new Claim(ClaimTypes.Name, name, ClaimValueTypes.String, "my issuer name"),
        new Claim(ClaimTypes.Role, role, ClaimValueTypes.String, "my issuer name"),
    }, "MyAuthType"));

    return Task.FromResult(AuthenticateResult.Success(new AuthenticationTicket(
        p, "Api"
    )));
}
```

そして、WeatherForecastController の Authorize 属性に Roles で Admin じゃないと呼べないように構成します。

```csharp
namespace MyApi.Controllers
{
    [ApiController]
    [Route("[controller]")]
    [Authorize(Roles = "Admin")] // 変更
    public class WeatherForecastController : ControllerBase
    {
        ... 省略
    }
}
```

実行してみましょう。
まずは、API Key が A の場合です。うまく呼べています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/70288/a8d9eea1-e500-39f3-ca8c-d58e86b6296e.png)

B にすると 403 ですね。いい感じ。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/70288/9a8cf692-e5bf-4964-163d-6b41731f43b0.png)

ロールが複数個になってくるとカンマ区切りにしたりとかめんどくさくなってくるので、ポリシーにしてしまうこともできます。その場合は Startup.cs の ConfigureServices に、AddAuthorization を追加してポリシーを追加するとすっきりします。
(今回の例は Admin ロールしかやってないので、あまりありがたみはないですが…）

```csharp
services.AddAuthorization(options =>
{
    options.AddPolicy("AdminOnly", policy => policy.RequireRole(new[]{ "Admin" }));
});
```

Controller とかにつける Authorize 属性は、以下のように第一引数にポリシー名を受け取るので AdminOnly という名前のポリシーに合致する人（今回の場合はロールが Admin の人）だけが呼べる API には以下のような属性を追加します。

```csharp
[Authorize("AdminOnly")]
```

## まとめ

ということで認証の動きをカスタマイズしてみました。
意外と簡単に API Key での認証ができました。今回は HTTP ヘッダーでやりましたが同じ要領でクエリーパラメーターや、クッキーでもできます。

もし、認証をカスタマイズしたい！という人は参考にしてみてください。

あ、ちなみに Azure 使う場合は API Key みたいなものでの認証は API Management 使うとプログラムに実装しなくてもいいので、そっちが使えるならそっちを使った方がいいと思います。