16
6

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.

BEAR.SundayAdvent Calendar 2018

Day 7

今まで作ったAOPアドバイス(Interceptor)を公開します

Last updated at Posted at 2018-12-06

はじめに

BEAR.SundayではAOPアライアンスのメソッドインターセプターAPIに準拠した強力なRay.AopというAOPフレームワークが利用可能です。
5年くらいBEAR.Sundayを使ってきた筆者が開発したInterceptorを駄作から良作?までご紹介します。

インフラ系

Basic認証

Basic認証をレスポンスします。Basic認証自体アレですが、アノテーションを一つ付ければ認証が出る手軽さは割と気に入っています。プロダクションで使う場合SSL必須です。用法用量を守って正しくご利用ください。

<?php
namespace MyVendor\MyApp\Interceptor;

use Ray\Aop\MethodInterceptor;
use Ray\Aop\MethodInvocation;
use Ray\Di\Di\Named;

class BasicAuth implements MethodInterceptor
{
    /**
     * @var string
     */
    private $basicAuthUser;

    /**
     * @var string
     */
    private $basicAuthPassword;

    /**
     * @Named("user=basicAuthUser, password=basicAuthPassword")
     * @param $user
     * @param $password
     */
    public function __construct($user, $password)
    {
        $this->basicAuthUser = $user;
        $this->basicAuthPassword = $password;
    }

    public function invoke(MethodInvocation $invocation)
    {
        if (PHP_SAPI === 'cli') {
            return $invocation->proceed();
        }

        switch (true) {
            case !isset($_SERVER['PHP_AUTH_USER'], $_SERVER['PHP_AUTH_PW']):
            case $_SERVER['PHP_AUTH_USER'] !== $this->basicAuthUser:
            case $_SERVER['PHP_AUTH_PW'] !== $this->basicAuthPassword:
                header('WWW-Authenticate: Basic realm="Enter username and password."');
                header('Content-Type: text/plain; charset=utf-8');
                exit('ログインに失敗しました。');
        }

        return $invocation->proceed();
    }
}

使用例

<?php
/**
 * @BasicAuth
 */
class Index extends ResourceObject
{
    public function onGet() : ResourceObject
    {
        return $this;
    }
}

IPアドレス制限

nginxやapacheでやることかもしれません。予め設定された許可IPアドレスのCIDRブロックに応じてアクセスを拒否/許可します。 $_SERVERAura\Web\Request などに変えれば更に良いでしょう。

<?php
namespace MyVendor\MyApp\Interceptor;

use IPSet\IPSet;
use Ray\Aop\MethodInterceptor;
use Ray\Aop\MethodInvocation;
use Ray\Di\Di\Named;
class RestrictIpAddress implements MethodInterceptor
{
    /**
     * @var array
     */
    private $ipBlockList;

    /**
     * @Named("adminIpAddressBlock")
     * @param array $ipBlockList
     */
    public function __construct(array $ipBlockList = [])
    {
        $this->ipBlockList = $ipBlockList;
    }

    /**
     * @param MethodInvocation $invocation
     * @return mixed
     */
    public function invoke(MethodInvocation $invocation)
    {
        $ipSet = new IPSet($this->ipBlockList);

        if (!$ipSet->match($_SERVER['REMOTE_ADDR'])) {
            exit('アクセスが禁止されています。');
        }

        return $invocation->proceed();
    }
}

使用例

<?php

/**
 * @RestrictIpAddress
 */
class Index extends ResourceObject
{
    public function onPost() : ResourceObject
    {
        return $this;
    }
}

Resource(HTTP)系

ResourceObjectのbodyが空ならステータスコードに404を設定

公式からの転載です(どこにあったか忘れました…)。ResourceObjectのbodyになにも値が入っていなければ自動でステータスコード404を設定します。任意で200 OK(デフォルト)以外のステータスコードが設定されていた場合は上書きしません。

<?php
namespace MyVendor\MyApp;

use BEAR\Resource\Code;
use BEAR\Resource\ResourceObject;
use Ray\Aop\MethodInterceptor;
use Ray\Aop\MethodInvocation;

class ReturnNotFound implements MethodInterceptor
{
    /**
     * @param MethodInvocation $invocation
     *
     * @return ResourceObject|mixed
     */
    public function invoke(MethodInvocation $invocation)
    {
        /** @var ResourceObject $resource */
        $resource = $invocation->proceed();
        if ((! isset($resource->body) || empty($resource->body)) && $resource->code === Code::OK) {
            $resource->body = null;
            $resource->code = Code::NOT_FOUND;
        }

        return $resource;
    }
}

使用例

<?php

class Index extends ResourceObject
{
    /**
     * @ReturnNotFound
     */
    public function onGet() : ResourceObject
    {
        return $this;
    }
}

ResourceObjectに設定されたステータスコードに応じてメッセージを設定

ResourceObjectに設定されたステータスコードによって、bodyのmessageを設定します。エラーページなどにとりあえず極めて汎用的なメッセージを表示できます。有用かどうかはわかりません。

<?php
namespace MyVendor\MyApp;

use BEAR\Resource\Code;
use BEAR\Resource\ResourceObject;
use Koriym\HttpConstants\StatusCode;
use Ray\Aop\MethodInterceptor;
use Ray\Aop\MethodInvocation;

class SetBodyMessageByHttpCode implements MethodInterceptor
{
    /**
     * @param MethodInvocation $invocation
     *
     * @return ResourceObject|mixed
     */
    public function invoke(MethodInvocation $invocation)
    {
        /** @var ResourceObject $resource */
        $resource = $invocation->proceed();
        if ($resource->code === Code::OK || isset($resource->body['message'])) {
            return $resource;
        }

        $resource->body['message'] = (new StatusCode)->statusText[$resource->code];

        return $resource;
    }
}

アプリケーション系

パスワードをハッシュする

メソッドの引数として与えられたパスワード文字列をハッシュします。 password_hash 関数を使うので、使い所はユーザ登録・更新部分くらいしかなく(ログインのときに利用するのは password_verify 関数)、あまり意味がないかもしれません。
セキュリティの面で比較的シビア(ハッシュのやり方はいくらでもある)なものを、スキルも様々な開発者に対して「アノテーションを記載するとパスワードをハッシュできる」という機能を提供できるという面で意味は多少あると思います。

<?php
namespace MyVendor\MyApp\Interceptor;

use Doctrine\Common\Annotations\Reader;
use MyVendor\MyApp\Annotation\PasswordHash;
use Ray\Aop\MethodInterceptor;
use Ray\Aop\MethodInvocation;
use Ray\Di\Di\Inject;

class PasswordHash implements MethodInterceptor
{
    /**
     * @var Reader
     */
    private $annotationReader;

    /**
     * @param Reader $reader
     * @Inject
     */
    public function __construct(Reader $reader)
    {
        $this->annotationReader = $reader;
    }

    /**
     * @param MethodInvocation $invocation
     *
     * @return mixed
     */
    public function invoke(MethodInvocation $invocation)
    {
        /** @var PasswordHash $annotation */
        $annotation = $this->annotationReader->getMethodAnnotation($invocation->getMethod(), PasswordHash::class);
        $target = $annotation->value;
        $args = $invocation->getArguments();

        foreach ($invocation->getMethod()->getParameters() as $index => $parameter) {
            if ($parameter->name === $target && ! empty($args[$index])) {
                $args[$index] = password_hash($args[$index]);

                return $invocation->proceed();
            }
        }

        return $invocation->proceed();
    }
}

使用例

<?php

class Index extends ResourceObject
{
    /**
     * @PasswordHash("password")
     */
    public function onPost(string $email, string $password) : ResourceObject
    {
        ($this->insertUser)(['email' => $email, 'password' => $password]);
        return $this;
    }
}

CSRF

リクエストに正しいCSRFトークン(ここでは X-CSRF-TOKEN ヘッダ)が含まれていない場合 403 Forbidden をレスポンスして処理を中断します。SPAから送信されるフォームのCSRF対策としてどうでしょうか。

<?php
namespace MyVendor\MyApp\Interceptor;

use Aura\Session\Session;
use BEAR\Resource\Code;
use BEAR\Resource\ResourceObject;
use MyVendor\MyApp\Annotation\WebRequest;
use Ray\Aop\MethodInterceptor;
use Ray\Aop\MethodInvocation;
use Ray\AuraWebModule\AuraWebRequestInject;

class AntiCsrfInterceptor implements MethodInterceptor
{
    use AuraWebRequestInject;

    /**
     * @var Session
     */
    private $session;

    /**
     * @param Session $session
     */
    public function __construct(Session $session)
    {
        $this->session = $session;
    }

    /**
     * @param MethodInvocation $invocation
     *
     * @return ResourceObject
     * @WebRequest
     */
    public function invoke(MethodInvocation $invocation) : ResourceObject
    {
        if ($this->request->headers->get('x-csrf-token') === $this->session->getCsrfToken()->getValue()) {
            return $invocation->proceed();
        }

        /** @var ResourceObject $ro */
        $ro = $invocation->getThis();
        $ro->code = Code::FORBIDDEN;

        return $ro;
    }
}

使用例

<?php

class Index extends ResourceObject
{
    /**
     * @AntiCsrf
     */
    public function onPost() : ResourceObject
    {
        return $this;
    }
}

CSRF(おまけ)

開発版ではCSRFトークンの照合を行えない場合、以下のような何もしない InterceptorDevModule でoverrideします。例えば開発環境ではnpm serverから配信されるエントリポイントHTMLにPHPで生成されるCSRFトークンが仕込めない場合などです。
こう言ったことが安全かつ簡単にできるのはBEAR.Sundayの大きな魅力でしょう。

<?php
namespace MyVendor\MyApp;

use BEAR\Resource\ResourceObject;
use Ray\Aop\MethodInterceptor;
use Ray\Aop\MethodInvocation;

class DevAntiCsrfInterceptor implements MethodInterceptor
{
    /**
     * @param MethodInvocation $invocation
     *
     * @return ResourceObject
     */
    public function invoke(MethodInvocation $invocation) : ResourceObject
    {
        return $invocation->proceed();
    }
}

JSONデコード

例えばJSON文字列としてDBに格納されている値があった場合、値をそのまま JSONRenderer に渡してしまうと、JSON文字列がエスケープされてただの文字列としてレスポンスされてしまいます。そのようなJSON文字列をレンダラーにそのまま(JSONとして)扱ってもらうために、一旦値をデコードするインターセプターです。

<?php

namespace MyVendor\MyApp;

use BEAR\Resource\ResourceObject;
use MyVendor\MyApp\Annotation\JsonDecode as JsonDecodeAnnotation;
use Ray\Aop\MethodInterceptor;
use Ray\Aop\MethodInvocation;

class JsonDecode implements MethodInterceptor
{
    /**
     * @param MethodInvocation $invocation
     * @return ResourceObject|mixed
     */
    public function invoke(MethodInvocation $invocation)
    {
        /** @var ResourceObject $resource */
        $resource = $invocation->proceed();
        if (!isset($resource->body) || empty($resource->body)) {
            return $resource;
        }

        $annotation = $invocation->getMethod()->getAnnotation(JsonDecodeAnnotation::class)->value;
        $targets = explode(',', str_replace(' ', '', $annotation));
        $resource->body = $this->decodeJsonTargetKeyInArray($resource->body, $targets);

        return $resource;
    }

    /**
     * @param array $array
     * @param array $targets
     * @return array
     */
    private function decodeJsonTargetKeyInArray(array $array, array $targets)
    {
        if (array_values($array) === $array) {
            foreach ($array as &$value) {
                $value = $this->decodeJsonTargetKeyInArray($value, $targets);
            }
            return $array;
        }

        foreach ($targets as $target) {
            if (isset($array[$target])) {
                $array[$target] = json_decode($array[$target]);
            }
        }

        return $array;
    }
}

使用例

<?php

class Index extends ResourceObject
{
    /**
     * @JsonDecode("my_complex_setting1, my_complext_setting2")
     */
    public function onGet(int $id) : ResourceObject
    {
        $this->body = ($this->userDetail)(['id' => $id]);
        return $this;
    }
}

最後に

ここまで色々なInterceptorを紹介してきましたが、クリーンで使いやすく有用なInterceptorとそうでもなさそうなものの違いは何でしょうか?アスペクト指向に関するこちらの記事を参考に考察してみます。

Homogeneous vs. Heterogeneous

上記の記事ではAOPアドバイス(Interceptor)を2つの区分で表す文献を紹介しています。

Homogeneous aspect は複数のジョインポイントで同じアドバイスを動かすもの, Heterogeneous aspect は異なるアドバイスを動かすもの,という区別です. プログラムの実行をトレースするアスペクトなど, 同じコードをあちこちにコピーして実現するような処理は Homogeneous に分類されます.

(中略) Homogeneous crosscuts のカプセル化に対するサポートがアスペクト指向の 強みである一方で,アスペクトでは Heterogeneous crosscuts を扱いにくいと述べられています.

アプリケーションの各所で繰り返し利用可能な処理はAOPとして扱うことに向いています。反対に特異性が高く、適用範囲が限られるものに対しては当然あまり向かない(AOPにしても仕方がない)と言えるでしょう。

AOPで扱うべき関心事

AOPを学ぶとき、その処理がAOPとして扱うべき「横断的関心事」と捉えて良いのかどうか迷うかもしれません。もしくは、そのAOPアドバイス(Interceptor)がなくても処理が成り立つものでなければAOPにすべきではないと思う場合もあるかもしれません。
上記の記事では「用途による分類」についても言及しています。

Development Aspects
プログラムを開発する際,補助的に用いるアスペクトのことです. このアスペクトは,製品出荷時に取り除かれます. これに分類されるアスペクトとして, Logging, Tracing, Profiling がサンプルとして挙げられています. 今のところ,この使い方がもっともよく知られており, 逆にこの使い方しかないのではないか,との声も出ています.
Production Aspects
プログラムの中の機能を担当するアスペクトです. これに分類されるアスペクトは,システムを正常に機能させるために必要な存在です. セキュリティのチェックや例外処理などがサンプルとして挙げられます.
Runtime Aspects
実行時の性能改善を行うアスペクトです. 取り除いてもシステムの機能自体には影響を与えません. バッファリング,オブジェクトのプールやキャッシングなどがこれに当たります.

多くの人がまず想像するのは Development Aspects に類するものだと思います。プロダクションに影響しないため気軽に利用できます。
一方、Production Aspects はプロダクションに影響を及ぼします。当然そのアドバイスが存在しないとアプリケーションが動作しません。
Development Aspects では AOPはロギング程度にしか使えない となりますが、AOPの力を真に活用できるのは Production Aspects においてです。
再利用可能な共通した処理をインターセプターとして切り出し、Moduleで(差し替え可能に)構成するRay.Aopのアプローチは、結合度が下がりテスト容易性や変更に対する強度も高く保たれます。
例えばtraitやmixin(または継承)で同様のことを行おうとした場合と比べても、それは利点と言えるでしょう。

16
6
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
16
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?