Spring Security&LINE FIDO2 Serverでパスキー認証を実装する #3
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 します。
<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
が働くようになります。
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が実行されますのでこのメソッドをオーバーライドして自分のやりたいように実装してユーザー情報を返します。
@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認証に進むようにする考慮はここが起点になります。
@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に教えてあげます。
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に教えてあげる必要があります。
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-fido2
にPOST
したらフィルタを実行する - setAuthenticationManager→
AuthenticationManager
を使う - setAuthenticationSuccessHandler→認証成功時は
/mypage
にリダイレクトする - setAuthenticationFailureHandler→認証失敗時は
/login&error
にリダイレクトする
6. FIDO認証フィルタ
WebAuthnをパスしてAssertionというオブジェクトを貰って /login-fido2 に POST します。
<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>
そうすると、すかさずFido2AuthenticationFilter
のattemptAuthentication
が実行されます。
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に登録します。
@Autowired
private lateinit var fido2AuthenticationProvider: Fido2AuthenticationProvider
override fun configure(
auth: AuthenticationManagerBuilder,
) {
auth.authenticationProvider(fido2AuthenticationProvider)
}
Fido2AuthenticationProvider
はauthenticate
とsupports
を実装します。
@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で以下のように設定します。
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認証に進むかどうかを判断することができます。
-
認証をパスしたことを示すロールを付与してアクセス制限をします。
-
ソースはこちら参照
おつかれさまでした
この実装が正しいかどうかいまいちわかりません。どこかにバグがあるかもです。
Discussion