Notice
Recent Posts
Recent Comments
Link
«   2025/04   »
1 2 3 4 5
6 7 8 9 10 11 12
13 14 15 16 17 18 19
20 21 22 23 24 25 26
27 28 29 30
Tags
more
Archives
Today
Total
관리 메뉴

DS's TechBlog

[Spring Security] 로그인 입력 값을 Json 형태 + userId로 받기 본문

Java & Spring

[Spring Security] 로그인 입력 값을 Json 형태 + userId로 받기

dsjo 2024. 4. 19. 02:10

SpringSecuriry를 활용해서 JWT 로그인을 구현했습니다. 그런데 2가지 문제가 생겼습니다.

  1. userId를 username으로 받아야 한다.- 진행 중인 프로젝트에서 user Entity에 id와 name 필드가 모두 존재합니다. 하지만, SpringSecurity에서는 id를 username 필드로 전달해 주기 때문에, 두 필드가 서로 헷갈리는 문제가 발생하였습니다.
  2. 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 클래스는 어떻게 처리하나 살펴봅시다.

UsernamePasswordAuthenticationFilterattemptAuthentication 메서드는 AuthenticationException을 throws 해주고 있었습니다.

그리고, AutenticationException은 RuntimeException을 상속받고 있었습니다. IOException은 Check-Exception라서 RutnimeExeption을 상속받지 않습니다. 그렇기에, LoginFilterattemptAuthentication 에는 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);
    }
}

작성한 LoginFilterUsernamePasswordAuthenticationFilter 위치에 넣어주면 됩니다.