SecurityFilterChain과 401·403
DB에 저장할 수 있는 API가 되었으니 이제 요청을 업무 코드에 넘기기 전에 걸러야 합니다. Spring Security는 Controller 안에 if문을 퍼뜨리는 대신 servlet filter chain에서 인증과 URL 인가를 처리합니다. 이 절의 목표는 필터 체인 하나로 401과 403의 의미를 정확히 나누는 것입니다.
보안 판단은 Controller보다 먼저 일어난다
인증은 “이 token을 믿을 수 있는가”를, 인가는 “그 주체가 이 작업을 해도 되는가”를 판단합니다. token이 없거나 검증에 실패하면 401, 주체는 확인됐지만 scope가 부족하면 403입니다. 이 둘을 모두 403으로 보내면 client는 token을 다시 받아야 할지, 권한을 요청해야 할지 알 수 없습니다.
상태 비저장 API의 필터 체인을 구성한다
Bearer token API는 요청마다 token을 검증하므로 HTTP session을 만들지 않습니다. GET은 study.read, 나머지 변경 메서드는 study.write로 나누고, 어떤 matcher에도 잡히지 않은 요청은 기본 거부합니다. 규칙은 구체적인 경로에서 넓은 경로 순으로 읽히므로 health 예외를 API matcher보다 앞에 놓습니다.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security-oauth2-resource-server</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://id.example.test/realms/study-log
jwk-set-uri: https://id.example.test/realms/study-log/protocol/openid-connect/certs
audiences: study-log-apijwk-set-uri를 함께 지정하면 시작 과정이 외부 issuer discovery에 묶이지 않습니다. 실제 Bearer 요청이 오기 전에는 원격 키를 읽지 않으므로 이 절의 health·익명 요청 확인을 실행할 수 있고, 다음 절에서 운영 검증과 로컬 fixture를 완성합니다.
package com.andongmin.studylog.security;
import static org.springframework.security.config.Customizer.withDefaults;
import org.springframework.boot.actuate.endpoint.web.WebServerNamespace;
import org.springframework.boot.health.actuate.endpoint.HealthEndpoint;
import org.springframework.boot.security.autoconfigure.actuate.web.servlet.EndpointRequest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
@Configuration(proxyBeanMethods = false)
public class SecurityConfiguration {
@Bean
SecurityFilterChain apiSecurity(HttpSecurity http) throws Exception {
return http
.csrf(csrf -> csrf.disable())
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(requests -> requests
.requestMatchers(EndpointRequest.to(HealthEndpoint.class)).permitAll()
.requestMatchers(EndpointRequest.toAdditionalPaths(
WebServerNamespace.SERVER, HealthEndpoint.class)).permitAll()
.requestMatchers(HttpMethod.GET, "/api/study-sessions/**")
.hasAuthority("SCOPE_study.read")
.requestMatchers("/api/study-sessions/**")
.hasAuthority("SCOPE_study.write")
.anyRequest().denyAll())
.oauth2ResourceServer(resourceServer -> resourceServer.jwt(withDefaults()))
.exceptionHandling(errors -> errors
.authenticationEntryPoint((request, response, failure) ->
response.sendError(401, "authentication required"))
.accessDeniedHandler((request, response, failure) ->
response.sendError(403, "insufficient authority")))
.build();
}
}토큰 없음과 권한 부족을 따로 호출한다
서버는 terminal A에서 계속 실행하고, terminal B에서 응답을 비교합니다. health가 200인데 익명 API가 401이라면 공개 matcher와 인증 진입점이 올바른 것입니다. 403은 아직 유효한 token이 없으므로 다음 절에서 직접 재현합니다.
./mvnw -q spring-boot:runcurl -i http://localhost:8080/actuator/health
curl -i http://localhost:8080/api/study-sessionshealth는 200, 토큰 없는 API 요청은 401이어야 합니다. 유효한 토큰에 권한이 없을 때는 403이어야 합니다.공개 경로는 최소로 유지한다
일단 모두 허용한 뒤 민감한 경로를 막기보다 일단 막고 필요한 경로만 열기가 안전합니다. health와 명시적 공개 문서만 예외로 두고, 새 endpoint는 의도한 scope를 정한 후에만 엽니다.
필터 체인은 완성됐지만 아직 token의 서명과 claim을 어떻게 믿을지를 정하지 않았습니다. 이 빈칸을 issuer와 JWT decoder로 채웁니다.