Spring Boot と Spring Security による堅牢なアプリケーション構築

アプリケーションセキュリティは、現代のエンタープライズシステムにおいて不可欠な要素です。特にWebアプリケーションでは、不正アクセスやデータ漏洩リスクが高まる中、適切な認証・認可の実装が求められます。本記事では、Spring BootアプリケーションにSpring Securityを統合し、実践的なセキュリティ対策を実現する方法を解説します。

  1. セキュリティの基本概念 アプリケーションの安全確保には以下の特性が重要です:

認証:ユーザーのアイデンティティを検証 認可:許可された操作・リソースへのアクセス制御 データ保護:機密情報の漏洩防止 攻撃対策:XSS、CSRF、SQLインジェクションなどへの防御 セッション管理:不正なセッション乗っ取りの防止

Spring Security は、上記すべてを包括的にカバーする、Spring公式のセキュリティフレームワークです。Spring Boot との統合により、環境構築と基本設定が非常に簡潔になります。

  1. 基本設定と初期動作 まずは依存関係の追加から開始します:
<dependencies>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
  </dependency>
</dependencies>

この依存を追加すると、デフォルトで以下の動作が有効になります:

すべてのHTTPリクエストが認証を必要とする 自動生成される一時ログインページが提供される デフォルトユーザー名:user、パスワード:コンソール出力で確認 CSRFトークンの自動発行と検証 HTTP Basic認証の有効化

簡易的なアプリケーションでは、プロパティファイルでユーザー情報を定義できます:

spring.security.user.name=steward
spring.security.user.password=s3cr3t
spring.security.user.roles=MANAGER

ただし、本番環境では静的設定に依存せず、動的かつ永続的なアカウント管理への対応が求められます。

  1. カスタムセキュリティ戦略 より細かな制御を行うには、Javaコンフィギュレーションを使用します。Spring Security 5.7以降では、従来のWebSecurityConfigurerAdapterに代わる新しい方法が推奨されています。
@Configuration
@EnableWebSecurity
public class SecurityConfiguration {

    @Bean
    public SecurityFilterChain chain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/static/**", "/health").permitAll()
                .requestMatchers("/admin/**").hasAuthority("ROLE_ADMIN")
                .requestMatchers("/api/**").authenticated()
                .anyRequest().denyAccess()
            )
            .formLogin(form -> form
                .loginPage("/login") // オリジナルログイン画面を指定可能
                .defaultSuccessUrl("/dashboard", true)
            )
            .logout(logout -> logout
                .logoutUrl("/logout")
                .logoutSuccessUrl("/")
            )
            .csrf(csrf -> csrf.ignoringRequestMatchers("/api/**")); // API系はCSRF豁免

        return http.build();
    }

    @Bean
    public PasswordEncoder encoder() {
        return new Argon2PasswordEncoder(); // BCryptに代わる推奨方式
    }

    @Bean
    public UserDetailsService userDetailsService(UserRepository repo) {
        return username -> repo.findByLogin(username)
            .map(user -> User.builder()
                .username(user.getLogin())
                .password(user.getPassword())
                .authorities(user.getRoles().stream()
                    .map(r -> "ROLE_" + r.toUpperCase()).toList())
                .build())
            .orElseThrow(() -> new UsernameNotFoundException(username));
    }
}

この構成により、細かいURLごとのアクセス制御と、永続化されたユーザーデータの統合が実現できます。

  1. ユーザー認証:実践的アプローチ 4.1 パスワードハッシュと暗号化 平文パスワードの保存は厳禁です。Spring Securityは多重ハッシュ方式に対応しており、DelegatingPasswordEncoderを活用することで、将来的なアルゴリズム移行も容易です。
PasswordEncoder encoder = PasswordEncoderFactories.createDelegatingPasswordEncoder();
// 出力例:{bcrypt}$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fzLmGqO

4.2 JWTによる状態LESS認証 REST APIやマイクロサービス間通信では、セッション非依存のJWT認証が広く採用されています。

首先、依存関係の追加:

<dependency>
  <groupId>com.fasterxml.jackson.core</groupId>
  <artifactId>jackson-databind</artifactId>
</dependency>
<dependency>
  <groupId>io.jsonwebtoken</groupId>
  <artifactId>jjwt-jackson</artifactId>
  <version>0.12.3</version>
</dependency>
<dependency>
  <groupId>io.jsonwebtoken</groupId>
  <artifactId>jjwt-api</artifactId>
  <version>0.12.3</version>
</dependency>
<dependency>
  <groupId>io.jsonwebtoken</groupId>
  <artifactId>jjwt-impl</artifactId>
  <version>0.12.3</version>
</dependency>

JWTトークンの生成と検証クラス:

@Component
public class TokenIssuer {

    private final String issuer;
    private final Key signingKey;

    public TokenIssuer(@Value("${security.jwt.issuer}") String issuer,
                       @Value("${security.jwt.secret}") String secret) {
        this.issuer = issuer;
        this.signingKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
    }

    public String issue(UserDetails principal, Duration validity) {
        Date now = new Date();
        Date expiry = new Date(now.getTime() + validity.toMillis());

        return Jwts.builder()
            .setIssuer(issuer)
            .setSubject(principal.getUsername())
            .claim("auth", principal.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority).toList())
            .setIssuedAt(now)
            .setExpiration(expiry)
            .signWith(signingKey, SignatureAlgorithm.HS256)
            .compact();
    }

    public JwtParser parser() {
        return Jwts.parserBuilder()
            .setSigningKey(signingKey)
            .build();
    }
}

HTTPリクエストごとのJWT検証フィルター:

@Component
public class JwtAuthFilter extends OncePerRequestFilter {

    private final TokenIssuer tokenIssuer;
    private final CustomUserDetails loadUser;

    public JwtAuthFilter(TokenIssuer issuer, CustomUserDetails service) {
        this.tokenIssuer = issuer;
        this.loadUser = service;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest req,
                                    HttpServletResponse resp,
                                    FilterChain chain) {
        String header = req.getHeader("Authorization");
        if (header != null && header.startsWith("Bearer ")) {
            String token = header.substring(7);
            try {
                Jws<Claims> claims = tokenIssuer.parser()
                    .parseClaimsJws(token);
                String subject = claims.getBody().getSubject();
                UserDetails details = loadUser.loadUserByUsername(subject);
                UsernamePasswordAuthenticationToken auth =
                    new UsernamePasswordAuthenticationToken(
                        details, null, details.getAuthorities()
                    );
                auth.setDetails(new WebAuthenticationDetailsSource().buildDetails(req));
                SecurityContextHolder.getContext().setAuthentication(auth);
            } catch (JwtException ex) {
                // 不正または期限切れトークンの場合は、認証失敗として処理
            }
        }
        chain.doFilter(req, resp);
    }
}

このフィルターをセキュリティチェーンに組み込むことで、状態LESSな認証が可能になります。

4.3 OAuth 2.0 クライアントと統合 Google、GitHub、Okta等の外部 IdPと連携するために、Spring BootのOAuth2 Client統合を使います。

spring:
  security:
    oauth2:
      client:
        registration:
          github:
            client-id: ${GITHUB_CLIENT_ID}
            client-secret: ${GITHUB_CLIENT_SECRET}
            scope: user:email,read:user
            client-name: GitHub Login
          google:
            client-id: ${GOOGLE_CLIENT_ID}
            client-secret: ${GOOGLE_CLIENT_SECRET}
            scope: openid,profile,email
        provider:
          google:
            authorization-uri: https://accounts.google.com/o/oauth2/v2/auth
            token-uri: https://oauth2.googleapis.com/token
            user-info-uri: https://www.googleapis.com/oauth2/v3/userinfo
            jwk-set-uri: https://www.googleapis.com/oauth2/v3/certs
            user-name-attribute: email
@Bean
SecurityFilterChain OAuth2Chain(HttpSecurity http) throws Exception {
    http
        .authorizeHttpRequests(auth -> auth
            .requestMatchers("/", "/login**", "/webjars/**").permitAll()
            .anyRequest().authenticated()
        )
        .oauth2Login(oauth2 -> oauth2
            .loginPage("/login")
            .defaultSuccessUrl("/profile", true)
        )
        .logout(st -> st.logoutUrl("/logout").logoutSuccessUrl("/"));
    return http.build();
}

ユーザー情報の取得は、以下のように@AuthenticationPrincipalで取得可能です:

@GetMapping("/profile")
public String profile(@AuthenticationPrincipal OAuth2User oauth2User,
                      Model model) {
    model.addAttribute("name", oauth2User.getAttribute("name"));
    model.addAttribute("email", oauth2User.getAttribute("email"));
    return "profile";
}
  1. メソッド単位のアクセス制御 APIやサービス層で、個別のメソッド単位で権限を制御したい場合、@PreAuthorizeや@PostAuthorizeが必要です。

まず、AOPライブラリとEnable属性を追加します:

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-aop</artifactId>
</dependency>
@Configuration
@EnableMethodSecurity(prePostEnabled = true, securedEnabled = true)
public class MethodSecurityConfig { }

メソッド側で:

@Service
public class AdminService {

    @PreAuthorize("hasAuthority('ADMIN') and @permissionChecker.canAccess(#id)")
    public User dtoById(Long id) { ... }

    @PostFilter("filterObject.staffOnly or hasRole('ADMIN')")
    public List<User> listAll() { ... }
}

カスタム権限評価は、PermissionEvaluator実装で拡張可能です:

@Component("permissionChecker")
public class CustomPermissionChecker implements PermissionEvaluator {

    @Override
    public boolean hasPermission(Authentication auth,
                                 Object target, Object permission) {
        // リソース・権限の動的評価ロジック
    }
}
  1. セキュリティにおける推奨事項

最小権限の原則——必要な最小限の権限のみ付与 HTTP Strict Transport Security(HSTS)の有効化 CSPヘッダーによるスクリプト実行制御 Cookieフラグ:HttpOnly+Secure+SameSite=Strict 入力検証(JSR-303 Bean Validation活用) SQLインジェクションにははもちろん、ORMやJOINの安全な使用 ログに機微情報が記録されないよう、シャドウロギングの抑制 CVEデータベース(NVD)とOSSスキャナー(OWASP DC)での定期監視

  1. 統合Tipsと拡張ポイント présente以下のような構成で柔軟なセキュリティ制御が可能です:

カスタムAccessDeniedHandlerで403レスポンスを制御 AuthenticationSuccessHandlerでログイン時イベント実装(例:多要素認証必須チェック) SessionRegistryで同一ユーザーの多重ログイン制限 認証成功後にカスタムヘッダの注入(X-Auth-User-IDなど)

このように、Spring Securityは非常に高い拡張性を備えており、プロジェクト要件に応じた厳密なセキュリティARDS(Access Control Rules)の構築が可能になります。

タグ: spring-security JWT OAuth2 authentication authorization

7月20日 16:33 投稿