見出し画像

【React】+【RailsAPI】 アプリ [4] ユーザー認証(devise/devise_token_auth/redux-token-auth)_後編

前回 に続き、フロントに React、バックエンドに Rails(APIモード)を使うアプリの、ユーザー認証周りについて書きたいと思います。

ユーザー管理や認証に devise、APIを利用するためのトークン認証に devise_token_auth、またそれを Redux で扱うための redux-token-auth を組み込みます。今回は redux-token-auth を用いてトークン認証を行います。

※自前で置き換えての実装や、本家とは別リポジトリを参照など行ったため安定しない環境です。

redux_token_auth とは

React と Redux を使用し、 Devise Token Auth を使用する Rails バックエンドを使用したユーザー認証のための npmモジュールである。

※利用にあたっては、React Router v4.0.0以上、また Redux-thunk が必要になります。

こちらのモジュールを利用することで、ログイン時(signInUser())にサーバー側(devise_token_auth)で生成された認証のためのアクセストークンをクライアント側(redux-token-auth)で受け取り、ローカルストレージへの保存やログイン状態などを管理している State が更新されます。
State であったり、ルーティングでコンポーネントをラップするメソッド(generateRequireSignInWrapper())を利用することで、コンポーネントを利用する際のログイン確認が行えるようになります。

# App.js
const requireSignIn = generateRequireSignInWrapper({
 redirectPathIfNotSignedIn: "/signin",
})

const App = () => {
 return (
   <Router>
     <div className="App">
       <Switch>
         <Route exact path="/" component={MonsterList} />
         <Route exact path="/signIn" component={SignInUser} />
         <Route exact path="/mypage" component={requireSignIn(Mypage)} />
       </Switch>
     </div>
   </Router>
 )
}

基本的には Router(ルーティング)の部分だけ、認証が必要かを意識すれば良いのかと思います。

インストール

下記redux-token-auth の参照リポジトリを変更の通り、本家からフォークされたリポジトリを取得します。

# package.json
"redux-token-auth": "zopelee/redux-token-auth"

redux-token-authの組み込み

GitHub のドキュメント を参考に実装していきます。

redux-token-auth の reducer を root に統合
アプリで管理している Reducer に redux-token-auth のものを import します。

# src/reducers/index.js
import { combineReducers } from "redux"
import { reduxTokenAuthReducer as reduxTokenAuth } from "redux-token-auth"
import hogeReducer from "./hoge"

export default combineReducers({
 reduxTokenAuth,
 hogeReducer,
})

設定ファイル redux-token-auth-config の実装
authUrl は http://localhost:3001/auth、userAttributes はユーザーモデルのプロパティ、userRegistrationAttributes は登録時に設定するプロパティを定義します。 

# config/redux-token-auth-config.js
import { generateAuthActions } from "redux-token-auth"
import Settings from "./config/application"

const config = {
 authUrl: Settings.AUTH_URL,
 userAttributes: {
   name: "name",
   image: "image",
 },
 userRegistrationAttributes: {
   name: "name",
 },
}
const {
 registerUser,
 signInUser,
 signOutUser,
 verifyCredentials,
} = generateAuthActions(config)
export { registerUser, signInUser, signOutUser, verifyCredentials }

起動時にアクセストークン認証を行い、ログイン状態をバックエンドとストアで同期させるための処理を追加
起動時に認証が行われ、ログイン状態として State を更新します。具体的にはローカルストレージに保存されたトークンを取得し、axios のヘッダーに追加して /validate_token へリクエストしトークンの検証を行います。

# index.js
import { verifyCredentials } from './redux-token-auth-config' // <-- note this is YOUR file, not the redux-token-auth NPM module
const store = configureStore()
verifyCredentials(store)

ちなみに本家Gemから取得した状態だと、認証の verifyToken は行われますが、validate_token が完了する前に generateRequireSignInWrapper 内部の componentWillMount が実行されて、signedIn が false と判定されてしまい、正常にコンポーネントのアクセス制限が行われませんでした。

CORSを有効にするため、トークン認証用のヘッダーを追加
expose に access-token などを指定して追加します。

# config/initializers/cors.rb
Rails.application.config.middleware.insert_before 0, Rack::Cors do
 allow do
   origins Rails.application.config.x.cors_allowed_origins
   resource '*',
     headers: :any,
     expose: ['access-token', 'expiry', 'token-type', 'uid', 'client'],
     methods: [:get, :post, :put, :patch, :delete, :options, :head]
 end
end

ユーザー登録、ログインを実装する

Usage Examples を参考に、registerUser、signInUser、signOutUser を実装します。ここまでがユーザー登録、ログインの繋ぎこみとなります。

generateRequireSignInWrapper のエラー
ルーティングでログイン制限したいコンポーネントに generateRequireSignInWrapper で作成したメソッドを渡しますが、エラーになります。

browser.js:34 Uncaught Invariant Violation: Could not find "store" in either the context or props of "Connect(GatedPage)". Either wrap the root component in a <Provider>, or explicitly pass "store" as a prop to "Connect(GatedPage)".
"Connect(GatedPage)"のコンテキストまたは小道具のどちらにも "store"が見つかりませんでした。

issue でも報告されていて解決されていないようです。
node_modules 下のコンポーネント(src/generate-require-signin-wrapper.tsx)を javascript で書き直したところ通るようになったため、ひとまずこれで動かしてみることにしました。

RailsAPI側に認証コールバックを追加

class HogesController < ApplicationController
 before_action :authenticate_user!, except: %i[index]
 def index
   render json: Hoge.all
 end
 def create
   @hoge = Hoge.new(hoges_params)
   if @hoge.save
     render status: 200, json: @hoge
   else
     render status: 500, json: { status: 500,
                                 message: 'Internal Server Error' }
   end
 end
 private
 def hoges_params
   params.permit(:name)
 end
end

トークン認証周りの実装

ユーザー登録、ログインなどの通信以外でトークンを付加してリクエストする機構が無いため実装します。
具体的には axios のリクエスト時にヘッダーを付加し、レスポンス時にヘッダーのトークンを保存する処理を実装します。そしてアプリ側で axios を使っていた箇所は、実装を施したものをインポートして使うよう修正します。
それぞれについての詳細と、実装コードは下記の通りです。

リクエスト時、ヘッダーにトークンを付加する設定を追加
前回のトークン認証のテスト でトークンをヘッダーにセットしてリクエストしていたのと同様、axios にセットします。interceptors.request.use() で、リクエスト実行前にヘッダーに追加します。

ローカルストレージに保存
前回 devise_token_auth.rb の設定で change_headers_on_each_request をコメントアウト(true)にしていたため、リクエスト毎にレスポンスのヘッダーで渡されるトークンが更新されます。クライアント側はこの更新されたトークンを次回のリクエストで使う必要があるため、interceptors.response.use() でレスポンス受信時にローカルストレージに保存しておきます。

redux-token-auth の参照リポジトリを変更
signOutUser の際、redux-token-auth で保持している axios が使われますが、signInUser でレスポンスで取得された古いトークンがセットされたままのため認証エラーを引き起こします。
また空のアクセストークンが返ってきた際にヘッダーなどをそのまま上書きしてしまい認証エラーとなる別の問題もあります。
これらに対応した PR や、PR作成者のリポジトリ(本家からフォークされたもの)があり、4月現在、本家の更新が停止しているためそちらのリポジトリを参照するよう変更しました。
古いトークン問題の対応は、redux-token-auth で公開されている axios をインポートしてアプリ側で使い、レスポンスで渡されたトークンで更新することで対応しました(空の値検知も対応)。

# src/helper/axios_helper.js
// import axios from "axios"
// Use the same axios instance as redux-token-auth
import { axios } from "redux-token-auth"

const getStorage = () => {
 return window.localStorage
}

const authHeaderKeys = ["access-token", "token-type", "client", "expiry", "uid"]

// see: https://github.com/kylecorbelli/redux-token-auth/blob/master/src/actions.ts#L239-L243
const getVerificationParams = () => {
 const result = {}
 const storage = getStorage()
 authHeaderKeys.forEach(key => {
   if (storage.getItem(key)) result[key] = storage.getItem(key)
 })
 return result
}

const saveHeaders = response => {
 if (response && response.headers) {
   const storage = getStorage()
   authHeaderKeys.forEach((key: string) => {
     // not to overwrite with empty parameters
     if (response.headers[key]) {
       storage.setItem(key, response.headers[key])
       // for redux-token-auth
       axios.defaults.headers.common[key] = response.headers[key]
     }
   })
 }
}

export default (() => {
 const instance = axios

 instance.interceptors.request.use(
   request => {
     const verificationParams = getVerificationParams()
     // request.headers.Authorization = `Bearer ${token}`
     request.headers = { ...verificationParams }
     console.log("Starting Request: ", request)
     return request
   },
   function(error) {
     console.log("Request Error: ", error.response)
     return Promise.reject(error)
   }
 )

 instance.interceptors.response.use(
   response => {
     console.log("Response: ", response)
     saveHeaders(response)
     return response
   },
   function(error) {
     console.log("Response Error: ", error.response)
     // for changed access-token
     saveHeaders(error.response)
     return Promise.reject(error)
   }
 )
 return instance
})()

あとがき

まずは公開されているモジュールを利用して安全に組んで実装を体系的にイメージ出来るようにと思ったのですが、枯れていないモジュールだとやはり不具合があったり更新が止まっていたりして、いかにコントリビュータの方に支えられている世界か痛感しました。ただそのおかげもあって、内部の実装や issue、PR を読んだり、挙動をつぶさに観察するなど非常に勉強になるところが多かったです。

いいなと思ったら応援しよう!