PHPでログイン機能を実装してみたいけど、いざ作ろうとすると何をどうしていいのか分からないな……
この記事では主にPHPでログイン機能を実装する方法を解説します。
- PHPにおけるログイン機能のソースコード解説
- PHPでログイン機能を実装する上で必要な「セキュリティ対策」とそのソースコード
- Webアプリケーションにおけるログイン機能の仕組み
- パスワードを安全に管理する「ハッシュ化」について
PHPにおけるログイン機能の仕組み
みなさんは「ログイン機能」と聞いて、具体的にどのような動きを思い浮かべるでしょうか?
恐らく多くの方は以下のような流れをイメージされるのではないかと思います。
一般的なログイン機能の流れ(イメージ)
- 会員登録をする
- ログイン画面でIDやパスワードを入力する
- ログインボタンを押す
- (ログインに成功したら)サイトのトップページなどに遷移する(ログインに失敗したら)エラーメッセージが表示される
- (ログイン後は)サイト内を回遊する
上記のような流れは、普段からIT技術に触れている、触れていないに関わらずイメージされるものかと思います。
しかし実を言うと、実際にプログラミングで「ログイン機能」を実装する場合、このイメージだけでは少し足りていない部分があります。
その点を踏まえた上で、次節より
「ログイン機能を実装する上で理解していなければならない仕組み」
について解説をしていきます。
新規会員登録機能
新規会員登録機能の仕組み(実装する場合の処理の流れ)は以下の通りです。
- ユーザーが会員登録フォームに必要情報を入力する
- ユーザーが「登録」ボタンを押す
- PHPで入力情報が正しいかどうか(同一ユーザーが存在しないかなど)をチェックする
- データベースにユーザー情報を登録する
- 会員登録が完了したことをユーザーに知らせる
仕組みという点では、上記の流れをおおよそイメージできれば追加で解説する内容はありません。
PHPで素直に上記の流れを追うかたちでプログラミングすれば「新規会員登録機能」を実装することが可能です。
ログイン機能
ログイン機能の仕組み(実装する場合の処理の流れ)は以下の通りです。
- ユーザーがログインフォームに必要情報(ユーザーIDやパスワード)を入力する
- ユーザーがログインボタンを押す
- PHPで入力された「ユーザーID」が存在するかどうかをチェックする
- (「ユーザーID」が存在すれば)「ユーザーID」を元にデータベースから会員情報を取得し、「パスワード」が正しいかどうかをチェックする
- (「パスワード」が正しければ)ユーザーに対してセッションを割り当て、セッション変数に会員情報を格納する(★)
恐らく④までの流れは、多くの方がイメージされている通りかと思います。
(「新規会員登録機能」と同様、「パスワード」のチェックでは「ハッシュ化」が関係してきますが)
特筆すべきは「⑤ユーザーに対してセッションを割り当て、セッション変数に会員情報を格納する」の部分です。
どうしてユーザーに対して「セッションを割り当てるのか」、「セッション変数に会員情報を格納するのか」については、すぐ次の節で解説します。
※そもそもセッションが何か分からないという方は以下の記事をご覧ください。
ログイン状態のチェック(セッション)
どういうこと……?
と思われる方もたくさんいらっしゃるかと思います。
恐らく一般的なログイン機能に対するイメージは
ログインに成功したら、ログアウトするまでは(なんとなく)勝手にログイン状態が維持される
という感じかと思われます。
僕もWebアプリの仕組みを知るまでは(なんとなく)こんな風に考えていました。
しかし、実際に「ログイン機能」を実装する場合の仕組みはそのようなシステム任せのものではなく、実装者がしっかりとセッションによって制御する必要があります。
- ユーザーがログイン画面からログインをする
- ユーザーがトップページにアクセスする
(と同時にPHPでセッション変数に「ユーザー情報」が正しく設定されているかどうかをチェックする) - ユーザーがページAへアクセスする
(と同時にPHPでセッション変数に「ユーザー情報」が正しく設定されているかどうかをチェックする) - ユーザーがページBへアクセスする
(と同時にPHPでセッション変数に「ユーザー情報」が正しく設定されているかどうかをチェックする) - ユーザーがページCへアクセスする
(と同時にPHPでセッション変数に「ユーザー情報」が正しく設定されているかどうかをチェックする) - ユーザーが再びページBへアクセスする
(と同時にPHPでセッション変数に「ユーザー情報」が正しく設定されているかどうかをチェックする) - ユーザーが「ログアウト」ボタンを押す
- PHPでセッション変数から「ユーザー情報」を削除し、セッション自体も破棄する
- ログイン画面へリダイレクトする
このように
ユーザーがログインした直後からは
ページ遷移する度に「ユーザー情報がセッション変数に設定されているかどうか」を逐一チェックする
ことで「ログイン済みかどうか」を確認する必要があります。
これはWebという仕組みが「ステートレス(以前の状態を維持しない性質)」であるためで、そもそもとしてWeb(正確にはHTTPやHTTPS通信)自体に「ログイン状態を自動で確認(保持)する仕組み」が無いことに由来しています。
「ログイン機能」の裏側がこうした仕組みになっていることを初めて知ったという方にとっては少々まどろっこしく感じるかもしれませんが、実際のプログラム内容自体はそれほど複雑なものではないのでご安心ください
※詳しくは「【解説】PHPでログイン機能を実装する」の章の中で解説します。
Web技術の仕組みを詳しく知りたい方向けの書籍
パスワードを安全に管理する「ハッシュ値」の仕組み
先ほど新規会員登録機能の解説で「ハッシュ化」というキーワードが出てきました。
「ハッシュ化」もログイン機能を実装する上で非常に重要な概念のため、この章で詳しく解説します。
「ハッシュ値」とは?
「ハッシュ化」というのは
文字列や数値などのデータを「ハッシュ値」に変換すること
です。
では、そもそも「ハッシュ値」とはどんなものなのでしょう?
- 入力されたデータに対する適当な値のこと
- ランダムな文字列・数値の組み合わせ
- よく似たものに「暗号」がある
よく比較に用いられるものとして「暗号」があります。
これはなんとなくイメージでご存じの方も多いかと思いますが、「暗号」も簡単に言えば「入力されたデータに対する適当(ランダム)な値のこと」です。
え、それじゃあ「ハッシュ値」と「暗号」って何が違うの?
「ハッシュ値」と「暗号」の大きな違いは
元のデータに戻せるかどうか
です。
- ハッシュ値 …… 元のデータ → ランダムな値 → 元のデータ ×
- 暗号 …… 元のデータ → ランダムな値 → 元のデータ ○
「暗号」は一度ランダムな値に変換した後も元のデータへ戻せますが(これを「復号」と言います)、
「ハッシュ値」は元のデータに戻せません。
つまり、「ハッシュ化」は一方通行ということです。
「ハッシュ化」することでパスワードを安全に管理できる
パスワードを安全に管理する上で重要なポイントは、主に以下の点です。
- ネット上の通信において、パスワードが「平文=そのままの状態」で送信されないようにする(HTTPS通信による暗号化を用いる)
- パスワードをデータベースから簡単に抜き出せないよう対策する(SQLインジェクション対策)
- システム管理者がデータベースを見ても、パスワードが分からないようにする(ハッシュ化)
- 万が一、パスワード(ハッシュ値)が漏洩しても、復元できない状態にしておく(ハッシュ化)
①や②についてはまた別の対策になりますが、③、④についてはパスワードを「ハッシュ化」をすることで実現可能です。
パスワードを「ハッシュ化」して「ハッシュ値」の状態で保存しておくことにより、システム管理者や攻撃者が元々のパスワードを知れないようにできます。
攻撃者だけではなく、システム管理者にも元々のパスワードが分かってしまわないようにするというのも大きなポイントです。
入力されたパスワードが正しいかどうかを「ハッシュ値」で判定する方法
しかし、ここで疑問に思う方もいらっしゃるかもしれません。
「ハッシュ値」は元の文字列に戻せないのに、どうやって「入力されたパスワード」が正しいかどうかを判定するんだろう……?
この疑問はつまり、「ハッシュ値自体からはそもそもパスワードを復元できないのだから、ユーザーが入力したパスワードと保存されているパスワード(ハッシュ値)の比較ができないのでは?」ということです。
この疑問は「ハッシュ化」の性質を知ることでスッキリ解消できます。
- 一度ハッシュ化した文字列は、元の文字列には復元できない(変換は一方通行)
- 同じ文字列を同じルールに従ってハッシュ化すると、必ず同じハッシュ値になる
ここで特に重要なのが「②同じ文字列を同じルールに従ってハッシュ化すると、必ず同じハッシュ値になる」です。
これはつまり
- 入力されたパスワードをハッシュ化した文字列(ハッシュ値)
- データベースに保存されているハッシュ値
この2つを比較して等しければ、入力されたパスワードが正しいと判定できることを意味します。
【解説】PHPでログイン機能を実装する
ここからは具体的にPHPにおけるログイン機能の実装方法を解説していきます。
ページ構成・機能イメージ
- トップページ
- ログインページ
- 新規会員登録ページ
ページごとの大まかな機能
ページ | 実装機能 |
---|---|
トップページ | ログインしていない状態でアクセスされたら「ログインページ」へ強制リダイレクト ログアウト機能 ログイン中のユーザー情報の表示 |
ログインページ | ログイン機能 ログイン認証機能(入力情報に誤りがあったらエラーメッセージを返す) |
新規会員登録ページ | 新規会員登録機能 既存会員情報チェック機能(すでに登録済みのIDが入力されていたらエラーメッセージを返す) |
ファイル構成
ファイル構成は以下の通りです。
データベース構成
データベース構成は以下の通りです。
テーブル
テーブル名 | 説明 |
---|---|
user | ユーザー情報を登録する |
userテーブルのカラム構成
カラム名 | 説明 |
---|---|
id | データを識別するための一意の値 ※データが追加されるごとに自動で設定(AUTO INCREMENT) |
name | ユーザー名 |
login_id | ログインID ※他ユーザーとの重複不可 |
password | パスワード |
created_at | 会員登録日時 |
ソースコード解説
早速ですが、まずは今回解説するソースコード全体を掲載します。
<?php
/**
* セッションスタート
*/
ini_set('session.gc_maxlifetime', 1800);
ini_set('session.gc_divisor', 1);
session_start();
session_regenerate_id(); // セッションIDを新しいものに置き換える(★セッションハイジャック)
/**
* DB接続情報
*/
const DB_HOST = 'mysql:dbname=login_app;host=127.0.0.1;charset=utf8';
const DB_USER = 'kekenta';
const DB_PASSWORD = 'kekenta_pass';
/**
* 会員登録
*/
if (isset($_POST['regist_btn']) &&
(isset($_POST['name']) && $_POST['name'] != '') &&
(isset($_POST['login_id']) && $_POST['login_id'] != '') &&
(isset($_POST['password']) && $_POST['password'] != '')
) {
/**
* トークンチェック(★CSRF)
*/
if (empty($_SESSION['regist_token']) || ($_SESSION['regist_token'] !== $_POST['regist_token'])) exit('不正なリクエストです');
if (isset($_SESSION['regist_token'])) unset($_SESSION['regist_token']);//トークン破棄
if (isset($_POST['regist_token'])) unset($_POST['regist_token']);//トークン破棄
// POSTデータの取得
$name = $_POST['name'];
$login_id = $_POST['login_id'];
$password = $_POST['password'];
// パスワードをハッシュ化する(★SQLインジェクション)
$password_hash = password_hash( $password, PASSWORD_DEFAULT );
try {
/**
* DB接続処理
*/
$pdo = new PDO(DB_HOST, DB_USER, DB_PASSWORD, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, // 例外が発生した際にスローする
PDO::ATTR_EMULATE_PREPARES => false, // (★SQLインジェクション)
]);
/**
* 会員情報重複チェック
* 入力されたIDがすでに登録済みかどうかをチェックする
*/
$sql = ('
SELECT login_id
FROM user
WHERE login_id = :LOGIN_ID;
');
$stmt = $pdo->prepare($sql);
// プレースホルダーに値をセット
$stmt->bindValue(':LOGIN_ID', $login_id, PDO::PARAM_STR);
// SQL実行
$stmt->execute();
// ユーザ情報の取得
$user_info = $stmt->fetchAll(PDO::FETCH_ASSOC);
// ユーザ情報が取得できている=件数が「1」の場合はエラーメッセージを返す
if (count($user_info)) {
$err_msg = 'そのIDはすでに使用されています。';
} else {
/**
* 会員情報登録処理
*/
$sql = ('
INSERT INTO
user (name, login_id, password)
VALUES
(:NAME, :LOGIN_ID, :PASSWORD)
');
$stmt = $pdo->prepare($sql);
// プレースホルダーに値をセット
$stmt->bindValue(':NAME', $name, PDO::PARAM_STR);
$stmt->bindValue(':LOGIN_ID', $login_id, PDO::PARAM_STR);
$stmt->bindValue(':PASSWORD', $password_hash, PDO::PARAM_STR);
// SQL実行
$stmt->execute();
// ログイン画面へ遷移
$msg = urlencode("会員登録が完了しました。");
header('Location: ./login.php?msg=' . $msg);
exit();
}
} catch (PDOException $e) {
echo '接続失敗' . $e->getMessage();
exit();
}
// DBとの接続を切る
$pdo = null;
$stmt = null;
}
?>
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>会員登録画面</title>
<link rel="stylesheet" href="./css/style.css">
</head>
<body>
<div>
<h2>会員登録画面</h2>
<!-- 登録エラーメッセージ -->
<?php if(isset($err_msg)) echo '<p class="err-msg">' . $err_msg . '</p>' ; ?>
<form action="#" method="post">
<p><label for="name">ニックネーム</label><input type="text" name="name"></p>
<p><label for="login_id">ID</label><input type="text" name="login_id"></p>
<p><label for="password">パスワード</label><input type="password" name="password"></p>
<input type="submit" value="登録" name="regist_btn">
<?php
// 不正リクエストチェック用のトークン生成(★CSRF)
$token = bin2hex(random_bytes(32));
$_SESSION['regist_token'] = $token;
echo '<input type="hidden" name="regist_token" value="'.$token.'" />';
?>
</form>
<a href="./login.php">ログイン画面へ戻る</a>
</div>
</body>
</html>
解説の流れ
通常、ユーザーがログイン機能を利用する際は、下記の手順を踏みます。
- 新規会員登録
- ログイン
- トップページへアクセス
本記事の解説も、基本的に上記の流れにそって進めさせていただきます。
新規会員登録ページ
ソースコード
<?php
/**
* セッションスタート
*/
ini_set('session.gc_maxlifetime', 1800);
ini_set('session.gc_divisor', 1);
session_start();
session_regenerate_id(); // セッションIDを新しいものに置き換える(★セッションハイジャック)
/**
* DB接続情報
*/
const DB_HOST = 'mysql:dbname=login_app;host=127.0.0.1;charset=utf8';
const DB_USER = 'kekenta';
const DB_PASSWORD = 'kekenta_pass';
/**
* 会員登録
*/
if (isset($_POST['regist_btn']) &&
(isset($_POST['name']) && $_POST['name'] != '') &&
(isset($_POST['login_id']) && $_POST['login_id'] != '') &&
(isset($_POST['password']) && $_POST['password'] != '')
) {
/**
* トークンチェック(★CSRF)
*/
if (empty($_SESSION['regist_token']) || ($_SESSION['regist_token'] !== $_POST['regist_token'])) exit('不正なリクエストです');
if (isset($_SESSION['regist_token'])) unset($_SESSION['regist_token']);//トークン破棄
if (isset($_POST['regist_token'])) unset($_POST['regist_token']);//トークン破棄
// POSTデータの取得
$name = $_POST['name'];
$login_id = $_POST['login_id'];
$password = $_POST['password'];
// パスワードをハッシュ化する(★SQLインジェクション)
$password_hash = password_hash( $password, PASSWORD_DEFAULT );
try {
/**
* DB接続処理
*/
$pdo = new PDO(DB_HOST, DB_USER, DB_PASSWORD, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, // 例外が発生した際にスローする
PDO::ATTR_EMULATE_PREPARES => false, // (★SQLインジェクション)
]);
/**
* 会員情報重複チェック
* 入力されたIDがすでに登録済みかどうかをチェックする
*/
$sql = ('
SELECT login_id
FROM user
WHERE login_id = :LOGIN_ID;
');
$stmt = $pdo->prepare($sql);
// プレースホルダーに値をセット
$stmt->bindValue(':LOGIN_ID', $login_id, PDO::PARAM_STR);
// SQL実行
$stmt->execute();
// ユーザ情報の取得
$user_info = $stmt->fetchAll(PDO::FETCH_ASSOC);
// ユーザ情報が取得できている=件数が「1」の場合はエラーメッセージを返す
if (count($user_info)) {
$err_msg = 'そのIDはすでに使用されています。';
} else {
/**
* 会員情報登録処理
*/
$sql = ('
INSERT INTO
user (name, login_id, password)
VALUES
(:NAME, :LOGIN_ID, :PASSWORD)
');
$stmt = $pdo->prepare($sql);
// プレースホルダーに値をセット
$stmt->bindValue(':NAME', $name, PDO::PARAM_STR);
$stmt->bindValue(':LOGIN_ID', $login_id, PDO::PARAM_STR);
$stmt->bindValue(':PASSWORD', $password_hash, PDO::PARAM_STR);
// SQL実行
$stmt->execute();
// ログイン画面へ遷移
$msg = urlencode("会員登録が完了しました。");
header('Location: ./login.php?msg=' . $msg);
exit();
}
} catch (PDOException $e) {
echo '接続失敗' . $e->getMessage();
exit();
}
// DBとの接続を切る
$pdo = null;
$stmt = null;
}
?>
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>会員登録画面</title>
<link rel="stylesheet" href="./css/style.css">
</head>
<body>
<div>
<h2>会員登録画面</h2>
<!-- 登録エラーメッセージ -->
<?php if(isset($err_msg)) echo '<p class="err-msg">' . $err_msg . '</p>' ; ?>
<form action="#" method="post">
<p><label for="name">ニックネーム</label><input type="text" name="name"></p>
<p><label for="login_id">ID</label><input type="text" name="login_id"></p>
<p><label for="password">パスワード</label><input type="password" name="password"></p>
<input type="submit" value="登録" name="regist_btn">
<?php
// 不正リクエストチェック用のトークン生成(★CSRF)
$token = bin2hex(random_bytes(32));
$_SESSION['regist_token'] = $token;
echo '<input type="hidden" name="regist_token" value="'.$token.'" />';
?>
</form>
<a href="./login.php">ログイン画面へ戻る</a>
</div>
</body>
</html>
- ① ユーザー情報入力フォーム
<!DOCTYPE html> <html lang="ja"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>会員登録画面</title> <link rel="stylesheet" href="./css/style.css"> </head> <body> <div> <h2>会員登録画面</h2> <!-- 登録エラーメッセージ --> <?php if(isset($err_msg)) echo '<p class="err-msg">' . $err_msg . '</p>' ; ?> <form action="#" method="post"> <p><label for="name">ニックネーム</label><input type="text" name="name"></p> <p><label for="login_id">ID</label><input type="text" name="login_id"></p> <p><label for="password">パスワード</label><input type="password" name="password"></p> <input type="submit" value="登録" name="regist_btn"> <?php // 不正リクエストチェック用のトークン生成(★CSRF) $token = bin2hex(random_bytes(32)); $_SESSION['regist_token'] = $token; echo '<input type="hidden" name="regist_token" value="'.$token.'" />'; ?> </form> <a href="./login.php">ログイン画面へ戻る</a> </div> </body> </html>
基本的な構造は一般的な入力フォームと変わらず、入力された各情報をPOST送信しています。
また、特筆する箇所はハイライト部分の2ヶ所です。ハイライト箇所 ①
<!-- 登録エラーメッセージ --> <?php if(isset($err_msg)) echo '<p class="err-msg">' . $err_msg . '</p>' ; ?>
ここでは、「入力されたIDがすでに使用済み」だった場合に表示するエラーメッセージを出力しています。
※エラーメッセージの設定箇所は後ほどご紹介ハイライト箇所 ②
<?php // 不正リクエストチェック用のトークン生成(★CSRF) $token = bin2hex(random_bytes(32)); $_SESSION['regist_token'] = $token; echo '<input type="hidden" name="regist_token" value="'.$token.'" />'; ?>
ここではCSRF対策としてワンタイムトークンを生成し、「登録ボタン」が押されたら一緒にPOST送信されるようにしています。
と、同時に、セッションにも同じワンタイムトークンを保存し、「登録ボタン」が押された後の処理の中で、POST送信されたトークンとセッション内のトークンを比較し、正規のリクエストかどうかを判定します。ワンタイムトークンを利用することで外部からの不正リクエスト(CSRF攻撃)を防ぐことが可能
- ② 新規会員登録処理
-
<?php /** * セッションスタート */ ini_set('session.gc_maxlifetime', 1800); ini_set('session.gc_divisor', 1); session_start(); session_regenerate_id(); // セッションIDを新しいものに置き換える(★セッションハイジャック) /** * DB接続情報 */ const DB_HOST = 'mysql:dbname=login_app;host=127.0.0.1;charset=utf8'; const DB_USER = 'kekenta'; const DB_PASSWORD = 'kekenta_pass'; /** * 会員登録 */ if (isset($_POST['regist_btn']) && (isset($_POST['name']) && $_POST['name'] != '') && (isset($_POST['login_id']) && $_POST['login_id'] != '') && (isset($_POST['password']) && $_POST['password'] != '') ) { /** * トークンチェック(★CSRF) */ if (empty($_SESSION['regist_token']) || ($_SESSION['regist_token'] !== $_POST['regist_token'])) exit('不正なリクエストです'); if (isset($_SESSION['regist_token'])) unset($_SESSION['regist_token']);//トークン破棄 if (isset($_POST['regist_token'])) unset($_POST['regist_token']);//トークン破棄 // POSTデータの取得 $name = $_POST['name']; $login_id = $_POST['login_id']; $password = $_POST['password']; // パスワードをハッシュ化する(★SQLインジェクション) $password_hash = password_hash( $password, PASSWORD_DEFAULT ); try { /** * DB接続処理 */ $pdo = new PDO(DB_HOST, DB_USER, DB_PASSWORD, [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, // 例外が発生した際にスローする PDO::ATTR_EMULATE_PREPARES => false, // (★SQLインジェクション) ]); /** * 会員情報重複チェック * 入力されたIDがすでに登録済みかどうかをチェックする */ $sql = (' SELECT login_id FROM user WHERE login_id = :LOGIN_ID; '); $stmt = $pdo->prepare($sql); // プレースホルダーに値をセット $stmt->bindValue(':LOGIN_ID', $login_id, PDO::PARAM_STR); // SQL実行 $stmt->execute(); // ユーザ情報の取得 $user_info = $stmt->fetchAll(PDO::FETCH_ASSOC); // ユーザ情報が取得できている=件数が「1」の場合はエラーメッセージを返す if (count($user_info)) { $err_msg = 'そのIDはすでに使用されています。'; } else { /** * 会員情報登録処理 */ $sql = (' INSERT INTO user (name, login_id, password) VALUES (:NAME, :LOGIN_ID, :PASSWORD) '); $stmt = $pdo->prepare($sql); // プレースホルダーに値をセット $stmt->bindValue(':NAME', $name, PDO::PARAM_STR); $stmt->bindValue(':LOGIN_ID', $login_id, PDO::PARAM_STR); $stmt->bindValue(':PASSWORD', $password_hash, PDO::PARAM_STR); // SQL実行 $stmt->execute(); // ログイン画面へ遷移 $msg = urlencode("会員登録が完了しました。"); header('Location: ./login.php?msg=' . $msg); exit(); } } catch (PDOException $e) { echo '接続失敗' . $e->getMessage(); exit(); } // DBとの接続を切る $pdo = null; $stmt = null; } ?>
ソースコードが少し長いですが、上の処理から順番にご説明します。
セッション初期設定
/** * セッションスタート */ ini_set('session.gc_maxlifetime', 1800); ini_set('session.gc_divisor', 1); session_start(); session_regenerate_id(); // セッションIDを新しいものに置き換える(★セッションハイジャック)
- 1~2行目……セッション破棄時に確実にセッションを削除できるように設定
- 3行目…………セッションをスタート
- 4行目…………セッションIDを新しいものに置き換えることによりセッションハイジャック対策
DB接続情報の定義
/** * DB接続情報 */ const DB_HOST = 'mysql:dbname=login_app;host=127.0.0.1;charset=utf8'; const DB_USER = 'kekenta'; const DB_PASSWORD = 'kekenta_pass';
こちらでDB接続に必要な情報を定義しています。
「dbname」、「DB_USER」、「DB_PASSWORD」の3つの値についてはご自身の環境に合わせて値をご入力ください。
※上記の「host」にはローカル環境で開発を行っている場合のIPアドレスを記述しています。会員登録操作が行われたかどうか
入力内容に問題が無いかチェック/** * 会員登録 */ if (isset($_POST['regist_btn']) && (isset($_POST['name']) && $_POST['name'] != '') && (isset($_POST['login_id']) && $_POST['login_id'] != '') && (isset($_POST['password']) && $_POST['password'] != '') )
このif文では下記の2点をチェックし、問題が無ければ次の処理に進むよう制御しています。
- 「登録」ボタンが押下されたかどうか
- 「名前」、「ID」、「パスワード」のすべてが入力されているかどうか
ワンタイムトークンチェック
/** * トークンチェック(★CSRF) */ if (empty($_SESSION['regist_token']) || ($_SESSION['regist_token'] !== $_POST['regist_token'])) exit('不正なリクエストです'); if (isset($_SESSION['regist_token'])) unset($_SESSION['regist_token']);//トークン破棄 if (isset($_POST['regist_token'])) unset($_POST['regist_token']);//トークン破棄
「入力フォームから送信されたワンタイムトークン」と「セッションに保存されているワンタイムトークン」の比較を行っています。
もしも
- セッションにワンタイムトークンが設定されていない
- セッションとPOST送信されたトークンが一致しない
という状況だった場合、そのリクエストは「攻撃者による不正リクエスト」である可能性が高いため、1行目にある「exit(‘不正なリクエストです’)」で処理を強制的に終了させるようにしています。
POSTデータの取得
// POSTデータの取得 $name = $_POST['name']; $login_id = $_POST['login_id']; $password = $_POST['password'];
ここでは単純にPOST送信されたデータを変数に格納しています。
パスワードのハッシュ化
// パスワードをハッシュ化する(★SQLインジェクション) $password_hash = password_hash( $password, PASSWORD_DEFAULT );
ここで、以前の章で解説した「ハッシュ化」を行っています。
- password_hash()関数を用いることで簡単に任意の値を「ハッシュ化」できる
- 第2引数では「どのようなルールによって値をランダムな値に変換するか」という指定を行っている(基本的にPASSWORD_DEFAULTを指定すれば、そのときのPHPバージョンで最適なハッシュアルゴリズムが適用されます)
DB接続
/** * DB接続処理 */ $pdo = new PDO(DB_HOST, DB_USER, DB_PASSWORD, [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, // 例外が発生した際にスローする PDO::ATTR_EMULATE_PREPARES => false, // (★SQLインジェクション) ]);
入力されたIDが既に使用されていないかチェック
/** * 会員情報重複チェック * 入力されたIDがすでに登録済みかどうかをチェックする */ $sql = (' SELECT login_id FROM user WHERE login_id = :LOGIN_ID; '); $stmt = $pdo->prepare($sql); // プレースホルダーに値をセット $stmt->bindValue(':LOGIN_ID', $login_id, PDO::PARAM_STR); // SQL実行 $stmt->execute(); // ユーザ情報の取得 $user_info = $stmt->fetchAll(PDO::FETCH_ASSOC); // ユーザ情報が取得できている=件数が「1」の場合はエラーメッセージを返す if (count($user_info)) { $err_msg = 'そのIDはすでに使用されています。'; } else { ~ 中略 ~ }
ここでは会員登録フォームで入力されたIDがすでに使用済みでないかどうかをチェックしています。
主な流れ
- ユーザーが入力した「ID」を条件に指定し、データベースからデータを取得
- このとき「データが取得できた=すでにそのIDは使用されている」という意味になる
- したがって、ユーザ情報が取得できた場合はエラーメッセージ($err_msg)を格納し、
- その後の「会員登録処理」は実行せず会員登録フォームを表示する
会員登録処理
// ユーザ情報が取得できている=件数が「1」の場合はエラーメッセージを返す if (count($user_info)) { $err_msg = 'そのIDはすでに使用されています。'; } else { /** * 会員情報登録処理 */ $sql = (' INSERT INTO user (name, login_id, password) VALUES (:NAME, :LOGIN_ID, :PASSWORD) '); $stmt = $pdo->prepare($sql); // プレースホルダーに値をセット $stmt->bindValue(':NAME', $name, PDO::PARAM_STR); $stmt->bindValue(':LOGIN_ID', $login_id, PDO::PARAM_STR); $stmt->bindValue(':PASSWORD', $password_hash, PDO::PARAM_STR); // SQL実行 $stmt->execute(); // ログイン画面へ遷移 $msg = urlencode("会員登録が完了しました。"); header('Location: ./login.php?msg=' . $msg); exit(); }
ユーザーが入力したIDがまだ使用されていないものと確定したら、次に会員登録処理を実行します
- 会員登録処理が完了後、ハイライト部分でログインページへリダイレクトされるようにしています。
- また、そのときGET送信を利用して、「会員登録が完了しました。」というメッセージも一緒に送っています。
以上が「新規会員登録ページ」で行っている主な処理です。
続いて「ログインページ」の解説に移ります。
ログイン処理
ソースコード
<?php
/**
* セッションスタート
*/
ini_set('session.gc_maxlifetime', 1800);
ini_set('session.gc_divisor', 1);
session_start();
session_regenerate_id(); // セッションIDを新しいものに置き換える(★セッションハイジャック)
/**
* DB接続情報
*/
const DB_HOST = 'mysql:dbname=login_app;host=127.0.0.1;charset=utf8';
const DB_USER = 'kekenta';
const DB_PASSWORD = 'kekenta_pass';
// 会員登録・ログアウト完了メッセージの取得
if ( isset( $_GET['msg'] ) ) $success_msg = $_GET['msg'];
/**
* ログイン
*/
if (isset($_POST['login_btn']) &&
(isset($_POST['login_id']) && $_POST['login_id'] != '') &&
(isset($_POST['password']) && $_POST['password'] != '')
)
{
/**
* トークンチェック(★CSRF)
*/
if (empty($_SESSION['login_token']) || ($_SESSION['login_token'] !== $_POST['login_token'])) exit('不正なリクエストです');
if (isset($_SESSION['login_token'])) unset($_SESSION['login_token']);//トークン破棄
if (isset($_POST['login_token'])) unset($_POST['login_token']);//トークン破棄
// POSTデータの取得
$login_id = $_POST['login_id'];
$password = $_POST['password'];
try {
/**
* DB接続処理
*/
$pdo = new PDO(DB_HOST, DB_USER, DB_PASSWORD, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, // 例外が発生した際にスローする
PDO::ATTR_EMULATE_PREPARES => false, // (★SQLインジェクション対策)
]);
/**
* ログイン処理
*/
$sql = ('
SELECT login_id, password, name
FROM user
WHERE login_id = :LOGIN_ID
');
$stmt = $pdo->prepare($sql);
// プレースホルダーに値をセット
$stmt->bindValue(':LOGIN_ID', $login_id, PDO::PARAM_STR);
// SQL実行
$stmt->execute();
/**
* ログイン情報が正しいかをチェック
*/
$user_info = $stmt->fetchAll(PDO::FETCH_ASSOC);
if (count($user_info) && password_verify( $password, $user_info[0]['password'] )) {
// ログイン状態確認用にセッションにデータ保存(★ログイン機能の実現)
$_SESSION['user'] = array(
'name' => $user_info[0]['name'],
'login_id' => $user_info[0]['login_id'],
);
// ログイン後はトップページへ遷移する
header('Location: ./index.php');
exit();
} else {
$err_msg = 'ログイン情報に誤りがあります。';
}
} catch (PDOException $e) {
echo '接続失敗' . $e->getMessage();
exit();
}
// DBとの接続を切る
$pdo = null;
$stmt = null;
}
?>
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ログイン画面</title>
<link rel="stylesheet" href="./css/style.css">
</head>
<body>
<div>
<h2>ログイン画面</h2>
<!-- 会員登録・ログアウト成功メッセージ -->
<?php if(isset($success_msg)) echo '<p>' . $success_msg . '</p>' ; ?>
<!-- ログイン失敗メッセージ -->
<?php if(isset($err_msg)) echo '<p class="err-msg">' . $err_msg . '</p>' ; ?>
<form action="" method="post">
<p><label for="login_id">ID</label><input type="text" name="login_id"></p>
<p><label for="password">パスワード</label><input type="password" name="password"></p>
<input type="submit" value="ログイン" name="login_btn">
<?php
// 不正リクエストチェック用のトークン生成(★CSRF)
$token = bin2hex(random_bytes(32));
$_SESSION['login_token'] = $token;
echo '<input type="hidden" name="login_token" value="'.$token.'" />';
?>
</form>
<a href="./regist.php">会員登録はこちら</a>
</div>
</body>
</html>
- ログイン情報入力フォーム
<!DOCTYPE html> <html lang="ja"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>ログイン画面</title> <link rel="stylesheet" href="./css/style.css"> </head> <body> <div> <h2>ログイン画面</h2> <!-- 会員登録・ログアウト成功メッセージ --> <?php if(isset($success_msg)) echo '<p>' . $success_msg . '</p>' ; ?> <!-- ログイン失敗メッセージ --> <?php if(isset($err_msg)) echo '<p class="err-msg">' . $err_msg . '</p>' ; ?> <form action="" method="post"> <p><label for="login_id">ID</label><input type="text" name="login_id"></p> <p><label for="password">パスワード</label><input type="password" name="password"></p> <input type="submit" value="ログイン" name="login_btn"> <?php // 不正リクエストチェック用のトークン生成(★CSRF) $token = bin2hex(random_bytes(32)); $_SESSION['login_token'] = $token; echo '<input type="hidden" name="login_token" value="'.$token.'" />'; ?> </form> <a href="./regist.php">会員登録はこちら</a> </div> </body> </html>
基本的な構造は「新規会員登録フォーム」のときと同様に一般的な入力フォームと変わらず、入力された各情報をPOST送信しています。また、特筆する箇所はハイライト部分の2ヶ所です。
ハイライト箇所の解説
<!-- 会員登録・ログアウト成功メッセージ --> <?php if(isset($success_msg)) echo '<p>' . $success_msg . '</p>' ; ?> <!-- ログイン失敗メッセージ --> <?php if(isset($err_msg)) echo '<p class="err-msg">' . $err_msg . '</p>' ; ?>
上記の2ヶ所の処理では、コメントに記載されているとおり、それぞれ「会員登録・ログアウトに成功したときのメッセージ」と「ログインに失敗したときのメッセージ」を出力しています。
- ログイン処理
-
<?php /** * セッションスタート */ ini_set('session.gc_maxlifetime', 1800); ini_set('session.gc_divisor', 1); session_start(); session_regenerate_id(); // セッションIDを新しいものに置き換える(★セッションハイジャック) /** * DB接続情報 */ const DB_HOST = 'mysql:dbname=login_app;host=127.0.0.1;charset=utf8'; const DB_USER = 'kekenta'; const DB_PASSWORD = 'kekenta_pass'; // 会員登録・ログアウト完了メッセージの取得 if ( isset( $_GET['msg'] ) ) $success_msg = $_GET['msg']; /** * ログイン */ if (isset($_POST['login_btn']) && (isset($_POST['login_id']) && $_POST['login_id'] != '') && (isset($_POST['password']) && $_POST['password'] != '') ) { /** * トークンチェック(★CSRF) */ if (empty($_SESSION['login_token']) || ($_SESSION['login_token'] !== $_POST['login_token'])) exit('不正なリクエストです'); if (isset($_SESSION['login_token'])) unset($_SESSION['login_token']);//トークン破棄 if (isset($_POST['login_token'])) unset($_POST['login_token']);//トークン破棄 // POSTデータの取得 $login_id = $_POST['login_id']; $password = $_POST['password']; try { /** * DB接続処理 */ $pdo = new PDO(DB_HOST, DB_USER, DB_PASSWORD, [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, // 例外が発生した際にスローする PDO::ATTR_EMULATE_PREPARES => false, // (★SQLインジェクション対策) ]); /** * ログイン処理 */ $sql = (' SELECT login_id, password, name FROM user WHERE login_id = :LOGIN_ID '); $stmt = $pdo->prepare($sql); // プレースホルダーに値をセット $stmt->bindValue(':LOGIN_ID', $login_id, PDO::PARAM_STR); // SQL実行 $stmt->execute(); /** * ログイン情報が正しいかをチェック */ $user_info = $stmt->fetchAll(PDO::FETCH_ASSOC); if (count($user_info) && password_verify( $password, $user_info[0]['password'] )) { // ログイン状態確認用にセッションにデータ保存(★ログイン機能の実現) $_SESSION['user'] = array( 'name' => $user_info[0]['name'], 'login_id' => $user_info[0]['login_id'], ); // ログイン後はトップページへ遷移する header('Location: ./index.php'); exit(); } else { $err_msg = 'ログイン情報に誤りがあります。'; } } catch (PDOException $e) { echo '接続失敗' . $e->getMessage(); exit(); } // DBとの接続を切る $pdo = null; $stmt = null; } ?>
トップページからログアウトしてきたら
「成功メッセージ」を格納する// 会員登録・ログアウト完了メッセージの取得 if ( isset( $_GET['msg'] ) ) $success_msg = $_GET['msg'];
説明が前後してしまいますが、上記のソースコードでは、ログイン後にアクセス可能な「トップページ」からログアウトしてきたときに「会員登録・ログアウト成功メッセージ」を変数に格納しています。
ログイン情報が正しいかどうかをチェック
/** * ログイン処理 */ $sql = (' SELECT login_id, password, name FROM user WHERE login_id = :LOGIN_ID '); $stmt = $pdo->prepare($sql); // プレースホルダーに値をセット $stmt->bindValue(':LOGIN_ID', $login_id, PDO::PARAM_STR); // SQL実行 $stmt->execute(); /** * ログイン情報が正しいかをチェック */ $user_info = $stmt->fetchAll(PDO::FETCH_ASSOC); if (count($user_info) && password_verify( $password, $user_info[0]['password'] )) { // ログイン状態確認用にセッションにデータ保存(★ログイン機能の実現) $_SESSION['user'] = array( 'name' => $user_info[0]['name'], 'login_id' => $user_info[0]['login_id'], ); // ログイン後はトップページへ遷移する header('Location: ./index.php'); exit(); } else { $err_msg = 'ログイン情報に誤りがあります。'; }
ここではユーザーが入力した「ログイン情報」が正しいかどうかをチェックし、その結果に応じて処理を分岐しています。
具体的には以下の流れでチェックしています。
- ユーザーが入力した「ログインID」によってそもそもデータが取得できているか=IDが登録済みかどうかを判定
【count($user_info)の部分】 - 「データベースから取得したパスワード(ハッシュ値)」と「入力されたパスワードをハッシュ化した値(ハッシュ値)」が同じかどうかを判定
【password_verify( $password, $user_info[0][‘password’] )の部分】
◯ ログイン情報が正しかった場合
// ログイン状態確認用にセッションにデータ保存(★ログイン機能の実現) $_SESSION['user'] = array( 'name' => $user_info[0]['name'], 'login_id' => $user_info[0]['login_id'], ); // ログイン後はトップページへ遷移する header('Location: ./index.php'); exit();
ログイン情報が正しければ、セッションに「ユーザー情報」を保存し
その後はログイン状態のチェックに利用します。また、ログインが成功した時点でトップページへリダイレクトされます。
(ハイライト箇所)× ログイン情報が誤っていた場合
$err_msg = 'ログイン情報に誤りがあります。';
ログイン情報に誤りがあった場合は、$err_msgにエラーメッセージを格納し、先ほど登場した以下のソースコードでユーザーへメッセージを出力します
<!-- ログイン失敗メッセージ --> <?php if(isset($err_msg)) echo '<p class="err-msg">' . $err_msg . '</p>' ; ?>
- ユーザーが入力した「ログインID」によってそもそもデータが取得できているか=IDが登録済みかどうかを判定
ここまでお疲れ様です。
次は最後に、ログイン後にアクセスする「トップページ」について解説をします。
トップページ(ログイン後にアクセス可能なページ)
ソースコード
<?php
/**
* セッションスタート
*/
ini_set('session.gc_maxlifetime', 1800);
ini_set('session.gc_divisor', 1);
session_start();
session_regenerate_id(); // セッションIDを新しいものに置き換える(★セッションハイジャック)
/**
* ログインしていなければログイン画面へ強制リダイレクト
*/
if (! isset($_SESSION['user'])) {
header('Location: ./login.php');
exit();
}
/**
* ログアウト
*/
if (isset($_POST['logout'])) {
// トークンチェック(★CSRF)
if (empty($_SESSION['logout_token']) || ($_SESSION['logout_token'] !== $_POST['logout_token'])) exit('不正な投稿です');
if (isset($_SESSION['logout_token'])) unset($_SESSION['logout_token']);//トークン破棄
if (isset($_POST['logout_token'])) unset($_POST['logout_token']);//トークン破棄
/**
* セッションを破棄する(★セッションハイジャック)
*/
// セッション変数の中身をすべて破棄
$_SESSION = array();
// クッキーに保存されているセッションIDを破棄
if (isset($_COOKIE["PHPSESSID"])) setcookie("PHPSESSID", '', time() - 1800, '/');
// セッションを破棄
session_destroy();
// ログインページに戻る
$msg = urlencode("ログアウトしました。");
header('Location: ./login.php?msg=' . $msg);
exit();
}
?>
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ログイン後の画面</title>
</head>
<body>
<?php
// ログイン中のユーザー情報を表示(★クロスサイトスクリプティング)
echo 'ID:' . htmlspecialchars($_SESSION['user']['login_id'], ENT_QUOTES, 'UTF-8') . '<br>';
echo 'ユーザー名:' . htmlspecialchars($_SESSION['user']['name'], ENT_QUOTES, 'UTF-8');
?>
<form action="#" method="post">
<input type="submit" name="logout" value="ログアウト">
<?php
// 不正リクエストチェック用のトークン生成(★CSRF)
$token = sha1(uniqid(mt_rand(), true));
$_SESSION['logout_token'] = $token;
echo '<input type="hidden" name="logout_token" value="'.$token.'" />';
?>
</form>
</body>
</html>
- トップページ(ブラウザ表示部分)
<!DOCTYPE html> <html lang="ja"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>ログイン後の画面</title> </head> <body> <?php // ログイン中のユーザー情報を表示(★クロスサイトスクリプティング) echo 'ID:' . htmlspecialchars($_SESSION['user']['login_id'], ENT_QUOTES, 'UTF-8') . '<br>'; echo 'ユーザー名:' . htmlspecialchars($_SESSION['user']['name'], ENT_QUOTES, 'UTF-8'); ?> <form action="#" method="post"> <input type="submit" name="logout" value="ログアウト"> <?php // 不正リクエストチェック用のトークン生成(★CSRF) $token = sha1(uniqid(mt_rand(), true)); $_SESSION['logout_token'] = $token; echo '<input type="hidden" name="logout_token" value="'.$token.'" />'; ?> </form> </body> </html>
ログイン中のユーザ情報を表示
- ログイン成功後の画面(トップページ)では、どのユーザーでログインしているのかが分かるようにユーザ情報を表示しています(ハイライト部分)
- ログアウトボタンを設置し、ログアウト機能も実装しています。
- ログイン状態のチェック・ログアウト機能
-
<?php /** * セッションスタート */ ini_set('session.gc_maxlifetime', 1800); ini_set('session.gc_divisor', 1); session_start(); session_regenerate_id(); // セッションIDを新しいものに置き換える(★セッションハイジャック) /** * ログインしていなければログイン画面へ強制リダイレクト */ if (! isset($_SESSION['user'])) { header('Location: ./login.php'); exit(); } /** * ログアウト */ if (isset($_POST['logout'])) { // トークンチェック(★CSRF) if (empty($_SESSION['logout_token']) || ($_SESSION['logout_token'] !== $_POST['logout_token'])) exit('不正な投稿です'); if (isset($_SESSION['logout_token'])) unset($_SESSION['logout_token']);//トークン破棄 if (isset($_POST['logout_token'])) unset($_POST['logout_token']);//トークン破棄 /** * セッションを破棄する(★セッションハイジャック) */ // セッション変数の中身をすべて破棄 $_SESSION = array(); // クッキーに保存されているセッションIDを破棄 if (isset($_COOKIE["PHPSESSID"])) setcookie("PHPSESSID", '', time() - 1800, '/'); // セッションを破棄 session_destroy(); // ログインページに戻る $msg = urlencode("ログアウトしました。"); header('Location: ./login.php?msg=' . $msg); exit(); } ?>
ログイン状態のチェック
/** * ログインしていなければログイン画面へ強制リダイレクト */ if (! isset($_SESSION['user'])) { header('Location: ./login.php'); exit(); }
- URL入力によって直接アクセスされる可能性が考えられるため、処理の冒頭でログイン状態をチェックしています。
- 具体的には、セッションにユーザー情報が保存されていない=ログインの手続きを行っていないと見なし、ログインページへ強制リダイレクトさせています。
ログアウト
/** * ログアウト */ if (isset($_POST['logout'])) { // トークンチェック(★CSRF) if (empty($_SESSION['logout_token']) || ($_SESSION['logout_token'] !== $_POST['logout_token'])) exit('不正な投稿です'); if (isset($_SESSION['logout_token'])) unset($_SESSION['logout_token']);//トークン破棄 if (isset($_POST['logout_token'])) unset($_POST['logout_token']);//トークン破棄 /** * セッションを破棄する(★セッションハイジャック) */ // セッション変数の中身をすべて破棄 $_SESSION = array(); // クッキーに保存されているセッションIDを破棄 if (isset($_COOKIE["PHPSESSID"])) setcookie("PHPSESSID", '', time() - 1800, '/'); // セッションを破棄 session_destroy(); // ログインページに戻る $msg = urlencode("ログアウトしました。"); header('Location: ./login.php?msg=' . $msg); exit(); }
ログアウト機能のポイント- ログイン状態はセッションに保存されているユーザー情報の有無で判定している
- したがって、セッションに保存されているユーザー情報を削除する=ログアウトという意味になる
- クッキーにもセッションIDが残されているため、セッションハイジャック対策として確実に破棄する
以上でログイン機能のソースコード解説は終了です!
最後までお疲れでした!
まとめ
いかがだったでしょうか。
今回はPHPでログイン機能を実装する上で必要な事前知識や仕組み、またソースコードの解説をさせていただきました。
- Webアプリでログイン機能を実装するにはセッションを利用する
- パスワードを安全に管理するためにハッシュ化を利用する
- ただ機能を実装するだけではなく、処理に応じたセキュリティ対策を施す
ログイン機能の大枠はシステムごとに大きく変わることはなく、基本的に似たような構造になるかと思います。
(違うとすればバリデーションチェックや二段階認証などのプラスアルファの部分でしょうか)
仕組みやセキュリティ対策のことを正しく理解してそれをソースコードに反映できるようになるまでが大変ですが、この記事が少しでもその一助となれば幸いです。
この記事のほかに、掲示板アプリの解説記事も公開していますので、ご興味のある方はぜひそちらもご覧いただけると嬉しいです。
また、もしPHPを独学で勉強しているけど、上達できている感じがしないと思っているなら、一度プログラミングスクールを検討してみるのも良いかもしれません。
無料でPHPが学べるプログミングスクール
コメント