diff --git a/.github/workflows/dev_deploy_beanstalk.yml b/.github/workflows/dev_deploy_beanstalk.yml index 2119839..54f3ca7 100644 --- a/.github/workflows/dev_deploy_beanstalk.yml +++ b/.github/workflows/dev_deploy_beanstalk.yml @@ -33,6 +33,9 @@ jobs: spring.datasource.url: ${{ secrets.DB_URL }} spring.datasource.username: ${{ secrets.DB_USER }} spring.datasource.password: ${{ secrets.DB_PASSWORD }} + spring.datasource.driver-class-name: ${{ secrets.DB_DRIVER }} + jwt.token.secret: ${{ secrets.JWT_TOKEN_SECRET }} + jwt.token.expiration: ${{ secrets.ACCESS_EXPIRY_SECONDS }} # gradlew 실행 권한 부여 - name: Grant execute permission for gradlew diff --git a/src/main/java/com/cmc/suppin/member/exception/.gitkeep b/src/main/java/com/cmc/suppin/crawl/controller/.gitkeep similarity index 100% rename from src/main/java/com/cmc/suppin/member/exception/.gitkeep rename to src/main/java/com/cmc/suppin/crawl/controller/.gitkeep diff --git a/src/main/java/com/cmc/suppin/global/enums/UserRole.java b/src/main/java/com/cmc/suppin/global/enums/UserRole.java new file mode 100644 index 0000000..f92b8d6 --- /dev/null +++ b/src/main/java/com/cmc/suppin/global/enums/UserRole.java @@ -0,0 +1,6 @@ +package com.cmc.suppin.global.enums; + +public enum UserRole { + ROLE_ADMIN, + ROLE_USER +} diff --git a/src/main/java/com/cmc/suppin/global/enums/UserStatus.java b/src/main/java/com/cmc/suppin/global/enums/UserStatus.java new file mode 100644 index 0000000..9476f0a --- /dev/null +++ b/src/main/java/com/cmc/suppin/global/enums/UserStatus.java @@ -0,0 +1,5 @@ +package com.cmc.suppin.global.enums; + +public enum UserStatus { + ACTIVE, INACTIVE, DELETED +} diff --git a/src/main/java/com/cmc/suppin/global/exception/BaseCode.java b/src/main/java/com/cmc/suppin/global/exception/BaseCode.java deleted file mode 100644 index b1efb66..0000000 --- a/src/main/java/com/cmc/suppin/global/exception/BaseCode.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.cmc.suppin.global.exception; - -public interface BaseCode { - public ReasonDTO getReason(); - - public ReasonDTO getReasonHttpStatus(); -} diff --git a/src/main/java/com/cmc/suppin/global/exception/BaseErrorCode.java b/src/main/java/com/cmc/suppin/global/exception/BaseErrorCode.java index 29b4a06..55eb83c 100644 --- a/src/main/java/com/cmc/suppin/global/exception/BaseErrorCode.java +++ b/src/main/java/com/cmc/suppin/global/exception/BaseErrorCode.java @@ -1,8 +1,13 @@ package com.cmc.suppin.global.exception; +import com.cmc.suppin.global.response.ErrorResponse; +import org.springframework.http.HttpStatus; + public interface BaseErrorCode { - public ErrorReasonDTO getReason(); + ErrorResponse getErrorResponse(); + + String getMessage(); - public ErrorReasonDTO getReasonHttpStatus(); + HttpStatus getStatus(); } diff --git a/src/main/java/com/cmc/suppin/global/exception/CommonErrorCode.java b/src/main/java/com/cmc/suppin/global/exception/CommonErrorCode.java new file mode 100644 index 0000000..31a10be --- /dev/null +++ b/src/main/java/com/cmc/suppin/global/exception/CommonErrorCode.java @@ -0,0 +1,37 @@ +package com.cmc.suppin.global.exception; + +import com.cmc.suppin.global.response.ErrorResponse; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum CommonErrorCode implements BaseErrorCode { + + // 가장 일반적인 에러 + _INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "COMMON500", "서버 에러, 관리자에게 문의 바랍니다."), + _BAD_REQUEST(HttpStatus.BAD_REQUEST, "COMMON400", "잘못된 요청입니다."), + _FORBIDDEN(HttpStatus.FORBIDDEN, "COMMON403", "금지된 요청입니다."), + + // test + TEMP_EXCEPTION(HttpStatus.BAD_REQUEST, "TEMP4001", "테스트"), + + // 페이징 관련 에러 + PAGE_NEGATIVE_INPUT(HttpStatus.BAD_REQUEST, "PAGE4001", "페이지 번호는 1이상의 숫자여야 합니다."), + ; + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + @Override + public ErrorResponse getErrorResponse() { + return null; + } + + @Override + public HttpStatus getStatus() { + return null; + } +} diff --git a/src/main/java/com/cmc/suppin/global/exception/CustomException.java b/src/main/java/com/cmc/suppin/global/exception/CustomException.java new file mode 100644 index 0000000..c862875 --- /dev/null +++ b/src/main/java/com/cmc/suppin/global/exception/CustomException.java @@ -0,0 +1,14 @@ +package com.cmc.suppin.global.exception; + +import lombok.Getter; + +@Getter +public class CustomException extends RuntimeException { + + private final BaseErrorCode errorCode; + + public CustomException(BaseErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + } +} diff --git a/src/main/java/com/cmc/suppin/global/exception/ErrorReasonDTO.java b/src/main/java/com/cmc/suppin/global/exception/ErrorReasonDTO.java deleted file mode 100644 index 4f911a2..0000000 --- a/src/main/java/com/cmc/suppin/global/exception/ErrorReasonDTO.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.cmc.suppin.global.exception; - -import lombok.Builder; -import lombok.Getter; -import org.springframework.http.HttpStatus; - -@Getter -@Builder -public class ErrorReasonDTO { - - private final HttpStatus httpStatus; - - private final boolean isSuccess; - private final String code; - private final String message; - - private final Integer status; - private final String reason; - - public boolean getIsSuccess() { - return isSuccess; - } -} diff --git a/src/main/java/com/cmc/suppin/global/exception/ExceptionAdvice.java b/src/main/java/com/cmc/suppin/global/exception/ExceptionAdvice.java deleted file mode 100644 index 99a7a54..0000000 --- a/src/main/java/com/cmc/suppin/global/exception/ExceptionAdvice.java +++ /dev/null @@ -1,115 +0,0 @@ -package com.cmc.suppin.global.exception; - -import com.cmc.suppin.global.exception.status.ErrorStatus; -import com.cmc.suppin.global.presentation.ApiResponse; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.validation.ConstraintViolationException; -import lombok.extern.slf4j.Slf4j; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.ExceptionHandler; -import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.bind.annotation.RestControllerAdvice; -import org.springframework.web.context.request.ServletWebRequest; -import org.springframework.web.context.request.WebRequest; -import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; - -import java.util.Map; - -@Slf4j -@RestControllerAdvice(annotations = {RestController.class}) -public class ExceptionAdvice extends ResponseEntityExceptionHandler { - - @org.springframework.web.bind.annotation.ExceptionHandler - public ResponseEntity validation(ConstraintViolationException e, WebRequest request) { - String errorMessage = e.getConstraintViolations().stream() - .map(constraintViolation -> constraintViolation.getMessage()) - .findFirst() - .orElseThrow(() -> new RuntimeException("ConstraintViolationException 추출 도중 에러 발생")); - - return handleExceptionInternalConstraint(e, ErrorStatus.valueOf(errorMessage), HttpHeaders.EMPTY, request); - } - - /* - @Override - public ResponseEntity handleMethodArgumentNotValid( - MethodArgumentNotValidException e, HttpHeaders headers, HttpStatus status, WebRequest request) { - - Map errors = new LinkedHashMap<>(); - - e.getBindingResult().getFieldErrors().stream() - .forEach(fieldError -> { - String fieldName = fieldError.getField(); - String errorMessage = Optional.ofNullable(fieldError.getDefaultMessage()).orElse(""); - errors.merge(fieldName, errorMessage, (existingErrorMessage, newErrorMessage) -> existingErrorMessage + ", " + newErrorMessage); - }); - - return handleExceptionInternalArgs(e, HttpHeaders.EMPTY, ErrorStatus.valueOf("_BAD_REQUEST"), request, errors); - } - */ - - - @org.springframework.web.bind.annotation.ExceptionHandler - public ResponseEntity exception(Exception e, WebRequest request) { - e.printStackTrace(); - - return handleExceptionInternalFalse(e, ErrorStatus._INTERNAL_SERVER_ERROR, HttpHeaders.EMPTY, ErrorStatus._INTERNAL_SERVER_ERROR.getHttpStatus(), request, e.getMessage()); - } - - @ExceptionHandler(value = GeneralException.class) - public ResponseEntity onThrowException(GeneralException generalException, HttpServletRequest request) { - ErrorReasonDTO errorReasonHttpStatus = generalException.getErrorReasonHttpStatus(); - return handleExceptionInternal(generalException, errorReasonHttpStatus, null, request); - } - - private ResponseEntity handleExceptionInternal(Exception e, ErrorReasonDTO reason, HttpHeaders headers, HttpServletRequest request) { - ApiResponse body = ApiResponse.onFailure(reason.getCode(), reason.getMessage(), null); -// e.printStackTrace(); - - WebRequest webRequest = new ServletWebRequest(request); - return super.handleExceptionInternal( - e, - body, - headers, - reason.getHttpStatus(), - webRequest - ); - } - - private ResponseEntity handleExceptionInternalFalse(Exception e, ErrorStatus errorCommonStatus, - HttpHeaders headers, HttpStatus status, WebRequest request, String errorPoint) { - ApiResponse body = ApiResponse.onFailure(errorCommonStatus.getCode(), errorCommonStatus.getMessage(), errorPoint); - return super.handleExceptionInternal( - e, - body, - headers, - status, - request - ); - } - - private ResponseEntity handleExceptionInternalArgs(Exception e, HttpHeaders headers, ErrorStatus errorCommonStatus, - WebRequest request, Map errorArgs) { - ApiResponse body = ApiResponse.onFailure(errorCommonStatus.getCode(), errorCommonStatus.getMessage(), errorArgs); - return super.handleExceptionInternal( - e, - body, - headers, - errorCommonStatus.getHttpStatus(), - request - ); - } - - private ResponseEntity handleExceptionInternalConstraint(Exception e, ErrorStatus errorCommonStatus, - HttpHeaders headers, WebRequest request) { - ApiResponse body = ApiResponse.onFailure(errorCommonStatus.getCode(), errorCommonStatus.getMessage(), null); - return super.handleExceptionInternal( - e, - body, - headers, - errorCommonStatus.getHttpStatus(), - request - ); - } -} diff --git a/src/main/java/com/cmc/suppin/global/exception/GeneralException.java b/src/main/java/com/cmc/suppin/global/exception/GeneralException.java deleted file mode 100644 index bd2bddc..0000000 --- a/src/main/java/com/cmc/suppin/global/exception/GeneralException.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.cmc.suppin.global.exception; - -import lombok.AllArgsConstructor; -import lombok.Getter; - -@Getter -@AllArgsConstructor -public class GeneralException extends RuntimeException { - - private BaseErrorCode code; - - public ErrorReasonDTO getErrorReason() { - return this.code.getReason(); - } - - public ErrorReasonDTO getErrorReasonHttpStatus() { - return this.code.getReasonHttpStatus(); - } -} diff --git a/src/main/java/com/cmc/suppin/global/exception/MemberErrorCode.java b/src/main/java/com/cmc/suppin/global/exception/MemberErrorCode.java new file mode 100644 index 0000000..6dc4bb6 --- /dev/null +++ b/src/main/java/com/cmc/suppin/global/exception/MemberErrorCode.java @@ -0,0 +1,30 @@ +package com.cmc.suppin.global.exception; + +import com.cmc.suppin.global.response.ErrorResponse; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +public enum MemberErrorCode implements BaseErrorCode { + MEMBER_NOT_FOUND("mem-404/01", HttpStatus.NOT_FOUND, "회원을 찾을 수 없습니다."), + VALIDATION_FAILED("mem-400/01", HttpStatus.BAD_REQUEST, "입력값에 대한 검증에 실패했습니다."), + MEMBER_ALREADY_DELETED("mem-400/02", HttpStatus.BAD_REQUEST, "탈퇴한 회원입니다."), + PASSWORD_CONFIRM_NOT_MATCHED("mem-400/03", HttpStatus.BAD_REQUEST, "비밀번호가 확인이 일치하지 않습니다."), + DUPLICATE_MEMBER_EMAIL("mem-409/01", HttpStatus.CONFLICT, "이미 존재하는 이메일입니다."), + DUPLICATE_NICKNAME("mem-409/01", HttpStatus.CONFLICT, "이미 존재하는 닉네임입니다."); + + private final String code; + private final HttpStatus status; + private final String message; + + MemberErrorCode(String code, HttpStatus status, String message) { + this.code = code; + this.status = status; + this.message = message; + } + + @Override + public ErrorResponse getErrorResponse() { + return ErrorResponse.of(code, message); + } +} diff --git a/src/main/java/com/cmc/suppin/global/exception/ReasonDTO.java b/src/main/java/com/cmc/suppin/global/exception/ReasonDTO.java deleted file mode 100644 index 9d4bfd0..0000000 --- a/src/main/java/com/cmc/suppin/global/exception/ReasonDTO.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.cmc.suppin.global.exception; - -import lombok.Builder; -import lombok.Getter; -import org.springframework.http.HttpStatus; - -@Getter -@Builder -public class ReasonDTO { - - private HttpStatus httpStatus; - - private final boolean isSuccess; - private final String code; - private final String message; - - public boolean getIsSuccess() { - return isSuccess; - } -} diff --git a/src/main/java/com/cmc/suppin/global/exception/SecurityErrorCode.java b/src/main/java/com/cmc/suppin/global/exception/SecurityErrorCode.java new file mode 100644 index 0000000..dc4e3d1 --- /dev/null +++ b/src/main/java/com/cmc/suppin/global/exception/SecurityErrorCode.java @@ -0,0 +1,32 @@ +package com.cmc.suppin.global.exception; + +import com.cmc.suppin.global.response.ErrorResponse; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +public enum SecurityErrorCode implements BaseErrorCode { + INVALID_TOKEN("sec-400/01", HttpStatus.BAD_REQUEST, "유효하지 않은 토큰입니다."), + INVALID_OAUTH_CODE("sec-400/02", HttpStatus.BAD_REQUEST, "유효하지 않은 소셜 로그인 코드입니다."), + UNAUTHORIZED("sec-401/01", HttpStatus.UNAUTHORIZED, "로그인 해주세요."), + ACCESS_TOKEN_EXPIRED("sec-401/02", HttpStatus.UNAUTHORIZED, "토큰이 만료되었습니다"), + REFRESH_TOKEN_EXPIRED("sec-401/03", HttpStatus.UNAUTHORIZED, "다시 로그인 해주세요."), + ALREADY_LOGOUT("sec-401/04", HttpStatus.UNAUTHORIZED, "로그아웃 상태로 재로그인이 필요합니다."), + FORBIDDEN("sec-403/01", HttpStatus.FORBIDDEN, "권한이 없습니다"), + OAUTH_LOGIN_FAILED("sec-500", HttpStatus.INTERNAL_SERVER_ERROR, "소셜 로그인 중 오류가 발생했습니다. 관리자에게 문의하세요."); + + private final String code; + private final HttpStatus status; + private final String message; + + SecurityErrorCode(String code, HttpStatus status, String message) { + this.code = code; + this.status = status; + this.message = message; + } + + @Override + public ErrorResponse getErrorResponse() { + return ErrorResponse.of(code, message); + } +} diff --git a/src/main/java/com/cmc/suppin/global/exception/handler/GlobalExceptionHandler.java b/src/main/java/com/cmc/suppin/global/exception/handler/GlobalExceptionHandler.java index dd0bb1e..e16b160 100644 --- a/src/main/java/com/cmc/suppin/global/exception/handler/GlobalExceptionHandler.java +++ b/src/main/java/com/cmc/suppin/global/exception/handler/GlobalExceptionHandler.java @@ -1,4 +1,45 @@ package com.cmc.suppin.global.exception.handler; +import com.cmc.suppin.global.exception.BaseErrorCode; +import com.cmc.suppin.global.exception.CustomException; +import com.cmc.suppin.global.response.ErrorResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.BindingResult; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.util.List; + +@Slf4j +@RestControllerAdvice public class GlobalExceptionHandler { + + @ExceptionHandler(CustomException.class) + protected ResponseEntity handleCustomException(CustomException e) { + log.warn(">>>>> Custom Exception: ", e); + BaseErrorCode errorCode = e.getErrorCode(); + return ResponseEntity.status(errorCode.getStatus()) + .body(errorCode.getErrorResponse()); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + protected ResponseEntity handleMethodArgumentNotValidException(MethodArgumentNotValidException e) { + log.warn(">>>>> Validation Failed: ", e); + BindingResult bindingResult = e.getBindingResult(); + List fieldErrors = bindingResult.getFieldErrors(); + ErrorResponse errorResponse = ErrorResponse.of("400", "입력값에 대한 검증에 실패했습니다."); + fieldErrors.forEach(error -> errorResponse.addValidation(error.getField(), error.getDefaultMessage())); + return ResponseEntity.status(e.getStatusCode()).body(errorResponse); + } + + @ExceptionHandler(Exception.class) + protected ResponseEntity handleGlobalException(Exception e) { + log.error(">>>>> Internal Server Error: ", e); + ErrorResponse errorResponse = ErrorResponse.of("500", e.getMessage()); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(errorResponse); + } } diff --git a/src/main/java/com/cmc/suppin/global/exception/handler/JwtHandler.java b/src/main/java/com/cmc/suppin/global/exception/handler/JwtHandler.java deleted file mode 100644 index fd32363..0000000 --- a/src/main/java/com/cmc/suppin/global/exception/handler/JwtHandler.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.cmc.suppin.global.exception.handler; - -import com.cmc.suppin.global.exception.BaseErrorCode; -import com.cmc.suppin.global.exception.GeneralException; - -public class JwtHandler extends GeneralException { - public JwtHandler(BaseErrorCode code) { - super(code); - } -} diff --git a/src/main/java/com/cmc/suppin/global/exception/status/ErrorStatus.java b/src/main/java/com/cmc/suppin/global/exception/status/ErrorStatus.java deleted file mode 100644 index daa338a..0000000 --- a/src/main/java/com/cmc/suppin/global/exception/status/ErrorStatus.java +++ /dev/null @@ -1,60 +0,0 @@ -package com.cmc.suppin.global.exception.status; - -import com.cmc.suppin.global.exception.BaseErrorCode; -import com.cmc.suppin.global.exception.ErrorReasonDTO; -import lombok.AllArgsConstructor; -import lombok.Getter; -import org.springframework.http.HttpStatus; - -@Getter -@AllArgsConstructor -public enum ErrorStatus implements BaseErrorCode { - - // 가장 일반적인 에러 - _INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "COMMON500", "서버 에러, 관리자에게 문의 바랍니다."), - _BAD_REQUEST(HttpStatus.BAD_REQUEST, "COMMON400", "잘못된 요청입니다."), - _UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "COMMON401", "인증이 필요합니다."), - _FORBIDDEN(HttpStatus.FORBIDDEN, "COMMON403", "금지된 요청입니다."), - - - // test - TEMP_EXCEPTION(HttpStatus.BAD_REQUEST, "TEMP4001", "테스트"), - - // Member - MEMBER_USERID_DUPLICATED(HttpStatus.BAD_REQUEST, "MEMBER4001", "중복된 아이디 입니다."), - MEMBER_PASSWORD_ERROR(HttpStatus.BAD_REQUEST, "MEMBER4002", "비밀번호가 잘못되었습니다."), - - //JWT - JWT_BAD_REQUEST(HttpStatus.UNAUTHORIZED, "JWT4001", "잘못된 JWT 서명입니다."), - JWT_ACCESS_TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED, "JWT4002", "액세스 토큰이 만료되었습니다."), - JWT_REFRESH_TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED, "JWT4003", "리프레시 토큰이 만료되었습니다. 다시 로그인하시기 바랍니다."), - JWT_UNSUPPORTED_TOKEN(HttpStatus.UNAUTHORIZED, "JWT4004", "지원하지 않는 JWT 토큰입니다."), - JWT_TOKEN_NOT_FOUND(HttpStatus.UNAUTHORIZED, "JWT4005", "유효한 JWT 토큰이 없습니다."), - - // 페이징 관련 에러 - PAGE_NEGATIVE_INPUT(HttpStatus.BAD_REQUEST, "PAGE4001", "페이지 번호는 1이상의 숫자여야 합니다."), - ; - - private final HttpStatus httpStatus; - private final String code; - private final String message; - - @Override - public ErrorReasonDTO getReason() { - return ErrorReasonDTO.builder() - .message(message) - .code(code) - .isSuccess(false) - .build(); - } - - @Override - public ErrorReasonDTO getReasonHttpStatus() { - return ErrorReasonDTO.builder() - .message(message) - .code(code) - .isSuccess(false) - .httpStatus(httpStatus) - .build(); - } -} diff --git a/src/main/java/com/cmc/suppin/global/exception/status/SuccessStatus.java b/src/main/java/com/cmc/suppin/global/exception/status/SuccessStatus.java deleted file mode 100644 index 4826f9d..0000000 --- a/src/main/java/com/cmc/suppin/global/exception/status/SuccessStatus.java +++ /dev/null @@ -1,43 +0,0 @@ -package com.cmc.suppin.global.exception.status; - -import com.cmc.suppin.global.exception.BaseCode; -import com.cmc.suppin.global.exception.ReasonDTO; -import lombok.AllArgsConstructor; -import lombok.Getter; -import org.springframework.http.HttpStatus; - -@Getter -@AllArgsConstructor -public enum SuccessStatus implements BaseCode { - _OK(HttpStatus.OK, "COMMON200", "성공입니다."), - - //Member - MEMBER_JOIN_SUCCESS(HttpStatus.OK, "MEMBER2000", "회원 가입 성공입니다."), - MEMBER_ID_CONFIRM_SUCCESS(HttpStatus.OK, "MEMBER2001", "아이디가 중복되지 않습니다."), - MEMBER_DELETE_SUCCESS(HttpStatus.OK, "MEMBER2002", "회원 탈퇴 성공입니다."), - MEMBER_LOGIN_SUCCESS(HttpStatus.OK, "MEMBER2003", "로그인 성공입니다."), - ; - - private final HttpStatus httpStatus; - private final String code; - private final String message; - - @Override - public ReasonDTO getReason() { - return ReasonDTO.builder() - .message(message) - .code(code) - .isSuccess(true) - .build(); - } - - @Override - public ReasonDTO getReasonHttpStatus() { - return ReasonDTO.builder() - .message(message) - .code(code) - .isSuccess(true) - .httpStatus(httpStatus) - .build(); - } -} diff --git a/src/main/java/com/cmc/suppin/global/presentation/ApiResponse.java b/src/main/java/com/cmc/suppin/global/presentation/ApiResponse.java deleted file mode 100644 index d4827a2..0000000 --- a/src/main/java/com/cmc/suppin/global/presentation/ApiResponse.java +++ /dev/null @@ -1,37 +0,0 @@ -package com.cmc.suppin.global.presentation; - -import com.cmc.suppin.global.exception.BaseCode; -import com.cmc.suppin.global.exception.status.SuccessStatus; -import com.fasterxml.jackson.annotation.JsonInclude; -import com.fasterxml.jackson.annotation.JsonPropertyOrder; -import lombok.AllArgsConstructor; -import lombok.Getter; - -@Getter -@AllArgsConstructor -@JsonPropertyOrder({"isSuccess", "code", "message", "result"}) -public class ApiResponse { - - @JsonPropertyOrder("isSuccess") - private final Boolean isSuccess; - private final String code; - private final String message; - @JsonInclude(JsonInclude.Include.NON_NULL) - private T result; - - public static ApiResponse onSuccess(T result) { - return new ApiResponse<>(true, SuccessStatus._OK.getCode(), SuccessStatus._OK.getMessage(), result); - } - - public static ApiResponse onSuccess(T result, SuccessStatus successStatus) { - return new ApiResponse<>(true, successStatus.getCode(), successStatus.getMessage(), result); - } - - public static ApiResponse of(BaseCode code, T result) { - return new ApiResponse<>(true, code.getReasonHttpStatus().getCode(), code.getReasonHttpStatus().getMessage(), result); - } - - public static ApiResponse onFailure(String code, String message, T data) { - return new ApiResponse<>(false, code, message, data); - } -} diff --git a/src/main/java/com/cmc/suppin/global/presentation/ErrorResponse.java b/src/main/java/com/cmc/suppin/global/presentation/ErrorResponse.java deleted file mode 100644 index f50e133..0000000 --- a/src/main/java/com/cmc/suppin/global/presentation/ErrorResponse.java +++ /dev/null @@ -1,40 +0,0 @@ -package com.cmc.suppin.global.presentation; - -import com.cmc.suppin.global.exception.ErrorReasonDTO; -import com.fasterxml.jackson.annotation.JsonProperty; -import com.fasterxml.jackson.annotation.JsonPropertyOrder; -import lombok.Getter; - -import java.time.LocalDateTime; - -@Getter -@JsonPropertyOrder({"isSuccess", "code", "message", "status", "timeStamp", "path"}) -public class ErrorResponse { - - @JsonProperty("isSuccess") - private final boolean success; - - private final int status; - private final String code; - private final String message; - private final LocalDateTime timeStamp; - private final String path; - - public ErrorResponse(ErrorReasonDTO errorReason, String path) { - this.success = false; - this.status = errorReason.getStatus(); - this.code = errorReason.getCode(); - this.message = errorReason.getReason(); - this.timeStamp = LocalDateTime.now(); - this.path = path; - } - - public ErrorResponse(int status, String code, String reason, String path) { - this.success = false; - this.status = status; - this.code = code; - this.message = reason; - this.timeStamp = LocalDateTime.now(); - this.path = path; - } -} diff --git a/src/main/java/com/cmc/suppin/global/response/ApiResponse.java b/src/main/java/com/cmc/suppin/global/response/ApiResponse.java new file mode 100644 index 0000000..7540613 --- /dev/null +++ b/src/main/java/com/cmc/suppin/global/response/ApiResponse.java @@ -0,0 +1,33 @@ +package com.cmc.suppin.global.response; + +import com.fasterxml.jackson.annotation.JsonInclude; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; + +@Getter +@Schema(title = "공통 응답 형식", requiredProperties = {"code", "message"}) +public class ApiResponse { + + private final String code; + private final String message; + @JsonInclude(JsonInclude.Include.NON_NULL) + private final T data; + + private ApiResponse(String code, String message, T data) { + this.code = code; + this.message = message; + this.data = data; + } + + public static ApiResponse of(T data) { + return new ApiResponse<>(ResponseCode.SUCCESS.getCode(), ResponseCode.SUCCESS.getMessage(), data); + } + + public static ApiResponse of(ResponseCode responseCode, T data) { + return new ApiResponse<>(responseCode.getCode(), responseCode.getMessage(), data); + } + + public static ApiResponse of(ResponseCode responseCode) { + return new ApiResponse<>(responseCode.getCode(), responseCode.getMessage(), null); + } +} diff --git a/src/main/java/com/cmc/suppin/global/response/ErrorResponse.java b/src/main/java/com/cmc/suppin/global/response/ErrorResponse.java new file mode 100644 index 0000000..469690d --- /dev/null +++ b/src/main/java/com/cmc/suppin/global/response/ErrorResponse.java @@ -0,0 +1,31 @@ +package com.cmc.suppin.global.response; + +import com.fasterxml.jackson.annotation.JsonInclude; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; + +import java.util.HashMap; +import java.util.Map; + +@Getter +@JsonInclude(value = JsonInclude.Include.NON_EMPTY) +@Schema(title = "공통 에러 형식", requiredProperties = {"errorCode", "errorMessage"}) +public class ErrorResponse { + + private final String errorCode; + private final String errorMessage; + private final Map validation = new HashMap<>(); + + private ErrorResponse(String errorCode, String errorMessage) { + this.errorCode = errorCode; + this.errorMessage = errorMessage; + } + + public static ErrorResponse of(String errorCode, String errorMessage) { + return new ErrorResponse(errorCode, errorMessage); + } + + public void addValidation(String field, String message) { + validation.put(field, message); + } +} diff --git a/src/main/java/com/cmc/suppin/global/response/ResponseCode.java b/src/main/java/com/cmc/suppin/global/response/ResponseCode.java new file mode 100644 index 0000000..092e3c7 --- /dev/null +++ b/src/main/java/com/cmc/suppin/global/response/ResponseCode.java @@ -0,0 +1,18 @@ +package com.cmc.suppin.global.response; + +import lombok.Getter; + +@Getter +public enum ResponseCode { + + SUCCESS("200", "정상 처리되었습니다."), + CREATE("201", "정상적으로 생성되었습니다."); + + private final String code; + private final String message; + + ResponseCode(String code, String message) { + this.code = code; + this.message = message; + } +} diff --git a/src/main/java/com/cmc/suppin/global/security/SecurityConfig.java b/src/main/java/com/cmc/suppin/global/security/SecurityConfig.java deleted file mode 100644 index 3b52087..0000000 --- a/src/main/java/com/cmc/suppin/global/security/SecurityConfig.java +++ /dev/null @@ -1,108 +0,0 @@ -package com.cmc.suppin.global.security; - -import com.cmc.suppin.global.security.jwt.JWTFilter; -import com.cmc.suppin.global.security.jwt.JWTUtil; -import com.cmc.suppin.global.security.jwt.LoginFilter; -import jakarta.servlet.http.HttpServletRequest; -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.http.SessionCreationPolicy; -import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; -import org.springframework.security.web.SecurityFilterChain; -import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; -import org.springframework.web.cors.CorsConfiguration; -import org.springframework.web.cors.CorsConfigurationSource; - -import java.util.Collections; - -@Configuration -@EnableWebSecurity -public class SecurityConfig { - - //AuthenticationManager가 인자로 받을 AuthenticationConfiguraion 객체 생성자 주입 - private final AuthenticationConfiguration authenticationConfiguration; - - //JWTUtil 주입 - private final JWTUtil jwtUtil; - - public SecurityConfig(AuthenticationConfiguration authenticationConfiguration, JWTUtil jwtUtil) { - - this.authenticationConfiguration = authenticationConfiguration; - this.jwtUtil = jwtUtil; - } - - //AuthenticationManager Bean 등록 - @Bean - public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception { - - return configuration.getAuthenticationManager(); - } - - @Bean - public BCryptPasswordEncoder bCryptPasswordEncoder() { - - return new BCryptPasswordEncoder(); - } - - @Bean - public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { - - http - .cors((corsCustomizer -> corsCustomizer.configurationSource(new CorsConfigurationSource() { - - @Override - public CorsConfiguration getCorsConfiguration(HttpServletRequest request) { - - CorsConfiguration configuration = new CorsConfiguration(); - - configuration.setAllowedOrigins(Collections.singletonList("http://localhost:3000")); - configuration.setAllowedMethods(Collections.singletonList("*")); - configuration.setAllowCredentials(true); - configuration.setAllowedHeaders(Collections.singletonList("*")); - configuration.setMaxAge(3600L); - - configuration.setExposedHeaders(Collections.singletonList("Authorization")); - - return configuration; - } - }))); - - //csrf disable - http - .csrf((auth) -> auth.disable()); - - //From 로그인 방식 disable - http - .formLogin((auth) -> auth.disable()); - - //http basic 인증 방식 disable - http - .httpBasic((auth) -> auth.disable()); - - //경로별 인가 작업 - http - .authorizeHttpRequests((auth) -> auth - .anyRequest().permitAll()); - - //JWTFilter 추가 - http - .addFilterBefore(new JWTFilter(jwtUtil), LoginFilter.class); - - //LoginFilter 추가 LoginFilter()는 인자를 받음 (AuthenticationManager() 메소드에 authenticationConfiguration 객체를 넣어야 함) 따라서 등록 필요 - http - .addFilterAt(new LoginFilter(authenticationManager(authenticationConfiguration), jwtUtil), UsernamePasswordAuthenticationFilter.class); - - //세션 설정 - http - .sessionManagement((session) -> session - .sessionCreationPolicy(SessionCreationPolicy.STATELESS)); - - - return http.build(); - } -} - diff --git a/src/main/java/com/cmc/suppin/global/security/config/WebMvcConfig.java b/src/main/java/com/cmc/suppin/global/security/config/WebMvcConfig.java new file mode 100644 index 0000000..0ee074e --- /dev/null +++ b/src/main/java/com/cmc/suppin/global/security/config/WebMvcConfig.java @@ -0,0 +1,42 @@ +package com.cmc.suppin.global.security.config; + +import com.cmc.suppin.global.security.reslover.CurrentAccountArgumentResolver; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import java.util.Arrays; +import java.util.List; + +@Configuration +@RequiredArgsConstructor +public class WebMvcConfig implements WebMvcConfigurer { + + private final CurrentAccountArgumentResolver currentAccountArgumentResolver; + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(currentAccountArgumentResolver); + } + + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/**") + .allowedOrigins(getAllowOrigins()) + .allowedHeaders("Authorization", "Cache-Control", "Content-Type") + .allowedMethods("GET", "POST", "PUT", "DELETE", "PATCH") + .allowCredentials(true); + } + + private String[] getAllowOrigins() { + return Arrays.asList( + "http://localhost:3000", + "https://localhost:3000", + "https://dev.suppin.store", + "https://suppin.store", + "http://192.168.0.100:3000" // 모바일 디바이스의 IP 주소 + ).toArray(String[]::new); + } +} diff --git a/src/main/java/com/cmc/suppin/global/security/config/WebSecurityConfig.java b/src/main/java/com/cmc/suppin/global/security/config/WebSecurityConfig.java new file mode 100644 index 0000000..bb0f6a9 --- /dev/null +++ b/src/main/java/com/cmc/suppin/global/security/config/WebSecurityConfig.java @@ -0,0 +1,139 @@ +package com.cmc.suppin.global.security.config; + +import com.cmc.suppin.global.security.jwt.JwtAccessDeniedHandler; +import com.cmc.suppin.global.security.jwt.JwtAuthenticationEntryPoint; +import com.cmc.suppin.global.security.jwt.JwtAuthenticationFilter; +import com.cmc.suppin.global.security.jwt.JwtTokenProvider; +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.annotation.web.configurers.HeadersConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.factory.PasswordEncoderFactories; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.access.ExceptionTranslationFilter; +import org.springframework.security.web.util.matcher.RequestMatcher; + +import java.util.List; + +import static com.cmc.suppin.global.enums.UserRole.ROLE_USER; +import static org.springframework.http.HttpMethod.PATCH; +import static org.springframework.security.web.util.matcher.AntPathRequestMatcher.antMatcher; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class WebSecurityConfig { + + private final JwtAccessDeniedHandler jwtAccessDeniedHandler; + private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; + private final JwtTokenProvider jwtTokenProvider; + + /** + * 패스워드 관련 여러 인코딩 알고리즘 사용을 제공하는 DelegatingPasswordEncoder + */ + @Bean + public PasswordEncoder passwordEncoder() { + return PasswordEncoderFactories.createDelegatingPasswordEncoder(); + } + + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration authConfig) throws Exception { + return authConfig.getAuthenticationManager(); + } + + /** + * permitAll 권한을 가진 엔드포인트에 적용되는 SecurityFilterChain + */ + @Bean + public SecurityFilterChain securityFilterChainPermitAll(HttpSecurity http) throws Exception { + configureCommonSecuritySettings(http); + http.securityMatchers(matchers -> matchers.requestMatchers(requestPermitAll())) + .authorizeHttpRequests(authorize -> authorize + .anyRequest() + .permitAll()); + return http.build(); + } + + /** + * 인증 및 인가가 필요한 엔드포인트에 적용되는 SecurityFilterChain 입니다. + */ + @Bean + public SecurityFilterChain securityFilterChainAuthorized(HttpSecurity http) throws Exception { + configureCommonSecuritySettings(http); + http + .securityMatchers(matchers -> matchers + .requestMatchers(requestHasRoleUser()) + ) + .authorizeHttpRequests(auth -> auth + .requestMatchers(requestHasRoleUser()).hasAuthority(ROLE_USER.name()) + .anyRequest().authenticated() // 여기서 GUEST 권한도 처리 + ) + .exceptionHandling(exception -> { + exception.authenticationEntryPoint(jwtAuthenticationEntryPoint); + exception.accessDeniedHandler(jwtAccessDeniedHandler); + }) + .addFilterAfter(new JwtAuthenticationFilter(jwtTokenProvider), ExceptionTranslationFilter.class); + return http.build(); + } + + /** + * 위에서 정의된 엔드포인트 이외에는 authenticated로 설정 + */ + @Bean + public SecurityFilterChain securityFilterChainDefault(HttpSecurity http) throws Exception { + configureCommonSecuritySettings(http); + http + .authorizeHttpRequests(authorize -> authorize + .anyRequest() + .authenticated() + ) + .addFilterAfter(new JwtAuthenticationFilter(jwtTokenProvider), ExceptionTranslationFilter.class) + .exceptionHandling(exception -> { + exception.authenticationEntryPoint(jwtAuthenticationEntryPoint); + exception.accessDeniedHandler(jwtAccessDeniedHandler); + }); + return http.build(); + } + + private RequestMatcher[] requestHasRoleUser() { + List requestMatchers = List.of( + antMatcher("/api/v1/members/**"), + antMatcher(PATCH, "/api/members") + ); + return requestMatchers.toArray(RequestMatcher[]::new); + } + + private RequestMatcher[] requestPermitAll() { + List requestMatchers = List.of( + antMatcher("/"), + antMatcher("/swagger-ui/**"), + antMatcher("/actuator/**"), + antMatcher("/v3/api-docs/**"), + antMatcher("/api/v1/members/login/**"), + antMatcher("/api/v1/members/join"), + antMatcher("/api/v1/survey/reply/**") + ); + return requestMatchers.toArray(RequestMatcher[]::new); + } + + private void configureCommonSecuritySettings(HttpSecurity http) throws Exception { + http + .csrf(AbstractHttpConfigurer::disable) + .anonymous(AbstractHttpConfigurer::disable) + .formLogin(AbstractHttpConfigurer::disable) + .httpBasic(AbstractHttpConfigurer::disable) + .rememberMe(AbstractHttpConfigurer::disable) + .headers(headers -> headers + .frameOptions(HeadersConfigurer.FrameOptionsConfig::disable) + ) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)); + } +} + diff --git a/src/main/java/com/cmc/suppin/global/security/exception/SecurityException.java b/src/main/java/com/cmc/suppin/global/security/exception/SecurityException.java new file mode 100644 index 0000000..cdf84f2 --- /dev/null +++ b/src/main/java/com/cmc/suppin/global/security/exception/SecurityException.java @@ -0,0 +1,15 @@ +package com.cmc.suppin.global.security.exception; + +import com.cmc.suppin.global.exception.BaseErrorCode; +import lombok.Getter; + +@Getter +public class SecurityException extends RuntimeException { + + private final BaseErrorCode errorCode; + + public SecurityException(BaseErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + } +} diff --git a/src/main/java/com/cmc/suppin/global/security/jwt/JWTFilter.java b/src/main/java/com/cmc/suppin/global/security/jwt/JWTFilter.java deleted file mode 100644 index e57b279..0000000 --- a/src/main/java/com/cmc/suppin/global/security/jwt/JWTFilter.java +++ /dev/null @@ -1,72 +0,0 @@ -package com.cmc.suppin.global.security.jwt; - -import com.cmc.suppin.member.controller.dto.MemberDetails; -import com.cmc.suppin.member.domain.Member; -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.web.filter.OncePerRequestFilter; - -import java.io.IOException; - -public class JWTFilter extends OncePerRequestFilter { - - private final JWTUtil jwtUtil; - - public JWTFilter(JWTUtil jwtUtil) { - this.jwtUtil = jwtUtil; - } - - @Override - protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { - - //request에서 Authorization 헤더를 찾음 - String authorization = request.getHeader("Authorization"); - - //Authorization 헤더 검증(토큰이 없는 경우 처리해주는 부분) - if (authorization == null || !authorization.startsWith("Bearer ")) { - - System.out.println("token null"); - filterChain.doFilter(request, response); - - //조건이 해당되면 메소드 종료 (필수) - return; - } - - //Bearer 부분 제거 후 순수 토큰만 획득 - String token = authorization.split(" ")[1]; - - //토큰 소멸 시간 검증(토큰 만료시 처리해주는 부분) - if (jwtUtil.isExpired(token)) { - - System.out.println("token expired"); - filterChain.doFilter(request, response); - - //조건이 해당되면 메소드 종료 (필수) - return; - } - - //토큰에서 username과 role 획득 - String username = jwtUtil.getUsername(token); - String role = jwtUtil.getRole(token); - - // Member Entity를 생성하여 값 set - Member member = new Member(username, "tempPassword", "tempEmail", "tempPhoneNumber", role); - - - //MemberDetails에 회원 정보 객체 담기 - MemberDetails customUserDetails = new MemberDetails(member); - - //스프링 시큐리티 인증 토큰 생성 - Authentication authToken = new UsernamePasswordAuthenticationToken(customUserDetails, null, customUserDetails.getAuthorities()); - //세션에 사용자 등록 - SecurityContextHolder.getContext().setAuthentication(authToken); - - filterChain.doFilter(request, response); - } -} - diff --git a/src/main/java/com/cmc/suppin/global/security/jwt/JWTUtil.java b/src/main/java/com/cmc/suppin/global/security/jwt/JWTUtil.java deleted file mode 100644 index 358c6a1..0000000 --- a/src/main/java/com/cmc/suppin/global/security/jwt/JWTUtil.java +++ /dev/null @@ -1,48 +0,0 @@ -package com.cmc.suppin.global.security.jwt; - -import io.jsonwebtoken.Jwts; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Component; - -import javax.crypto.SecretKey; -import javax.crypto.spec.SecretKeySpec; -import java.nio.charset.StandardCharsets; -import java.util.Date; - -@Component -public class JWTUtil { - - private SecretKey secretKey; - - public JWTUtil(@Value("${JWT_TOKEN_SECRET}") String secret) { - - this.secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), Jwts.SIG.HS256.key().build().getAlgorithm()); - } - - public String getUsername(String token) { - - return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("username", String.class); - } - - public String getRole(String token) { - - return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("role", String.class); - } - - public Boolean isExpired(String token) { - - return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().getExpiration().before(new Date()); - } - - public String createJwt(String userId, String role, Long expiredMs) { - - return Jwts.builder() - .claim("userId", userId) - .claim("role", role) - .issuedAt(new Date(System.currentTimeMillis())) - .expiration(new Date(System.currentTimeMillis() + expiredMs)) - .signWith(secretKey) - .compact(); - } -} - diff --git a/src/main/java/com/cmc/suppin/global/security/jwt/JwtAccessDeniedHandler.java b/src/main/java/com/cmc/suppin/global/security/jwt/JwtAccessDeniedHandler.java new file mode 100644 index 0000000..bf16017 --- /dev/null +++ b/src/main/java/com/cmc/suppin/global/security/jwt/JwtAccessDeniedHandler.java @@ -0,0 +1,21 @@ +package com.cmc.suppin.global.security.jwt; + +import com.cmc.suppin.global.exception.SecurityErrorCode; +import com.cmc.suppin.global.security.util.HttpResponseUtil; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +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 JwtAccessDeniedHandler implements AccessDeniedHandler { + + @Override + public void handle(HttpServletRequest request, HttpServletResponse response, + AccessDeniedException accessDeniedException) throws IOException { + HttpResponseUtil.writeErrorResponse(response, SecurityErrorCode.FORBIDDEN); + } +} diff --git a/src/main/java/com/cmc/suppin/global/security/jwt/JwtAuthenticationEntryPoint.java b/src/main/java/com/cmc/suppin/global/security/jwt/JwtAuthenticationEntryPoint.java new file mode 100644 index 0000000..7c7c1b0 --- /dev/null +++ b/src/main/java/com/cmc/suppin/global/security/jwt/JwtAuthenticationEntryPoint.java @@ -0,0 +1,21 @@ +package com.cmc.suppin.global.security.jwt; + +import com.cmc.suppin.global.exception.SecurityErrorCode; +import com.cmc.suppin.global.security.util.HttpResponseUtil; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { + + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, + AuthenticationException authException) throws IOException { + HttpResponseUtil.writeErrorResponse(response, SecurityErrorCode.UNAUTHORIZED); + } +} diff --git a/src/main/java/com/cmc/suppin/global/security/jwt/JwtAuthenticationFilter.java b/src/main/java/com/cmc/suppin/global/security/jwt/JwtAuthenticationFilter.java new file mode 100644 index 0000000..05d86a2 --- /dev/null +++ b/src/main/java/com/cmc/suppin/global/security/jwt/JwtAuthenticationFilter.java @@ -0,0 +1,52 @@ +package com.cmc.suppin.global.security.jwt; + +import com.cmc.suppin.global.exception.SecurityErrorCode; +import com.cmc.suppin.global.security.util.HttpResponseUtil; +import io.jsonwebtoken.ExpiredJwtException; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +import static org.springframework.util.StringUtils.hasText; + +/** + * jwt 토큰의 존재여부, 유효기간, 형식 검증 후 SecurityContextHolder에 Authentication을 저장해주는 역할 + */ +@Slf4j +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private static final String AUTHENTICATION_HEADER = "Authorization"; + private final JwtTokenProvider jwtTokenProvider; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + try { + String accessToken = jwtTokenProvider.resolveToken(request.getHeader(AUTHENTICATION_HEADER)); + if (hasText(accessToken)) { + log.info(">>>>>> AccessToken : {}", accessToken); + jwtTokenProvider.validateAccessToken(accessToken); + Authentication authentication = jwtTokenProvider.getAuthentication(accessToken); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + } catch (ExpiredJwtException e) { + log.warn(">>>>>> AccessToken is Expired : ", e); + HttpResponseUtil.writeErrorResponse(response, SecurityErrorCode.ACCESS_TOKEN_EXPIRED); + return; + } catch (Exception e) { + log.warn(">>>>>> Authentication Failed : ", e); + HttpResponseUtil.writeErrorResponse(response, SecurityErrorCode.INVALID_TOKEN); + return; + } + filterChain.doFilter(request, response); + } +} diff --git a/src/main/java/com/cmc/suppin/global/security/jwt/JwtTokenProvider.java b/src/main/java/com/cmc/suppin/global/security/jwt/JwtTokenProvider.java new file mode 100644 index 0000000..b3c703d --- /dev/null +++ b/src/main/java/com/cmc/suppin/global/security/jwt/JwtTokenProvider.java @@ -0,0 +1,139 @@ +package com.cmc.suppin.global.security.jwt; + + +import com.cmc.suppin.global.security.user.UserDetailsImpl; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.time.Instant; +import java.util.Arrays; +import java.util.Collection; +import java.util.Date; +import java.util.Map; +import java.util.stream.Collectors; + +import static org.springframework.util.StringUtils.hasText; + +@Slf4j +@Component +@RequiredArgsConstructor +public class JwtTokenProvider { + + private static final String AUTHENTICATION_CLAIM_NAME = "roles"; + private static final String AUTHENTICATION_SCHEME = "Bearer "; + + @Value("${JWT_SECRET_KEY}") + private String secretKey; + + @Value("${ACCESS_EXPIRY_SECONDS}") + private int accessExpirySeconds; + +// @Value("${jwt.refresh-expiry-seconds}") +// private int refreshExpirySeconds; + +// private final RedisKeyRepository redisKeyRepository; + + public String createAccessToken(UserDetailsImpl userDetails) { + Instant now = Instant.now(); + Instant expirationTime = now.plusSeconds(accessExpirySeconds); + + String authorities = userDetails.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .collect(Collectors.joining(",")); + + return Jwts.builder() + .subject((userDetails.getUsername())) + .claims( + Map.of("id", userDetails.getId(), + "userId", userDetails.getUserId(), + AUTHENTICATION_CLAIM_NAME, authorities)) + .issuedAt(Date.from(now)) + .expiration(Date.from(expirationTime)) + .signWith(extractSecretKey()) + .compact(); + } + +// public String createRefreshToken() { +// Instant now = Instant.now(); +// Instant expirationTime = now.plusSeconds(refreshExpirySeconds); +// +// return Jwts.builder() +// .issuedAt(Date.from(now)) +// .expiration(Date.from(expirationTime)) +// .signWith(extractSecretKey()) +// .compact(); +// } + + /** + * 권한 체크 + */ + public Authentication getAuthentication(String accessToken) { + Claims claims = verifyAndExtractClaims(accessToken); + + Collection authorities = null; + if (claims.get(AUTHENTICATION_CLAIM_NAME) != null) { + authorities = Arrays.stream(claims.get(AUTHENTICATION_CLAIM_NAME) + .toString() + .split(",")) + .map(SimpleGrantedAuthority::new) + .toList(); + } + + UserDetailsImpl principal = UserDetailsImpl.builder() + .id(claims.get("id", Long.class)) + .userId(claims.get("userId", String.class)) + .password(null) + .authorities(authorities) + .build(); + + return new UsernamePasswordAuthenticationToken(principal, accessToken, authorities); + } + + /** + * 토큰 추출 + */ + public String resolveToken(String bearerToken) { + if (hasText(bearerToken) && bearerToken.startsWith(AUTHENTICATION_SCHEME)) { + return bearerToken.substring(AUTHENTICATION_SCHEME.length()); + } + return null; + } + + /** + * Jwt 검증 및 클레임 추출 + */ + private Claims verifyAndExtractClaims(String accessToken) { + return Jwts.parser() + .verifyWith(extractSecretKey()) + .build() + .parseSignedClaims(accessToken) + .getPayload(); + } + + public void validateAccessToken(String accessToken) { + Jwts.parser() + .verifyWith(extractSecretKey()) + .build() + .parse(accessToken); + } + + /** + * SecretKey 추출 + */ + private SecretKey extractSecretKey() { + return Keys.hmacShaKeyFor(Decoders.BASE64.decode(secretKey)); + } +} + + diff --git a/src/main/java/com/cmc/suppin/global/security/jwt/LoginFilter.java b/src/main/java/com/cmc/suppin/global/security/jwt/LoginFilter.java deleted file mode 100644 index fd9a419..0000000 --- a/src/main/java/com/cmc/suppin/global/security/jwt/LoginFilter.java +++ /dev/null @@ -1,77 +0,0 @@ -package com.cmc.suppin.global.security.jwt; - -import com.cmc.suppin.member.controller.dto.MemberDetails; -import jakarta.servlet.FilterChain; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import org.springframework.security.authentication.AuthenticationManager; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.AuthenticationException; -import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; - -import java.util.Collection; -import java.util.Iterator; - -public class LoginFilter extends UsernamePasswordAuthenticationFilter { - - private final AuthenticationManager authenticationManager; - - private final JWTUtil jwtUtil; - - public LoginFilter(AuthenticationManager authenticationManager, JWTUtil jwtUtil) { - - this.authenticationManager = authenticationManager; - this.jwtUtil = jwtUtil; - } - - @Override - public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException { - - //클라이언트 요청에서 username, password 추출 - String username = obtainUsername(request); - String password = obtainPassword(request); - - //스프링 시큐리티에서 username과 password를 검증하기 위해서는 token에 담아야 함 - UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(username, password, null); - - //token에 담은 검증을 위한 AuthenticationManager로 전달 - return authenticationManager.authenticate(authToken); - } - - //로그인 성공시 실행하는 메소드 (여기서 JWT를 발급됨) - @Override - protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authentication) { - - MemberDetails memberDetails = (MemberDetails) authentication.getPrincipal(); - - String username = memberDetails.getUsername(); - - Collection authorities = authentication.getAuthorities(); - if (authorities.isEmpty()) { - throw new IllegalStateException("권한이 없습니다."); - } - - Iterator iterator = authorities.iterator(); - GrantedAuthority auth = iterator.next(); - - String role = auth.getAuthority(); - - long expirationTime = 1000 * 60 * 60 * 24 * 7; // 7일 - - String token = jwtUtil.createJwt(username, role, expirationTime); - - response.addHeader("Authorization", "Bearer " + token); - } - - - //로그인 실패시 실행하는 메소드 - @Override - protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) { - - //로그인 실패시 401 응답코드 반환 - response.setStatus(401); - } -} - diff --git a/src/main/java/com/cmc/suppin/global/security/reslover/Account.java b/src/main/java/com/cmc/suppin/global/security/reslover/Account.java new file mode 100644 index 0000000..22e8939 --- /dev/null +++ b/src/main/java/com/cmc/suppin/global/security/reslover/Account.java @@ -0,0 +1,11 @@ +package com.cmc.suppin.global.security.reslover; + +public record Account( + Long id, + String userId, + String role +) { + public static Account of(Long id, String userId, String role) { + return new Account(id, userId, role); + } +} diff --git a/src/main/java/com/cmc/suppin/global/security/reslover/CurrentAccount.java b/src/main/java/com/cmc/suppin/global/security/reslover/CurrentAccount.java new file mode 100644 index 0000000..b742eb8 --- /dev/null +++ b/src/main/java/com/cmc/suppin/global/security/reslover/CurrentAccount.java @@ -0,0 +1,14 @@ +package com.cmc.suppin.global.security.reslover; + +import io.swagger.v3.oas.annotations.Parameter; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Parameter(hidden = true) +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +public @interface CurrentAccount { +} diff --git a/src/main/java/com/cmc/suppin/global/security/reslover/CurrentAccountArgumentResolver.java b/src/main/java/com/cmc/suppin/global/security/reslover/CurrentAccountArgumentResolver.java new file mode 100644 index 0000000..9c3a92f --- /dev/null +++ b/src/main/java/com/cmc/suppin/global/security/reslover/CurrentAccountArgumentResolver.java @@ -0,0 +1,30 @@ +package com.cmc.suppin.global.security.reslover; + +import com.cmc.suppin.global.security.util.SecurityUtil; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.MethodParameter; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +@Slf4j +@Component +@RequiredArgsConstructor +public class CurrentAccountArgumentResolver implements HandlerMethodArgumentResolver { + + @Override + public boolean supportsParameter(MethodParameter parameter) { + boolean hasParameterAnnotation = parameter.hasParameterAnnotation(CurrentAccount.class); + boolean hasLongParameterType = parameter.getParameterType().isAssignableFrom(Account.class); + return hasParameterAnnotation && hasLongParameterType; + } + + @Override + public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, WebDataBinderFactory binderFactory) { + return SecurityUtil.getCurrentAccount(); + } +} diff --git a/src/main/java/com/cmc/suppin/global/security/service/MemberDetailsService.java b/src/main/java/com/cmc/suppin/global/security/service/MemberDetailsService.java new file mode 100644 index 0000000..378d38b --- /dev/null +++ b/src/main/java/com/cmc/suppin/global/security/service/MemberDetailsService.java @@ -0,0 +1,56 @@ +package com.cmc.suppin.global.security.service; + +import com.cmc.suppin.global.security.exception.SecurityException; +import com.cmc.suppin.global.security.user.UserDetailsImpl; +import com.cmc.suppin.member.domain.Member; +import com.cmc.suppin.member.domain.repository.MemberRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +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; + +import java.util.ArrayList; +import java.util.List; + +import static com.cmc.suppin.global.exception.MemberErrorCode.MEMBER_ALREADY_DELETED; +import static com.cmc.suppin.global.exception.MemberErrorCode.MEMBER_NOT_FOUND; + +@Service +@RequiredArgsConstructor +public class MemberDetailsService implements UserDetailsService { + + private final MemberRepository memberRepository; + + @Override + public UserDetails loadUserByUsername(String userId) throws UsernameNotFoundException { + Member member = memberRepository.findByUserId(userId) + .orElseThrow(() -> new SecurityException(MEMBER_NOT_FOUND)); + + if (member.isDeleted()) { + throw new SecurityException(MEMBER_ALREADY_DELETED); + } + + List authorities = getAuthorities(member); + + // 기본 ROLE_USER 권한 추가 + authorities.add(new SimpleGrantedAuthority("ROLE_USER")); + + return UserDetailsImpl.builder() + .id(member.getId()) + .userId(member.getUserId()) + .password(member.getPassword()) + .authorities(authorities) + .build(); + } + + private List getAuthorities(Member member) { + List authorities = new ArrayList<>(); + if (member.getRole() != null) { + authorities.add(new SimpleGrantedAuthority(member.getRole().name())); + } + return authorities; + } +} diff --git a/src/main/java/com/cmc/suppin/global/security/user/UserDetailsImpl.java b/src/main/java/com/cmc/suppin/global/security/user/UserDetailsImpl.java new file mode 100644 index 0000000..2f6363e --- /dev/null +++ b/src/main/java/com/cmc/suppin/global/security/user/UserDetailsImpl.java @@ -0,0 +1,71 @@ +package com.cmc.suppin.global.security.user; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import lombok.Builder; +import lombok.Getter; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.Collection; +import java.util.Collections; + +@Getter +public class UserDetailsImpl implements UserDetails { + + private Long id; + private String userId; + @JsonIgnore + private String password; + private Collection authorities; + + @Builder + public UserDetailsImpl( + Long id, + String userId, + String password, + Collection authorities) { + this.id = id; + this.userId = userId; + this.password = password; + this.authorities = authorities != null ? authorities : Collections.emptyList(); + } + + public String getAuthority() { + return authorities.isEmpty() ? null : authorities.iterator().next().getAuthority(); + } + + @Override + public Collection getAuthorities() { + return authorities != null ? authorities : Collections.emptyList(); + } + + @Override + public String getPassword() { + return password; + } + + @Override + public String getUsername() { + return userId; + } + + @Override + public boolean isAccountNonExpired() { + return true; + } + + @Override + public boolean isAccountNonLocked() { + return true; + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @Override + public boolean isEnabled() { + return true; + } +} diff --git a/src/main/java/com/cmc/suppin/global/security/util/HttpResponseUtil.java b/src/main/java/com/cmc/suppin/global/security/util/HttpResponseUtil.java new file mode 100644 index 0000000..d836aa6 --- /dev/null +++ b/src/main/java/com/cmc/suppin/global/security/util/HttpResponseUtil.java @@ -0,0 +1,37 @@ +package com.cmc.suppin.global.security.util; + +import com.cmc.suppin.global.exception.BaseErrorCode; +import com.cmc.suppin.global.response.ApiResponse; +import com.cmc.suppin.global.response.ErrorResponse; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletResponse; +import lombok.NoArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; + +import java.io.IOException; + +@NoArgsConstructor(access = lombok.AccessLevel.PRIVATE) +public class HttpResponseUtil { + + private static final ObjectMapper objectMapper = new ObjectMapper(); + + public static void setSuccessResponse(HttpServletResponse response, HttpStatus httpStatus, Object body) + throws IOException { + String responseBody = objectMapper.writeValueAsString(ApiResponse.of(body)); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setStatus(httpStatus.value()); + response.setCharacterEncoding("UTF-8"); + response.getWriter().write(responseBody); + } + + public static void writeErrorResponse(HttpServletResponse response, BaseErrorCode errorCode) throws + IOException { + final ErrorResponse errorResponse = errorCode.getErrorResponse(); + String responseBody = objectMapper.writeValueAsString(errorResponse); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setStatus(errorCode.getStatus().value()); + response.setCharacterEncoding("UTF-8"); + response.getWriter().write(responseBody); + } +} diff --git a/src/main/java/com/cmc/suppin/global/security/util/SecurityUtil.java b/src/main/java/com/cmc/suppin/global/security/util/SecurityUtil.java new file mode 100644 index 0000000..a523ea9 --- /dev/null +++ b/src/main/java/com/cmc/suppin/global/security/util/SecurityUtil.java @@ -0,0 +1,33 @@ +package com.cmc.suppin.global.security.util; + +import com.cmc.suppin.global.exception.SecurityErrorCode; +import com.cmc.suppin.global.security.exception.SecurityException; +import com.cmc.suppin.global.security.reslover.Account; +import com.cmc.suppin.global.security.user.UserDetailsImpl; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.util.Objects; + +@Slf4j +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class SecurityUtil { + + public static Account getCurrentAccount() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + validateAuthentication(authentication); + UserDetailsImpl userDetails = (UserDetailsImpl) authentication.getPrincipal(); + return Account.of(userDetails.getId(), userDetails.getUserId(), userDetails.getAuthority()); + } + + private static void validateAuthentication(Authentication authentication) { + if (Objects.isNull(authentication) || !(authentication instanceof UsernamePasswordAuthenticationToken)) { + log.error(">>>>>> Invalid Authentication : {}", authentication); + throw new SecurityException(SecurityErrorCode.UNAUTHORIZED); + } + } +} diff --git a/src/main/java/com/cmc/suppin/member/controller/MemberApi.java b/src/main/java/com/cmc/suppin/member/controller/MemberApi.java index 7c6f2be..7fe644b 100644 --- a/src/main/java/com/cmc/suppin/member/controller/MemberApi.java +++ b/src/main/java/com/cmc/suppin/member/controller/MemberApi.java @@ -1,19 +1,20 @@ package com.cmc.suppin.member.controller; -import com.cmc.suppin.global.exception.status.SuccessStatus; -import com.cmc.suppin.global.presentation.ApiResponse; -import com.cmc.suppin.member.controller.dto.MemberDetails; +import com.cmc.suppin.global.response.ApiResponse; +import com.cmc.suppin.global.response.ResponseCode; +import com.cmc.suppin.global.security.reslover.Account; +import com.cmc.suppin.global.security.reslover.CurrentAccount; import com.cmc.suppin.member.controller.dto.MemberRequestDTO; import com.cmc.suppin.member.controller.dto.MemberResponseDTO; import com.cmc.suppin.member.converter.MemberConverter; import com.cmc.suppin.member.domain.Member; -import com.cmc.suppin.member.service.command.MemberCommandService; +import com.cmc.suppin.member.service.command.MemberService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.http.ResponseEntity; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; @@ -22,52 +23,50 @@ @RequiredArgsConstructor @Validated @Tag(name = "Member", description = "Member 관련 API") -@RequestMapping("/api/members") +@RequestMapping("/api/v1/members") public class MemberApi { - private final MemberCommandService memberCommandService; + private final MemberService memberService; - /** - * 회원가입 - */ + // 회원가입 @PostMapping("/join") @Operation(summary = "회원가입 API", description = "request 파라미터 : id, password, name, phone, email") - public ApiResponse join(@RequestBody @Valid MemberRequestDTO.JoinDTO request) { - Member member = memberCommandService.join(request); - - return ApiResponse.onSuccess(MemberConverter.toJoinResultDTO(member), SuccessStatus.MEMBER_JOIN_SUCCESS); + public ResponseEntity> join(@RequestBody @Valid MemberRequestDTO.JoinDTO request) { + Member member = memberService.join(request); + return ResponseEntity.ok(ApiResponse.of(MemberConverter.toJoinResultDTO(member))); } + // 아이디 중복 체크 @PostMapping("/checkUserId") @Operation(summary = "아이디 중복 체크 API", description = "request : userId, response: 중복이면 false, 중복 아니면 true") - public ApiResponse checkUserId(@RequestBody MemberRequestDTO.IdConfirmDTO request) { - boolean checkUserId = memberCommandService.confirmUserId(request); + public ResponseEntity> checkUserId(@RequestBody MemberRequestDTO.IdConfirmDTO request) { + boolean checkUserId = memberService.confirmUserId(request); - return ApiResponse.onSuccess(MemberConverter.toIdConfirmResultDTO(checkUserId), SuccessStatus.MEMBER_ID_CONFIRM_SUCCESS); + return ResponseEntity.ok(ApiResponse.of(MemberConverter.toIdConfirmResultDTO(checkUserId))); } + // 회원탈퇴 @DeleteMapping("/delete") - @Operation(summary = "회원탈퇴 API", description = "JWT 토큰을 헤더에 포함시켜 보내주시면 됩니다.") - public ApiResponse deleteMember(@AuthenticationPrincipal MemberDetails memberDetails) { - if (memberDetails == null) { - return ApiResponse.onFailure("403", "인증된 사용자만 삭제할 수 있습니다.", null); - } - memberCommandService.deleteMember(memberDetails.getUserId()); - return ApiResponse.onSuccess(null, SuccessStatus.MEMBER_DELETE_SUCCESS); + @Operation(summary = "회원탈퇴 API", description = "로그인 시 발급받은 토큰으로 인가 필요") + public ResponseEntity> deleteMember( + @CurrentAccount Account account) { + memberService.deleteMember(account.id()); + return ResponseEntity.ok(ApiResponse.of(ResponseCode.SUCCESS)); } - /** - * TODO: 로그인, 로그아웃, 비밀번호 변경, 회원정보 상세 조회, 회원정보 수정 API - */ // 로그인 @PostMapping("/login") @Operation(summary = "로그인 API", description = "request : userId, password") - public ApiResponse login(@RequestBody @Valid MemberRequestDTO.LoginRequestDTO request) { - MemberResponseDTO.LoginResponseDTO response = memberCommandService.login(request); - return ApiResponse.onSuccess(response, SuccessStatus.MEMBER_LOGIN_SUCCESS); + public ResponseEntity> login(@RequestBody @Valid MemberRequestDTO.LoginRequestDTO request) { + MemberResponseDTO.LoginResponseDTO response = memberService.login(request); + + return ResponseEntity.ok(ApiResponse.of(response)); } + /** + * TODO: 로그아웃, 비밀번호 변경, 회원정보 상세 조회, 회원정보 수정 API + */ // // 로그아웃 // @PostMapping("/logout") diff --git a/src/main/java/com/cmc/suppin/member/controller/dto/MemberDetails.java b/src/main/java/com/cmc/suppin/member/controller/dto/MemberDetails.java deleted file mode 100644 index db9b49b..0000000 --- a/src/main/java/com/cmc/suppin/member/controller/dto/MemberDetails.java +++ /dev/null @@ -1,67 +0,0 @@ -package com.cmc.suppin.member.controller.dto; - -import com.cmc.suppin.member.domain.Member; -import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.core.userdetails.UserDetails; - -import java.util.ArrayList; -import java.util.Collection; - -public class MemberDetails implements UserDetails { - - private final Member member; - - public MemberDetails(Member member) { - this.member = member; - } - - @Override - public Collection getAuthorities() { - - Collection collection = new ArrayList<>(); - - collection.add(new GrantedAuthority() { - @Override - public String getAuthority() { - return member.getRole(); - } - }); - - return collection; - } - - @Override - public String getPassword() { - return member.getPassword(); - } - - @Override - public String getUsername() { - return member.getUserId(); - } - - public String getUserId() { - return member.getUserId(); - } - - - @Override - public boolean isAccountNonExpired() { - return true; - } - - @Override - public boolean isAccountNonLocked() { - return true; - } - - @Override - public boolean isCredentialsNonExpired() { - return true; - } - - @Override - public boolean isEnabled() { - return true; - } -} diff --git a/src/main/java/com/cmc/suppin/member/converter/MemberConverter.java b/src/main/java/com/cmc/suppin/member/converter/MemberConverter.java index e7f6681..14e6820 100644 --- a/src/main/java/com/cmc/suppin/member/converter/MemberConverter.java +++ b/src/main/java/com/cmc/suppin/member/converter/MemberConverter.java @@ -3,7 +3,7 @@ import com.cmc.suppin.member.controller.dto.MemberRequestDTO; import com.cmc.suppin.member.controller.dto.MemberResponseDTO; import com.cmc.suppin.member.domain.Member; -import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Component; import java.time.LocalDateTime; @@ -11,7 +11,7 @@ @Component public class MemberConverter { - public Member toEntity(MemberRequestDTO.JoinDTO request, BCryptPasswordEncoder encoder) { + public Member toEntity(MemberRequestDTO.JoinDTO request, PasswordEncoder encoder) { return Member.builder() .userId(request.getUserId()) .name(request.getName()) diff --git a/src/main/java/com/cmc/suppin/member/domain/Member.java b/src/main/java/com/cmc/suppin/member/domain/Member.java index c3a7016..7343681 100644 --- a/src/main/java/com/cmc/suppin/member/domain/Member.java +++ b/src/main/java/com/cmc/suppin/member/domain/Member.java @@ -2,6 +2,8 @@ import com.cmc.suppin.event.domain.Event; import com.cmc.suppin.global.domain.BaseDateTimeEntity; +import com.cmc.suppin.global.enums.UserRole; +import com.cmc.suppin.global.enums.UserStatus; import jakarta.persistence.*; import lombok.*; import org.hibernate.annotations.DynamicInsert; @@ -41,15 +43,27 @@ public class Member extends BaseDateTimeEntity { private Boolean termsAgree; - private String role; + @Enumerated(EnumType.STRING) + private UserRole role; + + @Enumerated(EnumType.STRING) + private UserStatus status; // 추가된 생성자 - public Member(String name, String password, String email, String phoneNumber, String role) { + public Member(String name, String password, String email, String phoneNumber, UserRole role) { this.name = name; this.password = password; this.email = email; this.phoneNumber = phoneNumber; this.role = role; } + + public boolean isDeleted() { + return this.status == UserStatus.DELETED; + } + + public void delete() { + this.status = UserStatus.DELETED; + } } diff --git a/src/main/java/com/cmc/suppin/member/domain/repository/MemberRepository.java b/src/main/java/com/cmc/suppin/member/domain/repository/MemberRepository.java index fb6eedd..985a7cd 100644 --- a/src/main/java/com/cmc/suppin/member/domain/repository/MemberRepository.java +++ b/src/main/java/com/cmc/suppin/member/domain/repository/MemberRepository.java @@ -9,6 +9,8 @@ public interface MemberRepository extends JpaRepository { Boolean existsByUserId(String userId); + Boolean existsByEmail(String email); + Optional findByUserId(String userId); void deleteByUserId(String userId); diff --git a/src/main/java/com/cmc/suppin/member/exception/MemberException.java b/src/main/java/com/cmc/suppin/member/exception/MemberException.java new file mode 100644 index 0000000..15c3b93 --- /dev/null +++ b/src/main/java/com/cmc/suppin/member/exception/MemberException.java @@ -0,0 +1,13 @@ +package com.cmc.suppin.member.exception; + +import com.cmc.suppin.global.exception.BaseErrorCode; +import com.cmc.suppin.global.exception.CustomException; +import lombok.Getter; + +@Getter +public class MemberException extends CustomException { + + public MemberException(BaseErrorCode errorCode) { + super(errorCode); + } +} diff --git a/src/main/java/com/cmc/suppin/member/service/command/MemberCommandService.java b/src/main/java/com/cmc/suppin/member/service/command/MemberCommandService.java deleted file mode 100644 index 051b4c1..0000000 --- a/src/main/java/com/cmc/suppin/member/service/command/MemberCommandService.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.cmc.suppin.member.service.command; - -import com.cmc.suppin.member.controller.dto.MemberRequestDTO; -import com.cmc.suppin.member.controller.dto.MemberResponseDTO; -import com.cmc.suppin.member.domain.Member; - -public interface MemberCommandService { - - Member join(MemberRequestDTO.JoinDTO request); - - Boolean confirmUserId(MemberRequestDTO.IdConfirmDTO request); - - void deleteMember(String memberId); - - MemberResponseDTO.LoginResponseDTO login(MemberRequestDTO.LoginRequestDTO request); -} diff --git a/src/main/java/com/cmc/suppin/member/service/command/MemberCommandServiceImpl.java b/src/main/java/com/cmc/suppin/member/service/command/MemberCommandServiceImpl.java deleted file mode 100644 index e5223fb..0000000 --- a/src/main/java/com/cmc/suppin/member/service/command/MemberCommandServiceImpl.java +++ /dev/null @@ -1,90 +0,0 @@ -package com.cmc.suppin.member.service.command; - -import com.cmc.suppin.global.security.jwt.JWTUtil; -import com.cmc.suppin.member.controller.dto.MemberRequestDTO; -import com.cmc.suppin.member.controller.dto.MemberResponseDTO; -import com.cmc.suppin.member.converter.MemberConverter; -import com.cmc.suppin.member.domain.Member; -import com.cmc.suppin.member.domain.repository.MemberRepository; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -@Service -@Slf4j -@RequiredArgsConstructor -@Transactional -public class MemberCommandServiceImpl implements MemberCommandService { - - private final MemberRepository memberRepository; - private final BCryptPasswordEncoder bCryptPasswordEncoder; - private final MemberConverter memberConverter; - private final JWTUtil jwtUtil; - - /** - * 회원가입 - */ - @Override - public Member join(MemberRequestDTO.JoinDTO request) { - // 중복된 아이디 체크 - if (memberRepository.existsByUserId(request.getUserId())) { - throw new IllegalArgumentException("이미 존재하는 유저입니다."); - } - - // 비밀번호 조건 검증 - String password = request.getPassword(); - if (!isValidPassword(password)) { - throw new IllegalArgumentException("비밀번호는 8~20자 영문, 숫자, 특수문자를 사용해야 합니다."); - } - - // DTO를 Entity로 변환 - Member member = memberConverter.toEntity(request, bCryptPasswordEncoder); - - // 회원 정보 저장 - memberRepository.save(member); - - return member; - } - - // 비밀번호 조건 검증 메서드 - private boolean isValidPassword(String password) { - return password.matches("(?=.*[0-9])(?=.*[a-zA-Z])(?=.*\\W)(?=\\S+$).{8,20}"); - } - - /** - * ID 중복 확인 - */ - @Override - public Boolean confirmUserId(MemberRequestDTO.IdConfirmDTO request) { - // 아이디 중복 체크 - return !memberRepository.existsByUserId(request.getUserId()); - } - - /** - * 회원 탈퇴 - */ - @Override - public void deleteMember(String memberId) { - memberRepository.deleteByUserId(memberId); - } - - /** - * 로그인 - */ - @Override - public MemberResponseDTO.LoginResponseDTO login(MemberRequestDTO.LoginRequestDTO request) { - Member member = memberRepository.findByUserId(request.getUserId()) - .orElseThrow(() -> new IllegalArgumentException("Invalid user ID or password")); - - if (!bCryptPasswordEncoder.matches(request.getPassword(), member.getPassword())) { - throw new IllegalArgumentException("Invalid user ID or password"); - } - - String token = jwtUtil.createJwt(member.getUserId(), member.getRole(), 604800000L); // 1주일 유효 토큰 - return MemberConverter.toLoginResponseDTO(token, member); - } - - -} diff --git a/src/main/java/com/cmc/suppin/member/service/command/MemberService.java b/src/main/java/com/cmc/suppin/member/service/command/MemberService.java new file mode 100644 index 0000000..6740516 --- /dev/null +++ b/src/main/java/com/cmc/suppin/member/service/command/MemberService.java @@ -0,0 +1,132 @@ +package com.cmc.suppin.member.service.command; + +import com.cmc.suppin.global.exception.MemberErrorCode; +import com.cmc.suppin.global.security.jwt.JwtTokenProvider; +import com.cmc.suppin.global.security.user.UserDetailsImpl; +import com.cmc.suppin.member.controller.dto.MemberRequestDTO; +import com.cmc.suppin.member.controller.dto.MemberResponseDTO; +import com.cmc.suppin.member.converter.MemberConverter; +import com.cmc.suppin.member.domain.Member; +import com.cmc.suppin.member.domain.repository.MemberRepository; +import com.cmc.suppin.member.exception.MemberException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Slf4j +@RequiredArgsConstructor +@Transactional +public class MemberService { + + private final MemberRepository memberRepository; + private final MemberConverter memberConverter; + private final JwtTokenProvider jwtTokenProvider; + private final AuthenticationManager authenticationManager; + private final PasswordEncoder passwordEncoder; + + /** + * 회원가입 + */ + public Member join(MemberRequestDTO.JoinDTO request) { + // 중복된 아이디 체크 + if (memberRepository.existsByUserId(request.getUserId())) { + throw new IllegalArgumentException("이미 존재하는 유저입니다."); + } + + // 비밀번호 조건 검증 + String password = request.getPassword(); + if (!isValidPassword(password)) { + throw new IllegalArgumentException("비밀번호는 8~20자 영문, 숫자, 특수문자를 사용해야 합니다."); + } + + // DTO를 Entity로 변환 + Member member = memberConverter.toEntity(request, passwordEncoder); + + // 회원 정보 저장 + memberRepository.save(member); + + return member; + } + + /** + * ID 중복 확인 + */ + public Boolean confirmUserId(MemberRequestDTO.IdConfirmDTO request) { + // 아이디 중복 체크 + return !memberRepository.existsByUserId(request.getUserId()); + } + + /** + * 이메일 중복 확인 + */ + public Boolean confirmEmail(MemberRequestDTO.JoinDTO request) { + // 이메일 중복 체크 + validateDuplicateEmail(request); + return true; + } + + /** + * 회원 탈퇴 + */ + public void deleteMember(Long memberId) { + final Member member = getMember(memberId); + if (member.isDeleted()) { + throw new MemberException(MemberErrorCode.MEMBER_NOT_FOUND); + } + member.delete(); + } + + /** + * 로그인 + */ + public MemberResponseDTO.LoginResponseDTO login(MemberRequestDTO.LoginRequestDTO request) { + validateMember(request.getUserId()); + + UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( + request.getUserId(), + request.getPassword() + ); + + Authentication authenticate = authenticationManager.authenticate(authentication); + + String accessToken = jwtTokenProvider.createAccessToken((UserDetailsImpl) authenticate.getPrincipal()); + + return MemberResponseDTO.LoginResponseDTO.builder() + .token(accessToken) + .userId(request.getUserId()) + .build(); + } + + // 검증 메서드 + private boolean isValidPassword(String password) { + return password.matches("(?=.*[0-9])(?=.*[a-zA-Z])(?=.*\\W)(?=\\S+$).{8,20}"); + } + + private void validateMember(String userId) { + Member member = memberRepository.findByUserId(userId) + .orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND)); + + if (member.isDeleted()) { + throw new MemberException(MemberErrorCode.MEMBER_NOT_FOUND); + } + } + + private Member getMember(Long memberId) { + return memberRepository.findById(memberId) + .orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND)); + } + + private void validateDuplicateEmail(MemberRequestDTO.JoinDTO request) { + if (Boolean.TRUE.equals(memberRepository.existsByEmail(request.getEmail()))) { + throw new MemberException(MemberErrorCode.DUPLICATE_MEMBER_EMAIL); + } + } + + +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 7c941fd..881f5bf 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -8,13 +8,18 @@ spring: driver-class-name: com.mysql.cj.jdbc.Driver jpa: hibernate: - ddl-auto: create + ddl-auto: update properties: hibernate: dialect: org.hibernate.dialect.MySQL8Dialect show_sql: true format_sql: true +jwt: + token: + secret: ${JWT_TOKEN_SECRET} + expriration: ${ACCESS_EXPIRY_SECONDS} + # multipart 파일 용량 늘려주는 부분 servlet: multipart: