saba1024のブログ

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

Grails 3.2.8と3.2.9でSpring Security CoreとDatabase Migrationを利用する際の注意点

前提条件

Spring Security Coreラグインのバージョンは3.1.2です。
Database Migrationプラグインのバージョンは3.0.0です。
また、認証用のドメインを作成するs2-quickstartコマンドを以下のように実行したと仮定します。

s2-quickstart example User Role

これで、UserRoleUserRoleというドメインが生成されます。

Grails 3.2.8の場合

Spring Security Coreプラグイン

デフォルトでは正しく動きません
というのも、Grails3.2.8からドメイン内のプロパティに対してDIがデフォルトで動かないようになったためです。
これはパフォーマンスのための措置とのことです。
通常のアプリケーションであれば特に問題になることはないと思いますが、Spring Security Coreラグインを利用している場合、UserドメインUser.groovy)にSpringSecurityService springSecurityServiceというプロパティが設定されています。
今までは、Userドメインsaveする際にUser#encodePassword()が呼ばれて、その中でspringSecurityServiceを使って自動的にパスワードを暗号化してくれていました。
しかしGrails 3.2.8からはspringSecurityServiceがDIされていないためnullになっており、それが原因でパスワードは暗号化されずに平文のままDBに保存される状態になっています。
ログインページからログインしようとした場合、入力したパスワードは自体は本来の暗号化アルゴリズムで暗号化されてDBに問い合わせされるので、当然一致するパスワードが無いのでログイエラーとなってしまいます。
この問題の解決するためには、GORMのDIを以前と同じように有効化する必要が有ります。
具体的には、application.ymlで、autowiretrueにするだけです。

grails:
    gorm:
        autowire: true #これをfalseからtrueへ。もしなければ単に追加すればOK。

Grailsを3.2.7から3.2.8にアップグレードした場合、既存ユーザのログイン自体は問題なく動作して、この問題は新しいユーザを追加したタイミングで初めて発生します。
非常に気付きづらいので、Spring Security Coreプラグインを利用している場合、3.2.8にアップグレードするのであればこの部分は要注意です。

Database Migrationプラグイン

さらに、データベースがH2(デフォルト)で、Database Migrationプラグインを利用する際にも注意が必要です。
問題は単純で、Userドメインpasswordというカラム名がDatabase Migrationプラグインからうまく利用できません。
起動時にマイグレーションツールを適用(updateOnStart: true)しようとすると、以下のようなエラーが発生します。

grails> run-app
| Running application...

Configuring Spring Security Core ...
... finished configuring Spring Security Core

INFO 17/05/24 13:14: liquibase: Can not use class org.grails.plugins.databasemigration.liquibase.GormDatabase as a Liquibase service because it does not have a no-argument constructor
2017-05-24 13:14:23.099 ERROR --- [           main] o.h.engine.jdbc.spi.SqlExceptionHelper   : 列 "THIS_.password" が見つかりません
Column "THIS_.password" not found; SQL statement:
select this_.id as id1_8_0_, this_.version as version2_8_0_, this_.account_expired as account_3_8_0_, this_.account_locked as account_4_8_0_, this_.date_created as date_cre5_8_0_, this_.enabled as enabled6_8_0_, this_.last_updated as last_upd7_8_0_, this_."password" as pa
ssword8_8_0_, this_.password_expired as password9_8_0_, this_.username as usernam10_8_0_ from user this_ where this_.username=? [42122-193]
2017-05-24 13:14:23.184 ERROR --- [           main] o.s.boot.SpringApplication               : Application startup failed

解決策として、User.groovyに自動生成されるmappingの部分でDBのカラム名を以下のように変更します。

static mapping = {
    // これは元々あったやつ。コレがマズイ。(H2)
    //password column: '`password`'
    //コレを追加
    password column: 'passwd'
}

これで、再度dbm-gorm-diffでGORMから差分適用用のchangelogを生成しなおせばちゃんとマイグレーションが動作します。

Grails 3.2.9の場合

Spring Security Coreプラグイン

Spring Security Coreプラグインを利用すること自体には特に問題はありません。

Database Migrationプラグイン

もしもDatabase Migrationプラグインも利用するなら要注意です。
というよりも、結論としてはDatabase MigrationプラグインとSpring Security Coreラグインを同時に利用するのであれば、Grails 3.2.9は見送ったほうが良いと思います。
Hibernate5との絡みの問題らしくて、既にフィックスしたという情報も有りますが少なくとも私の環境では動作しませんでした。

これはGrails 3.2.9がデフォルトで利用するGORMのバージョンが6.0.10.RELEASEになったために発生します。
このGORMのバージョンからs2-quickstartコマンドで生成するUser.goorvyには、パスワードの暗号化用のメソッドencodePassword()が記載されなくなっています。
その代わりに、新しいsrc/groovy/example/UserPasswordEncoderListener.groovyというファイルが生成されます。このファイルの中に暗号化用の処理が移動されたようです。
そして、resources.groovyにそのUserPasswordEncoderListenerインスタンスを生成する以下のコードが1行挿入されます。

userPasswordEncoderListener(UserPasswordEncoderListener, ref('hibernateDatastore'))

冒頭にも述べましたが、Spring Security Coreプラグイン自体は正しく動作するので、特に問題ありません。
また、最初のGrails 3.2.8のDI問題を解決するためにこの仕組みが導入されたわけなのでSpring Security Coreプラグインとしては正当な進化となります。
しかし、残念ながらDatabase Migrationプラグインと同時に利用することが出来ません。

この状態でDatabase Migrationプラグインのコマンド(dbm-*)を実行すると、以下のようなエラーが発生します。

***************************
APPLICATION FAILED TO START
***************************

Description:

The dependencies of some of the beans in the application context form a cycle:

   authenticationUserDetailsService
      ↓
   userDetailsService
┌─────┐
|  hibernateDatastore
↑     ↓
|  userPasswordEncoderListener
└─────┘

resources.groovyに追加したコードからインスタンスの参照なりが循環してしまってエラーになっているようです。
なので、もしもresources.groovyから件の1行のコードを省けばパット見動作はするようになりますが、既に述べたように本来それは平文のパスワードを暗号化するために利用されるものなので、結果的にGrails 3.2.8の時のように平文でパスワードが保存されるようになり、ログインできなくなります。

こちらに関しては、Grails 3.3以降、対応したSpring Security Coreプラグイン(3.2.0系になる予定)などがリリースされるはずですので、そのタイミングで再度確認したほうがいいかなと思います。

まとめ

今回詳細は述べませんでしたが、どうやらGrails 3.2.9だとAsset-Pipelineを利用するとwarに固めでデプロイするとAssetアクセスできないという問題も有るようです。
なので、個人的にはGrails 3.2.9は使わずに、Grails 3.2.8で、application.ymlgrails.gorm.autowiretrueにして様子見をするのが良いと思います。

参考

Grails Spring Security Core Plugin
Grails Database Migration Plugin
Error creating ‘hibernateDatastore’ - circular reference when running plugin commands
runCommand detects a dependency cycle for AbstractPersistenceEventListener Bean
23. Tutorials
Grails 3.2.8 Upgrade Notes.md

Grails3.2でのCSRF対策

CSRF対策を導入

標準だとGSPのフォームで利用する2重投稿防止用の<g:form useToken="true" ...>を使うことでCSRF対策が出来る。
ただしこの場合、当然コントローラ側でトークンが正しいかどうかのチェックをしないといけないので、

withForm {
   // OK!
}.invalidToken {
   // トークンがおかしい!
}

というコードを必要な箇所に全て記述しなければならない。
シンプルなコードとは言え、さすがに全コントローラにこのような記述を毎回記述するのは大変。
ということで、基本的にはSpringSecurityCoreを利用するのが楽。

SpringSecurityCoreでの設定

  • resource.groovyに以下を追記。
csrfFilter(CsrfFilter, new HttpSessionCsrfTokenRepository()) {
    // 振る舞いは自分でクラスを用意することで、以下のようにして変更可能
    // accessDeniedHandler = ref('自作AccessDeniedHandler')
    // requireCsrfProtectionMatcher = ref('自作RequireCsrfProtectionMatcher')
}
  • Bootstrap.groovyに以下を追加
SpringSecurityUtils.clientRegisterFilter('csrfFilter',  SecurityFilterPosition.LAST.order + 10)

基本的な設定はこの2つのみ。

クライアント側の設定

で、基本的なFORMからのPOSTとかの場合は、

<input type="hidden" name="_csrf" value="${_csrf.token}">

を付けるだけでもう完了。
これで後はSpringSecurityCoreが全て全自動でチェックしてくれる。わざわざ自分でコントローラ側にトークンの妥当性チェックを書く必要をない。

ただし、JavaScriptからajaxでデータを投げる場合にはHTTPヘッダーに埋め込む方が良いらしい。
なので、基本的には全ページのheadタグ内に以下の内容を追記。

<meta name="_csrf" content="${_csrf?.token}"/>
<meta name="_csrf_header" content="${_csrf?.headerName}"/>

で、実際にサーバにデータを投げるJavaScriptで、上記の2つのデータを元にHTTPヘッダーを生成すればOK。
以下は、自分が今作っているアプリケーションで、jQueryでmetaタグの情報を取得して、axiosで実際のそのヘッダーと共にデータをサーバに投げるサンプル。

// metaタグから値を取得
const csrfHeader = $("meta[name='_csrf_header']").attr("content");
const csrfToken = $("meta[name='_csrf']").attr("content");
const headers = {};
headers[csrfHeader] = csrfToken;

axios.post(
    '/u/r/l',  {
        // サーバに投げるデータを指定
        name: 'grails'
        version: '3.2.9'
    }, {
        // コレがそのヘッダー
        headers: headers
    }
    )
.then(function (response) {
    console.log(response);
})
.catch(function (error) {
    console.log(error);
});

(ちなみにヘッダー名はデフォルトではX-CSRF-TOKEN

なお、Bootstrapに追加したコードの詳細は現在調査中。
もしこれを省いた場合、別のサイトから未ログイン状態のユーザが悪意を持ってPOSTとかPUT、DELETEで遷移させられてきた場合には、まずログイン画面が表示される。
ユーザがもし疑問に思わずにログインすると、GET扱いでログイン先のページに移ってしまう。 (GETになるのでPOSTとかで送られたパラメタはnullになる為、大抵データを保存するような場面なのでvalidationエラーになるはず。だが、それは本来意図したエラーチェックではない)

上記のBootstrapの1行を追加すればちゃんとSpringSecurityCoreがエラー扱いにしてくれる(Statusコードが403じゃなくて999になるので本当に意図したエラーなのか不安。。。ここの辺りを現在調査中)

あと、Bootstrapの1行を追加するとログインフォームにもTokenが必要になるので、views/login/auth.gpsとdenied.gspプラグインからコピーしてきて修正する。

参考ページ

17 Security
8.1.11 Handling Duplicate Form Submissions
Grails 3 CSRF protection
6.7.2.3. AjaxによるCSRFトークンの送信
Spring Security Core Plugin - Reference Documentation