티스토리 뷰

이전 글에서는 SecurityConfig 구성과 OAuth Login으로 받아온 유저 정보 처리 방법을 알아봤다

이번 글에서는 Security 설정 중 JWT와 관련된 부분을 알아보고 로그인 이후의 흐름을 살펴보자

JWT 설정은 내용이 많지는 않지만 따로 클래스로 빼내 구성하고 있다

현재 프로젝트에서는 OAuth2 로그인 시에는 구글에서 발급해준 ID & Access Token을 그대로 이용하고

애플리케이션 자체 로그인 시에는 직접 발급한 ECC 알고리즘 기반 ID & Access Token을 이용한다

 

일반적으로 Access & Refresh Token으로 묶겠지만 로그인, 토큰 발급 과정의 개념적인 통일을 위해

살짝 번거롭더라도 ID 토큰으로 사용자의 정보를 파싱 한다, 따라서 ID Token이 JWT이고

Access Token은 ECC로 생성한 랜덤 문자열 뿐이고 구글 또한 JWT가 아닌 Access Token을 사용하기에 따랐다

구글과 같은 방식을 유지하려면 Refresh Token 역시 JWT가 아니어야 하는데

로직 흐름 상 Refresh Token도 파싱 가능하면 편리하기에 JWT로 만들었다

이 부분은 얼마든지 개인의 선호에 따라 달라지는데 JWT로 해도 지장이 없는 것은 Refresh Token은 DB로 관리하고

사용자에게는 전혀 노출하지 않아 사실 평문을 넣어놔도 아무런 상관이 없다 

 

JwtSecurityConfig에서는 토큰 검증을 위한 Filter 두 개만 추가해주고 있고 Local Filter -> OAuth2 Filter 순으로 흐른다

Filter에서 지정해둔 필터링 제외 url을 거치지 않는 한 사용자의 모든 요청은 필터를 거치게 된다

OAuth2 필터에서는 토큰 검증을 위해 구글로 API를 쏴줘야 한다

만약 OAuth2 Filter를 Local Filter 앞단에 두었다면 애플리케이션 자체 로그인한 회원의 경우에도 매번 API를 쏘고

올바르지 않은 토큰임을 확인한 후에야 Local Filter로 넘어가게 된다

@Configuration
@RequiredArgsConstructor
public class JwtSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {

  private final TokenAuthenticationEntryPoint tokenAuthenticationEntryPoint;
  private final LocalTokenAuthenticationFilter localTokenAuthenticationFilter;
  private final OAuth2TokenAuthenticationFilter oAuth2TokenAuthenticationFilter;

  @Override
  public void configure(HttpSecurity http) throws Exception {
    http
        .addFilterBefore(oAuth2TokenAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
        .addFilterBefore(localTokenAuthenticationFilter, OAuth2TokenAuthenticationFilter.class)
        .exceptionHandling()
        .authenticationEntryPoint(tokenAuthenticationEntryPoint);
  }
}

 

이 부분이 사실 엄청난 지연을 만들어내지는 않는다

토큰 검증 Filter들은 단 한 번만 실행함을 보장하는 OncePerRequestFilter를 상속받아 만들었는데

이 것만 믿고 까불다가 정적 자원에 대한 url마저 필터를 타고 거의 하나의 요청에 8-9번 API를 쏘는 현상이 있었다

이 얘기만 들으면 미친 애플리케이션이라 생각하겠지만 개발 과정에서는 느리다는 생각을 할 수가 없었다

화면은 thymeleaf로 렌더링하기 때문에 로딩 속도는 SPA와 비교하여 애초에 엄청나게 느렸는데

Filter에서 8-9번 쏘는 게 느리게 느껴지지 않은 게 신기하다

이 문제도 느려서 찾은게 아니라 Security Debug 중에 찾아냈다

 

SecurityConfig에 붙이는 @EnableWebSecurity에 debug 옵션을 줄 수 있는데

어떤 필터를 타는지와 요청에 대한 헤더, 페이로드 정보 등을 확인할 수 있어 개발 과정에 개꿀이다

로그가 엄청나게 쌓인다는 단점이 있지만 요청 흐름을 파악할 수 있어 유용하다

이 옵션을 켜두고 확인하니 정적 자원들도 필터를 거치고 있음을 알게 됐다

@EnableWebSecurity(debug = true)

 

느리게 느껴지지 않은 이유 중 하나는 외부 통신에 WebClient를 사용하고 있었기 때문인 것 같기도 하다

현재는 OncePerRequestFilter의 shouldNotFilter() 메서드를 재정의해 내가 지정한 정적 자원 url들은 패스하게 해 놨다

필터에서의 큰 흐름은 아래와 같다

 

  1. OAuth2 토큰인지 확인
    1. 아니라면 다음 필터로 넘긴다
    2. 맞다면 다음 코드로 간다
  2. 토큰의 유효성을 확인한다
    1. 유효하다면 SecurityContextHolder에 ID Token을 파싱 해 Authentication을 넣어준다
    2. 유효하지 않다면 갱신 요청을 할 수 있도록 forward 방식으로 넘긴다
  3. 다음 필터로 넘긴다

 

@Component
@RequiredArgsConstructor
public class OAuth2TokenAuthenticationFilter extends OncePerRequestFilter {

  private final TokenFilterHelper tokenFilterHelper;
  private final OAuth2TokenVerifier oAuth2TokenVerifier;

  @Override
  protected boolean shouldNotFilter(HttpServletRequest request) {
    return PatternMatchUtils.simpleMatch(UriConstants.SHOULD_NOT_FILTER_URL_PATTERNS, request.getRequestURI());
  }

  @Override
  protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException {
    String idToken = TokenUtils.resolveIdToken(request);
    if (!oAuth2TokenVerifier.isGoogleToken(idToken)) {
      chain.doFilter(request, response);
      return;
    }

    if (!tokenFilterHelper.setAuthenticationIfValid(oAuth2TokenVerifier, idToken))
      forwardToRenew(request, response);

    chain.doFilter(request, response);
  }

  private void forwardToRenew(HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException {
    String idToken = TokenUtils.resolveIdToken(request);
    String refreshToken = tokenFilterHelper.getRefreshTokenFromIdToken(oAuth2TokenVerifier, idToken);
    String targetUri = String.format(
        "/oauth2/google/renewal/redirect?redirect_uri=%s&refresh_token=%s", request.getRequestURI(), refreshToken);
    request.getRequestDispatcher(targetUri).forward(request, response);
  }
}

 

여기서도 애 먹었던 부분은 토큰 갱신 요청할 때 redirect 할 url에 필요한 정보를 다 넘겨서 보내니 forward 방식이 아닌

redirect 방식으로 보냈었는데 다음과 같은 에러를 만날 수 있었다, 원인은 다음과 같다

java.lang.IllegalStateException: Cannot call sendRedirect() after the response has been committed

여기서 redirect로 보내고 받는 Controller 쪽에서도 처리 후 redirect로 보내기 땜시 사용자의 요청 하나를

redirect 2번 보내려 했기 때문에 발생한 문제였다

Filter에서 보낼 때 redirect 방식을 고집할 필요가 없어 forward 방식으로 바꿔 해결했다

 

많은 로직을 Filter에서 직접 처리했으나 그렇게 하니 OAuth2, Local Filter에서 중복이 발생하고 너무 많은 책임을 갖게 됐다

이를 해결하는 가장 쉬운 방법은 Helper 클래스를 만들어 버리는 것이다

단 유사한 상황에 모두 무지성 Helper 클래스를 만들면 거대한 책임을 갖는 Helper를 갖게 되므로 남용은 지양하자

 

다음은 OAuth2 로그인이 완료된 후 거치게 되는 SuccessHandler다, 흐름은 아래와 같다

  1. 인자로 넘어오는 authentication에서 principal 객체를 꺼내고
  2. 애플리케이션 자체에서 만든 OAuth2UserPrincipal로 형 변환 때린다
  3. 가지고 있는 토큰 값들로 사용자에게 내려줄 TokenResponse를 만들어준다
  4. SecurityContextHolder에 authentication 넣어주고
  5. 클라이언트 브라우저에 토큰 쿠키를 박아준다
  6. 사용자의 요청 uri로 redirect 시켜주고 끝낸다
@Component
@RequiredArgsConstructor
public class OAuth2AuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {

  private final HttpCookieOAuth2AuthorizationRequestRepository httpCookieOAuth2AuthorizationRequestRepository;

  @Override
  public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException {
    httpCookieOAuth2AuthorizationRequestRepository.removeAuthorizationRequestCookies(request, response);

    OAuth2UserPrincipal principal = (OAuth2UserPrincipal) authentication.getPrincipal();
    OAuth2TokenResponse oAuth2TokenResponse = OAuth2TokenResponse.builder()
        .accessToken(principal.getOAuth2Token().getTokenValue())
        .oidcIdToken(principal.getIdToken().getTokenValue())
        .build();

    SecurityUtils.setAuthentication(authentication);
    CookieUtils.addOAuth2TokenToBrowser(response, oAuth2TokenResponse);

    String targetUri = getUriByRole(principal);
    response.sendRedirect(targetUri);
  }

  private String getUriByRole(UserPrincipal principal) {
    return isUser(principal)
        ? UriConstants.Mapping.POSTS
        : UriConstants.Mapping.MEMBERS;
  }

  private boolean isUser(UserPrincipal principal) {
    return principal.getAuthorities()
        .stream()
        .anyMatch(Role.USER::equals);
  }
}

 

 

쿠키는 어디에 저장해야 할까? 라는 고민이 생길 수 있다, 링크를 따라가 장단점을 비교해보고 선택하자

local-storage, cookie 둘 다 나쁘지 않다

나는 httpOnly, secure 속성들 때문에 cookie가 보안적으로 더 나을 거라 판단했고

쿠키의 중복된 요청은 http/2, http/3을 사용한다면 해결할 수 있는 문제라 생각했다

http/1.1에서도 바디 부분은 brotli, gzip 등의 압축을 통해 효율적으로 전송할 수 있는데

http/2 이상에서는 헤더도 압축하거나 중복된 경우 클라이언트에서 보내지 않아도 서버에서는 받은 것으로 인식할 수 있다

달라진 부분만 전송함으로써 불필요한 전송을 줄이는 최적화가 있었기 때문이다

 

언제까지 http/1.1 환경에 머무를 수는 없는 노릇이니 이 참에 http/2 공부도 조금 해봤다

http/2 in action 책을 추천한다, 난 두 번 정도 읽었는데 low-level 부분도 깊게 파야하기 때문에 다 이해할 수는 없었다

http/3부터는 TCP 기반에서 UDP 기반으로 바뀌고 엄청난 효율 상승이 올 예정이니 미리 조금 공부해두자

http/3에 대한 개꿀 블로그가 있으니 시간 날 때 읽어보도록 하자

 


 

 

GitHub - Ryu-JeongMoon/oauth2-post-service

Contribute to Ryu-JeongMoon/oauth2-post-service development by creating an account on GitHub.

github.com

 

'Project' 카테고리의 다른 글

[OAuth2] Spring-Security OAuth2 구글 연동 - 2  (0) 2022.04.24
[OAuth2] Spring-Security OAuth2 구글 연동 - 1  (0) 2022.04.24
[notice-service] 게시글 REST API 구현하기  (0) 2021.11.20
board-api  (0) 2021.10.25
product-api  (0) 2021.10.25
댓글
링크
글 보관함
«   2024/05   »
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 31
Total
Today
Yesterday