FFFT

主にwebプロダクト開発に関連する話をつらつらと

Spring Securityのデフォルト認証をWeb APIとして利用 / ステータスコードが302になるのを200, 401に変更

SpringやSpring Bootでサーバアプリケーションを作成すると認証を作る際にSpring Securityを利用することが多いかと思います。
自分も3, 4年前ぐらいにけっこう使っていて、0から設計・開発したアプリケーションにも使っていたのに完全に忘れていましたw
ライブラリがでかくて全容を理解するのが大変。(思い出すのも大変...)

ということで、今回はSpring Securityを使ってデフォルトの認証機能をフォーム入力からの認証ではなく、Web APIとして利用する際のミニマム対応まとめます。

直近触っているプロジェクトが、Kotlinを使っているのでKotlinでまとめます。まぁJavaでもやることは変わらないです。

spring-boot-starter-securityをdependencyに追加

build.gradle.ktsのdepencenciesにspring-boot-starter-securityを追加します。

...
dependencies {
...
  implementation("org.springframework.boot:spring-boot-starter-security")
...
}
...

コンフィグを設定

WebSecurityConfigurerAdapterを継承したコンフィグクラスを作成します。 EnableWebSecurityのアノテーションをつけることで、Spring Securityの設定として有効になります。

@Configuration
@EnableWebSecurity
class AuthTokenSecurityConfig() : WebSecurityConfigurerAdapter() {

  override fun configure(http: HttpSecurity) {
    http
        .csrf { c ->
          c.disable() // csrfは今回の話と関係ないのでdisableに
        }
        .authorizeRequests { r ->
          r.anyRequest().authenticated()  // すべてのエンドポイントで認証必須に
        }
        .formLogin { }
        .logout { }
  }


  @Bean
  fun users(): UserDetailsService {
    val user: UserDetails = User.builder()
        .username("user")
        .password(CustomPasswordEncoder().encode("SamplePassword123!"))
        .build()
    return InMemoryUserDetailsManager(user)
  }
}

認証するユーザー情報は今回はハードコードでインメモリで管理します。

username: user
password: SampleUser123!

ちゃんとやろうとするなら、org.springframework.security.core.userdetails.User を継承したUserクラスと、 org.springframework.security.core.userdetails.UserDetailsServiceを継承したクラスを実装します。 UserDetailsServiceを継承したクラスでloadUserByUsernameメソッドをオーバーライドして、DBからユーザー情報引っ張ってきて諸々検証する感じになるかと思います。

現状の挙動確認

上記の設定で認証は動きます。
Spring Securityではデフォルトで /login がログインのエンドポイント、 /logout がログアウトのエンドポイントになります。

curlで叩いてみます。

$ curl -X POST 'http://localhost:8080/login' -H 'Content-Type: application/x-www-form-urlencoded' --data-urlencode 'username=user' --data-urlencode 'password=SampleUser123!' -v

正常に認証が通ったのかどうかわからんレスポンスが返ってきます。(ちゃんと通ってます。)

*   Trying ::1...
* TCP_NODELAY set
* Connected to localhost (::1) port 8081 (#0)
> POST /login HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.54.0
> Accept: */*
> Content-Type: application/x-www-form-urlencoded
> Content-Length: 41
>
* upload completely sent off: 41 out of 41 bytes
< HTTP/1.1 302
< X-Content-Type-Options: nosniff
< X-XSS-Protection: 1; mode=block
< Cache-Control: no-cache, no-store, max-age=0, must-revalidate
< Pragma: no-cache
< Expires: 0
< X-Frame-Options: DENY
< Set-Cookie: JSESSIONID=DA9B54324442FD5FEF2B31D68E181900; Path=/; HttpOnly
< Location: http://localhost:8080/
< Content-Length: 0
< Date: Fri, 17 Jul 2020 05:50:40 GMT
<
* Connection #0 to host localhost left intact

内容を見ると、レスポンスのHTTPステータスが302でリダイレクト先が http://localhost:8080/ になっています。

認証失敗のレスポンスも確認してみましょう。

$ curl -X POST 'http://localhost:8080/login' -H 'Content-Type: application/x-www-form-urlencoded' --data-urlencode 'username=user' --data-urlencode 'password=FAILED' -v
*   Trying ::1...
* TCP_NODELAY set
* Connected to localhost (::1) port 8081 (#0)
> POST /login HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.54.0
> Accept: */*
> Content-Type: application/x-www-form-urlencoded
> Content-Length: 29
>
* upload completely sent off: 29 out of 29 bytes
< HTTP/1.1 302
< Set-Cookie: JSESSIONID=C83308084D39450F029A2A8EF5A4501D; Path=/; HttpOnly
< X-Content-Type-Options: nosniff
< X-XSS-Protection: 1; mode=block
< Cache-Control: no-cache, no-store, max-age=0, must-revalidate
< Pragma: no-cache
< Expires: 0
< X-Frame-Options: DENY
< Location: http://localhost:8080/login?error
< Content-Length: 0
< Date: Fri, 17 Jul 2020 06:47:44 GMT
<
* Connection #0 to host localhost left intact

失敗のレスポンスもHTTPステータスは302です。
リダイレクト先が http://localhost:8080login?error になっていますが、Web APIとして認証を利用しようとすると、かなりきついですね。。。
リダイレクト先を見て、認証の可否を判定するのは辛過ぎます。

そこで、認証が成功した場合は200を、認証が失敗した場合は401を返すようにします。

Handlerの実装

まずはCustomAuthenticationSuccessHandler, CustomAuthenticationFailureHandlerの2つを実装します。
それぞれ import org.springframework.security.web.authentication のAuthenticationSuccessHandler, AuthenticationFailureHandlerを実装します。

@Component
class CustomAuthenticationSuccessHandler : AuthenticationSuccessHandler {
  override fun onAuthenticationSuccess(request: HttpServletRequest,
                                       response: HttpServletResponse,
                                       auth: Authentication) {
    if (response.isCommitted) {
      return
    }
    response.status = HttpStatus.OK.value()
  }
}
@Component
class CustomAuthenticationFailureHandler : AuthenticationFailureHandler {
  override fun onAuthenticationFailure(request: HttpServletRequest,
                                       response: HttpServletResponse,
                                       exception: AuthenticationException) {
    if (response.isCommitted) {
      return
    }
    response.sendError(HttpStatus.UNAUTHORIZED.value(), HttpStatus.UNAUTHORIZED.reasonPhrase)
  }
}

ついでにログアウトも302がデフォルトでレスポンスされるので、CustomLogoutSuccessHandlerを実装します。

@Component
class CustomLogoutSuccessHandler : LogoutSuccessHandler {

  override fun onLogoutSuccess(request: HttpServletRequest,
                                       response: HttpServletResponse,
                                       auth: Authentication) {
    if (response.isCommitted) {
      return
    }
    response.status = HttpStatus.OK.value()
  }
}

コンフィグを修正

ログイン、ログアウト処理のハンドリングに先ほど実装したハンドラーを渡します。

@Configuration
@EnableWebSecurity
class AuthTokenSecurityConfig(
    private val loginSuccessHandler: CustomAuthenticationSuccessHandler,
    private val loginFailureHandler: CustomAuthenticationFailureHandler,
    private val logoutSuccessHandler: CustomLogoutSuccessHandler
) : WebSecurityConfigurerAdapter() {

  override fun configure(http: HttpSecurity) {
    http
        .csrf { c ->
          c.disable()
        }
        .authorizeRequests { r ->
          r.anyRequest().authenticated()
        }
        .formLogin { f ->
          f.successHandler(loginSuccessHandler)
          f.failureHandler(loginFailureHandler)
        }
        .logout { l ->
            l.logoutSuccessHandler(logoutSuccessHandler)
        }
  }


  @Bean
  fun users(): UserDetailsService {
    val user: UserDetails = User.builder()
        .username("user")
        .password(CustomPasswordEncoder().encode("SamplePassword123!"))
        .build()
    return InMemoryUserDetailsManager(user)
  }
}

これで認証が成功したときは200, 失敗したときは401が返るようになりました。

成功

$ curl -X POST 'http://localhost:8080/login' -H 'Content-Type: application/x-www-form-urlencoded' --data-urlencode 'username=user' --data-urlencode 'password=SampleUser123!' -v
*   Trying ::1...
* TCP_NODELAY set
* Connected to localhost (::1) port 8081 (#0)
> POST /login HTTP/1.1
> Host: localhost:8081
> User-Agent: curl/7.54.0
> Accept: */*
> Content-Type: application/x-www-form-urlencoded
> Content-Length: 41
>
* upload completely sent off: 41 out of 41 bytes
< HTTP/1.1 200
< X-Content-Type-Options: nosniff
< X-XSS-Protection: 1; mode=block
< Cache-Control: no-cache, no-store, max-age=0, must-revalidate
< Pragma: no-cache
< Expires: 0
< X-Frame-Options: DENY
< Set-Cookie: JSESSIONID=67BB2178B3E54F30EC142BBD432812F1; Path=/; HttpOnly
< Content-Length: 0
< Date: Fri, 17 Jul 2020 07:03:50 GMT
<
* Connection #0 to host localhost left intact

失敗

$ curl -X POST 'http://localhost:8080/login' -H 'Content-Type: application/x-www-form-urlencoded' --data-urlencode 'username=user' --data-urlencode 'password=FAILED' -v
*   Trying ::1...
* TCP_NODELAY set
* Connected to localhost (::1) port 8081 (#0)
> POST /login HTTP/1.1
> Host: localhost:8081
> User-Agent: curl/7.54.0
> Accept: */*
> Content-Type: application/x-www-form-urlencoded
> Content-Length: 31
>
* upload completely sent off: 31 out of 31 bytes
< HTTP/1.1 401
< X-Content-Type-Options: nosniff
< X-XSS-Protection: 1; mode=block
< Cache-Control: no-cache, no-store, max-age=0, must-revalidate
< Pragma: no-cache
< Expires: 0
< X-Frame-Options: DENY
< Set-Cookie: JSESSIONID=9B5B698AC4339FF19A4E6F122CCC9428; Path=/; HttpOnly
< Content-Length: 0
< Date: Fri, 17 Jul 2020 07:01:55 GMT
<
* Connection #0 to host localhost left intact