SC(非公式)Advent Calendar 2019 の19日目です。
はじめに
最近JWT周りのなんやかんやを触る機会が多いです。
別の言語での取り回しなんかもできるのが、JWTでの検証の良いところだと思います。
今回は.NetCore3.0で追加された 暗号化キーのインポート/エクスポートで、
RSAではなくECDsa(楕円暗号方式)で署名/検証しました。
実行環境
OS: mac OS Mojave 10.14.6
IDE: VS2019 for Mac community 8.3.6
.NetCore: 3.1.100
node: 10.14.1
npm: 6.4.1
クライアント: POSTMAN
余談ですが、MacでわざわざC#を触る人ってキチガイですよね~。
と、後輩に言われました。
秘密鍵・公開鍵の作成
以下のコマンドで楕円曲線暗号方式で秘密鍵と公開鍵を作成します。
ssh-keygen -t ecdsa -b 256 -m PEM -f jwtES256.key
openssl ec -in jwtES256.key -pubout -outform PEM -out jwtES256.key.pub
C#でIDProviderを作成
JWTを発行するC#のプロジェクトを立ち上げます。
必要なパッケージとして
Microsoft.AspNetCore.Authentication.JwtBearer
を追加しています。
# ワークフォルダ
mkdir JwtSample
cd JwtSample
# ソリューションの作成
dotnet new sln
# WebAPIテンプレートのプロジェクト作成
dotnet new webapi -o ./CSharpIDP
# ソリューションにプロジェクトを追加
dotnet sln add ./CSharpIDP
# JWTでの認証をするためにNugetパッケージを追加
dotnet add ./CSharpIDP package Microsoft.AspNetCore.Authentication.JwtBearer
いざJWTを生成
まずはAuthenticationController
を新しく作成します。
全貌がこちら。
using CSharpIDP.Utils;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Microsoft.IdentityModel.Tokens;
using System;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Threading.Tasks;
namespace CSharpIDP.Controllers
{
[ApiController]
[Route("[controller]")]
public class AuthenticationController : ControllerBase
{
private readonly ILogger<AuthenticationController> _logger;
public AuthenticationController(ILogger<AuthenticationController> logger)
{
_logger = logger;
}
[HttpPost]
public async Task<IActionResult> Token([FromBody] LoginModel model)
{
var tokenString = await AuthenticateAsync(model);
if (tokenString != "")
{
return Ok(new { token = tokenString });
}
return Unauthorized();
}
private async Task<string> AuthenticateAsync([FromBody] LoginModel model)
{
_logger.LogInformation("AuthenticateAsync");
var user = await FetchUserAsync(model.Email); // DB接続などを想定
if (user.Email == model.Email && user.Password == model.Password)
{
var tokenString = GenerateToken(user);
return tokenString;
}
return "";
}
private async Task<UserInfo> FetchUserAsync(string email)
{
_logger.LogInformation($"fetch user data by email={email}");
return await Task.Run(() => new UserInfo
{
UserId = 888,
UserName = "jwtSignningUser",
Email = "aaa@gmail.com",
Password = "password",
Groups = new int[] { 1, 2, 3 }
});
}
private string GenerateToken(UserInfo user)
{
var claims = new[] {
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
new Claim(JwtRegisteredClaimNames.Sid, user.UserId.ToString()),
new Claim(JwtRegisteredClaimNames.Sub, "JWT Sample for node.js"),
new Claim(JwtRegisteredClaimNames.Email, user.Email)
};
var pemStr = System.IO.File.ReadAllText(@"./jwtES256.key");
var der = StringUtil.ConvertX509PemToDer(pemStr);
using var ecdsa = ECDsa.Create();
ecdsa.ImportECPrivateKey(der, out _);
var key = new ECDsaSecurityKey(ecdsa);
var creds = new SigningCredentials(key, SecurityAlgorithms.EcdsaSha256);
var jwtHeader = new JwtHeader(creds);
var jwtPayload = new JwtPayload(
issuer: "https://localhost:5001/",
audience: "https://localhost:3000/",
claims: claims,
notBefore: DateTime.Now,
expires: DateTime.Now.AddMinutes(600),
issuedAt: DateTime.Now
);
var token = new JwtSecurityToken(jwtHeader, jwtPayload);
return new JwtSecurityTokenHandler().WriteToken(token);
}
}
public class LoginModel
{
public string Email { get; set; } = "";
public string Password { get; set; } = "";
}
public class UserInfo
{
public int UserId { get; set; }
public string? UserName { get; set; }
public string? Email { get; set; }
public string? Password { get; set; }
public int[]? Groups { get; set; }
}
}
ではでは、GenerateToken
メソッドの解説をしていきます
JWTのスキーマ
JWTは、RFC7519で定義されているスキーマを持っていて、大きく以下の3種類のスキーマ定義があります。詳しくはここのサイトが大変参考になります。(JSON Web Token(JWT)のClaimについて)
- Registered Claim Names
- Public Claim Names
- Private Claim Names
Registered Claim Names
Registered Claim Namesはあらかじめ決められた、「JWTならこれ持ってますよね」という定義です。
予約語 | 意味 | 役割 |
---|---|---|
iss | Issuer | JWTの発行者。文字列かURIの形式 |
sub | Subject | JWTの用途。文字列かURIの形式 |
aud | Audience | JWTの利用者。文字列かURIの形式 |
exp | Expiration Time | JWTの失効する日時 |
nbf | Not Before | JWTが有効になる日時 |
iat | Issued At | JWTの発行日時 |
jti | JWT ID | JWTを一意な識別子。UUIDなどを入れるのが一般的 |
これらのスキーマ定義に則って、JwtPayload
クラスの設定をしているのが、以下の部分です。
var claims = new[] {
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
new Claim(JwtRegisteredClaimNames.Sid, user.UserId.ToString()),
new Claim(JwtRegisteredClaimNames.Sub, "JWT Sample for node.js"),
new Claim(JwtRegisteredClaimNames.Email, user.Email)
};
// ... 略
var jwtPayload = new JwtPayload(
issuer: "https://localhost:5001/",
audience: "http://localhost:3000/",
claims: claims,
notBefore: DateTime.Now,
expires: DateTime.Now.AddMinutes(60),
issuedAt: DateTime.Now
);
現在から有効な、http://localhost:3000
向けのJWTを発行しています。
有効期限は現在から1時間です。
実際のアプリではユーザーIDなどを入れると思いますので、Private Claim Names
としてSid属性に入れています。
ECDsaでの署名
今回は、どの言語でも汎用的に使用できるように、opensslで秘密鍵と公開鍵のファイルを作成しました。
もちろんC#のプログラムからキーの生成を行うこともできますが、PEMファイルを読み込むとき少しハマったので、ご紹介。
// 秘密鍵ファイルの内容を取得
var pemStr = System.IO.File.ReadAllText(@"./jwtES256.key");
// PEM形式からbase64にデコード
var der = StringUtil.ConvertX509PemToDer(pemStr);
// ECDsaのインスタンス化
using var ecdsa = ECDsa.Create();
// der形式のデータをインポート
ecdsa.ImportECPrivateKey(der, out _);
// SecurityKeyインスタンス生成
var key = new ECDsaSecurityKey(ecdsa);
ECDsaのImportECPrivateKey
メソッドはこんな定義になっているので、
ファイルの余分な部分を削除して、base64デコードして渡してあげないとダメです。
ですので、Utilクラスでこんな泥くさいことをやっています。
public static byte[] ConvertX509PemToDer(string pemContents)
{
var base64 = pemContents
.Replace("-----BEGIN EC PRIVATE KEY-----", string.Empty)
.Replace("-----END EC PRIVATE KEY-----", string.Empty)
.Replace("\r\n", string.Empty)
.Replace("\n", string.Empty); // Windowsだったらこの行は不要かも
return Convert.FromBase64String(base64);
}
メールアドレス・パスワードでトークンを取得
POSTMANからメールアドレスとパスワードでアクセストークンを取得します。
定義 | 値 |
---|---|
URL | https://localhost:5001/authentication |
メソッド | POST |
ヘッダー | Content-Type:application/json |
BODY | { "Email": "aaa@gmail.com", "Password": "password"} |
DECsaの形式で署名されたJWTを取得できました。
Node.jsで検証サーバーを作成
つづいてはアクセストークンを検証するサーバーをNode.jsで作っていきます。
Expressのテンプレートを作成するexpress-generator
をグローバルインストールして、
適当なアプリを作成します。
npm install -g express-generator
# ワークディレクトリ
mkdir Express
cd Express
# verifyappという名前で作成
express verifyapp -e
cd verifyapp
# パッケージをインストール
npm i
# JWTを扱うためのパッケージもインストール
npm i jsonwebtoken
# サーバー起動
npm start
これでhttp://localhost:3000
でサーバーが立つはずです。
検証ミドルウェアを追加
app.jsに以下のコードを追加します。
// ... 略
+ var jwt = require("jsonwebtoken");
+ var fs = require('fs');
// ... 略
// ... 略
- app.use("/users", usersRouter);
+ app.use("/users", Authorize, usersRouter);
+ function Authorize(req, _, next) {
+ const authHeader = ParseAuthHeader(req.headers);
+ if (!authHeader) next(createError(401));
+ const token = authHeader.value;
+ const publicKey = fs.readFileSync("./jwtES256.key.pub", { encoding: "utf8" + });
+ const options = {
+ algorithms: ["ES256"] // 署名オプション
+ };
+ const decodedToken = jwt.verify(token, publicKey, options);
+ if (typeof decodedToken !== "object") next(createError(401));
+ req.token = decodedToken;
+ next();
+ }
+ function ParseAuthHeader(headers) {
+ const AUTH_HEADER = "authorization";
+ const regex = /(\S+)\s+(\S+)/;
+ if (!headers[AUTH_HEADER]) return undefined;
+ if (typeof headers[AUTH_HEADER] !== "string") return undefined;
+ const matches = headers[AUTH_HEADER].match(regex);
+ return matches && { scheme: matches[1], value: matches[2] };
+ }
// ... 略
module.exports = app;
Authorize
というミドルウェアを追加しています。
JWTの検証
function Authorize(req, _, next) {
// リクエストヘッダーのauthorizationヘッダーからベアラートークンを取得
const authHeader = ParseAuthHeader(req.headers);
if (!authHeader) next(createError(401));
const token = authHeader.value;
// 公開鍵ファイルを読み込み
const publicKey = fs.readFileSync("./jwtES256.key.pub", { encoding: "utf8" });
const options = {
algorithms: ["ES256"] // 署名アルゴリズムを指定
};
// トークンの検証
const decodedToken = jwt.verify(token, publicKey, options);
if (typeof decodedToken !== "object") next(createError(401));
// トークンからペイロードの情報が取れたら、reqにtokenとして保存
req.token = decodedToken;
next();
}
userRouterの先のuser.js
ファイルはこんな感じになっています。
var express = require('express');
var router = express.Router();
/* GET users listing. */
router.get('/', function(req, res, next) {
res.send(`${req.token.sub}(${req.token.sid})`);
});
module.exports = router;
POSTMANからアクセス
では先ほど取得したトークン情報をauthorizationヘッダーに載せてアクセスしてみます。
POSTMANでAuthorizationタブからBaerer Tokenを選択して、Tokenに先ほど取得したToken値を入れてGetでSendするだけです。
Tokenがなかった場合、きちんと401が返ってきます。
以上です。
C#でのJWTの検証
C#での検証もためしてみたので、雑に載せときます。
using CSharpIDP.Utils;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.IdentityModel.Tokens;
using System.IO;
using System.Security.Cryptography;
namespace CSharpIDP
{
public class Startup
{
public Startup(IConfiguration configuration)
{
Configuration = configuration;
Ecdsa = ECDsa.Create();
}
~Startup()
{
Ecdsa.Dispose();
}
public IConfiguration Configuration { get; }
private ECDsa Ecdsa { get; }
// This method gets called by the runtime. Use this method to add services to the container.
public void ConfigureServices(IServiceCollection services)
{
var pemStr = File.ReadAllText(@"./jwtES256.key.pub");
var der = StringUtil.ConvertPubKeyToDer(pemStr); // 秘密鍵と同じことやってます
Ecdsa.ImportSubjectPublicKeyInfo(der, out _);
services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters()
{
ValidateIssuer = true,
ValidIssuer = "https://localhost:5001/",
ValidateIssuerSigningKey = true,
IssuerSigningKey = new ECDsaSecurityKey(Ecdsa),
ValidateAudience = false,
ValidateLifetime = false,
};
});
services.AddControllers();
}
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
app.UseHttpsRedirection();
app.UseRouting();
app.UseAuthentication(); // 追加
app.UseAuthorization();
app.UseEndpoints(endpoints =>
{
endpoints.MapControllers();
});
}
}
}
これで、認可したいControllerに[Authorize]
属性つければ、
認証のフィルターができるようになります。
参考
.NET Core 3.0 の新機能
Embracing nullable reference types
[JWT Signing using ECDSA in .NET Core]
(https://www.scottbrady91.com/C-Sharp/JWT-Signing-using-ECDSA-in-dotnet-Core)