쇼핑몰 - 6 [Spring Security + JWT + OAuth2 + Redis]

2024. 12. 2. 14:13·Java/쇼핑몰

✅ 이전 포스팅

2024.11.19 - [Java/쇼핑몰] - 쇼핑몰 - 5 [spring security + JWT + Redis 로그인]

 

쇼핑몰 - 5 [spring security + JWT + Redis 로그인]

✅ 이전 포스팅2024.11.12 - [Java/쇼핑몰] - 쇼핑몰 - 4 [회원 가입 구현] 쇼핑몰 - 4 [회원 가입 구현]✅ 이전 포스팅2024.11.06 - [Java/쇼핑몰] - 쇼핑몰 - 3 [GitHub Actions를 통한 CI/CD]" data-og-description="✅ 이

magicmk.tistory.com

 

이전에는 JWT와 Redis를 이용해서 일반 로그인을 구현하는 방법에 대해 알아보았다.

이번 포스팅에서는 일반 로그인이 아닌 소셜 로그인을 구현하는 방법에 대해서 알아본다.


✅ 참고사항 및 안내

https://www.youtube.com/watch?v=7fMVxohRvl0&list=PLJkjrxxiBSFBPceOMrCQmuI8qipT7JD6w

 

위 유튜브의 내용처럼 Oauth 로직을 전부 백엔드에서 담당하여 처리하였기 때문에

Spring Security를 이용해서 처리하였다.

 


✅ Spring Security

package toy.shop.config;

import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import toy.shop.jwt.JwtAccessDeniedHandler;
import toy.shop.jwt.JwtAuthenticationFilter;
import toy.shop.jwt.JwtExceptionHandler;
import toy.shop.jwt.JwtProvider;
import toy.shop.service.oauth.CustomFailHandler;
import toy.shop.service.oauth.CustomOAuth2UserService;
import toy.shop.service.oauth.CustomSuccessHandler;

import java.util.Arrays;
import java.util.List;

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final String[] allowedUrls = {
            "/swagger-ui/**",
            "/v3/**",
            "/api/global/**",
            "/api/auth/**",
            "/images/**"
    };
    private final JwtProvider jwtProvider;
    private final JwtAccessDeniedHandler jwtAccessDeniedHandler;
    private final JwtExceptionHandler jwtExceptionHandler;

    private final CustomOAuth2UserService customOAuth2UserService;
    private final CustomSuccessHandler customSuccessHandler;
    private final CustomFailHandler customFailHandler;

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                .cors(cors -> cors.configurationSource(corsConfigurationSource()))
                .csrf(AbstractHttpConfigurer::disable)                                                                                           // CSRF 보호 비활성화
                .formLogin(AbstractHttpConfigurer::disable)                                                                                      // Form 로그인 비활성화
                .httpBasic(AbstractHttpConfigurer::disable)                                                                                      // http basic 인증 방식 비활성화
                .headers(headers -> headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin))                                    // X-Frame-Options 설정
                // 경로 작업
                .authorizeHttpRequests(request -> request
                        .requestMatchers(allowedUrls).permitAll()
                        .requestMatchers("/api/admin/**").hasRole("ADMIN")
                        .requestMatchers("/api/**").hasAnyRole("USER", "COMPANY", "ADMIN")
                        .anyRequest().authenticated()
                )
                .addFilterBefore(new JwtAuthenticationFilter(jwtProvider, jwtExceptionHandler), UsernamePasswordAuthenticationFilter.class)
                .exceptionHandling(handling -> handling
                        .accessDeniedHandler(jwtAccessDeniedHandler))

                .oauth2Login(oauth2 -> oauth2
                        .userInfoEndpoint(userInfoEndpointConfig -> userInfoEndpointConfig
                                        .userService(customOAuth2UserService))
                        .successHandler(customSuccessHandler)
                        .failureHandler(customFailHandler)
                )

                .sessionManagement(sessionManagement -> sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS));       // Session 사용하지 않음

        return http.build();
    }

    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration configuration = new CorsConfiguration();
        configuration.setAllowedOrigins(Arrays.asList("http://localhost:3000")); // 모든 도메인 허용, 필요시 특정 도메인 설정 가능
        configuration.setAllowedMethods(List.of("*")); // 허용할 HTTP 메서드
        configuration.setAllowedHeaders(List.of("*")); // 모든 헤더 허용
        configuration.setAllowCredentials(true); // 자격 증명 허용

        configuration.setExposedHeaders(Arrays.asList("Set-Cookie", "Authorization"));

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", configuration); // 모든 경로에 대해 CORS 설정 적용

        return source;
    }
}

 

Spring Security에 oauth2Login을 추가적으로 작성한다.

코드를 보면 이해하기 어렵지 않아서 어떤 방식으로 작동하는지 쉽게 알 수 있을 것 같다.

 

Security를 이용하면 클라이언트 측에서 http://localhost:8080/oauth2/authorization/google  과 같이

하이퍼링크 형식으로 클릭하면 자연스럽게 소셜 로그인 팝업이 나타나고 로그인 이후 Security에 지정한 로직이

동작하도록 되어있다.

 

해당 방식에는 한 가지 단점이 존재하는데 그것은 아래 성공 후 로직을 작성하는 부분에서 다루도록 한다.


✅ CustomOAuth2UserService

package toy.shop.service.oauth;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import toy.shop.domain.Role;
import toy.shop.domain.member.Member;
import toy.shop.dto.oauth.*;
import toy.shop.repository.member.MemberRepository;

import java.util.Optional;

@Slf4j
@Service
@RequiredArgsConstructor
public class CustomOAuth2UserService extends DefaultOAuth2UserService {

    private final MemberRepository memberRepository;

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

        String registrationId = userRequest.getClientRegistration().getRegistrationId();
        OAuth2Response oAuth2Response = null;

        if (registrationId.equals("google")) {
            oAuth2Response = new GoogleResponse(oAuth2User.getAttributes());
        } else if (registrationId.equals("naver")) {
            oAuth2Response = new NaverResponse(oAuth2User.getAttributes());
        } else if (registrationId.equals("kakao")) {
            oAuth2Response = new KakaoResponse(oAuth2User.getAttributes());
        } else {
            throw new OAuth2AuthenticationException(new OAuth2Error("unsupported_social_login"), "허용되지 않은 소셜 로그인입니다: " + registrationId);
        }

        String socialName = oAuth2Response.getProvider() + "_" + oAuth2Response.getProviderId();
        Optional<Member> memberOptional = memberRepository.findByEmail(oAuth2Response.getEmail());

        if (memberOptional.isPresent()) {
            Member member = memberOptional.get();
            if (member.getSocialName() == null) {
                throw new OAuth2AuthenticationException(new OAuth2Error("conflict"), "해당 메일로 가입된 일반 계정이 존재합니다: " + oAuth2Response.getEmail());
            }
            if (!member.getSocialName().equals(socialName)) {
                throw new OAuth2AuthenticationException(new OAuth2Error("conflict"), "이미 다른 소셜 계정으로 가입된 이메일입니다: " + oAuth2Response.getEmail());
            }
            member.setImagePath(oAuth2Response.getProfileUrl());
            member.setNickName(oAuth2Response.getName());
        } else {
            Member member = new Member(
                    oAuth2Response.getEmail(),
                    oAuth2Response.getName(),
                    Role.ROLE_USER,
                    oAuth2Response.getProfileUrl(),
                    socialName
            );
            memberRepository.save(member);
        }

        UserDTO userDTO = UserDTO.builder()
                .socialName(socialName)
                .name(oAuth2Response.getName())
                .email(oAuth2Response.getEmail())
                .profileImage(oAuth2Response.getProfileUrl())
                .role(Role.ROLE_USER)
                .build();

        return new CustomOAuthUser(userDTO);
    }
}

 

CustomOAuth2UserService에서는 클라이언트에서 소셜 로그인을 진행한 뒤 검증하는 로직이다.

어떤 소셜 로그인을 하였는지 / 현재 DB에 해당 이메일로 가입된 회원은 없는지 등을 검증하고

이상이 없다면 회원가입을 진행한 뒤 User 정보를 반환한다.


✅ Oauth DTO

위 CustomOAuth2UserService에서 DTO가 많이 나오는데 해당 코드들이다.

 

🟨 OAuth2Response

package toy.shop.dto.oauth;

public interface OAuth2Response {

    // 제공자
    String getProvider();

    // 제공자에서 발급해주는 아이디(번호)
    String getProviderId();

    // 이메일
    String getEmail();

    // 사용자 이름
    String getName();

    // 프로필 사진 url
    String getProfileUrl();
}

 

소셜마다 받는 정보가 다르기 때문에 인터페이스화 시켜주었다.

 

🟨 GoogleResponse

package toy.shop.dto.oauth;

import java.util.Map;

public class GoogleResponse implements OAuth2Response{

    private final Map<String, Object> attribute;

    public GoogleResponse(Map<String, Object> attribute) {
        this.attribute = attribute;
    }

    @Override
    public String getProvider() {
        return "google";
    }

    @Override
    public String getProviderId() {
        return attribute.get("sub").toString();
    }

    @Override
    public String getEmail() {
        return attribute.get("email").toString();
    }

    @Override
    public String getName() {
        return attribute.get("name").toString();
    }

    @Override
    public String getProfileUrl() {
        return attribute.get("picture").toString();
    }
}

 

🟨 KakaoResponse

package toy.shop.dto.oauth;

import java.util.Map;

public class KakaoResponse implements OAuth2Response {

    private Map<String, Object> attribute;
    private Map<String, Object> kakaoAccountAttributes;
    private Map<String, Object> profileAttributes;

    public KakaoResponse(Map<String, Object> attribute) {
        this.attribute = attribute;
        this.kakaoAccountAttributes = (Map<String, Object>) attribute.get("kakao_account");
        this.profileAttributes = (Map<String, Object>) kakaoAccountAttributes.get("profile");
    }

    @Override
    public String getProvider() {
        return "kakao";
    }

    @Override
    public String getProviderId() {
        return attribute.get("id").toString();
    }

    @Override
    public String getEmail() {
        return kakaoAccountAttributes.get("email").toString();
    }

    @Override
    public String getName() {
        return profileAttributes.get("nickname").toString();
    }

    @Override
    public String getProfileUrl() {
        return profileAttributes.get("profile_image_url").toString();
    }
}

 

🟨 NaverResponse

package toy.shop.dto.oauth;

import lombok.ToString;

import java.util.Map;

@ToString
public class NaverResponse implements OAuth2Response {

    private final Map<String, Object> attribute;

    public NaverResponse(Map<String, Object> attribute) {
        this.attribute = (Map<String, Object>) attribute.get("response");
    }

    @Override
    public String getProvider() {
        return "naver";
    }

    @Override
    public String getProviderId() {
        return attribute.get("id").toString();
    }

    @Override
    public String getEmail() {
        return attribute.get("email").toString();
    }

    @Override
    public String getName() {
        return attribute.get("name").toString();
    }

    @Override
    public String getProfileUrl() {
        return attribute.get("profile_image").toString();
    }
}

 

🟨 CustomOAuthUser

package toy.shop.dto.oauth;

import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.oauth2.core.user.OAuth2User;

import java.util.ArrayList;
import java.util.Collection;
import java.util.Map;

public class CustomOAuthUser implements OAuth2User {

    private final UserDTO userDTO;

    public CustomOAuthUser(UserDTO userDTO) {
        this.userDTO = userDTO;
    }

    @Override
    public Map<String, Object> getAttributes() {
        return null;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        Collection<GrantedAuthority> collection = new ArrayList<>();
        collection.add(new GrantedAuthority() {
            @Override
            public String getAuthority() {
                return userDTO.getRole().getRole();
            }
        });

        return collection;
    }

    @Override
    public String getName() {
        return userDTO.getName();
    }

    public String getSocialName() {
        return userDTO.getSocialName();
    }

    public String getEmail() {
        return userDTO.getEmail();
    }

    public String getProfileImage() {
        return userDTO.getProfileImage();
    }
}

 

🟨 UserDTO

package toy.shop.dto.oauth;

import lombok.Builder;
import lombok.Data;
import toy.shop.domain.Role;

@Data
@Builder
public class UserDTO {

    private String name;
    private String socialName;
    private String email;
    private String profileImage;
    private Role role;
}

✅ CustomSuccessHandler

package toy.shop.service.oauth;

import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler;
import org.springframework.stereotype.Component;
import toy.shop.cmmn.exception.ConflictException;
import toy.shop.dto.jwt.JwtResponseDTO;
import toy.shop.dto.oauth.CustomOAuthUser;

import java.io.IOException;
import java.net.URLEncoder;
import java.util.Collection;
import java.util.Iterator;

@Slf4j
@Component
@RequiredArgsConstructor
public class CustomSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {

    private final MemberTokenHelper memberTokenHelper;
    private final String SERVER = "Server";

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        // 클라이언트 IP 가져오기
        String clientIp = "http://localhost:3000";

        try {
            CustomOAuthUser customUserDetails = (CustomOAuthUser) authentication.getPrincipal();

            Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
            Iterator<? extends GrantedAuthority> iterator = authorities.iterator();
            GrantedAuthority auth = iterator.next();
            String role = auth.getAuthority();

            // 토근 발급 및 Redis에 RefreshToken 저장
            JwtResponseDTO token = memberTokenHelper.generateAndStoreToken(SERVER, customUserDetails.getEmail(), role);

            response.sendRedirect(clientIp + "?accessToken=" + token.getAccessToken() + "&refreshToken=" + token.getRefreshToken());
        } catch (ConflictException ex) {
            // 예외 발생 시 클라이언트에 에러 메시지를 전달하기 위해 리디렉트
            response.sendRedirect(clientIp + "/error?message=" + URLEncoder.encode(ex.getMessage(), "UTF-8"));
        }
    }
}

 

소셜 로그인이 성공하면 진행되는 코드이다.

여기서 백엔드가 OAuth 로그인을 전부 도맡으면 발생하는 문제점이 하나 생긴다.

바로 JSON 형식의 데이터를 전달할 수 없다는 점이다.

 

클라이언트에서 하이퍼링크 형식으로 URL을 클릭하여 진행하였기 때문에 서버에서 프론트로 리다이렉팅

해주지 않으면 서버 도메인으로 리다이렉트 돼버린다.

 

그렇기 때문에 서버는 클라이언트로 리다이렉팅 시켜주면서 토큰 값을 전달해 주어야 하는데 여기서 난관에 봉착했다.

 

1. Cookie에 토큰 값을 담아 전달.

 

보안상 이유로 인해 처음에는 Cookie에 토큰 값을 담아서 전달하였으나 배포 이후 통신에서 문제가 발생했다.

바로 서로 다른 도메인에서는 Cookie가 생성되지 않는다는 문제....

이를 해결하기 위해서는 도메인에 인증서를 발급하여 Https 통신을 해야 하는데 가벼운 사이드 프로젝트에 인증서를

넣기는 부담스러워서 과감하게 포기하였다.

 

2. 리다이렉트 URL에 토근 값을 담아 전달

 

굉장히 부적절한 데이터 전달 방식이지만 위 방법 밖에 남은 방식이 없었고 사이드 프로젝트임을 감안하여

해당 방식으로 토큰 값을 전달하기로 결정했다.

 

🟨 MemberTokenHelper

package toy.shop.service.oauth;

import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import toy.shop.dto.jwt.JwtResponseDTO;
import toy.shop.jwt.JwtProvider;
import toy.shop.service.RedisService;

@Component
@RequiredArgsConstructor
public class MemberTokenHelper {

    private final JwtProvider jwtProvider;
    private final RedisService redisService;

    public JwtResponseDTO generateAndStoreToken(String provider, String email, String role) {
        String redisKey = "RT(" + provider + "):" + email;
        redisService.deleteValues(redisKey);

        JwtResponseDTO tokenDto = jwtProvider.createToken(email, role);
        redisService.setValuesWithTimeout(redisKey, tokenDto.getRefreshToken(), jwtProvider.getTokenExpirationTime(tokenDto.getRefreshToken()));
        return tokenDto;
    }
}

 

위 코드는 토큰을 생성하고 Redis에 데이터를 저장하는 로직인데 기존에 JWT 서비스에 작성했던 내용을 사용하려고

했더니 무한 참조 오류가 발생하여 별도의 클래스로 빼서 관리했다.


✅ CustomFailHandler

package toy.shop.service.oauth;

import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.stereotype.Component;
import toy.shop.cmmn.exception.ConflictException;

import java.io.IOException;
import java.net.URLEncoder;

@Component
public class CustomFailHandler implements AuthenticationFailureHandler {

    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, org.springframework.security.core.AuthenticationException exception) throws IOException, ServletException {
        String errorMessage = null;

        // 예외 메시지를 확인하여 적절한 메시지를 설정
        if (exception instanceof OAuth2AuthenticationException) {
            OAuth2AuthenticationException oauthException = (OAuth2AuthenticationException) exception;
            errorMessage = oauthException.getMessage();
            if ("conflict".equals(oauthException.getError().getErrorCode())) {
                errorMessage = oauthException.getMessage();
            } else if ("unsupported_social_login".equals(oauthException.getError().getErrorCode())) {
                errorMessage = oauthException.getMessage();
            }
        }

        // 에러 메시지를 클라이언트에 전달하기 위해 리디렉트
        response.sendRedirect("http://localhost:3000/error?message=" + URLEncoder.encode(errorMessage, "UTF-8"));
    }
}

 

위 코드는 로그인에 실패하였을 경우 클라이언트에게 에러 내용을 보내기 위하여 작성한 코드이다.

지금 생각하니 리다이렉트 url을 yml파일로 관리해야 하는데 왜 하드코딩을 했지?...


✅ 마무리

테스트를 진행했을 때 문제없이 잘 로그인되는 것을 확인했다.

그런데 분명 스크린샷을 찍어뒀는데 사진이 없어진 관계로 테스트 항목은 버리도록 하겠다...

 

https://github.com/sideProject-org/shop-back

 

GitHub - sideProject-org/shop-back: 쇼핑몰 서버

쇼핑몰 서버. Contribute to sideProject-org/shop-back development by creating an account on GitHub.

github.com

저작자표시 비영리 (새창열림)

'Java > 쇼핑몰' 카테고리의 다른 글

쇼핑몰 8 - [배송지 관리]  (0) 2025.01.24
쇼핑몰 7 - [이메일로 비밀번호 변경 URL 전송]  (0) 2024.12.13
쇼핑몰 - 5 [spring security + JWT + Redis 로그인]  (0) 2024.11.19
쇼핑몰 - 4 [회원 가입 구현]  (0) 2024.11.12
쇼핑몰 - 3 [GitHub Actions를 통한 CI/CD]  (3) 2024.11.06
'Java/쇼핑몰' 카테고리의 다른 글
  • 쇼핑몰 8 - [배송지 관리]
  • 쇼핑몰 7 - [이메일로 비밀번호 변경 URL 전송]
  • 쇼핑몰 - 5 [spring security + JWT + Redis 로그인]
  • 쇼핑몰 - 4 [회원 가입 구현]
요술공주밍키
요술공주밍키
조금씩이라도 꾸준히..
  • 요술공주밍키
    삽질의흔적
    요술공주밍키
  • 전체
    오늘
    어제
    • 분류 전체보기 (139)
      • Java (42)
        • Spring Boot (14)
        • Spring Boot 게시판 (14)
        • 공중화장실 찾기 (4)
        • 쇼핑몰 (8)
      • JavaScript (8)
        • NodeJS (2)
      • Python (5)
        • Django (4)
      • Server (10)
        • Docker (4)
        • K8S (0)
        • Jenkins (1)
      • 알고리즘 (24)
        • 프로그래머스 (19)
        • 백준 (5)
      • Etc (21)
        • 개발 팁 (1)
      • 일상 (27)
        • 독서 포스트 (25)
        • 회고록 (2)
  • 인기 글

  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.1
요술공주밍키
쇼핑몰 - 6 [Spring Security + JWT + OAuth2 + Redis]
상단으로

티스토리툴바