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_token
もrefresh_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)"> <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_token
もrefresh_token
もユーザは参照できなくなり、結果的にログアウトしたことに出来ます。
ということでやることはとっても簡単!
今回は追加箇所のみ抜粋します。
$VUE_PROJECT/src/components/todo/Index.vue
<template> <div> <h1>ToDoリスト</h1> ...省略... <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
が用意されています。
例えば、あるユーザのenabled
をfalse
にすれば、そのユーザを今後ログイン出来なくすることが出来ます。
太字にした通り、実はそこに大きな落とし穴が有ります。
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経由で認証を行うようにしましょう!