お仕事でSpringBootとFreeMarker使う事になったので、簡単なログインページを実装してみた。
また、フロントエンドにVueを使う事になりそうな感じなので、ついでにWebpackとGradle連携させて、Vueを読み込むところまでやってみた。
SpringBootのKotlinはまだ日本語のドキュメントが少ないので、ソースコード載せつつ要点かいつまんで説明していく。
ソースコード
https://github.com/renoinn/springboot-freemarker-vue-sample
dockerとかでDB用意すればそのまま動くはず。
build.gradle
buildscript {
    ext {
        kotlinVersion = '1.2.51'
        springBootVersion = '2.0.5.RELEASE'
    }
    repositories {
        mavenCentral()
        maven {
            url "https://plugins.gradle.org/m2/"
        }
    }
    dependencies {
        classpath("org.springframework.boot:spring-boot-gradle-plugin:${springBootVersion}")
        classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:${kotlinVersion}")
        classpath("org.jetbrains.kotlin:kotlin-allopen:${kotlinVersion}")
        classpath("org.jetbrains.kotlin:kotlin-noarg:${kotlinVersion}")
        classpath "com.moowork.gradle:gradle-node-plugin:1.2.0"
    }
}
apply plugin: 'kotlin'
apply plugin: 'kotlin-spring'
apply plugin: 'kotlin-allopen'
apply plugin: 'kotlin-jpa'
apply plugin: 'eclipse'
apply plugin: 'org.springframework.boot'
apply plugin: 'io.spring.dependency-management'
apply plugin: 'com.moowork.node'
group = 'com.sample'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = 1.8
compileKotlin {
    kotlinOptions {
        freeCompilerArgs = ["-Xjsr305=strict"]
        jvmTarget = "1.8"
    }
}
compileTestKotlin {
    kotlinOptions {
        freeCompilerArgs = ["-Xjsr305=strict"]
        jvmTarget = "1.8"
    }
}
repositories {
    mavenCentral()
}
dependencies {
    // spring
    compile('org.springframework.boot:spring-boot-starter-actuator')
    compile('org.springframework.boot:spring-boot-starter-data-jpa')
    compile('org.springframework.boot:spring-boot-starter-freemarker')
    compile('org.springframework.boot:spring-boot-starter-security')
    compile('org.springframework.boot:spring-boot-starter-web')
    compile('org.springframework.session:spring-session-core')
    runtime('org.springframework.boot:spring-boot-devtools')
    compileOnly "org.springframework.boot:spring-boot-configuration-processor"
    // etc
    compile("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
    compile("org.jetbrains.kotlin:kotlin-reflect")
    compile('com.fasterxml.jackson.module:jackson-module-kotlin')
    compile('no.api.freemarker:freemarker-java8:1.2.0')
    // mysql
    runtime('mysql:mysql-connector-java')
    // test
    testCompile('org.springframework.boot:spring-boot-starter-test')
    testCompile('org.springframework.security:spring-security-test')
}
node {
    version = '9.11.2'
    npmVersion = '6.4.1'
    download = true
}
// Webpackでのビルドを実行するタスク
task webpack(type: NodeTask, dependsOn: 'npmInstall') {
    def osName = System.getProperty("os.name").toLowerCase()
    if (osName.contains("windows")) {
        script = project.file('node_modules/webpack/bin/webpack.js')
    } else {
        script = project.file('node_modules/.bin/webpack')
    }
}
// processResourcesタスクの前に上述のwebpackタスクを実行する
processResources.dependsOn 'webpack'
clean.delete << file('node_modules')
まずはbuild.gradle。kotlin-allopenとkotlin-noarg、kotlin-jpaはkotlinでSpringBoot開発する上で、少し面倒な部分を楽にしてくれる。
あと、webpackと連携させたいのでgradle-node-pluginも入れておく。
WebSecurityConfig.kt
@Configuration
@EnableWebSecurity
class WebSecurityConfig : WebSecurityConfigurerAdapter() {
    @Override
    @Throws(Exception::class)
    override fun configure(web: WebSecurity) {
        // これらにマッチするURLへのアクセスは無視する
        web.ignoring().antMatchers(
                "/images/**",
                "/css/**",
                "/js/**",
                "/webjars/**")
    }
    @Throws(Exception::class)
    override fun configure(http: HttpSecurity) {
        http.csrf().disable()
        http.authorizeRequests()
                .antMatchers("/").permitAll()
                .anyRequest().authenticated()
                .and()
                .formLogin() // フォームでのログインをする宣言
                .loginProcessingUrl("/login") // ログイン処理をするエンドポイント。formのactionでここを指定する
                .loginPage("/") // ログインフォームのURL
                .defaultSuccessUrl("/home") // ログイン成功後のURL
        http.logout()
                .logoutRequestMatcher(AntPathRequestMatcher("/logout**")) // マッチするリクエストがきたらログアウトする
                .logoutSuccessUrl("/") // ログアウト後のURL
    }
    @Configuration
    class AuthenticationConfiguration : GlobalAuthenticationConfigurerAdapter() {
        @Autowired
        var userDetailsService: UserDetailsServiceImpl = UserDetailsServiceImpl()
        @Throws(Exception::class)
        override fun init(auth: AuthenticationManagerBuilder) {
            auth.userDetailsService<UserDetailsService>(userDetailsService)
                    .passwordEncoder(BCryptPasswordEncoder())
        }
    }
}
次にSpringSecurity用のConfig。WebSecurityConfigurerAdapterのconfigureをオーバーライドして設定を追加していく。
あと、GlobalAuthenticationConfigurerAdapterのinitをオーバーライドしている部分。AuthenticationManagerBuilderにPasswordEncoderを渡してやらないとSpringSecurityが有効にならないので注意。仮にパスワードを平文で保存する場合でもNoOpPasswordEncoderを渡してやらないといけなかったりする。パスワード平文で保存とかほぼやらないと思うけど。
あとSpringSecurityを使う上で、UserDetailsを継承したエンティティクラスとUserDetailsServiceの実装クラスを用意してやる必要がある。
User.kt
UserDetailsServiceImpl.kt
@Service
class UserDetailsServiceImpl : UserDetailsService {
    @Autowired
    lateinit var userRepository: UserRepository
    override fun loadUserByUsername(username: String): UserDetails {
        val user = findByUsername(username)
        if (!user.isPresent)
            throw UsernameNotFoundException("Username $username is not found")
        if (!user.get().isAccountNonLocked)
            throw UsernameNotFoundException("Username $username is locked")
        return user.get().copy(authorities = getAuthorities())
    }
    private fun getAuthorities() = AuthorityUtils.createAuthorityList("ROLE_USER")
    fun findByUsername(username: String): Optional<User> {
        val user = userRepository.findByUsername(username)
        return user
    }
}
UserDetailsエンティティはリンク先を見てもらうとして、UserDetailsServiceImplの方はloadUserByUsernameをオーバーライドしてUserDetailsを返してやらなければならない。この時、返却するUserDetailsのauthoritiesの中身の値はROLE_のプレフィックスで始まる文字列じゃないといけないので注意。ROLE_USERとかROLE_ADMINとか。
webpack.config.js
const webpack = require("webpack");
const ExtractTextPlugin = require("extract-text-webpack-plugin");
module.exports = {
  entry: './src/main/js/app.js',
  output: {
    filename: 'js/bundle.js',
    path: __dirname + '/build/resources/main/static/'
  },
  module: {
    rules: [
      {
        test: /\.vue$/,
        loader: 'vue-loader',
        options: {
          loaders: {
            css: ExtractTextPlugin.extract({
              use: 'css-loader',
              fallback: 'vue-style-loader'
            })
          }
        }
      },
      {
        test: /\.js$/,
        use: 'babel-loader',
        exclude: /node_modules/
      },
      {
        test: /\.css$/,
        use: ExtractTextPlugin.extract({
          fallback: "style-loader",
          use: "css-loader"
        })
      }
    ]
  },
  resolve: {
    alias: {
      'vue$': 'vue/dist/vue.esm.js'
    }
  },
  plugins: [
    new ExtractTextPlugin("css/styles.css"),
  ]
}
;
webpackは特に複雑な事はしてないはず。/build/resources/main/static/jsにファイルを置くとhttps://hoge.com/jsでアクセスできるようになるので、outputはそこに設定している。
まだまだ勉強中なので、こうした方が良いみたいなコメントとか、プルリクとか大歓迎です。