From 0985de4036766c7794aeab7460c9648683503dce Mon Sep 17 00:00:00 2001 From: JeongHeumChoi <79458446+JeongHeumChoi@users.noreply.github.com> Date: Mon, 6 May 2024 18:29:20 +0900 Subject: [PATCH] =?UTF-8?q?=F0=9F=9A=80=20[Deploy]=20-=20=EC=86=8C?= =?UTF-8?q?=EC=85=9C=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EB=B0=8F=20ResponseD?= =?UTF-8?q?to=20=EB=B3=80=EA=B2=BD=EC=82=AC=ED=95=AD=20=EB=B0=98=EC=98=81?= =?UTF-8?q?=20(#13)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Feat: customException 구현 * Feat: responseDto 구현 * Feat: Spring Security, JWT Cookie, 소셜 로그인 기능 구현 * Feat: Spring Security, JWT Cookie, 소셜 로그인 기능 구현 * Fix: 코드 에러 수정 * Refactor: 아직 사용하지 않는 코드 삭제 * Feat: oauth2 의존성 추가 * Chore: Credentials 추가 --------- Co-authored-by: Jang99u Co-authored-by: 민장규 <133879283+Jang99u@users.noreply.github.com> --- build.gradle | 1 + spot-server-properties | 2 +- src/main/java/ice/spot/annotation/UserId.java | 11 +++ .../java/ice/spot/config/WebMVCConfig.java | 33 +++++++ .../java/ice/spot/constant/Constants.java | 16 +++ .../ice/spot/controller/AuthController.java | 27 +++++ src/main/java/ice/spot/domain/User.java | 33 ++++++- .../ice/spot/dto/global/ExceptionDto.java | 16 +++ .../java/ice/spot/dto/global/ResponseDto.java | 60 +++++++++++ .../ice/spot/dto/request/OauthSignUpDto.java | 9 ++ .../ice/spot/dto/response/JwtTokenDto.java | 18 ++++ .../java/ice/spot/dto/type/EProvider.java | 13 +++ src/main/java/ice/spot/dto/type/ERole.java | 16 +++ .../ice/spot/exeption/CommonException.java | 11 +++ .../java/ice/spot/exeption/ErrorCode.java | 40 ++++++++ .../spot/exeption/GlobalExceptionHandler.java | 48 +++++++++ .../interceptor/post/ResponseInterceptor.java | 32 ++++++ .../pre/UserIdArgumentResolver.java | 36 +++++++ .../interceptor/pre/UserIdInterceptor.java | 22 +++++ .../ice/spot/repository/UserRepository.java | 47 +++++++++ .../spot/security/config/PasswordConfig.java | 14 +++ .../spot/security/config/SecurityConfig.java | 75 ++++++++++++++ .../filter/JwtAuthenticationFilter.java | 70 +++++++++++++ .../security/filter/JwtExceptionFilter.java | 67 +++++++++++++ .../exception/CustomAccessDeniedHandler.java | 24 +++++ ...CustomAuthenticationEntryPointHandler.java | 30 ++++++ .../handler/login/Oauth2FailureHandler.java | 25 +++++ .../handler/login/Oauth2SuccessHandler.java | 45 +++++++++ .../logout/CustomLogoutProcessHandler.java | 29 ++++++ .../logout/CustomLogoutResultHandler.java | 37 +++++++ .../security/info/AuthenticationResponse.java | 72 ++++++++++++++ .../ice/spot/security/info/JwtUserInfo.java | 6 ++ .../security/info/KakaoOauth2UserInfo.java | 16 +++ .../ice/spot/security/info/UserPrincipal.java | 99 +++++++++++++++++++ .../security/info/factory/Oauth2UserInfo.java | 13 +++ .../info/factory/Oauth2UserInfoFactory.java | 22 +++++ .../provider/JwtAuthenticationManager.java | 22 +++++ .../provider/JwtAuthenticationProvider.java | 59 +++++++++++ .../CustomOauth2UserDetailService.java | 64 ++++++++++++ .../service/CustomUserDetailService.java | 38 +++++++ .../java/ice/spot/service/AuthService.java | 28 ++++++ src/main/java/ice/spot/util/CookieUtil.java | 85 ++++++++++++++++ src/main/java/ice/spot/util/HeaderUtil.java | 22 +++++ src/main/java/ice/spot/util/JwtUtil.java | 70 +++++++++++++ 44 files changed, 1520 insertions(+), 3 deletions(-) create mode 100644 src/main/java/ice/spot/annotation/UserId.java create mode 100644 src/main/java/ice/spot/config/WebMVCConfig.java create mode 100644 src/main/java/ice/spot/constant/Constants.java create mode 100644 src/main/java/ice/spot/controller/AuthController.java create mode 100644 src/main/java/ice/spot/dto/global/ExceptionDto.java create mode 100644 src/main/java/ice/spot/dto/global/ResponseDto.java create mode 100644 src/main/java/ice/spot/dto/request/OauthSignUpDto.java create mode 100644 src/main/java/ice/spot/dto/response/JwtTokenDto.java create mode 100644 src/main/java/ice/spot/dto/type/EProvider.java create mode 100644 src/main/java/ice/spot/dto/type/ERole.java create mode 100644 src/main/java/ice/spot/exeption/CommonException.java create mode 100644 src/main/java/ice/spot/exeption/ErrorCode.java create mode 100644 src/main/java/ice/spot/exeption/GlobalExceptionHandler.java create mode 100644 src/main/java/ice/spot/interceptor/post/ResponseInterceptor.java create mode 100644 src/main/java/ice/spot/interceptor/pre/UserIdArgumentResolver.java create mode 100644 src/main/java/ice/spot/interceptor/pre/UserIdInterceptor.java create mode 100644 src/main/java/ice/spot/repository/UserRepository.java create mode 100644 src/main/java/ice/spot/security/config/PasswordConfig.java create mode 100644 src/main/java/ice/spot/security/config/SecurityConfig.java create mode 100644 src/main/java/ice/spot/security/filter/JwtAuthenticationFilter.java create mode 100644 src/main/java/ice/spot/security/filter/JwtExceptionFilter.java create mode 100644 src/main/java/ice/spot/security/handler/exception/CustomAccessDeniedHandler.java create mode 100644 src/main/java/ice/spot/security/handler/exception/CustomAuthenticationEntryPointHandler.java create mode 100644 src/main/java/ice/spot/security/handler/login/Oauth2FailureHandler.java create mode 100644 src/main/java/ice/spot/security/handler/login/Oauth2SuccessHandler.java create mode 100644 src/main/java/ice/spot/security/handler/logout/CustomLogoutProcessHandler.java create mode 100644 src/main/java/ice/spot/security/handler/logout/CustomLogoutResultHandler.java create mode 100644 src/main/java/ice/spot/security/info/AuthenticationResponse.java create mode 100644 src/main/java/ice/spot/security/info/JwtUserInfo.java create mode 100644 src/main/java/ice/spot/security/info/KakaoOauth2UserInfo.java create mode 100644 src/main/java/ice/spot/security/info/UserPrincipal.java create mode 100644 src/main/java/ice/spot/security/info/factory/Oauth2UserInfo.java create mode 100644 src/main/java/ice/spot/security/info/factory/Oauth2UserInfoFactory.java create mode 100644 src/main/java/ice/spot/security/provider/JwtAuthenticationManager.java create mode 100644 src/main/java/ice/spot/security/provider/JwtAuthenticationProvider.java create mode 100644 src/main/java/ice/spot/security/service/CustomOauth2UserDetailService.java create mode 100644 src/main/java/ice/spot/security/service/CustomUserDetailService.java create mode 100644 src/main/java/ice/spot/service/AuthService.java create mode 100644 src/main/java/ice/spot/util/CookieUtil.java create mode 100644 src/main/java/ice/spot/util/HeaderUtil.java create mode 100644 src/main/java/ice/spot/util/JwtUtil.java diff --git a/build.gradle b/build.gradle index ca94c48..5d24f36 100644 --- a/build.gradle +++ b/build.gradle @@ -30,6 +30,7 @@ dependencies { // spring security implementation 'org.springframework.boot:spring-boot-starter-security' testImplementation 'org.springframework.security:spring-security-test' + implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' // spring boot implementation 'org.springframework.boot:spring-boot-starter-web' diff --git a/spot-server-properties b/spot-server-properties index 6bf7ffc..88515af 160000 --- a/spot-server-properties +++ b/spot-server-properties @@ -1 +1 @@ -Subproject commit 6bf7ffcb4dcbb7e8d1797c2b1df3d77944593e89 +Subproject commit 88515afcdc708913af815f585bce9affa47980ab diff --git a/src/main/java/ice/spot/annotation/UserId.java b/src/main/java/ice/spot/annotation/UserId.java new file mode 100644 index 0000000..2c3f090 --- /dev/null +++ b/src/main/java/ice/spot/annotation/UserId.java @@ -0,0 +1,11 @@ +package ice.spot.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +public @interface UserId { +} diff --git a/src/main/java/ice/spot/config/WebMVCConfig.java b/src/main/java/ice/spot/config/WebMVCConfig.java new file mode 100644 index 0000000..9b0cf84 --- /dev/null +++ b/src/main/java/ice/spot/config/WebMVCConfig.java @@ -0,0 +1,33 @@ +package ice.spot.config; + +import ice.spot.constant.Constants; +import ice.spot.interceptor.pre.UserIdArgumentResolver; +import ice.spot.interceptor.pre.UserIdInterceptor; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import java.util.List; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class WebMVCConfig implements WebMvcConfigurer { + private final UserIdArgumentResolver userIdArgumentResolver; + + @Override + public void addArgumentResolvers(List resolvers) { + WebMvcConfigurer.super.addArgumentResolvers(resolvers); + resolvers.add(this.userIdArgumentResolver); + } + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(new UserIdInterceptor()) + .addPathPatterns("/**") + .excludePathPatterns(Constants.NO_NEED_AUTH); + } +} diff --git a/src/main/java/ice/spot/constant/Constants.java b/src/main/java/ice/spot/constant/Constants.java new file mode 100644 index 0000000..8b85e53 --- /dev/null +++ b/src/main/java/ice/spot/constant/Constants.java @@ -0,0 +1,16 @@ +package ice.spot.constant; + +import java.util.List; + +public class Constants { + public static String CLAIM_USER_ID = "uuid"; + public static String CLAIM_USER_ROLE = "role"; + public static String PREFIX_BEARER = "Bearer "; + public static String PREFIX_AUTH = "authorization"; + public static String ACCESS_COOKIE_NAME = "access_token"; + public static String REFRESH_COOKIE_NAME = "refresh_token"; + public static List NO_NEED_AUTH = List.of( + "/api/auth/sign-up", + "/api/auth/sign-in" + ); +} diff --git a/src/main/java/ice/spot/controller/AuthController.java b/src/main/java/ice/spot/controller/AuthController.java new file mode 100644 index 0000000..173b2a9 --- /dev/null +++ b/src/main/java/ice/spot/controller/AuthController.java @@ -0,0 +1,27 @@ +package ice.spot.controller; + +import ice.spot.annotation.UserId; +import ice.spot.dto.global.ResponseDto; +import ice.spot.dto.request.OauthSignUpDto; +import ice.spot.service.AuthService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +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; + +@Slf4j +@RestController +@RequestMapping("/api") +@RequiredArgsConstructor +public class AuthController { + + private final AuthService authService; + + @PostMapping("/oauth2/sign-up") + public ResponseDto signUp(@UserId Long userId, @RequestBody OauthSignUpDto oauthSignUpDto) { + authService.signUp(userId, oauthSignUpDto); + return ResponseDto.ok(null); + } +} diff --git a/src/main/java/ice/spot/domain/User.java b/src/main/java/ice/spot/domain/User.java index 34e3c95..5c66be8 100644 --- a/src/main/java/ice/spot/domain/User.java +++ b/src/main/java/ice/spot/domain/User.java @@ -1,9 +1,13 @@ package ice.spot.domain; +import ice.spot.dto.type.EProvider; +import ice.spot.dto.type.ERole; import jakarta.persistence.*; import lombok.AccessLevel; +import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; +import org.hibernate.annotations.DynamicUpdate; import java.time.LocalDate; import java.util.ArrayList; @@ -11,8 +15,9 @@ @Entity @Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) @Table(name = "user") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@DynamicUpdate public class User { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @@ -31,17 +36,41 @@ public class User { @Column(name = "point") private Long point; + @Column(name = "role", nullable = false) + @Enumerated(EnumType.STRING) + private ERole role; + + @Column(name = "provider", nullable = false) + @Enumerated(EnumType.STRING) + private EProvider provider; + @Column(name = "created_at") private LocalDate createdAt; @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, fetch = FetchType.LAZY) private List boardingRecords = new ArrayList<>(); - public User(String serialId, String password, String nickname, Long point) { + @Column(name = "refresh_token") + private String refreshToken; + + @Builder + public User(String serialId, String password, String nickname, ERole role, EProvider provider, Long point) { this.serialId = serialId; this.password = password; this.nickname = nickname; + this.role = role; + this.provider = provider; this.point = point; this.createdAt = LocalDate.now(); } + + public void register(String nickname) { + this.nickname = nickname; + this.createdAt = LocalDate.now(); + this.role = ERole.USER; + } + + public void updateRefreshToken(String refreshToken) { + this.refreshToken = refreshToken; + } } diff --git a/src/main/java/ice/spot/dto/global/ExceptionDto.java b/src/main/java/ice/spot/dto/global/ExceptionDto.java new file mode 100644 index 0000000..7017212 --- /dev/null +++ b/src/main/java/ice/spot/dto/global/ExceptionDto.java @@ -0,0 +1,16 @@ +package ice.spot.dto.global; + +import ice.spot.exeption.ErrorCode; + +public record ExceptionDto( + Integer code, + String message +) { + public ExceptionDto(ErrorCode errorCode) { + this(errorCode.getCode(), errorCode.getMessage()); + } + + public static ExceptionDto of(ErrorCode errorCode) { + return new ExceptionDto(errorCode); + } +} diff --git a/src/main/java/ice/spot/dto/global/ResponseDto.java b/src/main/java/ice/spot/dto/global/ResponseDto.java new file mode 100644 index 0000000..939e0d6 --- /dev/null +++ b/src/main/java/ice/spot/dto/global/ResponseDto.java @@ -0,0 +1,60 @@ +package ice.spot.dto.global; + +import ice.spot.exeption.CommonException; +import ice.spot.exeption.ErrorCode; +import jakarta.annotation.Nullable; +import jakarta.validation.constraints.NotNull; +import net.minidev.json.annotate.JsonIgnore; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.MissingServletRequestParameterException; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; + +public record ResponseDto ( + @JsonIgnore HttpStatus httpStatus, + boolean success, + @Nullable T data, + @Nullable ExceptionDto exceptionDto +) { + public static ResponseDto ok(T data){ + return new ResponseDto<>( + HttpStatus.OK, + true, + data, + null + ); + } + public static ResponseDto created(Boolean data){ + return new ResponseDto<>( + HttpStatus.CREATED, + true, + data, + null + ); + } + public static ResponseDto fail(@NotNull CommonException e){ + return new ResponseDto<>( + e.getErrorCode().getHttpStatus(), + false, + null, + new ExceptionDto(e.getErrorCode()) + ); + } + + public static ResponseDto fail(final MissingServletRequestParameterException e) { + return new ResponseDto<>( + HttpStatus.BAD_REQUEST, + false, + null, + new ExceptionDto(ErrorCode.MISSING_REQUEST_PARAMETER) + ); + } + + public static ResponseDto fail(final MethodArgumentTypeMismatchException e) { + return new ResponseDto<>( + HttpStatus.INTERNAL_SERVER_ERROR, + false, + null, + new ExceptionDto(ErrorCode.INVALID_PARAMETER_FORMAT) + ); + } +} diff --git a/src/main/java/ice/spot/dto/request/OauthSignUpDto.java b/src/main/java/ice/spot/dto/request/OauthSignUpDto.java new file mode 100644 index 0000000..1d7c0b2 --- /dev/null +++ b/src/main/java/ice/spot/dto/request/OauthSignUpDto.java @@ -0,0 +1,9 @@ +package ice.spot.dto.request; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record OauthSignUpDto( + @JsonProperty("nickname") + String nickname +) { +} diff --git a/src/main/java/ice/spot/dto/response/JwtTokenDto.java b/src/main/java/ice/spot/dto/response/JwtTokenDto.java new file mode 100644 index 0000000..ecd8d33 --- /dev/null +++ b/src/main/java/ice/spot/dto/response/JwtTokenDto.java @@ -0,0 +1,18 @@ +package ice.spot.dto.response; + +import lombok.Builder; + +import java.io.Serializable; + +@Builder +public record JwtTokenDto( + String accessToken, + String refreshToken +) implements Serializable { + public static JwtTokenDto of(String accessToken, String refreshToken) { + return JwtTokenDto.builder() + .accessToken(accessToken) + .refreshToken(refreshToken) + .build(); + } +} diff --git a/src/main/java/ice/spot/dto/type/EProvider.java b/src/main/java/ice/spot/dto/type/EProvider.java new file mode 100644 index 0000000..235e685 --- /dev/null +++ b/src/main/java/ice/spot/dto/type/EProvider.java @@ -0,0 +1,13 @@ +package ice.spot.dto.type; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum EProvider { + + KAKAO("KAKAO"); + + private final String name; +} diff --git a/src/main/java/ice/spot/dto/type/ERole.java b/src/main/java/ice/spot/dto/type/ERole.java new file mode 100644 index 0000000..da41f59 --- /dev/null +++ b/src/main/java/ice/spot/dto/type/ERole.java @@ -0,0 +1,16 @@ +package ice.spot.dto.type; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum ERole { + + GUEST("GUEST", "ROLE_GUEST"), + USER("USER", "ROLE_USER"), + ADMIN("ADMIN", "ROLE_ADMIN"); + + private final String role; + private final String securityRole; +} diff --git a/src/main/java/ice/spot/exeption/CommonException.java b/src/main/java/ice/spot/exeption/CommonException.java new file mode 100644 index 0000000..427c4c6 --- /dev/null +++ b/src/main/java/ice/spot/exeption/CommonException.java @@ -0,0 +1,11 @@ +package ice.spot.exeption; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class CommonException extends RuntimeException { + private final ErrorCode errorCode; + public String getMessage() { return this.errorCode.getMessage(); } +} diff --git a/src/main/java/ice/spot/exeption/ErrorCode.java b/src/main/java/ice/spot/exeption/ErrorCode.java new file mode 100644 index 0000000..3eda73b --- /dev/null +++ b/src/main/java/ice/spot/exeption/ErrorCode.java @@ -0,0 +1,40 @@ +package ice.spot.exeption; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum ErrorCode { + //400 + WRONG_ENTRY_POINT(40000, HttpStatus.BAD_REQUEST, "잘못된 접근입니다"), + MISSING_REQUEST_PARAMETER(40001, HttpStatus.BAD_REQUEST, "필수 요청 파라미터가 누락되었습니다."), + INVALID_PARAMETER_FORMAT(40002, HttpStatus.BAD_REQUEST, "요청에 유효하지 않은 인자 형식입니다."), + BAD_REQUEST_JSON(40003, HttpStatus.BAD_REQUEST, "잘못된 JSON 형식입니다."), + + //401 + INVALID_HEADER_VALUE(40100, HttpStatus.UNAUTHORIZED, "올바르지 않은 헤더값입니다."), + EXPIRED_TOKEN_ERROR(40101, HttpStatus.UNAUTHORIZED, "만료된 토큰입니다."), + INVALID_TOKEN_ERROR(40102, HttpStatus.UNAUTHORIZED, "유효하지 않은 토큰입니다."), + TOKEN_MALFORMED_ERROR(40103, HttpStatus.UNAUTHORIZED, "토큰이 올바르지 않습니다."), + TOKEN_TYPE_ERROR(40104, HttpStatus.UNAUTHORIZED, "토큰 타입이 일치하지 않거나 비어있습니다."), + TOKEN_UNSUPPORTED_ERROR(40105, HttpStatus.UNAUTHORIZED, "지원하지않는 토큰입니다."), + TOKEN_GENERATION_ERROR(40106, HttpStatus.UNAUTHORIZED, "토큰 생성에 실패하였습니다."), + TOKEN_UNKNOWN_ERROR(40107, HttpStatus.UNAUTHORIZED, "알 수 없는 토큰입니다."), + LOGIN_FAILURE(40108, HttpStatus.UNAUTHORIZED, "로그인에 실패했습니다"), + + //403 + FORBIDDEN_ROLE(40300, HttpStatus.FORBIDDEN, "권한이 존재하지 않습니다."), + + //404 + NOT_FOUND_USER(40400, HttpStatus.NOT_FOUND, "존재하지 않는 사용자입니다."), + + //500 + INTERNAL_SERVER_ERROR(50000, HttpStatus.INTERNAL_SERVER_ERROR, "서버 내부 오류입니다"), + ; + + private final Integer code; + private final HttpStatus httpStatus; + private final String message; +} diff --git a/src/main/java/ice/spot/exeption/GlobalExceptionHandler.java b/src/main/java/ice/spot/exeption/GlobalExceptionHandler.java new file mode 100644 index 0000000..e8e105a --- /dev/null +++ b/src/main/java/ice/spot/exeption/GlobalExceptionHandler.java @@ -0,0 +1,48 @@ +package ice.spot.exeption; + +import ice.spot.dto.global.ResponseDto; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.HttpRequestMethodNotSupportedException; +import org.springframework.web.bind.MissingServletRequestParameterException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; +import org.springframework.web.servlet.NoHandlerFoundException; + +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + // 지원되지 않는 HTTP 메소드를 사용할 때 발생하는 exception + @ExceptionHandler(value = {NoHandlerFoundException.class, HttpRequestMethodNotSupportedException.class}) + public ResponseDto handleNoPageFoundException(Exception e) { + log.error("handleNoPageFoundException() in GlobalExceptionHandler throw NoHandlerFoundException : {}", e.getMessage()); + return ResponseDto.fail(new CommonException(ErrorCode.WRONG_ENTRY_POINT)); + } + + // 메소드의 인자 타입이 일치하지 않을 때 발생하는 exception + @ExceptionHandler(value = {MethodArgumentTypeMismatchException.class}) + public ResponseDto handleArgumentNotValidException(MethodArgumentTypeMismatchException e) { + log.error("handleArgumentNotValidException() in GlobalExceptionHandler throw MethodArgumentTypeMismatchException : {}", e.getMessage()); + return ResponseDto.fail(e); + } + + // 필수 파라미터가 누락되었을 때 발생하는 exception + @ExceptionHandler(value = {MissingServletRequestParameterException.class}) + public ResponseDto handleArgumentNotValidException(MissingServletRequestParameterException e) { + log.error("handleArgumentNotValidException() in GlobalExceptionHandler throw MethodArgumentNotValidException : {}", e.getMessage()); + return ResponseDto.fail(e); + } + + // 커스텀 exception + @ExceptionHandler(value = {CommonException.class}) + public ResponseDto handleCustomException(CommonException e){ + return ResponseDto.fail(e); + } + + // 서버 exception + @ExceptionHandler(value = {Exception.class}) + public ResponseDto handleServerException(Exception e){ + log.info("occurred exception in handleServerError = {}", e.getMessage()); + return ResponseDto.fail(new CommonException(ErrorCode.INTERNAL_SERVER_ERROR)); + } +} diff --git a/src/main/java/ice/spot/interceptor/post/ResponseInterceptor.java b/src/main/java/ice/spot/interceptor/post/ResponseInterceptor.java new file mode 100644 index 0000000..0d2a52f --- /dev/null +++ b/src/main/java/ice/spot/interceptor/post/ResponseInterceptor.java @@ -0,0 +1,32 @@ +package ice.spot.interceptor.post; + +import ice.spot.dto.global.ResponseDto; +import org.springframework.core.MethodParameter; +import org.springframework.http.MediaType; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice; + +@RestControllerAdvice +public class ResponseInterceptor implements ResponseBodyAdvice { + + @Override + public boolean supports(MethodParameter returnType, Class converterType) { + return true; + } + + @Override + public Object beforeBodyWrite( + Object body, + MethodParameter returnType, + MediaType selectedContentType, + Class selectedConverterType, + ServerHttpRequest request, + ServerHttpResponse response + ) { + if (returnType.getParameterType().equals(ResponseDto.class)) + response.setStatusCode(((ResponseDto) body).httpStatus()); + return body; + } +} diff --git a/src/main/java/ice/spot/interceptor/pre/UserIdArgumentResolver.java b/src/main/java/ice/spot/interceptor/pre/UserIdArgumentResolver.java new file mode 100644 index 0000000..46670bb --- /dev/null +++ b/src/main/java/ice/spot/interceptor/pre/UserIdArgumentResolver.java @@ -0,0 +1,36 @@ +package ice.spot.interceptor.pre; + +import ice.spot.annotation.UserId; +import ice.spot.exeption.CommonException; +import ice.spot.exeption.ErrorCode; +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.context.request.WebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +@Component +public class UserIdArgumentResolver implements HandlerMethodArgumentResolver { + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.getParameterType().equals(Long.class) + && parameter.hasParameterAnnotation(UserId.class); + } + + @Override + public Object resolveArgument( + MethodParameter parameter, + ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, + WebDataBinderFactory binderFactory + ) throws Exception { + final Object userId = webRequest + .getAttribute("USER_ID", WebRequest.SCOPE_REQUEST); + if (userId == null) + throw new CommonException(ErrorCode.INVALID_HEADER_VALUE); + return Long.valueOf(userId.toString()); + } +} diff --git a/src/main/java/ice/spot/interceptor/pre/UserIdInterceptor.java b/src/main/java/ice/spot/interceptor/pre/UserIdInterceptor.java new file mode 100644 index 0000000..d8796b8 --- /dev/null +++ b/src/main/java/ice/spot/interceptor/pre/UserIdInterceptor.java @@ -0,0 +1,22 @@ +package ice.spot.interceptor.pre; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.servlet.HandlerInterceptor; + +public class UserIdInterceptor implements HandlerInterceptor { + + @Override + public boolean preHandle( + HttpServletRequest request, + HttpServletResponse response, + Object handler + ) throws Exception { + final Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + request.setAttribute("USER_ID", authentication.getName()); + + return HandlerInterceptor.super.preHandle(request, response, handler); + } +} diff --git a/src/main/java/ice/spot/repository/UserRepository.java b/src/main/java/ice/spot/repository/UserRepository.java new file mode 100644 index 0000000..434046a --- /dev/null +++ b/src/main/java/ice/spot/repository/UserRepository.java @@ -0,0 +1,47 @@ +package ice.spot.repository; + +import ice.spot.domain.User; +import ice.spot.dto.type.ERole; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; + +import java.util.Optional; + +public interface UserRepository extends JpaRepository { + @Query("select u.id as id, u.role as role, u.password as password from User u where u.serialId = :serialId") + Optional findUserSecurityFromBySerialId(String serialId); + @Query("select u.id as id, u.role as role, u.password as password from User u where u.id = :id") + Optional findUserSecurityFromById(Long id); + Optional findByIdAndRefreshToken(Long id, String refreshToken); + @Modifying(clearAutomatically = true) + @Query("update User u set u.refreshToken = :refreshToken where u.id = :userId") + void updateRefreshToken(Long userId, String refreshToken); + Optional findById(Long userId); + + interface UserSecurityForm { + + Long getId(); + ERole getRole(); + String getPassword(); + + static UserSecurityForm invoke(User user){ + return new UserSecurityForm() { + @Override + public Long getId() { + return user.getId(); + } + + @Override + public ERole getRole() { + return user.getRole(); + } + + @Override + public String getPassword() { + return user.getPassword(); + } + }; + } + } +} diff --git a/src/main/java/ice/spot/security/config/PasswordConfig.java b/src/main/java/ice/spot/security/config/PasswordConfig.java new file mode 100644 index 0000000..d648a36 --- /dev/null +++ b/src/main/java/ice/spot/security/config/PasswordConfig.java @@ -0,0 +1,14 @@ +package ice.spot.security.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; + +@Configuration +public class PasswordConfig { + + @Bean + public BCryptPasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/src/main/java/ice/spot/security/config/SecurityConfig.java b/src/main/java/ice/spot/security/config/SecurityConfig.java new file mode 100644 index 0000000..ef21d48 --- /dev/null +++ b/src/main/java/ice/spot/security/config/SecurityConfig.java @@ -0,0 +1,75 @@ +package ice.spot.security.config; + +import ice.spot.security.filter.JwtAuthenticationFilter; +import ice.spot.security.filter.JwtExceptionFilter; +import ice.spot.security.handler.exception.CustomAccessDeniedHandler; +import ice.spot.security.handler.exception.CustomAuthenticationEntryPointHandler; +import ice.spot.security.handler.login.Oauth2FailureHandler; +import ice.spot.security.handler.login.Oauth2SuccessHandler; +import ice.spot.security.handler.logout.CustomLogoutProcessHandler; +import ice.spot.security.handler.logout.CustomLogoutResultHandler; +import ice.spot.security.provider.JwtAuthenticationManager; +import ice.spot.security.service.CustomOauth2UserDetailService; +import ice.spot.util.JwtUtil; +import lombok.RequiredArgsConstructor; +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.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.logout.LogoutFilter; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final Oauth2SuccessHandler oauth2SuccessHandler; + private final Oauth2FailureHandler oauth2FailureHandler; + private final CustomOauth2UserDetailService customOauth2UserDetailService; + private final CustomLogoutProcessHandler customLogoutProcessHandler; + private final CustomLogoutResultHandler customLogoutResultHandler; + private final CustomAccessDeniedHandler customAccessDeniedHandler; + private final CustomAuthenticationEntryPointHandler customAuthenticationEntryPointHandler; + private final JwtUtil jwtUtil; + private final JwtAuthenticationManager jwtAuthenticationManager; + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + return http + .csrf(AbstractHttpConfigurer::disable) + .httpBasic(AbstractHttpConfigurer::disable) + .sessionManagement((session -> + session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + ) + .authorizeHttpRequests(request -> + request + .requestMatchers("/api/oauth2/sign-up").permitAll() + .requestMatchers("/api/**").hasAnyRole("USER") + .anyRequest().authenticated() + ) + .oauth2Login(oauth2 -> oauth2 + .successHandler(oauth2SuccessHandler) + .failureHandler(oauth2FailureHandler) + .userInfoEndpoint(it -> it.userService(customOauth2UserDetailService)) + ) + .logout(logout -> logout + .logoutUrl("/api/users/logout") + .addLogoutHandler(customLogoutProcessHandler) + .logoutSuccessHandler(customLogoutResultHandler) + ) + .exceptionHandling(exception -> exception + .accessDeniedHandler(customAccessDeniedHandler) + .authenticationEntryPoint(customAuthenticationEntryPointHandler) + ) + .addFilterBefore( + new JwtAuthenticationFilter(jwtUtil, jwtAuthenticationManager), LogoutFilter.class + ) + .addFilterBefore( + new JwtExceptionFilter(), JwtAuthenticationFilter.class + ) + .getOrBuild(); + } +} diff --git a/src/main/java/ice/spot/security/filter/JwtAuthenticationFilter.java b/src/main/java/ice/spot/security/filter/JwtAuthenticationFilter.java new file mode 100644 index 0000000..b4626e6 --- /dev/null +++ b/src/main/java/ice/spot/security/filter/JwtAuthenticationFilter.java @@ -0,0 +1,70 @@ +package ice.spot.security.filter; + +import ice.spot.constant.Constants; +import ice.spot.dto.type.ERole; +import ice.spot.exeption.CommonException; +import ice.spot.exeption.ErrorCode; +import ice.spot.security.info.JwtUserInfo; +import ice.spot.security.provider.JwtAuthenticationManager; +import ice.spot.util.HeaderUtil; +import ice.spot.util.JwtUtil; +import io.jsonwebtoken.Claims; +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.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@Slf4j +@Component +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + private final JwtUtil jwtUtil; + private final JwtAuthenticationManager jwtAuthenticationManager; + @Override + protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException { + return Constants.NO_NEED_AUTH.contains(request.getRequestURI()); + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + String token = HeaderUtil.refineHeader(request, Constants.PREFIX_AUTH, Constants.PREFIX_BEARER) + .orElseThrow(() -> new CommonException(ErrorCode.INVALID_HEADER_VALUE)); + + Claims claims = jwtUtil.validateToken(token); + log.info("claim: getUserId() = {}", claims.get(Constants.CLAIM_USER_ID, Long.class)); + + JwtUserInfo jwtUserInfo = new JwtUserInfo( + claims.get(Constants.CLAIM_USER_ID, Long.class), + ERole.valueOf(claims.get(Constants.CLAIM_USER_ROLE, String.class)) + ); + + // 인증 받지 않은 인증용 객체 + UsernamePasswordAuthenticationToken unAuthenticatedToken = new UsernamePasswordAuthenticationToken( + jwtUserInfo, null, null + ); + + // 인증 받은 후의 인증 객체 + UsernamePasswordAuthenticationToken authenticatedToken + = (UsernamePasswordAuthenticationToken) jwtAuthenticationManager.authenticate(unAuthenticatedToken); + log.info("인증 성공"); + + // 사용자의 IP등 세부 정보 인증 정보에 추가 + authenticatedToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + + SecurityContext securityContext = SecurityContextHolder.createEmptyContext(); + securityContext.setAuthentication(authenticatedToken); + SecurityContextHolder.setContext(securityContext); + + filterChain.doFilter(request, response); + } +} \ No newline at end of file diff --git a/src/main/java/ice/spot/security/filter/JwtExceptionFilter.java b/src/main/java/ice/spot/security/filter/JwtExceptionFilter.java new file mode 100644 index 0000000..7a8cdb0 --- /dev/null +++ b/src/main/java/ice/spot/security/filter/JwtExceptionFilter.java @@ -0,0 +1,67 @@ +package ice.spot.security.filter; + +import ice.spot.constant.Constants; +import ice.spot.exeption.CommonException; +import ice.spot.exeption.ErrorCode; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.MalformedJwtException; +import io.jsonwebtoken.UnsupportedJwtException; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@Slf4j +public class JwtExceptionFilter extends OncePerRequestFilter { + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + try { + filterChain.doFilter(request, response); + } catch (SecurityException e) { + log.error("FilterException throw SecurityException Exception : {}", e.getMessage()); + request.setAttribute("exception", ErrorCode.FORBIDDEN_ROLE); + filterChain.doFilter(request, response); + } catch (MalformedJwtException e) { + log.error("FilterException throw MalformedJwtException Exception : {}", e.getMessage()); + request.setAttribute("exception", ErrorCode.TOKEN_MALFORMED_ERROR); + filterChain.doFilter(request, response); + } catch (IllegalArgumentException e) { + log.error("FilterException throw IllegalArgumentException Exception : {}", e.getMessage()); + request.setAttribute("exception", ErrorCode.TOKEN_TYPE_ERROR); + filterChain.doFilter(request, response); + } catch (ExpiredJwtException e) { + log.error("FilterException throw ExpiredJwtException Exception : {}", e.getMessage()); + request.setAttribute("exception", ErrorCode.EXPIRED_TOKEN_ERROR); + filterChain.doFilter(request, response); + } catch (UnsupportedJwtException e) { + log.error("FilterException throw UnsupportedJwtException Exception : {}", e.getMessage()); + request.setAttribute("exception", ErrorCode.TOKEN_UNSUPPORTED_ERROR); + filterChain.doFilter(request, response); + } catch (JwtException e) { + log.error("FilterException throw JwtException Exception : {}", e.getMessage()); + request.setAttribute("exception", ErrorCode.TOKEN_UNKNOWN_ERROR); + filterChain.doFilter(request, response); + } catch (CommonException e) { + log.error("FilterException throw CommonException Exception : {}", e.getMessage()); + request.setAttribute("exception", e.getErrorCode()); + filterChain.doFilter(request, response); + } catch (Exception e) { + log.error("FilterException throw Exception Exception : {}", e.getMessage()); + request.setAttribute("exception", ErrorCode.INTERNAL_SERVER_ERROR); + filterChain.doFilter(request, response); + } + } + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + return Constants.NO_NEED_AUTH.contains(request.getRequestURI()); + } +} \ No newline at end of file diff --git a/src/main/java/ice/spot/security/handler/exception/CustomAccessDeniedHandler.java b/src/main/java/ice/spot/security/handler/exception/CustomAccessDeniedHandler.java new file mode 100644 index 0000000..fa25844 --- /dev/null +++ b/src/main/java/ice/spot/security/handler/exception/CustomAccessDeniedHandler.java @@ -0,0 +1,24 @@ +package ice.spot.security.handler.exception; + +import ice.spot.exeption.ErrorCode; +import ice.spot.security.info.AuthenticationResponse; +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 CustomAccessDeniedHandler implements AccessDeniedHandler { + + @Override + public void handle( + HttpServletRequest request, + HttpServletResponse response, + AccessDeniedException accessDeniedException + ) throws IOException { + AuthenticationResponse.makeFailureResponse(response, ErrorCode.FORBIDDEN_ROLE); + } +} diff --git a/src/main/java/ice/spot/security/handler/exception/CustomAuthenticationEntryPointHandler.java b/src/main/java/ice/spot/security/handler/exception/CustomAuthenticationEntryPointHandler.java new file mode 100644 index 0000000..df9e2af --- /dev/null +++ b/src/main/java/ice/spot/security/handler/exception/CustomAuthenticationEntryPointHandler.java @@ -0,0 +1,30 @@ +package ice.spot.security.handler.exception; + +import ice.spot.exeption.ErrorCode; +import ice.spot.security.info.AuthenticationResponse; +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 CustomAuthenticationEntryPointHandler implements AuthenticationEntryPoint { + + @Override + public void commence( + HttpServletRequest request, + HttpServletResponse response, + AuthenticationException authenticationException + ) throws IOException { + // filter 단에서 발생한 에러 핸들링 + ErrorCode errorCode = (ErrorCode) request.getAttribute("exception"); + if (errorCode == null) { + AuthenticationResponse.makeFailureResponse(response, ErrorCode.WRONG_ENTRY_POINT); + return; + } + AuthenticationResponse.makeFailureResponse(response, errorCode); + } +} diff --git a/src/main/java/ice/spot/security/handler/login/Oauth2FailureHandler.java b/src/main/java/ice/spot/security/handler/login/Oauth2FailureHandler.java new file mode 100644 index 0000000..b9d2b5c --- /dev/null +++ b/src/main/java/ice/spot/security/handler/login/Oauth2FailureHandler.java @@ -0,0 +1,25 @@ +package ice.spot.security.handler.login; + +import ice.spot.exeption.ErrorCode; +import ice.spot.security.info.AuthenticationResponse; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.authentication.AuthenticationFailureHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +public class Oauth2FailureHandler implements AuthenticationFailureHandler { + + @Override + public void onAuthenticationFailure( + HttpServletRequest request, + HttpServletResponse response, + AuthenticationException exception + ) throws IOException, ServletException { + AuthenticationResponse.makeFailureResponse(response, ErrorCode.INTERNAL_SERVER_ERROR); + } +} diff --git a/src/main/java/ice/spot/security/handler/login/Oauth2SuccessHandler.java b/src/main/java/ice/spot/security/handler/login/Oauth2SuccessHandler.java new file mode 100644 index 0000000..0ae5bc4 --- /dev/null +++ b/src/main/java/ice/spot/security/handler/login/Oauth2SuccessHandler.java @@ -0,0 +1,45 @@ +package ice.spot.security.handler.login; + +import ice.spot.dto.response.JwtTokenDto; +import ice.spot.repository.UserRepository; +import ice.spot.security.info.AuthenticationResponse; +import ice.spot.security.info.UserPrincipal; +import ice.spot.util.JwtUtil; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.io.IOException; + +@Component +@RequiredArgsConstructor +public class Oauth2SuccessHandler implements AuthenticationSuccessHandler { + + @Value("${server.domain}") + private String domain; + private final JwtUtil jwtUtil; + private final UserRepository userRepository; + + @Override + @Transactional + public void onAuthenticationSuccess( + HttpServletRequest request, + HttpServletResponse response, + Authentication authentication + ) throws IOException, ServletException { + UserPrincipal principal = (UserPrincipal) authentication.getPrincipal(); + JwtTokenDto jwtTokenDto = jwtUtil.generateTokens(principal.getUserId(), principal.getRole()); + + userRepository.updateRefreshToken(principal.getUserId(), jwtTokenDto.refreshToken()); + + AuthenticationResponse.makeLoginSuccessResponse(response, domain, jwtTokenDto, jwtUtil.getRefreshExpiration()); + + response.sendRedirect("https://" + domain); + } +} diff --git a/src/main/java/ice/spot/security/handler/logout/CustomLogoutProcessHandler.java b/src/main/java/ice/spot/security/handler/logout/CustomLogoutProcessHandler.java new file mode 100644 index 0000000..80e07d6 --- /dev/null +++ b/src/main/java/ice/spot/security/handler/logout/CustomLogoutProcessHandler.java @@ -0,0 +1,29 @@ +package ice.spot.security.handler.logout; + +import ice.spot.repository.UserRepository; +import ice.spot.security.info.UserPrincipal; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.logout.LogoutHandler; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Component +@RequiredArgsConstructor +public class CustomLogoutProcessHandler implements LogoutHandler { + + private final UserRepository userRepository; + + @Override + @Transactional + public void logout( + HttpServletRequest request, + HttpServletResponse response, + Authentication authentication + ) { + UserPrincipal userPrincipal = (UserPrincipal) authentication.getPrincipal(); + userRepository.updateRefreshToken(userPrincipal.getUserId(), null); + } +} diff --git a/src/main/java/ice/spot/security/handler/logout/CustomLogoutResultHandler.java b/src/main/java/ice/spot/security/handler/logout/CustomLogoutResultHandler.java new file mode 100644 index 0000000..9b2cf4b --- /dev/null +++ b/src/main/java/ice/spot/security/handler/logout/CustomLogoutResultHandler.java @@ -0,0 +1,37 @@ +package ice.spot.security.handler.logout; + +import ice.spot.exeption.ErrorCode; +import ice.spot.security.info.AuthenticationResponse; +import ice.spot.util.CookieUtil; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.logout.LogoutSuccessHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Slf4j +@Component +public class CustomLogoutResultHandler implements LogoutSuccessHandler { + + @Value("${server.domain}") + private String domain; + + @Override + public void onLogoutSuccess( + HttpServletRequest request, + HttpServletResponse response, + Authentication authentication + ) throws IOException, ServletException { + if (authentication == null) { + log.info("인증 정보가 존재하지 않습니다. authentication is null."); + AuthenticationResponse.makeFailureResponse(response, ErrorCode.NOT_FOUND_USER); + } + CookieUtil.logoutCookie(request, response, domain); + AuthenticationResponse.makeSuccessResponse(response); + } +} diff --git a/src/main/java/ice/spot/security/info/AuthenticationResponse.java b/src/main/java/ice/spot/security/info/AuthenticationResponse.java new file mode 100644 index 0000000..01d9076 --- /dev/null +++ b/src/main/java/ice/spot/security/info/AuthenticationResponse.java @@ -0,0 +1,72 @@ +package ice.spot.security.info; + +import ice.spot.constant.Constants; +import ice.spot.dto.global.ExceptionDto; +import ice.spot.dto.response.JwtTokenDto; +import ice.spot.exeption.ErrorCode; +import ice.spot.util.CookieUtil; +import jakarta.servlet.http.HttpServletResponse; +import net.minidev.json.JSONValue; +import org.springframework.http.HttpStatus; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +public class AuthenticationResponse { + + public static void makeLoginSuccessResponse( + HttpServletResponse response, + String domain, + JwtTokenDto jwtTokenDto, + Integer refreshExpiration + ) throws IOException { + CookieUtil.addCookie( + response, + domain, + Constants.ACCESS_COOKIE_NAME, + jwtTokenDto.accessToken() + ); + CookieUtil.addSecureCookie( + response, + domain, + Constants.REFRESH_COOKIE_NAME, + jwtTokenDto.refreshToken(), + refreshExpiration + ); + + makeSuccessResponse(response); + } + + public static void makeSuccessResponse( + HttpServletResponse response + ) throws IOException { + response.setContentType("application/json"); + response.setCharacterEncoding("UTF-8"); + + response.setStatus(HttpStatus.OK.value()); + + Map body = new HashMap<>(); + body.put("success", "true"); + body.put("data", null); + body.put("error", null); + + response.getWriter().write(JSONValue.toJSONString(body)); + } + + public static void makeFailureResponse( + HttpServletResponse response, + ErrorCode errorCode + ) throws IOException { + response.setContentType("application/json"); + response.setCharacterEncoding("UTF-8"); + response.setStatus(errorCode.getHttpStatus().value()); + + Map body= new HashMap<>(); + body.put("success", false); + body.put("data", null); + body.put("error", ExceptionDto.of(errorCode)); + + response.getWriter().write(JSONValue.toJSONString(body)); + } +} diff --git a/src/main/java/ice/spot/security/info/JwtUserInfo.java b/src/main/java/ice/spot/security/info/JwtUserInfo.java new file mode 100644 index 0000000..1a1b987 --- /dev/null +++ b/src/main/java/ice/spot/security/info/JwtUserInfo.java @@ -0,0 +1,6 @@ +package ice.spot.security.info; + +import ice.spot.dto.type.ERole; + +public record JwtUserInfo(Long userId, ERole role) { +} diff --git a/src/main/java/ice/spot/security/info/KakaoOauth2UserInfo.java b/src/main/java/ice/spot/security/info/KakaoOauth2UserInfo.java new file mode 100644 index 0000000..456eaf2 --- /dev/null +++ b/src/main/java/ice/spot/security/info/KakaoOauth2UserInfo.java @@ -0,0 +1,16 @@ +package ice.spot.security.info; + +import ice.spot.security.info.factory.Oauth2UserInfo; + +import java.util.Map; + +public class KakaoOauth2UserInfo extends Oauth2UserInfo { + public KakaoOauth2UserInfo(Map attributes) { + super(attributes); + } + + @Override + public String getId() { + return attributes.get("id").toString(); + } +} diff --git a/src/main/java/ice/spot/security/info/UserPrincipal.java b/src/main/java/ice/spot/security/info/UserPrincipal.java new file mode 100644 index 0000000..651376b --- /dev/null +++ b/src/main/java/ice/spot/security/info/UserPrincipal.java @@ -0,0 +1,99 @@ +package ice.spot.security.info; + +import ice.spot.dto.type.ERole; +import ice.spot.repository.UserRepository; +import lombok.Builder; +import lombok.Getter; +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.oauth2.core.user.OAuth2User; + +import java.util.Collection; +import java.util.Collections; +import java.util.Map; + +@Getter +@Builder +@RequiredArgsConstructor +public class UserPrincipal implements UserDetails, OAuth2User { + + private final Long userId; + private final String password; + private final ERole role; + private final Map attributes; + private final Collection authorities; + + public static UserPrincipal create(UserRepository.UserSecurityForm securityForm) { + return UserPrincipal.builder() + .userId(securityForm.getId()) + .password(securityForm.getPassword()) + .role(securityForm.getRole()) + .attributes(Collections.emptyMap()) + .authorities(Collections.singleton( + new SimpleGrantedAuthority(securityForm.getRole().getSecurityRole())) + ) + .build(); + } + + public static UserPrincipal create( + UserRepository.UserSecurityForm securityForm, + Map attributes + ) { + return UserPrincipal.builder() + .userId(securityForm.getId()) + .password(securityForm.getPassword()) + .role(securityForm.getRole()) + .attributes(attributes) + .authorities(Collections.singleton( + new SimpleGrantedAuthority(securityForm.getRole().getSecurityRole())) + ) + .build(); + } + + @Override + public Map getAttributes() { + return attributes; + } + + @Override + public Collection getAuthorities() { + return this.authorities; + } + + @Override + public String getPassword() { + return this.password; + } + + @Override + public String getUsername() { + return this.userId.toString(); + } + + @Override + public boolean isAccountNonExpired() { + return true; + } + + @Override + public boolean isAccountNonLocked() { + return true; + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @Override + public boolean isEnabled() { + return true; + } + + @Override + public String getName() { + return userId.toString(); + } +} diff --git a/src/main/java/ice/spot/security/info/factory/Oauth2UserInfo.java b/src/main/java/ice/spot/security/info/factory/Oauth2UserInfo.java new file mode 100644 index 0000000..00c9389 --- /dev/null +++ b/src/main/java/ice/spot/security/info/factory/Oauth2UserInfo.java @@ -0,0 +1,13 @@ +package ice.spot.security.info.factory; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.util.Map; + +@Getter +@RequiredArgsConstructor +public abstract class Oauth2UserInfo { + protected final Map attributes; + public abstract String getId(); +} diff --git a/src/main/java/ice/spot/security/info/factory/Oauth2UserInfoFactory.java b/src/main/java/ice/spot/security/info/factory/Oauth2UserInfoFactory.java new file mode 100644 index 0000000..b93358b --- /dev/null +++ b/src/main/java/ice/spot/security/info/factory/Oauth2UserInfoFactory.java @@ -0,0 +1,22 @@ +package ice.spot.security.info.factory; + +import ice.spot.dto.type.EProvider; +import ice.spot.security.info.KakaoOauth2UserInfo; + +import java.util.Map; + +public class Oauth2UserInfoFactory { + + public static Oauth2UserInfo getOauth2UserInfo( + EProvider provider, + Map attributes + ) { + Oauth2UserInfo ret; + switch (provider) { + case KAKAO -> ret = new KakaoOauth2UserInfo(attributes); + default -> throw new IllegalAccessError("잘못된 제공자입니다."); + } + + return ret; + } +} diff --git a/src/main/java/ice/spot/security/provider/JwtAuthenticationManager.java b/src/main/java/ice/spot/security/provider/JwtAuthenticationManager.java new file mode 100644 index 0000000..4ecc927 --- /dev/null +++ b/src/main/java/ice/spot/security/provider/JwtAuthenticationManager.java @@ -0,0 +1,22 @@ +package ice.spot.security.provider; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class JwtAuthenticationManager implements AuthenticationManager { + + private final JwtAuthenticationProvider jwtAuthenticationProvider; + + @Override + public Authentication authenticate(Authentication authentication) throws AuthenticationException { + log.info("AuthenticationManager 진입"); + return jwtAuthenticationProvider.authenticate(authentication); + } +} diff --git a/src/main/java/ice/spot/security/provider/JwtAuthenticationProvider.java b/src/main/java/ice/spot/security/provider/JwtAuthenticationProvider.java new file mode 100644 index 0000000..b07dddd --- /dev/null +++ b/src/main/java/ice/spot/security/provider/JwtAuthenticationProvider.java @@ -0,0 +1,59 @@ +package ice.spot.security.provider; + +import ice.spot.security.info.JwtUserInfo; +import ice.spot.security.info.UserPrincipal; +import ice.spot.security.service.CustomUserDetailService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class JwtAuthenticationProvider implements AuthenticationProvider { + + private final BCryptPasswordEncoder bCryptPasswordEncoder; + private final CustomUserDetailService customUserDetailService; + + @Override + public Authentication authenticate( + Authentication authentication + ) throws AuthenticationException { + log.info("AuthenticationProvider 진입 성공"); + if (authentication.getPrincipal().getClass().equals(String.class)) { + // form login 요청인 경우 + log.info("로그인 로직 인증 과정"); + return authOfLogin(authentication); + } else { + log.info("로그인 한 사용자 검증 과정"); + return authOfAfterLogin((JwtUserInfo) authentication.getPrincipal()); + } + } + + private Authentication authOfLogin(Authentication authentication) { + // DB에 저장된 실제 데이터 + UserPrincipal userPrincipal = customUserDetailService + .loadUserByUsername(authentication.getPrincipal().toString()); + + // 비밀번호 검증 로직 + if (!bCryptPasswordEncoder.matches(authentication.getCredentials().toString(), userPrincipal.getPassword())) + throw new UsernameNotFoundException("비밀번호가 일치하지 않습니다 ! "); + return new UsernamePasswordAuthenticationToken(userPrincipal, null, userPrincipal.getAuthorities()); + } + + private Authentication authOfAfterLogin(JwtUserInfo userInfo){ + UserPrincipal userPrincipal = customUserDetailService.loadUserById(userInfo.userId()); + return new UsernamePasswordAuthenticationToken(userPrincipal, null, userPrincipal.getAuthorities()); + } + + @Override + public boolean supports(Class authentication) { + return authentication.equals(UsernamePasswordAuthenticationToken.class); + } +} diff --git a/src/main/java/ice/spot/security/service/CustomOauth2UserDetailService.java b/src/main/java/ice/spot/security/service/CustomOauth2UserDetailService.java new file mode 100644 index 0000000..1a684da --- /dev/null +++ b/src/main/java/ice/spot/security/service/CustomOauth2UserDetailService.java @@ -0,0 +1,64 @@ +package ice.spot.security.service; + +import ice.spot.domain.User; +import ice.spot.dto.type.EProvider; +import ice.spot.dto.type.ERole; +import ice.spot.repository.UserRepository; +import ice.spot.security.info.UserPrincipal; +import ice.spot.security.info.factory.Oauth2UserInfo; +import ice.spot.security.info.factory.Oauth2UserInfoFactory; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Service; + +import java.util.UUID; + +@Slf4j +@Service +@RequiredArgsConstructor +public class CustomOauth2UserDetailService extends DefaultOAuth2UserService { + + private final UserRepository userRepository; + private final BCryptPasswordEncoder bCryptPasswordEncoder; + + @Override + public OAuth2User loadUser( + OAuth2UserRequest userRequest + ) throws OAuth2AuthenticationException { + // provider 가져오기 + EProvider provider = EProvider.valueOf( + userRequest.getClientRegistration().getRegistrationId().toUpperCase() + ); + log.info("oauth 제공자 정보 가져오기 성공, 제공자 = {}", provider); + // 사용자 정보 가져오기 + Oauth2UserInfo oauth2UserInfo = Oauth2UserInfoFactory + .getOauth2UserInfo(provider, super.loadUser(userRequest).getAttributes()); + log.info("oauth 사용자 정보 가져오기 성공"); + log.info("attributes = {}", oauth2UserInfo.getAttributes().toString()); + + UserRepository.UserSecurityForm securityForm = userRepository + .findUserSecurityFromBySerialId(oauth2UserInfo.getId()) + .orElseGet(() -> { + log.info("새로운 사용자 접근, 저장 로직 진입"); + User newUser = userRepository.save( + User.builder() + .serialId(oauth2UserInfo.getId()) + .password( + bCryptPasswordEncoder + .encode(UUID.randomUUID().toString()) + ) + .provider(provider) + .role(ERole.GUEST) + .build() + ); + return UserRepository.UserSecurityForm.invoke(newUser); + }); + log.info("oauth2 사용자 조회 성공"); + return UserPrincipal.create(securityForm, oauth2UserInfo.getAttributes()); + } +} diff --git a/src/main/java/ice/spot/security/service/CustomUserDetailService.java b/src/main/java/ice/spot/security/service/CustomUserDetailService.java new file mode 100644 index 0000000..91ff7f5 --- /dev/null +++ b/src/main/java/ice/spot/security/service/CustomUserDetailService.java @@ -0,0 +1,38 @@ +package ice.spot.security.service; + +import ice.spot.exeption.CommonException; +import ice.spot.exeption.ErrorCode; +import ice.spot.repository.UserRepository; +import ice.spot.security.info.UserPrincipal; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class CustomUserDetailService implements UserDetailsService { + + private final UserRepository userRepository; + + @Override + public UserPrincipal loadUserByUsername( + String username + ) throws UsernameNotFoundException { + UserRepository.UserSecurityForm userSecurityForm = userRepository + .findUserSecurityFromBySerialId(username) + .orElseThrow(() -> new UsernameNotFoundException("존재하지 않는 아이디입니다.")); + log.info(("아이디 기반 조회 성공")); + return UserPrincipal.create(userSecurityForm); + } + + public UserPrincipal loadUserById(Long id) { + UserRepository.UserSecurityForm userSecurityForm = userRepository.findUserSecurityFromById(id) + .orElseThrow(() -> new CommonException(ErrorCode.NOT_FOUND_USER)); + log.info("user id 기반 조회 성공"); + + return UserPrincipal.create(userSecurityForm); + } +} diff --git a/src/main/java/ice/spot/service/AuthService.java b/src/main/java/ice/spot/service/AuthService.java new file mode 100644 index 0000000..0483a9e --- /dev/null +++ b/src/main/java/ice/spot/service/AuthService.java @@ -0,0 +1,28 @@ +package ice.spot.service; + +import ice.spot.domain.User; +import ice.spot.dto.request.OauthSignUpDto; +import ice.spot.exeption.CommonException; +import ice.spot.exeption.ErrorCode; +import ice.spot.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AuthService { + + private final UserRepository userRepository; + + @Transactional + public void signUp(Long userId, OauthSignUpDto oauthSignUpDto){ + User oauthUser = userRepository.findById(userId) + .orElseThrow(() -> new CommonException(ErrorCode.NOT_FOUND_USER)); + + oauthUser.register(oauthSignUpDto.nickname()); + } + +} diff --git a/src/main/java/ice/spot/util/CookieUtil.java b/src/main/java/ice/spot/util/CookieUtil.java new file mode 100644 index 0000000..5bd314b --- /dev/null +++ b/src/main/java/ice/spot/util/CookieUtil.java @@ -0,0 +1,85 @@ +package ice.spot.util; + +import ice.spot.constant.Constants; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.ResponseCookie; + +public class CookieUtil { + + public static void addCookie( + HttpServletResponse response, + String domain, + String key, + String value + ) { + ResponseCookie cookie = ResponseCookie.from(key, value) + .path("/") + .domain(domain) + .httpOnly(false) + .secure(true) + .build(); + + response.addHeader("Set-Cookie", cookie.toString()); + } + + public static void logoutCookie( + HttpServletRequest request, + HttpServletResponse response, + String domain + ) { + Cookie[] cookies = request.getCookies(); + if (cookies == null) + return; + + for (Cookie cookie : cookies) { + boolean isAccessCookie = cookie.getName().equals(Constants.ACCESS_COOKIE_NAME); + boolean isRefreshCookie = cookie.getName().equals(Constants.REFRESH_COOKIE_NAME); + + if (isAccessCookie || isRefreshCookie) { + ResponseCookie tempCookie = ResponseCookie.from(cookie.getName(), cookie.getValue()) + .path("/") + .domain(domain) + .secure(true) + .maxAge(0) + .httpOnly(isRefreshCookie) + .build(); + response.addHeader("Set-Cookie", tempCookie.toString()); + } + } + } + + public static void addSecureCookie( + HttpServletResponse response, + String domain, + String key, + String value, + Integer maxAge + ) { + Cookie cookie = new Cookie(key, value); + cookie.setPath("/"); + cookie.setDomain(domain); + cookie.setSecure(true); + cookie.setHttpOnly(true); + cookie.setMaxAge(maxAge); + response.addCookie(cookie); + } + + public static void deleteCookie( + HttpServletRequest request, + HttpServletResponse response, + String name + ) { + Cookie[] cookies = request.getCookies(); + if (cookies == null) + return; + + for (Cookie cookie : cookies) + if (cookie.getName().equals(name)) { + cookie.setMaxAge(0); + cookie.setPath("/"); + response.addCookie(cookie); + } + } +} diff --git a/src/main/java/ice/spot/util/HeaderUtil.java b/src/main/java/ice/spot/util/HeaderUtil.java new file mode 100644 index 0000000..4484455 --- /dev/null +++ b/src/main/java/ice/spot/util/HeaderUtil.java @@ -0,0 +1,22 @@ +package ice.spot.util; + +import ice.spot.exeption.CommonException; +import ice.spot.exeption.ErrorCode; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.util.StringUtils; + +import java.util.Optional; + +public class HeaderUtil { + + public static Optional refineHeader( + HttpServletRequest request, + String headerName, + String prefix + ) { + String headerValue = request.getHeader(headerName); + if (!StringUtils.hasText(headerValue) || !headerValue.startsWith(prefix)) + throw new CommonException(ErrorCode.INVALID_HEADER_VALUE); + return Optional.of(headerValue.substring(prefix.length())); + } +} diff --git a/src/main/java/ice/spot/util/JwtUtil.java b/src/main/java/ice/spot/util/JwtUtil.java new file mode 100644 index 0000000..13bc692 --- /dev/null +++ b/src/main/java/ice/spot/util/JwtUtil.java @@ -0,0 +1,70 @@ +package ice.spot.util; + +import ice.spot.constant.Constants; +import ice.spot.dto.response.JwtTokenDto; +import ice.spot.dto.type.ERole; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Header; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import lombok.Getter; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.security.Key; +import java.util.Date; + +@Component +public class JwtUtil implements InitializingBean { + + @Value("${jwt.secret}") + private String secretKey; + + @Value("${jwt.access-token.expiration}") + @Getter + private Integer accessExpiration; + + @Value("${jwt.refresh-token.expiration}") + @Getter + private Integer refreshExpiration; + + private Key key; + + @Override + public void afterPropertiesSet() throws Exception { + byte[] keyBytes = Decoders.BASE64.decode(secretKey); + this.key = Keys.hmacShaKeyFor(keyBytes); + } + + public Claims validateToken(String token) { + return Jwts.parserBuilder() + .setSigningKey(key) + .build() + .parseClaimsJws(token) + .getBody(); + } + + public String generateToken(Long id, ERole role, Integer expiration) { + Claims claims = Jwts.claims(); + claims.put(Constants.CLAIM_USER_ID, id); + if (role != null) + claims.put(Constants.CLAIM_USER_ROLE, role); + + return Jwts.builder() + .setHeaderParam(Header.JWT_TYPE, Header.JWT_TYPE) + .setClaims(claims) + .setIssuedAt(new Date(System.currentTimeMillis())) + .setExpiration(new Date(System.currentTimeMillis() + expiration)) + .signWith(key) + .compact(); + } + + public JwtTokenDto generateTokens(Long id, ERole role) { + return JwtTokenDto.of( + generateToken(id, role, accessExpiration), + generateToken(id, role, refreshExpiration) + ); + } +}