[재능교환소] Spring Boot: 소셜 로그인 - 로그아웃 & 회원탈퇴(Kakao, Google)

10000JI·2024년 6월 21일
0

프로젝트

목록 보기
13/14
post-thumbnail

🍃 상황

지난 포스팅에서 [재능교환소] Spring Boot+React 카카오 로그인 흐름 (JWT)[재능교환소] Spring Security, OAuth2, JWT를 통한 소셜 로그인 구현 (Kakao, Google)을 구현한 내용을 정리하였다.

사용자 정보를 받아 DB에 저장된 사용자라면 JWT로 Access Token 발급 & UUID로 Refresh Token를 만들어 반환하고, DB에 없는 사용자라면 회원가입을 진행하는 과정까지 마무리하였었다.

여기서 발급하는 Access Token과 Refresh Token은 필자의 서버, 즉 진행하는 서비스에서 발급하는 (일반 로그인과 동일하게 진행하기 위해) 토큰이다.

카카오 서버에서도 발급해주는 AccessToken이 있었고, 이를 가지고 카카오 로그인한 사용자의 정보를 가져온다.

하지만 이전 로그인 구현 과정까지 카카오 서버에서 발급해주는 AceessToken을 따로 관리하지 않았기에 카카오 로그아웃 및 회원탈퇴 기능을 구현하는데 어려움이 존재하였다.

따라서 카카오 소셜 로그인 요청이 들어오면 카카오 서버의 AccessToken을 Redis에 저장하고, 이를 이용해 로그아웃 및 회원탈퇴 기능을 구현하였다.

필자가 구현한 소셜 로그인은 카카오, 구글 둘 다 있음에 유의하자.

그럼 다시 소셜 로그인을 구현한 Spring Security에 설정해 놓은 UserOAuth2Service 로 돌아가 코드를 추가해보자

소셜 로그인 구현 과정은 하단 링크로 들어가면 볼 수 있다.

[재능교환소] Spring Security, OAuth2, JWT를 통한 소셜 로그인 구현 (Kakao, Google)

🌱 소셜 로그인 시 카카오/구글 서버에서 AccessToken 받아와서 저장하기

RedisUtil

@Component
@RequiredArgsConstructor
public class RedisUtil {
    private final RedisTemplate<String, Object> redisTemplate;

	.. (일부 코드 생략)

    // 소셜 로그인 탈퇴를 위해 저장, 만료일, 삭제 등

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

    /**
     * 키 삭제
     */
    @Transactional
    public void deleteValues(String key) {
        redisTemplate.delete(key);
    }
    
    
    /**
     * 키를 이용한 값 확인
     */
    public Object getValues(String key) {
        return redisTemplate.opsForValue().get(key);
    }
}

앞서 말했듯이 카카오(혹은 구글) 서버에서 발급해주는 AccessToken을 Redis에 저장하기 위해 위 메서드들을 추가하였다.

UserOAuth2Service

@Slf4j
@RequiredArgsConstructor
@Service
public class UserOAuth2Service implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {

    private final UserRepository userRepository;
    private final RedisUtil redisUtil;
    private final long ACCESS_TOKEN_EXPIRATION = 3600 * 1000;

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        OAuth2UserService<OAuth2UserRequest, OAuth2User> service = new DefaultOAuth2UserService();
        OAuth2User oAuth2User = service.loadUser(userRequest);  // OAuth2 정보를 가져옵니다.
        log.error("OAuth2User attributes: {}", oAuth2User.getAttributes());

        // 카카오(혹은 구글) 서버에서 발급해주는 AccessToken 추출
        String oauth2AccessToken = userRequest.getAccessToken().getTokenValue();

        Map<String, Object> originAttributes = oAuth2User.getAttributes(); // OAuth2User의 attribute

        // OAuth2 서비스 id (google, kakao)
        String registrationId = userRequest.getClientRegistration().getRegistrationId();    // 소셜 정보를 가져옵니다.

        // OAuthAttributes: OAuth2User의 attribute를 서비스 유형에 맞게 담아줄 클래스
        OAuthAttributes attributes = OAuthAttributes.of(registrationId, originAttributes);

        if (!userRepository.findById(attributes.getEmail()).isPresent()) { //db에 해당 회원정보 없다면 저장
            userRepository.save(attributes.toEntity());
        }

        /* 레디스 토큰 정보 */
        redisUtil.setValuesWithTimeout("AT(oauth2):" + attributes.getEmail() ,oauth2AccessToken, ACCESS_TOKEN_EXPIRATION);

        List<GrantedAuthority> authorities = Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER"));

        return new OAuth2CustomUser(registrationId, originAttributes, authorities, attributes.getEmail());
    }

}
/* 레디스 토큰 정보 */
redisUtil.setValuesWithTimeout("AT(oauth2):" + attributes.getEmail() ,oauth2AccessToken, ACCESS_TOKEN_EXPIRATION);

소셜 로그인 시에 카카오 혹은 구글에서 받아온 AccessToken을 Redis에 저장하는 작업을 추가해주었다.

소셜 로그인은 보통 AccessToken을 유효시간을 약 1시간으로 한다.

🌵 소셜 로그인 후 로그아웃하기

설명

먼저 어떤 과정으로 소셜 로그인 후에 로그아웃이 이루어져야 하는지 살펴보자.

< 카카오/구글 로그아웃 과정 >

  1. 클라이언트에서 로그아웃 요청한다.
  1. 서버에서는 자체 발급한 액세스/리프레시 토큰을 무효화한다.
  1. 서버에서 카카오 로그아웃 API를 호출하여 카카오 액세스 토큰을 무효화한다.
  1. 카카오 서버에서는 사용자의 동의 정보를 계속 유지하고 있게 된다.
  1. 따라서 동일한 사용자가 다시 로그인하면 동의 절차 없이 자동으로 로그인된다.

여기서 키 포인트는 카카오/구글 서버 측에서는 로그아웃 후에도 사용자의 동의 정보를 보관하고 있기 때문에, 다음 로그인 시 자동으로 인증 코드를 발급하고 로그인이 완료되는 것이다.

그럼 실제 구현한 소스코드를 살펴보자.

구현

UserController

@RestController
@RequiredArgsConstructor
@RequestMapping("/v1/user/")
@Slf4j
public class UserController {

    private final AuthService authService;

	...(생략)

    /**
     * 로그아웃
     */
    @PatchMapping("/logout")
    public UserDto.ResponseBasic logout(HttpServletRequest request) {
        return authService.logout(request);
    }
}

AuthServiceImpl

@Service
@RequiredArgsConstructor
@Slf4j
public class AuthServiceImpl implements AuthService{

    private final JwtService jwtService;
    private final RefreshRepository refreshRepository;
    private final SecurityUtil securityUtil;
    private final RedisUtil redisUtil;
    
    @Override
    public UserDto.ResponseBasic logout(HttpServletRequest request) {
    	// 액세스/리프레시 토큰 무효화
        String id = invalidateToken(request);

        /* oauth2 access 토큰 삭제 */
        if (redisUtil.getValues("AT(oauth2):" + id) != null) {
            String socialAccessToken = (String) redisUtil.getValues("AT(oauth2):" + id);
            int underscoreIndex = id.indexOf("_");
            if (underscoreIndex != -1) {
                String socialType = id.substring(0, underscoreIndex);
                if (socialType.equals("google")) {
                    googleLogout(socialAccessToken);
                } else if (socialType.equals("kakao")) {
                    kakaoLogout(socialAccessToken);
                }
            }
            redisUtil.deleteValues("AT(oauth2):" + id);
        }


        return new UserDto.ResponseBasic(200, "로그아웃 되었습니다.");
    }

    // 액세스/리프레시 토큰 무효화
    private String invalidateToken(HttpServletRequest request) {
        String token = request.getHeader("Authorization").substring(7);
        Date date = jwtService.extractExpiration(token);
        Long now = new Date().getTime();
        Long expiration = date.getTime() - now;
        String id = securityUtil.getCurrentMemberUsername();

        // 엑세스 토큰 블랙리스트 관리
        redisUtil.setBlackList(token, "logout", Duration.ofMillis(expiration));

        // 리프레시 토큰 삭제
        if (refreshRepository.findById(id).isPresent()) {
            refreshRepository.deleteById(id);
        }
        return id;
    }

    public void kakaoLogout(String access_Token) {
        String reqURL = "https://kapi.kakao.com/v1/user/logout";
        try {
            URL url = new URL(reqURL);
            HttpURLConnection conn = (HttpURLConnection) url.openConnection();
            conn.setRequestMethod("POST");
            conn.setRequestProperty("Authorization", "Bearer " + access_Token);

            int responseCode = conn.getResponseCode();
            System.out.println("responseCode : " + responseCode);

            BufferedReader br = new BufferedReader(new InputStreamReader(conn.getInputStream()));

            String result = "";
            String line = "";

            while ((line = br.readLine()) != null) {
                result += line;
            }
            System.out.println(result);
        } catch (IOException e) {
            // TODO Auto-generated catch block
            e.printStackTrace();
        }
    }

    public void googleLogout(String accessToken) {
        String tokenInfoUrl = "https://oauth2.googleapis.com/tokeninfo?access_token=" + accessToken;
        try {
            URL url = new URL(tokenInfoUrl);
            HttpURLConnection conn = (HttpURLConnection) url.openConnection();
            conn.setRequestMethod("GET");
            int responseCode = conn.getResponseCode();
            System.out.println("Google Logout Response Code: " + responseCode);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

컨트롤러에서 로그아웃 요청 엔드포인트를 만들었다.


 String id = invalidateToken(request);
 
// 액세스/리프레시 토큰 무효화
private String invalidateToken(HttpServletRequest request) {
    String token = request.getHeader("Authorization").substring(7);
    Date date = jwtService.extractExpiration(token);
    Long now = new Date().getTime();
    Long expiration = date.getTime() - now;
    String id = securityUtil.getCurrentMemberUsername();

    // 엑세스 토큰 블랙리스트 관리
    redisUtil.setBlackList(token, "logout", Duration.ofMillis(expiration));

    // 리프레시 토큰 삭제
    if (refreshRepository.findById(id).isPresent()) {
        refreshRepository.deleteById(id);
    }
    return id;
}

위와 같이 SpringBoot에서는 서비스 내에서 발급한 Access/Refresh Token을 무효화한다.

/* oauth2 access 토큰 삭제 */
if (redisUtil.getValues("AT(oauth2):" + id) != null) {
    String socialAccessToken = (String) redisUtil.getValues("AT(oauth2):" + id);
    int underscoreIndex = id.indexOf("_");
    if (underscoreIndex != -1) {
        String socialType = id.substring(0, underscoreIndex);
        if (socialType.equals("google")) {
            googleLogout(socialAccessToken);
        } else if (socialType.equals("kakao")) {
            kakaoLogout(socialAccessToken);
        }
    }
    redisUtil.deleteValues("AT(oauth2):" + id);
}

다음은 카카오/구글 서버에서 발급받았던 AccessToken과 함께 카카오/구글 서버의 로그아웃 API를 호출하여 AccessToken을 무효화 시켜야 한다.

필자는 DB에 저장된 userId가 소셜로그인 한 유저라면 Provider_이메일주소로 저장이 되기 때문에 Provider 부분을 추출하여 카카오와 구글을 구분 지었다.

그리고 Provider가 kakao라면 kakaoLogout 메서드를 호출하여 카카오 로그아웃 API를 호출하여 액세스 토큰을 무효화 시키고, Provider가 google이라면 googleLogout 메서드를 호출하여 구글 로그아웃 API를 호출하여 액세스 토큰을 무효화 시킨다.

카카오 로그아웃 Rest API

필자는 카카오 로그아웃은 액세스 토큰 방식을 사용하였다.

구글 로그아웃 Rest API

🍀 소셜 로그인 후 회원탈퇴하기

설명

먼저 어떤 과정으로 소셜 로그인 후에 회원탈퇴가 이루어져야 하는지 살펴보자.

< 카카오/구글 회원탈퇴 과정 >

  1. 카카오/구글 연결 끊기 API 호출
    카카오/구글에서 제공하는 '연결 끊기(unlink)' API를 호출한다.
    이 API는 사용자의 카카오 계정과 해당 서비스 간의 연동을 해제한다.

    만약 레디스에 저장된 카카오/구글 AceessToken이 만료되었다면 다시 로그인해야 한다.
    따라서 에러 메세지와 404 에러코드를 응답으로 내보낸다.
    또한 만료 시에는 서비스용 액세스 토큰과 리프레시 토큰을 무효화하기 때문에 인증이 필요한 다른 api를 호출해도 다시 로그인해달라는 메세지와 404 에러코드를 내보낸다.

  1. 카카오 동의 내역 삭제
    연결 끊기 API 호출로 인해 카카오 서버에서 해당 서비스에 대한 사용자의 동의 내역이 삭제된다.
  1. 토큰 무효화
    서비스에서 발급한 리프레시 토큰을 무효화한다.
  1. 서비스 내 회원 정보 삭제
    사용자의 개인 정보, 활동 내역 등 서비스 내부 데이터베이스에 저장된 모든 관련 정보를 삭제하거나 비활성화한다.
  1. 클라이언트 측 정리
    클라이언트 앱이나 웹에서 저장된 모든 관련 데이터(로컬 스토리지, 쿠키 등)를 삭제한다.
  1. 사용자에게 확인
    회원 탈퇴 절차가 완료되었음을 사용자에게 알린다.

구현

UserController

@RestController
@RequiredArgsConstructor
@RequestMapping("/v1/user/")
@Slf4j
public class UserController {

    private final AuthService authService;

	...(생략)

    /**
     * 회원탈퇴
     */
    @PostMapping("/withdraw")
    public UserDto.ResponseBasic withdraw(HttpServletRequest request) {
        return authService.withdraw(request);
    }
}

AuthServiceImpl

@Service
@RequiredArgsConstructor
@Slf4j
public class AuthServiceImpl implements AuthService{

    private final UserRepository userRepository;
    private final JwtService jwtService;
    private final RefreshRepository refreshRepository;
    private final SecurityUtil securityUtil;
    private final RedisUtil redisUtil;
    private final TalentRepository talentRepository;
    private final CommentRepository commentRepository;

 @Override
    public UserDto.ResponseBasic withdraw(HttpServletRequest request) {
        String id = SecurityUtil.getCurrentMemberUsername();

        /* 카카오 및 구글 연결 해제 */
        int underscoreIndex = id.indexOf("_");
        if (underscoreIndex != -1) {
            String socialType = id.substring(0, underscoreIndex);
            if (socialType.equals("google")) {
                googleUnlink(id, request);
            } else if (socialType.equals("kakao")) {
                kakaoUnlink(id, request);
            }
        }

        if (refreshRepository.findById(id).isPresent()) { //리프레시 토큰 삭제
            refreshRepository.deleteById(id);
        }
        List<Talent> talents = talentRepository.findByWriterId(id);
        if (!talents.isEmpty()) {
            for (Talent talent : talents) {
                talent.changeUserNull();
            }
        }
        List<Comment> comments = commentRepository.findByWriterId(id);
        if (!comments.isEmpty()) {
            for (Comment comment : comments) {
                comment.changeUserNull();
            }
        }

        userRepository.deleteById(id);

        return new UserDto.ResponseBasic(200, "회원 탈퇴가 정상적으로 처리되었습니다.");
    }
    
    // 액세스/리프레시 토큰 무효화
    private String invalidateToken(HttpServletRequest request) {
        String token = request.getHeader("Authorization").substring(7);
        Date date = jwtService.extractExpiration(token);
        Long now = new Date().getTime();
        Long expiration = date.getTime() - now;
        String id = securityUtil.getCurrentMemberUsername();

        // 엑세스 토큰 블랙리스트 관리
        redisUtil.setBlackList(token, "logout", Duration.ofMillis(expiration));

        // 리프레시 토큰 삭제
        if (refreshRepository.findById(id).isPresent()) {
            refreshRepository.deleteById(id);
        }
        return id;
    }

    public void kakaoUnlink(String id, HttpServletRequest request) {
        String accessToken = (String) redisUtil.getValues("AT(oauth2):" + id);
        // oauth2 토큰이 만료 시 재 로그인
        if (accessToken == null) {
            invalidateToken(request);
            throw SocialLoginRequriedException.EXCEPTION;
        } else {
            redisUtil.deleteValues("AT(oauth2):" + id);
        }

        String reqURL = "https://kapi.kakao.com/v1/user/unlink";
        try {
            URL url = new URL(reqURL);
            HttpURLConnection conn = (HttpURLConnection) url.openConnection();
            conn.setRequestMethod("POST");
            conn.setRequestProperty("Authorization", "Bearer " + accessToken);

            int responseCode = conn.getResponseCode();
            System.out.println("responseCode : " + responseCode);

            BufferedReader br = new BufferedReader(new InputStreamReader(conn.getInputStream()));

            String result = "";
            String line = "";

            while ((line = br.readLine()) != null) {
                result += line;
            }
            System.out.println(result);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    public void googleUnlink(String id, HttpServletRequest request) {
        String accessToken = (String) redisUtil.getValues("AT(oauth2):" + id);
        // oauth2 토큰이 만료 시 재 로그인
        if (accessToken == null) {
            invalidateToken(request);
            throw SocialLoginRequriedException.EXCEPTION;
        } else {
            redisUtil.deleteValues("AT(oauth2):" + id);
        }
        String tokenInfoUrl = "https://oauth2.googleapis.com/revoke";
        try {
            URL url = new URL(tokenInfoUrl);
            HttpURLConnection conn = (HttpURLConnection) url.openConnection();
            conn.setRequestMethod("POST");
            conn.setRequestProperty("Content-Type", "application/x-www-form-urlencoded");
            conn.setDoOutput(true);

            String postData = "token=" + accessToken;
            byte[] postDataBytes = postData.getBytes("UTF-8");

            conn.setRequestProperty("Content-Length", String.valueOf(postDataBytes.length));

            try (DataOutputStream wr = new DataOutputStream(conn.getOutputStream())) {
                wr.write(postDataBytes);
            }

            int responseCode = conn.getResponseCode();
            System.out.println("Google Unlink Response Code: " + responseCode);

            BufferedReader in = new BufferedReader(new InputStreamReader(conn.getInputStream()));
            String inputLine;
            StringBuilder response = new StringBuilder();
            while ((inputLine = in.readLine()) != null) {
                response.append(inputLine);
            }
            in.close();

        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

먼저 카카오/구글 연결 끊기 API 호출해야 한다.

/* 카카오 및 구글 연결 해제 */
int underscoreIndex = id.indexOf("_");
if (underscoreIndex != -1) {
    String socialType = id.substring(0, underscoreIndex);
    if (socialType.equals("google")) {
        googleUnlink(id, request);
    } else if (socialType.equals("kakao")) {
        kakaoUnlink(id, request);
    }
}

만약 카카오 로그인을 한 유저라면 kakaoUnlink 메서드를 호출하는데, 여기서 레디스에 저장된 카카오 AccessToken을 가져오는게 선행되어야 한다.

하지만 카카오 액세스 토큰은 유효시간 1시간 미만이기에 만약 유효시간이 지나면 자동으로 레디스에서 삭제될 것이다.

그렇기에 액세스 토큰이 null인 경우, 토큰을 무효화하고 SocialLoginRequiredException 예외를 발생시켰다.

레디스에 카카오 액세스 토큰이 저장되어 유효시간이 지나 자동으로 삭제되었다면 회원 탈퇴 시 위와 같은 에러 메세지와 404 에러코드가 뜬다.

고로 액세스 토큰이 헤더에 있다 한들, 블랙리스트에 등록하였기 때문에 인증이 필요한 엔드포인트엔 접근이 불가능하다.

레디스에 카카오 액세스 토큰이 남아있다면 카카오/구글 연결 끊기 API가 정상적으로 호출이 된다.

이후엔 레디스에 저장된 서비스에서 사용 중인 리프레시 토큰이 삭제되고, 사용자와 연관관계인 엔티티와의 관계로 끊은 뒤 정상적으로 DB에서 사용자 정보가 삭제된다.

사용자가 작성한 재능교환소 게시물은 삭제하고, 사용자가 작성한 댓글은 null로 변경해주어 연관관계를 제거하였다.

  1. 재능교환 게시물 삭제 이유
    사용자가 작성한 재능교환소 게시물은 완전히 삭제된다. 이는 탈퇴한 사용자의 게시물에 대해 다른 사용자가 더 이상 재능교환 요청을 보낼 수 없어 실질적인 가치를 잃기 때문이다. 불필요한 정보를 제거함으로써 시스템의 효율성을 유지하였다.
  1. 댓글의 fk인 사용자를 null로 변경하여 연관관계 제거
    기존 댓글(부모 및 자식 댓글)의 맥락과 정보의 연속성을 유지한다.
    다른 사용자들끼리와의 정보 교환에 대한 기록을 보존한다.

🌾 느낀점

이전에 Session 방식과 서버사이드 렌더링으로 구현했던 소셜 로그인을 이번에는 토큰 기반의 인증 방식으로 새롭게 구현하면서, 인증 시스템의 다양한 접근 방식과 각각의 장단점을 깊이 이해할 수 있었다.

블로그에 구현 과정을 정리하면서, 개념을 더욱 명확히 이해하고 체계화할 수 있었다. 특히 로그아웃과 회원 탈퇴 같은 덜 다뤄진 주제에 대해 정리한 것은 다른 개발자들에게도 도움이 될 수 있기를.. 😯

소셜 로그인 구현 과정에서 OAuth 2.0 프로토콜의 세부사항과 보안 고려사항을 깊이 있게 다루면서, 웹 애플리케이션 보안의 중요성을 새삼 실감했다.

이번 소셜 로그인 구현 경험은 기술적 역량 향상뿐만 아니라, 개발자로서의 전반적인 시야를 넓히는 기회였다고 생각한다.

앞으로도 이러한 과제들을 통해 지속적으로 성장해 나가고, 배운 내용을 공유하며 함께 발전해 나가고 싶다.

🌿 출처

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

profile
Velog에 기록 중

2개의 댓글

comment-user-thumbnail
2024년 10월 2일

글 잘 봤습니다 ! 코드를 참고하고 싶은데 혹시 위 코드가 올려진 레포 주소 알 수 있을까요?

1개의 답글