DS's TechBlog
[Spring Security] 로그인 입력 값을 Json 형태 + userId로 받기 본문
SpringSecuriry를 활용해서 JWT 로그인을 구현했습니다. 그런데 2가지 문제가 생겼습니다.
- userId를 username으로 받아야 한다.- 진행 중인 프로젝트에서 user Entity에 id와 name 필드가 모두 존재합니다. 하지만, SpringSecurity에서는 id를 username 필드로 전달해 주기 때문에, 두 필드가 서로 헷갈리는 문제가 발생하였습니다.
- form-data 형식으로 입력을 받아야 한다.- Json 기반의 API 서버를 만들고 있는데, 로그인만 form-data 로 입력을 받는 것은 일관성 부분에서 문제가 있습니다.
이러한 문제를 해결해보겠습니다.
기존코드
@RequiredArgsConstructor
public class LoginFilter extends UsernamePasswordAuthenticationFilter {
private final AuthenticationManager authenticationManager;
private final JWTUtil jwtUtil;
private final RedisService redisService;
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
String username = obtainUsername(request);
String password = obtainPassword(request);
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(username, password, null);
return authenticationManager.authenticate(authToken);
}
SpringSecurity를 활용해 JWT 로그인을 구현하기 위해, UsernamePasswordAuthenticationFilter를 상속받아서 구현해 주었습니다. obtain 메서드는 form-data의 값을 추출합니다.
변경코드 - 1
@RequiredArgsConstructor
public class LoginFilter extends UsernamePasswordAuthenticationFilter {
private final AuthenticationManager authenticationManager;
private final JWTUtil jwtUtil;
private final RedisService redisService;
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
ObjectMapper objectMapper = new ObjectMapper();
ServletInputStream inputStream = request.getInputStream();
String messageBody = StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8);
LoginDTO loginDTO = objectMapper.readValue(messageBody, LoginDTO.class);
String id = loginDTO.getId();
String password = loginDTO.getPassword();
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(id, password, null);
return authenticationManager.authenticate(authToken);
}
request를 Json 형태로 받기 위해 jacson 라이브러리의 ObjectMapper를 사용해 주었습니다.
또한, String type의 userId와 password를 가지는 loginDTO 클래스 또한 생성하여 사용했습니다.
하지만, Unhandled exception: java.io.IOException 문제가 발생하였습니다.

getInputStream() 메서드를 사용하기 위해서는 IOException을 처리해줘야 했습니다.
UsernamePasswordAuthenticationFilter 클래스는 어떻게 처리하나 살펴봅시다.


UsernamePasswordAuthenticationFilter의 attemptAuthentication 메서드는 AuthenticationException을 throws 해주고 있었습니다.
그리고, AutenticationException은 RuntimeException을 상속받고 있었습니다. IOException은 Check-Exception라서 RutnimeExeption을 상속받지 않습니다. 그렇기에, LoginFilter에 attemptAuthentication 에는 throws IOException을 달아 줄 수 없어서 문제가 생겼습니다. (오버라이드한 메서드의 예외는 상위 클래스의 예외의 세부 예외를 상속받거나 예외를 상속받지 않을 수 있지만, 다른 예외를 더 추가할 순 없습니다.)
그러면, UsernamePasswordAuthenticationFilter 가 상속하는 AbstractAuthenticationProcessingFilter를 살펴보겠습니다. (메서드가 긴 관계로 캡처 사진이 잘려, 코드로 직접 적어보겠습니다.)
public abstract class AbstractAuthenticationProcessingFilter extends GenericFilterBean implements ApplicationEventPublisherAware, MessageSourceAware {
public abstract Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException;
AbstractAuthenticationProcessingFilter 클래스의 attemptAutentication 메서드에서는 AuthenticationException 뿐 아니라, IOException 도 처리해주고 있었습니다. 그렇기 때문에, AbstractAuthenticationProcessingFilter를 직접 상속받아서 구현하면, throws IOExceprion를 달아줄 수 있을 것입니다. 하지만, 기존에 UsernamePasswordAuthenticationFilter를 상속받는 클래스를 구현해 놓은 관계로 throw-catch로 예외를 처리하겠습니다.
변경코드 - 2
@RequiredArgsConstructor
public class LoginFilter extends UsernamePasswordAuthenticationFilter {
private final AuthenticationManager authenticationManager;
private final JWTUtil jwtUtil;
private final RedisService redisService;
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
LoginDTO loginDTO;
try {
ObjectMapper objectMapper = new ObjectMapper();
ServletInputStream inputStream = request.getInputStream();
String messageBody = StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8);
loginDTO = objectMapper.readValue(messageBody, LoginDTO.class);
} catch (IOException e) {
// throw new BusinessException(ErrorCode.INVALID_LOGIN_CONTENTS_TYPE);
throw new AuthenticationServiceException("로그인 요청의 형식을 읽을 수 없습니다.", e);
}
String userId = loginDTO.getUserId();
String password = loginDTO.getPassword();
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(userId, password, null);
return authenticationManager.authenticate(authToken);
}
이와 같은 방식으로 Json 형태로 userId와 password를 받을 수 있습니다. BusinessException와 ControllerAdvice를 이용해서 예외를 처리하려고 했지만, ControllerAdvice는 filter단에서 작동하지 않으므로 처리되지 않습니다.
필터 등록
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http, AuthenticationConfiguration authenticationConfiguration, JWTUtil jwtUtil, RedisService redisService) throws Exception {
http
.addFilterAt(new LoginFilter(authenticationManager(authenticationConfiguration), jwtUtil, redisService), UsernamePasswordAuthenticationFilter.class);
}
}
작성한 LoginFilter는 UsernamePasswordAuthenticationFilter 위치에 넣어주면 됩니다.
'Java & Spring' 카테고리의 다른 글
[Spring Security] authorizeHttpRequests 우선순위 적용하기 (3) | 2024.05.17 |
---|---|
[Spring] Swagger default 200 response 삭제하기 (0) | 2024.05.03 |
[Spring] Swagger 공통 응답 코드 처리 및 Enum으로 정의한 응답 코드 사용하기 (0) | 2024.05.03 |
[Spring Security] SecurityFilterChain에서 발생하는 예외를 처리하기 (0) | 2024.04.24 |
[Java] Optional과 findById (0) | 2024.04.13 |