9/14-[翻訳]Rails API + JWT auth + VueJS SPA

■翻訳記事
当記事は、下記記事の翻訳となります。
Rails API + JWT auth + VueJS SPA Part1

これは、初級の開発者が認証API呼び出しでVueJS SPAを構築するよう設計されたガイドです。

APIファーストのRESTフルRailsバックエンドの構築から始めます。
APIファーストとは、同じAPIエンドポイントを異なる

  • Web / JSクライアント
  • モバイルアプリケーション
  • サードパーティのAPI
などのすべてのAPIで統合認証フローを使用する必要があり、JWTはこの目標に適しています。この記事では、単一VueJSクライアントの作成を検討しますが、API自体は他のAPI利用者が再利用するよう簡単に調整できます。

バックエンドのツール:
  1. Rails 5.2.0
  2. Ruby 2.4.4
  3. gem bcrypt 1.3.12
  4. gem jwt_sessions 2.1.0
  5. gem redis 4.0.1
  6. gem rack-cors 1.0.2
この正確なバージョンに縛られているわけではありません。単に、ローカル環境にインストールしたバージョンをリストしています。それでは、ToDoアプリを作成しましょう。

バックエンド

$rails app rails new silver-octo-invention --api -T
を作成します。派手なプロジェクト名はGitHubによって自動生成されます。
-Tオプションは、デフォルトのテストフレームワークであるMinitestを除外します。 RSpecを使用します。

このようにGemfileを調整します
source 'https://rubygems.org'
git_source(:github) { |repo| "https://github.com/#{repo}.git" }

ruby '2.4.4'

gem 'rails', '~> 5.2.0'
gem 'sqlite3'
gem 'puma', '~> 3.11'
gem 'redis', '~> 4.0'
gem 'bcrypt', '~> 3.1.7'
gem 'jwt_sessions', '~> 2'
gem 'bootsnap', '>= 1.1.0', require: false
gem 'rack-cors'

group :development, :test do
  gem 'pry-byebug', '~> 3.4'
  gem 'pry-rails', '~> 0.3.4'
  gem 'rspec-rails', '~> 3.7'
  gem 'factory_bot_rails', '~> 4.8'
end

group :development do
  gem 'listen', '>= 3.0.5', '< 3.2'
  gem 'spring'
  gem 'spring-watcher-listen', '~> 2.0.0'
end

bundle installを実行する

rails g rspec:installを実行する
下記が生成される
.rspec
spec/spec_helper.rb
spec/rails_helper.rb

Userモデルを作成しましょう。
まず、必要最小限のモデルフィールドから始めます。
rails g model user email:string password_digest:string

マイグレーションファイルにnull:false設定を追加します。
class CreateUsers < ActiveRecord::Migration[5.1]
  def change
    create_table :users do |t|
      t.string :email, null: false
      t.string :password_digest, null: false
      t.timestamps
    end
  end
end


rails db:createを実行する
rails db:migrateを実行する


has_secure_passwordメソッドをUserモデルに追加します。

class User < ApplicationRecord
  has_secure_password
end

ToDoを作成しましょう。下記コマンドを実行します。
rails g model todo title:string user:references && rails db:migrate

コントローラーを作成しましょう。
最初に、JWTSessions :: RailsAuthorizationをApplicationControllerに含める必要があります。
モジュールは、安全なエンドポイントを保護する承認アクションを提供します。
# app/controllers/application_controller.rb

class ApplicationController < ActionController::API
  include JWTSessions::RailsAuthorization
end

不正なリクエストには例外処理が必要なので、すぐに追加しましょう。
# app/controllers/application_controller.rb

class ApplicationController < ActionController::API
  include JWTSessions::RailsAuthorization
  rescue_from JWTSessions::Errors::Unauthorized, with: :not_authorized
  
  private
  
  def not_authorized
    render json: { error: 'Not authorized' }, status: :unauthorized
  end
end

ちなみに、JWTを使用するには、初期構成を指定する必要があります。
 JWTSessions gemはデフォルトでHS256アルゴリズムを使用し、提供される暗号化キーが必要です。
また、デフォルトでは、gemはトークンストアとしてredisを使用するため、機能するredis-serverインスタンスが必要になります。ただし、メモリをトークンストアとして選択する可能性があります(テスト環境で役立つ場合があります)。特定の設定の詳細については、READMEを参照してください。
# config/initializers/jwt_sessions.rb
JWTSessions.encryption_key = 'secret'

次に、サインアップエンドポイントを作成します。
トークンベースのセッションとSPAでは、クライアントのトークンを保存する場所として、CookieとlocalStorageの2つの最も一般的なオプションがあります。
トークンを格納する場所を決定するのは開発者の責任です。決定を下す際は注意してください。CookieはCSRFに対して脆弱であり、localStorageはXSS攻撃に対して脆弱です。
CSRFの脆弱性は解決可能です。私は通常、最も安全なトークンストアとしてhttpのみのcookieを好みます。

jwt_sessions gem自体は、Cookieがトークンストアとして選択された場合のトークンのセット(アクセス、更新、CSRF)を提供します。
これを踏まえて、gemによって提供されるCSRFトークンと一緒にcookieを使用してみましょう(gemは、JWTが要求cookieによって渡されるときに、CSRF検証を自動的に管理します)。

Gem内のセッションは、トークンのペア(アクセスと更新)として表されます。アクセストークンの有効期間は短く(デフォルトは1時間)、更新の有効期間は比較的長い(2週間)。有効期限は設定可能です。更新トークンは、有効期限が切れたアクセスを更新するために使用されます。

リフレッシュトークンを外部APIサービスまたはモバイルアプリケーションに渡すことは理にかなっていますが、JSクライアントは通常、貴重なリフレッシュトークンを格納するのに十分に安全ではありません。JSに渡す情報と渡さない情報を決定するのは開発者の責任です。 jwt_sessions gemは、期限切れの古いトークンを渡すことで新しいアクセストークンを発行する可能性を提供するため、更新トークンをJSクライアントに渡さないようにすることができます。更新トークンとアクセストークンの両方が相互にリンクされているため、JSクライアントからアクセスが盗まれたかどうかを簡単に検出し、リークされたセッションをフラッシュできます。(2人のユーザー—元のユーザーと攻撃者は、最終的に同じ更新トークンを指す2つの異なるアクセストークンを持ちます)。

それでは、実際に、サインアップエンドポイントを作成しましょう。エンドポイントは、ユーザーを作成し、JWTペイロードをアセンブルし、Cookieを介して応答とともに渡すとともに、応答本文を介してCSRFトークンを渡す必要があります。

class SignupController < ApplicationController
  def create
    user = User.new(user_params)
    if user.save
      payload  = { user_id: user.id }
      session = JWTSessions::Session.new(payload: payload, refresh_by_access_allowed: true)
      tokens = session.login

      response.set_cookie(JWTSessions.access_cookie,
                          value: tokens[:access],
                          httponly: true,
                          secure: Rails.env.production?)
      render json: { csrf: tokens[:csrf] }
    else
      render json: { error: user.errors.full_messages.join(' ') }, status: :unprocessable_entity
    end
  end

  private

  def user_params
    params.permit(:email, :password, :password_confirmation)
  end
end

サインアップを確実に機能させるためのRspecファイル。
# spec/support/response_helper.rb

module ResponseHelper
  def response_json
    JSON.parse(response.body) rescue {}
  end
end

# spec/spec_helper.rb

require_relative 'support/response_helper'
RSpec.configure do |config|
  # ...
  config.include ResponseHelper
  # ...
end

# spec/controllers/signup_controller_spec.rb

require 'rails_helper'

RSpec.describe SignupController, type: :controller do

  describe 'POST #create' do
    let(:user_params) { { email: 'test@email.com', password: 'password', password_confirmation: 'password' } }

    it 'returns http success' do
      post :create, params: user_params
      expect(response).to be_successful
      expect(response_json.keys).to eq ['csrf']
      expect(response.cookies[JWTSessions.access_cookie]).to be_present
    end

    it 'creates a new user' do
      expect do
        post :create, params: user_params
      end.to change(User, :count).by(1)
    end
  end
end

■14
これで、サインインコントローラーを構築できます。
# app/controllers/signin_controller.rb

class SigninController < ApplicationController

  def create
    user = User.find_by!(email: params[:email])
    if user.authenticate(params[:password])
      payload  = { user_id: user.id }
      session = JWTSessions::Session.new(payload: payload, refresh_by_access_allowed: true)
      tokens = session.login
      response.set_cookie(JWTSessions.access_cookie,
                          value: tokens[:access],
                          httponly: true,
                          secure: Rails.env.production?)

      render json: { csrf: tokens[:csrf] }
    else
      not_authorized
    end
  end
end
サインインのRSpec
# spec/controllers/signin_controller_spec.rb

RSpec.describe SigninController, type: :controller do

  describe 'POST #create' do
    let(:password) { 'password' }
    let(:user) { create(:user) }
    let(:user_params) { { email: user.email, password: password } }

    it 'returns http success' do
      post :create, params: user_params
      expect(response).to be_successful
      expect(response_json.keys).to eq ['csrf']
      expect(response.cookies[JWTSessions.access_cookie]).to be_present
    end

    it 'returns unauthorized for invalid params' do
      post :create, params: { email: user.email, password: 'incorrect' }
      expect(response).to have_http_status(401)
    end
  end
end


ここでは、更新のエンドポイントを示します。 Webクライアントのエンドポイントを構築しているときに、古いアクセス権を持つ新しいアクセス権を更新します。後で、別のエンドポイントのセットを作成して、リフレッシュトークンを介して動作する他のAPIコンシューマー(モバイルなど)で使用できますが、この場合、危険を冒してリフレッシュトークンを外の世界に広く見せることはしません。
期限切れのアクセストークンのみが更新に使用されることを想定しているため、refresh_by_access_payloadメソッド内で、ブロックがunauth例外で渡されます。
必要に応じて、ブロック内でサポートチームに通知したり、セッションをフラッシュしたり、ブロックをスキップしてこの種のアクティビティを無視したりできます。JWTライブラリは有効期限クレームを自動的にチェックし、期限切れのアクセストークンの例外を回避するために、claimless_payloadメソッドを使用します。
# app/controllers/refresh_controller.rb

class RefreshController < ApplicationController
  before_action :authorize_refresh_by_access_request!

  def create
    session = JWTSessions::Session.new(payload: claimless_payload, refresh_by_access_allowed: true)
    tokens = session.refresh_by_access_payload do
      raise JWTSessions::Errors::Unauthorized, 'Malicious activity detected'
    end
    response.set_cookie(JWTSessions.access_cookie,
                        value: tokens[:access],
                        httponly: true,
                        secure: Rails.env.production?)

    render json: { csrf: tokens[:csrf] }
  end
end

Specファイルは、
# spec/controllers/refresh_controller_spec.rb

RSpec.describe RefreshController, type: :controller do
  let(:access_cookie) { @tokens[:access] }
  let(:csrf_token) { @tokens[:csrf] }

  describe "POST #create" do
    let(:user) { create(:user) }

    context 'success' do
      before do
        # set expiration time to 0 to create an already expired access token
        JWTSessions.access_exp_time = 0
        payload = { user_id: user.id }
        session = JWTSessions::Session.new(payload: payload, refresh_by_access_allowed: true)
        @tokens = session.login
        JWTSessions.access_exp_time = 3600
      end

      it do
        request.cookies[JWTSessions.access_cookie] = access_cookie
        request.headers[JWTSessions.csrf_header] = csrf_token
        post :create
        expect(response).to be_successful
        expect(response_json.keys.sort).to eq ['csrf']
        expect(response.cookies[JWTSessions.access_cookie]).to be_present
      end
    end

    context 'failure' do
      before do
        payload = { user_id: user.id }
        session = JWTSessions::Session.new(payload: payload, refresh_by_access_allowed: true)
        @tokens = session.login
      end

      it do
        request.cookies[JWTSessions.access_cookie] = access_cookie
        request.headers[JWTSessions.csrf_header] = csrf_token
        post :create
        expect(response).to have_http_status(401)
      end
    end
  end
end


もう少しです。これが、todosコントローラを構築する時です。
# app/controllers/todos_controller.rb

class TodosController < ApplicationController
  before_action :authorize_access_request!
  before_action :set_todo, only: [:show, :update, :destroy]

  # GET /todos
  def index
    @todos = current_user.todos

    render json: @todos
  end

  # GET /todos/1
  def show
    render json: @todo
  end

  # POST /todos
  def create
    @todo = current_user.todos.build(todo_params)

    if @todo.save
      render json: @todo, status: :created, location: @todo
    else
      render json: @todo.errors, status: :unprocessable_entity
    end
  end

  # PATCH/PUT /todos/1
  def update
    if @todo.update(todo_params)
      render json: @todo
    else
      render json: @todo.errors, status: :unprocessable_entity
    end
  end

  # DELETE /todos/1
  def destroy
    @todo.destroy
  end

  private

  def set_todo
    @todo = current_user.todos.find(params[:id])
  end

  def todo_params
    params.require(:todo).permit(:title)
  end
end
これを機能させるにはcurrent_userも必要なので、追加しましょう。 トークンが承認されたら、ペイロードを調べて、格納することに決めたものを何でもフェッチできます。今回の場合はuser_idです。
# app/controllers/application_controller.rb

class ApplicationController < ActionController::API
  include JWTSessions::RailsAuthorization
  rescue_from JWTSessions::Errors::Unauthorized, with: :not_authorized

  private

  def current_user
    @current_user ||= User.find(payload['user_id'])
  end

  def not_authorized
    render json: { error: 'Not authorized' }, status: :unauthorized
  end
end

todoのSpecです
# specs/controller/todos_controller_spec.rb

RSpec.describe TodosController, type: :controller do
  let(:user) { create(:user) }

  let(:valid_attributes) {
    { title: 'new title' }
  }

  let(:invalid_attributes) {
    { title: nil }
  }

  before do
    payload = { user_id: user.id }
    session = JWTSessions::Session.new(payload: payload)
    @tokens = session.login
  end

  describe 'GET #index' do
    let!(:todo) { create(:todo, user: user) }

    it 'returns a success response' do
      request.cookies[JWTSessions.access_cookie] = @tokens[:access]
      get :index
      expect(response).to be_successful
      expect(response_json.size).to eq 1
      expect(response_json.first['id']).to eq todo.id
    end

    # usually there's no need to test this kind of stuff
    # within the resources endpoints
    # the quick spec is here only for the presentation purposes
    it 'unauth without cookie' do
      get :index
      expect(response).to have_http_status(401)
    end
  end

  describe 'GET #show' do
    let!(:todo) { create(:todo, user: user) }

    it 'returns a success response' do
      request.cookies[JWTSessions.access_cookie] = @tokens[:access]
      get :show, params: { id: todo.id }
      expect(response).to be_successful
    end
  end

  describe 'POST #create' do

    context 'with valid params' do
      it 'creates a new Todo' do
        request.cookies[JWTSessions.access_cookie] = @tokens[:access]
        request.headers[JWTSessions.csrf_header] = @tokens[:csrf]
        expect {
          post :create, params: { todo: valid_attributes }
        }.to change(Todo, :count).by(1)
      end

      it 'renders a JSON response with the new todo' do
        request.cookies[JWTSessions.access_cookie] = @tokens[:access]
        request.headers[JWTSessions.csrf_header] = @tokens[:csrf]
        post :create, params: { todo: valid_attributes }
        expect(response).to have_http_status(:created)
        expect(response.content_type).to eq('application/json')
        expect(response.location).to eq(todo_url(Todo.last))
      end

      it 'unauth without CSRF' do
        request.cookies[JWTSessions.access_cookie] = @tokens[:access]
        post :create, params: { todo: valid_attributes }
        expect(response).to have_http_status(401)
      end
    end

    context 'with invalid params' do
      it 'renders a JSON response with errors for the new todo' do
        request.cookies[JWTSessions.access_cookie] = @tokens[:access]
        request.headers[JWTSessions.csrf_header] = @tokens[:csrf]
        post :create, params: { todo: invalid_attributes }
        expect(response).to have_http_status(:unprocessable_entity)
        expect(response.content_type).to eq('application/json')
      end
    end
  end

  describe 'PUT #update' do
    let!(:todo) { create(:todo, user: user) }

    context 'with valid params' do
      let(:new_attributes) {
        { title: 'Super secret title' }
      }

      it 'updates the requested todo' do
        request.cookies[JWTSessions.access_cookie] = @tokens[:access]
        request.headers[JWTSessions.csrf_header] = @tokens[:csrf]
        put :update, params: { id: todo.id, todo: new_attributes }
        todo.reload
        expect(todo.title).to eq new_attributes[:title]
      end

      it 'renders a JSON response with the todo' do
        request.cookies[JWTSessions.access_cookie] = @tokens[:access]
        request.headers[JWTSessions.csrf_header] = @tokens[:csrf]
        put :update, params: { id: todo.to_param, todo: valid_attributes }
        expect(response).to have_http_status(:ok)
        expect(response.content_type).to eq('application/json')
      end
    end

    context 'with invalid params' do
      it 'renders a JSON response with errors for the todo' do
        request.cookies[JWTSessions.access_cookie] = @tokens[:access]
        request.headers[JWTSessions.csrf_header] = @tokens[:csrf]
        put :update, params: { id: todo.to_param, todo: invalid_attributes }
        expect(response).to have_http_status(:unprocessable_entity)
        expect(response.content_type).to eq('application/json')
      end
    end
  end

  describe 'DELETE #destroy' do
    let!(:todo) { create(:todo, user: user) }

    it 'destroys the requested todo' do
      request.cookies[JWTSessions.access_cookie] = @tokens[:access]
      request.headers[JWTSessions.csrf_header] = @tokens[:csrf]
      expect {
        delete :destroy, params: { id: todo.id }
      }.to change(Todo, :count).by(-1)
    end
  end
end

Next Post Previous Post
No Comment
Add Comment
comment url