PHPでの自動ログイン処理とセッションロックの備忘録
前回書いた「SQLiteのDatabase is lockedで悩む人に見て欲しいロックの話」とも微妙に関連するのですが、PHPでの自動ログイン処理※の実装について、注意点が多いわりに忘れがちなので個人的に軽くまとめておこうと思いました。
サンプルのテーブル構造
■ユーザーテーブル
user_id | TEXT | PRIMARY KEY | ユーザーID |
user_pw | TEXT | ハッシュ化パスワード |
※SQL文
CREATE TABLE user (user_id text PRIMARY KEY, user_pw TEXT);
サンプルなのでギリギリまで簡略化しており、IDとハッシュ化パスワードを保存する項目のみです。
■トークンテーブル
token | TEXT | PRIMARY KEY | 自動ログイントークン |
user_id | TEXT | ユーザーID | |
insert_date | DATETIME | トークン発行日時 |
※SQL文
CREATE TABLE user_token (token text PRIMARY KEY, user_id TEXT, insert_date DATETIME);
複数個所で自動ログインが使えるようにあえてtokenのみをPRIMARY KEYにしています。
tokenとuser_idの両方でユニークキーにしてしまうと、ユーザー1人につき、トークンが1つとなってしまい、例えば自宅のPCで自動ログイン設定にしていたのに職場で一度ログインするとトークンが消えて自宅PCではまたログインしなおさなければならない、という仕様だと不親切かなと感じたからです。
逆にそのほうが良いという場合はtokenとuser_idをユニークキーにして、トークンの再発行時はuser_idをキーに全削除すると良いでしょう。
テスト用データの作成
PHPソース
<?php
//データベース接続
$db = new PDO('sqlite:test.db');
//テスト用にユーザーデータを作る
function init()
{
global $db;
$db->query('CREATE TABLE IF NOT EXISTS user (user_id text PRIMARY KEY, user_pw TEXT)');
$db->query('CREATE TABLE IF NOT EXISTS user_token (token text PRIMARY KEY, user_id TEXT, insert_date DATETIME)');
$st = $db->prepare('INSERT INTO user (user_id, user_pw) VALUES (?, ?)');
$st->execute(array('testuser', password_hash('hogehoge', PASSWORD_DEFAULT)));
}
?>
テスト用データですが、パスワードはPHPのpassword_hash関数を使ってハッシュ化する必要があるため、PHPでテストデータを作成しています。
ユーザーID:testuser パスワード:hogehoge
パスワードに関する余談
一昔前はハッシュ化といえばmd5やsha1を使うのが主流でしたが、それではブルートフォース(総当たり)攻撃に弱いということで、openssl_random_pseudo_bytesとsaltを使い、更には1000回くらいハッシュ化を繰り返して複雑化するのが次の流行りとなりました。
しかし最近はそのへんをいい感じでやってくれるpassword_hash関数を使うのがトレンドらしいです。saltも自動で付けて生成してくれます。これにより、いわゆるレインボーテーブルと呼ばれるハッシュ化パスワードから平文を類推する方式での解析はかなり難しくなるらしい。
ただ、当たり前ですが、そのハッシュ化のパスワードが正しいかどうか比較するpassword_verify関数があるため、攻撃者が同関数を使って総当たりすれば理論上は解析できないこともないかも知れません。これには凄まじい計算量が必要な上、GPUでは処理しづらい計算方式を採用しているため、現実的には不可能に近い、ということらしい。詳しくは知らん!
いずれにせよ、このへんの技術革新はイタチごっこですし、大前提として「ハッシュ化パスワードが盗まれた場合」というのがあります。もうその時点でアレなわけでして。ハッシュ化されたパスワード、つまりデータベースが盗まれないことが大前提であり、もう一つの保険として、ハッキングがわかってから、解析されるまでの時間稼ぎができれば良いのでは、という考え方が根底にあるわけですね。
PHPによるログイン処理と自動ログイン処理
全体を見たいのか、個別に解説を必要とするかは人によると思うので、まずはドバッと全体のソースを掲載します。
ログイン画面を表示し、[ログイン]ボタンでログイン処理。ログイン中は「ログイン中です」と表示されるだけの画面構成。
<?php
session_start();
//データベース接続
$db = new PDO('sqlite:test.db', '', '', array(PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, PDO::ATTR_TIMEOUT => 20));
//処理分岐
if (isset($_POST['logout'])) {
logout();
} else if (!isset($_SESSION['user_id']) && isset($_COOKIE['token'])) {
//ログイン中ではないが、クッキーに自動ログイントークンがあった場合
auto_login();
} else if (isset($_POST['user_id']) && isset($_POST['user_pw'])) {
//ユーザーIDとパスワードがPOSTされた場合
login();
} else if (isset($_SESSION['user_id'])) {
//ログイン中の場合
viewLogout();
} else {
//それ以外はログイン画面を表示
viewLogin();
}
//ログイン画面の表示
function viewLogin()
{
?>
<html>
<body>
<form method="post">
ユーザーID:<input type="text" name="user_id" value="testuser">
パスワード:<input type="password" name="user_pw" value="hogehoge">
<button type="submit">ログイン</button>
</form>
</body>
</html>
<?php
}
//ログイン中の画面(ログアウトボタンのみ表示)
function viewLogout()
{
?>
<html>
<body>
ログイン中です
<form method="post">
<button type="submit" name="logout">ログアウト</button>
</form>
</body>
</html>
<?php
}
//ログイン処理
function login()
{
global $db;
$st = $db->query('SELECT * FROM user WHERE user_id=?');
$st->execute(array($_POST['user_id']));
if ($row = $st->fetch()) {
if (password_verify($_POST['user_pw'], $row['user_pw'])) {
//ログイン成功したのでセッションIDの振り直しとセッションへユーザーIDをセット
session_regenerate_id(true);
$_SESSION['user_id'] = $row['user_id'];
//自動ログイントークンの生成
setLoginToken($row['user_id']);
}
}
if (isset($_SESSION['user_id'])) {
echo 'ログインおっけー';
} else {
echo 'ユーザーIDまたはパスワードが違うよー';
}
}
//ログアウト処理
function logout()
{
$_SESSION = array();
if (isset($_COOKIE[session_name()])) {
//セッションクッキーを削除
setcookie(session_name(), '', time() - 42000, '/');
}
//自動ログイントークンを削除
setcookie('token', '', time()-42000, '/');
//セッションを削除
session_destroy();
header('location: login.php');
}
//自動ログイン処理
function auto_login()
{
global $db;
$_SESSION['dummy'] = 1;
$st = $db->query('SELECT * FROM user_token WHERE token=?');
$st->execute(array($_COOKIE['token']));
if ($row = $st->fetch()) {
//ログイン成功したのでセッションIDの振り直しとセッションへユーザーIDをセット
session_regenerate_id(true);
$_SESSION['user_id'] = $row['user_id'];
setLoginToken($row['user_id']);
viewLogout();
}
}
//自動ログイン用のトークンを生成
function setLoginToken($user_id)
{
global $db;
if (isset($_COOKIE['token'])) {
//発行済トークンは削除
$st = $db->prepare('DELETE FROM user_token WHERE token=?');
$st->execute(array($_COOKIE['token']));
}
//新しいトークンを生成(念のため重複チェックも)
$token = '';
$st = $db->prepare('SELECT * FROM user_token WHERE token=?');
for ($i = 0; $i < 100; $i++) {
$token_temp = bin2hex(openssl_random_pseudo_bytes(16));
$st->execute(array($token_temp));
if (!$st->fetch()) {
$token = $token_temp;
break;
}
}
if ($token == '') {
throw new Exception('token error');
}
//テーブルへトークンを保存
$st = $db->prepare('INSERT INTO user_token (token, user_id, insert_date) VALUES (?, ?, ?)');
$st->execute(array($token, $user_id, date('Y-m-d H:i:s')));
//クッキーへトークンを保存
setcookie('token', $token, time() + 60 * 60 * 24 * 7, '/');
}
?>
処理分岐の解説
//処理分岐
if (isset($_POST['logout'])) {
logout();
} else if (!isset($_SESSION['user_id']) && isset($_COOKIE['token'])) {
//ログイン中ではないが、クッキーに自動ログイントークンがあった場合
auto_login();
} else if (isset($_POST['user_id']) && isset($_POST['user_pw'])) {
//ユーザーIDとパスワードがPOSTされた場合
login();
} else if (isset($_SESSION['user_id'])) {
//ログイン中の場合
viewLogout();
} else {
//それ以外はログイン画面を表示
viewLogin();
}
■画面を表示する関数群
viewLogin | ログイン画面 |
viewLogout | ログイン中の画面(ログアウトボタンのみ表示) |
■ボタン押下時
login | [ログイン]ボタン押下で呼ばれる関数 |
logout | [ログアウト]ボタン押下で呼ばれる関数 |
■その他
auto_login | 自動ログイン実行時に呼ばれる関数 |
user_idとuser_pwがPOSTされたらログインボタンが押下されたと判断する等、だいぶ簡略化しています。
本番実装では入力エラーチェックはもちろんのこと、CSRF対策も忘れずに。
いずれにせよ、今回はUIの説明が主流ではないのでここはサラっと流します。
ログイン処理の解説
//ログイン処理
function login()
{
global $db;
$st = $db->query('SELECT * FROM user WHERE user_id=?');
$st->execute(array($_POST['user_id']));
if ($row = $st->fetch()) {
if (password_verify($_POST['user_pw'], $row['user_pw'])) {
//ログイン成功したのでセッションIDの振り直しとセッションへユーザーIDをセット
session_regenerate_id(true);
$_SESSION['user_id'] = $row['user_id'];
//自動ログイントークンの生成
setLoginToken($row['user_id']);
}
}
if (isset($_SESSION['user_id'])) {
echo 'ログインおっけー';
} else {
echo 'ユーザーIDまたはパスワードが違うよー';
}
}
[ログイン]ボタン押下時に呼ばれる関数です。
渡されたユーザーIDでuserテーブルを読み、同じく渡されたパスワード(平文)とハッシュ化パスワードをpassword_verify関数に渡し、パスワードが正しいかどうかをチェックしています。
そしてIDとパスワードの組み合わせが合っていたらログイン成功とみなし、session_regenerate_idでセッションIDを振りなおします。
これをやっておかないといわゆるセッションハイジャック時に困ったことになるので、ログイン処理実装時は必ず忘れずに。
トークン発行の解説
//自動ログイン用のトークンを生成
function setLoginToken($user_id)
{
global $db;
if (isset($_COOKIE['token'])) {
//発行済トークンは削除
$st = $db->prepare('DELETE FROM user_token WHERE token=?');
$st->execute(array($_COOKIE['token']));
}
//新しいトークンを生成(念のため重複チェックも)
$token = '';
$st = $db->prepare('SELECT * FROM user_token WHERE token=?');
for ($i = 0; $i < 100; $i++) {
$token_temp = bin2hex(openssl_random_pseudo_bytes(16));
$st->execute(array($token_temp));
if (!$st->fetch()) {
$token = $token_temp;
break;
}
}
if ($token == '') {
throw new Exception('token error');
}
//テーブルへトークンを保存
$st = $db->prepare('INSERT INTO user_token (token, user_id, insert_date) VALUES (?, ?, ?)');
$st->execute(array($token, $user_id, date('Y-m-d H:i:s')));
//クッキーへトークンを保存
setcookie('token', $token, time() + 60 * 60 * 24 * 7, '/');
}
今回の本命はここ。
[ログイン]ボタン押下時にも呼ばれるし、自動ログインと判定されたときにも呼ばれるトークン発行用の関数です。
- クッキーにtokenがあったら、それは古いトークンなのでuser_tokenテーブルから削除
- openssl_random_pseudo_bytesを使い、暗号学的に強い乱数を発行
- 重複する可能性は低いけれど、0ではないのでuser_tokenテーブルを読み、発行するトークンが重複していないか確認する
- この重複チェック&再発行処理は最大100回
- 100回やってもトークンが重複したら例外エラーを発生させる。
- 最後に発行したトークンをuser_tokenテーブルとクッキーに保存
- クッキーの有効期限は7日間とする
こんな処理をしています。
古いトークン(というより前回ログイン時のトークン)はここで削除していますし、クッキーの有効期限も7日としていますが、発行から7日以上経ったトークンはuser_tokenテーブルから削除するような処理をcronタスクなどに実装したほうが良いでしょう。
自動ログイン
$_SESSION['user_id']がセットされていないけれど、$_COOKIE['token']は存在する、という条件がそろったときに呼ばれる関数。
//自動ログイン処理
function auto_login()
{
global $db;
$_SESSION['dummy'] = 1;
$st = $db->query('SELECT * FROM user_token WHERE token=?');
$st->execute(array($_COOKIE['token']));
if ($row = $st->fetch()) {
//ログイン成功したのでセッションIDの振り直しとセッションへユーザーIDをセット
session_regenerate_id(true);
$_SESSION['user_id'] = $row['user_id'];
setLoginToken($row['user_id']);
viewLogout();
}
}
クッキーに保存されたトークンでuser_tokenテーブルを読み、ヒットしたユーザーIDでログインしています。
基本的にはログイン押下時の処理と同じで、セッションIDの振り直し、セッションへのユーザーID保存、そして最後にトークンの再発行。
自動ログイン用のトークンが盗まれたら勝手にログインされてしまうため、なるべく頻繁に再発行したほうが良いわけです。なんならこんな処理自体使わないほうが良いくらい。特に個人情報を扱うようなサイトなら絶対やめたほうが良い。
ただ、そういうサイトではなく、wikiみたいなユーザー投稿型のサイトで、あくまでユーザー識別程度の情報しか持っていなかったり、あるいは逆に社内用のWebシステムで外部に晒される可能性がないシステムでなら、こうした自動ログインの仕組みは便利です。
ちょっと脱線しましたが、ここで意外と重要なのが、↓この部分。
$_SESSION['dummy'] = 1;
dummyってなんぞやって話ですが、こうしてセッション変数に何らかのデータを入れることでセッションをロックしています。
自動ログインではこれをやっておかないと困るパターンがあります。1ページあたり絶対に1回しかここを通らない、という設計なら良いのですが、たぶん一般的に自動ログイン処理は include ファイル内で実装して、サイト内の全ページどこを開いても自動ログインする、という作りにすることが多いのではないでしょうか。
それだけならまだしも、iframeを使ったり、JavaScriptのXMLHttpRequestによる非同期実行などでPHPを読むパターン、あるいはPHPを通して画像を出力するパターンなどなど。
どんなケースでも構いませんが、1つのURLを開いたときに複数のPHPが同時にブラウザにロードされるケースで問題になります。
複数のPHPで同時に自動ログイン処理が実行され、しかもどのPHPが最初に動くかわからないのです。
するとどうなるか。
1番:クッキーのトークンAとuser_tokenを照合 → user_tokenからトークンAを削除 → トークンBを発行
2番:クッキーのトークンAがuser_tokenに存在しない → ログイン失敗
このとき、1番がindex.phpで、2番が(iframe等で読み込まれる)content.phpならページの大枠としてはログインしているけれど、サブコンテンツはログインユーザー用にパーソナライズされていないページとなります。
その逆だったら、全体としてはマイページではないのにサブコンテンツだけパーソナライズされている状態になります。どちらにせよ、チグハグな状態。
じゃあindex.phpだけで自動ログイン処理すればいいじゃん、といえばそのとおり。必ずindex.phpが先に動作するようにして、ページのロードが終わった後、JavaScriptのonloadイベントなどでサブコンテンツを読み込めばすべてのページがログイン状態と判定されるためみんな幸せ。
ただ、どうしてもそれができないケースというのも存在します。どのPHPから読み込まれるかはわからん。でも、どれか最初に読まれたPHPでログインを完了させ、その他のPHPはログイン状態で開いてほしい。
過去、そんな要件が発生してしまったことがあり、苦肉の策として、
$_SESSION['dummy'] = 1;
を使いました。
$_SESSION['dummy']に値を入れると、セッションロックが発生し、このdummyを書き換えようとするスクリプトはいったん停止状態になるのです。この仕組みを利用して、最初に読み込まれるPHPがどれかはわからんけど、一番最初に読まれたPHPでログイン処理を完結させ、続くPHPでログイン済となるようにしたというわけです。
セッションロックの例
文章ではわかりづらかったかもなので、簡単なサンプルコードでセッションロックの説明をしてみます。
■session1.php
<?php
session_start();
$_SESSION['test1'] = 1;
sleep(5); //なんかの処理
echo "Done.";
?>
■session2.php
<?php
session_start();
$_SESSION['test1'] = 1;
echo "Done.";
?>
ブラウザでsession1.phpを開いてから、すぐにsession2.phpを開いた場合、どちらも5秒待った後にDoneと表示されます。
session2.phpにはsleepを書いていないのに、です。
セッションロックって本来はやっかいな仕組みで、いろいろ重い処理をした後で、最後にセッションの更新をしましょう、ってのが定石なのですが、ロックされる仕組みを利用して、待ち行列的な処理も出来るのです。
あんま良い設計とはいえないので、ホント苦肉の策なんですけどね。1から設計する余裕があるなら、先述したとおり、ゆっくりロードして良いPHPはonloadイベント等で実行してあげたほうが良いです。
まとめ
随分昔に作ったECサイトの管理画面のログイン処理等、古いコードが残っていたので整理していたのですが、そのついでにログイン処理の基本部分について記事にしてみました。
大まかなポイントとしては、
- 最近のハッシュ化パスワードはpassword_hashとpassword_verify関数で実装するのが流行り
- ログインするたびにセッションの再生成(session_regenerate_id)を忘れずに
- 自動ログイントークンはopenssl_random_pseudo_bytesを使うけど重複する可能性が0ではないので重複チェックも忘れずに
- 同時、並列的に自動ログイン処理が呼ばれる可能性がある場合はセッションロックでロックしちゃうのが楽っちゃ楽
と、こんなところでしょうか。
本来、ログイン処理といえばログイン履歴の記録や、ログインの試行回数、ロックアウト機能等、まだまだ実装しなければならない機能が山ほどあります。しかしそれを全部載せていたらめんd…ごほん…とても長くなってしまうので、だいぶコードを削りました。
ですので、もし、万が一、このページへたどり着いてしまったプログラミング初学者の人はこれで万全!などと思わないよう十分ご注意ください。この記事は経験者向けの備忘録です。