diff --git a/src/main/java/project/backend/business/auth/AuthService.java b/src/main/java/project/backend/business/auth/AuthService.java index 765be32..63516dc 100644 --- a/src/main/java/project/backend/business/auth/AuthService.java +++ b/src/main/java/project/backend/business/auth/AuthService.java @@ -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; @@ -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; @@ -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 @@ -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()) diff --git a/src/main/java/project/backend/business/auth/implement/KakaoLoginManager.java b/src/main/java/project/backend/business/auth/implement/KakaoLoginManager.java index 8949882..00878c7 100644 --- a/src/main/java/project/backend/business/auth/implement/KakaoLoginManager.java +++ b/src/main/java/project/backend/business/auth/implement/KakaoLoginManager.java @@ -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) )); diff --git a/src/main/java/project/backend/business/auth/oauth/KakaoUserDetailsService.java b/src/main/java/project/backend/business/auth/oauth/KakaoUserDetailsService.java index f472b45..da06dbb 100644 --- a/src/main/java/project/backend/business/auth/oauth/KakaoUserDetailsService.java +++ b/src/main/java/project/backend/business/auth/oauth/KakaoUserDetailsService.java @@ -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) diff --git a/src/main/java/project/backend/business/user/implement/UserManager.java b/src/main/java/project/backend/business/user/implement/UserManager.java new file mode 100644 index 0000000..a32c7da --- /dev/null +++ b/src/main/java/project/backend/business/user/implement/UserManager.java @@ -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); + } +} diff --git a/src/main/java/project/backend/entity/user/User.java b/src/main/java/project/backend/entity/user/User.java index d914e9e..1d5ef22 100644 --- a/src/main/java/project/backend/entity/user/User.java +++ b/src/main/java/project/backend/entity/user/User.java @@ -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; @@ -17,7 +16,6 @@ @Getter @Table(name = "users") @NoArgsConstructor(access = AccessLevel.PROTECTED) -@EqualsAndHashCode(of = "id", callSuper = false) public class User extends BaseEntity { @Id @@ -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; + } } diff --git a/src/main/java/project/backend/presentation/archive/request/CreateUpdateArchiveRequest.java b/src/main/java/project/backend/presentation/archive/request/CreateUpdateArchiveRequest.java index bf53a87..89c1e14 100644 --- a/src/main/java/project/backend/presentation/archive/request/CreateUpdateArchiveRequest.java +++ b/src/main/java/project/backend/presentation/archive/request/CreateUpdateArchiveRequest.java @@ -1,6 +1,8 @@ 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; @@ -8,6 +10,8 @@ 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() { diff --git a/src/main/java/project/backend/presentation/auth/AuthController.java b/src/main/java/project/backend/presentation/auth/AuthController.java index 3bfcd8f..3b9d50c 100644 --- a/src/main/java/project/backend/presentation/auth/AuthController.java +++ b/src/main/java/project/backend/presentation/auth/AuthController.java @@ -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; @@ -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 @@ -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 loginKakao( @RequestParam(name = "code") String code, HttpServletResponse response) throws JsonProcessingException { @@ -63,4 +66,18 @@ public ResponseEntity logout( return new ResponseEntity<>(HttpStatus.OK); } + + @AssignCurrentUserInfo + @DeleteMapping("/withdraw") + public ResponseEntity 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); + } } diff --git a/src/main/java/project/backend/repository/user/UserRepository.java b/src/main/java/project/backend/repository/user/UserRepository.java index f3d30df..7ee5200 100644 --- a/src/main/java/project/backend/repository/user/UserRepository.java +++ b/src/main/java/project/backend/repository/user/UserRepository.java @@ -8,5 +8,5 @@ @Repository public interface UserRepository extends JpaRepository { - Optional findByEmail(String email); + Optional findByEmailAndActivatedTrue(String email); } diff --git a/src/main/java/project/backend/security/aop/AssignCurrentUserInfoAspect.java b/src/main/java/project/backend/security/aop/AssignCurrentUserInfoAspect.java index b010628..2853ea3 100644 --- a/src/main/java/project/backend/security/aop/AssignCurrentUserInfoAspect.java +++ b/src/main/java/project/backend/security/aop/AssignCurrentUserInfoAspect.java @@ -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."); @@ -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); @@ -94,11 +84,10 @@ private Optional 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 반환 @@ -108,7 +97,7 @@ private Long getCurrentUserIdOrNull() { } private Optional getCurrentUserIdCheck() { - // 현재 SecurityContext 에서 Authentication 객체를 가져옴 + // 현재 SecurityContext에서 Authentication 객체를 가져옴 final Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); if (authentication == null || authentication.getPrincipal() == null) { @@ -119,19 +108,19 @@ private Optional 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 getCurrentUserIdCheckOrNull() { - // 현재 SecurityContext 에서 Authentication 객체를 가져옴 + // 현재 SecurityContext에서 Authentication 객체를 가져옴 final Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); if (authentication == null || authentication.getPrincipal() == null) { @@ -142,7 +131,7 @@ private Optional 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()); diff --git a/src/main/java/project/backend/security/jwt/JwtFilter.java b/src/main/java/project/backend/security/jwt/JwtFilter.java index b1eb963..855f800 100644 --- a/src/main/java/project/backend/security/jwt/JwtFilter.java +++ b/src/main/java/project/backend/security/jwt/JwtFilter.java @@ -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; }