diff --git a/src/main/java/gg/agit/konect/domain/user/model/UnRegisteredUser.java b/src/main/java/gg/agit/konect/domain/user/model/UnRegisteredUser.java index 20e70a106..6146a00f5 100644 --- a/src/main/java/gg/agit/konect/domain/user/model/UnRegisteredUser.java +++ b/src/main/java/gg/agit/konect/domain/user/model/UnRegisteredUser.java @@ -50,6 +50,9 @@ public class UnRegisteredUser extends BaseEntity { @Column(name = "provider_id", length = 255) private String providerId; + @Column(name = "apple_refresh_token", length = 1024) + private String appleRefreshToken; + @Builder private UnRegisteredUser(Integer id, String email, Provider provider, String providerId) { this.id = id; @@ -57,4 +60,8 @@ private UnRegisteredUser(Integer id, String email, Provider provider, String pro this.provider = provider; this.providerId = providerId; } + + public void updateAppleRefreshToken(String appleRefreshToken) { + this.appleRefreshToken = appleRefreshToken; + } } diff --git a/src/main/java/gg/agit/konect/domain/user/model/User.java b/src/main/java/gg/agit/konect/domain/user/model/User.java index 47ac21a83..d185a8317 100644 --- a/src/main/java/gg/agit/konect/domain/user/model/User.java +++ b/src/main/java/gg/agit/konect/domain/user/model/User.java @@ -87,6 +87,9 @@ public class User extends BaseEntity { @Column(name = "image_url") private String imageUrl; + @Column(name = "apple_refresh_token", length = 1024) + private String appleRefreshToken; + @Builder private User( Integer id, @@ -99,7 +102,8 @@ private User( String providerId, UserRole role, Boolean isMarketingAgreement, - String imageUrl + String imageUrl, + String appleRefreshToken ) { this.id = id; this.university = university; @@ -112,6 +116,7 @@ private User( this.role = role == null ? UserRole.USER : role; this.isMarketingAgreement = isMarketingAgreement; this.imageUrl = imageUrl; + this.appleRefreshToken = appleRefreshToken; } public static User of( @@ -131,6 +136,7 @@ public static User of( .providerId(tempUser.getProviderId()) .isMarketingAgreement(isMarketingAgreement) .imageUrl(imageUrl) + .appleRefreshToken(tempUser.getAppleRefreshToken()) .build(); } @@ -161,4 +167,8 @@ public String getStudentNumberYear() { public boolean isAdmin() { return this.role.equals(UserRole.ADMIN); } + + public void updateAppleRefreshToken(String appleRefreshToken) { + this.appleRefreshToken = appleRefreshToken; + } } diff --git a/src/main/java/gg/agit/konect/domain/user/service/UserService.java b/src/main/java/gg/agit/konect/domain/user/service/UserService.java index f6b168ccf..fbc24080e 100644 --- a/src/main/java/gg/agit/konect/domain/user/service/UserService.java +++ b/src/main/java/gg/agit/konect/domain/user/service/UserService.java @@ -35,6 +35,7 @@ import gg.agit.konect.domain.user.repository.UserRepository; import gg.agit.konect.global.code.ApiResponseCode; import gg.agit.konect.global.exception.CustomException; +import gg.agit.konect.infrastructure.oauth.AppleTokenRevocationService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -56,6 +57,7 @@ public class UserService { private final CouncilNoticeReadRepository councilNoticeReadRepository; private final StudyTimeQueryService studyTimeQueryService; private final ApplicationEventPublisher applicationEventPublisher; + private final AppleTokenRevocationService appleTokenRevocationService; @Transactional public Integer signup(String email, String providerId, Provider provider, SignupRequest request) { @@ -196,6 +198,11 @@ public void deleteUser(Integer userId) { validateNotClubPresident(userId); validatePaidFees(userId); + + if (user.getProvider() == Provider.APPLE) { + appleTokenRevocationService.revoke(user.getAppleRefreshToken()); + } + userRepository.delete(user); applicationEventPublisher.publishEvent(UserWithdrawnEvent.from(user.getEmail())); diff --git a/src/main/java/gg/agit/konect/global/auth/oauth/OAuth2LoginSuccessHandler.java b/src/main/java/gg/agit/konect/global/auth/oauth/OAuth2LoginSuccessHandler.java index f92e871fd..b61607f1c 100644 --- a/src/main/java/gg/agit/konect/global/auth/oauth/OAuth2LoginSuccessHandler.java +++ b/src/main/java/gg/agit/konect/global/auth/oauth/OAuth2LoginSuccessHandler.java @@ -7,14 +7,20 @@ import org.springframework.beans.factory.ObjectProvider; import org.springframework.beans.factory.annotation.Value; import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClient; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService; import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; +import org.springframework.security.oauth2.core.OAuth2RefreshToken; import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.security.web.authentication.AuthenticationSuccessHandler; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; import gg.agit.konect.domain.user.enums.Provider; +import gg.agit.konect.domain.user.model.UnRegisteredUser; import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.domain.user.repository.UnRegisteredUserRepository; +import gg.agit.konect.domain.user.repository.UserRepository; import gg.agit.konect.domain.user.service.RefreshTokenService; import gg.agit.konect.domain.user.service.SignupTokenService; import gg.agit.konect.global.auth.web.AuthCookieService; @@ -24,7 +30,9 @@ import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpSession; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +@Slf4j @Component @RequiredArgsConstructor public class OAuth2LoginSuccessHandler implements AuthenticationSuccessHandler { @@ -37,6 +45,9 @@ public class OAuth2LoginSuccessHandler implements AuthenticationSuccessHandler { private final SignupTokenService signupTokenService; private final RefreshTokenService refreshTokenService; private final AuthCookieService authCookieService; + private final OAuth2AuthorizedClientService authorizedClientService; + private final UserRepository userRepository; + private final UnRegisteredUserRepository unRegisteredUserRepository; @Override public void onAuthenticationSuccess( @@ -60,6 +71,8 @@ public void onAuthenticationSuccess( } } + String appleRefreshTokenValue = extractAppleRefreshToken(oauthToken); + user = oauthLoginHelper.findUserByProvider(provider, email, providerId); if (user.isEmpty()) { @@ -71,10 +84,12 @@ public void onAuthenticationSuccess( } } + saveAppleRefreshTokenForUnRegisteredUser(provider, providerId, email, appleRefreshTokenValue); sendAdditionalInfoRequiredResponse(request, response, email, provider, providerId); return; } + saveAppleRefreshTokenForUser(provider, user.get(), appleRefreshTokenValue); sendLoginSuccessResponse(request, response, user.get()); } @@ -158,4 +173,53 @@ private String extractProviderId(OAuth2User oauthUser) { return providerId; } + + private String extractAppleRefreshToken(OAuth2AuthenticationToken oauthToken) { + String registrationId = oauthToken.getAuthorizedClientRegistrationId(); + + if (!Provider.APPLE.name().equalsIgnoreCase(registrationId)) { + return null; + } + + OAuth2AuthorizedClient client = authorizedClientService.loadAuthorizedClient( + registrationId, oauthToken.getName() + ); + + if (client == null) { + return null; + } + + OAuth2RefreshToken refreshToken = client.getRefreshToken(); + return refreshToken != null ? refreshToken.getTokenValue() : null; + } + + private void saveAppleRefreshTokenForUser(Provider provider, User user, String appleRefreshToken) { + if (provider != Provider.APPLE || !StringUtils.hasText(appleRefreshToken)) { + return; + } + + user.updateAppleRefreshToken(appleRefreshToken); + userRepository.save(user); + } + + private void saveAppleRefreshTokenForUnRegisteredUser( + Provider provider, String providerId, String email, String appleRefreshToken + ) { + if (provider != Provider.APPLE || !StringUtils.hasText(appleRefreshToken)) { + return; + } + + Optional unRegisteredUser; + + if (StringUtils.hasText(providerId)) { + unRegisteredUser = unRegisteredUserRepository.findByProviderIdAndProvider(providerId, Provider.APPLE); + } else { + unRegisteredUser = unRegisteredUserRepository.findByEmailAndProvider(email, Provider.APPLE); + } + + unRegisteredUser.ifPresent(u -> { + u.updateAppleRefreshToken(appleRefreshToken); + unRegisteredUserRepository.save(u); + }); + } } diff --git a/src/main/java/gg/agit/konect/infrastructure/oauth/AppleTokenRevocationService.java b/src/main/java/gg/agit/konect/infrastructure/oauth/AppleTokenRevocationService.java new file mode 100644 index 000000000..5d6778815 --- /dev/null +++ b/src/main/java/gg/agit/konect/infrastructure/oauth/AppleTokenRevocationService.java @@ -0,0 +1,56 @@ +package gg.agit.konect.infrastructure.oauth; + +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.util.StringUtils; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.RestTemplate; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AppleTokenRevocationService { + + private static final String APPLE_REVOKE_URL = "https://appleid.apple.com/auth/revoke"; + + private final RestTemplate restTemplate; + private final AppleClientSecretProvider appleClientSecretProvider; + private final AppleOAuthProperties appleOAuthProperties; + + public void revoke(String appleRefreshToken) { + if (!StringUtils.hasText(appleRefreshToken)) { + log.warn("Apple refresh token이 없어 revoke를 건너뜁니다."); + return; + } + + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("client_id", appleOAuthProperties.getClientId()); + params.add("client_secret", appleClientSecretProvider.getClientSecret()); + params.add("token", appleRefreshToken); + params.add("token_type_hint", "refresh_token"); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + + HttpEntity> request = new HttpEntity<>(params, headers); + + try { + ResponseEntity response = restTemplate.postForEntity(APPLE_REVOKE_URL, request, String.class); + + if (response.getStatusCode().is2xxSuccessful()) { + log.info("Apple 토큰 revoke 완료"); + } + } catch (HttpClientErrorException e) { + log.error("Apple 토큰 revoke 실패. status={}, body={}", e.getStatusCode(), e.getResponseBodyAsString()); + throw new IllegalStateException("Apple 토큰 revoke에 실패했습니다.", e); + } + } +} diff --git a/src/main/resources/db/migration/V24__add_apple_refresh_token_columns.sql b/src/main/resources/db/migration/V24__add_apple_refresh_token_columns.sql new file mode 100644 index 000000000..d21b38972 --- /dev/null +++ b/src/main/resources/db/migration/V24__add_apple_refresh_token_columns.sql @@ -0,0 +1,2 @@ +ALTER TABLE users ADD COLUMN apple_refresh_token VARCHAR(1024) NULL; +ALTER TABLE unregistered_user ADD COLUMN apple_refresh_token VARCHAR(1024) NULL;