😺

パスワードブルートフォースへの対策

2021/08/30に公開

目次ページ https://zenn.dev/gallu/articles/05335420b8e585

前提

パスワードブルートフォースへの対策は、それなりに重要だと思います(場所にもよりますが、「必須」と見なされる事も多いように思われます)。
そのため、パスワードブルートフォース対策用の実装を理解していきましょう。

想定するテーブルレイアウト

CREATE TABLE `ログインアカウント` (
  `login_id` varbinary(256) NOT NULL COMMENT 'ログインID',
  `password` varbinary(256) NOT NULL COMMENT 'パスワード(password_hash()使用)',
  `error_num` int unsigned NOT NULL default 0 COMMENT 'ログインエラー回数',
  `lock_datetime` datetime default NULL COMMENT 'ロック時間: ここが未来日付なら、このアカウントは一時的にログイン出来ない状態になっている',
  PRIMARY KEY (`login_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='1レコードが「(1ユーザの)ログイン情報」なテーブル'

別解として

-- 
CREATE TABLE `ログインアカウント` (
  `login_id` varbinary(256) NOT NULL COMMENT 'ログインID',
  `password` varbinary(256) NOT NULL COMMENT 'パスワード(password_hash()使用)',
  PRIMARY KEY (`login_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='1レコードが「(1ユーザの)ログイン情報」なテーブル'
-- 
CREATE TABLE `ログインエラーチェック` (
  `login_id` varbinary(256) NOT NULL COMMENT 'ログインID',
  `error_num` int unsigned NOT NULL default 0 COMMENT 'ログインエラー回数',
  `lock_datetime` datetime default NULL COMMENT 'ロック時間: ここが未来日付なら、このアカウントは一時的にログイン出来ない状態になっている',
  PRIMARY KEY (`login_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='1レコードが「(1ユーザの)ログインエラーを把握する」テーブル'

解説

基本的には

  • パスワードでエラーだった場合、error_numをインクリメント
  • error_num が「指定された回数」以上なら、lock_datetime に時刻を設定してログインをロックする
    という処理をします。
    ただ、上述のために
  • lock_datetime に未来時刻が入っている場合は、パスワード比較以前の問題として認証NGとする
    という処理が必要なのと、「連続でパスワードがn回エラーならロック」のために
  • ログインに成功したら、error_num を一端クリアする
    という処理が必要になります。

テーブルについては、基本「いわゆるUsers」的な所にerror_numとlock_datetimeを加える、でよいと思いますが、「比較的読み書きの激しいカラム、と、基本読み込みのみのカラム、とを切り分ける」設計をする所もあるので、その場合は切り出してもよいかと思います。
一端、本実装では「1枚のテーブルで片付ける」実装でコードを記述します。

実装

入力は一端、 $_POST['login_id']$_POST['password'] で取得可能である、とします。
また、DBハンドルは $dbh にすでに(PDOインスタンスが)入っているもの、とします。
また、「IDが違うのかpasswordが違うのか」がわかるのはあまり好ましくないので、「処理を簡単に関数化」して、戻り値をbooleanにしてエラー処理をします。
関数内の $dbh は「なんらかの方法で取得しているもの」としてください。

実装例

class Authentication
{
    // 定数
    const ERROR_COUNT_LIMIT = 5; // パスワード連続エラーの限界値(これを超えたらロック)
    const ERROR_LOCK_TIME = 120; // パスワード連続エラーの時のロック時間(単位:分)

    /**
     * 認証処理本体
     *
     * @param string $login_id ログインID
     * @param string $password ログインパスワード
     * @return array|null 認証の可否(arrayなら認証成功、nullなら認証失敗)
     */
    public static function login(string $login_id, string $password) : ?array
    {
        // ごく最低限のvalidate
        if ( ('' === $id)||('' === $password) ) {
            // ログイン失敗
            return null;
        }

        // プリペアドステートメントの作成
        $pre = $dbh->prepare('SELECT * FROM ログインアカウント WHERE login_id=:login_id;');
        // 値のバインド
        $pre->bindValue(':login_id', $login_id);
        // SQLの実行
        $r = $pre->execute();
        // レコードの取得
        $account = $pre->fetch( \PDO::FETCH_ASSOC );

        // レコードが空なら
        if (false === $account) {
            // ログイン失敗
            return null;
        }

        // ロック日付がnull以外、かつ未来日付なら「ロック中」なのでログイン失敗
        if ((null !== $account['lock_datetime'])&&(strtotime($account['lock_datetime']) > time())) {
            // XXX ここで、ログなり管理者mailなり本人へのmailなりで「ロック中なのにログインをしてこようとした」旨の連絡を入れてもよいでしょう
            // ログイン失敗
            return null;
        }

        // パスワードを比較
        if (false === password_verify($password, $account['password'])) {
            // 作業領域の確保
            $sql = 'UPDATE ログインアカウント SET ';
            $update_datum = [];

            // パスワード設定に失敗したのでエラーをカウント
            $sql .= ' error_count = :error_count ';
            $update_datum[':error_count'] = $account['error_count'] + 1;
            
            // 回数が限度を超えたら
            if (static::ERROR_COUNT_LIMIT < $update_datum['error_count']) {
                // ロック時間を設定する
                $sql .= ', lock_datetime = :lock_datetime ';
                $update_datum[':lock_datetime'] = date('Y-m-d H:i:s', time() + (static::ERROR_LOCK_TIME * 60));
                // XXX ここで、ログなり管理者mailなり本人へのmailなりで「ロックした」旨の連絡を入れてもよいでしょう
            }

            // UPDATE文の最後を記載
            $sql .= ' WHERE login_id=:login_id;';

            // DBをupdate
            $pre = $dbh->prepare($sql);
            // 値のバインド
            $pre->bindValue(':login_id', $login_id);
            foreach($update_datum as $k => $v) {
                $pre->bindValue($k, $v);
            }
            // SQLの実行
            $r = $pre->execute(); // XXX

            // ログイン失敗
            return null;
        }
        // else
        // パスワード比較に成功したので、必要ならレコードをupdateしてから正常終了
        // XXX 判定文無しで「毎回update」のほうが実装は楽ですが、判定入れたほうがDBへの負荷はちょっとだけ減ります
        $update_datum = [];
        // エラーカウントの確認: 0以外なら0にする(リセットする)
        if (0 != $account['error_count']) {
            $update_datum['error_count'] = 0;
        }
        // ロック時間の確認: null以外ならnullにしておく(リセットする)
        if (null != $account['lock_datetime']) {
            $update_datum['lock_datetime'] = null;
        }
        // update対象の情報があればupdateする
        if ([] !== $update_datum) {
            // SQLの動的な作成
            $sql = 'UPDATE ログインアカウント SET '
                 . implode(', ', array_map(function($s) { return "{$s} = :{$s}"; }, array_keys($update_datum)))
                 . ' WHERE login_id=:login_id;';
            // データのbindと実行
            $pre = $dbh->prepare($sql);
            // 値のバインド
            $pre->bindValue(':login_id', $login_id);
            foreach($update_datum as $k => $v) {
                $pre->bindValue($k, $v);
            }
            // SQLの実行
            $r = $pre->execute(); // XXX
        }

        // ログイン成功
        // XXX update_datumにデータがある場合はそちらが最新のはずなので、加算演算子使って配列を上書きする(SELECT再発行してもいいんだけどDBアクセスが勿体ないので)
        return $update_datum + $account;
    }
}

// ログイン
$login_account = Authentication::login(strval($_POST['login_id'] ?? ''), strval($_POST['password'] ?? ''));
if (null === $login_account) {
    // ログイン失敗
    echo 'NG';
    exit;
}
// XXX 以下、認可処理に続く

Discussion