[Spring] Security: Filter Chain, JWT, OAuth2
정의
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도 …
이 개념을 다룬 위키 페이지 (12)
- wiki[Java] Servlet Filter
- wiki[Java] ThreadLocal
- wiki[Spring Boot] Actuator 커스텀: Endpoint, HealthIndicator, Metric
- wiki[Spring Boot] SSL/HTTPS 설정
- wiki[Spring] HandlerInterceptor: preHandle, postHandle, afterCompletion
- wiki[JPA] Entity Lifecycle Callbacks와 Auditing
- wiki[Spring] Spring MVC: @RestController, @RequestMapping
- wiki[Spring] RequestContextHolder, LocaleContextHolder: ThreadLocal 기반 요청 컨텍스트
- wiki[Spring Security] OAuth2 Authorization Code Flow + PKCE
- wiki[Spring Security] Session Management와 Remember-Me
- wiki[Spring Security] Testing: @WithMockUser, MockMvc, jwt(), oauth2Login()
- wiki[Spring MVC] Thymeleaf: 서버 사이드 템플릿 엔진
💬 댓글