Keycloakで統合Windows認証（デスクトップSSO）を適用したときの実際

# はじめに
Keycloakで統合Windows認証（以下、デスクトップSSO）を使用する案件があり、フィジビリティ検証を実施しました。今回はフィジビリティ検証の実施内容と実施結果を紹介したいと思います。

なお、Keycloakでの統合Windows認証については『[Keycloakで統合Windows認証を試してみる](https://qiita.com/yagiaoskywalker/items/4d3c1c682aba29f89056)』をご参照下さい。

# 検証したモジュールのバージョン
検証したモジュールのバージョンは以下になります。異なるバージョンでは、記事の内容が当てはまらない可能性がありますので、ご注意下さい。

- Keycloakサーバ
 - Keycloak3.4.0.Final
 - OpenJDK 1.8.0_151-b12
 - CentOS 7.4

- Active Directoryサーバ
 - Windows Server 2016

# 要件
検証内容の説明の前に、今回の案件の要件を説明します。

- Active Directory（以下、AD）を使用して、デスクトップSSOがしたい。
- ADは以下の図のように複数フォレストが信頼関係を結んでいる構成。フォレスト内には複数のドメインが存在。（マルチドメイン、マルチフォレスト）
- フォレストによってはデスクトップSSOではなく、フォーム認証をしたい。具体的には下記図のフォレスト３のユーザーはデスクトップSSOではなく、フォーム認証をさせたい。さらに、フォーム認証の際はフォレスト１のユーザーでログインさせたい。
- SSO対象のアプリにはヘッダ連携により、ADの項目を受け渡したい。
- ユーザー管理はAD側で実施し、Keycloakからユーザー情報の更新は行わない。

![AD.png](https://qiita-image-store.s3.amazonaws.com/0/208533/6df0ae05-feff-4e19-f486-e710613917d7.png)


# システム構成
検証は以下のシステム構成にて行いました。

![system.png](https://qiita-image-store.s3.amazonaws.com/0/208533/fe4a2874-91e6-6aaf-2f0a-c387d5c9fb55.png)

また、Keycloakのサービスプリンシパル名`HTTP/keycloak.example.com`を以下のドメインにて登録しています。

```bat:ドメイン：ad101.example.com
ktpass -out keycloak.HTTP.keytab.ad101 -princ HTTP/keycloak.example.com@AD101.EXAMPLE.COM -ptype KRB5_NT_PRINCIPAL -mapuser keycloak -pass P@ssw0rd
```
```bat:ドメイン：ad201.example.com
ktpass -out keycloak.HTTP.keytab.ad201 -princ HTTP/keycloak.example.com@AD201.EXAMPLE.COM -ptype KRB5_NT_PRINCIPAL -mapuser keycloak -pass P@ssw0rd
```
```bat:ドメイン：ad301.example.com
ktpass -out keycloak.HTTP.keytab.ad301 -princ HTTP/keycloak.example.com@AD301.EXAMPLE.COM -ptype KRB5_NT_PRINCIPAL -mapuser keycloak -pass P@ssw0rd
```

:information_source:[サービスプリンシパル名はフォレスト内で一意](https://msdn.microsoft.com/library/dn392337.aspx)のため、ドメイン`ad102.example.com`では実施しません。


# Kerberos認証時の動作調査
早速検証に取り掛かりたいところですが、検証の前にKerberos認証時の動作内容を把握しておこうと思います。

KeycloakでデスクトップSSOをする場合の動作概要は、[Keycloakで統合Windows認証を試してみる](https://qiita.com/yagiaoskywalker/items/4d3c1c682aba29f89056)の内容になります。しかし、以下の点についてはよく分からなかったので調査してみました。

![question.png](https://qiita-image-store.s3.amazonaws.com/0/208533/f1962a58-104e-1674-667a-5f8b4248914e.png)


## 【疑問１】kerberosプロバイダとldapプロバイダによるkerberos認証の違い
### 疑問内容
Keycloakでユーザーフェデレーションを作成する際、プロバイダは`kerberos`と`ldap`を選択できます。どちらを選択してもkerberos認証ができるようですが、何が違うのでしょうか？

![Q1.png](https://qiita-image-store.s3.amazonaws.com/0/208533/4f26caa2-a312-b2dc-8c7f-db325f70188b.png)


### 調査結果
[Keycloakのドキュメント](http://www.keycloak.org/docs/latest/server_admin/index.html#_kerberos)を見ると、以下の記述があります。

> Keycloak validates token from the browser and authenticates the user. It provisions user data from LDAP (in case of LDAPFederationProvider with Kerberos authentication support) or let user to update his profile and prefill data (in case of KerberosFederationProvider).

どうやら、認証後の動作が異なるようです。

ldapプロバイダを使用した場合はLDAPからユーザーデータをプロビジョニングし、kerberosプロバイダを使用した場合はログイン時に自身によるプロフィール更新ができるという違いがあるようです。

今回は「SSO対象のアプリにはヘッダ連携により、ADの項目を受け渡したい」という要件があるため、LDAPからユーザーデータを取得する必要があります。そのためプロバイダは「ldap」を使用する必要がありますね。


## 【疑問２】マルチドメイン構成時のサービスチケット取得処理
### 疑問内容
ユーザーがログインしているドメインにKeycloakのサービスプリンシパル名が登録されていない場合（今回の例ではドメインad102.example.com）でも、クライアントとWindowsサーバ（KDC）とのやりとりは1回だけでしょうか？それとも各Windowsサーバ（KDC）とやりとりするのでしょうか？

### 調査結果
ユーザーがログインしているドメインにKeycloakのサービスプリンシパル名が登録されている場合（ドメイン：ad101.example.com）と、登録されていない場合（ドメイン：ad102.example.com）でパケットキャプチャを取り比較してみました。

結論から言うと、『各Windowsサーバ（KDC）とやりとりする』でした。

Keycloakのサービスプリンシパル名が登録されているドメインに対してサービスチケット取得を行った場合、Keycloakのサービスチケットを要求すると、Keycloakのサービスチケットが返却されるためクライアントとWindowsサーバ（KDC）のやりとりは1回になります。

![TGS-sequence-ad101.example.com.png](https://qiita-image-store.s3.amazonaws.com/0/208533/66d2972b-e621-a6ca-0535-e912ac8a4f42.png)

しかし、Keycloakのサービスプリンシパル名が登録されていないドメインの場合、Keycloakのサービスチケットを要求すると、Keycloakのサービスプリンシパル名が登録されているドメインのTGTが返却され、そのドメインに対して再度Keycloakのサービスチケットを要求するという動作になります。つまり、クライアントとWindowsサーバ（KDC）とのやりとりは1回ではなく、各Windowsサーバ（KDC）とやりとりしていました。

![TGS-sequence-ad102.example.com.png](https://qiita-image-store.s3.amazonaws.com/0/208533/be1a30ae-66a7-30a4-d27d-a3ff51c6fedb.png)

Keycloakの動作に直接関係はない部分ですが、何か問題が発生した際にこのあたりの動きを理解しておくと役立ちますね。


## 【疑問３】サービスチケット取得後の処理
### 疑問内容
Keycloakはサービスチケット取得後、「HTTPサービスログイン」、「チケット検証」を行っているようですが、具体的に何をしているのでしょうか？

### 調査結果
Keycloakのソースコードから処理を確認しました。ドキュメントを見ても分からない場合はソースコードで確認できるというのはオープンソースのいいところですね。

ソースコードの該当箇所は`org.keycloak.federation.kerberos.impl.SPNEGOAuthenticator.java`の`authenticate`メソッドで、処理内容としては大きく２つに分けられます。

![ServiceTicketProcessing.png](https://qiita-image-store.s3.amazonaws.com/0/208533/f70db959-67f5-63be-ece0-8346f37aedd7.png)


#### ①の処理
javaのLoginModuleを実行して、LDAPプロバイダ設定で指定したサーバープリンシパル、keyTabをもとに、`javax.security.auth.Subject`を作成します。

具体的には、LDAPプロバイダ設定で指定したサーバープリンシパルをSubjectの主体セットに、LDAPプロバイダ設定で指定したkeyTabファイルから秘密鍵をSubjectのprivate資格セットに設定します。

実行するLoginModuleは、使用しているJavaがIBM製かどうかで変わります。

**■IBM製Javaではない場合**
**LoginModule**
com.sun.security.auth.module.Krb5LoginModule
 
**パラメータ**

|key|value|
|:--|:--|
|storeKey|true|
|doNotPrompt|true|
|isInitiator|false|
|useKeyTab|true|
|keyTab|プロバイダ設定で指定したKeytabファイル|
|principal|プロバイダ設定で指定したサーバープリンシパル|
|debug|プロバイダ設定で指定したデバッグ|

パラメータの意味や処理の詳細については[こちら](https://docs.oracle.com/javase/jp/8/docs/jre/api/security/jaas/spec/com/sun/security/auth/module/Krb5LoginModule.html)をご参照下さい。

**■IBM製Javaの場合**
**LoginModule**
com.ibm.security.auth.module.Krb5LoginModule
 
**パラメータ**

|key|value|
|:--|:--|
|noAddress|true|
|credsType|acceptor|
|useKeyTab|プロバイダ設定で指定したKeytabファイルのURL|
|principal|プロバイダ設定で指定したサーバープリンシパル|
|debug|プロバイダ設定で指定したデバッグ|

パラメータの意味や処理の詳細については[こちら](https://www.ibm.com/support/knowledgecenter/en/SSYKE2_8.0.0/com.ibm.java.security.api.doc/jgss/com/ibm/security/auth/module/Krb5LoginModule.html)をご参照下さい。


#### ②の処理
GSS-API（Generic Security Service Application Program Interface）を使用して、サービスチケットからユーザープリンシパル（認証済みユーザー名）を取得します。①で作成したSubjectから秘密鍵を取得してサービスチケットを復号することによりユーザープリンシパルを取得します。

また、`krb5.conf`のlibdefaultsセクションから設定`clockskew`を取得し、サービスチケットのタイムスタンプチェックを行っています。`clockskew`は、チケットのタイムスタンプとサーバーのシステム時刻が異なる場合の許容時間で、デフォルトは300秒になります。

読み込む`krb5.conf`ファイルは以下のようにサーバーOSにより異なりますが、ファイルが存在しなくてもエラーにはならずにデフォルト値で動作します。

|OS|krb5.conf|
|:--|:--|
|Windows|（Windowsディレクトリ）\krb5.ini<BR>　　または<BR>c:\\winnt\krb5.ini
|SunOS|/etc/krb5/krb5.conf|
|OS X|（ユーザーホーム）/Library/Preferences/edu.mit.Kerberos<BR>　　または<BR>/Library/Preferences/edu.mit.Kerberos<BR>　　または<BR>/etc/krb5.conf|
|その他|/etc/krb5.conf|

今回は特に設定は行わずに、デフォルトの300秒で動作させようと思います。


# Keycloak設定内容
要件の内容とKerberos認証時の動作を調査した結果から、Keycloakの設定は以下のようにします。

## ログインID
「マルチドメイン」かつ「フォーム認証を使用する」という要件のため、ログインIDにはドメイン情報が必要になります。そのためログインIDには、ADの「UserPrincipalName」を使用します。

ユーザー名のLDAP属性設定
![UserNameSetting.png](https://qiita-image-store.s3.amazonaws.com/0/208533/d235f531-6d48-c309-3d65-32f951b8e217.png)

LDAPマッパー「Username」設定
![UserNameMapperSetting.png](https://qiita-image-store.s3.amazonaws.com/0/208533/4800592b-993e-077b-f63f-2b7e8720de63.png)

## ユーザーフェデレーション設定
アプリにADの項目を連携したいため、ユーザーの属性情報を取得する必要があります。ユーザーフェデレーション作成時に、kerberosプロバイダを選択すると属性情報の取得が行われないため、属性情報の取得を行うldapプロバイダを使用します。

## LDAP接続設定
フォレスト内には複数のドメインが存在し、かつ、属性取得を行う必要があります。属性取得する際に、Keycloakから各ドメインコントローラに接続してしまうと、ユーザーフェデレーション設定をドメインコントローラの数分設定する必要があり大変です。ADにはフォレスト内にあるすべてのオブジェクトのコピーを保持する「グローバルカタログ」があるため、Keycloakからはグローバルカタログに接続するようにします。

LDAP設定
![LDAPSetting.png](https://qiita-image-store.s3.amazonaws.com/0/208533/6cb5ec40-9f2c-2414-879a-359753c23378.png)

:warning: ユーザーDNはフォレスト内の全てのドメインが含まれる値を指定する必要があります。

## 設定まとめ
フォレスト３のユーザーではログインしないため、ドメイン「ad301.example.com」の接続設定は行いません。

![keycloakSetting1.png](https://qiita-image-store.s3.amazonaws.com/0/208533/77ad806d-c8a7-d64a-3043-df9c5e142efb.png)

![keycloakSetting2.png](https://qiita-image-store.s3.amazonaws.com/0/208533/a7ef407d-67e4-193e-7f76-23db00f984aa.png)

![keycloakSetting3.png](https://qiita-image-store.s3.amazonaws.com/0/208533/15453762-c695-a5fb-adb4-40465b79b0bf.png)


# 検証内容と結果
いよいよ検証内容と結果の説明になります。検証内容の説明の前に用語の説明をしたいと思います。

## 用語定義
KeycloakとADの接続構成を下記のように定義します。

|用語|定義|対象ドメインコントローラ|
|:--|:--|:--|
|Keycloak連携有りAD|Keycloakと接続しているドメインコントローラと、そのドメインコントローラを含むフォレスト内のドメインコントローラ。|ad101.example.com<BR>ad102.example.com<BR>ad201.example.com|
|Keycloak連携無しAD|Keycloakと接続しているドメインコントローラを含まないフォレスト内のドメインコントローラ|ad301.example.com|

![testEnvironment.png](https://qiita-image-store.s3.amazonaws.com/0/208533/efa2b0d9-2a0c-1767-5028-170cacf0bf56.png)

## 検証内容と結果
接続パターン毎に要件通りにログイン可能か検証しました。検証内容と結果は以下の通りです。

![testResult.png](https://qiita-image-store.s3.amazonaws.com/0/208533/b7bf1445-8cbd-08a1-a129-c608cf1f67fe.png)


ADユーザーのパターンは全滅ですね。。。NGは4種類発生しました。それぞれのNG内容と対応方法を見ていきましょう。

# NG内容と対応

## NG①
### 画面
以下のエラー画面が出力されました。
![NG1.png](https://qiita-image-store.s3.amazonaws.com/0/208533/c642501b-9633-b054-e515-86c10f223428.png)

### エラーログ
以下のWARNログが出力されました。

```
2018-03-08 17:16:19,134 WARN  [org.keycloak.storage.ldap.LDAPStorageProvider] (default task-14) Kerberos/SPNEGO authentication succeeded with username [user101], but couldn't find or create user with federation provider [ad101]
2018-03-08 17:16:19,134 WARN  [org.keycloak.events] (default task-14) type=LOGIN_ERROR, realmId=demo, clientId=account, userId=null, ipAddress=172.26.22.198, error=invalid_user_credentials, auth_method=openid-connect, auth_type=code, response_type=code, redirect_uri=https://keycloak.example.com:8443/auth/realms/demo/account/login-redirect, code_id=cfad83fb-d620-42d6-bfdf-e9a3cad4027c, response_mode=query
2018-03-08 17:16:19,134 WARN  [org.keycloak.services] (default task-14) KC-SERVICES0013: Failed authentication: org.keycloak.authentication.AuthenticationFlowException
        at org.keycloak.authentication.DefaultAuthenticationFlow.processResult(DefaultAuthenticationFlow.java:224)
        at org.keycloak.authentication.DefaultAuthenticationFlow.processFlow(DefaultAuthenticationFlow.java:201)
        …
```

### 原因
ソースコードを見ると、Kerberos認証後のユーザー情報取得時に、チケットから取得したユーザープリンシパルから@より左側の文字列を抽出し、LDAP検索してしまっています。そのためUsernameのLDAP属性にuserPrincipalNameを指定していると「user101@ad101.example.com」で検索するところを「user101」で検索してしまい、ユーザー情報が取得できずエラーとなってしまいます。

```java:org.keycloak.federation.kerberos.impl.SPNEGOAuthenticator
    public String getAuthenticatedUsername() {
        String[] tokens = authenticatedKerberosPrincipal.split("@");
        String username = tokens[0];  // ★ここで@より左側の文字列のみを取得している
        if (!tokens[1].equalsIgnoreCase(kerberosConfig.getKerberosRealm())) {
            throw new IllegalStateException("Invalid kerberos realm. Realm from the ticket: " + tokens[1] + ", configured realm: " + kerberosConfig.getKerberosRealm());
        }
        return username;
    }
```
### 対応
チケットから取得したユーザープリンシパルをそのまま返却するよう上記ソースコードを変更します。そうすることにより、userPrincipalNameを「user101@ad101.example.com」で検索するようになるため、ユーザー情報を取得できるようになります。

しかし、この対応は完全ではありません。

チケットから取得したユーザープリンシパルの値は、ADの場合、userPrincipalNameの値ではなく、「sAMAccountNameの値@Kerberosレルムの値」です。デフォルトではuserPrincipalNameと「sAMAccountNameの値@Kerberosレルムの値」は同じ値になるのですが、異なる値に変更することが可能です。

WindowsServerのアカウント設定画面
![sAMAccountName.png](https://qiita-image-store.s3.amazonaws.com/0/208533/adb5c8dd-e9bb-c524-6603-237e2e4f934b.png)

上図の設定の場合、チケットから取得したユーザープリンシパルは「user101@ad101.example.com」ではなく「hoge@ad101.example.com」になってしまいます。

この問題に対応するには、Kerberos認証後のユーザー情報取得処理を以下のように変更します。

- ADの場合は、UsernameのLDAP属性設定を使用せず、sAMAccountNameで検索する。
- LDAPサーチDNに、チケットから取得したユーザープリンシパルの@より右側の文字列を指定する。


## NG②
### 画面
NG①と同じです。

### エラーログ
以下のWARNログが出力されました。

```
2018-03-09 13:40:47,013 WARN  [org.keycloak.services] (default task-9) KC-SERVICES0013: Failed authentication: java.lang.IllegalStateException: Invalid kerberos realm. Realm from the ticket: AD102.EXAMPLE.COM, configured realm: AD101.EXAMPLE.COM
        at org.keycloak.federation.kerberos.impl.SPNEGOAuthenticator.getAuthenticatedUsername(SPNEGOAuthenticator.java:119)
        at org.keycloak.storage.ldap.LDAPStorageProvider.authenticate(LDAPStorageProvider.java:684)
        at org.keycloak.credential.UserCredentialStoreManager.authenticate(UserCredentialStoreManager.java:282)
        …
```

### 原因
Kerberos認証後にKeycloak内のチェック処理で、チケットから取得したユーザープリンシパルの@より右側の値と、LDAPプロバイダ設定の「kerberosレルム」に指定した値が異なる場合エラーにしてしまいます。

```java:org.keycloak.federation.kerberos.impl.SPNEGOAuthenticator
    public String getAuthenticatedUsername() {
        String[] tokens = authenticatedKerberosPrincipal.split("@");
        String username = tokens[0];
        if (!tokens[1].equalsIgnoreCase(kerberosConfig.getKerberosRealm())) {  // ★ここでエラーにしてしまう
            throw new IllegalStateException("Invalid kerberos realm. Realm from the ticket: " + tokens[1] + ", configured realm: " + kerberosConfig.getKerberosRealm());
        }
        return username;
    }
```
### 対応
マルチドメイン構成の場合、このチェック処理は不要のため、チェック処理を行わないようにソースコードを変更します。
:information_source: [この対応は2017/12/21にリリースされた3.4.2.Finalで取り込まれました。](https://github.com/keycloak/keycloak/pull/4846/files)

## NG③
### 画面
NG①と同じです。

### エラーログ
NG①と同じです。

### 原因
Kerberos認証後のユーザー情報取得時に、認証時に使用したLDAPプロバイダからユーザー情報を取得できない場合、他のLDAPプロバイダからユーザー情報を取得せずにエラーにしてしまいます。つまり、他フォレストのグローバルカタログは検索しないようになっています。

```java:org.keycloak.storage.ldap.LDAPStorageProvider
    public CredentialValidationOutput authenticate(RealmModel realm, CredentialInput cred) {
        if (!(cred instanceof UserCredentialModel)) CredentialValidationOutput.failed();
        UserCredentialModel credential = (UserCredentialModel)cred;
        if (credential.getType().equals(UserCredentialModel.KERBEROS)) {
            if (kerberosConfig.isAllowKerberosAuthentication()) {
                String spnegoToken = credential.getValue();
                SPNEGOAuthenticator spnegoAuthenticator = factory.createSPNEGOAuthenticator(spnegoToken, kerberosConfig);

                spnegoAuthenticator.authenticate();

                Map<String, String> state = new HashMap<String, String>();
                if (spnegoAuthenticator.isAuthenticated()) {

                    // TODO: This assumes that LDAP "uid" is equal to kerberos principal name. Like uid "hnelson" and kerberos principal "hnelson@KEYCLOAK.ORG".
                    // Check if it's correct or if LDAP attribute for mapping kerberos principal should be available (For ApacheDS it seems to be attribute "krb5PrincipalName" but on MSAD it's likely different)
                    String username = spnegoAuthenticator.getAuthenticatedUsername();
                    UserModel user = findOrCreateAuthenticatedUser(realm, username);

                    if (user == null) {
                        logger.warnf("Kerberos/SPNEGO authentication succeeded with username [%s], but couldn't find or create user with federation provider [%s]", username, model.getName());
                        return CredentialValidationOutput.failed();  // ★ここでエラーにしてしまう
                    } else {

```

### 対応
Kerberos認証はOKにもかかわらずユーザー情報が取得できなかった場合は、他のLDAPプロバイダからユーザー情報を取得するようソースコードを変更します。

:information_source: この対応方法以外にも、ユーザー情報を取得するLDAPプロバイダを特定して実行するという方法も考えられます。

## NG④
### 画面
NG①と同じです。

### エラーログ
NG①と同じです。

### 原因
Kerberos認証後のユーザー情報取得時に、認証時に使用したLDAPプロバイダからユーザー情報を取得できない場合エラー画面を表示してしまい、フォーム認証が表示されません。
NG③は「ユーザー情報を取得できない場合に他のLDAPプロバイダで検索しない」という内容ですが、NG④は「ユーザー情報を取得できなかった場合にフォーム認証を表示しない」という違いがあります。

### 対応
この件はNG扱いにしていますが、今回の要件に対してはNGであり、本来の動作としては問題ないと考えます。

今回のように「AD側は信頼関係を結んでいるが、フォーム認証させたい」という要件への対処を考慮すると、画面上から「デスクトップSSOを除外するドメイン」を設定できるようにし、そのドメインの場合はデスクトップSSOを行わずにフォーム認証を行うようにするとよいですね。


# まとめ
検証した結果、残念ながらKeycloak.3.4.0.FinalのLDAPプロバイダは、マルチドメイン・マルチフォレスト環境でのデスクトップSSOには対応できておりませんでした。そのため、マルチドメイン、マルチフォレスト環境でLDAPプロバイダを使用したデスクトップSSOを行う場合は、ソースコード修正が必要になりますのでご注意下さい。

今回の検証結果は、Keycloakコミュニティに報告しました。[こちら](https://issues.jboss.org/browse/KEYCLOAK-7009)で熱く議論中ですので興味のある方はご注目下さい！


[![mark.png](https://github.com/k-tamura/cocha-icons/raw/master/mark.png)](https://github.com/k-tamura/cocha-icons)