テストサーバ立ててサブドメイン割り当ててBasic――ではなくSAML認証でSSO(Keycloak)に組み込む

# 概要

Apacheのmod_auth_mellonモジュールとKeycloakを使用し、静的WebサイトやCMSテンプレート等の開発でありがちな「サブドメインを割り当てた案件/用途別テストサーバ」をSAML認証でSSOに組み込む。

本稿ではサブドメイン毎に個別のSP（サービスプロバイダ、Keycloakにおけるクライアント）に分割せず、**複数のサブドメインを一つのSPで扱う**。これにより運用フェーズにおいてKeycloakのクライアント設定の操作が不要になり[^1]、**設定手順をBasic認証並に簡略化**できる。

- アクセス許可の条件は「アカウントを持っているかどうか」からSAML属性と正規表現を使った複雑なものまで柔軟に設定可能
- 条件はApacheのLocationディレクティブ毎に指定できる
- サブドメインの追加はApache側の設定だけで完結
- SAML属性はApacheの環境変数に入る

[^1]: 操作で事故ると甚大な被害が生じる可能性がある

https://github.com/latchset/mod_auth_mellon

## 作業環境

- RHEL8互換
- Keycloak (12.0.4)
    - 稼働中のもの
- Apache (2.4.46)
    - Docker公式イメージ（Debianベース）、非SSL（リバースプロキシでSSLオフロードする構成のため）
- libapache2-mod-auth-mellon (0.17.0-1)
- Docker (20.10.6), docker-compose (1.29.1)

## ファイル構成

あくまで一例として。confや配信ファイルの配置はお好みで。カッコ内は対応する手順を示す。

```plaintext
httpd
|-- Dockerfile (1-1)
|-- docker-compose.yml (1-2)
`-- mount
    |-- httpd.conf
    |-- conf.d
    |   `-- mellon.conf (1-4)
    |-- mellon
    |   |-- <CLIENT_ID>.cert (1-3)
    |   |-- <CLIENT_ID>.key (1-3)
    |   |-- <CLIENT_ID>.xml (1-3)
    |   |-- client-cert.pem (2-2)
    |   `-- idp-metadata.xml (2-2)
    `-- vhost.d
        |-- include
        |   `-- mellon.conf (3-1)
        |-- metadata
        |   |-- subdomain1.xml (3-2-1)
        |   |-- subdomain2.xml (3-2-1)
        |   `-- subdomain3.xml (3-2-1)
        |-- subdomain1.conf (3-2-2)
        |-- subdomain2.conf (3-2-2)
        `-- subdomain3.conf (3-2-2)
```

# 1. Apache

mod_auth_mellonモジュールが動けばなんでもよい。

## 1-1. Dockerイメージ

Apacheの公式イメージには `mod_auth_mellon` モジュールが入っていないため、latestタグ（Debianベース）を元にモジュールをインストールするだけのDockerfileを用意する。

```dockerfile:Dockerfile
FROM httpd:latest

RUN apt-get update && \
    apt-get install -y libapache2-mod-auth-mellon && \
    apt-get clean
```

- [パッケージ詳細](https://packages.debian.org/sid/libapache2-mod-auth-mellon)

## 1-2. Composeファイル

設定ファイルと配信するファイルをマウントして起動するだけのもの。

```yaml:docker-compose.yml
version: '3.8'

services:
  httpd:
    image: <DOMAIN>/httpd:<VERSION>
    build:
      context: .
    container_name: httpd
    hostname: httpd-server
    environment:
      - TZ=Asia/Tokyo
    volumes:
      - ./mount/httpd.conf:/usr/local/apache2/conf/httpd.conf
      - ./mount/conf.d:/usr/local/apache2/conf.d
      - ./mount/vhost.d:/usr/local/apache2/vhost.d
      - ./mount/mellon:/usr/local/apache2/mellon
      - /var/www/html:/var/www/html
    expose:
      - '80'
  restart: always
  networks:
    - container-link
  logging:
    options:
      max-size: "10m"
      max-file: "3"

networks:
  default:
    external:
      name: bridge
  container-link:
    name: docker.internal
```

- `<DOMAIN>` は自家製イメージだとわかる文字列を適当に設定すればよい
- `<VERSION>` もなんでも構わないが、その時点のApacheのバージョンを入れておくと更新の確認に便利
- コンテナ間リンク上のリバースプロキシでSSLオフロードしているため非SSLかつ外向きには閉じている
    - 単独動作時は `expose` を適宜 `ports` に読み替えのこと

## 1-3. 証明書・鍵・メタデータ

Apacheのコンテナ上（または依存関係をインストールした環境）でmod_auth_mellon公式が用意している生成用シェルスクリプト `mellon_create_metadata.sh` を実行する。

以下はコンテナに入って生成して取り出す場合の手順。

```bash:shell
docker exec -it httpd /bin/bash

# ここからApacheコンテナ内
apt-get install -y wget
cd /tmp
wget https://raw.githubusercontent.com/latchset/mod_auth_mellon/master/mellon_create_metadata.sh
chmod +x mellon_create_metadata.sh
./mellon_create_metadata.sh <CLIENT_ID> https://*.<DOMAIN>/mellon
exit
# Apacheコンテナ内ここまで

docker cp httpd:/tmp/<CLIENT_ID>.key /path/to/httpd/mount/mellon
docker cp httpd:/tmp/<CLIENT_ID>.cert /path/to/httpd/mount/mellon
docker cp httpd:/tmp/<CLIENT_ID>.xml /path/to/httpd/mount/mellon
```

- `<CLIENT_ID>` はSP名（Keycloakにおける「クライアントID」）
    - `/` や `:` などが含まれる場合、ファイル名ではアンダースコアに変換される（ `mellon_create_metadata.sh` の出力するログを参照のこと）
- `<DOMAIN>` は使用するドメイン

生成されるファイルは以下の通り。

- `<CLIENT_ID>.key` : SP鍵, `<CLIENT_ID>.cert` : SP証明書
    - mod_auth_mellonが使用する
- `<CLIENT_ID>.xml` : SPメタデータ
    - Keycloakのクライアント設定にインポートする
    - サブドメイン毎にコピーし一部を書き換えたものをmod_auth_mellonが使用する

## 1-4. mod_auth_mellonグローバル設定

`LoadModule` でモジュールを読み込み、サーバ共通のグローバル設定を行う。

[モジュール公式リポジトリのReadme](https://github.com/latchset/mod_auth_mellon/blob/master/README.md)で大半を占めているサンプル設定の記述の内、上の方にある「Global configuration」の部分に項目と説明が記載されている。

```conf:mellon.conf
LoadModule auth_mellon_module /usr/lib/apache2/modules/mod_auth_mellon.so

<IfModule auth_mellon_module>
    # MellonCacheSize 100
    # MellonCacheEntrySize 196608
    # MellonLockFile "/var/run/mod_auth_mellon.lock"
    # MellonPostDirectory "/var/cache/mod_auth_mellon_postdata" # Default: None
    # MellonPostTTL 900
    # MellonPostSize 1048576
    # MellonPostCount 100
    # MellonDiagnosticsFile logs/mellon_diagnostics
    # MellonDiagnosticsEnable Off
</IfModule>
```

- 本稿では `httpd.conf` でimportする想定（各自のポリシーに沿って適宜読み替えのこと）
- サーバ共通の設定であり、ディレクティブの外に記述する
- 特にデフォルト値から変更する必要はないと思われる
    - 利用人数が多い場合に `MellonCacheSize` の調整が必要になる程度？
- 今回の導入方法ではDiagnostics関連の設定項目は存在しているだけでエラーを吐く
    - 依存関係がインストールされていないため

# 2. Keycloak

Keycloakの公式ドキュメントに[mad_auth_mellonの導入ガイド](https://www.keycloak.org/docs/latest/securing_apps/#_mod_auth_mellon)があるので参考に。

## 2-1. クライアント設定

1. 使用するレルムのクライアント設定の権限を持つユーザーで管理コンソールにログインし、サイドメニューから「クライアント」→右側の「作成」ボタンと進む
1. 「ファイルを選択」ボタンを押し、ファイル選択ダイアログで手順1-3で作成した `<CLIENT_ID>.xml` を選択してから「保存」（クライアントIDは変更不可）
1. 「有効なリダイレクトURI」を `*` に変更する
1. 「SAMLエンドポイントの詳細設定」アコーディオン配下の入力欄を全て空にする
1. 「保存」する

<details><summary>修正後の設定内容</summary>
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63934/626dab5a-80fe-0b31-51b3-909d2d580685.png)
</details>

## 2-2. メタデータと証明書の取得

1. 手順2-1を完了したら「インストール」タブに移動する
1. フォーマット・オプションで「Mod Auth Mellon files」を選択
1. 出現した「ダウンロード」ボタンを押してzipファイルをダウンロードする
1. zipに含まれる `client-cert.pem` と `idp-metadata.xml` をApacheで使用する

# 3. VirtualHost

サブドメイン（≒VirtualHost）の追加時に必要な作業はこの項の `3-2` のみで、Keycloakを触る必要はない。（アクセス制御のためにマッパーやユーザー情報を弄る必要がある場合は別として）

## 3-1. mod_auth_mellon共通設定

VirtualHostにおける共通の設定項目。これまでの手順で取得したファイル類のうち、共通で読み込むものの指定。

```conf:mellon.conf
MellonSPPrivateKeyFile /usr/local/apache2/mellon/<CLIENT_ID>.key
MellonSPCertFile /usr/local/apache2/mellon/<CLIENT_ID>.cert
MellonIdPMetadataFile /usr/local/apache2/mellon/idp-metadata.xml
MellonIdPCAFile /usr/local/apache2/mellon/client-cert.pem
```

- 本稿では各VirtualHostのconfでimportする想定（各自のポリシーに沿って適宜読み替えのこと）
- 詳細は[モジュール公式リポジトリのReadme](https://github.com/latchset/mod_auth_mellon/blob/master/README.md)を参照のこと

## 3-2. 各VirtualHostの設定

### 3-2-1. mod_auth_mellon用メタデータ

手順1-3で取得したメタデータ `<CLIENT_ID>.xml` をコピーし、使用するサブドメインに合わせて `<SUBDOMAIN>` の部分を修正する。

```xml:SUBDOMAIN.xml
<EntityDescriptor entityID="<CLIENT_ID>" xmlns="urn:oasis:names:tc:SAML:2.0:metadata" xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
  <SPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol" AuthnRequestsSigned="true">
    <KeyDescriptor use="signing">
      <ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
        <ds:X509Data>
          <ds:X509Certificate>MIID......</ds:X509Certificate>
        </ds:X509Data>
      </ds:KeyInfo>
    </KeyDescriptor>
    <SingleLogoutService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="https://<SUBDOMAIN>.<DOMAIN>/mellon/logout"/>
    <AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://<SUBDOMAIN>.<DOMAIN>/mellon/postResponse" index="0"/>
  </SPSSODescriptor>
</EntityDescriptor>
```

### 3-2-2. conf

概ね `AuthType Basic` が `AuthType Mellon` になるだけで、他は普段通りでよい。

```conf:SUBDOMAIN.conf
<VirtualHost *:80>
  DocumentRoot /var/www/html/<SUBDOMAIN>
  ServerName https://<SUBDOMAIN>.<DOMAIN>

  <Location />
    AllowOverride none

    # ここからmod_auth_moellonの設定
    Include vhost.d/include/mellon.conf
    MellonSPMetadataFile vhost.d/metadata/<SUBDOMAIN>.xml

    AuthType Mellon
    MellonEnable auth
    Require valid-user
  </Location>
</VirtualHost>
```

- この設定ではルート配下が全てアクセス制御の対象となり、Keycloak上にアカウントが存在していれば閲覧できる
- `ServerName` には実際にアクセスする時のプロトコルを含める
    - プロトコルを省略するとmod_auth_mellonがリダイレクトURLを生成する際にApacheの設定を元に補完されるが、本稿の構成ではApacheは非SSLのため、SSLの表側と一致せずエラーになる
- 設定項目の詳細は[モジュール公式リポジトリのReadme](https://github.com/latchset/mod_auth_mellon/blob/master/README.md)を参照のこと

# 4. アクセス制御

## 4-1. Keycloak側（マッパー）

アクセス制御に使用するKeycloakのユーザー情報（プロパティ・属性・グループ・ロール等）は、Keycloakのクライアント設定でマッパーを作成して受け渡す。

SAMLとKeycloakの仕様の範囲内であればなんでも渡せて、JavaScriptマッパーで加工もできるため、mod_auth_mellon側の制限や機能も勘案しつつ使いやすい形でフォーマットを整える。

### 例

「閲覧可能なサブドメインをカンマ区切りテキストで指定する」という運用ルールを想定した `subdomain` 属性をユーザー属性に追加。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63934/77772a2c-5cf3-d3e1-216a-e44af651e872.png)

`subdomain` という属性名で送信するマッパーを作成。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63934/df9e92d5-bca0-3020-6806-b76a896fac7a.png)

認証成功時にmod_auth_mellonがこのSAML属性を受け取ると、内容がApache環境変数 `MELLON_subdomain` に格納される。mod_auth_mellonのディレクティブからは属性名のまま扱える。（環境変数の接頭辞は変更可能）

※mod_auth_mellon側での使用例は後述

## 4-2. Apache側（conf）

mod_auth_mellonの `MellonRequire` と `Melloncond` ディレクティブを使用する。

### 4-2-3. MellonRequireディレクティブ

「特定のSAML属性の値が1個以上の列挙した値のいずれかに一致するかどうか」による判定を行う。

```conf
MellonRequire <SAML_ATTR> <VALID_VALUE_1> [<VALID_VALUE_2> ...]
```

- MellonRequireは複数記述することができる
    - 複数のMellonRequireは**AND条件**として処理される
- 同じ属性名を指定したMellonRequireが複数ある場合は最後に記述されたものが使用される

例えば「属性名 `role` の値が `admin` か `moderator` なら許可する」であれば以下のようになる。

```conf
MellonRequire "role" "admin" "moderator"
```

### 4-2-2. MellonCondディレクティブ

特定のSAML属性の値と、特定の値ないし正規表現との一致・不一致その他による判定を行う。

```conf
MellonCond <SAML_ATTR> <VALUE> [[<OPTION>, ...]]
```

- MellonCondは複数記述することができる
- オプションは大括弧で囲う
- 詳しくは[モジュール公式リポジトリのReadme](https://github.com/latchset/mod_auth_mellon/blob/master/README.md)を参照のこと

#### VALUEで展開できる変数

<dl>
<dt>%n, %{num}</dt>
<dd>正規表現使用時のマッチ結果の参照、1桁は <code>%n</code> 、2桁以上は <code>%{num}</code></dd>
<dt>%{ENV:env_name}</dt>
<dd>Apache環境変数の展開</dd>
</dl>

`%` をエスケープしたい場合は `%%` とする

#### オプション

<dl>
<dt>OR</dt>
<dd>そのMellonCondの評価結果がfalseだった場合、次のMellonCondを評価する</dd>
<dt>NOT</dt>
<dd>評価結果を反転する（値と一致しない場合にtrueを返す）</dd>
<dt>SUB</dt>
<dd>一致条件を部分一致にする</dd>
<dt>REG</dt>
<dd>VALUEを正規表現として扱い評価する（正規表現はApacheの <code>LocationMatch</code> 互換？）</dd>
<dt>NC</dt>
<dd>大文字・小文字を区別しない</dd>
<dt>MAP</dt>
<dd>MellonSetEnvによってリマップされた属性名を探して判定する？（詳細不明）</dd>
<dt>REF</dt>
<dd>REGと同時に使用し、次のMellonCondにおけるマッチ結果の参照に今回のマッチ結果を利用する？（詳細不明）</dd>
</dl>

### 例

手順4の例で設定したカンマ区切りテキストを格納しているSAML属性 `subdomain` に、 `asdf-preview` に一致するフィールドが含まれていたらアクセスを許可する。

```conf
MellonCond "subdomain" "(^|,)asdf-preview(,|$)" [REG]
```

# Tips

## Apache側の認証用エンドポイント

標準では `/mellon/*` が各種エンドポイントとして使用されるため、`/mellon` とその配下に実体ファイルを置くことはできない。

変更する場合は `MellonEndpointPath` ディレクティブを使用し、手順3-2-1のmod_auth_mellon用メタデータ内のエンドポイントを合わせる。

## 参考

- [One Service Provider and multiple Apache VirtualHosts?](https://github.com/Uninett/mod_auth_mellon/issues/185)
    - 単一SPマルチサブドメイン行けそうかも、と思った発端
- [mod_auth_mellon を使ってみた](https://qiita.com/aimoto/items/89ba104db85a2b89fa67)
- [【Keycloak】Apache の VirtualHost で分けられた複数のサイトをまとめてシングルサインオンしよう](https://qiita.com/thirdpenguin/items/1136c755560eea51b5b1)