Skip to content

Commit

Permalink
[feat] 구매자 로그인 (#64)
Browse files Browse the repository at this point in the history
* [chore] CustomerFixture 주석 추가
- CustomerFixture 의 기능과 목적을 설명하는 주석 명시

* [test] SignInCustomerServiceTest 추가
- 로그인 성공, 로그인 실패 - 이메일 없음, 패스워드 불일치 추가

* [feat] SignInCustomerService 구현
- SignInCustomerService.signIn 구현
- 이메일 없음과 패스워드 불일치를 같이 처리하면서 보안 강화

* [feat] SignInCustomerCommand 구현
- 구매자 로그인에 필요한 email, password 필드 추가

* [feat] CustomerRepository.findByEmail 추가
- 구매자 로그인 시 구매자 검색에 필요한 findByEmail 구현

* [feat] AuthenticationException
- 구매자 로그인 실패 시 인증 오류 구현

* [test] CustomerControllerTest.signIn 추가
- CustomerControllerTest.testSignInCustomer: 구매자 로그인 테스트 성공
- CustomerControllerTest.testSignInCustomerFail: 구매자 로그인 테스트 실패

* [feat] CustomerController.signIn 구현
- 구매자 로그인 성공 시 OK 반환
- 구매자 로그인 실패 시 BadRequest 반환

* [feat] ErrorCode.AUTH_INVALID_CREDENTIALS
- 로그인 실패 시 ErrorCode 추가

* [feat] SignInCustomerRequest
- 구매자 로그인을 위한 SignInCustomerRequest 이메일, 패스워드 필드 추가

* [feat] CustomerAuthenticationException 구현
- 구매자 로그인 실패 시 예외 추가

* [feat] CustomerErrorCode.AUTHENTICATION_FAILED 구현
- 로그인 실패 시 에러코드 구현

* [feat] SignInCustomerService.signIn 에러 분기
- 이메일이 존재하지 않는 지 비밀번호가 존재하지 않는 지 서버 시점에서 명확히 구분할 수 있도록 수정

* [feat] UnauthorizedException 생성자 추가
- UnauthorizedException(ErroCode, String) 생성자 추가

* [fix] AuthenticationException 수정에 따른 반영

* [docs] SignInCustomerService 예외처리 명시

* [feat] SignInCustomerService.signIn 반환 값 변경
- 로그인 된 구매자의 UUID 를 전달하도록 수정

* [refactor] SignInCustomerRequest 패키지 변경

* [feat] CustomerRepository.findByEmail 반환값 변경

* [feat] Customer 로그인 인증 주체 변경

* [test] CustomerApiController.login 테스트 추가
- CustomerApiControllerTest.testLoginCustomer: 로그인 성공 시 테스트
- CustomerApiControllerTest.testLoginFail: 로그인 실패 시 테스트

* [feat] CustomerApiController.login 추가
- 로그인 인증 후 session 에 LoginCustomer 저장

* [feat] CustomerExceptionHandler.handleCustomerAuthenticationException 추가
- 로그인 실패 시 발생하는 예외 처리

* [feat] SignInCustomerResponse 구현
- CustomerApiController.login 반환값 추가

* [refactor] CustomerErrorCode Status 처리
- 기존 400 로 관리하던 Status 를 HttpStatus 를 이용해 관리하도록 수정

* [fix] SignInCustomerService.signIn 로직 수정
- 비밀번호가 일치하지 않을 때 예외 던지도록 수정

* [fix] CustomerRepository.findByEmail 반환값 변경에 따른 수정
- 반환값을 Optional 로 랩핑

* [refactor] 중복 코드 추출
- ProblemDetail 를 생성하는 코드가 겹쳐서 private 메소드로 추출
  • Loading branch information
kimhyun5u authored Aug 15, 2024
1 parent 75a532e commit 8ca39de
Show file tree
Hide file tree
Showing 15 changed files with 272 additions and 15 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,8 @@ public class UnauthorizedException extends HttpStatusException {
public UnauthorizedException(ErrorCode errorCode) {
super(errorCode);
}

public UnauthorizedException(ErrorCode errorCode, String message) {
super(errorCode, message);
}
}
4 changes: 4 additions & 0 deletions src/main/java/camp/woowak/lab/customer/domain/Customer.java
Original file line number Diff line number Diff line change
Expand Up @@ -46,4 +46,8 @@ public Customer(String name, String email, String password, String phone, PayAcc
this.phone = phone;
this.payAccount = payAccount;
}

public boolean validatePassword(String password, PasswordEncoder passwordEncoder) {
return passwordEncoder.matches(password, this.password);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package camp.woowak.lab.customer.exception;

import camp.woowak.lab.common.exception.UnauthorizedException;

public class CustomerAuthenticationException extends UnauthorizedException {
public CustomerAuthenticationException(String message) {
super(CustomerErrorCode.AUTHENTICATION_FAILED, message);
}
}
Original file line number Diff line number Diff line change
@@ -1,24 +1,27 @@
package camp.woowak.lab.customer.exception;

import org.springframework.http.HttpStatus;

import camp.woowak.lab.common.exception.ErrorCode;

public enum CustomerErrorCode implements ErrorCode {
INVALID_CREATION(400, "C1", "잘못된 요청입니다."),
DUPLICATE_EMAIL(400, "C2", "이미 존재하는 이메일입니다.");
INVALID_CREATION(HttpStatus.BAD_REQUEST, "C1", "잘못된 요청입니다."),
DUPLICATE_EMAIL(HttpStatus.BAD_REQUEST, "C2", "이미 존재하는 이메일입니다."),
AUTHENTICATION_FAILED(HttpStatus.UNAUTHORIZED, "C3", "이메일 또는 비밀번호가 올바르지 않습니다.");

private final int status;
private final HttpStatus status;
private final String errorCode;
private final String message;

CustomerErrorCode(int status, String errorCode, String message) {
CustomerErrorCode(HttpStatus status, String errorCode, String message) {
this.status = status;
this.errorCode = errorCode;
this.message = message;
}

@Override
public int getStatus() {
return status;
return status.value();
}

@Override
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
package camp.woowak.lab.customer.repository;

import java.util.Optional;

import org.springframework.data.jpa.repository.JpaRepository;

import camp.woowak.lab.customer.domain.Customer;

public interface CustomerRepository extends JpaRepository<Customer, Long> {
Optional<Customer> findByEmail(String email);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package camp.woowak.lab.customer.service;

import java.util.UUID;

import org.springframework.stereotype.Service;

import camp.woowak.lab.customer.domain.Customer;
import camp.woowak.lab.customer.exception.CustomerAuthenticationException;
import camp.woowak.lab.customer.repository.CustomerRepository;
import camp.woowak.lab.customer.service.command.SignInCustomerCommand;
import camp.woowak.lab.web.authentication.PasswordEncoder;

@Service
public class SignInCustomerService {
private final CustomerRepository customerRepository;
private final PasswordEncoder passwordEncoder;

public SignInCustomerService(CustomerRepository customerRepository, PasswordEncoder passwordEncoder) {
this.customerRepository = customerRepository;
this.passwordEncoder = passwordEncoder;
}

/**
* @throws CustomerAuthenticationException 이메일이 존재하지 않거나 비밀번호가 일치하지 않으면
*/
public UUID signIn(SignInCustomerCommand cmd) {
Customer byEmail = customerRepository.findByEmail(cmd.email())
.orElseThrow(() -> new CustomerAuthenticationException("email not found"));
if (!byEmail.validatePassword(cmd.password(), passwordEncoder)) {
throw new CustomerAuthenticationException("password not matched");
}

return byEmail.getId();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package camp.woowak.lab.customer.service.command;

public record SignInCustomerCommand(
String email,
String password
) {
}
Original file line number Diff line number Diff line change
@@ -1,24 +1,36 @@
package camp.woowak.lab.web.api.customer;

import java.util.UUID;

import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;

import camp.woowak.lab.customer.service.SignInCustomerService;
import camp.woowak.lab.customer.service.SignUpCustomerService;
import camp.woowak.lab.customer.service.command.SignInCustomerCommand;
import camp.woowak.lab.customer.service.command.SignUpCustomerCommand;
import camp.woowak.lab.web.authentication.LoginCustomer;
import camp.woowak.lab.web.dto.request.customer.SignInCustomerRequest;
import camp.woowak.lab.web.dto.request.customer.SignUpCustomerRequest;
import camp.woowak.lab.web.dto.response.customer.SignInCustomerResponse;
import camp.woowak.lab.web.dto.response.customer.SignUpCustomerResponse;
import camp.woowak.lab.web.resolver.session.SessionConst;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import jakarta.validation.Valid;

@RestController
public class CustomerApiController {
private final SignUpCustomerService signUpCustomerService;
private final SignInCustomerService signInCustomerService;

public CustomerApiController(SignUpCustomerService signUpCustomerService) {
public CustomerApiController(SignUpCustomerService signUpCustomerService,
SignInCustomerService signInCustomerService) {
this.signUpCustomerService = signUpCustomerService;
this.signInCustomerService = signInCustomerService;
}

@PostMapping("/customers")
Expand All @@ -34,4 +46,16 @@ public SignUpCustomerResponse signUp(@Valid @RequestBody SignUpCustomerRequest r

return new SignUpCustomerResponse(registeredId);
}

@PostMapping("/customers/login")
@ResponseStatus(HttpStatus.NO_CONTENT)
public SignInCustomerResponse login(@RequestBody SignInCustomerRequest request, HttpSession session) {
SignInCustomerCommand command = new SignInCustomerCommand(request.email(), request.password());

UUID customerId = signInCustomerService.signIn(command);

session.setAttribute(SessionConst.SESSION_CUSTOMER_KEY, new LoginCustomer(customerId));

return new SignInCustomerResponse();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@

import camp.woowak.lab.common.advice.DomainExceptionHandler;
import camp.woowak.lab.common.exception.BadRequestException;
import camp.woowak.lab.common.exception.HttpStatusException;
import camp.woowak.lab.customer.exception.CustomerAuthenticationException;
import camp.woowak.lab.customer.exception.DuplicateEmailException;
import camp.woowak.lab.customer.exception.InvalidCreationException;
import lombok.extern.slf4j.Slf4j;
Expand All @@ -22,10 +24,7 @@ public class CustomerExceptionHandler extends ResponseEntityExceptionHandler {
@ExceptionHandler({InvalidCreationException.class})
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ProblemDetail handleBadRequestException(BadRequestException e) {
ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST,
e.errorCode().getErrorCode());
problemDetail.setProperty("errorCode", e.errorCode().getErrorCode());
return problemDetail;
return getProblemDetail(e, HttpStatus.BAD_REQUEST);
}

/**
Expand All @@ -35,8 +34,17 @@ public ProblemDetail handleBadRequestException(BadRequestException e) {
@ExceptionHandler({DuplicateEmailException.class})
@ResponseStatus(HttpStatus.CONFLICT)
public ProblemDetail handleDuplicateEmailException(DuplicateEmailException e) {
ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT,
e.errorCode().getMessage());
return getProblemDetail(e, HttpStatus.CONFLICT);
}

@ExceptionHandler({CustomerAuthenticationException.class})
@ResponseStatus(HttpStatus.UNAUTHORIZED)
public ProblemDetail handleCustomerAuthenticationException(CustomerAuthenticationException e) {
return getProblemDetail(e, HttpStatus.UNAUTHORIZED);
}

private ProblemDetail getProblemDetail(HttpStatusException e, HttpStatus status) {
ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(status, e.errorCode().getMessage());
problemDetail.setProperty("errorCode", e.errorCode().getErrorCode());
return problemDetail;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package camp.woowak.lab.web.dto.request.customer;

/**
* 이메일 비밀번호 조건을 알 수 없도록 모든 요청을 받을 수 있도록 구현
*/
public record SignInCustomerRequest(
String email,
String password
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package camp.woowak.lab.web.dto.response.customer;

public record SignInCustomerResponse() {
}
4 changes: 2 additions & 2 deletions src/main/java/camp/woowak/lab/web/error/ErrorCode.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

public enum ErrorCode {
AUTH_DUPLICATE_EMAIL("a1", "이미 가입된 이메일 입니다."),
SIGNUP_INVALID_REQUEST("s1", "잘못된 요청입니다.");

SIGNUP_INVALID_REQUEST("s1", "잘못된 요청입니다."),
AUTH_INVALID_CREDENTIALS("a2", "이메일 또는 비밀번호가 올바르지 않습니다.");
private final String code;
private final String message;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package camp.woowak.lab.customer.service;

import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.BDDMockito.*;

import java.util.Optional;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

import camp.woowak.lab.customer.domain.Customer;
import camp.woowak.lab.customer.exception.CustomerAuthenticationException;
import camp.woowak.lab.customer.repository.CustomerRepository;
import camp.woowak.lab.customer.service.command.SignInCustomerCommand;
import camp.woowak.lab.fixture.CustomerFixture;
import camp.woowak.lab.payaccount.domain.PayAccount;
import camp.woowak.lab.payaccount.domain.TestPayAccount;
import camp.woowak.lab.web.authentication.PasswordEncoder;

/**
*
*/
@ExtendWith(MockitoExtension.class)
public class SignInCustomerServiceTest implements CustomerFixture {
@InjectMocks
private SignInCustomerService signInCustomerService;

@Mock
private CustomerRepository customerRepository;

@Mock
private PasswordEncoder passwordEncoder;

@Test
@DisplayName("로그인 성공")
void testSignIn() {
// given
PayAccount newPayAccount = new TestPayAccount(1L);
Customer customer = createCustomer(newPayAccount, passwordEncoder);
SignInCustomerCommand cmd = new SignInCustomerCommand(customer.getEmail(), customer.getPassword());
given(customerRepository.findByEmail(customer.getEmail())).willReturn(Optional.of(customer));
given(passwordEncoder.matches(cmd.password(), customer.getPassword())).willReturn(true);

// when & then
assertDoesNotThrow(() -> signInCustomerService.signIn(cmd));
verify(customerRepository).findByEmail(customer.getEmail());
verify(passwordEncoder).matches(cmd.password(), customer.getPassword());
}

@Test
@DisplayName("로그인 실패 - 이메일 없음")
void testSignInFailEmailNotFound() {
// given
PayAccount newPayAccount = new TestPayAccount(1L);
Customer customer = createCustomer(newPayAccount, passwordEncoder);
SignInCustomerCommand cmd = new SignInCustomerCommand("[email protected]", customer.getPassword());
given(customerRepository.findByEmail(cmd.email())).willReturn(Optional.empty());

// when & then
assertThrows(CustomerAuthenticationException.class, () -> signInCustomerService.signIn(cmd));
verify(customerRepository).findByEmail(cmd.email());
}

@Test
@DisplayName("로그인 실패 - 패스워드 불일치")
void testSignInFail() {
// given
PayAccount newPayAccount = new TestPayAccount(1L);
Customer customer = createCustomer(newPayAccount, passwordEncoder);
SignInCustomerCommand cmd = new SignInCustomerCommand(customer.getEmail(), customer.getPassword());
given(customerRepository.findByEmail(customer.getEmail())).willReturn(Optional.of(customer));
given(passwordEncoder.matches(cmd.password(), customer.getPassword())).willReturn(false);

// when & then
assertThrows(CustomerAuthenticationException.class, () -> signInCustomerService.signIn(cmd));
verify(customerRepository).findByEmail(customer.getEmail());
verify(passwordEncoder).matches(cmd.password(), customer.getPassword());
}
}
6 changes: 5 additions & 1 deletion src/test/java/camp/woowak/lab/fixture/CustomerFixture.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,18 @@
import camp.woowak.lab.payaccount.domain.PayAccount;
import camp.woowak.lab.web.authentication.PasswordEncoder;

/**
* CustomerFixture는 Customer와 관련된 테스트에서 공통적으로 사용되는 객체를 생성하는 인터페이스입니다.
*/
public interface CustomerFixture {
default PayAccount createPayAccount() {
return new PayAccount();
}

default Customer createCustomer(PayAccount payAccount, PasswordEncoder passwordEncoder) {
try {
return new Customer("vendorName", "[email protected]", "vendorPassword", "010-0000-0000", payAccount,
return new Customer("customerName", "[email protected]", "customerPassword", "010-0000-0000",
payAccount,
passwordEncoder);
} catch (InvalidCreationException e) {
throw new RuntimeException(e);
Expand Down
Loading

0 comments on commit 8ca39de

Please sign in to comment.