blog

Spring Security の認証処理をカスタマイズ

今までSpring Security の認証処理を理解できていなかった。
単純なパスワード突合だけなら、UserDetailsService を実装してDB からselect するだけで良いので何とかそれで乗り切っていた。
しかしながら、現在のプロジェクトで複雑なログイン要件が追加されたため、それだけでは立ち行かなくなってしまった。

参考までに追加された要件

  • パスワードを連続5回失敗失敗したらアカウントをロックする
  • パスワード変更の有効期限を過ぎたらアカウントをロックする

いろいろ探しところ、DaoAuthenticationProvider のadditionalAuthenticationChecks() を継承すると非常に柔軟に実装できることが分かった。

コードはこちら。

@Component
@Slf4j
public class AppAuthenticationProvider extends DaoAuthenticationProvider {

  public static final String DEFAULT_LOGIN_ERROR = "ユーザー名またはパスワードが違います";
  public static final String ERROR_TOO_MANY_ATTEMPTS = "連続5回ログインに失敗しました";
  public static final String ERROR_PASSWORD_EXPIRED = "パスワードの有効期限が過ぎています";

  private final PlatformTransactionManager transactionManager;
  private final AccountRepository accountRepository;

  public AppAuthenticationProvider(PasswordEncoder passwordEncoder, UserDetailsService userDetailsService, PlatformTransactionManager transactionManager, AccountRepository accountRepository) {
    super(passwordEncoder);
    // 親クラスで必要
    setUserDetailsService(userDetailsService);
    this.transactionManager = transactionManager;
    this.accountRepository = accountRepository;
  }

  @Override
  protected void additionalAuthenticationChecks(
      UserDetails userDetails, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {

    // 5回以上ログイン失敗している場合はログイン不可
    if (LOGIN_FAIL_MAX_COUNT <= userDetails.getLoginFailCount()) {
      throw new AppLoginException(ERROR_TOO_MANY_ATTEMPTS);
    }

    // パスワード変更期限を過ぎていたらログイン不可
    if (userDetails.getPasswordExpiredDate() != null && now().isAfter(account.getPasswordExpiredDate())) {
      throw new AppLoginException(ERROR_PASSWORD_EXPIRED);
    }

    // パスワードチェック
    try {
      // 親クラス(DaoAuthenticationProvider)のパスワードチェックを呼び出す
      // パスワード相違時は例外がthrow される
      super.additionalAuthenticationChecks(userDetails, authentication);
      // パスワード一致時はカウンターをリセットする
      new TransactionTemplate(transactionManager).executeWithoutResult(status -> {
        // spring security から呼ばれるのでトランザクションを持っていない
        accountRepository.resetFailCounter(userDetails.getAccountId());
      });
    } catch (AuthenticationException ae) {
      // パスワード相違時は、カウンターをインクリメント
      new TransactionTemplate(transactionManager).executeWithoutResult(status -> {
        // spring security から呼ばれるのでトランザクションを持っていない
        accountRepository.incrementFailCounter(userDetails.getAccountId());
      });
      // すでに4回失敗していたら、エラーメッセージを変更する
      if ((LOGIN_FAIL_MAX_COUNT - 1) <= userDetails.getLoginFailCount()) {
        throw new AppLoginException(ERROR_TOO_MANY_ATTEMPTS);
      } else {
        throw new AppLoginException(DEFAULT_LOGIN_ERROR);
      }
    }
  }
}

こちらを@Bean 登録しすると有効になる。

@Bean
public AuthenticationManager authenticationManager(AppAuthenticationProvider provider) {
  return new ProviderManager(provider);
}

ビルトインの機能を使って、UserDetails のisAccountNonExpired() で似たような実装を行うこともできる。その場合、すでに4回失敗した状態でパスワード相違した時にメッセージを変更することができないと思った。

throw された例外は、formLogin のfailureHandler に入ってくるので、それを一時的にセッションへ格納し、ログイン画面を表示するコントローラでセッションの値を取得して画面に表示すると良い。
セッションの値は一度限りで即時削除する。(redirectAttribute と同じだがここでは使えない)

また、failureHandler を使わず、additionalAuthenticationChecks() でセッションに例外を詰めることもできるが、ユーザ名不正の場合は、additionalAuthenticationChecks() が呼ばれないのでハンドリングできない。(1敗)

【広告】

コメント

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です