스프링 부트 소셜 로그인 연결 끊기, 회원탈퇴 기능 구현

췌누의 개발·2024년 3월 12일
post-thumbnail

행운복권 프로젝트를 진행하면서 회원 탈퇴 기능을 구현해 보고자 한다.

현재 프로젝트 상황을 기준으로 봤을 때 고려해야 할 점들

  • 회원 탈퇴 시 redis에 저장했던 자체 refreshToken 삭제 (로그아웃이라고 보면 될 것 같다.)
  • 유저 정보 삭제(현재 서비스에서 유저가 회원을 탈퇴할 때 정보를 따로 저장하지 않고 모든 정보를 삭제하기로 결정했다. 서비스 특성상 크게 중요하지 않다고 판단했다. )
  • 연결 끊기는 앱과 사용자 카카오 계정, 구글 계정의 연결을 끊기

카카오 소셜로그인 연결 끊기

카카오 공식문서
https://developers.kakao.com/docs/latest/ko/kakaologin/common#link-and-signup

카카오 소셜 연결을 끊은 방법에는 두 가지 방법이 있다.

  • 카카오 액세스 토큰 방식으로 연결을 끊기,
  • 앱 어드민 키를 사용하여 연결 끊기

행운복권에서 유저가 카카오 AccessToken을 이용하여 회원을 탈퇴한다고 가정하면...

현재 카카오, 구글 AccessToken을 클라이언트에서 저장하지만 최신화를 하고 있지 않기 때문에 탈퇴 시 유저에게 카카오 재 로그인을 통해서 새로운 AccessToken을 서버에 전달하여 회원 탈퇴를 진행하는 흐름을 생각했다.

사용자의 경험 입장에서 본다면 사용자가 회원 탈퇴를 원할 때마다 추가적인 로그인 과정을 거쳐야 한다면, 이는 사용자에게 번거로움을 줄 수 있다고 판단하였다.

카카오 연결 끊기는 Admin 키를 이용한 방법을 도입했다.

Admin 키를 이용한 연결 끊기 스펙이다.

카카오 Admin 키는 나의 애플리케이션에서 확인할 수 있다.

그렇기 때문에 우리는 Admin 키를 암호화하여 환경 변수로 사용할 수 있도록 설정했다.

구글 소셜로그인 연결 끊기

구글에서는 Admin 키를 이용하여 사용자의 소셜 로그인 연결을 끊는 기능이 없었다. 그렇기 때문에 accessToken을 이용한 소셜 로그인 연결을 끊는 방법을 적용하였다.

POST /revoke HTTP/1.1
Host: oauth2.googleapis.com
Content-Type: application/x-www-form-urlencoded
token=ACCESS_TOKEN

구글 소셜 연결 끊기는 https://oauth2.googleapis.com/revoke?token=accessToken 이런 식으로 요청하면 된다.

그렇지만 구글에서 발급받은 AccessToken이 우리의 애플리케이션 행운복권에서 발급된 토큰인지 인증된 해당 사용자의 토큰인지 확인해야 하는 절차를 가져가야 한다.

AccessTokem으로 유저의 구글 ID 식별자를 가져와서, 저장 중인 유저의 OauthID(회원 가입 시 DB에 저장됨)와 비교해서 처리했다.

구글 공식문서
https://cloud.google.com/identity-platform/docs/web/google?hl=ko

간단하게 정리해보자면

카카오 로그인 유저 -> 회원 탈퇴 -> Admin 키를 이용한 카카오 연결 끊기 -> 회원 탈퇴 완료

구글 로그인 유저 -> 회원 탈퇴 -> 구글 재 로그인(AccessTokem 재발급) -> AccessTokem 구글 연결 끊기 -> 회원 탈퇴 완료

카카오 연결 끊기 또한 AccessTokem 방식이 있지만 카카오 로그인으로 회원가입을 진행하는 유저가 많을 것으로 판단했고 사용자의 경험 입장에서 생각했을 때 카카오는 Admin 키를 이용한 연결 끊기로 결정했다.

구현을 시작해보자

//KakaoUnlinkClient
@FeignClient(name = "KakaoUnlinkClient", url = "https://kapi.kakao.com")
public interface KakaoUnlinkClient {

    @PostMapping(value = "/v1/user/unlink", consumes = "application/x-www-form-urlencoded")
    void unlinkUser(
            @RequestHeader("Authorization") String authorization,
            @RequestParam(name = "target_id_type", defaultValue = "user_id") String targetIdType,
            @RequestParam("target_id") Long targetId
    );
}

행운복권에서 크롤링, 및 로그인에 FeignClient를 사용하고 있기 때문에 kakao unlink도 적용했다.

"Authorization" = "KakaoAK" +" "+ Admin 키
"target_id_type" = "user_id"(고정)
"target_id" = user의 OauthID(회원가입시 BD에 저장됨)

//GoogleAuthClient
@FeignClient(name = "GoogleAuthClient", url = "https://www.googleapis.com/oauth2")
public interface GoogleAuthClient {

    @GetMapping("/v1/userinfo?alt=json")
    UserInfoToOauthDto getGoogleInfo(@RequestHeader("Authorization") String token);

}

구글에서는 AccessToken으로 유저 정보를 가져오는 것이 선행돼야 한다.
유저 탈퇴 시 유저가 가지고 있는 OauthID(회원가입 시 저장)와 AccessToken 가져온 ID를 비교하는 로직이 필요하기 때문이다.
(행운복권에서 발급한 AccessToken 가 맞는지, 해당 토큰이 실제로 인증된 해당 유저인지 검증)

@FeignClient(name = "GoogleUnlinkClient", url = "https://oauth2.googleapis.com/revoke")
public interface GoogleUnlinkClient {

    @PostMapping
    @Headers("Content-type:application/x-www-form-urlencoded")
    void unlink(@RequestParam("token") String oauthAccessToken);
}

구글 unlink 구현
"token" = "Bearer" +" "+ 구글 AccessToken


public interface OauthStrategy {
    OIDCDecodePayload getOIDCDecodePayload(String token);

    String getOauthLink();

    OauthTokenInfoDto getOauthToken(String code);

    UserInfoToOauthDto getUserInfo(String oauthAccessToken);

    void unLink(UnlinkRequest unlinkRequest);
}

다양한 OAuth 제공자에 대한 구체적인 인증 전략을 쉽게 추가하거나 변경할 수 있도록 인터페이스 생성

@Getter
@Builder
public class UnlinkRequest {
    private String accessToken;
    private String oauthId;

    public static UnlinkRequest createWithAccessToken(String accessToken) {
        return UnlinkRequest.builder().accessToken(accessToken).build();
    }

    public static UnlinkRequest createWithOauthId(String oauthId) {
        return UnlinkRequest.builder().oauthId(oauthId).build();
    }
}

구글 로그인 유저는 AccessToken, 카카오는 유저 OauthID(회원 가입 시 저장됨)를 저장할 수 있도록 dto로 생성



@AllArgsConstructor
@Component("KAKAO")
@Slf4j
public class KaKaoOauthStrategy implements OauthStrategy {

    private final KakaoUnlinkClient kakaoUnlinkClient;
    private final OauthProperties oauthProperties;
    private static final String PREFIX = "KakaoAK ";
    private static final String TARGET_TYPE = "user_id";
    private static final String ISSUER = "https://kauth.kakao.com";
    private static final String QUERY_STRING = "/oauth/authorize?client_id=%s&redirect_uri=%s&response_type=code";
    
    @Override
    public UserInfoToOauthDto getUserInfo(String oauthAccessToken) {
        return null;
    }

    @Override
    public void unLink(UnlinkRequest unlinkRequest) {

        if (unlinkRequest.getOauthId() != null) {
            String kakaoAdminKey = oauthProperties.getKakaoAdminKey();
            kakaoUnlinkClient.unlinkUser(PREFIX + kakaoAdminKey,TARGET_TYPE, Long.valueOf(unlinkRequest.getOauthId()));
        }

    }


}

카카오 unlink, getUserInfo 인터페이스를 구현했다.

@AllArgsConstructor
@Component("GOOGLE")
public class GoogleOauthStrategy implements OauthStrategy{

    private final OauthProperties oauthProperties;
    private final GoogleAuthClient googleAuthClient;
    private final GoogleUnlinkClient googleUnlinkClient;
    private static final String PREFIX = "Bearer ";

    @Override
    public UserInfoToOauthDto getUserInfo(String accessToken){
         return googleAuthClient.getGoogleInfo(PREFIX + accessToken);
    }

    @Override
    public void unLink(UnlinkRequest unlinkRequest) {
        if (unlinkRequest.getAccessToken() != null) {
            googleUnlinkClient.unlink(unlinkRequest.getAccessToken());
        }

    }

}

구글 unlink, getUserInfo 인터페이스를 구현했다.


@Transactional
    public void deleteUser(String oauthAccessToken) {
        User user = userUtils.getUserFromSecurityContext();
        OauthProvider provider = OauthProvider.valueOf(user.getOauthProvider().toUpperCase());
        OauthStrategy oauthStrategy = oauthFactory.getOauthstrategy(provider);
        String userOauthId = user.getOauthId();

        if(provider.equals(OauthProvider.GOOGLE)) {
            verifyUserOauthIdWithAccessToken(oauthAccessToken, userOauthId, oauthStrategy);
        }

        deleteUserData(user);

        UnlinkRequest unlinkRequest = createUnlinkRequest(provider, oauthAccessToken, userOauthId);
        oauthStrategy.unLink(unlinkRequest);

        user.withdrawal();
    }

유저 회원 탈퇴 기능 구현


private void verifyUserOauthIdWithAccessToken(String oauthAccessToken, String oauthId, OauthStrategy oauthStrategy) {

        if(oauthAccessToken == null) {
            throw NotNullTokenException.EXCEPTION;
        }

        UserInfoToOauthDto userInfo = oauthStrategy.getUserInfo(oauthAccessToken);

        if (!userInfo.getId().equals(oauthId)) {
            throw UserIdMismatchException.EXCEPTION;
        }
    }

구글 소셜 로그인 유저일 경우 구글 AccessToken으로 유저 정보를 가져와
유저가 가지고 있는 OauthID(회원가입 시 저장)와 비교하는 과정을 구현했다.


private void deleteUserData(User user) {
        refreshTokenRedisEntityRepository.deleteById(user.getId().toString());
        userRepository.delete(user);
    }

Redis에 저장된 유저의 RefreshToken을 삭제하고 유저 정보를 삭제했다.


private UnlinkRequest createUnlinkRequest(OauthProvider provider, String oauthAccessToken, String oauthId) {

        if (provider.equals(OauthProvider.GOOGLE)) {
            return UnlinkRequest.createWithAccessToken(oauthAccessToken);
        } else {
            return UnlinkRequest.createWithOauthId(oauthId);
        }
    }

카카오 로그인 유저, 구글 로그인 유저인지 판단하여 UnlinkRequest dto를 생성하여 전달했다.

지금까지 스프링 부트 소셜 로그인 연결 끊기, 회원 탈퇴 기능 구현해 봤습니다.
카카오 로그인 유저와 구글 로그인 유저의 소셜 연결 끊기 방법을 다르게 해서 구현했습니다. 그래서 구현의 복잡도가 증가한 것 같습니다. 두 개의 연결 끊기 방법을 통일하거나, 소셜 로그인으로 받은 accessToken을 주기적으로 최신화해 서버에서 관리하여 유저가 다시 로그인하는 불편함이 없도록 구현하는 방법도 고려해 봐야겠습니다.

프로젝트 링크를 통해서 참고하시면 좋을 것 같습니다! 감사합니다.
도움이 되셨으면 좋겠습니다.👋🏼

행운 복권 깃허브 링크
https://github.com/Uttug-Seuja/luck-lottery-server

profile
아샷추를 좋아합니다

0개의 댓글