前回までで、基本的なToDoアプリの実装を行いました。
PHP --- フレームワークを利用しないでToDoアプリ作成(3) - 何でもプログラミング
今回はCSRF対策とログイン機能を盛り込んでみたいと思います。
両方ともセッションを利用しますので、$_SESSIONにアクセスする前までに下記を呼んでおいてください。
session_start();
iframe内で他ページを開くなどして、勝手に送出された悪意あるPOSTをはじくために、各フォームにtokenを埋め込んでおきます。
フォーム表示ごとにtokenを作り直してもいいのですが、今回はセッションIDと同じ寿命にして実装しました。
tokenを管理するために、下記のような関数群を定義しました。
tokenの生成のアルゴリズムや格納キーは適宜変更しても問題ありません。
<?php
function setNewCsrfToken(string $seed) : void {
$token = sha1($seed . session_id() . microtime());
$_SESSION['csrfToken'] = $token;
}
function isCsrfTokenSet() : bool {
return isset($_SESSION['csrfToken']);
}
function getCsrfToken() : string {
if (isset($_SESSION['csrfToken']) === false) {
throw new Exception403();
}
return $_SESSION['csrfToken'];
}
function assertCsrfToken(string $token) : void {
if (isset($_SESSION['csrfToken']) === false || $_SESSION['csrfToken'] !== $token) {
throw new Exception403();
}
}
アプリケーションの初めに、tokenが準備されていなかったら生成するコードを追加します。
setNewCsrfTokenに渡すseedは適宜変更してください。
<?php
if (isCsrfTokenSet() === false) {
setNewCsrfToken('todo');
}
今まで作成したすべてのフォームにtokenを埋め込みます。
<input type="hidden" name="token" value="<?php safeEcho(getCsrfToken()) ?>">
POSTを処理する関数の各々でtokenをチェックしてもいいのですが、今回はrespond関数を書き換えて一括でチェックします。
<?php
function respond(string $pathInfo, array $routesGET, array $routesPOST, callable $onError) : void {
try {
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
$response = getResponse($pathInfo, $routesGET);
} else if ($_SERVER['REQUEST_METHOD'] === 'POST') {
assert403(isset($_POST['token']));
assertCsrfToken($_POST['token']);
$response = getResponse($pathInfo, $routesPOST);
}
if (isset($response)) {
$response->send();
} else {
throw new Exception404();
}
} catch (Exception $e) {
$onError($e)->send();
}
}
ログイン機能
ToDoの編集を、ログインした人のみできるよう実装してみたいと思います。
ログイン状態を管理するよう、下記の関数群を定義します。
<?php
function setAuthenticated(bool $authenticated) : void {
$_SESSION['authenticated'] = $authenticated;
}
function isAuthenticated() : bool {
return $_SESSION['authenticated'] ?? false;
}
function assertAuthenticated() : void {
if (isAuthenticated() === false) {
throw new Exception403();
}
}
ログイン画面を下記のように定義します。
login.php
<?php ob_start(); ob_implicit_flush(0); ?>
<?php if ($showError) { ?>
<p>ログインできませんでした。</p>
<?php } ?>
<form action="/login" method="post">
<input type="hidden" name="token" value="<?php safeEcho(getCsrfToken()) ?>">
<ul>
<li>
<label>
ユーザー
<input type="text" name="user">
</label>
</li>
<li>
<label>
パスワード
<input type="password" name="password">
</label>
</li>
<li>
<button type="submit">送信</button>
</li>
</ul>
</form>
<?php
$title = 'ログイン';
$body = ob_get_clean();
require 'layout.php';
?>

layout.phpにログアウトボタンを追加します。
<html lang="ja">
<head>
<meta charset="UTF-8">
<title><?php safeEcho($title) ?></title>
</head>
<body>
<header>
<p>ToDo</p>
<?php if (isAuthenticated()) { ?>
<form action="/logout" method="post">
<input type="hidden" name="token" value="<?php safeEcho(getCsrfToken()) ?>">
<button type="submit">ログアウト</button>
</form>
<?php } ?>
</header>
<main>
<?php echo $body ?>
</main>
</body>
</html>
todoList.phpを、権限によって表示するものを変えるようにします。
<?php ob_start(); ob_implicit_flush(0); ?>
<?php if (isAuthenticated()) { ?>
<a href="/add">追加</a>
<?php } ?>
<table>
<?php foreach ($todos as $todo) { ?>
<tr>
<td>
<?php safeEcho($todo->date->format('Y/m/d')) ?>
</td>
<td>
<?php safeEcho($todo->text) ?>
</td>
<?php if (isAuthenticated()) { ?>
<td>
<form action="/delete" method="post">
<input type="hidden" name="token" value="<?php safeEcho(getCsrfToken()) ?>">
<input type="hidden" name="id" value="<?php safeEcho($todo->id) ?>">
<button type="submit">削除</button>
</form>
</td>
<?php } ?>
</tr>
<?php } ?>
</table>
<?php
$title = 'ToDo一覧';
$body = ob_get_clean();
require 'layout.php';
?>
index.phpを下記のように書き換えています。
ユーザーとパスワードは現状固定で判定しています。
セキュリティ向上のため、ログイン時にセッションIDとtokenの再生成を行っています。
<?php
function todoList() : Response {
}
function todoAdd() : Response {
assertAuthenticated();
}
function login() : Response {
$showError = false;
$content = render('login.php', compact('showError'));
return Response::ok($content);
}
function post_login() : Response {
$user = assertPOST('user');
$password = assertPOST('password');
if ($user === 'user' && $password === 'pass') {
setAuthenticated(true);
session_regenerate_id(true);
setNewCsrfToken('todo');
return Response::redirect('/');
} else {
$showError = true;
$content = render('login.php', compact('showError'));
return Response::badRequest($content);
}
}
function post_logout() : Response {
setAuthenticated(false);
return Response::redirect('/');
}
function post_todoAdd() : Response {
assertAuthenticated();
}
function post_todoDelete() : Response {
assertAuthenticated();
}
function onError(Exception $e) : Response {
if ($e instanceof Exception400) {
return Response::badRequest('400 Bad Request');
} else if ($e instanceof Exception403) {
return Response::forbidden('403 Forbidden');
} else if ($e instanceof Exception404) {
return Response::notFound('404 Not Found');
} else {
return Response::internalServerError('500 Internal Server Error');
}
}
$routesGET = [
'/' => 'todoList',
'/add' => 'todoAdd',
'/login' => 'login'
];
$routesPOST = [
'/add' => 'post_todoAdd',
'/delete' => 'post_todoDelete',
'/login' => 'post_login',
'/logout' => 'post_logout'
];
session_start();
if (isCsrfTokenSet() === false) {
setNewCsrfToken('todo');
}
respond(getPathInfo(), $routesGET, $routesPOST, 'onError');