はじめに
組織のID管理はLDAPで行われているので、参照権限を貰って手元でDexをk8sで動かしてOIDCのIPを運用しています。
これまでWebの静的コンテンツについては組織内で限られたグループにアクセスを許可するため、apache2(apache httpd)とmod_auth_ldapを使って認証していました。
静的コンテンツを表示するだけであれば、他の仕組みがあるだろうという気もするのですが、いにしえのApache2とBASIC認証による制御はシンプルで分かりやすい反面、セキュリティ的には少し脆弱なのとUXもあまり良くないと思われたのでOIDC認証に切り替えることにしました。
OIDCは少し面倒な気持ちがしていたのですが、クリスマスも近いので時間を使って挑戦してみることにしました。
最終的にはalpine linuxのコンテナで稼動させますが、とりあえずubuntu 24.04を利用して慣れてからalpine linuxに載せ換えていきます。
参考資料
mod_auth_openidcで検索した次の記事を参考にしました。
また今回テストしたDexはPostgreSQLをstorageに指定し、API経由で動的に構成を変更できるようにしたものをテストを兼ねて利用しています。こちらの情報は別の記事にまとめています。
mod_auth_openidc用のRequire行の書式については次の公式ドキュメントが参考になりました。
特にClaimsのJSONオブジェクトに配列が含まれている場合にはセパレーターが":"になる点だけ注意すれば、ほぼほぼClaims JSONの構造が分かっていれば柔軟な指定ができると思います。
準備作業
VMware上のUbuntu 24.04 Serverを使ってテスト環境を構築しました。
まず必要なパッケージを入れます。
$ sudo apt update
$ sudo apt install apache2 libapache2-mod-auth-openidc
ファイルが配置されているPATHを確認します。
$ dpkg -L libapache2-mod-auth-openidc
...
/etc/apache2/conf-available/auth_openidc.conf
/etc/apache2/mods-available/auth_openidc.load
...
/usr/lib/apache2/modules/mod_auth_openidc.so
...
/etc/apache2/*-enabled
ディレクトリに配置されているファイルを確認します。
$ ls -l /etc/apache2/*-enabled/*idc*
lrwxrwxrwx 1 root root 35 Dec 20 00:46 /etc/apache2/conf-enabled/auth_openidc.conf -> ../conf-available/auth_openidc.conf
lrwxrwxrwx 1 root root 35 Dec 20 00:46 /etc/apache2/mods-enabled/auth_openidc.load -> ../mods-available/auth_openidc.load
必要な設定は既に終っているので差分の設定だけを行っていきます。
設定ファイルの準備
/etc/apache2/conf-enabled/auth_openidc.confに接続するための情報を追加していきます。
参考資料ではEndpointの情報を個別に指定していますが、..//.well-known/openid-configuration
を通して情報が得られるので出来るだけ省略していくことにします。
最終的に記述したのは次の情報です。
OIDCRedirectURI https://httpd.example.com/redirect_uri
OIDCCryptoPassphrase "exec:/bin/bash -c \"head /dev/urandom | tr -dc A-Za-z0-9 | head -c 32\""
OIDCProviderMetadataURL https://example.com/dex/.well-known/openid-configuration
OIDCScope "openid email groups profile"
OIDCRemoteUserClaim preferred_username
OIDCClientID dex-client
OIDCClientSecret c925b0e48ba5722521b1cbadc0cf6559
OIDCClientName LDAP-ID
Alpine Linuxに載せ換えるところでも説明していますが、実行時に動的にOIDCCryptoPassphrase
を設定する方法はhttpdが1プロセスで動作する場合にのみ利用可能です。
クラスタリング構成の場合には全プロセスで同一の文字列を指定する必要がありますので注意してください。
OIDCRedirectURI
に指定するURLは、このWebサーバー配下にある存在しないページを指定します。
OIDCRemoteUserClaim
に指定したフィールドが、Require user
で指定できます。httpdのログファイルにもREMOTE_USERとして記録されます。これを指定しない場合にはLDAPのDNがbase64でエンコードされたと思われる文字列がログに残るので障害対応なども考えて適切に変更することをお勧めします。
.htaccessファイルを利用する際の留意点
OIDC*で始まる各種設定を.htaccess
ファイルに含めることはできません。
例えば次のように、Auth*とRequire行から構成される.htaccessファイルを実行時に配置することになります。
AuthName "LDAP-ID Authentication"
AuthType openid-connect
Require claim "groups:webadmin"
Require user "user01"
Require valid-user
の代わりに、claim
を利用すればIDから返却されるclaimによって柔軟な認可ポリシーが提供できます。
個人名の指定にRequire user <username>
を使っていますが、これはOIDCRemoteUserClaim
で指定したフィールドを利用することができます。
/var/www/html/protected/index.html
などを配置してから、このWebブラウザからアクセスすると自動的にOIDCの認証画面が表示され、適切な権限を持っているかチェックされ、返却されます。
.htaccessを利用する場合、ファイルを配置したディレクトリ直下のファイルをOIDCRedirectURI
に指定する必要があります。
/protected/.htaccessに対応するOIDCRedirectURIの指定はhttp://localhost:8080/protected/redirect_uriなどを指定します。
Ubuntuでの経験を活かしてAlpine Linuxを使ったコンテナを動かしていきます。
Alpineコンテナの準備
元々 mod_ldap を利用していたコンテナがあるので、今回はそれを改造してmod_auth_openidcを有効にしたコンテナを準備します。
Alpine linuxはやや特殊なところがあるので、内部の状態を確認しながらDockerfileを作成していきます。
$ podman run -it --rm alpine:latest sh
/ # apk add tzdata bash ca-certificates
/ # apk add --repository=http://dl-cdn.alpinelinux.org/alpine/edge/testing/ apache-mod-auth-openidc
ここからコンテナの内部でhttpdの設定ファイルを編集していくと、いくつか方針が立っていきます。
- "LoadModule"命令が/etc/apache2/conf.d/mod-auth-openidc.confの1行目に記述されているが、共有ライブラリへのpathに
modules/
を追加しないと正しくロードできない (alpine/issue#15999) - とはいえ
httpd:2.4-alpine
コンテナは/usr/local/apache2以下に導入されているため、/etc/apache2/以下のファイルを参照しない - mod_auth_openidc.soは利用できるので、LoadModuleを含めて設定ファイルを配置する必要がある
- .htaccessにOIDC*設定を含められないため、コンテナの実行時に動的に構成を変更する必要がある
これらの点に配慮しつつ、Dockerfileを作成していきます。
httpd-foreground ENTRYPOINT スクリプト
動的に設定を変更するための作業はhttpd:2.4-alpine
公式イメージのCMDに指定されているhttpd-foregroundスクリプトを改造します。
httpd:2.4-alpine
コンテナのために次のようなhttpd-foreground
スクリプトを準備しました。
#!/bin/bash
set -e
# Apache gets grumpy about PID files pre-existing
rm -f /usr/local/apache2/logs/httpd.pid
CONF_FILEPATH=/usr/local/apache2/conf/openidc.conf
echo "Include ${CONF_FILEPATH}" >> /usr/local/apache2/conf/httpd.conf
cat <<EOF | tee "${CONF_FILEPATH}"
<Directory /usr/local/apache2/htdocs>
AllowOverride All
</Directory>
LoadModule auth_openidc_module /usr/lib/apache2/mod_auth_openidc.so
OIDCRedirectURI ${OIDCRedirectURI:-https://httpd.example.com/redirect_uri}
OIDCCryptoPassphrase "${OIDCCryptoPassphrase:-exec:/bin/bash -c \\\"head /dev/urandom | tr -dc A-Za-z0-9 | head -c 32 \\\"}"
OIDCProviderMetadataURL ${OIDCProviderMetadataURL:-https://example.com/dex/.well-known/openid-configuration}
OIDCScope "${OIDCScope:-openid email groups profile}"
OIDCRemoteUserClaim preferred_username
OIDCClientID ${OIDCClientID:-dex-client}
OIDCClientSecret ${OIDCClientSecret:-c925b0e48ba5722521b1cbadc0cf6559}
OIDCClientName ${OIDCClientName:-LDAP-ID}
EOF
## for session cookies
if test -n "${OIDCCookiePath}"; then
cat <<EOF | tee -a "${CONF_FILEPATH}"
OIDCCookiePath ${OIDCCookiePath:-/}
EOF
fi
if test -n "${OIDCCookieHTTPOnly}"; then
cat <<EOF | tee -a "${CONF_FILEPATH}"
OIDCCookieHTTPOnly ${OIDCCookieHTTPOnly:-On}
EOF
fi
if test -n "${OIDCCookieSameSite}"; then
cat <<EOF | tee -a "${CONF_FILEPATH}"
OIDCCookieSameSite ${OIDCCookieSameSite:-On}
EOF
fi
if test -n "${OIDCXForwardedHeaders}"; then
cat <<EOF | tee -a "${CONF_FILEPATH}"
OIDCXForwardedHeaders ${OIDCXForwardedHeaders:-X-Forwarded-Host X-Forwarded-Port X-Forwarded-Proto}
EOF
fi
## for redis
if test "${OIDCCacheType}" = "redis"; then
cat <<EOF | tee -a "${CONF_FILEPATH}"
## for redis
OIDCCacheType redis
OIDCRedisCacheServer ${OIDCRedisCacheServer:-localhost:6379}
EOF
fi
if test -n "${OIDCRedisCacheUsername}"; then
cat <<EOF | tee -a "${CONF_FILEPATH}"
OIDCRedisCacheUsername ${OIDCRedisCacheUsername:-user}
OIDCRedisCachePassword ${OIDCRedisCachePassword:-password}
EOF
fi
if test -n "${OIDCRedisCacheDatabase}"; then
cat <<EOF | tee -a "${CONF_FILEPATH}"
OIDCRedisCacheDatabase ${OIDCRedisCacheDatabase:-0}
EOF
fi
exec httpd -DFOREGROUND "$@"
Dockerfile
これらのファイルを含めるためのDockerfileを準備しますが、実際に作成したものから余計な部分を落としたファイルの内容は次のようになっています。
FROM docker.io/library/httpd:2.4-alpine
RUN apk update && \
apk add --no-cache tzdata bash ca-certificates
RUN apk add --no-cache --repository=http://dl-cdn.alpinelinux.org/alpine/edge/testing/ apache-mod-auth-openidc
ENV OIDCRedirectURI https://httpd.example.com/redirect_ur
ENV OIDCProviderMetadataURL https://example.com/dex/.well-known/openid-configuration
ENV OIDCScope "openid email groups profile"
ENV OIDCClientID dex-client
ENV OIDCClientSecret c925b0e48ba5722521b1cbadc0cf6559
ENV OIDCClientName LDAP-ID
COPY httpd-foreground /usr/local/bin/httpd-foreground
RUN chmod a+rx /usr/local/bin/httpd-foreground
ENTRYPOINT ["/usr/local/bin/httpd-foreground"]
コンテナのビルド
Dockerfileとhttpd-foregroundスクリプトファイルを配置したディレクトリで次のコマンドでコンテナをbuildしています。
$ podman build . --pull --tag httpd-oidcauth:latest
コンテンツの配置
コンテンツは/usr/local/apache2/htdocs/
以下に配置することで閲覧可能です。
この際にOIDCで認証をかけたい場所に.htaccessファイルを配置してください。
例えば、次のようなディレクトリ構造を準備します。
$ tree -a html
html
├── index.html
└── protected
├── .htaccess
└── index.html
次のようなコマンドで作成したコンテナ内部を起動します。
$ podman run -it --rm -d \
-p 8080:80 \
-e OIDCRedirectURI=http://localhost:8080/protected/redirect_uri \
-e OIDCClientSecret=62284f8ae382a5fc9e4d3de420403143 \
-e OIDCProviderMetadataURL=https://example.com/dex/.well-known/openid-configuration \
-v `pwd`/html:/usr/local/apache2/htdocs \
--name httpd-oidcauth \
httpd-oidcauth:latest
http://localhost:8080/protected/にアクセスすることでOIDC認証が発生し、.htaccessの情報を元にアクセスが許可されます。
実際にクラスタリングする際にはRedisやMemcachedについても設定できるようにコンテナを作成する必要があります。
Kubernetesでの利用
これらを踏まえてKubernetesでの利用を検討してみます。
この時に外部のnginxと内部のingressへの接続はTLS化されています。
Dexは実際にはK8s内部に配置されていますが、アクセスはフロントエンドのReverse Proxyサーバーを経由して、その所在は問題ではありませんので図からは省略しています。
こうしてみると静的コンテンツをサービスするのに大袈裟なインフルが必要になりそうですが、k8sであれば簡単に配置できるので試してみます。
YAMLファイルの準備
共有ファイルシステムとコンテンツ配置用rsyncサーバーの配置
とりあえずrsync server
を経由してコンテンツファイルを転送できるようにしていきます。
まずコンテンツ配置用のPVCを準備します。
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: data
spec:
accessModes: [ "ReadWriteMany" ]
storageClassName: rook-cephfs
resources:
requests:
storage: 1Gi
次にrsync接続を受け入れるsshdサーバーを立てていきます。
基本的なコンセプトは以下の記事をご覧ください。
$ conf
$ ssh-keygen -t ed25519 -f conf/id_ed25519
$ ssh-keygen -t ed25519 -f conf/ssh_host_ed25519_key
$ sudo kubectl create secret generic ssh-host-keys --from-file=conf/ssh_host_ed25519_key
$ sudo kubectl create secret generic ssh-auhorized-keys --from-file=conf/id_ed25519.pub
---
apiVersion: v1
kind: Service
metadata:
name: rook-backup
spec:
loadBalancerIP: 192.168.10.22
type: LoadBalancer
ports:
- port: 22
protocol: TCP
targetPort: 22
selector:
app: rook-backup
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: rook-backup
spec:
selector:
matchLabels:
app: rook-backup
strategy:
type: RollingUpdate
replicas: 2
template:
metadata:
labels:
app: rook-backup
spec:
imagePullSecrets:
- name: regcred
containers:
- name: rook-backup
image: docker.io/yasuhiroabe/rook-backup:1.1.4
ports:
- containerPort: 22
env:
- name: ROOK_SSH_AUTHKEYS_FILEPATH
value: /root/.ssh/authorized_keys
- name: ROOK_SSH_PUBKEY_FILEPATH
value: /conf/keys/id_ed25519.pub
- name: ROOK_SSH_SERVERKEYS_FILEPATH_LIST
value: "/conf/sshd/ssh_host_ed25519_key"
volumeMounts:
- name: data-pv
mountPath: /data
readOnly: false
- name: sshd-keys
mountPath: /conf/sshd
readOnly: true
- name: ssh-keys
mountPath: /conf/keys
readOnly: true
volumes:
- name: data-pv
persistentVolumeClaim:
claimName: data
- name: sshd-keys
secret:
secretName: ssh-host-keys
defaultMode: 0400
- name: ssh-keys
secret:
secretName: ssh-auhorized-keys
defaultMode: 0400
/dataにmountしているPVCはreadOnly: false
に設定していますが、もしこのコンテナをバックアップの取得用に利用する場合にはreadOnly: true
にして不用意にコンテンツが変更されるのを防いでください。
コンテンツの配置
ここまでのYAMLファイルなどの反映が無事に終わると、作成してssh_ed25519
ファイルでサーバーに接続できるはずです。
$ ssh -i conf/id_ed25519 root@192.168.10.22
静的コンテンツはこのアドレスを経由してrsyncなどで配置してください。
$ rsync -av -e "ssh -i conf/id_ed25519" html/. root@192.168.10.22:/data/.
Redisの配置
キャッシュするためのRedisサーバーを配置します。
---
apiVersion: v1
kind: Service
metadata:
name: redis-svc
spec:
type: ClusterIP
ports:
- port: 6379
targetPort: 6379
protocol: TCP
selector:
app: redis
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: redis
spec:
accessModes: [ "ReadWriteOnce" ]
storageClassName: rook-ceph-block
resources:
requests:
storage: 1Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: redis
labels:
app: redis
spec:
replicas: 1
selector:
matchLabels:
app: redis
strategy:
type: Recreate
template:
metadata:
labels:
app: redis
spec:
containers:
- name: redis
image: redis:4.0.7
ports:
- containerPort: 6379
name: redis
volumeMounts:
- name: redis-pvc
mountPath: /data
readOnly: true
volumes:
- name: redis-pvc
persistentVolumeClaim:
claimName: redis-pvc
httpd本体の配置
次に先に作成しておいたhttpdサーバーを配置します。
apiVersion: v1
kind: Service
metadata:
namespace: uoa-class-interview
name: httpd
spec:
type: ClusterIP
ports:
- port: 80
targetPort: 80
protocol: TCP
selector:
app: httpd
最後にhttpd本体を起動します。
---
apiVersion: apps/v1
kind: Deployment
metadata:
namespace: uoa-class-interview
name: httpd
labels:
app: httpd
spec:
replicas: 3
selector:
matchLabels:
app: httpd
strategy:
type: RollingUpdate
template:
metadata:
labels:
app: httpd
spec:
imagePullSecrets:
- name: regcred
containers:
- name: httpd
image: docker.io/yasuhiroabe/httpd-oidcauth:latest
imagePullPolicy: Always
ports:
- containerPort: 80
name: httpd
env:
- name: OIDCRedirectURI
value: "https://www.example.com/protected/redirect_uri"
- name: OIDCClientSecret
value: "62284f8ae382a5fc9e4d3de420403143"
- name: OIDCProviderMetadataURL
value: "https://example.com/dex/.well-known/openid-configuration"
- name: OIDCCacheType
value: redis
- name: OIDCRedisCacheServer
value: redis:6379
- name: OIDCRedisCacheDatabase
value: "0"
- name: OIDCCookiePath
value: /labs/opm/report/class_interview/
- name: OIDCCookieHTTPOnly
value: "On"
- name: OIDCCookieSameSite
value: "On"
- name: OIDCXForwardedHeaders
value: "X-Forwarded-Host X-Forwarded-Port X-Forwarded-Proto"
- name: OIDCCryptoPassphrase
value: d034a0dbf430aac85f902617784ed8c8
volumeMounts:
- name: data-pvc
mountPath: /usr/local/apache2/htdocs
volumes:
- name: data-pvc
persistentVolumeClaim:
claimName: data
遭遇した不具合
以下の不具合については、YAMLファイルに反映されて既に解決していますが、まとめておきます。
OIDCXForwardedHeadersが未設定で表示されるエラー
[Thu Dec 26 01:40:21.723949 2024] [auth_openidc:warn] [pid 20:tid 24] [client 10.233.82.217:58256] oidc_check_x_forwarded_hdr: header X-Forwarded-Host received but OIDCXForwardedHeaders not configured for it
...
...
X-Forwarded-Host
だけでなく、X-Forwarded-Port
やX-Forwarded-Proto
が渡されている場合にもログにエラーが記録されています。
ログメッセージのとおりOIDCRedirectURI
のschemeと自身へのリクエストのschemeが一致していないことが原因です。
Ingressからの接続はTLS化していないので、Reverse Proxy ServerまではTLS化されていることを伝えるために、X-Forwarded-*ヘッダーを利用することをOIDCXForwardedHeaders
に明示的に指定する必要がありました。
Session CookieのSecureフラグ
HttpOnlyはtrueですが、Secureフラグはfalseになっています。
[Thu Dec 26 01:40:22.965667 2024] [auth_openidc:error] [pid 20:tid 26] [client 10.233.78.30:58712] oidc_request_check_cookie_domain: the URL scheme (https) of the configured OIDCRedirectURI does not match the URL scheme of the URL being accessed (http): the "state" and "session" cookies will not be shared between the two!
このメッセージは前述のOIDCXForwardedHeaders
を設定することで解消されます。
ERR invalid expire time in 'setex' command
このエラーメッセージはmod_auith_openidc
の2.4.16.6で修正されています。(Release Notes) 2.4.16.2で再発し、2.4.16.5までは影響を受けるので現在のAlpineのモジュールは影響を受けます。
将来的に修正されるはずですが、現状では特に不具合は発生していません。
2.6.16.6をコンパイルして入れ替えるとメッセージ自体が消えることは確認しています。
Redisからデータが取得できていない
[Thu Dec 26 04:46:25.087947 2024] [auth_openidc:error] [pid 38:tid 82] [client 10.233.78.30:53308] oidc_response_match_state: unable to restore state, referer: https://example.com/
[Thu Dec 26 04:46:25.087953 2024] [auth_openidc:error] [pid 38:tid 82] [client 10.233.78.30:53308] oidc_response_process: invalid authorization response state and no default SSO URL is set, sending an error..., referer: https://example.com/
これはクラスター間でRedisに格納された情報が復号化できない事に起因します。
サーバー間で復号鍵は共有しなければいけないため、必ずOIDCCryptoPassphrase
を設定してください。
CookieのSameSite設定をStrictに変更できない
mod_auth_openidcのバージョンが古い事に起因します。現在の2.4.16.4では"On"か"Off"のみを受け付けます。
ソースコードから2.4.16.6のmod_auth_openidc.soを作成し、置き換えることでStrictに設定することができます。
現状のコンテナはtestingに入っているパッケージをそのまま利用していますので、YAMLファイル中では"On"を設定しています。
さいごに
静的コンテンツを提供する仕組みを1つのサービスに統合すると、誰がどのファイルを変更するのか、責任分岐点が課題になりそうなので汎用的に使える認証機能付きの静的コンテンツ提供サービスの骨組みを作りました。
これまではad-hocにコンテンツを含むコンテナを構築していたので、セキュリティ上少し堅牢にしたかったのと、必要に応じてコンテンツを含むコンテナも簡単なステップの追加で作成できるようになりました。
これまでの仕組みを整理する良い機会だったと思います。
一連の作業の目玉はgRPCを使ってDEX APIを操作することなので、もう少し作業を進めたいと思います。
以上