Symfony2.4 で複数カラムで認証処理

# 複数項目で認証要件

ユーザーアカウントとパスワードで認証する方式は割と簡単なんだけど。
例えば、個人アカウントはユニークでは無いけど、会社等の所属組織とアカウントの組み合わせでユニークな状態の認証方法。複合ユニークキーでのアカウント認証をしたい。
これ必要ない気がするけど、実際必要で実装したのでその時の情報を。

## 要件

- companyテーブルに組織情報を格納
- company.accountカラムに組織アカウント設定
- user.accountカラムを個人アカウントに設定
+ user.account個別ではユニークキーは設定されていない
+ User.company_idカラムに外部キー設定
+ unique(company_id, account)が設定
+ ログインフォームでは 個人アカウント@会社アカウント で入力する

つまりは、会社アカウントとユーザアカウント・パスワードの３つが必要になる。

# 実装

## エンティティ

通常のゲッターセッターは省略。

```php:Company.php
# src/Acme/YourBundle/Entity/Company.php
namespace Acme\YourBundle\Entity;

use Doctrine\ORM\Mapping as ORM;

/**
 * @ORM\Entity
 * @ORM\Table(name="comany")
 */
class Company
{
    /**
     * @ORM\Id
     * @ORM\Column(name="id", type="integer")
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    private $id;

    /**
     * @ORM\Column(name="account", type="string", length=32)
     */
    private $account;

    /**
     * @ORM\Column(name="is_active", type="boolean")
     */
    private $isActive;

    /**
     * @ORM\OneToMany(targetEntity="User", mappedBy="company")
     */
    private $users;
}
```

``User`` エンティティでは ``AdbancedUserInterface`` を継承しなければならない。
インターフェースに定義されているメソッドを実装することで有効アカウントかどうかの状態を返せる。

現時点での[Symfony2日本語ドキュメント](http://docs.symfony.gr.jp/symfony2/cookbook/security/entity_provider.html "title")では ``serialize``メソッドについて触れていないけど、
`` \Serializable ``インターフェース継承してシリアライズメソッドを定義しないとエラーになった。
なので、2.4では ``Serializable`` インターフェースを継承。定義されているメソッドではidの値を含む配列をシリアライズする。アンシリアライズメソッドも同様。

```php:User.php
# src/Acme/YourBundle/Entity/User.php
namespace Acme\YourBundle\Entity;

use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\AdvancedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;

/**
 * @ORM\Entity
 * @ORM\Table(name="user")
 */
class User implements AdvancedUserInterface, \Serializable
{
    /**
     * @ORM\Id
     * @ORM\Column(name="id", type="integer")
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    private $id;

    /**
     * @ORM\Column(name="account", type="string", length=32)
     */
    private $account;

    /**
     * @ORM\Column(name="password", type="password", length=128)
     */
    private $password;

    /**
     * @ORM\Column(name="is_active", type="boolean")
     */
    private $isActive; 

    /**
     * @ORM\ManyToOne(targetEntity="Company", inversedBy="users")
     * @ORM\JoinColumn(name="company_id", referencedColumnName="id")
     */
    private $company;

    /**
     * 権限取得
     *
     * @return array
     * @see AdvancedUserInterface
     */
    public function getRoles()
    {
        return array('ROLL_USER');
    }

    /**
     * @inheritDoc
     */
    public function equals(UserInterface $user)
    {
        return $user->getAccount() === $this->account
            && $user->getCompany() === $this->company;
    }

    /**
     * @inheritDoc
     */
    public function eraseCredentials()
    {
    }

    /**
     * アカウントが期限切れになっているか判定
     *
     * @return boolean
     * @see AdvancedUserInterface
     */
    public function isAccountNonExpired()
    {
        // 有効期限切れの判定処理をここに。このサンプルでは常に期限内にした。
        return true;
    }

    /**
     * アカウントがロックされているか判定
     *
     * @return boolean
     * @see AdvancedUserInterface
     */
    public function isAccountNonLocked()
    {

        return true;
    }

    /**
     * パスワードが期限切れになっているか判定
     *
     * @return boolean
     * @see AdvancedUserInterface
     */
    public function isCredentialsNonExpired()
    {
        // 要件に合わせた期限切れ判定処理をここに。このサンプルでは常に有効にした。
        return true;
    }

    /**
     * アカウントが有効か判定
     *
     * @return boolean
     * @see AdvancedUserInterface
     */
    public function isEnabled()
    {
        // 個人と会社どちらもが有効フラグが立っていないと有効にならない
        return $this->getIsActive() == 1 && $this->getCompany()->getIsActive() == 1;
    }

    /**
     * @see \Serializable::serialize()
     */
    public function serialize()
    {
        return serialize(array($this->id,));
    }

    /**
     * @see \Serializable::unserialize()
     */
    public function unserialize($serialized)
    {
        list($this->id,) = unserialize($serialized);
    }
}
```

## リポジトリ

Symfony2の認証機能を利用するに当たり、``PersonRepository`` は ``UserProvideInterface`` を継承しなければならない。
よって、インターフェースで定義されているメソッドを今回の要件にマッチするようにすればいいわけだ。
特に、``loadUserByUsername`` メソッドで組織に所属するアカウントで検索できればよい。
検索クエリにリレーションテーブル ``company`` の条件を入れる。そしてHTMLフォームから会社アカウントと個人アカウントが取得出きるようにすれば要件が満たせる。
ポイントとしては、 ``loadUserByUsername`` メソッドが一つのPOSTデータしか受け取らないので、そこへ複数の情報を渡せる様にできるかだ。
場合によっては複数の入力フォーム項目を用意して、POST前にJSで加工した値をPOSTしてもいいんじゃないかな。

```php:PersonRepository.php
namespace Acme\YourBundle\Repository;

use Acme\YourBundle\Entity\User;

class UserRepository extends EntityRepository implements UserProviderInterface
{
    /**
     * ログインアカウント読み込み
     * @param string $username ポストされたアカウント名
     * @see UserProviderInterface
     * @return User
     */
    public function loadUserByUsername($username)
    {
        // POSTされた値のバリデーション実施
        try {
            $account = $this->validateLoadUserByUserName($username);
        } catch (\Exception $e) {
            throw new UsernameNotFoundException($e->getMessage());
        }

        $q = $this->createQueryFindByUsernameAndCompanyname($account);

        try {
            $user = $q->getSingleResult();
        } catch (\NoResultException $e) {
            throw new UsernameNotFoundException(sprintf('Unable to find a user identified by "%s".', $account['username']), null, 0, $e);
        }

        return $user;
    }

    /**
     * 認証パラメータバリデーション実行
     * @param string $username
     * @return boolean
     */
    public function validateLoadUserByUserName($username)
    {
        // ユーザアカウント@組織アカウント形式であるかバリデーション実施
        $matches = array();
        if (preg_match('/^[0-9a-z\.\-\_]+\@[0-9a-z\.\-\_]+$/i', $username, $matches) == 1) {
            return array('username' => $matches[1], 'companyname' => $matches[2]);
        }

        throw new \Exception('Validation error');
    }

    /**
     * アカウント検索クエリ作成
     * @param array $account
     * @return Query
     */
    public function createQueryFindByUsernameAndCompanyname(array $account)
    {
        return $this->createQueryBuilder('u')
            ->leftJoin('u.company', 'c')
            ->where('u.username = :username AND c.username = :companyname AND c.isActive = 1')
            ->setParameter('username', $account['username'])
            ->setParameter('companyname', $account['companyname'])
            ->getQuery();
    }

    /**
     * ログインユーザー情報更新
     *
     * @param UserInterface $user
     * @return User
     * @see UserProviderInterface
     */
    public function refreshUser(UserInterface $user)
    {
        $class = get_class($user);

        if (!$this->supportsClass($class)) {
            throw new UnsupportedUserException(sprintf('Instances of "%s" are not supported.', $class));
        }

        return $this->find($user->getId());
    }

    /**
     * 対象クラス判定
     *
     * @param string $class クラス名
     * @return boolean
     * @see UserProviderInterface
     */
    public function supportsClass($class)
    {
        return $this->getEntityName() === $class || is_subclass_of($class, $this->getEntityName());
    }
}
```

## YAML設定

実装したエンティティとリポジトリをSymfonyに設定して認証処理に利用できるようにする。
- /login でログイン画面を表示させ、ここへのアクセスには認証を必要としない。
- /login_check でSymfonyに認証処理を実行させ認証失敗時はログイン画面を表示させる
- ハッシュ化にはsha512を使用して1000回ストレッチングを行う

```yaml:security.yml
security:
  encoders:
    Acme\YourBundle\Entity\User:
      algorithm: sha512
      encode_as_base64: false
      iterations: 1000
  providers:
    main:
      entity: { class: AcmeYourBundle:User }
  firewalls:
    login:
      pattern: /login
      security: false
    secured_area:
      pattern: ^/
      form_login:
        check_path: /login_check
        login_path: /login
      logout:
        url: /logout
        target: /
```

## 最後に
　
こんな感じで要件を満たした実装ができた。
ログイン画面のコントローラーとかテンプレートとか ``UserType`` クラスとかは、公式ページと同じ作りで大丈夫なので省略。

ここで公開しているコードは、実際に稼働してるコードから公開用にコピペ＆編集したもので、テストはしていないので・・・まぁ言いたいことはわかるよな？

他にも方法がありそうな気がするが、Symfony2に触れて1ヶ月ではこの方法が最適だ。
もっとSymfonyに触れていれば、別の方法があるかもしれんなー。