Skip to content

Commit

Permalink
[MERGE] Merge pull request #226 from Team-WSS/feat/#218
Browse files Browse the repository at this point in the history
[FEAT] 애플 소셜 탈퇴 기능 구현
  • Loading branch information
ChaeAg authored Nov 19, 2024
2 parents 2188f2f + b6fdb86 commit ff3983b
Show file tree
Hide file tree
Showing 16 changed files with 202 additions and 61 deletions.
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<>();

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);
}
}
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 이거나, 공백일 수 없습니다.")
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

0 comments on commit ff3983b

Please sign in to comment.