ホームページビルダーでつくられた小規模なサイトをリニューアルして SPA (Single Page Application) をつくる際に検討したことをまとめました。
対象読者
小規模のコンテンツ提供と更新、ユーザー管理機能をもつサイトを題材に Silex の機能をどのように使うのかを学ぶことを主軸とします。結果として SPA に特徴的な話しはあまりありません。
フレームワークの選択肢
PHP
Silex と同じマイクロフレームワークを比較検討したいのであれば、
「PHP、Node.js、Go のミドルウェアに関する調査」の記事 をご参照ください。速度を大幅に改善できるという点で、著者は Phalcon Micro に興味をもっています。
Web API に特化したプロジェクトであれば、BEAR.Sunday や API 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-compat や Symfony 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
を追加することができます。
"scripts" :{
"test": [
"php vendor/bin/phpunit"
]
}
JSON レスポンス
コントローラーのテストのために WebTestCase
が用意されています。テストの HTTP クライアントには BrowserKit を使います。
アプリケーションのセットアップには createApplication
メソッドを使います。HTTP ステータスとヘッダーのためのヘルパーメソッドを用意しておくとよいでしょう。HTTP メッセージボディは getContent
メソッドで取得して、json_decode
で配列もしくはオブジェクトに変換します。
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.test
に true
を指定すれば、ストレージに MockFileSessionStorage
が使われるようになります。
$app['session.test'] = true;