目次
今回のゴール
- 認証情報を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を作成します。
なお、以下のコードで登場するペイロードのクレーム(iss
、sub
、exp
)の詳細解説は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)やってます。フォローしてもらえるとうれしいです!