본문으로 건너뛰기
김신건의 로그

[Spring] Security: Filter Chain, JWT, OAuth2

· 수정 · 📖 약 1분 · 542자/단어 #spring #security #authentication #authorization #jwt
spring security, SecurityFilterChain, JWT, OAuth2, PasswordEncoder, authentication

정의

Spring Security는 인증·인가의 사실상 표준. 서블릿 필터 체인 위에 다양한 인증/권한 처리를 모듈식으로. 6.0+ 부터 Lambda DSL 표준, SecurityFilterChain Bean 방식.

설정 (6.x)

implementation("org.springframework.boot:spring-boot-starter-security")

기본 자동 구성: 모든 endpoint 인증 필요, 폼 로그인, 임의 생성 비밀번호 출력.

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/", "/public/**", "/login", "/signup").permitAll()
                .requestMatchers("/admin/**").hasRole("ADMIN")
                .requestMatchers(HttpMethod.POST, "/api/**").authenticated()
                .anyRequest().authenticated()
            )
            .formLogin(form -> form
                .loginPage("/login")
                .defaultSuccessUrl("/")
                .permitAll()
            )
            .logout(logout -> logout
                .logoutSuccessUrl("/")
                .permitAll()
            )
            .csrf(csrf -> csrf.ignoringRequestMatchers("/api/**"))
            .headers(headers -> headers
                .contentSecurityPolicy(csp -> csp.policyDirectives("default-src 'self'"))
                .frameOptions(frame -> frame.deny())
            );
        return http.build();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

UserDetailsService

DB에서 사용자 로드.

@Service
public class CustomUserDetailsService implements UserDetailsService {
    private final UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String username) {
        User user = userRepository.findByEmail(username)
            .orElseThrow(() -> new UsernameNotFoundException(username));

        return org.springframework.security.core.userdetails.User
            .withUsername(user.getEmail())
            .password(user.getPassword())
            .roles(user.getRole().name())
            .build();
    }
}

PasswordEncoder

@Bean
public PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder();    // 권장
    // return new DelegatingPasswordEncoder("bcrypt", Map.of(...));
}

// 저장
String encoded = passwordEncoder.encode(plainPassword);

// 검증 (Spring Security가 자동)
passwordEncoder.matches(plain, encoded);

BCrypt, SCrypt, Argon2 (argon2 의존성 추가 시). 절대 평문 저장 X.

CSRF

기본 활성. POST/PUT/DELETE에 CSRF 토큰 필요. SPA/API에서 사용 안 하면 disable:

.csrf(csrf -> csrf.disable())    // JWT 등 stateless
.csrf(csrf -> csrf.ignoringRequestMatchers("/api/**"))

CORS

@Bean
public CorsConfigurationSource corsConfigurationSource() {
    CorsConfiguration config = new CorsConfiguration();
    config.setAllowedOrigins(List.of("https://app.example.com"));
    config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE"));
    config.setAllowedHeaders(List.of("*"));
    config.setAllowCredentials(true);

    UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
    source.registerCorsConfiguration("/api/**", config);
    return source;
}

// SecurityFilterChain
.cors(cors -> cors.configurationSource(corsConfigurationSource()))

JWT 인증

implementation("org.springframework.boot:spring-boot-starter-oauth2-resource-server")
spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://auth.example.com/realms/myrealm
          # 또는
          jwk-set-uri: https://auth.example.com/.well-known/jwks.json
.oauth2ResourceServer(oauth2 -> oauth2
    .jwt(jwt -> jwt
        .jwtAuthenticationConverter(jwtAuthenticationConverter())
    )
)

Token에서 권한 추출:

JwtAuthenticationConverter jwtAuthenticationConverter() {
    JwtGrantedAuthoritiesConverter authoritiesConverter = new JwtGrantedAuthoritiesConverter();
    authoritiesConverter.setAuthoritiesClaimName("roles");
    authoritiesConverter.setAuthorityPrefix("ROLE_");

    JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
    converter.setJwtAuthenticationConverter(authoritiesConverter);
    return converter;
}

자체 JWT 발급/검증 (간단)

복잡한 OAuth 없이 자체 JWT.

implementation("com.auth0:java-jwt:4.4.0")
public class JwtService {
    private final Algorithm algorithm;

    public String issue(String userId) {
        return JWT.create()
            .withSubject(userId)
            .withIssuedAt(Instant.now())
            .withExpiresAt(Instant.now().plus(1, ChronoUnit.HOURS))
            .sign(algorithm);
    }

    public DecodedJWT verify(String token) {
        return JWT.require(algorithm).build().verify(token);
    }
}

OncePerRequestFilter 커스텀 필터:

public class JwtAuthFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain) {
        String auth = req.getHeader("Authorization");
        if (auth != null && auth.startsWith("Bearer ")) {
            try {
                DecodedJWT decoded = jwtService.verify(auth.substring(7));
                Authentication authentication = new UsernamePasswordAuthenticationToken(
                    decoded.getSubject(), null, List.of(new SimpleGrantedAuthority("ROLE_USER")));
                SecurityContextHolder.getContext().setAuthentication(authentication);
            } catch (JWTVerificationException e) {
                // ignore, 인증 안 됨 상태
            }
        }
        chain.doFilter(req, res);
    }
}
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)

Method security

@EnableMethodSecurity

@PreAuthorize("hasRole('ADMIN')")
public void deleteUser(Long id) { ... }

@PreAuthorize("hasAuthority('SCOPE_users:write')")
public User create(...) { ... }

@PreAuthorize("#userId == authentication.principal.id")
public User get(Long userId) { ... }

@PostAuthorize("returnObject.owner == authentication.name")
public Document load(Long id) { ... }

@Secured("ROLE_ADMIN")    // 옛 어노테이션
public void admin() { ... }

SpEL로 복잡한 조건 표현 가능. 비즈니스 로직 메서드에 인가 표현.

OAuth2 Login (소셜)

implementation("org.springframework.boot:spring-boot-starter-oauth2-client")
spring:
  security:
    oauth2:
      client:
        registration:
          google:
            client-id: ${GOOGLE_CLIENT_ID}
            client-secret: ${GOOGLE_CLIENT_SECRET}
            scope: openid, profile, email
          github:
            client-id: ${GITHUB_CLIENT_ID}
            client-secret: ${GITHUB_CLIENT_SECRET}
.oauth2Login(oauth2 -> oauth2
    .loginPage("/login")
    .defaultSuccessUrl("/")
    .userInfoEndpoint(userInfo -> userInfo.userService(customUserService()))
)

/oauth2/authorization/google로 리다이렉트 → Google 로그인 → 콜백 → Spring이 사용자 정보 가져옴.

현재 사용자 조회

@GetMapping("/me")
public User me(@AuthenticationPrincipal UserDetails user) {
    return userService.findByEmail(user.getUsername());
}

// 또는
@GetMapping("/me")
public User me(Authentication auth) {
    return userService.findByEmail(auth.getName());
}

// SecurityContextHolder
Authentication auth = SecurityContextHolder.getContext().getAuthentication();

Rate limiting

표준은 없음. bucket4j 또는 API Gateway / nginx.

헤더 보안

.headers(headers -> headers
    .contentSecurityPolicy(csp -> csp.policyDirectives("default-src 'self'"))
    .httpStrictTransportSecurity(hsts -> hsts.maxAgeInSeconds(31536000).includeSubDomains(true))
    .frameOptions(frame -> frame.sameOrigin())
    .referrerPolicy(ref -> ref.policy(ReferrerPolicy.NO_REFERRER))
)

함정

1. 비밀번호 평문 저장

DB에 평문 → 절대 X. PasswordEncoder 필수.

2. 토큰 만료 처리

JWT는 stateless라 서버에서 폐기 어려움. blacklist + 짧은 만료 + refresh token.

3. CSRF disable의 함정

.csrf(csrf -> csrf.disable())

세션 기반인데 CSRF 끄면 위험. JWT/API 전용일 때만.

4. PreAuthorize SpEL 에러

@PreAuthorize("#user.id == authentication.principal.id")
public void update(User user) { ... }

런타임에 평가. 표현식 오류 발견 어려움. 테스트 필수.

5. Filter 순서

커스텀 필터의 위치를 명시. 잘못된 순서는 인증 우회 가능.

.addFilterBefore(myFilter, UsernamePasswordAuthenticationFilter.class)
.addFilterAfter(...)

보안 체크리스트

  • HTTPS 강제
  • HSTS
  • CSRF (또는 stateless + 안전 헤더)
  • CORS allowedOrigins 명시
  • 비밀번호 BCrypt/Argon2
  • JWT 짧은 만료
  • Method security
  • SQL injection: Spring Data parameterized
  • XSS: Thymeleaf 자동 escape
  • 의존성 보안 업데이트
  • 로깅에 비밀 정보 X
  • 에러 응답에 stacktrace X

SecurityContext와 ThreadLocal

SecurityContextHolder 는 인증된 Authentication 객체를 ThreadLocal 에 저장. 호출 스택 어디서든 꺼낼 수 있다:

Authentication auth = SecurityContextHolder.getContext().getAuthentication();
String username = auth.getName();

매 요청마다 SecurityContextPersistenceFilter (session 기반) 또는 JwtAuthenticationFilter (stateless) 가 set, 응답 후 clear. @Async 메서드에서는 ThreadLocal 전파 안 됨 (DelegatingSecurityContextExecutor 또는 TaskDecorator 필요).

자세한 메커니즘: java-threadlocal, spring-request-context.

관련 위키

이 글의 용어 (7개)
[Java] Servlet Filterjava
정의 Filter 는 servlet 호출 전/후 에 끼어들어 요청과 응답을 가로채는 컴포넌트. 인증, 로깅, 압축, CORS, 인코딩 변환 같은 횡단 관심사 (cross-cutt…
[Java] ThreadLocaljava
정의 는 각 스레드마다 독립된 변수 사본을 가지게 해 주는 컨테이너. 로 접근하며, 한 스레드의 값은 다른 스레드에서 볼 수 없다. 쓰임은 두 가지: (1) 같은 인스턴스를 여러…
[Spring] Bean Validation: @Valid, @NotBlank, customspring
정의 Spring은 Jakarta Bean Validation (JSR-380)을 표준으로 사용. , , 등 어노테이션으로 선언적 검증. Spring MVC가 로 자동 통합. 설…
[Spring] DispatcherServlet: Front Controller 내부spring
정의 가 / 사용법 을 다룬다면, 이 페이지는 그 모든 요청을 받아 처리하는 의 내부 메커니즘 을 다룬다. 은 본질적으로 하나의 Servlet (). Front Controlle…
[Spring] RequestContextHolder, LocaleContextHolder: ThreadLocal 기반 요청 컨텍스트spring
정의 Spring 은 HTTP 요청 / 응답 / locale / 트랜잭션 / 인증 같은 요청 범위 데이터를 ThreadLocal 에 저장 해 전역 노출한다. 메서드 인자로 일일이…
[Spring] Spring MVC: @RestController, @RequestMappingspring
정의 Spring MVC는 Servlet 기반 동기 웹 프레임워크. 이 요청을 받아 핸들러(컨트롤러 메서드)에 라우팅. REST API와 전통 MVC(HTML) 모두 지원. We…
[Spring] WebFlux: Reactive, Mono, Fluxspring
정의 Spring WebFlux는 Project Reactor 기반의 비동기·non-blocking 웹 프레임워크. Spring MVC와 별개. Netty 기본 (Tomcat도 …

💬 댓글

사이트 검색 / 명령어

검색

스크롤 = 확대/축소 · 드래그 = 이동 · 0 = 원래 크기 · ESC = 닫기