▼ Why ?
이번에 토이 프로젝트를 하면서 스프링 시큐리티 이용한 로그인을 구현하려고 하는데, 스프링 시큐리티에 대한 이해가 많이 부족하다고 느껴서 제대로 다시 공부해보려고 한다!
▼ What ?
- Spring Security Architecture
- Spring Security의 세션(Session)을 기반으로 하는 인증(Authentication) 과정과 인가(Authorization) 과정
- 주요한 Spring Security Filter
▼ 스프링 시큐리티 구조(Spring Security Architecture)
인증(Authentication)과 인가(Authorization)가 뭐지 ?
- 인증(Authentication): 자신이 A라고 주장하는 사용자가 A가 맞는지 확인하는 것.
- 인가(Authorization): 인증된 사용자가 요청된 특정 리소스에 접근할 권한이 있는지 결정하는 것.
“인증“과 ”인가“가 필요한 이유는 ?
- HTTP의 무상태성(stateless) !
➙ 여러 요청에 걸쳐 사용자가 누구인지 기억하는 메커니즘이 내장되어 있지 않기 때문에, 각 요청에서 사용자를 식별하기 위해선 인증이 필요하다 !
➙ 또한, 하나의 요청에서 다음 요청까지 사용자의 권한을 기억하지 못하기 때문에, 모든 요청엔 “사용자가 해당 작업을 할 수 있는지” 혹은 “해당 리소스에 접근할 수 있는지” 평가하기 위해 인가가 필요하다 !
HTTP (참고자료) - https://ukym-tistory.tistory.com/entry/Network-HTTP-HTTPS
[Network] 웹의 동작 원리 + HTTP & HTTPS
▼ Why ? What ? HTTP는 서버와 클라이언트가 데이터를 주고 받기 위한 프로토콜(protocol)이기 때문에, 웹이 동작하는 과정을 이해하려면 웹 개발을 하기 위해서 알아야 하는 가장 중요한 개념들 중 하
ukym-tistory.tistory.com
인증은 어떻게 이루어질까 ?
- Credential을 기반으로 하는 방식
: principal(아이디; username), credential(비밀번호; password) 이용을 이용하는 방식이다. - TwoFactor Authentication (2FA; 이중 인증)
: 사용자가 입력한 개인정보를 인증한 후에 다른 인증 체계를 이용하여 두 번 인증하는 방식이다.
ex) "Google Authenticator"
스프링 시큐리티(Spring Security)는 어떤 인증 방식을 ?
- 보편적인 "Credential" 기반의 인증 방식을 따르고 있다.
스프링 시큐리티(Spring Security) ?
- Spring Security는 “인증(who are you?)”과 “인가(what are you allowed to do)”을 분리하여 애플리케이션의 보안을 처리하는 Spring의 하위 프레임워크(Framework)이다 !
특정 리소스에 대해 접근하기 위해선 권한(인가)이 필요한데,
"유저"가 이 권한을 얻기 위해선 인증 정보(Authentication)를 제공해줘야 하고
"관리자"는 해당 정보를 참고해 권한을 인가(Authorization)하게 된다.
- Spring Security는 "필터(Filter)"라는 것을 기반으로 동작하기 때문에, Spirng MVC와 분리되어 작동할 수 있다는 장점이 있다 !
STEP 1: "서블릿 필터(Servlet Filter)"를 기반으로 동작하는 Spring Security
서블릿 (참고자료) - https://ukym-tistory.tistory.com/entry/Spring-%EC%84%9C%EB%B8%94%EB%A6%BFServlet
[Spring] 서블릿(Servlet)
▼ 서블릿(Servlet) 서블릿(Servlet) ? 클라이언트의 요청을 처리하고 그 결과를 반환해주는 프로그램. 동적 웹 페이지 서버에서 각 사용자의 요청을 하나의 스레드로 처리하는 자바(java) 웹 프로그램
ukym-tistory.tistory.com
- 서블릿 필터(Servlet Filter)가 하는 일은?
➙ WAS에 들어오는 요청에 대해 로깅, 인증, 권한 부여, 요쳥 변환 등과 같은 전처리 및 후처리 작업을 한다. - 서블릿 필터(Servlet Fliter)는 언제 작업 ?
➙ 서블릿이 호출되기 전(전처리) or 서블릿 응답 이후(후처리) - 여기서 문제 !
➙ 서블릿 컨테이너(Servlet Container)는 빈(Bean)을 인식하지 못해 빈을 직접 인스턴스화하거나 관리할 수가 없다.
( 왜? Servlet Container는 Bean이 초기화되기 이전에 작업하기 때문에 Bean을 인식하지 못하는 것은 당연하다 ! )
➙ "DelegatingFilterProxy"가 필요한 이유 !!
STEP 2
: "Dispatcher Servlet" vs "ContextLoaderListener"

Dispatcher Servlet ?

- 클라이언트 측에서 요청(Http Request)을 주면, "서블릿 컨테이너(Servlet Container)"인 톰캣(Tomcat)이 요청을 받는다.
➙ 이렇게 서버로 들어오는 모든 요청을 가장 앞단에서 처리하는 "Front Controller"라는 것을 Spring에서 정의한 것인데, 이 Front Controller가 바로 "DispatcherServlet"인 것이다 !
- 옛날엔 모든 Servlet에 대해 URL을 매핑해주기 위해선 web.xml이라는 것에 모두 등록해줘야 했다..
➙ 하지만, 아래 코드를 보면 알 수 있듯이, "Dispatcher Servlet"이 특정한 URL 패턴과 일치하는 웹 요청(Http Request)을 모두 처리(handling)해주기 때문에 작업의 효율이 높아졌다고 할 수 있다.
( Dispatcher Servlet을 이용한다는 것은 Spring에서 제공하는 MVC 패턴을 이용한다는 것으로 생각해볼 수 있다. )
@RestController
@RequiredArgsConstructor
@RequestMapping("/api")
public class UserController {
private final UserService userService;
@Operation(summary = "회원가입", description = "사용자가 회원가입을 진행합니다.")
@PostMapping("/register")
public BaseResponse<Long> signup(@RequestBody @Valid SignUpRequest request) {
return new BaseResponse<>(userService.signup(request.toUser()));
}
}
- Spring Boot에서 @SpringBootApplication이라는 애너테이션이 web.xml 파일을 대체할 수 있게 해줬던 것 !
@SpringBootApplication
public class KaraokeApplication {
public static void main(String[] args) {
SpringApplication.run(KaraokeApplication.class, args);
}
}
ContextLoaderListener ?
💡 "Context"가 뭐지 ?
"어떤 객체를 핸들링하기 위한 접근수단"이라고 할 수 있다.
따라서, AppicationContext는 Spring에서 "빈(Bean)"을 관리하기 위해 제공하는 인터페이스라고 볼 수 있다.
- Spring은 한 번에 여러 Context를 가질 수 있고, 그 중 하나는 "Root ApplicationContext"가 되고 다른 모든 ApplicationContext는 "Child ApplicationContext"가 된다.
➙ 이 Root ApplicationContext를 생성하는 클래스가 "ContextLoaderListner"인 것이다 !
( 나머지 Child ApplicationContext를 생성하는 것이 "DispatcherServlet"이다. )
- 아래 그림에서도 알 수 있듯이, ContextLoaderListner가 생성하는 ApplicationContext엔 흔히 아는 Service, Repository 같이 전역적으로 다뤄지는 빈(Bean)들이 포함되어 있다.

- Spring MVC와 관련된 컴포넌트들 대부분은 DispatcherServlet의 ApplicationContext에 의해 초기화되고,
나머지는 ContextLoaderListener에 의해 로드되며 Spring Security의 모든 설정도 ContextLoaderListener에 의해 초기화된다 !
Child ApplicationContext에선 Root ApplicationContext에 정의된 빈(Bean)에 접근할 수 있지만,
반대의 경우엔 직접 접근하는 것이 불가능하다고 한다 !
STEP 3
: Spring Bean에게 요청을 위임하기 위한 "DelegatingFilterProxy"
- 원래 필터(Filter)는 Servlet이 제공해주는 것으로 Servlet Container에 의해 생성되며 Servlet Container에 등록되었다.
➙ 이러한 필터도 Spring Bean을 주입받고 Bean으로 등록될 수 있도록 Spring Framework가 Servlet Filter의 구현체인 DelegatingFilterProxy를 제공하게 된 것 !
💡 근데 빈(bean)을 관리하는 건 "DI 컨테이너"가 아니었나?
실제로 빈(bean)을 관리하는 것은 DI 컨테이너가 맞고, "ApplicationContext"가 이러한 DI 컨테이너의 인터페이스 역할을 하는 것이다 !
( 그래서 보통 ApplicationContext 인터페이스를 "스프링 컨테이너(Spring Container)"라고 보는 것 같다. )
- DelegatingFilterProxy는 "Servlet Container"와 스프링의 "Application Context" 사이의 링크를 제공해주는 Servlet의 프록시(Proxy)용 필터(Filter)이자 필터를 사용하는 시작점이다 !
( "대리인"을 의미하는 "Proxy"의 뜻에서도 알 수 있듯이 Servlet Filter에게 받은 요청을 위임(보안 처리 X)해주기 위한 껍데기라고 보면 될 것 같다. )
- DelegatingFilterProxy는 그럼 이렇게 Servlet Container로 넘어온 사용자의 요청을 어디로 위임하는 것인가?
➙ Spring의 Filter Chain 역할을 하는 "SpringSecurityFilterChain" 이름으로 생성된 Bean을 ApplicationContext에서 찾는다.
➙ Bean을 찾으면 springSecurityFilterChain(= FilterChainProxy의 고정된 Bean 이름)으로 요청을 넘기게 된다 !

STEP 4
: DelegatingFitlerProxy에게 요청을 위임받는 "FilterChainProxy"
- "Filter Chain"은 여러 개의 Filter가 서로 연결되어 있고 순서대로 doFilter() 메서드를 이용해 실행시키는 인터페이스로, 쉽게 말해서 Filter 묶음이다.
➙ "FilterChainProxy"는 이처럼 "SecurityFilterChain"을 통해 많은 Filter 인스턴스들에 요청을 위임할 수 있는 특수 필터이다 ! ( ( 그 동시에 Bean이기 때문에 보통 DelegatingFilterProxy로 래핑(wrapping)된다고 한다. )

"FilterChainProxy"가 애플리케이션에 구성된 모든 Spring Security Filter를 관리한다고 생각하면 된다.
- FilterChainProxy는 단순히 하나의 SecurityFilterChain만 들고 있는 것은 아니고 아래처럼 여러"SecurityFilterChain"들이 담길 수 있다.
( 여러 개의 SecurityFilter가 들어있는 것이 SecurityFilterChain이다. )
➙ 단, 해당 요청의 URL 패턴에 따라 SecurityFilterChain 하나를 선택하고, 해당되는 Security Filter들에 거쳐가게 된다 !
( 참고로, FilterChainProxy는 "springSecurityFilterChain"이라는 고정된 이름의 Bean으로 등록된다 ! )

예를 들어,
"/foo/**"를 처리하는 SecurityFilterChain이 이미 만들어져있는데, "/bar/**", "/**"와 같은 SecurityFilterChain을 더 추가하고 싶다면 ?
➙ 아래처럼 WebSecurityConfigurerAdapter를 상속하는 Config 클래스를 추가적으로 생성해줘야 한다고 한다.
그 이유는 WebSecurityConfigurerAdapter를 상속하는 클래스 자체가 '하나의 SecurityFIlterChain'으로 등록되기 때문이다 !
( 추가적으로, 예시로 추가한 @Order 애너테이션처럼 SecurityFilterChain들이 FilterChainProxy의 리스트에 담기는 순서, 즉 Filter의 순서를 고려해야 한다 ! )
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.core.annotation.Order;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
@Configuration
@Import({SecurityConfig.FooSecurityConfig.class, SecurityConfig.BarSecurityConfig.class, SecurityConfig.AllSecurityConfig.class})
public class SecurityConfig {
@Order(100)
static class FooSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.mvcMatcher("/foo/**");
}
}
@Order(200)
static class BarSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.mvcMatcher("/bar/**");
}
}
@Order(300)
static class AllSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.mvcMatcher("/**");
}
}
▼ Spring Security Authentiction/Authorization Architecture
"세션(Session)" 기반의 인증 과정(Authentication Process)
Client 측에서 보낸 요청이 "FilterChainProxy"까지 위임된 이후에 "SecurityFilterChain"을 거쳐 온 요청에 대한 인증이 처리되는 과정이라고 생각하면 된다.

STEP 1
: 사용자가 보낸 인증 정보를 AuthenticationFilter에게 위임한다.
STEP 2
: 유저의 권한에 기반하여 인증 토큰(Authentication Token)을 생성한다.
- AuthenticationFilter가 인증 요청을 가로챈다.
( 아래 코드는 "UsernamePasswordAuthenticationFIlter"의 핵심 로직을 담고 있는 메서드이다! )
➙ 가로챈 HttpServletRequest 객체에서 username과 password를 추출하고, 이를 바탕으로 아직 인증되지 않은 UsernamePasswordAuthenticationToken 객체(인증용)를 생성한다.
( UsernamePasswordAuthenticationFilter는 Authentication 인터페이스의 구현체이다. )
💡 UsernamePasswordAuthenticationFilter의 핵심 로직은 ?
인증 결과가 포함되지 않은 UsernamePasswordAuthenticationToken인 authRequest(Authentication 구현 객체)를 AuthenticationManager에 전달하고,
AuthenticationManager는 AuthenticationProvider에게 인증 결과가 포함된 Authenticaiton 객체를 받아 반환해준다.
( 또한, POST로 요청되었을 경우에만 인증 절차를 수행하는 것을 알 수 있다. )
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
if (this.postOnly && !request.getMethod().equals("POST")) {
throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
} else {
String username = this.obtainUsername(request);
username = username != null ? username.trim() : "";
String password = this.obtainPassword(request);
password = password != null ? password : "";
UsernamePasswordAuthenticationToken authRequest = UsernamePasswordAuthenticationToken.unauthenticated(username, password);
this.setDetails(request, authRequest);
return this.getAuthenticationManager().authenticate(authRequest);
}
}
💡 그렇다면, 이 attemptAuthentication() 메서드는 언제 수행될까 ?
"AbstractAuthenticationProcessingFilter"라는 추상 클래스(인증 처리의 핵심)에 있는 doFilter 메서드 내에서 호출되고 있다.
인증에 성공해 예외가 발생하지 않고 attempAuthentication() 메서드를 통해서 null이 아닌 Authentication 객체를 반환받게 되면,
아래의 과정처럼 그 이후의 인증 과정을 거치게 된다 !

STEP 3
: 인증 토큰(UsernamePasswordAuthenticationToken 객체)을 AuthenticationManager를 통해 AuthenticationProvider에게 전달한다.
- 앞서서 attemptAuthentication() 메서드에서 살펴봤듯이 AuthenticationManager를 구현한 AuthenticationProvider 객체를 통해서 AuthenticationProvider에게 "인증 토큰"이 전달되고 있음을 알 수 있다 !
STEP 4
: AuthenticationProvider로 인증을 시도한다.
- AuthenticationManager는 등록된 AuthenticationProvider들을 조회하며 인증을 시도한다.
ex) DaoAuthenticationProvider: username/password 기반 인증 지원
JwtAuthenticationProvider: JWT 토큰 기반 인증 지원
- AuthenticationManger 인터페이스엔 authentication() 메서드만 정의되어 있다.
➙ "ProviderManager(AuthenticationManager의 구현체)"에 구현되어 있는 것을 보면,
while문을 통해 authentication() 메서드에서 파라미터로 받은 Authentication 객체를 처리할 수 있는 AuthenticationProvider를 조회하며 인증을 시도하고 있음을 알 수 있다.
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
// 생략
Class<? extends Authentication> toTest = authentication.getClass();
Iterator var9 = this.getProviders().iterator();
while(var9.hasNext()) {
AuthenticationProvider provider = (AuthenticationProvider)var9.next();
if (provider.supports(toTest)) {
if (logger.isTraceEnabled()) {
Log var10000 = logger;
String var10002 = provider.getClass().getSimpleName();
++currentPosition;
var10000.trace(LogMessage.format("Authenticating request with %s (%d/%d)", var10002, currentPosition, size));
}
// Authentication 객체를 처리할 수 있는 provider를 탐색하고 있다.
try {
result = provider.authenticate(authentication);
if (result != null) {
this.copyDetails(authentication, result);
break;
}
} catch (InternalAuthenticationServiceException | AccountStatusException var14) {
this.prepareException(var14, authentication);
throw var14;
} catch (AuthenticationException var15) {
lastException = var15;
}
}
}
if (result == null && this.parent != null) {
try {
parentResult = this.parent.authenticate(authentication);
result = parentResult;
} catch (ProviderNotFoundException var12) {
} catch (AuthenticationException var13) {
parentException = var13;
lastException = var13;
}
}
if (result != null) {
if (this.eraseCredentialsAfterAuthentication && result instanceof CredentialsContainer) {
((CredentialsContainer)result).eraseCredentials();
}
if (parentResult == null) {
this.eventPublisher.publishAuthenticationSuccess(result);
}
return result;
} else {
if (lastException == null) {
lastException = new ProviderNotFoundException(this.messages.getMessage("ProviderManager.providerNotFound", new Object[]{toTest.getName()}, "No AuthenticationProvider found for {0}"));
}
if (parentException == null) {
this.prepareException((AuthenticationException)lastException, authentication);
}
throw lastException;
}
}
- 위의 코드를 보면, "AuthenticationProvider"의 supports() 메서드와 authentication() 메서드를 호출하여 인증을 처리하고 있는 것것도 확인 가능하다.
( AuthenticationProvider는 실제 인증 로직을 담당하고 있다고 봐도 된다. )
➙ AuthenticationProvider도 AuthenticationManager 같은 인터페이스로 supports() 메서드와 authentication만을 정의하고 있다.
➙ AuthenticationProvider의 구현체인 "AbstractUserDetailAuthenticationProvider"를 살펴보면 두 메서드의 역할을 알 수 있다.
💡 supports() 메서드
Authentication 객체가 UsernamePasswordAuthenticationToken 객체(인증 토큰)인지 확인하고 인증 로직을 수행하기 위한 역할이다 !
public boolean supports(Class<?> authentication) {
return UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication);
}
💡 authenticate() 메서드
인증 로직을 포함하고 있다.
➙ additionalAuthenticationChecks() 메서드를 통해 인증 토큰의 credentials 값과 UserDetails를 통해 가져온 credentials 값을 비교한다.
➙ 동일할 경우 createSuccessAuthentication() 메서드를 통해 인증된 UsernamePasswordAuthenticationToken 객체(인증 토큰)을 만들어 반환한다.
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
Assert.isInstanceOf(UsernamePasswordAuthenticationToken.class, authentication, () -> {
return this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.onlySupports", "Only UsernamePasswordAuthenticationToken is supported");
});
String username = this.determineUsername(authentication);
boolean cacheWasUsed = true;
UserDetails user = this.userCache.getUserFromCache(username);
if (user == null) {
cacheWasUsed = false;
try {
user = this.retrieveUser(username, (UsernamePasswordAuthenticationToken)authentication);
} catch (UsernameNotFoundException var6) {
this.logger.debug("Failed to find user '" + username + "'");
if (!this.hideUserNotFoundExceptions) {
throw var6;
}
throw new BadCredentialsException(this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
}
Assert.notNull(user, "retrieveUser returned null - a violation of the interface contract");
}
try {
this.preAuthenticationChecks.check(user);
this.additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken)authentication);
} catch (AuthenticationException var7) {
if (!cacheWasUsed) {
throw var7;
}
cacheWasUsed = false;
user = this.retrieveUser(username, (UsernamePasswordAuthenticationToken)authentication);
this.preAuthenticationChecks.check(user);
this.additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken)authentication);
}
this.postAuthenticationChecks.check(user);
if (!cacheWasUsed) {
this.userCache.putUserInCache(user);
}
Object principalToReturn = user;
if (this.forcePrincipalAsString) {
principalToReturn = user.getUsername();
}
// 인증 성공 !
return this.createSuccessAuthentication(principalToReturn, authentication, user);
}
STEP 5
: UserDetailsService에 사용자 정보(아이디, 패스워드)를 넘겨주고 해당 사용자에 대한 정보를 탐색한다.
- UserDetailsService는 username으로 실제 Database에서 사용자의 인증 정보를 조회하는 역할을 한다.
➙ 그렇게 찾아낸 사용자 정보를 담은 User 객체(UserDetails 구현 객체)를 생성하여 다시 UserDetailsService로 반환해준다 !
public interface UserDetailsService {
UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}
💡 UserDetails 인터페이스
메서드 이름들이 직관적이라 무슨 기능인지 쉽게 알 수 있다 !
public interface UserDetails extends Serializable {
Collection<? extends GrantedAuthority> getAuthorities();
String getPassword();
String getUsername();
boolean isAccountNonExpired();
boolean isAccountNonLocked();
boolean isCredentialsNonExpired();
boolean isEnabled();
}
STEP 6
: UserDetailsService가 생성한 UserDetails 구현 객체를 AuthenticationProvider로 넘긴다.
- AuthenticaitonProvider들은 넘겨받은 User 객체로부터 추출한 encodedPassword(Password에 의해 암호화되어 Database에 저장되었던 password)와 사용자가 인증 요청시에 입력했던 password가 일치하는지 비교하는 인증 절차를 수행한다.
STEP 7
: 인증이 완료되면 사용자 정보(ex. 권한)를 담은 Authentication 객체를 반환하고, 인증에 실패하면 예외(AuthenticationException)로 처리한다.
- 인증에 실패하면 일반적으로 Client에 "HTTP 401 status code"가 반환된다.
STEP 8
: 인증이 완료된다.
- AuthenticationManager가 처음에 요청이 들어왔던 AuthenticationFilter에 인증된 Authenticaiton 구현 객체를 반환해준다.
STEP 9
: 그렇게 넘겨받은 사용자 정보(Authentication 객체)를 Security Context에 저장한다.
- SecurityContextHolder가 세션 영역에 있는 SecurityContext에 Authentication 객체를 저장한다.
➙ 따라서, 이는 세션-쿠키 기반의 인증 방식을 사용한다는 것을 의미한다 !
💡 세션(Session) 기반 인증
사용자가 로그인을 성공적으로 완료하면, 서버는 해당 사용자의 세션을 생성하고, 이 세션을 식별하는 쿠키를 사용자의 브라우저에 전송하게 된다.
➙ 사용자가 이후 요청을 보낼 때마다 이 쿠키를 함께 전송하고, 서버는 해당 쿠키를 통해 사용자의 세션을 식별하는 방식이다 !
Spring Security 5: Spring Security의 인가 과정(Authorization Process)

STEP 0
: 사용자가 HTTP 요청을 전송한다.
- REST API 호출, 웹 페이지 요청 또는 Spring Security로 보호되는 유형의 리소스에 접근하고자 요청한다.
STEP 1
: 인증 객체 조회
- FilterSecurityInterceptor가 들어오는 요청을 가로채서 SecurityContextHolder에서 Authentication 객체를 검색한다.
( Authenticaiton 객체는 사용자의 인증 정보를 담고 있다. )
💡 FilterSecurityInterceptor
인가 처리 담당 필터(Filter)로써 HTTP 자원의 Security을 처리하고, 사용자에 대한 특정 요청의 '승인' 및 '거부'를 결정한다.
STEP 2
: 현재 HTTP 요청에 대한 정보를 캡슐화한다.
- FilterSecurityInterceptor는 HttpServletRequest, HttpServletResponse, 그리고 FilterChain으로부터 FilterInvocation 객체를 생성한다
➙ FilterInvocation 객체는 HTTP 요청을 래핑(wrapping)하여 다른 컴포넌트(Component)에 제공한다.
( HTTP 요청 및 요청과 보안 의사결정 프로세스 사이의 링크 역할을 한다고 볼 수 있다. )
💡 FilterInvocation
들어오는 HTTP 요청(ServletRequest) 및 응답(ServletResponse) 객체를 래핑(wrapping)하는 Spring Security의 클래스이다.
Spring Security의 보안 처리 Component가 HTTP 요청 및 응답과 상호작용할 수 있는 공통 형식을 제공하는 역할을 한다 !
아래의 코드처럼 FilterInovation 객체를 이용해 "요청정보를" 알아낼 수 있다.
➙ 요청 정보를 알아낸다는 것은 사용자가 입력한 URL을 알아낸다는 의미이다 !


STEP 3
: SecurityMetadataSource 조회
- FilterSecurityInterceptor는 FilterInvocation을 SecurityMetadataSource에 전달하여 요청된 리소스에 대한 ConfigAttributes를 가져옵니다.
💡 ConfigAttributes
해당 리소스에 접근하기 위한 보안적인 요구 사항이 정의되어 있다.
예를 들어, ROLE_ADMIN 권한이 있는 사용자만 액세스할 수 있도록 '/admin' 엔드포인트를 보호하도록 Spring Security를 구성한다면?
➙ 지정된 리소스 경로("/admin/**")연결되어 있고 "ROLE_ADMIN" 권한을 나타내는 정보를 반드시 포함하는 ConfigAttributes를 생성하게 한다 !
( 자동으로 역할 앞에 "ROLE_" prefix가 붙는다. )
http
.authorizeRequests()
.antMatchers("/admin/**").hasRole("ADMIN")
...
➙ FilterSecurityInterceptor(~ HttpServletRequest 처리 ↔️ Method Security의 경우엔 MethodSecurityInterceptor)가 현재 사용자의 Authentication 객체와 비교하여 대상 리소스의 SecurityMetadataSource를 확인한다.
( 해당 리소스의 SecurityMetadataSource에는 hasRole 구성에 의해 생성된 ConfigAttributes가 포함된다. )
➙ AccessDecisionManager는 현재 Authentication과 함께 이 정보를 사용하여 승인 결정을 내립니다.
STEP 4
: AccessDecisionManager에 의한 인가 결정
- FilterSecurityInterceptor는 Authentication(인증 정보), FilterInvocation(요청 정보), 그리고 ConfigAttributes(권한 정보)를 AccessDecisionManager에 전달한다.
➙ AccessDecisionManager는 이 정보를 사용하여 접근을 허용할지 여부를 결정한다.
- STEP 4-1: 접근 거부 처리
➙ 인가가 거부되면 AccessDeniedException이 발생한다.
➙ ExceptionTranslationFilter가 이 예외를 처리하여 적절한 응답을 생성한다.
ex) 에러 페이지로 redirection - STEP 4-2: 접근 허용 및 요청 처리 지속
➙인가가 승인되면 FilterSecurityInterceptor는 FilterChain의 다음 필터로 처리를 계속하여 애플리케이션이 정상적으로 요청을 처리하게 한다.
- STEP 4-1: 접근 거부 처리
Spring Security 6: Spring Security의 인가 과정(Authorization Process)

STEP 1
: AuthorizationFilter의 도입
- AuthorizationFilter는 SecurityContextHolder에서 Authentication 객체를 검색하는 Supplier를 생성한다.
➙ 이는 인증 정보의 "lazy loading"을 가능하게 한다.
STEP 2
: AuthorizationManager를 사용한 인가 처리
- AuthorizationFilter는 Supplier<Authentication>와 HttpServletRequest를 AuthorizationManager에 전달합니다. AuthorizationManager는 요청을 authorizeHttpRequests에 정의된 패턴과 비교하고 해당하는 규칙을 실행합니다.
- STEP 2-1: 접근 거부 및 이벤트 발행
➙ 인가가 거부되면 "AuthorizationDeniedEvent"가 발행되고 AccessDeniedException이 발생한다.
➙ ExceptionTranslationFilter가 이 예외를 처리한다. - STEP 2-2: 접근 허용 및 이벤트 발행
➙ 인가가 승인되면 "AuthorizationGrantedEvent"가 발행된다.
➙ AuthorizationFilter는 FilterChain과 함께 요청 처리를 계속하여 애플리케이션이 정상적으로 작동하게 한다.
- STEP 2-1: 접근 거부 및 이벤트 발행
인가 처리: Spring Security 5 vs Spring Security 6
Component의 변경
- Spring Security 6에서는 FilterSecurityInterceptor 대신 AuthorizationFilter가 도입되어 인가 과정을 처리한다.
➙ 이는 인가 로직의 구조를 간소화하고 더 유연한 처리를 가능한다 !
"이벤트" 기반의 접근
- Spring Security 6은 인가 결정 과정에서 AuthorizationDeniedEvent와 AuthorizationGrantedEvent 이벤트를 발행한다.
➙ 이 덕분에 인가 과정에서 발생하는 중요한 사건들을 시스템의 다른 부분이나 외부 시스템에서 쉽게 감지하고 반응할 수 있게 된다 !
"AuthorizationManger"의 사용
- Spring Security 6은 AuthorizationManager를 통해 인가 로직을 더 명확하고 직관적으로 처리한다.
➙ 이는 요청과 인가 규칙의 매칭을 단순화하고, 코드의 가독성과 유지 관리를 향상시킨다 !
▼ Spring Security의 핵심 필터(Filter)

SecurityContextPersistenceFilter
- 사용자의 세션 정보를 사용하여 SecurityContext를 로드(load)하고 저장한다.
➙ 사용자의 요청이 들어올 때, 이 필터는 HTTP 세션(HttpSessionSecurityContextRepository)에서 해당 요청에 맞는 SecurityContext를 찾아 SecurityContextHolder에 저장한다.

- Spring Security 6부터는 SecurityContextPersitenceFilter가 위 그림처럼 SecurityContext의 변경 여부를 판단하는 로직도 복잡하고, 불필요한 쓰기 연산이 요구된다는 이유로 “deprecated” 됐다.
➙ 그대신 “SecurityContextHolder”로 바뀌었다

- SecurityContext의 변경 여부에 상관없이 SecurityContextRepository에 무조건 저장되고, SecurityContextHolder를 비워주게 된다 !
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws IOException, ServletException {
...
HttpRequestResponseHolder holder = new HttpRequestResponseHolder(request, response);
...
finally {
SecurityContext contextAfterChainExecution = SecurityContextHolder.getContext();
// Crucial removal of SecurityContextHolder contents before anything else.
SecurityContextHolder.clearContext();
this.repo.saveContext(contextAfterChainExecution, holder.getRequest(), holder.getResponse());
request.removeAttribute(FILTER_APPLIED);
this.logger.debug("Cleared SecurityContextHolder to complete request");
}
}
Spring Security의 컴포넌트
: Authentication, SecurityContext, SecurityContextHolder

- HTTP 요청이 도착 시, SecurityContextPersistenceFilter는 현재 HTTP 세션에 기존 SecurityContext가 있는지 체크한다.
➙ 발견되면 해당 SecurityContext를 가져와 SecurityContextHolder에 저장하여 현재 요청 처리 쓰레드(Thread)에서 사용할 수 있도록 한다.
➙ 해당 SecurityFilterChain에서 SecurityContextHolder를 통해 현재 사용자의 인증 정보에 접근할 수 있게 되는 것이다 !
💡 만약, 처음 인증하는 사용자이거나 익명 사용자가 HTTP 요청을 한 경우 ?
인증 과정을 거치고 인증된 Authentication 객체가 생성된다.
이후 모든 필터가 수행되면, SecurityContextPersistenceFilter가 인증된 Authentication 객체가 있는지 탐색하고, 해당 객체는 SecurityContextRepository에 저장된다.
( 일반적으로 HTTP 세션(HttpSessionSecurityContextRepository)에 저장된다고 한다. )
- 이렇게 저장되거나 업데이트된 SecurityContext는 해당 요청에 대한 시간 동안 사용할 수 있다 !
💡 SecurityContextHolder ?
실제 SecurityContext가 기본적으로(default) ThreadLocal에 저장된다.
( ThreadLocal: 쓰레드마다 할당되는 공유 저장소를 의미한다. )
- 세 가지 컴포넌트 사이의 관계를 간단하게 정리하면 다음과 같다.
- 유저의 아이디와 패스워드로 구성된 사용자 정보로 실제 가입된 사용자인지 체크하고, 인증에 성공하면 해당 사용자의 principal과 credential 값을 Authentication 안에 담는다.
- 스프링 시큐리티에서 그렇게 담은 Authentication을 SecurityContext에 보관한다.
- 이 SecurityContext를 SecurityContextHolder에 담아 보관하게 되는 것이다 !
- 유저의 아이디와 패스워드로 구성된 사용자 정보로 실제 가입된 사용자인지 체크하고, 인증에 성공하면 해당 사용자의 principal과 credential 값을 Authentication 안에 담는다.
LogoutFilter
- 명칭 그대로 '로그아웃'을 처리하는 필터이다.
➙ 세션 무효화, 인증 토큰 삭제, SecurityContext에서 특정 토큰 삭제 등 로그아웃을 위해 필요한 다양한 기능을 수행한다 !

UsernamePasswordAuthenticatoinFilter
- 앞서 이미 설명했던 필터로,
사용자가 입력한 인증 정보인 username과 password를 통해 인증하는 방식, 즉 폼(Form)을 기반으로 하는 인증을 처리하기 위한 필터이다.
ExceptionTranslationFilter
- FilterChain을 거치면서 발생하는 예외를 처리하기 위한 용도의 필터이다.