PHP --- フレームワークを利用しないでToDoアプリ作成(4)
前回までで、基本的なToDoアプリの実装を行いました。
PHP --- フレームワークを利用しないでToDoアプリ作成(3) - 何でもプログラミング
今回はCSRF対策とログイン機能を盛り込んでみたいと思います。
両方ともセッションを利用しますので、$_SESSIONにアクセスする前までに下記を呼んでおいてください。
session_start();
CSRF対策
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にログアウトボタンを追加します。
<!DOCTYPE html> <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');