JWTを使用したトークンベース認証と、ASP.NET Coreにおける実践

JWTを使用したトークンベース認証の実証。

れぽ

https://github.com/mia106dev/jwt-auth

## 概論
### JWTとは
*JSON Web Token*

JWTとは、JSONデータに対する暗号化・署名方法を定めたオープン標準（RFC7519）である。
JWT自体の詳細は割愛。
* [JSON Web Token - Wikipedia](https://ja.wikipedia.org/wiki/JSON_Web_Token)
* [RFC 7519 - JSON Web Token (JWT)](https://datatracker.ietf.org/doc/html/rfc7519)

JWT自体は「認証のために策定されたもの」ではないのだが、
その性質からOAuthといったSSO技術のコア技術として使用されていたりする。

### セッション VS トークン
#### JWTが特にセッションに勝っている点
**認証情報の検証がJWT単体で完結する点**。
セッション方式の場合、認証情報の有効性を検証するためにはセッションストアに記録されているIDの有効性との突合が必要になる。
一方のJWTは、JWT自身に付与されている署名を用いて有効性を検証するため、**JWTのみで検証が自己完結する**。
これにより、サービス間を疎結合に保ったまま、共通の認証基盤に依るSSOを実現することが出来る。

#### JWTが特にセッションに劣る点
JWTに**無効化という概念はない**。
Expires（有効期間）は規定されており、それによる無効化は可能だが、
セッションのように「ログアウト等の操作で、特定の認証情報を無効化する」という操作はできない。**ステートレスな機構ゆえ**の現象。
そのため、不用意に有効期間の長いJWTを発行してしまうと、セッションハイジャックと同様の脅威に晒される。

やろうと思えば「JWTを無効化する」機構を実装すること自体はできるが、それをやるとJWTの最大の利点であるステートレス性を失うため元も子もない。

## ASP.NET Core + JWT Bearer認証 実証
大筋、[Microsoft公式のガイドライン](https://learn.microsoft.com/ja-jp/aspnet/core/security/authentication/configure-jwt-bearer-authentication?view=aspnetcore-9.0)に従っている。

### JWT Bearerの登録
エンドポイントアクセス時に`Authentication`HTTPヘッダに添付されるJWTトークンを認証するサービスを登録する。

``` cs
void ConfigureAuthentications(WebApplicationBuilder builder)
{
    var services = builder.Services;
    // ConfigureSettingsが先行していることを前提
    var jwtSettings = services.BuildServiceProvider()
        .GetRequiredService<IJwtSettings>();

    _ = services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
        .AddJwtBearer(options =>
        {
            options.TokenValidationParameters = new TokenValidationParameters()
            {
                ValidIssuer = jwtSettings.Issuer,
                ValidAudience = jwtSettings.Audience,
                IssuerSigningKey = jwtSettings.GetSymmetricSecurityKey(),
                ValidateIssuer = true,
                ValidateIssuerSigningKey = true,
                ValidateAudience = true,
                ValidateLifetime = true
            };
        });
}
```

今回はAPIサーバー自体がIssuer（発行者）になるので、署名に用いる鍵も自身で生成する。
認証サーバーが分かれるなら、認証サーバーの公開鍵をここに登録すれば良い。

``` cs
public SymmetricSecurityKey GetSymmetricSecurityKey()
{
    // キーを直接格納しているなら以下のように
    //return new SymmetricSecurityKey(Encoding.UTF8.GetBytes(Key));

    // パスフレーズを登録させて、こちらでSHA256によるハッシュを行う方式
    var passphraseBytes = Encoding.UTF8.GetBytes(Passphrase);
    var keyBytes = SHA256.HashData(passphraseBytes);
    return new SymmetricSecurityKey(keyBytes);
}
```

### エンドポイントを要認証にする
要認証を表すシグネチャとして、`[Authorize]`属性を使用する。
エンドポイント単体を要認証にする場合は以下のように。

``` cs
[Authorize]
[HttpGet(Name = "GetWeatherForecast")]
public IEnumerable<WeatherForecast> Get()
{
    return Enumerable.Range(1, 5).Select(index => new WeatherForecast
    {
        Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
        TemperatureC = Random.Shared.Next(-20, 55),
        Summary = Summaries[Random.Shared.Next(Summaries.Length)]
    })
    .ToArray();
}

```

`ApiController`クラス全体を要認証にしたり、
原則を要認証としたうえで、特定エンドポイントのみ認証不要にするなど、このあたりは柔軟に設定できる。

ここまでの実装で、当該のエンドポイントにそのままアクセスすると自動で`401 Unauthorized`になる。

### JWTトークンの発行
#### エンドポイント
一方で、JWTトークンを発行する側の実装。
`Verification`部分についてはなんでもよい。
今回は単純なBasic認証でユーザ認証を行い、その結果に応じてJWTトークンを返却するエンドポイントとして実装をしている。

``` cs
[HttpPost("login")]
public async Task<IActionResult> Login([FromBody] LoginParameters parameters)
{
    // Verification
    var user = await userRepository.GetByIdAsync(parameters.Id);
    var flatPassword = parameters.Password;
    var verified = PasswordManager.VerifyPassword(flatPassword, user.Password);

    if (verified)
    {
        var token = jwt.GenerateToken(user);
        return Ok(new { token });
    }

    return Unauthorized();
}
```

#### JWTトークン生成
JWTトークン生成サービスはこんな感じ。

* Claimにユーザ情報とJTI（JWTの識別番号）を与えている
    * Claimは暗号化されないので、**機密情報は含めないこと**
* Expiresを規定している
    * 今回は1時間
* 署名鍵を登録している
    * 今回は署名者も検証者も自分なので、署名鍵と検証鍵は共通鍵

``` cs
public class JwtTokenService
{
    private readonly IJwtSettings settings;

    public JwtTokenService(IJwtSettings settings)
    {
        this.settings = settings;
    }

    public string GenerateToken(UserRecord user)
    {
        var credentials = new SigningCredentials(settings.GetSymmetricSecurityKey(), SecurityAlgorithms.HmacSha256);

        var claims = new Claim[]
        {
            new(JwtRegisteredClaimNames.Sub, user.Id),
            new(JwtRegisteredClaimNames.Email, user.Email),
            new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
        };

        var token = new JwtSecurityToken(
            issuer: settings.Issuer,
            audience: settings.Audience,
            claims: claims,
            expires: GetExpires(),
            signingCredentials: credentials);

        return new JwtSecurityTokenHandler().WriteToken(token);
    }

    private static DateTime GetExpires(int expiresHours = 1) => DateTime.Now.AddHours(expiresHours);
}
```

### JWTトークンを利用
あとは、上記のJWT取得エンドポイントから得たJWTトークンを、リクエスト時の`Authorization`HTTPヘッダに付与してやれば良い。


```
Authorization: Bearer <jwt-token>
```

## TODO
### ロールベース認証
軽い検証を行ったのみ。
こんな感じの属性でエンドポイントにアクセスできるロールを指定する。

``` cs
[Authorize(Role = "Admin")]
```

ロールをValidatorに登録する流れは、まだふわふわしてるので割愛。

### セッションとJWTを組み合わせた認証機構
セッションとJWTの互いの利点を組み合わせたハイブリッド認証機構。
クライアントのログイン状態管理には**セッション方式の認証**を行い、
APIへのアクセスにはJWTトークンを要求する（つまり、**認可**になる）。

JWTトークンの発行の際にはセッションIDの有効性を検証する。
また、JWTのExpiresは十分に短い（1-15分程度）ものを設定する。

これにより、
* セッション方式の利点は享受しつつ
* APIのステートレス性を担保
* JWTの有効期間を最小にすることで、窃取への耐性を高める

```mermaid
sequenceDiagram
    participant クライアント
    participant 認証サーバー
    participant セッションストア
    participant APIサーバー
    
    Note over クライアント,APIサーバー: ログイン処理
    クライアント->>認証サーバー: ユーザーID/パスワード送信
    認証サーバー->>セッションストア: セッション作成
    認証サーバー-->>クライアント: セッションID返却
    
    Note over クライアント,APIサーバー: JWT発行処理
    クライアント->>認証サーバー: セッションID送信
    認証サーバー->>セッションストア: セッション検証
    認証サーバー-->>クライアント: JWT発行（認証サーバーの秘密鍵で署名）
    
    Note over クライアント,APIサーバー: APIアクセス処理
    クライアント->>APIサーバー: JWT付きリクエスト
    APIサーバー-->>認証サーバー: 公開鍵は事前に取得
    APIサーバー->>APIサーバー: JWT検証（認証サーバーの公開鍵で検証）
    APIサーバー-->>クライアント: レスポンス
    
    Note over クライアント,APIサーバー: ログアウト処理
    クライアント->>認証サーバー: ログアウト要求
    認証サーバー->>セッションストア: セッション無効化
    認証サーバー-->>クライアント: ログアウト完了
```


一方で、
* 認証サーバーが単一障害点になる
    * JWTが発行できなくなるとAPIも共倒れ
* JWT発行頻度が高くなるので結局認証サーバーへの負荷は高め

といった問題は残る。

#### NOTE
エンタープライズレベルの高いセキュリティ要件が求められるシナリオ向けに考えたもの。
正味、PJの規模次第ではオーバーエンジニアリング。

#### 一方のセッション認証部分
ASP + Reditによるセッションストア管理がよいのかな～などと思っている段階。

## LINKS
#### ABOUT JWT
* [JWTについてまとめてみる](https://zenn.dev/mryhryki/articles/2021-03-28-json-web-token)
* [OAuth 2.0のAccess TokenへのJSON Web Token(JSON Web Signature)の適用 - r-weblife](https://ritou.hatenablog.com/entry/20140927/1411811648)
* [JSON Web Token（JWT）のClaimについて · なるはやで いい感じの 動作確認](https://kamichidu.github.io/post/2017/01/24-about-json-web-token/)
#### ASP + JWT
* [ASP.NET Core で JWT ベアラー認証を構成する | Microsoft Learn](https://learn.microsoft.com/ja-jp/aspnet/core/security/authentication/configure-jwt-bearer-authentication?view=aspnetcore-9.0)
* [C# ASP.NET CoreでJWT認証を実装 #C# - Qiita](https://qiita.com/te-k/items/600df0d0d812139de881)
* [ASP.NET Core でのロール ベースの認可 | Microsoft Learn](https://learn.microsoft.com/ja-jp/aspnet/core/security/authorization/roles?view=aspnetcore-9.0)