Webアプリケーションをセキュアに、"Security Bundle"

[Symfony Component Advent Calendar 2022](https://qiita.com/advent-calendar/2022/symfony-component)の14日目の記事です。

## 最初に
[Symfony](https://symfony.com)はPHPのフレームワークのひとつです。しかし、公式サイトの説明文には

> Symfony is a set of PHP Components, a Web Application framework, a Philosophy, and a Community — all working together in harmony.
  （SymfonyはPHPコンポーネントのセットで、Webアプリケーションフレームワークで、哲学、そしてコミュニティです。それらがハーモニーを奏でながら動作しています。）

と書かれている通り、PHPコンポーネントのセットで、たくさんのコンポーネントを提供しており、それらを組み合わせてひとつのフレームワークとして動作しています。Symfonyのコンポーネントは、Symfony上だけで動作するのではなく、他のPHPフレームワークやアプリケーションでも動作している強力なものが揃っています。

今回はそれらの中から、役立ちそうなもの・お薦めしたいものを紹介していきたいと思います。

※記事内ではautoloadのインポートは省略します。


## Webアプリケーションをセキュアに、"Security Bundle"

[Security Bundle](https://symfony.com/doc/current/security.html)は、SymfonyにおおけるWebに関わるセキュリティ向上をサポートするコンポーネントです。特に、認証・認可の機能が有名です。

ちょっとボリュームが大きいので、今回は認証部分について紹介します。

### インストール

```shell
composer require symfony/security-bundle
```

### 認証

認証、ざっくりいうとログイン機能に関しては`Firewall`というものが用意されています。この`Firewall`には認証に関わる設定や利用するクラスを設定することで、簡単に認証機能を用意することができます。

#### 認証ユーザEntityとその他諸々の作成

まず認証ユーザを扱うためのEntityクラスと、それに関わるファイルをサクッと作ります。
[Maker Bundle](https://qiita.com/ippey_s/private/ee1a83453ee7f9e301c6)を使うことで、簡単に作成できます。
`User`クラスは`UserInterface`, `PasswordAuthenticatedUserInterface` を実装しています。

```shell
bin/console make:user
```

#### Firewall設定

`Firewall`には、主に`認証ユーザを取得する方法`, `パスワードの暗号方法`, `認証する方法`, `認証が必要な場所（正確には認可したユーザのみがアクセスできる場所）` を設定します。

```yaml:config/packages/security.yaml
security:
    # パスワードの暗号方法
    password_hashers:
        Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'

    # 認証ユーザを取得する方法
    providers:
        app_user_provider: <- 設定名
            entity:
                class: App\Entity\User
                property: email
    firewalls:
        dev: <- 設定名
            pattern: ^/(_(profiler|wdt)|css|images|js)/
            security: false
        main: <- 設定名
            lazy: true
            provider: app_user_provider <- 利用するプロバイダ名
            form_login: <- 認証方法
                login_path: app_login
            login_throttling: <- ログイン試行回数
                max_attempts: 3
            # https://symfony.com/doc/current/security/impersonating_user.html
            switch_user: true

    # 認可したユーザのみアクセスできる場所
    access_control:
        - { path: ^/admin, roles: ROLE_ADMIN }
        - { path: ^/mypage, roles: ROLE_USER }
```

##### 認証ユーザを取得する方法

`providers`に認証ユーザをどこから取得する方法を設定できます。デフォルトで4つのProviderを用意してあり、

| 設定名 | 取得箇所 |
|---|---|
| entity | DBから（Doctrineを使って） |
| ldap | LDAPサーバから |
| memory | 設定ファイルから |
| chain | プロバイダの組み合わせ |

が利用できます。主に`entity`, テストで`memory`を利用します。`chain`はちょっと特殊で、別途設定したproviderを複数設定することで、順番に取得を試みます。

```yaml:config/packages/security.yaml
security:
    providers:
        db:
            entity:
                # ...

        memory:
            memory:
                # ...
        all_users:
            chain:
                providers: ['db', 'memory'] <- `db`で取得した後、ダメだったら`memory`で取得する
```

また、`UserProviderInterface`を実装したクラスを作って、独自のProviderを設定することもできます。

```php:UserProvider.php

use Symfony\Component\Security\Core\Exception\UnsupportedUserException;
use Symfony\Component\Security\Core\Exception\UserNotFoundException;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\PasswordUpgraderInterface;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface;

class UserProvider implements UserProviderInterface, PasswordUpgraderInterface
{
    public function loadUserByIdentifier(string $identifier): UserInterface
    {
        // 入力されたIDからユーザを取得する処理（代理ログインや、パスワード忘れなどで利用）
    }

    public function refreshUser(UserInterface $user)
    {
        // セッション内のユーザを取得して妥当かチェックする処理
    }

    public function supportsClass(string $class)
    {
        // このProviderの対象クラスかチェックする処理
    }

    /**
     * Upgrades the hashed password of a user, typically for using a better hash algorithm.
     */
    public function upgradePassword(PasswordAuthenticatedUserInterface $user, string $newHashedPassword): void
    {
        // パスワードを更新する処理
    }
}
```

```yaml:config/packages/security.yaml
security:
    providers:
        my_provider:
            id: App\Security\UserProvider
```

##### パスワードの暗号方法

`password_hashers`に暗号方法を設定できます。デフォルトでは`auto`が指定されていますが、この場合最もセキュアだとSymfonyが考えるアルゴリズムを選択してくれます。自分で`sha256`, `sodium`, `plaintext`（←超危険！テストの時だけ！） などを選択できますが、必要に迫られた時だけにした方が無難です。

##### 認証する方法

`firewall`に認証する方法を設定します。上記の例である`dev`はデバッグツールに対しての設定で、`security: false`により、ここは認証しないという設定になっています。

`provider`は先ほど紹介した、ユーザを取得する方法で設定したProvider名を指定することで、認証ユーザの取得先を設定できます。

`pattern`は認証が必要な場所を設定できますが、基本的に認証をする方法が複数ある場合のみ設定します。（例：ユーザ画面と管理画面で別のProviderを使うときなど）

`switch_user`ですが、これはユーザ切り替え、いわゆる代理ログインができるようになります。

さらにどのように認証するか設定する必要がありますが、Symfonyでは`Authenticator`というものを使ってユーザ認証します。`Authenticator`はいくつか用意されているものがあり

| 設定名 | 概要 |
|---|---|
| form_login | フォームからログイン |
| json_login | APIでjsonを送ってログイン |
| http_basic | Basic認証でログイン |
| login_link | ログイン用のURLを発行しててログイン |
| access_token | アクセストークンでログイン |
| x509 | 公開鍵でログイン |
| remote_user | `REMOTE_USER`ヘッダでログイン |

さらに`AbstractAuthenticator`の子クラスを作ることで、独自の認証方法を用意することができます。
くわしくは[以前書いたこちらの記事](https://qiita.com/ippey_s/items/417812e393322f378ae2)をご参照ください。
（書いててよかった）


#### 認証したユーザを取得する

認証方法を介して認証したユーザは`Security`オブジェクトから`UserInterface`オブジェクトとして取得できます。さらに、`AbstractController`の子クラスの場合は、さらに簡単に取得できます。


```php:SomeClass.php

use Symfony\Bundle\SecurityBundle\Security;

class SomeClass
{
    public function __construct(private readonly Security $security)
    {
    }

    public function invoke()
    {
        $user = $this->security->getUser();
    }
}
```

```php:UserController.php

class UserController extends AbstractController
{
    public function detail(): Response
    {
        $user = $this->getUesr();
    }
}
```

## まとめ

今回は`Security Bundle`をご紹介しました。
ここまで（かなり端折ったのにも関わらず）長く説明してきましたが、実際使うときは**Symfonyが用意してくれた設定を選んでsecurity.yamlに書き込めばサクッと認証機能が作れる**ので、思っている以上に簡単に認証できるようになります。

他にも**認可**や**CSRF**、**LDAP**などの機能もあります。詳しくは公式ドキュメントをじっくり読んでみてください！

（思った以上にボリュームがあったので、別途認可の記事をこっそり書きます。）