JWTを用いたリクエストパラメーターの改ざん検知のための署名検証実装(Rails)


JWTとは

JSON Web Tokenの略称です。

詳細内容については以下のリンクを参考にしてください。
https://openid-foundation-japan.github.io/draft-ietf-oauth-json-web-token-11.ja.html

JWT構成

ヘッダ、ペイロード、署名の3つのパートになっててそれぞれBase64でエンコードされている。

ヘッダ
{
  "typ":"JWT",
  "alg":"HS256"
}
ペイロード
{
  "sub": "1234567890",
  "iss": "John Doe",
  "aud": "audience",
  "exp": 1353604926
}
署名
HMACSHA256(
  base64UrlEncode(header) + "." +
  base64UrlEncode(payload),
  secret)

3つのパートは . (ドット) で結合されている。

ヘッダ・ペイロード・証明を簡単に作ってみたいなら以下のリンクでできます。
https://jwt.io/#debugger

JWT
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJ1c2VyIiwic3ViIjoidGVzdCIsImF1ZCI6ImF1ZGllbmNlIiwicGFyYW1zIjoie1wiZW1haWxcIjogXCJ0ZXN0QGdtYWlsLmNvbVwiLCBcInBhc3N3b3JkXCI6IFwicGFzc3dvcmRcIn0iLCJleHAiOiIxNTkzODM2ODA2In0.4fC4yLEmYTjiwaXk3R_AUUPEQSuI_ARmkoMqosWEJ-c

JWTクレーム

JWT クレームセットは JSON オブジェクトであり, それぞれのメンバは JWT として送られるクレームである. JWT クレームセット内のクレーム名は一意でなければならない
今回は以下の4つのクレームを使います。

"iss" (Issuer) クレーム:JWTの発行者の識別子
"sub" (Subject) クレーム:JWTの主語となる主体の識別子
"aud" (Audience) クレーム:JWTを利用することが想定された主体の識別子一覧
"exp" (Expiration Time):JWTの有効期限

今回やってみること

リクエストパラメータの改ざん検知のための署名検証の実装になります。
ペイロードにリクエストパラメーターを追加して、そのパラメーターと実際受け取ったパラメーターに改ざんがあるかを検証する実装をやってみます。

payloadにparams追加
  "params": "{\"email\":\"[email protected]\",\"password\":\"password\"}",

APIプロジェクト生成

console
$ rails new jwt --api

Gemfileにjwt追加

gem 'jwt'

Jwt署名検証チェックモジュール作成

app/controllers/concerns/signature.rb
module Signature
  extend ActiveSupport::Concern

  def verify_signature
    render status: 401, json: { message: '改ざんが見つかりました。'} if request.headers['jwt-signature'].blank?

    request_params = JSON.parse(request.body.read)

    @signature ||= JwtSignature.new(jwt: request.headers['jwt-signature'])
    @signature.verify!(params: request_params)
  rescue JwtSignature::InvalidSignature
    render status: 401, json: { message: '改ざんが見つかりました。'}
  end
end

Jwt署名検証処理モデル作成

app/models/jwt_signature.rb
class JwtSignature
  class InvalidSignature < StandardError; end
  ALGORITHM = 'HS256'
  ISSUER = 'user'
  AUDIENCE = 'audience'
  SUB = "test"
  TOKEN_TYPE = 'JWT'
  # SECRET_KEYは重要なので環境ごとに定義して安全に管理してください〜
  SECRET_KEY = '1gCi6S9oaleH22KWaXyXZAQccBx4lUQi'

  def initialize(jwt:)
    @jwt = jwt
  end

  def verify!(params:)
    raise InvalidSignature unless valid_payload? && valid_params?(params: params) && valid_header?
  end

  private

  def valid_payload?
    return false unless jwt_payload['iss'] == ISSUER

    return false unless jwt_payload['sub'] == SUB

    return false unless jwt_payload['aud'] == AUDIENCE

    true
  end

  def valid_header?
    return false unless jwt_header['alg'] == ALGORITHM

    return false unless jwt_header['typ'] == TOKEN_TYPE

    true
  end

  def valid_params?(params:)
    JSON.parse(jwt_payload['params']) == params
  end

  def jwt_header
    @jwt_header ||= decoded_jwt.second
  end

  def jwt_payload
    @jwt_payload ||= decoded_jwt.first
  end

  def decoded_jwt
    @decoded_jwt ||= JWT.decode(@jwt, SECRET_KEY, true, algorithm: ALGORITHM)
  rescue JWT::DecodeError
    raise InvalidSignature
  end
end

JWT.decodeはHashのArrayを返すので
decoded_jwt.firstでPayload、decoded_jwt.secondでHeaderを取得

User Table作成

app/models/user.rbが生成される

$ rails g model User name:string email:string token:string expired_at:datetime
$ rails db:migrate

routes.rbにAPI追加

routes.rb
Rails.application.routes.draw do
  post 'tokens/create'
end

Token Controller作成

tokens_controller.rb
class TokensController < ActionController::API
  include Signature

  # createの時だけ署名検証を確認する
  before_action :verify_signature, only: %i(create)

  def create
    # 今回は署名検証処理だけ実装
    user = User.find_by(email: params[:email], password: params[:password])

    return render status: 400, json: { message: 'ユーザーが存在しません。' } unless user

    # Tokenが存在しない場合は更新
    if user.token.blank?
      user.token = SecureRandom.uuid
      user.save
    end
    render status: 200, json: { name: user.name, email: user.email, token: user.token }
  end
end

テストユーザー登録

$rails c

irb(main):001:0> User.new(name: 'test', email: '[email protected]', password: 'password')
   (0.5ms)  SELECT sqlite_version(*)
=> #<User id: nil, name: "test", email: "[email protected]", password: [FILTERED], token: nil, expired_at: nil, created_at: nil, updated_at: nil>
irb(main):002:0> User.new(name: 'test', email: '[email protected]', password: 'password').save
   (0.1ms)  begin transaction
  User Create (0.9ms)  INSERT INTO "users" ("name", "email", "password", "created_at", "updated_at") VALUES (?, ?, ?, ?, ?)  [["name", "test"], ["email", "[email protected]"], ["password", "password"], ["created_at", "2020-07-04 02:30:17.701626"], ["updated_at", "2020-07-04 02:30:17.701626"]]
   (0.8ms)  commit transaction
=> true

JwtEncodedデータ生成

https://jwt.io/#debugger
上記のリンクからJwtEncodedデータを生成
Payload部分は自分が必要なJwtクレームとパラメータを追加
paramsにemail, passwordを追加してAPIのパラメーターのJsonとPayloadのJsonを比較している
VERIFY SIGNATUREのSecurityキーも自分のSecurityに変更(JwtSignatureモデルのSECRET_KEY使用)
満期時間を設定したい場合はexpにUnixtimeを設定

unixtime(現在時間から5分後)
irb(main):040:0> (Time.now + 300).to_i
=> 1593838401

Postmanで実行してみる

Headerに「jwt-signature」を追加してJwtEncodedデータを追加

JwtEncodedデータ
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJ1c2VyIiwic3ViIjoidGVzdCIsImF1ZCI6ImF1ZGllbmNlIiwicGFyYW1zIjoie1wiZW1haWxcIjogXCJ0ZXN0QGdtYWlsLmNvbVwiLCBcInBhc3N3b3JkXCI6IFwicGFzc3dvcmRcIn0iLCJleHAiOiIxNTkzODM4NDAxIn0.dSNqdhHBJKUJHnJa_2sS_3Qr4oNNdr5MKFx5ufwqLv4

Bodyにjson形式でemail, password追加

json
{ "email": "[email protected]", "password": "password"}

実行

署名検証後無事にtokenが取得できました〜

同じ状態で5分後実行

expに5分を設定したので5分後には署名検証失敗