본문 바로가기
Library&Framework/시큐리티

[JWT Authentication Process] React + Spring Security + OAuth2 Client

by 우지uz 2024. 8. 30.

쇼핑몰 프로젝트에서 구현했던 인증 프로세스 과정을 하나하나 곱씹어 보면서, 하나의 구현도로 표현해보

았습니다. 

첫째로 LoginFilter 를 통해서 JWT 로그인 프로세스를 진행 했으며, LoginFilter 은 AbstractAuthenticationProcessingFilter 를 상속받아 생성된 CustomJsonEmailPasswordAuthenticationFilter 를 한번 더 상속한 클래스 입니다.

public class CustomJsonEmailPasswordAuthenticationFilter extends AbstractAuthenticationProcessingFilter {

    public static final String SPRING_SECURITY_FORM_USERNAME_KEY = "email";

    public static final String SPRING_SECURITY_FORM_PASSWORD_KEY = "password";

    private static final AntPathRequestMatcher DEFAULT_ANT_PATH_REQUEST_MATCHER = new AntPathRequestMatcher("/login",
            "POST");

    private static final String CONTENT_TYPE = "application/json"; // JSON 타입의 데이터로 오는 로그인 요청만 처리

    private String usernameParameter = SPRING_SECURITY_FORM_USERNAME_KEY;

    private String passwordParameter = SPRING_SECURITY_FORM_PASSWORD_KEY;

    private boolean postOnly = true;

    private final ObjectMapper objectMapper;



    public CustomJsonEmailPasswordAuthenticationFilter(AuthenticationManager authenticationManager, ObjectMapper objectMapper) {
        super(DEFAULT_ANT_PATH_REQUEST_MATCHER, authenticationManager);
        this.objectMapper = objectMapper;
    }



    /**
     * 인증 처리 메소드
     *
     * UsernamePasswordAuthenticationFilter와 동일하게 UsernamePasswordAuthenticationToken 사용
     * StreamUtils를 통해 request에서 messageBody(JSON) 반환
     * 요청 JSON Example
     * {
     *    "email" : "aaa@bbb.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);
        // 제네릭 타입을 new TypeReference 선언해줌으로써 컴파일시 제네릭타입으로 검증.
        Map<String, String> usernamePasswordMap = objectMapper.readValue(messageBody, new TypeReference<Map<String, String>>() {});

        String email = usernamePasswordMap.get(SPRING_SECURITY_FORM_USERNAME_KEY);
        String password = usernamePasswordMap.get(SPRING_SECURITY_FORM_PASSWORD_KEY);

        UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(email, password);
        //principal 과 credentials 전달

        return this.getAuthenticationManager().authenticate(authRequest);
    }  
}

로그인 필터 (/login 경로로 request 되면, 그 요청을 가로 채는 필터)

public class LoginFilter extends CustomJsonEmailPasswordAuthenticationFilter {

    private final MemberService memberService;
    private final JWTUtil jwtUtil;
    private final RefreshService refreshService;
    private final ObjectMapper objectMapper;

    private static final String CONTENT_TYPE = "application/json"; // JSON 타입의 데이터로 오는 로그인 요청만 처리

    public LoginFilter(AuthenticationManager authenticationManager, ObjectMapper objectMapper, MemberService memberService, JWTUtil jwtUtil, RefreshService refreshService, ObjectMapper objectMapper1) {
        super(authenticationManager, objectMapper);
        this.memberService = memberService;
        this.jwtUtil = jwtUtil;
        this.refreshService = refreshService;
        this.objectMapper = objectMapper1;
    }

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException {

로그인 필터를 통해서, 해당 회원에 대한 검증과 로그인 처리가 클라이언트로 응답 되어 집니다. 

위 사진에서 실제적으로 인증을 처리하는 서비스 로직은 JWTFilterV3 입니다. 
위 필터에서는 클라이언트에게 받은 HttpOnly Cookie 에 있는 리프레쉬 토큰과, 헤더에 담긴 Authorization 내의 엑세스토큰에 대해
유효성 검사를 진행하며, 인증이 완료되었으면 SecurityContextHolder 에 사용자 인증 정보 토큰 (Authentication)을 넘겨주며 

서버에서는 Authentication 의 Granted Authority 를 확인하고, 접근이 허용된 리소스(controller, resource data, etx ...)에 대해
request 를 허가 해줍니다. (관리자, 일반 사용자 등등에 대한 권한이  Authentication 의 Granted Authority에 존재한다)

JWT + Social Login (Kakao, Google, Naver) 흐름도 에서 가장 중요한 점은

Href 하이퍼링크 클릭을 통해 백엔드 소셜 로그인 (필터를 통한 구현) 같은 경우 
Response Data, Header 를 통한 응답이 불가하며
Cookie 나 Query Parameter 를 통해 토큰을 전달 해야 한다

는 것 입니다. 이는 구현하는 데에 굉장히 신경 쓰이는 부분 이었습니다.

이를 고려 했을 때,

  1. Query Parameter 에 Token을 담아주는 것은 보안상 굉장히 좋지 않습니다. 
  2. Refresh Token 을 HttpOnly, https 등 보안을 높이기 위한 방식의 Cookie 로 클라이언트에게 전달 해야 합니다. (로컬 환경에서는 http, 배포 환경에서는 https 설정을 해줍니다)
  3. 그렇다고 Access Token 을 HttpOnly 쿠키로 보내버리면, 자바스크립트로 Access Token 를 쿠키에서 꺼내서 로컬 스토리지에 저장 할 수 없기 때문에 HttpOnly 쿠키로 보내주지 않았습니다.
  4. 결국에 Custom Social Login Success Filter 가 성공 하고 나서, 응답에는 Refresh Token 만을 HttpOnly Cookie 에 담아 주었습니다.
  5. 소셜 로그인 성공과 함께, 백엔드 -> 클라이언트 메인 페이지로 Redirect 시켜주면서 Query Parameter 
    " https://도메인?redirectedFromSocialLogin=true "
    redirectedFromSocialLogin 를 담아 주었고 
  6. 리액트(클라이언트)에서는 Query Parameter 에 redirectedFromSocialLogin 가 담긴 시점에 서버로부터 Access Token 을 받는 api 를 요청하도록 서비스 로직을 결정했습니다.

 

이해가 안되시거나, 궁금하신 점이 있으시면 댓글 남겨 주시면 감사하겠습니다.