🐯

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

2022/01/23に公開

Spring Security&LINE FIDO2 Serverで多要素認証を実装する #3 の続きです。

今回はパスワードレス認証を実装してみます。

はじめに

何を作るか

どんな認証方法か

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

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

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

パスワードレス認証とは

ログインするときにパスワードを使わない方法です。
セキュリティキーに設定されたPINまたは指紋を認証に使うことでセキュアかつ簡単な手順でのログインを実現できます。

上図🟩緑色ルート

  • 前回実装した認証です。
  • このデモではID/パスワードの後、TouchIDでログインしています。

上図🟥赤色ルート(Touch IDでのパスワードレス認証)

  • 今回実装するパスワードレス認証です。
  • このデモではPasswordless loginをクリックしてTouch IDでログインしています。
    • ID、パスワードは入力不要です。

上図🟥赤色ルート(Yubikeyでのパスワードレス認証)

  • Yubikeyを使ったパスワードレス認証です。
  • このデモではPasswordless loginをクリックしてYubikeyでログインしています。
  • このYubikeyは指紋認証できないタイプなので、YubikeyのPINを入力しています。

パスワードレス認証の実装

  • セキュリティキーを登録する
  • パスワードレス認証する

セキュリティキーを登録する

パスワードレス認証用にセキュリティキーを登録します。

登録の際、LINE FIDO2 ServerのGet Reg ChallengerequireResidentKeyをtrueにします(2段階認証の登録時はfalseです)。それ以外は2段階認証のセキュリティキー登録と同じです。詳細は#1を参照ください。

requireResidentKeyとは

セキュリティキー内にユーザー情報を記録するオプションです。記録したユーザー情報はWebAuthn認証でPINまたは生体をパスしたときに取得するAssertionに含まれます。
つまり、登録したときのユーザー情報を認証時に取り出しす機能がResident Keyです。

この機能によって認証フォームでのユーザID入力を省略することができます。

セキュリティキー内に記録可能なデータ量には限界があります(セキュリティキーによって異なります)。
削除したい場合の手順はこちらを参照ください。

パスワードレス認証する

フロー図

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

1. Get Auth Challenge

2段階認証のときと同じく、LINE FIDO2 ServerのGet Auth Challengeを使ってWebAuthnのオプション情報を取得します。

ポイントは以下

  • ユーザーIDを指定しない
  • UserVerificationをREQUIRED(必須)にする

LineFido2ServerServiceImpl.getAuthenticateOption

override fun getAuthenticateOption(
  userName: String?,
): Pair<ServerPublicKeyCredentialGetOptionsResponse, String> {
  val authOptionRequest = if (userName.isNullOrEmpty()) {
    // パスワードレス認証のときはこちらに入る
    AuthOptionRequest
    .builder()
    .rpId(RP_ID)
    .userVerification(UserVerificationRequirement.REQUIRED)
    .build()
  } else {
    AuthOptionRequest
    .builder()
    .rpId(RP_ID)
    .userId(createUserId(userName!!))
    .userVerification(UserVerificationRequirement.DISCOURAGED)
    .build()
  }

  val request = HttpEntity(authOptionRequest, HttpHeaders())
  val response = restTemplate.postForObject(AUTH_CHALLENGE_URI, request, AuthOptionResponse::class.java)
  if (response?.serverResponse?.internalErrorCode != 0) {
    return ServerPublicKeyCredentialGetOptionsResponse(
      Status.FAILED,
      response?.serverResponse!!.internalErrorCodeDescription
    ) to ""
  }

  return ServerPublicKeyCredentialGetOptionsResponse(response) to response.sessionId
}

2. WebAuthn

このあたりの処理は2段階認証のときと同じです。Get Auth Challengeで取得したオプションを指定してWebAuthnを実行すると いい感じにやってくれます。

3. Verify Assertion

WebAuthnの実行結果(Assertion)をしてログインする処理です。サーバー側は以下の流れで処理していきます。

3-1. FIDO認証フィルタ

HttpServletRequestからAssertion(WebAuthnの結果)を取り出してAssertionAuthenticationTokenに詰め直してAuthenticationManagerに引き渡しています→認証プロバイダに処理が委譲されます。

ここの処理は2段階認証でも通ります。2段階認証のときはPrincipal(ID/Password認証の結果)も取り出します。パスワードレス認証のときはPrincipalがnullになるので注意が必要です。このプログラムではPrincipalが取れなかったときは空のオブジェクトを生成して無理やり処理を進めるようにしています。

Fido2AuthenticationFilter.attemptAuthentication

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

  // `HttpServletRequest`からAssertion(WebAuthnの結果)を取り出す
  val assertion = obtainAssertion(request)
  // パスワードレス認証の場合`Principal`はダミー値の入った空のオブジェクト
  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)
}

private fun obtainPrincipal(request: HttpServletRequest): User {
  val session = request.session
  // パスワードレス認証のときはPrincipalがnullになるので注意
  // `Principal`が取れなかったときは空のオブジェクトを生成して無理やり処理を進める
  val securityContext = session.getAttribute("SPRING_SECURITY_CONTEXT") as? SecurityContext
  ?: return User("<dmy>","",emptyList())
  val principal = securityContext.authentication.principal
  if (principal !is User) {
    throw AuthenticationServiceException("assertion")
  }
  return principal
}

3-2. FIDO認証プロバイダ

FIDO認証プロバイダではAssertionのVerifyを行います。VerifyはLINE FIDO2 ServerのSend Auth Responseを使います。

Verifyが成功した後は、クレデンシャルに紐づくユーザー情報を取得します。これはLINE FIDO2 ServerのGet Credential by CredentialIdを使います。
また、自身のAppのリポジトリにも存在するかどうかをmUserRepository.findByIdで確認します。

これらの確認が無事終了したら認証OKということで AUTHENTICATED_FIDO,ROLE_USER のauthoritiesを付けた、認証済み(isAuthenticated=true)のPrincipalを生成して返します。

Fido2AuthenticationProvider.authenticate

override fun authenticate(authentication: Authentication): Authentication {
  val userName = if (authentication is AssertionAuthenticationToken) {
    // verify FIDO assertion
    // LINE FIDO2 ServerのSend Auth Responseを使う
    if (!lineFido2ServerService.verifyAuthenticateAssertion(
      authentication.credentials.sessionId,
      authentication.credentials.assertion,
    )
       ) {
      throw BadCredentialsException("Invalid Assertion")
    }
    // LINE FIDO2 ServerのGet Credential by CredentialIdでユーザー情報を取得する
    val credential = lineFido2ServerService.getCredentialWithCredentialId(authentication.credentials.assertion.id)

    // 自身のAppのリポジトリにも存在するかどうかを確認する
    mUserRepository.findById(credential.name)
    .orElse(null) ?: throw BadCredentialsException("Invalid Assertion")

    // これでオッケー
    credential.name
  } else {
    throw BadCredentialsException("Invalid Authentication")
  }

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

  val authencatedPrincipal = User(userName,"", authorities)

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

3-3. 認証成功時のハンドラ

認証成功時のハンドラは前回と同じです。

まとめ

  • パスワードレス認証は Resident Key の機能を使います。
  • Resident Keyを使ったパスワードレス認証はID/パスワードの入力が不要でスピーディにログインできます。
  • FIDOサーバーの処理は LINE FIDO2 Server がいい感じにやってくれる。

補足

Resident Keyでセキュリティキーに登録したユーザー情報の削除方法

Resident Keyでセキュリティキー内に記録されたユーザー情報は以下の手順で削除することができます。
ここではブラウザにChromeを使ったの場合のみ記載します。

Touch ID などの いわゆる プラットフォーム認証器

  • 閲覧履歴データの削除を表示する
    • chrome://settings/clearBrowserData
  • 詳細設定タブでパスワードとその他のログインデータをチェックして削除する
    • 注意: 個別に削除することはできず、期間指定で削除する方法しかないようです(Chrome 97時点)

Yubikey などの いわゆる クロスプラットフォーム認証器

  • セキュリティ キーの管理を表示する
    • chrome://settings/securityKeys
  • ログインデータから削除したいユーザー情報を選択して削除する

おつかれさまでした

実装は難しくなさそうだけど、そもそもFIDOの知識がある程度必要なところがハードルかもしれません。

Discussion