SPAのTwitterログイン認証(OAuth)
SPA(RailsAPI + Vue.js)でのTwiiterログインを実装したので、自分の実装方法をまとめておきます。
概要図(ステップ)
実装の概要
まずはじめに、 実装を完走した感想ですがSPAで外部のAPIを利用したログインは基本的に辛みが深いように感じました。
自分の場合は、クライアント側(Vue.js) → バックエンド側(RailsAPI) → 外部API(TwitterAPI) というリクエストの遷移を基本にして実装したわけですが、「バックエンド → 外部API」の結果手に入れた認証情報をクライアント側に返す処理をどのように行うかというところで参考になるサイトがとても少なかったことでなかなか苦戦を強いられたからです。
調べたところクッキーを利用する以外にズバッと解決できそうなものを見つけられなかったので、今回はクッキーを使っています。
つまり、「バックエンド側(RailsAPI) → 外部API(TwitterAPI)」のやり取りの結果の一部をクッキーでクライアント側に返し、クライアント側でそれを受け取ったあと、そのクッキー情報に基づいてクライアントが再びRailsAPIを叩き、完全なログインを実現するという流れです。
ベストプラクティスかどうかはわかりませんが、XSS対策への対応は出来ています。
ログイン認証の大本は、以下の記事通りに作成しています。
ステップ1
(簡略化してポイントだけ書いてます。)
VueからRailsAPIを叩き、ステップ5を行うためのコールバックURLを貰う。
signinWithTwitter () { this.$http.secured.get('/authenticate') .then(response => location.href = response.data.oauth_url ) .catch(error => this.signinFailed(error))
location.href = response.data.oauth_urlで、認可のためのコールバックURLを貰って、Twitterの承認画面に切り替えるという挙動になります。
ステップ2〜5
コールバックURLを返すための処理です。
また、クライアントサイドに「リクエストトークン」をクッキーで返しています。(アクセストークンの取得に必要)
def authenticate consumer ||= OAuth::Consumer.new( ENV['TWITTER_CONSUMER_KEY'], ENV['TWITTER_CONSUMER_KEY_SECRET'], { :site => "https://api.twitter.com" } ) request_token = consumer.get_request_token( oauth_callback: ENV['TWITTER_CALLBACK_URL'] ) response.set_cookie("request_token", value: request_token.token, httponly: true, secure: Rails.env.production?) response.set_cookie("request_token_secret", value: request_token.secret, httponly: true, secure: Rails.env.production?) rtn = {} rtn["status"] = 'return_callback_url' rtn["oauth_url"] = request_token.authorize_url render json: rtn end
ステップ6〜7
認証に成功した場合、アクセストークンをクッキーで返答します。
def twitter request_token = OAuth::RequestToken.new( consumer, request.cookies['request_token'], request.cookies['request_token_secret'] ) access_token = request_token.get_access_token( {}, :oauth_token => params[:oauth_token], :oauth_verifier => params[:oauth_verifier] ) twitter_response = consumer.request( :get, '/1.1/account/verify_credentials.json', access_token, { :scheme => :query_string } ) case twitter_response when Net::HTTPSuccess user_info = JSON.parse(twitter_response.body) if user_info["screen_name"] user = User.find_or_create(user_info, 'twitter') if user.persisted? payload = { user_id: user.id } session = JWTSessions::Session.new(payload: payload, refresh_by_access_allowed: true) tokens = session.login cookie_key_value_pairs = { 'oauth_token2' => access_token.params["oauth_token"], 'oauth_token_secret' => access_token.params["oauth_token_secret"], 'csrf' => tokens[:csrf], 'ac_token' => tokens[:access] } set_cookies_at_once(response, cookie_key_value_pairs) response.set_cookie('signedIn', value: true, domain: ENV["BASE_DOMAIN"], path: "/", secure: Rails.env.production?) set_cookie_at_token_validness(true) redirect_to ENV['CLIANT_SIDE_TOP_PAGE'] else redirect_to ENV['CLIANT_SIDE_TOP_PAGE'] end else # 認証失敗 end else # OAuthを通してユーザ情報取得失敗 end end
ステップ8
クライアントにリダイレクトした後、実行されるメソッドです。
クッキーのsignedInに値が入っていた場合、RailsAPIを叩きます。
checkSignedInWithCookie () { const cookies = document.cookie.split('; ') let obj = {} cookies.forEach(cookie => { let arr = cookie.split('=') obj[arr[0]] = arr[1] }) if (obj.signedIn) { this.$http.secured.get(`/api/v1/users/show_me`) .then(response => { localStorage.signedIn = true localStorage.csrf = response.data.csrf this.$router.replace('/') }) .catch(error => { this.setError(error, 'ログインエラー: なにかがおかしいです。') }) }
ステップ9
tokenをAuthorizionヘッダーに格納したのち、 authorize_access_request!メソッドで、ステップ6〜7で発行したアクセストークンかどうかの認証を行っています。
認証に成功すれば、必要なユーザー情報やCSRFトークンを返答します。
def show_me ac_token = request.cookies['ac_token'] request.headers['Authorization'] = "Bearer #{ac_token}" authorize_access_request! response.set_cookie('ac_token', value: nil, httponly: true, domain: ENV["BASE_DOMAIN"], path: "/", secure: Rails.env.production?) response.set_cookie('signedIn', value: nil, domain: ENV["BASE_DOMAIN"], path: "/", secure: Rails.env.production?) response.set_cookie(JWTSessions.access_cookie, value: request.cookies['ac_token'], httponly: true, secure: Rails.env.production?) render json: { csrf: request.cookies['csrf'], my_avatar: current_user.avatar.encode(:icon), uid: current_user.id } end
感想
ほとんど全ての記述をコントローラに書いてしまっているので、できればモデル層にコードを移したいなと思っています。
認証のためのクラスを作って、そこに処理を任せる形が一番きれいにリファクタリングできそうかな…。