서론
다운로드 도메인의 애플리케이션에는 권한 있는 사용자만 접근 가능한 API 1개로만 존재했습니다.
최근, 권한 체크 없이 접근 가능한 API를 한개 더 추가해야하면서 Spring Security의 설정을 수정해야 했습니다.
구체적인 기능에 대한 설명
1) 권한이 있는 사용자에 한해 다운로드할 수 있도록 하기 위해 애플리케이션 내 API에서 요청을 처리하도록 하였고,
2) 권한 체크가 필요 없는, 누구나 다운로드할 수 있는 파일들의 경우 Nginx에서 파일들을 나르도록 처리하고 있습니다.
(해당 서버는 NAS 서버와 마운트 되어있었기 때문에 파일을 매핑하여 전달하는 것이 가능했습니다.)
기존에는 VM 환경에서 구동 중이었으나 컨테이너 환경으로 전환해야하면서
Nginx가 수행하던 역할을 애플리케이션 내에서 처리하기로 결정하였습니다.
그래서 다운로드 애플리케이션 내에 적용되어 있는 Spring Security에
기존에 존재하던 API에 대해서만 권한 체크를 수행하고, 그외 API는 권한없이도 접근할 수 있도록 설정을 추가하기로 하였습니다.
그 과정에서 Security Filter를 잘못 사용되고 있는 부분이 있었고, 이를 해결하면면서 알게된 내용을 글로 정리합니다.
문제 상황
Security Config 클래스에서 기존에는 모든 API에 대해 권한 체크가 필요하도록 설정되어 있었습니다.
기존 SecurityConfig 클래스
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private final ObjectMapper objectMapper;
private final AuthenticationJwtTokenProvider authenticationJwtTokenProvider;
private final UserDetailService userDetailService;
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.cors().configurationSource(corsConfigurationSource())
.and()
.httpBasic().disable()
.csrf().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
.anyRequest().authenticated() // 기존 설정
.and()
.exceptionHandling().accessDeniedHandler(new CustomAccessDeniedHandler())
.and()
.exceptionHandling().authenticationEntryPoint(new CustomAuthenticationEntryPoint())
.and()
.addFilterBefore(new AuthenticationTokenFilter(authenticationJwtTokenProvider, userDetailService), UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(new JwtExceptionFilter(objectMapper), AuthenticationTokenFilter.class)
;
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
// 생략
}
@Override
public void configure(WebSecurity web) {
// 생략
}
}
그런데 신규로 등록한 API는 권한 체크 없이 접근하도록 설정해야 했습니다.
이제 애플리케이션에 존재하는 2개의 API 중 1개(/api/downloads/**
)는 권한 체크를 해야하고, 나머지 1개는 누구나 접근할 수 있도록 해야하는 상황이 됐습니다.
누구나 접근할 수 있도록 한 API를 'any접근 API'라고 부르도록 하겠습니다.
수정한 모습입니다.
// 수정한 일부만 발췌
.authorizeRequests()
.antMatchers("/api/downloads/**").authenticated()
.anyRequest().permitAll()
.and()
이제 'any접근 API'를 호출하면 권한이 없어도 파일을 다운로드받을 수 있을 것이라 예측했는데, 애플리케이션에서는 401 Unauthorized 상태코드와 함께 JWT 토큰이 없다는 에러를 반환해버립니다.
원인
Security Filter를 디버깅해보니, Filter Chain 중 AuthenticationTokenFilter 클래스 내 JWT 토큰을 체크하는 로직에서 토큰이 없어서
HttpServletResponse 에 에러 정보(상태, 에러 메시지)를 세팅하고 return;
문을 통해 클라이언트에게 바로 응답하도록 구현되어 있었습니다.
기존 Security Filter
@Slf4j
@RequiredArgsConstructor
public class AuthenticationTokenFilter extends OncePerRequestFilter {
private final AuthenticationJwtTokenProvider authenticationJwtTokenProvider;
private final UserDetailService userDetailService;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
try {
Optional<String> token = authenticationJwtTokenProvider.findToken(request);
// 토큰이 없으면 에러로 세팅하고 필터 중단.
// 여기서 에러 발생 후 응답 반환.
if (token.isEmpty()) {
doErrorHandle(request, response, "Jwt must not null");
return;
}
Optional<UserDetailDto> userToken = authenticationJwtTokenProvider.getUserToken(token.get());
// 토큰이 없으면 에러로 세팅하고 필터 중단.
if (userToken.isEmpty()) {
doErrorHandle(request, response, String.format("Failed translate Jwt to UserDetailDto (token:%s)", token));
return;
}
UserDetailDto userDetailDto = userToken.get();
// 사용자가 유효하지 않으면 에러로 세팅하고 필터 중단.
if (!validateUserDetail(userDetailDto)) {
doErrorHandle(request, response, String.format("Invalid UserDetailDto (userDetailDto:%s)", userDetailDto.toString()));
return;
}
UserDetails userDetail = userDetailService.loadUserDetail(userDetailDto);
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(userDetail, "", userDetail.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
} catch (InvalidAuthenticationException exception) {
// Exception 발생하면 에러로 세팅하고 필터 중단.
log.error(ExceptionUtils.getStackTrace(exception));
doErrorHandle(request, response, "Invalid UserDetail");
return;
}
filterChain.doFilter(request, response);
}
private boolean validateUserDetail(UserDetailDto userDetailDto) {
// true or false 반환
}
private void doErrorHandle(HttpServletRequest request, HttpServletResponse response, String message) {
try {
log.error(String.format("[authentication token] {msg=%s, url=%s, remote_ip=%s}",
message, request.getRequestURI(), request.getRemoteAddr()));
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, message);
} catch (IOException exception) {
log.error(ExceptionUtils.getStackTrace(exception));
}
}
사실 저는 'any접근 API'를 호출하면 아예 시큐리티 필터를 안 탈 것이라 생각했는데 Security Filter를 타서 조금 놀랐습니다.(...)
결국 SecurityConfig 클래스에서 permitAll()로 설정한 URI에 상관없이 위 클래스에서 필터를 중단시키는 것이
401 Unauthorized 에러의 원인이었습니다.
해결 방향과 간단한 흐름 정리
해당 문제를 해결하면서 Security Filter를 디버깅해보며 흐름을 확인하니,
개발자가 SecurityConfig에 원하는 설정 정보들을 지정하면,
이를 이용해서 각 요청들에 대해 승인/거부하는 것을 수행하는 필터가 Security Filter Chain 중 있었습니다.
저희는 SecurityConfig에 이미 각 URI에 대해 권한 설정을 해놓았다면,
Security Filter Chain 내에서 요청 URI에 대해 승인/거부 여부를 판단할 수 있도록 맡겨놓으면 되는 것입니다.
승인/거부에 따른 후처리 로직에 대해 커스텀하고 싶다면, 디폴트 클래스를 상속하여 커스텀할 수도 있습니다.
그래서 최종 해결한 방법은,
AuthenticationJwtFilter에서 아래의 경우와 같이 인증/인가에 이슈가 있어도 필터를 중간에 중지시키지 않도록 하였습니다.
- 요청 헤더 or 쿠키에 토큰이 없는 경우
- 토큰이 있으나, 토큰 내 정보를 까보니 유의미한 사용자가 아닌 경우
또한 당연하게도 위 케이스에 해당하면 Authentication 객체를 세팅하지 못하게 됩니다.
그러면 Security Filter에서 인증/인가를 승인할지 거부할지 심사하는 클래스(AccessDecisionVoter)에서 아래 정보들을 보고 심사합니다.
심사할 때 고려하는 값:
- Authentication - 인증 정보 (user)
- FilterInvocator (요청 정보)와 ConfigAttributes (권한 정보) : Security Config에 설정한 URI 와 그 URI에 세팅한 권한 정보
남은 이야기
WebSecurity 클래스의 ignoring() 메서드에 추가할 순 없었나?
처음에는 아예 필터를 타지 않도록 WebSecurity 에 해당 uri를 추가하고자 했으나
'any 접근 API'가 /api/downloads/**
uri 외의 모든 요청에 대해 처리해야하는 API였기 때문에 컨트롤러 모습이 아래와 같았습니다.
그래서 /**
패턴을 추가하면, 권한 체크를 해야하는 /api/downloads/
API마저 권한 없이 접근이 가능해져버려서 해당 부분에 세팅할 수는 없었습니다.
@Getmapping("/**")
public void noAuthFile(HttpServletRequest request, HttpServletResponse response, ...) {
// 생략
}
문제를 해결하는 과정에서 알게된 Spring Security Filter에 대한 내용은 다른 글에서 정리해보도록 하겠습니다!
잘못된 부분이나 궁금한 점 있다면 편하게 댓글 부탁드려요 =)