diff --git a/build.gradle b/build.gradle index a1b9038..603bf06 100644 --- a/build.gradle +++ b/build.gradle @@ -34,10 +34,10 @@ repositories { } dependencies { -// implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.7.0' + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.7.0' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-data-redis' -// implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-web' compileOnly 'org.projectlombok:lombok' diff --git a/src/main/java/dmu/dasom/api/ApiApplication.java b/src/main/java/dmu/dasom/api/ApiApplication.java index 0da72a1..721665e 100644 --- a/src/main/java/dmu/dasom/api/ApiApplication.java +++ b/src/main/java/dmu/dasom/api/ApiApplication.java @@ -2,7 +2,9 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +@EnableJpaAuditing @SpringBootApplication public class ApiApplication { diff --git a/src/main/java/dmu/dasom/api/domain/common/BaseEntity.java b/src/main/java/dmu/dasom/api/domain/common/BaseEntity.java new file mode 100644 index 0000000..66a9e98 --- /dev/null +++ b/src/main/java/dmu/dasom/api/domain/common/BaseEntity.java @@ -0,0 +1,36 @@ +package dmu.dasom.api.domain.common; + +import jakarta.persistence.*; +import lombok.Getter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@EntityListeners(AuditingEntityListener.class) +@Getter +@MappedSuperclass +public class BaseEntity { + + // 생성일 + @CreatedDate + @Column(name = "created_at", updatable = false, nullable = false) + private LocalDateTime createdAt; + + // 수정일 + @LastModifiedDate + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + // 상태 + @Column(name = "status", length = 16, nullable = false) + @Enumerated(EnumType.STRING) + private Status status = Status.ACTIVE; + + // 상태 변경 + public void updateStatus(final Status status) { + this.status = status; + } + +} diff --git a/src/main/java/dmu/dasom/api/domain/common/Status.java b/src/main/java/dmu/dasom/api/domain/common/Status.java new file mode 100644 index 0000000..f81dda6 --- /dev/null +++ b/src/main/java/dmu/dasom/api/domain/common/Status.java @@ -0,0 +1,7 @@ +package dmu.dasom.api.domain.common; + +public enum Status { + ACTIVE, + INACTIVE, + DELETED +} diff --git a/src/main/java/dmu/dasom/api/domain/common/exception/CustomControllerAdvice.java b/src/main/java/dmu/dasom/api/domain/common/exception/CustomControllerAdvice.java index 8d603de..4864a58 100644 --- a/src/main/java/dmu/dasom/api/domain/common/exception/CustomControllerAdvice.java +++ b/src/main/java/dmu/dasom/api/domain/common/exception/CustomControllerAdvice.java @@ -1,5 +1,8 @@ package dmu.dasom.api.domain.common.exception; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; @@ -7,8 +10,13 @@ public class CustomControllerAdvice { @ExceptionHandler(CustomException.class) - public ErrorResponse customException(final CustomException e) { - return new ErrorResponse(e.getErrorCode().getCode(), e.getErrorCode().getMessage()); + public ResponseEntity customException(final CustomException e) { + return ResponseEntity.status(e.getErrorCode().getStatus()).body(new ErrorResponse(e.getErrorCode())); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity methodArgumentNotValidException(final MethodArgumentNotValidException e) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(new ErrorResponse(ErrorCode.ARGUMENT_NOT_VALID)); } } diff --git a/src/main/java/dmu/dasom/api/domain/common/exception/CustomErrorController.java b/src/main/java/dmu/dasom/api/domain/common/exception/CustomErrorController.java new file mode 100644 index 0000000..dee586f --- /dev/null +++ b/src/main/java/dmu/dasom/api/domain/common/exception/CustomErrorController.java @@ -0,0 +1,28 @@ +package dmu.dasom.api.domain.common.exception; + +import jakarta.servlet.RequestDispatcher; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.boot.web.servlet.error.ErrorController; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class CustomErrorController implements ErrorController { + + @RequestMapping("/error") + public ResponseEntity handleError(final HttpServletRequest request) { + // 에러 코드 추출 + final Integer statusCode = (Integer) request.getAttribute(RequestDispatcher.ERROR_STATUS_CODE); + final HttpStatus status = statusCode != null ? HttpStatus.valueOf(statusCode) : HttpStatus.INTERNAL_SERVER_ERROR; + + // 404 에러 + if (status == HttpStatus.NOT_FOUND) + return ResponseEntity.status(status).body(new ErrorResponse(ErrorCode.NOT_FOUND)); + + // 기타 에러 + return ResponseEntity.status(status).body(new ErrorResponse(ErrorCode.INTERNAL_SERVER_ERROR)); + } + +} diff --git a/src/main/java/dmu/dasom/api/domain/common/exception/ErrorCode.java b/src/main/java/dmu/dasom/api/domain/common/exception/ErrorCode.java index fdf225c..ac0a036 100644 --- a/src/main/java/dmu/dasom/api/domain/common/exception/ErrorCode.java +++ b/src/main/java/dmu/dasom/api/domain/common/exception/ErrorCode.java @@ -9,6 +9,14 @@ public enum ErrorCode { UNAUTHORIZED(401, "C001", "인증되지 않은 사용자입니다."), FORBIDDEN(403, "C002", "권한이 없습니다."), + MEMBER_NOT_FOUND(400, "C003", "해당 회원을 찾을 수 없습니다."), + TOKEN_EXPIRED(400, "C004", "토큰이 만료되었습니다."), + LOGIN_FAILED(400, "C005", "로그인에 실패하였습니다."), + SIGNUP_FAILED(400, "C006", "회원가입에 실패하였습니다."), + ARGUMENT_NOT_VALID(400, "C007", "요청한 값이 올바르지 않습니다."), + TOKEN_NOT_VALID(400, "C008", "토큰이 올바르지 않습니다."), + INTERNAL_SERVER_ERROR(500, "C009", "서버에 문제가 발생하였습니다."), + NOT_FOUND(404, "C010", "해당 리소스를 찾을 수 없습니다.") ; private final int status; diff --git a/src/main/java/dmu/dasom/api/domain/common/exception/ErrorResponse.java b/src/main/java/dmu/dasom/api/domain/common/exception/ErrorResponse.java index 08c7333..3854b3a 100644 --- a/src/main/java/dmu/dasom/api/domain/common/exception/ErrorResponse.java +++ b/src/main/java/dmu/dasom/api/domain/common/exception/ErrorResponse.java @@ -1,11 +1,9 @@ package dmu.dasom.api.domain.common.exception; import lombok.AccessLevel; -import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; -@AllArgsConstructor @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) public class ErrorResponse { @@ -13,4 +11,9 @@ public class ErrorResponse { private String code; private String message; + public ErrorResponse(final ErrorCode errorCode) { + this.code = errorCode.getCode(); + this.message = errorCode.getMessage(); + } + } diff --git a/src/main/java/dmu/dasom/api/domain/member/controller/MemberController.java b/src/main/java/dmu/dasom/api/domain/member/controller/MemberController.java new file mode 100644 index 0000000..fa7240a --- /dev/null +++ b/src/main/java/dmu/dasom/api/domain/member/controller/MemberController.java @@ -0,0 +1,54 @@ +package dmu.dasom.api.domain.member.controller; + +import dmu.dasom.api.domain.common.exception.ErrorResponse; +import dmu.dasom.api.domain.member.dto.SignupRequestDto; +import dmu.dasom.api.domain.member.service.MemberService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +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; + +@RestController +@RequestMapping("/api") +@RequiredArgsConstructor +public class MemberController { + + private final MemberService memberService; + + // 회원가입 + @Operation(summary = "회원가입") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "회원가입 성공"), + @ApiResponse(responseCode = "400", description = "실패 케이스", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = { + @ExampleObject( + name = "이메일 중복", + value = "{ \"code\": \"C006\", \"message\": \"회원가입에 실패하였습니다.\" }" + ), + @ExampleObject( + name = "이메일 또는 비밀번호 형식 올바르지 않음", + value = "{ \"code\": \"C007\", \"message\": \"요청한 값이 올바르지 않습니다.\" }" + ) + } + ) + ) + }) + @PostMapping("/auth/signup") + public ResponseEntity signUp(@Valid @RequestBody final SignupRequestDto request) { + memberService.signUp(request); + return ResponseEntity.ok().build(); + } + +} diff --git a/src/main/java/dmu/dasom/api/domain/member/dto/SignupRequestDto.java b/src/main/java/dmu/dasom/api/domain/member/dto/SignupRequestDto.java new file mode 100644 index 0000000..62b395e --- /dev/null +++ b/src/main/java/dmu/dasom/api/domain/member/dto/SignupRequestDto.java @@ -0,0 +1,31 @@ +package dmu.dasom.api.domain.member.dto; + +import dmu.dasom.api.domain.member.entity.Member; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import org.hibernate.validator.constraints.Length; + +@Getter +@Schema(name = "SignupRequestDto", description = "회원가입 요청 DTO") +public class SignupRequestDto { + + @Email + @Length(max = 64) + @NotNull + @Schema(description = "이메일", example = "test@example.com", maxLength = 64) + private String email; + + @Length(min = 8, max = 128) + @NotNull + @Schema(description = "비밀번호", example = "password", minLength = 8, maxLength = 128) + private String password; + + public Member toEntity(final String password) { + return Member.builder() + .email(this.email) + .password(password) + .build(); + } +} diff --git a/src/main/java/dmu/dasom/api/domain/member/entity/Member.java b/src/main/java/dmu/dasom/api/domain/member/entity/Member.java new file mode 100644 index 0000000..b2c6c9a --- /dev/null +++ b/src/main/java/dmu/dasom/api/domain/member/entity/Member.java @@ -0,0 +1,36 @@ +package dmu.dasom.api.domain.member.entity; + +import dmu.dasom.api.domain.common.BaseEntity; +import dmu.dasom.api.domain.member.enums.Role; +import jakarta.persistence.*; +import jakarta.validation.constraints.Email; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@AllArgsConstructor +@Builder +@Entity +@Getter +@NoArgsConstructor +public class Member extends BaseEntity { + + @Column(name = "id") + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Id + private Long id; + + @Column(name = "email", unique = true, length = 64, nullable = false) + @Email + private String email; + + @Column(name = "password", length = 128, nullable = false) + private String password; + + @Builder.Default + @Column(name = "role", length = 16, nullable = false) + @Enumerated(EnumType.STRING) + private Role role = Role.ROLE_MEMBER; + +} diff --git a/src/main/java/dmu/dasom/api/domain/member/enums/Role.java b/src/main/java/dmu/dasom/api/domain/member/enums/Role.java new file mode 100644 index 0000000..ad609b7 --- /dev/null +++ b/src/main/java/dmu/dasom/api/domain/member/enums/Role.java @@ -0,0 +1,16 @@ +package dmu.dasom.api.domain.member.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor +@Getter +public enum Role { + + ROLE_MEMBER("MEMBER"), + ROLE_ADMIN("ADMIN"), + ; + + private final String name; + +} diff --git a/src/main/java/dmu/dasom/api/domain/member/repository/MemberRepository.java b/src/main/java/dmu/dasom/api/domain/member/repository/MemberRepository.java new file mode 100644 index 0000000..96e01fc --- /dev/null +++ b/src/main/java/dmu/dasom/api/domain/member/repository/MemberRepository.java @@ -0,0 +1,14 @@ +package dmu.dasom.api.domain.member.repository; + +import dmu.dasom.api.domain.member.entity.Member; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface MemberRepository extends JpaRepository { + + Optional findByEmail(final String email); + + boolean existsByEmail(final String email); + +} diff --git a/src/main/java/dmu/dasom/api/domain/member/service/MemberService.java b/src/main/java/dmu/dasom/api/domain/member/service/MemberService.java new file mode 100644 index 0000000..411d463 --- /dev/null +++ b/src/main/java/dmu/dasom/api/domain/member/service/MemberService.java @@ -0,0 +1,14 @@ +package dmu.dasom.api.domain.member.service; + +import dmu.dasom.api.domain.member.dto.SignupRequestDto; +import dmu.dasom.api.domain.member.entity.Member; + +public interface MemberService { + + Member getMemberByEmail(final String email); + + boolean checkByEmail(final String email); + + void signUp(final SignupRequestDto request); + +} diff --git a/src/main/java/dmu/dasom/api/domain/member/service/MemberServiceImpl.java b/src/main/java/dmu/dasom/api/domain/member/service/MemberServiceImpl.java new file mode 100644 index 0000000..b4e26fc --- /dev/null +++ b/src/main/java/dmu/dasom/api/domain/member/service/MemberServiceImpl.java @@ -0,0 +1,45 @@ +package dmu.dasom.api.domain.member.service; + +import dmu.dasom.api.domain.common.exception.CustomException; +import dmu.dasom.api.domain.common.exception.ErrorCode; +import dmu.dasom.api.domain.member.dto.SignupRequestDto; +import dmu.dasom.api.domain.member.entity.Member; +import dmu.dasom.api.domain.member.repository.MemberRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Service +@Transactional(readOnly = true) +public class MemberServiceImpl implements MemberService { + + private final BCryptPasswordEncoder encoder; + private final MemberRepository memberRepository; + + // 이메일로 사용자 조회 + @Override + public Member getMemberByEmail(final String email) { + return memberRepository.findByEmail(email) + .orElseThrow(() -> new CustomException(ErrorCode.MEMBER_NOT_FOUND)); + } + + // 이메일 확인 + @Override + public boolean checkByEmail(final String email) { + return memberRepository.existsByEmail(email); + } + + // 회원가입 + @Override + public void signUp(final SignupRequestDto request) { + // 이미 가입된 이메일인지 확인 + if (checkByEmail(request.getEmail())) + throw new CustomException(ErrorCode.SIGNUP_FAILED); + + // 비밀번호 암호화 후 저장 + memberRepository.save(request.toEntity(encoder.encode(request.getPassword()))); + } + +} diff --git a/src/main/java/dmu/dasom/api/global/auth/config/EncoderConfig.java b/src/main/java/dmu/dasom/api/global/auth/config/EncoderConfig.java new file mode 100644 index 0000000..9c675bc --- /dev/null +++ b/src/main/java/dmu/dasom/api/global/auth/config/EncoderConfig.java @@ -0,0 +1,15 @@ +package dmu.dasom.api.global.auth.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; + +@Configuration +public class EncoderConfig { + + @Bean + public BCryptPasswordEncoder bCryptPasswordEncoder() { + return new BCryptPasswordEncoder(); + } + +} diff --git a/src/main/java/dmu/dasom/api/global/auth/config/SecurityConfig.java b/src/main/java/dmu/dasom/api/global/auth/config/SecurityConfig.java new file mode 100644 index 0000000..f18463a --- /dev/null +++ b/src/main/java/dmu/dasom/api/global/auth/config/SecurityConfig.java @@ -0,0 +1,63 @@ +package dmu.dasom.api.global.auth.config; + +import dmu.dasom.api.domain.member.enums.Role; +import dmu.dasom.api.global.auth.filter.CustomAuthenticationFilter; +import dmu.dasom.api.global.auth.filter.CustomLogoutFilter; +import dmu.dasom.api.global.auth.filter.JwtFilter; +import dmu.dasom.api.global.auth.handler.AccessDeniedHandlerImpl; +import dmu.dasom.api.global.auth.handler.AuthenticationEntryPointImpl; +import dmu.dasom.api.global.auth.jwt.JwtUtil; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final AccessDeniedHandlerImpl accessDeniedHandler; + private final AuthenticationEntryPointImpl authenticationEntryPoint; + private final JwtFilter jwtFilter; + private final JwtUtil jwtUtil; + + @Bean + public AuthenticationManager authenticationManager(final AuthenticationConfiguration configuration) throws Exception { + return configuration.getAuthenticationManager(); + } + + @Bean + public SecurityFilterChain filterChain(final HttpSecurity http, final AuthenticationManager authenticationManager) throws Exception { + final CustomAuthenticationFilter customAuthenticationFilter = new CustomAuthenticationFilter(authenticationManager, jwtUtil); + customAuthenticationFilter.setFilterProcessesUrl("/api/auth/login"); + + return http + .csrf(AbstractHttpConfigurer::disable) + .cors(AbstractHttpConfigurer::disable) + .formLogin(AbstractHttpConfigurer::disable) + .httpBasic(AbstractHttpConfigurer::disable) + .logout(AbstractHttpConfigurer::disable) + .sessionManagement(session -> session + .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth + .requestMatchers("/api/admin/**").hasRole(Role.ROLE_ADMIN.getName()) + .requestMatchers("/api/auth/logout").authenticated() + .anyRequest().permitAll()) + .addFilterBefore(jwtFilter, CustomAuthenticationFilter.class) + .addFilterAt(customAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) + .addFilterAfter(new CustomLogoutFilter(jwtUtil), JwtFilter.class) + .exceptionHandling(handler -> handler + .accessDeniedHandler(accessDeniedHandler) + .authenticationEntryPoint(authenticationEntryPoint)) + .build(); + } + +} diff --git a/src/main/java/dmu/dasom/api/global/auth/dto/LoginRequestDto.java b/src/main/java/dmu/dasom/api/global/auth/dto/LoginRequestDto.java new file mode 100644 index 0000000..bb2f228 --- /dev/null +++ b/src/main/java/dmu/dasom/api/global/auth/dto/LoginRequestDto.java @@ -0,0 +1,21 @@ +package dmu.dasom.api.global.auth.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; + +@Getter +@Schema(description = "로그인 요청 DTO") +public class LoginRequestDto { + + @Email + @NotNull + @Schema(description = "이메일", example = "test@example.com") + private String email; + + @NotNull + @Schema(description = "비밀번호", example = "password") + private String password; + +} diff --git a/src/main/java/dmu/dasom/api/global/auth/dto/TokenBox.java b/src/main/java/dmu/dasom/api/global/auth/dto/TokenBox.java new file mode 100644 index 0000000..7ebf7b3 --- /dev/null +++ b/src/main/java/dmu/dasom/api/global/auth/dto/TokenBox.java @@ -0,0 +1,22 @@ +package dmu.dasom.api.global.auth.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.*; + +@AllArgsConstructor +@Builder +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Schema(description = "토큰 BOX") +public class TokenBox { + + @Schema(description = "AccessToken") + @NotNull + private String accessToken; + + @Schema(description = "RefreshToken") + @NotNull + private String refreshToken; + +} diff --git a/src/main/java/dmu/dasom/api/global/auth/filter/CustomAuthenticationFilter.java b/src/main/java/dmu/dasom/api/global/auth/filter/CustomAuthenticationFilter.java new file mode 100644 index 0000000..1338812 --- /dev/null +++ b/src/main/java/dmu/dasom/api/global/auth/filter/CustomAuthenticationFilter.java @@ -0,0 +1,67 @@ +package dmu.dasom.api.global.auth.filter; + +import com.fasterxml.jackson.databind.ObjectMapper; +import dmu.dasom.api.domain.common.exception.ErrorCode; +import dmu.dasom.api.domain.common.exception.ErrorResponse; +import dmu.dasom.api.global.auth.dto.LoginRequestDto; +import dmu.dasom.api.global.auth.dto.TokenBox; +import dmu.dasom.api.global.auth.jwt.JwtUtil; +import jakarta.servlet.FilterChain; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.InternalAuthenticationServiceException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +import java.io.IOException; + +@RequiredArgsConstructor +public class CustomAuthenticationFilter extends UsernamePasswordAuthenticationFilter { + + private final AuthenticationManager authenticationManager; + private final JwtUtil jwtUtil; + + @Override + public Authentication attemptAuthentication(final HttpServletRequest request, final HttpServletResponse response) throws AuthenticationException { + try { + final ObjectMapper objectMapper = new ObjectMapper(); + + // 로그인 요청 정보를 파싱 + final LoginRequestDto loginRequestDto = objectMapper.readValue(request.getInputStream(), LoginRequestDto.class); + + // 로그인 요청 정보로 인증 시도 + return authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(loginRequestDto.getEmail(), loginRequestDto.getPassword())); + } catch (InternalAuthenticationServiceException | IOException e) { // 인증 과정에서 내부 오류 발생 시 (ex. 사용자 정보 없음) + throw new AuthenticationException("Authentication Failed.", e) {}; + } + } + + @Override + protected void successfulAuthentication(final HttpServletRequest request, final HttpServletResponse response, final FilterChain chain, final Authentication authResult) { + // 기존 토큰 만료 처리 + jwtUtil.blacklistTokens(authResult.getName()); + + // 토큰 생성 + final TokenBox tokenBox = jwtUtil.generateTokenBox(authResult.getName()); + + response.setStatus(HttpStatus.OK.value()); + response.setHeader("Access-Token", tokenBox.getAccessToken()); + response.setHeader("Refresh-Token", tokenBox.getRefreshToken()); + } + + @Override + protected void unsuccessfulAuthentication(final HttpServletRequest request, final HttpServletResponse response, final AuthenticationException failed) throws IOException { + // 로그인 실패 응답 + response.setStatus(HttpStatus.BAD_REQUEST.value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding("UTF-8"); + response.getWriter().write(new ObjectMapper().writeValueAsString(new ErrorResponse(ErrorCode.LOGIN_FAILED))); + } + +} diff --git a/src/main/java/dmu/dasom/api/global/auth/filter/CustomLogoutFilter.java b/src/main/java/dmu/dasom/api/global/auth/filter/CustomLogoutFilter.java new file mode 100644 index 0000000..2a612ae --- /dev/null +++ b/src/main/java/dmu/dasom/api/global/auth/filter/CustomLogoutFilter.java @@ -0,0 +1,38 @@ +package dmu.dasom.api.global.auth.filter; + +import dmu.dasom.api.global.auth.jwt.JwtUtil; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@Component +@RequiredArgsConstructor +public class CustomLogoutFilter extends OncePerRequestFilter { + + private static final String LOGOUT_URI = "/api/auth/logout"; + + private final JwtUtil jwtUtil; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + // 로그아웃 요청 검증 + if (request.getRequestURI().equals(LOGOUT_URI) && request.getMethod().equals(HttpMethod.POST.name()) && SecurityContextHolder.getContext().getAuthentication() != null) { + // 로그아웃 요청 시 토큰 만료 처리 + jwtUtil.blacklistTokens(SecurityContextHolder.getContext().getAuthentication().getName()); + response.setStatus(HttpStatus.OK.value()); + return; + } + + filterChain.doFilter(request, response); + } + +} diff --git a/src/main/java/dmu/dasom/api/global/auth/filter/JwtFilter.java b/src/main/java/dmu/dasom/api/global/auth/filter/JwtFilter.java new file mode 100644 index 0000000..faea7b0 --- /dev/null +++ b/src/main/java/dmu/dasom/api/global/auth/filter/JwtFilter.java @@ -0,0 +1,70 @@ +package dmu.dasom.api.global.auth.filter; + +import com.fasterxml.jackson.databind.ObjectMapper; +import dmu.dasom.api.domain.common.exception.CustomException; +import dmu.dasom.api.domain.common.exception.ErrorCode; +import dmu.dasom.api.domain.common.exception.ErrorResponse; +import dmu.dasom.api.global.auth.jwt.JwtUtil; +import dmu.dasom.api.global.auth.userdetails.UserDetailsServiceImpl; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@Component +@RequiredArgsConstructor +public class JwtFilter extends OncePerRequestFilter { + + private static final String HEADER_PREFIX = "Authorization"; + private static final String TOKEN_PREFIX = "Bearer "; + + private final UserDetailsServiceImpl userDetailsService; + private final JwtUtil jwtUtil; + + @Override + protected void doFilterInternal(final HttpServletRequest request, final HttpServletResponse response, final FilterChain filterChain) throws ServletException, IOException { + // 헤더에서 토큰 추출 + final String authHeaderValue = request.getHeader(HEADER_PREFIX); + + // 토큰이 없거나 Bearer 토큰이 아닌 경우 + if (authHeaderValue == null || !authHeaderValue.startsWith(TOKEN_PREFIX)) { + filterChain.doFilter(request, response); + return; + } + + final String token = jwtUtil.parseToken(authHeaderValue); + + // 토큰이 블랙리스트에 등록되어 있거나 만료된 경우 + if (jwtUtil.isBlacklisted(token) || jwtUtil.isExpired(token)) { + response.setStatus(ErrorCode.TOKEN_EXPIRED.getStatus()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding("UTF-8"); + response.getWriter().write(new ObjectMapper().writeValueAsString(new ErrorResponse(ErrorCode.TOKEN_EXPIRED))); + return; + } + + // 토큰이 유효한 경우 SecurityContext에 인증 정보 저장 + try { + UserDetails userDetails = userDetailsService.loadUserByUsername(jwtUtil.parseClaims(token).getSubject()); + UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities()); + SecurityContextHolder.getContext().setAuthentication(authentication); + } catch (CustomException e) { + response.setStatus(e.getErrorCode().getStatus()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding("UTF-8"); + response.getWriter().write(new ObjectMapper().writeValueAsString(new ErrorResponse(e.getErrorCode()))); + return; + } + + filterChain.doFilter(request, response); + } +} diff --git a/src/main/java/dmu/dasom/api/global/auth/handler/AccessDeniedHandlerImpl.java b/src/main/java/dmu/dasom/api/global/auth/handler/AccessDeniedHandlerImpl.java new file mode 100644 index 0000000..217c369 --- /dev/null +++ b/src/main/java/dmu/dasom/api/global/auth/handler/AccessDeniedHandlerImpl.java @@ -0,0 +1,33 @@ +package dmu.dasom.api.global.auth.handler; + +import com.fasterxml.jackson.databind.ObjectMapper; +import dmu.dasom.api.domain.common.exception.ErrorCode; +import dmu.dasom.api.domain.common.exception.ErrorResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +public class AccessDeniedHandlerImpl implements AccessDeniedHandler { + + private final ObjectMapper objectMapper; + + public AccessDeniedHandlerImpl(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + @Override + public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException { + response.setStatus(HttpStatus.FORBIDDEN.value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding("UTF-8"); + response.getWriter().write(objectMapper.writeValueAsString(new ErrorResponse(ErrorCode.FORBIDDEN))); + } + +} diff --git a/src/main/java/dmu/dasom/api/global/auth/handler/AuthenticationEntryPointImpl.java b/src/main/java/dmu/dasom/api/global/auth/handler/AuthenticationEntryPointImpl.java new file mode 100644 index 0000000..ae15149 --- /dev/null +++ b/src/main/java/dmu/dasom/api/global/auth/handler/AuthenticationEntryPointImpl.java @@ -0,0 +1,33 @@ +package dmu.dasom.api.global.auth.handler; + +import com.fasterxml.jackson.databind.ObjectMapper; +import dmu.dasom.api.domain.common.exception.ErrorCode; +import dmu.dasom.api.domain.common.exception.ErrorResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +public class AuthenticationEntryPointImpl implements AuthenticationEntryPoint { + + private final ObjectMapper objectMapper; + + public AuthenticationEntryPointImpl(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException { + response.setStatus(HttpStatus.UNAUTHORIZED.value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding("UTF-8"); + response.getWriter().write(objectMapper.writeValueAsString(new ErrorResponse(ErrorCode.UNAUTHORIZED))); + } + +} diff --git a/src/main/java/dmu/dasom/api/global/auth/jwt/JwtUtil.java b/src/main/java/dmu/dasom/api/global/auth/jwt/JwtUtil.java new file mode 100644 index 0000000..bdac27d --- /dev/null +++ b/src/main/java/dmu/dasom/api/global/auth/jwt/JwtUtil.java @@ -0,0 +1,133 @@ +package dmu.dasom.api.global.auth.jwt; + +import dmu.dasom.api.domain.common.exception.CustomException; +import dmu.dasom.api.domain.common.exception.ErrorCode; +import dmu.dasom.api.global.auth.dto.TokenBox; +import io.jsonwebtoken.*; +import io.jsonwebtoken.security.SignatureException; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.util.Date; +import java.util.concurrent.TimeUnit; + +@Component +public class JwtUtil { + + @Value("${jwt.access-token-expiration}") + private long accessTokenExpiration; + + @Value("${jwt.refresh-token-expiration}") + private long refreshTokenExpiration; + + private static final String ACCESS_TOKEN_PREFIX = "ACCESS_"; + private static final String REFRESH_TOKEN_PREFIX = "REFRESH_"; + private static final String BLACKLIST_PREFIX = "BLACKLIST_"; + + private final SecretKey secretKey; + private final StringRedisTemplate redisTemplate; + + public JwtUtil(@Value("${jwt.secret}") final String secretKey, final StringRedisTemplate redisTemplate) { + this.secretKey = new SecretKeySpec(secretKey.getBytes(StandardCharsets.UTF_8), Jwts.SIG.HS256.key().build().getAlgorithm()); + this.redisTemplate = redisTemplate; + } + + // Access, Refresh 토큰 생성 + public TokenBox generateTokenBox(final String email) { + final TokenBox tokenBox = TokenBox.builder() + .accessToken(generateToken(email, accessTokenExpiration)) + .refreshToken(generateToken(email, refreshTokenExpiration)) + .build(); + + saveTokens(tokenBox, email); + + return tokenBox; + } + + // 토큰 생성 + public String generateToken(final String email, final long expirationMs) { + return Jwts.builder() + .issuer("dmu-dasom.or.kr") + .subject(email) + .issuedAt(new Date(System.currentTimeMillis())) + .expiration(new Date(System.currentTimeMillis() + expirationMs)) + .signWith(this.secretKey) + .compact(); + } + + // 토큰 저장 + public void saveTokens(final TokenBox tokenBox, final String email) { + redisTemplate.opsForValue().set(ACCESS_TOKEN_PREFIX.concat(email), tokenBox.getAccessToken(), accessTokenExpiration, TimeUnit.MILLISECONDS); + redisTemplate.opsForValue().set(REFRESH_TOKEN_PREFIX.concat(email), tokenBox.getRefreshToken(), refreshTokenExpiration, TimeUnit.MILLISECONDS); + } + + // Request 헤더에서 토큰 추출 + public String parseToken(final String authHeaderValue) { + return authHeaderValue.split(" ")[1]; + } + + // 토큰으로부터 Claims 추출 + public Claims parseClaims(final String token) { + try { + return Jwts.parser() + .verifyWith(this.secretKey) + .build() + .parseSignedClaims(token) + .getPayload(); + } catch (SecurityException | MalformedJwtException | SignatureException | + UnsupportedJwtException | IllegalArgumentException | ClaimJwtException e) { + throw new CustomException(ErrorCode.TOKEN_NOT_VALID); + } + } + + // 토큰의 남은 수명 반환 + public long getRemainingTokenExpiration(final String token) { + return parseClaims(token).getExpiration().getTime() - System.currentTimeMillis(); + } + + // Access, Refresh 토큰 블랙리스트 추가 + public void blacklistTokens(final String email) { + final String accessTokenKey = ACCESS_TOKEN_PREFIX.concat(email); + final String refreshTokenKey = REFRESH_TOKEN_PREFIX.concat(email); + + if (redisTemplate.hasKey(accessTokenKey)) { + blacklistToken(redisTemplate.opsForValue().get(accessTokenKey)); + redisTemplate.delete(accessTokenKey); + } + + if (redisTemplate.hasKey(refreshTokenKey)) { + blacklistToken(redisTemplate.opsForValue().get(refreshTokenKey)); + redisTemplate.delete(refreshTokenKey); + } + } + + // 토큰 블랙리스트 추가 + public void blacklistToken(final String token) { + redisTemplate.opsForValue().set(BLACKLIST_PREFIX.concat(token), token, getRemainingTokenExpiration(token)); + } + + // 토큰 블랙리스트 확인 + public boolean isBlacklisted(final String token) { + return redisTemplate.hasKey(BLACKLIST_PREFIX.concat(token)); + } + + // 토큰 만료 여부 확인 + public boolean isExpired(final String token) { + try { + return Jwts.parser() + .verifyWith(this.secretKey) + .build() + .parseSignedClaims(token) + .getPayload() + .getExpiration() + .before(new Date()); + } catch (ExpiredJwtException ex) { + return true; + } + } + +} diff --git a/src/main/java/dmu/dasom/api/global/auth/userdetails/UserDetailsImpl.java b/src/main/java/dmu/dasom/api/global/auth/userdetails/UserDetailsImpl.java new file mode 100644 index 0000000..90a4743 --- /dev/null +++ b/src/main/java/dmu/dasom/api/global/auth/userdetails/UserDetailsImpl.java @@ -0,0 +1,51 @@ +package dmu.dasom.api.global.auth.userdetails; + +import dmu.dasom.api.domain.common.Status; +import dmu.dasom.api.domain.member.entity.Member; +import lombok.AllArgsConstructor; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.Collection; +import java.util.List; + +@AllArgsConstructor +public class UserDetailsImpl implements UserDetails { + + private final Member member; + + @Override + public boolean isAccountNonExpired() { + return !this.member.getStatus().equals(Status.DELETED); + } + + @Override + public boolean isAccountNonLocked() { + return !this.member.getStatus().equals(Status.INACTIVE); + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @Override + public boolean isEnabled() { + return this.member.getStatus().equals(Status.ACTIVE); + } + + @Override + public Collection getAuthorities() { + return List.of((GrantedAuthority) () -> member.getRole().toString()); + } + + @Override + public String getPassword() { + return this.member.getPassword(); + } + + @Override + public String getUsername() { + return this.member.getEmail(); + } +} diff --git a/src/main/java/dmu/dasom/api/global/auth/userdetails/UserDetailsServiceImpl.java b/src/main/java/dmu/dasom/api/global/auth/userdetails/UserDetailsServiceImpl.java new file mode 100644 index 0000000..fb88697 --- /dev/null +++ b/src/main/java/dmu/dasom/api/global/auth/userdetails/UserDetailsServiceImpl.java @@ -0,0 +1,21 @@ +package dmu.dasom.api.global.auth.userdetails; + +import dmu.dasom.api.domain.member.service.MemberService; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class UserDetailsServiceImpl implements UserDetailsService { + + private final MemberService memberService; + + @Override + public UserDetails loadUserByUsername(final String username) throws UsernameNotFoundException { + return new UserDetailsImpl(memberService.getMemberByEmail(username)); + } + +} diff --git a/src/main/java/dmu/dasom/api/global/swagger/SwaggerConfig.java b/src/main/java/dmu/dasom/api/global/swagger/SwaggerConfig.java new file mode 100644 index 0000000..40b0e57 --- /dev/null +++ b/src/main/java/dmu/dasom/api/global/swagger/SwaggerConfig.java @@ -0,0 +1,33 @@ +package dmu.dasom.api.global.swagger; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class SwaggerConfig { + + @Bean + public OpenAPI customOpenAPI() { + SecurityScheme jwtAuthScheme = new SecurityScheme() + .name("Authorization") + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT"); + + SecurityRequirement securityRequirement = new SecurityRequirement().addList("bearerAuth"); + + return new OpenAPI() + .info(new Info() + .title("DASOM web API") + .version("v1.0.0")) + .components(new Components() + .addSecuritySchemes("bearerAuth", jwtAuthScheme)) + .addSecurityItem(securityRequirement); + } + +} diff --git a/src/main/resources/application-credentials.yml b/src/main/resources/application-credentials.yml index 4eb2020..b63ae1a 100644 --- a/src/main/resources/application-credentials.yml +++ b/src/main/resources/application-credentials.yml @@ -11,3 +11,11 @@ spring: properties: hibernate: dialect: org.hibernate.dialect.OracleDialect + data: + redis: + host: ${REDIS_HOST} + port: ${REDIS_PORT} +jwt: + secret: ${JWT_SECRET} + access-token-expiration: ${JWT_ACCESS_TOKEN_EXPIRATION} + refresh-token-expiration: ${JWT_REFRESH_TOKEN_EXPIRATION} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 9c92956..033ce89 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -3,3 +3,8 @@ server: spring: application: name: dasom-api +springdoc: + swagger-ui: + path: /api/swagger-ui + api-docs: + path: /api/v1/api-docs diff --git a/src/test/java/dmu/dasom/api/ApiApplicationTests.java b/src/test/java/dmu/dasom/api/ApiApplicationTests.java index 3b5843f..c4719d6 100644 --- a/src/test/java/dmu/dasom/api/ApiApplicationTests.java +++ b/src/test/java/dmu/dasom/api/ApiApplicationTests.java @@ -3,7 +3,7 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; -@SpringBootTest +@SpringBootTest(classes = ApiApplicationTests.class) class ApiApplicationTests { @Test diff --git a/src/test/java/dmu/dasom/api/domain/member/MemberServiceTest.java b/src/test/java/dmu/dasom/api/domain/member/MemberServiceTest.java new file mode 100644 index 0000000..9ba112c --- /dev/null +++ b/src/test/java/dmu/dasom/api/domain/member/MemberServiceTest.java @@ -0,0 +1,129 @@ +package dmu.dasom.api.domain.member; + +import dmu.dasom.api.domain.common.exception.CustomException; +import dmu.dasom.api.domain.common.exception.ErrorCode; +import dmu.dasom.api.domain.member.dto.SignupRequestDto; +import dmu.dasom.api.domain.member.entity.Member; +import dmu.dasom.api.domain.member.repository.MemberRepository; +import dmu.dasom.api.domain.member.service.MemberServiceImpl; +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 org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class MemberServiceTest { + + @Mock + private BCryptPasswordEncoder encoder; + + @Mock + MemberRepository memberRepository; + + @InjectMocks + private MemberServiceImpl memberService; + + @Test + @DisplayName("이메일로 사용자 조회 - 성공") + void getMemberByEmail_success() { + // given + Optional member = Optional.ofNullable(mock(Member.class)); + String email = "test@example.com"; + when(memberRepository.findByEmail(email)).thenReturn(member); + + // when + Member memberByEmail = memberService.getMemberByEmail(email); + + // then + assertNotNull(memberByEmail); + verify(memberRepository, times(1)).findByEmail(email); + } + + @Test + @DisplayName("이메일로 사용자 조회 - 실패") + void getMemberByEmail_fail() { + // given + String email = "test@example.com"; + when(memberRepository.findByEmail(email)).thenReturn(Optional.empty()); + + // when + CustomException exception = assertThrows(CustomException.class, () -> { + memberService.getMemberByEmail(email); + }); + + // then + assertEquals(ErrorCode.MEMBER_NOT_FOUND, exception.getErrorCode()); + verify(memberRepository, times(1)).findByEmail(email); + } + + @Test + @DisplayName("이메일 확인 - 존재") + void checkByEmail_true() { + // given + String email = "test@example.com"; + when(memberRepository.existsByEmail(email)).thenReturn(true); + + // when + boolean result = memberService.checkByEmail(email); + + // then + assertTrue(result); + } + + @Test + @DisplayName("이메일 확인 - 미존재") + void checkByEmail_false() { + // given + String email = "test@example.com"; + when(memberRepository.existsByEmail(email)).thenReturn(false); + + // when + boolean result = memberService.checkByEmail(email); + + // then + assertFalse(result); + } + + @Test + @DisplayName("회원가입 - 성공") + void signUp_success() { + // given + SignupRequestDto request = mock(SignupRequestDto.class); + when(request.getEmail()).thenReturn("test@example.com"); + when(request.getPassword()).thenReturn("password"); + when(encoder.encode("password")).thenReturn("encodedPassword"); + when(memberRepository.existsByEmail("test@example.com")).thenReturn(false); + + // when + memberService.signUp(request); + + // then + verify(memberRepository, times(1)).save(any()); + } + + @Test + @DisplayName("회원가입 - 실패") + void signUp_fail() { + // given + SignupRequestDto request = mock(SignupRequestDto.class); + when(request.getEmail()).thenReturn("test@example.com"); + when(memberRepository.existsByEmail("test@example.com")).thenReturn(true); + + // when + CustomException exception = assertThrows(CustomException.class, () -> { + memberService.signUp(request); + }); + + // then + assertEquals(ErrorCode.SIGNUP_FAILED, exception.getErrorCode()); + verify(memberRepository, never()).save(any()); + } +}