.NET web APIでJWT認証を実装する

達成したいことは、

・JWTによる認証
・独自のクレーム追加(クレームの値を各コントローラにて使用)
・Swaggerも使えるように

プログラム作成

dotnet new webapi --use-controllers -o TestApi
cd TestApi
dotnet restore
dotnet run

プログラム作成からとりあえずテストまで。
http://localhost:5221/weatherforecast
にアクセスすると値が返ってくる。また、以下だとSwaggerページ。
http://localhost:5221/swagger/index.html
※ポートは要確認。

vsCodeでプロジェクトを開き、まずはデバッグ情報を生成。launch.jsonを編集し、Swaggerページが都度開くようにする。

      "serverReadyAction": {
        "action": "openExternally",
        "pattern": "\\bNow listening on:\\s+(https?://\\S+)",
        "uriFormat": "%s/swagger/index.html", --追加
      },

vscCodeでデバッグ実行してSwagger開くかテスト。

JWTの仕組み導入

まずパッケージインストール。

dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer

続いてappsettings.jsonにjwt関係を追加。

  "Jwt": {
    "Key": "secretkey",
    "Issuer": "yourapp.com",
    "Audience": "yourapp.com"
 }

Program.csにJWTに関する記述追加。(ついでにSwaggerの記述も追加してますがお好みで)

// JWT 認証の設定
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = true,  // 発行者を検証
            ValidateAudience = true,  // 受信者を検証
            ValidateLifetime = true,  // トークンの有効期限を検証
            ValidateIssuerSigningKey = true,  // 発行者の署名キーを検証
            ValidIssuer = builder.Configuration["Jwt:Issuer"],  // 設定された発行者
            ValidAudience = builder.Configuration["Jwt:Audience"],  // 設定された受信者
            ClockSkew = TimeSpan.Zero,
            IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Key"]!))  // 秘密鍵
        };
    });

builder.Services.AddSwaggerGen(c =>
{
    c.SwaggerDoc("v1", new OpenApiInfo { Title = "JWTTest", Version = "v1" });
});

var app = builder.Build();

// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

app.UseHttpsRedirection();
app.UseAuthentication();  // ここで JWT 認証を使用
app.UseAuthorization();
app.MapControllers();

app.Run();

続いてこれもお好みですが、WeratherForecastControllerをテストしやすいように変更。

[ApiController]
[Route("weather")]
[Authorize]
public class WeatherForecastController : ControllerBase
{
    public WeatherForecastController(){}

    [Route("noauth")]
    [HttpGet]
    [AllowAnonymous]
    public WeatherForecast Get()
    {
        return new WeatherForecast(){
            Date = DateOnly.FromDateTime(DateTime.Now),
            TemperatureC = 1,
            Summary = "noAuth"
        };
    }
    
    [Route("auth")]
    [HttpGet]
    public WeatherForecast GetAuth()
    {
        return new WeatherForecast(){
            Date = DateOnly.FromDateTime(DateTime.Now),
            TemperatureC = 1,
            Summary = "Auth"
        };
    }
}

この状態でSwaggerを起動し、noauthが200、authが401を返せばOKです。

JWT作成機能追加

続いてJWTを返すログイン処理を作成します。

まずは新たにControllerとログイン処理を作成。今回はシンプルにGETすればJWTを返すようにします。

[ApiController]
[Route("auth")]
[Authorize]
public class AuthController : ControllerBase
{
    private readonly IConfiguration _configuration;

    public AuthController(IConfiguration configuration)
    {
        _configuration = configuration;
    }

    [Route("login")]
    [HttpGet]
    [AllowAnonymous]
    public IActionResult Get()
    {
        var tokenHandler = new JwtSecurityTokenHandler();
        var key = Encoding.ASCII.GetBytes(_configuration["Jwt:Key"]!);
        var issuer = _configuration["Jwt:Issuer"];
        var audience = _configuration["Jwt:Audience"];

        // アクセストークン作成
        var tokenDescriptor = new SecurityTokenDescriptor
        {
            Subject = new ClaimsIdentity(new Claim[]
            {
                // 認証に関係ないクレーム記述箇所
                new Claim("userid", "userid1"),
            }),
            Expires = DateTime.Now.AddMinutes(int.Parse(_configuration["Jwt:ExpireMinutes"]!)),
            SigningCredentials = new SigningCredentials(
                    new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha256Signature
                ),
            Issuer = issuer,
            Audience = audience
        };
        var token = tokenHandler.CreateToken(tokenDescriptor);
        var accessToken = tokenHandler.WriteToken(token);
        
        return Ok(new {AccessToken = accessToken});
    }
    
}

早速Swagger上でトークンを受け取ってみましょう。

またこの時以下のような例外が発生した場合は、暗号化用keyを長くしてください。

the key size must be greater than: '256' bits, key has '***' bits.'

SwaggerでBearer認証するように設定

このままだとトークンを取得してもセットすることができないので変更。Program.csにて、

builder.Services.AddSwaggerGen(c =>
{
    c.SwaggerDoc("v1", new OpenApiInfo { Title = "JWTTest", Version = "v1" });

    // Bearer認証を使用するためのセキュリティ定義を追加
    c.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
    {
        Description = "JWT Authorization header using the Bearer scheme. Example: 'Bearer {token}'",
        Name = "Authorization",
        In = ParameterLocation.Header,
        Type = SecuritySchemeType.ApiKey,
        Scheme = "Bearer",
        BearerFormat = "JWT"
    });

    c.AddSecurityRequirement(new OpenApiSecurityRequirement()
    {
        {
            new OpenApiSecurityScheme
            {
                Reference = new OpenApiReference
                {
                    Type = ReferenceType.SecurityScheme,
                    Id = "Bearer"
                }
            },
            Array.Empty<string>()
        }
    });

});

あとはテスト!

デバッグ実行

早速デバッグ実行し、Swagger上でトークンを発行します。

accessTokenにある文字列をコピーし、Swaggerの上側にあるAuthorizeボタンをクリック。するとトークンを入力する欄が出てくるので、「Bearer トークン文字列」となるように入力してください。

その後先ほど作成した認証が必要なAPIを叩くと、実行されるかと思います。

任意クレーム取り出し

実際にはこのJWTから任意のクレームを取り出し、認可等行う必要があります。今回は先ほどのサンプルでセットしてある、「userid1」を取り出してみましょう。

    [Route("auth")]
    [HttpGet]
    public WeatherForecast GetAuth()
    {
        var userIdClaim = User.FindFirst("userid")?.Value;

        if (userIdClaim == null)
        {
            throw new UnauthorizedAccessException(); // クレームがない場合
        }
        return new WeatherForecast(){
            Date = DateOnly.FromDateTime(DateTime.Now),
            TemperatureC = 1,
            Summary = "userid is " + userIdClaim
        };
    }

実行すると取得できていることがわかります。

補足ですが、.NETではClaimTypesという列挙型によく使いそうな名前があるのですが、これを使うと~~~~/name のようにバージョン情報的なものが先頭についてしまいます。メリットがあるのかもしれませんが、個人的にはデバッグ実行時のプロパティ一覧での見通しが悪くなりますし、プレフィックス文字列がいつの間にか変わることもあるかもしれませんので、別途自作Enumで管理する方がいいのかなと思っています。

[延長戦]クレームをDIで管理する

上記のようなクレームの取得でも良いのですが、保守性を考慮してDI化してみたいと思います。

まず、Interfaceを実装。

  public interface IUserContext
  {
      string UserId { get; }
  }

次に実体クラスを実装。

public class UserContext : IUserContext
  {
      public string UserId { get; private set; } = "";

      public UserContext(IHttpContextAccessor httpContextAccessor)
      {

          if (httpContextAccessor.HttpContext != null)
          {
              var user = httpContextAccessor.HttpContext.User;
              if (user != null && user.Identity != null && user.Identity.IsAuthenticated)
              {
                  UserId = user.FindFirst("UserId")?.Value ?? "";
              }
          }
      }
  }

上記をProgram.csにてインジェクション。

builder.Services.AddHttpContextAccessor();  // HttpContextへのアクセスを提供
builder.Services.AddScoped<IUserContext, UserContext>();  // IUserContextのDIを登録

試しにアクセスしてみましょう。WeatherForecastControllerのコンストラクタとクラス変数を変更。

    private readonly IUserContext _userContext;
    public WeatherForecastController(IUserContext userContext){
        _userContext = userContext;
    }

続いて実際にアクセス。先ほど作ったGetAuthの方でアクセスしてみてください。

   [Route("auth")]
    [HttpGet]
    public WeatherForecast GetAuth()
    {
        //DIからクレーム取得
        Console.WriteLine(_userContext.UserId);
        //直接クレーム取得
        var userIdClaim = User.FindFirst("userid")?.Value;

どうでしょうか?

DIの方が当たり前ではありますが見通しが良いですね。

まとめ

特に認証周りはフレームワークをフル活用する方が安全です。また、意外とこのあたりの記事はテックブログに載ってなかったりバージョンが違うと書き方が違ったりでした。変な独自実装が混ざらないよう気をつけていますが、各々自己判断で、調べながら参考にしてもらえたらと思います。

タイトルとURLをコピーしました