9
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

SC(非公式)Advent Calendar 2019

Day 19

C#でJWTを発行して、Node.jsで検証する簡単なお仕事です

Last updated at Posted at 2019-12-19

SC(非公式)Advent Calendar 2019 の19日目です。

はじめに

最近JWT周りのなんやかんやを触る機会が多いです。
別の言語での取り回しなんかもできるのが、JWTでの検証の良いところだと思います。

今回は.NetCore3.0で追加された 暗号化キーのインポート/エクスポートで、
RSAではなくECDsa(楕円暗号方式)で署名/検証しました。

サーバー構成としては以下になります。
image.png

実行環境

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を新しく作成します。
全貌がこちら。

AuthenticationController.cs

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デコードして渡してあげないとダメです。

image.png

ですので、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"}

image.png

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に以下のコードを追加します。

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の検証

app.js
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ファイルはこんな感じになっています。

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するだけです。

image.png

Tokenがなかった場合、きちんと401が返ってきます。

image.png

以上です。

C#でのJWTの検証

C#での検証もためしてみたので、雑に載せときます。

Startup.cs
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)

9
7
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?