前書き
JWT認証の際、非対称鍵による認証の手順です。
秘密鍵で暗号化し、公開鍵で複合します。
秘密鍵同士による共通鍵方式と違い、公開鍵なので基本誰でも復号できる前提です。
公開鍵で複合することで2つのことが証明されます
・発行元
・トークン改ざんされていないこと
例えば、
認証サーバーのみが秘密鍵を保持していて、
複数の別システムが公開鍵を持っている状況の認証フローを想定します。
- 認証サーバーはID/PASSでユーザー認証後と秘密鍵でJWTを生成
- 他のシステムはJWTを公開鍵で復号
- 発行元が認証サーバーであること、トークンが改ざんされていないことが確認できる
- 認証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
参考サイト
プロジェクトの作成
・今回はASP.NET CORE3.1のWebAPIで作成しました
NuGetでライブラリのインストール
・System.IdentityModel.Tokens.Jwt
・Microsoft.AspNetCore.Authentication.JwtBearer
appsetting.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クラスを作成
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の検証を挟むように設定します。
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();
});
}
検証用のコントローラー追加
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を取得します。
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に変更
JWTトークンが取得できます。
GetUserId
・MethodをGetに変更
・Authorizationタブに切り替え
・TypeをBearer Tokenに変更
・Loginで取得したJWTを貼り付けてSend
UserIdが取れることが確認できます。
終わりに
鍵の生成にC#のRSA.Create()を使ったら、内部的にSHA1が使われていたので、
CMDで生成する手順にしてます。