From c4e370d531bdbbec872954264920c7d674ba898f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=84=B1=EC=A4=80?= Date: Sat, 2 Aug 2025 04:41:19 +0900 Subject: [PATCH 1/2] =?UTF-8?q?[FEAT]=20=ED=9A=8C=EC=9B=90=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=20=EC=88=98=EC=A0=95=20=EB=B9=84=EB=B2=88/=EC=B9=B4?= =?UTF-8?q?=EC=B9=B4=EC=98=A4=20=EB=8F=84=EC=9E=85=20#79?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 5 + .../user/controller/ReauthController.java | 44 +++++++ .../controller/UserProfileController.java | 10 ++ .../user/dto/external/KakaoUserInfo.java | 10 ++ .../user/dto/request/ReauthKakaoRequest.java | 11 ++ .../dto/request/ReauthPasswordRequest.java | 10 ++ .../user/dto/response/ReauthResponse.java | 11 ++ .../service/AuthService/ReauthService.java | 107 ++++++++++++++++++ .../server/global/config/RedisConfig.java | 29 +++++ .../config/security/SecurityConfig.java | 10 +- src/main/resources/application-prod.yml | 7 ++ 11 files changed, 251 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/indayvidual/server/domain/user/controller/ReauthController.java create mode 100644 src/main/java/com/indayvidual/server/domain/user/dto/external/KakaoUserInfo.java create mode 100644 src/main/java/com/indayvidual/server/domain/user/dto/request/ReauthKakaoRequest.java create mode 100644 src/main/java/com/indayvidual/server/domain/user/dto/request/ReauthPasswordRequest.java create mode 100644 src/main/java/com/indayvidual/server/domain/user/dto/response/ReauthResponse.java create mode 100644 src/main/java/com/indayvidual/server/domain/user/service/AuthService/ReauthService.java create mode 100644 src/main/java/com/indayvidual/server/global/config/RedisConfig.java diff --git a/build.gradle b/build.gradle index 0562606..7158028 100644 --- a/build.gradle +++ b/build.gradle @@ -65,6 +65,11 @@ dependencies { // email implementation 'org.springframework.boot:spring-boot-starter-mail' + + // redis + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + implementation 'io.lettuce:lettuce-core' + } jar { diff --git a/src/main/java/com/indayvidual/server/domain/user/controller/ReauthController.java b/src/main/java/com/indayvidual/server/domain/user/controller/ReauthController.java new file mode 100644 index 0000000..81070f4 --- /dev/null +++ b/src/main/java/com/indayvidual/server/domain/user/controller/ReauthController.java @@ -0,0 +1,44 @@ +package com.indayvidual.server.domain.user.controller; + +import com.indayvidual.server.domain.user.dto.request.ReauthKakaoRequest; +import com.indayvidual.server.domain.user.dto.request.ReauthPasswordRequest; +import com.indayvidual.server.domain.user.dto.response.ReauthResponse; +import com.indayvidual.server.domain.user.service.AuthService.ReauthService; +import com.indayvidual.server.global.api.response.ApiResponse; +import com.indayvidual.server.global.config.security.JwtUserPrincipal; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Re-Auth(재인증)", description = "민감 정보 변경 전 재인증(비밀번호/카카오)") +@RestController +@RequestMapping("/api/auth/re-auth") +@RequiredArgsConstructor +public class ReauthController { + private final ReauthService reauthService; + + @Operation(summary = "비밀번호 재인증", description = "현재 비밀번호 검증 후 reauth_token 발급(TTL=5분)") + @PostMapping("/password") + public ResponseEntity> reauthByPassword( + @AuthenticationPrincipal JwtUserPrincipal principal, + @RequestBody @Valid ReauthPasswordRequest req) { + var res = reauthService.reauthByPassword(principal.userId(), req.getCurrentPassword()); + return ResponseEntity.ok(ApiResponse.onSuccess(res)); + } + + @Operation(summary = "카카오 재인증", description = "카카오 accessToken 검증 후 reauth_token 발급(TTL=5분)") + @PostMapping("/kakao") + public ResponseEntity> reauthByKakao( + @AuthenticationPrincipal JwtUserPrincipal principal, + @RequestBody @Valid ReauthKakaoRequest req) { + var res = reauthService.reauthByKakao(principal.userId(), req.getKakaoAccessToken()); + return ResponseEntity.ok(ApiResponse.onSuccess(res)); + } +} diff --git a/src/main/java/com/indayvidual/server/domain/user/controller/UserProfileController.java b/src/main/java/com/indayvidual/server/domain/user/controller/UserProfileController.java index 1215d5a..8f740ce 100644 --- a/src/main/java/com/indayvidual/server/domain/user/controller/UserProfileController.java +++ b/src/main/java/com/indayvidual/server/domain/user/controller/UserProfileController.java @@ -2,6 +2,7 @@ import com.indayvidual.server.domain.user.dto.request.UserRequestDTO; import com.indayvidual.server.domain.user.dto.response.UserResponseDTO; +import com.indayvidual.server.domain.user.service.AuthService.ReauthService; import com.indayvidual.server.domain.user.service.UserService.UserProfileService; import com.indayvidual.server.global.api.response.ApiResponse; import com.indayvidual.server.global.config.security.JwtUserPrincipal; @@ -21,11 +22,14 @@ public class UserProfileController { private final UserProfileService userProfileService; + private final ReauthService reauthService; @GetMapping("/profile") public ResponseEntity> getMyProfile( + @RequestHeader("X-Reauth-Token") String reauthToken, @AuthenticationPrincipal JwtUserPrincipal principal ) { + reauthService.assertReauthOrThrow(principal.userId(), reauthToken, false); var profile = userProfileService.getMyProfile(principal.userId()); return ResponseEntity.ok(ApiResponse.onSuccess(profile)); } @@ -33,8 +37,10 @@ public ResponseEntity> getMyProfile( @PatchMapping("/update_username") public ResponseEntity> updateUsername( @AuthenticationPrincipal JwtUserPrincipal principal, + @RequestHeader("X-Reauth-Token") String reauthToken, @RequestBody @Valid UserRequestDTO.UpdateUsername dto ) { + reauthService.assertReauthOrThrow(principal.userId(), reauthToken, false); userProfileService.updateUsername(principal.userId(), dto.getUsername()); return ResponseEntity.ok(ApiResponse.onSuccess("닉네임이 변경되었습니다.")); } @@ -42,8 +48,10 @@ public ResponseEntity> updateUsername( @PatchMapping("/update_password") public ResponseEntity> updatePassword( @AuthenticationPrincipal JwtUserPrincipal principal, + @RequestHeader("X-Reauth-Token") String reauthToken, @RequestBody @Valid UserRequestDTO.UpdatePassword dto ) { + reauthService.assertReauthOrThrow(principal.userId(), reauthToken, true); // 1회 소비 userProfileService.updatePassword(principal.userId(), dto.getCurrentPassword(), dto.getNewPassword()); return ResponseEntity.ok(ApiResponse.onSuccess("비밀번호가 변경되었습니다.")); } @@ -51,8 +59,10 @@ public ResponseEntity> updatePassword( @PatchMapping(value = "/profile-image", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public ResponseEntity> updateProfileImage( @AuthenticationPrincipal JwtUserPrincipal principal, + @RequestHeader("X-Reauth-Token") String reauthToken, @RequestPart("image") MultipartFile image ) { + reauthService.assertReauthOrThrow(principal.userId(), reauthToken, false); String url = userProfileService.updateProfileImage(principal.userId(), image); return ResponseEntity.ok(ApiResponse.onSuccess(url)); } diff --git a/src/main/java/com/indayvidual/server/domain/user/dto/external/KakaoUserInfo.java b/src/main/java/com/indayvidual/server/domain/user/dto/external/KakaoUserInfo.java new file mode 100644 index 0000000..f1f8048 --- /dev/null +++ b/src/main/java/com/indayvidual/server/domain/user/dto/external/KakaoUserInfo.java @@ -0,0 +1,10 @@ +package com.indayvidual.server.domain.user.dto.external; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.Getter; + +@Getter +@JsonIgnoreProperties(ignoreUnknown = true) +public class KakaoUserInfo { + private Long id; // 카카오 회원번호(필수) +} diff --git a/src/main/java/com/indayvidual/server/domain/user/dto/request/ReauthKakaoRequest.java b/src/main/java/com/indayvidual/server/domain/user/dto/request/ReauthKakaoRequest.java new file mode 100644 index 0000000..63b43fb --- /dev/null +++ b/src/main/java/com/indayvidual/server/domain/user/dto/request/ReauthKakaoRequest.java @@ -0,0 +1,11 @@ +package com.indayvidual.server.domain.user.dto.request; + + +import jakarta.validation.constraints.NotBlank; +import lombok.Getter; + +@Getter +public class ReauthKakaoRequest { + @NotBlank + private String kakaoAccessToken; +} diff --git a/src/main/java/com/indayvidual/server/domain/user/dto/request/ReauthPasswordRequest.java b/src/main/java/com/indayvidual/server/domain/user/dto/request/ReauthPasswordRequest.java new file mode 100644 index 0000000..8d7cdfd --- /dev/null +++ b/src/main/java/com/indayvidual/server/domain/user/dto/request/ReauthPasswordRequest.java @@ -0,0 +1,10 @@ +package com.indayvidual.server.domain.user.dto.request; + +import jakarta.validation.constraints.NotBlank; +import lombok.Getter; + +@Getter +public class ReauthPasswordRequest { + @NotBlank + private String currentPassword; +} \ No newline at end of file diff --git a/src/main/java/com/indayvidual/server/domain/user/dto/response/ReauthResponse.java b/src/main/java/com/indayvidual/server/domain/user/dto/response/ReauthResponse.java new file mode 100644 index 0000000..5ea0239 --- /dev/null +++ b/src/main/java/com/indayvidual/server/domain/user/dto/response/ReauthResponse.java @@ -0,0 +1,11 @@ +package com.indayvidual.server.domain.user.dto.response; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class ReauthResponse { + private String reauthToken; // UUID + private long expiresInSeconds; // 예: 300 +} diff --git a/src/main/java/com/indayvidual/server/domain/user/service/AuthService/ReauthService.java b/src/main/java/com/indayvidual/server/domain/user/service/AuthService/ReauthService.java new file mode 100644 index 0000000..427d6f2 --- /dev/null +++ b/src/main/java/com/indayvidual/server/domain/user/service/AuthService/ReauthService.java @@ -0,0 +1,107 @@ +package com.indayvidual.server.domain.user.service.AuthService; + +import com.indayvidual.server.domain.user.dto.external.KakaoUserInfo; +import com.indayvidual.server.domain.user.dto.response.ReauthResponse; +import com.indayvidual.server.domain.user.entity.User; +import com.indayvidual.server.domain.user.entity.UserProvider; +import com.indayvidual.server.domain.user.entity.enums.Provider; +import com.indayvidual.server.domain.user.repository.UserProviderRepository; +import com.indayvidual.server.domain.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.http.HttpHeaders; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient;// ReauthService.java 내 +import org.springframework.http.HttpStatusCode; +import java.time.Duration; +import java.time.Instant; +import java.util.Objects; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class ReauthService { + + @Value("${kakao.api-base}") + private String kakaoApiBase; + + private final UserRepository userRepository; + private final UserProviderRepository userProviderRepository; + private final PasswordEncoder passwordEncoder; + private final StringRedisTemplate redis; // Lettuce/Jedis 아무거나 + + private static final Duration REAUTH_TTL = Duration.ofMinutes(5); + + public ReauthResponse reauthByPassword(Long userId, String currentPassword) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다.")); + + if (user.getPassword() == null || !passwordEncoder.matches(currentPassword, user.getPassword())) { + throw new IllegalArgumentException("비밀번호가 일치하지 않습니다."); + } + return issueToken(userId, "PASSWORD"); + } + + public ReauthResponse reauthByKakao(Long userId, String kakaoAccessToken) { + // 1) kakaoAccessToken 으로 카카오 유저 정보 조회 (userinfo) + KakaoUserInfo info = fetchKakaoUserInfo(kakaoAccessToken); // 아래 참고 + + // 2) 우리 시스템에 연동된 카카오 계정인지 확인 + User user = userRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다.")); + boolean linked = userProviderRepository + .findByUserAndProvider(user, Provider.KAKAO) + .filter(UserProvider::getIsActive) + .map(up -> Objects.equals(up.getProviderId(), String.valueOf(info.getId()))) + .orElse(false); + + if (!linked) throw new IllegalArgumentException("연결된 카카오 계정이 아닙니다."); + + return issueToken(userId, "KAKAO"); + } + + private ReauthResponse issueToken(Long userId, String method) { + String token = UUID.randomUUID().toString(); + String key = key(userId, token); + String value = "{\"method\":\"" + method + "\",\"ts\":\"" + Instant.now() + "\"}"; + + redis.opsForValue().set(key, value, REAUTH_TTL); + return ReauthResponse.builder() + .reauthToken(token) + .expiresInSeconds(REAUTH_TTL.toSeconds()) + .build(); + } + + public void assertReauthOrThrow(Long userId, String reauthToken, boolean consumeOnce) { + String key = key(userId, reauthToken); + String v = redis.opsForValue().get(key); + if (v == null) throw new IllegalArgumentException("재인증이 필요합니다."); + + if (consumeOnce) { + redis.delete(key); + } + } + + private String key(Long userId, String token) { + return "reauth:" + userId + ":" + token; + } + + // --- Kakao userinfo 호출 --- + private KakaoUserInfo fetchKakaoUserInfo(String kakaoAccessToken) { + WebClient web = WebClient.builder() + .baseUrl(kakaoApiBase) + .defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer " + kakaoAccessToken) + .build(); + + return web.get() + .uri("/v2/user/me") + .retrieve() + .onStatus(HttpStatusCode::isError, rsp -> + rsp.bodyToMono(String.class) + .map(body -> new IllegalArgumentException("Kakao 인증 실패: " + body))) + .bodyToMono(KakaoUserInfo.class) + .block(); + } +} diff --git a/src/main/java/com/indayvidual/server/global/config/RedisConfig.java b/src/main/java/com/indayvidual/server/global/config/RedisConfig.java new file mode 100644 index 0000000..cc96ca7 --- /dev/null +++ b/src/main/java/com/indayvidual/server/global/config/RedisConfig.java @@ -0,0 +1,29 @@ +package com.indayvidual.server.global.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisStandaloneConfiguration; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.StringRedisTemplate; + + +@Configuration +public class RedisConfig { + + @Value("${spring.data.redis.host}") private String host; + @Value("${spring.data.redis.port}") private int port; + @Value("${spring.data.redis.password:}") private String password; + + @Bean + public LettuceConnectionFactory redisConnectionFactory() { + RedisStandaloneConfiguration cfg = new RedisStandaloneConfiguration(host, port); + if (password != null && !password.isBlank()) cfg.setPassword(password); + return new LettuceConnectionFactory(cfg); + } + + @Bean + public StringRedisTemplate stringRedisTemplate(LettuceConnectionFactory factory) { + return new StringRedisTemplate(factory); + } +} diff --git a/src/main/java/com/indayvidual/server/global/config/security/SecurityConfig.java b/src/main/java/com/indayvidual/server/global/config/security/SecurityConfig.java index 346cb69..a86d3c1 100644 --- a/src/main/java/com/indayvidual/server/global/config/security/SecurityConfig.java +++ b/src/main/java/com/indayvidual/server/global/config/security/SecurityConfig.java @@ -3,6 +3,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.http.SessionCreationPolicy; @@ -13,6 +14,7 @@ import org.springframework.web.cors.UrlBasedCorsConfigurationSource; import java.util.Arrays; +import java.util.List; @Configuration @RequiredArgsConstructor @@ -48,10 +50,12 @@ SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { exception.accessDeniedHandler(customAccessDeniedHandler); }) .authorizeHttpRequests(auth -> { + auth.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll(); auth.requestMatchers(AUTH_WHITELIST).permitAll(); auth.requestMatchers(SWAGGER_WHITELIST).permitAll(); auth.requestMatchers("/api/admin/**").hasAuthority("ROLE_ADMIN"); auth.requestMatchers("/api/user/**").hasAuthority("ROLE_USER"); + auth.requestMatchers("/api/auth/re-auth/**").authenticated(); auth.anyRequest().authenticated(); }) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); @@ -62,9 +66,9 @@ SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { @Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration configuration = new CorsConfiguration(); - configuration.setAllowedOriginPatterns(Arrays.asList("*")); - configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS")); - configuration.setAllowedHeaders(Arrays.asList("*")); + configuration.setAllowedOriginPatterns(List.of("*")); + configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")); + configuration.setAllowedHeaders(List.of("*")); configuration.setAllowCredentials(true); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 5e1cfe1..1f52fe7 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -49,6 +49,13 @@ spring: accessKey: ${AWS_ACCESS_KEY_ID} secretKey: ${AWS_SECRET_ACCESS_KEY} + data: + redis: + repositories: + enabled: false + host: ${REDIS_HOST} + port: ${REDIS_PORT:6379} + server: port: 8080 From 6611cce4990dcf1bfff13830a31c8fff0f398645 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=84=B1=EC=A4=80?= Date: Sat, 2 Aug 2025 05:13:43 +0900 Subject: [PATCH 2/2] =?UTF-8?q?[FEAT]=20=ED=9A=8C=EC=9B=90=20=ED=83=88?= =?UTF-8?q?=ED=87=B4=20=EA=B5=AC=ED=98=84=20#79?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/UserProfileController.java | 17 ++++++ .../dto/request/DeleteAccountRequest.java | 8 +++ .../dto/response/SimpleMessageResponse.java | 11 ++++ .../server/domain/user/entity/User.java | 11 ++-- .../repository/RefreshTokenRepository.java | 7 +++ .../UserService/UserProfileService.java | 52 +++++++++++++++++++ 6 files changed, 99 insertions(+), 7 deletions(-) create mode 100644 src/main/java/com/indayvidual/server/domain/user/dto/request/DeleteAccountRequest.java create mode 100644 src/main/java/com/indayvidual/server/domain/user/dto/response/SimpleMessageResponse.java diff --git a/src/main/java/com/indayvidual/server/domain/user/controller/UserProfileController.java b/src/main/java/com/indayvidual/server/domain/user/controller/UserProfileController.java index 8f740ce..597d33c 100644 --- a/src/main/java/com/indayvidual/server/domain/user/controller/UserProfileController.java +++ b/src/main/java/com/indayvidual/server/domain/user/controller/UserProfileController.java @@ -1,11 +1,14 @@ package com.indayvidual.server.domain.user.controller; +import com.indayvidual.server.domain.user.dto.request.DeleteAccountRequest; import com.indayvidual.server.domain.user.dto.request.UserRequestDTO; +import com.indayvidual.server.domain.user.dto.response.SimpleMessageResponse; import com.indayvidual.server.domain.user.dto.response.UserResponseDTO; import com.indayvidual.server.domain.user.service.AuthService.ReauthService; import com.indayvidual.server.domain.user.service.UserService.UserProfileService; import com.indayvidual.server.global.api.response.ApiResponse; import com.indayvidual.server.global.config.security.JwtUserPrincipal; +import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -66,4 +69,18 @@ public ResponseEntity> updateProfileImage( String url = userProfileService.updateProfileImage(principal.userId(), image); return ResponseEntity.ok(ApiResponse.onSuccess(url)); } + + @Operation(summary = "회원 탈퇴", description = "재인증 토큰(X-Reauth-Token) 필요. 기본은 소프트 삭제") + @DeleteMapping + public ResponseEntity> deleteMe( + @AuthenticationPrincipal JwtUserPrincipal principal, + @RequestHeader("X-Reauth-Token") String reauthToken, + @RequestBody(required = false) DeleteAccountRequest req + ) { + if (req == null) req = new DeleteAccountRequest(); // 기본값 + userProfileService.deleteMyAccount(principal.userId(), reauthToken, req); + return ResponseEntity.ok(ApiResponse.onSuccess( + SimpleMessageResponse.builder().message("탈퇴가 완료되었습니다.").build() + )); + } } diff --git a/src/main/java/com/indayvidual/server/domain/user/dto/request/DeleteAccountRequest.java b/src/main/java/com/indayvidual/server/domain/user/dto/request/DeleteAccountRequest.java new file mode 100644 index 0000000..c93bc7b --- /dev/null +++ b/src/main/java/com/indayvidual/server/domain/user/dto/request/DeleteAccountRequest.java @@ -0,0 +1,8 @@ +package com.indayvidual.server.domain.user.dto.request; + +import lombok.Getter; + +@Getter +public class DeleteAccountRequest { + private boolean hard; // true면 하드 삭제 +} diff --git a/src/main/java/com/indayvidual/server/domain/user/dto/response/SimpleMessageResponse.java b/src/main/java/com/indayvidual/server/domain/user/dto/response/SimpleMessageResponse.java new file mode 100644 index 0000000..5e6f649 --- /dev/null +++ b/src/main/java/com/indayvidual/server/domain/user/dto/response/SimpleMessageResponse.java @@ -0,0 +1,11 @@ +package com.indayvidual.server.domain.user.dto.response; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class SimpleMessageResponse { + private String message; +} + diff --git a/src/main/java/com/indayvidual/server/domain/user/entity/User.java b/src/main/java/com/indayvidual/server/domain/user/entity/User.java index 5b9bba5..863267f 100644 --- a/src/main/java/com/indayvidual/server/domain/user/entity/User.java +++ b/src/main/java/com/indayvidual/server/domain/user/entity/User.java @@ -3,6 +3,7 @@ import java.util.ArrayList; import java.util.List; +import lombok.*; import org.hibernate.annotations.DynamicInsert; import org.hibernate.annotations.DynamicUpdate; @@ -23,11 +24,6 @@ import jakarta.persistence.Id; import jakarta.persistence.OneToMany; import jakarta.persistence.Table; -import lombok.AccessLevel; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; @Entity @Getter @@ -52,7 +48,8 @@ public class User extends BaseEntity { @Column(name = "profile_image", length = 512) private String profile_image; - @Enumerated(EnumType.STRING) + @Setter + @Enumerated(EnumType.STRING) private Status status; @Enumerated(EnumType.STRING) @@ -70,7 +67,7 @@ public class User extends BaseEntity { @Builder.Default private List habits = new ArrayList<>(); - public void changeUsername(String username) { this.username = username; } + public void changeUsername(String username) { this.username = username; } public void changePassword(String encodedPassword) { this.password = encodedPassword; } public void changeProfileImage(String imageUrl) { this.profile_image = imageUrl; } diff --git a/src/main/java/com/indayvidual/server/domain/user/repository/RefreshTokenRepository.java b/src/main/java/com/indayvidual/server/domain/user/repository/RefreshTokenRepository.java index d690c70..e83c401 100644 --- a/src/main/java/com/indayvidual/server/domain/user/repository/RefreshTokenRepository.java +++ b/src/main/java/com/indayvidual/server/domain/user/repository/RefreshTokenRepository.java @@ -2,6 +2,9 @@ import com.indayvidual.server.domain.user.entity.RefreshToken; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.time.LocalDateTime; import java.util.Optional; @@ -9,5 +12,9 @@ public interface RefreshTokenRepository extends JpaRepository { Optional findByTokenId(String tokenId); void deleteByExpiredAtBefore(LocalDateTime time); + + @Modifying + @Query("delete from RefreshToken r where r.userId = :userId") + void deleteAllByUserId(@Param("userId") Long userId); } diff --git a/src/main/java/com/indayvidual/server/domain/user/service/UserService/UserProfileService.java b/src/main/java/com/indayvidual/server/domain/user/service/UserService/UserProfileService.java index ecf871e..db1fa92 100644 --- a/src/main/java/com/indayvidual/server/domain/user/service/UserService/UserProfileService.java +++ b/src/main/java/com/indayvidual/server/domain/user/service/UserService/UserProfileService.java @@ -1,8 +1,12 @@ package com.indayvidual.server.domain.user.service.UserService; +import com.indayvidual.server.domain.user.dto.request.DeleteAccountRequest; import com.indayvidual.server.domain.user.dto.response.UserResponseDTO; import com.indayvidual.server.domain.user.entity.User; +import com.indayvidual.server.domain.user.entity.enums.Status; +import com.indayvidual.server.domain.user.repository.RefreshTokenRepository; import com.indayvidual.server.domain.user.repository.UserRepository; +import com.indayvidual.server.domain.user.service.AuthService.ReauthService; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.security.crypto.password.PasswordEncoder; @@ -16,6 +20,8 @@ public class UserProfileService { private final UserRepository userRepository; + private final RefreshTokenRepository refreshTokenRepository; + private final ReauthService reauthService; private final PasswordEncoder passwordEncoder; private final S3Uploader s3Uploader; @@ -74,4 +80,50 @@ public String updateProfileImage(Long userId, MultipartFile image) { } return newUrl; } + + + public void deleteMyAccount(Long userId, String reauthToken, DeleteAccountRequest req) { + // 1) 재인증 토큰 검증 (하드/소프트 공통) + reauthService.assertReauthOrThrow(userId, reauthToken, true); // 1회성 소비 + + // 2) 유저 로드 + User user = userRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다.")); + + // 3) RefreshToken 전부 무효화 + refreshTokenRepository.deleteAllByUserId(userId); + + // (선택) Access token 블랙리스트 등록: 남은 만료시간 만큼만 유지 + // blacklistService.add(accessToken, remainingTtl); // 구현해두면 더 안전 + + // 4) 이미지 정리 (기본이미지 아닌 경우에만 삭제) + String old = user.getProfile_image(); + boolean deletable = old != null && !old.isBlank() && !old.equals(defaultProfileImageUrl); + + if (req.isHard()) { + // ---- 하드 삭제 ---- + if (deletable) s3Uploader.deleteObjectByUrl(old); + userRepository.delete(user); // 연관 엔티티 orphanRemoval=true면 함께 제거 + return; + } + + // ---- 소프트 삭제 ---- + if (deletable) s3Uploader.deleteObjectByUrl(old); + + // 민감 데이터 최소화 + 계정 비활성 + user.changeProfileImage(defaultProfileImageUrl); + user.changeUsername("탈퇴한 사용자"); + user.changePassword(null); // 이메일 비번 로그인 불가(소셜만 있던 계정이면 그대로 null) + + user.setStatus(Status.DELETED); // 상태 전이 (필드가 enum Status에 포함돼야 함) + // 필요 시 deletedAt 필드가 있으면 기록: user.setDeletedAt(LocalDateTime.now()); + + // 이메일 처리 정책 + // 1) 유지: 아무것도 안 함 + // 2) 익명화: user.setEmail("deleted_" + user.getId() + "@example.invalid"); 또는 해시 + // 3) 재사용 허용: 익명화하여 unique 제약 충족 + + // 사유 기록하고 싶으면 별도 테이블에 저장 (UserDeletionLog 등) + // deletionLogRepository.save(UserDeletionLog.of(userId, req.getReason())); + } }