SpringBootで1PJにRESTAPIと管理画面を共存させるためのSpringSecurityの設定

悪戦苦闘したのでメモ

小規模名案件でSpringBootを利用したバックエンドを実装していて、その時に一般利用者向けにRESTAPIで提供してなおかつ管理者向けにSSRで管理画面を提供する必要が出た。

よくある小規模でモノリシックなシステムを構築するパターンだとよくある光景だと思われるが、SpringBootでの方法がわからず苦戦したのでメモ

要件

  • 一般利用者向け
    • Content-Type: application/jsonなRESTAPIを提供する
    • 認証認可はJWTを利用
    • pahは /api/*
  • 管理者向け
    • SSRなviewを提供する
    • 認証認可はBasic認証を利用
    • pathは /admin/*

Controller

一般利用者向けのRESTAPIは、Controllerクラスに@RestControllerを付与してResponseEntityをreturnして終わり。

SSRは、Controllerクラスに@Controllerを付与して終わり。レスポンスはviewへのpathを渡すだけ。

SpringSecurityの設定

一般利用者向けにはJWT認証、管理者向けにはBasic認証を提供する必要がある。SpringBootにはSpringSecurityという認証認可を包括的に扱うライブラリがあるのでこちらを使う

springboot-starterkitというリポジトリを参考に実装した。Securityに関してはこちらで詳しく解説されている。

これを参考にしてConfigurationを2つ設定。それぞれが一般利用者向け・管理者向けのSpringSecurityの設定を表す。

package com.xxxxxx.yyyyy.security

// ...

@EnableWebSecurity
class MultiHttpSecurityConfig {

     @Configuration
     @Order(1)
     class ApiWebSecurityConfigurationAdapter : WebSecurityConfigurerAdapter() {
          // TODO
     }

     @Order(2)
     @Configuration
     class AdminWebSecurityConfigurerAdapter : WebSecurityConfigurerAdapter() {
          // TODO
     }
}

@Orderが付与されているため、ApiWebSecurityConfigurationAdapterを先に通過し、antMatcherが合致しない場合、その後AdminWebSecurityConfigurerAdapterを通過するという仕組み。

一般利用者向けのJWTの設定

WebSecurityConfigurerAdapterを継承した各configクラスの中でも重要なのがconfigure(http: HttpSecurity)の実装。

/api/**に合致するリクエストを対象に、以下の設定を行っている。

     override fun configure(http: HttpSecurity) {
            // https://auth0.com/blog/implementing-jwt-authentication-on-spring-boot/
            http.antMatcher("/api/**").cors().and().csrf().disable() // ①
                    .authorizeRequests()
                    .antMatchers(HttpMethod.POST, SIGN_UP_URL).permitAll()  // ②
                    .anyRequest().permitAll() // ②
                    .and()
                    .addFilter(ApiJWTAuthenticationFilter(authenticationManager())) // ③
                    .addFilter(ApiJWTAuthorizationFilter(authenticationManager())) // ④
                    .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) // ⑤
        }
  1. CORSとCSRFを無効化している。CORSを全面的に許可するには加えてController側にも@CrossOriginアノテーションで設定が必要
  2. 新規登録のためのエンドポイントおよび、それ以外全てのエンドポイントへのアクセスを許可している。今回は各Controller内の各メソッドごとにRoleのチェックを行いたいため、ここでは全エンドポイントを許可している。権限を絞る場合は、 .antMatchers("/api/hogehoge").hasRole()のような記述を行う
  3. 認証処理を行うFilterを定義
  4. 認可処理を行うFilterを定義
  5. 認証情報はJWTで毎回やりとりを行うため、セッションを保持させない。そのための設定

この先頭のantMatcher()で合致していないものは次の設定に流れる

ApiJWTAuthenticationFilterは具体的に以下のような処理になっている。UsernamePasswordAuthenticationFilterをそのまま利用することもできるが、レスポンスをカスタマイズするため、これを継承してoverrideする

class ApiJWTAuthenticationFilter(
  private val authenticationManager: AuthenticationManager,
  private val objectMapper: ObjectMapper = ObjectMapper(),
) : UsernamePasswordAuthenticationFilter() {  // ①
  
  @Throws(AuthenticationException::class)
  override fun attemptAuthentication(  // ②
    req: HttpServletRequest,
    res: HttpServletResponse,
  ): Authentication {
    // TODO
  }

  @Throws(IOException::class, ServletException::class)
  override fun successfulAuthentication(  // ③
    req: HttpServletRequest,
    res: HttpServletResponse,
    chain: FilterChain,
    auth: Authentication,
  ) {
    // TODO
  }
}
  1. UsernamePasswordAuthenticationFilterを継承して構成されている。
  2. attemptAuthenticationをoverrideしている。この関数は、リクエストの内容からユーザーの情報を読み取りAuthenticationをインスタンス化する役割がある。この後にUsernamePasswordAuthenticationFilterによる認証処理が行われる。
  3. successfulAuthenticationをoverrideしている。この関数は、UsernamePasswordAuthenticationFilterの認証が成功した場合に呼び出される。ここでレスポンスをカスタマイズすることができる

attemptAuthentication()の処理は以下のようになっている。

  @Throws(AuthenticationException::class)
  override fun attemptAuthentication(
    req: HttpServletRequest,
    res: HttpServletResponse,
  ): Authentication {
     val creds = objectMapper.readValue(req.inputStream, LoginRequest::class.java) // ①

     authenticationManager.authenticate(
       UsernamePasswordAuthenticationToken(
         creds.email,
         creds.password,
         listOf(SimpleGrantedAuthority("ROLE_EMPLOYEE")))
     ) // ②
  }
  1. リクエストbodyを読み取り、POJOにバインド。
  2. UsernamePasswordAuthenticationTokenを生成してauthenticationManagerに渡す。UsernamePasswordAuthenticationFilterのデフォルト実装だとauthoritiesが設定されない

successfulAuthentication()の処理は以下のようになっている。

  @Throws(IOException::class, ServletException::class)
  override fun successfulAuthentication(
    req: HttpServletRequest,
    res: HttpServletResponse,
    chain: FilterChain,
    auth: Authentication,
  ) {
    val token = JWT.create()
      .withSubject((auth.principal as User).username)
      .withExpiresAt(Date(System.currentTimeMillis() + SecurityConstants.EXPIRATION_TIME))
      .sign(Algorithm.HMAC512(SecurityConstants.SECRET.toByteArray())) // ①

    val responseBody = objectMapper.writeValueAsString(mapOf(
      "accessToken" to token
    )) // ②
    res.writer.write(responseBody) // ③
  }
  1. JWTトークンを生成。
  2. JSON形式でレスポンスするためのBodyを定義。
  3. レスポンスに書き込み

ApiJWTAuthorizationFilterは具体的に以下のような処理になっている。BasicAuthenticationFilterはBasic認証のための実装になっているため、これを継承してHeaderからJWTを受け取れるようにする

class ApiJWTAuthorizationFilter(authManager: AuthenticationManager?) : BasicAuthenticationFilter(authManager) {
    @Throws(IOException::class, ServletException::class)
    override fun doFilterInternal(req: HttpServletRequest, // ①
                                  res: HttpServletResponse,
                                  chain: FilterChain) {
        val authentication = getAuthentication(req) // ②
        if (authentication !== null) {
            SecurityContextHolder.getContext().authentication = authentication // ③
        }
        chain.doFilter(req, res)
    }

    private fun getAuthentication(request: HttpServletRequest): UsernamePasswordAuthenticationToken? { // ②
        val token = request.getHeader(SecurityConstants.HEADER_STRING)
        if (token === null) {
            return null
        }
        // parse the token.
        val user = JWT.require(Algorithm.HMAC512(SecurityConstants.SECRET.toByteArray()))
                .build()
                .verify(token.replace(SecurityConstants.TOKEN_PREFIX, ""))
                .subject
        return if (user != null) {
            UsernamePasswordAuthenticationToken(user, null, arrayListOf(SimpleGrantedAuthority("ROLE_EMPLOYEE")))
        } else null
    }
}
  1. OncePerRequestFilterdoFilterInternalをoverrideし、この処理の中でHeaderのJWTをみにいく
  2. リクエストからJWTトークンを抽出して検証
  3. 認証情報としてContextに保持させる。グローバルにアクセスして保持させていてこれであっているかどうか怪しいが、BasicAuthenticationFilterでも同じ実装だった

管理者向けのBasic認証の設定

長くなりそうなので別の記事で書く


See also