[IMAD] 소셜회원 탈퇴 기능 구현

NCOOKIE·2023년 7월 22일
0

IMAD 프로젝트

목록 보기
8/11

들어가며

IMAD 프로젝트에서는 자체 회원 가입 외에 소셜 로그인을 지원하고 있다. 이들은 회원이 탈퇴를 할 때 서비스의 유저 관련 데이터 삭제 외에도 토큰 revoke와 소셜 로그인 연결 해제 등의 추가적인 작업을 해주어야 한다. 때문에 관련된 부분을 추가로 구현하게 되었다.

방법 조사

지금 스프링 부트 프로젝트에서 소셜 회원 로그인 기능을 구현할 때 oauth2-client 라이브러리를 사용하고 있는데, 탈퇴 기능은 제공하고 있지 않다. 그리고 탈퇴 방법은 소셜 업체별로 다르다.

카카오

Kakao Developers

  • https://kapi.kakao.com/v1/user/unlink 에 access token을 헤더에 첨부하여 POST 요청
  • 정상적으로 처리되었다면 응답으로 해당 유저의 회원번호를 받음
  • 로그아웃 또한 함께 진행되어 access token과 refresh token이 만료 처리됨
이름설명필수
Authorization사용자 인증 수단, 액세스 토큰 값Authorization: Bearer ${ACCESS_TOKEN}O

예시

curl -v -X POST "https://kapi.kakao.com/v1/user/unlink" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -H "Authorization: Bearer ${ACCESS_TOKEN}"

계정 관리 페이지 / 고객센터에서 연동해제 진행할 경우

  • 개인 정보 삭제나 잔여 캐시 안내 등의 후처리를 위해 콜백 API를 설정할 수 있음
  • 이 부분은 당장 필요한 것 같지는 않아 나중에 기회가 되면 구현하기로 함


네이버

네이버 로그인 개발가이드 - LOGIN

  • https://nid.naver.com/oauth2.0/token 에 POST 요청하여 연동 해제 진행

Parameter

요청 변수명타입필수 여부기본값설명
client_idstringY-애플리케이션 등록 시 발급받은 Client ID 값
client_secretstringY-애플리케이션 등록 시 발급받은 Client Secret 값
access_tokenstringY-유효한 접근토큰 값
grant_typestringY-요청 타입. delete 으로 설정

예시

https://nid.naver.com/oauth2.0/token?grant_type=delete&client_id=CLIENT_ID&client_secret=CLIENT_SECRET&access_token=ACCESS_TOKEN

애플

Revoke tokens | Apple Developer Documentation

예시

curl -v POST "https://appleid.apple.com/auth/revoke" \
-H 'content-type: application/x-www-form-urlencoded' \
-d 'client_id=CLIENT_ID' \
-d 'client_secret=CLIENT_SECRET' \
-d 'token=ACCESS_TOKEN' \
-d 'token_type_hint=access_token'

구글

아무리 구글링해봐도 관련 공식문서가 안 보이고, 블로그에서도 정확하게 명세해둔 곳이 없어 혹시나 하는 마음으로 챗gpt에게 물어보았다. 그랬더니 바로 답을 내주더라…

https://accounts.google.com/o/oauth2/revoke?token=YOUR_ACCESS_TOKEN

위의 URL에 access token 넣어서 GET 또는 POST 요청 날려주면 된다.

구현

Oauth2 업체와의 로그인 연동(연결)을 해제하려면 공통적으로 access token을 요구한다. 이를 위해 DB에 access token을 저장하는 칼럼을 따로 추가해주었다. 내가 구상한 소셜 회원 탈퇴 절차는 다음과 같다.

1. (클라) 유저가 회원 탈퇴 버튼 CLICK
2. (서버) 해당 유저의 db에 저장된 auth access token을 통해 oauth2 업체 측에 회원탈퇴 요청
    a. access token이 validate 할 경우 : 3번으로
    b. refresh token이 validate 하지 않은 경우
        i. 유저에게 소셜 재로그인을 요구하고 access token 발급 받아 db에 저장
        ii. 메세지 등을 띄워서 회원탈퇴 버튼을 다시 누르도록 유도함 (1번으로)
3. (서버) oauth2 업체에게 회원탈퇴(revoke) 요청
4. (서버) revoke 완료 응답 수신
5. (클라) 유저에게 회원탈퇴가 정상적으로 완료되었음을 display

RevokeController.java

소셜 회원 탈퇴 요청들을 처리해주는 컨트롤러이다. 헤더의 Authorization에서 IMAD에서 사용하고 있는 JWT의 access token을 파싱한다.

@RestController
@RequiredArgsConstructor
public class RevokeController {
    private final RevokeService revokeService;

    @DeleteMapping("/api/oauth2/revoke/apple")
    public ApiResponse<?> revokeAppleAccount(@RequestHeader("Authorization") String accessToken) throws IOException {
        revokeService.deleteAppleAccount(accessToken);
        return ApiResponse.createSuccessWithNoContent(ResponseCode.USER_DELETE_SUCCESS);
    }

    @DeleteMapping("/api/oauth2/revoke/google")
    public ApiResponse<?> revokeGoogleAccount(@RequestHeader("Authorization") String accessToken) {
        revokeService.deleteGoogleAccount(accessToken);
        return ApiResponse.createSuccessWithNoContent(ResponseCode.USER_DELETE_SUCCESS);
    }

    @DeleteMapping("/api/oauth2/revoke/naver")
    public ApiResponse<?> revokeNaverAccount(@RequestHeader("Authorization") String accessToken) {
        revokeService.deleteNaverAccount(accessToken);
        return ApiResponse.createSuccessWithNoContent(ResponseCode.USER_DELETE_SUCCESS);
    }

    @DeleteMapping("/api/oauth2/revoke/kakao")
    public ApiResponse<?> revokeKakaoAccount(@RequestHeader("Authorization") String accessToken) {
        revokeService.deleteKakaoAccount(accessToken);
        return ApiResponse.createSuccessWithNoContent(ResponseCode.USER_DELETE_SUCCESS);
    }
}

RevokeService.java

@Slf4j
@RequiredArgsConstructor
@Service
public class RevokeService {
    private final UserAccountRepository userRepository;
    private final JwtService jwtService;

    private final AppleService appleService;

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

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


    public void deleteAppleAccount(String accessToken) throws IOException {
        UserAccount userAccount = extractUserFromAccessToken(accessToken);
        deleteUserAccount(userAccount);

        String data = "client_id=" + appleService.getAPPLE_CLIENT_ID() +
                "&client_secret=" + appleService.createClientSecretKey() +
                "&token=" + userAccount.getOauth2AccessToken() +
                "&token_type_hint=access_token";

        sendRevokeRequest(data, AuthProvider.APPLE, null);
    }

    public void deleteGoogleAccount(String accessToken) {
        UserAccount userAccount = extractUserFromAccessToken(accessToken);
        deleteUserAccount(userAccount);

        String data = "token=" + userAccount.getOauth2AccessToken();

        sendRevokeRequest(data, AuthProvider.GOOGLE, null);
    }

    public void deleteNaverAccount(String accessToken) {
        UserAccount userAccount = extractUserFromAccessToken(accessToken);
        deleteUserAccount(userAccount);

        String data = "client_id=" + naverClientId +
                "&client_secret=" + naverClientSecret +
                "&access_token=" + userAccount.getOauth2AccessToken() +
                "&service_provider=NAVER" +
                "&grant_type=delete";

        sendRevokeRequest(data, AuthProvider.NAVER, null);
    }

    public void deleteKakaoAccount(String accessToken) {
        UserAccount userAccount = extractUserFromAccessToken(accessToken);
        deleteUserAccount(userAccount);

        sendRevokeRequest(null, AuthProvider.KAKAO, userAccount.getOauth2AccessToken());
    }

    private UserAccount extractUserFromAccessToken(String accessToken) {
        Optional<String> email = jwtService.extractClaimFromJWT(JwtService.CLAIM_EMAIL, extractToken(accessToken));
        if (email.isEmpty()) {
            throw new BadRequestException(ResponseCode.USER_NOT_FOUND);
        }

        Optional<UserAccount> userAccount = userRepository.findByEmail(email.get());
        if (userAccount.isEmpty()) {
            throw new BadRequestException(ResponseCode.USER_NOT_FOUND);
        }

        return userAccount.get();
    }

    private void deleteUserAccount(UserAccount userAccount) {
        // 유저 관련 데이터 DB에서 삭제
        // TODO: 추후 DB 테이블 추가 시 관련 데이터 삭제 구문 구현 필요
        userRepository.delete(userAccount);
    }

    /**
     * @param data : revoke request의 body에 들어갈 데이터
     * @param provider : oauth2 업체
     * @param accessToken : 카카오의 경우 url이 아니라 헤더에 access token을 첨부해서 보내줘야 함
     */
    private void sendRevokeRequest(String data, AuthProvider provider, String accessToken) {
        String appleRevokeUrl = "https://appleid.apple.com/auth/revoke";
        String googleRevokeUrl = "https://accounts.google.com/o/oauth2/revoke";
        String naverRevokeUrl = "https://nid.naver.com/oauth2.0/token";
        String kakaoRevokeUrl = "https://kapi.kakao.com/v1/user/unlink";

        RestTemplate restTemplate = new RestTemplate();
        String revokeUrl = "";

        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);

        HttpEntity<String> entity = new HttpEntity<>(data, headers);

        switch (provider) {
            case APPLE -> revokeUrl = appleRevokeUrl;
            case GOOGLE -> revokeUrl = googleRevokeUrl;
            case NAVER -> revokeUrl = naverRevokeUrl;
            case KAKAO -> {
                revokeUrl = kakaoRevokeUrl;
                headers.setBearerAuth(accessToken);
            }
        }

        ResponseEntity<String> responseEntity = restTemplate.exchange(revokeUrl, HttpMethod.POST, entity, String.class);

        // Get the response status code and body
        HttpStatus statusCode = (HttpStatus) responseEntity.getStatusCode();
        String responseBody = responseEntity.getBody();

        logWithOauthProvider(AuthProvider.APPLE, "소셜 회원 연결해제 요청 결과");
        logWithOauthProvider(AuthProvider.APPLE, "Status Code: " + statusCode);
        logWithOauthProvider(AuthProvider.APPLE, "Response: " + responseBody);

    }
}

deleteAccount(String accessToken)

소셜 업체별로 요청 포맷이 다르기 때문에 각각 메소드를 만들어주었다.

access token에서 이메일 claim을 파싱(extractUserFromAccessToken(String accessToken))하고, 해당 정보로 DB에서 user Entity를 뽑아내고 관련 정보를 삭제(deleteUserAccount(UserAccount userAccount))해준다.

그리곡 각 업체별로 형식에 맞게 데이터를 설정해주고 request를 날리는 메소드에 전달한다.

profile
일단 해보자

0개의 댓글