diff --git a/src/main/java/org/runimo/runimo/auth/service/EncryptUtil.java b/src/main/java/org/runimo/runimo/auth/service/EncryptUtil.java new file mode 100644 index 00000000..e9d0f7d4 --- /dev/null +++ b/src/main/java/org/runimo/runimo/auth/service/EncryptUtil.java @@ -0,0 +1,44 @@ +package org.runimo.runimo.auth.service; + +import java.util.Base64; +import javax.crypto.Cipher; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class EncryptUtil { + + @Value("${runimo.security.secret-key}") + private String secretKey; + @Value("${runimo.security.iv}") + private String iv; + private static final String CIPHER_TRANS = "AES/CBC/PKCS5Padding"; + private static final String ALGORITHM = "AES"; + + public String encrypt(String plainText) throws Exception { + Cipher cipher = Cipher.getInstance(CIPHER_TRANS); + SecretKeySpec keySpec = new SecretKeySpec(secretKey.getBytes(), ALGORITHM); + IvParameterSpec ivSpec = new IvParameterSpec(iv.getBytes()); + + cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec); + byte[] encrypted = cipher.doFinal(plainText.getBytes()); + + return Base64.getEncoder().encodeToString(encrypted); + } + + public String decrypt(String cipherText) throws Exception { + Cipher cipher = Cipher.getInstance(CIPHER_TRANS); + SecretKeySpec keySpec = new SecretKeySpec(secretKey.getBytes(), ALGORITHM); + IvParameterSpec ivSpec = new IvParameterSpec(iv.getBytes()); + + cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec); + byte[] decodedBytes = Base64.getDecoder().decode(cipherText); + byte[] decrypted = cipher.doFinal(decodedBytes); + + return new String(decrypted); + } +} diff --git a/src/main/java/org/runimo/runimo/auth/service/apple/AppleLoginHandler.java b/src/main/java/org/runimo/runimo/auth/service/apple/AppleLoginHandler.java index d7f93784..791ff2c4 100644 --- a/src/main/java/org/runimo/runimo/auth/service/apple/AppleLoginHandler.java +++ b/src/main/java/org/runimo/runimo/auth/service/apple/AppleLoginHandler.java @@ -7,12 +7,15 @@ import org.runimo.runimo.auth.exceptions.UnRegisteredUserException; import org.runimo.runimo.auth.exceptions.UserJwtException; import org.runimo.runimo.auth.jwt.JwtTokenFactory; +import org.runimo.runimo.auth.service.EncryptUtil; import org.runimo.runimo.auth.service.dtos.AuthResponse; import org.runimo.runimo.auth.service.dtos.TokenPair; import org.runimo.runimo.auth.service.kakao.AppleUserInfo; +import org.runimo.runimo.user.domain.AppleUserToken; import org.runimo.runimo.user.domain.OAuthInfo; import org.runimo.runimo.user.domain.SocialProvider; import org.runimo.runimo.user.enums.UserHttpResponseCode; +import org.runimo.runimo.user.repository.AppleUserTokenRepository; import org.runimo.runimo.user.repository.OAuthInfoRepository; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; @@ -24,18 +27,28 @@ public class AppleLoginHandler { private final AppleTokenVerifier appleTokenVerifier; private final JwtTokenFactory jwtTokenFactory; private final OAuthInfoRepository oAuthInfoRepository; + private final AppleUserTokenRepository appleUserTokenRepository; + private final EncryptUtil encryptUtil; - @Transactional(readOnly = true) + @Transactional public AuthResponse validateAndLogin(final String authCode, final String verifier) { - String rawToken = appleTokenVerifier.getAccessTokenFromAuthCode(authCode, verifier); + TokenPair appleToken = appleTokenVerifier.getAccessTokenFromAuthCode(authCode, verifier); DecodedJWT decodedJWT; try { - decodedJWT = JWT.decode(rawToken); + decodedJWT = JWT.decode(appleToken.accessToken()); } catch (JWTDecodeException e) { throw UserJwtException.of(UserHttpResponseCode.LOGIN_FAIL_INVALID); } AppleUserInfo userInfo = appleTokenVerifier.verifyToken(decodedJWT); - OAuthInfo savedUser = oAuthInfoRepository.findByProviderAndProviderId( + OAuthInfo savedUser = getOrThrowWithRegisterToken(userInfo); + saveAppleRefreshToken(savedUser, appleToken); + TokenPair tokenPair = jwtTokenFactory.generateTokenPair(savedUser.getUser()); + return new AuthResponse(savedUser.getUser(), tokenPair); + } + + // 사용자 정보가 있으면 반환하고 없다면, 회원가입을 위한 토큰을 생성하여 예외를 던진다. + private OAuthInfo getOrThrowWithRegisterToken(AppleUserInfo userInfo) { + return oAuthInfoRepository.findByProviderAndProviderId( SocialProvider.APPLE, userInfo.getProviderId()) .orElseThrow(() -> @@ -44,7 +57,23 @@ public AuthResponse validateAndLogin(final String authCode, final String verifie jwtTokenFactory.generateRegisterTemporalToken(userInfo.getProviderId(), SocialProvider.APPLE)) ); - TokenPair tokenPair = jwtTokenFactory.generateTokenPair(savedUser.getUser()); - return new AuthResponse(savedUser.getUser(), tokenPair); + } + + // 애플에서 발급한 refresh token을 DB에 저장한다. + private void saveAppleRefreshToken(OAuthInfo savedUser, TokenPair appleToken) { + AppleUserToken appleUserToken = appleUserTokenRepository + .findByUserId(savedUser.getUser().getId()) + .orElse(createEncryptedRefreshToken(savedUser.getUser().getId(), appleToken.refreshToken())); + appleUserTokenRepository.save(appleUserToken); + } + + private AppleUserToken createEncryptedRefreshToken(Long userId, String refreshToken) { + try { + return new AppleUserToken( + userId, + encryptUtil.encrypt(refreshToken)); + } catch (Exception e) { + throw new RuntimeException("Failed to encrypt refresh token", e); + } } } diff --git a/src/main/java/org/runimo/runimo/auth/service/apple/AppleTokenVerifier.java b/src/main/java/org/runimo/runimo/auth/service/apple/AppleTokenVerifier.java index c224262e..369cd2b3 100644 --- a/src/main/java/org/runimo/runimo/auth/service/apple/AppleTokenVerifier.java +++ b/src/main/java/org/runimo/runimo/auth/service/apple/AppleTokenVerifier.java @@ -22,6 +22,7 @@ import lombok.extern.slf4j.Slf4j; import org.runimo.runimo.auth.exceptions.UserJwtException; import org.runimo.runimo.auth.jwt.JwtTokenFactory; +import org.runimo.runimo.auth.service.dtos.TokenPair; import org.runimo.runimo.auth.service.kakao.AppleUserInfo; import org.runimo.runimo.user.enums.UserHttpResponseCode; import org.springframework.beans.factory.annotation.Value; @@ -60,6 +61,8 @@ public class AppleTokenVerifier { @Value("${apple.client-secret}") private String applePrivateKey; + private static final String REVOKE_URL = "https://appleid.apple.com/auth/revoke"; + @Scheduled(fixedRate = 3600000) public void refreshPublicKeys() { String jwksUrl = "https://appleid.apple.com/auth/keys"; @@ -92,7 +95,7 @@ private RSAPublicKey createPublicKey(String modulusBase64, String exponentBase64 return (RSAPublicKey) factory.generatePublic(spec); } - public String getAccessTokenFromAuthCode(String authCode, String codeVerifier) { + public TokenPair getAccessTokenFromAuthCode(String authCode, String codeVerifier) { HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); @@ -111,13 +114,38 @@ public String getAccessTokenFromAuthCode(String authCode, String codeVerifier) { try { JsonNode node = objectMapper.readTree(response.getBody()); - return node.get("id_token").asText(); + return new TokenPair(node.get("id_token").asText(), node.get("refresh_token").asText()); } catch (Exception e) { log.error("Failed to verify Apple access token", e); throw UserJwtException.of(UserHttpResponseCode.TOKEN_INVALID); } } + public void revoke(final String appleRefreshToken) { + String clientSecret = generateAppleClientSecret(); // JWT 생성 + + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("client_id", clientId); + params.add("client_secret", clientSecret); + params.add("token", appleRefreshToken); + params.add("token_type_hint", "refresh_token"); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + + HttpEntity> entity = new HttpEntity<>(params, headers); + try { + ResponseEntity response = restTemplate.postForEntity(REVOKE_URL, entity, + String.class); + if (!response.getStatusCode().is2xxSuccessful()) { + throw new RuntimeException("Failed to revoke Apple refresh token"); + } + } catch (Exception e) { + log.error("Failed to revoke Apple refresh token", e); + throw new RuntimeException("Failed to revoke Apple refresh token", e); + } + } + public AppleUserInfo verifyToken(DecodedJWT token) { try { RSAPublicKey publicKey = publicKeys.get(token.getKeyId()); diff --git a/src/main/java/org/runimo/runimo/user/domain/AppleUserToken.java b/src/main/java/org/runimo/runimo/user/domain/AppleUserToken.java new file mode 100644 index 00000000..08e60b9d --- /dev/null +++ b/src/main/java/org/runimo/runimo/user/domain/AppleUserToken.java @@ -0,0 +1,26 @@ +package org.runimo.runimo.user.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.runimo.runimo.common.BaseEntity; + +@Table(name = "apple_user_token") +@Getter +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class AppleUserToken extends BaseEntity { + + @Column(name = "user_id") + private Long userId; + @Column(name = "refresh_token") + private String refreshToken; + + public AppleUserToken(Long userId, String refreshToken) { + this.userId = userId; + this.refreshToken = refreshToken; + } +} diff --git a/src/main/java/org/runimo/runimo/user/repository/AppleUserTokenRepository.java b/src/main/java/org/runimo/runimo/user/repository/AppleUserTokenRepository.java new file mode 100644 index 00000000..c9490dbb --- /dev/null +++ b/src/main/java/org/runimo/runimo/user/repository/AppleUserTokenRepository.java @@ -0,0 +1,10 @@ +package org.runimo.runimo.user.repository; + +import java.util.Optional; +import org.runimo.runimo.user.domain.AppleUserToken; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface AppleUserTokenRepository extends JpaRepository { + + Optional findByUserId(Long id); +} \ No newline at end of file diff --git a/src/main/java/org/runimo/runimo/user/service/WithdrawService.java b/src/main/java/org/runimo/runimo/user/service/WithdrawService.java index 61e935a4..4104afca 100644 --- a/src/main/java/org/runimo/runimo/user/service/WithdrawService.java +++ b/src/main/java/org/runimo/runimo/user/service/WithdrawService.java @@ -2,8 +2,13 @@ import java.util.NoSuchElementException; import lombok.RequiredArgsConstructor; +import org.runimo.runimo.auth.service.EncryptUtil; +import org.runimo.runimo.auth.service.apple.AppleTokenVerifier; +import org.runimo.runimo.user.domain.AppleUserToken; import org.runimo.runimo.user.domain.OAuthInfo; +import org.runimo.runimo.user.domain.SocialProvider; import org.runimo.runimo.user.domain.User; +import org.runimo.runimo.user.repository.AppleUserTokenRepository; import org.runimo.runimo.user.repository.OAuthInfoRepository; import org.runimo.runimo.user.repository.UserRepository; import org.springframework.stereotype.Service; @@ -15,13 +20,36 @@ public class WithdrawService { private final OAuthInfoRepository oAuthInfoRepository; private final UserRepository userRepository; + private final AppleTokenVerifier appleTokenVerifier; + private final AppleUserTokenRepository appleUserTokenRepository; + private final EncryptUtil encryptUtil; @Transactional public void withdraw(Long userId) { OAuthInfo oAuthInfo = oAuthInfoRepository.findByUserId(userId) .orElseThrow(NoSuchElementException::new); User user = oAuthInfo.getUser(); + if (oAuthInfo.getProvider() == SocialProvider.APPLE) { + withdrawAppleUser(user); + } oAuthInfoRepository.delete(oAuthInfo); userRepository.delete(user); } + + private void withdrawAppleUser(User user) { + AppleUserToken appleUserToken = appleUserTokenRepository + .findByUserId(user.getId()) + .orElseThrow(NoSuchElementException::new); + String decodedRefreshToken = getDecryptedToken(appleUserToken.getRefreshToken()); + appleTokenVerifier.revoke(decodedRefreshToken); + appleUserTokenRepository.delete(appleUserToken); + } + + private String getDecryptedToken(String encryptedRefreshToken) { + try { + return encryptUtil.decrypt(encryptedRefreshToken); + } catch (Exception e) { + throw new RuntimeException("Failed to decrypt ID token", e); + } + } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index fce2e02c..3a1e2357 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -6,6 +6,11 @@ server: accept-count: 100 max-connections: 8912 max-keep-alive-requests: 200 +runimo: + security: + secret-key: ${AES_KEY} + iv: ${AES_IV} + spring: config: import: diff --git a/src/main/resources/sql/schema.sql b/src/main/resources/sql/schema.sql index 97f88ebb..ca5be756 100644 --- a/src/main/resources/sql/schema.sql +++ b/src/main/resources/sql/schema.sql @@ -1,5 +1,6 @@ SET FOREIGN_KEY_CHECKS = 0; +DROP TABLE IF EXISTS apple_user_token; DROP TABLE IF EXISTS user_token; DROP TABLE IF EXISTS oauth_account; DROP TABLE IF EXISTS running_record; @@ -41,6 +42,17 @@ CREATE TABLE `user_token` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE ); +CREATE TABLE `apple_user_token` +( + `id` BIGINT PRIMARY KEY AUTO_INCREMENT, + `user_id` BIGINT NOT NULL, + `refresh_token` VARCHAR(255) NOT NULL, + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `deleted_at` TIMESTAMP NULL, + FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE +); + CREATE TABLE `user_love_point` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index 3b10d0a3..a45ad1bf 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -6,6 +6,11 @@ server: accept-count: 100 max-connections: 8912 max-keep-alive-requests: 200 +runimo: + security: + secret-key: ${AES_KEY} + iv: ${AES_IV} + spring: config: import: diff --git a/src/test/resources/sql/schema.sql b/src/test/resources/sql/schema.sql index f0d40792..6f30fc6d 100644 --- a/src/test/resources/sql/schema.sql +++ b/src/test/resources/sql/schema.sql @@ -1,5 +1,6 @@ SET FOREIGN_KEY_CHECKS = 0; +DROP TABLE IF EXISTS apple_user_token; DROP TABLE IF EXISTS user_token; DROP TABLE IF EXISTS oauth_account; DROP TABLE IF EXISTS running_record; @@ -64,6 +65,18 @@ CREATE TABLE `oauth_account` ); +CREATE TABLE `apple_user_token` +( + `id` BIGINT PRIMARY KEY AUTO_INCREMENT, + `user_id` BIGINT NOT NULL, + `refresh_token` VARCHAR(255) NOT NULL, + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `deleted_at` TIMESTAMP NULL, + FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE +); + + CREATE TABLE `running_record` ( `id` INTEGER PRIMARY KEY AUTO_INCREMENT,