Kotlin × Spring Securityを使用したRESTfulな認証機構の実装

アプリボットでは現在開発中のサービスで、サーバサイドの言語としてKotlinでSpringを使用しているプロジェクトがあります。
その中で今回は、認証機構の実装でSpring Securityを使用したので、導入方法をご紹介したいと思います。

Eclipseの環境設定

今回使用する環境

  • Kotlin 1.1.4
  • Spring Boot 1.5.7
  • MySQL 5.6.26
  • Redis 3.0.3
  • Gradle 4.2

STSのインストール

Help -> Eclipse MarketplaceからSTSで検索し、SpringSource Tool Suiteの最新版をインストールします。
スクリーンショット 2017-10-27 22.33.17.png

Kotlin Pluginのインストール

Help -> Eclipse MarketplaceからKotlinで検索し、Kotlin Plugin for Eclipseの最新版をインストールします。
スクリーンショット 2017-10-27 22.34.22.png

Gradle IDE Packのインストール

Help -> Eclipse MarketplaceからGradleで検索し、Gradle IDE Packの最新版をインストールします。
スクリーンショット 2017-10-27 22.36.08.png

Spring Starterプロジェクトの作成

スクリーンショット 2017-10-25 21.02.58.png

Package Exprolerから、
右クリック -> New -> Other

を開き、「Spring Boot」の下にある「Spring Starter Project」を選択し、下記の画面を開きます。
スクリーンショット 2017-10-25 21.03.27.png

Name:demo
Type:Gradle(STS)
Packaging:Jar
Java Version:1.8
Language:Kotlin

を選択し、その他の項目はデフォルトのまま「Next」を押下します。

スクリーンショット 2017-10-25 21.44.52.png

Avalilableで下記を選択し、Finishを押下します。

・Web -> Web
・Core -> Security
・Template Engines -> Thymeleaf
・SQL -> JPA
・SQL -> MySQL

スクリーンショット 2017-10-25 21.31.01.png

demoプロジェクトが作成されます。

Kotlin Runtimeの追加

demoプロジェクトを右クリックし、Configure Kotlin -> Add Kotlin Natureを押下します。

スクリーンショット 2017-10-25 21.31.53.png

Kotlin Runtimeが追加され、Eclipse上でもKotlinのクラスが認識されるようになります。

スクショ9.png

build.gradleの説明

buildscript {
    ext {
        kotlinVersion = '1.1.51'
        springBootVersion = '1.5.8.RELEASE'
    }
    repositories {
        mavenCentral()
    }
    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}")
    }
}

apply plugin: 'kotlin'
apply plugin: 'kotlin-spring'
apply plugin: 'eclipse'
apply plugin: 'org.springframework.boot'

group = 'com.example'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = 1.8
compileKotlin {
    kotlinOptions.jvmTarget = "1.8"
}
compileTestKotlin {
    kotlinOptions.jvmTarget = "1.8"
}

repositories {
    mavenCentral()
}


dependencies {
    compile('org.springframework.boot:spring-boot-starter-data-jpa') /* Spring Data Jpa */
    compile('org.springframework.boot:spring-boot-starter-security') /* Spring Security */
    compile('org.springframework.boot:spring-boot-starter-thymeleaf') /* Thymeleaf */
    compile('org.springframework.boot:spring-boot-starter-web') /* Spring Boot */
    compile("org.jetbrains.kotlin:kotlin-stdlib-jre8:${kotlinVersion}")
    compile("org.jetbrains.kotlin:kotlin-reflect:${kotlinVersion}")
    runtime('mysql:mysql-connector-java') /* MySQL Connector/J */
    testCompile('org.springframework.boot:spring-boot-starter-test')
    testCompile('org.springframework.security:spring-security-test')
}

上記のコメントにあるそれぞれの行が、

・Spring Boot
・Spring Data JPA
・Thymeleaf
・Spring Security
・MySQL Connector/J

のライブラリの依存を追加しています。

データベース関連

テーブル設計の設計、作成、テストデータの登録

下記のテーブルを作成します。

CREATE TABLE i_user (
  id int(11) NOT NULL AUTO_INCREMENT,
  email varchar(255) NOT NULL,
  password varchar(255) NOT NULL,
  role_type varchar(32) NOT NULL,
  PRIMARY KEY (id)
);

テストデータとして、下記を登録します。

INSERT INTO i_user (id, email, password, role_type)
VALUES
    (1, 'admin', 'admin', 'ADMIN'),
    (2, 'techbot', 'techbot', 'USER');

ユーザの情報を管理するテーブルになります。
ログイン時はこちらの中のデータから情報を取得して認証します。

Entity、Repositoryクラスの作成

UserDetailsを実装し、下記のEntityクラスを作成します。

passwordのプロパティ名を「pass」としているのは、Kotlinのデータクラスでgetterが自動生成され、getPasswordメソッドがUserDetailsインタフェースのメソッドと衝突してしまうためです。

package com.example.demo.entity

import org.springframework.security.core.GrantedAuthority
import org.springframework.security.core.authority.AuthorityUtils
import org.springframework.security.core.userdetails.UserDetails
import javax.persistence.Column
import javax.persistence.Entity
import javax.persistence.Id
import javax.persistence.Table

@Entity
@Table(name = "i_user")
data class IUser(
        @Id
        var id: Int = 0,

        @Column(name = "email")
        var email: String = "",

        @Column(name = "password")
        var pass: String = "",

        @Column(name = "role_type")
        var roleType: String = ""
) : UserDetails {

    override fun getAuthorities(): MutableCollection<out GrantedAuthority>? {
        return AuthorityUtils.createAuthorityList(this.roleType)
    }

    override fun isEnabled(): Boolean {
        return true
    }

    override fun getUsername(): String? {
        return this.email
    }

    override fun isCredentialsNonExpired(): Boolean {
        return true
    }

    override fun getPassword(): String? {
        return this.pass
    }

    override fun isAccountNonExpired(): Boolean {
        return true
    }

    override fun isAccountNonLocked(): Boolean {
        return true
    }
}

このUserDetailsインタフェースを実装したクラスが、ユーザ認証の処理で使用されます。
各メソッドをオーバーライドすることで色々な設定をしていますが、ここでポイントなのはgetUsername()とgetPassword()の2つです。
ログイン認証時に、後述するログインフォームで入力されたユーザ名とパスワードが、これらのメソッドで返却される値との比較が行われます。

この場合では、ユーザ名はi_userのemailの値と比較され、パスワードはpasswordの値と比較されます。

次にJPA Repositoryクラスを作成します。

package com.example.demo.repository

import com.example.demo.entity.IUser
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository

@Repository
interface IUserRepository : JpaRepository<IUser, Long> {
    fun findByEmail(email: String): IUser?
}

こちらはデータベースへの問い合わせをするクラスで、i_userテーブルに対応しています。
emailで検索するメソッドのみ追加しています。

src/main/resources配下のapplication.propertiesをapplication.ymlにリネームし、下記の設定を記述します。

spring:
  datasource:
    url:  jdbc:mysql://127.0.0.1:3306/example
    username: root
    password:
    driverClassName: com.mysql.jdbc.Driver

今回はアプリボットで使用している形式に合わせてYAMLで記述しましたが、デフォルトで用意されているapplication.propertiesにプロパティ形式で記述しても問題ありません。

設定関連のクラス実装

UserDetailsServiceの実装

UserDetailsServiceを実装し、下記のクラスを作成します。

package com.example.demo.service.user

import com.example.demo.entity.IUser
import com.example.demo.repository.IUserRepository
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.security.core.userdetails.UserDetails
import org.springframework.security.core.userdetails.UserDetailsService
import org.springframework.security.core.userdetails.UsernameNotFoundException

open class MyUserDetailsService @Autowired constructor(private val userRepository: IUserRepository) : UserDetailsService {

    @Throws(UsernameNotFoundException::class)
    override fun loadUserByUsername(username: String): UserDetails? {
        var iUser: IUser? = userRepository.findByEmail(username)

        return iUser
    }

}

こちらはログイン認証時にユーザ情報を取得するメソッドになります。
UserDetailsServiceを実装し、loadUserByUsernameをオーバーライドした中で書かれている処理で返却した値がログイン認証時の比較に使用されます。

WebSecurityConfigurerAdapterの実装

WebSecurityConfigurerAdapterを実装し、下記のクラスを作成します。

package com.example.demo.config

import com.example.demo.repository.IUserRepository
import com.example.demo.service.user.MyUserDetailsService
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.context.annotation.Bean
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder
import org.springframework.security.crypto.password.PasswordEncoder

@EnableWebSecurity
class SecurityConfig @Autowired constructor(private val userRepository: IUserRepository) : WebSecurityConfigurerAdapter() {

    override fun configure(http: HttpSecurity) {
        http.authorizeRequests()
                .mvcMatchers("/login").permitAll() // ①
                .anyRequest().authenticated() // ②
                .and()
                .formLogin() // ③
    }

    override fun configure(auth: AuthenticationManagerBuilder) {
        auth
                .userDetailsService(MyUserDetailsService(userRepository)) // ④
    }
}

こちらが大きなポイントとなるクラスです。
認証に関する設定が書かれているので、1つずつ説明していきます。

①ログインAPIの権限解放
Spring Securityではデフォルトで/loginのパスでログイン処理のAPIが用意されています。
ログインAPIは認証前でもアクセスできる必要があるので、permitAll()で誰でもアクセス可能にします。
mvcMatchersは可変長引数のため、複数のパスを指定することも可能です。

②認証の必要なAPIの設定
①で設定した以外のパスにauthenticatedで認証を設定します。

③フォームログインの有効化
フォームログインを有効化します。
Spring Securityは、デフォルトではベーシック認証が有効になっています。

④ユーザ検索用クラスの設定
先ほど作ったMyUserDetailsServiceをuserDetailsServiceに設定します。
ここで設定したクラスのloadUserByUsernameメソッドが、認証時のユーザ情報取得に使用されます。

テスト用のController、HTMLの作成

テスト用のページとして下記のController、HTMLを作成します。

package com.example.demo.controller

import org.springframework.stereotype.Controller
import org.springframework.web.bind.annotation.RequestMapping

@Controller
class SampleController {

    @RequestMapping("/")
    fun index(): String {
        return "index"
    }
}

HTMLはsrc/main/resorcesのtemplates配下に作成します。

<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>Hello Kotlin!</title>
</head>
<body>
Hello Kotlin!
</body>
</html>

起動

DemoApplication.ktを右クリックし、Run As -> Kotlin Applicationで実行します。

接続

ブラウザから下記にアクセスします。
http://localhost:8080

スクリーンショット 2017-11-07 13.44.25.png

ログインページが表示されます。
何も指定していない場合はこのSpringで用意されているデフォルトのログインページが表示されます。

User、Passwordは前述のテストデータで登録したemail、passwordの情報を入力し、「Login」ボタンを押下します。

スクリーンショット 2017-11-07 13.44.50.png

ログイン成功し、先程作成したindexページが表示されます。
ここでのログイン処理も、何も指定していない場合はSpringで用意されているデフォルトのログイン処理が使われます。
セッション情報はスレッドローカルに保存され、クライアント側ではCookieにセッションIDが保存されます。

なのでサーバ側で画面遷移し、1台のサーバで運用しているシステムであれば(ログイン画面も適当で良ければ)これで認証機構が作れます。

RESTful APIでの実装

最近のWebアプリケーションでは、サーバ側はRESTfulなAPIだけを用意し、クライアント側で画面遷移するものも多いです。
前述の方法ではサーバ側でリダイレクトしてログイン画面を返却する形になってしまうので、認証が通らなかった場合はリダイレクトせずHTTPステータスだけを返却する方法を説明します。

AuthenticationEntryPoint、AccessDeniedHandlerの実装

認証でエラーになった場合の処理を下記のインタフェースを実装することでで実現します。

AuthenticationEntryPoint:未認証のユーザーからのアクセスを拒否した際のエラー応答を行うためのインタフェース
AccessDeniedHandler:認証済みのユーザーからのアクセスを拒否した際のエラー応答を行うためのインタフェース

つまり、ログインしていないユーザがアクセスした場合の処理を書くのがAuthenticationEntryPoint、ログインしているユーザがアクセス権限のないURLにアクセスした場合の処理を書くのがAccessDeniedHandlerです。

それぞれ下記のように実装します。

package com.example.demo.handler

import org.springframework.security.core.AuthenticationException
import org.springframework.security.web.AuthenticationEntryPoint
import javax.servlet.http.HttpServletRequest
import javax.servlet.http.HttpServletResponse

class MyAuthenticationEntryPoint : AuthenticationEntryPoint {
    override fun commence(request: HttpServletRequest, response: HttpServletResponse, authException: AuthenticationException) {
        // HTTPステータスのみ設定する
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED)
    }
}
package com.example.demo.handler

import org.springframework.security.access.AccessDeniedException
import org.springframework.security.web.access.AccessDeniedHandler
import javax.servlet.http.HttpServletRequest
import javax.servlet.http.HttpServletResponse

class MyAccessDeniedHandler : AccessDeniedHandler {
    override fun handle(request: HttpServletRequest, response: HttpServletResponse, accessDeniedException: AccessDeniedException) {
        // HTTPステータスのみ設定する
        response.setStatus(HttpServletResponse.SC_FORBIDDEN)
    }
}

それぞれレスポンスの中のステータスを書き換えているだけで、未ログイン時はUNAUTHORIZED(401)、ログイン時はFORBIDDEN(403)を設定しています。

再び起動

再度アプリケーションを起動し、
http://localhost:8080
にアクセスすると、下記の状態になります。

スクリーンショット 2017-11-07 13.45.53.png

未ログイン時だと画面は真っ白になりますが、Chrome Developer Toolsで確認すると401を返却していることが分かります。

CORSの設定

クロスドメインでのAjax通信

ここまでの設定で一通りの認証機構は実装できましたが、クロスドメインでAjax通信された際に、エラーになってしまいます。
CORSのを有効にして、アクセスできるようにします。

まず、SecurityConfig.ktに下記のメソッドを追加します。

private fun corsConfigurationSource(): CorsConfigurationSource {
    val corsConfiguration = CorsConfiguration()
    corsConfiguration.addAllowedMethod("GET") // ①
    corsConfiguration.addAllowedHeader(CorsConfiguration.ALL) // ②
    corsConfiguration.addAllowedOrigin("http://sample.applibot.com") // ③
    corsConfiguration.setAllowCredentials(true)

    val corsConfigurationSource: UrlBasedCorsConfigurationSource = UrlBasedCorsConfigurationSource()
    corsConfigurationSource.registerCorsConfiguration("/**", corsConfiguration)

    return corsConfigurationSource
}

CORSの設定にはCorsConfiguration、CorsConfigurationSourceというクラスを使用します。
上記のコードでは、

①GETメソッドを許可
②Access-Control-Request-Headersで全ての値を許可
http://sample.applibot.com からのアクセスを許可

という設定をしています。
それぞれ複数の値を設定したい場合はaddAllowed〜メソッドを複数回呼んで設定すれば可能です。
また、どのメソッドもCorsConfiguration.ALLを設定すれば全ての値を許可することができますが、本番で使用する場合は適切な設定を行って下さい。

次に、SecurityConfigクラスのconfigureメソッドを下記のように設定変更します。

override fun configure(http: HttpSecurity) {
    http.authorizeRequests()
            .mvcMatchers("/login").permitAll()
            .anyRequest().authenticated()
            .and()
            .csrf().disable()
            .formLogin()
            .successHandler(MyAuthenticationSuccessHandler())
            .and()
            .exceptionHandling()
            .authenticationEntryPoint(MyAuthenticationEntryPoint())
            .accessDeniedHandler(MyAccessDeniedHandler())
            // CORS関連の設定
            .and()
            .cors()
            .configurationSource(corsConfigurationSource())
}

corsConfigurationSource()で返却されたCorsConfigurationSourceを設定しています。
これで許可した内容に応じたリクエストは全て受けられるようになります。

セッションの管理方法

前述した通り、サーバ側のセッション情報はデフォルトだとスレッドローカルに保存されます。
そのため、

  • 再起動すると消える
  • サーバをスケールした際、複数サーバで認証情報を共有できない

といった問題が発生するため、あまり実用的ではありません。

Redisでの管理に変更

上記の問題を解決するため、セッション情報をRedisに持たせるように変更します。

build.gradleの変更

まず、build.gradleのdependenciesにに下記の2行を追加します。

compile('org.springframework.session:spring-session')
compile('org.springframework.boot:spring-boot-starter-data-redis')

それぞれSpring Session(セッション管理のライブラリ)、Spring Data Redis(Redisに接続するためのライブラリ)への依存を追加している。

設定クラスの作成

セッションの保存方法を変更するため、下記のクラスを作成します。
(クラス名は任意)

package com.example.demo.config

import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.data.redis.connection.jedis.JedisConnectionFactory
import org.springframework.session.data.redis.config.annotation.web.http.EnableRedisHttpSession


@Configuration
@EnableRedisHttpSession
class HttpSessionConfig {

    @Bean
    fun connectionFactory() : JedisConnectionFactory {
        return JedisConnectionFactory()
    }
}

これで終わりです。
起動してログイン、再起動をしても認証は求められなくなります。

デフォルト設定だとlocalhost:6379で接続されますが、変更したい場合はconnectionFactoryメソッドの中で下記のように設定できます。

@Configuration
@EnableRedisHttpSession
class HttpSessionConfig {

    @Bean
    fun connectionFactory() : JedisConnectionFactory {
        val jedisConnectionFactory = JedisConnectionFactory()
        jedisConnectionFactory.setPort(6379);
        jedisConnectionFactory.setHostName("localhost");

        return jedisConnectionFactory
    }
}

セッションで保持している認証情報の使用方法

認証情報にアクセスするには、SecurityContextHolderというクラスのメソッドを使用して、下記のようにアクセスします。

@Controller
class SampleController {

    @RequestMapping("/")
    fun index(): String {
        val iUser: IUser = SecurityContextHolder.getContext().getAuthentication().getPrincipal() as IUser
        println(iUser.id)

        return "index"
    }
}

Authenticationの中に認証情報が保存されており、getPrincipal()でユーザ情報(UserDetailsを継承して作ったクラスの情報)を取得できます。

その他設定できる処理

WebSecurityConfigurerAdapterのconfigureメソッドで前述のHandlerも含め、認証関連の様々な設定ができます。
よく使うものとしては

  • loginProcessingUrl:認証処理のパス
  • loginPage:ログインフォームのパス
  • failureUrl:ログイン処理失敗時に遷移するパス
  • defaultSuccessUrl:ログイン成功時のに遷移するパス

等があります。
下記のように書きます。

override fun configure(http: HttpSecurity) {
    http.authorizeRequests()
            .mvcMatchers("/login").permitAll()
            .anyRequest().authenticated()
            .and()
            .formLogin()
            .loginProcessingUrl("/login") // 認証処理のパス
            .loginPage("/login/top") // ログインフォームのパス
            .failureUrl("/login/error") // ログイン処理失敗時に遷移するパス
            .defaultSuccessUrl("/") // ログイン成功時のに遷移するパス
            .and()
            .exceptionHandling()
            .authenticationEntryPoint(MyAuthenticationEntryPoint())
            .accessDeniedHandler(MyAccessDeniedHandler())
}

他にも色々あり、formLoginの設定で大体のやりたいことは実現できそうでした。

最後に

ユーザ認証はどんなサービスでも必ずと言っていい機能ですが、基本的な要件はほぼ同じです。
Spring Securityを使えば、デフォルトの機能は用意されていて、かつカスタマイズしたい部分は基本的には枠組みとして用意されているインタフェースを実装するだけで実現できるので、特殊な要件でない限りは簡単に実装できて良かったと思います。