謎言語使いの徒然

適当に気になった技術や言語を流すブログ。

SPA アプリそろそろ作って見たいんじゃい(3)

とりあえず SpringBootSecurity を設定してみた回

まずは build.gradle に依存を追加する。

dependencirs{
    compile('org.springframework.boot:spring-boot-starter-security')
    // あとはおすきに

そしてログインに使用する DB からデータを取得するためのインターフェースを追加する。
基本的に DB にアカウントとパスワードを格納しているので、それを取得する。

interface AccountRepository : JpaRepository<Account, Long> {
    fun findOneByName(name: String): Optional<Account>
}

次に、Spring がログインの際に使用するユーザ情報のデータ定義を作る。

package net.white_azalea.todo_demo.service.secure

import net.white_azalea.todo_demo.repositories.Account
import org.springframework.security.core.authority.AuthorityUtils
import org.springframework.security.core.userdetails.User

class LoginAccount(account: Account) : User(account.name, account.password, AuthorityUtils.createAuthorityList("LOGIN_ROLE")) {

}

中身はあってもなくてもいい。
要するにアカウント ID と、ハッシュ化されたパスワードがあり、その時の権限を用意する。

次は Spring がアカウント ID を受け付けたときにログイン情報を検索するロジックの実装。

package net.white_azalea.todo_demo.service.secure

import net.white_azalea.todo_demo.repositories.AccountRepository
import org.springframework.security.core.userdetails.UserDetails
import org.springframework.security.core.userdetails.UserDetailsService
import org.springframework.security.core.userdetails.UsernameNotFoundException
import org.springframework.stereotype.Component

@Component
class AccountDetailService(
        val accountRepository: AccountRepository
) : UserDetailsService {
    override fun loadUserByUsername(username: String?): UserDetails {
        val acc = accountRepository.findOneByName(username.orEmpty())

        if (acc.isPresent) {
            return LoginAccount(acc.get())
        } else {
            throw UsernameNotFoundException("Account does not exists.")
        }
    }
}

パスワードのエンクリプトと、チェックを行うためのクラスも作成。

package net.white_azalea.todo_demo.service.secure

import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.stereotype.Component
import java.util.*
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec

@Component
class Crypto : PasswordEncoder {

    override fun encode(rawPassword: CharSequence?): String {
        if (rawPassword == null) {
            return ""
        }
        return this.sign("SecretKey", rawPassword.toString())
    }

    /**
     * Check password matching.
     */
    override fun matches(rawPassword: CharSequence?, encodedPassword: String?): Boolean {
        if (rawPassword == null || encodedPassword == null) {
            return false
        }
        return this.sign("SecretKey", rawPassword.toString()).equals(encodedPassword)
    }

    /**
     * Sign with key and value.
     *
     * Not good to use password hash.
     * TODO: Have to use bcrypt to password.
     * https://docs.spring.io/spring-security/site/docs/current/apidocs/org/springframework/security/crypto/bcrypt/BCrypt.html
     */
    fun sign(key: String, value: String): String {
        val secretKey = SecretKeySpec(key.toByteArray(), "HmacSHA1")
        val mac = Mac.getInstance("HmacSHA1")
        mac.init(secretKey)
        val bytes = mac.doFinal(value.toByteArray())
        return Base64.getEncoder().encodeToString(bytes)
    }
}

そしてこれらを使ってログインをするための設定クラス

package net.white_azalea.todo_demo.service.secure

import org.springframework.context.annotation.Configuration
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder
import org.springframework.security.config.annotation.authentication.configurers.GlobalAuthenticationConfigurerAdapter
import org.springframework.security.core.userdetails.UserDetailsService

@Configuration
class AuthenticationConfiguration(
        val userDetailsService: UserDetailsService,
        val crypto: Crypto
) : GlobalAuthenticationConfigurerAdapter() {

    override fun init(auth: AuthenticationManagerBuilder?) {
        auth?.userDetailsService(userDetailsService)
                ?.passwordEncoder(crypto)
    }
}

最後に設定これがあれば /loginaccount, password を POST すれば認証処理が走る。

package net.white_azalea.todo_demo

import org.springframework.context.annotation.Configuration
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.config.annotation.web.builders.WebSecurity
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter
import org.springframework.security.web.authentication.AuthenticationFailureHandler
import org.springframework.security.web.util.matcher.AntPathRequestMatcher

@Configuration
@EnableWebSecurity
class SecurityConfig : WebSecurityConfigurerAdapter(false) {

    override fun configure(web: WebSecurity?) {
        web?.ignoring()?.antMatchers(
            "/images/**",
            "/css/**",
            "/javascript/**",
            "/webjars/**")
    }

    fun authFailure(): AuthenticationFailureHandler {
        return AuthenticationFailureHandler { request, response, exception ->
            response.status = 200
            response.sendRedirect("/?error=Failed to access.")
        }
    }

    override fun configure(http: HttpSecurity?) {

        // 認可設定
        http?.authorizeRequests()
                ?.antMatchers("/", "/login")?.permitAll() // index は全ユーザアクセス許可
                ?.anyRequest()?.authenticated() // それ以外は認証要求

        // ログイン認定
        http?.formLogin()
                ?.loginProcessingUrl("/login") // login url
                ?.loginPage("/") // ログインフォームのパス
                ?.failureHandler(authFailure()) // ログイン失敗時のハンドラ
                ?.defaultSuccessUrl("/menu") // ログイン結果
                ?.usernameParameter("account") // ユーザ名パラメータ
                ?.passwordParameter("password")  // パスワードのパラメータ名
                ?.and()

        // ログアウト設定
        http?.logout()
                ?.logoutRequestMatcher(AntPathRequestMatcher("/logout**")) // ログアウト処理のパス
                ?.logoutSuccessUrl("/") // ログアウト完了時のパス
    }
}

実際試して見たが、Cookie 嬢の JSESSION_ID なんかもきちんと更新されていたし、セッションハイジャック対策もそこそこされているのだろう。
送信フォームもこんなカンジ

<!DOCTYPE HTML>
<html lang="ja" xmlns:th="http://www.thymeleaf.org">
<head>
    <title>top page</title>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
</head>
<body>
    <h1>Login form!</h1>
    <form action="/login" method="post" th:action="@{'/login'}">
        <div>Account:</div>
        <input type="text" name="account" />
        <div>Pass:</div>
        <input type="password" name="password" />
        <button type="submit">Do login</button>
    </form>
</body>
</html>

気をつけなければならないのは @{'/login'} の部分。
これで作成しないと、URL に CSRF ヘッダも生成されない。つまり POST が蹴られる。

やっぱ大分考慮されてるのねーってカンジである。