最終更新日:2015年3月21日
安全なログインとパスワードの実装方法
はじめに
最近は、IPAが無料で公開している「安全なウェブサイトの作り方」や、書籍の「体系的に学ぶ 安全なWebアプリケーションの作り方」のおかげで、Web アプリケーションをどのように作れば安全になるかという情報が手に入りやすくなりました。
一方で、「じゃあ、具体的にどうするの?」となったとき、ネットで調べても断片的な情報しか入手できません。特に、ログインやパスワードの実装方法についてのまとまった情報がなかなか見つかりません。
当記事では、ユーザー登録からログイン、パスワードの変更、パスワードのリセットといった具体的な処理を、PHP + MDB2 + MySQL での実装方法を紹介します。
PDO ではなく MDB2 を採用しているのは、「体系的に学ぶ 安全なWebアプリケーションの作り方」で MDB2 が推奨されているためです。
システム概要
サンプルシステムでは、以下の要件を満たすものとします。
■機能要件
以下の機能を実装します。
- ログイン機能
- ID とパスワードでログインできる
- リセットフラグが立てられた時は、パスワードリセット機能へ強制遷移
- 新規登録機能
- ID、パスワード、メールアドレスを登録できる
- 登録は仮登録で、メールで送信されるアドレスをクリックすることで正式登録となる
- パスワードリセット機能
- ID を登録すると、登録済みメールアドレスにリセット用アドレスを送信する
- リセット画面で新規パスワードを設定する
- リセット後、リセット通知メールを登録済みメールアドレスに送信する
- パスワード変更機能
- ログイン後にパスワード変更機能を利用できる
- 旧パスワードと新パスワードを入力することで新パスワードを設定できる
- パスワード変更後、パスワード変更通知メールを登録済みメールアドレスに送信する
■セキュリティ要件
以下のセキュリティ要件を満たすものとします。
- パスワード強度通知
- パスワード登録時、パスワードの強度をリアルタイムに通知
- パスワード強度が弱い場合は、警告メッセージを表示する
- パスワード管理
- パスワードはソルトとストレッチング処理を行い保存する
- ソルトは8桁の乱数
- ストレッチングは1000回
- パスワードはソルトとストレッチング処理を行い保存する
- 通信の暗号化
- システムは HTTPS で暗号化された通信で使用する
- 脆弱性対策
- クロスサイトスクリプティング対策
- SQLインジェクション対策
- クロスサイトリクエストフォージェリ(CSRF)対策
- 必要な部分に実装する
- メールヘッダーインジェクション対策
主要画面イメージ
主要な画面イメージには、以下のようになります。
■ログイン画面
ID とパスワードを入力することでログインできます。
■登録画面
登録画面では、パスワードの強度がリアルタイムに表示されます。また、パスワードと確認用パスワードが異なる場合もリアルタイムにメッセージが表示されます。この仕組は、パスワード登録・変更機能に共通のものとなります。
■ Welcome 画面
ログイン後に表示される画面です。
■メール通知
メールでは、以下のように通知されます。
テーブル設計
データベースは「Test」、テーブル名は「USERS」のみです。
「USERS」テーブルの設計は以下のようになります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
+-----------------------+--------------+------+-----+---------+-------+ | Field | Type | Null | Key | Default | Extra | +-----------------------+--------------+------+-----+---------+-------+ | ID | varchar(255) | NO | PRI | NULL | | | SALT | varchar(255) | NO | | NULL | | | PASSWORD | varchar(255) | NO | | NULL | | | MAILADDRESS | varchar(255) | NO | | NULL | | | RESET | int(11) | NO | | 0 | | | IS_USER | int(11) | NO | | 0 | | | TEMP_PASS | varchar(255) | NO | | NULL | | | TEMP_LIMIT_TIME | datetime | NO | | NULL | | | LAST_CHANGE_PASS_TIME | datetime | NO | | NULL | | | LAST_LOGIN_TIME | datetime | NO | | NULL | | | RESISTER_TIME | datetime | NO | | NULL | | +-----------------------+--------------+------+-----+---------+-------+ |
機能別部品一覧
機能別部品一覧は、以下のようになります。
ソースコード
機能別のソースコードを掲載します。
共通部品
・function.php
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 |
<?php define("DNS","mysql://user01:pass@localhost/Test?charset=utf8"); define("SERVER", "192.168.11.11"); define("SENDER_EMAIL", "root@localhost"); define("STRETCH_COUNT", 1000); /* * CSRF トークン作成 */ function get_csrf_token() { $TOKEN_LENGTH = 16;//16*2=32byte $bytes = openssl_random_pseudo_bytes($TOKEN_LENGTH); return bin2hex($bytes); } /* * パスワードをソルト+ストレッチング */ function strechedPassword($salt, $password){ $hash_pass = ""; for ($i = 0; $i < STRETCH_COUNT; $i++){ $hash_pass = hash("sha256", ($hash_pass . $salt . $password)); } return $hash_pass; } /* * ソルトを作成 */ function get_salt() { $TOKEN_LENGTH = 4;//4*2=8byte $bytes = openssl_random_pseudo_bytes($TOKEN_LENGTH); return bin2hex($bytes); } /* * URL の一時パスワードを作成 */ function get_url_password() { $TOKEN_LENGTH = 16;//16*2=32byte $bytes = openssl_random_pseudo_bytes($TOKEN_LENGTH); return hash("sha256", $bytes); } ?> |
・common.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 |
/* * パスワード強度チェック * see * http://www.websec-room.com/passswordchecker */ var passwordLevel = 0; function setMessage(password) { passwordLevel = getPasswordLevel(password); var message = ""; if (passwordLevel == 1) {message = "弱い";} if (passwordLevel == 2) {message = "やや弱い";} if (passwordLevel == 3) {message = "普通";} if (passwordLevel == 4) {message = "やや強い";} if (passwordLevel == 5) {message = "強い";} var div = document.getElementById("pass_message"); if (!div.hasFistChild) {div.appendChild(document.createTextNode(""));} div.firstChild.data = message; } /* * パスワード一致チェック */ function setConfirmMessage(confirm_password) { var password = document.getElementById("password").value; var message = ""; if (password == confirm_password) { message = ""; } else { message = "パスワードが一致しません"; } var div = document.getElementById("pass_confirm_message"); if (!div.hasFistChild) {div.appendChild(document.createTextNode(""));} div.firstChild.data = message; } |
ログイン機能
・login.php
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 |
<?php require_once("function.php"); session_start(); 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'>IDまたはパスワードが異なります。</h2>"; } if ($_SESSION["error_status"] == 2) { echo "<h2 style='color:red'>不正なリクエストです。</h2>"; } //エラー情報のリセット $_SESSION["error_status"] = 0; ?> <form action="login_check.php" method="post"> <table border="0"> <tr> <td>ID </td> <td><input type="text" name="id"></td> </tr> <tr> <td> Password </td> <td> <input type="password" name="password"> </td> </tr> </table> <input type="submit" value="ログイン"> <input type="reset" value="リセット"> </form> <br> <a href="/register.php">新規登録</a><br> <a href="/password_reset.php"">パスワードリセット</a> </body> </html> |
・login_check.php
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 |
<?php require_once 'MDB2.php'; require_once("function.php"); session_start(); header("Content-type: text/html; charset=utf-8"); //パラメーター取得 $id = $_POST['id']; $password = $_POST['password']; //ログイン判定 //DB接続 $db = MDB2::connect(DNS); if (PEAR::isError($db)) { die($db->getMessage()); } //プレースホルダで SQL 作成 $sql = "SELECT * FROM USERS WHERE ID = ? AND IS_USER = 1;"; //パラメーターの型を指定 $stmt = $db->prepare($sql, array('text')); //パラメーターを渡して SQL 実行 $rs = $stmt->execute(array($id)); $count = 0; while ($row = $rs->fetchRow(MDB2_FETCHMODE_ASSOC)) { $id = $row["id"]; $salt = $row["salt"]; $db_password = $row["password"]; $reset = $row["reset"]; $count++; } $db->disconnect(); //ログイン失敗 if ($count != 1) { $_SESSION["error_status"] = 1; header("HTTP/1.1 301 Moved Permanently"); header("Location: login.php"); exit(); } //パスワードリセット対応 if ($reset == 1) { $_SESSION["error_status"] = 1; header("HTTP/1.1 301 Moved Permanently"); header("Location: password_reset.php"); exit(); } //パスワード生成 $hash = strechedPassword($salt, $password); if ($hash == $db_password) { //ログイン成功 //セッション ID の振り直し session_regenerate_id(true); //セッションに ID を格納 $_SESSION['id'] = $id; //CSRF のトークン作成 $_SESSION["token"] = get_csrf_token(); //DB接続 $db = MDB2::connect(DNS); if (PEAR::isError($db)) { die($db->getMessage()); } //プレースホルダで SQL 作成 $sql = "UPDATE USERS SET LAST_LOGIN_TIME = ? WHERE ID = ?"; //パラメーターの型を指定 $stmt = $db->prepare($sql, array('timestamp','text')); //パラメーターを渡して SQL 実行 $stmt->execute(array(date('Y-m-d H:i:s'), $id)); $db->disconnect(); //リダイレクト header("HTTP/1.1 301 Moved Permanently"); header("Location: welcome.php"); } else { //ログイン失敗 $_SESSION["error_status"] = 1; header("HTTP/1.1 301 Moved Permanently"); header("Location: login.php"); } ?> |
・logout.php
1 2 3 4 5 6 7 8 9 10 11 12 |
<?php session_start(); header("Content-type: text/html; charset=utf-8"); //セッション破棄 $_SESSION = array(); session_destroy(); //リダイレクト header("HTTP/1.1 301 Moved Permanently"); header("Location: login.php"); ?> |
・welcome.php
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
<?php require_once("function.php"); session_start(); header("Content-type: text/html; charset=utf-8"); //強制ブラウズはリダイレクト if (!isset($_SESSION['id'])){ $_SESSION["error_status"] = 2; header("HTTP/1.1 301 Moved Permanently"); header("Location: login.php"); exit(); } //エラー情報リセット $_SESSION["error_status"] = 0; ?> <html> <body> <h1>ようこそ</h1> <form action="password_change.php" method="post"> <input type="hidden" name="token" value="<?php echo htmlspecialchars($_SESSION['token'] , ENT_QUOTES, "UTF-8") ?>"> <input type="submit" name="password_change" value="パスワード変更"> </form> <form action="logout.php" method="post"> <input type="submit" name="logout" value="ログアウト"> </form> </body> </html> |
新規登録機能
・register.php
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 |
<?php session_start(); header("Content-type: text/html; charset=utf-8"); ?> <!DOCTYPE html> <html lang="ja"> <head> <meta charset="UTF-8"> <script src="passwordchecker.js" type="text/javascript"></script> <script src="common.js" type="text/javascript"></script> <script type="text/javascript"> /* * 登録前チェック */ function conrimMessage() { var id = document.getElementById("id").value; var mail = document.getElementById("mail").value; var pass = document.getElementById("password").value; var conf = document.getElementById("confirm_password").value; //必須チェック if((id == "") || (mail == "") || (pass == "") || (conf == "")) { alert("必須項目が入力されていません。"); return false; } //パスワードチェック if (pass != conf) { alert("パスワードが一致していません。"); return false; } if (passwordLevel < 3) { return confirm("パスワード強度が弱いですがよいですか?"); } return true; } </script> </head> <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;'>IDは既に登録されています。</h2>"; } if ($_SESSION["error_status"] == 3) { echo "<h2 style='color:red;'>タイムアウトか不正な URL です。</h2>"; } if ($_SESSION["error_status"] == 4) { echo "<h2 style='color:red;'>登録に失敗しました。</h2>"; } ?> <form action="register_check.php" method="post" onsubmit="return conrimMessage();"> <table border="0"> <tr> <td>ID</td> <td><input type="text" name="id" id="id"></td> </tr> <tr> <td>メールアドレス </td> <td><input type="text" name="mail" id="mail"></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> <td><div id="pass_confirm_message"></div></td> </tr> </table> <input type="submit" value="登録"> <input type="reset" value="リセット"> <input type="button" value="戻る" onclick="history.back();"> </form> </body> </html> |
・register_check.php
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 |
<?php require_once 'MDB2.php'; require_once("function.php"); session_start(); header("Content-type: text/html; charset=utf-8"); $_SESSION['token'] = get_csrf_token(); $id = $_POST["id"]; $mail = $_POST['mail']; $password = $_POST["password"]; $confirm_password = $_POST["confirm_password"]; if ($password != $confirm_password) { //パスワード不一致 $_SESSION["error_status"] = 1; header("HTTP/1.1 301 Moved Permanently"); header("Location: register.php"); exit(); } //IDチェック //DB接続 $db = MDB2::connect(DNS); if (PEAR::isError($db)) { die($db->getMessage()); } //プレースホルダで SQL 作成 $sql = "SELECT COUNT(*) AS CNT FROM USERS WHERE ID = ? ;"; //パラメーターの型を指定 $stmt = $db->prepare($sql, array('text')); //パラメーターを渡して SQL 実行 $rs = $stmt->execute(array($id)); while ($row = $rs->fetchRow(MDB2_FETCHMODE_ASSOC)) { $count = $row['cnt']; } $db->disconnect(); //既にIDが登録されていた if ($count != 0) { $_SESSION["error_status"] = 2; header("HTTP/1.1 301 Moved Permanently"); header("Location: register.php"); exit(); } //エラー情報リセット $_SESSION["error_status"] = 0; ?> <!DOCTYPE html> <head> <meta charset="utf-8"> </head> <html lang="ja"> <body> <h1>確認画面</h1> <h2>登録しますか?</h2> <form action="register_submit.php" method="post"> <table border="0"> <tr> <td>ID</td> <td><?php echo htmlspecialchars($id, ENT_QUOTES, "UTF-8") ?></td> </tr> <tr> <td>メールアドレス</td> <td><?php echo htmlspecialchars($mail, ENT_QUOTES, "UTF-8") ?></td> </tr> </table> <input type="hidden" name="id" value="<?php echo htmlspecialchars($id , ENT_QUOTES, "UTF-8") ?>"> <input type="hidden" name="mail" value="<?php echo htmlspecialchars($mail , 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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 |
<?php require_once 'MDB2.php'; require_once("function.php"); session_start(); header("Content-type: text/html; charset=utf-8"); //CSRF チェック if ($_SESSION['token'] != $_POST['token']) { $_SESSION = array(); session_destroy(); session_start(); $_SESSION["error_status"] = 2; header("HTTP/1.1 301 Moved Permanently"); header("Location: login.php"); exit(); } //エラー情報のリセット $_SESSION["error_status"] = 0; $id = $_POST["id"]; $mail = $_POST["mail"]; $password = $_POST["password"]; //ソルト作成 $salt = get_salt(); //一時URLパスワード作成 $url_pass = get_url_password(); //ユーザーの仮登録 //ストレッチングパスワード $hash = strechedPassword($salt, $password); //DB接続 $db = MDB2::connect(DNS); if (PEAR::isError($db)) { die($db->getMessage()); } //プレースホルダで SQL 作成 $sql = "INSERT INTO USERS (ID,SALT,PASSWORD,MAILADDRESS,TEMP_PASS,LAST_CHANGE_PASS_TIME,RESISTER_TIME) "; $sql .= " VALUES (?,?,?,?,?,?,?);"; //パラメーターの型を指定 $stmt = $db->prepare($sql, array('text','text','text','text','text','timestamp','timestamp')); //パラメーターを渡して SQL 実行 $res = $stmt->execute(array($id, $salt,$hash,$mail,$url_pass,date('Y-m-d H:i:s'),date('Y-m-d H:i:s'))); //ID重複の可能性があるのでチェック if (PEAR::isError($res)) { $db->disconnect(); $_SESSION["error_status"] = 4; header("HTTP/1.1 301 Moved Permanently"); header("Location: register.php"); exit(); } $db->disconnect(); //ユーザーにメールの送信 //メールヘッダーインジェクション対策 $mail = str_replace(array("\r\n","\r","\n"), "", $mail); $url = "https://" . SERVER . "/register_confirm.php?" . $url_pass; $msg = "以下のアドレスからアカウトを有効にしてください。" . PHP_EOL; $msg .= "アドレスの有効時間は10分間です。" . PHP_EOL; $msg .= "有効時間後はパスワードのリセットを行ってください。" . PHP_EOL . PHP_EOL; $msg .= $url; mb_send_mail($mail, "ユーザー登録", $msg, " From: " . SENDER_EMAIL); ?> <!DOCTYPE html> <html> <head> <meta charset="utf-8"> </head> <body> <h1>仮登録完了</h1> 仮登録が完了しました。<br> 登録を完了するには、送信されたメールで手続きを行ってください。<br><br> </body> </html> |
・register_confirm.php
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 |
<?php require_once 'MDB2.php'; require_once("function.php"); session_start(); header("Content-type: text/html; charset=utf-8"); //URLからパラメータ取得 $url_pass = parse_url($_SERVER['REQUEST_URI'], PHP_URL_QUERY); //ユーザー正式登録 //DB接続 $db = MDB2::connect(DNS); if (PEAR::isError($db)) { die($db->getMessage()); } //プレースホルダで SQL 作成 $sql = "SELECT * FROM USERS WHERE TEMP_PASS = ? AND RESISTER_TIME >= ?"; //パラメーターの型を指定 $stmt = $db->prepare($sql, array('text', 'timestamp')); //10分前の時刻を取得 $date = new DateTime("- 10 min"); //パラメーターを渡して SQL 実行 $rs = $stmt->execute(array($url_pass, $date->format('Y-m-d H:i:s'))); $count = 0; while ($row = $rs->fetchRow(MDB2_FETCHMODE_ASSOC)) { $id= $row['id']; $count++; } if ($count == 0) { //URLが不正か期限切れ $_SESSION["error_status"] = 3; header("HTTP/1.1 301 Moved Permanently"); header("Location: register.php"); $db->disconnect(); exit(); } $sql = "UPDATE USERS SET IS_USER = 1 WHERE ID = ? ;"; //パラメーターの型を指定 $stmt= $db->prepare($sql, array('text')); //パラメーターを渡して SQL 実行 $stmt->execute(array($id)); $db->disconnect(); ?> <!DOCTYPE html> <html lang="ja"> <head> <meta charset="utf-8"> </head> <body> <h1>登録完了</h1> ユーザーの登録が終了しました。<br> ログイン画面からログインしてください。<br><br> <a href="/login.php">ログイン画面に戻る</a> </body> </html> |
パスワードリセット機能
・password_reset.php
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 |
<?php require_once("function.php"); session_start(); header("Content-type: text/html; charset=utf-8"); //CSRF トークン $_SESSION['token'] = get_csrf_token(); ?> <!DOCTYPE html> <html lang="ja"> <head> <meta charset="UTF-8"> <script type="text/javascript"> /* * 登録前チェック */ function conrimMessage() { var id = document.getElementById("id").value; //必須チェック if(id == "") { alert("必須項目が入力されていません。"); return false; } return true; } </script> </head> <body> <h1>パスワードリセット 画面</h1> ID を登録すると、パスワードリセット用のアドレスを登録メールアドレスに送信します。 <?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>"; } ?> <form action="password_reset_mail.php" method="post" onsubmit="return conrimMessage();"> <table border="0"> <tr> <td>ID</td> <td><input type="text" name="id" id="id"></td> </tr> </table> <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> |
・password_reset_mail.php
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 |
<?php require_once 'MDB2.php'; require_once("function.php"); session_start(); header("Content-type: text/html; charset=utf-8"); $id = $_POST["id"]; $token = $_POST["token"]; // CSRFチェック if ($_SESSION["token"] != $token) { $_SESSION = array(); session_destroy(); session_start(); $_SESSION["error_status"] = 2; header("HTTP/1.1 301 Moved Permanently"); header("Location: login.php"); exit(); } //エラー情報リセット $_SESSION["error_status"] = 0; //旧パスワードチェック //DB接続 $db = MDB2::connect(DNS); if (PEAR::isError($db)) { die($db->getMessage()); } //プレースホルダで SQL 作成 $sql = "SELECT * FROM USERS WHERE ID = ? ;"; //パラメーターの型を指定 $stmt = $db->prepare($sql, array('text')); //パラメーターを渡して SQL 実行 $rs = $stmt->execute(array($id)); while ($row = $rs->fetchRow(MDB2_FETCHMODE_ASSOC)) { $mail = $row["mailaddress"]; } //URLパスワードを作成 $url_pass = get_url_password(); //プレースホルダで SQL 作成 $sql = "UPDATE USERS SET RESET = 1, TEMP_PASS = ?, TEMP_LIMIT_TIME = ? WHERE ID = ?"; //パラメーターの型を指定 $stmt = $db->prepare($sql, array("text","timestamp",'text')); //パラメーターを渡して SQL 実行 $stmt->execute(array($url_pass, date('Y-m-d H:i:s'), $id)); $db->disconnect(); //メール送信 //メールヘッダーインジェクション対策 $mail = str_replace(array("\r\n","\r","\n"), "", $mail); $msg = "以下のアドレスからパスワードのリセットを行ってください。" . PHP_EOL; $msg .= "アドレスの有効時間は10分間です。" . PHP_EOL . PHP_EOL; $msg .= "https://" . SERVER . "/password_reset_url.php?" . $url_pass; mb_send_mail($mail, "パスワードのリセット", $msg, " From : " . SENDER_EMAIL); ?> <!DOCTYPE html> <head> <meta charset="utf-8"> </head> <html lang="ja"> <body> <h1>メール送信</h1> パスワードのリセットのメールを送信しました。 <br><br> <a href="/login.php">ログイン画面に戻る</a> </body> </html> |
・password_reset_url.php
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 |
<?php require_once 'MDB2.php'; require_once("function.php"); session_start(); header("Content-type: text/html; charset=utf-8"); //URLからパラメータ取得 $url_pass = parse_url($_SERVER['REQUEST_URI'], PHP_URL_QUERY); //CSRF $_SESSION["token"] = get_csrf_token(); //ユーザー正式登録 //DB接続 $db = MDB2::connect(DNS); if (PEAR::isError($db)) { die($db->getMessage()); } //プレースホルダで SQL 作成 $sql = "SELECT * FROM USERS WHERE TEMP_PASS = ? AND TEMP_LIMIT_TIME >= ?"; //パラメーターの型を指定 $stmt = $db->prepare($sql, array('text', 'timestamp')); //10分前の時刻を取得 $date = new DateTime("- 10 min"); //パラメーターを渡して SQL 実行 $rs = $stmt->execute(array($url_pass, $date->format('Y-m-d H:i:s'))); $count = 0; while ($row = $rs->fetchRow(MDB2_FETCHMODE_ASSOC)) { $id= $row['id']; $count++; } $db->disconnect(); if ($count == 0) { //URLが不正か期限切れ $_SESSION["error_status"] = 3; header("HTTP/1.1 301 Moved Permanently"); header("Location: password_reset.php"); exit(); } //IDをセッションに格納 $_SESSION["id"] = $id; ?> <!DOCTYPE html> <html lang="ja"> <head> <meta charset="UTF-8"> <script src="passwordchecker.js" type="text/javascript"></script> <script src="common.js" type="text/javascript"></script> <script type="text/javascript"> /* * 登録前チェック */ function confirmMessage() { var pass = document.getElementById("password").value; var conf = document.getElementById("confirm_password").value; //必須チェック if((pass == "") || (conf == "")) { alert("必須項目が入力されていません。"); return false; } //パスワードチェック if (pass != conf) { alert("パスワードが一致していません。"); return false; } if (passwordLevel < 3) { return confirm("パスワード強度が弱いですがよいですか?"); } return true; } </script> </head> <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>"; } ?> <form action="password_reset_submit.php" method="post" onsubmit="return confirmMessage();"> <table border="0"> <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> <td><div id="pass_confirm_message"></div></td> </tr> </table> <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> |
・password_reset_submit.php
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 |
<?php require_once 'MDB2.php'; require_once("function.php"); session_start(); header("Content-type: text/html; charset=utf-8"); $token = $_POST["token"]; $id = $_POST["id"]; $password = $_POST["password"]; $confirm_password = $_POST["confirm_password"]; //CSRF エラー if ($_SESSION["token"] != $token) { $_SESSION = array(); session_destroy(); session_start(); $_SESSION["error_status"] = 2; header("HTTP/1.1 301 Moved Permanently"); header("Location: login.php"); exit(); } //パスワード不一致 if ($password != $confirm_password) { $_SESSION["error_status"] = 2; header("HTTP/1.1 301 Moved Permanently"); header("Location: password_reset_url.php"); exit(); } //パスワード更新 //DB接続 $db = MDB2::connect(DNS); if (PEAR::isError($db)) { die($db->getMessage()); } //プレースホルダで SQL 作成 $sql = "SELECT * FROM USERS WHERE ID = ? AND RESET = 1"; //パラメーターの型を指定 $stmt = $db->prepare($sql, array('text')); //パラメーターを渡して SQL 実行 $id = $_SESSION['id']; $rs = $stmt->execute(array($id)); $count = 0; while ($row = $rs->fetchRow(MDB2_FETCHMODE_ASSOC)) { $salt = $row['salt']; $mail = $row['mailaddress']; $count++; } if ($count == 0) { //期限切れとか $db->disconnect(); $_SESSION["error_status"] = 2; header("HTTP/1.1 301 Moved Permanently"); header("Location: password_reset.php"); exit(); } //新パスワード作成 $hash = strechedPassword($salt, $password); //プレースホルダで SQL 作成 $sql = "UPDATE USERS SET RESET = 0, IS_USER = 1, PASSWORD = ?, LAST_CHANGE_PASS_TIME = ? WHERE ID = ? ;"; //パラメーターの型を指定 $stmt= $db->prepare($sql, array("text", "timestamp", "text")); //パラメーターを渡して SQL 実行 $stmt->execute(array($hash, date('Y-m-d H:i:s'), $id)); $db->disconnect(); //メール送信 $mail = str_replace(array("\r\n","\r","\n"), "", $mail); //メールヘッダーインジェクション対策 $msg = "パスワードがリセットされました。" ; mb_send_mail($mail, "パスワードのリセット完了", $msg, " From : " . SENDER_EMAIL); ?> <!DOCTYPE html> <html lang="ja"> <head> <meta charset="utf-8"> </head> <body> <h1>パスワードリセット完了</h1> パスワードのリセットが終了しました。<br> ログイン画面からログインしてください。<br><br> <a href="/login.php">ログイン画面に戻る</a> </body> </html> |
パスワード変更機能
・password_change.php
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 |
<?php require_once("function.php"); session_start(); header("Content-type: text/html; charset=utf-8"); $token = $_POST["token"]; //CSRF エラー if ($_SESSION["token"] != $token) { $_SESSION = array(); session_destroy(); session_start(); $_SESSION["error_status"] = 2; header("HTTP/1.1 301 Moved Permanently"); header("Location: login.php"); } ?> <!DOCTYPE html> <html lang="ja"> <head> <meta charset="UTF-8"> <script src="passwordchecker.js" type="text/javascript"></script> <script src="common.js" type="text/javascript"></script> <script type="text/javascript"> /* * 登録前チェック */ function conrimMessage() { var old_pass = document.getElementById("old_password").value; var pass = document.getElementById("password").value; var conf = document.getElementById("confirm_password").value; //必須チェック if((old_pass == "") || (pass == "") || (conf == "")) { alert("必須項目が入力されていません。"); return false; } //パスワードチェック if (pass != conf) { alert("パスワードが一致していません。"); return false; } if (passwordLevel < 3) { return confirm("パスワード強度が弱いですがよいですか?"); } return true; } </script> </head> <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>"; } ?> <form action="password_change_submit.php" method="post" onsubmit="return conrimMessage();"> <table border="0"> <tr> <td>古いパスワード</td> <td><input type="password" name="old_password" id="old_password"></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> <td><div id="pass_confirm_message"></div></td> </tr> </table> <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> |
・password_change_submit.php
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 |
<?php require_once 'MDB2.php'; require_once("function.php"); session_start(); header("Content-type: text/html; charset=utf-8"); $id = $_SESSION["id"]; $old_password = $_POST['old_password']; $password = $_POST["password"]; $confirm_password = $_POST["confirm_password"]; $token = $_POST["token"]; // CSRFチェック if ($_SESSION["token"] != $token) { $_SESSION = array(); session_destroy(); session_start(); $_SESSION["error_status"] = 2; header("HTTP/1.1 301 Moved Permanently"); header("Location: login.php"); exit(); } //パスワード不一致 if ($password != $confirm_password) { $_SESSION["error_status"] = 1; //POSTで戻る echo_html_submit(); exit(); } //エラー情報をリセット $_SESSION["error_status"] = 0; //旧パスワードチェック //DB接続 $db = MDB2::connect(DNS); if (PEAR::isError($db)) { die($db->getMessage()); } //プレースホルダで SQL 作成 $sql = "SELECT * FROM USERS WHERE ID = ? AND IS_USER = 1;"; //パラメーターの型を指定 $stmt = $db->prepare($sql, array('text')); //パラメーターを渡して SQL 実行 $rs = $stmt->execute(array($id)); while ($row = $rs->fetchRow(MDB2_FETCHMODE_ASSOC)) { $salt = $row["salt"]; $mail = $row["mailaddress"]; $db_password = $row["password"]; } $db->disconnect(); //旧パスワードのストレッチング $hash = strechedPassword($salt, $old_password); //旧パスワードエラー if ($hash != $db_password) { $_SESSION["error_status"] = 1; //POST で戻る echo_html_submit(); exit(); } //パスワード更新 //新パスワード生成 $hash_new = strechedPassword($salt, $password); //DB接続 $db = MDB2::connect(DNS); if (PEAR::isError($db)) { die($db->getMessage()); } //プレースホルダで SQL 作成 $sql = "UPDATE USERS SET PASSWORD = ? , RESET = 0, LAST_CHANGE_PASS_TIME = ? WHERE ID = ?"; //パラメーターの型を指定 $stmt = $db->prepare($sql, array('text',"timestamp",'text')); //パラメーターを渡して SQL 実行 $stmt->execute(array($hash_new, date('Y-m-d H:i:s'), $id)); $db->disconnect(); //メール送信 $mail = str_replace(array("\r\n","\r","\n"), "", $mail); //メールヘッダーインジェクション対策 $msg = "パスワードが変更されました。"; mb_send_mail($mail, "パスワードの変更", $msg, " From : " . SENDER_EMAIL); /* * HTML を出力してPOSTリクエストで戻る */ function echo_html_submit() { echo "<!DOCTYPE html>"; echo "<head>"; echo "<meta charset='utf-8'>"; echo "</head>"; echo "<html lang='ja'>"; echo "<body onload='document.returnForm.submit();'>"; echo "<form name='returnForm' method='post' action='password_change.php'>"; echo "<input type='hidden' name='token' value='" . htmlspecialchars($_SESSION["token"], ENT_QUOTES, "UTF-8") . "''>"; echo "</form>"; echo "</body>"; echo "</html>"; } ?> <!DOCTYPE html> <head> <meta charset="utf-8"> </head> <html lang="ja"> <body> <h1>完了画面</h1> パスワードの変更が完了しました。 <br><br> <a href="/login.php">ログイン画面に戻る</a> </body> </html> |
おわりに
駆け足で作成したので、突っ込みどころがあるとは思いますが、ログインやパスワード管理といった数少ない実装サンプルを提供できたのではないかと思います。
これがみなさんの Web アプリケーションを作成する際の参考になれば幸いです。
改訂履歴
・2015/03/21 微修正
・2015/03/20 主に XSS 対策を強化
・2015/03/19 更にリファクタリング
・2015/03/16 リファクタリング
・2015/03/15 投稿
スポンサーリンク
カテゴリー:ブログ
Twitter でも、いろんな情報を発信しています。@fnyaさんをフォロー
PHPのDBアクセスはPDOに決まりだと思う理由、という記事を書かれていながらMDB2で実装されているのは何か意図があるのでしょうか?(PDO版を参考にしたく、期待しています)
コメントが遅くなってすみません。
単純に時期の問題ですね。
この記事を書いたあとで、PDOの記事を書いたので。
PDO版にすることは考えてなかったです。
どうするか考えてみます。
早速、テスト実装して参考にさせて頂きました。
すごく勉強なります。
ちなみにPDO版にしてみました。
ありがとうございます。
参考になったようでなによりです。