🐯

Spring Security&LINE FIDO2 Serverでパスキー認証を実装する #3

2022/01/15に公開

Spring Security&LINE FIDO2 Serverでパスキー認証を実装する #2 の続きです。

今回はLINE FIDO2 Serverではなく、Spring Securityのところだけメモっています。

はじめに

  • これはどちらかというと自分用のメモです。

  • この実装が正しいかどうかいまいちわかりません。

  • バグがあるかもです。

  • ソースはこちら参照

何を作るか

どんな認証方法か

⭕ 2段階2要素認証 : ID / パスワード入力(記憶) → セキュリティキー(所持)

❌ 2段階2要素パスワードレス認証 : ID入力 → セキュリティキー(所持) / PIN(記憶)

❌ 1段階2要素パスワードレス認証 : セキュリティキー(所持) / PIN(記憶)

2段階認証の実装方法

前回はデフォルトのセキュリティフィルターをカスタマイズする方法での実装でした。この場合、図1のようにVeriyするポイントは1箇所です。
今回は2段階目のFIDO認証をカスタムセキュリティフィルターで実装します。この場合、図2のように認証の段階毎にVerifyする形になります。

ID/パスワーフォームでパスワードのVeriyをやってからFIDO認証を行うところがポイントです。

前回の実装は*の場所でVerify前にFIDO登録状況をチェックするため、任意のユーザーの名を指定してユーザーの存在確認ができるリスクがありましたが、今回の実装だとそういうリスクはなさそうです。

図1(前回の実装)

図2(今回の実装)

概要

  • ID/パスワード入力フォームで認証する
    • 既存のセキュリティフィルタを使う → UsernamePasswordAuthenticationFilter
  • FIDO認証フォームで認証する
    • カスタムセキュリティフィルタ を作る→ Fido2AuthenticataionFilter

実装のポイント

実装のポイントを見ていきます。

1. ID/パスワード入力→パスワード認証フィルタ

ID/パスワード入力フォームで入力してloginボタンをクリックしたときの処理です。

フォームでは /login に POST します。

login.html

<form method="post" th:action="@{/login}">
  ...
  <input type="text" id="username" name="username" placeholder="username" required autofocus>
  <input type="password" id="password" name="password" placeholder="passowrd" required>
  <button class="btn" type="submit">login</button>
</form>

Spring Securityでは WebSecurityConfigurerAdapter を継承したクラスの configure メソッドでFilterの設定をします。
こうすると POST /login をインターセプトしてデフォルトのパスワード認証フィルタ UsernamePasswordAuthenticationFilter が働くようになります。

SampleWebSecurityConfig

http
	.formLogin()
		.loginPage("/login").permitAll()

2. デフォルトのパスワード認証フィルタ

デフォルトのパスワード認証フィルタUsernamePasswordAuthenticationFilterの処理です。

UsernamePasswordAuthenticationFilterクラスのattemptAuthenticationメソッドが実行されます。

フォームで入力された username と password を取り出してUsernamePasswordAuthenticationTokenクラスに詰めて AuthenticationManager経由で DaoAuthenticationProvider に流します。
この一連の流れは既存のSpring Securityのままです。

3. デフォルトのパスワード認証プロバイダ

DaoAuthenticationProviderの処理です。パスワードのVerifyをここでやります。

既存のDaoAuthenticationProviderをそのまま使う形になりますが、ユーザー情報を取得する処理だけ自前で作ります。

UserDetailsServiceを継承したクラスを用意して、WebSecurityConfigurerAdapter を継承したクラスの configure メソッドで登Spring Securityに教えてあげれば、DaoAuthenticationProviderからloadUserByUsernameが実行されますのでこのメソッドをオーバーライドして自分のやりたいように実装してユーザー情報を返します。

SampleWebSecurityConfig

@Autowired
private lateinit var userDetailsService: SampleUserDetailsService

override fun configure(
  auth: AuthenticationManagerBuilder,
) {
  val daoAuthenticationProvider = DaoAuthenticationProvider().also {
    it.setUserDetailsService(userDetailsService)
  }
  auth.authenticationProvider(daoAuthenticationProvider)
}

loadUserByUsernameは引数でユーサーIDだけを受け取るんで、ユーザーマスタインタフェースを実装しているMuserRepositoryからユーザーの存在有無をチェックします。

同時に LINE-FIDO2-Serverに問い合わせてFIDOクレデンシャルの有無をチェックします。

FIDOクレデンシャルの有無によってauthoritiesを以下のように設定します。

  • FIDOクレデンシャルがある時 → autoritiesにPRE_AUTHENTICATE_FIDOを設定する
    • FIDO認証が必要だという意味
  • FIDOクレデンシャルがない時 → autoritiesにAUTHENTICATED_PASSWORD,ROLE_USERを設定する
    • パスワード認証で認証完了したという意味、また、ロールでUSERも付与します。

FIDOクレデンシャルがあるときだけ2段階目のFIDO認証に進むようにする考慮はここが起点になります。

SampleUserDetailsService

@Service
class SampleUserDetailsService(
  private val mUserRepository: MuserRepository,
  private val lineFido2ServerService: LineFido2ServerService,
) : UserDetailsService {
  override fun loadUserByUsername(userId: String?): UserDetails {
    if (userId == null || userId.isEmpty()) {
      throw UsernameNotFoundException("userId is null or empty")
    }

    val mUser = mUserRepository.findById(userId).orElse(null) ?: throw UsernameNotFoundException("Not found userId")

    val getCredentialßsResult = lineFido2ServerService.getCredentialsWithUsername(userId)

    val authorities = if (getCredentialsResult.credentials.isEmpty()) {
      listOf(
        SimpleGrantedAuthority(SampleUtil.Auth.AUTHENTICATED_PASSWORD.value),
        SimpleGrantedAuthority(SampleUtil.Role.USER.value)
      )
    } else {
      listOf(
        SimpleGrantedAuthority(SampleUtil.Auth.PRE_AUTHENTICATE_FIDO.value)
      )
    }

    return User(mUser.id, mUser.password, authorities)
  }
}

4. 認証成功時のハンドラ

認証成功時のハンドラを実装します。
ここで、次の認証どうするか(FIDO認証するかこのままログインするか)を指示します。

カスタムハンドラは SimpleUrlAuthenticationSuccessHandler を継承したクラスを作ります→UsernamePasswordAuthenticationSuccessHandler

作成たカスタムハンドラクラスは例によってWebSecurityConfigurerAdapter を継承したクラスの configure メソッドでSpring Securityに教えてあげます。

SampleWebSecurityConfig

http
	.formLogin()
		.loginPage("/login").permitAll()
    .successHandler(UsernamePasswordAuthenticationSuccessHandler("/login-fido2","/mypage"))

↑これで、デフォルトの認証プロバイダ(DaoAuthenticationProvider)でパスワードVerify成功したときだけこのハンドラが実行されます。UsernamePasswordAuthenticationSuccessHandlerのコンストラクタにはFIDO認証する場合としない場合の遷移先を指定します。

具体的な処理は↓のとおりです。先程設定したauthoritiesを持って入ってくるんで、PRE_AUTHENTICATE_FIDO であれば /login-fido2にリダイレクトします。

UsernamePasswordAuthenticationSuccessHandler

override fun onAuthenticationSuccess(
  request: HttpServletRequest?,
  response: HttpServletResponse?,
  authentication: Authentication
) {
  val needFido = authentication.authorities?.any {
    it.authority == SampleUtil.Auth.PRE_AUTHENTICATE_FIDO.value
  } ?: false

  if (needFido) {
    // Redirect to /login-fido2
    response?.sendRedirect(redirectUrl)
  } else {
    super.onAuthenticationSuccess(request, response, authentication)
  }
}

AuthenticationSuccessHandler

ちなみにAuthenticationSuccessHandlerには以下のようなものがあります。

  • SimpleUrlAuthenticationSuccessHandler
    • コンストラクタで指定されたURLにリダイレクトする
  • ForwardAuthenticationSuccessHandler
    • コンストラクタで指定されたURLにフォワードする
  • SavedRequestAwareAuthenticationSuccessHandler
    • ログイン画面に飛ばされる前にアクセスしようとしていた URLにリダイレクする

5. FIDO認証フォーム→FIDO認証フィルタ

ここで一旦フォームに戻ってjavascriptのWebAuthnを使ってFIDO認証します。前回の実装とほぼ同じなので省略します。

WebAuthnをパスするとAssertionというオブジェクトを貰えるのでそれを持って帰ります、今回はFIDO認証フィルタを用意してそこに帰るようにします。

FIDO認証フィルタは AbstractAuthenticationProcessingFilterを継承したクラスで実装しました。実装の詳細は次で説明するとして、まずは例によってSpring Securiyに教えてあげる必要があります。

SampleWebSecurityConfig

override fun configure(http: HttpSecurity) {
  ...

  http.addFilterAt(createFido2AuthenticationFilter(), UsernamePasswordAuthenticationFilter::class.java)

  ...
}

private fun createFido2AuthenticationFilter(): Fido2AuthenticationFilter {
  return Fido2AuthenticationFilter("/login-fido2", "POST").also {
    it.setAuthenticationManager(authenticationManagerBean())
    it.setAuthenticationSuccessHandler(SimpleUrlAuthenticationSuccessHandler("/mypage"))
    it.setAuthenticationFailureHandler(SimpleUrlAuthenticationFailureHandler("/login?error"))
  }
}

↑では FIDO認証フィルタとして Fido2AuthenticationFilter を登録しています。以下の設定を行っています。

  • コンストラクタ→/login-fido2POSTしたらフィルタを実行する
  • setAuthenticationManager→AuthenticationManagerを使う
  • setAuthenticationSuccessHandler→認証成功時は/mypageにリダイレクトする
  • setAuthenticationFailureHandler→認証失敗時は/login&errorにリダイレクトする

6. FIDO認証フィルタ

WebAuthnをパスしてAssertionというオブジェクトを貰って /login-fido2 に POST します。

login-fido2.html

<div>
    <p style="text-align: left;">FIDO2 Authenticate</p>
    <form name="authenticate" th:action="@{/login-fido2}" method="post">
        <p hidden>
            <input type="text" id="assertion" name="assertion">
        </p>
    </form>
    <div>
        <button href="#" type="button" id="fido">authenticate</button>
    </div>
</div>

そうすると、すかさずFido2AuthenticationFilterattemptAuthenticationが実行されます。

Fido2AuthenticationFilter

override fun attemptAuthentication(request: HttpServletRequest?, response: HttpServletResponse?): Authentication {
  if (request!!.method != "POST") {
    throw AuthenticationServiceException("Authentication method not supported: " + request.method)
  }

  val assertion = obtainAssertion(request)
  val principal = obtainPrincipal(request)

  val credentials = AssertionAuthenticationToken.Fido2Credentials(
    SampleUtil.getFido2SessionId(request),
    assertion
  )

  val authorities = principal.authorities.map {
    SimpleGrantedAuthority(it.authority)
  }

  val authRequest = AssertionAuthenticationToken(principal, credentials, authorities)
  setDetails(request, authRequest)
  return authenticationManager.authenticate(authRequest)
}

↑リクエストからAssertion(WebAuthnの結果)、セッションからprincipal(パスワード認証の結果)を取り出してAssertionAuthenticationTokenに詰め直してAuthenticationManagerに引き渡しています→認証プロバイダに処理が委譲されます。

7. FIDO認証プロバイダ

FIDO認証フィルタから認証プロバイダに処理が委譲されますが、委譲される認証プロバイダはデフォルトでは対応できないため作ります。AuthenticationProviderを継承したクラスを作ります→Fido2AuthenticationProvider

作成したFIDO認証プロバイダを例のConfigでSpring Securityに登録します。

SampleWebSecurityConfig

@Autowired
private lateinit var fido2AuthenticationProvider: Fido2AuthenticationProvider

override fun configure(
  auth: AuthenticationManagerBuilder,
) {
  auth.authenticationProvider(fido2AuthenticationProvider)
}

Fido2AuthenticationProviderauthenticatesupportsを実装します。

Fido2AuthenticationProvider

@Component
class Fido2AuthenticationProvider(
  private val lineFido2ServerService: LineFido2ServerService,
) : AuthenticationProvider {
  override fun authenticate(authentication: Authentication): Authentication {
    if (authentication is AssertionAuthenticationToken) {
      // verify FIDO assertion
      if (!lineFido2ServerService.verifyAuthenticateAssertion(
        authentication.credentials.sessionId,
        authentication.credentials.assertion,
      )
         ) {
        throw BadCredentialsException("Invalid Assertion")
      }
    } else {
      throw BadCredentialsException("Invalid Authentication")
    }

    // set Authenticated
    val authorities = listOf(
      SimpleGrantedAuthority(SampleUtil.Auth.AUTHENTICATED_FIDO.value),
      SimpleGrantedAuthority(SampleUtil.Role.USER.value)
    )

    val principalNew = User(
      authentication.principal.username,
      authentication.principal.password ?: "",
      authorities)

    var result = AssertionAuthenticationToken(principalNew, authentication.credentials, authorities)
    result.isAuthenticated = true
    return result
  }

  override fun supports(authentication: Class<*>?): Boolean {
    return AssertionAuthenticationToken::class.java.isAssignableFrom(authentication)
  }
}

supports

supportsって何だかよくわからないと思いますが、ブレークポイントを入れて実行してみるとよくわかります。authenticateの呼び出しに先立って実行されます。引数で渡されたauthenticationクラスがAssertionAuthenticationTokenだったらtrueを返す、というお作法です。

authenticate

authenticateは重要な処理です。ここでAssertionをVerifyします。重要な処理ですけど、LINE-FIDO2-ServerにAssertionを投げて結果をもらうだけなんで簡単です。Verifyに失敗したらBadCredentialsExceptionを投げます。

AssertionのVerifyに成功したら戻り値のAssertionAuthenticationTokenを作ります(引数のAuthenticationとは別のものとして作ります)、このとき

  • authoritiesにAUTHENTICATED_FIDOをセットします→FIDO認証完了したという意味です。
  • また、authoritiesにROLE_USERもつけます→一般ユーザーという意味です。
  • isAuthenticatedをtrueにします。

8. 認証成功時のハンドラ

FIDO認証をパスすると SimpleUrlAuthenticationSuccessHandlerの設定の通り、Spring Securityに認証済みと認識され、/mypageにリダイレクトされます。

Spring Securityでの認証済みの場合、以下の状態になります。

FIDO認証してログインした場合 ID/Password認証だけでログインした場合
SecurityContextHolder
.getContext()
で取れるオブジェクト
AssertionAuthenticationToken UserPasswordAuthenticationToken
Authorities AUTHENTICATED_FIDO
ROLE_USER
AUTHENTICATED_PASSWORD
ROLE_USER

いずれの認証でログインしてもAuthoritiesにROLE_USERが付きます。
mypage.htmlはログインした後だけ表示できるようにしたいので、configで以下のように設定します。

SampleWebSecurityConfig

override fun configure(http: HttpSecurity) {
  http
  .authorizeRequests()
  .antMatchers("/login", "/login-fido2", "/authenticate/option").permitAll()
  .anyRequest().hasRole(SampleUtil.Role.USER.name) // ← ROLE_USERだけアクセス可能
}

まとめ

  • 2段階認証をカスタムセキュリティフィルタを作る方法で実装しました。

  • ID/パスワーフォームでパスワードのVeriyをやってから次のFIDO認証に進むかどうかを判断することができます。

  • 認証をパスしたことを示すロールを付与してアクセス制限をします。

  • ソースはこちら参照

おつかれさまでした

この実装が正しいかどうかいまいちわかりません。どこかにバグがあるかもです。

Spring Security&LINE FIDO2 Serverで多要素認証を実装する #4 につづく

Discussion