悪戦苦闘したのでメモ
小規模名案件で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) // ⑤
}
- CORSとCSRFを無効化している。CORSを全面的に許可するには加えてController側にも
@CrossOrigin
アノテーションで設定が必要 - 新規登録のためのエンドポイントおよび、それ以外全てのエンドポイントへのアクセスを許可している。今回は各Controller内の各メソッドごとにRoleのチェックを行いたいため、ここでは全エンドポイントを許可している。権限を絞る場合は、
.antMatchers("/api/hogehoge").hasRole()
のような記述を行う - 認証処理を行うFilterを定義
- 認可処理を行うFilterを定義
- 認証情報は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
}
}
UsernamePasswordAuthenticationFilter
を継承して構成されている。attemptAuthentication
をoverrideしている。この関数は、リクエストの内容からユーザーの情報を読み取りAuthentication
をインスタンス化する役割がある。この後にUsernamePasswordAuthenticationFilter
による認証処理が行われる。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")))
) // ②
}
- リクエストbodyを読み取り、POJOにバインド。
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) // ③
}
- JWTトークンを生成。
- JSON形式でレスポンスするためのBodyを定義。
- レスポンスに書き込み
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
}
}
OncePerRequestFilter
のdoFilterInternal
をoverrideし、この処理の中でHeaderのJWTをみにいく- リクエストからJWTトークンを抽出して検証
- 認証情報としてContextに保持させる。グローバルにアクセスして保持させていてこれであっているかどうか怪しいが、
BasicAuthenticationFilter
でも同じ実装だった
管理者向けのBasic認証の設定
長くなりそうなので別の記事で書く