Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand All @@ -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);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand All @@ -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
Expand All @@ -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())
Expand Down

This file was deleted.

40 changes: 28 additions & 12 deletions src/main/java/chaeso/zip/server/auth/presentation/AuthApiDocs.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -178,19 +176,36 @@ 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 = {
@ExampleObject(name = "VALIDATION_ERROR", value = VALIDATION_ERROR_EXAMPLE),
@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 = {
Expand All @@ -199,7 +214,7 @@ public interface AuthApiDocs {
@ExampleObject(name = "ACCOUNT_DELETION_IN_PROGRESS",
value = ACCOUNT_DELETION_IN_PROGRESS_EXAMPLE)
}))
ApiResponse<UserResponse> signup(@Valid @RequestBody SignupRequest request);
ApiResponse<TokenResponse> signup(@Valid @RequestBody SignupRequest request);

String INVALID_CREDENTIALS_EXAMPLE = """
{
Expand Down Expand Up @@ -287,8 +302,10 @@ public interface AuthApiDocs {

@Operation(operationId = "loginMethods", summary = "로그인 수단 조회",
description = """
이메일로 사용 가능한 로그인 수단을 조회한다. 비밀번호 입력 전 화면 분기용.

탈퇴 여부에 상관없이 이메일로 사용 가능한 로그인 수단을 조회한다.
탈퇴 후 30일 이내(휴면) 계정도 로그인 화면으로 안내하면 되며,
로그인에 성공하면 자동으로 복구된다.

methods
[LOCAL]: 비밀번호 입력창.
[LOCAL, GOOGLE]: 비밀번호 입력창과 구글 버튼.
Expand Down Expand Up @@ -459,8 +476,7 @@ ApiResponse<LoginMethodsResponse> 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),
Expand Down Expand Up @@ -547,7 +563,7 @@ ApiResponse<LoginMethodsResponse> 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 = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -53,7 +52,7 @@ public ApiResponse<Void> verifySignupCode(
@Override
@PostMapping("/signup")
@ResponseStatus(HttpStatus.CREATED)
public ApiResponse<UserResponse> signup(@Valid @RequestBody SignupRequest request) {
public ApiResponse<TokenResponse> signup(@Valid @RequestBody SignupRequest request) {
return ApiResponse.success(authService.signup(request.toCommand()));
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<User> 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<AuthIdentity> captor = ArgumentCaptor.forClass(AuthIdentity.class);
verify(authIdentityRepository).save(captor.capture());
Expand Down Expand Up @@ -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"),
Expand All @@ -1087,23 +1095,35 @@ 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");

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);
Expand All @@ -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());
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
Loading