Spring Securityを本番環境で安全に運用するには、開発時とは異なる視点での設定確認と継続的なセキュリティ対策が必要です。本記事では、本番デプロイ前に確認すべきセキュリティチェックリストと、運用フェーズで必要となるベストプラクティスを体系的に解説します。
実行環境と前提条件#
本記事の内容を実践するにあたり、以下の環境を前提としています。
| 項目 |
バージョン・要件 |
| Java |
17以上 |
| Spring Boot |
3.4.x |
| Spring Security |
6.4.x |
| ビルドツール |
Maven または Gradle |
| CI/CD |
GitHub Actions または Jenkins |
事前に以下の知識があると理解がスムーズです。
- Spring Securityの基本的な設定方法
- SecurityFilterChainの仕組み
- ログ設定(Logback)の基礎
- CI/CDパイプラインの基本概念
期待される学習成果#
本記事を読み終えると、以下のことができるようになります。
- 本番環境に最適なPasswordEncoderを選定し、適切なパラメータで設定できる
- セキュリティ設定の監査ポイントを理解し、設定漏れを防止できる
- 依存ライブラリの脆弱性を自動検出する仕組みを構築できる
- セキュリティログの設計と監視体制を整備できる
- インシデント発生時の対応準備を完了できる
本番運用チェックリスト概要#
本番環境でSpring Securityを安全に運用するためのチェックリストを以下の5つのカテゴリに分類します。
flowchart TB
subgraph checklist["本番運用チェックリスト"]
A[1. PasswordEncoder設定]
B[2. セキュリティ設定監査]
C[3. 依存ライブラリ脆弱性チェック]
D[4. セキュリティログ設計]
E[5. インシデント対応準備]
end
A --> A1[アルゴリズム選定]
A --> A2[ワークファクター調整]
A --> A3[漏洩パスワードチェック]
B --> B1[認証設定確認]
B --> B2[認可設定確認]
B --> B3[セキュリティヘッダー確認]
C --> C1[Dependabot設定]
C --> C2[OWASP Dependency-Check]
C --> C3[CI/CD統合]
D --> D1[認証イベントログ]
D --> D2[監査ログ]
D --> D3[アラート設定]
E --> E1[対応フロー策定]
E --> E2[連絡体制整備]
E --> E3[復旧手順書作成]各カテゴリの詳細を順に解説します。
本番環境向けPasswordEncoderの選定#
パスワードの安全な保存は認証システムの基盤です。本番環境では、攻撃者による総当たり攻撃やレインボーテーブル攻撃に耐えられるアルゴリズムを選定する必要があります。
PasswordEncoderの選択基準#
Spring Security 6.4では、以下のPasswordEncoderが利用可能です。本番環境での推奨度を比較します。
| アルゴリズム |
推奨度 |
特徴 |
用途 |
| Argon2 |
最高 |
メモリハード関数、Password Hashing Competition優勝 |
新規システム |
| BCrypt |
高 |
広く普及、十分な安全性 |
既存システム、互換性重視 |
| SCrypt |
高 |
メモリハード関数、BCryptより強力 |
高セキュリティ要件 |
| PBKDF2 |
中 |
FIPS認証対応 |
政府系・金融系システム |
新規システムではArgon2を、既存システムや広い互換性が必要な場合はBCryptを選択することを推奨します。
Argon2PasswordEncoderの設定#
Argon2はPassword Hashing Competitionで優勝したアルゴリズムで、メモリと計算時間の両方を消費することで、専用ハードウェアによる攻撃を困難にします。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.argon2.Argon2PasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
@Configuration
public class PasswordEncoderConfig {
@Bean
public PasswordEncoder passwordEncoder() {
// Spring Security 6.4推奨のデフォルト設定
// saltLength: 16, hashLength: 32, parallelism: 1
// memory: 19456 (19MB), iterations: 2
return Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8();
}
}
|
高セキュリティ要件の場合は、パラメータをカスタマイズします。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
|
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.argon2.Argon2PasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
@Configuration
public class HighSecurityPasswordEncoderConfig {
@Bean
public PasswordEncoder passwordEncoder() {
// カスタムパラメータ
// saltLength: ソルト長(バイト)
// hashLength: ハッシュ長(バイト)
// parallelism: 並列度
// memory: メモリコスト(KB)
// iterations: 反復回数
int saltLength = 16;
int hashLength = 32;
int parallelism = 4;
int memory = 65536; // 64MB
int iterations = 3;
return new Argon2PasswordEncoder(
saltLength,
hashLength,
parallelism,
memory,
iterations
);
}
}
|
BCryptPasswordEncoderの設定#
BCryptは広く普及しており、多くのシステムで互換性があります。strengthパラメータでワークファクターを調整します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
@Configuration
public class BCryptPasswordEncoderConfig {
@Bean
public PasswordEncoder passwordEncoder() {
// strength: 4〜31の範囲で指定(デフォルト: 10)
// 本番環境では12以上を推奨
// 目安: システムで約1秒かかる値を選定
int strength = 12;
return new BCryptPasswordEncoder(strength);
}
}
|
ワークファクターの調整方法#
PasswordEncoderのワークファクター(計算コスト)は、パスワード検証に約1秒かかるように調整することが推奨されています。以下のコードで適切な値を測定できます。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
|
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.argon2.Argon2PasswordEncoder;
public class WorkFactorBenchmark {
public static void main(String[] args) {
String testPassword = "testPassword123!";
// BCryptのstrength値を測定
for (int strength = 10; strength <= 14; strength++) {
BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(strength);
long start = System.currentTimeMillis();
String encoded = encoder.encode(testPassword);
encoder.matches(testPassword, encoded);
long duration = System.currentTimeMillis() - start;
System.out.printf("BCrypt strength=%d: %dms%n", strength, duration);
}
// Argon2のパラメータを測定
int[] memoryValues = {16384, 32768, 65536};
for (int memory : memoryValues) {
Argon2PasswordEncoder encoder = new Argon2PasswordEncoder(
16, 32, 1, memory, 2
);
long start = System.currentTimeMillis();
String encoded = encoder.encode(testPassword);
encoder.matches(testPassword, encoded);
long duration = System.currentTimeMillis() - start;
System.out.printf("Argon2 memory=%dKB: %dms%n", memory, duration);
}
}
}
|
実行結果の例(環境により異なります):
1
2
3
4
5
6
7
8
|
BCrypt strength=10: 98ms
BCrypt strength=11: 195ms
BCrypt strength=12: 389ms
BCrypt strength=13: 778ms
BCrypt strength=14: 1556ms
Argon2 memory=16384KB: 245ms
Argon2 memory=32768KB: 478ms
Argon2 memory=65536KB: 956ms
|
この場合、BCryptはstrength=13〜14、Argon2はmemory=65536KBが約1秒となり、本番環境に適しています。
漏洩パスワードのチェック#
Spring Security 6.4では、Have I Been Pwned APIと連携して、漏洩したパスワードをチェックする機能が提供されています。
1
2
3
4
5
6
7
8
9
10
11
12
13
|
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.password.CompromisedPasswordChecker;
import org.springframework.security.web.authentication.password.HaveIBeenPwnedRestApiPasswordChecker;
@Configuration
public class CompromisedPasswordConfig {
@Bean
public CompromisedPasswordChecker compromisedPasswordChecker() {
return new HaveIBeenPwnedRestApiPasswordChecker();
}
}
|
この設定により、認証時に漏洩パスワードが検出された場合、CompromisedPasswordExceptionがスローされます。ユーザーにパスワード変更を促すハンドラを実装します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
|
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.password.CompromisedPasswordException;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.DefaultRedirectStrategy;
import org.springframework.security.web.RedirectStrategy;
import java.io.IOException;
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/reset-password", "/change-password").permitAll()
.anyRequest().authenticated()
)
.formLogin(form -> form
.failureHandler(compromisedPasswordHandler())
);
return http.build();
}
private AuthenticationFailureHandler compromisedPasswordHandler() {
return new AuthenticationFailureHandler() {
private final SimpleUrlAuthenticationFailureHandler defaultHandler =
new SimpleUrlAuthenticationFailureHandler("/login?error");
private final RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();
@Override
public void onAuthenticationFailure(
HttpServletRequest request,
HttpServletResponse response,
AuthenticationException exception) throws IOException, ServletException {
if (exception instanceof CompromisedPasswordException) {
// 漏洩パスワードの場合はパスワードリセットページへ
redirectStrategy.sendRedirect(
request, response, "/reset-password?compromised=true"
);
return;
}
defaultHandler.onAuthenticationFailure(request, response, exception);
}
};
}
}
|
セキュリティ設定の監査#
本番デプロイ前に、SecurityFilterChainの設定を網羅的に監査します。
認証設定の監査チェックリスト#
以下の項目を確認し、設定漏れがないことを検証します。
| チェック項目 |
確認内容 |
重要度 |
| PasswordEncoder |
適切なアルゴリズム(Argon2/BCrypt)を使用しているか |
必須 |
| セッション固定攻撃対策 |
sessionManagement().sessionFixation().changeSessionId()が設定されているか |
必須 |
| Remember-Me |
本番で不要な場合は無効化されているか |
高 |
| ログアウト処理 |
セッション無効化とCookie削除が設定されているか |
必須 |
| 同時セッション制御 |
必要に応じてmaximumSessions()が設定されているか |
中 |
監査用の設定クラス例を示します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
|
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@EnableWebSecurity
@Profile("production")
public class ProductionSecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
// 認証設定
.formLogin(form -> form
.loginPage("/login")
.defaultSuccessUrl("/dashboard", true)
.failureUrl("/login?error=true")
)
// セッション管理
.sessionManagement(session -> session
.sessionFixation().changeSessionId()
.maximumSessions(1)
.maxSessionsPreventsLogin(true)
)
// ログアウト設定
.logout(logout -> logout
.logoutUrl("/logout")
.logoutSuccessUrl("/login?logout=true")
.invalidateHttpSession(true)
.deleteCookies("JSESSIONID")
.clearAuthentication(true)
);
return http.build();
}
}
|
認可設定の監査チェックリスト#
| チェック項目 |
確認内容 |
重要度 |
| デフォルト拒否 |
明示的に許可されていないパスはすべて拒否されるか |
必須 |
| 公開エンドポイント |
permitAll()の対象が最小限か |
必須 |
| 管理者エンドポイント |
適切なロール制限が設定されているか |
必須 |
| アクチュエーター |
/actuator/**が適切に保護されているか |
必須 |
| 静的リソース |
必要最小限のパスのみ公開されているか |
高 |
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
|
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.boot.actuate.autoconfigure.security.servlet.EndpointRequest;
@Configuration
public class AuthorizationAuditConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(authorize -> authorize
// 静的リソース(最小限)
.requestMatchers("/css/**", "/js/**", "/images/**").permitAll()
// 公開エンドポイント(最小限)
.requestMatchers("/login", "/error", "/health").permitAll()
// アクチュエーター(管理者のみ)
.requestMatchers(EndpointRequest.toAnyEndpoint())
.hasRole("ADMIN")
// 管理者エンドポイント
.requestMatchers("/admin/**").hasRole("ADMIN")
// APIエンドポイント
.requestMatchers("/api/**").hasAnyRole("USER", "ADMIN")
// デフォルト拒否(重要)
.anyRequest().authenticated()
);
return http.build();
}
}
|
セキュリティヘッダーの監査チェックリスト#
| ヘッダー |
推奨設定 |
目的 |
| Content-Security-Policy |
default-src 'self' |
XSS攻撃防止 |
| X-Frame-Options |
DENY |
クリックジャッキング防止 |
| X-Content-Type-Options |
nosniff |
MIMEスニッフィング防止 |
| Strict-Transport-Security |
max-age=31536000; includeSubDomains |
HTTPS強制 |
| X-XSS-Protection |
0 |
古いXSSフィルター無効化 |
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
|
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
public class SecurityHeadersConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.headers(headers -> headers
// Content-Security-Policy
.contentSecurityPolicy(csp -> csp
.policyDirectives(
"default-src 'self'; " +
"script-src 'self'; " +
"style-src 'self' 'unsafe-inline'; " +
"img-src 'self' data:; " +
"font-src 'self'; " +
"frame-ancestors 'none'"
)
)
// X-Frame-Options
.frameOptions(frame -> frame.deny())
// X-Content-Type-Options(デフォルトで有効)
.contentTypeOptions(contentType -> {})
// HTTP Strict Transport Security
.httpStrictTransportSecurity(hsts -> hsts
.includeSubDomains(true)
.maxAgeInSeconds(31536000)
)
// Referrer-Policy
.referrerPolicy(referrer -> referrer
.policy(org.springframework.security.web.header.writers
.ReferrerPolicyHeaderWriter.ReferrerPolicy.STRICT_ORIGIN_WHEN_CROSS_ORIGIN)
)
// Permissions-Policy
.permissionsPolicy(permissions -> permissions
.policy("geolocation=(), camera=(), microphone=()")
)
);
return http.build();
}
}
|
設定監査の自動化#
テストコードでセキュリティ設定を自動監査します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
|
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("production")
class SecurityAuditTest {
@Autowired
private MockMvc mockMvc;
@Test
void 未認証ユーザーは保護されたエンドポイントにアクセスできない() throws Exception {
mockMvc.perform(get("/api/users"))
.andExpect(status().isUnauthorized());
}
@Test
void 管理者エンドポイントは一般ユーザーからアクセスできない() throws Exception {
mockMvc.perform(get("/admin/settings"))
.andExpect(status().isForbidden());
}
@Test
void セキュリティヘッダーが正しく設定されている() throws Exception {
mockMvc.perform(get("/login"))
.andExpect(header().string("X-Frame-Options", "DENY"))
.andExpect(header().string("X-Content-Type-Options", "nosniff"))
.andExpect(header().exists("Content-Security-Policy"));
}
@Test
void アクチュエーターエンドポイントは保護されている() throws Exception {
mockMvc.perform(get("/actuator/env"))
.andExpect(status().isUnauthorized());
}
}
|
依存ライブラリの脆弱性チェック#
サードパーティライブラリの既知の脆弱性は、アプリケーション全体のセキュリティリスクとなります。自動化された脆弱性チェックをCI/CDパイプラインに組み込みます。
Dependabotの設定#
GitHubリポジトリでDependabotを有効化し、依存ライブラリの脆弱性を自動検出します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
|
# .github/dependabot.yml
version: 2
updates:
# Maven依存関係の更新
- package-ecosystem: "maven"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
time: "09:00"
timezone: "Asia/Tokyo"
open-pull-requests-limit: 10
# セキュリティアップデートは自動マージ対象
groups:
security-updates:
applies-to: security-updates
patterns:
- "*"
labels:
- "dependencies"
- "security"
commit-message:
prefix: "deps"
include: "scope"
# Gradle依存関係の更新(Gradleプロジェクトの場合)
- package-ecosystem: "gradle"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 10
# GitHub Actionsの更新
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
|
セキュリティアップデートの自動マージを設定するワークフローも追加します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
|
# .github/workflows/dependabot-auto-merge.yml
name: Dependabot Auto Merge
on:
pull_request:
types: [opened, synchronize, reopened]
permissions:
contents: write
pull-requests: write
jobs:
auto-merge:
runs-on: ubuntu-latest
if: github.actor == 'dependabot[bot]'
steps:
- name: Dependabot metadata
id: metadata
uses: dependabot/fetch-metadata@v2
with:
github-token: "${{ secrets.GITHUB_TOKEN }}"
- name: Auto-merge security updates
if: steps.metadata.outputs.update-type == 'version-update:semver-patch' ||
steps.metadata.outputs.update-type == 'version-update:semver-minor'
run: gh pr merge --auto --squash "$PR_URL"
env:
PR_URL: ${{ github.event.pull_request.html_url }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
OWASP Dependency-Checkの導入#
OWASP Dependency-Checkは、NVD(National Vulnerability Database)と連携して、より詳細な脆弱性分析を行います。
Mavenプロジェクトでの設定#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
|
<!-- pom.xml -->
<build>
<plugins>
<plugin>
<groupId>org.owasp</groupId>
<artifactId>dependency-check-maven</artifactId>
<version>12.1.9</version>
<configuration>
<!-- CVSSスコア7以上でビルド失敗 -->
<failBuildOnCVSS>7</failBuildOnCVSS>
<!-- レポート形式 -->
<formats>
<format>HTML</format>
<format>JSON</format>
</formats>
<!-- 除外設定ファイル -->
<suppressionFile>dependency-check-suppressions.xml</suppressionFile>
<!-- NVD API Key(推奨) -->
<nvdApiKey>${env.NVD_API_KEY}</nvdApiKey>
</configuration>
<executions>
<execution>
<goals>
<goal>check</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
|
Gradleプロジェクトでの設定#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
// build.gradle
plugins {
id 'org.owasp.dependencycheck' version '12.1.9'
}
dependencyCheck {
// CVSSスコア7以上でビルド失敗
failBuildOnCVSS = 7.0f
// レポート形式
formats = ['HTML', 'JSON']
// 除外設定ファイル
suppressionFile = 'dependency-check-suppressions.xml'
// NVD API Key(推奨)
nvd {
apiKey = System.getenv('NVD_API_KEY')
}
}
|
除外設定ファイル#
誤検知や対応済みの脆弱性を除外する設定ファイルを作成します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
|
<!-- dependency-check-suppressions.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<suppressions xmlns="https://dependency-check.github.io/schema/suppression/3.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="https://dependency-check.github.io/schema/suppression/3.0
https://dependency-check.github.io/schema/suppression/3.0/suppression.xsd">
<!-- 例: 誤検知の除外 -->
<suppress>
<notes>
<![CDATA[
この脆弱性は当該アプリケーションの使用方法では影響を受けない。
確認日: 2026-01-13
確認者: security-team
]]>
</notes>
<cve>CVE-2024-XXXXX</cve>
</suppress>
<!-- 例: 特定のライブラリの除外 -->
<suppress>
<notes>対応バージョンへのアップデートを計画中(2026-02-01予定)</notes>
<gav regex="true">^com\.example:vulnerable-lib:.*$</gav>
<cve>CVE-2024-YYYYY</cve>
</suppress>
</suppressions>
|
CI/CDパイプラインへの統合#
GitHub Actionsで脆弱性チェックを自動実行します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
|
# .github/workflows/security-check.yml
name: Security Check
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
schedule:
# 毎日午前3時(JST)に実行
- cron: '0 18 * * *'
jobs:
dependency-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
cache: 'maven'
- name: Run OWASP Dependency-Check
run: mvn dependency-check:check
env:
NVD_API_KEY: ${{ secrets.NVD_API_KEY }}
- name: Upload Dependency-Check Report
if: always()
uses: actions/upload-artifact@v4
with:
name: dependency-check-report
path: target/dependency-check-report.html
retention-days: 30
- name: Notify on vulnerability detection
if: failure()
uses: slackapi/slack-github-action@v2
with:
webhook: ${{ secrets.SLACK_WEBHOOK_URL }}
webhook-type: incoming-webhook
payload: |
{
"text": "脆弱性が検出されました: ${{ github.repository }}",
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "*リポジトリ:* ${{ github.repository }}\n*ブランチ:* ${{ github.ref_name }}\n*詳細:* <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|ワークフロー実行結果>"
}
}
]
}
|
セキュリティログの設計#
セキュリティインシデントの検知と事後分析のために、適切なログ設計が不可欠です。
認証イベントのログ記録#
Spring Securityの認証イベントをリスナーで捕捉し、構造化ログとして出力します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
|
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.event.EventListener;
import org.springframework.security.authentication.event.AbstractAuthenticationFailureEvent;
import org.springframework.security.authentication.event.AuthenticationSuccessEvent;
import org.springframework.security.authentication.event.LogoutSuccessEvent;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import jakarta.servlet.http.HttpServletRequest;
import java.util.Optional;
@Component
public class AuthenticationEventListener {
private static final Logger log = LoggerFactory.getLogger(AuthenticationEventListener.class);
@EventListener
public void onAuthenticationSuccess(AuthenticationSuccessEvent event) {
Authentication auth = event.getAuthentication();
String username = auth.getName();
String ipAddress = getClientIpAddress();
String userAgent = getUserAgent();
log.info("認証成功 - username={}, ip={}, userAgent={}, authorities={}",
username, ipAddress, userAgent, auth.getAuthorities());
}
@EventListener
public void onAuthenticationFailure(AbstractAuthenticationFailureEvent event) {
String username = event.getAuthentication().getName();
String ipAddress = getClientIpAddress();
String failureReason = event.getException().getClass().getSimpleName();
String failureMessage = event.getException().getMessage();
log.warn("認証失敗 - username={}, ip={}, reason={}, message={}",
username, ipAddress, failureReason, failureMessage);
}
@EventListener
public void onLogoutSuccess(LogoutSuccessEvent event) {
String username = event.getAuthentication().getName();
String ipAddress = getClientIpAddress();
log.info("ログアウト - username={}, ip={}", username, ipAddress);
}
private String getClientIpAddress() {
return getRequest()
.map(request -> {
String xForwardedFor = request.getHeader("X-Forwarded-For");
if (xForwardedFor != null && !xForwardedFor.isEmpty()) {
return xForwardedFor.split(",")[0].trim();
}
return request.getRemoteAddr();
})
.orElse("unknown");
}
private String getUserAgent() {
return getRequest()
.map(request -> request.getHeader("User-Agent"))
.orElse("unknown");
}
private Optional<HttpServletRequest> getRequest() {
return Optional.ofNullable(RequestContextHolder.getRequestAttributes())
.filter(ServletRequestAttributes.class::isInstance)
.map(ServletRequestAttributes.class::cast)
.map(ServletRequestAttributes::getRequest);
}
}
|
構造化ログの設定#
本番環境では、JSON形式の構造化ログを出力することで、ログ分析ツールとの連携が容易になります。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
|
<!-- src/main/resources/logback-spring.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<springProfile name="production">
<appender name="JSON" class="ch.qos.logback.core.ConsoleAppender">
<encoder class="net.logstash.logback.encoder.LogstashEncoder">
<includeMdcKeyName>traceId</includeMdcKeyName>
<includeMdcKeyName>spanId</includeMdcKeyName>
<includeMdcKeyName>userId</includeMdcKeyName>
<customFields>{"app":"myapp","env":"production"}</customFields>
</encoder>
</appender>
<!-- セキュリティログ専用アペンダー -->
<appender name="SECURITY_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>/var/log/myapp/security.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>/var/log/myapp/security.%d{yyyy-MM-dd}.log.gz</fileNamePattern>
<maxHistory>90</maxHistory>
</rollingPolicy>
<encoder class="net.logstash.logback.encoder.LogstashEncoder"/>
</appender>
<logger name="com.example.security" level="INFO" additivity="false">
<appender-ref ref="SECURITY_FILE"/>
<appender-ref ref="JSON"/>
</logger>
<root level="INFO">
<appender-ref ref="JSON"/>
</root>
</springProfile>
</configuration>
|
不正アクセス検知とアラート#
連続した認証失敗を検知し、アラートを発報する仕組みを実装します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
|
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.event.EventListener;
import org.springframework.security.authentication.event.AbstractAuthenticationFailureEvent;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
@Component
public class BruteForceDetector {
private static final Logger log = LoggerFactory.getLogger(BruteForceDetector.class);
private static final int MAX_ATTEMPTS = 5;
private static final Duration WINDOW = Duration.ofMinutes(15);
private final Map<String, AttemptInfo> attempts = new ConcurrentHashMap<>();
private final AlertService alertService;
public BruteForceDetector(AlertService alertService) {
this.alertService = alertService;
}
@EventListener
public void onAuthenticationFailure(AbstractAuthenticationFailureEvent event) {
String username = event.getAuthentication().getName();
AttemptInfo info = attempts.compute(username, (key, existing) -> {
if (existing == null || existing.isExpired()) {
return new AttemptInfo();
}
existing.increment();
return existing;
});
if (info.getCount() >= MAX_ATTEMPTS) {
log.error("ブルートフォース攻撃の可能性 - username={}, attempts={}, window={}分",
username, info.getCount(), WINDOW.toMinutes());
alertService.sendAlert(
"ブルートフォース攻撃検知",
String.format("ユーザー '%s' に対して%d回の認証失敗が%d分以内に発生しました",
username, info.getCount(), WINDOW.toMinutes())
);
}
}
private static class AttemptInfo {
private final Instant startTime = Instant.now();
private final AtomicInteger count = new AtomicInteger(1);
void increment() {
count.incrementAndGet();
}
int getCount() {
return count.get();
}
boolean isExpired() {
return Instant.now().isAfter(startTime.plus(WINDOW));
}
}
}
|
1
2
3
4
5
6
7
8
9
10
|
import org.springframework.stereotype.Service;
@Service
public class AlertService {
public void sendAlert(String title, String message) {
// Slack、PagerDuty、メールなどへの通知を実装
// 例: Slack Webhook
}
}
|
監査ログの実装#
重要な操作(ユーザー作成、権限変更、設定変更など)の監査ログを記録します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.util.Arrays;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Auditable {
String action();
String resource() default "";
}
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
|
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import java.util.Arrays;
@Aspect
@Component
public class AuditAspect {
private static final Logger auditLog = LoggerFactory.getLogger("AUDIT");
@Around("@annotation(auditable)")
public Object audit(ProceedingJoinPoint joinPoint, Auditable auditable) throws Throwable {
String username = getCurrentUsername();
String action = auditable.action();
String resource = auditable.resource();
String methodName = joinPoint.getSignature().getName();
Object[] args = joinPoint.getArgs();
long startTime = System.currentTimeMillis();
Object result = null;
String status = "SUCCESS";
String errorMessage = null;
try {
result = joinPoint.proceed();
return result;
} catch (Exception e) {
status = "FAILURE";
errorMessage = e.getMessage();
throw e;
} finally {
long duration = System.currentTimeMillis() - startTime;
auditLog.info(
"監査ログ - user={}, action={}, resource={}, method={}, args={}, " +
"status={}, duration={}ms, error={}",
username, action, resource, methodName,
Arrays.toString(args), status, duration, errorMessage
);
}
}
private String getCurrentUsername() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
return auth != null ? auth.getName() : "anonymous";
}
}
|
使用例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
import org.springframework.stereotype.Service;
@Service
public class UserService {
@Auditable(action = "CREATE_USER", resource = "USER")
public User createUser(CreateUserRequest request) {
// ユーザー作成処理
}
@Auditable(action = "UPDATE_ROLE", resource = "USER")
public void updateUserRole(Long userId, String newRole) {
// 権限変更処理
}
@Auditable(action = "DELETE_USER", resource = "USER")
public void deleteUser(Long userId) {
// ユーザー削除処理
}
}
|
インシデント対応の準備#
セキュリティインシデントが発生した際に迅速に対応できるよう、事前準備を行います。
インシデント対応フロー#
flowchart TD
A[インシデント検知] --> B{重大度判定}
B -->|Critical| C[即座にエスカレーション]
B -->|High| D[1時間以内に対応開始]
B -->|Medium| E[24時間以内に対応]
B -->|Low| F[次回スプリントで対応]
C --> G[対応チーム招集]
D --> G
G --> H[影響範囲の特定]
H --> I[一次対応・封じ込め]
I --> J[根本原因分析]
J --> K[恒久対策実施]
K --> L[事後レビュー・報告]
I --> M{アカウント侵害?}
M -->|Yes| N[セッション無効化]
M -->|Yes| O[パスワードリセット強制]
N --> J
O --> Jセッション強制無効化機能#
インシデント発生時に特定ユーザーのセッションを強制無効化する機能を実装します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
|
import org.springframework.security.core.session.SessionInformation;
import org.springframework.security.core.session.SessionRegistry;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class SessionInvalidationService {
private final SessionRegistry sessionRegistry;
public SessionInvalidationService(SessionRegistry sessionRegistry) {
this.sessionRegistry = sessionRegistry;
}
/**
* 特定ユーザーの全セッションを無効化
*/
public void invalidateUserSessions(String username) {
List<Object> principals = sessionRegistry.getAllPrincipals();
for (Object principal : principals) {
if (principal instanceof org.springframework.security.core.userdetails.UserDetails userDetails) {
if (userDetails.getUsername().equals(username)) {
List<SessionInformation> sessions =
sessionRegistry.getAllSessions(principal, false);
for (SessionInformation session : sessions) {
session.expireNow();
}
}
}
}
}
/**
* 全ユーザーのセッションを無効化(緊急時)
*/
public void invalidateAllSessions() {
List<Object> principals = sessionRegistry.getAllPrincipals();
for (Object principal : principals) {
List<SessionInformation> sessions =
sessionRegistry.getAllSessions(principal, false);
for (SessionInformation session : sessions) {
session.expireNow();
}
}
}
}
|
SessionRegistryをBean登録します。
1
2
3
4
5
6
7
8
9
10
11
12
13
|
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.core.session.SessionRegistry;
import org.springframework.security.core.session.SessionRegistryImpl;
@Configuration
public class SessionConfig {
@Bean
public SessionRegistry sessionRegistry() {
return new SessionRegistryImpl();
}
}
|
パスワードリセット強制機能#
アカウント侵害が疑われる場合に、パスワードリセットを強制する機能を実装します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
|
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class PasswordResetService {
private final UserRepository userRepository;
private final SessionInvalidationService sessionInvalidationService;
public PasswordResetService(
UserRepository userRepository,
SessionInvalidationService sessionInvalidationService) {
this.userRepository = userRepository;
this.sessionInvalidationService = sessionInvalidationService;
}
/**
* パスワードリセットを強制(次回ログイン時に変更必須)
*/
@Transactional
public void forcePasswordReset(String username) {
userRepository.findByUsername(username).ifPresent(user -> {
user.setCredentialsExpired(true);
userRepository.save(user);
// 既存セッションを無効化
sessionInvalidationService.invalidateUserSessions(username);
});
}
/**
* 複数ユーザーのパスワードリセットを強制
*/
@Transactional
public void forcePasswordResetBatch(List<String> usernames) {
for (String username : usernames) {
forcePasswordReset(username);
}
}
}
|
緊急連絡体制の設定#
インシデント発生時の連絡体制を設定ファイルで管理します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
# application-production.yml
security:
incident:
contacts:
- name: "セキュリティチーム"
email: "security@example.com"
slack: "#security-alerts"
phone: "03-XXXX-XXXX"
- name: "インフラチーム"
email: "infra@example.com"
slack: "#infra-alerts"
escalation:
critical:
notify-within-minutes: 5
contacts: ["security@example.com", "cto@example.com"]
high:
notify-within-minutes: 60
contacts: ["security@example.com"]
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
|
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.util.List;
@Component
@ConfigurationProperties(prefix = "security.incident")
public class IncidentContactProperties {
private List<Contact> contacts;
private Escalation escalation;
// getters, setters
public static class Contact {
private String name;
private String email;
private String slack;
private String phone;
// getters, setters
}
public static class Escalation {
private EscalationLevel critical;
private EscalationLevel high;
// getters, setters
}
public static class EscalationLevel {
private int notifyWithinMinutes;
private List<String> contacts;
// getters, setters
}
}
|
本番デプロイ前チェックリスト(まとめ)#
最後に、本番デプロイ前に確認すべき項目をまとめます。
PasswordEncoder設定#
セキュリティ設定#
依存ライブラリ#
ログ・監視#
インシデント対応#
まとめ#
本記事では、Spring Securityを本番環境で安全に運用するためのチェックリストとベストプラクティスを解説しました。セキュリティは一度設定して終わりではなく、継続的な監視と改善が必要です。定期的にこのチェックリストを見直し、新たな脅威に対応できる体制を維持してください。
参考リンク#