PHP
OAuth
laravel
vue.js
nuxt.js

Nuxt.jsとLaravelを使ってTwitterログイン機能を実装する

最近Vue.js界隈で人気のSSRフレームワーク「Nuxt.js

そのNuxt.jsとLaravelを使用して、Twitterでログインする機能の実装方法をご紹介します。
英語含め、まだまだNuxt.jsの知見が広まっていないので参考になれば幸いです。

ちなみにLaravel公式パッケージのLaravel PassportLaravel Socialiteを利用します。
実装したコードはGitHubに上げています

おおまかなTwitterログインの流れ

  1. ユーザーが「Twitterでログイン」ボタンを押す
  2. TwitterへのリダイレクトURLをLaravelから取得する
  3. 受け取ったリダイレクトURLにユーザーを遷移させる
  4. ユーザーがTwitterで認証を行い、Nuxt.jsのCallback URLへ戻ってくる
  5. (4)で受け取ったoauth_verifierなどのクエリをLaravelへ送信
  6. Laravel側でTwitterユーザー情報を取得し、Laravelのユーザーを取得(or 作成)
  7. ユーザーを元にPassportのAccessTokenを作成して返す
  8. 以降、Nuxt.jsではAuthorizationヘッダーにAccessTokenを付加してAPIリクエストを行う

上記の流れを元に解説していきます。
またNuxt.jsにはAxios moduleがありますので、HTTPクライアントにはそちらを使用します。

実装方法の解説

Nuxt.jsのリダイレクトページを作成

ユーザーが「Twitterでログイン」ボタンを押した時に遷移するページを作成します。
APIの/oauth/twitter/redirectからリダイレクトURLを取得しています。

pages/oauth/twitter/redirect.vue
<template>
  <p>Twitterへリダイレクトしています</p>
</template>

<script>
export default {
  middleware: 'guest',

  asyncData ({ app, error }) {
    return app.$axios.$get('/oauth/twitter/redirect')
      .then(data => {
        return { twitterAuthUrl: data.redirect_url }
      })
      .catch(e => error({ message: e.message, statusCode: e.statusCode }))
  },

  mounted () {
    window.location.href = this.twitterAuthUrl
  }
}
</script>

mountedメソッドは必ずクライアント側で呼ばれるライフサイクルメソッドです。(SSR時には呼ばれません)

リダイレクトURLを取得するAPIを作成

先ほどNuxt.jsで呼び出した/oauth/twitter/redirectをLaravelで実装します。
TwitterへのリダイレクトURLはLaravel Socialiteで簡単に取得できます。

use Laravel\Socialite\Facades\Socialite;

// SocialiteからリダイレクトURLを取得
Socialite::driver('twitter')->redirect()->getTargetUrl();

注意点としてSocialiteではセッション(Session)を利用します。
APIでSessionを利用するためには「CORS(Cross-Origin Resource Sharing)」の概念が必要になります。今回は説明を省略しますが、API通信ではセキュリティにおいても重要なので把握しておきましょう。

CORSの設定

Laravelにはlaravel-corsという便利なCORSパッケージがあるのでそちらを利用します。
supportsCredentialsをtrueに、そしてallowedOriginsにNuxt.jsのURLを指定しておきます。

config\cors.php
return [
    'supportsCredentials' => true,
    'allowedOrigins' => [
        env('WEB_URL', 'https://my-nuxt-app.com')
    ],
    'allowedHeaders' => ['*'],
    'allowedMethods' => ['*'],
    'exposedHeaders' => [],
    'maxAge' => 0,

];

Nuxt.jsのAxios側でもcredentialsを有効にします。

nuxt.config.js
axios: {
  credentials: true
}

Kernel.phpのmiddlewareGroupsに新たなsessionグループを追加します。

App\Http\Kernel.php
    protected $middlewareGroups = [
        // sessionグループを追加
        'session' => [
            \App\Http\Middleware\EncryptCookies::class,
            \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
            \Illuminate\Session\Middleware\StartSession::class,
        ],
    ];

これで完了です。
Controllerのミドルウェアに「session」を指定すれば、セッションを利用できるようになります。

Twitter認証後のCallbackページを実装

さて、ユーザーが先ほど取得したリダイレクトURLに遷移してアプリへ戻ってきました。
戻ってきたURLは以下のようになります。
https://my-nuxt-app.me/oauth/twitter/callback?oauth_token=xxxx&oauth_verifier=xxxx

そのままURLのクエリをthis.$router.queryで取得し、APIで送信してLaravel側で処理してもらいます。

pages/oauth/twitter/callback.vue
<template>
  <div>
    <p v-if="attempting">Twitterでログインしています。</p>

    <template v-else>
      <p v-else>Twitterでのログインに失敗しました。</p>
      <p>{{ failedMessage }}</p>
    </template>
  </div>
</template>

<script>
import { mapMutations } from 'vuex'

export default {
  middleware: 'guest',

  data () {
    return {
      failedMessage: null
    }
  },

  computed: {
    attempting () {
      return !this.failedMessage
    }
  },

  methods: mapMutations([
    'setToken',
    'setUser'
  ]),

  async mounted () {
    try {
      const callbackData = await this.$axios.$get('/oauth/twitter/callback', { params: this.$route.query })

      this.setToken({ token: callbackData.access_token })
      this.setUser({ user: callbackData.user })

      this.$router.replace('/')
    } catch (error) {
      this.failedMessage = error.message
    }
  }
}
</script>

/oauth/twitter/callbackからAcessTokenとユーザー情報を取得し、Vuexへと保存しています。

Laravel側でユーザー情報を取得しAccessTokenと共に返す

先の/oauth/twitter/callbackを実装します。
Twitterのユーザー情報自体はSocialiteで簡単に取得できます。

$twitterUser = Socialite::driver('twitter')->user();

$user->getId();
$user->getNickname();
$user->getName();
$user->getEmail();
$user->getAvatar();

このユーザー情報を元に、アプリ側のユーザーを取得もしくは作成します。
この辺はアプリの仕様によって大きく異なりますが、私の場合は以下のように実装しています。

ユーザーのソーシャルアカウントModelを作成する(筆者の場合)

providerカラムに「twitter」、account_idカラムにはTwitterのid_strを格納します。

Schema::create('user_social_accounts', function (Blueprint $table) {
    $table->unsignedInteger('user_id');
    $table->string('provider');
    $table->string('account_id');

    $table->unique(['provider', 'account_id']);

    $table->foreign('user_id')
        ->references('id')
        ->on('users')
        ->onDelete('cascade');
});

ログイン時にこのデータベースに存在していなければ、新たにユーザーと共に作成します。
以下のコードは簡略化していますが、実際にはEメールアドレスの重複チェックなどが必要でしょう。完全なコードはGitHubを参照してください。

$twitterUser = Socialite::driver('twitter')->user();

$socialAccount = SocialAccount::firstOrNew([
    'provider'   => 'twitter',
    'account_id' => $twitterUser->getId(),
]);

if ($socialAccount->exists) {
    $user = User::find($socialAccount->getAttribute('user_id'));
} else {
    $user = User::create([
        'name'         => $twitterUser->getName(),
        'email'        => $twitterUser->getEmail(),
        'password'     => null,
        'twitter_id'   => $twitterUser->getNickName(),
    ]);

    $socialAccount->setAttribute('user_id', $createdUser->id);
    $socialAccount->save();
}

return [
    'user'         => $user,
    'access_token' => $user->createToken(null, ['*'])->accessToken,
];

返ってきたユーザー情報をVuexへ、AccessTokenをCookieへ保存

もう一度Nuxt.js側のコールバック処理を見てみましょう。

const callbackData = await this.$axios.$get('/oauth/twitter/callback', { params: this.$route.query })

this.setToken({ token: callbackData.access_token })
this.setUser({ user: callbackData.user })

取得したAccessTokenとユーザー情報を、それぞれVuexへ保存しています。
vuexファイルは以下のように実装します。

store/index.js
const inBrowser = typeof window !== 'undefined'

export const state = () => {
  return {
    user: null,
    loggedIn: false,
    token: null
  }
}

export const getters = {}

export const mutations = {
  setUser (state, { user }) {
    state.user = user
    state.loggedIn = Boolean(user)
  },

  setToken (state, { token }) {
    state.token = token

    // Store token in cookies
    if (inBrowser) {
      if (token) {
        this.$cookies.set('token', token, { expires: 30 })
      } else {
        this.$cookies.remove('token')
      }
    }
  }
}

export const actions = {
  // ユーザーの訪問時(SSR)で呼ばれるメソッド
  nuxtServerInit ({ dispatch, state, commit }, { error }) {
    const token = this.$cookies.cookies.token

    if (!token) {
      return Promise.resolve()
    }

    return dispatch('fetchUserByAccessToken', { token }).catch(e => {
      return dispatch('logout').catch(e => {
        error({ message: e.message, statusCode: e.statusCode })
      })
    })
  },

  fetchUserByAccessToken ({ commit, dispatch }, { token }) {
    commit('setToken', { token })

    return this.$axios.$get('/users/@me').then(user => {
      commit('setUser', { user })
    })
  },

  logout ({ commit }) {
    commit('setUser', { user: null })

    // Revoke access token
    return this.$axios.delete('/oauth/token/destroy').then(() => {
      commit('setToken', { token: null })
    }).catch(e => {
      commit('setToken', { token: null })
    })
  }
}

これによりSSR+SPAに対応した、Twitterログイン機能が実装できました。
またAxiosのrequestInterceptorを設定すれば、自動でAuthorizationヘッダーを付加してくれます。

nuxt.config.js
axios: {
  credentials: true,
  requestInterceptor: (config, { store }) => {
    if (store.state.token) {
      config.headers.common['Authorization'] = `Bearer ${store.state.token}`
    }

    return config
  }
}

※@nuxtjs/axiosのv5から上記の設定は非推奨となりました。
代わりにpluginを使ってください。

plugins/axios.js
export default function ({ $axios, store }) {
  $axios.onRequest(config => {
    if (store.state.token) {
      config.headers.common['Authorization'] = `Bearer ${store.state.token}`
    }
    return config
  })
}

Nuxt.js いかがですか?

Nuxt.jsでは基本的な機能をほとんど不自由なく実装できます。
またESLintが標準搭載されており、公式モジュールなどを利用してsitemapやPWAの構築を簡単に行えます。

メジャーバージョン(1.0)も2017/1/9にリリースされ、Vue.js用のMDフレームワークVuetify.jsもv1.0.0がもうすぐリリース予定です。