Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions src/main/java/org/runimo/runimo/auth/service/EncryptUtil.java
Original file line number Diff line number Diff line change
@@ -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";

Comment thread
ekgns33 marked this conversation as resolved.
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);
}
Comment thread
ekgns33 marked this conversation as resolved.

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);
}
Comment thread
ekgns33 marked this conversation as resolved.
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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(() ->
Expand All @@ -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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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);

Expand All @@ -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<String, String> 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<MultiValueMap<String, String>> entity = new HttpEntity<>(params, headers);
try {
ResponseEntity<String> 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());
Expand Down
26 changes: 26 additions & 0 deletions src/main/java/org/runimo/runimo/user/domain/AppleUserToken.java
Original file line number Diff line number Diff line change
@@ -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;
}
}
Comment thread
ekgns33 marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -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<AppleUserToken, Long> {

Optional<AppleUserToken> findByUserId(Long id);
}
28 changes: 28 additions & 0 deletions src/main/java/org/runimo/runimo/user/service/WithdrawService.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
}
}
}
5 changes: 5 additions & 0 deletions src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Comment thread
ekgns33 marked this conversation as resolved.

spring:
config:
import:
Expand Down
12 changes: 12 additions & 0 deletions src/main/resources/sql/schema.sql
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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,
Expand Down
5 changes: 5 additions & 0 deletions src/test/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Comment thread
ekgns33 marked this conversation as resolved.

spring:
config:
import:
Expand Down
13 changes: 13 additions & 0 deletions src/test/resources/sql/schema.sql
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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,
Expand Down
Loading