[Spring boot] spring security 자체 로그인 + 소셜 로그인 백엔드에서 모두 처리 시 회원 탈퇴 (카카오, 구글, 네이버 연결 끊기)

JO Yeongmu·2024년 5월 17일
1

Spring Security

목록 보기
2/2
post-thumbnail

백엔드에서 자체로그인 + 소셜로그인을 모두 처리하는 경우 회원탈퇴에 어려움을 겪는 분들이 보여서 공유하고자 작성하는 글입니다. 저는 Redis를 통해서 처리하였고 다른 DB테이블 혹은 컬럼을 지정해서 사용하셔도 될 것 같습니다.

필자는 소셜 회원 탈퇴시 엔드포인트를 여러개 만드는 것이 아닌 /users 하나의 엔드포인트를 통해 모두 처리하도록 구현하였습니다. (프론트단의 개발 피로도를 줄여 줍니다.)


📗 소셜 로그인 테스트 하는 법 (백엔드 개발자라서 React.js를 모르겠어요.. 의 경우)

import './App.css';

const onNaverLogin = () => {
  window.location.href = 'http://localhost:8080/oauth2/authorization/naver';
};

const onKakaoLogin = () => {
  window.location.href = 'http://localhost:8080/oauth2/authorization/kakao';
};
const onGoogleLogin = () => {
  window.location.href = 'http://localhost:8080/oauth2/authorization/google';
};

function App() {
  return (
    <>
      <h1>소셜 로그인 테스트</h1>
      
      <h1>OAuth2 Login & SignUp </h1>

      <button onClick={onNaverLogin} className="naver">
        Naver
      </button>
      <button onClick={onKakaoLogin} className="kakao">
        Kakao
      </button>
      <button onClick={onGoogleLogin} className="google">
        Google
      </button>
    </>
  );
}

export default App;

📗 전체과정

  • CutomOAuth2UserService 에서 provider 추출 과 함께 accessToken Redis에 저장 (소셜로그인 플랫폼 accessToken 시간은 3600초 이므로 redis의 값 유지 시간을 3600초로 일관성 유지)
  • 로그아웃accessToken을 삭제 하여 1시간 이내의 로그인하는 경우의 redis 키 값 중복 방지를 위한 삭제 과정
  • 회원 탈퇴 시 redis에서 accessToken을 불러와 각 플랫폼에 전달하여 연결 끊기 진행 (소셜 프로바이더 값이 유니크 제약조건이 걸린 컬럼이거나 깔끔하게 필요없는 값을 지우고 싶을 시 연결 끊기가 될 시 1시간이네 재가입 경우 일 때 redis 키 값 중복 방지를 위해 삭제 하는 과정 필요

📗 Redis 의존성 주입

implementation 'org.springframework.boot:spring-boot-starter-data-redis'

⚙️ RedisRepositoryConfig 작성

package com.carumuch.capstone.global.config;

import lombok.RequiredArgsConstructor;
import org.springframework.boot.autoconfigure.data.redis.RedisProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.repository.configuration.EnableRedisRepositories;
import org.springframework.data.redis.serializer.StringRedisSerializer;

@RequiredArgsConstructor
@Configuration
@EnableRedisRepositories
public class RedisRepositoryConfig {

    private final RedisProperties redisProperties;

    /* Lettuce */
    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        return new LettuceConnectionFactory(redisProperties.getHost(), redisProperties.getPort());
    }

    @Bean
    public RedisTemplate<String, Object> redisTemplate() {
        RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(redisConnectionFactory());
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setValueSerializer(new StringRedisSerializer());
        return redisTemplate;
    }
}

⚙️ RedisService 작성

package com.carumuch.capstone.auth.service;

import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.concurrent.TimeUnit;

@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class RedisService {

    private final RedisTemplate<String, String> redisTemplate;

    /**
     * 만료 시간 없는 키 값 지정
     */
    @Transactional
    public void setValues(String key, String value){
        redisTemplate.opsForValue().set(key, value);
    }

    /**
     * 만료시간 설정 -> 자동삭제
     */
    @Transactional
    public void setValuesWithTimeout(String key, String value, long timeout){
        redisTemplate.opsForValue().set(key, value, timeout, TimeUnit.MILLISECONDS);
    }

    /**
     * 키를 이용한 값 확인
     */
    public String getValues(String key){
        return redisTemplate.opsForValue().get(key);
    }

    /**
     * 키 삭제
     */
    @Transactional
    public void deleteValues(String key) {
        redisTemplate.delete(key);
    }
}


⚙️ Controller 작성

/**
     * Delete: 회원 탈퇴
     */
    @DeleteMapping
    public ResponseEntity<?> delete(HttpServletRequest request) {
        authService.delete(request.getHeader("Authorization"));
        ResponseCookie responseCookie = ResponseCookie.from("refresh-token", null)
                .maxAge(0)
                .path("/")
                .build();
        return ResponseEntity
                .status(OK)
                .header(HttpHeaders.SET_COOKIE, responseCookie.toString())
                .body(ResponseDto.success(OK,null));
    }

⚙️ AuthService 작성

 /**
     * DELETE
     */
    @Transactional
    public void delete(String requestAccessTokenInHeader) {
        String requestAccessToken = resolveToken(requestAccessTokenInHeader);
        String principal = getPrincipal(requestAccessToken);

        /* Redis에 저장되어 있는 Refresh Token 삭제 */
        String refreshTokenInRedis = redisService.getValues("RT(" + SERVER + "):" + principal);
        if (refreshTokenInRedis != null) {
            redisService.deleteValues("RT(" + SERVER + "):" + principal);
        }
        /* Redis에 회원탈퇴 처리한 Access Token 저장 */
        long expiration = jwtTokenProvider.getTokenExpirationTime(requestAccessToken) - new Date().getTime();
        redisService.setValuesWithTimeout(requestAccessToken,
                "delete",
                expiration);
        /* DB에 저장 되어 있는 회원 삭제 */
        userRepository.deleteByLoginId(principal);
        if (principal.startsWith("kakao") || principal.startsWith("google") || principal.startsWith("naver")) {
            oAuth2UnlinkService.unlink(principal);
        }
        /* oauth2 access 토큰 삭제 */
        if (redisService.getValues("AT(oauth2):" + principal) != null) {
            redisService.deleteValues("AT(oauth2):" + principal);
        }
        log.info(principal + " : " + "delete" + "(" + new Date() + ")");
    }
  • 이부분 같은 경우에는 기본 적인 회원 탈퇴 요청에 직접 추가 하셔도 무관한 부분입니다.
  • private final RedisService redisService 를 주입 받아 주어 진행합니다.
  • 바로 아래 부분이 일반 회원탈퇴 로직에 직접 추가 할 부분입니다.
 if (principal.startsWith("kakao") || principal.startsWith("google") || principal.startsWith("naver")) {
            oAuth2UnlinkService.unlink(principal);
        }
        /* oauth2 access 토큰 삭제 */
        if (redisService.getValues("AT(oauth2):" + principal) != null) {
            redisService.deleteValues("AT(oauth2):" + principal);
        }

⚙️ OAuth2UnlinkService 작성

  • 현재 유저 예시 loginId -> google 123412341234
package com.carumuch.capstone.auth.service;

import com.carumuch.capstone.global.common.ErrorCode;
import com.carumuch.capstone.global.common.exception.CustomException;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.stereotype.Service;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestTemplate;

@Service
@RequiredArgsConstructor
public class OAuth2UnlinkService {

    private static final String GOOGLE_URL = "https://oauth2.googleapis.com/revoke";
    private static final String KAKAO_URL = "https://kapi.kakao.com/v1/user/unlink";
    private static final String NAVER_URL = "https://nid.naver.com/oauth2.0/token";

    private final RestTemplate restTemplate;
    private final RedisService redisService;

    @Value("${spring.security.oauth2.client.registration.naver.client-id}")
    private String NAVER_CLIENT_ID;
    @Value("${spring.security.oauth2.client.registration.naver.client-secret}")
    private String NAVER_CLIENT_SECRET;


    public void unlink(String provider) {
        if (provider.startsWith("google")) {
            googleUnlink(provider);
        } else if (provider.startsWith("kakao")) {
            kakaoUnlink(provider);
        } else if (provider.startsWith("naver")) {
            naverUnlink(provider);
        } else {
            throw new CustomException(ErrorCode.INVALID_REQUEST);
        }
    }

    /**
     * 구글 연결 해제
     */
    public void googleUnlink(String provider) {
        String accessToken = redisService.getValues("AT(oauth2):" + provider);
        // oauth2 토큰이 만료 시 재 로그인
        if (accessToken == null) {
            throw new CustomException(ErrorCode.EXPIRED_AUTH_TOKEN);
        }
        // 바디 설정
        MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
        params.add("token", accessToken);
        restTemplate.postForObject(GOOGLE_URL, params, String.class);
        }

    /**
     * 카카오 연결 해제
     */
    public void kakaoUnlink(String provider) {
        String accessToken = redisService.getValues("AT(oauth2):" + provider);
        // oauth2 토큰이 만료 시 재 로그인
        if (accessToken == null) {
            throw new CustomException(ErrorCode.EXPIRED_AUTH_TOKEN);
        }
        HttpHeaders headers = new HttpHeaders();
        headers.setBearerAuth(accessToken);
        HttpEntity<Object> entity = new HttpEntity<>("", headers);
        restTemplate.exchange(KAKAO_URL, HttpMethod.POST, entity, String.class);
    }

    /**
     * 네이버 연결 해제
     */
    public void naverUnlink(String provider) {

        String accessToken = redisService.getValues("AT(oauth2):" + provider);

        // oauth2 토큰이 만료 시 재 로그인
        if (accessToken == null) {
            throw new CustomException(ErrorCode.EXPIRED_AUTH_TOKEN);
        }

        String url = NAVER_URL +
                "?service_provider=NAVER" +
                "&grant_type=delete" +
                "&client_id=" +
                NAVER_CLIENT_ID +
                "&client_secret=" +
                NAVER_CLIENT_SECRET +
                "&access_token=" +
                accessToken;

        UnlinkResponse response = restTemplate.getForObject(url, UnlinkResponse.class);

        if (response != null && !"success".equalsIgnoreCase(response.getResult())) {
            throw new CustomException(ErrorCode.INTERNAL_SERVER_ERROR);
        }
    }

    /**
     * 네이버 응답 데이터
     */
    @Getter
    @RequiredArgsConstructor
    public static class UnlinkResponse {
        @JsonProperty("access_token")
        private final String accessToken;
        private final String result;
    }
}



⚙️ LoginFilter 에서 accessToken 저장 부분 추가

  • 추가 해야 될 부분은
String oauth2AccessToken = userRequest.getAccessToken().getTokenValue();
/*레디스 소셜 로그인 토큰 저장*/
            redisService.setValuesWithTimeout("AT(oauth2):" + loginId , oauth2AccessToken, ACCESS_TOKEN_EXPIRATION); -> 회원 가입과 함께 진행하는 부분
            

 /* oauth2 토큰 중복 방지 */
            if (redisService.getValues("AT(oauth2):" + loginId) != null) {
                redisService.deleteValues("AT(oauth2):" + loginId);
            }
            /* 레디스 토큰 정보 */
            redisService.setValuesWithTimeout("AT(oauth2):" + loginId ,oauth2AccessToken, ACCESS_TOKEN_EXPIRATION); -> 로그인만 진행 하는 경우
            
  • 전체 코드 입니다.
package com.carumuch.capstone.auth.service;

import com.carumuch.capstone.auth.dto.*;
import com.carumuch.capstone.user.domain.User;
import com.carumuch.capstone.user.domain.type.Role;
import com.carumuch.capstone.user.repository.UserRepository;
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.user.OAuth2User;
import org.springframework.stereotype.Service;


@Service
public class CustomOAuth2UserService extends DefaultOAuth2UserService {

    private final UserRepository userRepository;
    private final RedisService redisService;
    private final long ACCESS_TOKEN_EXPIRATION = 3600 * 1000;

    public CustomOAuth2UserService(UserRepository userRepository, RedisService redisService) {
        this.userRepository = userRepository;
        this.redisService = redisService;
    }

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {

        OAuth2User oAuth2User = super.loadUser(userRequest);

        // 회원 탈퇴 토큰 추출
        String oauth2AccessToken = userRequest.getAccessToken().getTokenValue();

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

        if (registrationId.equals("naver")) {

            oAuth2Response = new NaverResponse(oAuth2User.getAttributes());
        }
        else if (registrationId.equals("kakao")) {

            oAuth2Response = new KakaoResponse(oAuth2User.getAttributes());
        }
        else if (registrationId.equals("google")) {

            oAuth2Response = new GoogleResponse(oAuth2User.getAttributes());
        }
        else {

            return null;
        }
        String loginId = oAuth2Response.getProvider()+" "+oAuth2Response.getProviderId();

        if (!userRepository.existsByLoginId(loginId)) {

            /* 유저 저장 */
            userRepository.save(User.builder()
                    .loginId(loginId)
                    .name(oAuth2Response.getName())
                    .email(oAuth2Response.getEmail())
                    .role(Role.USER)
                    .build());

            /*레디스 소셜 로그인 토큰 저장*/
            redisService.setValuesWithTimeout("AT(oauth2):" + loginId , oauth2AccessToken, ACCESS_TOKEN_EXPIRATION);

            /* 유저 정보 전달 */
            return new CustomOAuth2User(UserDto.builder()
                    .loginId(loginId)
                    .name(oAuth2Response.getName())
                    .role(Role.USER)
                    .build());
        }
        else {
            User user = userRepository.findOAuth2UserByLoginId(loginId);
            user.updateOAuth2(oAuth2Response.getName(), oAuth2Response.getEmail());

            /* oauth2 토큰 중복 방지 */
            if (redisService.getValues("AT(oauth2):" + loginId) != null) {
                redisService.deleteValues("AT(oauth2):" + loginId);
            }
            /* 레디스 토큰 정보 */
            redisService.setValuesWithTimeout("AT(oauth2):" + loginId ,oauth2AccessToken, ACCESS_TOKEN_EXPIRATION);

            return new CustomOAuth2User(UserDto.builder()
                    .loginId(user.getLoginId())
                    .name(oAuth2Response.getName()) // 받아온 값
                    .role(user.getRole())
                    .build());
        }
    }
}



⚙️ Logoutfilter 토큰 삭제 과정

  • 저같은 경우에는 authservice 에서 따로 로그아웃 로직을 구현한 상태 입니다.

CustomLogoutFilter

package com.carumuch.capstone.auth.jwt;

import com.carumuch.capstone.auth.service.AuthService;
import com.carumuch.capstone.global.common.ResponseDto;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.web.filter.GenericFilterBean;

import java.io.IOException;

public class CustomLogoutFilter extends GenericFilterBean {

    private final AuthService authService;
    private final ObjectMapper objectMapper;

    public CustomLogoutFilter(AuthService authService, ObjectMapper objectMapper) {
        this.authService = authService;
        this.objectMapper = objectMapper;
    }

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {

        doFilter((HttpServletRequest) request, (HttpServletResponse) response, chain);
    }

    private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws IOException, ServletException {

        /* 경로, Http 메소드 */
        String requestUri = request.getRequestURI();
        if (!requestUri.matches("^\\/logout$")) {
            filterChain.doFilter(request, response);
            return;
        }
        String requestMethod = request.getMethod();
        if (!requestMethod.equals("POST")) {
            filterChain.doFilter(request, response);
            return;
        }

        /* Refresh Token 추출 */
        String refresh = null;
        Cookie[] cookies = request.getCookies();
        for (Cookie cookie : cookies) {
            if (cookie.getName().equals("refresh-token")) {
                refresh = cookie.getValue();
            }
        }
        String accessToken = request.getHeader("Authorization");

        authService.logout(accessToken);

        /* 응답 설정 */
        Cookie cookie = new Cookie("refresh-token", null);
        cookie.setMaxAge(0);
        cookie.setPath("/");

        response.addCookie(cookie);
        response.setStatus(HttpServletResponse.SC_OK);
        response.setCharacterEncoding("utf-8");
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);

        response.getWriter().write(objectMapper.writeValueAsString(
                ResponseDto.success(HttpStatus.OK,null)));
    }
}

  • authService 의 로그아웃로직
  • 기존 사용 하시는 로그아웃 로직에 추가해야될 부분만 추가 하시면 됩니다. (간단합니다 !)
 /* 소셜 로그인 유저 일 경우 oauth2 access 토큰 삭제 */
        if (redisService.getValues("AT(oauth2):" + principal) != null) {
            redisService.deleteValues("AT(oauth2):" + principal);
        }
  • 필자의 로그아웃 로직
 /**
     * 로그아웃: Access Token 무효화
     */
    @Transactional
    public void logout(String requestAccessTokenInHeader) {
        String requestAccessToken = resolveToken(requestAccessTokenInHeader);
        String principal = getPrincipal(requestAccessToken);

        /* Redis에 저장되어 있는 Refresh Token 삭제 */
        String refreshTokenInRedis = redisService.getValues("RT(" + SERVER + "):" + principal);
        if (refreshTokenInRedis != null) {
            redisService.deleteValues("RT(" + SERVER + "):" + principal);
        }

        /* 소셜 로그인 유저 일 경우 oauth2 access 토큰 삭제 */
        if (redisService.getValues("AT(oauth2):" + principal) != null) {
            redisService.deleteValues("AT(oauth2):" + principal);
        }

        /* Redis에 로그아웃 처리한 Access Token 저장 */
        long expiration = jwtTokenProvider.getTokenExpirationTime(requestAccessToken) - new Date().getTime();
        redisService.setValuesWithTimeout(requestAccessToken,
                "logout",
                expiration);
        log.info(principal + " : " + "logout" + "(" + new Date() + ")");
    }

📗 Redis & React 테스트 진행

1. 테스트를 하기 위해 리액트를 준비합니다.

2. 소셜 로그인 진행

  • 토큰이 잘 전달 되고 있는 상황 ( 하지만 이글에서 중요한 것은 redis에 서버측 토큰이 잘 저장되었는지가 중요합니다.)

3. Redis CLI로 확인하기

  • AT(oauth2) 라고 지정한 토큰이 저장 된 것을 볼 수 있습니다. (key *)

  • 토큰 값이 정확히 들어 갔는지 value를 확인 합니다. (GET {키이름})

4. 회원 탈퇴 진행하기

  • 2번 과정에서 받은 토큰을 기반으로 postman으로 테스트를 진행합니다.
  • 결과는 성공적 입니다.

5. 회원 탈퇴가 잘 되었는지 확인

  • 회원 테이블 확인

  • Redis 확인
  • AT(oauth2) 토큰도 삭제된것을 확인 할 수 있습니다. (아래 토큰은 자체 로그인 시 발급한 accessToken 무효화를 위해 저장한 것이라 글의 목적과 무관 합니다.)

🎉 연결 끊기가 진행 되었는지 확인

  • 첫 회원가입 때 보았던 화면이 뜨기 시작했습니다.

  • 정확한 확인을 위해 플랫폼 연결 정보 확인 (성공 🎉)

profile
도전해 보는 것이 성장의 첫걸음입니다 :)

0개의 댓글