以前、OpenID Connect によるシングルサインオン環境を構築しました。
Railsとdoorkeeper-openid_connectやOmniAuth を使って、OpenID Connectの OpenID Provider と Relying Party を作ってみた - メモ的な思考的な
OpenID Connect以外でシングルサインオン環境を構築する方法として SAML
がありますが、今までさわってきませんでした。
そんな中、書籍「SAML入門」を読む機会がありました。
書籍では
- SAMLの認証フロー
- 認証フローのリクエスト・レスポンスの中身を掲載
- Dockerを使って実際にSAML認証を試す
- SAMLの仕様へのリンクやお役立ちツール
などが分かりやすく記載されており、とてもためになりました。ありがとうございました。
本を読んでみて気持ちが盛り上がり、自分でもSPとIdPの環境を構築してみたくなりました。
そこで、Python + Flask + pysaml2 でSP、KeycloakでIdPを構築し、SAML2 の SP-initiated フローでSAML認証してみたので、メモを残します。
目次
環境
- WSL2
- SP
- Python 3.11.7
- Flask 3.0.2
- pysaml2 7.5.0
- IdP
なお、ソースコードは必要に応じて記載しているものの、全部は記載していません。
詳細はGithubのリポジトリを確認してください。
https://github.com/thinkAmi-sandbox/flask_with_pysaml2_and_keycloak-example
やらないこと
あくまで「SAMLの認証フローを体験する」がメインなので、以下は行いません。
- 本番運用に即した、Keycloakやpysaml2の設定
- セキュリティまわりを真剣に考えること
また、SPとIdPの両方を自作すると完成が遅くなりそうでした。
そのため、SAML入門同様、今回はIdPにKeycloakを使うことにして、IdPの自作は行いません。
SP向けのライブラリについて検討
今回はPythonで書いてみようと考え、SAML2関連のライブラリを探したところ、以下の2つがありました。
両方とも同じくらいのstarだったため、どんな違いがあるのか調べたところ、2016年のstackoverflowに情報がありました。
single sign on - Python SSO: pysaml2 and python3-saml - Stack Overflow
python3-saml のauthorのコメントだったものの、python3-saml
が良さそうに感じました。
ただ、
- サンプルコードを見ると、
onelogin
というサービス名が気になった
pysaml2
+ Keycloak という組み合わせのサンプルコードもあまり多くなかった
djangosaml2
や django-saml2-auth
が pysaml2
を使っていた
ということから、今回は pysaml2
でSPを作ることにしました。
ライブラリは決まったものの、 pysaml2
を使ってゼロから作るのは大変そうです。
サンプルコードを探したところ、oktaにてサンプルコードが公開されていました。
そこで、これをベースに作っていくことにしました。
なお、上記oktaのサンプルだと Flask-Login
を使うことでログインまわりをきちんと作っています。
ただ、今回は必要最低限の実装にするので、ログインまわりについては
Flask-Login
は使用しない
- その代わり、SAML認証成功時にセッションへデータをセットする
とします。
SAMLのリクエスト・レスポンスをChromeで確認できると便利です。
調べた見たところ、SAML-tracer がありました。
SAML-tracer
この拡張ですが、
ということから、今回使ってみることにしました。
この拡張を使うことで、SAMLのパラメータを見たり、
実際のリクエストで使われるSAMLを見れたりと、開発をする上で便利になりました。
Keycloakのセットアップ
Keycloakは、公式のDockerイメージが quay.io で提供されています。
https://quay.io/repository/keycloak/keycloak?tab=tags
今回は、最新バージョンの docker compose で Keycloak をたてることにしました。
そこで、以下の compose.yaml
を用意しました。
ちなみに、ポート 8080
はよく見かけるため、 18080
へと変更しています。
services:
keycloak:
image: quay.io/keycloak/keycloak:23.0.6
tty: true
stdin_open: true
ports:
- 18080:8080
environment:
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: admin
command:
- start-dev
準備ができたので、起動します。
$ docker compose up -d
続いて、公式ドキュメントの手順に従い、Keycloakの設定を行います。
Docker - Keycloak
- ポートを変更しているので、以下のURLにアクセス
- realmを作る
- nameを
myrealm
とする
- それ以外はデフォルト
- ユーザー作る
myrealm
に切り替える
- 以下を入力
- Username:
myuser
- First Name:
Foo
- Last Name:
Bar
- ユーザーにパスワードを設定する
Credentials
タブを開く
- 以下を入力
- Password:
baz
- Password confirmation:
baz
- Temporary:
Off
- 作成したユーザー
myuser
でKeycloakへログインしてみる
- Realm settings の
Endpoints
をクリックし、エンドポイント情報を確認しておく
pysaml2を使って、SPを作る
各ライブラリのインストール
今回、WSL2上にSPをたてます。
はじめに、pysaml2のREADMEにある通り、 xmlsec1
をインストールします。
$ sudo apt install xmlsec1
続いて、Flaskとpysaml2をインストールします。
$ pip install pysaml2 flask
Flaskアプリの作成
ミニマムな saml_client_for メソッドへと変更
oktaのサンプルコードを一部改変し、ミニマムな実装にします。
まず、今回はHTTP通信だけ使うので、変数 asc_url
をSAML Requestのみの
acs_url = url_for(
'saml_request',
_external=True)
とします。
変数 settings
については、
endpoint
は以下の2つ分を定義
- SAML Requestのときの HTTP Redirect Binding
- SAML Responseのときの HTTP POST Binding
- 各種 signed は
False
allow_unsolicited
は True
- 未設定だと
saml2.response.UnsolicitedResponse: Unsolicited response: id-***
エラーが発生する
metadata
には remote
を追加
- これがないと、
saml2.client_base.SignOnError: {'message': 'No supported bindings available for authentication', 'bindings_to_try': ['urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect'], 'unsupported_bindings': ['urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect']}
エラーが発生する
- ただ、Keycloakの設定不足なのかもしれない
- なお、
metadata
の remote
についてはドキュメントに情報が見当たらなく、以下の記事の djangosaml2
の記事を見て追加してみた
とします。
関数全体は以下の通りです。
def saml_client_for():
acs_url = url_for(
'saml_request',
_external=True)
settings = {
'entityid': 'flask',
'metadata': {
'remote': [
{'url': 'http://localhost:18080/realms/myrealm/protocol/saml/descriptor'},
],
},
'service': {
'sp': {
'endpoint': {
'assertion_consumer_service': [
(acs_url, BINDING_HTTP_REDIRECT),
(acs_url, BINDING_HTTP_POST),
]
},
'allow_unsolicited': True,
'authn_requests_signed': False,
'want_assertions_signed': False,
'want_response_signed': False,
}
}
}
spConfig = Saml2Config()
spConfig.load(settings)
spConfig.allow_unknown_attributes = True
saml_client = Saml2Client(config=spConfig)
return saml_client
SAML Requestを送信する関数を作成
今回のSAML Request は HTTP Redirect Binding
とするため、oktaのサンプルコードとほぼ同じです。
なお、今回は SP-initiated
フローでの認証のみ動作確認することから、メソッド名を sp_initiated
から saml_request
へと変更しています。
def saml_request():
saml_client = saml_client_for()
_reqid, info = saml_client.prepare_for_authenticate()
redirect_url = None
for key, value in info['headers']:
if key == 'Location':
redirect_url = value
response = redirect(redirect_url, code=302)
NOTE
response.headers['Cache-Control'] = 'no-cache, no-store'
response.headers['Pragma'] = 'no-cache'
return response
SAML ResponseをPOSTで受け付ける関数を作成
元々のサンプルコードでは idp_initiated
関数でしたが、 IdP-initiated
フロー向けと誤解しそうなので、関数名を saml_response
へと変更しました。
また、 authn_response.parse_assertion()
をしないと、 get_identity()
や get_subject()
で値が取得できなかったことから、修正を加えています。
他に、セッションの中にSAML入門で確認していた各値を設定し、ブラウザ上で表示できるようにしておきます。
なお、セッションの各値についてはpysaml2のドキュメントでは示されていなかったため、デバッガを使って一つ一つどこにあるかを確認しました。
@app.route('/saml/response/keycloak', methods=['POST'])
def saml_response():
saml_client = saml_client_for()
authn_response = saml_client.parse_authn_request_response(
request.form['SAMLResponse'],
entity.BINDING_HTTP_POST)
authn_response.parse_assertion()
user_info = authn_response.get_subject()
session['saml_attributes'] = {
'name_id': user_info.text,
'name_id_format': user_info.format,
'name_id_name_qualifier': user_info.name_qualifier,
'name_id_sp_name_qualifier': user_info.sp_name_qualifier,
'session_index': authn_response.assertion.authn_statement[0].session_index,
'session_expiration': authn_response.assertion.authn_statement[0].session_not_on_or_after,
'message_id': authn_response.response.id,
'message_issue_instant': authn_response.response.issue_instant,
'assertion_id': authn_response.assertion.id,
'assertion_not_on_or_after': authn_response.assertion.issue_instant,
'relay_status': 'NOT_USED',
'identity': authn_response.get_identity()
}
return redirect('/')
SAML Requestを送信するためのリンクやセッションの中身を表示するindexを用意
テンプレートを描画するだけです。
@app.route('/')
def index():
return render_template('index.html')
テンプレートはこんな感じで、セッションの値の有無により表示を分岐しています。
{% if session['saml_attributes'] %}
{% set s = session['saml_attributes'] %}
<h1>KeyCloak Status</h1>
<table>
<thead>
<tr>
<th>Attribute</th>
<th>Value</th>
</tr>
</thead>
<tbody>
<tr>
<td>Name ID</td>
<td>{{ s['name_id'] }}</td>
</tr>
<tr>
<td>Name ID Format</td>
<td>{{ s['name_id_format'] }}</td>
</tr>
<tr>
<td>Name ID Name Qualifier</td>
<td>{{ s['name_id_name_qualifier'] }}</td>
</tr>
<tr>
<td>Name ID SP Name Qualifier</td>
<td>{{ s['name_id_sp_name_qualifier'] }}</td>
</tr>
<tr>
<td>Session Index</td>
<td>{{ s['session_index'] }}</td>
</tr>
<tr>
<td>Session Expiration</td>
<td>{{ s['session_expiration'] }}</td>
</tr>
<tr>
<td>Message ID</td>
<td>{{ s['message_id'] }}</td>
</tr>
<tr>
<td>Message Issue Instant</td>
<td>{{ s['message_issue_instant'] }}</td>
</tr>
<tr>
<td>Assertion ID</td>
<td>{{ s['assertion_id'] }}</td>
</tr>
<tr>
<td>Assertion NotOnOrAfter</td>
<td>{{ s['assertion_not_on_or_after'] }}</td>
</tr>
<tr>
<td>Relay Status</td>
<td>{{ s['relay_status'] }}</td>
</tr>
<tr>
<td>Identity</td>
<td>{{ s['identity'] }}</td>
</tr>
</tbody>
</table>
{% else %}
<h1>Login</h1>
<ul>
<li><a href="/saml/login/keycloak">KeyCloak</a></li>
</ul>
{% endif %}
以上で、SP側の実装は完了です。
Keycloakへ設定を追加
続いて、公式ドキュメントを参考にしつつ、SPの情報をKeycloakへ設定します。
Creating a SAML client | Server Administration Guide
- Create Client でクライアントを作成
- Client Typeは
SAML
- Client IDは任意の値
- 今回は
flask
- ただし、本番運用の場合は重複しないような値のほうが良さそう
- Valid Redirect URIsは
http://localhost:15000/saml/response/keycloak
- NameID Formatは
username
- Force POST bindingは
On
- clientから
flask
を選択
- Keyタブを選択
Client signature required
は Off
にする
- Client scopes タブから、
flask-dedicated
を選択
- Scopeタブを選択
Full scope allowed
を Off にする
- Mappersタブを選択
Configure a new mapper
をクリック
- Nameで
User Attribute
をクリック
- Name, User Attribute, Friendly Name, SAML Attribute Name のいずれも
username
- SAML Attribute NameFormatは
Basic
(デフォルト)
- Aggregate attribute valuesは Off (デフォルト)
- Advancedタブを選択
- Assertion Consumer Service POST Binding URL に
http://localhost:15000/saml/response/keycloak
を設定
- SAML Response の送信先
- これがないと、Keycloak上で
Invalid Request
エラーが表示されてしまう
動作確認
ここまでで環境構築が完了したので、実際に動作を確認してみます。
http://localhost:15000/
にアクセスすると、Keycloakでログインするためのリンクが表示されます。
リンクをクリックすると、Keycloak上のログイン画面が表示されるので、ログインユーザーとパスワードを入力します。
ログインに成功するとSPに戻り、SAML Responseの内容が表示されます。
SAML-tracerの状態も確認します。
SAML Request の時はこんな感じでした。
2回目のSAML Response の場合はこんな感じです。
以上で、Python + Flask + pysaml2 でSP、KeycloakでIdPを構築し、SAML2 の SP-initiated フローでSAML認証ができるようになりました。
その他の参考資料
Githubにあげました。
https://github.com/thinkAmi-sandbox/flask_with_pysaml2_and_keycloak-example
今回のプルリクはこちら。
https://github.com/thinkAmi-sandbox/flask_with_pysaml2_and_keycloak-example/pull/1