Silex で SPA (Single Page Application) の開発

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

対象読者
-------

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

フレームワークの選択肢
-------------------

### PHP

Silex と同じマイクロフレームワークを比較検討したいのであれば、
「[PHP、Node.js、Go のミドルウェアに関する調査](http://qiita.com/masakielastic/items/c9825434fda404231cbc)」の記事 をご参照ください。速度を大幅に改善できるという点で、著者は [Phalcon Micro](https://docs.phalconphp.com/ja/latest/reference/micro.html) に興味をもっています。

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

### JavaScript

「[静的なサイトに JavaScript のルーターを導入する](http://qiita.com/masakielastic/items/2d43829edbac51ea366c)」や「[ES6、React、Cycle.js、RxJS に関する調査](http://qiita.com/masakielastic/items/e769f111932e9061ffd4)」をご参照ください。

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

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

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

Web API の自動テストツールとして [Postman](https://www.getpostman.com/) および [Newman](https://github.com/postmanlabs/newman) が挙げられます。

サーバー構築
----------

### Docker Compose によるサーバー構築

「[Docker Compose で PHP 7.0 の開発環境を構築する](http://qiita.com/masakielastic/items/9fdc52b47cc7e3850b9f)」の記事をご参照ください。

### Apache HTTP、nginx の設定

公式マニュアルに[記載](http://silex.sensiolabs.org/doc/web_servers.html)されています。

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

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

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

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

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

Redis の運用に関して、「[Redis 本番障害から学んだコードレビューの勘所](http://qiita.com/haminiku/items/43bafbb9d74ef3a1f74c)」の記事が読み応えあります。

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

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

プロジェクトの構成
---------------

### スケルトン
公式の [Silex-Skeleton](https://github.com/silexphp/Silex-Skeleton) があります。

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

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

```php
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();
```

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

```php
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 のコントローラープロバイダーをつくれば、複数のアプリで共有することができます。

```php
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 の[例](https://github.com/masakielastic/silex-simplepage)は Github で公開しています。

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

[ServiceControllerServiceProvider](http://silex-users-jp.phper.jp/doc/html/providers/service_controller.html) を参照。コントローラーをスリムに保つことで、複数のアプリやフレームワークのあいだでコントローラーを共有したり、テストしやすくすることができます。


### HttpKernelInterface ミドルウェア

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

セキュリティ
----------

### SSL/TLS を強制する

IE11 とそれ以降が対象であれば、`Strict-Transport-Security` ヘッダーを使うことができます。Symfony/Silex の[ミドルウェア](https://github.com/Zae/strict-transport-security)が公開されています。

### CSRF 対策のトークンを使う

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

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` ミドルウェアで検証します。

```php
$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](https://github.com/paragonie/random_compat) をプロジェクトに導入します。送信されてきた CSRF トークンの比較には `==` や `===` 演算子ではなく、`hash_equals` を使います。PHP 5.5 以前の環境を考慮する必要がある場合、[hash-compat](https://github.com/indigophp/hash-compat) や [Symfony Polyfill](https://github.com/symfony/polyfill) が挙げられます。

### JavaScript/JSON のエスケープ

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

```php
<?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 の[回答](http://stackoverflow.com/a/7252311/531320)が示すように REST API に似せることができます。

```bash
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 のサブリクエストを使って対応する必要があります。

```php
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` メソッドを使います。

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

### JSON レスポンスを返す

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

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

    $condition = true;

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

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

主要なステータスコードの定数はあらかじめ定義されてあります ([Response](https://github.com/symfony/http-foundation/blob/master/Response.php))。

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

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

```php
use Symfony\Component\HttpFoundation\JsonResponse;

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

### JSON リクエストを扱う

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

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

`json_decode` には  HashDos への対策がほどこされていないので、数十キロバイト規模のデータが来た場合に、Fatal Error にするとよいでしょう ([PHPのJSON HashDosに関する注意喚起](http://blog.hash-c.co.jp/2015/10/phpjson-hashdos.html))。

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

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

```php
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](https://github.com/typicode/json-server) というツールがあります。ファイルを読み込んで表示させるだけなら、Silex でちょっとしたコードを書けばすみます。

```php
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 は次のようになります。

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

### 静的な HTML を生成する

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

```php
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 ではありません。

```php
$ret = [];

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

```

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

[PSR7BridgeServiceProvider](https://github.com/masakielastic/silex-psr7bridge-service-provider) のコードをご参照ください。


データベース
----------

### DoctrineServiceProvider のセットアップ

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

```bash
composer require "doctrine/dbal:~2.5"
```

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

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

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

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

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

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

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

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

### テーブルの作成

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

```php
$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](https://github.com/doctrine/dbal/blob/f44782e91c8c910736272986f543989d214daf1c/lib/Doctrine/DBAL/Platforms/MySqlPlatform.php#L477.L485))。サンプルのコードはテストケース([MySQL](https://github.com/doctrine/dbal/blob/master/tests/Doctrine/Tests/DBAL/Functional/Schema/MySqlSchemaManagerTest.php)) を見るとよいでしょう。外部キーを使うコードの[例](http://blog.sarabande.jp/post/134393514663)はこちらの記事をご参照ください


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

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

### 問い合わせのメソッド

[PDO から Doctrine DBAL への書き換え](http://blog.sarabande.jp/post/28545565604) の記事をご参照ください。

認証
---
### SecurityServiceProvider

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

ユーザー管理機能を使う人がかぎられていたり、使う頻度が低いなど、SPA 対応にする必要がなければ、[Simple User Provider](https://github.com/jasongrimes/silex-simpleuser) を導入する選択肢があります。

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

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

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

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

```php
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']` サービスを使います。

```php
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();
```

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

```php
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']` サービスを使う場合、次のように書くことができます。

```php
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` を使ってパスワードを認証できます。

```php
$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 の実験例としてたとえば、次の構成を試してみました。コードは[こちら](http://blog.sarabande.jp/post/127781282043)をご参照ください。

```php
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 の[回答](http://stackoverflow.com/q/21357182/531320)より)。

Silex の実装例は [security-jwt-service-provider](https://github.com/cnam/security-jwt-service-provider) をご参照ください。

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

```php
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 も一緒にセットアップしておきましょう。

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

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

```bash
vendor/bin/phpunit --version
```

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

```bash
composer global require phpunit/phpunit ~5.0
```

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

```bash
composer global remove phpunit/phpunit
```

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

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

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

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

### JSON レスポンス

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

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

```php: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;
```

```php
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.test` に `true` を指定すれば、ストレージに `MockFileSessionStorage` が使われるようになります。

```php
$app['session.test'] = true;
```