Edited at

PHPでログイン機能を実装するチュートリアル #11

More than 3 years have passed since last update.


  1. 基本設計、ユーザーモデル

  2. オートローダー

  3. 例外・ログ


  4. PDO シングルトン SQLインジェクション



  5. ユーザーモデルの作成


  6. クラスの継承


  7. テンプレート・クラスの実装


  8. アカウントロック機能の実装


  9. メール送信機能の実装


  10. アカウントロック解除機能の実装


  11. CSRF対策


CSRF(クロスサイトリクエストフォージェリ) 対策についてはwikiを参照ください。

つまりは、<form> から何らかの値を送信して、サーバーに処理させる場合、この対策を怠らないようにしましょうということ。

基本的には、form の内部に、<input type="hidden"> で値を送信し、SESSIONに保存された値と一致するかどうかを確認する。

このCSRF対策には、二重送信を防止するという副産物もあり、正規ユーザーに対しては、「CSRFを検知したので、処理を中断しました」というような「不正」を表現するのではなく、「二重送信されたので処理を中断しました。」のようなメッセージにすると良いかもしれません。


実装

classes/common/Csrf.class.phpCsrfクラスを定義しました。


classes/common/Csrf.class.php


classes/common/Csrf.class.php

<?php

namespace MyApp\common;

/**
* Csrf.class.php
*/

class Csrf
{

/**
* トークン
* @var string
*/

private static $token = null;

/**
* 初期化
*/

private static function init()
{
self::$token = sha1(uniqid());
}

/**
* CSRF用にトークンを生成する
* @return string
*/

public static function get()
{
if (is_null(self::$token)) {
self::init();
}
$_SESSION['csrf_token'] = self::$token;
return self::$token;
}

/**
* CSRFをチェックする
* @return boolean
* @throws ApplicationErrorException
*/

public static function check()
{
$csrf_token = (isset($_SESSION['csrf_token'])) ? $_SESSION['csrf_token'] : null;
$_SESSION['csrf_token'] = null;

if (filter_input(INPUT_POST, 'csrf_token') !== $csrf_token) {
// 二重送信されたので処理を中断しました。
throw new InvalidErrorException(ExceptionCode::INVALID_CSRF_ERR);
}
return true;
}

}



classes/common/ExceptionCode.class.php


classes/common/ExceptionCode.class.php

<?php

namespace MyApp\common;

/**
* ExceptionCode.class.php
* @since 2015/07/25
*/

class ExceptionCode
{

/**
* InvalidError
*/

const INVALID_ERR = 1000;
const INVALID_LOCK = 1001;
const INVALID_LOGIN_FAIL = 1002;
const ARGUMENT_REQUIRED = 1003;
const INVALID_CSRF_ERR = 1004;

/**
* ApplicationError
*/

const APPLICATION_ERR = 2000;

/**
* SystemError
*/

const SYSTEM_ERR = 3000;
const TEMPLATE_ERR = 3001;
const TEMPLATE_ARG_ERR = 3002;

/**
* エラーメッセージ
* @var array<string>
*/

private static $_arrMessage = array(
self::INVALID_ERR => 'エラーが発生しました。'
, self::INVALID_LOCK => 'アカウントがロックされています。'
, self::INVALID_LOGIN_FAIL => 'ログインに失敗しました。'
, self::INVALID_CSRF_ERR => '二重送信されたので処理を中断しました。'
, self::ARGUMENT_REQUIRED => 'メールアドレスおよびパスワードは入力必須です。'
, self::APPLICATION_ERR => 'アプリケーション・エラーが発生しました。'
, self::SYSTEM_ERR => 'システム・エラーが発生しました。'
, self::TEMPLATE_ERR => 'テンプレート[%s]が見つかりません。'
, self::TEMPLATE_ARG_ERR => '引数に[%s]は利用できません。'
);

/**
* エラーメッセージを取得する
* @param int $intCode
* @return string
*/

static public function getMessage($intCode)
{
if (array_key_exists($intCode, self::$_arrMessage)) {
return self::$_arrMessage[$intCode];
}
}

}



利用方法

前述のように、form の中に hidden で梅子見るのですから、以下のような使い方になります。

<form method="post">

// その他のフォーム部品

<input type="hidden" name="csrf_token" value="<?= Csrf::get(); ?>">
</form>

基本的に form を利用するところには、CSRF対策を施す必要がありますが、開発メンバーの全員が CSRFについて理解した上で、利用しているとは限りません。「処理に関係ない値」と思ってわざわざ削除してしまわないとも限りません。多くのフレームワークで、たかだか <form> を生成するためのヘルパーメソッドが用意されているには、理由があり、この CSRF対策formヘルパーで自動的に付与される仕組みになっています。

テンプレート・エンジン Smartyには、カスタムプラグイン機能が用意されており、form のように、開始タグと終了タグを生成するためのブロック関数を拡張することができます。


smarty/plugins/block.form.php


smarty/plugins/block.form.php

<?php

/**
* block.form.php
*/

function smarty_block_form($params, $contents)
{
if (!$contents) {
return;
}

$attr = [];
foreach ($params as $key => $val) {
$attr[] = sprintf('%s="%s"', $key, $val);
}
$attribute = (count($attr) > 0) ? ' ' . implode(' ', $attr) : '';

$html = sprintf('<form%s>', $attribute);
$html .= $contents;

$html .= sprintf('<input type="hidden" name="csrf_token" value="%s">'
, MyApp\common\Csrf::get()
);
$html .= '</form>';

return $html;
}


テンプレート側については以下のようにして呼び出します。

{form method="post"}{/form} の部分がそれにあたります。


smarty/templates/unlock.tpl


smarty/templates/unlock.tpl

{block name='meta'}

<style type="text/css">
.login-box, .register-box {
margin: 20px auto;
}
</style>
{/block}
{block name='content'}
<div class="login-box">
<div class="login-logo">
<a href="/">
<b>Admin</b>
LTE
</a>
</div>
<!-- /.login-logo -->
<div class="login-box-body">

{if $success|default:null}
<p class="login-box-msg">アカウントロックを解除しました。</p>
<p class="login-box-msg">
<a href="/">ログインページ</a> よりログインしてください。
</p>
{else}

{if $is_lock}
<p class="login-box-msg">ロックを解除するには、下のボタンを押してください。</p>

{form method="post"}

<div class="row">
<div class="col-xs-6 col-xs-offset-3">
<button type="submit" class="btn btn-primary btn-block btn-flat">
ロックを解除する
</button>
</div>
</div>

{/form}

{else}
<p class="login-box-msg">アカウントはロックされていません。</p>
<p class="login-box-msg">
<a href="/">ログインページ</a> よりログインしてください。
</p>
{/if}
{/if}

</div>
<!-- /.login-box-body -->
</div>
<!-- /.login-box -->
{/block}
{block name='script'}
{/block}


同様に index.tpl についても行っておけば良いでしょう。


Csrf::check()の使い方


classes/controller/LoginController.class.php

    /**

* ロックを解除する
* @return boolean
*/

public static function unlock()
{
if (null == filter_input_array(INPUT_POST)) {
return;
}

// CSRFチェック
Csrf::check();

$token = filter_input(INPUT_GET, 'token');

Db::transaction();
$objUserModel = new UserModel();
$objUserModel->getModelByToken($token);
$objUserModel->setLoginFailureCount(0)
->setLoginFailureDatetime(NULL)
->setToken('')
->save();
DB::commit();

return true;
}



GitHub レポジトリ

これまでの解説で作成したソースコードについては、こちらに。

レポジトリ