From 88d7f1ea6b39f47f7fb82c2e9c0fe504b99a88df Mon Sep 17 00:00:00 2001 From: SinnoLn Date: Fri, 21 Aug 2026 23:29:45 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat(auth):=20=ED=83=88=ED=87=B4=20?= =?UTF-8?q?=ED=9B=84=2030=EC=9D=BC=20=EC=9D=B4=EB=82=B4=20=EC=9E=AC?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=8B=9C=20=EA=B3=84=EC=A0=95=20?= =?UTF-8?q?=EC=9E=90=EB=8F=99=20=EB=B3=B5=EA=B5=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 탈퇴 즉시 재가입이 불가능해 이메일이 영구 봉인되는 문제를 완화하기 위해, 탈퇴 유예기간(30일)을 두고 그 안에 재로그인하면 계정을 조용히 복구한다. - User: 유예기간 판정/복구 로직을 도메인 메서드로 추가 (deletedAt만 복원, 세션 버전은 탈퇴 시점에 이미 무효화됐으므로 추가로 올리지 않음) - 로컬/구글 로그인(이미 연동된 계정)은 30일 이내면 자동 복구 후 정상 로그인 처리 - 신규 가입/미연동 구글 인증 시도는 계속 차단하되, 30일 이내는 로그인을 유도하는 AUTH-014(ACCOUNT_DORMANT)로, 초과는 기존 AUTH-013 그대로 응답 --- .../auth/application/AuthServiceImpl.java | 11 +- .../auth/application/AuthSessionService.java | 15 ++- .../zip/server/auth/domain/AuthErrorCode.java | 3 +- .../chaeso/zip/server/user/domain/User.java | 22 ++++ .../auth/application/AuthServiceTest.java | 117 ++++++++++++++++-- .../zip/server/user/domain/UserTest.java | 59 +++++++++ 6 files changed, 208 insertions(+), 19 deletions(-) diff --git a/src/main/java/chaeso/zip/server/auth/application/AuthServiceImpl.java b/src/main/java/chaeso/zip/server/auth/application/AuthServiceImpl.java index 99e3a910..d8129ebc 100644 --- a/src/main/java/chaeso/zip/server/auth/application/AuthServiceImpl.java +++ b/src/main/java/chaeso/zip/server/auth/application/AuthServiceImpl.java @@ -226,15 +226,16 @@ public GoogleAuthResponse googleAuth(String idToken) { if (user == null) { return GoogleAuthResponse.signupRequired(googleSignupStore.save(info), email, info.name()); } - rejectWithdrawn(user); Optional googleIdentity = findGoogleIdentity(user); if (googleIdentity.isPresent()) { if (!googleIdentity.get().getProviderUid().equals(info.sub())) { throw new AuthBusinessException(AuthErrorCode.GOOGLE_AUTH_FAILED); } + authSessionService.restoreIfPossible(user); return GoogleAuthResponse.login(authSessionService.openSession(user, AuthProvider.GOOGLE)); } + rejectWithdrawn(user); return GoogleAuthResponse.linkRequired(email); } @@ -349,11 +350,17 @@ private User saveUser(SignupCommand command, String email) { } } - /** 탈퇴한 회원이면 ACCOUNT_DELETION_IN_PROGRESS 에러로 거절한다. */ + /** + * 탈퇴한 회원이면 거절한다. 유예기간(30일) 이내면 로그인을 유도하는 ACCOUNT_DORMANT로, + * 유예기간이 지났다면 ACCOUNT_DELETION_IN_PROGRESS로 구분해 응답한다. + */ private void rejectWithdrawn(User user) { if (user.getDeletedAt() == null) { return; } + if (user.isWithinWithdrawalGracePeriod(LocalDateTime.now(clock))) { + throw new AuthBusinessException(AuthErrorCode.ACCOUNT_DORMANT); + } throw new AuthBusinessException(AuthErrorCode.ACCOUNT_DELETION_IN_PROGRESS); } diff --git a/src/main/java/chaeso/zip/server/auth/application/AuthSessionService.java b/src/main/java/chaeso/zip/server/auth/application/AuthSessionService.java index f424d521..0eddbe68 100644 --- a/src/main/java/chaeso/zip/server/auth/application/AuthSessionService.java +++ b/src/main/java/chaeso/zip/server/auth/application/AuthSessionService.java @@ -28,15 +28,24 @@ public class AuthSessionService { private final RefreshTokenStore refreshTokenStore; private final Clock clock; - /** 비밀번호 검증을 마친 로컬 사용자를 잠근 뒤 탈퇴 여부를 확인하고 세션을 발급한다. */ + /** 비밀번호 검증을 마친 로컬 사용자를 잠근 뒤 탈퇴 여부를 확인하고(유예기간 이내면 복구) 세션을 발급한다. */ @Transactional public TokenResponse openLocalSession(UUID authenticatedUserId) { User user = userRepository.findByIdForUpdate(authenticatedUserId) .orElseThrow(() -> new AuthBusinessException(AuthErrorCode.INVALID_CREDENTIALS)); - if (user.getDeletedAt() != null) { + restoreIfPossible(user); + return openSession(user, AuthProvider.LOCAL); + } + + /** 탈퇴 유예기간 이내면 계정을 복구하고, 지났다면 탈퇴 처리 예외를 던진다. */ + public void restoreIfPossible(User user) { + if (user.getDeletedAt() == null) { + return; + } + boolean restored = user.restoreIfWithinGracePeriod(LocalDateTime.now(clock)); + if (!restored) { throw new AuthBusinessException(AuthErrorCode.ACCOUNT_DELETION_IN_PROGRESS); } - return openSession(user, AuthProvider.LOCAL); } /** 새 refresh family를 열고 토큰 쌍을 발급한다. */ diff --git a/src/main/java/chaeso/zip/server/auth/domain/AuthErrorCode.java b/src/main/java/chaeso/zip/server/auth/domain/AuthErrorCode.java index df28c70f..df5217d9 100644 --- a/src/main/java/chaeso/zip/server/auth/domain/AuthErrorCode.java +++ b/src/main/java/chaeso/zip/server/auth/domain/AuthErrorCode.java @@ -21,7 +21,8 @@ public enum AuthErrorCode implements ErrorCode { ACCOUNT_REGISTERED_WITH_GOOGLE(HttpStatus.UNAUTHORIZED, "AUTH-010", "Google 계정으로 가입된 이메일입니다. Google 로그인을 이용해 주세요."), GOOGLE_SIGNUP_SESSION_INVALID(HttpStatus.BAD_REQUEST, "AUTH-011", "가입 세션이 만료되었습니다. Google 로그인을 다시 시도해 주세요."), LOGIN_METHOD_LOOKUP_COOLDOWN(HttpStatus.TOO_MANY_REQUESTS, "AUTH-012", "조회 요청이 많습니다. 잠시 후 다시 시도해 주세요."), - ACCOUNT_DELETION_IN_PROGRESS(HttpStatus.CONFLICT, "AUTH-013", "탈퇴 처리된 계정입니다."); + ACCOUNT_DELETION_IN_PROGRESS(HttpStatus.CONFLICT, "AUTH-013", "탈퇴 처리된 계정입니다."), + ACCOUNT_DORMANT(HttpStatus.CONFLICT, "AUTH-014", "휴면 처리된 계정입니다. 로그인해 주세요."); private final HttpStatus httpStatus; private final String code; diff --git a/src/main/java/chaeso/zip/server/user/domain/User.java b/src/main/java/chaeso/zip/server/user/domain/User.java index f908d690..f9839332 100644 --- a/src/main/java/chaeso/zip/server/user/domain/User.java +++ b/src/main/java/chaeso/zip/server/user/domain/User.java @@ -21,6 +21,9 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) public class User extends BaseEntity { + /** 탈퇴 후 재로그인 시 계정을 복구해주는 유예기간(일). */ + public static final long WITHDRAWAL_GRACE_PERIOD_DAYS = 30; + @Column(nullable = false) private String email; @@ -108,6 +111,25 @@ public void withdraw(LocalDateTime now) { sessionVersion++; } + /** + * 탈퇴 후 유예기간 이내 재로그인이면 계정을 복구한다. + * 마케팅 수신 동의는 별도 동의 사항이므로 재동의 없이는 복구하지 않는다. + * + * @return 복구되었으면 true, 유예기간이 지났거나 탈퇴 상태가 아니면 false + */ + public boolean restoreIfWithinGracePeriod(LocalDateTime now) { + if (!isWithinWithdrawalGracePeriod(now)) { + return false; + } + deletedAt = null; + return true; + } + + /** 탈퇴 상태이면서 유예기간(복구 가능 기간) 이내인지 확인한다. 상태를 변경하지 않는다. */ + public boolean isWithinWithdrawalGracePeriod(LocalDateTime now) { + return deletedAt != null && !deletedAt.plusDays(WITHDRAWAL_GRACE_PERIOD_DAYS).isBefore(now); + } + /** 사용자의 직군/회사명을 수정한다 */ public void updateProfile(String companyName, Occupation occupation) { this.companyName = companyName; diff --git a/src/test/java/chaeso/zip/server/auth/application/AuthServiceTest.java b/src/test/java/chaeso/zip/server/auth/application/AuthServiceTest.java index 48622584..d0e62b3f 100644 --- a/src/test/java/chaeso/zip/server/auth/application/AuthServiceTest.java +++ b/src/test/java/chaeso/zip/server/auth/application/AuthServiceTest.java @@ -1144,9 +1144,9 @@ void rateLimited_throwsAndSkipsLookup() { class WithdrawnAccount { @Test - @DisplayName("탈퇴한 LOCAL 계정은 비밀번호가 맞아도 로그인을 거절한다") - void localLoginRejectsWithdrawnAccount() { - User user = withdrawnUser(1); + @DisplayName("탈퇴 유예기간(30일)이 지난 LOCAL 계정은 비밀번호가 맞아도 로그인을 거절한다") + void localLoginRejectsWithdrawnAccountPastGracePeriod() { + User user = withdrawnUser(31); doReturn(Optional.of(user)).when(userRepository).findByEmail("user@chaeso.zip"); given(userRepository.findByIdForUpdate(user.getId())).willReturn(Optional.of(user)); given(authIdentityRepository.findByUserIdAndProvider(user.getId(), AuthProvider.LOCAL)) @@ -1159,10 +1159,30 @@ void localLoginRejectsWithdrawnAccount() { .extracting("errorCode").isEqualTo(AuthErrorCode.ACCOUNT_DELETION_IN_PROGRESS); } + @Test + @DisplayName("탈퇴 유예기간(30일) 이내의 LOCAL 계정은 로그인 시 자동으로 복구되어 토큰을 발급한다") + void localLoginRestoresWithdrawnAccountWithinGracePeriod() { + User user = withdrawnUser(1); + doReturn(Optional.of(user)).when(userRepository).findByEmail("user@chaeso.zip"); + given(userRepository.findByIdForUpdate(user.getId())).willReturn(Optional.of(user)); + given(authIdentityRepository.findByUserIdAndProvider(user.getId(), AuthProvider.LOCAL)) + .willReturn(Optional.of(AuthIdentity.createLocal(user.getId(), "ENCODED"))); + given(passwordEncoder.matches("P@ssw0rd!", "ENCODED")).willReturn(true); + given(refreshTokenStore.save(any(), anyString(), anyString())).willReturn(Duration.ofDays(14)); + given(jwtTokenProvider.createAccessToken(any(), anyInt())).willReturn("access"); + given(jwtTokenProvider.createRefreshToken(any(), anyInt(), anyString(), anyString())) + .willReturn("refresh"); + + TokenResponse response = authService.login(loginCommand()); + + assertThat(response.accessToken()).isEqualTo("access"); + assertThat(user.getDeletedAt()).isNull(); + } + @Test @DisplayName("탈퇴한 계정(로컬 identity 없음)으로 로컬 로그인 시도 시 ACCOUNT_DELETION_IN_PROGRESS로 실패한다") void withdrawnAccountWithoutLocalIdentity_rejectionHasPriority() { - User user = withdrawnUser(1); + User user = withdrawnUser(31); doReturn(Optional.of(user)).when(userRepository).findByEmail("user@chaeso.zip"); given(authIdentityRepository.findByUserIdAndProvider(user.getId(), AuthProvider.LOCAL)) .willReturn(Optional.empty()); @@ -1174,10 +1194,10 @@ void withdrawnAccountWithoutLocalIdentity_rejectionHasPriority() { } @Test - @DisplayName("탈퇴한 계정 이메일로 Google 인증 진입 시 가입 수단과 관계없이 AUTH-013을 반환한다") - void googleAuthRejectsWithdrawnAccount() { + @DisplayName("탈퇴 유예기간(30일)이 지난 계정 이메일로 Google 인증 진입 시 AUTH-013을 반환한다") + void googleAuthRejectsWithdrawnAccountPastGracePeriod() { givenVerifiedGoogleToken(); - User user = withdrawnUser(1); + User user = withdrawnUser(31); given(userRepository.findByEmailForUpdate("user@chaeso.zip")).willReturn(Optional.of(user)); assertThatThrownBy(() -> authService.googleAuth("id-token")) @@ -1186,12 +1206,57 @@ void googleAuthRejectsWithdrawnAccount() { } @Test - @DisplayName("탈퇴한 이메일로 신규 가입하면 AUTH-013을 반환한다") - void signupRejectsWithdrawnAccount() { + @DisplayName("탈퇴 유예기간(30일) 이내에 연결된 Google 계정으로 인증하면 자동 복구되어 로그인 처리된다") + void googleAuthRestoresWithdrawnAccountWithinGracePeriod() { + givenVerifiedGoogleToken(); + User user = withdrawnUser(1); + given(userRepository.findByEmailForUpdate("user@chaeso.zip")).willReturn(Optional.of(user)); + givenGoogleIdentity(user, true); + given(refreshTokenStore.save(any(), anyString(), anyString())).willReturn(Duration.ofDays(14)); + given(jwtTokenProvider.createAccessToken(any(), anyInt())).willReturn("access"); + given(jwtTokenProvider.createRefreshToken(any(), anyInt(), anyString(), anyString())) + .willReturn("refresh"); + + GoogleAuthResponse response = authService.googleAuth("id-token"); + + assertThat(response.accessToken()).isEqualTo("access"); + assertThat(user.getDeletedAt()).isNull(); + } + + @Test + @DisplayName("탈퇴 유예기간(30일) 이내에 아직 연결되지 않은 계정이면 복구하지 않고 linkRequired로 안내한다") + void googleAuthDoesNotRestoreWhenLinkRequired() { + givenVerifiedGoogleToken(); + User user = withdrawnUser(1); + given(userRepository.findByEmailForUpdate("user@chaeso.zip")).willReturn(Optional.of(user)); + givenGoogleIdentity(user, false); + + assertThatThrownBy(() -> authService.googleAuth("id-token")) + .isInstanceOf(AuthBusinessException.class) + .extracting("errorCode").isEqualTo(AuthErrorCode.ACCOUNT_DORMANT); + assertThat(user.getDeletedAt()).isNotNull(); + } + + @Test + @DisplayName("탈퇴 유예기간(30일) 이내의 이메일로 신규 가입하면 로그인을 유도하는 AUTH-014를 반환한다") + void signupRejectsDormantAccountWithinGracePeriod() { User user = withdrawnUser(1); given(verificationCodeStore.isVerified("user@chaeso.zip")).willReturn(true); given(userRepository.findByEmail("user@chaeso.zip")).willReturn(Optional.of(user)); + SignupCommand signupCmd = command("user@chaeso.zip"); + assertThatThrownBy(() -> authService.signup(signupCmd)) + .isInstanceOf(AuthBusinessException.class) + .extracting("errorCode").isEqualTo(AuthErrorCode.ACCOUNT_DORMANT); + } + + @Test + @DisplayName("탈퇴 유예기간(30일)이 지난 이메일로 신규 가입하면 AUTH-013을 반환한다") + void signupRejectsWithdrawnAccountPastGracePeriod() { + User user = withdrawnUser(31); + given(verificationCodeStore.isVerified("user@chaeso.zip")).willReturn(true); + given(userRepository.findByEmail("user@chaeso.zip")).willReturn(Optional.of(user)); + SignupCommand signupCmd = command("user@chaeso.zip"); assertThatThrownBy(() -> authService.signup(signupCmd)) .isInstanceOf(AuthBusinessException.class) @@ -1199,11 +1264,23 @@ void signupRejectsWithdrawnAccount() { } @Test - @DisplayName("탈퇴한 이메일에는 신규 가입 인증 코드를 보내지 않는다") - void verificationCodeRejectsWithdrawnAccount() { + @DisplayName("탈퇴 유예기간(30일) 이내의 이메일에는 로그인을 유도하며 신규 가입 인증 코드를 보내지 않는다") + void verificationCodeRejectsDormantAccountWithinGracePeriod() { User user = withdrawnUser(1); given(userRepository.findByEmail("user@chaeso.zip")).willReturn(Optional.of(user)); + assertThatThrownBy(() -> authService.sendSignupVerificationCode("user@chaeso.zip")) + .isInstanceOf(AuthBusinessException.class) + .extracting("errorCode").isEqualTo(AuthErrorCode.ACCOUNT_DORMANT); + verify(verificationMailSender, never()).sendVerificationCode(anyString(), anyString()); + } + + @Test + @DisplayName("탈퇴 유예기간(30일)이 지난 이메일에는 신규 가입 인증 코드를 보내지 않는다") + void verificationCodeRejectsWithdrawnAccountPastGracePeriod() { + User user = withdrawnUser(31); + given(userRepository.findByEmail("user@chaeso.zip")).willReturn(Optional.of(user)); + assertThatThrownBy(() -> authService.sendSignupVerificationCode("user@chaeso.zip")) .isInstanceOf(AuthBusinessException.class) .extracting("errorCode").isEqualTo(AuthErrorCode.ACCOUNT_DELETION_IN_PROGRESS); @@ -1211,12 +1288,26 @@ void verificationCodeRejectsWithdrawnAccount() { } @Test - @DisplayName("탈퇴 계정 이메일의 Google 가입 티켓으로도 새 회원을 만들지 않는다") - void googleSignupRejectsWithdrawnAccount() { + @DisplayName("탈퇴 유예기간(30일) 이내의 Google 가입 티켓으로도 새 회원을 만들지 않고 AUTH-014를 반환한다") + void googleSignupRejectsDormantAccountWithinGracePeriod() { User user = withdrawnUser(1); given(googleSignupStore.find("signup-ticket")).willReturn(Optional.of(GOOGLE_INFO)); given(userRepository.findByEmail("user@chaeso.zip")).willReturn(Optional.of(user)); + GoogleSignupCommand googleSignupCmd = googleSignupCommand("signup-ticket"); + assertThatThrownBy(() -> authService.signupGoogle(googleSignupCmd)) + .isInstanceOf(AuthBusinessException.class) + .extracting("errorCode").isEqualTo(AuthErrorCode.ACCOUNT_DORMANT); + verify(userRepository, never()).saveAndFlush(any()); + } + + @Test + @DisplayName("탈퇴 유예기간(30일)이 지난 Google 가입 티켓으로도 새 회원을 만들지 않는다") + void googleSignupRejectsWithdrawnAccountPastGracePeriod() { + User user = withdrawnUser(31); + given(googleSignupStore.find("signup-ticket")).willReturn(Optional.of(GOOGLE_INFO)); + given(userRepository.findByEmail("user@chaeso.zip")).willReturn(Optional.of(user)); + GoogleSignupCommand googleSignupCmd = googleSignupCommand("signup-ticket"); assertThatThrownBy(() -> authService.signupGoogle(googleSignupCmd)) .isInstanceOf(AuthBusinessException.class) diff --git a/src/test/java/chaeso/zip/server/user/domain/UserTest.java b/src/test/java/chaeso/zip/server/user/domain/UserTest.java index 35bc7429..53b54b36 100644 --- a/src/test/java/chaeso/zip/server/user/domain/UserTest.java +++ b/src/test/java/chaeso/zip/server/user/domain/UserTest.java @@ -50,4 +50,63 @@ void recordLoginUsesProvidedTime() { assertThat(user.getLastLoginAt()).isEqualTo(loggedInAt); assertThat(user.getLastLoginProvider()).isEqualTo(AuthProvider.LOCAL); } + + @Test + @DisplayName("탈퇴 유예기간(30일) 이내면 복구되어 탈퇴 시각이 지워진다") + void restoreIfWithinGracePeriod_withinPeriod_restores() { + User user = UserFixture.user(false); + user.withdraw(WITHDRAWN_AT); + + boolean restored = user.restoreIfWithinGracePeriod(WITHDRAWN_AT.plusDays(29)); + + assertThat(restored).isTrue(); + assertThat(user.getDeletedAt()).isNull(); + } + + @Test + @DisplayName("탈퇴 유예기간(30일) 경계 시각까지는 복구된다") + void restoreIfWithinGracePeriod_atBoundary_restores() { + User user = UserFixture.user(false); + user.withdraw(WITHDRAWN_AT); + + boolean restored = user.restoreIfWithinGracePeriod(WITHDRAWN_AT.plusDays(30)); + + assertThat(restored).isTrue(); + assertThat(user.getDeletedAt()).isNull(); + } + + @Test + @DisplayName("탈퇴 유예기간(30일)이 지나면 복구하지 않고 탈퇴 상태를 유지한다") + void restoreIfWithinGracePeriod_pastPeriod_doesNotRestore() { + User user = UserFixture.user(false); + user.withdraw(WITHDRAWN_AT); + + boolean restored = user.restoreIfWithinGracePeriod(WITHDRAWN_AT.plusDays(31)); + + assertThat(restored).isFalse(); + assertThat(user.getDeletedAt()).isEqualTo(WITHDRAWN_AT); + } + + @Test + @DisplayName("탈퇴하지 않은 회원은 복구 시도해도 아무 일도 일어나지 않는다") + void restoreIfWithinGracePeriod_notWithdrawn_doesNothing() { + User user = UserFixture.user(false); + + boolean restored = user.restoreIfWithinGracePeriod(WITHDRAWN_AT); + + assertThat(restored).isFalse(); + assertThat(user.getDeletedAt()).isNull(); + } + + @Test + @DisplayName("탈퇴 유예기간 이내인지 확인해도 상태를 바꾸지 않는다") + void isWithinWithdrawalGracePeriod_doesNotMutateState() { + User user = UserFixture.user(false); + user.withdraw(WITHDRAWN_AT); + + boolean within = user.isWithinWithdrawalGracePeriod(WITHDRAWN_AT.plusDays(1)); + + assertThat(within).isTrue(); + assertThat(user.getDeletedAt()).isEqualTo(WITHDRAWN_AT); + } } From 023bcefcec79972c007b13cf8d6a97a7faae842c Mon Sep 17 00:00:00 2001 From: SinnoLn Date: Fri, 21 Aug 2026 23:30:51 +0900 Subject: [PATCH 2/5] docs: edit docs --- src/main/java/chaeso/zip/server/user/domain/User.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/java/chaeso/zip/server/user/domain/User.java b/src/main/java/chaeso/zip/server/user/domain/User.java index f9839332..53eb46f9 100644 --- a/src/main/java/chaeso/zip/server/user/domain/User.java +++ b/src/main/java/chaeso/zip/server/user/domain/User.java @@ -113,7 +113,6 @@ public void withdraw(LocalDateTime now) { /** * 탈퇴 후 유예기간 이내 재로그인이면 계정을 복구한다. - * 마케팅 수신 동의는 별도 동의 사항이므로 재동의 없이는 복구하지 않는다. * * @return 복구되었으면 true, 유예기간이 지났거나 탈퇴 상태가 아니면 false */ From b8557d83f7628698583907367aaa2ad9653a7174 Mon Sep 17 00:00:00 2001 From: SinnoLn Date: Fri, 21 Aug 2026 23:41:41 +0900 Subject: [PATCH 3/5] docs: update docs --- .../server/auth/presentation/AuthApiDocs.java | 83 +++++++++++++------ 1 file changed, 57 insertions(+), 26 deletions(-) diff --git a/src/main/java/chaeso/zip/server/auth/presentation/AuthApiDocs.java b/src/main/java/chaeso/zip/server/auth/presentation/AuthApiDocs.java index 63d13dfc..be539cf7 100644 --- a/src/main/java/chaeso/zip/server/auth/presentation/AuthApiDocs.java +++ b/src/main/java/chaeso/zip/server/auth/presentation/AuthApiDocs.java @@ -75,6 +75,19 @@ public interface AuthApiDocs { } """; + String ACCOUNT_DORMANT_EXAMPLE = """ + { + "success": false, + "data": null, + "error": { + "code": "AUTH-014", + "message": "휴면 처리된 계정입니다. 로그인해 주세요.", + "fieldErrors": [] + }, + "code": null + } + """; + String VERIFICATION_CODE_SEND_COOLDOWN_EXAMPLE = """ { "success": false, @@ -99,9 +112,11 @@ public interface AuthApiDocs { @Operation(operationId = "sendSignupCode", summary = "회원가입 이메일 인증코드 발송", description = """ - 가입할 이메일로 6자리 인증코드를 발송한다. \ - 로컬로 이미 가입된 이메일: 409. \ - 구글로만 가입된 이메일: 발송하지 않고 200 과 code: EMAIL_ALREADY_USED_WITH_GOOGLE. \ + 가입할 이메일로 6자리 인증코드를 발송한다. + 로컬로 이미 가입된 이메일: 409. + 탈퇴 후 30일 이내(휴면) 이메일: 발송하지 않고 409(AUTH-014), 로그인을 안내한다. + 탈퇴 후 30일이 지난 이메일: 409(AUTH-013). + 구글로만 가입된 이메일: 발송하지 않고 200 과 code: EMAIL_ALREADY_USED_WITH_GOOGLE. 그 외: 발송 후 200.""") @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "발송 성공. 구글로만 가입된 이메일이면 발송 없이 안내 코드만 실린다", @@ -113,10 +128,12 @@ public interface AuthApiDocs { content = @Content(schema = @Schema(implementation = ApiResponse.class), examples = @ExampleObject(name = "VALIDATION_ERROR", value = VALIDATION_ERROR_EXAMPLE))) @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", - description = "이미 사용 중이거나 탈퇴 처리 중인 이메일", + description = "이미 사용 중인 이메일(AUTH-002), 탈퇴 후 유예기간 이내라 로그인이 필요한 휴면 계정(AUTH-014), " + + "또는 유예기간이 지나 탈퇴 처리 중인 계정(AUTH-013)", content = @Content(schema = @Schema(implementation = ApiResponse.class), examples = { @ExampleObject(name = "EMAIL_ALREADY_EXISTS", value = EMAIL_ALREADY_EXISTS_EXAMPLE), + @ExampleObject(name = "ACCOUNT_DORMANT", value = ACCOUNT_DORMANT_EXAMPLE), @ExampleObject(name = "ACCOUNT_DELETION_IN_PROGRESS", value = ACCOUNT_DELETION_IN_PROGRESS_EXAMPLE) })) @@ -162,7 +179,9 @@ public interface AuthApiDocs { """; @Operation(operationId = "signup", summary = "회원가입 최종 제출", - description = "이메일 인증 완료 후 로컬 계정을 생성한다. 인증 미완료 시 400, 이메일 중복 시 409.") + description = """ + 이메일 인증 완료 후 로컬 계정을 생성한다. 인증 미완료 시 400, 이메일 중복 시 409. + 탈퇴 후 30일 이내(휴면) 이메일이면 가입 대신 409(AUTH-014)로 로그인을 안내한다.""") @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "201", description = "가입 성공") @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "입력값 검증 실패 또는 이메일 미인증", content = @Content(schema = @Schema(implementation = ApiResponse.class), @@ -171,10 +190,12 @@ public interface AuthApiDocs { @ExampleObject(name = "EMAIL_NOT_VERIFIED", value = EMAIL_NOT_VERIFIED_EXAMPLE) })) @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", - description = "이미 사용 중이거나 탈퇴 처리 중인 이메일", + description = "이미 사용 중인 이메일(AUTH-002), 탈퇴 후 유예기간 이내라 로그인이 필요한 휴면 계정(AUTH-014), " + + "또는 유예기간이 지나 탈퇴 처리 중인 계정(AUTH-013)", content = @Content(schema = @Schema(implementation = ApiResponse.class), examples = { @ExampleObject(name = "EMAIL_ALREADY_EXISTS", value = EMAIL_ALREADY_EXISTS_EXAMPLE), + @ExampleObject(name = "ACCOUNT_DORMANT", value = ACCOUNT_DORMANT_EXAMPLE), @ExampleObject(name = "ACCOUNT_DELETION_IN_PROGRESS", value = ACCOUNT_DELETION_IN_PROGRESS_EXAMPLE) })) @@ -208,9 +229,11 @@ public interface AuthApiDocs { @Operation(operationId = "login", summary = "로컬 로그인", description = """ - 이메일/비밀번호로 로그인하고 access/refresh 토큰을 발급한다. 자격증명이 올바르지 않으면 401. \ - 구글로만 가입되어 로컬 인증정보가 없는 이메일이면 AUTH-010 으로 별도 응답한다.""") - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "로그인 성공") + 이메일/비밀번호로 로그인하고 access/refresh 토큰을 발급한다. 자격증명이 올바르지 않으면 401. + 구글로만 가입되어 로컬 인증정보가 없는 이메일이면 AUTH-010 으로 별도 응답한다. + 탈퇴 후 30일 이내(휴면) 계정이 이메일/비번이 맞으면 자동으로 복구한 뒤 로그인 처리.""") + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", + description = "로그인 성공(탈퇴 후 30일 이내 자동 복구)") @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "입력값 검증 실패(C-001)", content = @Content(schema = @Schema(implementation = ApiResponse.class), examples = @ExampleObject(name = "VALIDATION_ERROR", value = VALIDATION_ERROR_EXAMPLE))) @@ -223,7 +246,7 @@ public interface AuthApiDocs { value = ACCOUNT_REGISTERED_WITH_GOOGLE_EXAMPLE) })) @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", - description = "탈퇴 처리된 계정(AUTH-013)", + description = "탈퇴 후 유예기간이 지나 탈퇴 처리 중인 계정(AUTH-013)", content = @Content(schema = @Schema(implementation = ApiResponse.class), examples = @ExampleObject(name = "ACCOUNT_DELETION_IN_PROGRESS", value = ACCOUNT_DELETION_IN_PROGRESS_EXAMPLE))) @@ -411,14 +434,16 @@ ApiResponse loginMethods( @Operation(operationId = "googleAuth", summary = "구글 인증 진입", description = """ - 구글 idToken 을 검증하고 계정 상태에 따라 status 로 분기한다. \ - 탈퇴 처리된 계정인 경우 가입 수단과 관계없이 409(AUTH-013) 예외를 반환한다. \ - - LOGIN: 토큰 발급. \ - LINK_REQUIRED: linkRequired 와 email 을 내려주며, 사용자 확인 후 POST /auth/google/link 호출. \ + 구글 idToken 을 검증하고 계정 상태에 따라 status 로 분기한다. + 이미 구글이 연결된 계정이 탈퇴 후 30일 이내(휴면)라면 자동으로 복구한 뒤 LOGIN 으로 응답한다. + 아직 구글이 연결되지 않은 탈퇴 계정(LINK_REQUIRED 대상)이거나 유예기간이 지난 탈퇴 계정이면 + 409 예외(AUTH-014 또는 AUTH-013)를 반환한다. + + LOGIN: 토큰 발급. + LINK_REQUIRED: linkRequired 와 email 을 내려주며, 사용자 확인 후 POST /auth/google/link 호출. SIGNUP_REQUIRED: signupRequired 와 일회성 signupToken, 프리필 값을 내려준다.""") @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", - description = "로그인 성공 / 연결 확인 필요 / 가입 필요", + description = "로그인 성공(탈퇴 후 30일 이내 자동 복구) / 연결 확인 필요 / 가입 필요", useReturnTypeSchema = true, content = @Content( examples = { @@ -434,10 +459,13 @@ ApiResponse loginMethods( content = @Content(schema = @Schema(implementation = ApiResponse.class), examples = @ExampleObject(name = "GOOGLE_AUTH_FAILED", value = GOOGLE_AUTH_FAILED_EXAMPLE))) @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", - description = "탈퇴 처리된 계정(AUTH-013)", + description = "30일 유예기간이 지나 탈퇴 처리 중인 계정(AUTH-013)", content = @Content(schema = @Schema(implementation = ApiResponse.class), - examples = @ExampleObject(name = "ACCOUNT_DELETION_IN_PROGRESS", - value = ACCOUNT_DELETION_IN_PROGRESS_EXAMPLE))) + examples = { + @ExampleObject(name = "ACCOUNT_DORMANT", value = ACCOUNT_DORMANT_EXAMPLE), + @ExampleObject(name = "ACCOUNT_DELETION_IN_PROGRESS", + value = ACCOUNT_DELETION_IN_PROGRESS_EXAMPLE) + })) ApiResponse googleAuth(@Valid @RequestBody GoogleAuthRequest request); String GOOGLE_ACCOUNT_LINKED = "GOOGLE_ACCOUNT_LINKED"; @@ -457,8 +485,8 @@ ApiResponse loginMethods( @Operation(operationId = "linkGoogle", summary = "구글 계정 연결 확인", description = """ - linkRequired:true 응답을 받고 사용자가 '예'를 선택했을 때만 호출한다. \ - 진입 때 보낸 idToken 을 재전송하면 재검증 후 같은 이메일의 로컬 계정에 구글 로그인을 \ + linkRequired:true 응답을 받고 사용자가 '예'를 선택했을 때만 호출한다. + 진입 때 보낸 idToken 을 재전송하면 재검증 후 같은 이메일의 로컬 계정에 구글 로그인을 연결하고 토큰을 발급한다.""") @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "연결 완료(code: GOOGLE_ACCOUNT_LINKED) 및 토큰 발급", @@ -501,9 +529,10 @@ ApiResponse loginMethods( @Operation(operationId = "signupGoogle", summary = "구글 최종 회원가입", description = """ - POST /auth/google 이 내려준 signupToken 과 추가 프로필(닉네임/회사명/직무/약관 동의)로 \ - 신규 회원가입을 완료하고 토큰을 발급한다. \ - 가입에 성공하면 signupToken 은 즉시 폐기되어 재사용 불가능.""") + POST /auth/google 이 내려준 signupToken 과 추가 프로필(닉네임/회사명/직무/약관 동의)로 + 신규 회원가입을 완료하고 토큰을 발급한다. + 가입에 성공하면 signupToken 은 즉시 폐기되어 재사용 불가능. + 탈퇴 후 30일 이내(휴면) 이메일이면 가입 대신 409(AUTH-014)로 로그인을 안내한다.""") @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "가입 성공, 토큰 발급", useReturnTypeSchema = true, content = @Content( @@ -517,10 +546,12 @@ ApiResponse loginMethods( value = GOOGLE_SIGNUP_SESSION_INVALID_EXAMPLE) })) @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", - description = "이미 사용 중이거나 탈퇴 처리 중인 이메일", + description = "이미 사용 중인 이메일(AUTH-002), 탈퇴 후 유예기간 이내라 로그인이 필요한 휴면 계정(AUTH-014), " + + "또는 유예기간이 지나 탈퇴 처리 중인 계정(AUTH-013)", content = @Content(schema = @Schema(implementation = ApiResponse.class), examples = { @ExampleObject(name = "EMAIL_ALREADY_EXISTS", value = EMAIL_ALREADY_EXISTS_EXAMPLE), + @ExampleObject(name = "ACCOUNT_DORMANT", value = ACCOUNT_DORMANT_EXAMPLE), @ExampleObject(name = "ACCOUNT_DELETION_IN_PROGRESS", value = ACCOUNT_DELETION_IN_PROGRESS_EXAMPLE) })) @@ -533,7 +564,7 @@ ApiResponse loginMethods( @SecurityRequirement(name = "bearerAuth") @Operation(operationId = "logout", summary = "로그아웃", description = """ - 제출한 Refresh Token 의 세션(family)을 폐기한다. 인증된 사용자만 호출할 수 있고, \ + 제출한 Refresh Token 의 세션(family)을 폐기한다. 인증된 사용자만 호출할 수 있고, 토큰 소유자가 인증 사용자와 다르면 401. 이미 폐기된 세션이어도 200 을 반환한다.""") @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "로그아웃 성공") @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "입력값 검증 실패(C-001)", From 9acee5a2080d3edb3a8034a29cb84268fc1a8949 Mon Sep 17 00:00:00 2001 From: SinnoLn Date: Fri, 21 Aug 2026 23:50:04 +0900 Subject: [PATCH 4/5] fix(auth): fix stale withdrawal tests and missing AUTH-014 in google auth docs --- .../server/auth/presentation/AuthApiDocs.java | 19 ++++++------- .../auth/application/AuthServiceTest.java | 12 --------- .../UserWithdrawalIntegrationTest.java | 27 +++++++++++++++++-- 3 files changed, 35 insertions(+), 23 deletions(-) diff --git a/src/main/java/chaeso/zip/server/auth/presentation/AuthApiDocs.java b/src/main/java/chaeso/zip/server/auth/presentation/AuthApiDocs.java index be539cf7..1000908d 100644 --- a/src/main/java/chaeso/zip/server/auth/presentation/AuthApiDocs.java +++ b/src/main/java/chaeso/zip/server/auth/presentation/AuthApiDocs.java @@ -287,12 +287,12 @@ public interface AuthApiDocs { @Operation(operationId = "loginMethods", summary = "로그인 수단 조회", description = """ - 이메일로 사용 가능한 로그인 수단을 조회한다. 비밀번호 입력 전 화면 분기용. \ + 이메일로 사용 가능한 로그인 수단을 조회한다. 비밀번호 입력 전 화면 분기용. methods \ - [LOCAL]: 비밀번호 입력창. \ - [LOCAL, GOOGLE]: 비밀번호 입력창과 구글 버튼. \ - [GOOGLE]: 구글로 가입된 계정 안내. \ + [LOCAL]: 비밀번호 입력창. + [LOCAL, GOOGLE]: 비밀번호 입력창과 구글 버튼. + [GOOGLE]: 구글로 가입된 계정 안내. []: 미가입 -> 회원가입 화면.""") @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true, @@ -339,10 +339,10 @@ ApiResponse loginMethods( @Operation(operationId = "refresh", summary = "토큰 재발급", description = """ - Refresh Token 을 회전시켜 새 access/refresh 토큰 쌍을 발급한다. \ - 기존 Refresh Token 은 즉시 폐기된다. 이미 회전된 토큰을 다시 제출하면 재사용으로 보고 \ - 해당 세션(family) 전체를 폐기하므로, 동시에 여러 번 호출하지 말고 한 번만 보낸다. \ - Access Token 이 만료된 상태에서 호출하므로 인증이 필요 없다. \ + Refresh Token 을 회전시켜 새 access/refresh 토큰 쌍을 발급한다. + 기존 Refresh Token 은 즉시 폐기된다. 이미 회전된 토큰을 다시 제출하면 재사용으로 보고 + 해당 세션(family) 전체를 폐기하므로, 동시에 여러 번 호출하지 말고 한 번만 보낸다. + Access Token 이 만료된 상태에서 호출하므로 인증이 필요 없다. refreshTokenExpiresIn 은 로그인 후 90일에 가까워질수록 짧아진다.""") @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "재발급 성공") @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "입력값 검증 실패(C-001)", @@ -459,7 +459,8 @@ ApiResponse loginMethods( content = @Content(schema = @Schema(implementation = ApiResponse.class), examples = @ExampleObject(name = "GOOGLE_AUTH_FAILED", value = GOOGLE_AUTH_FAILED_EXAMPLE))) @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", - description = "30일 유예기간이 지나 탈퇴 처리 중인 계정(AUTH-013)", + description = "구글 미연결 상태로 탈퇴 후 유예기간 이내라 로그인이 필요한 휴면 계정(AUTH-014), " + + "또는 유예기간이 지나 탈퇴 처리 중인 계정(AUTH-013)", content = @Content(schema = @Schema(implementation = ApiResponse.class), examples = { @ExampleObject(name = "ACCOUNT_DORMANT", value = ACCOUNT_DORMANT_EXAMPLE), diff --git a/src/test/java/chaeso/zip/server/auth/application/AuthServiceTest.java b/src/test/java/chaeso/zip/server/auth/application/AuthServiceTest.java index d0e62b3f..4448220e 100644 --- a/src/test/java/chaeso/zip/server/auth/application/AuthServiceTest.java +++ b/src/test/java/chaeso/zip/server/auth/application/AuthServiceTest.java @@ -1063,18 +1063,6 @@ void alreadyExistingEmail_rejected() { .extracting("errorCode").isEqualTo(AuthErrorCode.EMAIL_ALREADY_EXISTS); } - @Test - @DisplayName("탈퇴 처리 진행 중인 회원의 이메일로 구글 회원가입 시도 시 ACCOUNT_DELETION_IN_PROGRESS로 실패한다") - void withdrawnUserEmail_rejected() { - given(googleSignupStore.find("signup-ticket")).willReturn(Optional.of(GOOGLE_INFO)); - User withdrawnUser = withdrawnUser(1); - given(userRepository.findByEmail("user@chaeso.zip")).willReturn(Optional.of(withdrawnUser)); - - GoogleSignupCommand command = googleSignupCommand("signup-ticket"); - assertThatThrownBy(() -> authService.signupGoogle(command)) - .isInstanceOf(AuthBusinessException.class) - .extracting("errorCode").isEqualTo(AuthErrorCode.ACCOUNT_DELETION_IN_PROGRESS); - } } @Nested diff --git a/src/test/java/chaeso/zip/server/user/presentation/UserWithdrawalIntegrationTest.java b/src/test/java/chaeso/zip/server/user/presentation/UserWithdrawalIntegrationTest.java index 3df05033..40528c2f 100644 --- a/src/test/java/chaeso/zip/server/user/presentation/UserWithdrawalIntegrationTest.java +++ b/src/test/java/chaeso/zip/server/user/presentation/UserWithdrawalIntegrationTest.java @@ -1,5 +1,6 @@ package chaeso.zip.server.user.presentation; +import static org.assertj.core.api.Assertions.assertThat; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; @@ -81,8 +82,8 @@ void withdrawalImmediatelyBlocksExistingAccessToken() throws Exception { } @Test - @DisplayName("탈퇴한 계정은 같은 비밀번호로도 다시 로그인할 수 없다") - void withdrawnAccountCannotLogInAgain() throws Exception { + @DisplayName("탈퇴 후 유예기간(30일) 이내면 같은 비밀번호로 재로그인 시 계정이 자동 복구된다") + void withdrawnAccountWithinGracePeriodCanLogInAgain() throws Exception { User user = userRepository.save(UserFixture.user("withdrawn@chaeso.zip")); authIdentityRepository.save( AuthIdentity.createLocal(user.getId(), passwordEncoder.encode("P@ssw0rd!"))); @@ -95,6 +96,28 @@ void withdrawnAccountCannotLogInAgain() throws Exception { .content(""" {"email": "withdrawn@chaeso.zip", "password": "P@ssw0rd!"} """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.accessToken").exists()); + + User restored = userRepository.findByEmailAndDeletedAtIsNull("withdrawn@chaeso.zip").orElseThrow(); + assertThat(restored.getDeletedAt()).isNull(); + } + + @Test + @DisplayName("탈퇴 후 유예기간(30일)이 지나면 같은 비밀번호로도 다시 로그인할 수 없다") + void withdrawnAccountPastGracePeriodCannotLogInAgain() throws Exception { + User user = userRepository.save(UserFixture.user("withdrawn-old@chaeso.zip")); + authIdentityRepository.save( + AuthIdentity.createLocal(user.getId(), passwordEncoder.encode("P@ssw0rd!"))); + authService.login(new LoginCommand("withdrawn-old@chaeso.zip", "P@ssw0rd!")); + user.withdraw(LocalDateTime.now(ZoneOffset.UTC).minusDays(31)); + userRepository.saveAndFlush(user); + + mockMvc.perform(post("/api/v1/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"email": "withdrawn-old@chaeso.zip", "password": "P@ssw0rd!"} + """)) .andExpect(status().isConflict()) .andExpect(jsonPath("$.error.code").value("AUTH-013")); } From 0a80a22f5cfaf1491d190667221086cfd6af9a3f Mon Sep 17 00:00:00 2001 From: SinnoLn Date: Fri, 21 Aug 2026 23:51:43 +0900 Subject: [PATCH 5/5] docs: doc update --- .../java/chaeso/zip/server/auth/presentation/AuthApiDocs.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/chaeso/zip/server/auth/presentation/AuthApiDocs.java b/src/main/java/chaeso/zip/server/auth/presentation/AuthApiDocs.java index 1000908d..b09e2f8b 100644 --- a/src/main/java/chaeso/zip/server/auth/presentation/AuthApiDocs.java +++ b/src/main/java/chaeso/zip/server/auth/presentation/AuthApiDocs.java @@ -289,7 +289,7 @@ public interface AuthApiDocs { description = """ 이메일로 사용 가능한 로그인 수단을 조회한다. 비밀번호 입력 전 화면 분기용. - methods \ + methods [LOCAL]: 비밀번호 입력창. [LOCAL, GOOGLE]: 비밀번호 입력창과 구글 버튼. [GOOGLE]: 구글로 가입된 계정 안내.