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.gradle
のdependencies
に以下を追記します。
なお、下記のプラグインは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'] ]
変更点の概要は大まかに以下のとおりです
- ログアウトがデフォルトだとPOST限定なので、GETなどでも行えるようにする
- 権限の確認方法をアノテーションベースからURLベースに変更
- SpringSecurityRESTの設定を追加
- SpringSecurityCoreがどのURLに対してどのロールを要求するのか設定
grails.plugin.springsecurity.filterChain.chainMap
これはSpringSecurityREST
の為に追加しました。
コード内にJOINED_FILTERS
という項目が現れていますが、コレはどういった認証方法(Filter)をpattern(URI)
に対して許可/利用するか、というものになります。
すでにSpringSecurityCore
によってJOINED_FILTERS
には複数の認証方法が定義されていますが、SpringSecurityREST
によってさらにrestTokenValidationFilter
、restExceptionTranslationFilter
が追加されています。
つまり、以下の2行では
[pattern: '/api/**',filters: 'JOINED_FILTERS,-anonymousAuthenticationFilter,-exceptionTranslationFilter,-authenticationProcessingFilter,-securityContextPersistenceFilter,-rememberMeAuthenticationFilter'], [pattern: '/manual/**',filters: 'JOINED_FILTERS,-anonymousAuthenticationFilter,-exceptionTranslationFilter,-authenticationProcessingFilter,-securityContextPersistenceFilter,-rememberMeAuthenticationFilter'],
/api/**
、/manual/**
へのアクセスの場合、
anonymousAuthenticationFilter
、
exceptionTranslationFilter
、
authenticationProcessingFilter
、
securityContextPersistenceFilter
、
rememberMeAuthenticationFilter
、
を認証方法(Filter)から省いています。
そして、すでに述べた通り、restTokenValidationFilter
、restExceptionTranslationFilter
の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_USER
かROLE_ADMIN
のロールを持っているユーザのみアクセス可、としています。
ココで1点分かりづらい部分が有るのですが、/api/**
には、SpringSecurityRESTによって、
login
logout
validate
の3つのURLがGrailsに追加されています。
しかし、/api/**
にアクセスするのにROLE_USER
かROLE_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)"> <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経由で認証を行うようにしましょう!