Webセキュリティの小部屋

Twitter のフォローはこちらから Facebook ページはこちら RSSフィードのご登録はこちらから
公開日:2015年3月15日
最終更新日:2020年8月13日

安全なログインとパスワードのPHP実装例

はじめに

最近は、IPAが無料で公開している「安全なウェブサイトの作り方」や、書籍の「体系的に学ぶ 安全なWebアプリケーションの作り方」のおかげで、Web アプリケーションをどのように作れば安全になるかという情報が手に入りやすくなりました。

一方で、「じゃあ、具体的にどうするの?」となったとき、ネットで調べても断片的な情報しか入手できません。特に、ログインやパスワードの実装方法についてのまとまった情報がなかなか見つかりません。

当記事では、ユーザー登録からログイン、パスワードの変更、パスワードのリセットといった具体的な処理を、PHP + PDO + MySQL での実装方法を紹介します。

システム概要

サンプルシステムでは、以下の要件を満たすものとします。

■機能要件

以下の機能を実装します。

  • ログイン機能
    • ID とパスワードでログインできる
    • リセットフラグが立てられた時は、パスワードリセット機能へ強制遷移
  • 新規登録機能
    • ID、パスワード、メールアドレスを登録できる
    • 登録は仮登録で、メールで送信されるアドレスをクリックすることで正式登録となる
  • パスワードリセット機能
    • ID を登録すると、登録済みメールアドレスにリセット用アドレスを送信する
    • リセット画面で新規パスワードを設定する
    • リセット後、リセット通知メールを登録済みメールアドレスに送信する
  • パスワード変更機能
    • ログイン後にパスワード変更機能を利用できる
    • 旧パスワードと新パスワードを入力することで新パスワードを設定できる
    • パスワード変更後、パスワード変更通知メールを登録済みメールアドレスに送信する

■セキュリティ要件

以下のセキュリティ要件を満たすものとします。

  • パスワード強度通知
    • パスワード登録時、パスワードの強度をリアルタイムに通知
    • パスワード強度が弱い場合は、警告メッセージを表示する
  • パスワード管理
    • パスワードはPHP標準のパスワード用ハッシュ関数を使用する
  • 通信の暗号化
    • システムは HTTPS で暗号化された通信で使用する
  • 脆弱性対策
    • クロスサイトスクリプティング対策
    • SQLインジェクション対策
    • クロスサイトリクエストフォージェリ(CSRF)対策
    • メールヘッダーインジェクション対策

主要画面イメージ

主要な画面イメージには、以下のようになります。

■ログイン画面

ID とパスワードを入力することでログインできます。

Pic01

■登録画面

登録画面では、パスワードの強度がリアルタイムに表示されます。また、パスワードと確認用パスワードが異なる場合もリアルタイムにメッセージが表示されます。この仕組は、パスワード登録・変更機能に共通のものとなります。

Pic02

■ Welcome 画面

ログイン後に表示される画面です。

Pic03

■メール通知

メールでは、以下のように通知されます。

Pic04

テーブル設計

データベースは「loginsample」、テーブル名は「users」です。

「users」テーブルの定義は以下のようになります。

mysql> desc users;
+-----------------------+--------------+------+-----+---------+-------+
| Field                 | Type         | Null | Key | Default | Extra |
+-----------------------+--------------+------+-----+---------+-------+
| id                    | varchar(255) | NO   | PRI | NULL    |       |
| password              | varchar(255) | NO   |     | NULL    |       |
| mailaddress           | varchar(255) | NO   |     | NULL    |       |
| reset                 | int(11)      | YES  |     | 0       |       |
| is_user               | int(11)      | YES  |     | 0       |       |
| temp_pass             | varchar(255) | YES  |     | NULL    |       |
| temp_limit_time       | datetime     | YES  |     | NULL    |       |
| last_change_pass_time | datetime     | YES  |     | NULL    |       |
| last_login_time       | datetime     | YES  |     | NULL    |       |
| register_time         | datetime     | YES  |     | NULL    |       |
+-----------------------+--------------+------+-----+---------+-------+
10 rows in set (0.00 sec)

 

機能別部品一覧

機能別部品一覧は、以下のようになります。

Pic05

ソースコード

機能別のソースコードを掲載します。

共通部品

・function.php

<?php
define('DNS','mysql:host=localhost;dbname=loginsample;charset=utf8');
define('USER_NAME', 'mysql');
define('PASSWORD', 'Mysql@1234');
define('SERVER', '192.168.33.10');
define('SENDER_EMAIL', 'admin@example.com');

/*
* PDO の接続オプション取得
*/
function get_pdo_options() {
  return array(PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
               PDO::MYSQL_ATTR_MULTI_STATEMENTS => false,
               PDO::ATTR_EMULATE_PREPARES => false);
}

/*
* CSRF トークン作成
*/
function get_csrf_token() {
 $token_legth = 16;//16*2=32byte
 $bytes = openssl_random_pseudo_bytes($token_legth);
 return bin2hex($bytes);
}

/*
* URL の一時パスワードを作成
*/
function get_url_password() {
  $token_legth = 16;//16*2=32byte
  $bytes = openssl_random_pseudo_bytes($token_legth);
  return hash('sha256', $bytes);
}

/*
* ログイン画面へのリダイレクト
*/
function redirect_to_login() {
  header('HTTP/1.1 301 Moved Permanently');
  header('Location: login.php');
}

/*
* パスワードリセット画面へのリダイレクト
*/
function redirect_to_password_reset() {
  header('HTTP/1.1 301 Moved Permanently');
  header('Location: password_reset.php');
}

/*
* Welcome画面へのリダイレクト
*/
function redirect_to_welcome() {
  header('HTTP/1.1 301 Moved Permanently');
  header('Location: welcome.php');
}

/*
* 登録画面へのリダイレクト
*/
function redirect_to_register() {
  header('HTTP/1.1 301 Moved Permanently');
  header('Location: register.php');
}

 

・common.js

/*
*  パスワード強度チェック
*  see
*     https://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

<?php
  session_start();
  require_once('function.php');
  header('Content-type: text/html; charset=utf-8');

  $_SESSION['token'] = get_csrf_token(); // CSRFのトークンを取得する
?>
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="utf-8">
</head>
<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="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><br>
  <a href="password_reset.php"">パスワードリセット</a>
</body>
</html>

 

・login_check.php

<?php
  session_start();
  require_once('function.php');

  // パラメーター取得
  $id = $_POST['id'];
  $password = $_POST['password'];
  $token = $_POST['token'];

  // CSRF チェック
  if ($token != $_SESSION['token']) {
    // リダイレクト
    $_SESSION['error_status'] = 2;
    redirect_to_login();
    exit();
  }

  try {
    // DB接続
    $pdo = new PDO(DNS, USER_NAME, PASSWORD, get_pdo_options());

    //プレースホルダで SQL 作成
    $sql = "SELECT * FROM users WHERE id = ?;";

    $stmt = $pdo->prepare($sql);
    $stmt->bindValue(1, $id, PDO::PARAM_STR);
    $stmt->execute();

    $row = $stmt->fetch(PDO::FETCH_ASSOC);

    // ログイン失敗
    if (empty($row)) {
      $_SESSION['error_status'] = 1;
      redirect_to_login();
      exit();
    }

    // 値取得
    $id = $row['id'];
    $db_password = $row['password'];
    $reset = $row['reset'];
    $is_user = $row['is_user'];

    //パスワードリセット対応
    if ($reset == 1) {
      $_SESSION['error_status'] = 1;
     redirect_to_password_reset();
     exit();
    }

    // ログイン判定
    if ($is_user == 1 && password_verify($password, $db_password)) {
      // ログイン成功

      // セッション ID の振り直し
      session_regenerate_id(true);
      // セッションに ID を格納
      $_SESSION['id'] = $id;

      // ログイン日時更新
      $sql = "UPDATE users SET last_login_time = ? WHERE id = ?";
      $stmt = $pdo->prepare($sql);

      // トランザクションの開始
      $pdo->beginTransaction();
      try {
        $stmt->bindValue(1, date('Y-m-d H:i:s'), PDO::PARAM_STR);
        $stmt->bindValue(2, $id, PDO::PARAM_STR);
        $stmt->execute();

        // コミット
        $pdo->commit();
      } catch (PDOException $e) {
        // ロールバック
        $pdo->rollBack();

        throw $e;
      }

      // リダイレクト
      redirect_to_welcome();

    } else {
      // ログイン失敗
      $_SESSION['error_status'] = 1;
      // リダイレクト
      redirect_to_login();
      exit();
    }
  } catch (PDOException $e) {
    die($e->getMessage());
  }

 

・logout.php

<?php
  session_start();
  require_once('function.php');

  $token = $_POST['token'];

  // CSRF チェック
  if ($token != $_SESSION['token']) {
    // リダイレクト
    redirect_to_welcome();
    exit();
  }

  //セッション破棄
  $_SESSION = array();
  session_destroy();

  //リダイレクト
  redirect_to_login();

 

・welcome.php

<?php
  session_start();
  require_once('function.php');
  header('Content-type: text/html; charset=utf-8');

  //強制ブラウズはリダイレクト
  if (!isset($_SESSION['id'])){
    $_SESSION['error_status'] = 2;
    redirect_to_login();
    exit();
  }

  $_SESSION['token'] = get_csrf_token(); // CSRFのトークンを取得する
?>
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="utf-8">
</head>
<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="hidden" name="token" value="<?php echo htmlspecialchars($_SESSION['token']  , ENT_QUOTES, "UTF-8") ?>">
    <input type="submit" name="logout" value="ログアウト">
  </form>
</body>
</html>

 

新規登録機能

・register.php

<?php
  session_start();
  require_once('function.php');
  header('Content-type: text/html; charset=utf-8');

  $_SESSION['token'] = get_csrf_token(); // CSRFのトークンを取得する
?>
<!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;">パスワードが不一致です。</h2>';
      }
      if ($_SESSION['error_status'] == 3) {
        echo '<h2 style="color:red;">IDは既に登録されています。</h2>';
      }
      if ($_SESSION['error_status'] == 4) {
        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;">タイムアウトか不正な URL です。</h2>';
      }
      //エラー情報リセット
      $_SESSION['error_status'] = 0;
    ?>
    <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="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
  session_start();
  require_once('function.php');
  header('Content-type: text/html; charset=utf-8');

  $id = $_POST['id'];
  $mail = $_POST['mail'];
  $password = $_POST['password'];
  $confirm_password = $_POST['confirm_password'];
  $token = $_POST['token'];

  // CSRF チェック
  if ($token != $_SESSION['token']) {
    $_SESSION['error_status'] = 4;
    redirect_to_register();
    exit();
  }

  // 必須項目チェック
  if (empty($id) || empty($mail) || empty($password) || empty($confirm_password)) {
    $_SESSION['error_status'] = 1;
    redirect_to_register();
    exit();
  }

  //パスワード不一致
  if ($password != $confirm_password) {
    $_SESSION['error_status'] = 2;
    redirect_to_register();
    exit();
  }

  //IDチェック
  try {
    // DB接続
    $pdo = new PDO(DNS, USER_NAME, PASSWORD, get_pdo_options());
    //プレースホルダで SQL 作成
    $sql = "SELECT COUNT(*) AS cnt FROM users WHERE id = ?;";

    $stmt = $pdo->prepare($sql);
    $stmt->bindValue(1, $id, PDO::PARAM_STR);
    $stmt->execute();

    $row = $stmt->fetch(PDO::FETCH_ASSOC);

    //既にIDが登録されていた
    if (!empty($row) && $row['cnt'] > 0) {
      $_SESSION['error_status'] = 3;
      redirect_to_register();
      exit();
    }

  } catch (PDOException $e) {
    die($e->getMessage());
  }
?>
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="utf-8">
</head>
<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="document.location.href='register.php';">
  </form>
</body>
</html>

 

・register_submit.php

<?php
  session_start();
  require_once('function.php');
  header('Content-type: text/html; charset=utf-8');

  $id = $_POST['id'];
  $mail = $_POST['mail'];
  $password = $_POST['password'];
  $token = $_POST['token'];

  //CSRF チェック
  if ($token != $_SESSION['token']) {
    $_SESSION['error_status'] = 4;
    redirect_to_register();
    exit();
  }

  //ユーザーの仮登録

  //一時URLパスワード作成
  $url_pass = get_url_password();
  // パスワードハッシュ化
  $hashed_password = password_hash($password, PASSWORD_DEFAULT);
  // 現在日時
  $datetime = date('Y-m-d H:i:s');

  try {
    // DB接続
    $pdo = new PDO(DNS, USER_NAME, PASSWORD, get_pdo_options());

    //プレースホルダで SQL 作成
    $sql = "INSERT INTO users (id,password,mailaddress,temp_pass,temp_limit_time,register_time) ";
    $sql .=  "VALUES (?,?,?,?,?,?);";
    $stmt = $pdo->prepare($sql);
    // トランザクションの開始
    $pdo->beginTransaction();
    try {
      $stmt->bindValue(1, $id, PDO::PARAM_STR);
      $stmt->bindValue(2, $hashed_password, PDO::PARAM_STR);
      $stmt->bindValue(3, $mail, PDO::PARAM_STR);
      $stmt->bindValue(4, $url_pass, PDO::PARAM_STR);
      $stmt->bindValue(5, $datetime, PDO::PARAM_STR);
      $stmt->bindValue(6, $datetime, PDO::PARAM_STR);
      $stmt->execute();

      // コミット
      $pdo->commit();
    } catch (PDOException $e) {
      // ロールバック
      $pdo->rollBack();

      throw $e;
    }


  } catch (PDOException $e) {
    // ID重複の可能性
    $_SESSION['error_status'] = 5;
    redirect_to_register();
    exit();
  }

  //ユーザーにメールの送信
  //メールヘッダーインジェクション対策
  $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 lang="ja">
<head>
  <meta charset="utf-8">
</head>
<body>
<h1>仮登録完了</h1>
仮登録が完了しました。<br>
登録を完了するには、送信されたメールで手続きを行ってください。<br><br>
<a href="login.php">ログイン画面に戻る</a>
</body>
</html>

 

・register_confirm.php

<?php
  session_start();
  require_once('function.php');
  header('Content-type: text/html; charset=utf-8');

  //URLからパラメータ取得
  $url_pass = parse_url($_SERVER['REQUEST_URI'], PHP_URL_QUERY);

  //ユーザー正式登録
  try {
    // DB接続
    $pdo = new PDO(DNS, USER_NAME, PASSWORD, get_pdo_options());
    //プレースホルダで SQL 作成
    $sql = "SELECT * FROM users WHERE temp_pass = ? AND register_time >= ?;";
    $stmt = $pdo->prepare($sql);

    //10分前の時刻を取得
    $datetime = new DateTime('- 10 min');
    $stmt->bindValue(1, $url_pass, PDO::PARAM_STR);
    $stmt->bindValue(2, $datetime->format('Y-m-d H:i:s'), PDO::PARAM_STR);
    $stmt->execute();

    $row = $stmt->fetch(PDO::FETCH_ASSOC);

    //URLが不正か期限切れ
    if (empty($row)) {
      $_SESSION['error_status'] = 6;
      redirect_to_register();
      exit();
    }

    $id = $row['id'];
    $sql = "UPDATE users SET is_user = 1 WHERE id = ?;";
    $stmt = $pdo->prepare($sql);

    // トランザクションの開始
    $pdo->beginTransaction();
    try {
      $stmt->bindValue(1, $id, PDO::PARAM_STR);
      $stmt->execute();

      // コミット
      $pdo->commit();
    } catch (PDOException $e) {
      // ロールバック
      $pdo->rollBack();

      throw $e;
    }

  } catch (PDOException $e) {
    die($e->getMessage());
  }
?>
<!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

<?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>";
      }
      if ($_SESSION['error_status'] == 4) {
        echo '<h2 style="color:red;">タイムアウトか不正なURLです。</h2>';
      }
      //エラー情報のリセット
      $_SESSION['error_status'] = 0;
    ?>
  <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="document.location.href='login.php';">
  </form>
</body>
</html>

 

・password_reset_mail.php

<?php
  session_start();
  require_once('function.php');
  header('Content-type: text/html; charset=utf-8');

  $id = $_POST['id'];
  $token = $_POST['token'];

  // CSRFチェック
  if ($_SESSION['token'] != $token) {
    $_SESSION['error_status'] = 3;
    redirect_to_password_reset();
    exit();
  }

  try {
    // DB接続
    $pdo = new PDO(DNS, USER_NAME, PASSWORD, get_pdo_options());

    //プレースホルダで SQL 作成
    $sql = "SELECT * FROM users WHERE id = ?;";
    $stmt = $pdo->prepare($sql);
    $stmt->bindValue(1, $id, PDO::PARAM_STR);
    $stmt->execute();

    $row = $stmt->fetch(PDO::FETCH_ASSOC);

    // IDが存在しない
    if (empty($row)) {
      $_SESSION['error_status'] = 2;
      redirect_to_password_reset();
      exit();
    }

    //リセット処理

    $mail = $row['mailaddress'];

    //URLパスワードを作成
    $url_pass = get_url_password();

    //プレースホルダで SQL 作成
    $sql = "UPDATE users SET reset = 1, temp_pass = ?, temp_limit_time = ? WHERE id = ?;";
    $stmt = $pdo->prepare($sql);
    // トランザクションの開始
    $pdo->beginTransaction();
    try {
      $stmt->bindValue(1, $url_pass, PDO::PARAM_STR);
      $stmt->bindValue(2, date('Y-m-d H:i:s'), PDO::PARAM_STR);
      $stmt->bindValue(3, $id, PDO::PARAM_STR);
      $stmt->execute();

      // コミット
      $pdo->commit();
    } catch (PDOException $e) {
      // ロールバック
      $pdo->rollBack();

      throw $e;
    }

  } catch (PDOException $e) {
    die($e->getMessage());
  }


  //メール送信
  //メールヘッダーインジェクション対策
  $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

<?php
  session_start();
  require_once('function.php');
  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();

  //ユーザー正式登録
  try {
    // DB接続
    $pdo = new PDO(DNS, USER_NAME, PASSWORD, get_pdo_options());
    //10分前の時刻を取得
    $datetime = new DateTime('- 10 min');

    //プレースホルダで SQL 作成
    $sql = "SELECT * FROM users WHERE temp_pass = ? AND temp_limit_time >= ?;";
    $stmt = $pdo->prepare($sql);
    $stmt->bindValue(1, $url_pass, PDO::PARAM_STR);
    $stmt->bindValue(2, $datetime->format('Y-m-d H:i:s'), PDO::PARAM_STR);
    $stmt->execute();

    $row = $stmt->fetch(PDO::FETCH_ASSOC);

    //URLが不正か期限切れ
    if (empty($row)) {
      $_SESSION['error_status'] = 4;
      redirect_to_password_reset();
      exit();
    }

    $_SESSION['id'] = $row['id'];
    $_SESSION['url_pass'] = $url_pass; // エラー制御のため格納

  } catch (PDOException $e) {
    die($e->getMessage());
  }
?>
<!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>';
      }
    ?>

  <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="document.location.href='login.php';">
  </form>
</body>
</html>

 

・password_reset_submit.php

<?php
  session_start();
  require_once('function.php');
  header('Content-type: text/html; charset=utf-8');

  $id = $_SESSION['id'];
  $password = $_POST['password'];
  $confirm_password = $_POST['confirm_password'];
  $token = $_POST['token'];

  //CSRF エラー
  if ($token != $_SESSION['token']) {
     $_SESSION['error_status'] = 2;
     redirect_to_login();
     exit();
  }

  //パスワード不一致
  if ($password != $confirm_password) {
    $_SESSION['error_status'] = 1;
    header('HTTP/1.1 301 Moved Permanently');
    header('Location: password_reset_url.php?' . $_SESSION['url_pass']);
    exit();
  }

  //パスワード更新


  try {
    // DB接続
    $pdo = new PDO(DNS, USER_NAME, PASSWORD, get_pdo_options());

    //プレースホルダで SQL 作成
    $sql = "SELECT * FROM users WHERE id = ? AND reset = 1;";

    $stmt = $pdo->prepare($sql);
    $stmt->bindValue(1, $id, PDO::PARAM_STR);
    $stmt->execute();

    $row = $stmt->fetch(PDO::FETCH_ASSOC);

    if (empty($row)) {
      $_SESSION['error_status'] = 3;
      redirect_to_password_reset();
      exit();
    }

    $mail = $row['mailaddress'];

    //プレースホルダで SQL 作成
    $sql = "UPDATE users SET reset = 0, is_user = 1, password = ?, last_change_pass_time = ? WHERE id = ?;";
    $stmt = $pdo->prepare($sql);

    // パスワードハッシュ化
    $hashed_password = password_hash($password, PASSWORD_DEFAULT);

    // トランザクションの開始
    $pdo->beginTransaction();
    try {
      $stmt->bindValue(1, $hashed_password, PDO::PARAM_STR);
      $stmt->bindValue(2, date('Y-m-d H:i:s'), PDO::PARAM_STR);
      $stmt->bindValue(3, $id, PDO::PARAM_STR);
      $stmt->execute();

      // コミット
      $pdo->commit();
    } catch (PDOException $e) {
      // ロールバック
      $pdo->rollBack();

      throw $e;
    }

  } catch (PDOException $e) {
    die($e->getMessage());
  }

  //メール送信
  $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

<?php
  require_once('function.php');
  session_start();
  header('Content-type: text/html; charset=utf-8');

  $token = $_POST['token'];

  //CSRF エラー
  if ($token != $_SESSION['token']) {
     $_SESSION['error_status'] = 2;
     redirect_to_login();
     exit();
  }

  $_SESSION['token'] = get_csrf_token(); // CSRFのトークンを取得する
?>
<!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

<?php
  session_start();
  require_once('function.php');
  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 ($token != $_SESSION['token']) {
     $_SESSION = array();
     session_destroy();
     session_start();
    $_SESSION['error_status'] = 2;
    redirect_to_login();
    exit();
  }
    //パスワード不一致
  if ($password != $confirm_password) {
    $_SESSION['error_status'] = 1;
    //POSTで戻る
    echo_html_submit();
    exit();
  }

  try {
    // DB接続
    $pdo = new PDO(DNS, USER_NAME, PASSWORD, get_pdo_options());

    //旧パスワードチェック

    //プレースホルダで SQL 作成
    $sql = "SELECT * FROM users WHERE id = ? AND is_user = 1;";
    $stmt = $pdo->prepare($sql);
    $stmt->bindValue(1, $id, PDO::PARAM_STR);
    $stmt->execute();

    $row = $stmt->fetch(PDO::FETCH_ASSOC);

    // IDがない
    if (empty($row)) {
      $_SESSION['error_status'] = 2;
      //POSTで戻る
      echo_html_submit();
      exit();
    }

    $mail = $row['mailaddress'];

    // 旧パスワードチェック
    if (!password_verify($old_password, $row['password'])) {
      $_SESSION['error_status'] = 1;
      //POST で戻る
      echo_html_submit();
      exit();
    }

    //パスワード更新

    //新パスワード生成
    $hashed_password = password_hash($password, PASSWORD_DEFAULT);

    //プレースホルダで SQL 作成
    $sql = "UPDATE users SET password = ?, reset = 0, last_change_pass_time = ? WHERE id = ?;";
    $stmt = $pdo->prepare($sql);

    // トランザクションの開始
    $pdo->beginTransaction();
    try {
      $stmt->bindValue(1, $hashed_password, PDO::PARAM_STR);
      $stmt->bindValue(2, date('Y-m-d H:i:s'), PDO::PARAM_STR);
      $stmt->bindValue(3, $id, PDO::PARAM_STR);
      $stmt->execute();

      // コミット
      $pdo->commit();
    } catch (PDOException $e) {
      // ロールバック
      $pdo->rollBack();

      throw $e;
    }

  } catch (PDOException $e) {
    die($e->getMessage());
  }

  //メール送信
  $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 アプリケーションを作成する際の参考になれば幸いです。

改訂履歴

・2020/08/12 MDB2からPDOに変更。リファクタリング。
・2020/08/10 微修正
・2015/03/21 微修正
・2015/03/20 主に XSS 対策を強化
・2015/03/19 更にリファクタリング
・2015/03/16 リファクタリング
・2015/03/15 投稿

参考

Webアプリケーションセキュリティに関する記事は、以下のページにまとまっています。ぜひご確認ください。


スポンサーリンク





カテゴリー:ブログ

“安全なログインとパスワードのPHP実装例” への5件のフィードバック

  1. NOG より:

    PHPのDBアクセスはPDOに決まりだと思う理由、という記事を書かれていながらMDB2で実装されているのは何か意図があるのでしょうか?(PDO版を参考にしたく、期待しています)

    • fnya より:

      コメントが遅くなってすみません。
      単純に時期の問題ですね。
      この記事を書いたあとで、PDOの記事を書いたので。
      PDO版にすることは考えてなかったです。
      どうするか考えてみます。

  2. ロイド より:

    早速、テスト実装して参考にさせて頂きました。
    すごく勉強なります。
    ちなみにPDO版にしてみました。

    ありがとうございます。

  3. xxx より:

    恐れ入りますが
    require_once ‘MDB2.php’ によるエラーはどうやって解決すればいいなのか

コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です

CAPTCHA