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

[Feature] 회원탈퇴 구현 #55

Merged
merged 3 commits into from
Oct 4, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
47 changes: 31 additions & 16 deletions src/main/java/project/backend/business/auth/AuthService.java
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import project.backend.business.auth.implement.KakaoLoginManager;
import project.backend.business.user.implement.UserManager;
import project.backend.business.user.implement.UserReader;
import project.backend.entity.token.BlacklistToken;
import project.backend.business.auth.request.TokenServiceRequest;
import project.backend.common.error.CustomException;
Expand All @@ -25,8 +27,10 @@
@RequiredArgsConstructor
public class AuthService {

private final TokenProvider tokenProvider;
private final KakaoLoginManager kakaoLoginManager;
private final UserManager userManager;
private final UserReader userReader;
private final TokenProvider tokenProvider;
private final RefreshTokenRedisRepository refreshTokenRedisRepository;
private final BlacklistTokenRedisRepository blacklistTokenRedisRepository;

Expand All @@ -45,22 +49,14 @@ public TokenServiceResponse kakaoLogin(String code) throws JsonProcessingExcepti

@Transactional
public void logout(TokenServiceRequest tokenServiceRequest) {
String accessToken = tokenServiceRequest.getAccessToken();
String refreshToken = tokenServiceRequest.getRefreshToken();

if (accessToken == null || !tokenProvider.validate(accessToken)) {
throw new CustomException(ErrorCode.INVALID_ACCESS_TOKEN);
}

long expiration = tokenProvider.getExpiration(accessToken);
blacklistTokenRedisRepository.save(BlacklistToken.builder()
.token(accessToken)
.expiration(expiration / 1000)
.build());

refreshTokenRedisRepository.deleteById(refreshToken);
invalidateTokens(tokenServiceRequest);
}

SecurityContextHolder.clearContext();
@Transactional
public void withdraw(Long userId, TokenServiceRequest tokenServiceRequest) {
User user = userReader.readUserById(userId);
userManager.withdrawUser(user);
invalidateTokens(tokenServiceRequest);
}

@Transactional
Expand Down Expand Up @@ -102,6 +98,25 @@ public TokenServiceResponse reissueAccessToken(TokenServiceRequest tokenServiceR
return tokenServiceResponse;
}

private void invalidateTokens(TokenServiceRequest tokenServiceRequest) {
String accessToken = tokenServiceRequest.getAccessToken();
String refreshToken = tokenServiceRequest.getRefreshToken();

if (accessToken == null || !tokenProvider.validate(accessToken)) {
throw new CustomException(ErrorCode.INVALID_ACCESS_TOKEN);
}

long expiration = tokenProvider.getExpiration(accessToken);
blacklistTokenRedisRepository.save(BlacklistToken.builder()
.token(accessToken)
.expiration(expiration / 1000)
.build());

refreshTokenRedisRepository.deleteById(refreshToken);

SecurityContextHolder.clearContext();
}

private void saveRefreshTokenOnRedis(User user, TokenServiceResponse response) {
RefreshToken refreshToken = RefreshToken.builder()
.id(user.getId())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ public User getKakaoUser(String token) throws JsonProcessingException {
String name = kakaoUserInfo.getName();
String profileImageUrl = kakaoUserInfo.getProfileImageUrl();

return userRepository.findByEmail(email)
return userRepository.findByEmailAndActivatedTrue(email)
.orElseGet(() -> userRepository.save(
User.createUser(email, name, profileImageUrl)
));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ public OAuth2User loadUser(OAuth2UserRequest userRequest) {
String name = kakaoUserInfo.getName();
String profileImageUrl = kakaoUserInfo.getProfileImageUrl();

User user = userRepository.findByEmail(email)
User user = userRepository.findByEmailAndActivatedTrue(email)
.orElseGet(
() -> userRepository.save(
User.createUser(email, name, profileImageUrl)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package project.backend.business.user.implement;

import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import project.backend.entity.user.User;
import project.backend.repository.user.UserRepository;

@Component
@RequiredArgsConstructor
public class UserManager {

private final UserRepository userRepository;

public void withdrawUser(User user) {
user.withdraw();
userRepository.save(user);
}
}
7 changes: 5 additions & 2 deletions src/main/java/project/backend/entity/user/User.java
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
import jakarta.persistence.Table;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NoArgsConstructor;
import project.backend.entity.BaseEntity;
Expand All @@ -17,7 +16,6 @@
@Getter
@Table(name = "users")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@EqualsAndHashCode(of = "id", callSuper = false)
public class User extends BaseEntity {

@Id
Expand Down Expand Up @@ -48,4 +46,9 @@ public static User createUser(String email, String name, String profileImageUrl)
.profileImageUrl(profileImageUrl)
.build();
}

public void withdraw() {
this.setActivated(false);
this.email = null;
}
}
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
package project.backend.presentation.archive.request;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import lombok.Getter;
import project.backend.business.archive.request.CreateUpdateArchiveServiceRequest;

@Getter
public class CreateUpdateArchiveRequest {

@NotBlank(message = "아카이브 이름은 필수 입력 값입니다.")
@Size(min = 2, max = 30, message = "아카이브 이름은 2자 이상 30자 이하로 입력해야 합니다.")
@Pattern(regexp = "^[a-zA-Z0-9가-힣 ]*$", message = "아카이브 이름에는 특수 문자를 포함할 수 없습니다.")
private String name;

public CreateUpdateArchiveServiceRequest toServiceRequest() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
Expand All @@ -17,6 +18,8 @@
import project.backend.presentation.auth.docs.AuthControllerDocs;
import project.backend.presentation.auth.util.TokenCookieManager;
import project.backend.presentation.auth.util.TokenExtractor;
import project.backend.security.aop.AssignCurrentUserInfo;
import project.backend.security.aop.CurrentUserInfo;

@RestController
@RequiredArgsConstructor
Expand All @@ -27,7 +30,7 @@ public class AuthController implements AuthControllerDocs {
private final TokenExtractor tokenExtractor;
private final TokenCookieManager tokenCookieManager;

@RequestMapping("/login/kakao")
@PostMapping("/login/kakao")
public ResponseEntity<TokenServiceResponse> loginKakao(
@RequestParam(name = "code") String code,
HttpServletResponse response) throws JsonProcessingException {
Expand Down Expand Up @@ -63,4 +66,18 @@ public ResponseEntity<Void> logout(

return new ResponseEntity<>(HttpStatus.OK);
}

@AssignCurrentUserInfo
@DeleteMapping("/withdraw")
public ResponseEntity<Void> withdraw(
CurrentUserInfo userInfo,
HttpServletRequest request,
HttpServletResponse response) {
TokenServiceRequest tokenServiceRequest = tokenExtractor.extractTokenRequest(request);
authService.withdraw(userInfo.getUserId(), tokenServiceRequest);

tokenCookieManager.removeRefreshTokenCookie(response);

return new ResponseEntity<>(HttpStatus.OK);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,5 @@
@Repository
public interface UserRepository extends JpaRepository<User, Long> {

Optional<User> findByEmail(String email);
Optional<User> findByEmailAndActivatedTrue(String email);
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
@Component
public class AssignCurrentUserInfoAspect {

// @AssignCurrentUserInfo 가 있는 메서드 실행 전에 현재 유저의 ID를 CurrentUserInfo 객체에 할당
// @AssignCurrentUserInfo가 있는 메서드 실행 전에 현재 유저의 ID를 CurrentUserInfo 객체에 할당
@Before("@annotation(project.backend.security.aop.AssignCurrentUserInfo)")
public void assignUserId(JoinPoint joinPoint) {
log.info("Starting assignUserId before method execution.");
Expand All @@ -30,50 +30,40 @@ public void assignUserId(JoinPoint joinPoint) {

if (arg instanceof CurrentUserInfo) {
log.info("Processing argument at index {}: {}", i, arg.getClass().getSimpleName());
getMethod(arg.getClass()).ifPresentOrElse(
setUserId -> {
Long userId = getCurrentUserId();
log.info("Setting userId: {}", userId);
invokeMethod(arg, setUserId, userId);
},
() -> log.warn("No setUserId method found for class: {}",
arg.getClass().getSimpleName())
);
getMethod(arg.getClass()).ifPresentOrElse(setUserId -> {
Long userId = getCurrentUserId();
log.info("Setting userId: {}", userId);
invokeMethod(arg, setUserId, userId);
}, () -> log.warn("No setUserId method found for class: {}", arg.getClass().getSimpleName()));
} else {
log.info("Skipping argument at index {}: Not a CurrentUserInfo instance", i);
}
}
}

// Login 했을 경우 현재 유저의 ID를 CurrentUserInfo 객체에 할당
// Login 하지 않았을 경우 CurrentUserInfo 객체에 null 할당
// 로그인 했을 경우 현재 유저의 ID를 CurrentUserInfo 객체에 할당
// 로그인 하지 않았을 경우 CurrentUserInfo 객체에 null 할당
@Before("@annotation(project.backend.security.aop.AssignOrNullCurrentUserInfo)")
public void assignUserIdOrNull(JoinPoint joinPoint) {
log.info("Starting assignUserIdOrNull before method execution.");
Arrays.stream(joinPoint.getArgs())
.forEach(arg -> {
if (arg == null) {
log.warn("Argument is null, skipping this argument.");
} else {
log.info("Processing argument: {}", arg.getClass().getSimpleName());
getMethod(arg.getClass()).ifPresentOrElse(
setUserId -> {
Long userId = getCurrentUserIdOrNull();
log.info("Setting userId: {}", userId);
invokeMethod(arg, setUserId, userId);
},
() -> log.warn("No setUserId method found for class: {}",
arg.getClass().getSimpleName())
);
}
});
Arrays.stream(joinPoint.getArgs()).forEach(arg -> {
if (arg == null) {
log.warn("Argument is null, skipping this argument.");
} else {
log.info("Processing argument: {}", arg.getClass().getSimpleName());
getMethod(arg.getClass()).ifPresentOrElse(setUserId -> {
Long userId = getCurrentUserIdOrNull();
log.info("Setting userId: {}", userId);
invokeMethod(arg, setUserId, userId);
}, () -> log.warn("No setUserId method found for class: {}", arg.getClass().getSimpleName()));
}
});
}

// arg 객체의 setUserId 메서드를 호출하여 현재 유저의 ID를 할당
private void invokeMethod(Object arg, Method method, Long currentUserId) {
try {
log.info("Invoking method: {} on class: {} with userId: {}", method.getName(),
arg.getClass().getSimpleName(), currentUserId);
log.info("Invoking method: {} on class: {} with userId: {}", method.getName(), arg.getClass().getSimpleName(), currentUserId);
method.invoke(arg, currentUserId);
} catch (ReflectiveOperationException e) {
log.error("Error invoking method: {}", method.getName(), e);
Expand All @@ -94,11 +84,10 @@ private Optional<Method> getMethod(Class<?> clazz) {
// 현재 유저의 ID를 반환
private Long getCurrentUserId() {
log.info("Retrieving current user ID.");
return getCurrentUserIdCheck()
.orElseThrow(() -> {
log.error("No current user ID found.");
return new RuntimeException("User ID not found.");
});
return getCurrentUserIdCheck().orElseThrow(() -> {
log.error("No current user ID found.");
return new RuntimeException("User ID not found.");
});
}

// 현재 유저의 ID가 존재한다면 ID 반환, 없다면 null 반환
Expand All @@ -108,7 +97,7 @@ private Long getCurrentUserIdOrNull() {
}

private Optional<Long> getCurrentUserIdCheck() {
// 현재 SecurityContext 에서 Authentication 객체를 가져옴
// 현재 SecurityContext에서 Authentication 객체를 가져옴
final Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

if (authentication == null || authentication.getPrincipal() == null) {
Expand All @@ -119,19 +108,19 @@ private Optional<Long> getCurrentUserIdCheck() {

Object principal = authentication.getPrincipal();

// principal 이 UserDetails 타입인 경우에만 ID 반환
// principal이 UserDetails 타입인 경우에만 ID 반환
if (principal instanceof KakaoUserDetails kakaoUserDetails) {
log.info("KakaoUserDetails found, user ID: {}", kakaoUserDetails.getId());
return Optional.ofNullable(kakaoUserDetails.getId());
}

// principal 이 UserDetails 가 아닌 경우 예외를 던짐
// principal이 UserDetails가 아닌 경우 예외를 던짐
log.error("Principal is not an instance of KakaoUserDetails.");
throw new CustomException(ErrorCode.NOT_AUTHENTICATED);
}

private Optional<Long> getCurrentUserIdCheckOrNull() {
// 현재 SecurityContext 에서 Authentication 객체를 가져옴
// 현재 SecurityContext에서 Authentication 객체를 가져옴
final Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

if (authentication == null || authentication.getPrincipal() == null) {
Expand All @@ -142,7 +131,7 @@ private Optional<Long> getCurrentUserIdCheckOrNull() {

Object principal = authentication.getPrincipal();

// principal 이 UserDetails 타입인 경우에만 ID 반환
// principal이 UserDetails 타입인 경우에만 ID 반환
if (principal instanceof KakaoUserDetails kakaoUserDetails) {
log.info("KakaoUserDetails found, user ID (or null): {}", kakaoUserDetails.getId());
return Optional.ofNullable(kakaoUserDetails.getId());
Expand Down
4 changes: 4 additions & 0 deletions src/main/java/project/backend/security/jwt/JwtFilter.java
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,10 @@ private static boolean isRequestPassURI(HttpServletRequest request) {
return true;
}

if (request.getRequestURI().equals("/auth/withdraw")) {
return false; // 탈퇴는 필터를 거치도록 설정
}

if (request.getRequestURI().startsWith("/auth")) {
return true;
}
Expand Down
Loading