Spring Security: SecurityContextHolder のスレッド共有戦略
多数の情報をリスト表示するページで、現在ログインしているユーザーが持つ権限に基づいて一部の情報が表示されないようにする処理が必要でした。そこで、まずAPIからリストを取得し、現在のSpring Securityログインセッションに保存されている権限を使用して一部の情報をフィルタリングし、最終的に表示ページにレンダリングする作業を行いました。
しかし、不思議なことに、リストに表示される行が全部で10個ある場合、約2〜3個、つまり約1/4の行にのみ「セッション権限フィルタリング」ロジックが適用され、残りの3/4には適用されないというバグを発見しました。 さらに、1/4に該当する2〜3個は、リロードするたびに不規則に変化していました。例えば、一度リロードすると2番目と3番目の行に「セッション権限フィルタリング」が適用され、もう一度リロードすると5番目と6番目の行に適用されるといった具合です。まるでシュレーディンガーの猫のようでした…
実装は以下の通りでした。
List<SomeInformation> list = someApi.retreive(condition);
list.parallelStream()
.forEach(each -> {
if (!SecurityHelper.hasRole("ROLE_CAN_SEE_SENSITIVE_NUMBERS")) {
each.setSensitiveNumber1(null);
each.setSensitiveNumber2(null);
}
})
public class SecurityHelper {
public static boolean hasRole(String role) {
SecurityContext context = SecurityContextHolder.getContext();
if (Objects.isNull(context))) {
return false;
}
Authentication authentication = context.getAuthentication();
if (Objects.isNull(authentication))) { // `auth`を`authentication`に修正
return false;
}
for (GrantedAuthority eachAuthority : authentication.getAuthorities()) { // `GrantAuthority`を`GrantedAuthority`に修正
if (role.equals(eachAuthority.getAuthority())) {
return true;
}
}
return false;
}
}
実際にテストしたログインアカウントにはROLE_CAN_SEE_SENSITIVE_NUMBERS権限があったため、リストのすべての行でsensitiveNumber1、sensitiveNumber2が正常に表示されるのが正しいはずです。しかし、1/4しか表示されないのはどう考えてもおかしいので、parallelStream.forEachの内部にログを追加してみたところ、以下の結果が得られました。
INFO 2021-01-01 00:00:01 [ForkJoinPool.commonPool-worker-3] [TEST] hasRole: false
INFO 2021-01-01 00:00:01 [ForkJoinPool.commonPool-worker-2] [TEST] hasRole: false
INFO 2021-01-01 00:00:01 [ForkJoinPool.commonPool-worker-7] [TEST] hasRole: false
INFO 2021-01-01 00:00:01 [ForkJoinPool.commonPool-worker-1] [TEST] hasRole: false
INFO 2021-01-01 00:00:01 [http-nio-80-exec-3] [TEST] hasRole: true
INFO 2021-01-01 00:00:01 [ForkJoinPool.commonPool-worker-4] [TEST] hasRole: false
INFO 2021-01-01 00:00:01 [ForkJoinPool.commonPool-worker-5] [TEST] hasRole: false
INFO 2021-01-01 00:00:01 [ForkJoinPool.commonPool-worker-6] [TEST] hasRole: false
INFO 2021-01-01 00:00:01 [ForkJoinPool.commonPool-worker-2] [TEST] hasRole: false
INFO 2021-01-01 00:00:01 [http-nio-80-exec-3] [TEST] hasRole: true
見てみると、ForkJoinPool、つまりParallelStreamの実行のために割り当てられた子スレッド(ForkJoinPool.commonPool-worker-1~7)ではhasRoleが異常にfalseを返し、メインスレッド(http-nio-80-exec-3)ではhasRoleが正常にtrueを返していることがわかります。どうやらParallelStreamとSecurityContextHolderの併用が問題のようです。
SecurityContextHolder のスレッド間共有モード
ParallelStreamのスレッドでhasRole = falseが返された第一の原因は、SecurityContext context = SecurityContextHolder.getContext()呼び出し時にnullが返されていたことです。一方、メインスレッドでSecurityContextHolder.getContext()を呼び出すと、正常にセッションデータを取得でき、hasRoleに適切な比較ロジックを実行できました。調べてみたところ、以下の事実を発見しました。
SecurityContextHolder は、SecurityContext ログインセッション情報をどのレベルのスレッドまで共有するかモードを指定できるようになっています。デフォルト値はMODE_THREADLOCALであり、SecurityContext 情報は**「メインスレッド」**でのみ見ることができます。
共有モードは合計3種類あります。
- MODE_THREADLOCAL: (デフォルト) ローカルスレッド内でのみ共有可能
- MODE_INHERITABLETHREADLOCAL: ローカルスレッドが生成した子スレッドにまで共有可能
- MODE_GLOCAL: すべてのスレッド、アプリケーション全体で共有可能
デフォルトモードはMODE_THREADLOCALだったため、何も設定していなかったサーバーでは、メインスレッド(http-nio-80-exec-3)でのみSecurityContextが返され、残りの子スレッド(ForkJoinPool.commonPool-worker-1~7)ではnullが返されていたのです。
まとめ
SecurityContextHolderのデフォルト設定は、SecurityContext情報をローカルスレッドのみで共有するように設計されているため、SecurityContextHolderを子スレッド内で直接呼び出して使用するよりも、メインスレッドで呼び出してその値を子スレッドから参照する方が、パフォーマンス的にもコードの可読性的にもよりクリーンなコードになるでしょう。
ParallelStreamやAsync関連の機能を使用する際、子スレッドでSecurityContextHolderを使用する必要がある場合は、SecurityContextHolderの共有モードをMODE_INHERITABLETHREADLOCALに切り替えることを検討すべきです。