😺
パスワードブルートフォースへの対策
目次ページ 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