diff --git a/build.gradle b/build.gradle index a78b131..deea3d2 100644 --- a/build.gradle +++ b/build.gradle @@ -24,6 +24,7 @@ repositories { } dependencies { + implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' compileOnly 'org.projectlombok:lombok' runtimeOnly 'com.mysql:mysql-connector-j' @@ -33,6 +34,12 @@ dependencies { // swagger implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0' + + //유효성 검사 + implementation 'org.springframework.boot:spring-boot-starter-validation' + + // Spring Security + implementation 'org.springframework.boot:spring-boot-starter-security' } tasks.named('test') { diff --git a/src/main/java/com/bookmile/backend/domain/user/controller/UserController.java b/src/main/java/com/bookmile/backend/domain/user/controller/UserController.java new file mode 100644 index 0000000..3eefcc3 --- /dev/null +++ b/src/main/java/com/bookmile/backend/domain/user/controller/UserController.java @@ -0,0 +1,36 @@ +package com.bookmile.backend.domain.user.controller; + +import com.bookmile.backend.domain.user.dto.req.SignInReqDto; +import com.bookmile.backend.domain.user.dto.req.SignUpReqDto; +import com.bookmile.backend.domain.user.dto.res.UserResDto; +import com.bookmile.backend.domain.user.service.UserService; +import com.bookmile.backend.global.common.CommonResponse; +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; + +import static com.bookmile.backend.global.common.StatusCode.*; + +@RestController +@RequestMapping("/api/v1/users") +@RequiredArgsConstructor +public class UserController { + private final UserService userService; + + @PostMapping("/sign-up") + public ResponseEntity> signUp(@RequestBody @Valid SignUpReqDto signUpReqDto) { + return ResponseEntity.status(SIGN_UP.getStatus()) + .body(CommonResponse.from(SIGN_UP.getMessage(),userService.signUp(signUpReqDto))); + } + + @PostMapping("/sign-in") + public ResponseEntity> signIn(@RequestBody @Valid SignInReqDto signInReqDto) { + return ResponseEntity.status(SIGN_IN.getStatus()) + .body(CommonResponse.from(SIGN_IN.getMessage(),userService.signIn(signInReqDto))); + } +} + diff --git a/src/main/java/com/bookmile/backend/domain/user/dto/req/SignInReqDto.java b/src/main/java/com/bookmile/backend/domain/user/dto/req/SignInReqDto.java new file mode 100644 index 0000000..5d6ceb4 --- /dev/null +++ b/src/main/java/com/bookmile/backend/domain/user/dto/req/SignInReqDto.java @@ -0,0 +1,23 @@ +package com.bookmile.backend.domain.user.dto.req; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class SignInReqDto { + + @NotBlank(message = "이메일은 필수 입력 사항입니다.") + @Schema(description = "회원의 이메일 주소", example = "user@example.com") + @Pattern(regexp="^[0-9a-zA-Z]([-_.]?[0-9a-zA-Z])*@[0-9a-zA-Z]([-_.]?[0-9a-zA-Z])+[.][a-zA-Z]{2,3}$", message="이메일 형식이 올바르지 않습니다.") + private String email; + + @NotBlank(message = "비빌번호는 필수 입력 사항입니다.") + @Schema(description = "회원의 비밀번호", example = "userPassword!") + @Pattern(regexp = "^[0-9a-zA-Z]([-_.]?[0-9a-zA-Z])*@[0-9a-zA-Z]([-_.]?[0-9a-zA-Z])+[.][a-zA-Z]{2,3}$", message = "비밀번호는 8~16자의 영문 대소문자, 숫자, 특수문자로 이루어져야 합니다.") + private String password; + +} diff --git a/src/main/java/com/bookmile/backend/domain/user/dto/req/SignUpReqDto.java b/src/main/java/com/bookmile/backend/domain/user/dto/req/SignUpReqDto.java new file mode 100644 index 0000000..80b4982 --- /dev/null +++ b/src/main/java/com/bookmile/backend/domain/user/dto/req/SignUpReqDto.java @@ -0,0 +1,37 @@ +package com.bookmile.backend.domain.user.dto.req; + +import com.bookmile.backend.domain.user.entity.User; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class SignUpReqDto { + + + @NotBlank(message = "이메일은 필수 입력 사항입니다.") + @Schema(description = "회원의 이메일 주소", example = "user@example.com") + @Pattern(regexp="^[0-9a-zA-Z]([-_.]?[0-9a-zA-Z])*@[0-9a-zA-Z]([-_.]?[0-9a-zA-Z])+[.][a-zA-Z]{2,3}$", message="이메일 형식이 올바르지 않습니다.") + private String email; + + @NotBlank(message = "비빌번호는 필수 입력 사항입니다.") + @Schema(description = "회원의 비밀번호", example = "userPassword!") + @Pattern(regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[!@#$%^&*])[a-zA-Z0-9!@#$%^&*]{8,16}$", message = "비밀번호는 8~16자의 영문 대소문자, 숫자, 특수문자로 이루어져야 합니다.") + private String password; + + @NotBlank(message = "비빌번호 확인는 필수 입력 사항입니다.") + @Schema(description = "회원의 비밀번호 확인", example = "userPassword!") + // 확인용이므로 Pattern 이 아니라, password 와 매치하기만 하면 됨. + private String checkPassword; + + + public User toEntity(String email, String password) { + return User.builder() + .email(email) + .password(password) + .build(); + } +} diff --git a/src/main/java/com/bookmile/backend/domain/user/dto/res/UserResDto.java b/src/main/java/com/bookmile/backend/domain/user/dto/res/UserResDto.java new file mode 100644 index 0000000..03b5e66 --- /dev/null +++ b/src/main/java/com/bookmile/backend/domain/user/dto/res/UserResDto.java @@ -0,0 +1,24 @@ +package com.bookmile.backend.domain.user.dto.res; + +import com.bookmile.backend.domain.user.entity.User; +import lombok.Builder; +import lombok.Getter; + +@Getter +public class UserResDto { + private final Long id; + private final String email; + + @Builder + private UserResDto(Long id, String email) { + this.id = id; + this.email = email; + } + + public static UserResDto toDto(User user) { + return UserResDto.builder() + .id(user.getId()) + .email(user.getEmail()) + .build(); + } +} diff --git a/src/main/java/com/bookmile/backend/domain/user/entity/User.java b/src/main/java/com/bookmile/backend/domain/user/entity/User.java index 8a8e7c0..52bf6f8 100644 --- a/src/main/java/com/bookmile/backend/domain/user/entity/User.java +++ b/src/main/java/com/bookmile/backend/domain/user/entity/User.java @@ -1,25 +1,13 @@ package com.bookmile.backend.domain.user.entity; -import com.bookmile.backend.domain.review.entity.Review; -import com.bookmile.backend.domain.userGroup.entity.UserGroup; import com.bookmile.backend.global.config.BaseEntity; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.OneToMany; -import java.util.ArrayList; -import java.util.List; -import lombok.AccessLevel; -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.NoArgsConstructor; +import jakarta.persistence.*; + +import lombok.*; @Entity @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor public class User extends BaseEntity { @Id @@ -27,19 +15,13 @@ public class User extends BaseEntity { @Column(name = "user_id") private Long id; - @OneToMany(mappedBy = "user") - private List userGroup = new ArrayList<>(); - - @OneToMany(mappedBy = "user") - private List review = new ArrayList<>(); - - @Column(nullable = false) - private String name; + @Column(nullable = true, unique = true) + private String nickname; - @Column(nullable = false) + @Column(nullable = false, unique = true) private String email; - @Column(nullable = false) + @Column(nullable = true) private String password; @Column @@ -48,13 +30,9 @@ public class User extends BaseEntity { @Column(nullable = false) private Boolean isDeleted = false; - public void addUserGroup(UserGroup userGroup) { - this.userGroup.add(userGroup); - userGroup.addUser(this); - } - - public User(String name, String email, String password, String image) { - this.name = name; + @Builder + public User(String nickname, String email, String password, String image) { + this.nickname = nickname; this.email = email; this.password = password; this.image = image; diff --git a/src/main/java/com/bookmile/backend/domain/user/repository/UserRepository.java b/src/main/java/com/bookmile/backend/domain/user/repository/UserRepository.java new file mode 100644 index 0000000..6574114 --- /dev/null +++ b/src/main/java/com/bookmile/backend/domain/user/repository/UserRepository.java @@ -0,0 +1,11 @@ +package com.bookmile.backend.domain.user.repository; + +import com.bookmile.backend.domain.user.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface UserRepository extends JpaRepository { + Optional findByEmail(String email); + Boolean existsByEmail(String email); +} diff --git a/src/main/java/com/bookmile/backend/domain/user/service/UserService.java b/src/main/java/com/bookmile/backend/domain/user/service/UserService.java new file mode 100644 index 0000000..596ebe9 --- /dev/null +++ b/src/main/java/com/bookmile/backend/domain/user/service/UserService.java @@ -0,0 +1,10 @@ +package com.bookmile.backend.domain.user.service; + +import com.bookmile.backend.domain.user.dto.req.SignInReqDto; +import com.bookmile.backend.domain.user.dto.req.SignUpReqDto; +import com.bookmile.backend.domain.user.dto.res.UserResDto; + +public interface UserService { + UserResDto signUp(SignUpReqDto signUpReqDto); + UserResDto signIn(SignInReqDto signInReqDto); +} diff --git a/src/main/java/com/bookmile/backend/domain/user/service/impl/UserServiceImpl.java b/src/main/java/com/bookmile/backend/domain/user/service/impl/UserServiceImpl.java new file mode 100644 index 0000000..e02c641 --- /dev/null +++ b/src/main/java/com/bookmile/backend/domain/user/service/impl/UserServiceImpl.java @@ -0,0 +1,58 @@ +package com.bookmile.backend.domain.user.service.impl; + +import com.bookmile.backend.domain.user.dto.req.SignInReqDto; +import com.bookmile.backend.domain.user.dto.req.SignUpReqDto; +import com.bookmile.backend.domain.user.dto.res.UserResDto; +import com.bookmile.backend.domain.user.entity.User; +import com.bookmile.backend.domain.user.repository.UserRepository; +import com.bookmile.backend.domain.user.service.UserService; +import com.bookmile.backend.global.exception.CustomException; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +import static com.bookmile.backend.global.common.StatusCode.*; + +@Service +@RequiredArgsConstructor +public class UserServiceImpl implements UserService { + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + + @Override + public UserResDto signUp(SignUpReqDto signUpReqDto) { + existsByEmail(signUpReqDto.getEmail()); + + // 비밀번호 일치 여부 확인 + if (!(signUpReqDto.getPassword().equals(signUpReqDto.getCheckPassword()))) + throw new CustomException(PASSWORD_NOT_MATCH); + + String enCodePassword = passwordEncoder.encode(signUpReqDto.getPassword()); + + User user = userRepository.save(signUpReqDto.toEntity(signUpReqDto.getEmail(), enCodePassword)); + return UserResDto.toDto(user); + } + + @Override + public UserResDto signIn(SignInReqDto signInReqDto) { + User user = findByEmail(signInReqDto.getEmail()); + + if(!passwordEncoder.matches(signInReqDto.getPassword(), user.getPassword())) + throw new CustomException(AUTHENTICATION_FAILED); // 유저는 아이디, 비밀번호 중 한개만 틀려도 '일치하는 정보가 없음' 메세지 표시 + + return UserResDto.toDto(user); + } + + + private void existsByEmail(String email) { + if(userRepository.existsByEmail(email)){ + throw new CustomException(USER_ALREADY_EXISTS); + }; + } + + private User findByEmail(String email) { + return userRepository.findByEmail(email).orElseThrow(() -> new CustomException(AUTHENTICATION_FAILED)); + } +} + + diff --git a/src/main/java/com/bookmile/backend/global/common/CommonResponse.java b/src/main/java/com/bookmile/backend/global/common/CommonResponse.java new file mode 100644 index 0000000..eac93f0 --- /dev/null +++ b/src/main/java/com/bookmile/backend/global/common/CommonResponse.java @@ -0,0 +1,34 @@ +package com.bookmile.backend.global.common; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Builder; +import lombok.Getter; + +@Getter +@JsonInclude(JsonInclude.Include.NON_NULL)// Null 값인 필드 json으로 보낼 시 제외 +//@RequiredArgsConstructor(staticName = "of") 이 어노테이션으로 아래 static 메소드를 만들 수 있음. +public class CommonResponse { + private final String message; + private final T response; + + @Builder + private CommonResponse(String message, T response) { + this.message = message; + this.response = response; + } + + // <제네릭 타입> 반환타입 메소드이름() + // 제네릭 타입을 정의해야 메소드를 호출할 때마다 다른 타입을 사용할 수 있다 + public static CommonResponse from(String message, T response) { + return new CommonResponse(message, response); + + } + + // response 없이 메시지만 보낼 때 + public static CommonResponse from(String message){ + return CommonResponse.builder() + .message(message) + .build(); + } +} + diff --git a/src/main/java/com/bookmile/backend/global/common/StatusCode.java b/src/main/java/com/bookmile/backend/global/common/StatusCode.java new file mode 100644 index 0000000..334c9d0 --- /dev/null +++ b/src/main/java/com/bookmile/backend/global/common/StatusCode.java @@ -0,0 +1,35 @@ +package com.bookmile.backend.global.common; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +import static org.springframework.http.HttpStatus.*; +import static org.springframework.http.HttpStatus.CONFLICT; + +@Getter +@RequiredArgsConstructor +public enum StatusCode { + + /* User */ + SIGN_UP(CREATED, "회원가입이 완료되었습니다."), + SIGN_IN(OK, "로그인에 성공하였습니다."), + + /* 400 BAD_REQUEST : 잘못된 요청 */ + PASSWORD_NOT_MATCH(BAD_REQUEST, "비밀번호가 일치하지 않습니다."), + + /* 401 UNAUTHORIZED : 비인증 사용자 */ + AUTHENTICATION_FAILED(UNAUTHORIZED, "회원의 정보가 일치하지 않습니다."), + + /* 403 FORBIDDEN : 권한 없음 */ + + /* 404 NOT_FOUND : 존재하지 않는 리소스 */ + INPUT_VALUE_INVALID(NOT_FOUND, "유효하지 않은 입력입니다."), + USER_NOT_FOUND(NOT_FOUND, "존재하는 회원이 없습니다."), + + /* 409 CONFLICT : 리소스 충돌 */ + USER_ALREADY_EXISTS(CONFLICT, "이미 존재하는 회원입니다."); + + private final HttpStatus status; + private final String message; +} diff --git a/src/main/java/com/bookmile/backend/global/config/JpaAuditingConfig.java b/src/main/java/com/bookmile/backend/global/config/JpaAuditingConfig.java new file mode 100644 index 0000000..fc20de0 --- /dev/null +++ b/src/main/java/com/bookmile/backend/global/config/JpaAuditingConfig.java @@ -0,0 +1,9 @@ +package com.bookmile.backend.global.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@Configuration +@EnableJpaAuditing +public class JpaAuditingConfig { +} diff --git a/src/main/java/com/bookmile/backend/global/config/SecurityConfig.java b/src/main/java/com/bookmile/backend/global/config/SecurityConfig.java new file mode 100644 index 0000000..9e8ff4b --- /dev/null +++ b/src/main/java/com/bookmile/backend/global/config/SecurityConfig.java @@ -0,0 +1,31 @@ +package com.bookmile.backend.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +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.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; + +@Configuration +@EnableWebSecurity +public class SecurityConfig { + + @Bean + public PasswordEncoder getPasswordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .csrf(AbstractHttpConfigurer::disable) + .httpBasic(AbstractHttpConfigurer::disable); + + return http.build(); + + } + +} \ No newline at end of file diff --git a/src/main/java/com/bookmile/backend/global/exception/CustomException.java b/src/main/java/com/bookmile/backend/global/exception/CustomException.java new file mode 100644 index 0000000..9ea5aae --- /dev/null +++ b/src/main/java/com/bookmile/backend/global/exception/CustomException.java @@ -0,0 +1,11 @@ +package com.bookmile.backend.global.exception; + +import com.bookmile.backend.global.common.StatusCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class CustomException extends RuntimeException { + private final StatusCode statusCode; +} diff --git a/src/main/java/com/bookmile/backend/global/exception/ErrorResponse.java b/src/main/java/com/bookmile/backend/global/exception/ErrorResponse.java new file mode 100644 index 0000000..381c5cd --- /dev/null +++ b/src/main/java/com/bookmile/backend/global/exception/ErrorResponse.java @@ -0,0 +1,57 @@ +package com.bookmile.backend.global.exception; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Builder; +import lombok.Getter; +import org.springframework.validation.BindingResult; +import org.springframework.validation.FieldError; + +import java.util.List; +import java.util.stream.Collectors; + +@Getter +@JsonInclude(JsonInclude.Include.NON_NULL) +public class ErrorResponse { + + private final String message; + private final List validationErrors; + + @Builder + private ErrorResponse(String message, List validationErrors) { + this.message = message; + this.validationErrors = validationErrors; + } + + //ErrorResponse에서만 쓰이기 때문에 따로 클래스를 안만들고 내부 정적 클래스로 구현 + @Getter + public static class ValidationError{ + + private final String field; + private final String value; + private final String reason; + + private ValidationError(String field, String value, String reason) { + this.field = field; + this.value = value; + this.reason = reason; + } + + /* + 1. BindingResult.getFieldErrors()를 사용해 BindingResult의 오류들을 가져온다. + 2. 필요한 값들을 Validation Error에 매핑하여 사용한다. + + Field : 예외가 발생한 field + RejectedValue : 어떤 값으로 인해 예외가 발생하였는지 + DefaultMessage : 해당 예외가 발생했을 때 제공할 message 는 무엇인지 + */ + public static List from(BindingResult bindingResult){ + List fieldErrors= bindingResult.getFieldErrors(); + return fieldErrors.stream() + .map(error -> new ValidationError( + error.getField(), + (error.getRejectedValue()==null) ? null : error.getRejectedValue().toString(), + error.getDefaultMessage())) + .collect(Collectors.toList()); + } + } +} diff --git a/src/main/java/com/bookmile/backend/global/exception/ExceptionControllerAdvice.java b/src/main/java/com/bookmile/backend/global/exception/ExceptionControllerAdvice.java new file mode 100644 index 0000000..41edaa3 --- /dev/null +++ b/src/main/java/com/bookmile/backend/global/exception/ExceptionControllerAdvice.java @@ -0,0 +1,40 @@ +package com.bookmile.backend.global.exception; + +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; + +import static com.bookmile.backend.global.common.StatusCode.INPUT_VALUE_INVALID; + +@RestControllerAdvice +public class ExceptionControllerAdvice { + + /* + MethodArgumentNotValidException는 유효성 검사에서 실패하면 나타나는 예외로 bindingReult에 오류를 담는다. + bindingResult가 없으면 400오류가 발생해 컨트롤러를 호출하지않고 오류페이지로 이동한다. + */ + + // 유효성 검사 실패로 인한 예외로 메시지와 오류 객체도 담는다. + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleMethodArgumentNotValid(MethodArgumentNotValidException e){ + ErrorResponse errorResponse= ErrorResponse.builder() + .message(INPUT_VALUE_INVALID.getMessage()) + .validationErrors(ErrorResponse.ValidationError.from(e.getBindingResult())) + .build(); + return ResponseEntity.status(INPUT_VALUE_INVALID.getStatus()) + .body(errorResponse); + } + + + + // 무슨 예외가 터졌는지 메시지만 담는다. + @ExceptionHandler(CustomException.class) + public ResponseEntity handleCustomException(CustomException e){ + ErrorResponse errorResponse=ErrorResponse.builder() + .message(e.getStatusCode().getMessage()) + .build(); + return ResponseEntity.status(e.getStatusCode().getStatus()) + .body(errorResponse); + } +}