saba1024のブログ

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

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

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

この記事は、以下の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を使ってGrailsとVue.js/axiosでCRUD操作を実装しました。
こうなるとやはりログイン処理を実装したくなりますね。
ということで今回は、GrailsにRESTful APIで利用可能な認証機構を追加していきます。

前提条件

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

準備

プラグインの追加

$GRAILS_PROJECT/build.gradledependenciesに以下を追記します。 なお、下記のプラグインは2017/11/10の時点で最新のものです。

compile 'org.grails.plugins:spring-security-core:3.2.0'
compile "org.grails.plugins:spring-security-rest:2.0.0.M2"

コレで、一旦Grailsを完全に終了して、インタラクティブモードからも抜けます。
そして、再度grailsコマンドを実行すれば、自動的に上記2プラグインがダウンロード、セットアップされます。
sprng-security-coreが、Grailsに認証機構を追加するプラグインで、spring-security-restがさらにその上でRESTful API経由で認証出来るようにするプラグインとなります。

認証用ドメインと設定ファイルの作成

当然ログインするユーザのIDとパスワード(とモロモロ)を管理する必要が有ります。
これらはSpringSecurityCoreプラグインが提供してくれているs2-quickstartコマンド一発で生成できます。
以下のようにコマンドを実行して、認証用のユーザ情報を保持するドメインなどを作成します。
grailsインタラクティブモードに入ったら、s2-quickstart gvtodo AuthUser AuthRoleを実行してください。

grails> s2-quickstart gvtodo AuthUser AuthRole
| Creating User class 'AuthUser' and Role class 'AuthRole' in package 'gvtodo'
| Rendered template PersonWithoutInjection.groovy.template to destination grails-app/domain/gvtodo/AuthUser.groovy
| Rendered template PersonPasswordEncoderListener.groovy.template to destination src/main/groovy/gvtodo/AuthUserPasswordEncoderListener.groovy
| Rendered template Authority.groovy.template to destination grails-app/domain/gvtodo/AuthRole.groovy
| Rendered template PersonAuthority.groovy.template to destination grails-app/domain/gvtodo/AuthUserAuthRole.groovy
| 
************************************************************
* Created security-related domain classes. Your            *
* grails-app/conf/application.groovy has been updated with *
* the class names of the configured domain classes;        *
* please verify that the values are correct.               *
************************************************************

grails> 

書式は、s2-quickstart パッケージ名 認証ユーザドメイン名 認証ロール名となります。

認証ユーザとロールの作成(デフォルトのAdministrator)

では、テスト用に初期ユーザを登録します。 Bootstrap.groovyを以下のようにします。

package gvtodo

class BootStrap {

    def init = { servletContext ->
        def userRole = new AuthRole(authority: 'ROLE_USER').save()
        def adminRole = new AuthRole(authority: 'ROLE_ADMIN').save()

        def admin = new AuthUser(username: 'admin', password: 'password').save()

        AuthUserAuthRole.create admin, adminRole

        AuthUserAuthRole.withSession {
            it.flush()
            it.clear()
        }
    }
    def destroy = {
    }
}

これでGrails起動時に常にこのログイン情報(ロールとユーザ)が作成されるようになりました。

認証情報の設定

Grails3がデフォルトで持っている設定ファイルはapplication.ymlですが、SpringSecurityCoreによって古い設定ファイル方式のapplication.groovyが生成されて、デフォルトの設定が自動的に記述されています。
デフォルトでは以下のようになっています。

// Added by the Spring Security Core plugin:
grails.plugin.springsecurity.userLookup.userDomainClassName = 'gvtodo.AuthUser'
grails.plugin.springsecurity.userLookup.authorityJoinClassName = 'gvtodo.AuthUserAuthRole'
grails.plugin.springsecurity.authority.className = 'gvtodo.AuthRole'
grails.plugin.springsecurity.controllerAnnotations.staticRules = [
    [pattern: '/',               access: ['permitAll']],
    [pattern: '/error',          access: ['permitAll']],
    [pattern: '/index',          access: ['permitAll']],
    [pattern: '/index.gsp',      access: ['permitAll']],
    [pattern: '/shutdown',       access: ['permitAll']],
    [pattern: '/assets/**',      access: ['permitAll']],
    [pattern: '/**/js/**',       access: ['permitAll']],
    [pattern: '/**/css/**',      access: ['permitAll']],
    [pattern: '/**/images/**',   access: ['permitAll']],
    [pattern: '/**/favicon.ico', access: ['permitAll']]
]

grails.plugin.springsecurity.filterChain.chainMap = [
    [pattern: '/assets/**',      filters: 'none'],
    [pattern: '/**/js/**',       filters: 'none'],
    [pattern: '/**/css/**',      filters: 'none'],
    [pattern: '/**/images/**',   filters: 'none'],
    [pattern: '/**/favicon.ico', filters: 'none'],
    [pattern: '/**',             filters: 'JOINED_FILTERS']
]

コレを以下のように修正します。

// Added by the Spring Security Core plugin:
grails.plugin.springsecurity.userLookup.userDomainClassName = 'gvtodo.AuthUser'
grails.plugin.springsecurity.userLookup.authorityJoinClassName = 'gvtodo.AuthUserAuthRole'
grails.plugin.springsecurity.authority.className = 'gvtodo.AuthRole'

grails.plugin.springsecurity.logout.postOnly = false
grails.plugin.springsecurity.securityConfigType = "InterceptUrlMap"
grails.plugin.springsecurity.interceptUrlMap = [

        // for SpringSecurityREST
        [pattern: '/oauth/**',           access: ['permitAll']],
        [pattern: '/api/**',             access: ['ROLE_USER', 'ROLE_ADMIN']],
        [pattern: '/manual/**',          access: ['ROLE_USER', 'ROLE_ADMIN']],

        // for /dbconsole
        [pattern: '/login/**',           access: ['permitAll']],
        [pattern: '/dbconsole/**',       access: ['ROLE_ADMIN']],

        [pattern: '/**',                 access: ['ROLE_USER', 'ROLE_ADMIN']]
]

grails.plugin.springsecurity.filterChain.chainMap = [
        //Stateless chain
        [pattern: '/api/**',filters: 'JOINED_FILTERS,-anonymousAuthenticationFilter,-exceptionTranslationFilter,-authenticationProcessingFilter,-securityContextPersistenceFilter,-rememberMeAuthenticationFilter'],
        [pattern: '/manual/**',filters: 'JOINED_FILTERS,-anonymousAuthenticationFilter,-exceptionTranslationFilter,-authenticationProcessingFilter,-securityContextPersistenceFilter,-rememberMeAuthenticationFilter'],
        [pattern: '/assets/**',      filters: 'none'],
        [pattern: '/**/js/**',       filters: 'none'],
        [pattern: '/**/css/**',      filters: 'none'],
        [pattern: '/**/images/**',   filters: 'none'],
        [pattern: '/**/favicon.ico', filters: 'none'],
        [pattern: '/**',             filters: 'JOINED_FILTERS,-restTokenValidationFilter,-restExceptionTranslationFilter']
]

変更点の概要は大まかに以下のとおりです

  1. ログアウトがデフォルトだとPOST限定なので、GETなどでも行えるようにする
  2. 権限の確認方法をアノテーションベースからURLベースに変更
  3. SpringSecurityRESTの設定を追加
  4. SpringSecurityCoreがどのURLに対してどのロールを要求するのか設定

grails.plugin.springsecurity.filterChain.chainMap

これはSpringSecurityRESTの為に追加しました。
コード内にJOINED_FILTERSという項目が現れていますが、コレはどういった認証方法(Filter)をpattern(URI)に対して許可/利用するか、というものになります。

すでにSpringSecurityCoreによってJOINED_FILTERSには複数の認証方法が定義されていますが、SpringSecurityRESTによってさらにrestTokenValidationFilterrestExceptionTranslationFilterが追加されています。

つまり、以下の2行では

[pattern: '/api/**',filters: 'JOINED_FILTERS,-anonymousAuthenticationFilter,-exceptionTranslationFilter,-authenticationProcessingFilter,-securityContextPersistenceFilter,-rememberMeAuthenticationFilter'],
[pattern: '/manual/**',filters: 'JOINED_FILTERS,-anonymousAuthenticationFilter,-exceptionTranslationFilter,-authenticationProcessingFilter,-securityContextPersistenceFilter,-rememberMeAuthenticationFilter'],

/api/**/manual/**へのアクセスの場合、 anonymousAuthenticationFilterexceptionTranslationFilterauthenticationProcessingFiltersecurityContextPersistenceFilterrememberMeAuthenticationFilter、 を認証方法(Filter)から省いています。
そして、すでに述べた通り、restTokenValidationFilterrestExceptionTranslationFilterの2つのSpringSecuiryRESTによって追加されたFilterは省いていないので、このFilterによってRESTful APIでの認証が可能になる、ということになります。

なお、それ以外のURLに関しては最後の行の、

[pattern: '/**',filters: 'JOINED_FILTERS,-restTokenValidationFilter,-restExceptionTranslationFilter']

で指定されている通り、SpringSecurityRESTで追加されたFilterを省いたモノすべて、つまり通常のSpringSecurityCoreプラグインでの認証方法、ということになっています。

grails.plugin.springsecurity.interceptUrlMap

この設定はSpringSecurityCoreの設定で、どのURLがどのような認証/認可を行うのかというルールを指定します。
今回特に重要なのは、

// for SpringSecurityREST
[pattern: '/oauth/**',    access: ['permitAll']],
[pattern: '/api/**',        access: ['ROLE_USER', 'ROLE_ADMIN']],
[pattern: '/manual/**', access: ['ROLE_USER', 'ROLE_ADMIN']],

の部分です。
/oauth/**には、アクセストークンの再発行の為にアクセスしますので、アクセストークン無しでアクセスできるようにpermitAllにしてあります。(このアクセストークンの再発行は明日の記事にて説明します。)
/api/**/manual/**に関しては、すでにログインしていて、ROLE_USERROLE_ADMINのロールを持っているユーザのみアクセス可、としています。

ココで1点分かりづらい部分が有るのですが、/api/**には、SpringSecurityRESTによって、

  1. login
  2. logout
  3. validate

の3つのURLがGrailsに追加されています。
しかし、/api/**にアクセスするのにROLE_USERROLE_ADMINが無いといけないという指定をしているので、そもそもログインしたいが為に/api/loginにアクセスしてもエラーになっちゃうのでは?と言う疑問が残ります。

この部分はSpringSecurityRESTがうまいことやってくれていて、/api/loginへのアクセスは例外としてOK、という処理になっているようです。
実際に以下のように各URLにcurlでアクセスすると、基本的に401エラーが返ってくるけど、/api/loginだけはエラーになりませんでした。

[koji:~]$ curl http://localhost:8080/api/
{"timestamp":1512134061526,"status":401,"error":"Unauthorized","message":"No message available","path":"/api/"}%
[koji:~]$ curl http://localhost:8080/api/logout
{"timestamp":1512134065363,"status":401,"error":"Unauthorized","message":"No message available","path":"/api/logout"}%
[koji:~]$ curl http://localhost:8080/api/validate
{"timestamp":1512134068645,"status":401,"error":"Unauthorized","message":"No message available","path":"/api/validate"}%
[koji:~]$ curl http://localhost:8080/api/login   
[koji:~]$ 

コレで基本的に設定は完了です。Grailsをrun-appで起動して、実際に直接Grailsにアクセス(http://localhost:8080)してみるとログイン画面が表示されると思います。

ログイン処理(Vue.js/axios)

さて、それでは実際にログイン出来る用にアプリケーションを改良していきます。

ログイン画面

新たにログイン画面を作成します。

  • `$VUE_PROJECT/src/components/Login.vue
<template>
  <section class="section">
    <div class="container is-fluid">
      <input v-model="username" class="input" placeholder="Login id" v-bind:class="{'is-danger': hasError}"/><br>
      <input v-model="password" class="input" type="password" placeholder="password" v-bind:class="{'is-danger': hasError}" @keyup.enter="login"/><br>
      <br>
      <button @click="login" class="button is-primary">Login</button><br />
    </div>
  </section>
</template>

<script>
  import axios from 'axios'
  import UserInfoRepository from '@/UserInfoRepository'

  export default {

    data: function () {
      return {
        username: '',
        password: '',
        hasError: false
      }
    },
    methods: {
      login: function () {
        // SpringSecurityRESTがある前提。
        // で、以下のURLにログイン情報を送信すると、認証情報が返る。
        const url = `http://localhost:8080/api/login`
        axios.post(url, this.$data)
          .then((response) => {
            UserInfoRepository.save(response.data)
            this.$router.push({name: 'ToDoIndex'})
          })
          .catch((error) => {
            console.log(error.response)
            this.hasError = true
          })
      }
    }
  }
</script>

ログインした情報を保持する

ユーザ情報の取り扱い

ログインしたユーザの情報を取り扱うsrc/UserInfo.jsを作成します。

  • $VUE_PROJECT/src/UserInfo.js
export default class UserInfo {
  constructor (rawJson) {
    this.json = rawJson
  }

  isAdmin () {
    return this.json.roles.some((role) => {
      return role === 'ROLE_ADMIN'
    })
  }

  getUserName () {
    return this.json.username
  }

  getAccessToken () {
    return this.json.access_token
  }

  getRefreshToken () {
    return this.json.refresh_token
  }

  generateAuthHeader () {
    return {
      'Authorization': `Bearer ${this.getAccessToken()}`
    }
  }
}

ログインしたユーザ情報の保存

今回、フロントエンドで、サーバから受け取った認証情報はlocalStoageに保存するようにします。
ソレを扱うためのsrc/UserInfoRepository.jsを作成します。

  • $VUE_PROJECT/src/UserInfoRepository
import UserInfo from '@/UserInfo'

export default class UserInfoRepository {
  static save (userInfo) {
    localStorage.setItem('userInfo', JSON.stringify(userInfo))
  }

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

  static get () {
    const userString = localStorage.getItem('userInfo')
    if (userString) {
      const userJson = JSON.parse(userString)
      return new UserInfo(userJson)
    } else {
      return null
    }
  }
}

ログインを必須にする

そして全てのリクエスで必ず、ログインしているかどうかをチェックするように、src/router/index.jsを以下のように修正します。

  • $VUE_PROJECT/src/router/index.js
import Vue from 'vue'
import Router from 'vue-router'
import ToDoIndex from '@/components/todo/Index'
import Login from '@/components/Login'
import UserInfoRepository from '@/UserInfoRepository'

Vue.use(Router)

const vr = new Router({
  routes: [
    {
      path: '/',
      name: 'ToDoIndex',
      component: ToDoIndex
    },
    {
      path: '/login',
      name: 'Login',
      component: Login
    }
  ]
})

vr.beforeEach((to, from, next) => {
  const toLoginPage = (err) => {
    console.log(err)
    next({
      path: '/login',
      query: { redirect: to.fullPath }
    })
  }

  if (to.name === 'Login') {
    next()
    return
  }

  const userInfo = UserInfoRepository.get()
  if (userInfo) {
    next()
  } else {
    toLoginPage()
  }
})
export default vr

APIへのアクセスに認証情報(access_token)を付与する

最後に、ToDoをRESTful APIを利用して操作している部分で、認証用のトークンをヘッダーにセットするように変更します。
SpringSecurityRESTでは、デフォルトでは認証情報をHTTPヘッダーのAuthritzationからBearer認証スキームを前提として抜き出します。
つまり、HTTPリクエストヘッダーのAuthaorizationは以下のような形になります。

Authorization: Bearer {ログイン時にSpringSecurityRESTが返してくれたaccess_token}

この辺りの詳細はRFCを参照してください。
The OAuth 2.0 Authorization Framework: Bearer Token Usage(日本語)

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

コレでアプリケーションに認証を機能を付けることが出来ました!
GrailsとVueアプリケーションを起動してから、実際にhttp://localhost:8081にアクセスしてみてください。ちゃんとログイン画面が表示されて、ログイン後操作できることを確認できると思います。

まとめ

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

参考

Spring Security Core
Spring Security REST