🐯

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

2022/01/03に公開

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

前回は FIDOクレデンシャルの登録を実装したので今回は認証の実装です。

何を作るか

どんな認証方法か

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

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

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

認証機能の実装

Spring Securiyデフォルトの ID/パスワードで認証する状態のものを2段階認証にして2段階目をFIDO認証にします。

前回登録機能を実装したソースに以下の実装を追加します。

  • ID/パスワード入力フォームの次にFIDO認証フォームを追加する
  • ただし、FIDOクレデンシャルが登録されていない場合はID/パスワードだけの認証にする
  • パスワードVerifyの後にFIDO認証結果のVerifyを行う

フロー図

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

1. Get Credential by UserId

セキュリティキーを使った2要素認証ができるかできないかをチェックします。

今回のサンプルはFIDOクレデンシャルが登録されている場合は2要素認証を必須にして、クレデンシャルが登録されていない場合はID/パスワードだけでログインできるようにします。

loginフォームでユーザーIDとパスワードを入力してloginボタンクリックしたらまずは何もチェックしないでlogin-fido2フォームに遷移します。

login-fido2フォームでは画面表示時のイベントでユーザーのFIDOクレデンシャルの有無をチェックします。
ここは LINE-FIDO2-Server の Get Credential by UserId エンドポイントに問い合わせます。

ユーザーIDに紐づくFIDOクレデンシャルがあればFIDO認証に進みます。FIDOクレデンシャルがない場合はFIDO認証せずにそのまま 4. Verify ID/Password に進みます。

LineFido2ServerServiceImpl.getCredentialsWithUsername()

override fun getCredentialsWithUsername(
  username: String,
): GetCredentialsResult {
  val userId = createUserId(username)
  val uriComponentsBuilder = UriComponentsBuilder.fromUriString(CREDENTIALS_URI)
  val uri = uriComponentsBuilder.queryParam("rpId", RP_ID)
  .queryParam("userId", userId)
  .build().toUri()
  val response = restTemplate.exchange(uri, HttpMethod.GET, null, GetCredentialsResult::class.java)
  return response.body!!
}

2. Get Auth Challenge

LINE-FIDO2-Server の Get Auth Challenge エンドポイントからFIDO認証するためのオプションを取得します。

Get Auth Challenge には AuthOptionRequest を指定して AuthOptionResponse を受け取ります。

AuthOptionResponse に指定するパラメータは以下の通り

  • RPID: localhost

    • 登録時と同じRPIDを指定します。

      サンプルアプリなので localhost にしています。

  • userId: ログインするユーザーID

    • フォームで入力されたユーザーIDをSHA256で加工にしています。
  • UserVerificationRequirement: DISCOURAGED

  • ユーザー認証レベルを指定します。(後述)

LineFido2ServerServiceImpl.getAuthenticateOption()

override fun getAuthenticateOption(
  userName: String,
): Pair<ServerPublicKeyCredentialGetOptionsResponse, String> {
  val authOptionRequest = 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
}

AuthOptionResponseServerPublicKeyCredentialGetOptionsResponse に入れ直してフロントに返却します。また、Get Auth Challengeによって独自のセッションIDが割り当てられるので fido2-session-id としてCookieにセットします(fido2-session-idはこの次の Send Auth Response で使います)。

UserVerificationRequirementについて

認証するときのユーザー認証レベルで、以下のいずれかの値を指定します。

  • REQUIRED

    • このオプションが指定されると、認証時にセキュリティキーのPIN または生体による認証を必須にします。
    • WeAuthnの認証操作のときにPIN入力や指紋認証が必要になります。
  • PREFERRED

    • このオプションは認証時にセキュリティキーのPIN または生体による認証が必須ではないという微妙な指定です。

      多分PINが未設定のセキュリティキーでも使えるということだと思いますが、このサンプルではそもそも登録時に UserVerificationRequirement.REQUIRED って指定しているんでPIN未設定のセキュリティキーは登録できないです。

  • DISCOURAGED

    • このオプションが指定されると、認証時にセキュリティキーのPIN または生体による認証がされません。その代わりにユーザーの存在と同意を確認するセキュリティキーへのタッチが必要です。

REQUREDとPREFERREDの認証画面に違いはありませんね、利用するセキュリティキーの状態とかで変わってくるんでしょう。

このサンプルでは UserVerificationRequirement.DISCOURAGED にしています。
パスワードは入力しているので2要素を満たしている、PINの入力が不要なぶん楽な操作で認証できるんでこれでいいかなという判断です。

本番環境ではどうすべきかちゃんと考えたほうがいいところです

3. WebAuthn

フロントのJava Scriptでユーザー認証をします。WebAuthnを使います。

Sample-Appから返ってきた ServerPublicKeyCredentialGetOptionsResponse をそのまま WebAuthn navigator.credentials.get() に引き渡します。
WebAuthnでユーザー認証が完了すると Assertionオブジェクト をもらって返ってきます。

getAssertion()

function getAssertion(options) {
  return navigator.credentials.get({publicKey: options, signal: abortSignal})
    .then(rawAssertion => {
    let assertion = {
      rawId: base64UrlEncode(rawAssertion.rawId),
      id: base64UrlEncode(rawAssertion.rawId),
      response: {
        clientDataJSON: base64UrlEncode(rawAssertion.response.clientDataJSON),
        userHandle: base64UrlEncode(rawAssertion.response.userHandle),
        signature: base64UrlEncode(rawAssertion.response.signature),
        authenticatorData: base64UrlEncode(rawAssertion.response.authenticatorData)
      },
      type: rawAssertion.type,
    };

    if (rawAssertion.getClientExtensionResults) {
      assertion.extensions = rawAssertion.getClientExtensionResults();
    }
    return Promise.resolve(assertion);
  })
    .catch(function(error) {
    logVariable("get assertion error", error);
    if (error == "AbortError") {
      console.info("Aborted by user");
    }
    return Promise.reject(error);
  });
}

さて、ここまでで

  • ID
  • Password
  • Assertion(FIDO認証結果)

が揃いました。ここから Spring Security の 認証処理になります。

POST /login とすると Spring Security の認証処理が始まります。

Spring Securityの認証処理

POST /login とすると Spring Security の認証処理が始まります。

これは SampleWebSecurityConfig.configure() の設定によるものです。

Spring Securityのデフォルトでは UsernamePasswordAuthenticationFilter が認証処理を行っているんですが、これは ID/Password の認証をするものです。2要素認証するためにはポイントポイントを拡張実装していく必要があります。

まず最初に POST /login の Controller に相当する処理です。

UsernamePasswordAuthenticationFilter を継承した UsernamePasswordAssertionAuthenticationFilter クラスを作成し、attemptAuthentication() をオーバーライドします。

attemptAuthentication()は POST /login の Controller に相当するメソッドで HttpServletRequest ,HttpServletResponse を引数に受け取ります。
HttpServletRequestから以下のデータを取り出します。

  • username: フォームで入力されたもの
  • password: フォームで入力されたもの
  • fido2-session-id: Get Auth Challengeで取得したもの
  • assertion: WebAuthnによる認証結果

これら全てが正しい値かどうか Verify します。attemptAuthentication()の中で自分でゴリゴリコード書いて全部やっちゃってもいいんですかね、だめですね
ちゃんとSpring Securityの仕組みに乗っかっていきましょう。

というわけで UsernamePasswordAuthenticationToken を継承した UsernamePasswordAssertionAuthenticationToken を作成して データを放り込んで authenticationManager.authenticate() に渡してやります。

UsernamePasswordAssertionAuthenticationFilter

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

    val json = request.getParameter("assertion")
    return if (!json.isNullOrEmpty()) {
      val username = obtainUsername(request) ?: ""
      val password = obtainPassword(request) ?: ""
      val sessionId = LineFido2Util.getFido2SessionId(request)
      val assertion = ObjectMapper().readValue(json, Assertion::class.java)

      val authRequest = UsernamePasswordAssertionAuthenticationToken(username, password, sessionId, assertion)
      setDetails(request, authRequest)
      authenticationManager.authenticate(authRequest)
    } else {
      super.attemptAuthentication(request, response)
    }
  }
}

UsernamePasswordAssertionAuthenticationToken

class UsernamePasswordAssertionAuthenticationToken(
    username:String,
    password:String,
    val sessionId: String,
    val assertion: Assertion,
): UsernamePasswordAuthenticationToken(username,password)

4. Verify ID/Password

まずは ID(username) と Password の検証です。

DaoAuthenticationProvider を継承したFido2AuthenticationProvideradditionalAuthenticationChecks() をオーバーライドしてその中でやります。

これは Spring Security のデフォルトの処理そのままなんで↓を実行するだけです。

super.additionalAuthenticationChecks(userDetails, authentication)

5. Verify Assertion(Send Auth Response)

次に Assertion (とfido2-session-id) の検証です。ID/Passwordと同じ additionalAuthenticationChecks() の中でやります。

検証は LINE-FIDO2-Server の Send Auth Response エンドポイントに放り投げるだけです。

以下のサンプルプログラムでは verifyAuthenticateAssertion() で Send Auth Response にPOSTリクエストしています。

Fido2AuthenticationProvider

@Component
class Fido2AuthenticationProvider(
  private val lineFido2ServerService: LineFido2ServerService,
) : DaoAuthenticationProvider() {
  override fun additionalAuthenticationChecks(
    userDetails: UserDetails,
    authentication: UsernamePasswordAuthenticationToken
  ) {
    // verify password
    super.additionalAuthenticationChecks(userDetails, authentication)

    if (authentication is UsernamePasswordAssertionAuthenticationToken) {
      // verify FIDO assertion
      if (!lineFido2ServerService.verifyAuthenticateAssertion(
        authentication.sessionId,
        authentication.assertion,
      )
         ) {
        throw BadCredentialsException("Invalid Assertion")
      }
    } else {
      // verify password only
      val getCredentialsResult = lineFido2ServerService.getCredentialsWithUsername(userDetails.username)
      if (getCredentialsResult.credentials.isNotEmpty()) {
        throw BadCredentialsException("Two-factor authentication required")
      }
    }
  }

  override fun doAfterPropertiesSet() {}
}

Assertionの検証がNGであれば BadCredentialsException を throw します。検証OKの場合はそのままメソッドをreturnすればオッケーです。

動作確認

ID / パスワード入力(記憶) → セキュリティキー(所持)

2段階2要素認証です。

Touch ID

Yubikey

ID / パスワード入力(記憶)

FIDOクレデンシャル登録されていない場合、パスワードだけの1要素認証となります。

まとめ

  • LINE-FIDO2-Server
  • Get Credential by UserId エンドポイント → FIDOクレデンシャルをUserIdを指定して取得する
  • Get Auth Challenge エンドポイント → WebAuthn(navigator.credentials.get) に指定する オプションを取得する
  • Send Auth Response エンドポイント → WebAuthn(navigator.credentials.get) の認証結果(Assertion)を検証する
  • Spring Security で パスワード+ナニカ、の2段階認証を実装するときは、以下のクラスを継承してカスタマイズする
    • UsernamePasswordAuthenticationFilter
    • UsernamePasswordAuthenticationToken
    • DaoAuthenticationProvider
  • 上記カスタマイズしたクラスは WebSecurityConfigurerAdapter.configure(http: HttpSecurity) をオーバーライドしたメソッドで登録する
    • http.addFilterAt()で登録する
  • ここまでのソースは GitHub に上げています。

おつかれさまでした

エラー処理とかjavascriptのお作法とかめちゃくちゃなところありますが Spring Securiy の基本と LINE FIDO2 Serverのことが少しわかった気がします。

Spring Securiy難しすぎ

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

Discussion