Fessのパスワードハッシュ化をBCryptに強化

Fessではユーザーパスワードの保存形式を、これまでのソルトなしSHA-256(1ラウンド)から、Spring Security v5.8互換のBCryptベースの形式へと刷新しました。既存の環境を止めることなく、ログインをきっかけに新形式へ段階的に移行できるようになっています。

変更の背景

これまでのFessはパスワードを sha256(plain) の16進文字列としてそのまま保存していました。ソルトがなくラウンド数も1回のため、万が一パスワードハッシュが漏洩した場合にレインボーテーブルやGPUによる総当たりで平文を復元されるリスクがありました。今回の対応では、保存形式を {bcrypt}$2a$10$... に切り替え、ログインごとに古い形式を新形式へ置き換える仕組みを導入しています。

パスワードハッシュ化の刷新

BCryptへの移行

新たに PasswordHashHelperorg.codelibs.fess.helper.PasswordHashHelper)を追加しました。ComponentUtil.getPasswordHashHelper() 経由で取得し、encode / matches / upgradeEncoding という公開APIを通して利用します。

保存形式にはSpring Securityの DelegatingPasswordEncoder と互換性のある {bcrypt} プレフィクス付きの文字列を採用しました。将来的にアルゴリズムを切り替える場合も、プレフィクスを見ることで旧方式と新方式を混在させたまま検証できます。

jBCryptの同梱

新規のMaven依存は追加せず、jBCrypt v0.4(ISCライセンス)を org.codelibs.fess.crypto.bcrypt.BCrypt としてソースに同梱しました。NOTICEファイルおよびlicense-plugin・formatter-pluginの除外リストもあわせて更新しています。jBCrypt 0.4は $2a$ のみをサポートし、他システムで使われる $2b$ / $2y$ は受け付けません。

既存ハッシュの互換性

プレフィクスのないレガシーな値は、app.digest.algorithm の設定(sha256 / sha512 / md5)に従って小文字16進で比較します。比較は MessageDigest.isEqual による定数時間比較で行い、未知の {id} プレフィクスや不正な値は常に false を返します。

認証フローの変更

FessLoginAssist.doAuthenticateLocal では、これまで「ユーザー名+ハッシュ済みパスワード」でDBを引いていた処理を、「ユーザー名のみで引いて PasswordHashHelper.matches で照合する」形に変更しました。BCryptはレコードごとにソルトを持つため、事前にハッシュを計算してWHERE句に入れるやり方は使えないためです。

ユーザー不在・null・レガシーhex・未知プレフィクスなどの失敗経路はすべて、applyTimingPadding でダミーのBCrypt検証を1回走らせ、成功時と同じ処理コストがかかるようにしています。これによりユーザーの存在有無が応答時間の差から推測されるタイミング攻撃を防いでいます。

ログイン時の遅延リハッシュ

既存ユーザーの旧形式パスワードは、ログイン成功のタイミングで自動的にBCryptへ置き換わります。この処理は app.password.upgrade.enabled=true(デフォルト有効)かつ upgradeEncoding が true を返した場合のみ走ります。

書き込みには UserService.updateStoredPasswordHash(username, expectedCurrentHash, newEncodedPassword) を新設し、AuthenticationManager を経由しない専用のリハッシュパスとしました。LDAPやSSOなど外部で管理されているユーザーには影響を与えないためです。

更新はOpenSearchの楽観的並行制御で原子性を担保しており、読み出し時の _seq_no / _primary_termIndexRequestBuilder.setIfSeqNo / setIfPrimaryTerm に伝搬させています。バージョン競合はDEBUGレベルでログ出力するにとどめ、ログインそのものは成功扱いのままとします。

書き込みパスの整理

UserService.changePasswordAdminUserAction.getUser、初期管理ユーザーをブートストラップする SearchEngineClient では、ComponentUtil.getPasswordHashHelper().encode(plain) を直接呼ぶように変更しました。これにより UserService から FessLoginAssist への不自然な依存を取り除いています。

新たなコードからパスワードを書き込む場合も、FessLoginAssist.encryptPassword は使わず PasswordHashHelper.encode を呼ぶようにしてください。

新しい設定項目

fess_config.properties に以下の設定を追加しました。

  • app.password.algorithm=bcrypt : 新規書き込み時のアルゴリズム
  • app.password.bcrypt.cost=10 : BCryptのコストパラメータ
  • app.password.upgrade.enabled=true : 旧形式からの遅延リハッシュの有効化

app.digest.algorithm はレガシーハッシュの検証用として残していますが、今後は新規書き込みには使われません。

ダウングレード時の注意

本変更以降、保存されるパスワードは {bcrypt}$2a$10$... の形式になります。BCrypt対応前のFessへロールバックするとこれらの値は検証できなくなるため、管理者パスワードのリセットが必要になります。運用でダウングレードを想定する場合はリリースノートでの周知と、手順の準備をおすすめします。

スキーマやAPI、i18nリソースへの変更はありません。

詳細

詳細はPR #3116を参照してください。

Fessのクリックログ・お気に入りログの自動パージに対応する

FessではPurgeLogJobによって古い検索ログが定期的にパージされる仕組みがあるが、クリックログとお気に入りログは対象外で、OpenSearch上に無制限に蓄積され続けていた。今回、これらのログも検索ログと同じ保持日数でパージされるようにした。

背景

Fessは検索体験の分析のために、以下の3種類のログをOpenSearchに記録する。

  • 検索ログ(Search Log) — 検索クエリやヒット件数などの情報
  • クリックログ(Click Log) — 検索結果がクリックされた際の情報
  • お気に入りログ(Favorite Log) — 検索結果がお気に入り登録された際の情報

クリックログとお気に入りログは、検索ログを親データとする子データとして扱われる。つまり、親となる検索ログが削除されても、子であるクリックログ・お気に入りログは残り続けるため、親を参照できない孤立したデータが蓄積していく状態となっていた。

PurgeLogJobはこれまで検索ログ・ジョブログ・ユーザー情報ログ・クロール情報をパージしていたが、クリックログとお気に入りログは対象外だったため、長期運用しているFess環境ではインデックスが無制限に膨らむ問題があった。

変更内容

SearchLogServiceに削除メソッドを追加

SearchLogServiceに、クリックログとお気に入りログを日数指定で削除するメソッドを追加した。

public void deleteClickLogBefore(final int days) {
    clickLogBhv.queryDelete(cb -> {
        cb.query().setRequestedAt_LessEqual(
            systemHelper.getCurrentTimeAsLocalDateTime().minusDays(days));
    });
}

public void deleteFavoriteLogBefore(final int days) {
    favoriteLogBhv.queryDelete(cb -> {
        cb.query().setCreatedAt_LessEqual(
            systemHelper.getCurrentTimeAsLocalDateTime().minusDays(days));
    });
}

クリックログはrequestedAt(検索リクエスト日時)、お気に入りログはcreatedAt(作成日時)を基準に、指定日数より古いドキュメントを削除する。

PurgeLogJobにパージ処理を追加

PurgeLogJob.execute()の検索ログパージの直後に、クリックログとお気に入りログのパージ処理を追加した。

// purge click logs
try {
    final int days = ComponentUtil.getFessConfig().getPurgeSearchLogDay();
    if (days >= 0) {
        searchLogService.deleteClickLogBefore(days);
    } else {
        resultBuf.append("Skipped to purge click logs.\n");
    }
} catch (final Exception e) {
    logger.error("Failed to purge click logs.", e);
    resultBuf.append(e.getMessage()).append("\n");
}

お気に入りログ側もほぼ同じ実装となっている。例外が発生しても他のパージ処理に影響しないよう、それぞれ独立したtry-catchで囲んでいる。

保持日数は検索ログと共有

クリックログ・お気に入りログの保持日数には、専用の設定は新設せず、既存のpurge.searchlog.dayを共用する設計とした。

これは、前述のとおりクリックログとお気に入りログが検索ログの子データだからである。親の検索ログが消えた時点で参照元を失うため、検索ログと同じ保持期間でパージするのが自然な振る舞いとなる。

将来的に保持期間を個別に設定したくなった場合は、専用の設定プロパティを追加し、未指定時はpurge.searchlog.dayにフォールバックする形で拡張できる。

まとめ

Fess 15.6.0から、purge.searchlog.dayで指定した日数に基づいて、検索ログ・クリックログ・お気に入りログが同時にパージされるようになる。これまで長期運用でクリックログやお気に入りログが肥大化していた環境では、アップグレード後に古いデータが自動的に整理されるため、OpenSearchのストレージ負荷軽減が期待できる。

Fessの全般設定にSSO関連の設定項目を追加

Fessの管理画面にある「全般」設定ページで設定できる項目を大幅に増やしました。これまでsystem.propertiesファイルを直接編集する必要があった設定を、管理画面から変更できるようにしています。

背景

Fessでは、system.propertiesで多くの設定項目を管理していますが、すべてが管理画面から設定できるわけではありませんでした。設定を変更するためにサーバー上のファイルを直接編集する必要があり、運用の手間が発生していました。今回、管理画面の全般設定に45項目を追加し、ほぼすべての設定をブラウザから変更できるようにしました。

追加した設定項目

各セクションに追加した項目は以下の通りです。

システム設定

  • 検索ファイルプロキシ: ファイルプロキシの有効/無効
  • ブラウザロケール使用: 検索時にブラウザのロケールを使用するかどうか
  • SSOタイプ: SSO認証方式の選択(none、oic、saml、spnego、entraid)

クローラー設定

  • User Agent: クローリング時に使用するユーザーエージェント文字列

LDAP設定

  • Security Authentication: LDAP認証タイプ
  • Initial Context Factory: LDAPコンテキストファクトリのクラス名

通知設定

  • 詳細検索ページ通知: 詳細検索ページに表示する通知メッセージ
  • Slack Webhook URL: Slack通知用のWebhook URL
  • Google Chat Webhook URL: Google Chat通知用のWebhook URL

OpenID Connect設定(新規セクション)

クライアントID、クライアントシークレット、認証サーバーURL、トークンサーバーURL、リダイレクトURL、スコープ、ベースURL、デフォルトグループ、デフォルトロールの9項目を設定できます。

SAML設定(新規セクション)

SPベースURL、グループ属性名、ロール属性名、デフォルトグループ、デフォルトロールの5項目を設定できます。

SPNEGO設定(新規セクション)

Kerberos設定ファイルパス、ログイン設定ファイルパス、クライアント/サーバーモジュール名、事前認証のユーザー名/パスワード、Basic認証やNTLMプロンプトの有効/無効、localhost許可、委任許可、除外ディレクトリの12項目を設定できます。

Entra ID設定(新規セクション)

クライアントID、クライアントシークレット、テナントID、Authority URL、Reply URL、State TTL、デフォルトグループ、デフォルトロール、パーミッションフィールド、ドメインサービス利用の10項目を設定できます。

セキュリティへの配慮

クライアントシークレットやパスワードなどの機密情報は、管理画面上ではマスク表示(**********)されます。値が設定済みかどうかは確認でき、新しい値を入力して更新することも可能ですが、現在の値がそのまま表示されることはありません。

まとめ

今回の変更により、SSO関連の設定を含む45項目が管理画面から設定可能になりました。サーバー上のファイルを直接編集する必要がなくなるため、運用の効率化が期待できます。

関連リンク