SpringSecurity+JWT+OAuth2를 사용한 소셜로그인

Wintering·2022년 8월 8일
1

PROJECT

목록 보기
2/5

SpringSecurity

JWT

OAuth2

프로젝트에 적용한 OAuth 인증 과정

프로젝트에 적용 된 소셜 로그인 FLOW

1️⃣ from Frontend to Authorization Server [ 로그인 URL 요청 ]

• redirect_uri : OAuth2 provider가 성공적으로 인증을 완료했을 때 redirect 할 URI를 지정합니다.
(OAuth2의 redirectUri 와는 다름!)

<div onclick="location.href='/oauth2/authorization/naver'">네이버로 시작하기</div>
<div onclick="location.href='/oauth2/authorization/kakao'">카카오로 시작하기</div>
<div onclick="location.href='/oauth2/authorization/google'">구글로 시작하기</div>

2️⃣ endpoint로 인증 요청을 받으면, Spring Security의 OAuth2 클라이언트는 user를 provider가 제공하는 AuthorizationUrl로 redirect 합니다.

  • Authorization request와 관련된 state는 authorizationRequestRepository 에 저장됩니다.
    (Security Config에 정의)
  • provider에서 제공한 AutorizationUrl에서 허용/거부가 정해집니다.
    • 이때 만약 유저가 앱에 대한 권한을 모두 허용하면 provider는 사용자를 callback url로 redirect한다. (http://localhost:8080/oauth2/callback/{provider}) 그리고 이때 사용자 인증코드 (authroization code) 도 함께 갖고있습니다.
    • 만약 거부하면 callbackUrl로 똑같이 redirect 하지만 error가 발생!

SecurityConfig.java

@Configuration
@RequiredArgsConstructor
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
   private final CorsProperties corsProperties;
   private final AppProperties appProperties;
   private final AuthTokenProvider tokenProvider;
   private final CustomOAuth2UserService oAuth2UserService;
   private final TokenAccessDeniedHandler tokenAccessDeniedHandler;
   private final UserRefreshTokenRepository userRefreshTokenRepository;

   @Override
   protected void configure(HttpSecurity http) throws Exception {
       http.cors()
               .and()
               .sessionManagement()
               .sessionCreationPolicy(SessionCreationPolicy.STATELESS) //JWT 인증에는 기본적으로 session을 사용하지 않기 때문에 STATELESS
               .and()
               .csrf().disable()
               .httpBasic().disable()
               .formLogin().disable()
               .exceptionHandling()
               .authenticationEntryPoint(new RestAuthenticationEntryPoint())
               .accessDeniedHandler(tokenAccessDeniedHandler)
               .and()
               .authorizeRequests()
               .requestMatchers(CorsUtils::isPreFlightRequest).permitAll()
               .antMatchers("/", "/favicon.ico", "/**/*.png", "/**/*.gif", "/**/*.svg", "/**/*.jpg", "/**/*.html", "/**/*.css", "/**/*.js").permitAll()
               .antMatchers("/register/**").hasAuthority(RoleType.NORMAL.getCode())
               .antMatchers("/login").permitAll()
               .antMatchers("/userLogout").permitAll()
               .antMatchers("/events/**").permitAll()
               .antMatchers("/cafes/**").permitAll()
               .antMatchers("/posts/**").permitAll()
               .antMatchers("/api/**").permitAll()
               .antMatchers("/swagger-resources/**").permitAll()
               .anyRequest().authenticated() //설정된 값 이외의 나머지 URL, 인증된 사용자, 로그인한 사용자만 볼 수 있음
               .and()
               .oauth2Login()  //Oauth2 로그인 기능에대한 여러가지 설정의 진입점
               .authorizationEndpoint()
               .baseUri("/oauth2/authorization")
               .authorizationRequestRepository(oAuth2AuthorizationRequestbasedOnCookieRepository()) //Authorization request와 관련된 state가 저장됨
               .and()
               .redirectionEndpoint()//endpoint로 인증요청을 받으면, Spring security의 Oauth2 사용자를 provider가 제공하는 AuthorizationUriRedirect
               .baseUri("/*/oauth2/code/*") // 이 때, 사용자 인증코드 (authorization code)를 함께 갖고감
               .and()
               .userInfoEndpoint() //Oauth2 로그인 성공 이후 사용자 정보를 가져올때의 설정 담당
               .userService(oAuth2UserService) // 소셜 로그인 성공 시 후속조치를 진행할 UserService인터페이스의 구현체 등록
               .and()
               .successHandler(oAuth2AuthenticationSuccessHandler()) // JWT authentication token을 만들고, client가 정의한 redirect로 token을 갖고 넘어감
               .failureHandler(oAuth2AuthenticationFailureHandler()); // 인증이 실패하면 error코드를 담은 uri를 넘겨줌

       http.addFilterBefore(tokenAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
   }

   @Override
   public void configure(WebSecurity web) throws Exception {
       web.ignoring().antMatchers("/static/css/**, /static/js/**, *.ico");

       // swagger
       web.ignoring().antMatchers(
               "/v2/api-docs",  "/configuration/ui",
               "/swagger-resources", "/configuration/security",
               "/swagger-ui.html", "/webjars/**","/swagger/**", "/swagger-ui/index.html");
   }

   //auth 매니저 설정
   @Override
   @Bean(BeanIds.AUTHENTICATION_MANAGER)
   protected AuthenticationManager authenticationManager() throws Exception {
       return super.authenticationManager();
   }

   //토큰 필터 설정
   @Bean
   public TokenAuthenticationFilter tokenAuthenticationFilter() {
       return new TokenAuthenticationFilter(tokenProvider);
   }

   //Oauth 인증 실패 핸들러
   @Bean
   public OAuth2AuthenticationFailureHandler oAuth2AuthenticationFailureHandler() {
       return new OAuth2AuthenticationFailureHandler(oAuth2AuthorizationRequestbasedOnCookieRepository());
   }

   //Oauth 인증 성공 핸들러
   @Bean
   public OAuth2AuthenticationSuccessHandler oAuth2AuthenticationSuccessHandler() {
       return new OAuth2AuthenticationSuccessHandler(
               tokenProvider,
               appProperties,
               userRefreshTokenRepository,
               oAuth2AuthorizationRequestbasedOnCookieRepository()
       );
   }

   //쿠키 기반 인가 repository, 인가 응답을 연계 하고 검증할 때 사용
   @Bean
   public OAuth2AuthorizationRequestBasedOnCookieRepository oAuth2AuthorizationRequestbasedOnCookieRepository() {
       return new OAuth2AuthorizationRequestBasedOnCookieRepository();
   }

   //Security 설정 시, 사용할 인코더 설정
   @Bean
   public BCryptPasswordEncoder passwordEncoder() {
       return new BCryptPasswordEncoder();
   }

   //Cors 설정
   @Bean
   public UrlBasedCorsConfigurationSource corsConfigurationSource() {
       UrlBasedCorsConfigurationSource corsConfigSource = new UrlBasedCorsConfigurationSource();

       CorsConfiguration corsConfig = new CorsConfiguration();
       corsConfig.setAllowedHeaders(Arrays.asList(corsProperties.getAllowedHeaders().split(",")));
       corsConfig.setAllowedMethods(Arrays.asList(corsProperties.getAllowedMethods().split(",")));
       corsConfig.setAllowedOrigins(Arrays.asList(corsProperties.getAllowedOrigins().split(",")));
       corsConfig.setAllowCredentials(true);
       corsConfig.setMaxAge(corsConfig.getMaxAge());

       corsConfigSource.registerCorsConfiguration("/**", corsConfig);
       return corsConfigSource;
   }
}

3️⃣ Oauth2 에서의 콜백 결과가 에러이면 Spring Security는 OAuth2AuthenticationFailureHanlder 를 호출합니다. (Security Config에 정의함)

OAuth2AuthenticationFailureHandler.java

@Component
@RequiredArgsConstructor
public class OAuth2AuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler {

    private final OAuth2AuthorizationRequestBasedOnCookieRepository authorizationRequestRepository;

    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
        String targetUrl = CookieUtil.getCookie(request, REDIRECT_URI_PARAM_COOKIE_NAME)
                .map(Cookie::getValue)
                .orElse(("/"));

        exception.printStackTrace();

        targetUrl = UriComponentsBuilder.fromUriString(targetUrl)
                .queryParam("error", exception.getLocalizedMessage())
                .build().toUriString();

        authorizationRequestRepository.removeAuthorizationRequest(request, response);

        getRedirectStrategy().sendRedirect(request, response, targetUrl);
    }
}

4️⃣ Oauth2 에서의 콜백 결과가 성공이고 사용자 인증코드 (authorization code)도 포함하고 있다면 Spring Security는 access_token 에 대한 authroization code를 교환하고, customOAuth2UserService 를 호출합니다 (Security Config에 정의)

  • customOAuth2UserService 는 인증된 사용자의 세부사항을 검색한 후에 데이터베이스에 Create를 하거나 동일 Email로 Update 하는 로직을 작성합니다.

CustomOAuth2UserService.java

@Service
@RequiredArgsConstructor
public class CustomOAuth2UserService extends DefaultOAuth2UserService {

   private final UserRepository userRepository;

   @Override
   public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
       OAuth2User user = super.loadUser(userRequest);

       try {
           return this.process(userRequest, user);
       } catch (AuthenticationException ex) {
           throw ex;
       } catch (Exception ex) {
           ex.printStackTrace();
           throw new InternalAuthenticationServiceException(ex.getMessage(), ex.getCause());
       }
   }

   //인증을 요청하는 사용자에 따라서 없는 회원이면 회원가입, 이미 존재하는 회원이면 업데이트를 실행
   private OAuth2User process(OAuth2UserRequest userRequest, OAuth2User user) {

       //현재 진행중인 서비스를 구분하기 위해 문자열을 받음
       ProviderType providerType = ProviderType.valueOf(userRequest.getClientRegistration().getRegistrationId().toUpperCase());
       OAuth2UserInfo userInfo = OAuth2UserInfoFactory.getOAuth2UserInfo(providerType, user.getAttributes());
       Optional<User> checkUser = userRepository.findByUserEmail(userInfo.getEmail());
       User savedUser = checkUser.isEmpty() ? createUser(userInfo, providerType) : checkUser.get();

       if (providerType != savedUser.getUserRegPath()) {
           throw new OAuthProviderMissMatchException(
                   "가입 경로가 잘못 되었습니다. " + savedUser.getUserRegPath() + "로 다시 로그인해주세요"
           );
       }
       updateUser(savedUser, userInfo);

       return UserPrincipal.create(savedUser, user.getAttributes());
   }

   //가져온 사용자 정보에 변경이 있다면 업데이트를 실행
   private User updateUser(User user, OAuth2UserInfo userInfo) {
       if (userInfo.getNickname() != null && !user.getUserNickname().equals(userInfo.getNickname())) {
           user.setUserNickname(userInfo.getNickname());
       }

       if (userInfo.getUserImage() != null && !user.getUserImage().equals(userInfo.getUserImage())) {
           user.setUserImage(userInfo.getUserImage());
       }

       return user;
   }

   //가져온 사용자 정보를 통해서 회원가입 실행
   private User createUser(OAuth2UserInfo userInfo, ProviderType providerType) {
       User user = User.builder()
               .userEmail(userInfo.getEmail())
               .userNickname(userInfo.getNickname())
//                .userGender(userInfo.getGender())
               .userImage(userInfo.getUserImage())
               .userRegPath(providerType)
               .userStatus(StatusType.ACTIVATE)
               .role(RoleType.NORMAL)
               .build();

       return userRepository.saveAndFlush(user);
   }
}

5️⃣  마지막으로 oAuth2AuthenticationSuccessHandler이 불리고 그것이 JWT authentication token을 만들고, access_token과 refresh_token을 cookie에 저장합니다.

  • 프론트와 백이 분리되지 않은 SSR 방식으로 프로젝트를 설계하고 구성했고, 프론트에서는 ajax요청이 아닌 thymeleaf를 주로 사용했기 때문에 프론트에서 url을 넘겨 받을 때마다
    쿠키에 토큰을 심어주는 방식이 합당하지 않다고 판단되어, 서버에서 쿠키를 심도록 코드를 구성했습니다.

OAuth2AuthenticationSuccessHandler.java

@Component
@RequiredArgsConstructor
public class OAuth2AuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {

    private final AuthTokenProvider tokenProvider;
    private final AppProperties appProperties;
    private final UserRefreshTokenRepository userRefreshTokenRepository;
    private final OAuth2AuthorizationRequestBasedOnCookieRepository authorizationRequestRepository;

    //oauth2 인증이 성공적으로 이뤄졌을 때 실행됨
    //token을 포함한 uri를 생성 후 인증요청 쿠키를 비워주고 redirect 함
    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException {
        String targetUrl = determineTargetUrl(request, response, authentication);

        if (response.isCommitted()) {
            logger.debug("Response has already been committed. Unable to redirect to " + targetUrl);
            return;
        }

        clearAuthenticationAttributes(request, response);
        getRedirectStrategy().sendRedirect(request, response, targetUrl);
    }

    //token을 생성하고 이를 포함한 프론트엔드의 URI를 생성한다.
    protected String determineTargetUrl(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
        Optional<String> redirectUri = CookieUtil.getCookie(request, REDIRECT_URI_PARAM_COOKIE_NAME)
                .map(Cookie::getValue);

        if (redirectUri.isPresent() && !isAuthorizedRedirectUri(redirectUri.get())) {
            throw new IllegalArgumentException("잘못된 리다이렉트 경로입니다. 인증을 진행할 수 없습니다.");
        }

        String targetUrl = redirectUri.orElse("/");

        OAuth2AuthenticationToken authToken = (OAuth2AuthenticationToken) authentication;
        ProviderType providerType = ProviderType.valueOf(authToken.getAuthorizedClientRegistrationId().toUpperCase());

        OidcUser user = ((OidcUser) authentication.getPrincipal());
        OAuth2UserInfo userInfo = OAuth2UserInfoFactory.getOAuth2UserInfo(providerType, user.getAttributes());
        Collection<? extends GrantedAuthority> authorities = ((OidcUser) authentication.getPrincipal()).getAuthorities();

        RoleType roleType = hasAuthority(authorities);

        Date now = new Date();
        AuthToken accessToken = tokenProvider.createAuthToken(
                userInfo.getEmail(),
                userInfo.getNickname(),
                roleType.getCode(),
                new Date(now.getTime() + appProperties.getAuth().getTokenExpiry())
        );

        // refresh 토큰 설정
        long refreshTokenExpiry = appProperties.getAuth().getRefreshTokenExpiry();

        AuthToken refreshToken = tokenProvider.createAuthToken(
                appProperties.getAuth().getTokenSecret(),
                new Date(now.getTime() + refreshTokenExpiry)
        );

        // DB 저장
        UserRefreshToken userRefreshToken = userRefreshTokenRepository.findByUserEmail(userInfo.getEmail());
        if (userRefreshToken != null) {
            userRefreshToken.setRefreshToken(refreshToken.getToken());
        } else {
            userRefreshToken = new UserRefreshToken(userInfo.getEmail(), refreshToken.getToken());
            userRefreshTokenRepository.saveAndFlush(userRefreshToken);
        }

        int cookieMaxAge = (int) refreshTokenExpiry / 60;
        int cookieMaxAgeForAccess = (int) appProperties.getAuth().getTokenExpiry() / 1000;

        /*
        Access Token 저장
         */
        CookieUtil.deleteCookie(request, response, ACCESS_TOKEN);
        CookieUtil.addCookieForAccess(response, ACCESS_TOKEN, accessToken.getToken(), cookieMaxAgeForAccess);

        /*
        Refresh Token 저장
         */
        CookieUtil.deleteCookie(request, response, REFRESH_TOKEN);
        CookieUtil.addCookie(response, REFRESH_TOKEN, refreshToken.getToken(), cookieMaxAge);

        return UriComponentsBuilder.fromUriString(targetUrl)
                .build().toUriString();
    }

    //인증정보 요청 내역에서 쿠키를 삭제
    protected void clearAuthenticationAttributes(HttpServletRequest request, HttpServletResponse response) {
        super.clearAuthenticationAttributes(request);
        authorizationRequestRepository.removeAuthorizationRequestCookies(request, response);
    }

    private RoleType hasAuthority(Collection<? extends GrantedAuthority> authorities) {
        if (authorities == null) {
            return RoleType.NORMAL;
        }

        for (GrantedAuthority grantedAuthority : authorities) {
            if (RoleType.HOST.getCode().equals(grantedAuthority.getAuthority())) {
                return RoleType.HOST;
            } else if (RoleType.ADMIN.getCode().equals(grantedAuthority.getAuthority())) {
                return RoleType.ADMIN;
            }
        }
        return RoleType.NORMAL;
    }

    //application.oauth.yml을 통해서 등록해놓은 Redirect uri가 맞는지 확인한다.
    private boolean isAuthorizedRedirectUri(String uri) {
        URI clientRedirectUri = URI.create(uri);

        return appProperties.getOauth2().getAuthorizedRedirectUris()
                .stream()
                .anyMatch(authorizedRedirectUri -> {
                    // Only validate host and port. Let the clients use different paths if they want to
                    URI authorizedURI = URI.create(authorizedRedirectUri);
                    return authorizedURI.getHost().equalsIgnoreCase(clientRedirectUri.getHost())
                            && authorizedURI.getPort() == clientRedirectUri.getPort();
                });
    }
}

-, refresh_token의 경우 프론트엔드에서 조작할 수 없도록 httpOnly 설정을 넣어주었고, 
access_token은 로그아웃 과정 시, 프론트엔드에서 쿠키에서 토큰을 지워주는 작업을 할 수 있도록 httpOnly 설정을 뺐습니다.
- 또한, www가 붙고 안붙고를 다른 도메인으로 인식하는 문제를 해결하기 위해 쿠키의 도메인을 일괄적으로 지정받을 수 있도록 setDomain을 사용하여 맞춰주었고, 이에 따라 로그아웃 과정도 프론트에서 쿠키를 지우는 것이 아닌 백엔드에서 쿠키 시간을 0로 설정하여 새로 만들 수 있도록 로직을 바꿔주었습니다.
- **CookieUtil.java**
```java
public class CookieUtil {

  /*
  쿠키에서 access_token을 가져오는 메소드
   */
  public static String getAccessToken(HttpServletRequest request) {
      Optional<Cookie> cookie = getCookie(request, "access_token");
      if (cookie.isEmpty()) {
          return null;
      }
      return cookie.get().getValue();
  }

  public static Optional<Cookie> getCookie(HttpServletRequest request, String name) {
      Cookie[] cookies = request.getCookies();

      if (cookies != null && cookies.length > 0) {
          for (Cookie cookie : cookies) {
              if (name.equals(cookie.getName())) {
                  return Optional.of(cookie);
              }
          }
      }
      return Optional.empty();
  }

  /*
  httpOnly 권한을 풀고 access 토큰을 저장하기 위함
   */
  public static void addCookieForAccess(HttpServletResponse response, String name, String value, int maxAge) {
      Cookie cookie = new Cookie(name, value);
      cookie.setPath("/");
      cookie.setHttpOnly(false);
      cookie.setMaxAge(maxAge);
      response.addCookie(cookie);
  }

  public static void addCookie(HttpServletResponse response, String name, String value, int maxAge) {
      Cookie cookie = new Cookie(name, value);
      cookie.setPath("/");
      cookie.setHttpOnly(true);
      cookie.setMaxAge(maxAge);
      response.addCookie(cookie);
  }

  public static void deleteCookie(HttpServletRequest request, HttpServletResponse response, String name) {
      Cookie[] cookies = request.getCookies();

      if (cookies != null && cookies.length > 0) {
          for (Cookie cookie : cookies) {
              if (name.equals(cookie.getName())) {
                  cookie.setValue("");
                  cookie.setPath("/");
                  cookie.setMaxAge(0);
                  response.addCookie(cookie);
              }
          }
      }
  }

  public static String serialize(Object obj) {
      return Base64.getUrlEncoder()
              .encodeToString(SerializationUtils.serialize(obj));
  }

  public static <T> T deserialize(Cookie cookie, Class<T> cls) {
      return cls.cast(
              SerializationUtils.deserialize(
                      Base64.getUrlDecoder().decode(cookie.getValue())
              )
      );
  }
}
  • 단, refresh_token의 경우 프론트엔드에서 조작할 수 없도록 httpOnly 설정을 넣어주었고,
    access_token은 로그아웃 과정 시, 프론트엔드에서 쿠키에서 토큰을 지워주는 작업을 할 수 있도록 httpOnly 설정을 뺐습니다.
  • 또한, www가 붙고 안붙고를 다른 도메인으로 인식하는 문제를 해결하기 위해 쿠키의 도메인을 일괄적으로 지정받을 수 있도록 setDomain을 사용하여 맞춰주었고, 이에 따라 로그아웃 과정도 프론트에서 쿠키를 지우는 것이 아닌 백엔드에서 쿠키 시간을 0로 설정하여 새로 만들 수 있도록 로직을 바꿔주었습니다.

CookieUtil.java

public class CookieUtil {
    /*
    httpOnly 권한을 풀고 access 토큰을 저장하기 위함
     */
    public static void addCookieForAccess(HttpServletResponse response, String name, String value, int maxAge) {
        Cookie cookie = new Cookie(name, value);
        cookie.setPath("/");
        cookie.setHttpOnly(false);
        cookie.setMaxAge(maxAge);
        cookie.setDomain("eventcafecloud.com");
        response.addCookie(cookie);
    }

    public static void addCookie(HttpServletResponse response, String name, String value, int maxAge) {
        Cookie cookie = new Cookie(name, value);
        cookie.setPath("/");
        cookie.setHttpOnly(true);
        cookie.setMaxAge(maxAge);
        cookie.setDomain("eventcafecloud.com");
        response.addCookie(cookie);
    }

    public static void deleteCookie(HttpServletRequest request, HttpServletResponse response, String name) {
        Cookie[] cookies = request.getCookies();

        if (cookies != null && cookies.length > 0) {
            for (Cookie cookie : cookies) {
                if (name.equals(cookie.getName())) {
                    cookie.setValue("");
                    cookie.setPath("/");
                    cookie.setMaxAge(0);
                    cookie.setDomain("eventcafecloud.com");
                    response.addCookie(cookie);
                }
            }
        }
    }
}

0개의 댓글