diff --git a/src/main/java/chaeso/zip/server/auth/application/AuthService.java b/src/main/java/chaeso/zip/server/auth/application/AuthService.java index 7fe39ab0..cfe7f54b 100644 --- a/src/main/java/chaeso/zip/server/auth/application/AuthService.java +++ b/src/main/java/chaeso/zip/server/auth/application/AuthService.java @@ -6,7 +6,6 @@ import chaeso.zip.server.auth.application.dto.LoginMethodsResponse; import chaeso.zip.server.auth.application.dto.SignupCommand; import chaeso.zip.server.auth.application.dto.TokenResponse; -import chaeso.zip.server.auth.application.dto.UserResponse; import java.util.UUID; /** @@ -25,15 +24,15 @@ public interface AuthService { /** 인증코드를 검증하고 인증완료 상태로 전환한다. 불일치/만료면 예외. */ void verifySignupCode(String email, String code); - /** 이메일 인증 완료를 전제로 로컬 계정을 생성한다. */ - UserResponse signup(SignupCommand command); + /** 이메일 인증 완료를 전제로 로컬 계정을 생성하고 액세스/리프레시 토큰을 발급한다. */ + TokenResponse signup(SignupCommand command); /** 이메일/비밀번호로 로컬 로그인하고 액세스/리프레시 토큰을 발급한다. 실패 시 예외. */ TokenResponse login(LoginCommand command); /** - * 이메일로 그 계정의 로그인 수단을 조회한다. 미가입이거나 탈퇴한 계정이면 빈 목록, - * {@code clientIp} 단위 조회 한도를 넘으면 예외. + * 휴먼 여부와 상관없이 이메일로 그 계정의 로그인 수단을 조회한다. + * 미가입이면 빈 목록, {@code clientIp} 단위 조회 한도를 넘으면 예외. */ LoginMethodsResponse findLoginMethods(String email, String clientIp); 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 d8129ebc..7945774e 100644 --- a/src/main/java/chaeso/zip/server/auth/application/AuthServiceImpl.java +++ b/src/main/java/chaeso/zip/server/auth/application/AuthServiceImpl.java @@ -6,7 +6,6 @@ import chaeso.zip.server.auth.application.dto.LoginMethodsResponse; import chaeso.zip.server.auth.application.dto.SignupCommand; import chaeso.zip.server.auth.application.dto.TokenResponse; -import chaeso.zip.server.auth.application.dto.UserResponse; import chaeso.zip.server.auth.domain.AuthBusinessException; import chaeso.zip.server.auth.domain.AuthErrorCode; import chaeso.zip.server.auth.domain.AuthIdentity; @@ -126,7 +125,7 @@ public void verifySignupCode(String email, String code) { @Override @Transactional - public UserResponse signup(SignupCommand command) { + public TokenResponse signup(SignupCommand command) { String email = normalizeEmail(command.email()); if (!verificationCodeStore.isVerified(email)) { throw new AuthBusinessException(AuthErrorCode.EMAIL_NOT_VERIFIED); @@ -140,7 +139,7 @@ public UserResponse signup(SignupCommand command) { authIdentityRepository.save( AuthIdentity.createLocal(user.getId(), passwordEncoder.encode(command.rawPassword()))); verificationCodeStore.clearVerified(email); - return UserResponse.from(user); + return authSessionService.openSession(user, AuthProvider.LOCAL); } @Override @@ -167,13 +166,19 @@ public TokenResponse login(LoginCommand command) { return authSessionService.openLocalSession(user.getId()); } + /** + * 탈퇴 여부와 무관하게 가입 이력이 있으면 로그인 수단을 내려준다. + * + * 탈퇴 후 30일 이내(휴면)면 로그인 시 자동 복구 + * 가입 시도는 AUTH-014로 로그인을 안내 + */ @Override @Transactional(readOnly = true) public LoginMethodsResponse findLoginMethods(String email, String clientIp) { if (!loginMethodLookupLimiter.tryAcquire(clientIp)) { throw new AuthBusinessException(AuthErrorCode.LOGIN_METHOD_LOOKUP_COOLDOWN); } - return userRepository.findByEmailAndDeletedAtIsNull(normalizeEmail(email)) + return userRepository.findByEmail(normalizeEmail(email)) .map(user -> authIdentityRepository.findAllByUserId(user.getId()).stream() .map(AuthIdentity::getProvider) .sorted(Comparator.naturalOrder()) diff --git a/src/main/java/chaeso/zip/server/auth/application/dto/UserResponse.java b/src/main/java/chaeso/zip/server/auth/application/dto/UserResponse.java deleted file mode 100644 index b6ee70bc..00000000 --- a/src/main/java/chaeso/zip/server/auth/application/dto/UserResponse.java +++ /dev/null @@ -1,20 +0,0 @@ -package chaeso.zip.server.auth.application.dto; - -import chaeso.zip.server.user.domain.User; -import io.swagger.v3.oas.annotations.media.Schema; -import java.util.UUID; - -/** 회원가입 성공 응답. 토큰은 로그인 API 소관이므로 반환하지 않는다. */ -@Schema(description = "회원 응답") -public record UserResponse( - @Schema(description = "회원 식별자", example = "0b8b8f2e-1c3a-4e5b-9a7d-2f1c6e8b4a90", requiredMode = Schema.RequiredMode.REQUIRED) - UUID id, - @Schema(description = "이메일", example = "user@chaeso.zip", requiredMode = Schema.RequiredMode.REQUIRED) - String email, - @Schema(description = "닉네임", example = "채소러버", requiredMode = Schema.RequiredMode.REQUIRED) - String nickname) { - - public static UserResponse from(User user) { - return new UserResponse(user.getId(), user.getEmail(), user.getNickname()); - } -} 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 b09e2f8b..ba399890 100644 --- a/src/main/java/chaeso/zip/server/auth/presentation/AuthApiDocs.java +++ b/src/main/java/chaeso/zip/server/auth/presentation/AuthApiDocs.java @@ -4,7 +4,6 @@ import chaeso.zip.server.auth.application.dto.GoogleAuthResponse; import chaeso.zip.server.auth.application.dto.LoginMethodsResponse; import chaeso.zip.server.auth.application.dto.TokenResponse; -import chaeso.zip.server.auth.application.dto.UserResponse; import chaeso.zip.server.auth.presentation.dto.GoogleAuthRequest; import chaeso.zip.server.auth.presentation.dto.GoogleSignupRequest; import chaeso.zip.server.auth.presentation.dto.LoginMethodsRequest; @@ -128,8 +127,7 @@ 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 = "이미 사용 중인 이메일(AUTH-002), 탈퇴 후 유예기간 이내라 로그인이 필요한 휴면 계정(AUTH-014), " - + "또는 유예기간이 지나 탈퇴 처리 중인 계정(AUTH-013)", + 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), @@ -178,11 +176,28 @@ public interface AuthApiDocs { } """; + String SIGNUP_SUCCESS_EXAMPLE = """ + { + "success": true, + "data": { + "accessToken": "eyJhbGciOiJIUzI1NiJ9...", + "refreshToken": "eyJhbGciOiJIUzI1NiJ9...", + "accessTokenExpiresIn": 1800, + "refreshTokenExpiresIn": 1209600 + }, + "error": null, + "code": null + } + """; + @Operation(operationId = "signup", summary = "회원가입 최종 제출", description = """ - 이메일 인증 완료 후 로컬 계정을 생성한다. 인증 미완료 시 400, 이메일 중복 시 409. + 이메일 인증 완료 후 로컬 계정을 생성하고 토큰을 발급한다. 인증 미완료 시 400, 이메일 중복 시 409. 탈퇴 후 30일 이내(휴면) 이메일이면 가입 대신 409(AUTH-014)로 로그인을 안내한다.""") - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "201", description = "가입 성공") + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "201", description = "가입 성공, 토큰 발급", + useReturnTypeSchema = true, + content = @Content( + examples = @ExampleObject(name = "SIGNUP_SUCCESS", value = SIGNUP_SUCCESS_EXAMPLE))) @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "입력값 검증 실패 또는 이메일 미인증", content = @Content(schema = @Schema(implementation = ApiResponse.class), examples = { @@ -190,7 +205,7 @@ public interface AuthApiDocs { @ExampleObject(name = "EMAIL_NOT_VERIFIED", value = EMAIL_NOT_VERIFIED_EXAMPLE) })) @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", - description = "이미 사용 중인 이메일(AUTH-002), 탈퇴 후 유예기간 이내라 로그인이 필요한 휴면 계정(AUTH-014), " + description = "이미 사용 중인 이메일(AUTH-002), 휴면 계정(AUTH-014), " + "또는 유예기간이 지나 탈퇴 처리 중인 계정(AUTH-013)", content = @Content(schema = @Schema(implementation = ApiResponse.class), examples = { @@ -199,7 +214,7 @@ public interface AuthApiDocs { @ExampleObject(name = "ACCOUNT_DELETION_IN_PROGRESS", value = ACCOUNT_DELETION_IN_PROGRESS_EXAMPLE) })) - ApiResponse signup(@Valid @RequestBody SignupRequest request); + ApiResponse signup(@Valid @RequestBody SignupRequest request); String INVALID_CREDENTIALS_EXAMPLE = """ { @@ -287,8 +302,10 @@ public interface AuthApiDocs { @Operation(operationId = "loginMethods", summary = "로그인 수단 조회", description = """ - 이메일로 사용 가능한 로그인 수단을 조회한다. 비밀번호 입력 전 화면 분기용. - + 탈퇴 여부에 상관없이 이메일로 사용 가능한 로그인 수단을 조회한다. + 탈퇴 후 30일 이내(휴면) 계정도 로그인 화면으로 안내하면 되며, + 로그인에 성공하면 자동으로 복구된다. + methods [LOCAL]: 비밀번호 입력창. [LOCAL, GOOGLE]: 비밀번호 입력창과 구글 버튼. @@ -459,8 +476,7 @@ 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-014), " - + "또는 유예기간이 지나 탈퇴 처리 중인 계정(AUTH-013)", + description = "구글 미연결 상태의 휴면 계정(AUTH-014), 또는 유예기간이 지나 탈퇴 처리 중인 계정(AUTH-013)", content = @Content(schema = @Schema(implementation = ApiResponse.class), examples = { @ExampleObject(name = "ACCOUNT_DORMANT", value = ACCOUNT_DORMANT_EXAMPLE), @@ -547,7 +563,7 @@ ApiResponse loginMethods( value = GOOGLE_SIGNUP_SESSION_INVALID_EXAMPLE) })) @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", - description = "이미 사용 중인 이메일(AUTH-002), 탈퇴 후 유예기간 이내라 로그인이 필요한 휴면 계정(AUTH-014), " + description = "이미 사용 중인 이메일(AUTH-002), 휴면 계정(AUTH-014), " + "또는 유예기간이 지나 탈퇴 처리 중인 계정(AUTH-013)", content = @Content(schema = @Schema(implementation = ApiResponse.class), examples = { diff --git a/src/main/java/chaeso/zip/server/auth/presentation/AuthController.java b/src/main/java/chaeso/zip/server/auth/presentation/AuthController.java index 8827fe2d..550b3c2e 100644 --- a/src/main/java/chaeso/zip/server/auth/presentation/AuthController.java +++ b/src/main/java/chaeso/zip/server/auth/presentation/AuthController.java @@ -3,7 +3,6 @@ import chaeso.zip.server.auth.application.AuthService; import chaeso.zip.server.auth.application.UserPrincipal; import chaeso.zip.server.auth.application.dto.TokenResponse; -import chaeso.zip.server.auth.application.dto.UserResponse; import chaeso.zip.server.auth.application.dto.GoogleAuthResponse; import chaeso.zip.server.auth.application.dto.LoginMethodsResponse; import chaeso.zip.server.auth.presentation.dto.GoogleAuthRequest; @@ -53,7 +52,7 @@ public ApiResponse verifySignupCode( @Override @PostMapping("/signup") @ResponseStatus(HttpStatus.CREATED) - public ApiResponse signup(@Valid @RequestBody SignupRequest request) { + public ApiResponse signup(@Valid @RequestBody SignupRequest request) { return ApiResponse.success(authService.signup(request.toCommand())); } 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 4448220e..8c0d196c 100644 --- a/src/test/java/chaeso/zip/server/auth/application/AuthServiceTest.java +++ b/src/test/java/chaeso/zip/server/auth/application/AuthServiceTest.java @@ -19,7 +19,6 @@ import chaeso.zip.server.auth.application.dto.LoginMethodsResponse; import chaeso.zip.server.auth.application.dto.SignupCommand; import chaeso.zip.server.auth.application.dto.TokenResponse; -import chaeso.zip.server.auth.application.dto.UserResponse; import chaeso.zip.server.auth.domain.AuthBusinessException; import chaeso.zip.server.auth.domain.AuthErrorCode; import chaeso.zip.server.auth.domain.AuthIdentity; @@ -342,17 +341,27 @@ void duplicate() { } @Test - @DisplayName("인증된 미가입 이메일이면 비밀번호를 인코딩해 회원과 로컬 인증 정보를 저장한다") + @DisplayName("인증된 미가입 이메일이면 비밀번호를 인코딩해 회원과 로컬 인증 정보를 저장하고 토큰을 발급한다") void success() { given(verificationCodeStore.isVerified("user@chaeso.zip")).willReturn(true); given(userRepository.existsByEmailAndDeletedAtIsNull("user@chaeso.zip")).willReturn(false); given(userRepository.saveAndFlush(any(User.class))).willAnswer(invocation -> invocation.getArgument(0)); given(passwordEncoder.encode("P@ssw0rd!")).willReturn("ENCODED"); + 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"); - UserResponse response = authService.signup(command("user@chaeso.zip")); + TokenResponse response = authService.signup(command("user@chaeso.zip")); - assertThat(response.email()).isEqualTo("user@chaeso.zip"); - assertThat(response.nickname()).isEqualTo("채소러버"); + assertThat(response.accessToken()).isEqualTo("access"); + assertThat(response.refreshToken()).isEqualTo("refresh"); + + ArgumentCaptor userCaptor = ArgumentCaptor.forClass(User.class); + verify(userRepository).saveAndFlush(userCaptor.capture()); + assertThat(userCaptor.getValue().getEmail()).isEqualTo("user@chaeso.zip"); + assertThat(userCaptor.getValue().getNickname()).isEqualTo("채소러버"); + assertThat(userCaptor.getValue().getLastLoginProvider()).isEqualTo(AuthProvider.LOCAL); ArgumentCaptor captor = ArgumentCaptor.forClass(AuthIdentity.class); verify(authIdentityRepository).save(captor.capture()); @@ -1073,8 +1082,7 @@ class FindLoginMethods { @DisplayName("로컬과 구글이 모두 연동된 계정은 LOCAL, GOOGLE 순으로 돌려준다") void localAndGoogle_ordered() { User user = UserFixture.user("user@chaeso.zip"); - given(userRepository.findByEmailAndDeletedAtIsNull("user@chaeso.zip")) - .willReturn(Optional.of(user)); + given(userRepository.findByEmail("user@chaeso.zip")).willReturn(Optional.of(user)); given(authIdentityRepository.findAllByUserId(user.getId())) .willReturn(List.of( AuthIdentity.createGoogle(user.getId(), "google-sub-1"), @@ -1087,10 +1095,9 @@ void localAndGoogle_ordered() { } @Test - @DisplayName("가입 이력이 없거나 탈퇴한 계정은 빈 목록을 돌려준다") + @DisplayName("가입 이력이 없으면 빈 목록을 돌려준다") void notFound_returnsEmpty() { - given(userRepository.findByEmailAndDeletedAtIsNull("ghost@chaeso.zip")) - .willReturn(Optional.empty()); + given(userRepository.findByEmail("ghost@chaeso.zip")).willReturn(Optional.empty()); given(loginMethodLookupLimiter.tryAcquire(anyString())).willReturn(true); LoginMethodsResponse response = authService.findLoginMethods("ghost@chaeso.zip", "203.0.113.7"); @@ -1098,12 +1105,25 @@ void notFound_returnsEmpty() { assertThat(response.methods()).isEmpty(); } + @Test + @DisplayName("탈퇴한 계정도 가입 이력이 있으면 로그인 수단을 그대로 돌려준다") + void withdrawnAccount_stillReturnsMethods() { + User user = withdrawnUser(1); + given(userRepository.findByEmail("user@chaeso.zip")).willReturn(Optional.of(user)); + given(authIdentityRepository.findAllByUserId(user.getId())) + .willReturn(List.of(AuthIdentity.createLocal(user.getId(), "hashed"))); + given(loginMethodLookupLimiter.tryAcquire(anyString())).willReturn(true); + + LoginMethodsResponse response = authService.findLoginMethods("user@chaeso.zip", "203.0.113.7"); + + assertThat(response.methods()).containsExactly(AuthProvider.LOCAL); + } + @Test @DisplayName("대소문자와 공백이 달라도 같은 계정으로 조회한다") void normalizesEmail() { User user = UserFixture.user("user@chaeso.zip"); - given(userRepository.findByEmailAndDeletedAtIsNull("user@chaeso.zip")) - .willReturn(Optional.of(user)); + given(userRepository.findByEmail("user@chaeso.zip")).willReturn(Optional.of(user)); given(authIdentityRepository.findAllByUserId(user.getId())) .willReturn(List.of(AuthIdentity.createLocal(user.getId(), "hashed"))); given(loginMethodLookupLimiter.tryAcquire(anyString())).willReturn(true); @@ -1123,7 +1143,7 @@ void rateLimited_throwsAndSkipsLookup() { .isInstanceOf(AuthBusinessException.class) .extracting("errorCode").isEqualTo(AuthErrorCode.LOGIN_METHOD_LOOKUP_COOLDOWN); - verify(userRepository, never()).findByEmailAndDeletedAtIsNull(anyString()); + verify(userRepository, never()).findByEmail(anyString()); } } diff --git a/src/test/java/chaeso/zip/server/auth/presentation/AuthControllerTest.java b/src/test/java/chaeso/zip/server/auth/presentation/AuthControllerTest.java index 3f10350a..a8a706c8 100644 --- a/src/test/java/chaeso/zip/server/auth/presentation/AuthControllerTest.java +++ b/src/test/java/chaeso/zip/server/auth/presentation/AuthControllerTest.java @@ -19,7 +19,6 @@ import chaeso.zip.server.auth.application.dto.LoginMethodsResponse; import chaeso.zip.server.auth.application.dto.SignupCommand; import chaeso.zip.server.auth.application.dto.TokenResponse; -import chaeso.zip.server.auth.application.dto.UserResponse; import chaeso.zip.server.auth.domain.AuthBusinessException; import chaeso.zip.server.auth.domain.AuthErrorCode; import chaeso.zip.server.auth.domain.AuthProvider; @@ -107,17 +106,18 @@ void clearSecurityContext() { class Signup { @Test - @DisplayName("회원가입 요청이 성공하면 201과 회원 정보를 반환한다") + @DisplayName("회원가입 요청이 성공하면 201과 액세스/리프레시 토큰을 반환한다") void success() throws Exception { given(authService.signup(any(SignupCommand.class))) - .willReturn(new UserResponse(UUID.randomUUID(), "user@chaeso.zip", "채소러버")); + .willReturn(new TokenResponse("access", "refresh", 1800L, 1209600L)); mockMvc.perform(post("/api/v1/auth/signup") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(validSignupRequest()))) .andExpect(status().isCreated()) .andExpect(jsonPath("$.success").value(true)) - .andExpect(jsonPath("$.data.email").value("user@chaeso.zip")); + .andExpect(jsonPath("$.data.accessToken").value("access")) + .andExpect(jsonPath("$.data.refreshToken").value("refresh")); } @Test