Kotlin
freemarker
vue.js
spring-boot

SpringBoot(Kotlin)とFreemarkerでログインするサンプル作った

お仕事で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-allopenkotlin-noargkotlin-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。WebSecurityConfigurerAdapterconfigureをオーバーライドして設定を追加していく。
あと、GlobalAuthenticationConfigurerAdapterinitをオーバーライドしている部分。AuthenticationManagerBuilderPasswordEncoderを渡してやらないとSpringSecurityが有効にならないので注意。仮にパスワードを平文で保存する場合でもNoOpPasswordEncoderを渡してやらないといけなかったりする。パスワード平文で保存とかほぼやらないと思うけど。

あとSpringSecurityを使う上で、UserDetailsを継承したエンティティクラスとUserDetailsServiceの実装クラスを用意してやる必要がある。

 User.kt

https://github.com/renoinn/springboot-freemarker-vue-sample/blob/master/src/main/kotlin/com/sample/app/domain/entity/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を返してやらなければならない。この時、返却するUserDetailsauthoritiesの中身の値は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はそこに設定している。

まだまだ勉強中なので、こうした方が良いみたいなコメントとか、プルリクとか大歓迎です。

 参考URL