LaravelアプリケーションにSAML認証方式のシングルサインオンログインを実装する(ライブラリ編)

これは[株式会社マイホム アドベントカレンダー2022](https://qiita.com/advent-calendar/2022/myhm)の17日目の記事です。
記事の内容は会社の業務とは一切関係ありません。

2024.08 追記
記事投稿当時 Microsoft Azure Active Directory の名称だったサービスは、現在 [Microsoft Entra IDに名称が変わっています。](https://learn.microsoft.com/ja-jp/entra/fundamentals/new-name)
以下の記事内ではサービス名を読み替えてください。

## 概要

LaravelアプリケーションにSAML認証方式によるシングルサインオンでログインする実装例です。

## 前提

- Laravel及びスターターキットのLaravel Breezeをインストール済みで、ログイン可能なユーザーも登録済みであること。`https://localhost/`にブラウザアクセス可能な状態となっていること
- Laravel Breezeのセットアップ等については公式、有志邦訳ドキュメントをはじめとして参考例が多数ありますので省略します
- Identity Provider(IdP)にはMicrosoft Azure ActiveDirectory(AzureAD)を使用します
    - 本記事のIdP側の解説ベースはAzureADですが、SAML認証方式で扱う情報は標準化されていますので、Google Workspace(旧G Suite)を使用しても設定する内容は基本的に変わりません
- 本記事執筆時点では下記のバージョンを使用しています
```
laravel/framework: 9.43.0
24slides/laravel-saml2: 2.0.11
```

## 手順

OneLogin toolkitをベースに作られた[24Slides/laravel-saml2](https://github.com/24Slides/laravel-saml2)というライブラリを使用します。

### 1. インストール

```shell
composer require 24slides/laravel-saml2
```

### 2. configファイル展開とミドルウェア設定

```shell
php artisan vendor:publish --provider="Slides\Saml2\ServiceProvider"
```

`config/saml2.php`ファイルが作成されます。
ライブラリのREADMEとcofigファイル内にも説明がかかれていますが、コアな部分を簡単に解説します。

```php:config/saml2.php
return [

    /*
    |--------------------------------------------------------------------------
    | Tenant Model
    |--------------------------------------------------------------------------
    |
    | ここを変更することでテナントモデルを独自のものにオーバーライドできます
    |
    */

    'tenantModel' => \Slides\Saml2\Models\Tenant::class,

    /*
    |--------------------------------------------------------------------------
    | Use built-in routes
    |--------------------------------------------------------------------------
    |
    | trueに設定するとライブラリから提供されるビルトインルートです。
    |
    | Method | URI                               | Name
    | -------|-----------------------------------|------------------
    | POST   | {routesPrefix}/{uuid}/acs       | saml.acs
    | GET    | {routesPrefix}/{uuid}/login     | saml.login
    | GET    | {routesPrefix}/{uuid}/logout    | saml.logout
    | GET    | {routesPrefix}/{uuid}/metadata  | saml.metadata
    | GET    | {routesPrefix}/{uuid}/sls       | saml.sls
    |
    */

    'useRoutes' => true,

    /*
    |--------------------------------------------------------------------------
    | Built-in routes prefix
    |--------------------------------------------------------------------------
    |
    | ライブラリのビルトインルートで使用するプレフィックス。
    |
    */

    'routesPrefix' => '/saml2',

```

ほぼコメントのままですが、展開された初期状態でライブラリからビルトインのルートが提供されるようになっています。
上記の設定なら`https://localhost/saml2/{uuid}/acs`のような感じです。
コントローラをカスタマイズしたいなどの場合は、ビルトインルートをやめて独自に定義することもできます。
ただし、デフォルトのコントローラの動作を変える場合、脆弱性を作り込まないように注意する必要があります。よくわからない場合は無闇に改造しないようにしましょう。

使い始めるにあたってもう一つ重要なオプションが下記のビルトインルートにかかるミドルウェアグループの指定です。
初期状態では`[]`と未指定になっていますので、以下のように変更します。

```php:config/saml2.php
'routesMiddleware' => ['saml'],
```

次にREADMEを参考に`app/Http/Kernel.php`でミドルウェアグループを追加定義します。
グループ名はconfigで指定した名称と合わせます。

```php:app/Http/Kernel.php
    protected $middlewareGroups = [
        // 省略

        'saml' => [
            \App\Http\Middleware\EncryptCookies::class,
            \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
            \Illuminate\Session\Middleware\StartSession::class,
        ],
    ];
```

最低限必要なミドルウェアは`StartSession`ですが、アプリケーションの都合で追加しても大丈夫です。
ただし、`VerifyCsrfToken`ミドルウェアは追加しないようにします。
大雑把にいうとSAMLは意図的にCSRFさせているようなものでもあるため、このミドルウェアで保護すると動作を阻害してしまいます。

その他多くのオプションを[OneLogin Toolkit](https://github.com/onelogin/php-saml)から引き継いでいますので、必要に応じてそちらも確認してください。

### 3. マイグレーション

```shell
php artisan migrate
```

ライブラリのマイグレーションから`saml2_tenants`テーブルが作成されます。

### この後の作業にあたっての注意点

こちらのライブラリの使用にあたり、困りごとが一つ。
ライブラリではIdPを**テナント**という単位で管理するのですが、ライブラリが提供するテナント登録コマンド`saml2:create-tenant`ではいくつかの必須オプションがあり、ブランクも入力不可となるように制限されています。
しかもそれらの値はIdPの設定を進めないと登録すべき値が取得できません。
ところが！
IdPで設定を進めようにも、こちらはこちらでSPでテナント登録を行うと取得できる値を要求してきます。(IdPがGSuiteでも同じような状況です)

![2022-12-14_16-58-26.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/260612/1e40ff29-5cd7-973d-43cc-a69aced7b1ed.png)

初手からデッドロック状態なので登録コマンドを自作してしまいましょう。
(コマンド作るの面倒という方はIdP側の必須項目を仮の値で進めて、テナント登録に必要な証明書などを先に取得してしまうという手順で回避もする方法もあります)

### テナント登録コマンドの作成と実行

オリジナルのコマンドは`vendor/24slides/laravel-saml2/src/Commands/CreateTenant.php`で内容を見てみると、やっていることはuuidの生成と値の整形程度でほぼDB登録するだけのお仕事です。自作といっても簡単な改造程度です。

```shell
php artisan make:command CreateTenant
```

```php:app/Console/Commands/CreateTenant.php
<?php

namespace App\Console\Commands;

use Slides\Saml2\Commands\CreateTenant as Origin;
use Slides\Saml2\Helpers\ConsoleHelper;

class CreateTenant extends Origin
{
    /**
     * The name and signature of the console command.
     *
     * @var string
     */
    protected $signature = 'saml2:create-tenant-custom
                            { --k|key= : A tenant custom key }
                            { --entityId= : IdP Issuer URL }
                            { --loginUrl= : IdP Sign on URL }
                            { --logoutUrl= : IdP Logout URL }
                            { --relayStateUrl= : Redirection URL after successful login }
                            { --nameIdFormat= : Name ID Format ("persistent" by default) }
                            { --x509cert= : x509 certificate (base64) }
                            { --metadata= : A custom metadata }';

    /**
     * The console command description.
     *
     * @var string
     */
    protected $description = 'テナント登録を行い、Entity ID、各種ルートを表示する';


    /**
     * Execute the console command.
     *
     * @return int
     */
    public function handle()
    {
        // DBの制約でnullが許可されていないので空文字を初期値に設定
        $entityId = $this->option('entityId') ?? '';
        $loginUrl = $this->option('loginUrl') ?? '';
        $logoutUrl = $this->option('logoutUrl') ?? '';
        $x509cert = $this->option('x509cert') ?? '';
        $key = $this->option('key'); // null OK
        $metadata = ConsoleHelper::stringToArray($this->option('metadata'));

        if($key && ($tenant = $this->tenants->findByKey($key))) {
            $this->renderTenants($tenant, 'Already found tenant(s) using this key');
            $this->error(
                'Cannot create a tenant because the key is already being associated with other tenants.'
                . PHP_EOL . 'Firstly, delete tenant(s) or try to create with another with another key.'
            );

            return 0;
        }

        $tenant = new \Slides\Saml2\Models\Tenant([
            'key' => $key,
            'uuid' => \Ramsey\Uuid\Uuid::uuid4(),
            'idp_entity_id' => $entityId,
            'idp_login_url' => $loginUrl,
            'idp_logout_url' => $logoutUrl,
            'idp_x509_cert' => $x509cert,
            'relay_state_url' => $this->option('relayStateUrl'),
            'name_id_format' => $this->resolveNameIdFormat(),
            'metadata' => $metadata,
        ]);

        if(!$tenant->save()) {
            $this->error('Tenant cannot be saved.');
            return 0;
        }

        $this->info("The tenant #{$tenant->id} ({$tenant->uuid}) was successfully created.");

        $this->renderTenantCredentials($tenant);

        $this->output->newLine();
        return 0;
    }
}
```

コマンド実行します。
`relayStateUrl`オプションは省略できますが、この時点でログイン成功後のリダイレクト先が決まっているのであれば指定しましょう。
(メモ代わりなので`key`オプションはそもそも無くてもOKです)

```shell
php artisan saml2:create-tenant-custom --relayStateUrl="/dashboard" --key=azure
```

するとこのようにIdPに設定するための情報を取得することができます。

![2022-12-18_00-21-37.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/260612/b37874d6-2cd0-8c7f-27f9-1c6bd7a2505b.png)

長くなるのでこの記事は一旦ここまでで区切ります。
次回の記事でIdPの設定について解説していきます。