프로젝트에 적용 된 소셜 로그인 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 합니다.
http://localhost:8080/oauth2/callback/{provider}) 그리고 이때 사용자 인증코드 (authroization code) 도 함께 갖고있습니다.@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가 제공하는 AuthorizationUri로 Redirect
               .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에 정의함)
@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 하는 로직을 작성합니다.@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에 저장합니다.
@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())
              )
      );
  }
}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);
                }
            }
        }
    }
}