
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の最新版をインストールします。
Kotlin Pluginのインストール
Help -> Eclipse MarketplaceからKotlinで検索し、Kotlin Plugin for Eclipseの最新版をインストールします。
Gradle IDE Packのインストール
Help -> Eclipse MarketplaceからGradleで検索し、Gradle IDE Packの最新版をインストールします。
Spring Starterプロジェクトの作成
Package Exprolerから、
右クリック -> New -> Other
を開き、「Spring Boot」の下にある「Spring Starter Project」を選択し、下記の画面を開きます。
Name:demo
Type:Gradle(STS)
Packaging:Jar
Java Version:1.8
Language:Kotlin
を選択し、その他の項目はデフォルトのまま「Next」を押下します。
Avalilableで下記を選択し、Finishを押下します。
・Web -> Web
・Core -> Security
・Template Engines -> Thymeleaf
・SQL -> JPA
・SQL -> MySQL
demoプロジェクトが作成されます。
Kotlin Runtimeの追加
demoプロジェクトを右クリックし、Configure Kotlin -> Add Kotlin Natureを押下します。
Kotlin Runtimeが追加され、Eclipse上でもKotlinのクラスが認識されるようになります。
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で実行します。
接続
ブラウザから下記にアクセスします。
ログインページが表示されます。
何も指定していない場合はこのSpringで用意されているデフォルトのログインページが表示されます。
User、Passwordは前述のテストデータで登録したemail、passwordの情報を入力し、「Login」ボタンを押下します。
ログイン成功し、先程作成した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)を設定しています。
再び起動
再度アプリケーションを起動し、
にアクセスすると、下記の状態になります。
未ログイン時だと画面は真っ白になりますが、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を使えば、デフォルトの機能は用意されていて、かつカスタマイズしたい部分は基本的には枠組みとして用意されているインタフェースを実装するだけで実現できるので、特殊な要件でない限りは簡単に実装できて良かったと思います。
この記事へのコメントはありません。