Skip to content

Commit

Permalink
feat: 애플 탈퇴 구현
Browse files Browse the repository at this point in the history
  • Loading branch information
2hy2on committed Aug 5, 2024
1 parent b218988 commit 0287294
Show file tree
Hide file tree
Showing 11 changed files with 229 additions and 10 deletions.
10 changes: 9 additions & 1 deletion .github/workflows/gradle.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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", "이미 존재하는 닉네임입니다."),
Expand Down Expand Up @@ -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;
Expand Down
11 changes: 11 additions & 0 deletions src/main/java/umc/th/juinjang/controller/OAuthController.java
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;

Expand Down Expand Up @@ -93,4 +96,12 @@ public ApiResponse<LoginResponseDto> appleSignUp(@RequestBody @Validated AppleSi
throw new ExceptionHandler(APPLE_ID_TOKEN_EMPTY);
return ApiResponse.onSuccess(oauthService.appleSignUp(appleSignUpReqDto));
}

@DeleteMapping("/auth/withdraw")
public ApiResponse<Void> withdraw(@AuthenticationPrincipal Member member,
@Nullable@RequestHeader("X-Apple-Code") final String code){
oauthService.withdraw(member, code);
return ApiResponse.onSuccess(null);
}

}
Original file line number Diff line number Diff line change
@@ -1,18 +1,26 @@
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 속성 제거함

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);

}
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
@@ -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.");
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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) {


}
25 changes: 22 additions & 3 deletions src/main/java/umc/th/juinjang/service/auth/OAuthService.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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 발급
Expand Down Expand Up @@ -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인지 추후 논의 예정
}
}

0 comments on commit 0287294

Please sign in to comment.