diff --git a/.gitignore b/.gitignore index 990eb61c..7299a212 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,5 @@ out/ **/resources/.env **/resources/.env.* !**/resources/.env.example + +**/resources/*.p8 diff --git a/build.gradle.kts b/build.gradle.kts index 880819e0..5da87889 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -31,6 +31,8 @@ dependencies { implementation(libs.jetbrains.annotations) implementation(libs.springdoc) + implementation(libs.bcpkix) + // JWT implementation(libs.jjwt.api) runtimeOnly(libs.jjwt.impl) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8d0b121c..f04c3ba1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" } diff --git a/src/main/java/com/dnd/runus/domain/oauth/service/OauthService.java b/src/main/java/com/dnd/runus/application/oauth/OauthService.java similarity index 53% rename from src/main/java/com/dnd/runus/domain/oauth/service/OauthService.java rename to src/main/java/com/dnd/runus/application/oauth/OauthService.java index 40c745da..83afcf2c 100644 --- a/src/main/java/com/dnd/runus/domain/oauth/service/OauthService.java +++ b/src/main/java/com/dnd/runus/application/oauth/OauthService.java @@ -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; @@ -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; /** * 회원가입 유뮤 확인 후 회원가입/로그인 진행 @@ -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)) { @@ -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)); @@ -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); + } } diff --git a/src/main/java/com/dnd/runus/auth/oidc/provider/OidcProvider.java b/src/main/java/com/dnd/runus/auth/oidc/provider/OidcProvider.java index 29e4f3bf..80076c56 100644 --- a/src/main/java/com/dnd/runus/auth/oidc/provider/OidcProvider.java +++ b/src/main/java/com/dnd/runus/auth/oidc/provider/OidcProvider.java @@ -17,6 +17,14 @@ public interface OidcProvider { Claims getClaimsBy(String idToken); + String getAccessToken(String code); + + /** + * + * @param accessToken 엑세스토큰 + */ + void revoke(String accessToken); + default Map parseHeaders(String token) { String header = token.split("\\.")[0]; diff --git a/src/main/java/com/dnd/runus/auth/oidc/provider/OidcProviderFactory.java b/src/main/java/com/dnd/runus/auth/oidc/provider/OidcProviderFactory.java index 108e01dc..d2df1205 100644 --- a/src/main/java/com/dnd/runus/auth/oidc/provider/OidcProviderFactory.java +++ b/src/main/java/com/dnd/runus/auth/oidc/provider/OidcProviderFactory.java @@ -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; @@ -22,13 +21,13 @@ public OidcProviderFactory(List 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; } } diff --git a/src/main/java/com/dnd/runus/auth/oidc/provider/impl/AppleAuthProvider.java b/src/main/java/com/dnd/runus/auth/oidc/provider/impl/AppleAuthProvider.java index 38c979f4..54e75950 100644 --- a/src/main/java/com/dnd/runus/auth/oidc/provider/impl/AppleAuthProvider.java +++ b/src/main/java/com/dnd/runus/auth/oidc/provider/impl/AppleAuthProvider.java @@ -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 @@ -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; @@ -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() @@ -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(); + } } diff --git a/src/main/java/com/dnd/runus/client/vo/AppleAuthRevokeRequest.java b/src/main/java/com/dnd/runus/client/vo/AppleAuthRevokeRequest.java new file mode 100644 index 00000000..890879ef --- /dev/null +++ b/src/main/java/com/dnd/runus/client/vo/AppleAuthRevokeRequest.java @@ -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 toMultiValueMap() { + return new LinkedMultiValueMap<>(){{ + add("client_id", client_id); + add("client_secret", client_secret); + add("token", token); + add("token_type_hint", token_type_hint); + }}; + } +} diff --git a/src/main/java/com/dnd/runus/client/vo/AppleAuthTokenRequest.java b/src/main/java/com/dnd/runus/client/vo/AppleAuthTokenRequest.java new file mode 100644 index 00000000..3adf0312 --- /dev/null +++ b/src/main/java/com/dnd/runus/client/vo/AppleAuthTokenRequest.java @@ -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 toMultiValueMap() { + return new LinkedMultiValueMap<>(){{ + add("code", code); + add("client_id", client_id); + add("client_secret", client_secret); + add("grant_type", grant_type); + }}; + } +} diff --git a/src/main/java/com/dnd/runus/client/vo/AppleAuthTokenResponse.java b/src/main/java/com/dnd/runus/client/vo/AppleAuthTokenResponse.java new file mode 100644 index 00000000..0f1830be --- /dev/null +++ b/src/main/java/com/dnd/runus/client/vo/AppleAuthTokenResponse.java @@ -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 +) { + +} diff --git a/src/main/java/com/dnd/runus/client/web/AppleAuthClient.java b/src/main/java/com/dnd/runus/client/web/AppleAuthClient.java index 06503d23..0845cff2 100644 --- a/src/main/java/com/dnd/runus/client/web/AppleAuthClient.java +++ b/src/main/java/com/dnd/runus/client/web/AppleAuthClient.java @@ -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; @@ -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()); + } } diff --git a/src/main/java/com/dnd/runus/client/web/AppleAuthClientComponent.java b/src/main/java/com/dnd/runus/client/web/AppleAuthClientComponent.java index 3b2151a5..5e39c4cd 100644 --- a/src/main/java/com/dnd/runus/client/web/AppleAuthClientComponent.java +++ b/src/main/java/com/dnd/runus/client/web/AppleAuthClientComponent.java @@ -1,13 +1,31 @@ package com.dnd.runus.client.web; +import com.dnd.runus.client.vo.AppleAuthTokenResponse; import com.dnd.runus.client.vo.OidcPublicKeyList; +import org.springframework.http.MediaType; import org.springframework.stereotype.Component; +import org.springframework.util.MultiValueMap; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.service.annotation.GetExchange; import org.springframework.web.service.annotation.HttpExchange; +import org.springframework.web.service.annotation.PostExchange; @Component @HttpExchange public interface AppleAuthClientComponent { + @GetExchange("/keys") OidcPublicKeyList getPublicKeys(); + + @PostExchange( + url = "/token", + contentType = MediaType.APPLICATION_FORM_URLENCODED_VALUE, + accept = MediaType.APPLICATION_JSON_VALUE) + AppleAuthTokenResponse getAccessAppleToken(@RequestBody MultiValueMap request); + + @PostExchange( + url = "/revoke", + contentType = MediaType.APPLICATION_FORM_URLENCODED_VALUE, + accept = MediaType.APPLICATION_JSON_VALUE) + void revoke(@RequestBody MultiValueMap request); } diff --git a/src/main/java/com/dnd/runus/domain/badge/BadgeAchievement.java b/src/main/java/com/dnd/runus/domain/badge/BadgeAchievement.java index 94801b93..41e2746d 100644 --- a/src/main/java/com/dnd/runus/domain/badge/BadgeAchievement.java +++ b/src/main/java/com/dnd/runus/domain/badge/BadgeAchievement.java @@ -4,4 +4,9 @@ import java.time.OffsetDateTime; -public record BadgeAchievement(Badge badge, Member member, OffsetDateTime createdAt, OffsetDateTime updatedAt) {} +public record BadgeAchievement(Badge badge, Member member, OffsetDateTime createdAt, OffsetDateTime updatedAt) { + + public BadgeAchievement(Badge badge, Member member) { + this(badge, member, null, null); + } +} diff --git a/src/main/java/com/dnd/runus/domain/badge/BadgeAchievementRepository.java b/src/main/java/com/dnd/runus/domain/badge/BadgeAchievementRepository.java new file mode 100644 index 00000000..13a49f97 --- /dev/null +++ b/src/main/java/com/dnd/runus/domain/badge/BadgeAchievementRepository.java @@ -0,0 +1,6 @@ +package com.dnd.runus.domain.badge; + +public interface BadgeAchievementRepository { + + void deleteByMemberId(long memberId); +} diff --git a/src/main/java/com/dnd/runus/domain/member/MemberLevelRepository.java b/src/main/java/com/dnd/runus/domain/member/MemberLevelRepository.java new file mode 100644 index 00000000..5867120f --- /dev/null +++ b/src/main/java/com/dnd/runus/domain/member/MemberLevelRepository.java @@ -0,0 +1,6 @@ +package com.dnd.runus.domain.member; + +public interface MemberLevelRepository { + + void deleteByMemberId(long memberId); +} diff --git a/src/main/java/com/dnd/runus/domain/member/MemberRepository.java b/src/main/java/com/dnd/runus/domain/member/MemberRepository.java index 09db06ac..cbcb834c 100644 --- a/src/main/java/com/dnd/runus/domain/member/MemberRepository.java +++ b/src/main/java/com/dnd/runus/domain/member/MemberRepository.java @@ -6,4 +6,6 @@ public interface MemberRepository { Optional findById(long id); Member save(Member member); + + void deleteById(long memberId); } diff --git a/src/main/java/com/dnd/runus/domain/member/SocialProfileRepository.java b/src/main/java/com/dnd/runus/domain/member/SocialProfileRepository.java index b4df8b45..b1e536a9 100644 --- a/src/main/java/com/dnd/runus/domain/member/SocialProfileRepository.java +++ b/src/main/java/com/dnd/runus/domain/member/SocialProfileRepository.java @@ -5,6 +5,7 @@ import java.util.Optional; public interface SocialProfileRepository { + Optional findById(Long socialProfileId); Optional findBySocialTypeAndOauthId(SocialType socialType, String oauthId); @@ -12,4 +13,6 @@ public interface SocialProfileRepository { SocialProfile save(SocialProfile socialProfile); void updateOauthEmail(long socialProfileId, String oauthEmail); + + void deleteByMemberId(long memberId); } diff --git a/src/main/java/com/dnd/runus/infrastructure/persistence/domain/badge/BadgeAchievementRepositoryImpl.java b/src/main/java/com/dnd/runus/infrastructure/persistence/domain/badge/BadgeAchievementRepositoryImpl.java new file mode 100644 index 00000000..080feefb --- /dev/null +++ b/src/main/java/com/dnd/runus/infrastructure/persistence/domain/badge/BadgeAchievementRepositoryImpl.java @@ -0,0 +1,18 @@ +package com.dnd.runus.infrastructure.persistence.domain.badge; + +import com.dnd.runus.domain.badge.BadgeAchievementRepository; +import com.dnd.runus.infrastructure.persistence.jpa.badge.JpaBadgeAchievementRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class BadgeAchievementRepositoryImpl implements BadgeAchievementRepository { + + private final JpaBadgeAchievementRepository jpaBadgeAchievementRepository; + + @Override + public void deleteByMemberId(long memberId) { + jpaBadgeAchievementRepository.deleteByMemberId(memberId); + } +} diff --git a/src/main/java/com/dnd/runus/infrastructure/persistence/domain/member/MemberLevelRepositoryImpl.java b/src/main/java/com/dnd/runus/infrastructure/persistence/domain/member/MemberLevelRepositoryImpl.java new file mode 100644 index 00000000..2a4c4639 --- /dev/null +++ b/src/main/java/com/dnd/runus/infrastructure/persistence/domain/member/MemberLevelRepositoryImpl.java @@ -0,0 +1,18 @@ +package com.dnd.runus.infrastructure.persistence.domain.member; + +import com.dnd.runus.domain.member.MemberLevelRepository; +import com.dnd.runus.infrastructure.persistence.jpa.member.JpaMemberLevelRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class MemberLevelRepositoryImpl implements MemberLevelRepository { + + private final JpaMemberLevelRepository jpaMemberLevelRepository; + + @Override + public void deleteByMemberId(long memberId) { + jpaMemberLevelRepository.deleteByMemberId(memberId); + } +} diff --git a/src/main/java/com/dnd/runus/infrastructure/persistence/domain/member/MemberRepositoryImpl.java b/src/main/java/com/dnd/runus/infrastructure/persistence/domain/member/MemberRepositoryImpl.java index b91cae6c..eafef719 100644 --- a/src/main/java/com/dnd/runus/infrastructure/persistence/domain/member/MemberRepositoryImpl.java +++ b/src/main/java/com/dnd/runus/infrastructure/persistence/domain/member/MemberRepositoryImpl.java @@ -23,4 +23,9 @@ public Optional findById(long id) { public Member save(Member member) { return jpaMemberRepository.save(MemberEntity.from(member)).toDomain(); } + + @Override + public void deleteById(long memberId) { + jpaMemberRepository.deleteById(memberId); + } } diff --git a/src/main/java/com/dnd/runus/infrastructure/persistence/domain/member/SocialProfileRepositoryImpl.java b/src/main/java/com/dnd/runus/infrastructure/persistence/domain/member/SocialProfileRepositoryImpl.java index 750ea8de..cc247ec2 100644 --- a/src/main/java/com/dnd/runus/infrastructure/persistence/domain/member/SocialProfileRepositoryImpl.java +++ b/src/main/java/com/dnd/runus/infrastructure/persistence/domain/member/SocialProfileRepositoryImpl.java @@ -13,6 +13,7 @@ @Repository @RequiredArgsConstructor public class SocialProfileRepositoryImpl implements SocialProfileRepository { + private final JpaSocialProfileRepository jpaSocialProfileRepository; @Override @@ -40,4 +41,9 @@ public void updateOauthEmail(long socialProfileId, String oauthEmail) { .findById(socialProfileId) .ifPresent(socialProfileEntity -> socialProfileEntity.updateOauthEmail(oauthEmail)); } + + @Override + public void deleteByMemberId(long memberId) { + jpaSocialProfileRepository.deleteByMemberId(memberId); + } } diff --git a/src/main/java/com/dnd/runus/infrastructure/persistence/jpa/badge/JpaBadgeAchievementRepository.java b/src/main/java/com/dnd/runus/infrastructure/persistence/jpa/badge/JpaBadgeAchievementRepository.java new file mode 100644 index 00000000..3f678547 --- /dev/null +++ b/src/main/java/com/dnd/runus/infrastructure/persistence/jpa/badge/JpaBadgeAchievementRepository.java @@ -0,0 +1,9 @@ +package com.dnd.runus.infrastructure.persistence.jpa.badge; + +import com.dnd.runus.infrastructure.persistence.jpa.badge.entity.BadgeAchievementEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface JpaBadgeAchievementRepository extends JpaRepository { + + void deleteByMemberId(long memberId); +} diff --git a/src/main/java/com/dnd/runus/infrastructure/persistence/jpa/member/JpaMemberLevelRepository.java b/src/main/java/com/dnd/runus/infrastructure/persistence/jpa/member/JpaMemberLevelRepository.java new file mode 100644 index 00000000..ce0621c6 --- /dev/null +++ b/src/main/java/com/dnd/runus/infrastructure/persistence/jpa/member/JpaMemberLevelRepository.java @@ -0,0 +1,9 @@ +package com.dnd.runus.infrastructure.persistence.jpa.member; + +import com.dnd.runus.infrastructure.persistence.jpa.member.entity.MemberLevelEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface JpaMemberLevelRepository extends JpaRepository { + + void deleteByMemberId(long memberId); +} diff --git a/src/main/java/com/dnd/runus/infrastructure/persistence/jpa/member/JpaSocialProfileRepository.java b/src/main/java/com/dnd/runus/infrastructure/persistence/jpa/member/JpaSocialProfileRepository.java index 9adf7df3..8c0904fb 100644 --- a/src/main/java/com/dnd/runus/infrastructure/persistence/jpa/member/JpaSocialProfileRepository.java +++ b/src/main/java/com/dnd/runus/infrastructure/persistence/jpa/member/JpaSocialProfileRepository.java @@ -7,5 +7,8 @@ import java.util.Optional; public interface JpaSocialProfileRepository extends JpaRepository { + Optional findBySocialTypeAndOauthId(SocialType socialType, String oauthId); + + void deleteByMemberId(long memberId); } diff --git a/src/main/java/com/dnd/runus/domain/oauth/controller/OauthController.java b/src/main/java/com/dnd/runus/presentation/v1/oauth/OauthController.java similarity index 69% rename from src/main/java/com/dnd/runus/domain/oauth/controller/OauthController.java rename to src/main/java/com/dnd/runus/presentation/v1/oauth/OauthController.java index a9a7475b..d4b21ecf 100644 --- a/src/main/java/com/dnd/runus/domain/oauth/controller/OauthController.java +++ b/src/main/java/com/dnd/runus/presentation/v1/oauth/OauthController.java @@ -1,10 +1,12 @@ -package com.dnd.runus.domain.oauth.controller; +package com.dnd.runus.presentation.v1.oauth; -import com.dnd.runus.domain.oauth.dto.request.OauthRequest; -import com.dnd.runus.domain.oauth.dto.response.TokenResponse; -import com.dnd.runus.domain.oauth.service.OauthService; +import com.dnd.runus.application.oauth.OauthService; import com.dnd.runus.global.exception.type.ApiErrorType; import com.dnd.runus.global.exception.type.ErrorType; +import com.dnd.runus.presentation.annotation.MemberId; +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.swagger.v3.oas.annotations.Operation; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -30,4 +32,9 @@ public class OauthController { public TokenResponse signIn(@Valid @RequestBody OauthRequest request) { return oauthService.signIn(request); } + + @PostMapping("/withdraw") + public void withdraw(@MemberId long memberId, @Valid @RequestBody WithdrawRequest request) { + oauthService.withdraw(memberId, request); + } } diff --git a/src/main/java/com/dnd/runus/domain/oauth/dto/request/OauthRequest.java b/src/main/java/com/dnd/runus/presentation/v1/oauth/dto/request/OauthRequest.java similarity index 87% rename from src/main/java/com/dnd/runus/domain/oauth/dto/request/OauthRequest.java rename to src/main/java/com/dnd/runus/presentation/v1/oauth/dto/request/OauthRequest.java index 3c24b75e..9f1110b9 100644 --- a/src/main/java/com/dnd/runus/domain/oauth/dto/request/OauthRequest.java +++ b/src/main/java/com/dnd/runus/presentation/v1/oauth/dto/request/OauthRequest.java @@ -1,4 +1,4 @@ -package com.dnd.runus.domain.oauth.dto.request; +package com.dnd.runus.presentation.v1.oauth.dto.request; import com.dnd.runus.global.constant.SocialType; import io.swagger.v3.oas.annotations.media.Schema; @@ -9,9 +9,7 @@ @Schema(description = "로그인 및 회원가입 요청 DTO") public record OauthRequest( @Schema( - description = "소셜 로그인 타입", - type = "string", - example = "APPLE" + description = "소셜 로그인 타입" ) @NotNull SocialType socialType, diff --git a/src/main/java/com/dnd/runus/presentation/v1/oauth/dto/request/WithdrawRequest.java b/src/main/java/com/dnd/runus/presentation/v1/oauth/dto/request/WithdrawRequest.java new file mode 100644 index 00000000..f15422a4 --- /dev/null +++ b/src/main/java/com/dnd/runus/presentation/v1/oauth/dto/request/WithdrawRequest.java @@ -0,0 +1,20 @@ +package com.dnd.runus.presentation.v1.oauth.dto.request; + +import com.dnd.runus.global.constant.SocialType; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +@Schema(description = "회원 탈퇴 DTO") +public record WithdrawRequest( + @Schema(description = "소셜 로그인 타입") + @NotNull + SocialType socialType, + @Schema(description = "authorizationCode") + @NotBlank + String authorizationCode, + @NotBlank + String idToken +) { + +} diff --git a/src/main/java/com/dnd/runus/domain/oauth/dto/response/TokenResponse.java b/src/main/java/com/dnd/runus/presentation/v1/oauth/dto/response/TokenResponse.java similarity index 91% rename from src/main/java/com/dnd/runus/domain/oauth/dto/response/TokenResponse.java rename to src/main/java/com/dnd/runus/presentation/v1/oauth/dto/response/TokenResponse.java index a6f8d31a..df9af8c9 100644 --- a/src/main/java/com/dnd/runus/domain/oauth/dto/response/TokenResponse.java +++ b/src/main/java/com/dnd/runus/presentation/v1/oauth/dto/response/TokenResponse.java @@ -1,4 +1,4 @@ -package com.dnd.runus.domain.oauth.dto.response; +package com.dnd.runus.presentation.v1.oauth.dto.response; import com.dnd.runus.auth.token.dto.AuthTokenDto; diff --git a/src/main/resources/.env.example b/src/main/resources/.env.example index cbc719c6..8aebb1b5 100644 --- a/src/main/resources/.env.example +++ b/src/main/resources/.env.example @@ -9,4 +9,6 @@ ALLOW_ORIGINS= ACCESS_TOKEN_EXPIRATION= ACCESS_TOKEN_SECRET_KEY= -APPLE_AUTH_URL= +APPLE_CLIENT_ID= +APPLE_KEY_ID= +APPLE_TEAM_ID= diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 5baa7b51..d88b78d8 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -31,6 +31,9 @@ app: oauth: apple: public-key-url: https://appleid.apple.com/auth + client_id: ${APPLE_CLIENT_ID} + key_id: ${APPLE_KEY_ID} + team_id: ${APPLE_TEAM_ID} --- spring.config.activate.on-profile: local diff --git a/src/test/java/com/dnd/runus/infrastructure/persistence/domain/badge/BadgeAchievementRepositoryImplTest.java b/src/test/java/com/dnd/runus/infrastructure/persistence/domain/badge/BadgeAchievementRepositoryImplTest.java new file mode 100644 index 00000000..94e448b4 --- /dev/null +++ b/src/test/java/com/dnd/runus/infrastructure/persistence/domain/badge/BadgeAchievementRepositoryImplTest.java @@ -0,0 +1,58 @@ +package com.dnd.runus.infrastructure.persistence.domain.badge; + +import com.dnd.runus.domain.badge.Badge; +import com.dnd.runus.domain.badge.BadgeAchievement; +import com.dnd.runus.domain.badge.BadgeAchievementRepository; +import com.dnd.runus.domain.member.Member; +import com.dnd.runus.domain.member.MemberRepository; +import com.dnd.runus.global.constant.BadgeType; +import com.dnd.runus.global.constant.MemberRole; +import com.dnd.runus.infrastructure.persistence.annotation.RepositoryTest; +import com.dnd.runus.infrastructure.persistence.jpa.badge.JpaBadgeAchievementRepository; +import com.dnd.runus.infrastructure.persistence.jpa.badge.entity.BadgeAchievementEntity; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import static org.junit.jupiter.api.Assertions.assertFalse; + +@RepositoryTest +class BadgeAchievementRepositoryImplTest { + + @Autowired + private BadgeAchievementRepository badgeAchievementRepository; + + @Autowired + private JpaBadgeAchievementRepository jpaBadgeAchievementRepository; + + @Autowired + private MemberRepository memberRepository; + + private Member savedMember; + + @BeforeEach + void beforeEach() { + // BadgeAchievement는 Member의 자식임으로 테스트 시 임의이 Member 추가 + Member member = new Member(MemberRole.USER, "nickname"); + savedMember = memberRepository.save(member); + } + + @DisplayName("member에 해당하는 badgeAchievement을 삭제한다.") + @Test + public void deleteByMember() { + // given + Badge badge = new Badge(1L, "testBadge", "testBadge", "tesUrl", BadgeType.DISTANCE_METER, 2); + BadgeAchievement badgeAchievement = new BadgeAchievement(badge, savedMember); + BadgeAchievementEntity savedBadgeAchievement = + jpaBadgeAchievementRepository.save(BadgeAchievementEntity.from(badgeAchievement)); + + // when + badgeAchievementRepository.deleteByMemberId(savedMember.memberId()); + + // then + assertFalse(jpaBadgeAchievementRepository + .findById(savedBadgeAchievement.getId()) + .isPresent()); + } +} diff --git a/src/test/java/com/dnd/runus/infrastructure/persistence/domain/member/MemberLevelRepositoryImplTest.java b/src/test/java/com/dnd/runus/infrastructure/persistence/domain/member/MemberLevelRepositoryImplTest.java new file mode 100644 index 00000000..6e0907d8 --- /dev/null +++ b/src/test/java/com/dnd/runus/infrastructure/persistence/domain/member/MemberLevelRepositoryImplTest.java @@ -0,0 +1,50 @@ +package com.dnd.runus.infrastructure.persistence.domain.member; + +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.global.constant.MemberRole; +import com.dnd.runus.infrastructure.persistence.annotation.RepositoryTest; +import com.dnd.runus.infrastructure.persistence.jpa.member.JpaMemberLevelRepository; +import com.dnd.runus.infrastructure.persistence.jpa.member.entity.MemberLevelEntity; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import static org.junit.jupiter.api.Assertions.assertFalse; + +@RepositoryTest +class MemberLevelRepositoryImplTest { + + @Autowired + private MemberLevelRepository memberLevelRepository; + + @Autowired + private JpaMemberLevelRepository jpaMemberLevelRepository; + + @Autowired + private MemberRepository memberRepository; + + private Member savedMember; + + @BeforeEach + void beforeEach() { + // Member의 자식임으로 테스트 시 임의이 Member 추가 + Member member = new Member(MemberRole.USER, "nickname"); + savedMember = memberRepository.save(member); + } + + @DisplayName("멤버 레벨을 member id로 삭제한다.") + @Test + void deleteByMemberId() { + // given + MemberLevelEntity savedMemberLevel = jpaMemberLevelRepository.save(MemberLevelEntity.of(savedMember, 1L, 100)); + + // when + memberLevelRepository.deleteByMemberId(savedMember.memberId()); + + // then + assertFalse(jpaMemberLevelRepository.findById(savedMemberLevel.getId()).isPresent()); + } +} diff --git a/src/test/java/com/dnd/runus/infrastructure/persistence/domain/member/MemberRepositoryImplTest.java b/src/test/java/com/dnd/runus/infrastructure/persistence/domain/member/MemberRepositoryImplTest.java index 9930cb79..4c5352e2 100644 --- a/src/test/java/com/dnd/runus/infrastructure/persistence/domain/member/MemberRepositoryImplTest.java +++ b/src/test/java/com/dnd/runus/infrastructure/persistence/domain/member/MemberRepositoryImplTest.java @@ -8,10 +8,14 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; @RepositoryTest class MemberRepositoryImplTest { + @Autowired private MemberRepository memberRepository; @@ -31,4 +35,18 @@ void save() { assertNotNull(savedMember.createdAt()); assertNotNull(savedMember.updatedAt()); } + + @Test + @DisplayName("Member를 삭제한다.") + void delete() { + // given + Member member = new Member(MemberRole.USER, "nickname"); + long savedMemberId = memberRepository.save(member).memberId(); + + // when + memberRepository.deleteById(savedMemberId); + + // then + assertFalse(memberRepository.findById(savedMemberId).isPresent()); + } } diff --git a/src/test/java/com/dnd/runus/infrastructure/persistence/domain/member/SocialProfileRepositoryImplTest.java b/src/test/java/com/dnd/runus/infrastructure/persistence/domain/member/SocialProfileRepositoryImplTest.java new file mode 100644 index 00000000..ee8a8417 --- /dev/null +++ b/src/test/java/com/dnd/runus/infrastructure/persistence/domain/member/SocialProfileRepositoryImplTest.java @@ -0,0 +1,153 @@ +package com.dnd.runus.infrastructure.persistence.domain.member; + +import com.dnd.runus.domain.member.Member; +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.global.constant.MemberRole; +import com.dnd.runus.global.constant.SocialType; +import com.dnd.runus.infrastructure.persistence.annotation.RepositoryTest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@RepositoryTest +class SocialProfileRepositoryImplTest { + + @Autowired + private SocialProfileRepository socialProfileRepository; + + @Autowired + private MemberRepository memberRepository; + + private Member savedMember; + + @BeforeEach + void beforeEach() { + // Member의 자식임으로 테스트 시 임의이 Member 추가 + Member member = new Member(MemberRole.USER, "nickname"); + savedMember = memberRepository.save(member); + } + + @DisplayName("소셜 프로파일 저장한다.") + @Test + void save() { + // given + String oauthId = "oauthId"; + SocialType socialType = SocialType.APPLE; + String email = "email@email.com"; + + // when + SocialProfile savedSocialProfile = socialProfileRepository.save(SocialProfile.builder() + .member(savedMember) + .socialType(socialType) + .oauthId(oauthId) + .oauthEmail(email) + .build()); + + // then + assertNotEquals(0, savedSocialProfile.socialProfileId()); + assertEquals(oauthId, savedSocialProfile.oauthId()); + assertEquals(socialType, socialType); + assertEquals(email, email); + } + + @DisplayName("findById socialProfile 조회한다.") + @Test + void findById() { + // given + String oauthId = "oauthId"; + SocialType socialType = SocialType.APPLE; + String email = "email@email.com"; + + SocialProfile savedSocialProfile = socialProfileRepository.save(SocialProfile.builder() + .member(savedMember) + .socialType(socialType) + .oauthId(oauthId) + .oauthEmail(email) + .build()); + + // when & then + assertTrue(socialProfileRepository + .findById(savedSocialProfile.socialProfileId()) + .isPresent()); + } + + @DisplayName("SocialType과 OauthId로 socialProfile 조회한다.") + @Test + void findBySocialTypeAndOauthId() { + // given + String oauthId = "oauthId"; + SocialType socialType = SocialType.APPLE; + String email = "email@email.com"; + + socialProfileRepository.save(SocialProfile.builder() + .member(savedMember) + .socialType(socialType) + .oauthId(oauthId) + .oauthEmail(email) + .build()); + + // when & then + assertTrue(socialProfileRepository + .findBySocialTypeAndOauthId(socialType, oauthId) + .isPresent()); + } + + @DisplayName("email를 update한다.") + @Test + void updateOauthEmail() { + // given + String oauthId = "oauthId"; + SocialType socialType = SocialType.APPLE; + String preEmail = "preEmail@email.com"; + + String updateEmail = "updateEmail@email.com"; + + SocialProfile savedSocialProfile = socialProfileRepository.save(SocialProfile.builder() + .member(savedMember) + .socialType(socialType) + .oauthId(oauthId) + .oauthEmail(preEmail) + .build()); + + // when + socialProfileRepository.updateOauthEmail(savedSocialProfile.socialProfileId(), updateEmail); + + // then + SocialProfile updated = socialProfileRepository + .findById(savedSocialProfile.socialProfileId()) + .orElse(SocialProfile.builder().build()); + assertEquals(updateEmail, updated.oauthEmail()); + } + + @DisplayName("Member로 소설프로파일 데이터를 삭제한다.") + @Test + void deleteByMember() { + // given + String oauthId = "oauthId"; + SocialType socialType = SocialType.APPLE; + String email = "email@email.com"; + + SocialProfile savedSocialProfile = socialProfileRepository.save(SocialProfile.builder() + .member(savedMember) + .socialType(socialType) + .oauthId(oauthId) + .oauthEmail(email) + .build()); + + // when + socialProfileRepository.deleteByMemberId(savedMember.memberId()); + + // then + assertFalse(socialProfileRepository + .findById(savedSocialProfile.socialProfileId()) + .isPresent()); + } +} diff --git a/src/test/java/com/dnd/runus/infrastructure/persistence/domain/running/RunningRecordRepositoryImplTest.java b/src/test/java/com/dnd/runus/infrastructure/persistence/domain/running/RunningRecordRepositoryImplTest.java index eb75fef0..3bd2fb68 100644 --- a/src/test/java/com/dnd/runus/infrastructure/persistence/domain/running/RunningRecordRepositoryImplTest.java +++ b/src/test/java/com/dnd/runus/infrastructure/persistence/domain/running/RunningRecordRepositoryImplTest.java @@ -16,9 +16,8 @@ import org.springframework.beans.factory.annotation.Autowired; import java.time.Duration; -import java.time.LocalTime; import java.time.OffsetDateTime; -import java.util.ArrayList; +import java.util.List; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -34,7 +33,7 @@ class RunningRecordRepositoryImplTest { @Autowired private MemberRepository memberRepository; - private static Member savedMember; + private Member savedMember; @BeforeEach void beforeEach() { @@ -49,17 +48,12 @@ void deleteByMember() { 0, savedMember, 1, - Duration.between(LocalTime.of(0, 0, 0), LocalTime.of(12, 23, 56)), + Duration.ofHours(12).plusMinutes(23).plusSeconds(56), 1, new Pace(5, 11), OffsetDateTime.now(), OffsetDateTime.now(), - new ArrayList<>() { - { - add(new Coordinate(1, 2, 3)); - add(new Coordinate(4, 5, 6)); - } - }, + List.of(new Coordinate(1, 2, 3), new Coordinate(4, 5, 6)), "location", RunningEmoji.BAD); RunningRecordEntity entity = RunningRecordEntity.from(runningRecord); diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index 2ec07225..2aa26fd6 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -26,3 +26,6 @@ app: oauth: apple: public-key-url: https://appleid.apple.com/auth + client_id: client_id + key_id: key-id + team_id: team-id