[PreProject] [OAuth2.0 트러블슈팅 1편] OAuth2.0, localhost, HttpServlet

NtoZ·2023년 8월 17일
0

PreProject

목록 보기
6/12

OAuth2.0의 이해

  • 위 그림은 CSR 방식의 OAuth2.0 인증 과정을 잘 설명하는 그림이다.

  • (1) Resource Owner가 웹 브라우저에서 ‘Google 로그인 링크’를 클릭.


  • (2) ⭐Frontend 애플리케이션에서 Backend 애플리케이션의 http://localhost:8080/oauth2/authorization/google로 request를 전송. 이 URI의 request는 OAuth2LoginAuthenticationFilter⚡ 가 처리합니다.
    • 서버 쪽에 어떤 url 엔드포인트 설정도 하지 않았는데http://localhost:8080/oauth2/authorization/google를 GET 요청하면 뜬금없이 구글 폼이 열리는 이유를 모르겠다면?

      아하, 💡스프링 시큐리티 OAUTH2는 인증 공급자(Identity Provider)와의 연동을 위해
      http://localhost:8080/oauth2/authorization/{registrationId}와 같은 엔드포인트를 기본적으로 제공
      한다.
      또한 이 URL은 인증 공급자의 설정과 클라이언트 ID, 시크릿 등을 기반으로 만들어지며, OAuth2 인증 흐름을 시작하는 엔드포인트이다! (인텔리제이에서 환경변수 설정을 해놓았다. ec2에서도 동일한 환경변수를 설정해놓아야 할 것이다.)

  • (3) Google의 로그인 화면을 요청하는 URI로 리다이렉트 한다. 이때 ⭐Authorization Server가 Backend 애플리케이션 쪽으로 Authorization Code를 전송할 Redirect URI(http://localhost:8080/login/oauth2/code/google)를 쿼리 파라미터로 전달한다. Redirect URI는 Spring Security가 내부적으로 제공한다.
    (따라서 설정을 명시적으로 하지 않아도 동작이 가능하다.)

  • (4) Google 로그인 화면을 오픈.

  • (5) Resource Owner가 Google 로그인 인증 정보를 입력해서 로그인을 수행.

  • (6) 로그인에 성공하면 (3)에서 전달한 Backend Redirect URI(http://localhost:8080/login/oauth2/code/google)로 Authorization Code를 요청한다.

  • (7) Authorization Server가 Backend 애플리케이션에게 Authorization Code를 응답으로 전송한다.

  • (8) ⭐Backend 애플리케이션이 Authorization Server에 Access Token을 요청한다.

  • (9) Authorization Server가 Backend 애플리케이션에게 Access Token을 응답으로 전송한다.

    여기에서 Access Token은 Google Resource Server에게 Resource를 요청하는 용도로 사용된다.

  • (10) ⭐Backend 애플리케이션이 Resource Server에 User Info를 요청합니다.

    여기서의 User Info는 Resource Owner에 대한 이메일 주소, 프로필 정보 등을 의미한다.

  • (11) Resource Server가 Backend 애플리케이션에 User Info를 응답으로 전송한다.
    • User Info란, 구글 클라우드에서 미리 지정한 범위를 의미한다.

  • (12) ⭐Backend 애플리케이션은 JWT로 구성된 Access Token과 Refresh Token을 생성한 후, Frontend 애플리케이션에 JWT(Access Token과 Refresh Token)를 전달하기 위해 Frontend 애플리케이션(http://localhost?access_token={jwt-access-token}&refresh_token={jwt-refresh-token})으로 Redirect한다.
    • 여기서 프론트엔드 애플리케이션은 아파치로 구동한 상태를 의미한다. 교육과정에서 리다이렉션 되는 URI는 다음과 같다.
# OAuth2AccountSuccessHandler 클래스의 리다이렉션 코드 중 일부
return UriComponentsBuilder
                .newInstance()
                .scheme("http")
                .host("localhost")
//                .port(80) // 80은 디폴트 값이므로 생략 가능
                .path("/receive-token.html")
                .path("/login")
                .queryParams(queryParams)
                .build()
                .toUri();
  • 위 코드 설명
    • 위 코드는 구글 OAuth2.0 로그인 인증이 성공했을 경우 리디렉션되는 과정의 일부이다.
      OAuth2AccountSuccessHandler는 구글로부터 User Resource를 성공적으로 받아올 때 리다이렉트 되는 주소를 지정함과 동시에 쿼리파라미터로 OAuth Client(Backend 애플리케이션)에서 자체적인 인증/인가 관리를 위해 발급하는 액세스 토큰과 리프레쉬 토큰을 전달한다.
      (이 토큰들이 구글 자원에 대한 액세스 토큰을 의미하지 않음을 명심하자. OAuth2AccountSuccessHandler를 구현할 때 자체적인 토큰을 발급해 파라미터에 담아주도록 구현했었다. (단, 자체 로그인 과정에서는 헤더로 담아주도록 구현했었다.))

    • 위 코드에서는 결과적으로 "http://localhost:80/receive-token.html/login?access_token=토큰값&refresh_token=토큰값"와 같은 형식의 URI를 생성한다.
      recieve-token.html의 소스는 다음과 같다.
  <!DOCTYPE html>
  <html>
  <head>
      <meta charset="UTF-8">
      <title>OAuth2 + JWT My page</title>
  </head>
  <body>
      <script type="text/javascript">
          let accessToken = (new URL(location.href)).searchParams.get('access_token');
          let refreshToken = (new URL(location.href)).searchParams.get('refresh_token');

          localStorage.setItem("accessToken", accessToken)
          localStorage.setItem("refreshToken", refreshToken)

          location.href = 'my-page.html'
      </script>
  </body>
  </html>

<!--  코드는 URL의 파라미터에 담겨 있는 access_token과 refresh_token  
	로컬 스토리지에 저장한  my-page로 리다이렉션하는 코드이다.--!>
  • 목적이 되는 리다이렉트 URI는 무엇일까? 당연히 프론트엔드 웹 서버일 것이고, Param을 받아 액세스 토큰과 리프레시 토큰을 저장하도록 하는 URL이면 될 것이다.

  • 💡⭐ 이 부분의 배포 단계에서 큰 오해를 했다. S3와 EC2를 예시로 들어보자.
    S3와 EC2는 8080이 아닌 80 포트를 가지고 있다.
    (http의 기본 포트는 80포트이며, 웹애플리케이션 서버(톰캣)의 기본 포트가 8080인데, 그 동안 이 두 가지 포트의 개념을 헷갈려했다.)
    따라서 S3 호스트와 더불어 기본 80번 포트의 액세스 토큰과 리프레쉬 토큰을 로컬 저장소 등에 받아 저장할 수 있는 로직이 있는 html 페이지로 전송해야 한다. (우리는 http://{s3호스트}/login 페이지에서 쿼리파라미터로 오는 액세스 토큰과 리프레시 토큰을 받기로 했다.)

  • LoginPage.js

...전략
  useEffect(() => {
    const urlSearchParams = new URLSearchParams(window.location.search);
    const accessToken = urlSearchParams.get("access_token");
    const refreshToken = urlSearchParams.get("refresh_token");


    if (accessToken && refreshToken) {
      try {
        // 로컬 스토리지에 토큰 저장
        localStorage.setItem("access_token", accessToken);
        localStorage.setItem("refresh_token", refreshToken);


        // 토큰을 헤더에 포함시켜서 요청
        axios.defaults.headers.common["Authorization"] = `Bearer ${accessToken}`;

        // displayName 불러오기
        axios.get("http://ec2-3-36-128-133.ap-northeast-2.compute.amazonaws.com/v1/auth/oauth")
          .then(response => {
            console.log(response);
            const displayName = response.data.displayName;

            console.log(displayName);

            // 로그인 성공 처리
            dispatch(loginSuccess({ accessToken, displayName }));
          })

        // 로그인 성공 후 리다이렉션 처리
       navigate("/");

      } catch (error) {
        console.error("로그인 에러:", error);
        // 로그인 에러 처리
        dispatch(loginFailure("로그인 중 에러가 발생했습니다."));
        // 오류 메시지 표출
        setGoogleLoginError(true);
        // 로그인 페이지로 리다이렉션
       navigate("/login");
      }
    }
  }, [dispatch, navigate]);
... 후략

  • ⭐ 동작 흐름이 아주 복잡해 보이지만 (6)부터 (11)까지는 Spring Security에서 내부적으로 알아서 처리해 주기 때문에 기본적으로 우리가 건드릴 필요가 없다.
    • 위 설명에서 리다이렉트 URI은 직접 구성해야 함을 알 수 있다.

localhost

localhost에 대한 개념

  • "localhost"는 네트워크에서 자신의 컴퓨터를 가리키는 특수한 호스트 이름입니다. 이는 컴퓨터가 자기 자신을 참조할 때 사용되는 표준적인 호스트 이름입니다. 대부분의 운영 체제에서 이 호스트 이름은 자동으로 루프백 주소인 127.0.0.1로 매핑됩니다.

    즉, "localhost"는 현재 실행 중인 컴퓨터의 로컬 루프백 인터페이스를 가리키며, 네트워크를 통해 다른 컴퓨터와 통신하지 않고 자기 자신과 상호작용할 때 사용됩니다. 이를 이용하여 웹 서버나 데이터베이스 서버 등을 로컬에서 개발하고 테스트할 수 있습니다.

    예를 들어, 웹 개발에서 웹 서버를 localhost에 띄워서 개발 중인 웹 애플리케이션을 웹 브라우저로 접근하거나 API 호출을 테스트할 수 있습니다. 이런식으로 개발 시에는 외부 네트워크 연결을 거치지 않고도 로컬 환경에서 작업을 진행할 수 있어 편리합니다.

문제 상황과 오해가 풀리는 과정

  • 프론트엔드 웹 서버(S3)에 대한 로컬 서버의 CORS 설정을 확인하고자 했으나 개인적인 오해와 무지 때문에 문제 상황이 발생했다.

    이 상황 직전 나는 로컬환경에서 localhost 3000번 (로컬 리액트 서버) -> localhost 8080번 (톰캣 서버)로의 요청에 대해 CORS 정책이 정상적으로 적용되는 것을 확인한 상태였다.

    프론트 엔드 웹 서버에 CI/CD된 S3 파일이 서버에 업로드 되어 있기 때문에
    S3 주소 -> localhost:8080으로 요청도 당연히 허용될 줄 알았으나...

    해당 CORS 정책 위반 문제가 발생하며, 내 로컬 웹 애플리케이션 서버에서는 아무런 요청도 받아들이지 못했다.

    이는 내가 localhost를 해당 서버의 로컬 환경이 아닌, '내 컴퓨터'의 로컬 환경으로 오해했기 때문에 발생한 문제였다. 다시 생각해보자.

    localhost는 '내 컴퓨터의 로컬환경'이 아닌 '해당하는 컴퓨터 자신의 로컬 환경'이다. S3 역시 일종의 컴퓨터이므로, S3가 내 컴퓨터의 로컬 톰캣 서버에 요청을 보내는 것이 아닌, S3 자기 자신의 컴퓨터 톰캣 서버에 요청을 보내고 있었던 것이다. 당연히 포트번호가 바뀌니 ORIGIN이 다르고, S3에서 별도의 CORS 설정을 하지 않았기 때문에 CORS 정책 위반이 출력되는 것이다.

  • 따라서 위의 논리에 따라 두 가지 방법을 생각할 수 있다.
    • S3의 호스트 -> EC2의 호스트 URI로 요청을 보낸다.
    • Ngrok을 켜고 외부에서 로컬 호스트(내 컴퓨터)의 서버로 접근하도록 설정한다.

  • 위의 해결책은 모두 프론트엔드 개발자가 요청 링크를 수정해야 하므로 이후 이에 대해 다시 작성하기로 한다.

HttpServletRequest과 HttpServletResponse

  • HttpServletRequest 및 HttpServletResponse는 서블릿 컨테이너에서 제공하는 클래스로, 웹 애플리케이션에서 HTTP 요청 및 응답과 관련된 정보를 다룰 수 있도록 도와줍니다. 아래는 주요한 정보들을 가져올 수 있는 몇 가지 메서드와 속성에 대한 예시입니다.

  • HttpServletRequest:

HttpServlet 요청과 응답의 사용방법

BackEnd - 자바 주소 가져오기

  • HttpServlet의 객체는 다음과 같은 메소드를 사용해서 요청 객체의 정보를 얻어올 수 있다.

  • HttpServletRequest:

    getParameter(String name): HTTP 요청 파라미터 값을 가져옵니다.
    getParameterValues(String name): 동일한 이름의 여러 파라미터 값을 가져옵니다.
    getMethod(): HTTP 요청 메서드(GET, POST 등)를 가져옵니다.
    getRequestURL(): 요청된 URL의 문자열을 가져옵니다.
    getRequestURI(): 요청된 URI의 문자열을 가져옵니다.
    getHeader(String name): 지정된 헤더의 값을 가져옵니다.
    getInputStream(): 요청 바디의 내용을 읽기 위한 InputStream을 가져옵니다.

  • HttpServletResponse:

    setStatus(int sc): HTTP 응답 상태 코드를 설정합니다.
    sendRedirect(String location): 지정된 위치로 리다이렉트합니다.
    addHeader(String name, String value): 응답 헤더에 새로운 헤더를 추가합니다.
    setContentType(String type): 응답 컨텐츠의 MIME 타입을 설정합니다.
    getWriter(): 응답 데이터를 쓰기 위한 PrintWriter를 가져옵니다.


구체적인 예시

실례1

@WebServlet("/example")
public class ExampleServlet extends HttpServlet {

    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {
        String parameterValue = request.getParameter("param");
        String method = request.getMethod();
        String requestURI = request.getRequestURI();
        String headerValue = request.getHeader("HeaderName");

        response.setStatus(HttpServletResponse.SC_OK);
        response.sendRedirect("https://www.example.com");
        response.addHeader("CustomHeader", "HeaderValue");
        response.setContentType("text/html");

        PrintWriter writer = response.getWriter();
        writer.println("<html><body>Hello, Servlet!</body></html>");
    }
}
  • 이는 특히 필터와 연계하여 로직을 구현한다면 좋은 활용 방법이 될 것이다.

실례2 : OAuth2MemberSuccessHandler

package com.example.stackoverflowclone.global.security.auth.handler;


import com.example.stackoverflowclone.domain.member.dto.MemberLoginResponseDto;
import com.example.stackoverflowclone.domain.member.entity.Member;
import com.example.stackoverflowclone.domain.member.service.MemberService;
import com.example.stackoverflowclone.global.response.DataResponseDto;
import com.example.stackoverflowclone.global.security.auth.jwt.JwtTokenizer;
import com.example.stackoverflowclone.global.security.auth.utils.CustomAuthorityUtils;
import com.google.gson.Gson;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.util.UriComponentsBuilder;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.net.URI;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;


@Slf4j
@AllArgsConstructor
public class OAuth2MemberSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
    private final JwtTokenizer jwtTokenizer;
    private final CustomAuthorityUtils authorityUtils;
    private final MemberService memberService;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        // log.info("# Redirect to Frontend");
        OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal();
        String name = (String) oAuth2User.getAttributes().get("name");
        String email = (String) oAuth2User.getAttributes().get("email");
        String picture = (String) oAuth2User.getAttributes().get("picture");

        // log.info("# getPrincipal : " + oAuth2User);
        // log.info("# name : "+ name);
        // log.info("# email : "+ email);
        // log.info("# picture : "+ picture);

        // email을 토대로 Member 객체 만들어서 DB에 저장
        Member member = buildOAuth2Member(name, email, picture);
        Member saveMember = saveMember(member);

        // 얻은 email 주소로 권한 List 만들기
        List<String> authorities = authorityUtils.createRoles(email);

        // 리다이렉트를 하기위한 정보들을 보내줌
        redirect(request,response,saveMember,authorities);
    }
    private Member buildOAuth2Member(String name, String email, String picture){
        return Member.builder()
                .username(name)
                .email(email)
                .password("")
                .location("")
                .aboutMe("")
                .title("")
                .fullname("")
                .image(picture)
                .websiteLink("")
                .twitterLink("")
                .githubLink("")
                .fullname("")
                .build();
    }

    public Member saveMember(Member member){
        return memberService.createMemberOAuth2(member);
    }

    private void redirect(HttpServletRequest request, HttpServletResponse response, Member member, List<String> authorities) throws IOException {
        // 받은 정보를 토대로 AccessToken, Refresh Token을 만듬
        String accessToken = delegateAccessToken(member, authorities);
        String refreshToken = delegateRefreshToken(member);

        // Token을 토대로 URI를 만들어서 String으로 변환
        String uri = createURI(request, accessToken, refreshToken).toString();

        // 헤더에 전송해보기
        String headerValue = "Bearer "+ accessToken;
        response.setHeader("Authorization",headerValue); //⭐ Header에 등록
        response.setHeader("Refresh",refreshToken); //⭐ Header에 등록
        // response.setHeader("Access-Control-Allow-Credentials:", "true");
        // response.setHeader("Access-Control-Allow-Origin", "*");
        // response.setHeader("Access-Control-Expose-Headers", "Authorization");

        // 만든 URI로 리다이렉트 보냄
        getRedirectStrategy().sendRedirect(request,response,uri);
    }

    private String delegateAccessToken(Member member, List<String> authorities){

        Map<String,Object> claims = new HashMap<>();
        claims.put("roles", member.getRoles());
        claims.put("memberId", member.getMemberId());

        String subject = member.getEmail();
        Date expiration = jwtTokenizer.getTokenExpiration(jwtTokenizer.getAccessTokenExpirationMinutes());
        String base64EncodedSecretKey = jwtTokenizer.encodeBase64SecretKey(jwtTokenizer.getSecretKey());
        return jwtTokenizer.generateAccesToken(claims, subject, expiration, base64EncodedSecretKey);
    }

    private String delegateRefreshToken(Member member){
        String subject = member.getEmail();
        Date expiration = jwtTokenizer.getTokenExpiration(jwtTokenizer.getRefreshTokenExpirationMinutes());
        String base64EncodedSecretKey = jwtTokenizer.encodeBase64SecretKey(jwtTokenizer.getSecretKey());
        return jwtTokenizer.generateRefreshToken(subject, expiration, base64EncodedSecretKey);
    }

	//⭐ createURI메서드에 요청객체 인자를 전달
    private URI createURI(HttpServletRequest request, String accessToken, String refreshToken){
        // 리다이렉트시 JWT를 URI로 보내는 방법
        MultiValueMap<String, String> queryParams = new LinkedMultiValueMap<>();
        queryParams.add("access_token", accessToken);
        queryParams.add("refresh_token", refreshToken);

        String serverName = request.getServerName(); //⭐ 이 WAS에 요청한 곳의 호스트
        // log.info("# serverName = {}",serverName);

        return UriComponentsBuilder
                .newInstance()
                .scheme("http")
                .host(serverName) // ⭐⭐ 여기서 요청이 들어온 곳 '웹 서버'가 아닌 구글 리소스 서버일 것
                //.host("localhost")
                .port(80) // 기본 포트가 80이기 때문에 괜찮다
                .path("/token")
                .queryParams(queryParams)
                .build()
                .toUri();
    }
}
  • ⭐⭐ OAuth2.0에서 OAuth2AccountSuccessHandler에 요청을 보내는 주체는 무엇인가? 나는 최초 요청의 발신지가 웹 서버라고 생각했기 때문에 웹서버(S3라고 가정하면 S3의 호스트명)의 것이라고 생각했다. 그러나 실제 디버깅에 찍힌 값은 예상과 달랐다.
    그런데 실제로는 구글 리소스 서버쪽의 것이었다. 생각해보니 HTTP는 무상태성을 보장하고, Redirect 직전 요청은 구글 리소스 서버로부터 왔기 때문에 이런 값이 전해지는 것이다.

  • ⭐다시 생각해보니, 6번에서 보낸 Redirect URI가 특정 로직을 거쳐 다시 11번으로 돌아오는 것인 것 같기도 하다. (이후 공부해볼 것)⭐

profile
9에서 0으로, 백엔드 개발블로그

0개의 댓글