Skip to content

Commit 78eaaf2

Browse files
authored
Merge pull request #99 from JECT-Study/feat/account-withdrawal-provider-unlink
feat: 회원 탈퇴 시 소셜 연동 해제(unlink) 지원
2 parents 2e27b1d + cf784b3 commit 78eaaf2

12 files changed

Lines changed: 556 additions & 3 deletions

src/main/java/com/zimdugo/auth/application/AccountWithdrawalService.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.zimdugo.auth.application;
22

33
import com.zimdugo.auth.domain.RefreshTokenRepository;
4+
import com.zimdugo.auth.domain.SocialProviderTokenRepository;
45
import com.zimdugo.core.exception.BusinessException;
56
import com.zimdugo.core.exception.ErrorCode;
67
import com.zimdugo.user.application.UserQueryService;
@@ -24,6 +25,8 @@ public class AccountWithdrawalService {
2425
private final UserQueryService userQueryService;
2526
private final UserStore userStore;
2627
private final SocialAccountStore socialAccountStore;
28+
private final SocialAccountUnlinkService socialAccountUnlinkService;
29+
private final SocialProviderTokenRepository socialProviderTokenRepository;
2730
private final RefreshTokenRepository refreshTokenRepository;
2831

2932
public void withdraw(String accessToken) {
@@ -41,10 +44,13 @@ public void withdraw(String accessToken) {
4144
throw new BusinessException(ErrorCode.USER_ALREADY_WITHDRAWN);
4245
}
4346

47+
socialAccountUnlinkService.unlinkAll(userId);
48+
4449
user.anonymizeForWithdrawal();
4550
userStore.store(user);
4651

4752
socialAccountStore.deleteAllByUserId(userId);
53+
socialProviderTokenRepository.deleteAllByUserId(userId);
4854
refreshTokenRepository.deleteAllByUserId(userId);
4955
log.info("회원 탈퇴 처리가 완료되었습니다. userId={}", userId);
5056
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
package com.zimdugo.auth.application;
2+
3+
import com.zimdugo.auth.domain.SocialProviderToken;
4+
import com.zimdugo.auth.domain.SocialProviderTokenRepository;
5+
import com.zimdugo.user.domain.AuthProvider;
6+
import java.time.Instant;
7+
import lombok.RequiredArgsConstructor;
8+
import lombok.extern.slf4j.Slf4j;
9+
import org.springframework.security.core.Authentication;
10+
import org.springframework.security.oauth2.client.OAuth2AuthorizedClient;
11+
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService;
12+
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
13+
import org.springframework.stereotype.Service;
14+
15+
@Slf4j
16+
@Service
17+
@RequiredArgsConstructor
18+
public class OAuth2ProviderTokenService {
19+
20+
private final OAuth2AuthorizedClientService authorizedClientService;
21+
private final SocialProviderTokenRepository socialProviderTokenRepository;
22+
23+
public void saveProviderToken(Authentication authentication, Long userId) {
24+
if (!(authentication instanceof OAuth2AuthenticationToken oauth2AuthenticationToken)) {
25+
return;
26+
}
27+
28+
String registrationId = oauth2AuthenticationToken.getAuthorizedClientRegistrationId();
29+
OAuth2AuthorizedClient authorizedClient = authorizedClientService.loadAuthorizedClient(
30+
registrationId,
31+
oauth2AuthenticationToken.getName()
32+
);
33+
if (authorizedClient == null || authorizedClient.getAccessToken() == null) {
34+
log.warn("OAuth provider token 저장을 건너뜁니다. registrationId={}, userId={}", registrationId, userId);
35+
return;
36+
}
37+
38+
Instant accessTokenExpiresAt = authorizedClient.getAccessToken().getExpiresAt();
39+
AuthProvider authProvider = resolveAuthProvider(registrationId, userId);
40+
if (authProvider == null) {
41+
return;
42+
}
43+
44+
socialProviderTokenRepository.save(
45+
userId,
46+
authProvider,
47+
new SocialProviderToken(
48+
authorizedClient.getAccessToken().getTokenValue(),
49+
accessTokenExpiresAt,
50+
extractRefreshToken(authorizedClient)
51+
)
52+
);
53+
}
54+
55+
private AuthProvider resolveAuthProvider(String registrationId, Long userId) {
56+
try {
57+
return AuthProvider.valueOf(registrationId.toUpperCase());
58+
} catch (IllegalArgumentException exception) {
59+
log.warn("지원하지 않는 registrationId라 provider token 저장을 건너뜁니다. registrationId={}, userId={}",
60+
registrationId, userId);
61+
return null;
62+
}
63+
}
64+
65+
private String extractRefreshToken(OAuth2AuthorizedClient authorizedClient) {
66+
if (authorizedClient.getRefreshToken() == null) {
67+
return null;
68+
}
69+
return authorizedClient.getRefreshToken().getTokenValue();
70+
}
71+
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
package com.zimdugo.auth.application;
2+
3+
import com.zimdugo.auth.domain.SocialAccountUnlinkClient;
4+
import com.zimdugo.auth.domain.SocialProviderToken;
5+
import com.zimdugo.auth.domain.SocialProviderTokenRepository;
6+
import com.zimdugo.user.domain.AuthProvider;
7+
import com.zimdugo.user.domain.SocialAccount;
8+
import com.zimdugo.user.domain.SocialAccountReader;
9+
import java.util.EnumMap;
10+
import java.util.List;
11+
import java.util.Map;
12+
import lombok.extern.slf4j.Slf4j;
13+
import org.springframework.stereotype.Service;
14+
15+
@Slf4j
16+
@Service
17+
public class SocialAccountUnlinkService {
18+
19+
private final SocialAccountReader socialAccountReader;
20+
private final SocialProviderTokenRepository socialProviderTokenRepository;
21+
private final Map<AuthProvider, SocialAccountUnlinkClient> unlinkClients;
22+
23+
public SocialAccountUnlinkService(
24+
SocialAccountReader socialAccountReader,
25+
SocialProviderTokenRepository socialProviderTokenRepository,
26+
List<SocialAccountUnlinkClient> unlinkClients
27+
) {
28+
this.socialAccountReader = socialAccountReader;
29+
this.socialProviderTokenRepository = socialProviderTokenRepository;
30+
this.unlinkClients = new EnumMap<>(AuthProvider.class);
31+
for (SocialAccountUnlinkClient unlinkClient : unlinkClients) {
32+
this.unlinkClients.put(unlinkClient.provider(), unlinkClient);
33+
}
34+
}
35+
36+
public void unlinkAll(Long userId) {
37+
for (SocialAccount socialAccount : socialAccountReader.findAllByUserId(userId)) {
38+
unlinkSocialAccount(userId, socialAccount);
39+
}
40+
}
41+
42+
private void unlinkSocialAccount(Long userId, SocialAccount socialAccount) {
43+
SocialAccountUnlinkClient unlinkClient = unlinkClients.get(socialAccount.getProvider());
44+
if (unlinkClient == null) {
45+
logMissingClient(userId, socialAccount);
46+
return;
47+
}
48+
49+
SocialProviderToken token = socialProviderTokenRepository.find(userId, socialAccount.getProvider())
50+
.orElse(null);
51+
if (token == null) {
52+
logMissingToken(userId, socialAccount);
53+
return;
54+
}
55+
56+
unlinkWithLogging(userId, socialAccount, unlinkClient, token);
57+
}
58+
59+
private void unlinkWithLogging(
60+
Long userId,
61+
SocialAccount socialAccount,
62+
SocialAccountUnlinkClient unlinkClient,
63+
SocialProviderToken token
64+
) {
65+
try {
66+
unlinkClient.unlink(socialAccount, token);
67+
log.info(
68+
"소셜 연동 해제가 완료되었습니다. userId={}, provider={}",
69+
userId,
70+
socialAccount.getProvider()
71+
);
72+
} catch (RuntimeException exception) {
73+
log.error(
74+
"소셜 연동 해제에 실패했지만 내부 탈퇴 처리는 계속 진행합니다. userId={}, provider={}",
75+
userId,
76+
socialAccount.getProvider(),
77+
exception
78+
);
79+
}
80+
}
81+
82+
private void logMissingClient(Long userId, SocialAccount socialAccount) {
83+
log.warn(
84+
"연동 해제 클라이언트가 없어 소셜 연동 해제를 건너뜁니다. userId={}, provider={}",
85+
userId,
86+
socialAccount.getProvider()
87+
);
88+
}
89+
90+
private void logMissingToken(Long userId, SocialAccount socialAccount) {
91+
log.warn(
92+
"저장된 provider token이 없어 소셜 연동 해제를 건너뜁니다. userId={}, provider={}",
93+
userId,
94+
socialAccount.getProvider()
95+
);
96+
}
97+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package com.zimdugo.auth.domain;
2+
3+
import com.zimdugo.user.domain.AuthProvider;
4+
import com.zimdugo.user.domain.SocialAccount;
5+
6+
public interface SocialAccountUnlinkClient {
7+
8+
AuthProvider provider();
9+
10+
void unlink(SocialAccount socialAccount, SocialProviderToken token);
11+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package com.zimdugo.auth.domain;
2+
3+
import java.time.Instant;
4+
5+
public record SocialProviderToken(
6+
String accessToken,
7+
Instant accessTokenExpiresAt,
8+
String refreshToken
9+
) {
10+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package com.zimdugo.auth.domain;
2+
3+
import com.zimdugo.user.domain.AuthProvider;
4+
import java.util.Optional;
5+
6+
public interface SocialProviderTokenRepository {
7+
8+
void save(Long userId, AuthProvider provider, SocialProviderToken token);
9+
10+
Optional<SocialProviderToken> find(Long userId, AuthProvider provider);
11+
12+
void deleteAllByUserId(Long userId);
13+
}

src/main/java/com/zimdugo/auth/entrypoint/oauth2/OAuth2SuccessHandler.java

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import com.zimdugo.auth.application.OAuth2LoginSessionResult;
44
import com.zimdugo.auth.application.OAuth2LoginSessionService;
5+
import com.zimdugo.auth.application.OAuth2ProviderTokenService;
56
import jakarta.servlet.http.HttpServletRequest;
67
import jakarta.servlet.http.HttpServletResponse;
78
import java.io.IOException;
@@ -28,6 +29,7 @@ public class OAuth2SuccessHandler implements AuthenticationSuccessHandler {
2829

2930
private final OAuth2LoginSessionService loginSessionService;
3031
private final OAuth2CallbackUrlCookieManager callbackUrlCookieManager;
32+
private final OAuth2ProviderTokenService providerTokenService;
3133

3234
@Value("${auth.cookie.refresh.same-site:Strict}")
3335
private String refreshTokenCookieSameSite;
@@ -52,12 +54,13 @@ public void onAuthenticationSuccess(
5254
Map<String, Object> attributes = oAuth2User.getAttributes();
5355
Long userId = extractUserId(attributes);
5456
if (userId == null) {
55-
handleInvalidUserInfo(response, callbackUrl, "OAuth2 사용자 식별값(userId)을 가져오지 못했습니다.");
57+
handleInvalidUserInfo(response, callbackUrl, "OAuth2 사용자 식별값 userId를 가져오지 못했습니다.");
5658
return;
5759
}
5860

5961
String email = extractNullableAttribute(attributes, "email");
6062
String role = Objects.requireNonNullElse(extractNullableAttribute(attributes, "role"), "USER");
63+
providerTokenService.saveProviderToken(authentication, userId);
6164

6265
OAuth2LoginSessionResult session = loginSessionService.createSession(userId, email, role);
6366

@@ -72,7 +75,7 @@ public void onAuthenticationSuccess(
7275
response.addHeader(HttpHeaders.SET_COOKIE, rtCookie.toString());
7376
callbackUrlCookieManager.clearCallbackUrl(response);
7477

75-
log.info("OAuth 로그인 성공. userId={}, sid={}, callbackUrl={}", userId, session.sid(), callbackUrl);
78+
log.info("OAuth 로그인이 성공했습니다. userId={}, sid={}, callbackUrl={}", userId, session.sid(), callbackUrl);
7679
response.sendRedirect(appendCode(callbackUrl, "LOGIN_SUCCESS"));
7780
}
7881

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
package com.zimdugo.auth.infrastructure.client;
2+
3+
import com.zimdugo.auth.domain.SocialAccountUnlinkClient;
4+
import com.zimdugo.auth.domain.SocialProviderToken;
5+
import com.zimdugo.core.exception.BusinessException;
6+
import com.zimdugo.core.exception.ErrorCode;
7+
import com.zimdugo.user.domain.AuthProvider;
8+
import com.zimdugo.user.domain.SocialAccount;
9+
import lombok.extern.slf4j.Slf4j;
10+
import org.springframework.http.MediaType;
11+
import org.springframework.stereotype.Component;
12+
import org.springframework.util.LinkedMultiValueMap;
13+
import org.springframework.util.MultiValueMap;
14+
import org.springframework.web.client.RestClient;
15+
import org.springframework.web.client.RestClientException;
16+
17+
@Slf4j
18+
@Component
19+
public class GoogleSocialAccountUnlinkClient implements SocialAccountUnlinkClient {
20+
21+
private final RestClient restClient;
22+
23+
public GoogleSocialAccountUnlinkClient(RestClient.Builder restClientBuilder) {
24+
this.restClient = restClientBuilder.baseUrl("https://oauth2.googleapis.com").build();
25+
}
26+
27+
@Override
28+
public AuthProvider provider() {
29+
return AuthProvider.GOOGLE;
30+
}
31+
32+
@Override
33+
public void unlink(SocialAccount socialAccount, SocialProviderToken token) {
34+
try {
35+
MultiValueMap<String, String> formData = new LinkedMultiValueMap<>();
36+
formData.add("token", resolveRevocationToken(token));
37+
38+
restClient.post()
39+
.uri("/revoke")
40+
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
41+
.body(formData)
42+
.retrieve()
43+
.toBodilessEntity();
44+
} catch (RestClientException exception) {
45+
log.error("구글 연동 해제에 실패했습니다. userId={}", socialAccount.getUser().getId(), exception);
46+
throw new BusinessException(ErrorCode.EXTERNAL_API_ERROR, exception);
47+
}
48+
}
49+
50+
private String resolveRevocationToken(SocialProviderToken token) {
51+
if (token.refreshToken() != null && !token.refreshToken().isBlank()) {
52+
return token.refreshToken();
53+
}
54+
if (token.accessToken() != null && !token.accessToken().isBlank()) {
55+
return token.accessToken();
56+
}
57+
throw new BusinessException(ErrorCode.EXTERNAL_API_ERROR);
58+
}
59+
}

0 commit comments

Comments
 (0)