Published on

OAuth2 Login + JWT (4) 자체 JSON 로그인 커스텀

Authors
  • avatar
    Name
    ywj9811
    Twitter

자체 JSON 로그인 커스텀

Form 데이터가 아닌 JSON을 통한 로그인을 구현할 것이기 때문에 자체적으로 관련 클래스를 커스텀 하도록 할 것이다.

{
	"username" : "user"
	"psasword" : "1234"
}

이런 식으로 JSON 타입으로 username과 password를 보내 로그인 하는 방식으로 구현할 것이다.

Spring Security 에서는 일반 Form Login을 기본으로 제공하기 때문에 JSON 형식으로 로그인 하는 방식에 대해서는 커스텀 필터를 구현해야 한다.

Form Login 에서 사용되는 UsernamePasswordAuthenticationFilter의 코드를 참고하여 구현할 것이다.

UsernamePasswordAuthenticationFilterAbstractAuthenticationProcessingFilter 상속 받기 때문에 JSON필터에서도 AbstractAuthenticationProcessingFilter를 상속 받아 구현할 것이다.

CustomJsonUsernamePasswordAuthenticationFilter 클래스

import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.security.authentication.AuthenticationServiceException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import org.springframework.util.StreamUtils;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Map;

/**
 * Form Login 시 기본적으로 사용되는
 * UsernamePasswordAuthenticationFilter에서
 * AbstractAuthenticationProcessingFilter를 상속받아 구현하기 때문에,
 * 커스텀 JSON 필터에서도 AbstractAuthenticationProcessingFilter를 상속받아 구현.
 */
public class CustomJsonUsernamePasswordAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
    private static final String DEFAULT_LOGIN_REQUEST_URL = "/login"; //login으로 오는 요청 처리
    private static final String HTTP_METHOD = "POST"; //로그인 HTTP 메소드는 post
    private static final String CONTENT_TYPE = "application/json"; //로그인 시 요청은 JSON
    private static final String USERNAME_KEY = "username";
    private static final String PASSWORD_KEY = "password";
    private static final AntPathRequestMatcher DEFAULT_LOGIN_PATH_REQUEST_MATCHER =
            new AntPathRequestMatcher(DEFAULT_LOGIN_REQUEST_URL, HTTP_METHOD);
    // "/login" + post 로 요청시 매칭된다.

    private final ObjectMapper objectMapper;

    public CustomJsonUsernamePasswordAuthenticationFilter(ObjectMapper objectMapper) {
        super(DEFAULT_LOGIN_PATH_REQUEST_MATCHER); //매칭 처리 설정
        this.objectMapper = objectMapper;
    }

    /**
     * 인증 처리 메소드
     *
     * usernamePasswordAuthenticationFilter와 동일하게 UsernamePasswordAuthenticationToken 사용
     * StringUtils 통해 request에서 messageBody(JSON) 반환
     * ex)
     * {
     *     "username" : "user"
     *     "password" : "2443"
     * }
     * 이렇게 요청이 오면
     * messageBody를 objectMapper.readValue() 을 통해 Map으로 변환
     * Map에서 key로 이메일, 패스워드 추출 후
     * UsernamePasswordAuthenticationToken의 파라마터 principal, credentials에 대입
     *
     * AbstractAuthenticationProcessingFilter(부모)의 getAuthenticationManager()로 AuthenticationManager 객체를 반환 받은 후
     * authenticate()의 파라미터로 UsernamePasswordAuthenticationToken 객체를 넣고 인증 처리
     * (여기서 AuthenticationManager 객체는 ProviderManager -> SecurityConfig에서 설정)
     */

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException {
        if (request.getContentType() == null || !request.getContentType().equals(CONTENT_TYPE)) {
            throw new AuthenticationServiceException("Authentication Content-Type not supported : " + request.getContentType());
        }

        String messageBody = StreamUtils.copyToString(request.getInputStream(), StandardCharsets.UTF_8);

        Map<String, String> usernamePasswordMap = objectMapper.readValue(messageBody, Map.class);
        //JSON 요청을 String으로 변환한 messageBody를 objectMapper.readValue를 통해 Map으로 변환하여 각각 저장

        String username = usernamePasswordMap.get(USERNAME_KEY);
        String password = usernamePasswordMap.get(PASSWORD_KEY);

        UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username, password);
        //principal과 credentials 전달
        //AuthenticationManager가 인증 시 사용할 인증 대상 객체가

        return this.getAuthenticationManager().authenticate(authRequest);
        //이 AuthenticationManager 객체가 인증 성공/실패 처리를 함
    }@Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException {
        if (request.getContentType() == null || !request.getContentType().equals(CONTENT_TYPE)) {
            throw new AuthenticationServiceException("Authentication Content-Type not supported : " + request.getContentType());
        }

        String messageBody = StreamUtils.copyToString(request.getInputStream(), StandardCharsets.UTF_8);

        Map<String, String> usernamePasswordMap = objectMapper.readValue(messageBody, Map.class);
        //JSON 요청을 String으로 변환한 messageBody를 objectMapper.readValue를 통해 Map으로 변환하여 각각 저장

        String username = usernamePasswordMap.get(USERNAME_KEY);
        String password = usernamePasswordMap.get(PASSWORD_KEY);

        UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username, password);
        //principal과 credentials 전달
        //AuthenticationManager가 인증 시 사용할 인증 대상 객체가

        return this.getAuthenticationManager().authenticate(authRequest);
        //이 AuthenticationManager 객체가 인증 성공/실패 처리를 함
    }
}

PART1

public CustomJsonUsernamePasswordAuthenticationFilter(ObjectMapper objectMapper) {
    super(DEFAULT_LOGIN_PATH_REQUEST_MATCHER); // 위에서 설정한 "login" + POST로 온 요청을 처리하기 위해 설정
    this.objectMapper = objectMapper;
}

위의 생성자는 어떤 요청이 들어 왔을 때 이에 맵핑될지 설정해주는 부분이며, ObjectMapper를 생성자 주입하는 부분이다.

PART2

@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException {
    if (request.getContentType() == null || !request.getContentType().equals(CONTENT_TYPE)) {
        throw new AuthenticationServiceException("Authentication Content-Type not supported : " + request.getContentType());
    }

    String messageBody = StreamUtils.copyToString(request.getInputStream(), StandardCharsets.UTF_8);

    Map<String, String> usernamePasswordMap = objectMapper.readValue(messageBody, Map.class);
    //JSON 요청을 String으로 변환한 messageBody를 objectMapper.readValue를 통해 Map으로 변환하여 각각 저장

    String username = usernamePasswordMap.get(USERNAME_KEY);
    String password = usernamePasswordMap.get(PASSWORD_KEY);

    UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username, password);
    //principal과 credentials 전달
    //AuthenticationManager가 인증 시 사용할 인증 대상 객체가

    return this.getAuthenticationManager().authenticate(authRequest);
    //이 AuthenticationManager 객체가 인증 성공/실패 처리를 함
}

이 부분은 AbstractAuthenticationProcessingFilter의 attemptAuthentication()@Override 한 부분으로 인증 처리 메소드이다.

  • 시작 부분에서 만약 JSON타입으로 들어온 요청이 아닐 경우 예외를 발생시키게 된다.
  • objectMapper.readValue(messageBody, Map.class) 를 통해서 JSON 요청을 String 으로 변환한 messageBody를 Map으로 변환하여 각각 저장한다.
  • UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username, password); 을 통해서 인증 처리 객체인 AuthenticationManager가 인증 시 사용할 인증 대상 객체를 생성한다. 파라미터로 넘겨준 username과 password는 인증 대상 객체의 credentials 가 된다.AuthenticaionManager가 인증 성공 / 인증 실패 처리를 하게 된다.

⚠️ 이때, 인증 처리 객체 AuthenticationManager로 무슨 객체를 사용할지 setter로 설정해야 하는데, 이 과정은 이후의 스프링 시큐리티 설정 클래스인 SecurtyConfig 클래스를 다룰 때 설명할 것이다.

⚠️ 인증 처리 객체 AuthenticationManager로는 FormLogin과 동일하게 ProviderManager를 사용, ProviderManager의 구현체로 DaoAuthenticationProvider 객체를 사용한다.

다음에는 JSON 로그인 서비스를 구현하도록 할 것이다.