ASP.NET Core3.1でJWT認証の実装(公開鍵、非対称鍵認証)

#前書き
JWT認証の際、非対称鍵による認証の手順です。
秘密鍵で暗号化し、公開鍵で複合します。

秘密鍵同士による共通鍵方式と違い、公開鍵なので基本誰でも復号できる前提です。

公開鍵で複合することで２つのことが証明されます
・発行元
・トークン改ざんされていないこと

例えば、
認証サーバーのみが秘密鍵を保持していて、
複数の別システムが公開鍵を持っている状況の認証フローを想定します。

1. 認証サーバーはID/PASSでユーザー認証後と秘密鍵でJWTを生成
2. 他のシステムはJWTを公開鍵で復号
3. 発行元が認証サーバーであること、トークンが改ざんされていないことが確認できる
4. 認証OK


ただし、今から説明するプロジェクトは暗号化と復号化が同じサーバーなので、非対称鍵のメリットはありません。
非対称鍵のJWTのやり方だけ説明します。

# 環境・使用ツール
・VisualStudio2019
・CMD
・ASP.NET Core

検証時
・Postman


#下準備
## 秘密鍵・公開鍵の生成
SHA256暗号化方式

以下のコマンドをCMDで実行、実行したディレクトリにファイルができます。

```
ssh-keygen -t rsa -b 4096 -m PEM -f jwtRS256.key
openssl rsa -in jwtRS256.key -pubout -outform PEM -out jwtRS256.key.pub 
```
参考サイト

https://gist.github.com/ygotthilf/baa58da5c3dd1f69fae9
## プロジェクトの作成
・今回はASP.NET CORE3.1のWebAPIで作成しました
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/139166/d10e044b-0429-625d-4a8d-749664478d8a.png)


## NuGetでライブラリのインストール
・System.IdentityModel.Tokens.Jwt
・Microsoft.AspNetCore.Authentication.JwtBearer
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/139166/babf3672-6336-d7fb-374d-3ddd49d9ea07.png)

## appsetting.jsonの修正

#### 秘密鍵と公開鍵を設定します(貼り付けるのに少しコツがいるかも。。)
```json:appseting.json
{
  "Jwt": {
    "Asymmetric": {
      "PrivateKey": "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",
      "PublicKey": "MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAty7lSC0kTsS48PSZXBptuBWj4An57LgunUwnLyAx+IhxHPZqnSOAjwFh2dOC1MVEjWz9GtMlMW4wiIuwsBSvxzMzlnI/9L5VuW5aKU5m34DrR0hwtJE3rCByIxhQ9nbT2Y/hO8epWqh0yNKqQwVIXEJ/jf/5Zv8+UqCKy1BjAQ0BCRr+pzJqNq6VV8PjQb8XPw3glvrnm3hP34NoQAc0qdnNIPZ36eYlJX/oTAZBAdUNbgOaV7piL1mBg/R+sOAOnMvy3plhWyg3WH73dLq/Gg+eqODEUYZ3p+WGw/Vt0FFfdudeWMUTUkR+Go0EVndITkppiYlL3L0WtnHhiGS0B8zr6r8ftR1TVRt+fpE4d4gciIliJ9QP/jn1rwMY7moUrumJv4zRwhSkWBgtn/viRlCOsboZVgP5lmQ9JTJXw1o2NJ/uwU26i9dMtjxtAGGjzTL281eIOpIDQtMUdJ8DKO/3Pecnhf3uBUgONRf21RV/SAyjHac1Jiqase4nYYtzphhLqLhUAkj77W2zf60lNncpszNp+GWoZrl7HJqwjZrQsrT+ep2D5oSb6p3fwR9IyBqcfk8obZMZMJX2iUrpvHi8TMW7/Iw3564kFfj4EVKRZcjxgT5q9CvSSd7ByxZAwXGzFPzqAadu5w0e5SUr88aIbixrr7CldozVwdgybPcCAwEAAQ=="
    },
    "TokenExpiredHours": 24
  },
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft": "Warning",
      "Microsoft.Hosting.Lifetime": "Information"
    }
  },
  "AllowedHosts": "*"
}

```

# 実装
#### JwtServiceクラスを作成
```C#:JwtService
using Microsoft.Extensions.Configuration;
using Microsoft.IdentityModel.Tokens;
using System;
using System.IdentityModel.Tokens.Jwt;
using System.Linq;
using System.Security.Claims;
using System.Security.Cryptography;


namespace ASPNETCORE_JWT.Services
{

    public class JwtService
    {
        private RsaSecurityKey _rsa { get; }
        private IConfiguration _configuration { get; }
        public JwtService(IConfiguration configuration)
        {
            _configuration = configuration;
            // 公開鍵の設定
            RSA rsa = RSA.Create();
            rsa.ImportSubjectPublicKeyInfo(
                source: Convert.FromBase64String(configuration["Jwt:Asymmetric:PublicKey"]),
                bytesRead: out int _
            );
            _rsa = new RsaSecurityKey(rsa);
        }

        public string GenerateJwt(Guid userId)
        {
            using RSA rsa = RSA.Create();
            rsa.ImportRSAPrivateKey(
                source: Convert.FromBase64String(_configuration["Jwt:Asymmetric:PrivateKey"]),
                bytesRead: out int _);

            var signingCredentials = new SigningCredentials(
                key: new RsaSecurityKey(rsa)
                {
                    // using文でも破棄されないキャッシュがあり
                    // 偶数リクエストで失敗するため、修正されるまで追加
                    CryptoProviderFactory = new CryptoProviderFactory()
                    {
                        CacheSignatureProviders = false
                    }
                },
                algorithm: SecurityAlgorithms.RsaSha256
            );

            var tokenExpiredHours = double.Parse(_configuration["Jwt:TokenExpiredHours"]);

            // JWTのペイロードの中身
            var jwtToken = new JwtSecurityToken(
                audience: "JWT_Client",
                issuer: "JWT_Server",
                claims: new Claim[] {
                    new Claim(ClaimTypes.NameIdentifier, userId.ToString())
                },
                notBefore: DateTime.UtcNow,
                expires: DateTime.UtcNow.AddHours(tokenExpiredHours),
                signingCredentials: signingCredentials
            );

            var token = new JwtSecurityTokenHandler().WriteToken(jwtToken);

            return token;
        }

        public TokenValidationParameters GetTokenValidationParameters()
        {
            // 公開鍵でトークンの検証
            // JWTの有効期限の検証
            return new TokenValidationParameters
            {
                IssuerSigningKey = _rsa,
                RequireSignedTokens = false,
                RequireExpirationTime = true,
                ValidateLifetime = true,
                LifetimeValidator = LifetimeValidator,
                ValidateAudience = false,
                ValidateIssuer = false,
                CryptoProviderFactory = new CryptoProviderFactory()
                {
                    // using文でも破棄されないキャッシュがあり
                    // 偶数リクエストで失敗するため、修正されるまで追加
                    CacheSignatureProviders = false
                }
            };
        }

        public bool LifetimeValidator(DateTime? notBefore, DateTime? expires, SecurityToken token, TokenValidationParameters @params)
        {
            if (expires != null)
            {
                return expires > DateTime.UtcNow;
            }
            return false;
        }
    }
}
```
#### リクエストの最初にJWTの検証を挟むように設定します。

```C#:Startup.cs
   public void ConfigureServices(IServiceCollection services)
        {
            services.AddControllers();
            services.AddHttpContextAccessor();

            services.AddSingleton<JwtService>();

            services.AddAuthentication()
              .AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options =>
              {
                  var sp = services.BuildServiceProvider();
                  var ssoTokenService = sp.GetRequiredService<JwtService>();
                  options.TokenValidationParameters = ssoTokenService.GetTokenValidationParameters();
              });
        }
```
#### 検証用のコントローラー追加

```C#:LoginController.cs
using ASPNETCORE_JWT.Extentions;
using ASPNETCORE_JWT.Services;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using System;

namespace ASPNETCORE_JWT.Controllers
{
    [ApiController]
    [Route("api/[controller]/[action]")]
    public class LoginController : ControllerBase
    {
        private IHttpContextAccessor _httpContextAccessor { get; }
        private JwtService _jwtService { get; }
        public LoginController(IHttpContextAccessor httpContextAccessor, JwtService jwtService)
        {
            _httpContextAccessor = httpContextAccessor;
            _jwtService = jwtService;
        }

        [HttpPost]
        public string Login()
        {
            var userId = Guid.NewGuid();
            var jwt = _jwtService.GenerateJwt(userId);
            return jwt;
        }

        [HttpGet]
        // Authorize属性を指定したメソッドのみJWTの検証の対象になります。
        [Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
        public Guid GetUserId()
        {
            return _httpContextAccessor.GetUserId();
        }
    }
}
```

#### JWTの検証を通ったリクエストからUserIdを取得します。
```C#:HttpContextAccessorExtension.cs
using Microsoft.AspNetCore.Http;
using System;
using System.Linq;
using System.Security.Claims;

namespace ASPNETCORE_JWT.Extentions
{
    public static class HttpContextAccessorExtension
    {
        public static Guid GetUserId(this IHttpContextAccessor self)
        {
            var nameIdentifier = self.HttpContext.User?.Claims.FirstOrDefault(x => x.Type == ClaimTypes.NameIdentifier);
            if (nameIdentifier == null)
                throw new Exception("ClaimにIdが含まれていません");
            
            return Guid.Parse(nameIdentifier.Value);
        }
    }
}

```

# 確認

サーバーを起動して、Postmanでリクエストを投げます。
## Login
・MethodをPostに変更
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/139166/b563c9b5-6ccd-ef7f-44e1-e5bf52de8d6b.png)
JWTトークンが取得できます。

## GetUserId
・MethodをGetに変更
・Authorizationタブに切り替え
・TypeをBearer Tokenに変更
・Loginで取得したJWTを貼り付けてSend
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/139166/aa74d909-28c7-aa5a-6955-8ae6126fcf59.png)

UserIdが取れることが確認できます。

# 終わりに
鍵の生成にC#のRSA.Create()を使ったら、内部的にSHA1が使われていたので、
CMDで生成する手順にしてます。


リポジトリ
https://github.com/shinnosukekubo/ASPNETCORE_JWT