MediawikiでOpenID Connect拡張とDexを組み合わせてみた

# はじめに

Ubuntu 20.04上でMediawikiにOpenIDConnect extensionをインストールして使おうとした際に遭遇したもろもろのメモです。

【2020/03/04追記】
https://github.com/dexidp/dex/pull/1473 のコミットによってDexにuserinfo_endpointが実装されました。

【2020/12/01追記】
2020/03/04に追記したようにuserinfo_endpointがサポートされました。変更からかなりの時間が経過しましたので、古い内容は削除し、新しくリリースされたLTSの1.35.0に合わせて内容を修正しています。

この記事では、[Dex](https://github.com/dexidp/dex)をOpenID ConnectのProviderとして利用し、MediawikiのOpenIDConnect拡張を利用して動いています。

## 利用したソフトウェアのバージョン等

Webサーバーはapache2を利用しています。

* OS: Ubuntu 20.04 on VMWare Workstation
* Packages: [下記の"導入しなければいけないモジュール"](#導入しなければいけないモジュール)を参照
* Mediawiki: 1.35.0 (LTSの最新版)
* Mediawiki Extensions: 1.35に対応している最新のモジュール)
  * PluggableAuth-REL1_35-2a465ae.tar.gz
  * OpenIDConnect-REL1_35-05d76c0.tar.gz

1.35.0からは、PHP 7.3.19以降を要求します。このためUbuntuのバージョンは20.04 LTSにアップグレードしています。

## 参考文献

* https://www.mediawiki.org/wiki/Extension:OpenID_Connect
* https://www.mediawiki.org/wiki/Extension:PluggableAuth
* https://developers.google.com/identity/protocols/OpenIDConnect
* https://connect2id.com/products/server/docs/api/token

## 基本的な手順

1. パッケージの導入
2. mediawikiのtar.gzアーカイブを/var/www/html等に展開
3. ($wgResourceBasePathに対応する)展開したディレクトリ直下のextensionsディレクトリに必要なExtensionのアーカイブを展開
4. LocalSettings.phpの編集
5. maintenanceディレクトリに移動し、```php update.php```コマンドを実行
6. (maintenanceから../に移動した)mediawikiを展開したディレクトリ直下で、composer.local.jsonファイルを作成
7. (5)のディレクトリで```composer update```を実行

だいたいこのような流れでしょうか。

### コマンドライン

```bash:
$ cd /var/www/html
$ sudo tar xvzf ~/mediawiki-1.35.0.tar.gz 
$ cd mediawiki-1.35.0
$ cd extensions/
$ sudo tar xvzf ~/OpenIDConnect-REL1_35-05d76c0.tar.gz
$ sudo tar xvzf ~/PluggableAuth-REL1_35-2a465ae.tar.gz
$ cd ..
$ sudo chown -R www-data:www-data  .
$ 
```

## 導入しなければいけないモジュール

Ansibleを利用してパッケージを導入しましたが、次のようなリストをwith_items:に指定しています。

```ansible:AnsibleのTaskに指定したパッケージ
  with_items:
    - apache2
    - mysql-server
    - mysql-client
    - libapache2-mod-php
    - php-mysql
    - php-mbstring
    - php-xml
    - php-curl
    - python3-pymysql
    - composer
    - unzip
```

意外なところは、unzipがなくて、composerがダウンロードしたパッケージをunpackできないところでした。
composerの依存関係に入っていないんですね…。

### ansibleによる設定の内容

mysql-serverのパッケージを導入し、mysqldが稼動した状態になったら、作業用にあらかじめALTER USERでroot@localhostに適当なパスワードを設定します。

```bash
$ ALTER USER 'root'@'localhost' IDENTIFIED WITH mysql_native_password BY 'xxxxxxxx';
```

検証用の環境設定なので作業はansibleを利用しています。
"xxxxxxxx"と"yyyyyyyy"の部分は適当なパスワードで置き換えてください。

```yaml:ansible-playbook/tasks/main.yaml
- name: setup mysql database
  become: True
  mysql_db:
    name: "mediadb"
    login_user: "root"
    login_password: "xxxxxxxx"
    state: present

- name: setup mysql user and password
  become: True
  mysql_user:
    login_user: "root"
    login_password: "xxxxxxxx"
    name: mwiki
    password: yyyyyyyy
    priv: '*.*:ALL,GRANT'
    state: present

- name: extract mediawiki-1.35.0 archive to /var/www/html
  become: True
  unarchive:
    src: "https://releases.wikimedia.org/mediawiki/1.35/mediawiki-1.35.0.tar.gz"
    dest: "/var/www/html/"
    remote_src: yes

- name: extract openid connect module to /var/www/html/mediawiki-1.31.3/extensions
  become: True
  unarchive:
    src: "{{ item }}"
    dest: "/var/www/html/mediawiki-1.35.0/extensions/"
    remote_src: yes
  with_items:
    - "https://extdist.wmflabs.org/dist/extensions/OpenIDConnect-REL1_35-05d76c0.tar.gz"
    - "https://extdist.wmflabs.org/dist/extensions/PluggableAuth-REL1_35-2a465ae.tar.gz"

- name: change owner ship
  become: True
  file:
    path: "/var/www/html/mediawiki-1.35.0"
    recurse: yes
    owner: www-data
    group: www-data
    mode: "a+rwX,go-w"
```

これで/var/www/html/以下にファイルが展開され、MySQLサーバー上に"media_db”と接続用のID, mwikiが設定されました。

ここまで設定すると、http://127.0.0.1/mediawiki-1.35.0/ でアクセスができ、初期設定が可能になります。


### 自己署名TSL接続用のファイル配置

設定自体は正規のTSL鍵でも同様ですが、外部からアクセスできない環境なのでLet's Encryptは利用できません。
ここでは自分でeasy-rsa3によって立ち上げたCA局を利用して、サーバー用PEMファイルと、KEYファイルを配置します。

Ubuntu系列の場合は、PEMファイルは /usr/share/ca-certificates/以下に適当なディレクトリを作成して配置し、/etc/ca-certificates.conf に作成したディレクトリからのエントリを追記します。

```bash:PEMファイル等の配置状況
$ $ ls /usr/share/ca-certificates/local/
oidcmediawiki.example.net.crt
```

```bash:/etc/ca-certificates.confから抜粋
$ tail -2 /etc/ca-certificates.conf
mozilla/UCA_Global_G2_Root.crt
local/oidcmediawiki.example.net.crt
```

/etc/ca-certificates.confを更新してから、update-ca-certificatesコマンドを実行すると、/etc/ssl/certs/が更新されます。

```bash:
$ sudo update-ca-certificates
Updating certificates in /etc/ssl/certs...
1 added, 0 removed; done.
Running hooks in /etc/ca-certificates/update.d...
done.

$ ls -l /etc/ssl/certs/ | grep example.net
lrwxrwxrwx 1 root root     34 Dec  1 13:40 85f6afaf.0 -> oidcmediawiki.example.net.pem
lrwxrwxrwx 1 root root     67 Dec  1 13:40 oidcmediawiki.example.net.pem -> /usr/share/ca-certificates/local/oidcmediawiki.example.net.crt
```

## mediawikiの初期設定

以前のこのドキュメントはmediawikiが動き出している前提でしたが、今回は mediawiki 1.35.0を新しいVMに構築したので初期化処理も行なっていきます。

oidcmediawiki.example.netのIPアドレスを **192.168.1.100** として、Webブラウザからアクセスします。

http://192.168.1.100/mediawiki-1.35.0/

今回はTLSの設定は後にしています。
今回のDB名はansibleで設定したように、**mediadb**、DBにアクセスするID:PWは、**mwiki:yyyyyyyy** になっています。

設定が終わると、LocalSettings.php がダウンロードできるので、oidcmediawiki.example.netの/var/www/html/mediawiki-1.35.0/ 直下に配置します。

## Apache2のTLS設定

PHPはパッケージ導入と一緒にモジュールが有効になっているはずですが、いくつかTLSに必要なモジュールが mod-enabled にないと思いますので、設定しておきます。

```bash:oidcmediawiki.example.netのVM上で行なう設定
$ sudo a2enmod ssl
$ sudo a2enmod rewrite
```

設定ファイルは、/etc/apache2/sites-available/default-ssl.conf が配置されていますが、反映されていないので、今回はこのファイルを直接編集します。
もうちょっとちゃんとしたテスト、本番環境を設定する場合には、別途ansibleなどで配布することをお勧めします。

```diff:/etc/apache2/sites-available/default-ssl.confの差分
--- default-ssl.conf.orig       2020-12-01 13:55:35.711255633 +0900
+++ default-ssl.conf    2020-12-01 13:55:45.147523460 +0900
@@ -29,8 +29,8 @@
                #   /usr/share/doc/apache2/README.Debian.gz for more info.
                #   If both key and certificate are stored in the same file, only the
                #   SSLCertificateFile directive is needed.
-               SSLCertificateFile      /etc/ssl/certs/ssl-cert-snakeoil.pem
-               SSLCertificateKeyFile /etc/ssl/private/ssl-cert-snakeoil.key
+               SSLCertificateFile      /etc/ssl/certs/oidcmediawiki.example.net.pem
+               SSLCertificateKeyFile /etc/ssl/private/oidcmediawiki.example.net.nopass.key
 
                #   Server Certificate Chain:
                #   Point SSLCertificateChainFile at a file containing the
```

```diff:/etc/apache2/sites-available/000-default.confの差分
--- 000-default.conf.orig       2020-12-01 13:58:46.908394031 +0900
+++ 000-default.conf    2020-12-01 13:58:49.188455670 +0900
@@ -1,4 +1,7 @@
 <VirtualHost *:80>
+RewriteEngine on
+RewriteCond %{HTTPS} off
+RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L] 
        # The ServerName directive sets the request scheme, hostname and port that
        # the server uses to identify itself. This is used when creating
        # redirection URLs. In the context of virtual hosts, the ServerName
```

設定を/etc/apache2/sites-enabled/ に反映させます。

```bash:
$ sudo a2ensite default-ssl
Enabling site default-ssl.
...

$ sudo systemctl restart apache2
```

初期設定時にダウンロードした LocalSettings.php を、配置したことを確認してからアクセスすると、https://にリダイレクトされます。

## LocalSettings.phpの追記内容

```php:LocalSettings.phpの最後に追記した内容
$wgGroupPermissions['*']['edit'] = false;
$wgGroupPermissions['*']['read'] = false;
$wgGroupPermissions['*']['autocreateaccount'] = true;

wfLoadExtension( 'PluggableAuth' );
$wgPluggableAuth_EnableAutoLogin = true;
$wgPluggableAuth_EnableLocalLogin = false;

wfLoadExtension( 'OpenIDConnect' );
$wgOpenIDConnect_UseEmailNameAsUserName = true;
$wgOpenIDConnect_Config['https://dex.example.com/dex'] = [
    'clientID' => 'yourclientid',
    'clientsecret' => '.........your secret..........',
    'scope' => [ 'openid','profile','email' ]
];
```

## composer.local.jsonの内容

雛形はcomposer.local.json-sampleの名前でmediawikiのtar.gzを展開した直下にあるはずなので、これをコピーして中央の1行を編集しています。

```bash:
$ sudo cp -ip composer.local.json-sample composer.local.json
```

コピーいたら composer.local.json ファイルを編集します。

```json:composer.local.json
{
        "extra": {
                "merge-plugin": {
                        "include": [
                                "extensions/OpenIDConnect/composer.json"

                        ]
                }
        }
}
```

## update.phpの実行

LocalSettings.phpの内容を更新したタイミングで、update.php等を実行します。

```bash:update.phpの実行
$ cd /var/www/html/mediawiki-1.35.0
$ sudo composer update
$ sudo php maintenance/update.php
```

update.phpを実行しないと、動作は正常に見えますが、dexからredirectした時点でエラーになります。

## Dex側でのclientidの登録など

Dex側はkubernetesで動かしているので、ConfigMapでconfig-ldap.yamlを管理しています。

ポイントはredirectURIsの登録で、```index.php/Special:PluggableAuthLogin```を指定して、次のようになっています。

httpsを利用するか、httpを利用するかは、LocalSettings.phpの wgServer の設定を確認してください。

```yaml
redirectURIs:
     - 'http://mediawiki.example.com/mediawiki-1.35.0/index.php/特別:PluggableAuthLogin'
     - 'http://mediawiki.example.com/mediawiki-1.35.0/index.php/Special:PluggableAuthLogin'
```

修正前のDexではここで日本語や記号などの%エンコードの対象となる文字列を含んでいると正常に動作しません。

この問題は後述します。

## デバッグ用の追加設定

いろいろ問題が発生するので、LocalSettings.phpの先頭に次のようなデバッグ設定を追加しています。

```php:LocalSettings.phpの冒頭
<?php
error_reporting( -1 );
ini_set( 'display_startup_errors', 1 );
ini_set( 'display_errors', 1 );
$wgDebugToolbar = true;
$wgShowExceptionDetails = true;

# This file was automatically generated by the MediaWiki 1.31.3
# installer. If you make manual changes, please keep track in case you
# need to recreate them later.
```

ページの最下部にDebug LogやConsoleなどのタブが表示されるので、そこをクリックすることでメッセージを確認することができます。

この他にも```$wgDebugLogFile```を試してみましたが、あまり有用ではなかったので、最終的に削除しています。

## composer updateに失敗した場合のエラー

しばらくunzipがない、php-curlがない、などの理由でcomposerの実行がエラーになっている事に気がつかず、依存関係が解決できなかったので、次のようなエラーが表示されていました。

```text:Loginページでのエラー表示
[7f109e2d098b727e2ab5738a] /mediawiki-1.31.3/index.php/Special:PluggableAuthLogin Error from line 131 of /var/www/html/mediawiki-1.31.3/extensions/OpenIDConnect/src/OpenIDConnect.php: Class 'Jumbojett\OpenIDConnectClient' not found

Backtrace:

#0 /var/www/html/mediawiki-1.31.3/extensions/PluggableAuth/includes/PluggableAuthLogin.php(31): OpenIDConnect->authenticate(NULL, NULL, NULL, NULL, NULL)
#1 /var/www/html/mediawiki-1.31.3/includes/specialpage/SpecialPage.php(565): PluggableAuthLogin->execute(NULL)
#2 /var/www/html/mediawiki-1.31.3/includes/specialpage/SpecialPageFactory.php(568): SpecialPage->run(NULL)
#3 /var/www/html/mediawiki-1.31.3/includes/MediaWiki.php(288): SpecialPageFactory::executePath(Title, RequestContext)
#4 /var/www/html/mediawiki-1.31.3/includes/MediaWiki.php(861): MediaWiki->performRequest()
#5 /var/www/html/mediawiki-1.31.3/includes/MediaWiki.php(524): MediaWiki->main()
#6 /var/www/html/mediawiki-1.31.3/index.php(42): MediaWiki->run()
#7 {main}
```

## dexからredirectされた後に、ログイン状態にならない

> この現象は最新のDexではuserinfo_endpointに対応しているため問題にはなりません。過去にあった事象の記録として掲載していますが、基本的にはこのセクションは無視してください。

DexからSpecial:PluggableAuthLoginにRedirectされた後、ログイン状態とならずに画面に何も表示されない状態になっています。

```text:Debugログに表示されていたエラー
[PluggableAuth] ERROR: Jumbojett\OpenIDConnectClientException: The provider userinfo_endpoint could not be fetched. Make sure your provider has a well known configuration available. in /var/www/html/mediawiki-1.31.3/vendor/jumbojett/openid-connect-php/src/OpenIDConnectClient.php:474
Stack trace:
#0 /var/www/html/mediawiki-1.31.3/vendor/jumbojett/openid-connect-php/src/OpenIDConnectClient.php(439): Jumbojett\OpenIDConnectClient->getWellKnownConfigValue('userinfo_endpoi...', NULL)
#1 /var/www/html/mediawiki-1.31.3/vendor/jumbojett/openid-connect-php/src/OpenIDConnectClient.php(917): Jumbojett\OpenIDConnectClient->getProviderConfigValue('userinfo_endpoi...')
#2 /var/www/html/mediawiki-1.31.3/extensions/OpenIDConnect/src/OpenIDConnect.php(163): Jumbojett\OpenIDConnectClient->requestUserInfo('name')
#3 /var/www/html/mediawiki-1.31.3/extensions/PluggableAuth/includes/PluggableAuthLogin.php(31): OpenIDConnect->authenticate(NULL, NULL, NULL, NULL, NULL)
#4 /var/www/html/mediawiki-1.31.3/includes/specialpage/SpecialPage.php(565): PluggableAuthLogin->execute(NULL)
#5 /var/www/html/mediawiki-1.31.3/includes/specialpage/SpecialPageFactory.php(568): SpecialPage->run(NULL)
#6 /var/www/html/mediawiki-1.31.3/includes/MediaWiki.php(288): SpecialPageFactory::executePath(Object(Title), Object(RequestContext))
#7 /var/www/html/mediawiki-1.31.3/includes/MediaWiki.php(861): MediaWiki->performRequest()
#8 /var/www/html/mediawiki-1.31.3/includes/MediaWiki.php(524): MediaWiki->main()
#9 /var/www/html/mediawiki-1.31.3/index.php(42): MediaWiki->run()
#10 {main}
```

"userinfo_endpoint"と"dex"をキーワードに検索すると、[issue#376](https://github.com/dexidp/dex/issues/376)の中で、MUSTなんだけれどdexではuserinfo_endpointに対応していない様子が分かります。

自分の環境で、https://dex.example.com/dex/.well-known/openid-configuration にアクセスすると、dexが公開している情報は次のようになっています。

```json:openid-configuration
{
  "issuer": "https://dex.example.com/dex",
  "authorization_endpoint": "https://dex.example.com/dex/auth",
  "token_endpoint": "https://dex.example.com/dex/token",
  "jwks_uri": "https://dex.example.com/dex/keys",
  "response_types_supported": [
    "code"
  ],
  "subject_types_supported": [
    "public"
  ],
  "id_token_signing_alg_values_supported": [
    "RS256"
  ],
  "scopes_supported": [
    "openid",
    "email",
    "groups",
    "profile",
    "offline_access"
  ],
  "token_endpoint_auth_methods_supported": [
    "client_secret_basic"
  ],
  "claims_supported": [
    "aud",
    "email",
    "email_verified",
    "exp",
    "iat",
    "iss",
    "locale",
    "name",
    "sub"
  ]
}
```

この一方で、https://accounts.google.com/.well-known/openid-configuration では、次のような情報が得られます。

```json:googleのopenid-configuration
{
 "issuer": "https://accounts.google.com",
 "authorization_endpoint": "https://accounts.google.com/o/oauth2/v2/auth",
 "token_endpoint": "https://oauth2.googleapis.com/token",
 "userinfo_endpoint": "https://openidconnect.googleapis.com/v1/userinfo",
 "revocation_endpoint": "https://oauth2.googleapis.com/revoke",
 "jwks_uri": "https://www.googleapis.com/oauth2/v3/certs",
 "response_types_supported": [
  "code",
  "token",
  "id_token",
  "code token",
  "code id_token",
  "token id_token",
  "code token id_token",
  "none"
 ],
 "subject_types_supported": [
  "public"
 ],
 "id_token_signing_alg_values_supported": [
  "RS256"
 ],
 "scopes_supported": [
  "openid",
  "email",
  "profile"
 ],
 "token_endpoint_auth_methods_supported": [
  "client_secret_post",
  "client_secret_basic"
 ],
 "claims_supported": [
  "aud",
  "email",
  "email_verified",
  "exp",
  "family_name",
  "given_name",
  "iat",
  "iss",
  "locale",
  "name",
  "picture",
  "sub"
 ],
 "code_challenge_methods_supported": [
  "plain",
  "S256"
 ]
}
```

気を取り直して、OpenIDConnectClient.phpのエラー元の474行目付近を確認すると、ここでは関数の呼び出し時に指定されたendpointの情報があるか or ないかでエラーを出しているだけなので、917行目を確認すると、userinfo_endpointを明示的に指定しています。

どんな情報を利用しているのか、呼び出し元のOpenIDConnect.phpの163行目付近を確認してみると、要求しているのは name, email, sub のみで、これなら認証した時の$claimsに格納されているよね、と思いました。

そこで、少しコードを変更して、比較のため元のコードは一応残していますが、実際には認証時のclaimsを参照するようにしました。

```diff:OpenIDConnect.phpの差分
--- OpenIDConnect.php.orig      2019-06-18 04:14:42.000000000 +0900
+++ OpenIDConnect.php   2019-08-07 15:52:53.728532724 +0900
@@ -160,9 +160,9 @@
                        wfDebugLog( 'OpenID Connect', 'Redirect URL: ' . $redirectURL );
                        if ( $oidc->authenticate() ) {
 
-                               $realname = $oidc->requestUserInfo( 'name' );
-                               $email = $oidc->requestUserInfo( 'email' );
-                               $this->subject = $oidc->requestUserInfo( 'sub' );
+                               $realname = $oidc->getVerifiedClaims( 'name') or $oidc->requestUserInfo( 'name' );
+                               $email = $oidc->getVerifiedClaims( 'email' ) or $oidc->requestUserInfo( 'email' );
+                               $this->subject = $oidc->getVerifiedClaims( 'sub' ) or $oidc->requestUserInfo( 'sub' );
                                $this->issuer = $oidc->getProviderURL();
                                wfDebugLog( 'OpenID Connect', 'Real name: ' . $realname .
                                        ', Email: ' . $email . ', Subject: ' . $this->subject .
@@ -318,7 +318,7 @@
                        $preferred_username =
                                $oidc->requestUserInfo( $config['preferred_username'] );
                } else {
-                       $preferred_username = $oidc->requestUserInfo( 'preferred_username' );
+                       $preferred_username = $oidc->getVerifiedClaims( 'preferred_username' ) or $oidc->requestUserInfo( 'preferred_username' );
                }
                if ( strlen( $preferred_username ) > 0 ) {
                        $preferred_username = $preferred_username;
```

一応これでログインできるようになりました。