はじめに
みなさん、こんにちは torihaziです
今日はdevise-jwtを使用してログイン機能を実装していたところ
予想と異なる挙動を示したのでその調査をしていきたいと思います
ではltg
※ 結論、原因は究明できていません。
結局frontendにおいて応急処置のような処理を追記し、実現しました。
原因はbackendではなくfrontendでした。
バージョン
Rails 7.0.6
Ruby 3.2.1
devise
devise-jwt
どういう挙動なのか
例えば userAとしてフロントエンドからログインをしたあと、
別タブから開いたログインページにおいてuserBとしてログインを行ったら
userAとしてログインされてしまっている状況です。
ログインに成功したらそのレスポンスヘッダのAuthorizationの値をcookieに保存し、
以降の認証が必要な処理はリクエスト時に適宜保存したtokenを添えて送る感じです。
原因究明
という事なので1つずつやっていこうと思います。
利用しているRailsアプリケーションでセッションストレージが有効になっており、デフォルトのDeviseセットアップが設定済みの場合は、ヘッダーにトークンが存在するかどうかにかかわらず、同一オリジンのリクエストがセッションで認証される場合があります。
ふむふむ、なるほど。
確かにDeviseのセットアップは多分デフォルトで、ヘッダーにトークンは存在してますね。
-
:database_authenticatable戦略に基づいてユーザーがサインインすると、以下の条件のいずれかが満たされない場合はユーザーをセッションに保存します
- セッションが無効になっている場合
- Deviseのconfig.skip_session_storageコンフィグに:params_authが含まれている場合
- 未検証のリクエストがRailsのRequest forgery protectionで処理される場合(ただし通常はAPIリクエストで無効になっています)
-
Warden(Devise内部のエンジン)は、ユーザーがセッション内で持っているリクエストを、戦略(ここでは:jwt_authenticatable)がなくても認証します。
でこれらを回避するためにいくつかできることがあるらしいので見ていきましょう。
セッションを無効にする
apiフラグを立てて、rails newをしたのでこれは平気。
2、3、4も特に該当しなさそう。
DeviseにあるデフォルトのSessionsControllerを独自のコントローラでオーバーライドしている場合
あーしてますね。これかな。
今自分のコードがこんな感じなので
def create
self.resource = warden.authenticate!(auth_options)
sign_in(resource_name, resource)
render json: { message: 'Login successful', data: resource }
end
sign_in(resource_name, resource, store:false)
こんな感じでどうでしょう笑
ダメそうですね。
変わらず前ユーザのログインとなってしまっています。
1番をやってみます。
Rails.application.config.session_store :disabled
一度dockerを落として上げ直し、再度トライ。
結果変わらず。まぁでしょうね。
次は2番。
config.skip_session_storage = [:http_auth, :params_auth]
これもダメ。
次3番。
self.skip_session_storage = [:http_auth, :params_auth]
ダメ。次。
次もダメ。
え、なんだ。
そういえばこれしてないな。
class User < ApplicationRecord
include Devise::JWT::RevocationStrategies::JTIMatcher
devise :database_authenticatable,
:jwt_authenticatable, jwt_revocation_strategy: self
end
この戦略は、ユーザーモデルでjwt_payloadメソッドを利用する点にご注意ください。したがって、jwt_payloadメソッドを利用する必要がある場合は、以下のようにsuperを呼び出すことを忘れてはいけません。
def jwt_payload
super.merge('foo' => 'bar')
end
これやってみてリトライ。
ダメだ。
ギブ。
日が変わりまして、結局相談をした結果、
devise側のバグでは?
という結論に落ち着きました。
原因(追記)
私が実装しているfrontend側のコードに問題があったようです。
私はログインの際にaxios、axiosのinterceptorを利用しているのですが、
// リクエストを送る前の処理
api.interceptors.request.use((config: InternalAxiosRequestConfig) => {
// サーバ側にブラウザが受け取れるデータ形式を設定
config.headers.set("Accept", "application/json");
↓これ
// Authorizationの項目に値が入っていなかった場合、設定
if (!config.headers.has("Authorization")) {
config.headers.set("Authorization", Cookies.get("token"));
}
return config;
});
このコードがダメだということでした。
このコードだと以下のような場合の時に今回のタイトルの事象が発生します。
=> cookieにtokenがない状態
=> userAとしてログイン(axios使用)
=> postする前にinterceptorの上の処理が働く
=> しかしtokenにはまだないのでundefinedのまま送信される
=> ログイン成功と同時にレスポンスヘッダのAuthorizationをcookieにtokenとして保存する
=> 以降のaxiosはこのtokenをリクエストヘッダに添えて動く
=> 別タブでログインページを開く
=> userBとしてログインする(axios使用)
=> postする前にinterceptorの上の処理が働く
=> 今回はtokenにあるのでここでリクエストヘッダにuserAのAuthorizationが添えられてしまう
=> 結果userAとしてログインされてしまう
この結果からわかるようにログインページからemailとpasswordを添えて認証情報をpostしましたがdevise-jwtのtokenが優先されるようですね。
はい。そしてこの問題を防ぐために教えていただいたコードが下記です。
if (
!config.headers.has("Authorization") &&
config.url !== "/users/sign_in"
) {
config.headers.set("Authorization", Cookies.get("token"));
}
こうすることで回避できます。
終わりに
devise-token-authを使用すれば、本タイトルは実現できるそうです。
devise-jwtだとできないっぽいようです。
我こそはという方、いましたらぜひ私までお願いします笑
ちなみに冒頭で申し上げた暫定処置というのは
ログインページに飛ぶごとにuseEffectで cookieのtokenを削除するような感じです。
でログインしたらまたtokenに保存して、以降の処理はそのtokenを添えて使うみたいな。
そうすればいけました。
CTO様様です。