diff --git a/src/main/java/project/flipnote/auth/controller/AuthController.java b/src/main/java/project/flipnote/auth/controller/AuthController.java index ff93aeb2..565f125f 100644 --- a/src/main/java/project/flipnote/auth/controller/AuthController.java +++ b/src/main/java/project/flipnote/auth/controller/AuthController.java @@ -1,20 +1,23 @@ package project.flipnote.auth.controller; +import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.Cookie; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import project.flipnote.auth.model.EmailVerificationConfirmRequest; import project.flipnote.auth.model.EmailVerificationRequest; import project.flipnote.auth.model.TokenPair; -import project.flipnote.auth.model.UserLoginDto; +import project.flipnote.auth.model.UserLoginRequest; +import project.flipnote.auth.model.UserLoginResponse; import project.flipnote.auth.service.AuthService; import project.flipnote.common.security.jwt.JwtConstants; +import project.flipnote.common.security.jwt.JwtProperties; import project.flipnote.common.util.CookieUtil; @RequiredArgsConstructor @@ -23,24 +26,33 @@ public class AuthController { private final AuthService authService; + private final JwtProperties jwtProperties; @PostMapping("/login") - public ResponseEntity login( - @Valid @RequestBody UserLoginDto.Request req, - HttpServletResponse servletResponse + public ResponseEntity login( + @Valid @RequestBody UserLoginRequest req ) { TokenPair tokenPair = authService.login(req); - CookieUtil.addCookie(servletResponse, JwtConstants.REFRESH_TOKEN, tokenPair.refreshToken(), 30 * 24 * 60 * 60); + long expirationSeconds = jwtProperties.getRefreshTokenExpiration().toSeconds(); + Cookie cookie = CookieUtil.createCookie( + JwtConstants.REFRESH_TOKEN, + tokenPair.refreshToken(), + Math.toIntExact(expirationSeconds) + ); - return ResponseEntity.ok(UserLoginDto.Response.from(tokenPair.accessToken())); + return ResponseEntity.ok() + .header(HttpHeaders.SET_COOKIE, cookie.toString()) + .body(UserLoginResponse.from(tokenPair.accessToken())); } @PostMapping("/logout") - public ResponseEntity logout(HttpServletResponse servletResponse) { - CookieUtil.deleteCookie(servletResponse, JwtConstants.REFRESH_TOKEN); + public ResponseEntity logout() { + Cookie expiredCookie = CookieUtil.createExpiredCookie(JwtConstants.REFRESH_TOKEN); - return ResponseEntity.ok().build(); + return ResponseEntity.ok() + .header(HttpHeaders.SET_COOKIE, expiredCookie.toString()) + .build(); } @PostMapping("/email") diff --git a/src/main/java/project/flipnote/auth/model/UserLoginDto.java b/src/main/java/project/flipnote/auth/model/UserLoginDto.java deleted file mode 100644 index a17a13cd..00000000 --- a/src/main/java/project/flipnote/auth/model/UserLoginDto.java +++ /dev/null @@ -1,26 +0,0 @@ -package project.flipnote.auth.model; - -import jakarta.validation.constraints.Email; -import jakarta.validation.constraints.NotBlank; - -public class UserLoginDto { - - public record Request( - - @Email @NotBlank - String email, - - @NotBlank - String password - ) { - } - - public record Response( - String accessToken - ) { - - public static Response from(String accessToken) { - return new Response(accessToken); - } - } -} diff --git a/src/main/java/project/flipnote/auth/model/UserLoginRequest.java b/src/main/java/project/flipnote/auth/model/UserLoginRequest.java new file mode 100644 index 00000000..a7e2068d --- /dev/null +++ b/src/main/java/project/flipnote/auth/model/UserLoginRequest.java @@ -0,0 +1,15 @@ +package project.flipnote.auth.model; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import project.flipnote.common.validation.annotation.ValidPassword; + +public record UserLoginRequest( + + @Email @NotBlank + String email, + + @ValidPassword + String password +) { +} diff --git a/src/main/java/project/flipnote/auth/model/UserLoginResponse.java b/src/main/java/project/flipnote/auth/model/UserLoginResponse.java new file mode 100644 index 00000000..a19a20e0 --- /dev/null +++ b/src/main/java/project/flipnote/auth/model/UserLoginResponse.java @@ -0,0 +1,10 @@ +package project.flipnote.auth.model; + +public record UserLoginResponse( + String accessToken +) { + + public static UserLoginResponse from(String accessToken) { + return new UserLoginResponse(accessToken); + } +} diff --git a/src/main/java/project/flipnote/auth/service/AuthService.java b/src/main/java/project/flipnote/auth/service/AuthService.java index f8426723..790011ea 100644 --- a/src/main/java/project/flipnote/auth/service/AuthService.java +++ b/src/main/java/project/flipnote/auth/service/AuthService.java @@ -15,11 +15,12 @@ import project.flipnote.auth.model.EmailVerificationConfirmRequest; import project.flipnote.auth.model.EmailVerificationRequest; import project.flipnote.auth.model.TokenPair; -import project.flipnote.auth.model.UserLoginDto; +import project.flipnote.auth.model.UserLoginRequest; import project.flipnote.auth.repository.EmailVerificationRedisRepository; import project.flipnote.common.exception.BizException; import project.flipnote.common.security.jwt.JwtComponent; import project.flipnote.user.entity.User; +import project.flipnote.user.entity.UserStatus; import project.flipnote.user.repository.UserRepository; @Slf4j @@ -35,26 +36,14 @@ public class AuthService { private static final SecureRandom random = new SecureRandom(); - public TokenPair login(UserLoginDto.Request req) { - User user = findByEmailOrThrow(req); + public TokenPair login(UserLoginRequest req) { + User user = findActiveUserByEmail(req.email()); validatePasswordMatch(req.password(), user.getPassword()); - log.error("{}", user.getPhone()); return jwtComponent.generateTokenPair(user.getEmail(), user.getId(), user.getRole().name()); } - private User findByEmailOrThrow(UserLoginDto.Request req) { - return userRepository.findByEmail(req.email()) - .orElseThrow(() -> new BizException(AuthErrorCode.INVALID_CREDENTIALS)); - } - - private void validatePasswordMatch(String rawPassword, String encodedPassword) { - if (!passwordEncoder.matches(rawPassword, encodedPassword)) { - throw new BizException(AuthErrorCode.INVALID_CREDENTIALS); - } - } - public void sendEmailVerificationCode(EmailVerificationRequest req) { final String email = req.email(); @@ -68,18 +57,6 @@ public void sendEmailVerificationCode(EmailVerificationRequest req) { eventPublisher.publishEvent(new EmailVerificationSendEvent(email, code)); } - private void validateEmailIsAvailable(String email) { - if (userRepository.existsByEmail(email)) { - throw new BizException(AuthErrorCode.EXISTING_EMAIL); - } - } - - private void validateVerificationCodeNotExists(String email) { - if (emailVerificationRedisRepository.existCode(email)) { - throw new BizException(AuthErrorCode.ALREADY_ISSUED_VERIFICATION_CODE); - } - } - public void confirmEmailVerificationCode(EmailVerificationConfirmRequest req) { String email = req.email(); @@ -91,17 +68,6 @@ public void confirmEmailVerificationCode(EmailVerificationConfirmRequest req) { emailVerificationRedisRepository.markAsVerified(email); } - private String findVerificationCodeOrThrow(String email) { - return emailVerificationRedisRepository.findCode(email) - .orElseThrow(() -> new BizException(AuthErrorCode.NOT_ISSUED_VERIFICATION_CODE)); - } - - private void validateVerificationCode(String inputCode, String savedCode) { - if (!Objects.equals(inputCode, savedCode)) { - throw new BizException(AuthErrorCode.INVALID_VERIFICATION_CODE); - } - } - public void validateEmail(String email) { if (!emailVerificationRedisRepository.isVerified(email)) { throw new BizException(AuthErrorCode.UNVERIFIED_EMAIL); @@ -117,4 +83,38 @@ private String generateVerificationCode(int length) { int bound = (int)Math.pow(10, length); return String.valueOf(random.nextInt(origin, bound)); } + + private User findActiveUserByEmail(String email) { + return userRepository.findByEmailAndStatus(email, UserStatus.ACTIVE) + .orElseThrow(() -> new BizException(AuthErrorCode.INVALID_CREDENTIALS)); + } + + private void validatePasswordMatch(String rawPassword, String encodedPassword) { + if (!passwordEncoder.matches(rawPassword, encodedPassword)) { + throw new BizException(AuthErrorCode.INVALID_CREDENTIALS); + } + } + + private void validateEmailIsAvailable(String email) { + if (userRepository.existsByEmail(email)) { + throw new BizException(AuthErrorCode.EXISTING_EMAIL); + } + } + + private void validateVerificationCodeNotExists(String email) { + if (emailVerificationRedisRepository.existCode(email)) { + throw new BizException(AuthErrorCode.ALREADY_ISSUED_VERIFICATION_CODE); + } + } + + private String findVerificationCodeOrThrow(String email) { + return emailVerificationRedisRepository.findCode(email) + .orElseThrow(() -> new BizException(AuthErrorCode.NOT_ISSUED_VERIFICATION_CODE)); + } + + private void validateVerificationCode(String inputCode, String savedCode) { + if (!Objects.equals(inputCode, savedCode)) { + throw new BizException(AuthErrorCode.INVALID_VERIFICATION_CODE); + } + } } diff --git a/src/main/java/project/flipnote/common/util/CookieUtil.java b/src/main/java/project/flipnote/common/util/CookieUtil.java index 7f6f5337..39600c39 100644 --- a/src/main/java/project/flipnote/common/util/CookieUtil.java +++ b/src/main/java/project/flipnote/common/util/CookieUtil.java @@ -8,8 +8,7 @@ @NoArgsConstructor(access = AccessLevel.PRIVATE) public class CookieUtil { - public static void addCookie( - HttpServletResponse response, + public static Cookie createCookie( String name, String value, int maxAge, @@ -20,14 +19,14 @@ public static void addCookie( cookie.setMaxAge(maxAge); cookie.setHttpOnly(httpOnly); cookie.setPath(path); - response.addCookie(cookie); + return cookie; } - public static void addCookie(HttpServletResponse response, String name, String value, int maxAge) { - addCookie(response, name, value, maxAge, true, "/"); + public static Cookie createCookie(String name, String value, int maxAge) { + return createCookie(name, value, maxAge, true, "/"); } - public static void deleteCookie(HttpServletResponse response, String name) { - addCookie(response, name, "", 0, true, "/"); + public static Cookie createExpiredCookie(String name) { + return createCookie(name, "", 0, true, "/"); } } diff --git a/src/main/java/project/flipnote/user/repository/UserRepository.java b/src/main/java/project/flipnote/user/repository/UserRepository.java index 2e90fe25..05761956 100644 --- a/src/main/java/project/flipnote/user/repository/UserRepository.java +++ b/src/main/java/project/flipnote/user/repository/UserRepository.java @@ -5,6 +5,7 @@ import org.springframework.data.jpa.repository.JpaRepository; import project.flipnote.user.entity.User; +import project.flipnote.user.entity.UserStatus; public interface UserRepository extends JpaRepository { @@ -12,5 +13,5 @@ public interface UserRepository extends JpaRepository { boolean existsByPhone(String phone); - Optional findByEmail(String email); + Optional findByEmailAndStatus(String email, UserStatus status); } diff --git a/src/test/java/project/flipnote/auth/service/AuthServiceTest.java b/src/test/java/project/flipnote/auth/service/AuthServiceTest.java index 8af9a35d..77fb0eeb 100644 --- a/src/test/java/project/flipnote/auth/service/AuthServiceTest.java +++ b/src/test/java/project/flipnote/auth/service/AuthServiceTest.java @@ -14,14 +14,21 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.context.ApplicationEventPublisher; +import org.springframework.security.crypto.password.PasswordEncoder; import project.flipnote.auth.constants.VerificationConstants; import project.flipnote.auth.event.EmailVerificationSendEvent; import project.flipnote.auth.exception.AuthErrorCode; import project.flipnote.auth.model.EmailVerificationConfirmRequest; import project.flipnote.auth.model.EmailVerificationRequest; +import project.flipnote.auth.model.TokenPair; +import project.flipnote.auth.model.UserLoginRequest; import project.flipnote.auth.repository.EmailVerificationRedisRepository; import project.flipnote.common.exception.BizException; +import project.flipnote.common.security.jwt.JwtComponent; +import project.flipnote.fixture.UserFixture; +import project.flipnote.user.entity.User; +import project.flipnote.user.entity.UserStatus; import project.flipnote.user.repository.UserRepository; @DisplayName("인증 서비스 단위 테스트") @@ -40,6 +47,12 @@ class AuthServiceTest { @Mock ApplicationEventPublisher eventPublisher; + @Mock + PasswordEncoder passwordEncoder; + + @Mock + JwtComponent jwtComponent; + @DisplayName("이메일 인증번호 전송 테스트") @Nested class SendEmailVerificationCode { @@ -146,6 +159,81 @@ void fail_invalidVerificationCode() { verify(emailVerificationRedisRepository, never()).deleteCode(any(String.class)); verify(emailVerificationRedisRepository, never()).markAsVerified(any(String.class)); } + } + + @DisplayName("로그인 테스트") + @Nested + class Login { + + @DisplayName("성공") + @Test + void success() { + UserLoginRequest req = new UserLoginRequest("test@example.com", "testPass"); + + User foundUser = UserFixture.createActiveUser(); + + TokenPair expectedTokenPair = new TokenPair("access-token", "refresh-token"); + + given(userRepository.findByEmailAndStatus(req.email(), UserStatus.ACTIVE)) + .willReturn(Optional.of(foundUser)); + given(passwordEncoder.matches(req.password(), foundUser.getPassword())) + .willReturn(true); + given(jwtComponent.generateTokenPair(foundUser.getEmail(), foundUser.getId(), foundUser.getRole().name())) + .willReturn(expectedTokenPair); + + TokenPair resultTokenPair = authService.login(req); + + assertThat(resultTokenPair).isNotNull(); + assertThat(resultTokenPair.accessToken()).isEqualTo(expectedTokenPair.accessToken()); + assertThat(resultTokenPair.refreshToken()).isEqualTo(expectedTokenPair.refreshToken()); + + verify(userRepository).findByEmailAndStatus(anyString(), any(UserStatus.class)); + verify(passwordEncoder).matches(anyString(), anyString()); + verify(jwtComponent).generateTokenPair(anyString(), anyLong(), anyString()); + } + + @Test + @DisplayName("이메일이 존재하지 않는 경우 예외 발생") + void fail_invalidCredentials_wrongEmail() { + UserLoginRequest req = new UserLoginRequest("wrong@test.com", "testPass"); + when(userRepository.findByEmailAndStatus(req.email(), UserStatus.ACTIVE)) + .thenReturn(Optional.empty()); + + BizException exception = assertThrows( + BizException.class, + () -> authService.login(req) + ); + + assertThat(exception).isNotNull(); + assertThat(exception.getErrorCode()).isEqualTo(AuthErrorCode.INVALID_CREDENTIALS); + + verify(passwordEncoder, never()).matches(anyString(), anyString()); + verify(jwtComponent, never()).generateTokenPair(anyString(), anyLong(), anyString()); + } + + @Test + @DisplayName("비밀번호가 일치하지 않는 경우 예외 발생") + void fail_invalidCredentials_wrongPassword() { + UserLoginRequest req = new UserLoginRequest("wrong@test.com", "wrongPass"); + + User foundUser = UserFixture.createActiveUser(); + + given(userRepository.findByEmailAndStatus(req.email(), UserStatus.ACTIVE)) + .willReturn(Optional.of(foundUser)); + given(passwordEncoder.matches(req.password(), foundUser.getPassword())) + .willReturn(false); + + BizException exception = assertThrows( + BizException.class, + () -> authService.login(req) + ); + + assertThat(exception).isNotNull(); + assertThat(exception.getErrorCode()).isEqualTo(AuthErrorCode.INVALID_CREDENTIALS); + + verify(jwtComponent, never()).generateTokenPair(anyString(), anyLong(), anyString()); + } } + } diff --git a/src/test/java/project/flipnote/fixture/UserFixture.java b/src/test/java/project/flipnote/fixture/UserFixture.java new file mode 100644 index 00000000..21000475 --- /dev/null +++ b/src/test/java/project/flipnote/fixture/UserFixture.java @@ -0,0 +1,22 @@ +package project.flipnote.fixture; + +import org.springframework.test.util.ReflectionTestUtils; + +import project.flipnote.user.entity.User; + +public class UserFixture { + + public static final String ENCODED_PASSWORD = "encodedPass"; + public static final String USER_EMAIL = "test@test.com"; + + public static User createActiveUser() { + User user = User.builder() + .email(USER_EMAIL) + .password(ENCODED_PASSWORD) + .build(); + + ReflectionTestUtils.setField(user, "id", 1L); + + return user; + } +}