かんがるーさんの日記

最近自分が興味をもったものを調べた時の手順等を書いています。今は Spring Boot をいじっています。

Spring Boot 1.5.x の Web アプリを 2.0.x へバージョンアップする ( その26 )( Gradle を 4.10.2 → 4.10.3 へ、Spring Boot を 2.0.6 → 2.0.7 へバージョンアップする。。。が Spring Security の bug のため Spring Boot は 2.0.6 へ戻す )

概要

記事一覧はこちらです。

Spring Boot 1.5.x の Web アプリを 2.0.x へバージョンアップする ( その25 )( Docker で PostreSQL+pgAdmin4+ Flyway の環境を構築する2 ) の続きです。

  • 今回の手順で確認できるのは以下の内容です。
    • Gradle を 4.10.2 → 4.10.3 へ、Spring Boot を 2.0.x 系の最新バージョンである 2.0.7 へバージョンアップします。。。が Spring Security 5.0.10.RELEASE の bug で build が通らなかったので Spring Boot は 2.0.6 のままにします。
    • ライブラリは出来るだけ最新バージョンにします。

参照したサイト・書籍

  1. Gradle Build Tool - Releases
    https://gradle.org/releases/

  2. Spring Security 5.0 解剖速報
    https://www.slideshare.net/TakuyaIwatsuka/spring-security5report

  3. AuthenticationFailureBadCredentialsEvent published twice
    https://github.com/spring-projects/spring-security/issues/6281

目次

  1. gradle を 4.10.2 → 4.10.3 へバージョンアップする
  2. build.gradle を変更する
  3. AuthenticationFailureBadCredentialsEvent が2回発生する原因を調査する
  4. build.gradle を変更する2
  5. 最後に

手順

gradle を 4.10.2 → 4.10.3 へバージョンアップする

build.gradle の wrapper タスクの記述を以下のように変更します。

wrapper {
    gradleVersion = "4.10.3"
    distributionType = Wrapper.DistributionType.ALL
}
  • gradleVersion = "4.10.2"gradleVersion = "4.10.3" に変更します。

コマンドプロンプトから gradlew wrapper --gradle-version=4.10.3gradlew --version コマンドを実行します。

f:id:ksby:20190103175532p:plain

gradle/wrapper/gradle-wrapper.properties は以下の内容になります。

distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-4.10.3-all.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

Gradle Tool Window の左上にある「Refresh all Gradle projects」ボタンをクリックして更新した後、clean タスク実行 → Rebuild Project 実行 → build タスクを実行して、"BUILD SUCCESSFUL" のメッセージが出力されることを確認します(画面キャプチャはなし)。

build.gradle を変更する

build.gradle の以下の点を変更します。

buildscript {
    ext {
        group "ksbysample"
        version "2.0.7-RELEASE"
    }
    repositories {
        mavenCentral()
        maven { url "https://repo.spring.io/release/" }
        maven { url "https://plugins.gradle.org/m2/" }
    }
}

plugins {
    id "java"
    id "eclipse"
    id "idea"
    id "org.springframework.boot" version "2.0.7.RELEASE"
    id "io.spring.dependency-management" version "1.0.6.RELEASE"
    id "groovy"
    id "checkstyle"
    id "com.github.spotbugs" version "1.6.8"
    id "pmd"
    id "net.ltgt.errorprone" version "0.0.16"
    id "de.undercouch.download" version "3.4.3"
    id "com.gorylenko.gradle-git-properties" version "2.0.0-beta1"
}

sourceCompatibility = 1.8
targetCompatibility = 1.8

wrapper {
    gradleVersion = "4.10.3"
    distributionType = Wrapper.DistributionType.ALL
}

[compileJava, compileTestGroovy, compileTestJava]*.options*.encoding = "UTF-8"
[compileJava, compileTestGroovy, compileTestJava]*.options*.compilerArgs = [
        "-Xlint:all,-options,-processing,-path"
        , "-Xep:RemoveUnusedImports:WARN"
        , "-Xep:InsecureCryptoUsage:OFF"
        , "-Xep:ParameterName:OFF"
]

// for Doma 2
// JavaクラスとSQLファイルの出力先ディレクトリを同じにする
processResources.destinationDir = compileJava.destinationDir
// コンパイルより前にSQLファイルを出力先ディレクトリにコピーするために依存関係を逆転する
compileJava.dependsOn processResources

springBoot {
    buildInfo()
}

idea {
    module {
        inheritOutputDirs = false
        outputDir = file("$buildDir/classes/main/")
    }
}

configurations {
    // for Doma 2
    domaGenRuntime
}

checkstyle {
    configFile = file("${rootProject.projectDir}/config/checkstyle/google_checks.xml")
    toolVersion = "8.16"
    sourceSets = [project.sourceSets.main]
}

spotbugs {
    toolVersion = "3.1.10"
    ignoreFailures = true
    effort = "max"
    spotbugsTest.enabled = false
}
tasks.withType(com.github.spotbugs.SpotBugsTask) {
    reports {
        xml.enabled = false
        html.enabled = true
    }
}

pmd {
    toolVersion = "6.10.0"
    sourceSets = [project.sourceSets.main]
    ignoreFailures = true
    consoleOutput = true
    ruleSetFiles = rootProject.files("/config/pmd/pmd-project-rulesets.xml")
    ruleSets = []
}

repositories {
    mavenCentral()
}

dependencyManagement {
    imports {
        // mavenBom は以下の URL のものを使用する
        // https://repo.spring.io/release/org/springframework/boot/spring-boot-starter-parent/2.0.7.RELEASE/
        // bomProperty に指定可能な property は以下の URL の BOM に記述がある
        // https://repo.spring.io/release/org/springframework/boot/spring-boot-dependencies/2.0.7.RELEASE/spring-boot-dependencies-2.0.7.RELEASE.pom
        mavenBom(org.springframework.boot.gradle.plugin.SpringBootPlugin.BOM_COORDINATES) {
            // Spring Boot の BOM に定義されているバージョンから変更する場合には、ここに以下のように記述する
            // bomProperty "thymeleaf.version", "3.0.9.RELEASE"
            bomProperty "groovy.version", "2.5.4"
        }
    }
}

dependencies {
    def jdbcDriver = "org.postgresql:postgresql:42.2.5"
    def spockVersion = "1.2-groovy-2.5"
    def domaVersion = "2.20.0"
    def lombokVersion = "1.18.4"
    def errorproneVersion = "2.3.1"
    def powermockVersion = "2.0.0-RC.4"
    def spotbugsVersion = "3.1.10"

    // dependency-management-plugin によりバージョン番号が自動で設定されるもの
    // Appendix F. Dependency versions ( https://docs.spring.io/spring-boot/docs/current/reference/html/appendix-dependency-versions.html ) 参照
    implementation("org.springframework.boot:spring-boot-starter-web")
    implementation("org.springframework.boot:spring-boot-starter-thymeleaf") {
        exclude group: "org.codehaus.groovy", module: "groovy"
    }
    implementation("org.thymeleaf.extras:thymeleaf-extras-springsecurity5")
    implementation("org.thymeleaf.extras:thymeleaf-extras-java8time")
    implementation("org.springframework.boot:spring-boot-starter-data-jpa")
    implementation("org.springframework.boot:spring-boot-starter-freemarker")
    implementation("org.springframework.boot:spring-boot-starter-mail")
    implementation("org.springframework.boot:spring-boot-starter-security")
    implementation("org.springframework.boot:spring-boot-starter-data-redis")
    implementation("org.springframework.boot:spring-boot-starter-amqp")
    implementation("org.springframework.boot:spring-boot-starter-actuator")
    runtimeOnly("org.springframework.boot:spring-boot-devtools")
    implementation("org.springframework.session:spring-session-core")
    implementation("org.springframework.session:spring-session-data-redis")
    implementation("org.springframework.retry:spring-retry")
    implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310")
    implementation("com.fasterxml.jackson.dataformat:jackson-dataformat-xml")
    implementation("org.apache.commons:commons-lang3")
    implementation("org.codehaus.janino:janino")
    implementation("io.micrometer:micrometer-registry-prometheus")
    implementation("redis.clients:jedis")
    testImplementation("org.springframework.boot:spring-boot-starter-test")
    testImplementation("org.springframework.security:spring-security-test")
    testImplementation("org.yaml:snakeyaml")
    testImplementation("org.mockito:mockito-core")
    runtimeOnly("org.springframework.boot:spring-boot-properties-migrator")

    // dependency-management-plugin によりバージョン番号が自動で設定されないもの、あるいは最新バージョンを指定したいもの
    runtimeOnly("${jdbcDriver}")
    implementation("com.integralblue:log4jdbc-spring-boot-starter:1.0.2")
    implementation("org.simpleframework:simple-xml:2.7.1")
    implementation("com.univocity:univocity-parsers:2.7.6")
    implementation("com.google.guava:guava:27.0.1-jre")
    implementation("org.flywaydb:flyway-core:5.2.4")
    testImplementation("org.dbunit:dbunit:2.6.0")
    testImplementation("com.icegreen:greenmail:1.5.9")
    testImplementation("org.assertj:assertj-core:3.11.1")
    testImplementation("com.jayway.jsonpath:json-path:2.4.0")
    testImplementation("org.jsoup:jsoup:1.11.3")
    testImplementation("cglib:cglib-nodep:3.2.10")
    testImplementation("org.spockframework:spock-core:${spockVersion}")
    testImplementation("org.spockframework:spock-spring:${spockVersion}")
    
    // for lombok
    annotationProcessor("org.projectlombok:lombok:${lombokVersion}")
    compileOnly("org.projectlombok:lombok:${lombokVersion}")
    testCompileOnly("org.projectlombok:lombok:${lombokVersion}")

    // for Doma
    annotationProcessor("org.seasar.doma:doma:${domaVersion}")
    implementation("org.seasar.doma:doma:${domaVersion}")
    domaGenRuntime("org.seasar.doma:doma-gen:${domaVersion}")
    domaGenRuntime("${jdbcDriver}")

    // for Error Prone ( http://errorprone.info/ )
    errorprone("com.google.errorprone:error_prone_core:${errorproneVersion}")
    compileOnly("com.google.errorprone:error_prone_annotations:${errorproneVersion}")

    // PowerMock
    testImplementation("org.powermock:powermock-module-junit4:${powermockVersion}")
    testImplementation("org.powermock:powermock-api-mockito2:${powermockVersion}")

    // for SpotBugs
    compileOnly("com.github.spotbugs:spotbugs:${spotbugsVersion}")
    compileOnly("net.jcip:jcip-annotations:1.0")
    compileOnly("com.github.spotbugs:spotbugs-annotations:${spotbugsVersion}")
    testImplementation("com.google.code.findbugs:jsr305:3.0.2")
}

..........
  • buildscript block 内で version "2.0.6-RELEASE"version "2.0.7-RELEASE" に変更します。
  • plugins block の以下の点を変更します。
    • id "org.springframework.boot" version "2.0.6.RELEASE"id "org.springframework.boot" version "2.0.7.RELEASE"
    • id "com.github.spotbugs" version "1.6.5"id "com.github.spotbugs" version "1.6.8"
  • checkstyle タスクで toolVersion = "8.14"toolVersion = "8.16" に変更します。
  • spotbugs タスクで toolVersion = "3.1.8"toolVersion = "3.1.10" に変更します。
  • pmd タスクで toolVersion = "6.9.0"toolVersion = "6.10.0" に変更します。
  • dependencies block の以下の点を変更します。
    • def domaVersion = "2.19.3"def domaVersion = "2.20.0"
    • def spotbugsVersion = "3.1.8"def spotbugsVersion = "3.1.10"
    • implementation("org.thymeleaf.extras:thymeleaf-extras-springsecurity4")implementation("org.thymeleaf.extras:thymeleaf-extras-springsecurity5")Tomcat 起動時に Auto-configuration for thymeleaf-extras-springsecurity4 is deprecated in favour of thymeleaf-extras-springsecurity5 という WARN ログが出力されていました。
    • implementation("com.google.guava:guava:27.0-jre")implementation("com.google.guava:guava:27.0.1-jre")
    • testImplementation("com.icegreen:greenmail:1.5.8")testImplementation("com.icegreen:greenmail:1.5.9")
    • testImplementation("cglib:cglib-nodep:3.2.9")testImplementation("cglib:cglib-nodep:3.2.10")

Gradle Tool Window の左上にある「Refresh all Gradle projects」ボタンをクリックして更新します。

clean タスク実行 → Rebuild Project 実行 → build タスクを実行してみると、テストが1件失敗しました。

f:id:ksby:20190103204541p:plain

もう少し詳細な情報が欲しいので、Project Tool Window で src/test を選択した後コンテキストメニューを表示して「Run 'All Tests'」を選択します。

テストが失敗したのは「ログインを5回失敗すればアカウントはロックされる」のテストで、ログインの失敗回数が1回だけのはずが2回カウントされているためでした。

f:id:ksby:20190103210042p:plain f:id:ksby:20190103210236p:plain

debug 実行してみるとログイン前は失敗回数が 0 回ですが、

f:id:ksby:20190103211614p:plain f:id:ksby:20190103211811p:plain

1度ログインに失敗しただけなのに失敗回数が2回になっていました。

f:id:ksby:20190103211929p:plain f:id:ksby:20190103212043p:plain

もう少し調査して分かったことは ksbysample.webapp.lending.security.AuthenticationFailureBadCredentialsEventListener#onApplicationEvent が2回呼び出されている(AuthenticationFailureBadCredentialsEvent が2回発生している)ということでした。build.gradle を変更前に戻して試してみると AuthenticationFailureBadCredentialsEventListener#onApplicationEvent は1回しか呼び出されません。

f:id:ksby:20190103232534p:plain

AuthenticationFailureBadCredentialsEvent が2回発生する原因を調査する

2回実行されるのが問題と分かっている ksbysample.webapp.lending.security.AuthenticationFailureBadCredentialsEventListener#onApplicationEvent メソッドの最初の位置に breakpoint を設定してから、「ログインを5回失敗すればアカウントはロックされる」のテストを debug 実行して、breakpoint で止まった後に IntelliJ IDEA の Debug Window でスタックトレースをたどって通過している位置を確認しながら何が起きているのかを調べます。

f:id:ksby:20190105080448p:plain

そうして分かったことは、まず org.springframework.security.authentication.ProviderManager#authenticate が呼び出されますが、この時は for (AuthenticationProvider provider : getProviders()) { ... } では例外が発生せず lastException も null のままです。getProviders() で AnonymousAuthenticationProvider, RememberMeAuthenticationProvider が返ってきますが、この2つは if (!provider.supports(toTest)) { continue; } の処理で continue されます。

f:id:ksby:20190105021223p:plain f:id:ksby:20190105021345p:plain

その下に if (result == null && parent != null) { ... } という if 文がありますが、parent が null ではなく ProviderManager のインスタンスがセットされているので、その中の result = parentResult = parent.authenticate(authentication); が実行されます。

f:id:ksby:20190105022013p:plain f:id:ksby:20190105022133p:plain

org.springframework.security.authentication.ProviderManager#authenticate が呼び出されてますが、今度は for (AuthenticationProvider provider : getProviders()) { ... } で例外が発生して lastException に org.springframework.security.authentication.BadCredentialsExceptionインスタンスがセットされます。getProviders() で DaoAuthenticationProvider が3つ返ってきますが(3つは何かおかしいので後で見直します)、全て result = provider.authenticate(authentication); が実行されて AuthenticationException が発生します。

f:id:ksby:20190105023034p:plain f:id:ksby:20190105023408p:plain f:id:ksby:20190105023520p:plain

その下の if 文3つはどれも条件が一致せず、次の prepareException(lastException, authentication); が実行されます。

f:id:ksby:20190105024310p:plain

org.springframework.security.authentication.ProviderManager#prepareException の中の eventPublisher.publishAuthenticationFailure(ex, auth); が実行されると eventPublisher に DefaultAuthenticationEventPublisher のインスタンスがセットされているので AuthenticationFailureBadCredentialsEvent が発生して ksbysample.webapp.lending.security.AuthenticationFailureBadCredentialsEventListener#onApplicationEvent が呼び出されてログインの失敗回数が +1 されます。

f:id:ksby:20190105024550p:plain f:id:ksby:20190105024701p:plain

その後で org.springframework.security.authentication.ProviderManager#authenticate の最後の throw lastException; が実行されると、最初の org.springframework.security.authentication.ProviderManager#authenticate に戻り、org.springframework.security.authentication.BadCredentialsException の例外が throw されているので catch (AuthenticationException e) { lastException = e; } の処理が実行されて lastException に org.springframework.security.authentication.BadCredentialsExceptionインスタンスがセットされます。

f:id:ksby:20190105030150p:plain

その下の if 文2つはどれも条件が一致せず、次の prepareException(lastException, authentication); が実行されます。

f:id:ksby:20190105030523p:plain

org.springframework.security.authentication.ProviderManager#prepareException の中の eventPublisher.publishAuthenticationFailure(ex, auth); が実行されると先程と同じく eventPublisher に DefaultAuthenticationEventPublisher のインスタンスがセットされているので AuthenticationFailureBadCredentialsEvent が発生して ksbysample.webapp.lending.security.AuthenticationFailureBadCredentialsEventListener#onApplicationEvent が呼び出されてログインの失敗回数が +1 されます。

f:id:ksby:20190105030648p:plain f:id:ksby:20190105030751p:plain

Spring Boot を 2.0.6 → 2.0.7 へ上げる前に戻して何が違うのか確認してみたところ、2回目の org.springframework.security.authentication.ProviderManager#prepareException の中の eventPublisher.publishAuthenticationFailure(ex, auth); が実行された時に eventPublisher にセットされているのが ProviderManager$NullEventPublisher のインスタンスで、この場合 ksbysample.webapp.lending.security.AuthenticationFailureBadCredentialsEventListener#onApplicationEvent が呼び出されていませんでした(ログインの失敗回数も +1 されません)。

f:id:ksby:20190105032050p:plain f:id:ksby:20190105032206p:plain

なぜ eventPublisher にセットされているインスタンスが変わるのか Spring Security の 5.0.9.RELEASE と 5.0.10.RELEASE の差異を追ってみると、org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter#getHttp で authenticationBuilder.authenticationEventPublisher(eventPublisher); という行が追加されていたことが原因でした。

f:id:ksby:20190105033604p:plain

変更が入った commit は Set AuthenticationEventPublisher on each AuthenticationManagerBuilder で、Fixes gh-6009 のリンクをクリックすると AuthenticationSuccessEvent not published for oauth2Login() #6009 の Issue へ飛んで、更に別の AuthenticationFailureBadCredentialsEvent published twice #6281 の Issue が見つかりました。修正されていて次のバージョンに入るようです。

build が通らなくなるので Spring Boot は 2.0.6 に戻します。Gradle やライブラリ関連はバージョンアップしたままにします。

build.gradle を変更する2

build.gradle の以下の点を変更します。

buildscript {
    ext {
        group "ksbysample"
        version "2.0.6-RELEASE"
    }
    repositories {
        mavenCentral()
        maven { url "https://repo.spring.io/release/" }
        maven { url "https://plugins.gradle.org/m2/" }
    }
}

plugins {
    id "java"
    id "eclipse"
    id "idea"
    id "org.springframework.boot" version "2.0.6.RELEASE"
    id "io.spring.dependency-management" version "1.0.6.RELEASE"
    id "groovy"
    id "checkstyle"
    id "com.github.spotbugs" version "1.6.8"
    id "pmd"
    id "net.ltgt.errorprone" version "0.0.16"
    id "de.undercouch.download" version "3.4.3"
    id "com.gorylenko.gradle-git-properties" version "2.0.0-beta1"
}

..........
  • buildscript block 内で version "2.0.7-RELEASE"version "2.0.6-RELEASE" に戻します。
  • plugins block の以下の点を変更します。
    • id "org.springframework.boot" version "2.0.7.RELEASE"id "org.springframework.boot" version "2.0.6.RELEASE"

Gradle Tool Window の左上にある「Refresh all Gradle projects」ボタンをクリックして更新します。

clean タスク実行 → Rebuild Project 実行 → build タスクを実行すると "BUILD SUCCESSFUL" のメッセージが出力されました。

f:id:ksby:20190105041926p:plain

最後に

2.0 系の 2.0.7 とかなり後のバージョンなのでバージョンアップも簡単にできるはずと考えていましたが、そんなことはありませんでした! そうか、こんなこともあるんですね。。。

履歴

2019/01/05
初版発行。