From 0287294ed063bdfeb6ab55ed24fb8c569fc09dc6 Mon Sep 17 00:00:00 2001 From: mashin2002 Date: Mon, 5 Aug 2024 14:50:53 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=95=A0=ED=94=8C=20=ED=83=88=ED=87=B4?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/gradle.yml | 10 ++++- build.gradle | 3 ++ .../apiPayload/code/status/ErrorStatus.java | 5 ++- .../juinjang/controller/OAuthController.java | 11 +++++ .../model/dto/auth/apple/AppleClient.java | 18 +++++--- .../apple/AppleClientSecretGenerator.java | 42 ++++++++++++++++++ .../dto/auth/apple/AppleOAuthProvider.java | 43 ++++++++++++++++++ .../auth/apple/ApplePrivateKeyGenerator.java | 44 +++++++++++++++++++ .../dto/auth/apple/AppleTokenRequest.java | 15 +++++++ .../dto/auth/apple/AppleTokenResponse.java | 23 ++++++++++ .../juinjang/service/auth/OAuthService.java | 25 +++++++++-- 11 files changed, 229 insertions(+), 10 deletions(-) create mode 100644 src/main/java/umc/th/juinjang/model/dto/auth/apple/AppleClientSecretGenerator.java create mode 100644 src/main/java/umc/th/juinjang/model/dto/auth/apple/AppleOAuthProvider.java create mode 100644 src/main/java/umc/th/juinjang/model/dto/auth/apple/ApplePrivateKeyGenerator.java create mode 100644 src/main/java/umc/th/juinjang/model/dto/auth/apple/AppleTokenRequest.java create mode 100644 src/main/java/umc/th/juinjang/model/dto/auth/apple/AppleTokenResponse.java diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index 999bead..8951ea0 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -52,7 +52,15 @@ jobs: ls * echo "${{ secrets.PROPERTIES_PROD }}" > ./application.yml shell: bash - + + ## create .p8 + - name: create .p8 + if: contains(github.ref, 'main') + run: | + echo "${{ secrets.APPLE_AUTH }}" > src/main/resources/AUTHKEY_JUINJAG.p8 + shell: bash + + - name: Build With Gradle if: contains(github.ref, 'main') run: ./gradlew build -x test diff --git a/build.gradle b/build.gradle index a4b1f60..0b59a55 100644 --- a/build.gradle +++ b/build.gradle @@ -73,6 +73,9 @@ dependencies { annotationProcessor "jakarta.annotation:jakarta.annotation-api" annotationProcessor "jakarta.persistence:jakarta.persistence-api" implementation 'com.github.gavlyukovskiy:p6spy-spring-boot-starter:1.9.0' // 쿼리 파라미터 로그 남기기 + + implementation 'commons-codec:commons-codec:1.16.0' // Base64 인코딩을 위한 의존성 + } tasks.named('test') { diff --git a/src/main/java/umc/th/juinjang/apiPayload/code/status/ErrorStatus.java b/src/main/java/umc/th/juinjang/apiPayload/code/status/ErrorStatus.java index b701252..5165c3c 100644 --- a/src/main/java/umc/th/juinjang/apiPayload/code/status/ErrorStatus.java +++ b/src/main/java/umc/th/juinjang/apiPayload/code/status/ErrorStatus.java @@ -25,7 +25,9 @@ public enum ErrorStatus implements BaseErrorCode { UNCORRECTED_INFO(HttpStatus.BAD_REQUEST, "MEMBER4005", "올바르지 않은 정보입니다."), MEMBER_NOT_FOUND_IN_APPLE(HttpStatus.BAD_REQUEST, "MEMBER4006", "KAKAO로 회원가입한 회원입니다."), ALREADY_MEMBER(HttpStatus.BAD_REQUEST, "MEMBER4007", "이미 가입된 회원입니다."), - + //로그아웃 에러 + FAILED_TO_LOAD_PRIVATE_KEY(HttpStatus.BAD_REQUEST, "REVOKE4002", "private key 실패"), + LOGOUT_FAILED(HttpStatus.BAD_REQUEST, "REVOKE4002", "private key 실패"), // nickname 에러 NICKNAME_EMPTY(HttpStatus.BAD_REQUEST, "NICKNAME4001", "닉네임이 존재하지 않습니다. 닉네임을 입력해주세요."), ALREADY_NICKNAME(HttpStatus.BAD_REQUEST, "NICKNAME4002", "이미 존재하는 닉네임입니다."), @@ -85,6 +87,7 @@ public enum ErrorStatus implements BaseErrorCode { //record 에러 RECORD_NOT_FOUND(HttpStatus.NOT_FOUND, "RECORD400", "record가 존재하지 않습니다"); + private final HttpStatus httpStatus; private final String code; private final String message; diff --git a/src/main/java/umc/th/juinjang/controller/OAuthController.java b/src/main/java/umc/th/juinjang/controller/OAuthController.java index 3e151fe..ea93a42 100644 --- a/src/main/java/umc/th/juinjang/controller/OAuthController.java +++ b/src/main/java/umc/th/juinjang/controller/OAuthController.java @@ -1,8 +1,10 @@ package umc.th.juinjang.controller; import com.fasterxml.jackson.core.JsonProcessingException; +import io.micrometer.common.lang.Nullable; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.util.StringUtils; import org.springframework.validation.BindingResult; import org.springframework.validation.ObjectError; @@ -16,6 +18,7 @@ import umc.th.juinjang.model.dto.auth.apple.AppleSignUpRequestDto; import umc.th.juinjang.model.dto.auth.kakao.KakaoLoginRequestDto; import umc.th.juinjang.model.dto.auth.kakao.KakaoSignUpRequestDto; +import umc.th.juinjang.model.entity.Member; import umc.th.juinjang.service.JwtService; import umc.th.juinjang.service.auth.OAuthService; @@ -93,4 +96,12 @@ public ApiResponse appleSignUp(@RequestBody @Validated AppleSi throw new ExceptionHandler(APPLE_ID_TOKEN_EMPTY); return ApiResponse.onSuccess(oauthService.appleSignUp(appleSignUpReqDto)); } + + @DeleteMapping("/auth/withdraw") + public ApiResponse withdraw(@AuthenticationPrincipal Member member, + @Nullable@RequestHeader("X-Apple-Code") final String code){ + oauthService.withdraw(member, code); + return ApiResponse.onSuccess(null); + } + } \ No newline at end of file diff --git a/src/main/java/umc/th/juinjang/model/dto/auth/apple/AppleClient.java b/src/main/java/umc/th/juinjang/model/dto/auth/apple/AppleClient.java index f8ff4c1..33cb361 100644 --- a/src/main/java/umc/th/juinjang/model/dto/auth/apple/AppleClient.java +++ b/src/main/java/umc/th/juinjang/model/dto/auth/apple/AppleClient.java @@ -1,12 +1,11 @@ package umc.th.juinjang.model.dto.auth.apple; -import org.springframework.cloud.openfeign.EnableFeignClients; import org.springframework.cloud.openfeign.FeignClient; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.web.bind.annotation.GetMapping; -import umc.th.juinjang.JuinjangApplication; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.*; + +import static org.springframework.http.MediaType.APPLICATION_FORM_URLENCODED_VALUE; @FeignClient(name = "appleClient", url = "https://appleid.apple.com/auth") // configuration 속성 제거함 @@ -14,5 +13,14 @@ public interface AppleClient { @GetMapping(value = "/keys") ApplePublicKeyResponse getAppleAuthPublicKey(); + @PostMapping(value = "/token", consumes = APPLICATION_FORM_URLENCODED_VALUE) + AppleTokenResponse getAppleTokens(@RequestBody AppleTokenRequest request); + + + @PostMapping(value = "/revoke", consumes = APPLICATION_FORM_URLENCODED_VALUE) + void revoke(@RequestPart(value = "token") String token, + @RequestPart(value = "client_id") String client_id, + @RequestPart(value = "client_secret") String client_secret, + @RequestPart(value = "token_type_hint") String token_type_hint); } diff --git a/src/main/java/umc/th/juinjang/model/dto/auth/apple/AppleClientSecretGenerator.java b/src/main/java/umc/th/juinjang/model/dto/auth/apple/AppleClientSecretGenerator.java new file mode 100644 index 0000000..a41ed7d --- /dev/null +++ b/src/main/java/umc/th/juinjang/model/dto/auth/apple/AppleClientSecretGenerator.java @@ -0,0 +1,42 @@ +package umc.th.juinjang.model.dto.auth.apple; + +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.Date; + +@Component +@RequiredArgsConstructor +public class AppleClientSecretGenerator { + + private static final String AUDIENCE = "https://appleid.apple.com"; + private final ApplePrivateKeyGenerator applePrivateKeyGenerator; + + @Value("${apple.key.id}") + private String keyId; + @Value("${apple.team-id}") + private String teamId; + @Value("${apple.aud}") + private String clientId; + + public String generateClientSecret() throws IOException { + Date expirationDate = Date.from(LocalDateTime.now().plusDays(5) + .atZone(ZoneId.systemDefault()).toInstant()); + return Jwts.builder() + .setHeaderParam("alg", SignatureAlgorithm.ES256) + .setHeaderParam("kid", keyId) + .setIssuer(teamId) + .setIssuedAt(new Date(System.currentTimeMillis())) + .setExpiration(expirationDate) + .setAudience(AUDIENCE) + .setSubject(clientId) + .signWith(applePrivateKeyGenerator.getPrivateKey(), SignatureAlgorithm.ES256) + .compact(); + } +} diff --git a/src/main/java/umc/th/juinjang/model/dto/auth/apple/AppleOAuthProvider.java b/src/main/java/umc/th/juinjang/model/dto/auth/apple/AppleOAuthProvider.java new file mode 100644 index 0000000..f5a132d --- /dev/null +++ b/src/main/java/umc/th/juinjang/model/dto/auth/apple/AppleOAuthProvider.java @@ -0,0 +1,43 @@ +package umc.th.juinjang.model.dto.auth.apple; + + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import umc.th.juinjang.apiPayload.exception.handler.MemberHandler; + +import static umc.th.juinjang.apiPayload.code.status.ErrorStatus.FAILED_TO_LOAD_PRIVATE_KEY; + +@Component +@RequiredArgsConstructor +@Slf4j +public class AppleOAuthProvider { + + @Value("${apple.aud}") + private String clientId; + private final String GRANTTYPE = "authorization_code"; + + private final AppleClient appleClient; + + public String getAppleRefreshToken(final String code, final String clientSecret) { + try { + AppleTokenRequest appleTokenRequest = AppleTokenRequest.builder() + .client_id(clientId) + .client_secret(clientSecret) + .grant_type(GRANTTYPE) + .code(code).build(); + AppleTokenResponse appleTokenResponse = appleClient.getAppleTokens(appleTokenRequest); + log.info("Apple token response: {}", appleTokenResponse); + return appleTokenResponse.refreshToken(); + } catch (Exception e) { + log.error("Failed to get apple refresh token."); + throw new MemberHandler(FAILED_TO_LOAD_PRIVATE_KEY); + } + } + + public void requestRevoke(final String refreshToken, final String clientSecret) { + appleClient.revoke(clientSecret,refreshToken,clientId, "refresh_token"); + log.error("Failed to revoke apple refresh token."); + } +} diff --git a/src/main/java/umc/th/juinjang/model/dto/auth/apple/ApplePrivateKeyGenerator.java b/src/main/java/umc/th/juinjang/model/dto/auth/apple/ApplePrivateKeyGenerator.java new file mode 100644 index 0000000..77d921c --- /dev/null +++ b/src/main/java/umc/th/juinjang/model/dto/auth/apple/ApplePrivateKeyGenerator.java @@ -0,0 +1,44 @@ +package umc.th.juinjang.model.dto.auth.apple; + +import org.apache.commons.codec.binary.Base64; +import org.springframework.core.io.ClassPathResource; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.security.KeyFactory; +import java.security.PrivateKey; +import java.security.spec.PKCS8EncodedKeySpec; + +@Component +public class ApplePrivateKeyGenerator { + + public PrivateKey getPrivateKey() throws IOException { + // .p8 파일의 경로를 가져옴. + ClassPathResource resource = new ClassPathResource("AUTHKEY_JUINJAG.p8"); + + // 파일의 내용을 String으로 읽어옴. + String privateKeyContent = new String(Files.readAllBytes(Paths.get(resource.getURI()))); + + // PEM 파일의 헤더와 푸터를 제거하고 Base64 인코딩된 문자열을 추출. + String privateKeyPEM = privateKeyContent + .replace("-----BEGIN PRIVATE KEY-----", "") + .replace("-----END PRIVATE KEY-----", "") + .replaceAll("\\s+", ""); + + // Base64로 인코딩된 문자열을 디코딩. + byte[] encoded = Base64.decodeBase64(privateKeyPEM); + + // PKCS8EncodedKeySpec을 생성. + PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(encoded); + + try { + // KeyFactory를 사용하여 PrivateKey 객체를 생성 + KeyFactory keyFactory = KeyFactory.getInstance("EC"); // 키 타입에 따라 "RSA" 또는 "EC"를 사용. + return keyFactory.generatePrivate(keySpec); + } catch (Exception e) { + throw new IOException("Failed to convert private key.", e); + } + } +} diff --git a/src/main/java/umc/th/juinjang/model/dto/auth/apple/AppleTokenRequest.java b/src/main/java/umc/th/juinjang/model/dto/auth/apple/AppleTokenRequest.java new file mode 100644 index 0000000..a214dc5 --- /dev/null +++ b/src/main/java/umc/th/juinjang/model/dto/auth/apple/AppleTokenRequest.java @@ -0,0 +1,15 @@ +package umc.th.juinjang.model.dto.auth.apple; + +import lombok.Builder; +import lombok.Getter; +import lombok.ToString; + +@Getter +@Builder +@ToString +public class AppleTokenRequest { + private String client_id; + private String client_secret; + private String code; //authorization code + private String grant_type; +} diff --git a/src/main/java/umc/th/juinjang/model/dto/auth/apple/AppleTokenResponse.java b/src/main/java/umc/th/juinjang/model/dto/auth/apple/AppleTokenResponse.java new file mode 100644 index 0000000..5a1d73f --- /dev/null +++ b/src/main/java/umc/th/juinjang/model/dto/auth/apple/AppleTokenResponse.java @@ -0,0 +1,23 @@ +package umc.th.juinjang.model.dto.auth.apple; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record AppleTokenResponse( @JsonProperty(value = "access_token") + @Schema(description = "애플 access_token") String accessToken, + + @JsonProperty(value = "expires_in") + @Schema(description = "애플 토큰 만료 기한 expires_in") String expiresIn, + + @JsonProperty(value = "id_token") + @Schema(description = "애플 id_token") String idToken, + + @JsonProperty(value = "refresh_token") + @Schema(description = "애플 token_tyoe") String refreshToken, + + @Schema(description = "error") String error) { + + +} diff --git a/src/main/java/umc/th/juinjang/service/auth/OAuthService.java b/src/main/java/umc/th/juinjang/service/auth/OAuthService.java index 9e1dce0..cda665d 100644 --- a/src/main/java/umc/th/juinjang/service/auth/OAuthService.java +++ b/src/main/java/umc/th/juinjang/service/auth/OAuthService.java @@ -10,9 +10,7 @@ import umc.th.juinjang.apiPayload.exception.handler.MemberHandler; import umc.th.juinjang.model.dto.auth.LoginResponseDto; import umc.th.juinjang.model.dto.auth.TokenDto; -import umc.th.juinjang.model.dto.auth.apple.AppleInfo; -import umc.th.juinjang.model.dto.auth.apple.AppleLoginRequestDto; -import umc.th.juinjang.model.dto.auth.apple.AppleSignUpRequestDto; +import umc.th.juinjang.model.dto.auth.apple.*; import umc.th.juinjang.model.dto.auth.kakao.KakaoLoginRequestDto; import umc.th.juinjang.model.dto.auth.kakao.KakaoSignUpRequestDto; import umc.th.juinjang.model.entity.Member; @@ -21,6 +19,7 @@ import umc.th.juinjang.service.JwtService; import javax.naming.AuthenticationException; +import java.io.IOException; import java.security.NoSuchAlgorithmException; import java.security.spec.InvalidKeySpecException; import java.time.LocalDateTime; @@ -35,6 +34,8 @@ public class OAuthService { private final MemberRepository memberRepository; private final JwtService jwtService; + private final AppleClientSecretGenerator appleClientSecretGenerator; + private final AppleOAuthProvider appleOAuthProvider; // 카카오 로그인 (회원가입된 경우) // 프론트에서 받은 사용자 정보로 accessToken, refreshToken 발급 @@ -251,4 +252,22 @@ public LoginResponseDto appleSignUp(AppleSignUpRequestDto appleSignUpRequestDto) return createToken(member); } + public void withdraw(Member member, String code) { + + if(member.getProvider() != MemberProvider.APPLE){ + throw new MemberHandler(MEMBER_NOT_FOUND_IN_APPLE); + } + try { + String clientSecret = appleClientSecretGenerator.generateClientSecret(); + String refreshToken = appleOAuthProvider.getAppleRefreshToken(code, clientSecret); + appleOAuthProvider.requestRevoke(refreshToken, clientSecret); + } catch (Exception e) { + throw new MemberHandler(FAILED_TO_LOAD_PRIVATE_KEY); + } + log.info("애플 탈퇴 성공"); + //디이베서 지우기 +// memberRepository.delete(member); + + //soft인지 hard인지 추후 논의 예정 + } }