【ハンズオン】PHPでシンプルなRemember Me(自動ログイン)を実装する


初めに

認証周りが分からないというコンプレックスを克服すべく、下記の2記事に続き、Remember Me(自動ログイン機能)を実装してみました。

https://zenn.dev/syamozipc/articles/php_auth_register
https://zenn.dev/syamozipc/articles/php_password_reset

どなたかの役に立てば幸いです。

実装の流れ

  1. ログインフォームで「ログイン情報を記憶する」チェックボックスを置く
  2. メールアドレスとパスワードがusersテーブルのレコードに合致したら、そのユーザーとしてログイン
  3. もし「ログイン情報を記憶する」にチェックが入っていたら、下記の処理をし、ログイン
    • ランダムな文字列のトークンを生成し、cookieにremember_tokenとして保存
    • 上記トークンを暗号化したものを、usersテーブルのremember_tokenカラムに保存
  4. マイページへアクセス時、下記の通りログインチェック
    • セッションにユーザーIDがあれば、ログイン済みとしてマイページを表示
    • ユーザーIDは無いがcookieにremember_tokenがあれば、その値をusersテーブルのremember_tokenに持つユーザーとしてログインし、マイページへ
    • 上記どちらも当てはまらなければ、未ログインとしてログインフォームを表示
  5. マイページのログアウトボタンを押下時、usersテーブルのremember_tokenカラムをNULLに更新しログアウト

ファイル構成

.
├─ database.php
├─ login.php
├─ logout.php
├─ show_login_form.php
├─ show_mypage.php
└─ views
    ├── login_form.php
    └── mypage.php

使用するテーブル

usersテーブル

カラム名 備考
id 主キー
email
register_token 仮登録時に使用。ユーザーを一意に識別する
register_token_sent_at 仮登録の有効期限管理に使用
register_token_verified_at 本登録時に更新
password
remember_token remember me実装に使用。ユーザーを一意に識別する
status enum型。tentative:仮登録、puvlic:本登録
created_at
updated_at
作成したDDLはこちら(MySQLを想定)
CREATE TABLE `users` (
    `id` INT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
    `email` VARCHAR(50) UNIQUE NOT NULL,
    `register_token` VARCHAR(80),
    `register_token_sent_at` DATETIME,
    `register_token_verified_at` DATETIME,
    `password` VARCHAR(80),
    `remember_token` VARCHAR(80),
    `status` ENUM('tentative', 'public') NOT NULL DEFAULT 'tentative',
    `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP  ON UPDATE CURRENT_TIMESTAMP
);
流し込み用SQL
INSERT INTO `users` (`id`, `email`, `register_token`, `register_token_sent_at`, `register_token_verified_at`, `password`, `status`, `created_at`, `updated_at`)
VALUES
	(1,'[email protected]','45085a3ce32c6623c4159dc3e202007c6ef26d0a8201ab9802426a35a0bc2474','2022-03-27 12:21:41','2022-03-27 12:21:58','$2y$10$BmxkPc6uMmRyWvgvpUUs1OOlSuplcyLb6FzQEooh1LHS/aPUt6no2','public','2022-03-27 12:21:41','2022-03-27 16:38:58');

1. メールアドレスとパスワードがusersテーブルのレコードに合致したら、そのユーザーとしてログイン

show_login_form.php
<?php
session_start();

// formに埋め込むcsrf tokenの生成
if (empty($_SESSION['_csrf_token'])) {
    $_SESSION['_csrf_token'] = bin2hex(random_bytes(32));
}

// 本登録フォームを読み込む
require_once './views/login_form.php';

フォーム部分(formタグ内を抜粋)

views/login_form.php
<p>ログイン</p>
<form action="login.php" method="POST">
    <input type="hidden" name="_csrf_token" value="<?= $_SESSION['_csrf_token']; ?>">
    <label>メールアドレス
        <input type="email" name="email">
    </label>
    <br>
    <label>パスワード
        <input type="password" name="password">
    </label>
    <br>
    <label>ログイン情報を記憶する
        <input type="checkbox" name="remember_me">
    </label>
    <br>
    <button type="submit">ログイン</button>
</form>

2. メールアドレスがusersテーブルに登録済みなら3へ、未登録ならメール送信完了画面を表示して終了

login.php
<?php
session_start();

require_once './database.php';
$pdo = getPdo();

$request = filter_input_array(INPUT_POST);

// csrf tokenが正しければOK
if (
    empty($request['_csrf_token'])
    || empty($_SESSION['_csrf_token'])
    || $request['_csrf_token'] !== $_SESSION['_csrf_token']
) {
    exit('不正なリクエストです');
}

// 本来はここでメールアドレスとパスワードのバリデーションをする

// 入力されたメールアドレスに合致するユーザーを取得
$sql = 'SELECT * FROM users WHERE `email` = :email AND `status` = :status';
$stmt = $pdo->prepare($sql);
$stmt->bindValue(':email', $request['email'], \PDO::PARAM_STR);
$stmt->bindValue(':status', 'public', \PDO::PARAM_STR);
$stmt->execute();
$user = $stmt->fetch(\PDO::FETCH_OBJ);

// 「メールアドレスが間違っている」のようにどちらが間違っているのか表示すると、
// 別人のアカウントでログインしようとする悪意あるユーザーに、
// 「メールアドレスが間違っている」「パスワードは間違っていない」という情報を与えてしまうので、明示しない
if (
    !$user
    || !password_verify($request['password'], $user->password)
) {
    exit('登録情報が間違っています。');
}

// セッションが有効な間はログイン済みとなる
$_SESSION['user_id'] = $user->id;

// 「ログイン情報を記憶する」をチェックしていなければ、このままマイページへ
if (empty($request['remember_me'])) {
    header('Location: ./show_mypage.php');
    exit();
}

コード内に出てくるdatabase.phpの中身はこちらです。

database.php
database.php
<?php

function getPdo()
{
    $dsn = 'mysql:host=localhost;dbname=zenn;charset=utf8mb4';
    $options = [
        \PDO::ATTR_PERSISTENT => true,
        \PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION,
    ];

    try {
        return new \PDO($dsn, 'root', 'root', $options);
    } catch (\PDOException $e) {
        exit($e->getMessage());
    }
}

3. もし「ログイン情報を記憶する」にチェックが入っていたら、Remember Me(自動ログイン)の処理をする

login.php(※2.のファイルの続き)
// ランダムな文字列のtokenを生成
$rememberToken = bin2hex(random_bytes(32));

// 暗号化したremember tokenを保存
$sql = 'UPDATE users SET `remember_token` = :remember_token WHERE `email` = :email';
$stmt = $pdo->prepare($sql);
$stmt->bindValue(':remember_token', md5($rememberToken), \PDO::PARAM_STR);
$stmt->bindValue(':email', $request['email'], \PDO::PARAM_STR);
$stmt->execute();

// cookieのオプション
$options = [
    'expires' => time() + 60 * 60 * 24 * 365, // cookieの有効期限を1年間に設定
    'path' => '/', // 有効範囲を「ドメイン配下全て」に設定
    'httponly' => true // HTTPを通してのみcookieにアクセス可能(JavaScriptからのアクセスは不可となる)
];

setcookie('remember_token', $rememberToken, $options);

header('Location: ./show_mypage.php');
exit();

4. マイページへアクセス時、2通りのログインチェックをし、マイページを表示

show_mypage.php(※3.のファイルの続き)
<?php
session_start();

require_once './database.php';
$pdo = getPdo();

// (1) セッションにユーザーIDが保存されている場合
if (isset($_SESSION['user_id'])) {
    // セッションに保存されたユーザーIDに合致するユーザーを取得
    $sql = 'SELECT * FROM users WHERE `id` = :id AND `status` = :status';
    $stmt = $pdo->prepare($sql);
    $stmt->bindValue(':id', $_SESSION['user_id'], \PDO::PARAM_INT);
    $stmt->bindValue(':status', 'public', \PDO::PARAM_STR);
    $stmt->execute();
    $user = $stmt->fetch(\PDO::FETCH_OBJ);

// (2) セッションのユーザーIDは破棄されているが、cookieにremember_tokenが保存されている場合
} else if (isset($_COOKIE['remember_token'])) {
    // usersテーブルに保存されている remember_token は md5() で暗号化されているので、同様に暗号化した token をWHERE句に指定する
    $sql = 'SELECT * FROM users WHERE `remember_token` = :remember_token AND `status` = :status';
    $stmt = $pdo->prepare($sql);
    $stmt->bindValue(':remember_token', md5($_COOKIE['remember_token']), \PDO::PARAM_STR);
    $stmt->bindValue(':status', 'public', \PDO::PARAM_STR);
    $stmt->execute();
    $user = $stmt->fetch(\PDO::FETCH_OBJ);
}

// ユーザーが一致しなければ、ログインフォームへ
if (empty($user)) {
    header('Location: ./show_login_form.php');
    exit();
}

// ユーザーIDをセッションに保存してログイン済みとする
$_SESSION['user_id'] = $user->id;

// formに埋め込むcsrf tokenの生成
if (empty($_SESSION['_csrf_token'])) {
    $_SESSION['_csrf_token'] = bin2hex(random_bytes(32));
}

require_once './views/mypage.php';

マイページ(formタグ内を抜粋)

mypage.php
<p><?= $user->email ?>さんのマイページ</p>
<form action="logout.php" method="POST">
    <input type="hidden" name="_csrf_token" value="<?= $_SESSION['_csrf_token']; ?>">
    <button type="submit">ログアウトはこちら</button>
</form>

cookieを確認するため、検証ツール → Applicationタブ → Storage →Cookies と確認してみましょう。

(1) セッションにユーザーIDが保存されている場合

cookieに保存されているのはセッションクッキーのみです。

1度ブラウザを終了し、ブラウザを再度起動して直接マイページへ行ってみましょう。
セッションはブラウザを終了すると破棄されるため、ログアウト状態となりログインフォームが表示されます。

(2) セッションのユーザーIDは破棄されているが、cookieにremember_tokenが保存されている場合

cookieにはセッションクッキーに加え、remember_tokenが保存されています。

cookieは有効期限を1年に設定したため、ブラウザを閉じても破棄されません。
ブラウザを終了してから再度起動しマイページへ行くと、remmeber_tokenを取得し、値が一致するユーザーでログインされます。

※session_startを宣言しているので、PHPSESSID自体は発行されています

5. マイページのログアウトボタンを押下時、usersテーブルのremember_tokenカラムをNULLに更新しログアウト

logout.php
<?php
session_start();

require_once './database.php';
$pdo = getPdo();

$request = filter_input_array(INPUT_POST);

// csrf tokenが正しければOK
if (
    empty($request['_csrf_token'])
    || empty($_SESSION['_csrf_token'])
    || $request['_csrf_token'] !== $_SESSION['_csrf_token']
) {
    exit('不正なリクエストです');
}

// remember_tokenをNULLにする
$sql = 'UPDATE users SET remember_token = NULL WHERE `id` = :id';
$stmt = $pdo->prepare($sql);
$stmt->bindValue(':id', $_SESSION['user_id'], \PDO::PARAM_INT);
$stmt->execute();

// remeber_tokenをcookieから削除
setcookie('remember_token', '', time() - 6000, '/');

// 以下、セッションの削除処理

// セッション変数を初期化(メモリから削除するため)
$_SESSION = [];

// セッションクッキーを削除
setcookie('PHPSESSID', '', time() - 6000, '/');

// セッションファイル(セッションの実データ)を削除
session_destroy();

// ログインフォームへ
header('Location: ./show_login_form.php');
exit();

最後までお読みいただき、ありがとうございました!