yuusuke-roughの日記

Java,SpringBoot,趣味等

二要素認証のログイン画面遷移 in Spring Security

はじめに

STOMP over WebSocketもInterceptorを挟む処理の実装途中だが、今回は書きかけの左記問題から少し距離を置いて、既存の実装の見直しを行う。

今回のテーマは、「ログイン画面を二段階構成にして、ユーザ名とパスワードを入力後にワンタイムパスワード入力画面を表示する」を行う。

 

内容

まずは、実装方法の検討から...

フォームログイン :: Spring Security - リファレンス

デフォルトのユーザ名とパスワードでの認証を行い、そこで保持しているAuthenticationを元に、OTPの認証を行うという二重での認証を考える。

AuthenticationSuccessHandler (spring-security-docs API) - Javadoc

onAuthenticationSuccessでユーザがOTPUserかどうかで遷移先を変更する。

 

挙動を確認すると、SecurityFilterChainでは、認証時のログイン先を指定するdefaultSuccessUrlとsuccessHandlerは同時に使用できないようだ。

通常ユーザーのホーム画面遷移は、HttpServletResponseで問題ない。

response.sendRedirect("/home");

 

次に、OTPユーザのAuthenticationProviderでの認証を考える。

1.ユーザー名とパスワードの認証のみを行い、OTPユーザーには仮認証にあたるロールを与える。

AuthenticaitonProviderにて、

String verificationCode = *1.getVerificationCode();

上記が入力されていない時点で、仮認証のロールを付与する。

if(verificationCode.isEmpty()) {
                
                Authentication result = super.authenticate(auth);
                List<GrantedAuthority> OTPUserRole = new ArrayList<>();
                OTPUserRole.add(new SimpleGrantedAuthority("OTP_USER"));
                
                return new UsernamePasswordAuthenticationToken(result.getPrincipal(), result.getCredentials(), OTPUserRole);
            }

※OTP_USERはDBのROLEテーブルに追加済。権限はnullとする。

このコードの続きだが、もちろん、verificationCodeがあれば、OTPと通常の認証のダブルチェックを経て、通常ユーザと同じロールを持ったAuthenticaitonが付与されるようにしている。

 

onAuthenticationSuccess側

public class CustomAuthenticationSuccessHandler implements AuthenticationSuccessHandler{
    private final SiteUserRepository siteUserRepository;
    public CustomAuthenticationSuccessHandler(SiteUserRepository siteUserRepository) {
        this.siteUserRepository = siteUserRepository;
    }

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException {
        SiteUser user = siteUserRepository.findByMail(authentication.getName());
        if(user.isUsing2FA() && authentication.getAuthorities().contains(new SimpleGrantedAuthority("OTP_USER"))) {
            System.out.println("OTPゆーざです。");
        }else {
            response.sendRedirect("/home");
        }
    }

}

以上、OTPユーザと通常ユーザを分ける事ができた。

 

2.successHandlerにて、仮認証のOTPユーザは専用の入力ページに遷移する。

Controller

@GetMapping("/OTPLogin")
    public String OTPLogin() {
        return "OTPLogin";
    }

 

successHandler

response.sendRedirect("/OTPLogin")

 

3.OTPを入力をすると既存のAuthenticationと合わせてOTP認証が試みられる。

POST先をHttpSecurity#formLogin()で指定しているURLに送れないのは、使用されるFilterが「調べた内容」におけるUsernamePasswordAuthenticationFilterにあたる。

アーキテクチャー :: Spring Security - リファレンス セキュリティーフィルターの項参照 

実行順で言うと何のFilterが既存のAuthenticationを初期化しているのかわからない。

 

UsernamePasswordAuthenticationFilterのソースコードを確認すると、attempAuthentication()内のUsernamePasswordAuthenticationTokenにて、ユーザ名のパラメータとパスワードのパラメータを利用して、unauthenticated()を呼び出し、新たにUsernamePasswordAuthenticationTokenを作成しなおしている。

つまり、初期化ではなく、POSTされたこの二つのパラメータに応じて再作成されているだけだった。

また、切り分けと前回試した際に他のURLにもカスタムフィルターが反応する現象を下記と考える。

java - Spring Security filter chain not ignoring specified path - Stack Overflow

 

使用するフィルターは下記。

AbstractAuthenticationProcessingFilter (spring-security-docs API) - Javadoc

コンストラクタで処理するURLを含むことができる。(ソースコードではsetFilterProcessesUrl()を使用している。)

 

次に、AuthenticationProvider内で認証するためにOTPのコードをDetailsに含める。

一連の流れをUsernamePasswordAuthenticationFilterのソースコードから読むと、setDetails(request,autheRequest)で、指定したauthenticationDetailsSourceを呼び出し、authRequest、つまりUsernamePasswordAuthenticationTokenに追加情報のDetailを含ませる。最後に、このFilterに設定されているAuthenticationManagerのauthenticate()で認証してしまう。(直接、UsernamePasswordAuthenticationToken.setDetails()にしようか迷った。)

 

Cannot invoke "Object.equals(Object)" because the return value of "org.springframework.security.core.Authentication.getCredentials()" is null

Credentialsは認証が終わった後に削除されるのでnull...

successHandlerを呼び出している時点でnullになっている。

 

UsernamePasswordAuthenticationTokenにて

@Override
    public void eraseCredentials() {
        super.eraseCredentials();
        this.credentials = null;
    }

ここかなと思ったら既にあった。

java - SecurityContextHolder.getContext().getAuthentication().getCredentials() returns null after authentication - Stack Overflow

ソースコードもっと読むのはまたの機会に...

Credentialsにパスワードを長期保存ってあまり気乗りしない...

 

ひとまず、RoleとPrincipalとOTPで認証を行う。

 

記述量だけが増えるので概要を書くと上記。

※以下、ハマったポイント

UsernamePasswordAuthenticationFilterとOTPAuthenticationProcessingFilterが共に実装されており、OTPAuthenticationProcessingFilterに、CustomAuthenticactionProviderとCustomOTPAuthenticationProviderを持つAuthenticationManagerを実装すると、左記AuthenticationManagerを通過せずにデフォルトのAuthenticationManagerが使用される。

公式リファレンスより、

AuthenticationManager は、Spring Security のフィルターが認証を実行する方法を定義する API です。返された Authentication は、AuthenticationManager を呼び出したコントローラー(つまり、Spring Security の Filters インスタンス)によって SecurityContextHolder に設定されます。Spring Security の Filters インスタンスと統合していない場合は、SecurityContextHolder を直接設定でき、AuthenticationManager を使用する必要はありません。

よって、各Filterで使うAuthenticaitonProviderとAuthenticationManagerを明記する事で改善した。

 

記事を分ける

4./OTPLoginはROLEがOTP_USERのみアクセスできるようにする。

5.ROLEにUSERのないユーザーの認可調整

 

-------------------------------------------------------------------------------------------------

調べた内容

整理:

 

各フィルターが呼び出される順番について

拡張して、独自の機能をつけるには? | Java Springの逆引きメモ

 

現在のWebアプリの動作を調べると...

Will secure any request with [org.springframework.security.web.session.DisableEncodeUrlFilter@446ef8f8, org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter@1b632097, org.springframework.security.web.context.SecurityContextHolderFilter@2e5bbd7a, org.springframework.security.web.header.HeaderWriterFilter@54eb00d9, org.springframework.security.web.csrf.CsrfFilter@3280f5f1, org.springframework.security.web.authentication.logout.LogoutFilter@378705aa, org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter@2865923e, org.springframework.security.web.session.ConcurrentSessionFilter@23cf2b2c, org.springframework.security.web.savedrequest.RequestCacheAwareFilter@47207eeb, org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter@67eade4e, org.springframework.security.web.authentication.rememberme.RememberMeAuthenticationFilter@36f661bf, org.springframework.security.web.authentication.AnonymousAuthenticationFilter@1e9d41dd, org.springframework.security.web.session.SessionManagementFilter@5d10719b, org.springframework.security.web.access.ExceptionTranslationFilter@23dddc55, org.springframework.security.web.access.intercept.AuthorizationFilter@74a057c9]

SecurityContextHolderFilter (spring-security-docs API) - Javadoc

SecurityContextRepository を使用して SecurityContext を取得し、それを SecurityContextHolder に設定する Filter

...

UsernamePasswordAuthenticationFilter (spring-security-docs API) - Javadoc

認証フォームの送信を処理します。Spring Security 3.0 の前に AuthenticationProcessingFilter と呼ばれていました。

ConcurrentSessionFilter (spring-security-docs API) - Javadoc

リクエストごとに SessionRegistry から SessionInformation を取得し、セッションが期限切れとしてマークされているかどうかを確認します。

 

感想

最近はソースコードに触れる場面が増えたので、以前より理解の質も速度も上がったので楽しいです。

しかし、依然として、近況はお世話になったエンジニアの方々へ会わす顔がない状況には変わりません。

社内と社外の方々から指摘された事を実践して、「何が足りないのか」を自問して今に至りますが、完成には程遠いです。

また、技術以外では、進歩に相反して「何で東京に来たのだろう」という問いが生まれます。私がどうとらえるかどうかの問題ではないので、これは解消できません。

いずれ安心して、ムエタイジムに通ったり、もっと気持ちに余裕のある生活を送れるようになりたいです。

*1:CustomWebAuthenticationDetails) auth.getDetails(