KeycloakのJavaアダプターには、アクセストークンの有効期限が切れる前(もしくは切れた後)に内部処理でリフレッシュトークンを利用してトークン更新を行う機能があります。
ある顧客環境ではセキュリティの要件により、Keycloakでリフレッシュトークンの再利用不可(Revoke Refresh Token: ON、Refresh Token Max Reuse: 0)に設定していました。この設定の場合、発行されたリフレッシュトークンは1回だけ利用可能で、同じ値の再利用は不可となります。このような環境でJavaアダプターのトークン更新処理が競合してしまい、リフレッシュトークンの再利用不可のエラーが発生するという問い合わせがあり、調査を行いました。
顧客環境では、Keycloak 6.0.1(およびJavaサーブレットフィルターアダプター)が使われていました。まずは、その当時の最新版であるKeycloak 9.0.0でも再現するかどうか、シンプルなSPAを作成し再現確認を行います。
Keycloakでは下記のようにリフレッシュトークンの再利用不可の設定および、アクセストークンの有効期限を最小(1分)に設定します。
Javaサーブレットフィルターアダプターの適用されているSPAでは、Ajaxにより指定した回数のリクエストを同時に投げられるボタンをいくか用意しておき、非同期で複数のリクエストが行われる状況を再現できるようにしておきます(カッコ内の数字が同時に投げるリクエスト数を表しています)。
下記はこのアプリケーションにログインしてから、数分経過後(アクセストークンの有効期限切れの後)に "ajaxPosting(3)"ボタンを押下した場合のcatalina.outログの一部抜粋です。
03-Mar-2020 20:15:35.743 DEBUG [http-nio-8080-exec-1] org.keycloak.adapters.RefreshableKeycloakSecurityContext.refreshExpiredToken Token Verification succeeded!
03-Mar-2020 20:15:35.794 ERROR [http-nio-8080-exec-10] org.keycloak.adapters.RefreshableKeycloakSecurityContext.refreshExpiredToken Refresh token failure status: 400 {"error":"invalid_grant","error_description":"Maximum allowed refresh token reuse exceeded"}
03-Mar-2020 20:15:35.957 ERROR [http-nio-8080-exec-2] org.keycloak.adapters.RefreshableKeycloakSecurityContext.refreshExpiredToken Refresh token failure status: 400 {"error":"invalid_grant","error_description":"Maximum allowed refresh token reuse exceeded"}
最初のリクエストに対応するスレッド(http-nio-8080-exec-1)では、リフレッシュトークンは成功しますが、後続の2リクエストに対応するスレッド(http-nio-8080-exec-10、http-nio-8080-exec-2)でもトークン更新処理が実行されてしまい、リフレッシュトークンの再利用不可のエラー("Maximum allowed refresh token reuse exceeded")が発生していることが分かります。
そこで、Javaサーブレットフィルターアダプター側のトークン更新箇所のソースコード調査を進めたところ、下記の「★★★」の箇所のメソッド呼び出しに排他制御がかかっておらず、同タイミングで複数のリクエストがあると、同じリフレッシュトークンでトークン更新処理を複数のスレッドで競合して呼び出してしまうことが分かりました。
@Override
public void checkCurrentToken() {
HttpSession httpSession = request.getSession(false);
if (httpSession == null) return;
SerializableKeycloakAccount account = (SerializableKeycloakAccount)httpSession.getAttribute(KeycloakAccount.class.getName());
if (account == null) {
return;
}
RefreshableKeycloakSecurityContext session = account.getKeycloakSecurityContext();
if (session == null) return;
// just in case session got serialized
if (session.getDeployment() == null) session.setCurrentRequestInfo(deployment, this);
if (session.isActive() && !session.getDeployment().isAlwaysRefreshToken()) return;
// FYI: A refresh requires same scope, so same roles will be set. Otherwise, refresh will fail and token will
// not be updated
boolean success = session.refreshExpiredToken(false); <=== ★★★
if (success && session.isActive()) return;
// Refresh failed, so user is already logged out from keycloak. Cleanup and expire our session
//log.fine("Cleanup and expire session " + httpSession.getId() + " after failed refresh");
cleanSession(httpSession);
httpSession.invalidate();
}
暫定的にこのメソッド内のsessionインスタンスの利用される範囲をsynchronizedブロックで排他制御を加えたところ、問題が再現しなくなることが確認できました。つまり、Javaアダプターのリフレッシュトークン処理ではスレッドの排他制御に関して、潜在的なバグがあることが判明しました。
上記のような調査結果から、以下の3つの条件を満たした場合に、問題の事象が再現することが確認できました。
直接の原因は、Javaアダプター内のトークン更新処理に排他制御がかかっていなことに起因したものでした。当該問題に関しては、Keycloakのコミュニティに報告を行い、バグとして修正されました。 実際のやりとりは以下の通りです。
Keycloak 10.0.0以上でこの問題は修正されています。
]]>mod_auth_openidcでは、認証リクエストでmod_auth_openidcが発行するstateにタイムアウト(デフォルトは5分)を設定することができます。OPでの認証の際にログイン画面表示後にすぐにログインせずに放置したケースなどで、認証レスポンスで送信されきたstateが上記タイムアウト時間を超過することがあります。その場合、mod_auth_openidcはstateのタイムアウトを検知して、タイムアウトエラー画面を表示します。
ある顧客環境で、このstateタイムアウトエラーが発生した際に、エラー画面が適切に表示されないという問い合わせがあり、調査を行いました。
顧客環境では、ELB + Apache(2.4.43) + mod_auth_openidc(2.4.0.3)が利用されていました。Apacheとmod_auth_openidcだけを使ったシンプルな構成では問題が再現しなかったため、ブラウザやELBの経由の有無を変えながら調査を行っていたところ、ELB経由かつIEもしくはChromeを使っている場合に問題が再現することが特定できました。
ELB経由\ブラウザ |
IE |
Chrome |
FireFox |
---|---|---|---|
ELB経由なし |
再現せず |
再現せず |
再現せず |
ELB経由あり |
★再現★ |
★再現★ |
再現せず |
なぜこのような動作の違いが生じているのか確認するため、ネットワークキャプチャを取得してレスポンスの中身を確認したところ、下記のようにエラー画面のレスポンスが想定外な状態になっていることが分かりました。
「本来のエラー画面レスポンス」の下に、本来は出力されるべきでない「重複したエラー画面レスポンス + Apacheのエラー画面レスポンス」が出力されてしまっており、Content-Lengthに一致したレスポンスサイズになっていないことが分かりました(Content-Lengthは、「本来のエラー画面レスポンス」のサイズだけになっています)。
リバースプロキシとしてApacheだけを使っている場合は、実際のレスポンスボディサイズとContent-Lengthが一致していない場合でも、何事もなかったように処理されてしまうため、顧客環境の問題は再現しませんでした。一方で、 ELBを経由している場合には、実際のレスポンスサイズとContent-Lengthが一致していないと、IEやChromeを使っている場合には、ブラウザが正しくレスポンスを受け取ることができなくなります(ブラウザ自身のエラー画面や真っ白い画面になる)。
上記のような調査結果から、以下の4つの条件を満たした場合に、問題の事象が再現することが判明しました。
直接の原因は、stateタイムアウト発生時のエラー画面レスポンスが不正な状態で出力されていることです。
当該問題に関しては、mod_auth_openidcのコミュニティに報告を行い、バグとして修正されました。 実際のやりとりは以下の通りです。
mod_auth_openidc 2.4.2以降でこの問題は修正されています。
]]>このページでは、Keycloakのアカウントロックについての問い合わせに対して、どのようなサポートをしたかを紹介します。
この問い合わせは、ブルートフォース攻撃の対策のためにKeycloakのブルートフォース検知機能を有効にしたところ、アカウントが意図せずロックされることが度々があったため、なぜこのようなことが起きるのかを教えて欲しいというものでした。 ブルートフォース検知機能は管理コンソールの「Realm Settings」の「Security Defenses」の「Brute Force Detection」タブから 設定ができます。
使用しているKeycloakのバージョンは4.8.3でした。
問い合わせの内容などをもとにKeycloakの動作確認をしてみると、どうもログインボタンを二度押しするとアカウントがロックされるようです。 しかし、ログインボタンをクリックすると、レスポンスが返ってくるまでの間、ボタンのプロテクトがかかり、 二度押しを防止する対策はできているように見えます(以下のようにボタンをクリックした直後は色が変わり、 クリックしてもリクエストは送信されていません)。
さらに動作検証してみると、ログインボタンをクリックし、レスポンスが返ってきてすぐにログインボタンをクリックすると (パスワードを入力せずすぐに)、アカウントが一時的にロックされることが分かりました。このような操作を、 ユーザーが意図的に行うことはありませんが、誤ってボタンをクリックしてしまうことは十分にありえます。
このようなKeycloakの動作はユーザーにとっては好ましいものではありません。 ユーザーはアカウントがロックされたことに気づかないまま、正しいパスワードでのログインに失敗し続けることになるでしょう。 ユーザーは混乱してしまいます。そして、パスワードが間違っていると判断し、本来必要のないパスワードのリセットを行うことにもなります。
しかし、なぜこのような誤操作でアカウントがロックされてしまうのでしょうか? Keycloakのブルートフォース検知機能のうち以下の2つの設定が影響していました。
つまり、1秒以内にログインが2回失敗すると、アカウントは1分間ロックされてしまうことになります。
Keycloakのドキュメントに記載されている通りの動作ではあるのですが、ユーザーの利便性にとってよくありません。 ひとまず顧客への暫定対応として「Quick Login Check Milli Seconds」を短くすることを提案し、 根本対策のためにKeycloakの課題管理システムへのバグ報告(改善案の提示)とその修正のプルリクエストをすることを検討しました。
ではどのようにこの問題を改善すればいいのでしょうか?「Quick Login Check」自体を無くしてしまえばいいのでしょうか? しかし、短時間に連続でログインリクエストを送信するブルートフォース攻撃の対策のためには残しておいた方がいいでしょう。
少し考えてみると、結論は単純でした。今回の問題はパスワードを入力する間もなく短時間にログインリクエストを送信している場合の問題ですが、 そもそもブルートフォース攻撃は様々なパスワードを試行するので、パスワードが未入力のリクエストを何度も送信してくることを防御する必要はないはずです (認証成功するはずがログイン試行を攻撃者はしないので)。
ということで、パスワードが未入力のログインリクエストはブルートフォース検知の処理を行う前に入力エラーにしてしまうことにしました。 その後、プルリクエストを行い、無事マージされました。
]]>この問題は、以下のようなものでした。
問題が解決するまでは、APサーバの定期的な再起動などで回避してもらうことにして、調査を開始します。 調べてみると、Spring Sessionが使用するSPRING_SESSION_ATTRIBUTESテーブルのBLOB型のATTRIBUTE_BYTES列があり、 このテーブルへのINSERTのたびにこのテーブルだけでなく、一時表領域も増えていくことが分かりました。 Oracleのドキュメントを見ると、以下のような記述があります。
一時表領域に登録された「一時LOB」を解放するには、java.sql.Blobのfree()メソッドを呼ぶ必要がりますが、呼ばれていない可能性が高そうです。
ということで、ソースコードをチェックします。SPRING_SESSION_ATTRIBUTESテーブルにINSERTしているのはこのメソッドです。
private void insertSessionAttributes(JdbcSession session, List attributeNames) {
Assert.notEmpty(attributeNames, "attributeNames must not be null or empty");
if (attributeNames.size() > 1) {
this.jdbcOperations.batchUpdate(this.createSessionAttributeQuery, new BatchPreparedStatementSetter() {
@Override
public void setValues(PreparedStatement ps, int i) throws SQLException {
String attributeName = attributeNames.get(i);
ps.setString(1, attributeName);
getLobHandler().getLobCreator().setBlobAsBytes(ps, 2,
serialize(session.getAttribute(attributeName)));
ps.setString(3, session.getId());
}
@Override
public int getBatchSize() {
return attributeNames.size();
}
});
}
else {
this.jdbcOperations.update(this.createSessionAttributeQuery, (ps) -> {
String attributeName = attributeNames.get(0);
ps.setString(1, attributeName);
getLobHandler().getLobCreator().setBlobAsBytes(ps, 2, serialize(session.getAttribute(attributeName)));
ps.setString(3, session.getId());
});
}
}
このコードのgetLobHandler().getLobCreator()で取得されるTemporaryLobCreatorには以下のclose()メソッドがありましたが、 これを呼び出している個所はinsertSessionAttributes()メソッドにはありません。
@Override
public void close() {
for (Blob blob : this.temporaryBlobs) {
try {
blob.free();
}
catch (SQLException ex) {
logger.warn("Could not free BLOB", ex);
}
}
for (Clob clob : this.temporaryClobs) {
try {
clob.free();
}
catch (SQLException ex) {
logger.warn("Could not free CLOB", ex);
}
}
}
TemporaryLobCreatorはjava.io.Closeableを継承するLobCreatorを実装し、一時LOBを解放するclose()メソッドを持っていますが、 TemporaryLobCreatorはtry-with-resourcesステートメントなしで生成されるため、close()メソッドが呼び出されず、一時LOBは解放されないと考えられます。
try-with-resourcesステートメントを使用すると、ステートメント内の処理終了後に自動的にjava.io.Closeableを 実装しているオブジェクトのclose()メソッドが呼び出されます。
ということで、バグの可能性が高いことを顧客に伝え、検証のために簡単なサンプルアプリをつくってみることにしました。Spring Sessionを利用してOracleにセッション情報を保存するSpring Bootアプリです。Oracle DBのインストールに時間を奪われつつも、再現環境を構築し、検証です。
JMeterでアプリケーションにアクセスし続けると、ORA-01652が発生し、SQLExceptionがスローされます。再現しました。そして、以下のSQLを発行すると、
select * from V$TEMPORARY_LOBS;
CACHE_LOBSが増え続けているのが分かります。
SID CACHE_LOBS NOCACHE_LOBS ABSTRACT_LOBS CON_ID
---------- ---------- ------------ ------------- ----------
18 371 0 0 0
23 0 0 0 0
25 229 0 0 0
173 144 0 0 0
178 723 0 0 0
346 533 0 0 0
では、以下のようにtry-with-resourcesステートメント(try (LobCreator lobCreator = lobHandler.getLobCreator()) {・・・})を付加してから、同じことをしてみましょう。
private void insertSessionAttributes(JdbcSession session, List attributeNames) {
Assert.notEmpty(attributeNames, "attributeNames must not be null or empty");
try (LobCreator lobCreator = lobHandler.getLobCreator()) {
if (attributeNames.size() > 1) {
this.jdbcOperations.batchUpdate(this.createSessionAttributeQuery, new BatchPreparedStatementSetter() {
@Override
public void setValues(PreparedStatement ps, int i) throws SQLException {
String attributeName = attributeNames.get(i);
ps.setString(1, attributeName);
lobCreator.setBlobAsBytes(ps, 2,
serialize(session.getAttribute(attributeName)));
ps.setString(3, session.getId());
}
@Override
public int getBatchSize() {
return attributeNames.size();
}
});
} else {
this.jdbcOperations.update(this.createSessionAttributeQuery, (ps) -> {
String attributeName = attributeNames.get(0);
ps.setString(1, attributeName);
lobCreator.setBlobAsBytes(ps, 2, serialize(session.getAttribute(attributeName)));
ps.setString(3, session.getId());
});
}
}
}
SQLExceptionがスローされず、CACHE_LOBSも増えていません。
SID CACHE_LOBS NOCACHE_LOBS ABSTRACT_LOBS CON_ID
---------- ---------- ------------ ------------- ----------
23 0 0 0 0
Spring Sessionプロジェクトにもプルリクエストを提出しました。
レビューに時間はかかりましたが、修正が正しいと判断され、マージされました。
]]>