[Spring Security] 스프링 시큐리티의 간략한 구조와 시큐리티 인증, 인가용 필터 구현하기.
Filter *(Servlet Filter Interface)*는 클라이언트의 요청을 가장 먼저 받는 대상으로, 서블릿 호출은 이 필터를 거쳐야 이루어지게 된다.
요청 스레드가 서블릿 컨테이너에 도착하기 전에 수행됨으로써, 필터가 사용자의 요청 정보에 대해 검증하고 필요에 따라 데이터를 추가하거나 변조할 수 있다.
🌱 필터 사용 예시@RequiredArgsConstructor
public class JwtAuthenticationFilter extends GenericFilterBean {
private final JwtTokenProvider jwtTokenProvider;
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
// 1. Request Header에서 JWT 토큰 추출
String token = resolveToken((HttpServletRequest) request);
// 2. validateToken으로 토큰의 유효성 검사
if (token != null && jwtTokenProvider.validateToken(token)) {
// 토큰이 유효할 경우, 토큰에서 Authentication 객체를 가지고 와서 SecurityContext에 저장한다.
Authentication authentication = jwtTokenProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
chain.doFilter(request, response);
}
// Request Header에서 토큰 정보를 추출하기 위한 메서드
private String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer")) {
return bearerToken.substring(6);
}
return null;
}
}
Filter의 본래 기능을 확장하여 Spring의 설정정보를 가져올 수 있도록 만들어진 필터 → 매 서블릿마다 호출된다.
서블릿은 사용자의 요청을 받으면 서블릿을 생성해 메모리에 저장해두고, 같은 클라이언트의 요청을 받으면 생성해둔 서블릿 객체를 재활용하여 요청을 처리한다. 이때 서블릿 간 실행 흐름을 동적으로 전달하는 역할의 디스패치(Dispatch)가 이루어지는 상황에서 문제가 발생할 수 있다.
Spring Security에서 인증과 접근 제어 기능이 이 Filter로 구현되는데, 이러한 인증과 접근 제어는 RequestDispatcher 클래스에 의해 다른 서블릿으로 dispatch 되고, 이때 이동할 서블릿에 도착하기 전 다시 한 번 filter chain을 거쳐 여러 번 인증처리가 수행되는 문제가 있다.
@Slf4j
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider jwtTokenProvider;
@Override
protected void doFilterInternal(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull FilterChain filterChain) throws IOException, ServletException {
try {
final String token = getJwtFromRequest(request);
if (StringUtils.hasText(token) && jwtTokenProvider.validateToken(token) == VALID_JWT) {
Long userId = jwtTokenProvider.getUserFromJwt(token);
UserAuthentication authentication = new UserAuthentication(userId, null, null);
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
}
} catch (Exception exception) {
log.error("error : ", exception);
}
filterChain.doFilter(request, response);
}
private String getJwtFromRequest(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring("Bearer ".length());
}
return null;
}
}
사용자의 1번의 요청 당 딱 1번만 실행되도록 만들어진 필터 → 모든 서블릿에 일관된 요청 처리가 가능하다.
Spring Security의 인증 필터가 사용자의 인증 처리를 여러 번 수행하는 것은 불필요하므로, 1번의 인증 처리만 이루어지도록 하는 것이 좋다.
🙋🏻♂️ 공통점은?대상을 필터로 등록해주는 인터페이스이다!
인증을 처리하는 UsernameAuthenticationFilter
필터를 확장하여 인증 양식을 제출하는 역할을 한다. username
, password
두 필드를 기본적으로 필요로 하며, **/login**
url에 응답한다.
인증 예외가 발생하면 AuthenticationException 을 던지게 되는데, 이때 AuthenticationEntryPoint
인터페이스로 예외처리에 대한 커스텀이 가능하다.
인가 예외가 발생하면 AccessDeniedException 을 던지며, 이는 AccessDeniedHandler
인터페이스로 커스텀이 가능하다
*인가와 인증에 대한 구현을 명확하게 분리하여 커스텀하는 것이 중요하다!
인증을 처리하는 UsernameAuthenticationFilter
필터로부터 인증 처리를 지시받는 첫 번째 클래스
username
, password
를 저장한 Authentication 인증 객체를 전달받으면, 각 인증 처리 요건(Form / Oauth / Remember-Me)에 맞는 AuthenticationProvider를 찾아 인증 처리를 위임한다. 인증을 완료한 후 자신에게로 되돌아오면, 자신을 호출했던 Filter에게 결과를 넘겨준다.
*Remember Me란? 세션이 만료되고 브라우저를 끈 후에도 사용자를 기억하는 기능 (쿠키 기반)
로그인 정보(username(id)
, password
)에 대한 실질적인 검증 로직이 이루어지는 클래스로, authenticate()
메서드가 그 역할을 수행한다.
matches()
메서드)로그인 요청 (username, password) 로그인 요청
Http 요청이 들어오면 Filter를 가장 먼저 거치게 되는데, 커스텀한 JwtAuthenticationFilter
에서 헤더에 실려온 Access Token으로 유저 정보를 추출하여, doFilter()를 딱 한번만 실행시키며 FilterChain을 순차적으로 수행하게 됨으로써 사용자의 인증 처리가 이루어진다.
// SecurityConfig.java
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
// JwtAuthenticationFilter.java
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, **FilterChain filterChain**) throws ServletException, IOException {
try {
// 1. Request Header에서 JWT 토큰 추출
**final String token = resolveToken(request);** // request에서 JWT 토큰 정보 추출하기
// 2. validateToken으로 토큰의 유효성 검사
if (StringUtils.hasText(token) && jwtTokenProvider.validateAccessToken(token)) {
Long userId = jwtTokenProvider.getUserFromJwt(token);
// 토큰이 유효할 경우, 토큰에서 Authentication 객체를 가지고 와서 SecurityContext에 저장한다.
Authentication authentication = jwtTokenProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
} catch (Exception e) {
log.error("error: ", e);
}
filterChain.doFilter(request, response);
}
단일 HTTP 요청을 처리하는 전형적인 레이어로, 여러 개의 Filter들이 사슬처럼 서로 연결되어 연쇄적으로 동작하게 된다.
컨테이너는 서블릿과 여러 필터로 구성된 FilterChain을 만들어 요청된 URI를 기반으로 HTTP 요청을 처리한다. Filter들은 이 FilterChain 안에 있을 때 효력을 발휘하게 된다.
특징
Filter 타입의 @Beans
에 @Order
를 붙이기
Ordered 구현
→ FilterRegistrationBean 의 일부가 됨
UsernamePasswordAuthenticationFilter에서 요청으로 온 정보를 이용해 검증 작업 시작
DI로 받은 AuthenticationManager 객체의 authenticate()
메서드를 통해 로그인 시도
/**
* 인증 처리 메소드
* - UsernamePasswordAuthenticationToken 사용 (UsernamePasswordAuthenticationFilter와 동일)
*
* AbstractAuthenticationProcessingFilter(부모)의 getAuthenticationManager() 로 AuthenticationManager 객체를 반환 받은 후,
* authenticate()의 파라미터로 UsernamePasswordAuthenticationToken 객체를 넣고 인증 처리
* (여기서 AuthenticationManager 객체는 ProviderManager -> SecurityConfig 에서 설정)
*
*/
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException {
if (request.getContentType() == null || !request.getContentType().equals(CONTENT_TYPE)) {
throw new AuthenticationServiceException("Authentication Content-Type not supported: " + request.getContentType());
}
/**
* StreamUtils -> request의 messageBody (JSON 형식) 반환
* - messageBody -> objectMapper.readValue() ; Map 변환
* (Key : JSON의 키인 'email', 'password')
*/
String messageBody = StreamUtils.copyToString(request.getInputStream(), StandardCharsets.UTF_8);
/**
* Map의 Key('email', 'password') -> 키에 해당하는 값인 이메일, 패스워드 추출하여
* UsernamePasswordAuthenticationToken의 파라미터 principal, credentials 에 대입
*/
Map<String, String> usernamePasswordMap = objectMapper.readValue(messageBody, Map.class);
String email = usernamePasswordMap.get(USERNAME_KEY);
String password = usernamePasswordMap.get(PASSWORD_KEY);
UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(email, password); // principal과 credentials 전달
return this.getAuthenticationManager().authenticate(authRequest);
}
UserDetailsService를 상속받은 PrincipalDetailsService 클래스가 호출되고 loadUserByUsername()
메서드 실행 → 우리 서비스에서는 소셜 로그인만 구현했으므로 생략
UsernamePasswordAuthenticationFilter에서 Authentication 인증 객체를 반환받고 이를 SecurityContext에 저장
검증 완료 시 JWT 발급
→ 자체 로그인 없이 소셜 로그인으로 인증된 유저만 존재한다면, 소셜 서비스 자체에서 발급하는 Authorization Token으로 인증 후에 사용자 정보를 불러올 수 있는 로그인 완료 상태가 되었을 때 JWT 토큰 발급이 이루어진다.
https://ws-pace.tistory.com/250