【Rails】JWTを利用したログインAPIと認証付きAPIの実装

Ruby

今回のゴール

  • 認証情報をJWTで管理する方法について理解する
  • メールアドレスとパスワードをPOSTするとJWTがCookieにセットされるログインAPIを作成する
  • JWTを利用して認証を実施する認証付きAPIを作成する。今回は例としてユーザー取得APIを作成する

実装の仕様について

バックエンド関連

  • フロントエンドとバックエンドが分かれた環境を想定し、Rails APIで実装をする
  • Cookieはサーバサイドで作成する
  • ユーザーのパスワードはhas_secure_passwordで管理する

JWT関連

  • JWTはtokenというキー名でCookieに保存する
  • JWTの署名アルゴリズムはRSA256を利用する
  • JWTのエンコードでは事前に作成したRSA暗号の秘密鍵を利用する
  • JWTにはユーザーIDを保存する
  • JWTの有効期限は14日とする

API関連

  • ログインAPIのエンドポイントは『/sign_in』
  • ログインAPIは認証ができなかった場合は401(認証エラー)を返す
  • ユーザー取得APIのエンドポイントは『/user』
  • ユーザー取得APIは認証ができなかった場合は401(認証エラー)を返す

下準備

ユーザーの作成

認証対象のユーザーを作成します。ActiveModel::SecurePassword::ClassMethodsのドキュメントに従い、has_secure_passwordでパスワード管理されたデータを作成します。

Gemfile

gem "bcrypt"
### モデルのマイグレーション
$ rails g model user name email password_digest
$ rails db:migrate

user.rb

class User < ActiveRecord::Base
  has_secure_password
end
$ rails c

### ユーザーの作成
> user = User.new(name: 'David', email: 'test@example.com', password: 'password123', password_confirmation: 'password123')
> user.save

作成したユーザーは以下のように取得できます。

$ rails c

### ユーザーの取得
> User.find_by(name: 'David')&.authenticate('password123')
=> #<User:0x00007fe622d46e20
 id: 1,
 name: "David",
 email: "test@example.com",
 password_digest: "[FILTERED]",
 created_at: Mon, 28 Feb 2022 12:27:34.496789000 UTC +00:00,
 updated_at: Mon, 28 Feb 2022 22:36:28.923149000 UTC +00:00>

RSA暗号の秘密鍵の作成と配置

JWTのエンコード・デコードで必要になるRSA暗号の秘密鍵を作成し、Railsアプリケーションに配置します。

$ cd /path/to/project
$ mkdir auth && cd $_
$ openssl genrsa 2024 > service.key

必要であれば.gitignoreを編集します。

.gitignore

auth/

RailsでJWTを利用できるようにする

今回はruby-jwt(jwt gem)を利用して、Railsアプリケーション上でJWTを操作します。
jwt gemを利用したJWTのエンコード・デコード方法の詳細解説は【Ruby】jwt gemの使い方とJWTのエンコード・デコード方法の具体例で紹介しています。

Gemfile

gem "jwt"
$ rails c

### 配置した『service.key』を読み込む
> rsa_private = OpenSSL::PKey::RSA.new(File.read(Rails.root.join('auth/service.key')))

### テストデータ(payload)の作成
> payload = { id: 1, name: 'Yamada' }

### 秘密鍵を使ってテストデータをエンコード
# JWT(文字列)が作成されればOK
> token = JWT.encode(payload, rsa_private, 'RS256')

### 秘密鍵を使ってJWTをデコード
# ペイロードが取得できればOK
> JWT.decode(token, rsa_public, true, { algorithm: 'RS256' })
=> [{"id"=>1, "name"=>"Yamada"}, {"alg"=>"RS256"}]

サーバサイドでCookieを作成できるようにする

サーバサイドでcookiesメソッドを利用できるようにするため、設定を追加します。
Rails API上でのCookie作成の詳細解説はRails API(サーバサイド)でCookieを作成する方法で紹介しています。

application_controller.rb

class ApplicationController < ActionController::API
  include ActionController::Cookies
end

config/application.rb

module App
  class Application < Rails::Application
    (略)
+   config.middleware.use ActionDispatch::Cookies
  end
end

ログインAPIの作成

メールアドレスとパスワードを受け取り、JWTをCookieにセットするAPIを作成します。
なお、以下のコードで登場するペイロードのクレーム(isssubexp)の詳細解説はJWTの概要と構成要素(ヘッダ/ペイロード/署名)を理解するで紹介しています。

routes.rb

post "/sign_in", to: "sessions#create"

sessions_controller.rb

class SessionsController < ApplicationController
  def create
    # ユーザの取得
    user = User.find_by(email: params[:email])&.authenticate(params[:password])

    # ペイロードの作成
    payload = {
      iss: "example_app", # JWTの発行者
      sub: user.id, # JWTの主体
      exp: (DateTime.current + 14.days).to_i # JWTの有効期限
    }

    # 秘密鍵の取得
    rsa_private = OpenSSL::PKey::RSA.new(File.read(Rails.root.join('auth/service.key')))

    # JWTの作成
    token = JWT.encode(payload, rsa_private, "RS256")

    # JWTをCookieにセット
    cookies[:token] = token

    render status: :created
  end
end

curlコマンドを実行して結果を確認していみます。以下のようにレスポンスヘッダにSet-Cookie: token=xxxxという記述があればOKです。

$ curl -i -X POST -H "Content-Type: application/json" -d '{"email":"test@example.com", "password":"password123"}' localhost:3001/sign_in

HTTP/1.1 201 Created
# (略)
Set-Cookie: token=eyJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJleGFtcGxlX2FwcCIsInN1YiI6MSwiZXhwIjoxNjQ3Mjk4MTc4fQ.O4AYzSrFf2CuD8DqmpCdEIQh8glfO3ngEIGaWfLTkp0sN-BnZ5Qit9CwPOaRwaglIIrCNsNt6PnX_SAxzY_eh6ACKsNHFvxEzQufTzJ0ZrIhLOPqbsK6kcSk5QKvronRTt_u5cgY0-9jr6pbKgCe7RBTZWY7FtO-oY27rAijucxiAmr36Jxe87mXnAQvianAi3irCcSbE5RGHX22Bn9BiUUkPal46u-KYm1QUq7ozVbw1qqSAfh4tVoIbhiCL1ppzdbZF-psR_2O7L9wpYC4h7odBFMJ_DxoUr-KDZafXR8O5pgC_0sWgOpxUQsIjhyQbIR6L-XSb5z-OrbL0w; path=/; SameSite=Lax
# (略)

レスポンスを受け取ったフロントエンド側は、Cookie内のJWTの有無で認証状態が確認できます。

認証付きAPI(ユーザー取得API)の作成

Cookieに保存されたJWTを利用して認証情報を取得する方法について紹介します。
今回は例としてユーザー情報を取得するAPIの実装をします。

routes.rb

resource :user, only: [:show]

users_controller.rb

class UsersController < ApplicationController
  def show
    # CookieからJWTを取得
    token = cookies[:token]

    # 秘密鍵の取得
    rsa_private = OpenSSL::PKey::RSA.new(File.read(Rails.root.join('auth/service.key')))

    # JWTのデコード。JWTからペイロードが取得できない場合は認証エラーにする
    begin
      decoded_token = JWT.decode(token, rsa_private, true, { algorithm: 'RS256' })
    rescue JWT::DecodeError, JWT::ExpiredSignature, JWT::VerificationError
      return render json: { message: 'unauthorized' }, status: :unauthorized
    end

    # subクレームからユーザーIDを取得
    user_id = decoded_token.first["sub"]

    # ユーザーを検索
    user = User.find(user_id)

    # userが取得できた場合はユーザー情報を返す、取得できない場合は認証エラー
    if user.nil?
      render json: { message: 'unauthorized' }, status: :unauthorized
    else
      render json: {
        user: {
          id: user.id,
          name: user.name,
          emai: user.email
        }
      }, status: :ok
    end
  end
end

先程作成したログインAPIのレスポンスで取得したJWTをCookieにセットしてcurlコマンドを実行します。
以下のようにユーザー情報が取得できればOKです。

$ curl -b 'token=eyJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJleGFtcGxlX2FwcCIsInN1YiI6MSwiZXhwIjoxNjQ3Mjk4MTc4fQ.O4AYzSrFf2CuD8DqmpCdEIQh8glfO3ngEIGaWfLTkp0sN-BnZ5Qit9CwPOaRwaglIIrCNsNt6PnX_SAxzY_eh6ACKsNHFvxEzQufTzJ0ZrIhLOPqbsK6kcSk5QKvronRTt_u5cgY0-9jr6pbKgCe7RBTZWY7FtO-oY27rAijucxiAmr36Jxe87mXnAQvianAi3irCcSbE5RGHX22Bn9BiUUkPal46u-KYm1QUq7ozVbw1qqSAfh4tVoIbhiCL1ppzdbZF-psR_2O7L9wpYC4h7odBFMJ_DxoUr-KDZafXR8O5pgC_0sWgOpxUQsIjhyQbIR6L-XSb5z-OrbL0w' http://localhost:3001/user

{"user":{"id":1,"name":"David","emai":"test@example.com"}}%

フロントエンド側は認証が完了するとCookieにJWTがセットされます。認証後はサーバへAPIリクエストをする際にCookieのJWTも合わせて送信することで認証の確認や認証情報を取得できます。

参考: 実装のリファクタリング

参考としてリファクタリングしたコードの紹介をします。

カスタム例外の追加

認証エラーのたびにrender json: { message: 'unauthorized' }, status: :unauthorizedを記述するのは冗長ですので、カスタム例外を利用して処理をまとめます。
カスタム例外の作成方法の詳細解説は【Ruby】カスタム例外の作成と利用に関する基礎知識で紹介しています。

まずは認証エラーを扱うカスタム例外の作成をします。

config/initializers/exceptions.rb

class AuthenticationError < StandardError; end

次に認証エラーが発生した際の処理をapplication_controllerに作成します。

app/controllers/application_controller.rb

class ApplicationController < ActionController::API
  include ActionController::Cookies
+ rescue_from AuthenticationError, with: :render_unauthorized_error

+ def render_unauthorized_error
+   render json: { message: 'unauthorized' }, status: :unauthorized
+ end
end

users_controllerの401エラーを返していた部分をカスタム例外に変更します。

users_controller.rb

- return render json: { message: 'unauthorized' }, status: :unauthorized
+ raise AuthenticationError

ユーザー認証に関するクラスの作成

『メールアドレスとパスワードによるユーザー取得』『JWTによるユーザー取得』と、ユーザー取得に関するロジックが散見されるので1つのクラスにまとめます。

app/services/authentication_service.rb

class AuthenticationService
  def self.authenticate_user_with_password!(email, password)
    user = User.find_by(email: email)&.authenticate(password)
    raise AuthenticationError if user.nil?

    user
  end

  def self.authenticate_user_with_token!(token)
    rsa_private = OpenSSL::PKey::RSA.new(File.read(Rails.root.join('auth/service.key')))
    begin
      decoded_token = JWT.decode(token, rsa_private, true, { algorithm: 'RS256' })
    rescue JWT::DecodeError, JWT::ExpiredSignature, JWT::VerificationError
      raise AuthenticationError
    end
    user_id = decoded_token.first["sub"]
    user = User.find(user_id)
    raise AuthenticationError if user.nil?

    user
  end
end

上記のクラスを利用するとsessions_controllerおよびusers_controllerは以下のようになります。

sessions_controller.rb

def create
  user = AuthenticationService.authenticate_user_with_password!(params[:email], params[:password])
  # (略)

users_controller.rb

class UsersController < ApplicationController
  def show
    user = AuthenticationService.authenticate_user_with_token!(cookies[:token])

    render json: {
      user: {
        id: user.id,
        name: user.name,
        emai: user.email
      }
    }, status: :ok
  end
end

トークンに関するクラスの作成

トークンの作成ロジックを管理するクラスを作成します。

app/services/token_service.rb

class TokenService
  class << self
    def issue_by_password!(email, password)
      user = AuthenticationService.authenticate_user_with_password!(email, password)
      issue_token(user.id)
    end

    private

    def issue_token(id)
      payload = {
        iss: "example_app",
        sub: id,
        exp: (DateTime.current + 14.days).to_i
      }
      rsa_private = OpenSSL::PKey::RSA.new(File.read(Rails.root.join('auth/service.key')))
      JWT.encode(payload, rsa_private, "RS256")
    end
  end
end

上記のクラスを利用するとsessions_controllerは以下のようになります。

sessions_controller.rb

class SessionsController < ApplicationController
  def create
    token = TokenService.issue_by_password!(params[:email], params[:password])
    cookies[:token] = token
    render status: :created
  end
end

認証用モジュールの作成

認証が必要なエンドポイントで行う認証ロジックをモジュールとして切り出します。
『認証エラーであればAuthenticationErrorが発生し、認証に成功すればcurrent_userにログイン済みユーザー(ペイロードのユーザーIDに一致するユーザー)がセットされる』というモジュールの実装は以下の通りです。

app/controllers/concerns/authenticatable.rb

module Authenticatable
  def authenticate_with_token!
    raise AuthenticationError if unauthorized?
  end

  def current_user
    AuthenticationService.authenticate_user_with_token!(cookies[:token])
  rescue AuthenticationError
    nil
  end

  def unauthorized?
    current_user.nil?
  end
end

上記のモジュールを利用するとusers_controllerは以下のようになります。

users_controller.rb

class UsersController < ApplicationController
  include Authenticatable
  before_action :authenticate_with_token!

  def show
    render json: {
      user: {
        id: current_user.id,
        name: current_user.name,
        emai: current_user.email
      }
    }, status: :ok
  end
end

さいごに

Twitter(@nishina555)やってます。フォローしてもらえるとうれしいです!