注意事項
現在は、独自実装でパスワードを保存する方式は推奨されておらず、実装環境にあるパスワード保存方法を採用することが推奨されています。
実装環境ごとのパスワード保存方法は、以下の記事を参照してください。
はじめに
Web アプリケーションでは、パスワードを、ソルト+ハッシュ化+ストレッチングの手法で安全に保存されていると思います。もし、こうしたパスワードの保存方法を行っていない場合は、早急にパスワードの保存方法を改善することをお勧めします。パスワードが漏洩して信用を失ってからでは遅いですからね。
パスワードの安全な保存方法は、以下の記事を参考にしてください。PHP, Java, C#, VB.NET の実装サンプルもあります。
さて、ハッシュ方式として、MD5 と SHA1 がありますが、今はもう危殆化(安全ではなくなる)してしまって、使用するのにはよろしくありません。
ですが、既存のシステムではこのハッシュ方式を使用しているケースも多いと思います。では、こういった既存システムや、今後のシステムではどうすればよいのでしょうか?
それは、パスワードの保存方法にバージョンを付与して判断・更新することです。これにより、ユーザーのパスワードは最新の方式で、安全に保存することができます。
この記事では、この実装方法をご紹介します。
PHP + PDO + MySQL (CentOS 7) で動作検証していますが、理屈は他の言語でも同じなので参考になると思います。
設計イメージ
Test データベースに、以下のような USERS テーブルを作成します。
VERSION フィールドはパスワード保存ロジックのバージョンを保存します。
+----------+--------------+------+-----+---------+-------+ | Field | Type | Null | Key | Default | Extra | +----------+--------------+------+-----+---------+-------+ | ID | varchar(255) | NO | PRI | NULL | | | VERSION | int(11) | NO | | NULL | | | SALT | varchar(255) | NO | | NULL | | | PASSWORD | varchar(255) | NO | | NULL | | +----------+--------------+------+-----+---------+-------+
現在のパスワード保存ロジックのバージョン番号を、PHP の設定ファイルに保存しておきます。そして、ログイン時、ユーザーの VERSION フィールドと、設定ファイルのバージョン番号が異なる場合は、VERSION, SALT, PASSWORD を最新のものに更新します。以降は、新しいパスワード保存ロジックでログインするようにします。
PHP のファイルは以下の4種類用意します。
function.php | 共通設定ファイル |
login.php | ログイン画面 |
login_check.php | ログインチェック |
welcome.php | ログイン後画面 |
サンプルコード
・function.php
<?php define("DSN","mysql:host=localhost;dbname=Test;charset=utf8"); //文字エンコーディング必須 define("USERNAME", "user01”); //DBユーザーID define("PASSWORD", "pass”); //DBユーザーパスワード define("PASSWORD_VERSION", 1); //パスワードロジック変更でカウントアップ define("STRETCH_COUNT", 1000); /* * バージョンに合わせたパスワードを取得 */ function get_password($version, $salt, $password) { switch ($version) { case 1: return get_md5_hash_password($salt, $password); break; case 2: return get_streched_password($salt, $password); break; default: die("バージョンエラー"); } } /* * パスワードをソルト+MD5ハッシュ(Ver.1) */ function get_md5_hash_password($salt, $password) { return md5($salt . $password); } /* * パスワードをソルト+ストレッチング (Ver.2) */ function get_streched_password($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); } ?>
・login.php
<?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>"; } //エラー情報のリセット $_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> </body> </html>
・login_check.php
<?php require_once("function.php"); session_start(); header("Content-type: text/html; charset=utf-8"); //パラメーター取得 $id = $_POST['id']; $password = $_POST['password']; //ログイン判定 try { //文字エンコーディングを必ず指定する $dbh = new PDO(DSN, USERNAME, PASSWORD); // 静的プレースホルダを指定 $dbh->setAttribute(PDO::ATTR_EMULATE_PREPARES, false); // エラー発生時に例外を投げる $dbh->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); $stmt = $dbh->prepare("SELECT * FROM USERS WHERE ID = ?;"); $stmt->setFetchMode(PDO::FETCH_ASSOC); $stmt->bindParam(1,$id, PDO::PARAM_STR); $stmt->execute(); $count = 0; while ($row = $stmt->fetch()) { $id = $row["ID"]; $version = $row["VERSION"]; $salt = $row["SALT"]; $db_password = $row["PASSWORD"]; $count++; } $dbh = null; //ログイン失敗 if ($count != 1) { $_SESSION["error_status"] = 1; header("HTTP/1.1 301 Moved Permanently"); header("Location: login.php"); exit(); } //パスワード生成 $hash = get_password($version, $salt, $password); if ($hash == $db_password) { //ログイン成功 //セッション ID の振り直し session_regenerate_id(true); //セッションに ID を格納 $_SESSION['id'] = $id; //パスワードのバージョン更新処理 if (PASSWORD_VERSION != $version) { update_password($id, $password); } //リダイレクト 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"); } } catch(PDOException $e){ echo $e->getMessage(); } //パスワードの更新処理 function update_password($id, $password){ try { //登録情報生成 //バージョン(変数に入れないとPDOでエラー) $version = PASSWORD_VERSION; //ソルト $salt = get_salt(); //パスワード生成 $hash = get_password($version, $salt, $password); //文字エンコーディングを必ず指定する $dbh = new PDO(DSN, USERNAME, PASSWORD); // 静的プレースホルダを指定 $dbh->setAttribute(PDO::ATTR_EMULATE_PREPARES, false); // エラー発生時に例外を投げる $dbh->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); $stmt = $dbh->prepare("UPDATE USERS SET VERSION = ?, SALT = ?, PASSWORD = ? WHERE ID = ?;"); $stmt->setFetchMode(PDO::FETCH_ASSOC); $stmt->bindParam(1, $version, PDO::PARAM_INT); $stmt->bindParam(2, $salt, PDO::PARAM_STR); $stmt->bindParam(3, $hash, PDO::PARAM_STR); $stmt->bindParam(4, $id, PDO::PARAM_STR); $stmt->execute(); } catch(PDOException $e){ echo $e->getMessage(); } } ?>
・welcome.php
<?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> </body> </html>
解説
ほぼコードを見たままなので、細かい解説はあまり必要ないかと思いますが、パスワード保存ロジックのバージョンが変わった場合は、function.php の PASSWORD_VERSION 定数をカウントアップし、get_password 関数をアップデートします。
これで、パスワード保存ロジックが変更になっても、自動的にパスワード保存ロジックが最新のデータに反映されるようになります。
但し、ログインしないユーザーはデータが更新されないので、リセットフラグを設定して、一定期間経過後パスワードが変更されないユーザーには、ログイン依頼メールを送信するというのが現実的な対応でしょう。
なお、PHP 5.5.0 からは、これらの処理を自動で行ってくれる password-hash 関数があるので、こちらの使用がいろいろなサイトで推奨されています。
ただ個人的には、面倒でもこの記事にある方法で対応した方が柔軟性が高くてよいのではないかと考えています。言語も関係ありませんし、ストレッチングの回数を増やすといった対応もできますし。
おわりに
PHP + PDO + MySQL で、Web アプリケーションのパスワード保存ロジック変更時に対応が可能な実装方法を見てきましたが、いかがだったでしょうか。テーブルにバージョンフィールドを追加するだけで、思ったより簡単に対応できることが分かります。
ですので、現在パスワードの保存方法に不安がある方は、 こういう簡単な方法があるということで安全なパスワード保存方法へ移行するとよいかと思います。
コメント