JWT Ruby

認証JWTRubyセキュリティトークンRFC7519

ライブラリ

JWT Ruby

概要

JWT Rubyは、RFC 7519標準に準拠したJSON Web Token(JWT)のRuby実装で、安全な認証とトークン管理を提供します。

詳細

JWT Ruby gemは、Ruby/Rails アプリケーションでJWT認証を実装するための包括的なライブラリです。HMAC、RSASSA、ECDSA、RSASSA-PSS、EdDSAアルゴリズムをサポートし、OpenSSLライブラリを通じてネイティブ実装を提供します。Ruby 3.3までの最新バージョンをサポートし、高いパフォーマンスとセキュリティを実現します。トークンの生成、検証、デコード機能を提供し、カスタムクレームの処理や期限切れ検証も自動的に行います。Rails アプリケーションとの統合が容易で、多くのサードパーティライブラリからも利用されています。GitHub の jwt/ruby-jwt リポジトリで活発に開発が続けられており、エンタープライズグレードの認証システムに適用できる信頼性の高いライブラリです。

メリット・デメリット

メリット

  • RFC 7519準拠: 標準的なJWT仕様への完全準拠
  • 豊富なアルゴリズム: HMAC、RSA、ECDSA、EdDSA対応
  • 高パフォーマンス: OpenSSLネイティブ実装による高速処理
  • Rails統合: Ruby on Railsとの自然な統合
  • 拡張性: カスタムアルゴリズムとエクステンション対応
  • 成熟したライブラリ: 長期にわたる開発とメンテナンス実績

デメリット

  • 設定複雑性: セキュリティ設定の複雑さと学習コスト
  • Ruby固有: Ruby以外のランタイム環境では使用不可
  • 依存関係: OpenSSLライブラリへの依存
  • アルゴリズム選択: 適切な暗号化アルゴリズムの選択が必要
  • 古いRuby: Ruby 2.5以下のサポート終了

主要リンク

書き方の例

基本的なトークン生成と検証

require 'jwt'

# シークレットキー
secret = 'my_secret_key'

# ペイロード
payload = {
  user_id: 123,
  email: '[email protected]',
  exp: Time.now.to_i + 3600 # 1時間後に期限切れ
}

# トークンを生成
token = JWT.encode(payload, secret, 'HS256')
puts "Generated token: #{token}"

# トークンをデコード・検証
begin
  decoded_payload = JWT.decode(token, secret, true, algorithm: 'HS256')
  puts "Decoded payload: #{decoded_payload[0]}"
  puts "Header: #{decoded_payload[1]}"
rescue JWT::DecodeError => e
  puts "Decode error: #{e.message}"
end

RSA秘密鍵/公開鍵での署名

require 'jwt'
require 'openssl'

# RSA鍵ペアを生成
rsa_private = OpenSSL::PKey::RSA.generate(2048)
rsa_public = rsa_private.public_key

# ペイロード
payload = {
  user_id: 456,
  role: 'admin',
  iat: Time.now.to_i,
  exp: Time.now.to_i + 3600
}

# RSA-256で署名
token = JWT.encode(payload, rsa_private, 'RS256')
puts "RSA signed token: #{token}"

# 公開鍵で検証
begin
  decoded = JWT.decode(token, rsa_public, true, algorithm: 'RS256')
  puts "Successfully verified with public key"
  puts "User ID: #{decoded[0]['user_id']}"
  puts "Role: #{decoded[0]['role']}"
rescue JWT::VerificationError => e
  puts "Verification failed: #{e.message}"
end

Rails認証システムの実装

# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  before_action :authenticate_request
  
  private
  
  def authenticate_request
    header = request.headers['Authorization']
    if header.present?
      token = header.split(' ').last
      begin
        decoded = JWT.decode(token, Rails.application.secrets.secret_key_base, true, algorithm: 'HS256')
        @current_user_id = decoded[0]['user_id']
      rescue JWT::DecodeError => e
        render json: { error: 'Invalid token' }, status: :unauthorized
      end
    else
      render json: { error: 'Missing token' }, status: :unauthorized
    end
  end
  
  def current_user
    @current_user ||= User.find(@current_user_id) if @current_user_id
  end
end

# app/controllers/auth_controller.rb
class AuthController < ApplicationController
  skip_before_action :authenticate_request, only: [:login]
  
  def login
    user = User.find_by(email: params[:email])
    
    if user&.authenticate(params[:password])
      payload = {
        user_id: user.id,
        email: user.email,
        role: user.role,
        iat: Time.now.to_i,
        exp: Time.now.to_i + 24.hours.to_i
      }
      
      token = JWT.encode(payload, Rails.application.secrets.secret_key_base, 'HS256')
      
      render json: {
        token: token,
        user: {
          id: user.id,
          email: user.email,
          name: user.name
        }
      }
    else
      render json: { error: 'Invalid credentials' }, status: :unauthorized
    end
  end
  
  def me
    render json: current_user
  end
end

JWTサービスクラス

# app/services/jwt_service.rb
class JwtService
  ALGORITHM = 'HS256'.freeze
  
  class << self
    def encode(payload, expiration = 24.hours.from_now)
      payload[:exp] = expiration.to_i
      payload[:iat] = Time.now.to_i
      
      JWT.encode(payload, secret_key, ALGORITHM)
    end
    
    def decode(token)
      decoded = JWT.decode(token, secret_key, true, algorithm: ALGORITHM)
      decoded[0].with_indifferent_access
    rescue JWT::DecodeError => e
      Rails.logger.error "JWT Decode Error: #{e.message}"
      nil
    end
    
    def verify_and_decode(token)
      payload = decode(token)
      return nil unless payload
      
      # 追加のカスタム検証
      return nil if payload[:exp] < Time.now.to_i
      
      payload
    end
    
    def refresh_token(old_token)
      payload = decode(old_token)
      return nil unless payload
      
      # リフレッシュ可能期間のチェック(例:期限切れから1週間以内)
      expiry_time = Time.at(payload[:exp])
      return nil if expiry_time < 1.week.ago
      
      # 新しいトークンを生成
      new_payload = payload.except(:exp, :iat)
      encode(new_payload)
    end
    
    private
    
    def secret_key
      Rails.application.secrets.secret_key_base
    end
  end
end

# 使用例
token = JwtService.encode({ user_id: 1, role: 'user' })
payload = JwtService.verify_and_decode(token)
new_token = JwtService.refresh_token(token)

権限管理とミドルウェア

# app/controllers/concerns/authorization.rb
module Authorization
  extend ActiveSupport::Concern
  
  def authorize_admin
    unless current_user&.admin?
      render json: { error: 'Admin access required' }, status: :forbidden
    end
  end
  
  def authorize_owner_or_admin(resource)
    unless current_user&.admin? || resource.user_id == current_user.id
      render json: { error: 'Access denied' }, status: :forbidden
    end
  end
end

# app/middleware/jwt_middleware.rb
class JwtMiddleware
  def initialize(app)
    @app = app
  end
  
  def call(env)
    request = Rack::Request.new(env)
    
    # 認証が不要なパスをスキップ
    unless requires_authentication?(request.path)
      return @app.call(env)
    end
    
    auth_header = request.get_header('HTTP_AUTHORIZATION')
    
    if auth_header&.start_with?('Bearer ')
      token = auth_header[7..-1]
      payload = JwtService.verify_and_decode(token)
      
      if payload
        env['jwt.payload'] = payload
        env['jwt.user_id'] = payload['user_id']
      else
        return unauthorized_response
      end
    else
      return unauthorized_response
    end
    
    @app.call(env)
  end
  
  private
  
  def requires_authentication?(path)
    # 認証が必要ないパスを定義
    excluded_paths = ['/auth/login', '/auth/register', '/health']
    !excluded_paths.any? { |excluded| path.start_with?(excluded) }
  end
  
  def unauthorized_response
    [401, { 'Content-Type' => 'application/json' }, 
     [{ error: 'Unauthorized' }.to_json]]
  end
end

カスタムクレームとバリデーション

# app/models/jwt_token.rb
class JwtToken
  include ActiveModel::Model
  include ActiveModel::Validations
  
  attr_accessor :user_id, :email, :role, :permissions, :iat, :exp, :jti
  
  validates :user_id, presence: true
  validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
  validates :role, inclusion: { in: %w[admin user guest] }
  
  def self.from_token(token)
    payload = JwtService.decode(token)
    return nil unless payload
    
    new(payload)
  end
  
  def to_token
    return nil unless valid?
    
    payload = {
      user_id: user_id,
      email: email,
      role: role,
      permissions: permissions,
      jti: jti || SecureRandom.uuid
    }
    
    JwtService.encode(payload)
  end
  
  def expired?
    return true unless exp
    Time.at(exp) < Time.now
  end
  
  def admin?
    role == 'admin'
  end
  
  def can?(permission)
    return true if admin?
    permissions&.include?(permission.to_s)
  end
end

# 使用例
jwt_token = JwtToken.new(
  user_id: 1,
  email: '[email protected]',
  role: 'user',
  permissions: ['read_posts', 'write_comments']
)

if jwt_token.valid?
  token = jwt_token.to_token
  
  # トークンから復元
  restored_token = JwtToken.from_token(token)
  puts restored_token.can?('read_posts') # => true
  puts restored_token.can?('delete_users') # => false
end

テストでの使用例

# spec/support/jwt_helpers.rb
module JwtHelpers
  def generate_jwt_token(user, additional_claims = {})
    payload = {
      user_id: user.id,
      email: user.email,
      role: user.role
    }.merge(additional_claims)
    
    JwtService.encode(payload)
  end
  
  def auth_headers(user)
    token = generate_jwt_token(user)
    { 'Authorization' => "Bearer #{token}" }
  end
end

# spec/requests/api/posts_spec.rb
RSpec.describe 'API::Posts', type: :request do
  include JwtHelpers
  
  let(:user) { create(:user) }
  let(:admin) { create(:user, :admin) }
  
  describe 'GET /api/posts' do
    it 'returns posts for authenticated user' do
      get '/api/posts', headers: auth_headers(user)
      
      expect(response).to have_http_status(:ok)
      expect(JSON.parse(response.body)).to be_an(Array)
    end
    
    it 'returns unauthorized for invalid token' do
      get '/api/posts', headers: { 'Authorization' => 'Bearer invalid_token' }
      
      expect(response).to have_http_status(:unauthorized)
    end
  end
  
  describe 'DELETE /api/posts/:id' do
    let(:post) { create(:post, user: user) }
    
    it 'allows owner to delete their post' do
      delete "/api/posts/#{post.id}", headers: auth_headers(user)
      
      expect(response).to have_http_status(:no_content)
    end
    
    it 'allows admin to delete any post' do
      delete "/api/posts/#{post.id}", headers: auth_headers(admin)
      
      expect(response).to have_http_status(:no_content)
    end
  end
end