Spring Security & JWT를 이용한 자체 Login & OAuth2 Login(네이버, 카카오) 구현 (3) - 커스텀 로그인

오형상·2024년 9월 21일
0

CoinToZ

목록 보기
3/9
post-thumbnail

1. 커스텀 필터 구현

Spring Security에서는 기본적으로 폼 기반의 UsernamePasswordAuthenticationFilter를 통해 로그인 요청을 처리합니다. 하지만 REST API를 사용한 인증에서는 JSON 형식으로 로그인 데이터를 전달해야 하는 경우가 많습니다. 이때 기본 필터를 그대로 사용할 수 없기 때문에, 커스텀 필터를 구현하여 JSON 데이터를 처리하는 방식으로 인증을 확장할 수 있습니다.

📌 CustomUsernamePasswordAuthenticationFilter 전체 코드

/**
 * 스프링 시큐리티의 폼 기반의 UsernamePasswordAuthenticationFilter를 참고하여 만든 커스텀 필터
 * 거의 구조가 같고, Type이 Json인 Login만 처리하도록 설정한 부분만 다르다. (커스텀 API용 필터 구현)
 * Username : 회원 아이디 -> email로 설정
 * "/login" 요청 왔을 때 JSON 값을 매핑 처리하는 필터
 */
public class CustomUsernamePasswordAuthenticationFilter extends AbstractAuthenticationProcessingFilter {

    private static final String DEFAULT_LOGIN_REQUEST_URL = "/api/v1/users/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 = "email"; // 회원 로그인 시 이메일 요청 JSON Key : "email"
    private static final String PASSWORD_KEY = "password"; // 회원 로그인 시 비밀번호 요청 JSon 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 CustomUsernamePasswordAuthenticationFilter(ObjectMapper objectMapper) {
        super(DEFAULT_LOGIN_PATH_REQUEST_MATCHER); // 위에서 설정한 "login" + POST로 온 요청을 처리하기 위해 설정
        this.objectMapper = objectMapper;
    }

    /**
     * 인증 처리 메소드
     *
     * UsernamePasswordAuthenticationFilter와 동일하게 UsernamePasswordAuthenticationToken 사용
     * StreamUtils를 통해 request에서 messageBody(JSON) 반환
     * 요청 JSON Example
     * {
     *    "email" : "zvyg1023@naver.com"
     *    "password" : "test123"
     * }
     * 꺼낸 messageBody를 objectMapper.readValue()로 Map으로 변환 (Key : JSON의 키 -> email, password)
     * Map의 Key(email, password)로 해당 이메일, 패스워드 추출 후
     * UsernamePasswordAuthenticationToken의 파라미터 principal, credentials에 대입
     *
     * 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());
        }

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

        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);
    }
}

상세 설명

  1. 필터 설정:

    • 이 커스텀 필터는 /api/v1/users/login 경로에 대해 POST 메소드로 들어오는 요청을 처리하도록 설정되었습니다. 또한, 요청의 Content-Typeapplication/json인 경우에만 인증을 처리합니다. 이는 JSON 형식으로 사용자 인증 정보를 받아 처리하기 위해 커스터마이징된 부분입니다.
  2. 인증 처리 (attemptAuthentication 메서드):

    • HttpServletRequest에서 들어온 요청 데이터를 StreamUtils로 읽어 JSON 형식의 문자열로 변환한 후, 이를 ObjectMapper를 사용해 Map으로 변환합니다. Map의 키는 JSON 필드(email, password)와 매핑되어 있습니다.
    • 추출된 이메일과 비밀번호는 UsernamePasswordAuthenticationToken에 넣어 AuthenticationManager를 통해 인증을 처리합니다. 이 AuthenticationManager는 Spring Security 설정 (SecurityConfig)에서 설정된 인증 관리 객체입니다.
  3. JSON 데이터 처리:

    • 기본적으로 Spring Security의 UsernamePasswordAuthenticationFilter는 HTML 폼 데이터를 처리하도록 되어 있습니다. 하지만 이 필터에서는 JSON 형식의 데이터를 처리할 수 있도록 ObjectMapper를 통해 요청 본문을 파싱하고 있습니다. 이를 통해 RESTful API 스타일의 로그인 처리 흐름을 구현할 수 있습니다.

2. 커스텀 로그인 구현

인증 요청이 커스텀 필터를 통과하면, 인증 서비스가 호출되어 사용자의 인증 정보를 확인하고 검증합니다. 이때 Spring Security의 UserDetailsService 인터페이스를 구현한 커스텀 로그인 서비스가 사용됩니다. 이 서비스는 주로 데이터베이스에서 사용자의 정보를 조회하고, 인증이 성공할 경우 Spring Security가 이해할 수 있는 UserDetails 객체를 반환합니다.

📌 LoginService 전체 코드

@Service
@RequiredArgsConstructor
public class LoginService implements UserDetailsService {

    private final UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String email) {
        User user = userRepository.findByEmail(email)
                .orElseThrow(() -> new AppException(EMAIL_NOT_FOUND, EMAIL_NOT_FOUND.getMessage()));

        return org.springframework.security.core.userdetails.User.builder()
                .username(user.getEmail())
                .password(user.getPassword())
                .roles(user.getUserRole().name())
                .build();
    }
}

상세 설명

  1. UserDetailsService 구현:

    • LoginServiceUserDetailsService를 구현하며, loadUserByUsername(String email) 메서드를 오버라이드하여 사용자의 인증 정보를 로드합니다.
    • 이 메서드는 사용자 인증이 필요할 때 Spring Security가 자동으로 호출하는 메서드입니다. 즉, 클라이언트에서 입력된 이메일을 바탕으로 데이터베이스에서 해당 사용자의 정보를 조회하는 역할을 합니다.
  2. 사용자 정보 조회:

    • userRepository를 사용하여 데이터베이스에서 이메일을 기준으로 사용자를 조회합니다. 사용자가 존재하지 않는 경우, 커스텀 예외인 AppException을 발생시켜 적절한 오류 메시지를 반환합니다.
    • AppException은 일반적인 오류 처리 방식으로, 이메일이 존재하지 않는 경우 이를 처리하는 로직입니다. EMAIL_NOT_FOUND는 사전에 정의된 ErrorCode를 사용할 수 있습니다.
  3. Spring Security의 UserDetails 객체 반환:

    • 조회된 사용자 정보를 Spring Security가 이해할 수 있는 UserDetails 객체로 변환하여 반환합니다. User.builder()를 사용하여 사용자 이메일, 비밀번호, 역할 정보를 설정하고 UserDetails로 변환합니다.
    • UserDetails 객체는 이후 Spring Security의 인증 로직에서 사용되어 로그인한 사용자의 권한 및 세션 관리를 처리하게 됩니다.
  4. 비밀번호와 역할 정보 설정:

    • UserDetails 객체에는 사용자 비밀번호(password())와 역할 정보(roles())도 설정됩니다. 이는 Spring Security가 내부적으로 인증을 수행할 때, 제공된 비밀번호가 일치하는지 확인하고 사용자의 권한을 부여하는 데 사용됩니다.

3. JSON 로그인 성공 시 핸들러 구현

로그인 요청이 커스텀 JSON 로그인 필터를 통해 성공적으로 인증되면, 로그인 성공 핸들러(LoginSuccessHandler)가 실행됩니다. 이 핸들러는 SimpleUrlAuthenticationSuccessHandler를 상속받아 커스터마이징했으며, JWT 발급과 Redis를 사용한 세션 관리를 처리하는 역할을 합니다.

📌 LoginSuccessHandler 전체 코드

@RequiredArgsConstructor
public class LoginSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {

    private final JwtService jwtService; // JWT 관련 로직을 처리하는 JwtService 객체 주입
    private final RedisTemplate<String, String> redisTemplate; // Redis를 사용하여 RefreshToken을 저장하는 데 사용하는 RedisTemplate 주입
    private final ObjectMapper objectMapper; // 응답을 JSON으로 변환하기 위한 ObjectMapper 주입

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
                                        Authentication authentication) throws IOException {
        String email = extractUsername(authentication); // 인증 정보에서 email 추출
        String accessToken = jwtService.createAccessToken(email); // JwtService의 createAccessToken을 사용하여 AccessToken 발급
        String refreshToken = jwtService.createRefreshToken(); // JwtService의 createRefreshToken을 사용하여 RefreshToken 발급

        // AccessToken과 RefreshToken을 응답 헤더에 추가하여 클라이언트로 전달
        jwtService.sendAccessAndRefreshToken(response, accessToken, refreshToken);

        // Redis에 RefreshToken 저장 (Key: "RT:" + email)
        redisTemplate.opsForValue().set("RT:" + authentication.getName(), refreshToken);

        // 응답 상태 코드 설정 (200 OK)
        response.setStatus(HttpServletResponse.SC_OK);
        response.setContentType("application/json"); // 응답 형식을 JSON으로 설정
        response.setCharacterEncoding("utf-8");

        // 클라이언트에 응답할 데이터를 UserLoginResponse 객체로 생성
        UserLoginResponse userLoginResponse = new UserLoginResponse(email, accessToken, refreshToken);

        // 성공 응답을 Response<UserLoginResponse>로 감싸서 처리
        Response<UserLoginResponse> responseBody = Response.success(userLoginResponse);

        // ObjectMapper를 사용하여 JSON으로 직렬화 후 클라이언트에 응답
        objectMapper.writeValue(response.getWriter(), responseBody);
    }

    private String extractUsername(Authentication authentication) {
        UserDetails userDetails = (UserDetails) authentication.getPrincipal();
        return userDetails.getUsername();
    }
}

상세 설명

  1. 인증된 사용자 정보 추출:

    • extractUsername(authentication) 메서드를 통해 인증된 사용자의 이메일(Username)을 추출합니다. 이 정보는 JWT 생성에 사용됩니다.
  2. JWT 생성 및 전달:

    • jwtService를 사용하여 AccessToken과 RefreshToken을 생성합니다. AccessToken은 사용자 인증을 위해 클라이언트 측에서 사용되며, RefreshToken은 만료된 AccessToken을 갱신하는 데 사용됩니다. 두 토큰은 응답 헤더에 추가되어 클라이언트로 전달됩니다.
  3. Redis에 RefreshToken 저장:

    • Redis는 세션 관리용으로 사용되며, RedisTemplate을 통해 RefreshToken을 "RT:email" 형식의 키로 저장합니다. 이 키를 사용하여 특정 사용자의 RefreshToken을 쉽게 관리할 수 있습니다.
  4. JSON 응답:

    • 인증 성공 시, UserLoginResponse 객체로 사용자에게 응답할 데이터를 생성한 후, 이를 Response<UserLoginResponse> 객체로 감싸서 클라이언트에게 반환합니다. 이를 통해 클라이언트는 로그인 성공 시 사용자 정보와 발급된 토큰을 JSON 형식으로 받아볼 수 있습니다.

성공 테스트 (postman)


4. 로그인 실패 시 핸들러 구현

로그인 요청이 커스텀 JSON 로그인 필터에서 실패한 경우, 로그인 실패 핸들러(LoginFailureHandler)가 실행됩니다. 이 핸들러는 SimpleUrlAuthenticationFailureHandler를 상속받아 커스터마이징했으며, 인증 실패 시 클라이언트에게 적절한 오류 메시지와 상태 코드를 반환하는 역할을 합니다.

📌 LoginFailureHandler 전체 코드

/**
 * JWT 로그인 실패 시 처리하는 핸들러
 * SimpleUrlAuthenticationFailureHandler를 상속받아서 구현
 */
@RequiredArgsConstructor
public class LoginFailureHandler extends SimpleUrlAuthenticationFailureHandler {

    private final ObjectMapper objectMapper; // Java 객체를 JSON으로 변환하기 위한 ObjectMapper 인스턴스

    /**
     * 인증 실패 시 호출되는 메서드
     * 인증이 실패하면 실패 이유를 ErrorResponse로 감싸서 클라이언트에 전달
     */
    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
                                        AuthenticationException exception) throws IOException {
        // HTTP 응답 상태 코드를 400 (Bad Request)로 설정
        response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
        response.setContentType("application/json"); // 응답의 Content-Type을 JSON 형식으로 설정
        response.setCharacterEncoding("utf-8");

        // 로그인 실패 시 사용할 ErrorResponse 객체 생성 (ErrorCode와 예외 메시지를 담음)
        ErrorResponse errorResponse = new ErrorResponse(ErrorCode.LOGIN_FAILED, exception.getMessage());

        // ErrorResponse 객체를 Response<ErrorResponse>로 감싸서 통일된 응답 형식으로 변환
        Response<ErrorResponse> responseBody = Response.error("ERROR", errorResponse);

        // ObjectMapper를 사용하여 Response 객체를 JSON으로 직렬화하고, 클라이언트에 응답으로 보냄
        objectMapper.writeValue(response.getWriter(), responseBody);
    }
}

상세 설명

  1. 인증 실패 처리:

    • onAuthenticationFailure() 메서드는 인증이 실패했을 때 실행됩니다. 이 메서드는 로그인 실패 원인을 클라이언트에게 알리고, 실패 이유를 JSON 형식으로 반환하는 역할을 합니다.
  2. 오류 응답 구성:

    • ErrorResponse 객체를 생성하여 로그인 실패 시의 오류 코드를 ErrorCode.LOGIN_FAILED로 지정하고, 발생한 AuthenticationException의 메시지를 함께 전달합니다.
  3. 통일된 응답 형식:

    • ErrorResponse 객체는 Response<ErrorResponse>로 감싸져서 클라이언트에게 반환됩니다. 이를 통해 클라이언트는 응답을 통일된 형식으로 받을 수 있으며, 상태 코드와 오류 메시지를 확인할 수 있습니다.

실패 테스트 (postman)


Reference

0개의 댓글