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';
?>

f:id:any-programming:20180627113837p:plain

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');