diff --git a/Dockerfile b/Dockerfile index dbf4550..2340f1c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,6 +10,8 @@ RUN mvn clean package -DskipTests -B FROM eclipse-temurin:21-jre +ENV TZ=Asia/Seoul + RUN apt-get update \ && apt-get install -y --no-install-recommends curl \ && rm -rf /var/lib/apt/lists/* diff --git a/pom.xml b/pom.xml index 4c418f3..d8b1cf5 100644 --- a/pom.xml +++ b/pom.xml @@ -59,10 +59,7 @@ lombok true - - org.springframework.cloud - spring-cloud-netflix-eureka-client - + org.springframework.boot spring-boot-starter-test @@ -126,15 +123,43 @@ spring-boot-starter-mail + + + org.springframework.cloud + spring-cloud-starter-openfeign + + + + org.springframework.cloud + spring-cloud-starter-loadbalancer + + + + org.springframework.cloud + spring-cloud-starter-circuitbreaker-resilience4j + + io.micrometer micrometer-registry-prometheus + - org.springframework.cloud - spring-cloud-config-client + io.micrometer + micrometer-tracing-bridge-brave + + + io.zipkin.reporter2 + zipkin-reporter-brave + + + + + org.redisson + redisson-spring-boot-starter + 3.52.0 diff --git a/src/main/java/com/nhnacademy/insightonauth/InsightonAuthApplication.java b/src/main/java/com/nhnacademy/insightonauth/InsightonAuthApplication.java index 8820c31..e9bc098 100644 --- a/src/main/java/com/nhnacademy/insightonauth/InsightonAuthApplication.java +++ b/src/main/java/com/nhnacademy/insightonauth/InsightonAuthApplication.java @@ -3,9 +3,13 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.cloud.client.discovery.EnableDiscoveryClient; +import org.springframework.cloud.openfeign.EnableFeignClients; +import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication @EnableDiscoveryClient +@EnableFeignClients +@EnableScheduling public class InsightonAuthApplication { public static void main(String[] args) { diff --git a/src/main/java/com/nhnacademy/insightonauth/client/CoreClient.java b/src/main/java/com/nhnacademy/insightonauth/client/CoreClient.java new file mode 100644 index 0000000..b0d05ef --- /dev/null +++ b/src/main/java/com/nhnacademy/insightonauth/client/CoreClient.java @@ -0,0 +1,13 @@ +package com.nhnacademy.insightonauth.client; + +import com.nhnacademy.insightonauth.dto.core.ManagerGroupExistsResponse; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; + +@FeignClient(name = "insighton-core") +public interface CoreClient { + + @GetMapping("/internal/v1/users/{userId}/manager-groups/exists") + ManagerGroupExistsResponse existsManagerGroup(@PathVariable Long userId); +} diff --git a/src/main/java/com/nhnacademy/insightonauth/client/OauthClient.java b/src/main/java/com/nhnacademy/insightonauth/client/OauthClient.java index 123875f..7ae7851 100644 --- a/src/main/java/com/nhnacademy/insightonauth/client/OauthClient.java +++ b/src/main/java/com/nhnacademy/insightonauth/client/OauthClient.java @@ -1,65 +1,8 @@ package com.nhnacademy.insightonauth.client; import com.nhnacademy.insightonauth.dto.oauth.OauthUserInfo; -import lombok.RequiredArgsConstructor; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.MediaType; -import org.springframework.stereotype.Component; -import org.springframework.util.LinkedMultiValueMap; -import org.springframework.util.MultiValueMap; -import org.springframework.web.client.RestClient; -import java.util.Map; +public interface OauthClient { -@Component -@RequiredArgsConstructor -public class OauthClient { - - @Value("${oauth.google.client-id}") - private String clientId; - - @Value("${oauth.google.client-secret}") - private String clientSecret; - - @Value("${oauth.google.redirect-uri}") - private String redirectUri; - - private final RestClient restClient = RestClient.create(); - - public OauthUserInfo getUserInfo(String provider, String code) { - String accessToken = requestAccessToken(code); - return requestUserInfo(accessToken); - } - - private String requestAccessToken(String code) { - MultiValueMap body = new LinkedMultiValueMap<>(); - body.add("code", code); - body.add("client_id", clientId); - body.add("client_secret", clientSecret); - body.add("redirect_uri", redirectUri); - body.add("grant_type", "authorization_code"); - - Map response = restClient.post() - .uri("https://oauth2.googleapis.com/token") - .contentType(MediaType.APPLICATION_FORM_URLENCODED) - .body(body) - .retrieve() - .body(Map.class); - - return (String) response.get("access_token"); - } - - private OauthUserInfo requestUserInfo(String accessToken) { - Map userInfo = restClient.get() - .uri("https://www.googleapis.com/oauth2/v3/userinfo") - .header("Authorization", "Bearer " + accessToken) - .retrieve() - .body(Map.class); - - return new OauthUserInfo( - (String) userInfo.get("email"), - (String) userInfo.get("name"), - (String) userInfo.get("sub") - ); - } + OauthUserInfo getUserInfo(String code); } diff --git a/src/main/java/com/nhnacademy/insightonauth/client/OauthClientResolver.java b/src/main/java/com/nhnacademy/insightonauth/client/OauthClientResolver.java new file mode 100644 index 0000000..8e880bc --- /dev/null +++ b/src/main/java/com/nhnacademy/insightonauth/client/OauthClientResolver.java @@ -0,0 +1,22 @@ +package com.nhnacademy.insightonauth.client; + +import com.nhnacademy.insightonauth.exception.UnsupportedOAuthProviderException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Map; + +@Component +@RequiredArgsConstructor +public class OauthClientResolver { + // 스프링이 bean 객체를 가져다줌 + private final Map clients; + + public OauthClient resolve(String provider) { + OauthClient client = clients.get(provider + "OauthClient"); + if (client == null) { + throw new UnsupportedOAuthProviderException(provider); + } + return client; + } +} diff --git a/src/main/java/com/nhnacademy/insightonauth/client/impl/GithubOauthClient.java b/src/main/java/com/nhnacademy/insightonauth/client/impl/GithubOauthClient.java new file mode 100644 index 0000000..882b302 --- /dev/null +++ b/src/main/java/com/nhnacademy/insightonauth/client/impl/GithubOauthClient.java @@ -0,0 +1,103 @@ +package com.nhnacademy.insightonauth.client.impl; + +import com.nhnacademy.insightonauth.client.OauthClient; +import com.nhnacademy.insightonauth.dto.oauth.OauthUserInfo; +import com.nhnacademy.insightonauth.exception.EmailNotFoundException; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.http.client.ClientHttpRequestFactoryBuilder; +import org.springframework.boot.http.client.ClientHttpRequestFactorySettings; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestClient; + +import java.time.Duration; +import java.util.List; +import java.util.Map; + +@Component("githubOauthClient") +@RequiredArgsConstructor +public class GithubOauthClient implements OauthClient { + + @Value("${oauth.github.client-id}") + private String clientId; + + @Value("${oauth.github.client-secret}") + private String clientSecret; + + @Value("${oauth.redirect-uri}") + private String redirectUri; + + private final RestClient restClient = RestClient.builder() + .requestFactory(ClientHttpRequestFactoryBuilder.detect() + .build(ClientHttpRequestFactorySettings.defaults() + .withConnectTimeout(Duration.ofSeconds(3)) + .withReadTimeout(Duration.ofSeconds(5)))) + .build(); + + @Override + public OauthUserInfo getUserInfo(String code) { + String accessToken = requestAccessToken(code); + return requestUserInfo(accessToken); + } + + private String requestAccessToken(String code) { + MultiValueMap body = new LinkedMultiValueMap<>(); + body.add("code", code); + body.add("client_id", clientId); + body.add("client_secret", clientSecret); + body.add("redirect_uri", redirectUri); + + Map response = restClient.post() + .uri("https://github.com/login/oauth/access_token") // GitHub URL로 수정 + .header("Accept", "application/json") // JSON 응답 요청 (필수) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .body(body) + .retrieve() + .body(Map.class); + + return (String) response.get("access_token"); + } + + private OauthUserInfo requestUserInfo(String accessToken) { + Map userInfo = restClient.get() + .uri("https://api.github.com/user") // GitHub URL로 수정 + .header("Authorization", "Bearer " + accessToken) + .retrieve() + .body(Map.class); + + String email = requestPrimaryEmail(accessToken); + + String name = (String) userInfo.get("name"); + if (name == null || name.isBlank()) { + name = (String) userInfo.get("login"); // name이 없으면 login(아이디)으로 대체 + } + + Object id = userInfo.get("id"); // GitHub은 sub가 아니라 id (숫자) + + return new OauthUserInfo( + email, + name, + String.valueOf(id) + ); + } + + // 이메일 열람 불가의 경우 api를 통해서 가져와야함 + private String requestPrimaryEmail(String accessToken) { + List> emails = restClient.get() + .uri("https://api.github.com/user/emails") + .header("Authorization", "Bearer " + accessToken) + .retrieve() + .body(List.class); + + // primary git에서 대표로 지정된 이메일 가져옴, verified 그 중에 인증된거 가져옴 + return emails.stream() + .filter(e -> + Boolean.TRUE.equals(e.get("primary")) && Boolean.TRUE.equals(e.get("verified"))) + .map(e -> (String) e.get("email")) + .findFirst() + .orElseThrow(() -> new EmailNotFoundException("GitHub 계정에서 이메일을 찾을 수 없습니다.")); + } +} diff --git a/src/main/java/com/nhnacademy/insightonauth/client/impl/GoogleOauthClient.java b/src/main/java/com/nhnacademy/insightonauth/client/impl/GoogleOauthClient.java new file mode 100644 index 0000000..ac5e2c0 --- /dev/null +++ b/src/main/java/com/nhnacademy/insightonauth/client/impl/GoogleOauthClient.java @@ -0,0 +1,76 @@ +package com.nhnacademy.insightonauth.client.impl; + +import com.nhnacademy.insightonauth.client.OauthClient; +import com.nhnacademy.insightonauth.dto.oauth.OauthUserInfo; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.http.client.ClientHttpRequestFactoryBuilder; +import org.springframework.boot.http.client.ClientHttpRequestFactorySettings; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestClient; + +import java.time.Duration; +import java.util.Map; + +@Component("googleOauthClient") +@RequiredArgsConstructor +public class GoogleOauthClient implements OauthClient { + + @Value("${oauth.google.client-id}") + private String clientId; + + @Value("${oauth.google.client-secret}") + private String clientSecret; + + @Value("${oauth.redirect-uri}") + private String redirectUri; + + private final RestClient restClient = RestClient.builder() + .requestFactory(ClientHttpRequestFactoryBuilder.detect() + .build(ClientHttpRequestFactorySettings.defaults() + .withConnectTimeout(Duration.ofSeconds(3)) + .withReadTimeout(Duration.ofSeconds(5)))) + .build(); + + @Override + public OauthUserInfo getUserInfo(String code) { + String accessToken = requestAccessToken(code); + return requestUserInfo(accessToken); + } + + private String requestAccessToken(String code) { +// FormHttpMessageConverter + MultiValueMap body = new LinkedMultiValueMap<>(); + body.add("code", code); + body.add("client_id", clientId); + body.add("client_secret", clientSecret); + body.add("redirect_uri", redirectUri); + body.add("grant_type", "authorization_code"); + + Map response = restClient.post() + .uri("https://oauth2.googleapis.com/token") + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .body(body) + .retrieve() + .body(Map.class); + + return (String) response.get("access_token"); + } + + private OauthUserInfo requestUserInfo(String accessToken) { + Map userInfo = restClient.get() + .uri("https://www.googleapis.com/oauth2/v3/userinfo") + .header("Authorization", "Bearer " + accessToken) + .retrieve() + .body(Map.class); + + return new OauthUserInfo( + (String) userInfo.get("email"), + (String) userInfo.get("name"), + (String) userInfo.get("sub") + ); + } +} diff --git a/src/main/java/com/nhnacademy/insightonauth/controller/AdminController.java b/src/main/java/com/nhnacademy/insightonauth/controller/AdminController.java index b31fc66..e744613 100644 --- a/src/main/java/com/nhnacademy/insightonauth/controller/AdminController.java +++ b/src/main/java/com/nhnacademy/insightonauth/controller/AdminController.java @@ -1,7 +1,6 @@ package com.nhnacademy.insightonauth.controller; -import com.nhnacademy.insightonauth.dto.ApiResponse; import com.nhnacademy.insightonauth.dto.admin.AdminFindUsersResponse; import com.nhnacademy.insightonauth.dto.admin.AdminUserDetailResponse; import com.nhnacademy.insightonauth.dto.admin.RoleChangeRequest; @@ -24,41 +23,41 @@ public class AdminController { // 회원 목록 조회 (검색·페이징) @GetMapping("/users") - public ResponseEntity>> findUsers( + public ResponseEntity> findUsers( @RequestParam(required = false) String email, @RequestParam(required = false) String userName, @RequestParam(required = false) Status status, Pageable pageable) { Page response = adminUserService.findUsers(email, userName, status, pageable); - return ResponseEntity.ok(new ApiResponse<>(response)); + return ResponseEntity.ok(response); } // 회원 상세 조회 @GetMapping("/users/{userId}") - public ResponseEntity> findUserDetail(@PathVariable Long userId) { + public ResponseEntity findUserDetail(@PathVariable Long userId) { AdminUserDetailResponse response = adminUserService.findUserDetail(userId); - return ResponseEntity.ok(new ApiResponse<>(response)); + return ResponseEntity.ok(response); } // 회원 상태 변경 @PutMapping("/users/{userId}/status") - public ResponseEntity> changeStatus( + public ResponseEntity changeStatus( @PathVariable Long userId, @RequestBody @Valid StatusChangeRequest request) { adminUserService.changeStatus(userId, request.status()); - return ResponseEntity.ok(new ApiResponse<>(null)); + return ResponseEntity.ok().build(); } // 회원 권한 변경 @PutMapping("/users/{userId}/roles") - public ResponseEntity> changeRole( + public ResponseEntity changeRole( @PathVariable Long userId, @RequestBody @Valid RoleChangeRequest request) { adminUserService.addUserRole(userId, request.role()); - return ResponseEntity.ok(new ApiResponse<>(null)); + return ResponseEntity.ok().build(); } // 회원 삭제 (실제로는 상태 변경 처리) diff --git a/src/main/java/com/nhnacademy/insightonauth/controller/CoreController.java b/src/main/java/com/nhnacademy/insightonauth/controller/CoreController.java new file mode 100644 index 0000000..3138435 --- /dev/null +++ b/src/main/java/com/nhnacademy/insightonauth/controller/CoreController.java @@ -0,0 +1,27 @@ +package com.nhnacademy.insightonauth.controller; + +import com.nhnacademy.insightonauth.dto.core.AuthUserResponse; +import com.nhnacademy.insightonauth.entity.User; +import com.nhnacademy.insightonauth.service.UserService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/internal/v1/users") +@RequiredArgsConstructor +public class CoreController { + + private final UserService userService; + + @GetMapping("{userId}") + public ResponseEntity getUserById(@PathVariable("userId") Long userId) { + User user = userService.findById(userId); + + return ResponseEntity.ok( + new AuthUserResponse(user.getUserId(), user.getUserName(), user.getPhoneNumber(), user.getStatus())); + } +} diff --git a/src/main/java/com/nhnacademy/insightonauth/controller/MypageController.java b/src/main/java/com/nhnacademy/insightonauth/controller/MypageController.java index dea0265..1a47404 100644 --- a/src/main/java/com/nhnacademy/insightonauth/controller/MypageController.java +++ b/src/main/java/com/nhnacademy/insightonauth/controller/MypageController.java @@ -1,6 +1,5 @@ package com.nhnacademy.insightonauth.controller; -import com.nhnacademy.insightonauth.dto.ApiResponse; import com.nhnacademy.insightonauth.dto.mypage.MyInfoResponse; import com.nhnacademy.insightonauth.dto.mypage.PasswordChangeRequest; import com.nhnacademy.insightonauth.dto.mypage.RoleResponse; @@ -32,16 +31,16 @@ public class MypageController { // 내 정보 조회 @GetMapping("/me") - public ResponseEntity> findMyInfo( + public ResponseEntity findMyInfo( @RequestHeader(name = X_USER_ID) @Valid Long userId) { MyInfoResponse response = myPageService.findMyInfo(userId); - return ResponseEntity.ok(new ApiResponse<>(response)); + return ResponseEntity.ok(response); } // 내 정보 수정 @PutMapping("/me") - public ResponseEntity> updateMyInfo( + public ResponseEntity updateMyInfo( @RequestHeader(name = X_USER_ID) @Valid Long userId, @RequestBody @Valid MyInfoUpdateRequest request) { @@ -59,7 +58,7 @@ public ResponseEntity withdraw(@RequestHeader(name = X_USER_ID) @Valid Lon // 비밀번호 변경 @PutMapping("/me/password") - public ResponseEntity> changePassword( + public ResponseEntity changePassword( @RequestHeader(name = X_USER_ID) @Valid Long userId, @RequestBody @Valid PasswordChangeRequest request) { @@ -69,25 +68,25 @@ public ResponseEntity> changePassword( // 내 권한 목록 조회 @GetMapping("/me/roles") - public ResponseEntity>> findMyRoles( + public ResponseEntity> findMyRoles( @RequestHeader(name = X_USER_ID) @Valid Long userId) { List response = myPageService.findMyRoles(userId); - return ResponseEntity.ok(new ApiResponse<>(response)); + return ResponseEntity.ok(response); } // 연동 소셜 계정 목록 @GetMapping("/me/oauths") - public ResponseEntity>> findMyOauths( + public ResponseEntity> findMyOauths( @RequestHeader(name = X_USER_ID) @Valid Long userId) { List response = myPageService.findMyOauths(userId); - return ResponseEntity.ok(new ApiResponse<>(response)); + return ResponseEntity.ok(response); } // 소셜 계정 신규 연동 @PostMapping("/me/oauths/{provider}") - public ResponseEntity> linkOauth( + public ResponseEntity linkOauth( @RequestHeader(name = X_USER_ID) @Valid Long userId, @PathVariable String provider, @RequestBody @Valid OauthLoginRequest request) { @@ -106,5 +105,4 @@ public ResponseEntity unlinkOauth( oauthService.delete(user, oauthId); return ResponseEntity.noContent().build(); } - } diff --git a/src/main/java/com/nhnacademy/insightonauth/controller/UserController.java b/src/main/java/com/nhnacademy/insightonauth/controller/UserController.java index 3b1b2e0..b86b905 100644 --- a/src/main/java/com/nhnacademy/insightonauth/controller/UserController.java +++ b/src/main/java/com/nhnacademy/insightonauth/controller/UserController.java @@ -1,16 +1,19 @@ package com.nhnacademy.insightonauth.controller; -import com.nhnacademy.insightonauth.dto.*; import com.nhnacademy.insightonauth.dto.auth.*; import com.nhnacademy.insightonauth.dto.oauth.OauthLoginRequest; import com.nhnacademy.insightonauth.entity.Role; import com.nhnacademy.insightonauth.service.UserService; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseCookie; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import java.time.Duration; + @RestController @RequestMapping("/api/v1/auth") @RequiredArgsConstructor @@ -23,114 +26,125 @@ public class UserController { @PostMapping("/email/verify-request") public ResponseEntity sendEmailVerify(@RequestBody @Valid EmailVerifyRequest emailVerifyRequest) { userService.emailVerifyRequest(emailVerifyRequest.email()); - return ResponseEntity.noContent().build(); } @PostMapping("/email/verify-confirm") - public ResponseEntity> emailCodeConfirm( + public ResponseEntity emailCodeConfirm( @RequestBody @Valid EmailVerifyConfirmRequest emailVerifyConfirmRequest) { String verificationToken = userService.emailVerifyConfirm(emailVerifyConfirmRequest.email(), emailVerifyConfirmRequest.code()); - return ResponseEntity.ok(new ApiResponse<>(new EmailVerifyConfirmResponse(verificationToken))); + return ResponseEntity.ok(new EmailVerifyConfirmResponse(verificationToken)); } @PostMapping("/check-email") - public ResponseEntity> checkEmailAvailable( + public ResponseEntity checkEmailAvailable( @RequestBody @Valid EmailAvailableRequest emailAvailableRequest) { boolean available = userService.checkEmailAvailable(emailAvailableRequest.email()); - return ResponseEntity.ok(new ApiResponse<>(new EmailAvailableResponse(available))); + return ResponseEntity.ok(new EmailAvailableResponse(available)); } @PostMapping("/signup") - public ResponseEntity> doSignup( + public ResponseEntity doSignup( @RequestBody @Valid UserSignupRequest userSignupRequest) { UserSignupResponse userSignupResponse = userService.createUser(userSignupRequest.email(), - userSignupRequest.password(), - userSignupRequest.userName(), - userSignupRequest.phoneNumber(), - Role.MEMBER, - userSignupRequest.token()); - - return ResponseEntity.status(HttpStatus.CREATED) - .body(new ApiResponse<>(userSignupResponse)); + userSignupRequest.password(), + userSignupRequest.userName(), + userSignupRequest.phoneNumber(), + Role.MEMBER, + userSignupRequest.token()); + + return ResponseEntity.status(HttpStatus.CREATED).body(userSignupResponse); } @PostMapping("/login") - public ResponseEntity> doLogin( + public ResponseEntity doLogin( @RequestBody @Valid UserLoginRequest userLoginRequest) { UserLoginResponse userLoginResponse = userService.login(userLoginRequest.email(), userLoginRequest.password()); - return ResponseEntity.ok(new ApiResponse<>(userLoginResponse)); + // 도커용 + ResponseCookie refreshCookie = ResponseCookie.from("refreshToken", userLoginResponse.refreshToken()) + .httpOnly(true) + .secure(true) // https라 true + .path("/") + .sameSite("Lax") + .maxAge(Duration.ofDays(7)) + .build(); // domain 안 박음 + + return ResponseEntity.ok() + .header(HttpHeaders.SET_COOKIE, refreshCookie.toString()) // refresh 쿠키 헤더에 설정 + .body(userLoginResponse.accessToken()); + } @PostMapping("/logout") public ResponseEntity doLogout(@RequestHeader(name = X_USER_ID) @Valid Long userId) { userService.logout(userId); - return ResponseEntity.noContent().build(); } + @PostMapping("/reactive") - public ResponseEntity> userReactive( + public ResponseEntity userReactive( @RequestBody @Valid ReactiveRequest request) { UserLoginResponse response = userService.reactive(request.reactiveToken()); - return ResponseEntity.ok(new ApiResponse<>(response)); + return ResponseEntity.ok(response); } @PostMapping("/reactivate/email-verify-request") - public ResponseEntity userReactive(@RequestBody @Valid EmailVerifyRequest emailVerifyRequest) { + public ResponseEntity userReactivateRequest(@RequestBody @Valid EmailVerifyRequest emailVerifyRequest) { userService.reactivateRequest(emailVerifyRequest.email()); - return ResponseEntity.noContent().build(); } @PostMapping("/reactivate/email-verify-confirm") - public ResponseEntity> userReactiveConfirm(@RequestBody @Valid EmailVerifyConfirmRequest emailVerifyConfirmRequest) { - UserLoginResponse userLoginResponse = userService.reactivateConfirm(emailVerifyConfirmRequest.email(), emailVerifyConfirmRequest.code()); + public ResponseEntity userReactiveConfirm( + @RequestBody @Valid EmailVerifyConfirmRequest emailVerifyConfirmRequest) { + UserLoginResponse userLoginResponse = + userService.reactivateConfirm(emailVerifyConfirmRequest.email(), emailVerifyConfirmRequest.code()); - return ResponseEntity.ok(new ApiResponse<>(userLoginResponse)); + return ResponseEntity.ok(userLoginResponse); } @PostMapping("/find-email") - public ResponseEntity> findEmail(@RequestBody @Valid FindEmailRequest findEmailRequest) { + public ResponseEntity findEmail(@RequestBody @Valid FindEmailRequest findEmailRequest) { String email = userService.findMaskedEmail(findEmailRequest.userName(), findEmailRequest.phoneNumber()); - return ResponseEntity.ok(new ApiResponse<>(email)); + return ResponseEntity.ok(email); } @PostMapping("/password/reset-request") - public ResponseEntity> passwordReset(@RequestBody @Valid PasswordResetRequest passwordResetRequest) { + public ResponseEntity passwordReset(@RequestBody @Valid PasswordResetRequest passwordResetRequest) { userService.passwordResetRequest(passwordResetRequest.email()); - - return ResponseEntity.ok(new ApiResponse<>(null)); + return ResponseEntity.noContent().build(); } @PostMapping("/password/reset-confirm") - public ResponseEntity passwordResetConfirm(@RequestBody @Valid PasswordResetConfirmRequest passwordResetConfirmRequest) { + public ResponseEntity passwordResetConfirm( + @RequestBody @Valid PasswordResetConfirmRequest passwordResetConfirmRequest) { userService.passwordResetConfirm(passwordResetConfirmRequest.token(), passwordResetConfirmRequest.password()); return ResponseEntity.ok().build(); } @PostMapping("/oauth/{provider}") - public ResponseEntity> oauthLogin( + public ResponseEntity oauthLogin( @PathVariable String provider, @RequestBody @Valid OauthLoginRequest request) { UserLoginResponse response = userService.oauthLogin(provider, request.code()); - return ResponseEntity.ok(new ApiResponse<>(response)); + return ResponseEntity.ok(response); } @PostMapping("/refresh") - public ResponseEntity> refresh( + public ResponseEntity refresh( @RequestHeader(name = X_USER_ID) @Valid Long userId, @CookieValue("refreshToken") String refreshToken) { TokenRefreshResponse tokenRefreshResponse = userService.refresh(userId, refreshToken); - return ResponseEntity.ok(new ApiResponse<>(tokenRefreshResponse)); + return ResponseEntity.ok(tokenRefreshResponse); } } diff --git a/src/main/java/com/nhnacademy/insightonauth/dto/ApiResponse.java b/src/main/java/com/nhnacademy/insightonauth/dto/ApiResponse.java deleted file mode 100644 index 5db731b..0000000 --- a/src/main/java/com/nhnacademy/insightonauth/dto/ApiResponse.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.nhnacademy.insightonauth.dto; - -import lombok.Getter; - -@Getter -public class ApiResponse { - private final boolean success; - private final T data; - private final ErrorResponse error; - - // 성공 응답용 생성자 - public ApiResponse(T data) { - this.success = true; - this.data = data; - this.error = null; - } - - public ApiResponse(String code, String message) { - this.success = false; - this.data = null; - this.error = new ErrorResponse(code, message); - } -} diff --git a/src/main/java/com/nhnacademy/insightonauth/dto/ErrorResponse.java b/src/main/java/com/nhnacademy/insightonauth/dto/ErrorResponse.java deleted file mode 100644 index f44eba7..0000000 --- a/src/main/java/com/nhnacademy/insightonauth/dto/ErrorResponse.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.nhnacademy.insightonauth.dto; - -public class ErrorResponse { - private final String code; - private final String message; - - public ErrorResponse(String code, String message) { - this.code = code; - this.message = message; - } - - public String getCode() { - return code; - } - - public String getMessage() { - return message; - } -} diff --git a/src/main/java/com/nhnacademy/insightonauth/dto/core/AuthUserResponse.java b/src/main/java/com/nhnacademy/insightonauth/dto/core/AuthUserResponse.java new file mode 100644 index 0000000..2760afb --- /dev/null +++ b/src/main/java/com/nhnacademy/insightonauth/dto/core/AuthUserResponse.java @@ -0,0 +1,11 @@ +package com.nhnacademy.insightonauth.dto.core; + +import com.nhnacademy.insightonauth.entity.Status; + +public record AuthUserResponse( + Long userId, + String userName, + String userPhoneNumber, + Status userStatus +) { +} diff --git a/src/main/java/com/nhnacademy/insightonauth/dto/core/ManagerGroupExistsResponse.java b/src/main/java/com/nhnacademy/insightonauth/dto/core/ManagerGroupExistsResponse.java new file mode 100644 index 0000000..66715b1 --- /dev/null +++ b/src/main/java/com/nhnacademy/insightonauth/dto/core/ManagerGroupExistsResponse.java @@ -0,0 +1,6 @@ +package com.nhnacademy.insightonauth.dto.core; + +public record ManagerGroupExistsResponse( + boolean exists +) { +} diff --git a/src/main/java/com/nhnacademy/insightonauth/email/EmailService.java b/src/main/java/com/nhnacademy/insightonauth/email/EmailService.java index 9f25f91..a24173b 100644 --- a/src/main/java/com/nhnacademy/insightonauth/email/EmailService.java +++ b/src/main/java/com/nhnacademy/insightonauth/email/EmailService.java @@ -1,9 +1,6 @@ package com.nhnacademy.insightonauth.email; -import com.nhnacademy.insightonauth.exception.EmailSendException; -import com.nhnacademy.insightonauth.exception.InvalidVerificationCodeException; -import com.nhnacademy.insightonauth.exception.InvalidVerificationTokenException; -import com.nhnacademy.insightonauth.exception.VerificationTemporarilyLockedException; +import com.nhnacademy.insightonauth.exception.*; import com.nhnacademy.insightonauth.redis.RedisKey; import com.nhnacademy.insightonauth.redis.RedisService; import jakarta.mail.MessagingException; @@ -51,14 +48,23 @@ public void sendVerificationCode(String email) { // 비밀번호 재설정 경로 발성 public void sendPasswordResetPath(String email) { + // 1. 역방향 키로 이 이메일의 기존 토큰을 찾아 예전 링크를 무효화 + String oldUuid = redisService.get(RedisKey.PASSWORD_RESET_BY_EMAIL.getPrefix() + email); + if (oldUuid != null && !oldUuid.isBlank()) { + redisService.delete(RedisKey.PASSWORD_RESET.getPrefix() + oldUuid); + } + + // 2. 새 토큰 발급 String uuid = UUID.randomUUID().toString(); - // 재설정 경로 String path = "https://insighton.store/password/reset?token=" + uuid; - // Redis에 저장 (10분 TTL) + // 3. 정방향 키 저장 (uuid → email, 10분 TTL) redisService.set(RedisKey.PASSWORD_RESET.getPrefix() + uuid, email, Duration.ofMinutes(10)); - // 메일 발송 + // 4. 역방향 키 갱신 (email → uuid, 같은 TTL) + redisService.set(RedisKey.PASSWORD_RESET_BY_EMAIL.getPrefix() + email, uuid, Duration.ofMinutes(10)); + + // 5. 메일 발송 send(email, "[InsightOn] 비밀번호 재설정", "비밀번호 재설정 경로: " + path + "\n10분 이내에 수정해 주세요."); } @@ -75,7 +81,6 @@ private void send(String to, String subject, String text) { javaMailSender.send(message); } catch (MessagingException | UnsupportedEncodingException e) { throw new EmailSendException("이메일 발송에 실패했습니다.", e); - } catch (MailException e) { log.error("이메일 전송 실패(타임아웃 등) - to: {}, error: {}", to, e.getMessage()); throw new EmailSendException("이메일 발송에 실패했습니다. 잠시 후 다시 시도해주세요.", e); @@ -83,7 +88,8 @@ private void send(String to, String subject, String text) { } // 이메일 코드 확인 - public String emailVerify(String email, String inputCode) { + public String emailCodeVerify(String email, String inputCode) { + // 입력 실패 잠금 체크 (검증 전용) if (redisService.hasKey(RedisKey.VERIFY_FAIL_LOCK.getPrefix() + email)) { throw new VerificationTemporarilyLockedException("인증 시도가 5회 초과되어 5분간 잠겼습니다."); } @@ -91,16 +97,16 @@ public String emailVerify(String email, String inputCode) { String savedCode = redisService.get(RedisKey.VERIFY.getPrefix() + email); if (savedCode == null || !savedCode.equals(inputCode)) { - // 만료되었거나 애초에 요청한 적 없음 - increaseVerifyFailCount(email); + increaseVerifyFailCount(email); // 입력 실패 카운트 (여기서 5회 넘으면 VERIFY_FAIL_LOCK 걸림) throw new InvalidVerificationCodeException("인증 코드가 올바르지 않거나 만료되었습니다."); } + + // 성공 처리 redisService.delete(RedisKey.VERIFY_FAIL.getPrefix() + email); redisService.delete(RedisKey.VERIFY.getPrefix() + email); String verificationToken = UUID.randomUUID().toString(); redisService.set(RedisKey.VERIFIED.getPrefix() + email, verificationToken, Duration.ofMinutes(15)); - redisService.delete(RedisKey.VERIFY.getPrefix() + email); return verificationToken; } @@ -118,13 +124,20 @@ public boolean emailVerifyCheck(String email, String inputToken) { public String emailTokenVerify(String token) { String savedEmail = redisService.get(RedisKey.PASSWORD_RESET.getPrefix() + token); - - // 토큰 만료 또는 존재하지 않는 토큰 if (savedEmail == null || savedEmail.isBlank()) { throw new InvalidVerificationTokenException("인증 토큰이 올바르지 않거나 만료되었습니다."); } + // 정방향 토큰은 삭제 (이 token은 사용됨) redisService.delete(RedisKey.PASSWORD_RESET.getPrefix() + token); + + // 역방향 키는 "아직 이 token을 가리킬 때만" 삭제 + String currentUuid = redisService.get(RedisKey.PASSWORD_RESET_BY_EMAIL.getPrefix() + savedEmail); + if (token.equals(currentUuid)) { + redisService.delete(RedisKey.PASSWORD_RESET_BY_EMAIL.getPrefix() + savedEmail); + } + // 다르면 = 그 사이 새 토큰 발급됨 = 최신 역방향은 건드리지 않음 + return savedEmail; } diff --git a/src/main/java/com/nhnacademy/insightonauth/entity/Oauth.java b/src/main/java/com/nhnacademy/insightonauth/entity/Oauth.java index c9b158e..cb842fc 100644 --- a/src/main/java/com/nhnacademy/insightonauth/entity/Oauth.java +++ b/src/main/java/com/nhnacademy/insightonauth/entity/Oauth.java @@ -40,4 +40,8 @@ public Oauth(User user, String provider, String providerUserId) { this.providerUserId = providerUserId; this.createdAt = OffsetDateTime.now(ZoneOffset.UTC); } + + public void reassignUser(User newUser) { + this.user = newUser; + } } diff --git a/src/main/java/com/nhnacademy/insightonauth/entity/User.java b/src/main/java/com/nhnacademy/insightonauth/entity/User.java index 040661c..8a89dd0 100644 --- a/src/main/java/com/nhnacademy/insightonauth/entity/User.java +++ b/src/main/java/com/nhnacademy/insightonauth/entity/User.java @@ -40,7 +40,6 @@ public class User { @Column(name = "status", nullable = false, length = 20) private Status status; - @Setter @Column(name = "last_login_at", nullable = true) private OffsetDateTime lastLoginAt; @@ -66,6 +65,14 @@ public User(String email, String userName, String phoneNumber) { this.createdAt = now; } + public void updateLastLoginAt() { + this.lastLoginAt = OffsetDateTime.now(ZoneOffset.UTC); + } + + public void updateLastLoginAt(OffsetDateTime lastLoginAt) { // 테스트/특정 시각 지정용 + this.lastLoginAt = lastLoginAt; + } + public void reactivate() { if (this.status != Status.SLEEP && this.status != Status.WITHDRAW) { throw new InvalidUserStatusException("휴면 또는 탈퇴 상태가 아닙니다."); diff --git a/src/main/java/com/nhnacademy/insightonauth/exception/BusinessException.java b/src/main/java/com/nhnacademy/insightonauth/exception/BusinessException.java new file mode 100644 index 0000000..1a704af --- /dev/null +++ b/src/main/java/com/nhnacademy/insightonauth/exception/BusinessException.java @@ -0,0 +1,17 @@ +package com.nhnacademy.insightonauth.exception; + +import org.springframework.http.HttpStatus; + +public class BusinessException extends RuntimeException { + private final HttpStatus status; + private final ErrorCode errorCode; + + public BusinessException(ErrorCode errorCode, String message) { + super(message); + this.errorCode = errorCode; + this.status = errorCode.getStatus(); + } + + public HttpStatus getStatus() { return status; } + public ErrorCode getErrorCode() { return errorCode; } +} \ No newline at end of file diff --git a/src/main/java/com/nhnacademy/insightonauth/exception/CoreServiceUnavailableException.java b/src/main/java/com/nhnacademy/insightonauth/exception/CoreServiceUnavailableException.java new file mode 100644 index 0000000..c5b05d1 --- /dev/null +++ b/src/main/java/com/nhnacademy/insightonauth/exception/CoreServiceUnavailableException.java @@ -0,0 +1,7 @@ +package com.nhnacademy.insightonauth.exception; + +public class CoreServiceUnavailableException extends BusinessException { + public CoreServiceUnavailableException(String message) { + super(ErrorCode.CORE_SERVICE_UNAVAILABLE, message); + } +} diff --git a/src/main/java/com/nhnacademy/insightonauth/exception/DuplicateEmailException.java b/src/main/java/com/nhnacademy/insightonauth/exception/DuplicateEmailException.java index 4e2d5e8..e741874 100644 --- a/src/main/java/com/nhnacademy/insightonauth/exception/DuplicateEmailException.java +++ b/src/main/java/com/nhnacademy/insightonauth/exception/DuplicateEmailException.java @@ -1,7 +1,7 @@ package com.nhnacademy.insightonauth.exception; -public class DuplicateEmailException extends RuntimeException { +public class DuplicateEmailException extends BusinessException { public DuplicateEmailException(String message) { - super(message); + super(ErrorCode.DUPLICATE_EMAIL, message); } } diff --git a/src/main/java/com/nhnacademy/insightonauth/exception/DuplicatePhoneNumberException.java b/src/main/java/com/nhnacademy/insightonauth/exception/DuplicatePhoneNumberException.java index 04cac4e..98868c6 100644 --- a/src/main/java/com/nhnacademy/insightonauth/exception/DuplicatePhoneNumberException.java +++ b/src/main/java/com/nhnacademy/insightonauth/exception/DuplicatePhoneNumberException.java @@ -1,7 +1,7 @@ package com.nhnacademy.insightonauth.exception; -public class DuplicatePhoneNumberException extends RuntimeException { +public class DuplicatePhoneNumberException extends BusinessException { public DuplicatePhoneNumberException(String message) { - super(message); + super(ErrorCode.DUPLICATE_PHONE_NUMBER, message); } } diff --git a/src/main/java/com/nhnacademy/insightonauth/exception/EmailAlreadyRegisteredException.java b/src/main/java/com/nhnacademy/insightonauth/exception/EmailAlreadyRegisteredException.java new file mode 100644 index 0000000..7bfb50a --- /dev/null +++ b/src/main/java/com/nhnacademy/insightonauth/exception/EmailAlreadyRegisteredException.java @@ -0,0 +1,7 @@ +package com.nhnacademy.insightonauth.exception; + +public class EmailAlreadyRegisteredException extends BusinessException { + public EmailAlreadyRegisteredException(String message) { + super(ErrorCode.EMAIL_ALREADY_REGISTERED, message); + } +} diff --git a/src/main/java/com/nhnacademy/insightonauth/exception/EmailNotFoundException.java b/src/main/java/com/nhnacademy/insightonauth/exception/EmailNotFoundException.java new file mode 100644 index 0000000..6973962 --- /dev/null +++ b/src/main/java/com/nhnacademy/insightonauth/exception/EmailNotFoundException.java @@ -0,0 +1,7 @@ +package com.nhnacademy.insightonauth.exception; + +public class EmailNotFoundException extends BusinessException { + public EmailNotFoundException(String message) { + super(ErrorCode.EMAIL_NOT_FOUND, message); + } +} diff --git a/src/main/java/com/nhnacademy/insightonauth/exception/EmailSendException.java b/src/main/java/com/nhnacademy/insightonauth/exception/EmailSendException.java index 3de9e2f..10a7f7e 100644 --- a/src/main/java/com/nhnacademy/insightonauth/exception/EmailSendException.java +++ b/src/main/java/com/nhnacademy/insightonauth/exception/EmailSendException.java @@ -1,7 +1,9 @@ package com.nhnacademy.insightonauth.exception; -public class EmailSendException extends RuntimeException { +public class EmailSendException extends BusinessException { public EmailSendException(String message, Exception e) { - super(message); + super(ErrorCode.EMAIL_SEND_FAILED, message); + // 왜 이메일이 실패했는지 정확인 exception이 로그이 보이기 위한 코드 + initCause(e); } } diff --git a/src/main/java/com/nhnacademy/insightonauth/exception/ErrorCode.java b/src/main/java/com/nhnacademy/insightonauth/exception/ErrorCode.java new file mode 100644 index 0000000..cb5a603 --- /dev/null +++ b/src/main/java/com/nhnacademy/insightonauth/exception/ErrorCode.java @@ -0,0 +1,52 @@ +package com.nhnacademy.insightonauth.exception; + +import org.springframework.http.HttpStatus; + +public enum ErrorCode { + CORE_SERVICE_UNAVAILABLE(HttpStatus.SERVICE_UNAVAILABLE), + DUPLICATE_EMAIL(HttpStatus.CONFLICT), + DUPLICATE_PHONE_NUMBER(HttpStatus.CONFLICT), + EMAIL_NOT_FOUND(HttpStatus.NOT_FOUND), + EMAIL_SEND_FAILED(HttpStatus.SERVICE_UNAVAILABLE), + INVALID_CREDENTIALS(HttpStatus.UNAUTHORIZED), + INVALID_REACTIVE_TOKEN(HttpStatus.BAD_REQUEST), + INVALID_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED), + INVALID_USER(HttpStatus.FORBIDDEN), + INVALID_USER_STATUS(HttpStatus.CONFLICT), + INVALID_VERIFICATION_CODE(HttpStatus.BAD_REQUEST), + INVALID_VERIFICATION_TOKEN(HttpStatus.BAD_REQUEST), + LAST_LOGIN_METHOD(HttpStatus.CONFLICT), + LOGIN_TEMPORARILY_LOCKED(HttpStatus.LOCKED), + MANAGER_GROUP_EXISTS(HttpStatus.CONFLICT), + OAUTH_ALREADY_LINKED(HttpStatus.CONFLICT), + OAUTH_NOT_FOUND(HttpStatus.NOT_FOUND), + REFRESH_TOKEN_NOT_FOUND(HttpStatus.NOT_FOUND), + RESTORE_PERIOD_EXPIRED(HttpStatus.GONE), + UNSUPPORTED_OAUTH_PROVIDER(HttpStatus.BAD_REQUEST), + USER_CREDENTIALS_NOT_FOUND(HttpStatus.NOT_FOUND), + USER_NOT_FOUND(HttpStatus.NOT_FOUND), + USER_ROLE_NOT_FOUND(HttpStatus.NOT_FOUND), + VERIFICATION_TEMPORARILY_LOCKED(HttpStatus.LOCKED), + EMAIL_ALREADY_REGISTERED(HttpStatus.CONFLICT), + OAUTH_LINKED_TO_OTHER_ACCOUNT(HttpStatus.CONFLICT), + INVALID_MERGE_REQUEST(HttpStatus.BAD_REQUEST), + // 이메일 인증 코드 재전송 — 연타 방지(쿨다운) + VERIFICATION_RESEND_TOO_SOON(HttpStatus.TOO_MANY_REQUESTS), + // 이메일 인증 코드 재전송 — 횟수 초과 잠금 + VERIFICATION_RESEND_LOCKED(HttpStatus.LOCKED), + + // 비밀번호 재설정 메일 재전송 — 연타 방지(쿨다운) + PASSWORD_RESET_RESEND_TOO_SOON(HttpStatus.TOO_MANY_REQUESTS), + // 비밀번호 재설정 메일 재전송 — 횟수 초과 잠금 + PASSWORD_RESET_RESEND_LOCKED(HttpStatus.LOCKED); + + private final HttpStatus status; + + ErrorCode(HttpStatus status) { + this.status = status; + } + + public HttpStatus getStatus() { + return status; + } +} diff --git a/src/main/java/com/nhnacademy/insightonauth/exception/ErrorResponse.java b/src/main/java/com/nhnacademy/insightonauth/exception/ErrorResponse.java new file mode 100644 index 0000000..bca0216 --- /dev/null +++ b/src/main/java/com/nhnacademy/insightonauth/exception/ErrorResponse.java @@ -0,0 +1,7 @@ +package com.nhnacademy.insightonauth.exception; + +public record ErrorResponse( + int status, + String message +) { +} diff --git a/src/main/java/com/nhnacademy/insightonauth/exception/InvalidCredentialsException.java b/src/main/java/com/nhnacademy/insightonauth/exception/InvalidCredentialsException.java index 1af1f55..98c00e8 100644 --- a/src/main/java/com/nhnacademy/insightonauth/exception/InvalidCredentialsException.java +++ b/src/main/java/com/nhnacademy/insightonauth/exception/InvalidCredentialsException.java @@ -1,7 +1,7 @@ package com.nhnacademy.insightonauth.exception; -public class InvalidCredentialsException extends RuntimeException { +public class InvalidCredentialsException extends BusinessException { public InvalidCredentialsException(String message) { - super(message); + super(ErrorCode.INVALID_CREDENTIALS, message); } } diff --git a/src/main/java/com/nhnacademy/insightonauth/exception/InvalidMergeRequestException.java b/src/main/java/com/nhnacademy/insightonauth/exception/InvalidMergeRequestException.java new file mode 100644 index 0000000..34b38a8 --- /dev/null +++ b/src/main/java/com/nhnacademy/insightonauth/exception/InvalidMergeRequestException.java @@ -0,0 +1,7 @@ +package com.nhnacademy.insightonauth.exception; + +public class InvalidMergeRequestException extends BusinessException { + public InvalidMergeRequestException(String message) { + super(ErrorCode.INVALID_MERGE_REQUEST, message); + } +} diff --git a/src/main/java/com/nhnacademy/insightonauth/exception/InvalidReactiveTokenException.java b/src/main/java/com/nhnacademy/insightonauth/exception/InvalidReactiveTokenException.java index 074a82b..f168d04 100644 --- a/src/main/java/com/nhnacademy/insightonauth/exception/InvalidReactiveTokenException.java +++ b/src/main/java/com/nhnacademy/insightonauth/exception/InvalidReactiveTokenException.java @@ -1,7 +1,7 @@ package com.nhnacademy.insightonauth.exception; -public class InvalidReactiveTokenException extends RuntimeException { +public class InvalidReactiveTokenException extends BusinessException { public InvalidReactiveTokenException(String message) { - super(message); + super(ErrorCode.INVALID_REACTIVE_TOKEN, message); } } diff --git a/src/main/java/com/nhnacademy/insightonauth/exception/InvalidRefreshTokenException.java b/src/main/java/com/nhnacademy/insightonauth/exception/InvalidRefreshTokenException.java index bf1e007..73a606a 100644 --- a/src/main/java/com/nhnacademy/insightonauth/exception/InvalidRefreshTokenException.java +++ b/src/main/java/com/nhnacademy/insightonauth/exception/InvalidRefreshTokenException.java @@ -1,7 +1,7 @@ package com.nhnacademy.insightonauth.exception; -public class InvalidRefreshTokenException extends RuntimeException { +public class InvalidRefreshTokenException extends BusinessException { public InvalidRefreshTokenException(String message) { - super(message); + super(ErrorCode.INVALID_REFRESH_TOKEN, message); } } diff --git a/src/main/java/com/nhnacademy/insightonauth/exception/InvalidUserException.java b/src/main/java/com/nhnacademy/insightonauth/exception/InvalidUserException.java index 3262d98..fc50ed7 100644 --- a/src/main/java/com/nhnacademy/insightonauth/exception/InvalidUserException.java +++ b/src/main/java/com/nhnacademy/insightonauth/exception/InvalidUserException.java @@ -1,7 +1,7 @@ package com.nhnacademy.insightonauth.exception; -public class InvalidUserException extends RuntimeException { +public class InvalidUserException extends BusinessException { public InvalidUserException(String message) { - super(message); + super(ErrorCode.INVALID_USER, message); } } diff --git a/src/main/java/com/nhnacademy/insightonauth/exception/InvalidUserStatusException.java b/src/main/java/com/nhnacademy/insightonauth/exception/InvalidUserStatusException.java index d4a8bd5..be026ad 100644 --- a/src/main/java/com/nhnacademy/insightonauth/exception/InvalidUserStatusException.java +++ b/src/main/java/com/nhnacademy/insightonauth/exception/InvalidUserStatusException.java @@ -1,7 +1,7 @@ package com.nhnacademy.insightonauth.exception; -public class InvalidUserStatusException extends RuntimeException { +public class InvalidUserStatusException extends BusinessException { public InvalidUserStatusException(String message) { - super(message); + super(ErrorCode.INVALID_USER_STATUS, message); } } diff --git a/src/main/java/com/nhnacademy/insightonauth/exception/InvalidVerificationCodeException.java b/src/main/java/com/nhnacademy/insightonauth/exception/InvalidVerificationCodeException.java index e06567f..91e10f0 100644 --- a/src/main/java/com/nhnacademy/insightonauth/exception/InvalidVerificationCodeException.java +++ b/src/main/java/com/nhnacademy/insightonauth/exception/InvalidVerificationCodeException.java @@ -1,7 +1,7 @@ package com.nhnacademy.insightonauth.exception; -public class InvalidVerificationCodeException extends RuntimeException { +public class InvalidVerificationCodeException extends BusinessException { public InvalidVerificationCodeException(String message) { - super(message); + super(ErrorCode.INVALID_VERIFICATION_CODE, message); } } diff --git a/src/main/java/com/nhnacademy/insightonauth/exception/InvalidVerificationTokenException.java b/src/main/java/com/nhnacademy/insightonauth/exception/InvalidVerificationTokenException.java index 2b58f9c..ac51b62 100644 --- a/src/main/java/com/nhnacademy/insightonauth/exception/InvalidVerificationTokenException.java +++ b/src/main/java/com/nhnacademy/insightonauth/exception/InvalidVerificationTokenException.java @@ -1,7 +1,7 @@ package com.nhnacademy.insightonauth.exception; -public class InvalidVerificationTokenException extends RuntimeException { +public class InvalidVerificationTokenException extends BusinessException { public InvalidVerificationTokenException(String message) { - super(message); + super(ErrorCode.INVALID_VERIFICATION_TOKEN, message); } } diff --git a/src/main/java/com/nhnacademy/insightonauth/exception/LastLoginMethodException.java b/src/main/java/com/nhnacademy/insightonauth/exception/LastLoginMethodException.java index e4c71e8..a7537fa 100644 --- a/src/main/java/com/nhnacademy/insightonauth/exception/LastLoginMethodException.java +++ b/src/main/java/com/nhnacademy/insightonauth/exception/LastLoginMethodException.java @@ -1,7 +1,7 @@ package com.nhnacademy.insightonauth.exception; -public class LastLoginMethodException extends RuntimeException { +public class LastLoginMethodException extends BusinessException { public LastLoginMethodException(String message) { - super(message); + super(ErrorCode.LAST_LOGIN_METHOD, message); } } diff --git a/src/main/java/com/nhnacademy/insightonauth/exception/LoginTemporarilyLockedException.java b/src/main/java/com/nhnacademy/insightonauth/exception/LoginTemporarilyLockedException.java index 60d6100..c8dd2ce 100644 --- a/src/main/java/com/nhnacademy/insightonauth/exception/LoginTemporarilyLockedException.java +++ b/src/main/java/com/nhnacademy/insightonauth/exception/LoginTemporarilyLockedException.java @@ -1,7 +1,7 @@ package com.nhnacademy.insightonauth.exception; -public class LoginTemporarilyLockedException extends RuntimeException { +public class LoginTemporarilyLockedException extends BusinessException { public LoginTemporarilyLockedException(String message) { - super(message); + super(ErrorCode.LOGIN_TEMPORARILY_LOCKED, message); } } diff --git a/src/main/java/com/nhnacademy/insightonauth/exception/ManagerGroupExistsException.java b/src/main/java/com/nhnacademy/insightonauth/exception/ManagerGroupExistsException.java new file mode 100644 index 0000000..e5ad93a --- /dev/null +++ b/src/main/java/com/nhnacademy/insightonauth/exception/ManagerGroupExistsException.java @@ -0,0 +1,7 @@ +package com.nhnacademy.insightonauth.exception; + +public class ManagerGroupExistsException extends BusinessException { + public ManagerGroupExistsException(String message) { + super(ErrorCode.MANAGER_GROUP_EXISTS, message); + } +} diff --git a/src/main/java/com/nhnacademy/insightonauth/exception/OauthAlreadyLinkedException.java b/src/main/java/com/nhnacademy/insightonauth/exception/OauthAlreadyLinkedException.java index 3bf4f8f..8350e51 100644 --- a/src/main/java/com/nhnacademy/insightonauth/exception/OauthAlreadyLinkedException.java +++ b/src/main/java/com/nhnacademy/insightonauth/exception/OauthAlreadyLinkedException.java @@ -1,7 +1,7 @@ package com.nhnacademy.insightonauth.exception; -public class OauthAlreadyLinkedException extends RuntimeException { +public class OauthAlreadyLinkedException extends BusinessException { public OauthAlreadyLinkedException(String message) { - super(message); + super(ErrorCode.OAUTH_ALREADY_LINKED, message); } } diff --git a/src/main/java/com/nhnacademy/insightonauth/exception/OauthConflictResponse.java b/src/main/java/com/nhnacademy/insightonauth/exception/OauthConflictResponse.java new file mode 100644 index 0000000..35acc0f --- /dev/null +++ b/src/main/java/com/nhnacademy/insightonauth/exception/OauthConflictResponse.java @@ -0,0 +1,9 @@ +package com.nhnacademy.insightonauth.exception; + +public record OauthConflictResponse( + int status, + String message, + Long conflictingUserId +) { + +} diff --git a/src/main/java/com/nhnacademy/insightonauth/exception/OauthLinkedToOtherAccountException.java b/src/main/java/com/nhnacademy/insightonauth/exception/OauthLinkedToOtherAccountException.java new file mode 100644 index 0000000..1307fef --- /dev/null +++ b/src/main/java/com/nhnacademy/insightonauth/exception/OauthLinkedToOtherAccountException.java @@ -0,0 +1,14 @@ +package com.nhnacademy.insightonauth.exception; + +import lombok.Getter; + +@Getter +public class OauthLinkedToOtherAccountException extends BusinessException { + private final Long conflictingUserId; + + public OauthLinkedToOtherAccountException(String message, Long conflictingUserId) { + super(ErrorCode.OAUTH_LINKED_TO_OTHER_ACCOUNT, message); + this.conflictingUserId = conflictingUserId; + } + +} diff --git a/src/main/java/com/nhnacademy/insightonauth/exception/OauthNotFoundException.java b/src/main/java/com/nhnacademy/insightonauth/exception/OauthNotFoundException.java index ceba420..02c3ad2 100644 --- a/src/main/java/com/nhnacademy/insightonauth/exception/OauthNotFoundException.java +++ b/src/main/java/com/nhnacademy/insightonauth/exception/OauthNotFoundException.java @@ -1,7 +1,7 @@ package com.nhnacademy.insightonauth.exception; -public class OauthNotFoundException extends RuntimeException { +public class OauthNotFoundException extends BusinessException { public OauthNotFoundException(String message) { - super(message); + super(ErrorCode.OAUTH_NOT_FOUND, message); } } diff --git a/src/main/java/com/nhnacademy/insightonauth/exception/PasswordResetResendLockedException.java b/src/main/java/com/nhnacademy/insightonauth/exception/PasswordResetResendLockedException.java new file mode 100644 index 0000000..f458bfd --- /dev/null +++ b/src/main/java/com/nhnacademy/insightonauth/exception/PasswordResetResendLockedException.java @@ -0,0 +1,7 @@ +package com.nhnacademy.insightonauth.exception; + +public class PasswordResetResendLockedException extends BusinessException { + public PasswordResetResendLockedException(String message) { + super(ErrorCode.PASSWORD_RESET_RESEND_LOCKED, message); + } +} diff --git a/src/main/java/com/nhnacademy/insightonauth/exception/PasswordResetResendTooSoonException.java b/src/main/java/com/nhnacademy/insightonauth/exception/PasswordResetResendTooSoonException.java new file mode 100644 index 0000000..964267b --- /dev/null +++ b/src/main/java/com/nhnacademy/insightonauth/exception/PasswordResetResendTooSoonException.java @@ -0,0 +1,7 @@ +package com.nhnacademy.insightonauth.exception; + +public class PasswordResetResendTooSoonException extends BusinessException { + public PasswordResetResendTooSoonException(String message) { + super(ErrorCode.PASSWORD_RESET_RESEND_TOO_SOON, message); + } +} diff --git a/src/main/java/com/nhnacademy/insightonauth/exception/RefreshTokenNotFoundException.java b/src/main/java/com/nhnacademy/insightonauth/exception/RefreshTokenNotFoundException.java index 7dfd0c8..5df5727 100644 --- a/src/main/java/com/nhnacademy/insightonauth/exception/RefreshTokenNotFoundException.java +++ b/src/main/java/com/nhnacademy/insightonauth/exception/RefreshTokenNotFoundException.java @@ -1,7 +1,7 @@ package com.nhnacademy.insightonauth.exception; -public class RefreshTokenNotFoundException extends RuntimeException { +public class RefreshTokenNotFoundException extends BusinessException { public RefreshTokenNotFoundException(String message) { - super(message); + super(ErrorCode.REFRESH_TOKEN_NOT_FOUND, message); } } diff --git a/src/main/java/com/nhnacademy/insightonauth/exception/RestorePeriodExpiredException.java b/src/main/java/com/nhnacademy/insightonauth/exception/RestorePeriodExpiredException.java index 75684d2..9b18984 100644 --- a/src/main/java/com/nhnacademy/insightonauth/exception/RestorePeriodExpiredException.java +++ b/src/main/java/com/nhnacademy/insightonauth/exception/RestorePeriodExpiredException.java @@ -1,7 +1,7 @@ package com.nhnacademy.insightonauth.exception; -public class RestorePeriodExpiredException extends RuntimeException { +public class RestorePeriodExpiredException extends BusinessException { public RestorePeriodExpiredException(String message) { - super(message); + super(ErrorCode.RESTORE_PERIOD_EXPIRED, message); } } diff --git a/src/main/java/com/nhnacademy/insightonauth/exception/UnsupportedOAuthProviderException.java b/src/main/java/com/nhnacademy/insightonauth/exception/UnsupportedOAuthProviderException.java new file mode 100644 index 0000000..b5ff99c --- /dev/null +++ b/src/main/java/com/nhnacademy/insightonauth/exception/UnsupportedOAuthProviderException.java @@ -0,0 +1,7 @@ +package com.nhnacademy.insightonauth.exception; + +public class UnsupportedOAuthProviderException extends BusinessException { + public UnsupportedOAuthProviderException(String message) { + super(ErrorCode.UNSUPPORTED_OAUTH_PROVIDER, "지원하지 않는 OAuth 제공자입니다: " + message); + } +} diff --git a/src/main/java/com/nhnacademy/insightonauth/exception/UserCredentialsNotFoundException.java b/src/main/java/com/nhnacademy/insightonauth/exception/UserCredentialsNotFoundException.java index 6b4d2f7..0d0cef9 100644 --- a/src/main/java/com/nhnacademy/insightonauth/exception/UserCredentialsNotFoundException.java +++ b/src/main/java/com/nhnacademy/insightonauth/exception/UserCredentialsNotFoundException.java @@ -1,7 +1,7 @@ package com.nhnacademy.insightonauth.exception; -public class UserCredentialsNotFoundException extends RuntimeException { +public class UserCredentialsNotFoundException extends BusinessException { public UserCredentialsNotFoundException(String message) { - super(message); + super(ErrorCode.USER_CREDENTIALS_NOT_FOUND, message); } } diff --git a/src/main/java/com/nhnacademy/insightonauth/exception/UserNotFoundException.java b/src/main/java/com/nhnacademy/insightonauth/exception/UserNotFoundException.java index 7b2266a..74473c4 100644 --- a/src/main/java/com/nhnacademy/insightonauth/exception/UserNotFoundException.java +++ b/src/main/java/com/nhnacademy/insightonauth/exception/UserNotFoundException.java @@ -1,7 +1,7 @@ package com.nhnacademy.insightonauth.exception; -public class UserNotFoundException extends RuntimeException { +public class UserNotFoundException extends BusinessException { public UserNotFoundException(String message) { - super(message); + super(ErrorCode.USER_NOT_FOUND, message); } } diff --git a/src/main/java/com/nhnacademy/insightonauth/exception/UserRoleNotFoundException.java b/src/main/java/com/nhnacademy/insightonauth/exception/UserRoleNotFoundException.java index 9e8856c..0256109 100644 --- a/src/main/java/com/nhnacademy/insightonauth/exception/UserRoleNotFoundException.java +++ b/src/main/java/com/nhnacademy/insightonauth/exception/UserRoleNotFoundException.java @@ -1,7 +1,7 @@ package com.nhnacademy.insightonauth.exception; -public class UserRoleNotFoundException extends RuntimeException { +public class UserRoleNotFoundException extends BusinessException { public UserRoleNotFoundException(String message) { - super(message); + super(ErrorCode.USER_ROLE_NOT_FOUND, message); } } diff --git a/src/main/java/com/nhnacademy/insightonauth/exception/VerificationResendLockedException.java b/src/main/java/com/nhnacademy/insightonauth/exception/VerificationResendLockedException.java new file mode 100644 index 0000000..8a63ff6 --- /dev/null +++ b/src/main/java/com/nhnacademy/insightonauth/exception/VerificationResendLockedException.java @@ -0,0 +1,7 @@ +package com.nhnacademy.insightonauth.exception; + +public class VerificationResendLockedException extends BusinessException { + public VerificationResendLockedException(String message) { + super(ErrorCode.VERIFICATION_RESEND_LOCKED, message); + } +} diff --git a/src/main/java/com/nhnacademy/insightonauth/exception/VerificationResendTooSoonException.java b/src/main/java/com/nhnacademy/insightonauth/exception/VerificationResendTooSoonException.java new file mode 100644 index 0000000..bf588da --- /dev/null +++ b/src/main/java/com/nhnacademy/insightonauth/exception/VerificationResendTooSoonException.java @@ -0,0 +1,7 @@ +package com.nhnacademy.insightonauth.exception; + +public class VerificationResendTooSoonException extends BusinessException { + public VerificationResendTooSoonException(String message) { + super(ErrorCode.VERIFICATION_RESEND_TOO_SOON, message); + } +} diff --git a/src/main/java/com/nhnacademy/insightonauth/exception/VerificationTemporarilyLockedException.java b/src/main/java/com/nhnacademy/insightonauth/exception/VerificationTemporarilyLockedException.java index d989fc3..6712f8c 100644 --- a/src/main/java/com/nhnacademy/insightonauth/exception/VerificationTemporarilyLockedException.java +++ b/src/main/java/com/nhnacademy/insightonauth/exception/VerificationTemporarilyLockedException.java @@ -1,7 +1,7 @@ package com.nhnacademy.insightonauth.exception; -public class VerificationTemporarilyLockedException extends RuntimeException { +public class VerificationTemporarilyLockedException extends BusinessException { public VerificationTemporarilyLockedException(String message) { - super(message); + super(ErrorCode.VERIFICATION_TEMPORARILY_LOCKED, message); } } diff --git a/src/main/java/com/nhnacademy/insightonauth/filter/HeaderAuthenticationFilter.java b/src/main/java/com/nhnacademy/insightonauth/filter/HeaderAuthenticationFilter.java index c6a7c08..8c02189 100644 --- a/src/main/java/com/nhnacademy/insightonauth/filter/HeaderAuthenticationFilter.java +++ b/src/main/java/com/nhnacademy/insightonauth/filter/HeaderAuthenticationFilter.java @@ -1,7 +1,5 @@ package com.nhnacademy.insightonauth.filter; -import com.nhnacademy.insightonauth.entity.User; -import com.nhnacademy.insightonauth.entity.UserRole; import com.nhnacademy.insightonauth.service.UserRoleService; import com.nhnacademy.insightonauth.service.UserService; import jakarta.servlet.FilterChain; @@ -16,10 +14,10 @@ import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; +import java.util.ArrayList; import java.util.List; @Slf4j @@ -33,18 +31,25 @@ public class HeaderAuthenticationFilter extends OncePerRequestFilter { @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String userIdHeader = request.getHeader("X-User-Id"); + // admin만 role 줄거임 그외는 다 null로 들어올거라 null체크 + String rolesHeader = request.getHeader("X-User-Role"); if (userIdHeader != null) { - Long userId = Long.valueOf(userIdHeader); - - User user = userService.findById(userId); - List userRoles = userRoleService.findByUser(user); - List authorities = userRoles.stream() - .map(userRole -> (GrantedAuthority) new SimpleGrantedAuthority("ROLE_" + userRole.getRole().name())) - .toList(); - - Authentication auth = new UsernamePasswordAuthenticationToken(userId, null, authorities); - SecurityContextHolder.getContext().setAuthentication(auth); + try { + Long userId = Long.valueOf(userIdHeader); + + List authorities = new ArrayList<>(); + if (rolesHeader != null && !rolesHeader.isBlank()) { + for (String role : rolesHeader.split(",")) { + authorities.add(new SimpleGrantedAuthority("ROLE_" + role.trim())); + } + } + + Authentication auth = new UsernamePasswordAuthenticationToken(userId, null, authorities); + SecurityContextHolder.getContext().setAuthentication(auth); + } catch (Exception e) { + log.debug("유효하지 않은 인증 헤더 - X-User-Id: {}, X-User-Role: {}", userIdHeader, rolesHeader); + } } filterChain.doFilter(request, response); diff --git a/src/main/java/com/nhnacademy/insightonauth/handler/GlobalExceptionHandler.java b/src/main/java/com/nhnacademy/insightonauth/handler/GlobalExceptionHandler.java new file mode 100644 index 0000000..e3b7558 --- /dev/null +++ b/src/main/java/com/nhnacademy/insightonauth/handler/GlobalExceptionHandler.java @@ -0,0 +1,57 @@ +package com.nhnacademy.insightonauth.handler; + +import com.nhnacademy.insightonauth.exception.BusinessException; +import com.nhnacademy.insightonauth.exception.ErrorResponse; +import com.nhnacademy.insightonauth.exception.OauthConflictResponse; +import com.nhnacademy.insightonauth.exception.OauthLinkedToOtherAccountException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + // OAuth 병합 유도 — conflictingUserId를 응답에 포함 + @ExceptionHandler(OauthLinkedToOtherAccountException.class) + public ResponseEntity handleOauthLinkedToOtherAccount( + OauthLinkedToOtherAccountException e) { + return ResponseEntity.status(e.getStatus()) + .body(new OauthConflictResponse( + e.getStatus().value(), + e.getMessage(), + e.getConflictingUserId())); + } + + // 비즈니스 로직 실패 + @ExceptionHandler(BusinessException.class) + public ResponseEntity handleBusinessException(BusinessException e) { + return ResponseEntity.status(e.getStatus()) + .body(new ErrorResponse(e.getStatus().value(), e.getMessage())); + } + + // Validate 실패 + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleValidation(MethodArgumentNotValidException e) { + String message = e.getBindingResult().getFieldErrors().stream() + .findFirst() + .map(FieldError::getDefaultMessage) + .orElse("잘못된 요청입니다."); + return ResponseEntity.badRequest() + .body(new ErrorResponse(HttpStatus.BAD_REQUEST.value(), message)); + } + + // 그 외 + @ExceptionHandler(Exception.class) + public ResponseEntity handleUnexpected(Exception e) { + log.error("예상치 못한 예외 발생", e); + return ResponseEntity.internalServerError() + .body(new ErrorResponse( + HttpStatus.INTERNAL_SERVER_ERROR.value(), + "서버 오류가 발생했습니다.")); + } +} diff --git a/src/main/java/com/nhnacademy/insightonauth/redis/RedisKey.java b/src/main/java/com/nhnacademy/insightonauth/redis/RedisKey.java index c43c880..619eb88 100644 --- a/src/main/java/com/nhnacademy/insightonauth/redis/RedisKey.java +++ b/src/main/java/com/nhnacademy/insightonauth/redis/RedisKey.java @@ -1,16 +1,62 @@ package com.nhnacademy.insightonauth.redis; public enum RedisKey { + // 리프레시 토큰 (userId → jti). 재발급/로그아웃 시 검증에 사용 REFRESH("refresh:"), + + // 이메일 인증 최종 완료 토큰 (email → token, 15분). 회원가입 시 인증 여부 확인 VERIFIED("verified:"), + + // 이메일 인증 코드 (email → 6자리 코드, 5분) VERIFY("verify:"), + + // 이메일 인증 코드 입력 실패 횟수 (email → count, 5분). 5회 초과 시 잠금 VERIFY_FAIL("verify-fail:"), + + // 이메일 인증 실패 잠금 (email → "locked", 5분). 존재 시 인증 시도 차단 VERIFY_FAIL_LOCK("verify-fail-lock:"), + + // 인증 코드 재전송 연타 방지 쿨다운 (email, 예: 60초). 존재 시 재전송 거부 + VERIFY_RESEND_COOLDOWN("verify-resend-cooldown:"), + + // 인증 코드 재전송 누적 횟수 (email → count). 임계치 초과 시 잠금 + VERIFY_RESEND_COUNT("verify-resend-count:"), + + // 인증 코드 재전송 잠금 (email → "locked"). 존재 시 재전송 차단 + VERIFY_RESEND_LOCK("verify-resend-lock:"), + + // 로그인 실패 횟수 (email → count). 임계치 초과 시 잠금 LOGIN_FAIL("login-fail:"), + + // 로그인 실패 잠금 (email → "locked"). 존재 시 로그인 차단 LOGIN_LOCK("login-lock:"), + + // 비밀번호 재설정 토큰 (uuid → email, 10분). 재설정 경로 접근 시 검증 PASSWORD_RESET("password-reset:"), + + // 비밀번호 재설정 역방향 키 (email → uuid, 10분). 재전송 시 예전 토큰 무효화용 + PASSWORD_RESET_BY_EMAIL("password-reset-by-email:"), + + // 비밀번호 재설정 메일 재전송 연타 방지 (email, 예: 60초) + PASSWORD_RESET_RESEND_COOLDOWN("password-reset-resend-cooldown:"), + + // 비밀번호 재설정 메일 재전송 누적 횟수 (email → count) + PASSWORD_RESET_RESEND_COUNT("password-reset-resend-count:"), + + // 비밀번호 재설정 메일 재전송 잠금 (email → "locked") + PASSWORD_RESET_RESEND_LOCK("password-reset-resend-lock:"), + + // 휴면 계정 복구 관련 (reactive) REACTIVE("reactive:"), - BLACKLIST("blacklist:"); + + // 무효화된 액세스 토큰 블랙리스트 (로그아웃/차단 토큰) + BLACKLIST("blacklist:"), + + // 탈퇴 계정 하드 삭제 스케줄러 분산 락 (Redisson RLock) + HARD_DELETE_SCHEDULER_LOCK("scheduler-lock:hard-delete-users"), + + // 휴면 전환 스케줄러 분산 락 (Redisson RLock) + SLEEP_CONVERSION_SCHEDULER_LOCK("scheduler-lock:sleep-conversion"); private final String prefix; diff --git a/src/main/java/com/nhnacademy/insightonauth/redis/RedisService.java b/src/main/java/com/nhnacademy/insightonauth/redis/RedisService.java index daff191..5bfc60e 100644 --- a/src/main/java/com/nhnacademy/insightonauth/redis/RedisService.java +++ b/src/main/java/com/nhnacademy/insightonauth/redis/RedisService.java @@ -27,4 +27,10 @@ public void delete(String key) { public boolean hasKey(String key) { return Boolean.TRUE.equals(redisTemplate.hasKey(key)); } + + public boolean setIfAbsent(String key, String value, Duration ttl) { + return Boolean.TRUE.equals( + redisTemplate.opsForValue().setIfAbsent(key, value, ttl) + ); + } } diff --git a/src/main/java/com/nhnacademy/insightonauth/repository/UserRepository.java b/src/main/java/com/nhnacademy/insightonauth/repository/UserRepository.java index 86ce5ee..616dead 100644 --- a/src/main/java/com/nhnacademy/insightonauth/repository/UserRepository.java +++ b/src/main/java/com/nhnacademy/insightonauth/repository/UserRepository.java @@ -6,6 +6,8 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import java.time.OffsetDateTime; +import java.util.List; import java.util.Optional; public interface UserRepository extends JpaRepository { @@ -25,4 +27,8 @@ Page findByEmailContainingAndUserNameContaining( boolean existsByEmail(String email); boolean existsByPhoneNumber(String phoneNumber); + + List findByStatusAndWithdrawnAtBefore(Status status, OffsetDateTime withdrawnAtBefore); + + List findByStatusAndLastLoginAtBefore(Status status, OffsetDateTime dateTime);; } diff --git a/src/main/java/com/nhnacademy/insightonauth/scheduler/UserHardDeleteScheduler.java b/src/main/java/com/nhnacademy/insightonauth/scheduler/UserHardDeleteScheduler.java new file mode 100644 index 0000000..1fba30b --- /dev/null +++ b/src/main/java/com/nhnacademy/insightonauth/scheduler/UserHardDeleteScheduler.java @@ -0,0 +1,69 @@ +package com.nhnacademy.insightonauth.scheduler; + +import com.nhnacademy.insightonauth.entity.User; +import com.nhnacademy.insightonauth.redis.RedisKey; +import com.nhnacademy.insightonauth.service.UserService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.concurrent.TimeUnit; + +@Slf4j +@Component +@RequiredArgsConstructor +public class UserHardDeleteScheduler { + + private final UserService userService; + private final RedissonClient redissonClient; + + @Scheduled(cron = "0 0 1 * * *") // 매일 새벽 1시 + public void hardDeleteExpiredUsers() { + RLock lock = redissonClient.getLock(RedisKey.HARD_DELETE_SCHEDULER_LOCK.getPrefix()); + + boolean acquired; + try { + // waitTime=0 : 이미 남이 잡았으면 기다리지 않고 즉시 스킵 + // leaseTime=-1 : 워치독 활성화 (작업이 끝날 때까지 TTL 자동 갱신) + acquired = lock.tryLock(0, -1, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.warn("하드 삭제 락 획득 중 인터럽트 발생"); + return; + } + + if (!acquired) { + log.info("다른 인스턴스가 이미 이 작업을 실행 중입니다. 건너뜁니다."); + return; + } + + try { + List targets = userService.findExpiredWithdrawnUsers(); + + for (User user : targets) { + try { + userService.deleteUser(user.getUserId()); + } catch (Exception e) { + log.warn("탈퇴 계정 삭제 실패 - userId: {}, error: {}", user.getUserId(), e.getMessage()); + } + } + + log.info("탈퇴 계정 물리 삭제 완료 - 대상 {}건", targets.size()); + } finally { + // 내가 쥔 락일 때만 해제 (남의 락/이미 만료된 락은 건드리지 않음) + if (lock.isHeldByCurrentThread()) { + try { + lock.unlock(); + } catch (IllegalMonitorStateException e) { + // 확인~해제 사이 워치독 갱신 실패로 락이 만료돼 소유권을 잃은 경우 + log.warn("락 해제 실패 - 이미 소유권을 상실함 (TTL 만료 추정). lockKey={}", + RedisKey.HARD_DELETE_SCHEDULER_LOCK.getPrefix()); + } + } + } + } +} diff --git a/src/main/java/com/nhnacademy/insightonauth/scheduler/UserSleepConversionScheduler.java b/src/main/java/com/nhnacademy/insightonauth/scheduler/UserSleepConversionScheduler.java new file mode 100644 index 0000000..1b1cda2 --- /dev/null +++ b/src/main/java/com/nhnacademy/insightonauth/scheduler/UserSleepConversionScheduler.java @@ -0,0 +1,74 @@ +package com.nhnacademy.insightonauth.scheduler; + +import com.nhnacademy.insightonauth.entity.User; +import com.nhnacademy.insightonauth.redis.RedisKey; +import com.nhnacademy.insightonauth.service.UserService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.concurrent.TimeUnit; + +@Slf4j +@Component +@RequiredArgsConstructor +public class UserSleepConversionScheduler { + + private final UserService userService; + private final RedissonClient redissonClient; + + @Scheduled(cron = "0 0 2 * * *") // 매일 새벽 2시 + public void convertInactiveUsersToSleep() { + // 이중화된 여러 인스턴스가 동시에 이 배치를 실행하지 않도록 Redisson 분산 락 사용. + // 워치독이 작업 시간에 맞춰 락 TTL을 자동 갱신하므로, 배치가 오래 걸려도 + // 락이 만료돼 다른 인스턴스가 끼어드는 일이 없고, 소유권 확인 해제로 남의 락 삭제도 방지됨. + // 락 객체(핸들) 획득 — 실제 잠금은 tryLock에서 수행 + RLock lock = redissonClient.getLock(RedisKey.SLEEP_CONVERSION_SCHEDULER_LOCK.getPrefix()); + + boolean acquired; + try { + // waitTime=0 : 이미 다른 인스턴스가 잡았으면 대기 없이 즉시 실패(false) → 스킵 + // leaseTime=-1: 워치독 활성화. 고정 만료 대신, 인스턴스가 살아있는 동안 TTL을 자동 갱신 + acquired = lock.tryLock(0, -1, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.warn("휴면 전환 락 획득 중 인터럽트 발생"); + return; + } + + if (!acquired) { + log.info("다른 인스턴스가 이미 휴면 전환 작업을 실행 중입니다. 건너뜁니다."); + return; + } + + try { + List targets = userService.findInactiveUsers(); + + for (User user : targets) { + try { + userService.sleep(user.getUserId()); + } catch (Exception e) { + // 하나가 실패해도 나머지는 진행되게 + log.warn("휴면 전환 실패 - userId: {}, error: {}", user.getUserId(), e.getMessage()); + } + } + + log.info("휴면 전환 완료 - 대상 {}건", targets.size()); + } finally { + // 내가 쥔 락일 때만 해제 (혹시 만료로 소유권이 넘어갔으면 건드리지 않음 → 예외/남의 락 삭제 방지) + if (lock.isHeldByCurrentThread()) { + try { + lock.unlock(); + } catch (IllegalMonitorStateException e) { + // 확인~해제 사이 워치독 갱신 실패로 락이 만료돼 소유권을 잃은 경우 + log.warn("락 해제 실패 - 이미 소유권을 상실함 (TTL 만료 추정). lockKey={}", + RedisKey.HARD_DELETE_SCHEDULER_LOCK.getPrefix()); + } + } + } + } +} diff --git a/src/main/java/com/nhnacademy/insightonauth/service/MyPageService.java b/src/main/java/com/nhnacademy/insightonauth/service/MyPageService.java index 9c1bc1b..723af48 100644 --- a/src/main/java/com/nhnacademy/insightonauth/service/MyPageService.java +++ b/src/main/java/com/nhnacademy/insightonauth/service/MyPageService.java @@ -17,4 +17,6 @@ public interface MyPageService { List findMyOauths(Long userId); void linkOauth(Long userId, String provider, String code); + + void mergeAccount(Long primaryUserId, Long secondaryUserId, String provider, String providerUserId); } diff --git a/src/main/java/com/nhnacademy/insightonauth/service/UserService.java b/src/main/java/com/nhnacademy/insightonauth/service/UserService.java index c010b69..df64a93 100644 --- a/src/main/java/com/nhnacademy/insightonauth/service/UserService.java +++ b/src/main/java/com/nhnacademy/insightonauth/service/UserService.java @@ -47,8 +47,6 @@ public interface UserService { String findMaskedEmail(String userName, String phoneNumber); - void updateLastLoginAt(Long userId); - void activate(Long userId); void withdraw(Long userId); @@ -62,4 +60,8 @@ public interface UserService { UserLoginResponse oauthLogin(String provider, String code); TokenRefreshResponse refresh(Long userId, String refreshToken); + + List findExpiredWithdrawnUsers(); + + List findInactiveUsers(); } diff --git a/src/main/java/com/nhnacademy/insightonauth/service/impl/MyPageServiceImpl.java b/src/main/java/com/nhnacademy/insightonauth/service/impl/MyPageServiceImpl.java index 9d6031f..c6f71da 100644 --- a/src/main/java/com/nhnacademy/insightonauth/service/impl/MyPageServiceImpl.java +++ b/src/main/java/com/nhnacademy/insightonauth/service/impl/MyPageServiceImpl.java @@ -1,14 +1,15 @@ package com.nhnacademy.insightonauth.service.impl; import com.nhnacademy.insightonauth.client.OauthClient; +import com.nhnacademy.insightonauth.client.OauthClientResolver; import com.nhnacademy.insightonauth.dto.mypage.MyInfoResponse; import com.nhnacademy.insightonauth.dto.mypage.RoleResponse; import com.nhnacademy.insightonauth.dto.oauth.OauthResponse; import com.nhnacademy.insightonauth.dto.oauth.OauthUserInfo; +import com.nhnacademy.insightonauth.entity.Oauth; import com.nhnacademy.insightonauth.entity.User; import com.nhnacademy.insightonauth.entity.UserCredential; -import com.nhnacademy.insightonauth.exception.InvalidCredentialsException; -import com.nhnacademy.insightonauth.exception.OauthAlreadyLinkedException; +import com.nhnacademy.insightonauth.exception.*; import com.nhnacademy.insightonauth.service.*; import lombok.RequiredArgsConstructor; import org.springframework.security.crypto.password.PasswordEncoder; @@ -18,6 +19,7 @@ import java.time.OffsetDateTime; import java.time.ZoneOffset; import java.util.List; +import java.util.Optional; @Service @Transactional @@ -28,10 +30,11 @@ public class MyPageServiceImpl implements MyPageService { private final UserCredentialService userCredentialService; private final UserRoleService userRoleService; private final OauthService oauthService; - private final OauthClient oauthClient; + private final OauthClientResolver oauthClientResolver; private final PasswordEncoder passwordEncoder; @Override + @Transactional(readOnly = true) public MyInfoResponse findMyInfo(Long userId) { User user = userService.findById(userId); @@ -52,6 +55,7 @@ public void updatePassword(Long userId, String currentPassword, String newPasswo } @Override + @Transactional(readOnly = true) public List findMyRoles(Long userId) { User user = userService.findById(userId); @@ -61,6 +65,7 @@ public List findMyRoles(Long userId) { } @Override + @Transactional(readOnly = true) public List findMyOauths(Long userId) { User user = userService.findById(userId); @@ -71,16 +76,50 @@ public List findMyOauths(Long userId) { @Override public void linkOauth(Long userId, String provider, String code) { - User user = userService.findById(userId); // 이미 로그인된 그 사람 + User primaryUser = userService.findById(userId); + OauthClient oauthClient = oauthClientResolver.resolve(provider); + OauthUserInfo userInfo = oauthClient.getUserInfo(code); - OauthUserInfo userInfo = oauthClient.getUserInfo(provider, code); // Google 검증 + Optional conflictingOauth = oauthService.findByProviderAndProviderUserId(provider, userInfo.providerId()); - // 이 소셜 계정이 이미 다른 사람 것인지 확인 (중요!) - oauthService.findByProviderAndProviderUserId(provider, userInfo.providerId()) - .ifPresent(existing -> { - throw new OauthAlreadyLinkedException("이미 연동된 소셜 계정입니다."); - }); + if (conflictingOauth.isPresent()) { + User conflictingUser = conflictingOauth.get().getUser(); - oauthService.create(user, provider, userInfo.providerId()); + if (conflictingUser.getUserId().equals(primaryUser.getUserId())) { + throw new OauthAlreadyLinkedException("이미 연동된 소셜 계정입니다."); + } + + // 다른 사람 계정에 연동되어 있음 → "병합할지" 물어봐야 하는 상황 + throw new OauthLinkedToOtherAccountException( + "이 계정은 이미 다른 계정에 연동되어 있습니다. 병합하시려면 확인 후 다시 요청해주세요.", + conflictingUser.getUserId()); + } + + oauthService.create(primaryUser, provider, userInfo.providerId()); + } + + // 다른 계정 삭제하고 하나로 합치기 + @Override + public void mergeAccount(Long primaryUserId, Long secondaryUserId, String provider, String providerUserId) { + // ★ 같은 계정끼리 병합 거부 (자기 자신 병합 시 계정 삭제 방지) + if (primaryUserId.equals(secondaryUserId)) { + throw new InvalidMergeRequestException("자기 자신과는 병합할 수 없습니다."); + } + + User primaryUser = userService.findById(primaryUserId); + + // 2차 확인 - secondaryUser가 정말 이 provider/providerUserId를 갖고 있는지 검증 + Oauth secondaryOauth = oauthService.findByProviderAndProviderUserId(provider, providerUserId) + .orElseThrow(() -> new OauthNotFoundException("연동 정보를 찾을 수 없습니다.")); + + if (!secondaryOauth.getUser().getUserId().equals(secondaryUserId)) { + throw new InvalidMergeRequestException("병합 요청이 유효하지 않습니다."); + } + + // Oauth를 primaryUser로 재연결 + secondaryOauth.reassignUser(primaryUser); + + // secondaryUser(연동 전에 사용하던 계정 삭제) 삭제 + userService.deleteUser(secondaryUserId); } } diff --git a/src/main/java/com/nhnacademy/insightonauth/service/impl/OauthServiceImpl.java b/src/main/java/com/nhnacademy/insightonauth/service/impl/OauthServiceImpl.java index 0fc9acc..0626830 100644 --- a/src/main/java/com/nhnacademy/insightonauth/service/impl/OauthServiceImpl.java +++ b/src/main/java/com/nhnacademy/insightonauth/service/impl/OauthServiceImpl.java @@ -55,6 +55,7 @@ public void deleteAllByUser(User user) { } @Override + @Transactional(readOnly = true) public Oauth findOauth(User user, String provider) { return oauthRepository.findByUserAndProvider(user, provider) .orElseThrow(() -> new OauthNotFoundException("연동된 소셜 계정을 찾을 수 없습니다.")); @@ -62,11 +63,13 @@ public Oauth findOauth(User user, String provider) { // 전체 삭제 @Override + @Transactional(readOnly = true) public List findAllByUser(User user) { return oauthRepository.findByUser(user); } @Override + @Transactional(readOnly = true) public Optional findByProviderAndProviderUserId(String provider, String providerUserId) { return oauthRepository.findByProviderAndProviderUserId(provider, providerUserId); } diff --git a/src/main/java/com/nhnacademy/insightonauth/service/impl/UserCredentialServiceImpl.java b/src/main/java/com/nhnacademy/insightonauth/service/impl/UserCredentialServiceImpl.java index 978e0e0..0dc8306 100644 --- a/src/main/java/com/nhnacademy/insightonauth/service/impl/UserCredentialServiceImpl.java +++ b/src/main/java/com/nhnacademy/insightonauth/service/impl/UserCredentialServiceImpl.java @@ -28,6 +28,7 @@ public void create(User user, String password) { } @Override + @Transactional(readOnly = true) public UserCredential findByUser(User user) { UserCredential userCredential = userCredentialRepository.findByUser(user) .orElseThrow(() -> new UserCredentialsNotFoundException("유저 인증 정보가 없습니다.")); @@ -49,6 +50,7 @@ public void updatePassword(OffsetDateTime now, User user, String password) { } @Override + @Transactional(readOnly = true) public boolean exists(User user) { return userCredentialRepository.existsByUser(user); } diff --git a/src/main/java/com/nhnacademy/insightonauth/service/impl/UserRoleServiceImpl.java b/src/main/java/com/nhnacademy/insightonauth/service/impl/UserRoleServiceImpl.java index 822a041..b9d4855 100644 --- a/src/main/java/com/nhnacademy/insightonauth/service/impl/UserRoleServiceImpl.java +++ b/src/main/java/com/nhnacademy/insightonauth/service/impl/UserRoleServiceImpl.java @@ -46,6 +46,7 @@ public void removeRole(User user, Role role) { } @Override + @Transactional(readOnly = true) public List findByUser(User user) { List userRoleList = userRoleRepository.findByUser(user); diff --git a/src/main/java/com/nhnacademy/insightonauth/service/impl/UserServiceImpl.java b/src/main/java/com/nhnacademy/insightonauth/service/impl/UserServiceImpl.java index 903b8d4..5b6006c 100644 --- a/src/main/java/com/nhnacademy/insightonauth/service/impl/UserServiceImpl.java +++ b/src/main/java/com/nhnacademy/insightonauth/service/impl/UserServiceImpl.java @@ -1,9 +1,12 @@ package com.nhnacademy.insightonauth.service.impl; +import com.nhnacademy.insightonauth.client.CoreClient; import com.nhnacademy.insightonauth.client.OauthClient; +import com.nhnacademy.insightonauth.client.OauthClientResolver; import com.nhnacademy.insightonauth.dto.auth.TokenRefreshResponse; import com.nhnacademy.insightonauth.dto.auth.UserLoginResponse; import com.nhnacademy.insightonauth.dto.auth.UserSignupResponse; +import com.nhnacademy.insightonauth.dto.core.ManagerGroupExistsResponse; import com.nhnacademy.insightonauth.dto.oauth.OauthUserInfo; import com.nhnacademy.insightonauth.email.EmailService; import com.nhnacademy.insightonauth.entity.*; @@ -16,6 +19,7 @@ import com.nhnacademy.insightonauth.util.PhoneNumberUtil; import io.jsonwebtoken.JwtException; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -27,11 +31,14 @@ import java.util.Optional; import java.util.UUID; +@Slf4j @Service @Transactional @RequiredArgsConstructor public class UserServiceImpl implements UserService { + // Transactional 전파 필요없으면 빼기 어노테이션 붙이기 + // private 메소드의 Transactional의 붙는 경우 proxy가 적용안 될수 있음 private final UserRepository userRepository; private final UserCredentialService userCredentialService; private final UserRoleService userRoleService; @@ -39,8 +46,9 @@ public class UserServiceImpl implements UserService { private final JwtProvider jwtProvider; private final RedisService redisService; private final EmailService emailService; - private final OauthClient oauthClient; + private final OauthClientResolver oauthClientResolver; private final OauthService oauthService; + private final CoreClient coreClient; @Override public UserSignupResponse createUser(String email, String password, String userName, String phoneNumber, Role role, String verificationToken) { @@ -69,12 +77,33 @@ public boolean checkEmailAvailable(String email) { @Override public void emailVerifyRequest(String email) { + // 1. 재전송 잠금 체크 (더 강한 제한 먼저) + if (redisService.hasKey(RedisKey.VERIFY_RESEND_LOCK.getPrefix() + email)) { + throw new VerificationResendLockedException("재전송 시도가 초과되어 잠겼습니다."); + } + // 2. 쿨다운을 원자적으로 설정 + boolean acquired = redisService.setIfAbsent( + RedisKey.VERIFY_RESEND_COOLDOWN.getPrefix() + email, + "1", + Duration.ofSeconds(60) + ); + + if (!acquired) { + throw new VerificationResendTooSoonException( + "잠시 후 다시 시도해 주세요." + ); + } + + // 3. 발송 emailService.sendVerificationCode(email); + + // 4. 카운터 증가 + increaseResendCount(email); } @Override public String emailVerifyConfirm(String email, String code) { - return emailService.emailVerify(email, code); + return emailService.emailCodeVerify(email, code); } @Override @@ -99,6 +128,7 @@ public UserLoginResponse login(String email, String password) { } // 유저 계정 존재 여부 숨기기 + // 기존대로 UserNotFound로 하는건 어떤가 User user = userRepository.findByEmail(email) .orElseThrow(() -> new InvalidCredentialsException("유저를 찾을 수 없습니다.")); UserCredential credential = userCredentialService.findByUser(user); @@ -120,7 +150,7 @@ public UserLoginResponse login(String email, String password) { throw new InvalidUserException(user.getStatus().getMessage()); } - user.setLastLoginAt(OffsetDateTime.now(ZoneOffset.UTC)); + user.updateLastLoginAt(); return issueTokens(user, email); } @@ -144,7 +174,7 @@ public void reactivateRequest(String email) { @Override public UserLoginResponse reactivateConfirm(String email, String code) { - emailService.emailVerify(email, code); + emailService.emailCodeVerify(email, code); User user = userRepository.findByEmail(email) .orElseThrow(() -> new UserNotFoundException("유저를 찾을 수 없습니다.")); @@ -168,12 +198,25 @@ public UserLoginResponse reactive(String reactiveToken) { return issueTokens(user, user.getEmail()); } + // 이러면 없는 계정은 응답이 더 빨리 나가기 때문에 공격자가 계정 존재 여부를 알 수 있음 @Override public void passwordResetRequest(String email) { - // 탈퇴 계정은 메일이 나가지 않게 조정, 예외를 던지면 공격자가 계정 존재를 알 수 있음 + // 1. 연타 방지/잠금 체크 — 계정 여부와 무관하게 항상 + if (redisService.hasKey(RedisKey.PASSWORD_RESET_RESEND_LOCK.getPrefix() + email)) { + throw new PasswordResetResendLockedException("재전송 시도가 초과되어 잠겼습니다."); + } + if (redisService.hasKey(RedisKey.PASSWORD_RESET_RESEND_COOLDOWN.getPrefix() + email)) { + throw new PasswordResetResendTooSoonException("잠시 후 다시 시도해 주세요."); + } + + // 2. 연타 방지/카운터 기록 — 계정 여부와 무관하게 항상 (열거 방지 핵심) + redisService.set(RedisKey.PASSWORD_RESET_RESEND_COOLDOWN.getPrefix() + email, "1", Duration.ofSeconds(60)); + increasePasswordResetResendCount(email); + + // 3. 실제 메일 발송만 계정 있고 정상일 때 (없어도 예외 안 던짐) userRepository.findByEmail(email).ifPresent(user -> { if (user.getStatus() == Status.WITHDRAW) { - return; + return; // 탈퇴 계정: 메일만 안 보냄 (rate limit은 이미 걸림) } emailService.sendPasswordResetPath(email); }); @@ -213,6 +256,7 @@ public void updatePhoneNumber(Long userId, String phoneNumber) { user.setUpdatedAt(OffsetDateTime.now(ZoneOffset.UTC)); } + // 전화번호 찾기시 인증이 방법시 생각해보기 @Override public String findMaskedEmail(String userName, String phoneNumber) { String normalized = PhoneNumberUtil.normalize(phoneNumber); @@ -236,12 +280,6 @@ public String findMaskedEmail(String userName, String phoneNumber) { return visible + masked + domain; } - @Override - public void updateLastLoginAt(Long userId) { - User user = findById(userId); - user.setLastLoginAt(OffsetDateTime.now(ZoneOffset.UTC)); - } - @Override public void activate(Long userId) { User user = findById(userId); @@ -254,6 +292,7 @@ public void activate(Long userId) { user.setUpdatedAt(OffsetDateTime.now(ZoneOffset.UTC)); } + //탈톼시 비밀번호 확인 @Override public void withdraw(Long userId) { User user = findById(userId); @@ -262,7 +301,21 @@ public void withdraw(Long userId) { throw new InvalidUserStatusException("이미 탈퇴한 계정입니다."); } + ManagerGroupExistsResponse response; + try { + response = coreClient.existsManagerGroup(userId); + } catch (Exception e) { + log.warn("Core 서비스 호출 실패로 탈퇴를 차단합니다 - userId: {}, 원인: {}", userId, e.getMessage()); + throw new CoreServiceUnavailableException( + "일시적으로 그룹 정보를 확인할 수 없어 탈퇴가 제한됩니다. 잠시 후 다시 시도해주세요."); + } + + if (response.exists()) { + throw new ManagerGroupExistsException("그룹 관리자 역할이 있어 탈퇴할 수 없습니다."); + } + user.withdraw(); + redisService.delete(RedisKey.REFRESH.getPrefix() + userId); } @Override @@ -306,7 +359,8 @@ public void deleteUser(Long userId) { @Override public UserLoginResponse oauthLogin(String provider, String code) { - OauthUserInfo userInfo = oauthClient.getUserInfo(provider, code); + OauthClient oauthClient = oauthClientResolver.resolve(provider); + OauthUserInfo userInfo = oauthClient.getUserInfo(code); Optional existingOauth = oauthService.findByProviderAndProviderUserId(provider, userInfo.providerId()); @@ -324,19 +378,29 @@ public UserLoginResponse oauthLogin(String provider, String code) { throw new InvalidUserException(user.getStatus().getMessage()); } + user.updateLastLoginAt(); return issueTokens(user, user.getEmail()); } + // 새 User 만들기 전에, 이 이메일이 이미 가입돼 있는지 확인 + if (userRepository.existsByEmail(userInfo.email())) { + // 이미 이 이메일로 가입된 계정이 있음 → 자동 생성/연결하지 않고 차단 + throw new EmailAlreadyRegisteredException( + "이미 가입된 이메일입니다. 로그인 후 마이페이지에서 소셜 계정을 연동해 주세요."); + } + User newUser = new User(userInfo.email(), userInfo.name(), null); userRepository.save(newUser); userRoleService.create(newUser, Role.MEMBER); oauthService.create(newUser, provider, userInfo.providerId()); + newUser.updateLastLoginAt(); return issueTokens(newUser, userInfo.email()); } @Override public TokenRefreshResponse refresh(Long userId, String refreshToken) { + // base64 왜쓰는지 try { jwtProvider.validateRefreshToken(userId, refreshToken); // Redis의 jti와 대조 검증 } catch (JwtException e) { @@ -357,6 +421,20 @@ public TokenRefreshResponse refresh(Long userId, String refreshToken) { return new TokenRefreshResponse(accessToken); } + @Override + @Transactional(readOnly = true) + public List findExpiredWithdrawnUsers() { + return userRepository.findByStatusAndWithdrawnAtBefore( + Status.WITHDRAW, OffsetDateTime.now(ZoneOffset.UTC).minusDays(90)); + } + + @Override + @Transactional(readOnly = true) + public List findInactiveUsers() { + return userRepository.findByStatusAndLastLoginAtBefore( + Status.ACTIVE, OffsetDateTime.now(ZoneOffset.UTC).minusDays(30)); + } + private User findActiveUser(Long userId) { User user = findById(userId); if (user.getStatus() != Status.ACTIVE) { @@ -409,4 +487,31 @@ private UserLoginResponse handleWithdrawnLogin(User user) { return UserLoginResponse.pendingRestore(restoreToken); } + + private void increaseResendCount(String email) { + String saved = redisService.get(RedisKey.VERIFY_RESEND_COUNT.getPrefix() + email); + int count = (saved == null || saved.isBlank()) ? 0 : Integer.parseInt(saved); + count++; + + if (count >= 5) { + redisService.delete(RedisKey.VERIFY_RESEND_COUNT.getPrefix() + email); + redisService.set(RedisKey.VERIFY_RESEND_LOCK.getPrefix() + email, "locked", Duration.ofMinutes(30)); + } else { + redisService.set(RedisKey.VERIFY_RESEND_COUNT.getPrefix() + email, + String.valueOf(count), Duration.ofMinutes(30)); + } + } + + private void increasePasswordResetResendCount(String email) { + String saved = redisService.get(RedisKey.PASSWORD_RESET_RESEND_COUNT.getPrefix() + email); + int count = (saved == null || saved.isBlank()) ? 0 : Integer.parseInt(saved); + count++; + if (count >= 5) { + redisService.delete(RedisKey.PASSWORD_RESET_RESEND_COUNT.getPrefix() + email); + redisService.set(RedisKey.PASSWORD_RESET_RESEND_LOCK.getPrefix() + email, "locked", Duration.ofMinutes(30)); + } else { + redisService.set(RedisKey.PASSWORD_RESET_RESEND_COUNT.getPrefix() + email, + String.valueOf(count), Duration.ofMinutes(30)); + } + } } diff --git a/src/main/resources/application-auth1.properties b/src/main/resources/application-auth1.properties deleted file mode 100644 index 73e6970..0000000 --- a/src/main/resources/application-auth1.properties +++ /dev/null @@ -1 +0,0 @@ -eureka.instance.instance-id=${spring.application.name}:insignton-auth-1 \ No newline at end of file diff --git a/src/main/resources/application-auth2.properties b/src/main/resources/application-auth2.properties deleted file mode 100644 index e7976e8..0000000 --- a/src/main/resources/application-auth2.properties +++ /dev/null @@ -1 +0,0 @@ -eureka.instance.instance-id=${spring.application.name}:insignton-auth-2 \ No newline at end of file diff --git a/src/main/resources/application-dev.properties b/src/main/resources/application-dev.properties index 7163775..59e8ac3 100644 --- a/src/main/resources/application-dev.properties +++ b/src/main/resources/application-dev.properties @@ -1,81 +1,3 @@ -spring.application.name=insighton-auth - -# =============================== -# PostgreSql -# =============================== -spring.datasource.url=${DB_URL} -spring.datasource.username=${DB_USERNAME} -spring.datasource.password=${DB_PASSWORD} - -# =============================== -# H2 Database -# =============================== -#spring.datasource.url=jdbc:h2:mem:test;DB_CLOSE_DELAY=-1;MODE=PostgreSQL -#spring.datasource.driver-class-name=org.h2.Driver -#spring.datasource.username=sa -#spring.datasource.password= -# H2 콘솔 -#spring.h2.console.enabled=true -#spring.h2.console.path=/h2-console - -# =============================== -# JPA -# =============================== -spring.jpa.hibernate.ddl-auto=validate +# dev 프로파일에서만 다른 설정. 공통 설정은 application.properties 참고. +spring.config.import=classpath:config/dev/actuator.properties, classpath:config/dev/redis.properties, classpath:config/dev/db.properties, classpath:config/dev/mail.properties spring.jpa.show-sql=true -spring.jpa.properties.hibernate.format_sql=true -spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect -#spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.H2Dialect - -# =============================== -# Redis -# =============================== -spring.data.redis.host=${REDIS_HOST} -spring.data.redis.port=${REDIS_PORT} -spring.data.redis.password=${REDIS_PASSWORD} -spring.data.redis.database=${REDIS_DATABASE} - -# =============================== -# jwt token keys -# =============================== -jwt.private-key=${JWT_PRIVATE_KEY} -jwt.public-key=${JWT_PUBLIC_KEY} -jwt.key-id=monitoring-key-v1 -jwt.access-token-validity=15m -jwt.refresh-token-validity=14d - -eureka.client.enabled=false - -# =============================== -# Brevo -# =============================== -spring.mail.host=smtp-relay.brevo.com -spring.mail.port=587 -spring.mail.username=${BREVO_SMTP_LOGIN} -spring.mail.password=${BREVO_SMTP_KEY} -spring.mail.properties.mail.smtp.auth=true -spring.mail.properties.mail.smtp.starttls.enable=true - -# 타임아웃 -spring.mail.properties.mail.smtp.connectiontimeout=5000 -spring.mail.properties.mail.smtp.timeout=5000 -spring.mail.properties.mail.smtp.writetimeout=5000 - -# =============================== -# MailHog (테스트용) -# =============================== -#spring.mail.host=localhost -#spring.mail.port=1025 -#spring.mail.properties.mail.smtp.auth=false -#spring.mail.properties.mail.smtp.starttls.enable=false - -# 발신 이메일 주소 (코드에서 참조용) -mail.from-address=insighton@insighton.store -mail.from-name=InsightOn - -# =============================== -# OAuth -# =============================== -oauth.google.client-id=${GOOGLE_CLIENT_ID} -oauth.google.client-secret=${GOOGLE_CLIENT_SECRET} -oauth.google.redirect-uri=https://insighton.store/oauth/callback \ No newline at end of file diff --git a/src/main/resources/application-prod.properties b/src/main/resources/application-prod.properties new file mode 100644 index 0000000..2a23271 --- /dev/null +++ b/src/main/resources/application-prod.properties @@ -0,0 +1,5 @@ +# prod 프로파일에서만 다른 설정. 공통 설정은 application.properties 참고. + +spring.config.import=classpath:config/prod/actuator.properties, classpath:config/prod/redis.properties, classpath:config/prod/db.properties, classpath:config/prod/mail.properties + +spring.jpa.show-sql=false diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 13b1dcf..a340030 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1,14 +1,53 @@ spring.application.name=insighton-auth -eureka.client.service-url.defaultZone=http://insighton-eureka:8761/eureka/ +management.endpoints.web.exposure.include=health,prometheus +management.endpoint.health.show-details: always -# redis에 refresh, access를 저장 gateway에서 access 확인, auth에서 refresh 확인 +# =============================== +# JPA +# =============================== +spring.jpa.hibernate.ddl-auto=validate +spring.jpa.properties.hibernate.format_sql=true +spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect -eureka.instance.instance-id=true +# =============================== +# jwt token keys +# =============================== +jwt.private-key=${JWT_PRIVATE_KEY} +jwt.public-key=${JWT_PUBLIC_KEY} +jwt.key-id=monitoring-key-v1 +jwt.access-token-validity=15m +jwt.refresh-token-validity=14d -spring.config.import=optional:configserver:http://insighton-config:8888 -spring.cloud.config.username=${CONFIG_SERVER_USERNAME} -spring.cloud.config.password=${CONFIG_SERVER_PASSWORD} +# 타임아웃 +spring.mail.properties.mail.smtp.connectiontimeout=5000 +spring.mail.properties.mail.smtp.timeout=5000 +spring.mail.properties.mail.smtp.writetimeout=5000 -management.endpoints.web.exposure.include=health,prometheus -management.endpoint.health.show-details: always \ No newline at end of file +# 발신 이메일 주소 (코드에서 참조용) +mail.from-address=insighton@insighton.store +mail.from-name=InsightOn + +# =============================== +# OAuth - Google +# =============================== +oauth.google.client-id=${GOOGLE_CLIENT_ID} +oauth.google.client-secret=${GOOGLE_CLIENT_SECRET} +oauth.redirect-uri=https://insighton.store/oauth/callback + +# =============================== +# OAuth - GitHub +# =============================== +oauth.github.client-id=${GITHUB_CLIENT_ID} +oauth.github.client-secret=${GITHUB_CLIENT_SECRET} + +# =============================== +# Resilience4j +# =============================== +spring.cloud.openfeign.circuitbreaker.enabled=true + +# Feign이 생성하는 CB 이름(CoreClient#메서드(파라미터))과 인스턴스 이름 매칭이 +# 까다로워, 이름 무관하게 적용되는 configs.default로 공통 정책 설정 +resilience4j.circuitbreaker.configs.default.sliding-window-size=10 +resilience4j.circuitbreaker.configs.default.failure-rate-threshold=50 +resilience4j.circuitbreaker.configs.default.wait-duration-in-open-state=10s diff --git a/src/main/resources/config/dev/actuator.properties b/src/main/resources/config/dev/actuator.properties new file mode 100644 index 0000000..99f784f --- /dev/null +++ b/src/main/resources/config/dev/actuator.properties @@ -0,0 +1 @@ +management.tracing.sampling.probability=0.0 diff --git a/src/main/resources/config/dev/db.properties b/src/main/resources/config/dev/db.properties new file mode 100644 index 0000000..42472b2 --- /dev/null +++ b/src/main/resources/config/dev/db.properties @@ -0,0 +1,8 @@ +spring.datasource.url=jdbc:h2:mem:test;DB_CLOSE_DELAY=-1;MODE=PostgreSQL +spring.datasource.driver-class-name=org.h2.Driver +spring.datasource.username=sa +spring.datasource.password= +spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.H2Dialect +# H2 콘솔 +spring.h2.console.enabled=true +spring.h2.console.path=/h2-console \ No newline at end of file diff --git a/src/main/resources/config/dev/mail.properties b/src/main/resources/config/dev/mail.properties new file mode 100644 index 0000000..3ff447c --- /dev/null +++ b/src/main/resources/config/dev/mail.properties @@ -0,0 +1,4 @@ +#spring.mail.host=localhost +#spring.mail.port=1025 +#spring.mail.properties.mail.smtp.auth=false +#spring.mail.properties.mail.smtp.starttls.enable=false \ No newline at end of file diff --git a/src/main/resources/config/dev/redis.properties b/src/main/resources/config/dev/redis.properties new file mode 100644 index 0000000..7c54fb2 --- /dev/null +++ b/src/main/resources/config/dev/redis.properties @@ -0,0 +1,4 @@ +spring.data.redis.host=s4.java21.net +spring.data.redis.port=6379 +spring.data.redis.password=${REDIS_PASSWORD} +spring.data.redis.database=43 \ No newline at end of file diff --git a/src/main/resources/config/prod/actuator.properties b/src/main/resources/config/prod/actuator.properties new file mode 100644 index 0000000..bcc3538 --- /dev/null +++ b/src/main/resources/config/prod/actuator.properties @@ -0,0 +1,5 @@ +management.tracing.export.zipkin.endpoint=http://insighton-zipkin:9411/api/v2/spans +management.tracing.sampling.probability=1.0 +management.endpoint.health.probes.enabled=true +management.health.livenessState.enabled=true +management.readinessState.enabled=true diff --git a/src/main/resources/config/prod/db.properties b/src/main/resources/config/prod/db.properties new file mode 100644 index 0000000..c986401 --- /dev/null +++ b/src/main/resources/config/prod/db.properties @@ -0,0 +1,3 @@ +spring.datasource.url=jdbc:postgresql://s3.java21.net:8000/aiot3-team3-project?currentSchema=auth +spring.datasource.username=aiot3-team3 +spring.datasource.password=${DB_PASSWORD} \ No newline at end of file diff --git a/src/main/resources/config/prod/mail.properties b/src/main/resources/config/prod/mail.properties new file mode 100644 index 0000000..20b843a --- /dev/null +++ b/src/main/resources/config/prod/mail.properties @@ -0,0 +1,6 @@ +spring.mail.host=smtp-relay.brevo.com +spring.mail.port=587 +spring.mail.username=${BREVO_SMTP_LOGIN} +spring.mail.password=${BREVO_SMTP_KEY} +spring.mail.properties.mail.smtp.auth=true +spring.mail.properties.mail.smtp.starttls.enable=true \ No newline at end of file diff --git a/src/main/resources/config/prod/redis.properties b/src/main/resources/config/prod/redis.properties new file mode 100644 index 0000000..266948a --- /dev/null +++ b/src/main/resources/config/prod/redis.properties @@ -0,0 +1,4 @@ +spring.data.redis.host=s4.java21.net +spring.data.redis.port=6379 +spring.data.redis.password=${REDIS_PASSWORD} +spring.data.redis.database=321 \ No newline at end of file diff --git a/src/test/java/com/nhnacademy/insightonauth/InsightonAuthApplicationTest.java b/src/test/java/com/nhnacademy/insightonauth/InsightonAuthApplicationTest.java index 89e7359..2eb1bce 100644 --- a/src/test/java/com/nhnacademy/insightonauth/InsightonAuthApplicationTest.java +++ b/src/test/java/com/nhnacademy/insightonauth/InsightonAuthApplicationTest.java @@ -1,11 +1,20 @@ package com.nhnacademy.insightonauth; import org.junit.jupiter.api.Test; +import org.redisson.api.RedissonClient; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.bean.override.mockito.MockitoBean; @SpringBootTest class InsightonAuthApplicationTest { + + // 스케줄러(UserHardDeleteScheduler 등)가 주입받는 RedissonClient는 빈 생성 시점에 + // 즉시 실제 Redis 연결을 시도하기 때문에, 컨텍스트 로드만 확인하는 이 테스트에서는 + // 실 연결 없이 목으로 대체한다. + @MockitoBean + private RedissonClient redissonClient; + @Test void contextLoads() { } -} \ No newline at end of file +} diff --git a/src/test/java/com/nhnacademy/insightonauth/entity/OauthTest.java b/src/test/java/com/nhnacademy/insightonauth/entity/OauthTest.java new file mode 100644 index 0000000..2c3f97d --- /dev/null +++ b/src/test/java/com/nhnacademy/insightonauth/entity/OauthTest.java @@ -0,0 +1,27 @@ +package com.nhnacademy.insightonauth.entity; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; + +class OauthTest { + private User user; + + @BeforeEach + void setUp() { + user = new User("test@test.com", "test", "01012345678"); + } + + @Test + @DisplayName("oauth 생성자 생성 성공") + void createOauth() { + Oauth oauth = new Oauth(user, "google", "provider-user-id-123"); + + assertThat(oauth.getUser()).isEqualTo(user); + assertThat(oauth.getProvider()).isEqualTo("google"); + assertThat(oauth.getProviderUserId()).isEqualTo("provider-user-id-123"); + assertThat(oauth.getCreatedAt()).isNotNull(); + } +} diff --git a/src/test/java/com/nhnacademy/insightonauth/entity/UserCredentialTest.java b/src/test/java/com/nhnacademy/insightonauth/entity/UserCredentialTest.java new file mode 100644 index 0000000..e9d2282 --- /dev/null +++ b/src/test/java/com/nhnacademy/insightonauth/entity/UserCredentialTest.java @@ -0,0 +1,45 @@ +package com.nhnacademy.insightonauth.entity; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.OffsetDateTime; +import java.time.ZoneOffset; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; + +class UserCredentialTest { + + private User user; + + @BeforeEach + void setUp() { + user = new User("test@test.com", "test", "01012345678"); + } + + @Test + @DisplayName("userCredential 생성자 생성 성공") + void createUserCredential() { + User user = new User("test@test.com", "test", "01012345678"); + UserCredential credential = new UserCredential(user, "hashed-password"); + + assertThat(credential.getUser()).isEqualTo(user); + assertThat(credential.getPasswordHash()).isEqualTo("hashed-password"); + assertThat(credential.getCreatedAt()).isNotNull(); + assertThat(credential.getUpdatedAt()).isNotNull(); + } + + @Test + @DisplayName("changePassword 비밀번호, 수정시각 변경") + void changePassword_updatesPasswordAndUpdatedAt() { + UserCredential credential = new UserCredential(user, "old-hashed-password"); + OffsetDateTime newTime = OffsetDateTime.now(ZoneOffset.UTC).plusDays(1); + + credential.changePassword(newTime, "new-hashed-password"); + + assertThat(credential.getPasswordHash()).isEqualTo("new-hashed-password"); + assertThat(credential.getUpdatedAt()).isEqualTo(newTime); + } +} diff --git a/src/test/java/com/nhnacademy/insightonauth/entity/UserRoleTest.java b/src/test/java/com/nhnacademy/insightonauth/entity/UserRoleTest.java new file mode 100644 index 0000000..cec8b11 --- /dev/null +++ b/src/test/java/com/nhnacademy/insightonauth/entity/UserRoleTest.java @@ -0,0 +1,28 @@ +package com.nhnacademy.insightonauth.entity; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; + +class UserRoleTest { + + private User user; + + @BeforeEach + void setUp() { + user = new User("test@test.com", "test", "01012345678"); + } + + @Test + @DisplayName("userRole 생성자 생성 성공") + void createUserRole() { + UserRole userRole = new UserRole(user, Role.MEMBER); + + assertThat(userRole.getUser()).isEqualTo(user); + assertThat(userRole.getRole()).isEqualTo(Role.MEMBER); + assertThat(userRole.getCreatedAt()).isNotNull(); + } + +} diff --git a/src/test/java/com/nhnacademy/insightonauth/entity/UserTest.java b/src/test/java/com/nhnacademy/insightonauth/entity/UserTest.java new file mode 100644 index 0000000..a7b143f --- /dev/null +++ b/src/test/java/com/nhnacademy/insightonauth/entity/UserTest.java @@ -0,0 +1,93 @@ +package com.nhnacademy.insightonauth.entity; + +import com.nhnacademy.insightonauth.exception.InvalidUserStatusException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; + +class UserTest { + + @Test + @DisplayName("user 생성자 생성 성공") + void createUser() { + User user = new User("test@test.com", "test", "01012345678"); + + assertThat(user.getEmail()).isEqualTo("test@test.com"); + assertThat(user.getUserName()).isEqualTo("test"); + assertThat(user.getPhoneNumber()).isEqualTo("01012345678"); + assertThat(user.getStatus()).isEqualTo(Status.ACTIVE); + } + + @Test + @DisplayName("withdraw시 접미사 확인") + void withdraw_suffix() { + User user = new User("test@test.com", "test", "01012345678"); + + user.withdraw(); + + assertThat(user.getEmail()).startsWith("test@test.com;"); + assertThat(user.getPhoneNumber()).startsWith("01012345678;"); + assertThat(user.getStatus()).isEqualTo(Status.WITHDRAW); + assertThat(user.getWithdrawnAt()).isNotNull(); + } + + @Test + @DisplayName("withdraw후 reactivate시 원복") + void withdraw_then_reactivate_restoresEmail() { + User user = new User("test@test.com", "test", "01012345678"); + user.withdraw(); + + user.reactivate(); + + assertThat(user.getEmail()).isEqualTo("test@test.com"); + assertThat(user.getPhoneNumber()).isEqualTo("01012345678"); + assertThat(user.getStatus()).isEqualTo(Status.ACTIVE); + assertThat(user.getWithdrawnAt()).isNull(); + } + + @Test + @DisplayName("ACTIVE 상태에서 reactivate 호출 시 예외 발생") + void reactivate_whenActive_throwsException() { + User user = new User("test@test.com", "test", "01012345678"); + + assertThatThrownBy(user::reactivate) + .isInstanceOf(InvalidUserStatusException.class) + .hasMessage("휴면 또는 탈퇴 상태가 아닙니다."); + } + + @Test + @DisplayName("전화번호가 null이어도 withdraw 정상 동작") + void withdraw_withNullPhoneNumber_worksCorrectly() { + User user = new User("test@test.com", "test", null); + user.withdraw(); + + assertThat(user.getPhoneNumber()).isNull(); + assertThat(user.getStatus()).isEqualTo(Status.WITHDRAW); + } + + @Test + @DisplayName("SLEEP 상태에서 reactivate 호출 시 ACTIVE로 전환") + void reactivate_whenSleep_becomesActive() { + User user = new User("test@test.com", "test", "01012345678"); + user.setStatus(Status.SLEEP); + + user.reactivate(); + + assertThat(user.getStatus()).isEqualTo(Status.ACTIVE); + assertThat(user.getEmail()).isEqualTo("test@test.com"); + } + + @Test + @DisplayName("BLOCK 상태에서 reactivate 호출 시 예외 발생") + void reactivate_whenBlocked_throwsException() { + User user = new User("test@test.com", "test", "01012345678"); + user.setStatus(Status.BLOCK); + + assertThatThrownBy(user::reactivate) + .isInstanceOf(InvalidUserStatusException.class) + .hasMessage("휴면 또는 탈퇴 상태가 아닙니다."); + } + +} diff --git a/src/test/java/com/nhnacademy/insightonauth/repository/OauthRepositoryTest.java b/src/test/java/com/nhnacademy/insightonauth/repository/OauthRepositoryTest.java new file mode 100644 index 0000000..0b4a6e7 --- /dev/null +++ b/src/test/java/com/nhnacademy/insightonauth/repository/OauthRepositoryTest.java @@ -0,0 +1,109 @@ +package com.nhnacademy.insightonauth.repository; + +import com.nhnacademy.insightonauth.entity.Oauth; +import com.nhnacademy.insightonauth.entity.User; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.AssertionsForInterfaceTypes.assertThat; + +@DataJpaTest +class OauthRepositoryTest { + + @Autowired + private OauthRepository oauthRepository; + + @Autowired + private UserRepository userRepository; + + private User user1; + private User user2; + + @BeforeEach + void setUp() { + user1 = new User("test@test.com", "test", "01012345678"); + userRepository.save(user1); + oauthRepository.save(new Oauth(user1, "google", "google-provider-id-123")); + + user2 = new User("other@test.com", "other", "01099998888"); + userRepository.save(user2); + oauthRepository.save(new Oauth(user2, "google", "other-provider-id")); + } + + @Test + @DisplayName("user, provider로 연동 정보 조회") + void findByUserAndProvider_returnsOauth() { + Optional found = oauthRepository.findByUserAndProvider(user1, "google"); + + assertThat(found) + .isPresent() + .get() + .extracting(Oauth::getProviderUserId) + .isEqualTo("google-provider-id-123"); + } + + @Test + @DisplayName("연동 없는 provider 조회") + void findByUserAndProvider_whenNotExists_returnsEmpty() { + Optional found = oauthRepository.findByUserAndProvider(user1, "github"); + + assertThat(found).isEmpty(); + } + + @Test + @DisplayName("provider, providerUserId로 연동 정보 조회") + void findByProviderAndProviderUserId_returnsOauth() { + Optional found = oauthRepository.findByProviderAndProviderUserId("google", "google-provider-id-123"); + + assertThat(found) + .isPresent() + .get() + .extracting(oauth -> oauth.getUser().getUserId()) + .isEqualTo(user1.getUserId()); + } + + @Test + @DisplayName("없는 providerUserId 조회") + void findByProviderAndProviderUserId_whenNotExists_returnsEmpty() { + Optional found = oauthRepository.findByProviderAndProviderUserId("google", "not-exist-id"); + + assertThat(found).isEmpty(); + } + + @Test + @DisplayName("user로 연동 목록 조회, 다른 사용자 제외 확인") + void findByUser_returnsOnlyOwnOauthList() { + List result = oauthRepository.findByUser(user1); + + assertThat(result).hasSize(1); + assertThat(result.get(0).getProvider()).isEqualTo("google"); + assertThat(result.get(0).getUser().getUserId()).isEqualTo(user1.getUserId()); + } + + @Test + @DisplayName("user로 연동 개수 조회, 다른 사용자 제외 확인") + void countByUser_excludesOtherUserOauths() { + oauthRepository.save(new Oauth(user1, "github", "github-provider-id-456")); + + Long count = oauthRepository.countByUser(user1); + + assertThat(count).isEqualTo(2L); + } + + @Test + @DisplayName("user로 연동 정보 전체 삭제, 다른 사용자 유지 확인") + void deleteByUser_removesOnlyOwnOauths() { + oauthRepository.save(new Oauth(user1, "github", "github-provider-id-456")); + + oauthRepository.deleteByUser(user1); + + assertThat(oauthRepository.findByUser(user1)).isEmpty(); + assertThat(oauthRepository.findByUser(user2)).hasSize(1); + } +} diff --git a/src/test/java/com/nhnacademy/insightonauth/repository/UserCredentialRepositoryTest.java b/src/test/java/com/nhnacademy/insightonauth/repository/UserCredentialRepositoryTest.java new file mode 100644 index 0000000..f8566fd --- /dev/null +++ b/src/test/java/com/nhnacademy/insightonauth/repository/UserCredentialRepositoryTest.java @@ -0,0 +1,90 @@ +package com.nhnacademy.insightonauth.repository; + +import com.nhnacademy.insightonauth.entity.User; +import com.nhnacademy.insightonauth.entity.UserCredential; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; + +import java.util.Optional; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; + +@DataJpaTest +class UserCredentialRepositoryTest { + @Autowired + private UserCredentialRepository userCredentialRepository; + + @Autowired + private UserRepository userRepository; + + private User user1; + private User user2; + + @BeforeEach + void setUp() { + user1 = new User("test@test.com", "test", "01012345678"); + userRepository.save(user1); + userCredentialRepository.save(new UserCredential(user1, "hashed-password")); + + user2 = new User("other@test.com", "other", "01099998888"); + userRepository.save(user2); + userCredentialRepository.save(new UserCredential(user2, "other-hashed-password")); + } + + @Test + @DisplayName("user로 존재 여부 확인 - true") + void existsByUser_returnsTrueWhenExists() { + boolean exists = userCredentialRepository.existsByUser(user1); + + assertThat(exists).isTrue(); + } + + @Test + @DisplayName("user로 존재 여부 확인 - false") + void existsByUser_returnsFalseWhenNotExists() { + User noCredentialUser = new User("nocred@test.com", "nocred", "01077776666"); + userRepository.save(noCredentialUser); + + boolean exists = userCredentialRepository.existsByUser(noCredentialUser); + + assertThat(exists).isFalse(); + } + + @Test + @DisplayName("user로 인증 정보 조회") + void findByUser_returnsOwnUserCredential() { + Optional found = userCredentialRepository.findByUser(user1); + + assertThat(found) + .isPresent() + .get() + .extracting(UserCredential::getPasswordHash) + .isEqualTo("hashed-password"); + } + + @Test + @DisplayName("다른 사용자 인증 정보 조회") + void findByUser_returnsOtherUsersOwnCredential() { + Optional found = userCredentialRepository.findByUser(user2); + + assertThat(found) + .isPresent() + .get() + .extracting(UserCredential::getPasswordHash) + .isEqualTo("other-hashed-password"); + } + + @Test + @DisplayName("인증 정보 없는 user 조회") + void findByUser_whenNotExists_returnsEmpty() { + User noCredentialUser = new User("nocred@test.com", "nocred", "01077776666"); + userRepository.save(noCredentialUser); + + Optional found = userCredentialRepository.findByUser(noCredentialUser); + + assertThat(found).isEmpty(); + } +} diff --git a/src/test/java/com/nhnacademy/insightonauth/repository/UserRepositoryTest.java b/src/test/java/com/nhnacademy/insightonauth/repository/UserRepositoryTest.java new file mode 100644 index 0000000..ab92e16 --- /dev/null +++ b/src/test/java/com/nhnacademy/insightonauth/repository/UserRepositoryTest.java @@ -0,0 +1,169 @@ +package com.nhnacademy.insightonauth.repository; + +import com.nhnacademy.insightonauth.entity.Status; +import com.nhnacademy.insightonauth.entity.User; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; + +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.AssertionsForInterfaceTypes.assertThat; + +@DataJpaTest +class UserRepositoryTest { + + @Autowired + private UserRepository userRepository; + + private User activeUser; + + @BeforeEach + void setUp() { + activeUser = new User("test@test.com", "test", "01012345678"); + userRepository.save(activeUser); + } + + @Test + @DisplayName("email로 유저 조회") + void findByEmail_returnsUser() { + Optional found = userRepository.findByEmail("test@test.com"); + + assertThat(found).isPresent(); + assertThat(found.get().getUserName()).isEqualTo("test"); + } + + @Test + @DisplayName("없는 email 조회 시 빈 값 반환") + void findByEmail_whenNotExists_returnsEmpty() { + Optional found = userRepository.findByEmail("notfound@test.com"); + + assertThat(found).isEmpty(); + } + + @Test + @DisplayName("userName, phoneNumber로 유저 조회") + void findByUserNameAndPhoneNumber_returnsUser() { + Optional found = userRepository.findByUserNameAndPhoneNumber("test", "01012345678"); + + assertThat(found).isPresent(); + assertThat(found.get().getEmail()).isEqualTo("test@test.com"); + } + + @Test + @DisplayName("탈퇴 email 접두어, status로 유저 조회") + void findByEmailStartingWithAndStatus_returnsUser() { + activeUser.withdraw(); + userRepository.save(activeUser); + String originalEmailPrefix = "test@test.com;"; + + Optional found = userRepository.findByEmailStartingWithAndStatus(originalEmailPrefix, Status.WITHDRAW); + + assertThat(found).isPresent(); + } + + @Test + @DisplayName("email, userName, status로 페이징 조회") + void findByEmailContainingAndUserNameContainingAndStatus_returnsPage() { + Pageable pageable = PageRequest.of(0, 10); + + Page result = userRepository.findByEmailContainingAndUserNameContainingAndStatus( + "test", "test", Status.ACTIVE, pageable); + + assertThat(result.getContent()).hasSize(1); + assertThat(result.getContent().getFirst().getEmail()).isEqualTo("test@test.com"); + } + + @Test + @DisplayName("email, userName으로 페이징 조회") + void findByEmailContainingAndUserNameContaining_returnsPage() { + Pageable pageable = PageRequest.of(0, 10); + + Page result = userRepository.findByEmailContainingAndUserNameContaining( + "test", "test", pageable); + + assertThat(result.getContent()).hasSize(1); + } + + @Test + @DisplayName("email 존재 여부 확인 - true") + void existsByEmail_returnsTrueWhenExists() { + boolean exists = userRepository.existsByEmail("test@test.com"); + + assertThat(exists).isTrue(); + } + + @Test + @DisplayName("email 존재 여부 확인 - false") + void existsByEmail_returnsFalseWhenNotExists() { + boolean exists = userRepository.existsByEmail("notfound@test.com"); + + assertThat(exists).isFalse(); + } + + @Test + @DisplayName("phoneNumber 존재 여부 확인") + void existsByPhoneNumber_returnsTrueWhenExists() { + boolean exists = userRepository.existsByPhoneNumber("01012345678"); + + assertThat(exists).isTrue(); + } + + @Test + @DisplayName("탈퇴 90일 경과 유저 조회") + void findByStatusAndWithdrawnAtBefore_returnsExpiredWithdrawnUsers() { + activeUser.withdraw(); + activeUser.setWithdrawnAt(OffsetDateTime.now(ZoneOffset.UTC).minusDays(100)); + userRepository.save(activeUser); + + List result = userRepository.findByStatusAndWithdrawnAtBefore( + Status.WITHDRAW, OffsetDateTime.now(ZoneOffset.UTC).minusDays(90)); + + assertThat(result).hasSize(1); + } + + @Test + @DisplayName("탈퇴 90일 미경과 유저 제외") + void findByStatusAndWithdrawnAtBefore_excludesRecentWithdrawnUsers() { + activeUser.withdraw(); + userRepository.save(activeUser); + + List result = userRepository.findByStatusAndWithdrawnAtBefore( + Status.WITHDRAW, OffsetDateTime.now(ZoneOffset.UTC).minusDays(90)); + + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("30일 미접속 유저 조회") + void findByStatusAndLastLoginAtBefore_returnsInactiveUsers() { + activeUser.updateLastLoginAt(OffsetDateTime.now(ZoneOffset.UTC).minusDays(40)); + userRepository.save(activeUser); + + List result = userRepository.findByStatusAndLastLoginAtBefore( + Status.ACTIVE, OffsetDateTime.now(ZoneOffset.UTC).minusDays(30)); + + assertThat(result).hasSize(1); + } + + @Test + @DisplayName("최근 접속 유저 제외") + void findByStatusAndLastLoginAtBefore_excludesRecentlyActiveUsers() { + activeUser.updateLastLoginAt(OffsetDateTime.now(ZoneOffset.UTC)); + userRepository.save(activeUser); + + List result = userRepository.findByStatusAndLastLoginAtBefore( + Status.ACTIVE, OffsetDateTime.now(ZoneOffset.UTC).minusDays(30)); + + assertThat(result).isEmpty(); + } +} + diff --git a/src/test/java/com/nhnacademy/insightonauth/repository/UserRoleRepositoryTest.java b/src/test/java/com/nhnacademy/insightonauth/repository/UserRoleRepositoryTest.java new file mode 100644 index 0000000..d8fb7c6 --- /dev/null +++ b/src/test/java/com/nhnacademy/insightonauth/repository/UserRoleRepositoryTest.java @@ -0,0 +1,102 @@ +package com.nhnacademy.insightonauth.repository; + +import com.nhnacademy.insightonauth.entity.Role; +import com.nhnacademy.insightonauth.entity.User; +import com.nhnacademy.insightonauth.entity.UserRole; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.AssertionsForInterfaceTypes.assertThat; + +@DataJpaTest +class UserRoleRepositoryTest { + + @Autowired + private UserRoleRepository userRoleRepository; + + @Autowired + private UserRepository userRepository; + + private User user1; + private User user2; + + @BeforeEach + void setUp() { + user1 = new User("test@test.com", "test", "01012345678"); + userRepository.save(user1); + userRoleRepository.save(new UserRole(user1, Role.MEMBER)); + + user2 = new User("other@test.com", "other", "01099998888"); + userRepository.save(user2); + userRoleRepository.save(new UserRole(user2, Role.MEMBER)); + userRoleRepository.save(new UserRole(user2, Role.ADMIN)); + } + + @Test + @DisplayName("user로 권한 목록 조회, 다른 사용자 제외 확인") + void findByUser_returnsOnlyOwnRoles() { + List result = userRoleRepository.findByUser(user1); + + assertThat(result).hasSize(1); + assertThat(result.get(0).getRole()).isEqualTo(Role.MEMBER); + assertThat(result.get(0).getUser().getUserId()).isEqualTo(user1.getUserId()); + } + + @Test + @DisplayName("다중 권한 사용자 목록 조회") + void findByUser_otherUserHasMultipleRoles() { + List result = userRoleRepository.findByUser(user2); + + assertThat(result).hasSize(2); + } + + @Test + @DisplayName("user, role로 존재 여부 확인 - true") + void existsByUserAndRole_returnsTrueWhenExists() { + boolean exists = userRoleRepository.existsByUserAndRole(user1, Role.MEMBER); + + assertThat(exists).isTrue(); + } + + @Test + @DisplayName("user, role로 존재 여부 확인 - false") + void existsByUserAndRole_returnsFalseWhenNotExists() { + boolean exists = userRoleRepository.existsByUserAndRole(user1, Role.ADMIN); + + assertThat(exists).isFalse(); + } + + @Test + @DisplayName("다른 사용자 역할 미포함 확인") + void existsByUserAndRole_doesNotMixOtherUsersRole() { + boolean exists = userRoleRepository.existsByUserAndRole(user1, Role.ADMIN); + + assertThat(exists).isFalse(); + } + + @Test + @DisplayName("user, role로 권한 조회") + void findByUserAndRole_returnsUserRole() { + Optional found = userRoleRepository.findByUserAndRole(user1, Role.MEMBER); + + assertThat(found) + .isPresent() + .get() + .extracting(userRole -> userRole.getUser().getUserId()) + .isEqualTo(user1.getUserId()); + } + + @Test + @DisplayName("없는 role로 조회") + void findByUserAndRole_whenNotExists_returnsEmpty() { + Optional found = userRoleRepository.findByUserAndRole(user1, Role.ADMIN); + + assertThat(found).isEmpty(); + } +} diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties index 7631639..84e56da 100644 --- a/src/test/resources/application.properties +++ b/src/test/resources/application.properties @@ -31,7 +31,6 @@ spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.H2Dialect # =============================== spring.data.redis.host=localhost spring.data.redis.port=6379 -spring.data.redis.password= spring.data.redis.database=0 # =============================== @@ -72,7 +71,24 @@ mail.from-name=InsightOn # =============================== oauth.google.client-id=GOOGLE_CLIENT_ID_TEST oauth.google.client-secret=GOOGLE_CLIENT_SECRET_TEST -oauth.google.redirect-uri=https://insighton.store/oauth/callback # test의 경우 config가 필요 없음 -spring.cloud.config.enabled=false \ No newline at end of file +spring.cloud.config.enabled=false + +oauth.redirect-uri=https://insighton.store/oauth/callback + +# =============================== +# OAuth - GitHub +# =============================== +oauth.github.client-id=GITHUB_CLIENT_ID_TEST +oauth.github.client-secret=GITHUB_CLIENT_SECRET_TEST + + +# =============================== +# Resilience4j +# =============================== +spring.cloud.openfeign.circuitbreaker.enabled=true + +resilience4j.circuitbreaker.configs.default.sliding-window-size=10 +resilience4j.circuitbreaker.configs.default.failure-rate-threshold=50 +resilience4j.circuitbreaker.configs.default.wait-duration-in-open-state=10s