Spring Securityでセキュリティを強化する #1

自社の社内SNSIPA ISEC セキュア・プログラミング講座:Webアプリケーション編 第2章 アクセス制限対策:ユーザー認証対策が話題になっていたので、Spring Securityを利用している場合に更にアプリケーション側でセキュリティを強化する場合にどうやるのがベターなのか考えてみます。

まずIPAのページでは以下の内容について言及しています。

  • 常にユーザIDとパスワードを求める
  • アカウントのロックアウト
  • パスワードフィルタ
  • パスワードの有効期限
  • ランダム化桁とチェック桁
  • ログインエラーメッセージ
  • パスワードリマインダの慎重な設定
  • パスワード再設定と再発行手順
  • 管理者権限の制限

これらのうち、Spring Securityでカバーできそうなのはアカウントのロックアウトくらいでしょうか。また、最近ではパスワードにSaltを追加するのは必須と言われているようなので、まずはこれら2つについてどうやるのかを考えていきます。

アカウントのロックアウト

SpringSecurityでは、ユーザ認証に成功するとAuthenticationSuccessEventが、ユーザ認証に失敗するとAuthenticationFailureBadCredentialsEventが発生しますので、これらのイベントを操作するためにApplicationListenerを実装したリスナークラスを用意するのが良さそうです。

ユーザ認証に失敗した場合

まずはユーザ認証失敗イベントのリスナーから。まだ処理は適当です。

public class AuthenticationFailureBadCredentialsEventListener implements
        ApplicationListener<AuthenticationFailureBadCredentialsEvent> {
    @Override
    public void onApplicationEvent(
            AuthenticationFailureBadCredentialsEvent event) {
        System.out.println("login failure : "
                + event.getAuthentication().getPrincipal());
    }
}

このリスナーの登録についてですが、3. The IoC containerを読むと、イベントが発生するとコンテナに登録されているApplicationListenerの実装クラスに自動で通知されるようなので、web.xmlではなくapplicationContext.xmlに登録します。

<bean id="authenticationFailureBadCredentialsEventListener"
    class="net.sasuke.tutorial.listener.AuthenticationFailureBadCredentialsEventListener" />
ユーザ認証に成功した場合

次にユーザ認証に成功した場合のイベントリスナーです。こちらも処理は適当に。

public class AuthenticationSuccessEventListener implements
        ApplicationListener<AuthenticationSuccessEvent> {
    @Override
    public void onApplicationEvent(AuthenticationSuccessEvent event) {
        System.out.println("login success : "
                + event.getAuthentication().getPrincipal());
    }
}
<bean id="authenticationSuccessEventListener"
    class="net.sasuke.tutorial.listener.AuthenticationSuccessEventListener" />
ロックアウトの条件

件のページではロックアウトの条件について

ログイン失敗回数やロックアウト期間は、利用上の利便性との兼ね合いにより設定する。

と書かれていますので、ここでは3回ログイン失敗したらロックアウト(いわゆる三振法)を取りたいと思います。ただし、1回ログイン成功すればこれまでの失敗はなしとします。

テーブルの準備

Spring Securityは標準でユーザテーブルと権限テーブルを用意していますが、そこにはログイン失敗回数というカラムが存在しないので、ユーザテーブルを拡張します。

CREATE  TABLE `tutorial`.`users` (
  `username` VARCHAR(50) NOT NULL ,
  `password` VARCHAR(64) NOT NULL ,
  `enabled` TINYINT(1) NOT NULL ,
  `failurecount` SMALLINT NOT NULL DEFAULT 0 ,
  PRIMARY KEY (`username`) );

failurecountが新たに追加したログイン失敗回数を表すカラムです。またpasswordはSHA-256でハッシュ化する予定なので64桁にしました。ちなみにenabledの型がTINYINTになっていますが、これはMySQL5.1の仕様でBOOLEANはTINYINT(1)と同義語であるためです。
MySQL :: MySQL 5.1 リファレンスマニュアル :: 10.1.1 数値タイプの概要

権限テーブルは標準のままで。

CREATE  TABLE `tutorial`.`authorities` (
  `username` VARCHAR(50) NOT NULL ,
  `authority` VARCHAR(50) NOT NULL ,
  PRIMARY KEY (`username`) );

なお、これらの標準テーブルについては、JdbcDaoImplJavadocに記載されています。

ユーザクラスの準備

Spring Securityの標準ユーザクラスとして、Userが存在しますが、先ほど追加したログイン失敗回数のフィールドがないのでこのクラスを拡張したオリジナルのユーザクラスを作成します。

public class MyUser extends User {
    private static final long serialVersionUID = -220654011834826964L;
    private int failurecount;
    public MyUser(String username, String password, boolean enabled,
            int failurecount, boolean accountNonExpired,
            boolean credentialsNonExpired, boolean accountNonLocked,
            Collection<? extends GrantedAuthority> authorities) {
        super(username, password, enabled, accountNonExpired,
                credentialsNonExpired, accountNonLocked, authorities);
        this.failurecount = failurecount;
    }

    public int getFailurecount() {
        return failurecount;
    }
    public void setFailurecount(int failurecount) {
        this.failurecount = failurecount;
    }
}

次にDBから取得した結果を基に上記のユーザ情報クラスを組み立てるクラスを作成します。

public class MyUserDetailService extends JdbcDaoImpl {
    @Override
    protected List<UserDetails> loadUsersByUsername(String username) {
        return getJdbcTemplate().query(getUsersByUsernameQuery(),
                new String[] { username }, new RowMapper<UserDetails>() {
                    public UserDetails mapRow(ResultSet rs, int rowNum)
                            throws SQLException {
                        String username = rs.getString(1);
                        String password = rs.getString(2);
                        boolean enabled = rs.getBoolean(3);
                        int failurecount = rs.getInt(4);
                        return new MyUser(username, password, enabled,
                                failurecount, true, true, true,
                                AuthorityUtils.NO_AUTHORITIES);
                    }
                });
    }
    @Override
    protected UserDetails createUserDetails(String username,
            UserDetails userFromUserQuery,
            List<GrantedAuthority> combinedAuthorities) {
        UserDetails user = super.createUserDetails(username, userFromUserQuery,
                combinedAuthorities);
        if (userFromUserQuery instanceof MyUser) {
            MyUser myUser = (MyUser) userFromUserQuery;
            return new MyUser(user.getUsername(), user.getPassword(), user
                    .isEnabled(), myUser.getFailurecount(), user
                    .isAccountNonExpired(), user.isCredentialsNonExpired(),
                    user.isAccountNonLocked(), user.getAuthorities());
        } else {
            return user;
        }
    }
}

これでだいたいの準備が整いましたが、一つ問題が残っています。それはどうやってUsersテーブルを更新するか、です。Springを利用する場合、Hibernateと組み合わせて利用することが多いかと思いますが、先ほど作成したMyUserはHibernateのEntityとはなり得ません。また、MyUserDetailServiceは見ての通りResultSetを直接操作しています。SpringSecurityではDBを利用したログイン認証を行う場合にJDBCを使用しているのでログイン失敗回数の操作はJDBCで行っても良いのかもしれませんが、アプリケーションでユーザ管理機能などを開発する場合はHibernateでDBを操作することになるかと思います。ここら辺をどうするかがまだ自分の中でちゃんと消化できていないのですが、今回はSpringSecurityに関わる部分はJDBC、それ以外はHibernateで、という風に切り分けたいと思います。何か良い方法があれば教えて頂けると幸いです。

ユーザ情報操作クラス

JDBCを利用してユーザ情報を操作するクラスを作成します。イベントリスナーから呼び出す必要があるので、インターフェースと実装クラスのペアで作成します。まずはインターフェースから。

public interface MyUserManager {
    /**
     * 指定されたユーザのログイン失敗回数をインクリメントします。
     * 
     * @param username
     *            ユーザ名
     */
    void incrementFailureCount(String username);

    /**
     * 指定されたユーザのアカウントの可不可、ログイン失敗回数を更新します。
     * 
     * @param username
     *            ユーザ名
     * @param enabled
     *            アカウントの可不可
     * @param failurecount
     *            ログイン失敗回数
     *
    void update(String username, boolean enabled, int failurecount);
}

続いて実装クラスです。ここでログイン失敗回数が3回以上の場合はアカウントの利用を不可にする処理を入れています。

public class MyUserManagerImpl extends JdbcDaoSupport implements MyUserManager {

    private static final String SQL_UPDATE_FAILURECOUNT = "UPDATE users SET enabled = ?, failurecount = ? "
            + "WHERE username = ?";

    /** ログイン失敗回数の最大値です。 */
    private int maxFailureCount;

    @Autowired
    private MyUserDetailService userDetailService;

    @Override
    public void incrementFailureCount(String username) {

        List<UserDetails> users = userDetailService
                .loadUsersByUsername(username);

        if (0 == users.size()) {
            return;
        }
        MyUser user = (MyUser) users.get(0);
        int failurecount = user.getFailurecount() + 1;
        boolean isEnabled = true;
        if (maxFailureCount <= failurecount) {
            isEnabled = false;
        }
        update(username, isEnabled, failurecount);
    }

    @Override
    public void update(final String username, final boolean isEnabled,
            final int failurecount) {
        getJdbcTemplate().update(SQL_UPDATE_FAILURECOUNT,
                new PreparedStatementSetter() {
                    @Override
                    public void setValues(PreparedStatement ps)
                            throws SQLException {
                        ps.setBoolean(1, isEnabled);
                        ps.setInt(2, failurecount);
                        ps.setString(3, username);
                    }
                });
    }
    /**
     * ログイン失敗回数の最大値を設定します。
     * 
     * @param maxFailureCount
     *            the maxFailureCount to set
     */
    public void setMaxFailureCount(int maxFailureCount) {
        Assert.isTrue(maxFailureCount > 0,
                "maxFailureCount value must be greater than zero");
        this.maxFailureCount = maxFailureCount;
    }
}

ログイン失敗回数の最大値については、設定ファイルで指定できるようにしました。SpringSecurityの設定ファイルに以下の内容を追記します。

    <beans:bean id="myUserManager" class="net.sasuke.tutorial.security.MyUserManagerImpl">
        <beans:property name="dataSource" ref="dataSource" />
        <beans:property name="maxFailureCount" value="3"/>
    </beans:bean>

後はそれぞれのリスナーに上記のクラスを呼び出す処理を追加します。

ログイン認証失敗の場合
public class AuthenticationFailureBadCredentialsEventListener implements
        ApplicationListener<AuthenticationFailureBadCredentialsEvent> {
    /** ユーザ情報管理クラスです。 */
    @Autowired
    private MyUserManager userManager;

    @Override
    public void onApplicationEvent(
            AuthenticationFailureBadCredentialsEvent event) {
        userManager.incrementFailureCount((String) event.getAuthentication()
                .getPrincipal());
    }
}
ログイン認証成功の場合
public class AuthenticationSuccessEventListener implements
        ApplicationListener<AuthenticationSuccessEvent> {
    /** ユーザ情報管理クラスです。 */
    @Autowired
    private MyUserManager userManager;

    @Override
    public void onApplicationEvent(AuthenticationSuccessEvent event) {
        MyUser user = (MyUser) event.getAuthentication().getPrincipal();
        userManager.update(user.getUsername(), user.isEnabled(), 0);
    }
}
ユーザ検索SQLの変更

最後にユーザ検索用のSQLにアカウントが有効であるかどうかを追加します。

    <beans:bean id="myUserDetailsService"
        class="net.sasuke.tutorial.security.MyUserDetailService">
        <beans:property name="dataSource" ref="dataSource" />
        <beans:property name="usersByUsernameQuery"
            value="SELECT username, password, enabled, failurecount FROM users WHERE username = ? AND enabled = true" />
    </beans:bean>

これでアカウントが有効の場合のみログイン認証を行うようになります。

まとめ

かなり長くなってしまいましたが、SpringSecurityを利用した場合にアカウントのロックアウトを行う場合はこのような感じでやるのがベターなのかなと思います。プログラムコード的には突っ込みどころが満載な気がしますが、取り敢えずこれをベースに進めていこうかなと。次回はパスワードのSalt対応について考えていきます。