Web アプリケーションの自動ログイン方法をネットで調べてみると、内容が不完全だったりセキュリティ上問題のある情報しか見つかりません。自動ログインは一般的であるにも関わらず、議論の土台すらネット上で示されていないのは問題だと感じます。
という訳で、タイトルは釣りですが、自動ログイン処理をどのように行えばよいか真面目に考察し、PHP での実装例を示してみます。
本当は、セキュリティでガチガチに固めたい場合は、自動ログインは実装しない方が安全なのですが、自動ログインそのものに対する課題は前提にあるとし、その課題を解消する方向で考えていきます。
まずは、自動ログインの定義を明確にしましょう。
「自動ログイン」とは、一度ログインしたら「ユーザーの選択」により、2回目以降のログインを省略できる機能のこととします。この機能が目的とするのは、ログインというユーザーの負担を省略することにより、ユーザーの負担を減少させ、ユーザー体験を向上させることです。
ここで「ユーザーの選択」を強調していることに注意してください。自動ログインはセキュリティレベルを下げる可能性があるため、あくまでオプションとして提供し、採用自体はユーザーに任せています。
また、自動ログインは、利便性とセキュリティの確保のため、以下の機能を持つものとします。
1.複数ブラウザ、複数パソコンで個別に管理される
2.想定されるセキュリティの脆弱性に対処する
1については、1つのブラウザやパソコンではログアウトして自動ログインを無効にするけれども、他の環境では自動ログインを継続するようなケースを想定しています。例えば、会社のパソコンではログアウトするけれども、自宅のパソコンでは自動ログインを有効にしておきたいようなケースです。
2がこの記事の主題です。ここをなんとかしないと最強の自動ログインは実現できません。
まず、大前提として自動ログインでは、以下の2点が担保されていなければ安全とは言えません。
・HTTPS による通信を行う
・ログイン画面から HTTPS にする
ログイン画面から HTTPS にするのは、ログイン画面が改ざんされていないことを保証するためです。ユーザーに安心感を与えることにもなるので、必須の対応です。
また、自動ログインを実現するためには、Cookie を利用することが必須です。しかし、Cookie に以下の情報を格納して自動ログインを実現することは NG です。暗号化しようがハッシュ化しようが NG なものは NG です。
・ユーザーID
・パスワード
これは、Cookie の性質として、Cookie の内容は人間が簡単に見ることができるし、クロスサイトスクリプティングの脆弱性によって攻撃者に盗まれる可能性があるためです。攻撃者の手に渡れば、これらの情報は簡単に悪用されてしまいます。
別の自動ログインの実現方法として、セッションの有効期間を2週間などの長期間にするとの方法も考えられますが、この場合、2つの問題が起こりえます。
1つ目は、サーバーリソースの問題です。自動ログインを必要としないユーザーを含めた多数のユーザーがログイン状態になって、サーバーのメモリを圧迫します。
2つ目の方が問題なのですが、長期間セッション状態を維持するということは、セッション ID を固定化するということでもあります。
これにより起こりうるリスクは、セッション ID の漏洩によってセッションハイジャック(のっとり)の被害を受けることです。セッション ID が長期間固定化されればされるほど、このリスクは高まります。
では、どうすればよいのか?
それは、一時的なトークンを用いて自動ログインを実現することです。
具体的には、一度目のログイン時に暗号論的擬似乱数生成器による乱数を生成し、それをトークンとして Cookie とサーバー側で保持します。そして、2回目以降のアクセスで、Cookie とサーバーのトークンを比較することで自動ログインを実現します。
この時、注意しなければいけないのは、トークンは「一時的」なものにする必要があるということです。トークンを固定にすると、セッション ID を長期間同じにすることと同じ問題、つまりセッションハイジャック(のっとり)のリスクが高まります。
この問題に対応するために、自動ログインでは、自動ログインが成功する度に、トークンの振り直しを行います。また、古いトークンは忘れずに削除します。
さらに安全にするために、Cookie のトークンでは、HttpOnly 属性と secure 属性を有効にします。
HttpOnly 属性を有効にすることにより、JavaScript から Cookie にアクセスできなくなり、クロスサイトスクリプティングの脆弱性による影響を緩和できます。secure 属性をつけることにより、HTTPS での通信でのみ Cookie の値をサーバーに送信するようになります。
これが自動ログインを安全に行うための対策です。
前置きが長くなりましたが、PHP で実装してみましょう。
環境は、PHP + PDO + MySQL になります。
処理は以下の図のようになります。
テーブルは以下のようになります。ここでは、USER_ID は固定で不変のものとします。また、AUTO_LOGIN テーブルの ID はオートインクリメントの数値です。
・users テーブル
mysql> desc users; +----------+------------------+------+-----+---------+----------------+ | Field | Type | Null | Key | Default | Extra | +----------+------------------+------+-----+---------+----------------+ | id | int(10) unsigned | NO | PRI | NULL | auto_increment | | user_id | varchar(255) | NO | UNI | NULL | | | password | varchar(255) | NO | | NULL | | +----------+------------------+------+-----+---------+----------------+ 3 rows in set (0.00 sec)
・auto_login テーブル
mysql> desc auto_login; +------------------+------------------+------+-----+---------+----------------+ | Field | Type | Null | Key | Default | Extra | +------------------+------------------+------+-----+---------+----------------+ | id | int(10) unsigned | NO | PRI | NULL | auto_increment | | user_id | varchar(255) | NO | | NULL | | | token | varchar(255) | NO | | NULL | | | registrated_time | datetime | NO | | NULL | | +------------------+------------------+------+-----+---------+----------------+ 4 rows in set (0.00 sec)
■共通部品(function.php)
共通部品のポイントは PDO のオプションを定義していることと、リダイレクトの共通関数を作成していることです。
<?php define("DNS","mysql:host=mysql;dbname=autologin;charset=utf8"); define("USER_NAME", "mysql"); define("PASSWORD", "Mysql@1234"); /* * トークンを作成 */ function get_token() { $TOKEN_LENGTH = 16;//16*2=32桁 $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"); } /* * Welcome画面へのリダイレクト */ function redirect_to_welcome() { header("HTTP/1.1 301 Moved Permanently"); header("Location: welcome.php"); }
<?php session_start(); require_once("function.php"); $_SESSION['csrf_token'] = get_token(); // CSRFのトークンを取得する //トークンがセットされていたらリダイレクト if (isset($_COOKIE['cookie_token'])) { header("HTTP/1.1 301 Moved Permanently"); header("Location: logincheck.php"); } ?> <!DOCTYPE html> <html lang="ja"> <body> <h1>ログイン</h1> <form action="logincheck.php" method="post"> ID:<input type="text" name="id"><br> Password:<input type="password" name="password"><br> 自動ログイン:<input type="checkbox" name="auto" value="false"><br> <input type="hidden" name="csrf_token" value="<?php echo htmlspecialchars($_SESSION['csrf_token'], ENT_QUOTES, "UTF-8") ?>"> <input type="submit" value="ログイン"> <input type="reset" value="リセット"> </form> </body> </html>
■ログイン処理(logincheck.php)
ログイン処理には、自動ログインのキモなので重要な部分が多いのですが、いくつかポイントを説明します。
まず、トークンの生成ですが、openssl_random_pseudo_bytes (>=PHP5.3.0) とう関数を利用しています。この関数は、PHP で暗号論的擬似乱数生成器を用いた乱数を生成できます。また、乱数の桁数は 32 桁にしています。セッション ID と同等の強度です。
トークンは、サーバー側も Cookie も 2週間の期限をつけてあります。サーバーとクライアントの双方で期限を設けることにより、安全性が増すことを期待しています。
また、ログインが失敗した場合は、トークンの削除(サーバー側・Cookie)をしています。成功した場合も古いトークンを削除します。トークンは自動ログインの度に新規作成しますが、古いトークンが悪用されないようにするためです。
<?php session_start(); require_once("function.php"); //パラメーター取得 $id = $_POST['id']; $password = $_POST['password']; $auto = $_POST['auto']; $csrf_token = $_POST['csrf_token']; $cookie_token = $_COOKIE['cookie_token']; //CSRF チェック if (empty($cookie_token) && $csrf_token != $_SESSION['csrf_token']) { $_SESSION = array(); session_destroy(); session_start(); // リダイレクト redirect_to_login(); exit(); } //ログイン判定フラグ $normal_result = false; $auto_result = false; try { // DBとの接続 $pdo = new PDO(DNS, USER_NAME, PASSWORD, get_pdo_options()); //簡易ログイン if (empty($cookie_token)) { if (check_user($id, $password, $pdo)) { $normal_result = true; } } //自動ログイン if (!empty($cookie_token) ) { if (check_auto_login($cookie_token, $pdo)) { $auto_result = true; $id = $_SESSION['user_id']; // 後続の処理のため格納 } } //トークン生成処理 if (($normal_result && $auto == true) || $auto_result) { //トークンの作成 $token = get_token(); //トークンの登録 register_token($id, $token, $pdo); //自動ログインのトークンを2週間の有効期限でCookieにセット setCookie("cookie_token", $token, time()+60*60*24*14, "/", null, TRUE, TRUE); // secure, httponly if ($auto_result) { //古いトークンの削除 delete_old_token($cookie_token, $pdo); } // リダイレクト redirect_to_welcome(); exit(); } else if ($normal_result) { // リダイレクト redirect_to_welcome(); } else { // リダイレクト redirect_to_login(); exit(); } } catch (PDOException $e) { die($e->getMessage()); } /* * ログイン処理 */ function check_user($id, $password, $pdo) { //プレースホルダで SQL 作成 $sql = "SELECT COUNT(*) AS cnt FROM users WHERE user_id = ? AND password = ?;"; $stmt = $pdo->prepare($sql); $stmt->bindValue(1, $id, PDO::PARAM_STR); $stmt->bindValue(2, $password, PDO::PARAM_STR); $stmt->execute(); $row = $stmt->fetch(PDO::FETCH_ASSOC); if ($row['cnt'] == 1) { //ログイン成功 $_SESSION['user_id'] = $id; return true; } else { //ログイン失敗 return false; } } /* * 自動ログイン処理 */ function check_auto_login($cookie_token, $pdo) { //プレースホルダで SQL 作成 $sql = "SELECT * FROM auto_login WHERE token = ? AND registrated_time >= ?;"; //2週間前の日付を取得 $date = new DateTime("- 14 days"); $stmt = $pdo->prepare($sql); $stmt->bindValue(1, $cookie_token, PDO::PARAM_STR); $stmt->bindValue(2, $date->format('Y-m-d H:i:s'), PDO::PARAM_STR); $stmt->execute(); $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); if (count($rows) == 1) { //自動ログイン成功 $_SESSION['user_id'] = $rows[0]['user_id']; return true; } else { //自動ログイン失敗 //Cookie のトークンを削除 setCookie("cookie_token", '', -1, "/", null, TRUE, TRUE); // secure, httponly //古くなったトークンを削除 delete_old_token($cookie_token, $pdo); return false; } } /* * トークンの登録 */ function register_token($id, $token, $pdo) { //プレースホルダで SQL 作成 $sql = "INSERT INTO auto_login ( user_id, token, registrated_time) VALUES (?,?,?);"; // 現在日時を取得 $date = date('Y-m-d H:i:s'); $stmt = $pdo->prepare($sql); $stmt->bindValue(1, $id, PDO::PARAM_STR); $stmt->bindValue(2, $token, PDO::PARAM_STR); $stmt->bindValue(3, $date, PDO::PARAM_STR); $stmt->execute(); } /* * トークンの削除 */ function delete_old_token($token, $pdo) { //プレースホルダで SQL 作成 $sql = "DELETE FROM auto_login WHERE token = ?"; $stmt = $pdo->prepare($sql); $stmt->bindValue(1, $token, PDO::PARAM_STR); $stmt->execute(); }
■Welcome 画面 (welcome.php)
Welcome 画面には、ログアウト機能をつけてあります。自動ログインは重要ですが、場合によりログアウトが必要になるためです。
<?php session_start(); require_once("function.php"); $_SESSION['csrf_token'] = get_token(); // CSRFのトークンを取得する // 強制ブラウズ対策 if (empty($_SESSION['user_id'])) { // リダイレクト redirect_to_login(); exit(); } ?> <!DOCTYPE html> <html lang="ja"> <body> <h1>ようこそ</h1> <form action="logout.php" method="post"> <input type="hidden" name="csrf_token" value="<?php echo htmlspecialchars($_SESSION['csrf_token'], ENT_QUOTES, "UTF-8") ?>"> <input type="submit" name="logout" value="ログアウト"> </form> </body> </html>
■ログアウト処理(logout.php)
ログアウト処理には、特筆すべき点はありません。サーバー側と Cookie のトークンを削除しているだけです。
<?php session_start(); require_once("function.php"); $csrf_token = $_POST['csrf_token']; $cookie_token = $_COOKIE['cookie_token']; //CSRF チェック if ($csrf_token != $_SESSION['csrf_token']) { // リダイレクト redirect_to_welcome(); exit(); } try { // DBとの接続 $pdo = new PDO(DNS, USER_NAME, PASSWORD, get_pdo_options()); //プレースホルダで SQL 作成 $sql = "DELETE FROM auto_login WHERE token = ?"; $stmt = $pdo->prepare($sql); $stmt->bindValue(1, $cookie_token, PDO::PARAM_STR); $stmt->execute(); //Cookie のトークンを削除 setCookie("token", '', -1, "/", null, TRUE, TRUE); // secure, httponly //リダイレクト redirect_to_login(); } catch (PDOException $e) { die($e->getMessage()); }
おわりに
自動ログイン処理の内容と PHP での実装を見てきましたが、いかがだったでしょうか。
簡単に思える自動ログインですが、いざ実装するとなると考慮することが多く、意外に長いコードになっているかと思います。
ですが、自動ログイン処理では、この程度の考慮は必要です。
ユーザーの利便性のためとはいえ、セキュリティレベルを下げる実装では、相応の対応が必要ということですね。
この記事が自動ログインの議論のきっかけになれば幸いです。
改訂履歴
・2020/08/11
データベースアクセスをMDB2からPDOに変更。あわせてリファクタリング。
参考
Webアプリケーションセキュリティに関する記事は、以下のページにまとまっています。ぜひご確認ください。
コメント
自動ログイン機能作成の記事を探している途中こちらの記事を見つけました。
大変分かりやすい内容で感謝しております。
1点お聞きしたいことがありまして、
check_auto_login関数のdelete_old_token($token, $pdo);の
引数$tokenは$cookie_tokenかと思うのですが如何でしょうか。
もし間違っていたら申し訳ありません。
宮川さん
こんにちは。fnyaです。
ご指摘ありがとうございます!
ご指摘どおり間違いだったので修正しました。