49
54

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

Silex で SPA (Single Page Application) の開発

Last updated at Posted at 2015-11-11

ホームページビルダーでつくられた小規模なサイトをリニューアルして SPA (Single Page Application) をつくる際に検討したことをまとめました。

対象読者

小規模のコンテンツ提供と更新、ユーザー管理機能をもつサイトを題材に Silex の機能をどのように使うのかを学ぶことを主軸とします。結果として SPA に特徴的な話しはあまりありません。

フレームワークの選択肢

PHP

Silex と同じマイクロフレームワークを比較検討したいのであれば、
PHP、Node.js、Go のミドルウェアに関する調査」の記事 をご参照ください。速度を大幅に改善できるという点で、著者は Phalcon Micro に興味をもっています。

Web API に特化したプロジェクトであれば、BEAR.SundayAPI Platform を挙げます。BEAR.Sunday 開発者の koriym さんは日本の PHP カンファレンスや Symfony 勉強会などで精力的に講演なさっています。API Platform の開発者の Dunglas さんは Symfony のコアチームのメンバーです(2015年時点)。

JavaScript

静的なサイトに JavaScript のルーターを導入する」や「ES6、React、Cycle.js、RxJS に関する調査」をご参照ください。

HTTP クライアントとテストツール

筆者はサーバーとのやりとりの検証に httpie を使っています。HTTP/2 対応のプラグイン (httpie-http2) もあります。

http -f --session=test POST example.org foo=bar baz=qux

Web API の自動テストツールとして Postman および Newman が挙げられます。

サーバー構築

Docker Compose によるサーバー構築

Docker Compose で PHP 7.0 の開発環境を構築する」の記事をご参照ください。

Apache HTTP、nginx の設定

公式マニュアルに記載されています。

セッションの管理に Redis を使う

NativeSessionStorage を使わないようにする必要があります。

$app['session.storage.handler'] = null;

php.ini の設定があれば Silex の側では特に設定は不要です。

session.save_handler = redis
session.save_path = tcp://127.0.0.1:6379

Redis の運用に関して、「Redis 本番障害から学んだコードレビューの勘所」の記事が読み応えあります。

Pimple・Twig の PHP エクステンション導入

Pimple・Twig の PHP エクステンションは公式リポジトリに含まれています。 最新の Pimple を使うためには Silex 2系に移行する必要があります。

プロジェクトの構成

スケルトン

公式の Silex-Skeleton があります。

共有サービスとサービスプロバイダー

複数のコントローラーで共有する機能は共有サービスとして定義するか検討します。小規模のコードであれば、PHP 7.0 で導入された無名クラスを使うことで、名前空間の汚染を減らすことができます。

use Silex\Application;
use Symfony\Component\HttpFoundation\Request;

$app = new Application();
$app['util'] = $app->share(function() {
    return new class {
        public function base64urlEncode($data)
        {
            return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
        }

        public function randomToken($length = 32)
        {
            if (function_exists('random_bytes')) {
                $bytes = random_bytes($length);
            } else {
                $bytes = openssl_random_pseudo_bytes($length);
            }

            return $this->base64urlEncode($bytes);
        }
    };
});
$app->get('/', function(Application $app) {
    return $app['util']->randomToken();
});

$app->run();

一定規模を超えるのであれば、サービスプロバイダーとして開発することを検討しましょう。

use Silex\Application;
use Silex\ServiceProviderInterface;
use Symfony\Component\HttpFoundation\Request;

class UtilServiceProvider implements ServiceProviderInterface
{
    public function register(Application $app)
    {
        $app['util'] = $app->share(function() {
            return new Util;
        });
    }

    public function boot(Application $app)
    {
    }
}

class Util
{
    public function base64urlEncode($data)
    {
        return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
    }

    public function randomToken($length = 32)
    {
        if (function_exists('random_bytes')) {
            $bytes = random_bytes($length);
        } else {
            $bytes = openssl_random_pseudo_bytes($length);
        }

        return $this->base64urlEncode($bytes);
    }   
}

$app = new Application();
$app->register(new UtilServiceProvider);
$app->get('/', function(Application $app) {
    return $app['util']->randomToken();
});

$app->run();

コントローラープロバイダー

Web API のコントローラープロバイダーをつくれば、複数のアプリで共有することができます。

use Silex\Application;
use Silex\ControllerProviderInterface;
use Symfony\Component\HttpFoundation\Request;

class HelloControllerProvider implements ControllerProviderInterface
{
    public function connect(Application $app)
    {
        $controllers = $app['controllers_factory'];

        $controllers->before(function(Request $request) {
            var_dump('before ミドルウェア');
        });

        $controllers->get('/{name}', function (Application $app, Request $request, $name) {
            return 'Hello '.$name;
        });

        return $controllers;
    }
}

$app = new Application();
$app->mount('/hello', new HelloControllerProvider());
$app['debug'] = true;

$app->run();

シンプルなページ情報に関する Web API のは Github で公開しています。

コントローラーのサービス化

ServiceControllerServiceProvider を参照。コントローラーをスリムに保つことで、複数のアプリやフレームワークのあいだでコントローラーを共有したり、テストしやすくすることができます。

HttpKernelInterface ミドルウェア

Silex や Symfony SE で開発された複数のアプリで機能の共有化を図るとき、StackPHP のサイトで示されているコミュニティの成果物を利用するか、検討するとよいでしょう。

セキュリティ

SSL/TLS を強制する

IE11 とそれ以降が対象であれば、Strict-Transport-Security ヘッダーを使うことができます。Symfony/Silex のミドルウェアが公開されています。

CSRF 対策のトークンを使う

CSRF トークンは HTTP ヘッダーもしくは HTTP ボディのどちらかに添付するか考える必要があります。双方の利点・欠点はWhy is it common to put CSRF prevention tokens in cookies? の質問への回答でまとめられています。

Symfony のコミュニティではフォームフレームワークを通じて HTTP ボディに添付されきましたが、SPA の場合、PHP でフォームを生成しない場合や、複数のフォームや JavaScript フレームワークとの連携を考慮すると、HTTP ヘッダーのほうが便利です。

Silex 2.0 では CSRF Service Provider が導入され、$app['csrf.token_manager']->isTokenValid(new CsrfToken('token_id', 'TOKEN')) が使えます。

Ruby on Rails や AngularJS のコミュニティでは X-XSRF-TOKEN ヘッダーや X-CSRF-TOKEN ヘッダーが使われています。

送信されてきた CSRF トークンは before ミドルウェアで検証します。

$app->before(function (Request $request, Application $app) {
    $app['session']->start();

    if ($request->isMethod('POST') || $request->isMethod('DELETE')) {
        $csrf_token = $request->headers->get('x-xsrf-token');
        $session_token = $app['session']->get('csrf_token');

        $valid = $csrf_token !== null &&
                 $session_token !== null &&
                 hash_equals($csrf_token, $session_token);

        if (!$valid) {
            return $app->json([
                'msg' => 'fail',
                'description' => 'CSRF トークンが送信されていないもしくは一致しません。'
            ]);
        }
    }
});

CSRF トークンを自分で生成する場合、標準関数の random_bytes (PHP 7.0) を使います。PHP 5.x であれば、random_compat をプロジェクトに導入します。送信されてきた CSRF トークンの比較には ===== 演算子ではなく、hash_equals を使います。PHP 5.5 以前の環境を考慮する必要がある場合、hash-compatSymfony Polyfill が挙げられます。

JavaScript/JSON のエスケープ

PHP から JavaScript・JSON を動的に生成する必要がある場合、エスケープの処理には json_encode を使うとよいでしょう。

<?php
$msg = 'こんにちは';
$options = JSON_HEX_QUOT|JSON_HEX_AMP|JSON_HEX_APOS|JSON_HEX_TAG;
?>
<script>
var a = <?= json_encode( $msg, $options ) ?>;
var b = <?= json_encode( ['msg' => $msg], $options ) ?>;

console.log(a);
console.log(b['msg']);
</script>

Web API

ログイン・ログアウトの REST 風 API

セッションによるログイン・ログアウトの URL はステートレスではないのですが、stackoverflow の回答が示すように REST API に似せることができます。

GET    /session/new ログインフォームをもつウェブページを取得する
POST   /session データベースに対してクレデンシャルを認証する
DELETE /session セッションを破壊して、/ にリダイレクトする
GET  /users/new 登録フォームをもつウェブページを取得する
POST /users 入力された情報を /user/xxx としてデータベースに記録する 
GET  /users/xxx // プロファイルビューでの現在のユーザーデータを取得してレンダリングする
POST /users/xxx // 新しいユーザー情報を更新する

後述する SecurityServiceProvider を使う場合、Silex 1.3 の時点ではログアウトの HTTP メソッドの制約があるので、サブリクエストが必要になります。

商品の在庫数の更新を考える

商品の在庫数を扱う場合、全在庫数の更新もしくは相対的な加算・減算のサポートを考える必要があります。全在庫数の更新の API のみの場合、更新の際に HTTP クライアントはあらかじめ全在庫数を取得した上で計算しなければなりません。

コントローラー

トップページ以外のページでのアプリの初期化

一番最初にアクセスするページがトップページ以外である場合、HTTP サーバー側の対応が必要になります。共有ホスティングサービスなど HTTP サーバーの設定ファイルを修正できない場合、Silex のサブリクエストを使って対応する必要があります。

use Silex\Application;
use Silex\Provider;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\HttpKernelInterface;

$app = new Application;
$app->get('/{name}', function(Application $app, $name) {
    $subRequest = Request::create("/#{$name}", 'GET');

    return $app->handle($subRequest, HttpKernelInterface::SUB_REQUEST);
});

複数の HTTP メソッドに対応させる

match メソッドを使います。

$app->match('/api/foo', function () {
    // ...
})
->method('POST|PUT');

JSON レスポンスを返す

$app->json を使います。ステータスコードは第2引数で指定します。

$app->get('/foo', function () use ($app) {

    $condition = true;

    if (!$condition) {
        return $app->json(['msg' => 'error'], 404);
    }

    return $app->json(['msg' => 'ok']);
});

主要なステータスコードの定数はあらかじめ定義されてあります (Response)。

return $app->json(['msg' => 'ok'], Response::HTTP_OK);

JsonResponse オブジェクトを直接生成することもできます。

use Symfony\Component\HttpFoundation\JsonResponse;

return new JsonResponse(['msg' => 'ok']);

JSON リクエストを扱う

Request オブジェクトの getContent を使います。

if (0 === strpos($request->headers->get('Content-Type'), 'application/json')) {
    $data = json_decode($request->getContent(), true);
    }

json_decode には HashDos への対策がほどこされていないので、数十キロバイト規模のデータが来た場合に、Fatal Error にするとよいでしょう (PHPのJSON HashDosに関する注意喚起)。

連想配列の戻り値を JSON に変換する

view ハンドラーを使えば、戻り値で指定した連想配列を自動的に JSON に変換することができます。

use Silex\Application;

$app = new Application();
$app->get('/', function(Application $app) {
    return ['msg' => 'Hello World'];
});
$app->view(function (array $controllerResult) use ($app) {
    return $app->json($controllerResult);
});

$app->run();

JSON ファイルからフェイク Web API をつくる

ちょっとしたアイディアを試すのに、テストデータから Web API を用意できれば便利です。Node.js で書かれた JSON Server というツールがあります。ファイルを読み込んで表示させるだけなら、Silex でちょっとしたコードを書けばすみます。

use Silex\Application;

$app = new Application(); 
$app['debug'] = true;

$app['api'] = json_decode(file_get_contents(__DIR__.'/db.json'));

foreach ($app['api'] as $key => $value) {

    $app->get('/'.$key, function(Application $app) use ($value) {
        return $app->json($value); 
    }); 

}

$app->run();

テストデータの db.json は次のようになります。

{
  "pages": [
    {
      "id": 1,
      "name": "index",
      "title": "ホーム",
      "body": "ホームの内容"
    },
    {
      "id": 2,
      "name": "about",
      "title": "自己紹介",
      "body": "自己紹介の内容"
    }
  ],
  "profile": {
    "name": "Masaki"
  }
}

静的な HTML を生成する

生成したいページの URL のリストを用意した上で、handle メソッドで1つずつ生成してゆきます。

use Silex\Application;
use Symfony\Component\HttpFoundation\Request;

$app = new Application(); 
$app['debug'] = true;

$app->get('/foo', function() {
    return 'foo'; 
});

$app->get('/', function() {
    return 'Hello'; 
});

$response = $app->handle(Request::create('/foo'));

$app[‘routes’] (RouteCollection) は IteratorAggregate を実装するので、foreach ループからすべてのルートのパターンを求めることができますが、個別の記事の URL ではありません。

$ret = [];

foreach ($app['routes'] as $elm) {
    $ret[] = $elm->getPattern();
}

PSR-7 対応のリクエスト・レスポンスオブジェクトを扱う

PSR7BridgeServiceProvider のコードをご参照ください。

データベース

DoctrineServiceProvider のセットアップ

Doctrine DBAL (Database Abstraction Layer) を Composer で導入します。

composer require "doctrine/dbal:~2.5"

サービスプロバイダークラスをアプリケーションに登録します。

$app = new Silex\Application();
$app->register(new Silex\Provider\DoctrineServiceProvider());

SQLite の場合、設定オプションは次のようになります。

$app['db.options'] = [
    'driver' => 'pdo_sqlite',
    'path'   => __DIR__.'/app.db'
];

インメモリデータベースを使うのであれば、次のようになります。

$app['db.options'] = [
    'driver' => 'pdo_sqlite',
    'memory' => true
];

MySQL の場合、次のとおりです。

$app['db.options'] = [
    'driver' => 'pdo_mysql',
    'user' => 'myuser',
    'password' => 'mypassword',
    'charset' => 'utf8',
    'dbname' => 'testdb'
];

テーブルの作成

SQL を直接使う以外に Schema Manager で生成することができます。テスト環境では SQLite、運用環境では MySQL のようにデータベースを使いわける必要がある場合に便利です。

$tableName = 'Page';

$sm = $app['db']->getSchemaManager();
$schema = $sm->createSchema();

$page = $schema->createTable($tableName);
$page->addOption('charset', 'utf8mb4');
$page->addOption('collate', 'utf8mb4_unicode_ci');
$page->addOption('engine', 'InnoDB');

$page->addColumn('id', 'integer', ['unsigned' => true, 'autoincrement' => true]);
$page->setPrimaryKey(['id']);
$page->addColumn('name', 'string', ['length' => 255]);
$page->addColumn('title', 'string', ['length' => 255]);
$page->addColumn('content', 'text');

$sm->createTable($page);

DBAL 2.5 では何も指定しない場合、デフォルトの文字セットは utf8、文字照合は utf8_unicode_ci になります(MySqlPlatform.php)。サンプルのコードはテストケース(MySQL) を見るとよいでしょう。外部キーを使うコードのはこちらの記事をご参照ください

データの登録には insert メソッドを使うことができます。

$app['db']->insert('Page', [
    'name' => 'main',
    'title' => 'タイトル',
    'content' => 'コンテンツ'
]);

問い合わせのメソッド

PDO から Doctrine DBAL への書き換え の記事をご参照ください。

認証

SecurityServiceProvider

Simple User Provider によるユーザー管理

ユーザー管理機能を使う人がかぎられていたり、使う頻度が低いなど、SPA 対応にする必要がなければ、Simple User Provider を導入する選択肢があります。

ユーザープロバイダーをつくる

ユーザーの情報を管理するためにデータベースを使う場合、UserProviderInterface を実装するユーザープロバイダーをつくる必要があります。コードの例はこちらの記事を参照してください。

ログイン・ログアウトのレスポンスを JSON にする

ログイン・ログアウトハンドラーを登録します。

use Silex\Application;
use Silex\Provider;

use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Http\Authentication\AuthenticationSuccessHandlerInterface;
use Symfony\Component\Security\Http\Authentication\AuthenticationFailureHandlerInterface;
use Symfony\Component\Security\Http\Logout\LogoutSuccessHandlerInterface;

class AuthenticationHandler implements AuthenticationSuccessHandlerInterface, AuthenticationFailureHandlerInterface
{
        public function onAuthenticationSuccess(Request $request, TokenInterface $token)
        {
        return new JsonResponse(['login' => true]);
        }

    public function onAuthenticationFailure(Request $request, AuthenticationException $exception)
        {
        return new JsonResponse(['login' => false]);
        }
}

class LogutSuccessHandler implements LogoutSuccessHandlerInterface
{
    public function onLogoutSuccess(Request $request)
    {
        return new JsonResponse(['login' => false]);
    }
}

$app = new Application();
$app->register(new Provider\SessionServiceProvider());
$app->register(new Provider\SecurityServiceProvider());

$app['debug'] = true;
$app['security.firewalls'] = [
    'admin' => [
        'anonymous' => true,
        'form' => [
            'check_path' => '/login_check'
            ],
        'logout' => ['logout_path' => '/logout', 'invalidate_session' => true],
        'users' => [
            'admin' => [
              'ROLE_ADMIN',
              // foo
              '5FZ2Z8QIkA7UTZ4BYkoC+GsReLf569mSKDsfods6LYQ8t+a8EW9oaircfMpmaLbPBh4FOBiiFyLfuZmTSUwzZg=='
            ]
        ]
    ]
];

$app['security.authentication.success_handler.admin'] = $app->share(function ($app) {
    return new AuthenticationHandler();
});
$app['security.authentication.failure_handler.admin'] = $app->share(function ($app) {
    return new AuthenticationHandler();
});

$app['security.authentication.logout_handler.admin'] = $app->share(function () use ($app) {
    return new LogutSuccessHandler();
});

$app->before(function (Request $request) {
    $request->getSession()->start();
});

$app->get('/', function(Application $app, Request $request) {
    $user = $app['security.token_storage']->getToken()->getUser();

    $name = $user === 'anon.' ? 'ゲスト' : $user->getUserName();

    return "こんにちは、{$name} さん。";
});
$app->run();

手動でログイン・ログアウトする

Facebook の OAuth 認証などで、手動でログイン・ログアウトを実行する必要がある場合、$app['security.token_storage'] サービスを使います。

use Silex\Application;
use Silex\Provider;

use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;

$app = new Application();
$app->register(new Provider\SessionServiceProvider());
$app->register(new Provider\SecurityServiceProvider());

$app['debug'] = true;
$app['security.firewalls'] = [
    'admin_area' => ['anonymous' => true]
];

$app->before(function (Request $request) {
    $request->getSession()->start();
});

$app->get('/', function(Application $app, Request $request) {

    if (!$app['security.authorization_checker']->isGranted('ROLE_ADMIN')) {
        return 'ログインしてください。';
    }

    $token = $app['security.token_storage']->getToken();
    $name = $token->getUsername();

    return "こんにちは、{$name} さん。";
});

$app->get('/login', function(Application $app) {
    $name = 'foo';

    $token = new UsernamePasswordToken(
        $name,
        null,
        'admin_area',
        ['ROLE_ADMIN']
    );

    $app['security.token_storage']->setToken($token);

    return 'ログインしました。';
});

$app->get('/logout', function(Application $app) {
    $app['security.token_storage']->setToken(null);

    return 'ログアウトしました。';
});

$app->run();

ユーザー登録と同時にログインする

use Silex\Application;
use Silex\Provider;

use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;

use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Schema\Table;

$app = new Application();
$app->register(new Provider\SessionServiceProvider());
$app->register(new Provider\SecurityServiceProvider());
$app->register(new Provider\DoctrineServiceProvider());

$app['debug'] = true;
$app['security.firewalls'] = [
    'admin_area' => ['anonymous' => true]
];
$app['db.options'] = [
    'driver'   => 'pdo_sqlite',
    'path'     => __DIR__.'/app.db'
];

$sm = $app['db']->getSchemaManager();

if (!$sm->tablesExist('users')) {
    $users = new Table('users');
    $users->addColumn('id', 'integer', ['unsigned' => true, 'autoincrement' => true]);
    $users->setPrimaryKey(['id']);
    $users->addColumn('username', 'string', ['length' => 32]);
    $users->addColumn('email', 'string', ['length' => 255]);
    $users->addColumn('telephone', 'string', ['notnull' => false, 'length' => 30]);
    $users->addColumn('password', 'string', ['length' => 255]);
    $users->addColumn('roles', 'string', ['default' => 'ROLE_USER', 'length' => 255]);

    $sm->createTable($users);

    $app['db']->insert('users', [
      'username' => 'foo',
      'password' => password_hash('bar', PASSWORD_DEFAULT),
      'email' => 'foo@example.com',
      'telephone' => '123456789',
      'roles' => 'ROLE_ADMIN'
    ]);
}

$app->before(function (Request $request) {
    $request->getSession()->start();
});

$app->get('/', function(Application $app, Request $request) {

    if (!$app['security.authorization_checker']->isGranted('ROLE_ADMIN')) {
        return 'ログインしてください。';
    }

    $token = $app['security.token_storage']->getToken();
    $name = $token->getUsername();

    return "こんにちは、{$name} さん。";
});

$app->get('/register', function(Application $app, Request $request) {

    $email ='bar@example.com';
    $stmt = $app['db']->executeQuery('SELECT * FROM users WHERE email = ?', [$email]);

    $token = new UsernamePasswordToken(
        $email,
        null,
        'admin_area',
        ['ROLE_ADMIN']
    );

    if ($stmt->fetch()) { 
        $app['security.token_storage']->setToken($token);

        return 'すでに登録されています。ログインします。';
    } 

    $password = random_token(15);
    $hash = password_hash($password, PASSWORD_DEFAULT);

    $count = $app['db']->executeUpdate('INSERT INTO users(username, email, password) VALUES(?, ?, ?)', [$email, $email, $hash]);

    if ($count !== 1) {
        return '登録できませんでした。';
    }

    $app['security.token_storage']->setToken($token);

    return '登録しました。';
});

$app->get('/logout', function(Application $app) {
    $app['security.token_storage']->setToken(null);

    return 'ログアウトしました。';
});

$app->get('/users', function(Application $app) {
    $users = $app['db']->fetchAll('SELECT * FROM users');

    return $app->json($users);
});
$app->run();

Facebook の OAuth 認証

$app['security.token_storage'] サービスを使う場合、次のように書くことができます。

use Silex\Application;
use Silex\Provider;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;

$app = new Application();
$app->register(new Provider\SessionServiceProvider());
$app->register(new Provider\SecurityServiceProvider());

$app['debug'] = true;
$app['security.firewalls'] = [
    'admin_area' => ['anonymous' => true]
];

$app['fb'] = $app->share(function() {
    return new Facebook\Facebook([
        'app_id' => '{app-id}',
        'app_secret' => '{app-secret}',
        'default_graph_version' => 'v2.4',
    ]);
});


$app->before(function(Request $request) {
    $session = $request->getSession()->start();
});

$app->get('/login', function (Application $app) {
    $helper = $app['fb']->getRedirectLoginHelper();
    $permissions = ['email'];
    $loginUrl = $helper->getLoginUrl('http://example.com/login-callback', $permissions);

    return '<a href="' . $loginUrl . '">Facebook でログイン</a>';
});

$app->get('/login-callback', function(Application $app) {

    $helper = $app['fb']->getRedirectLoginHelper();

    try {
        $accessToken = $helper->getAccessToken();
    } catch (Facebook\Exceptions\FacebookResponseException $e) {
        return 'Graph がエラーを返しました: ' . $e->getMessage();
    } catch(Facebook\Exceptions\FacebookSDKException $e) {
        return 'Facebook SDK がエラーを返しました: ' . $e->getMessage();
    }

    $app['fb']->setDefaultAccessToken((string) $accessToken);

    try {
        $response = $app['fb']->get('/me?locale=ja_JP&fields=id,name,email');
        $userNode = $response->getGraphUser();
    } catch(Facebook\Exceptions\FacebookResponseException $e) {
        return 'Graph がエラーを返しました: ' . $e->getMessage();
    } catch(Facebook\Exceptions\FacebookSDKException $e) {
        return 'Facebook SDK がエラーを返しました: ' . $e->getMessage();
    }

    $email = $userNode['email'];

    $token = new UsernamePasswordToken(
        $email,
        null,
        'admin_area',
        ['ROLE_ADMIN']
    );


    $app['security.token_storage']->setToken($token);

    return $app->redirect('/');
});

$app->get('/', function(Application $app, Request $request) {

    if (!$app['security.authorization_checker']->isGranted('ROLE_ADMIN')) {
        return 'ログインしてください。';
    }

    $token = $app['security.token_storage']->getToken();
    $name = $token->getUsername();

    $script = "<script type=\"text/javascript\">
    // http://stackoverflow.com/q/7131909/531320

    if (window.location.hash && window.location.hash == '#_=_') {
      if (window.history && history.pushState) {
        window.history.pushState('', document.title, window.location.pathname);
      }
    }
    </script>";

    return "こんにちは、{$name} さん。".$script;
});

$app->get('/logout', function(Application $app) {
    $app['security.token_storage']->setToken(null);

    return 'ログアウトしました。';
});

$app->run();

SecurityServiceProvider を使わない認証

ロールが不要であったり、サイトをメンテナンスする人のスキルを考慮したり、Web API の実験をしたい場合、SecurityServiceProvider を使わずに認証を実装する必要があるでしょう。PHP 5.5 とそれ以降であれば password_hash および password_verify を使ってパスワードを認証できます。

$hash = password_hash('mypass');

if (!password_verify($password, $hash)) {
    return $app->json(['msg' => 'fail', 400);
}

$app['session']->migrate(true);
$app['session']->set('login', true);

Web API の実験例としてたとえば、次の構成を試してみました。コードはこちらをご参照ください。

GET    /sessions ログインの状態を確認する
POST   /sessions ログイン
DELETE /sessions ログアウト

JWT (JSON Web Token) による認証

PHP のセッションの代わりに JWT (JSON Web Token) による認証が少しずつ注目を集めるようになってきています。わかりやすい解説は [JSON Web Token の効用] をご参照ください。

JWT による認証のメリットはユーザーから送られてきたトークンを検証するために、以前発行したトークンをサーバーのファイルや Redis のような KVS (Key Value Store) に保存しなくてよいことです。

言い換えると、サーバーがステートレスになるので、サーバーを増設しやすくなるということです。サーバーがステートレスになるといっても、Cookie (セッション) を使う場合は CSRF 対策が必要とのことです (stackoverflow の回答より)。

Silex の実装例は security-jwt-service-provider をご参照ください。

HTTP ヘッダーを通じてユーザーから送信されてきたトークンは次のように検証されます。

try {
     $data = \JWT::decode($token, $secretKey, $allowed_algs);
} catch (\UnexpectedValueException $e) {
    throw new \UnexpectedValueException($e->getMessage());
} catch (\DomainException $e) {
    throw new \UnexpectedValueException($e->getMessage());
}

if ($data->exp < time()) {
    throw new \UnexpectedValueException('token not allowed');
}

テスト

PHPUnit のセットアップ

composer require を使います。HTTP クライアントの BrowserKit も一緒にセットアップしておきましょう。

composer require --dev phpunit/phpunit symfony/browser-kit

コマンドツールが使えるか確認しましょう。

vendor/bin/phpunit --version

グローバルにインストールする場合、次のコマンドを実行します。

composer global require phpunit/phpunit ~5.0

グローバルにインストールされたコマンドツールをアンインストールする場合、次のとおりです。

composer global remove phpunit/phpunit

phpunit コマンドにテストディレクトリのパスを伝えるための phpunit.xml は次のようになります。

<phpunit>
  <testsuites>
    <testsuite name="Silex Test">
      <directory>tests</directory>
    </testsuite>
  </testsuites>
</phpunit>

カスタムコマンドの composer test を追加することができます。

composer.json
"scripts" :{
    "test": [
        "php vendor/bin/phpunit"
    ]
}

JSON レスポンス

コントローラーのテストのために WebTestCase が用意されています。テストの HTTP クライアントには BrowserKit を使います。

アプリケーションのセットアップには createApplication メソッドを使います。HTTP ステータスとヘッダーのためのヘルパーメソッドを用意しておくとよいでしょう。HTTP メッセージボディは getContent メソッドで取得して、json_decode で配列もしくはオブジェクトに変換します。

app.php
use Silex\Application;
use Symfony\Component\HttpFoundation\Request;

$app = new Silex\Application();
$app->get('/', function(Application $app, Request $request) {
    return $app->json(['msg' => 'hello']);
});

return $app;
use Silex\WebTestCase;

class MyControllerTest extends WebTestCase
{
    public function createApplication()
    {
        $app = require __DIR__ . '/../app.php';
        $app['debug'] = true;

        return $app;
    }

    // http://marcjuch.li/blog/2014/04/06/symfony2-rest-functional-testing-with-fixtures/
    protected function assertJsonResponse($response, $statusCode = 200) {
        $this->assertEquals(
            $statusCode, $response->getStatusCode(),
            $response->getContent()
        );
        $this->assertTrue(
            $response->headers->contains('Content-Type', 'application/json'),
            $response->headers
        );
    }

    public function testIndex()
    {
        $client = $this->createClient();
        $crawler = $client->request('GET', '/');
        $response = $client->getResponse();
        $data = json_decode($response->getContent(), true);

        $this->assertJsonResponse($response);
        $this->assertEquals('hello', $data['msg']);
    }
}

セッション

session.testtrue を指定すれば、ストレージに MockFileSessionStorage が使われるようになります。

$app['session.test'] = true;
49
54
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
49
54

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?