IMAD 프로젝트에서는 자체 회원 가입 외에 소셜 로그인을 지원하고 있다. 이들은 회원이 탈퇴를 할 때 서비스의 유저 관련 데이터 삭제 외에도 토큰 revoke와 소셜 로그인 연결 해제 등의 추가적인 작업을 해주어야 한다. 때문에 관련된 부분을 추가로 구현하게 되었다.
지금 스프링 부트 프로젝트에서 소셜 회원 로그인 기능을 구현할 때 oauth2-client
라이브러리를 사용하고 있는데, 탈퇴 기능은 제공하고 있지 않다. 그리고 탈퇴 방법은 소셜 업체별로 다르다.
https://kapi.kakao.com/v1/user/unlink
에 access token을 헤더에 첨부하여 POST 요청이름 | 설명 | 필수 |
---|---|---|
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}"
https://nid.naver.com/oauth2.0/token
에 POST 요청하여 연동 해제 진행요청 변수명 | 타입 | 필수 여부 | 기본값 | 설명 |
---|---|---|---|---|
client_id | string | Y | - | 애플리케이션 등록 시 발급받은 Client ID 값 |
client_secret | string | Y | - | 애플리케이션 등록 시 발급받은 Client Secret 값 |
access_token | string | Y | - | 유효한 접근토큰 값 |
grant_type | string | Y | - | 요청 타입. 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
https://appleid.apple.com/auth/revoke
에 POST로 요청하여 연동 해제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
소셜 회원 탈퇴 요청들을 처리해주는 컨트롤러이다. 헤더의 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);
}
}
@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);
}
}
소셜 업체별로 요청 포맷이 다르기 때문에 각각 메소드를 만들어주었다.
access token에서 이메일 claim을 파싱(extractUserFromAccessToken(String accessToken)
)하고, 해당 정보로 DB에서 user Entity를 뽑아내고 관련 정보를 삭제(deleteUserAccount(UserAccount userAccount)
)해준다.
그리곡 각 업체별로 형식에 맞게 데이터를 설정해주고 request를 날리는 메소드에 전달한다.
만약에, 1년 이상 접속하지 않은 사용자에 대한 삭제를 구현해야 한다고 하면, 연동 해제를 할 때 배치 서버를 사용하여 refresh 토큰을 주기적으로 업데이트 하도록 구현하실까요?
사용자 요청의 의한 삭제가 아닌 경우 구현을 여쭤봅니다