はじめに
インターネットに Web アプリケーションを公開すると、ログイン認証を突破しようと、あらゆるところから攻撃を受けてしまいます。
ユーザーIDやメールアドレスと、パスワードの組み合わせを総当りで試すブルートフォース攻撃という攻撃がありますが、何の対策も取らないとログイン認証が突破されてしまいます。
このブルートフォース攻撃に対する対策が、ログイン認証にアカウントロック機能を追加することになります。
この記事では、アカウントロック機能とは何か、そして PHP での実装例を提示します。
目次
アカウントロック機能とは
アカウントロックとは、ログインが一定回数以上失敗したら、そのユーザーのアカウントをロックして、ログインをできなくする機能のことです。
アカウントをロックすることで、前述のブルートフォース攻撃の効率を大幅に低下させ、アカウントを守ることができます。
アカウントロックの解除は、実装によって変わりますが、一定時間が経ったら自動的に解除されるものや、電話で問い合わせて本人確認を行わないと解除できないものなどがあります。
一般的には、運用の負荷を考慮して、一定時間が経ったら自動的に解除されるものが多いのではないでしょうか。
アカウントロック機能で注意が必要なのが、アカウントロック機能で守れるのはパスワードを総当りする攻撃のみで、パスワードを固定してユーザーIDまたはメールアドレスを変える「リバースブルートフォース攻撃」には無力だということです。
リバースブルートフォース攻撃には、アカウントロック機能とは別に、一定時間に同じIPアドレスからの不正なログインをブロックするなどの別の対策が必要になります。
アカウントロック機能のPHP実装例
PHP でのアカウントロック機能の実装例になります。
環境は、PHP 7.2 + PDO + MySQL 8 です。
テーブル定義
データベース名は loginsample で、テーブル名は users とします。
また、users テーブルの定義は以下の通りです。
mysql> desc users; +--------------+------------------+------+-----+---------+----------------+ | Field | Type | Null | Key | Default | Extra | +--------------+------------------+------+-----+---------+----------------+ | id | int(10) unsigned | NO | PRI | NULL | auto_increment | | email | varchar(255) | NO | UNI | NULL | | | password | varchar(255) | NO | | NULL | | | failed_count | int(11) | NO | | 0 | | | locked_time | datetime | YES | | NULL | | +--------------+------------------+------+-----+---------+----------------+ 5 rows in set (0.00 sec)
画面遷移
ログイン認証の主な画面遷移は以下の通りです。以下とは別に、ユーザー登録画面があります。
画面イメージ
主な画面イメージは、以下の通りです。
・ログイン画面
・登録画面
パスワードの保存方法
パスワードは、PHP 標準にあるパスワードをハッシュ化するための、password_hash関数とpassword_verify 関数を使用します。
この2つの関数を使用すると、テーブルには以下のようにパスワードが保存されるようになります。
mysql> select * from users; +----+--------------------+--------------------------------------------------------------+--------------+-------------+ | id | email | password | failed_count | locked_time | +----+--------------------+--------------------------------------------------------------+--------------+-------------+ | 1 | user01@example.com | $2y$10$MKx6.KtyGmmk/f/nbRWhTOoLN2cwqxQU0PfdIJj0W0Zxhhzo8eNJG | 0 | NULL | | 2 | user02@example.com | $2y$10$/zPX2kMUhA0m1vHVW4NGjuxZUTSyZg6Na51uxC6fX.qyV7CoLi5d2 | 0 | NULL | +----+--------------------+--------------------------------------------------------------+--------------+-------------+ 2 rows in set (0.00 sec)
パスワードの保存方法の詳細は、以下の記事を参照してください。
サンプルコード
PHP のサンプルコードは、以下のようになります。
アカウントロック機能付きログイン機能
・function.php
共通関数や定数の定義を行います。アカウントロック機能の、ログイン失敗の上限回数や、アカウントロック時間(秒)を指定します。
サンプルプログラムでは、上限回数とアカウントロック時間を短く設定していますが、実運用では上限回数が10回程度、アカウントロック時間が30分から1時間程度あるとよいのではないでしょうか。
<?php define("DNS","mysql:host=mysql;dbname=loginsample;charset=utf8"); define("USER_NAME", "mysql"); define("PASSWORD", "Mysql@1234"); define("LOGIN_FAILED_LIMIT", 5); // 上限回数 define("LOGIN_LOCK_PERIOD", 30); // 秒 /* * CSRF トークン作成 */ function get_csrf_token() { $TOKEN_LENGTH = 16;//16*2=32byte $bytes = openssl_random_pseudo_bytes($TOKEN_LENGTH); return bin2hex($bytes); } /* * PDO の接続オプション取得 */ function get_pdo_options() { return array(PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::MYSQL_ATTR_MULTI_STATEMENTS => false, PDO::ATTR_EMULATE_PREPARES => false); } /* * ログイン画面へのリダイレクト */ function redirect_to_login() { header("HTTP/1.1 301 Moved Permanently"); header("Location: login.php"); } /* * 登録画面へのリダイレクト */ function redirect_to_register() { header("HTTP/1.1 301 Moved Permanently"); header("Location: register.php"); } /* * Welcome画面へのリダイレクト */ function redirect_to_welcome() { header("HTTP/1.1 301 Moved Permanently"); header("Location: welcome.php"); }
PDOの接続オプションについては、説明が必要でしょう。
以下のPDOのオプションは、MySQL との接続で、①エラー発生時は例外をスローするようにする、②MySQLのSQLの複文を禁止する、③静的プレースホルダを使用する、というセキュリティ対策のために指定するものです。
/* * PDO の接続オプション取得 */ function get_pdo_options() { return array(PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, // ① PDO::MYSQL_ATTR_MULTI_STATEMENTS => false, // ② PDO::ATTR_EMULATE_PREPARES => false); // ③ }
PDOのオプションの詳細については、いわゆる「徳丸本」でおなじみの「体系的に学ぶ 安全なWebアプリケーションの作り方 第2版 脆弱性が生まれる原理と対策の実践」を参照してください。
・login.php
ログイン画面になります。
<?php session_start(); require_once("function.php"); $_SESSION['token'] = get_csrf_token(); // CSRFのトークンを取得する header("Content-type: text/html; charset=utf-8"); ?> <!DOCTYPE html> <html lang="ja"> <body> <h1>ログイン</h1> <?php if ($_SESSION["error_status"] == 1) { echo "<h2 style='color:red'>メールアドレスまたはパスワードが異なります。</h2>"; } if ($_SESSION["error_status"] == 2) { echo "<h2 style='color:red'>不正なリクエストです。</h2>"; } if ($_SESSION["error_status"] == 3) { echo "<h2 style='color:red'>アカウントがロックされました。時間を空けてから再度お試しください。</h2>"; } //エラー情報のリセット $_SESSION["error_status"] = 0; ?> <form action="login_check.php" method="post"> <table> <tr> <td>メールアドレス:</td> <td><input type="text" name="email"></td> </tr> <tr> <td> パスワード: </td> <td> <input type="password" name="password"> </td> </tr> </table> <input type="hidden" name="token" value="<?php echo htmlspecialchars($_SESSION['token'], ENT_QUOTES, "UTF-8") ?>"> <input type="submit" value="ログイン"> <input type="reset" value="リセット"> </form> <br> <a href="register.php">登録画面へ</a> </body> </html>
・login_check.php
これがアカウントロック機能のメインです。実際に作成してみて、細かなバランスが必要だと感じました。
<?php session_start(); require_once("function.php"); //パラメーター取得 $email = $_POST['email']; $password = $_POST['password']; $token = $_POST['token']; //CSRF チェック if ($token != $_SESSION['token']) { $_SESSION = array(); session_destroy(); session_start(); // リダイレクト $_SESSION["error_status"] = 2; redirect_to_login(); exit(); } //ログイン判定 try { $pdo = new PDO(DNS, USER_NAME, PASSWORD, get_pdo_options()); // ユーザー抽出 $rows = login_get_user_rows($email, $pdo); // アカウントロックチェック if (count($rows) > 0 && !empty($rows[0]['locked_time'])) { $lock_time_diff = strtotime('now') - strtotime($rows[0]['locked_time']); // アカウントロック中 if ($lock_time_diff < LOGIN_LOCK_PERIOD) { // リダイレクト $_SESSION["error_status"] = 3; redirect_to_login(); exit(); } else { // アカウントロック期間終了だったらロック解除 unlock_login_account($email, $pdo); } } // ログイン認証 // メールアドレスアンマッチ if (count($rows) == 0) { // リダイレクト $_SESSION["error_status"] = 1; redirect_to_login(); exit(); } // パスワード認証失敗 if (!password_verify($password, $rows[0]['password'])) { // 失敗カウントアップ login_failed_count_up($email, $pdo); // 失敗カウント取得 $count = get_login_failed_count($email, $pdo); if ($count >= LOGIN_FAILED_LIMIT) { // アカウントロック lock_login_account($email, $pdo); // リダイレクト $_SESSION["error_status"] = 3; redirect_to_login(); exit(); } // リダイレクト $_SESSION["error_status"] = 1; redirect_to_login(); exit(); } // ログイン成功 // アカウントロック解除 unlock_login_account($email, $pdo); // セッションIDの振り直し session_regenerate_id(true); // リダイレクト $_SESSION['email'] = $rows[0]['email']; redirect_to_welcome(); exit(); } catch (PDOException $e) { die($e->getMessage()); } /* * メールアドレスからユーザー情報を取得する */ function login_get_user_rows($email, $pdo) { $sql = "SELECT * FROM users WHERE email = ?;"; $stmt = $pdo->prepare($sql); $stmt->bindValue(1, $email, PDO::PARAM_STR); $stmt->execute(); return $stmt->fetchAll(PDO::FETCH_ASSOC); } /* * ログイン失敗のカウントアップをする */ function login_failed_count_up($email, $pdo) { $sql = "UPDATE users SET failed_count = failed_count + 1 WHERE email = ?;"; $stmt = $pdo->prepare($sql); // トランザクションの開始 $pdo->beginTransaction(); try { $stmt->bindValue(1, $email, PDO::PARAM_STR); $stmt->execute(); // コミット $pdo->commit(); } catch (PDOException $e) { // ロールバック $pdo->rollBack(); throw $e; } } /* * ログイン失敗のカウントを取得する */ function get_login_failed_count($email, $pdo) { $sql = "SELECT failed_count FROM users WHERE email = ?;"; $stmt = $pdo->prepare($sql); $stmt->bindValue(1, $email, PDO::PARAM_STR); $stmt->execute(); $row = $stmt->fetch(PDO::FETCH_ASSOC); if (!empty($row)) { return $row['failed_count']; } return 0; // emailが該当しない場合は処理なし } /* * アカウントロックを行う */ function lock_login_account($email, $pdo) { $sql = "UPDATE users SET locked_time = ? WHERE email = ?;"; $stmt = $pdo->prepare($sql); // トランザクションの開始 $pdo->beginTransaction(); try { $stmt->bindValue(1, date('Y-m-d H:i:s'), PDO::PARAM_STR); $stmt->bindValue(2, $email, PDO::PARAM_STR); $stmt->execute(); // コミット $pdo->commit(); } catch (PDOException $e) { // ロールバック $pdo->rollBack(); throw $e; } } /* * アカウントのアンロックを行う */ function unlock_login_account($email, $pdo) { $sql = "UPDATE users SET failed_count = 0, locked_time = NULL WHERE email = ?;"; $stmt = $pdo->prepare($sql); // トランザクションの開始 $pdo->beginTransaction(); try { $stmt->bindValue(1, $email, PDO::PARAM_STR); $stmt->execute(); // コミット $pdo->commit(); } catch (PDOException $e) { // ロールバック $pdo->rollBack(); throw $e; } }
・welcome.php
ログイン後の Welcome 画面です。
<?php require_once("function.php"); session_start(); header("Content-type: text/html; charset=utf-8"); //強制ブラウズはリダイレクト if (!isset($_SESSION['email'])){ $_SESSION["error_status"] = 2; redirect_to_login(); exit(); } ?> <html> <body> <h1>ようこそ</h1> <?php if ($_SESSION["error_status"] == 2) { echo "<h2 style='color:red'>不正なリクエストです。</h2>"; } //エラー情報のリセット $_SESSION["error_status"] = 0; ?> <form action="logout.php" method="post"> <input type="hidden" name="token" value="<?php echo htmlspecialchars($_SESSION['token'] , ENT_QUOTES, "UTF-8") ?>"> <input type="submit" name="logout" value="ログアウト"> </form> </body> </html>
・logout.php
ログアウト処理を行います。
<?php session_start(); require_once("function.php"); //パラメーター取得 $token = $_POST['token']; //CSRF チェック if ($token != $_SESSION['token']) { // リダイレクト $_SESSION["error_status"] = 2; redirect_to_welcome(); exit(); } //セッション破棄 $_SESSION = array(); session_destroy(); //リダイレクト redirect_to_login();
ユーザー登録機能
・register.php
ユーザーの登録画面です。
<?php session_start(); require_once("function.php"); $_SESSION['token'] = get_csrf_token(); header("Content-type: text/html; charset=utf-8"); ?> <!DOCTYPE html> <html lang="ja"> <head> <meta charset="UTF-8"> </head> <body> <h1>登録画面</h1> <?php if ($_SESSION["error_status"] == 2) { echo "<h2 style='color:red'>不正なリクエストです。</h2>"; } if ($_SESSION["error_status"] == 5) { echo "<h2 style='color:red'>入力されてない項目があります。</h2>"; } if ($_SESSION["error_status"] == 6) { echo "<h2 style='color:red'>パスワードが一致しません。</h2>"; } //エラー情報のリセット $_SESSION["error_status"] = 0; ?> <form action="register_check.php" method="post"> <table> <tr> <td>メールアドレス </td> <td><input type="text" name="email"></td> </tr> <tr> <td>パスワード</td> <td><input type="password" name="password" id="password" onkeyup="setMessage(this.value);"></td> <td><div id="pass_message"></div></td> </tr> <tr> <td>パスワード(確認)</td> <td><input type="password" name="confirm_password" id="confirm_password" onkeyup="setConfirmMessage(this.value);"></td> </tr> </table> <input type="hidden" name="token" value="<?php echo htmlspecialchars($_SESSION['token'], ENT_QUOTES, "UTF-8") ?>"> <input type="submit" value="登録"> <input type="reset" value="リセット"> <input type="button" value="戻る" onclick="document.location.href='login.php';"> </form> </body> </html>
・register_check.php
データのチェックと確認画面です。
<?php require_once("function.php"); session_start(); header("Content-type: text/html; charset=utf-8"); $email = $_POST['email']; $password = $_POST["password"]; $confirm_password = $_POST["confirm_password"]; $token = $_POST['token']; //CSRF チェック if ($token != $_SESSION['token']) { $_SESSION = array(); session_destroy(); session_start(); // リダイレクト $_SESSION["error_status"] = 2; redirect_to_register(); exit(); } // 必須項目チェック if (empty($email) || empty($password) || empty($confirm_password)) { $_SESSION["error_status"] = 5; redirect_to_register(); exit(); } if ($password != $confirm_password) { //パスワード不一致 $_SESSION["error_status"] = 6; redirect_to_register(); exit(); } ?> <!DOCTYPE html> <head> <meta charset="utf-8"> </head> <html lang="ja"> <body> <h1>確認画面</h1> <h2>登録しますか?</h2> <form action="register_submit.php" method="post"> <table> <tr> <td>メールアドレス</td> <td><?php echo htmlspecialchars($email, ENT_QUOTES, "UTF-8") ?></td> </tr> </table> <input type="hidden" name="email" value="<?php echo htmlspecialchars($email , ENT_QUOTES, "UTF-8") ?>"> <input type="hidden" name="password" value="<?php echo htmlspecialchars($password , ENT_QUOTES, "UTF-8") ?>"> <input type="hidden" name="token" value="<?php echo htmlspecialchars($_SESSION['token'] , ENT_QUOTES, "UTF-8") ?>"> <input type="submit" value="登録"> <input type="button" value="戻る" onclick="history.back();"> </form> </body> </html>
・register_submit.php
ユーザーの登録処理と結果画面です。
<?php require_once("function.php"); session_start(); $email = $_POST['email']; $password = $_POST['password']; $token = $_POST['token']; //CSRF チェック if ($_SESSION['token'] != $_POST['token']) { $_SESSION = array(); session_destroy(); session_start(); // リダイレクト $_SESSION["error_status"] = 2; redirect_to_register(); exit(); } // 登録 try { $pdo = new PDO(DNS, USER_NAME, PASSWORD, get_pdo_options()); $sql = "INSERT INTO users (email, password) values (?, ?);"; $stmt = $pdo->prepare($sql); // パスワードハッシュ化 $hashed_password = password_hash($password, PASSWORD_DEFAULT); // トランザクションの開始 $pdo->beginTransaction(); try { $stmt->bindValue(1, $email, PDO::PARAM_STR); $stmt->bindValue(2, $hashed_password, PDO::PARAM_STR); $stmt->execute(); // コミット $pdo->commit(); } catch (PDOException $e) { // ロールバック $pdo->rollBack(); throw $e; } } catch (PDOException $e) { die($e->getMessage()); } header("Content-type: text/html; charset=utf-8"); ?> <!DOCTYPE html> <html> <head> <meta charset="utf-8"> </head> <body> <h1>登録完了</h1> 登録が完了しました。<br> <br><br> <a href="login.php">ログイン画面に戻る</a> </body> </html>
おわりに
ログイン認証にアカウントロック機能を追加する方法をご紹介しました。
以外と簡単なようで、細かな部分で考慮が必要だったりします。
ネットでもアカウントロック機能の実装例はほぼなかったので、この記事が議論のきっかけとなると幸いです。
参考
Webアプリケーションセキュリティに関する記事は、以下のページにまとまっています。ぜひご確認ください。
コメント