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] 애플 소셜 탈퇴 기능 구현 #226

Merged
merged 14 commits into from
Nov 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
a732d9d
[FEAT] 애플 소셜 로그인 탈퇴 API 구현
ChaeAg Nov 13, 2024
f9a2cd6
[REFACTOR] 순환 참조 해결을 위해 Apple 로그인 로직 AppleService로 위임
ChaeAg Nov 13, 2024
7458a8c
[REFACTOR] 공통된 유저 리프레시 토큰 제거 로직을 통합하여 코드 간소화
ChaeAg Nov 13, 2024
b3a143e
[REFACTOR] 에러 발생 가능 메서드를 최우선으로 실행하도록 로직 순서 조정
ChaeAg Nov 13, 2024
055949e
[FIX] 애플 소셜 로그인 탈퇴 방식 애플 액세스 토큰 대신 애플 리프레시 토큰을 이용하도록 변경
ChaeAg Nov 13, 2024
8c92091
[REMOVE] 탈퇴 방식 변경에 따라 더 이상 사용되지 않는 코드 제거
ChaeAg Nov 13, 2024
f739ef8
[FEAT] 탈퇴 요청 시 사용하는 Request DTO에 예외 메시지 추가
ChaeAg Nov 14, 2024
4fbfc0d
[FEAT] 사용자 탈퇴 시 디스코드 웹훅을 통한 알림 기능 추가
ChaeAg Nov 14, 2024
8c59ff2
[REFACTOR] Discord Webhook 타입을 Enum으로 리팩토링하여 문자열 상수 관리
ChaeAg Nov 14, 2024
6382060
[REFACTOR] 사용자 탈퇴 로직 메서드 분리
ChaeAg Nov 14, 2024
437b1a7
[FEAT] 애플 소셜 유저 탈퇴 시 DB내 해당 유저 UserAppleToken 삭제 로직 추가
ChaeAg Nov 18, 2024
20a3bb8
[FIX] 사용자 탈퇴 시 연관 관계를 활용하여 관련된 신고 피드 및 신고 댓글이 함께 삭제되도록 수정
ChaeAg Nov 18, 2024
3babe1a
[FEAT] 유저 탈퇴 시 탈퇴 사유 저장 로직 구현
ChaeAg Nov 18, 2024
b6fdb86
[REMOVE] 로그 확인용 코드 제거
ChaeAg Nov 18, 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
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,7 @@ public ResponseEntity<Void> logout(Principal principal,
public ResponseEntity<Void> withdrawUser(Principal principal,
@Valid @RequestBody WithdrawalRequest withdrawalRequest) {
User user = userService.getUserOrException(Long.valueOf(principal.getName()));
String refreshToken = withdrawalRequest.refreshToken();
kakaoService.unlinkFromKakao(user, refreshToken);
userService.withdrawUser(user, withdrawalRequest);
return ResponseEntity
.status(NO_CONTENT)
.build();
Expand Down
6 changes: 6 additions & 0 deletions src/main/java/org/websoso/WSSServer/domain/User.java
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,12 @@ public class User {
@OneToMany(mappedBy = "user", cascade = ALL, orphanRemoval = true)
private List<UserNovel> userNovels = new ArrayList<>();

@OneToMany(mappedBy = "user", cascade = ALL, orphanRemoval = true)
private List<ReportedFeed> reportedFeeds = new ArrayList<>();

@OneToMany(mappedBy = "user", cascade = ALL, orphanRemoval = true)
private List<ReportedComment> reportedComments = new ArrayList<>();

ChaeAg marked this conversation as resolved.
Show resolved Hide resolved
public void updateProfileStatus(Boolean profileStatus) {
this.isProfilePublic = profileStatus;
}
Expand Down
33 changes: 33 additions & 0 deletions src/main/java/org/websoso/WSSServer/domain/WithdrawalReason.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package org.websoso.WSSServer.domain;

import static jakarta.persistence.GenerationType.IDENTITY;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class WithdrawalReason {

@Id
@GeneratedValue(strategy = IDENTITY)
@Column(nullable = false)
private Long withdrawalReasonId;

@Column(columnDefinition = "varchar(80)", nullable = false)
private String withdrawalReasonContent;

private WithdrawalReason(String withdrawalReasonContent) {
this.withdrawalReasonContent = withdrawalReasonContent;
}

public static WithdrawalReason create(String withdrawalReasonContent) {
return new WithdrawalReason(withdrawalReasonContent);
}
}
ChaeAg marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
package org.websoso.WSSServer.domain.common;

public record DiscordWebhookMessage(
String content
String content,
DiscordWebhookMessageType type
) {
public static DiscordWebhookMessage of(String content) {

public static DiscordWebhookMessage of(String content, DiscordWebhookMessageType type) {
if (content.length() >= 2000) {
content = content.substring(0, 1993) + "\n...```";
}
return new DiscordWebhookMessage(content);
return new DiscordWebhookMessage(content, type);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package org.websoso.WSSServer.domain.common;

public enum DiscordWebhookMessageType {
WITHDRAW, REPORT
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,7 @@
public record WithdrawalRequest(
@Size(max = 80, message = "탈퇴 사유는 80자를 초과할 수 없습니다.")
String reason,

@NotBlank
@NotBlank(message = "리프레시 토큰은 null 이거나, 공백일 수 없습니다.")
ChaeAg marked this conversation as resolved.
Show resolved Hide resolved
String refreshToken
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import static org.springframework.http.HttpStatus.BAD_REQUEST;
import static org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR;
import static org.springframework.http.HttpStatus.NOT_FOUND;

import lombok.AllArgsConstructor;
import lombok.Getter;
Expand All @@ -20,7 +21,8 @@ public enum CustomAppleLoginError implements ICustomError {
UNSUPPORTED_JWT_TYPE("APPLE-006", "지원되지 않는 jwt 타입입니다.", BAD_REQUEST),
EMPTY_JWT("APPLE-007", "비어있는 jwt입니다.", BAD_REQUEST),
JWT_VERIFICATION_FAILED("APPLE-008", "jwt 검증 또는 분석에 실패했습니다.", INTERNAL_SERVER_ERROR),
INVALID_APPLE_KEY("APPLE-009", "잘못된 애플 키입니다.", INTERNAL_SERVER_ERROR);
INVALID_APPLE_KEY("APPLE-009", "잘못된 애플 키입니다.", INTERNAL_SERVER_ERROR),
USER_APPLE_REFRESH_TOKEN_NOT_FOUND("APPLE-010", "유저의 애플 리프레시 토큰을 찾을 수 없습니다.", NOT_FOUND);

private final String code;
private final String description;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import static org.websoso.WSSServer.exception.error.CustomAppleLoginError.PRIVATE_KEY_READ_FAILED;
import static org.websoso.WSSServer.exception.error.CustomAppleLoginError.TOKEN_REQUEST_FAILED;
import static org.websoso.WSSServer.exception.error.CustomAppleLoginError.UNSUPPORTED_JWT_TYPE;
import static org.websoso.WSSServer.exception.error.CustomAppleLoginError.USER_APPLE_REFRESH_TOKEN_NOT_FOUND;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonMappingException;
Expand Down Expand Up @@ -43,17 +44,26 @@
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestClient;
import org.websoso.WSSServer.config.jwt.JwtProvider;
import org.websoso.WSSServer.config.jwt.UserAuthentication;
import org.websoso.WSSServer.domain.RefreshToken;
import org.websoso.WSSServer.domain.User;
import org.websoso.WSSServer.domain.UserAppleToken;
import org.websoso.WSSServer.dto.auth.AppleLoginRequest;
import org.websoso.WSSServer.dto.auth.ApplePublicKey;
import org.websoso.WSSServer.dto.auth.ApplePublicKeys;
import org.websoso.WSSServer.dto.auth.AppleTokenResponse;
import org.websoso.WSSServer.dto.auth.AuthResponse;
import org.websoso.WSSServer.exception.exception.CustomAppleLoginException;
import org.websoso.WSSServer.service.UserService;
import org.websoso.WSSServer.repository.RefreshTokenRepository;
import org.websoso.WSSServer.repository.UserAppleTokenRepository;
import org.websoso.WSSServer.repository.UserRepository;

@Transactional
@Service
@RequiredArgsConstructor
public class AppleService {
Expand All @@ -67,7 +77,10 @@ public class AppleService {
private static final String KEY_ID_HEADER = "kid";
private static final int POSITIVE_SIGN_NUMBER = 1;
private final ObjectMapper objectMapper;
private final UserService userService;
private final RefreshTokenRepository refreshTokenRepository;
private final UserRepository userRepository;
private final UserAppleTokenRepository userAppleTokenRepository;
private final JwtProvider jwtProvider;

@Value("${apple.public-keys-url}")
private String applePublicKeysUrl;
Expand Down Expand Up @@ -107,8 +120,23 @@ public AuthResponse getUserInfoFromApple(AppleLoginRequest request) {
String customSocialId = APPLE_PREFIX + "_" + userIdentifier;
String defaultNickname = APPLE_PREFIX.charAt(0) + "*" + userIdentifier.substring(7, 15);

return userService.authenticateWithApple(customSocialId, email, defaultNickname,
appleTokenResponse.getRefreshToken());
return authenticate(customSocialId, email, defaultNickname, appleTokenResponse.getRefreshToken());
}

public void unlinkFromApple(User user) {
UserAppleToken userAppleToken = userAppleTokenRepository.findByUser(user).orElseThrow(
() -> new CustomAppleLoginException(USER_APPLE_REFRESH_TOKEN_NOT_FOUND,
"cannot find the user Apple refresh token"));

RestClient restClient = RestClient.create();
restClient.post()
.uri(appleAuthUrl + "/auth/revoke")
.headers(headers -> headers.add("Content-Type", "application/x-www-form-urlencoded"))
.body(createUserRevokeParams(createClientSecret(), userAppleToken.getAppleRefreshToken()))
.retrieve()
.body(String.class);

userAppleTokenRepository.delete(userAppleToken);
}

private Map<String, String> parseAppleTokenHeader(String appleToken) {
Expand Down Expand Up @@ -170,7 +198,6 @@ private Claims extractClaims(String appleToken, PublicKey publicKey) {
} catch (IllegalArgumentException e) {
throw new CustomAppleLoginException(EMPTY_JWT, "empty jwt");
} catch (JwtException e) {
System.out.println(e.getMessage());
throw new CustomAppleLoginException(JWT_VERIFICATION_FAILED, "jwt validation or analysis failed");
}
}
Expand All @@ -185,7 +212,6 @@ private String createClientSecret() {

return jwt.serialize();
} catch (Exception e) {
System.out.println(e.getMessage());
throw new CustomAppleLoginException(CLIENT_SECRET_CREATION_FAILED, "failed to generate client secret");
}
}
Expand All @@ -211,20 +237,16 @@ private void signJwt(SignedJWT jwt) {
JWSSigner signer = new ECDSASigner(ecPrivateKey.getS());
jwt.sign(signer);
} catch (Exception e) {
System.out.println(e.getMessage());
throw new CustomAppleLoginException(CLIENT_SECRET_CREATION_FAILED, "failed to create client secret");
}
}

private byte[] readPrivateKey(String keyPath) {
Resource resource = new ClassPathResource(keyPath);
System.out.println("Resource exists: " + resource.exists());
System.out.println("Resource file path: " + resource.getFilename());
try (PemReader pemReader = new PemReader(new InputStreamReader(resource.getInputStream()))) {
PemObject pemObject = pemReader.readPemObject();
return pemObject.getContent();
} catch (IOException e) {
System.out.println(e.getMessage());
throw new CustomAppleLoginException(PRIVATE_KEY_READ_FAILED, "failed to read private key");
}
}
Expand All @@ -239,7 +261,6 @@ private AppleTokenResponse requestAppleToken(String authorizationCode, String cl
.retrieve()
.body(AppleTokenResponse.class);
} catch (Exception e) {
System.out.println(e.getMessage());
throw new CustomAppleLoginException(TOKEN_REQUEST_FAILED, "failed to get token from Apple server");
}
}
Expand All @@ -253,4 +274,33 @@ private MultiValueMap<String, String> createTokenRequestParams(String authorizat
params.add("redirect_uri", appleRedirectUrl);
return params;
}

private AuthResponse authenticate(String socialId, String email, String nickname, String appleRefreshToken) {
User user = userRepository.findBySocialId(socialId);

if (user == null) {
user = userRepository.save(User.createBySocial(socialId, nickname, email));
userAppleTokenRepository.save(UserAppleToken.create(user, appleRefreshToken));
}

UserAuthentication userAuthentication = new UserAuthentication(user.getUserId(), null, null);
String accessToken = jwtProvider.generateAccessToken(userAuthentication);
String refreshToken = jwtProvider.generateRefreshToken(userAuthentication);

refreshTokenRepository.save(new RefreshToken(refreshToken, user.getUserId()));

boolean isRegister = !user.getNickname().contains("*");

return AuthResponse.of(accessToken, refreshToken, isRegister);
}

private MultiValueMap<String, String> createUserRevokeParams(String clientSecret, String appleRefreshToken) {
MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
params.add("grant_type", "refresh_token");
params.add("client_id", appleClientId);
params.add("client_secret", clientSecret);
params.add("token", appleRefreshToken);
params.add("token_type_hint", "refresh_token");
return params;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,6 @@
import org.websoso.WSSServer.dto.auth.AuthResponse;
import org.websoso.WSSServer.exception.exception.CustomKakaoException;
import org.websoso.WSSServer.oauth2.dto.KakaoUserInfo;
import org.websoso.WSSServer.repository.CommentRepository;
import org.websoso.WSSServer.repository.FeedRepository;
import org.websoso.WSSServer.repository.RefreshTokenRepository;
import org.websoso.WSSServer.repository.UserRepository;

Expand All @@ -30,8 +28,6 @@
public class KakaoService {

private final UserRepository userRepository;
private final FeedRepository feedRepository;
private final CommentRepository commentRepository;
private final RefreshTokenRepository refreshTokenRepository;
private final JwtProvider jwtProvider;

Expand Down Expand Up @@ -106,16 +102,10 @@ public void kakaoLogout(User user) {
.toBodilessEntity();
}

public void unlinkFromKakao(User user, String refreshToken) {
refreshTokenRepository.findByRefreshToken(refreshToken).ifPresent(refreshTokenRepository::delete);

public void unlinkFromKakao(User user) {
String socialId = user.getSocialId();
String kakaoUserInfoId = socialId.replaceFirst("kakao_", "");

feedRepository.updateUserToUnknown(user.getUserId());
commentRepository.updateUserToUnknown(user.getUserId());
userRepository.delete(user);

MultiValueMap<String, String> withdrawInfoBodies = new LinkedMultiValueMap<>();
withdrawInfoBodies.add("target_id_type", "user_id");
withdrawInfoBodies.add("target_id", kakaoUserInfoId);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
package org.websoso.WSSServer.repository;

import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import org.websoso.WSSServer.domain.User;
import org.websoso.WSSServer.domain.UserAppleToken;

@Repository
public interface UserAppleTokenRepository extends JpaRepository<UserAppleToken, Long> {

Optional<UserAppleToken> findByUser(User user);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package org.websoso.WSSServer.repository;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import org.websoso.WSSServer.domain.WithdrawalReason;

@Repository
public interface WithdrawalReasonRepository extends JpaRepository<WithdrawalReason, Long> {
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import static org.websoso.WSSServer.domain.common.Action.DELETE;
import static org.websoso.WSSServer.domain.common.Action.UPDATE;
import static org.websoso.WSSServer.domain.common.DiscordWebhookMessageType.REPORT;
import static org.websoso.WSSServer.domain.common.ReportedType.IMPERTINENCE;
import static org.websoso.WSSServer.domain.common.ReportedType.SPOILER;
import static org.websoso.WSSServer.exception.error.CustomCommentError.COMMENT_NOT_FOUND;
Expand Down Expand Up @@ -98,7 +99,7 @@ public void createReportedComment(Feed feed, Long commentId, User user, Reported
messageService.sendDiscordWebhookMessage(
DiscordWebhookMessage.of(
MessageFormatter.formatCommentReportMessage(comment, reportedType, commentCreatedUser,
reportedCount, shouldHide)));
reportedCount, shouldHide), REPORT));
}

private Comment getCommentOrException(Long commentId) {
Expand Down
4 changes: 3 additions & 1 deletion src/main/java/org/websoso/WSSServer/service/FeedService.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import static org.websoso.WSSServer.domain.common.Action.DELETE;
import static org.websoso.WSSServer.domain.common.Action.UPDATE;
import static org.websoso.WSSServer.domain.common.DiscordWebhookMessageType.REPORT;
import static org.websoso.WSSServer.exception.error.CustomFeedError.BLOCKED_USER_ACCESS;
import static org.websoso.WSSServer.exception.error.CustomFeedError.FEED_NOT_FOUND;
import static org.websoso.WSSServer.exception.error.CustomFeedError.HIDDEN_FEED_ACCESS;
Expand Down Expand Up @@ -196,7 +197,8 @@ public void reportFeed(User user, Long feedId, ReportedType reportedType) {

messageService.sendDiscordWebhookMessage(
DiscordWebhookMessage.of(
MessageFormatter.formatFeedReportMessage(feed, reportedType, reportedCount, shouldHide)));
MessageFormatter.formatFeedReportMessage(feed, reportedType, reportedCount, shouldHide),
REPORT));
}

public void reportComment(User user, Long feedId, Long commentId, ReportedType reportedType) {
Expand Down
16 changes: 16 additions & 0 deletions src/main/java/org/websoso/WSSServer/service/MessageFormatter.java
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,13 @@ public class MessageFormatter {
"[신고 횟수]\n총 신고 횟수 %d회.\n" +
"%s\n```";

private static final String USER_WITHDRAW_MESSAGE =
"```[%s] 사용자가 탈퇴하였습니다.\n\n" +
"[탈퇴한 사용자]\n" +
"유저 아이디 : %d\n" +
"유저 닉네임 : %s\n\n" +
"[탈퇴 사유]\n%s\n\n```";

public static String formatFeedReportMessage(Feed feed, ReportedType reportedType, int reportedCount,
boolean isHidden) {
String hiddenMessage = isHidden ? "해당 피드는 숨김 처리되었습니다." : "해당 피드는 숨김 처리되지 않았습니다.";
Expand Down Expand Up @@ -71,4 +78,13 @@ public static String formatCommentReportMessage(Comment comment, ReportedType re
);
}

public static String formatUserWithdrawMessage(Long userId, String userNickname, String reason) {
return String.format(
USER_WITHDRAW_MESSAGE,
LocalDateTime.now().format(DateTimeFormatter.ofPattern(DATE_TIME_FORMAT)),
userId,
userNickname,
reason
);
}
}
Loading
Loading