saba1024のブログ

どうでも良い思いついた事とかプログラミング関係のメモとか書いていきます。

Grails/Vue.jsでTodoアプリ(認証) 2/2

これはG* Advent Calendar 2017の10日目の記事です。

この記事は、以下の5つの投稿で成り立っています。

Grails/Vue.jsでTodoアプリ(環境構築)
Grails/Vue.jsでTodoアプリ(CRUD)
Grails/Vue.jsでTodoアプリ(認証) 1/2
Grails/Vue.jsでTodoアプリ(認証) 2/2
Grails/Vue.jsでTodoアプリ(Deploy as war)

初めに

昨日の記事でRESTful APIで利用できる認証機構を追加しました。
今日はさらに追加で、アクセストークンの更新方法とログアウト、そしてSpringSecurityCoreとSpringSeucirtyRESTの組合せで発生するちょっとした問題の回避方法ご紹介します。

前提条件

Grailsで作成したアプリーションのディレクトリは今後$GRAILS_PROJECTとし、Vue.jsのアプリケーションのディレクトリはその直下で$VUE_PROJECTと記述します。
私の環境では、$GRAILS_PROJECT/home/koji/work/grails/gvtodoで、$VUE_PROJECT$GRAILS_PROJECT/frontendになっています。

access_tokenのリフレッシュ

なぜ?

SpringSecurityRESTがクライアントに発行してくれるaccess_tokenを使って、認証/認可が出来るようになりました。
しかし、実はこのaccess_token、デフォルトの設定だと3600秒(1時間)しか有効期限がありません。
1時間経過した後に、その有効期限の切れたaccess_tokenを使ってRESTful APIにアクセスすると、HTTPレスポンスコード401が返されます。(つまりログインしていないのと実質同じ)

ではどうすれば良いのか?
単純にトークンの有効期限を伸ばすことも可能です。
その場合、application.groovy内に

grails.plugin.springsecurity.rest.token.storage.jwt.expiration = 秒数

と指定することで可能です。ただ、コレでもやはり根本的な問題の解決ではありません。
SpringSecurityRESTでは、refresh_tokenを使って、新しいaccess_tokenを取得することが可能です。
access_tokenrefresh_tokenも、ログイン成功時にSpringSecurityRESTから渡されています。

なお、すでに述べた通り、access_tokenには制限期間があって、application.groovyでその値を変更することが出来ますが、このrefresh_tokenには制限期間はありません。

リフレッシュ方法

実際にaccess_tokenをリフレッシュするには、/oauth/access_tokenに、POSTメソッドでリクエストを投げます。
送信しなければならないデータは、grant_typeと、ログイン時に渡されたrefresh_tokenの2種類だけです。

const url = `http://localhost:8080/oauth/access_token`
// Content-Type: application/x-www-form-urlencodedで送らないとダメなので、そのためにURLSearchParamsを使って構築。
// Vue.jsではくて、axiosでそれを実現する方法
const params = new URLSearchParams()
params.append('grant_type', 'refresh_token')
params.append('refresh_token', UserInfoRepository.get().getRefreshToken())

axios.post(url, params)
  .then((response) => {
    // 新しいaccess_tokenを含む認証情報が再度得られるので、ココで再保存。
    UserInfoRepository.save(response.data)
})

上記のようコードを実行することで、新しいaccess_tokenが取得できるので、それを利用することでユーザは常にログインしている状態に出来ます。

しかし、もし上記ようにaccess_tokenのリフレッシュをリクエスト時に確認してしまうと、その画面で1時間放置しておいてToDoの追加ボタンを押すと、当然その際にリクエストヘッダーに付与されるaccess_tokenはSpringSecurityREST側ではタイムアウト扱いになり、401が返されます。
その場合は、一旦画面をリフレッシュすれば新しいaccess_token再セットされますが、スマートではないですよね。

ということで、ToDoのRESTful APIを実行する前に必ずこのaccess_tokenをリフレッシュする処理を実行するように、以下のようにコードを変更してみました。

  • $VUE_PROJECT/src/components/todo/Index.vue
<template>
  <div>
    <h1>ToDoリスト</h1>
    <input v-model="title">
    <button @click="add">add</button>
    <ul>
      <li v-for="todo in todoList">
        <input
          class="todo-title"
          v-bind:value="todo.title"
          @keyup.enter="edit($event, todo.id)"
          @blur="edit($event, todo.id)">

        &nbsp;<button @click="remove(todo.id)">del</button>
      </li>
    </ul>
  </div>
</template>
<script>
  import axios from 'axios'
  import UserInfoRepository from '@/UserInfoRepository'

  let errorFunc = (error) => {
    alert(`HTTP Status: ${error.response.status}, [${error.response.data.message}]`)
  }

  // const baseURL = 'http://localhost:8080/todo'
  const baseURL = 'http://localhost:8080/manual/items'

  export default {
    data: function () {
      return {
        title: '',
        todoList: []
      }
    },
    mounted: function () {
      this.list()
    },
    methods: {
      api: function (func) {
        const url = `http://localhost:8080/oauth/access_token`

        // Content-Type: application/x-www-form-urlencodedで送らないとダメなので、そのためにURLSearchParamsを使って構築
        const params = new URLSearchParams()
        params.append('grant_type', 'refresh_token')
        params.append('refresh_token', UserInfoRepository.get().getRefreshToken())

        axios.post(url, params)
          .then((response) => {
            // 新しいaccess_tokenを含む認証情報が再度得られるので、ココで再保存。
            UserInfoRepository.save(response.data)
            func()
          })
      },

      list: function () {
        this.api(() => {
          axios.get(`${baseURL}`, {headers: UserInfoRepository.get().generateAuthHeader()})
            .then((response) => {
              this.todoList = response.data
            }).catch(errorFunc)
        })
      },
      add: function () {
        this.api(() => {
          axios.post(`${baseURL}`, {
            title: this.title
          }, {headers: UserInfoRepository.get().generateAuthHeader()})
            .then((response) => {
              this.title = ''
              this.list()
            })
            .catch(errorFunc)
        })
      },
      edit: function (event, id) {
        this.api(() => {
          axios.put(`${baseURL}/${id}`, {
            title: event.target.value
          }, {headers: UserInfoRepository.get().generateAuthHeader()})
            .then((response) => {
              this.list()
            })
            .catch(errorFunc)
        })
      },
      remove: function (id) {
        this.api(() => {
          axios.delete(`${baseURL}/${id}`, {headers: UserInfoRepository.get().generateAuthHeader()})
            .then((response) => {
              this.list()
            })
            .catch(errorFunc)
        })
      }
    }
  }
</script>
<style>
  .todo-title {
    border: none;
  }
</style>

新たにapi関数を用意して、この中でaccess_tokeknをリフレッシュしています。
そして各RESTful APIにアクセスする処理自体を無名関数化して、それを今回用意したこのapi関数の中で実行するようにしました。
これで常に新しいaccess_tokenが得られるようになりました。

UserInfoRepositoryを使って新しいaccess_tokenを含むレスポンスデータをlocalStorageに再保存していますので、その後実行する、渡された関数内では、UserInfoRepositoryは当然その新しいaccess_tokenを返してくれるので、ToDo用のRESTful APIには常に新しいaccess_tokenが利用できる、という流れになっています。

当然この方式は私がこの記事のために実装しただけなので、要件によって実装方法などは変わります。

これで、ユーザが意図的にlocalStorageを削除しない限り常にログイン状態を保てるようになりました。

ログアウト

永続的にログイン状態に出来るようになりました。
では当然必要になってくるログアウトも実装しましょう。
が、SpringSeurityRESTの公式ドキュメントを読むとデフォルトのJWTはそもそもステートレスなんだからサーバは状態を保持していない、つまりログアウトなんて不可能だよ!とのことです。
しかしどうしてもユーザにログアウトさせてあげたい、というのであれば単純にlocalStorageに保存している情報を削除すれば、access_tokenrefresh_tokenもユーザは参照できなくなり、結果的にログアウトしたことに出来ます。

ということでやることはとっても簡単!
今回は追加箇所のみ抜粋します。

  • $VUE_PROJECT/src/components/todo/Index.vue
<template>
  <div>
    <h1>ToDoリスト</h1>
        ...省略...
        &nbsp;<button @click="remove(todo.id)">del</button>

        <!--この1行をを追加-->
       <hr><a @click="logout">Logout</a>
      </li>
    </ul>
  </div>
</template>
<script>
    ....省略....
    methods: {
      ...省略...
      logout: function () {
        UserInfoRepository.logout()
        this.$router.push({name: 'Login'})
      }
    }
  }
</script>

これだけです!
ToDo一覧の一番下にログアウトリンクを追加したので、それをクリックすると単純にUserInfoRepository.logout()を実行して、ログインページにリダイレクトします。
このUserInfoRepository.logout()は、単純にlocalStorageからデータを削除しているだけです。

static logout () {
  localStorage.removeItem('userInfo')
}

これでユーザは実質ログインしたのと同じことになります。簡単ですね!

Grails側でユーザの情報を更新する際のワークアラウンド

さて、Grails側ではSpringSecurityCoreで各ユーザのステータスを管理できます。
デフォルトでは、SpringSecurityCoreのユーザ用のドメイン(今回のサンプルではAuthUser.groovy)を見ると

boolean enabled = true
boolean accountExpired
boolean accountLocked
boolean passwordExpired

が用意されています。
例えば、あるユーザのenabledfalseにすれば、そのユーザを今後ログイン出来なくすることが出来ます。
太字にした通り、実はそこに大きな落とし穴が有ります。
SpringSecurityCoreプラグインでは、上記の4つのユーザのステータスを更新(直接SQLでデータを更新でも)しても、その時点のログイン状態には影響せず、次回ログイン時にそのステータスをチェックするようになっています。
つまり今回のSpringSecurityRESTベースで、毎回自動的にrefresh_tokenを使ってaccess_tokenを更新する方式だと、BANさせたいユーザが居たとしても、永久にそのユーザがログインした状態になってしまいます。

実際に、GrailsのDBConsoelにアクセスして、以下のSQLを実行してみてください。

UPDATE AUTH_USER SET enabled = false WHERE id = 1;

間違いなくDB上のデータは更新されるのですが、すでにログインしているユーザは引き続き操作を問題なく行えてしまいます。
これは非常に不味いです。恐らくSpringSecurityCoreのinterface等を実装した自前の認証方法などを用意すれば回避することも出来そうですが、自分でそこまではたどり着けませんでした。
ということで、その問題を回避するワークアラウンドをご紹介します。

特に難しいことは全くなくて、単純にGrails3のInterceptorで、毎アクセスごとにそのユーザのステータスをチェックして、ステータスによって403を返したりするだけです。
まずはInterceptorを適当な名前で作成します。

grails> create-interceptor CheckUserStatusInterceptor
| Created grails-app/controllers/gvtodo/CheckUserStatusInterceptor.groovy
| Created src/test/groovy/gvtodo/CheckUserStatusInterceptorSpec.groovy

作成されたInterceptorを以下のようにします。

package gvtodo

import groovy.json.JsonBuilder
import org.springframework.dao.DataAccessResourceFailureException


class CheckUserStatusInterceptor {

    def springSecurityService

    CheckUserStatusInterceptor() {
        matchAll()
                .excludes(uri: '/login/**')
                .excludes(uri: '/oauth/access_token/**')
    }

    boolean before() {
        try {
            AuthUser user = springSecurityService.currentUser
            String errorMessage
            if (user != null && !user.enabled) {
                errorMessage = 'Sorry, your account is disabled.'
            }
            if (user?.accountLocked) {
                errorMessage = 'Sorry, your account is locked.'
            }
            if (user?.passwordExpired) {
                errorMessage = 'Sorry, your password has expired.'
            }
            if (user?.accountExpired) {
                errorMessage = 'Sorry, your account has expired.'
            }

            if (errorMessage) {
                response.status = 403
                JsonBuilder json = new JsonBuilder()
                json([message: errorMessage])
                render json
                return false
            }
            true
        } catch (DataAccessResourceFailureException e) {
            // What is this!!??
            // If user has no Authority, SpringSecurityCore returns 403.(Please check application.groovy)
            // But this interceptor is executed too!
            // This exception is thrown if be used springSecurityService when user has no authority.
            // Grails/SpringSecurityCore already preparation to return a 403 status to client.
            // There fore it return true simply here it is enough.
            // Could not obtain current Hibernate Session; nested exception is org.hibernate.HibernateException: No Session found for current thread
            true
        }
    }

    boolean after() { true }

    void afterView() {
        // no-op
    }
}

以上です!ココで一旦Grailsを再起動しておいてください。
この方法で全てのリクエストでユーザの情報を必ずチェックできるようになったので、ユーザがすでにログインしている状態で、再度GrailsのDBConsoelにアクセスして、以下のSQLで状態を更新します。

UPDATE AUTH_USER SET enabled = false WHERE id = 1;

これでログインしているユーザのToDo一覧画面でページをリロードすると、403が返り、ユーザはもうToDoにアクセスできなくなっています。

注意点

いかがだったでしょうか?個人的にオールドスクールなSpringSecurityCoreのみで認証するモノリシックなシステムを開発運用してきたので、中々今回のRESTful APIベースでの認証には手こずりました。。。

さて、前回も述べましたが、今回実装してはっきりわかると思いますが、トークン(access_tokenかrefresh_token)が第3者に漏れるとアカウントがっ取られます。
ただのHTTPヘッダーにトークンを埋め込むわけですので、SSL環境では絶対に利用するべきではありません!
幸いなことに、現在はLet's Encryptのように無料で利用できるSSLがあります。必ずSSL経由で認証を行うようにしましょう!

参考

Spring Security Core
Spring Security REST