Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat: 회원탈퇴 구현 #55

Merged
merged 32 commits into from
Aug 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
2048a63
Fix: ddl-auto validate 속성으로 변경
Jaewon-pro Aug 5, 2024
caaf16e
Fix: flyway 설정 application.yml에 추가
Jaewon-pro Aug 5, 2024
78df8fb
Fix: running record 테이블 pace 컬럼 타입을 정수형으로 변경 (#46)
Jaewon-pro Aug 6, 2024
bd99a18
Merge remote-tracking branch 'origin/feature/#40/account-delete' into…
hee9841 Aug 6, 2024
993a9d6
Chore : pem key 파싱을 위한 bouncycastle 라이브러리 추가
hee9841 Aug 7, 2024
9c517d1
Chore : pem key 파싱을 위한 bouncycastle 라이브러리 추가
hee9841 Aug 7, 2024
78b239b
Merge remote-tracking branch 'origin/feature/#40/account-delete' into…
hee9841 Aug 7, 2024
7ea6084
Feat: BadgeAchievement Member로 삭제 기능
hee9841 Aug 7, 2024
ce2d685
Test: BadgeAchievement Member로 삭제 기능 테스트
hee9841 Aug 7, 2024
2964439
Feat: SocialProfileRepository Member로 삭제 기능 추가
hee9841 Aug 7, 2024
c4e6829
Test: SocialProfileRepository 테스트
hee9841 Aug 7, 2024
9430259
Fix: badgeAchievementRepository의 deleteByMember의 인자를 member_id로 받게 수정
hee9841 Aug 7, 2024
e5a7205
Fix: SocialProfileRepository의 deleteByMember의 인자를 member_id로 받게 수정
hee9841 Aug 7, 2024
cd5bc87
Feat: memberId에 의해 MemberLevel 삭제 구현
hee9841 Aug 7, 2024
6a4cc3f
Test: memberId에 의해 MemberLevel 삭제 테스트
hee9841 Aug 7, 2024
21e37cd
Feat: memberId에 의해 Member 삭제
hee9841 Aug 7, 2024
79e40d0
Test: memberId로 Member 삭제 테스트
hee9841 Aug 7, 2024
e008ccf
Chore: 애플 관련 환경 변수 추가
hee9841 Aug 7, 2024
6bd64b5
Feat: 애플 토큰 발급, 토큰 해제 로직 구현
hee9841 Aug 7, 2024
256ba9e
Feat: 러닝 기록 멤버id로 삭제 구현 (#51)
hee9841 Aug 7, 2024
097036f
Fix: Repository Transactional 삭제
hee9841 Aug 7, 2024
b7669a7
Feat: 회원탈퇴 구현
hee9841 Aug 7, 2024
0b6ec3a
Fix: 시크릿 파일 위치 변경으로 인한 수정
hee9841 Aug 7, 2024
e410cdc
Fix: member_level table member_id fk 오류 수정 (#54)
Jaewon-pro Aug 7, 2024
4f308c7
Fix: 테스트 환경변수 파일 가짜 정보 입력
hee9841 Aug 7, 2024
31ea1a4
Fix: pr 리뷰 사항 반영
hee9841 Aug 7, 2024
62868c0
Refactor: OidcProviderFactory와 OidcProvider 역활 분리
hee9841 Aug 7, 2024
250cd49
Rename: 패키지 구조 변경
hee9841 Aug 7, 2024
98d303b
Merge branch 'main' into feature/#40/account-delete
hee9841 Aug 7, 2024
3d85eb9
Chore: p8 파일 gitIgnore 추가
hee9841 Aug 8, 2024
476ad53
Fix: PR 리뷰 사항 반영
hee9841 Aug 8, 2024
b31090f
Fix: AppleAuthTokenResponse 코드 카멜로 변경으로 인핸 오류 수정
hee9841 Aug 8, 2024
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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,5 @@ out/
**/resources/.env
**/resources/.env.*
!**/resources/.env.example

**/resources/*.p8
2 changes: 2 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ dependencies {
implementation(libs.jetbrains.annotations)
implementation(libs.springdoc)

implementation(libs.bcpkix)

// JWT
implementation(libs.jjwt.api)
runtimeOnly(libs.jjwt.impl)
Expand Down
2 changes: 2 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ spring-boot-devtools = { group = "org.springframework.boot", name = "spring-boot
spring-boot-starter-test = { group = "org.springframework.boot", name = "spring-boot-starter-test", version.ref = "spring-boot" }
spring-boot-testcontainers = { group = "org.springframework.boot", name = "spring-boot-testcontainers", version.ref = "spring-boot" }

bcpkix = {group= "org.bouncycastle", name = "bcpkix-jdk18on", version = "1.78.1" }

lombok = { group = "org.projectlombok", name = "lombok", version = "1.18.34" }
dotenv = { group = "me.paulschwarz", name = "spring-dotenv", version = "4.0.0" }
jetbrains-annotations = { group = "org.jetbrains", name = "annotations", version = "24.1.0" }
Expand Down
Original file line number Diff line number Diff line change
@@ -1,18 +1,24 @@
package com.dnd.runus.domain.oauth.service;
package com.dnd.runus.application.oauth;

import com.dnd.runus.auth.exception.AuthException;
import com.dnd.runus.auth.oidc.provider.OidcProvider;
import com.dnd.runus.auth.oidc.provider.OidcProviderFactory;
import com.dnd.runus.auth.token.TokenProviderModule;
import com.dnd.runus.auth.token.dto.AuthTokenDto;
import com.dnd.runus.domain.badge.BadgeAchievementRepository;
import com.dnd.runus.domain.member.Member;
import com.dnd.runus.domain.member.MemberLevelRepository;
import com.dnd.runus.domain.member.MemberRepository;
import com.dnd.runus.domain.member.SocialProfile;
import com.dnd.runus.domain.member.SocialProfileRepository;
import com.dnd.runus.domain.oauth.dto.request.OauthRequest;
import com.dnd.runus.domain.oauth.dto.response.TokenResponse;
import com.dnd.runus.domain.running.RunningRecordRepository;
import com.dnd.runus.global.constant.MemberRole;
import com.dnd.runus.global.constant.SocialType;
import com.dnd.runus.global.exception.NotFoundException;
import com.dnd.runus.global.exception.type.ErrorType;
import com.dnd.runus.presentation.v1.oauth.dto.request.OauthRequest;
import com.dnd.runus.presentation.v1.oauth.dto.request.WithdrawRequest;
import com.dnd.runus.presentation.v1.oauth.dto.response.TokenResponse;
import io.jsonwebtoken.Claims;
import io.micrometer.common.util.StringUtils;
import lombok.RequiredArgsConstructor;
Expand All @@ -30,6 +36,9 @@ public class OauthService {

private final MemberRepository memberRepository;
private final SocialProfileRepository socialProfileRepository;
private final BadgeAchievementRepository badgeAchievementRepository;
private final RunningRecordRepository runningRecordRepository;
private final MemberLevelRepository memberLevelRepository;

/**
* 회원가입 유뮤 확인 후 회원가입/로그인 진행
Expand All @@ -39,8 +48,9 @@ public class OauthService {
*/
@Transactional
public TokenResponse signIn(OauthRequest request) {
OidcProvider oidcProvider = oidcProviderFactory.getOidcProviderBy(request.socialType());

Claims claim = oidcProviderFactory.getClaims(request.socialType(), request.idToken());
Claims claim = oidcProvider.getClaimsBy(request.idToken());
String oauthId = claim.getSubject();
String email = String.valueOf(claim.get("email"));
if (StringUtils.isBlank(email)) {
Expand All @@ -64,6 +74,36 @@ public TokenResponse signIn(OauthRequest request) {
return TokenResponse.from(tokenDto);
}

@Transactional
public void withdraw(long memberId, WithdrawRequest request) {

memberRepository.findById(memberId).orElseThrow(() -> new NotFoundException(Member.class, memberId));

OidcProvider oidcProvider = oidcProviderFactory.getOidcProviderBy(request.socialType());

// 토큰 검증 -> 애플 지침
Claims claim = oidcProvider.getClaimsBy(request.idToken());
String oauthId = claim.getSubject();

SocialProfile socialProfile = socialProfileRepository
.findBySocialTypeAndOauthId(request.socialType(), oauthId)
.orElseThrow(() -> new NotFoundException("존재하지 않은 SocialProfile"));

if (memberId != socialProfile.member().memberId()) {
log.error(
"MemberId and MemberId find by SocialProfile oauthId do not match each other! memberId: {}, socialProfileId: {}",
memberId,
socialProfile.socialProfileId());
throw new NotFoundException("잘못된 데이터: Member and SocialProfile do not match each other");
}

// 탈퇴를 위한 access token 발급
String accessToken = oidcProvider.getAccessToken(request.authorizationCode());
oidcProvider.revoke(accessToken);

deleteMember(memberId);
}

private SocialProfile createMember(String oauthId, String email, SocialType socialType, String nickname) {
Member member = memberRepository.save(new Member(MemberRole.USER, nickname));

Expand All @@ -74,4 +114,12 @@ private SocialProfile createMember(String oauthId, String email, SocialType soci
.oauthEmail(email)
.build());
}

private void deleteMember(long memberId) {
badgeAchievementRepository.deleteByMemberId(memberId);
runningRecordRepository.deleteByMemberId(memberId);
memberLevelRepository.deleteByMemberId(memberId);
socialProfileRepository.deleteByMemberId(memberId);
memberRepository.deleteById(memberId);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,14 @@ public interface OidcProvider {

Claims getClaimsBy(String idToken);

String getAccessToken(String code);

/**
*
* @param accessToken 엑세스토큰
*/
void revoke(String accessToken);

default Map<String, String> parseHeaders(String token) {
String header = token.split("\\.")[0];

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
import com.dnd.runus.global.constant.SocialType;
import com.dnd.runus.global.exception.BusinessException;
import com.dnd.runus.global.exception.type.ErrorType;
import io.jsonwebtoken.Claims;
import org.springframework.stereotype.Component;

import java.util.EnumMap;
Expand All @@ -22,13 +21,13 @@ public OidcProviderFactory(List<OidcProvider> providers) {
providers.forEach(provider -> authProviderMap.put(provider.getSocialType(), provider));
}

public Claims getClaims(SocialType socialType, String idToken) {
public OidcProvider getOidcProviderBy(SocialType socialType) {
OidcProvider oidcProvider = authProviderMap.get(socialType);

if (isNull(oidcProvider)) {
throw new BusinessException(ErrorType.UNSUPPORTED_SOCIAL_TYPE, socialType.getValue());
}

return oidcProvider.getClaimsBy(idToken);
return oidcProvider;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,38 @@
import com.dnd.runus.auth.exception.AuthException;
import com.dnd.runus.auth.oidc.provider.OidcProvider;
import com.dnd.runus.auth.oidc.provider.PublicKeyProvider;
import com.dnd.runus.client.vo.AppleAuthRevokeRequest;
import com.dnd.runus.client.vo.AppleAuthTokenRequest;
import com.dnd.runus.client.vo.OidcPublicKeyList;
import com.dnd.runus.client.web.AppleAuthClient;
import com.dnd.runus.global.constant.SocialType;
import com.dnd.runus.global.exception.BusinessException;
import com.dnd.runus.global.exception.type.ErrorType;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.Jwts.SIG;
import io.jsonwebtoken.MalformedJwtException;
import io.jsonwebtoken.security.SignatureException;
import lombok.RequiredArgsConstructor;
import org.bouncycastle.asn1.pkcs.PrivateKeyInfo;
import org.bouncycastle.openssl.PEMParser;
import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Component;
import org.springframework.web.client.HttpClientErrorException;

import java.io.IOException;
import java.io.Reader;
import java.io.StringReader;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.Date;

@Component
@RequiredArgsConstructor
Expand All @@ -24,6 +43,15 @@ public class AppleAuthProvider implements OidcProvider {
private final AppleAuthClient appleAuthClient;
private final PublicKeyProvider publicKeyProvider;

@Value("${oauth.apple.key_id}")
private String keyId;

@Value("${oauth.apple.team_id}")
private String teamId;

@Value("${oauth.apple.client_id}")
private String clientId;

@Override
public SocialType getSocialType() {
return SocialType.APPLE;
Expand All @@ -39,6 +67,36 @@ public Claims getClaimsBy(String identityToken) {
return parseClaims(identityToken, publicKey);
}

@Override
public String getAccessToken(String code) {
try {
return appleAuthClient
.getAccessAppleToken(AppleAuthTokenRequest.builder()
.client_secret(createClientSecret())
.client_id(clientId)
.code(code)
.grant_type("authorization_code")
.build())
.accessToken();
} catch (HttpClientErrorException e) {
throw new BusinessException(ErrorType.FAILED_AUTHENTICATION, e.getMessage());
}
}

@Override
public void revoke(String accessToken) {
try {
appleAuthClient.revokeAccount(AppleAuthRevokeRequest.builder()
.client_id(clientId)
.client_secret(createClientSecret())
.token(accessToken)
.token_type_hint("access_token")
.build());
} catch (HttpClientErrorException e) {
throw new BusinessException(ErrorType.FAILED_AUTHENTICATION, e.getMessage());
}
}

private Claims parseClaims(String token, PublicKey publicKey) {
try {
return Jwts.parser()
Expand All @@ -53,4 +111,57 @@ private Claims parseClaims(String token, PublicKey publicKey) {
throw new AuthException(ErrorType.EXPIRED_ACCESS_TOKEN, e.getMessage());
}
}

private void validateClaims(Claims claims) {
// todo 4가지 검증 추가
// 1. Verify the nonce for the authentication
// 2. Verify that the iss field contains https://appleid.apple.com
// 3. Verify that the aud field is the developer’s client_id
// 4. Verify that the time is earlier than the exp value of the token
// nonce 검증 방법 알아보기
// if(claims.get("nonce") == null) {
// throw new AuthException(ErrorType.TAMPERED_ACCESS_TOKEN,
// }
// if (!claims.getIssuer().contains("https://appleid.apple.com")) {
// throw new AuthException(ErrorType.TAMPERED_ACCESS_TOKEN, "잘못된 iss");
// }
}

private PrivateKey getPrivateKey() {
ClassPathResource resource = new ClassPathResource("AuthKey_" + keyId + ".p8");
try {
String privateKey = new String(Files.readAllBytes(Paths.get(resource.getURI())));
Reader pemReader = new StringReader(privateKey);
PEMParser pemParser = new PEMParser(pemReader);
JcaPEMKeyConverter converter = new JcaPEMKeyConverter();
PrivateKeyInfo object = (PrivateKeyInfo) pemParser.readObject();

converter.getPrivateKey(object);
return converter.getPrivateKey(object);
} catch (IOException e) {
throw new BusinessException(ErrorType.UNHANDLED_EXCEPTION, e.getMessage());
}
}

private String createClientSecret() {
Date expirationDate = Date.from(LocalDateTime.now()
.plusMinutes(10)
.atZone(ZoneId.systemDefault())
.toInstant());

return Jwts.builder()
.header()
.add("kid", keyId)
.add("alg", "ES256")
.and()
.issuer(teamId)
.issuedAt(new Date(System.currentTimeMillis()))
.expiration(expirationDate)
.audience()
.add("https://appleid.apple.com")
.and()
.subject(clientId)
.signWith(getPrivateKey(), SIG.ES256)
.compact();
}
}
23 changes: 23 additions & 0 deletions src/main/java/com/dnd/runus/client/vo/AppleAuthRevokeRequest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.dnd.runus.client.vo;


import lombok.Builder;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;

@Builder
public record AppleAuthRevokeRequest(
String client_id,
String client_secret,
String token,
String token_type_hint
) {
public MultiValueMap<String, String> toMultiValueMap() {
return new LinkedMultiValueMap<>(){{
add("client_id", client_id);
add("client_secret", client_secret);
add("token", token);
add("token_type_hint", token_type_hint);
}};
}
}
23 changes: 23 additions & 0 deletions src/main/java/com/dnd/runus/client/vo/AppleAuthTokenRequest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.dnd.runus.client.vo;

import lombok.Builder;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;

@Builder
public record AppleAuthTokenRequest(
String code,
String client_id,
String client_secret,
String grant_type
) {

public MultiValueMap<String, String> toMultiValueMap() {
return new LinkedMultiValueMap<>(){{
add("code", code);
add("client_id", client_id);
add("client_secret", client_secret);
add("grant_type", grant_type);
}};
}
}
16 changes: 16 additions & 0 deletions src/main/java/com/dnd/runus/client/vo/AppleAuthTokenResponse.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.dnd.runus.client.vo;

import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.fasterxml.jackson.databind.annotation.JsonNaming;

@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
public record AppleAuthTokenResponse(
String accessToken,
String expiresIn,
String idToken,
String refreshToken,
String tokenType,
String error
) {

}
11 changes: 11 additions & 0 deletions src/main/java/com/dnd/runus/client/web/AppleAuthClient.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
package com.dnd.runus.client.web;

import com.dnd.runus.client.vo.AppleAuthRevokeRequest;
import com.dnd.runus.client.vo.AppleAuthTokenRequest;
import com.dnd.runus.client.vo.AppleAuthTokenResponse;
import com.dnd.runus.client.vo.OidcPublicKeyList;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
Expand All @@ -13,4 +16,12 @@ public class AppleAuthClient {
public OidcPublicKeyList getPublicKeys() {
return appleAuthClientComponent.getPublicKeys();
}

public AppleAuthTokenResponse getAccessAppleToken(AppleAuthTokenRequest request) {
return appleAuthClientComponent.getAccessAppleToken(request.toMultiValueMap());
}

public void revokeAccount(AppleAuthRevokeRequest request) {
appleAuthClientComponent.revoke(request.toMultiValueMap());
}
}
Loading