diff --git a/build.gradle b/build.gradle index 1794334..d2241b3 100644 --- a/build.gradle +++ b/build.gradle @@ -24,24 +24,46 @@ repositories { } dependencies { - implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + // Web MVC implementation 'org.springframework.boot:spring-boot-starter-web' compileOnly 'org.projectlombok:lombok' - runtimeOnly 'com.mysql:mysql-connector-j' annotationProcessor 'org.projectlombok:lombok' implementation 'org.projectlombok:lombok' - implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.0.2' - // test testImplementation 'org.springframework.boot:spring-boot-starter-test' + // DB + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + runtimeOnly 'com.mysql:mysql-connector-j' + // TEST testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + // OAUTH2 + implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' + implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server' + // jwt + implementation 'io.jsonwebtoken:jjwt-api:0.12.3' + implementation 'io.jsonwebtoken:jjwt-impl:0.12.3' + implementation 'io.jsonwebtoken:jjwt-jackson:0.12.3' + // SPRING SECURITY + implementation 'org.springframework.boot:spring-boot-starter-security' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + // Validation + implementation 'org.springframework.boot:spring-boot-starter-validation' + + // S3 + implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE' + implementation 'com.amazonaws:aws-java-sdk-s3:1.12.683' + // Resilience4j + implementation 'io.github.resilience4j:resilience4j-spring-boot2:1.7.1' + implementation 'io.github.resilience4j:resilience4j-bulkhead:1.7.1' + // 비동기 실행 + implementation 'org.springframework.boot:spring-boot-starter-aop' + testImplementation 'org.springframework.security:spring-security-test' testImplementation 'org.awaitility:awaitility:4.3.0' testImplementation 'com.h2database:h2' - } tasks.named('test') { diff --git a/src/main/java/com/example/gtable/global/api/ApiError.java b/src/main/java/com/example/gtable/global/api/ApiError.java index f2026b6..cb5ffc0 100644 --- a/src/main/java/com/example/gtable/global/api/ApiError.java +++ b/src/main/java/com/example/gtable/global/api/ApiError.java @@ -1,23 +1,24 @@ package com.example.gtable.global.api; -import lombok.Getter; import org.springframework.http.HttpStatus; +import lombok.Getter; + @Getter public class ApiError { - private final String message; - private final int status; + private final String message; + private final int status; - public ApiError(String message, int status) { - this.message = message; - this.status = status; - } + public ApiError(String message, int status) { + this.message = message; + this.status = status; + } - public ApiError(Throwable throwable, HttpStatus status) { - this(throwable.getMessage(), status); - } + public ApiError(Throwable throwable, HttpStatus status) { + this(throwable.getMessage(), status); + } - public ApiError(String message, HttpStatus status) { - this(message, status.value()); - } + public ApiError(String message, HttpStatus status) { + this(message, status.value()); + } } diff --git a/src/main/java/com/example/gtable/global/api/ApiResult.java b/src/main/java/com/example/gtable/global/api/ApiResult.java index ce4684e..6a75a71 100644 --- a/src/main/java/com/example/gtable/global/api/ApiResult.java +++ b/src/main/java/com/example/gtable/global/api/ApiResult.java @@ -1,21 +1,21 @@ package com.example.gtable.global.api; public class ApiResult { - private final boolean success; - private final T response; - private final ApiError error; + private final boolean success; + private final T response; + private final ApiError error; - public ApiResult(boolean success, T response, ApiError error) { - this.success = success; - this.response = response; - this.error = error; - } + public ApiResult(boolean success, T response, ApiError error) { + this.success = success; + this.response = response; + this.error = error; + } - public boolean isSuccess() { - return success; - } + public boolean isSuccess() { + return success; + } - public T getResponse() { - return response; - } + public T getResponse() { + return response; + } } diff --git a/src/main/java/com/example/gtable/global/api/ApiUtils.java b/src/main/java/com/example/gtable/global/api/ApiUtils.java index a472b96..3ee9f10 100644 --- a/src/main/java/com/example/gtable/global/api/ApiUtils.java +++ b/src/main/java/com/example/gtable/global/api/ApiUtils.java @@ -1,20 +1,21 @@ package com.example.gtable.global.api; +import org.springframework.http.HttpStatus; + import lombok.AccessLevel; import lombok.NoArgsConstructor; -import org.springframework.http.HttpStatus; @NoArgsConstructor(access = AccessLevel.PROTECTED) public class ApiUtils { - public static ApiResult success(T response) { - return new ApiResult<>(true, response,null); - } + public static ApiResult success(T response) { + return new ApiResult<>(true, response, null); + } - public static ApiResult error(Throwable throwable, HttpStatus status) { - return new ApiResult<>(false, null, new ApiError(throwable, status)); - } + public static ApiResult error(Throwable throwable, HttpStatus status) { + return new ApiResult<>(false, null, new ApiError(throwable, status)); + } - public static ApiResult error(String message,HttpStatus status) { - return new ApiResult<>(false, null, new ApiError(message,status)); - } + public static ApiResult error(String message, HttpStatus status) { + return new ApiResult<>(false, null, new ApiError(message, status)); + } } diff --git a/src/main/java/com/example/gtable/global/config/AsyncConfig.java b/src/main/java/com/example/gtable/global/config/AsyncConfig.java new file mode 100644 index 0000000..6b01fae --- /dev/null +++ b/src/main/java/com/example/gtable/global/config/AsyncConfig.java @@ -0,0 +1,21 @@ +package com.example.gtable.global.config; + +import java.util.concurrent.Executor; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +@Configuration +public class AsyncConfig { + @Bean(name = "s3UploadExecutor") + public Executor s3UploadExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(5); + executor.setMaxPoolSize(10); + executor.setQueueCapacity(100); + executor.setThreadNamePrefix("S3Upload-"); + executor.initialize(); + return executor; + } +} diff --git a/src/main/java/com/example/gtable/global/config/AwsS3Config.java b/src/main/java/com/example/gtable/global/config/AwsS3Config.java new file mode 100644 index 0000000..ffb6bd1 --- /dev/null +++ b/src/main/java/com/example/gtable/global/config/AwsS3Config.java @@ -0,0 +1,33 @@ +package com.example.gtable.global.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import com.amazonaws.auth.AWSStaticCredentialsProvider; +import com.amazonaws.auth.BasicAWSCredentials; +import com.amazonaws.services.s3.AmazonS3Client; +import com.amazonaws.services.s3.AmazonS3ClientBuilder; + +@Configuration +public class AwsS3Config { + + @Value("${cloud.aws.credentials.access-key}") + private String accessKey; + + @Value("${cloud.aws.credentials.secret-key}") + private String secretKey; + + @Value("${cloud.aws.region.static}") + private String region; + + @Bean + public AmazonS3Client amazonS3Client() { + BasicAWSCredentials awsCredentials = new BasicAWSCredentials(accessKey, secretKey); + return (AmazonS3Client)AmazonS3ClientBuilder.standard() + .withRegion(region) + .withCredentials(new AWSStaticCredentialsProvider(awsCredentials)) + .build(); + } + +} diff --git a/src/main/java/com/example/gtable/global/config/CorsConfig.java b/src/main/java/com/example/gtable/global/config/CorsConfig.java new file mode 100644 index 0000000..1c02f40 --- /dev/null +++ b/src/main/java/com/example/gtable/global/config/CorsConfig.java @@ -0,0 +1,28 @@ +package com.example.gtable.global.config; + +import java.util.List; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +@Configuration +public class CorsConfig { + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration config = new CorsConfiguration(); + + config.setAllowCredentials(true); // 쿠키나 인증헤더 자격증명 허용 + config.setAllowedOrigins(List.of("http://localhost:3000")); // 허용할 출처 설정 + config.setAllowedMethods(List.of("GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS")); // 메서드 허용 + config.setAllowedHeaders(List.of("*")); //클라이언트가 보낼 수 있는 헤더 + config.setExposedHeaders(List.of("Authorization")); //클라이언트(브라우저)가 접근할 수 있는 헤더 지정 + // config.setAllowCredentials(true); // 쿠키 포함 허용 + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); //** 뜻은 모든 URL 경로에 적용한다는 의미 + return source; + } +} diff --git a/src/main/java/com/example/gtable/global/config/SecurityConfig.java b/src/main/java/com/example/gtable/global/config/SecurityConfig.java new file mode 100644 index 0000000..f54ed19 --- /dev/null +++ b/src/main/java/com/example/gtable/global/config/SecurityConfig.java @@ -0,0 +1,65 @@ +package com.example.gtable.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfigurationSource; + +import com.example.gtable.global.security.jwt.JwtAuthorizationFilter; +import com.example.gtable.global.security.jwt.JwtUtil; +import com.example.gtable.global.security.oauth2.CustomOAuth2UserService; + +import lombok.RequiredArgsConstructor; + +@Configuration +@EnableWebSecurity // security 활성화 어노테이션 +@RequiredArgsConstructor +public class SecurityConfig { + private final CustomOAuth2UserService customOAuth2UserService; + private final com.example.gtable.global.security.oauth2.OAuth2LoginSuccessHandler OAuth2LoginSuccessHandler; + private final JwtUtil jwtUtil; + + private final CorsConfigurationSource corsConfigurationSource; + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + // CSRF 방어 기능 비활성화 (jwt 토큰을 사용할 것이기에 필요없음) + .csrf(AbstractHttpConfigurer::disable) + // 시큐리티 폼 로그인 비활성화 + .formLogin(AbstractHttpConfigurer::disable) + // HTTP Basic 인증 비활성화 + .httpBasic(AbstractHttpConfigurer::disable) + // oauth2 로그인 + // - userInfoEndPoint에서 사용자 정보 불러오고, + // - successHandler에서 로그인 성공 시 JWT 생성 및 반환로직 + .oauth2Login(oauth2 -> + oauth2.userInfoEndpoint(userInfoEndpoint -> + userInfoEndpoint.userService(customOAuth2UserService) + ).successHandler(OAuth2LoginSuccessHandler) + ) + // 세션 사용하지 않음 + .sessionManagement(session -> + session.sessionCreationPolicy(SessionCreationPolicy.STATELESS) + ) + .authorizeHttpRequests(auth -> auth + .requestMatchers( + "/oauth2/authorization/kakao", // 카카오 로그인 요청 + "/login/oauth2/code/**", // 카카오 인증 콜백 + "/api/refresh-token", + "/stores/**") // refresh token (토큰 갱신) + .permitAll() + .anyRequest().authenticated() // 그외 요청은 허가된 사람만 인가 + ) + // JWTFiler + .addFilterBefore(new JwtAuthorizationFilter(jwtUtil), UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } + +} diff --git a/src/main/java/com/example/gtable/global/s3/S3Service.java b/src/main/java/com/example/gtable/global/s3/S3Service.java new file mode 100644 index 0000000..1fecf4a --- /dev/null +++ b/src/main/java/com/example/gtable/global/s3/S3Service.java @@ -0,0 +1,57 @@ +package com.example.gtable.global.s3; + +import java.io.InputStream; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import com.amazonaws.services.s3.AmazonS3Client; +import com.amazonaws.services.s3.model.ObjectMetadata; + +import io.github.resilience4j.bulkhead.annotation.Bulkhead; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class S3Service { + private final AmazonS3Client amazonS3Client; + + @Value("${cloud.aws.s3.bucket}") + private String bucket; + + public record S3UploadResult(String key, String url) { + } + + @Bulkhead(name = "s3UploadBulkhead", type = Bulkhead.Type.THREADPOOL) + @Async("s3UploadExecutor") + public CompletableFuture upload(Long storeId, MultipartFile file) { + try (InputStream inputStream = file.getInputStream()) { + String key = createFileKey(storeId, file.getOriginalFilename()); + ObjectMetadata metadata = new ObjectMetadata(); + metadata.setContentLength(file.getSize()); + + amazonS3Client.putObject(bucket, key, inputStream, metadata); + String url = amazonS3Client.getUrl(bucket, key).toString(); + + return CompletableFuture.completedFuture(new S3UploadResult(key, url)); + } catch (Exception e) { + throw new RuntimeException("S3 업로드 실패", e); + } + } + + public void delete(String filename) { + try { + amazonS3Client.deleteObject(bucket, filename); + } catch (Exception e) { + throw new RuntimeException("S3 파일 삭제 실패", e); + } + } + + private String createFileKey(Long storeId, String filename) { + return "store/" + storeId + "/" + UUID.randomUUID() + "-" + filename; + } +} diff --git a/src/main/java/com/example/gtable/global/security/exception/BusinessException.java b/src/main/java/com/example/gtable/global/security/exception/BusinessException.java new file mode 100644 index 0000000..118ef78 --- /dev/null +++ b/src/main/java/com/example/gtable/global/security/exception/BusinessException.java @@ -0,0 +1,14 @@ +package com.example.gtable.global.security.exception; + +public abstract class BusinessException extends RuntimeException { + private final ErrorMessage errorMessage; + + protected BusinessException(ErrorMessage errorMessage) { + super(errorMessage.getMessage()); + this.errorMessage = errorMessage; + } + + public String getCode() { + return errorMessage.getCode(); + } +} diff --git a/src/main/java/com/example/gtable/global/security/exception/ErrorMessage.java b/src/main/java/com/example/gtable/global/security/exception/ErrorMessage.java new file mode 100644 index 0000000..93b9467 --- /dev/null +++ b/src/main/java/com/example/gtable/global/security/exception/ErrorMessage.java @@ -0,0 +1,22 @@ +package com.example.gtable.global.security.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum ErrorMessage { + // global + INVALID_INPUT_VALUE("입력값이 올바르지 않습니다.", "g001"), + + // auth + UNAUTHORIZED("권한이 없습니다", "a001"), + + // token + REFRESH_TOKEN_NOT_FOUND("기존 리프레시 토큰을 찾을 수 없습니다.", "t001"), + DOES_NOT_MATCH_REFRESH_TOKEN("기존 리프레시 토큰이 일치하지 않습니다.", "t002"); + + private final String message; + private final String code; + +} diff --git a/src/main/java/com/example/gtable/global/security/exception/ErrorResponse.java b/src/main/java/com/example/gtable/global/security/exception/ErrorResponse.java new file mode 100644 index 0000000..305e2cd --- /dev/null +++ b/src/main/java/com/example/gtable/global/security/exception/ErrorResponse.java @@ -0,0 +1,26 @@ +package com.example.gtable.global.security.exception; + +import java.util.HashMap; +import java.util.Map; + +import lombok.Getter; + +@Getter +public class ErrorResponse { + private final String message; + private final String code; + private final Map errors; + + public ErrorResponse(String message, String code) { + this.message = message; + this.code = code; + errors = new HashMap<>(); + } + + public ErrorResponse(String message, String code, Map errors) { + this.message = message; + this.code = code; + this.errors = errors; + } + +} diff --git a/src/main/java/com/example/gtable/global/security/exception/GlobalExceptionHandler.java b/src/main/java/com/example/gtable/global/security/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..a8f4ad8 --- /dev/null +++ b/src/main/java/com/example/gtable/global/security/exception/GlobalExceptionHandler.java @@ -0,0 +1,105 @@ +package com.example.gtable.global.security.exception; + +import static com.example.gtable.global.security.exception.ErrorMessage.*; +import static org.springframework.http.HttpStatus.UNAUTHORIZED; +import static org.springframework.http.HttpStatus.*; + +import java.util.Map; +import java.util.stream.Collectors; + +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.validation.FieldError; +import org.springframework.validation.ObjectError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingRequestValueException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.multipart.MultipartException; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + // OAUTH 인증 실패 에러처리 메서드 + @ResponseStatus(value = BAD_REQUEST) + @ExceptionHandler(OAuth2AuthenticationException.class) + public ErrorResponse handlerOAuth2AuthenticationException(OAuth2AuthenticationException e) { + log.error("handleOAuth2AuthenticationException", e); + + return new ErrorResponse("OAuth 인증 실패 : " + e.getMessage(), INVALID_INPUT_VALUE.getCode()); + } + + @ResponseStatus(value = BAD_REQUEST) + @ExceptionHandler(BusinessException.class) + public ErrorResponse handleBusinessException(BusinessException e) { + log.error("handleBusinessException", e); + return new ErrorResponse(e.getMessage(), e.getCode()); + } + + @ResponseStatus(value = BAD_REQUEST) + @ExceptionHandler(MethodArgumentNotValidException.class) + public ErrorResponse handleMethodArgumentNotValidException(MethodArgumentNotValidException e) { + log.error("handleMethodArgumentNotValidException", e); + Map errors = getErrors(e); + return new ErrorResponse(INVALID_INPUT_VALUE.getMessage(), INVALID_INPUT_VALUE.getCode(), errors); + } + + @ResponseStatus(value = BAD_REQUEST) + @ExceptionHandler(HttpMessageNotReadableException.class) + public ErrorResponse handleHttpMessageNotReadableException(HttpMessageNotReadableException e) { + log.error("handleHttpMessageNotReadableException", e); + return new ErrorResponse(INVALID_INPUT_VALUE.getMessage(), INVALID_INPUT_VALUE.getCode()); + } + + @ResponseStatus(value = BAD_REQUEST) + @ExceptionHandler(IllegalArgumentException.class) + public ErrorResponse handleIllegalArgumentException(IllegalArgumentException e) { + log.error("handleIllegalArgumentException", e); + return new ErrorResponse(e.getMessage(), INVALID_INPUT_VALUE.getCode()); + } + + @ResponseStatus(value = BAD_REQUEST) + @ExceptionHandler(MissingRequestValueException.class) + public ErrorResponse handleMissingRequestValueException(MissingRequestValueException e) { + log.error("handleMissingRequestValueExceptionException", e); + return new ErrorResponse(INVALID_INPUT_VALUE.getMessage(), INVALID_INPUT_VALUE.getCode()); + } + + @ResponseStatus(value = UNAUTHORIZED) + @ExceptionHandler(UnauthorizedException.class) + public ErrorResponse handleUnauthorizedException(UnauthorizedException e) { + log.error("handleUnauthorizedExceptionException", e); + return new ErrorResponse(e.getMessage(), e.getCode()); + } + + @ResponseStatus(value = NOT_FOUND) + @ExceptionHandler(ResourceNotFoundException.class) + public ErrorResponse handleResourceNotFoundException(ResourceNotFoundException e) { + log.error("handleResourceNotFoundExceptionException", e); + return new ErrorResponse(e.getMessage(), e.getCode()); + } + + @ResponseStatus(value = BAD_REQUEST) + @ExceptionHandler(MultipartException.class) + public ErrorResponse handleMultipartException(MultipartException e) { + log.error("handleMultipartException", e); + return new ErrorResponse(e.getMessage(), INVALID_INPUT_VALUE.getCode()); + } + + private static Map getErrors(MethodArgumentNotValidException e) { + return e.getBindingResult() + .getAllErrors() + .stream() + .filter(ObjectError.class::isInstance) + .collect(Collectors.toMap( + error -> error instanceof FieldError ? ((FieldError)error).getField() : error.getObjectName(), + ObjectError::getDefaultMessage, + (msg1, msg2) -> msg1 + ";" + msg2 + )); + } + +} diff --git a/src/main/java/com/example/gtable/global/security/exception/RefreshTokenNotFoundException.java b/src/main/java/com/example/gtable/global/security/exception/RefreshTokenNotFoundException.java new file mode 100644 index 0000000..65472b4 --- /dev/null +++ b/src/main/java/com/example/gtable/global/security/exception/RefreshTokenNotFoundException.java @@ -0,0 +1,9 @@ +package com.example.gtable.global.security.exception; + +public class RefreshTokenNotFoundException extends ResourceNotFoundException { + + public RefreshTokenNotFoundException() { + super(ErrorMessage.REFRESH_TOKEN_NOT_FOUND); + } + +} diff --git a/src/main/java/com/example/gtable/global/security/exception/ResourceNotFoundException.java b/src/main/java/com/example/gtable/global/security/exception/ResourceNotFoundException.java new file mode 100644 index 0000000..32ff882 --- /dev/null +++ b/src/main/java/com/example/gtable/global/security/exception/ResourceNotFoundException.java @@ -0,0 +1,14 @@ +package com.example.gtable.global.security.exception; + +public abstract class ResourceNotFoundException extends RuntimeException { + private final ErrorMessage errorMessage; + + protected ResourceNotFoundException(ErrorMessage errorMessage) { + super(errorMessage.getMessage()); + this.errorMessage = errorMessage; + } + + public String getCode() { + return errorMessage.getCode(); + } +} diff --git a/src/main/java/com/example/gtable/global/security/exception/TokenBadRequestException.java b/src/main/java/com/example/gtable/global/security/exception/TokenBadRequestException.java new file mode 100644 index 0000000..38df8e0 --- /dev/null +++ b/src/main/java/com/example/gtable/global/security/exception/TokenBadRequestException.java @@ -0,0 +1,8 @@ +package com.example.gtable.global.security.exception; + +public class TokenBadRequestException extends BusinessException { + public TokenBadRequestException() { + super(ErrorMessage.DOES_NOT_MATCH_REFRESH_TOKEN); + } + +} diff --git a/src/main/java/com/example/gtable/global/security/exception/UnauthorizedException.java b/src/main/java/com/example/gtable/global/security/exception/UnauthorizedException.java new file mode 100644 index 0000000..6621357 --- /dev/null +++ b/src/main/java/com/example/gtable/global/security/exception/UnauthorizedException.java @@ -0,0 +1,19 @@ +package com.example.gtable.global.security.exception; + +public class UnauthorizedException extends RuntimeException { + private final ErrorMessage errorMessage; + + public UnauthorizedException() { + super(ErrorMessage.UNAUTHORIZED.getMessage()); + this.errorMessage = ErrorMessage.UNAUTHORIZED; + } + + public UnauthorizedException(ErrorMessage errorMessage) { + super(errorMessage.getMessage()); + this.errorMessage = errorMessage; + } + + public String getCode() { + return errorMessage.getCode(); + } +} diff --git a/src/main/java/com/example/gtable/global/security/jwt/JwtAuthorizationFilter.java b/src/main/java/com/example/gtable/global/security/jwt/JwtAuthorizationFilter.java new file mode 100644 index 0000000..bce7891 --- /dev/null +++ b/src/main/java/com/example/gtable/global/security/jwt/JwtAuthorizationFilter.java @@ -0,0 +1,93 @@ +package com.example.gtable.global.security.jwt; + +import java.io.IOException; + +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.filter.OncePerRequestFilter; + +import com.example.gtable.global.security.oauth2.dto.CustomOAuth2User; +import com.example.gtable.user.entity.Role; +import com.example.gtable.user.entity.SocialType; +import com.example.gtable.user.entity.User; + +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; + +// JWT 검증 필터 +// 1. 헤더에서 accessToken 추출, 2. 토큰 검증, 3. 유효하면 사용자정보를 SecurityContextHolder에 세팅 +// 그러면, 이후 컨트롤러에서 @AuthenticationPrincipal에서 저장했던 사용자 정보를 꺼내쓸 수 있음 +@RequiredArgsConstructor +@Slf4j +public class JwtAuthorizationFilter extends OncePerRequestFilter { + private final JwtUtil jwtUtil; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + String header = request.getHeader("Authorization"); + + // 인증헤더 Bearer가 없다면, 다음 필터로 넘김 + if (header == null || !header.startsWith("Bearer ")) { + filterChain.doFilter(request, response); + + log.info("JwtAuthorizationFilter 1 "); + return; + } + + log.info("header :: {}, header.substring(7) :: {}", header, header.substring(7)); + String accessToken = header.substring(7); + + // 토큰 만료 여부 확인, 만료 시 다음 필터로 넘기지 않음 + try { + jwtUtil.isExpired(accessToken); + } catch (ExpiredJwtException e) { + + // response status code + msg + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.getWriter().print("access token expired"); + + log.info("JwtAuthorizationFilter 2 "); + return; + } + + // 토큰이 accessToken 종류인지 확인 + String tokenCategory = jwtUtil.getTokenCategory(accessToken); + + if (!tokenCategory.equals("accessToken")) { + //response status code + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.getWriter().print("invalid access token"); + + log.info("JwtAuthorizationFilter 3 "); + return; + } + + // userId와 role 값 추출 + Long userId = jwtUtil.getUserId(accessToken); + String roleString = jwtUtil.getRole(accessToken); + + User user = User.createUserWithId(userId, "sampleEmail", "sampleNickname", "sampleProfileImg" + , SocialType.KAKAO, Role.fromString(roleString)); + + CustomOAuth2User customOAuth2User = new CustomOAuth2User(user); + + // 스프링 시큐리티 인증 토큰 생성 + UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken( + customOAuth2User, null, customOAuth2User.getAuthorities()); + + // 생성한 인증 정보를 SecurityContext에 설정 + SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken); + + log.info("JwtAuthorizationFilter 4 "); + + filterChain.doFilter(request, response); + + } + +} diff --git a/src/main/java/com/example/gtable/global/security/jwt/JwtUtil.java b/src/main/java/com/example/gtable/global/security/jwt/JwtUtil.java new file mode 100644 index 0000000..b32846f --- /dev/null +++ b/src/main/java/com/example/gtable/global/security/jwt/JwtUtil.java @@ -0,0 +1,76 @@ +package com.example.gtable.global.security.jwt; + +import java.nio.charset.StandardCharsets; +import java.util.Date; + +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import io.jsonwebtoken.Jwts; + +@Component +public class JwtUtil { + private final SecretKey secretKey; + + // 시크릿 키를 암호화하여, 키 생성 + public JwtUtil(@Value("${jwt.secret}") String secret) { + this.secretKey = new SecretKeySpec( + secret.getBytes(StandardCharsets.UTF_8), + Jwts.SIG.HS256.key().build().getAlgorithm() + ); + } + + public String createAccessToken(String tokenCategory, Long userId, String role, Long expiredMs) { + return Jwts.builder() + .claim("tokenCategory", tokenCategory) // accessToken + .claim("userId", userId) + .claim("role", role) + .issuedAt(new Date(System.currentTimeMillis())) + .expiration(new Date(System.currentTimeMillis() + expiredMs)) + .signWith(secretKey) + .compact(); + } + + public String createRefreshToken(String tokenCategory, Long userId, Long expiredMs) { + return Jwts.builder() + .claim("tokenCategory", tokenCategory) // refreshToken + .claim("userId", userId) + .issuedAt(new Date(System.currentTimeMillis())) + .expiration(new Date(System.currentTimeMillis() + expiredMs)) + .signWith(secretKey) + .compact(); + } + + public String getTokenCategory(String token) { + return Jwts.parser().verifyWith(secretKey).build() + .parseClaimsJws(token) + .getBody() + .get("tokenCategory", String.class); + } + + public String getRole(String token) { + return Jwts.parser().verifyWith(secretKey).build() + .parseClaimsJws(token) + .getBody() + .get("role", String.class); + } + + public Long getUserId(String token) { + return Jwts.parser().verifyWith(secretKey).build() + .parseClaimsJws(token) + .getBody() + .get("userId", Long.class); + } + + public Boolean isExpired(String token) { + return Jwts.parser().verifyWith(secretKey).build() + .parseSignedClaims(token) + .getPayload() + .getExpiration() + .before(new Date()); + } + +} diff --git a/src/main/java/com/example/gtable/global/security/oauth2/CustomOAuth2UserService.java b/src/main/java/com/example/gtable/global/security/oauth2/CustomOAuth2UserService.java new file mode 100644 index 0000000..0cb6aab --- /dev/null +++ b/src/main/java/com/example/gtable/global/security/oauth2/CustomOAuth2UserService.java @@ -0,0 +1,70 @@ +package com.example.gtable.global.security.oauth2; + +import java.util.Optional; + +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 com.example.gtable.global.security.oauth2.dto.CustomOAuth2User; +import com.example.gtable.global.security.oauth2.dto.KaKaoResponse; +import com.example.gtable.global.security.oauth2.dto.OAuth2Response; +import com.example.gtable.user.entity.Role; +import com.example.gtable.user.entity.SocialType; +import com.example.gtable.user.entity.User; +import com.example.gtable.user.repository.UserRepository; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +// OAuth2 제공자(카카오)로부터 제공받은 사용자 정보를, 우리 서비스에 맞게 가공, 변환 +@Service +@RequiredArgsConstructor +@Slf4j +public class CustomOAuth2UserService extends DefaultOAuth2UserService { + private final UserRepository userRepository; + + @Override + public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { + OAuth2User oAuth2User = super.loadUser(userRequest); + + log.info("CustomOAuth2UserService :: {}", oAuth2User); + log.info("oAuthUser.getAttributes :: {}", oAuth2User.getAttributes()); + + String registrationId = userRequest.getClientRegistration().getRegistrationId(); + OAuth2Response oAuth2Response = null; + + if (registrationId.equals("kakao")) { + oAuth2Response = new KaKaoResponse(oAuth2User.getAttributes()); + } else { + throw new OAuth2AuthenticationException("지원하지 않는 OAuth2 Provider 입니다."); + } + + // DB에 유저가 있는지 판단 + Optional foundUser = userRepository.findByEmail(oAuth2Response.getEmail()); + + // DB에 유저 없으면 - 회원가입 + if (foundUser.isEmpty()) { + + User user = User.builder() + .email(oAuth2Response.getEmail()) + .nickname(oAuth2Response.getNickName()) + .profileImage(oAuth2Response.getProfileImage()) + .socialType(SocialType.KAKAO) + .role(Role.USER) // 일반 유저 설정 + .build(); + + userRepository.save(user); + + return new CustomOAuth2User(user); + } else { + // DB에 유저 존재하면 - 로그인 진행 (이때 로그인 처리는 안하고, OAuth2LoginSuccessHandler에서 담당함) + User user = foundUser.get(); + + return new CustomOAuth2User(user); + } + } + +} diff --git a/src/main/java/com/example/gtable/global/security/oauth2/OAuth2LoginSuccessHandler.java b/src/main/java/com/example/gtable/global/security/oauth2/OAuth2LoginSuccessHandler.java new file mode 100644 index 0000000..60ce2a6 --- /dev/null +++ b/src/main/java/com/example/gtable/global/security/oauth2/OAuth2LoginSuccessHandler.java @@ -0,0 +1,81 @@ +package com.example.gtable.global.security.oauth2; + +import java.io.IOException; +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.Iterator; +import java.util.Map; + +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; +import org.springframework.stereotype.Component; + +import com.example.gtable.global.security.jwt.JwtUtil; +import com.example.gtable.global.security.oauth2.dto.CustomOAuth2User; +import com.example.gtable.token.entity.Token; +import com.example.gtable.token.repository.TokenRepository; +import com.example.gtable.user.entity.User; +import com.fasterxml.jackson.databind.ObjectMapper; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +// 카카오 로그인 성공 시, 콜백 핸들러 +// 1. JWT 토큰 발급 +// - 이때, JWT payload는 보안상 최소한의 정보(userId, role)만 담겠다 +// 2. refreshToken만 DB에 저장 +// 3. JSON 응답으로, accessToken과 refreshToken 을 반환해준다. +@Component +@RequiredArgsConstructor +@Slf4j +public class OAuth2LoginSuccessHandler extends SimpleUrlAuthenticationSuccessHandler { + private final JwtUtil jwtUtil; + private final TokenRepository tokenRepository; + + @Override + public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, + Authentication authentication) throws IOException { + + // 1. CustomOAuth2UserService에서 설정한 OAuth2User 정보 가져오기 + CustomOAuth2User customUserDetails = (CustomOAuth2User)authentication.getPrincipal(); + + User user = customUserDetails.getUser(); + Long userId = customUserDetails.getUserId(); + String email = customUserDetails.getName(); + + Collection authorities = authentication.getAuthorities(); + Iterator iterator = authorities.iterator(); + GrantedAuthority auth = iterator.next(); + + String role = auth.getAuthority(); + + log.info("user, userId, email, role :: {} {} {} {}", user, userId, email, role); + + // 2. 1)의 사용자 정보를 담아, accessToken과 refreshToken 발행 + String accessToken = jwtUtil.createAccessToken("accessToken", userId, role, 30 * 60 * 1000L); // 유효기간 30분 + String refreshToken = jwtUtil.createRefreshToken("refreshToken", userId, + 30 * 24 * 60 * 60 * 1000L); // 유효기간 30일 + + // 3. refreshToken을 DB에 저장 + Token refreshTokenEntity = Token.toEntity(user, refreshToken, LocalDateTime.now().plusDays(30)); + tokenRepository.save(refreshTokenEntity); + + // 4. JSON 응답으로, accessToken과 refreshToken 을 반환해준다. + response.setContentType("application/json"); + response.setCharacterEncoding("utf-8"); + + ObjectMapper objectMapper = new ObjectMapper(); // 객체 -> json 문자열로 변환 + String body = objectMapper.writeValueAsString( + Map.of( + "accessToken", accessToken, + "refreshToken", refreshToken + ) + ); + response.getWriter().write(body); + } + +} diff --git a/src/main/java/com/example/gtable/global/security/oauth2/dto/CustomOAuth2User.java b/src/main/java/com/example/gtable/global/security/oauth2/dto/CustomOAuth2User.java new file mode 100644 index 0000000..32e9f94 --- /dev/null +++ b/src/main/java/com/example/gtable/global/security/oauth2/dto/CustomOAuth2User.java @@ -0,0 +1,60 @@ +package com.example.gtable.global.security.oauth2.dto; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Map; + +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.oauth2.core.user.OAuth2User; + +import com.example.gtable.user.entity.User; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class CustomOAuth2User implements OAuth2User { + private User user; + + // User 객체를 받는 생성자 + public CustomOAuth2User(User user) { + this.user = user; + } + + @Override + public Map getAttributes() { + return null; + } + + // 사용자가 가지는 권한 설정 + @Override + public Collection getAuthorities() { + Collection authorities = new ArrayList<>(); + + authorities.add(new GrantedAuthority() { + @Override + public String getAuthority() { + return user.getRole().getName(); // 유저의 권한 리턴 + } + }); + + return authorities; + } + + @Override + public String getName() { + return user.getEmail(); + } + + public User getUser() { + return user; + } + + public Long getUserId() { + return user.getId(); + } + + public String getNickname() { + return user.getNickname(); + } + +} diff --git a/src/main/java/com/example/gtable/global/security/oauth2/dto/KaKaoResponse.java b/src/main/java/com/example/gtable/global/security/oauth2/dto/KaKaoResponse.java new file mode 100644 index 0000000..f7d06b1 --- /dev/null +++ b/src/main/java/com/example/gtable/global/security/oauth2/dto/KaKaoResponse.java @@ -0,0 +1,40 @@ +package com.example.gtable.global.security.oauth2.dto; + +import java.util.Map; + +import lombok.RequiredArgsConstructor; + +// 카카오 OAuth2 응답에서 필요한 정보를 추출하는 역할 +@RequiredArgsConstructor +public class KaKaoResponse implements OAuth2Response { + private final Map attributes; + + @Override + public String getProvider() { + return "kakao"; + } + + @Override + public String getProviderId() { + return attributes.get("id").toString(); + } + + @Override + public String getEmail() { + Map kakaoAccount = (Map)attributes.get("kakao_account"); + return kakaoAccount.get("email").toString(); + } + + @Override + public String getNickName() { + Map properties = (Map)attributes.get("properties"); + return properties.get("nickname").toString(); + } + + @Override + public String getProfileImage() { + Map properties = (Map)attributes.get("properties"); + return properties.get("profile_image").toString(); + } + +} diff --git a/src/main/java/com/example/gtable/global/security/oauth2/dto/OAuth2Response.java b/src/main/java/com/example/gtable/global/security/oauth2/dto/OAuth2Response.java new file mode 100644 index 0000000..919c49e --- /dev/null +++ b/src/main/java/com/example/gtable/global/security/oauth2/dto/OAuth2Response.java @@ -0,0 +1,19 @@ +package com.example.gtable.global.security.oauth2.dto; + +public interface OAuth2Response { + // 제공자 (ex. naver, kakao) + String getProvider(); + + // 제공자에서 발급해주는 아이디 (번호) + String getProviderId(); + + // 아래 이메일, 닉네임, 프로필이미지는 내가 카카오 developers에서 발급받겠다고 신청한 정보들이다 + // 이메일 + String getEmail(); + + // 닉네임 + String getNickName(); + + // 프로필이미지 + String getProfileImage(); +} diff --git a/src/main/java/com/example/gtable/store/dto/StoreCreateRequest.java b/src/main/java/com/example/gtable/store/dto/StoreCreateRequest.java index e400dd3..cdcda45 100644 --- a/src/main/java/com/example/gtable/store/dto/StoreCreateRequest.java +++ b/src/main/java/com/example/gtable/store/dto/StoreCreateRequest.java @@ -23,15 +23,12 @@ public class StoreCreateRequest { private String description; - private String storeImageUrl; - public Store toEntity() { return Store.builder() .departmentId(departmentId) .name(name) .location(location) .description(description) - .storeImageUrl(storeImageUrl) .isActive(false) .deleted(false) .build(); diff --git a/src/main/java/com/example/gtable/store/dto/StoreCreateResponse.java b/src/main/java/com/example/gtable/store/dto/StoreCreateResponse.java index caeeaf4..0e5bfb4 100644 --- a/src/main/java/com/example/gtable/store/dto/StoreCreateResponse.java +++ b/src/main/java/com/example/gtable/store/dto/StoreCreateResponse.java @@ -18,7 +18,6 @@ public class StoreCreateResponse { private String name; private String location; private String description; - private String storeImageUrl; private Boolean isActive; private Boolean deleted; private LocalDateTime createdAt; @@ -31,7 +30,6 @@ public static StoreCreateResponse fromEntity(Store store) { .name(store.getName()) .location(store.getLocation()) .description(store.getDescription()) - .storeImageUrl(store.getStoreImageUrl()) .isActive(store.getIsActive()) .deleted(store.getDeleted()) .build(); diff --git a/src/main/java/com/example/gtable/store/dto/StoreReadDto.java b/src/main/java/com/example/gtable/store/dto/StoreReadDto.java index 610f176..1fb4579 100644 --- a/src/main/java/com/example/gtable/store/dto/StoreReadDto.java +++ b/src/main/java/com/example/gtable/store/dto/StoreReadDto.java @@ -1,8 +1,10 @@ package com.example.gtable.store.dto; import java.time.LocalDateTime; +import java.util.List; import com.example.gtable.store.model.Store; +import com.example.gtable.storeImage.dto.StoreImageUploadResponse; import lombok.AllArgsConstructor; import lombok.Builder; @@ -17,12 +19,12 @@ public class StoreReadDto { private String name; private String location; private String description; - private String storeImageUrl; + private List images; private Boolean isActive; private Boolean deleted; private LocalDateTime createdAt; - public static StoreReadDto fromEntity(Store store) { + public static StoreReadDto fromEntity(Store store, List images) { return StoreReadDto.builder() .createdAt(store.getCreatedAt()) .storeId(store.getStoreId()) @@ -30,9 +32,9 @@ public static StoreReadDto fromEntity(Store store) { .name(store.getName()) .location(store.getLocation()) .description(store.getDescription()) - .storeImageUrl(store.getStoreImageUrl()) .isActive(store.getIsActive()) .deleted(store.getDeleted()) + .images(images) .build(); } } diff --git a/src/main/java/com/example/gtable/store/dto/StoreReadResponse.java b/src/main/java/com/example/gtable/store/dto/StoreReadResponse.java index 061206f..b44c9f7 100644 --- a/src/main/java/com/example/gtable/store/dto/StoreReadResponse.java +++ b/src/main/java/com/example/gtable/store/dto/StoreReadResponse.java @@ -14,7 +14,7 @@ public class StoreReadResponse { private List storeReadDtos; private boolean hasNext; - public static StoreReadResponse fromEntity(List storeReadDtos, boolean hasNext) { + public static StoreReadResponse of(List storeReadDtos, boolean hasNext) { return StoreReadResponse.builder() .storeReadDtos(storeReadDtos) .hasNext(hasNext) diff --git a/src/main/java/com/example/gtable/store/dto/StoreUpdateRequest.java b/src/main/java/com/example/gtable/store/dto/StoreUpdateRequest.java index 033b8ee..cb95159 100644 --- a/src/main/java/com/example/gtable/store/dto/StoreUpdateRequest.java +++ b/src/main/java/com/example/gtable/store/dto/StoreUpdateRequest.java @@ -13,6 +13,5 @@ public class StoreUpdateRequest { private String name; private String location; private String description; - private String storeImageUrl; private Boolean isActive; } diff --git a/src/main/java/com/example/gtable/store/model/Store.java b/src/main/java/com/example/gtable/store/model/Store.java index cefa782..32dc069 100644 --- a/src/main/java/com/example/gtable/store/model/Store.java +++ b/src/main/java/com/example/gtable/store/model/Store.java @@ -36,8 +36,6 @@ public class Store extends BaseTimeEntity { private String description; - private String storeImageUrl; - @Column(name = "is_active", nullable = false) private Boolean isActive = false; @@ -45,39 +43,32 @@ public class Store extends BaseTimeEntity { private Boolean deleted = false; public Store(LocalDateTime createdAt, Long storeId, Long departmentId, String name, String location, - String description, String storeImageUrl, Boolean isActive, Boolean deleted) { + String description, Boolean isActive, Boolean deleted) { super(createdAt); this.storeId = storeId; this.departmentId = departmentId; this.name = name; this.location = location; this.description = description; - this.storeImageUrl = storeImageUrl; this.isActive = isActive; this.deleted = deleted; } - public void setName(String name) { + public void updateInfo(String name, String location, String description) { this.name = name; - } - - public void setLocation(String location) { this.location = location; - } - - public void setDescription(String description) { this.description = description; } - public void setStoreImageUrl(String url) { - this.storeImageUrl = url; + public void markAsDeleted() { + this.deleted = true; } - public void setIsActive(Boolean isActive) { - this.isActive = isActive; + public void activate() { + this.isActive = true; } - public void setDeleted(Boolean deleted) { - this.deleted = deleted; + public void deactivate() { + this.isActive = false; } } diff --git a/src/main/java/com/example/gtable/store/service/StoreServiceImpl.java b/src/main/java/com/example/gtable/store/service/StoreServiceImpl.java index 165f3e6..17518cf 100644 --- a/src/main/java/com/example/gtable/store/service/StoreServiceImpl.java +++ b/src/main/java/com/example/gtable/store/service/StoreServiceImpl.java @@ -12,6 +12,9 @@ import com.example.gtable.store.dto.StoreUpdateRequest; import com.example.gtable.store.model.Store; import com.example.gtable.store.repository.StoreRepository; +import com.example.gtable.storeImage.dto.StoreImageUploadResponse; +import com.example.gtable.storeImage.model.StoreImage; +import com.example.gtable.storeImage.repository.StoreImageRepository; import jakarta.persistence.EntityNotFoundException; import lombok.RequiredArgsConstructor; @@ -21,6 +24,7 @@ public class StoreServiceImpl implements StoreService { private final StoreRepository storeRepository; + private final StoreImageRepository storeImageRepository; @Override @Transactional @@ -38,15 +42,18 @@ public StoreReadResponse getAllStores() { List stores = storeRepository.findAllByDeletedFalse(); List storeRead = stores.stream() - .map(StoreReadDto::fromEntity) + .map(store -> { + List images = storeImageRepository.findByStore(store); + List imageDto = images.stream() + .map(StoreImageUploadResponse::fromEntity) + .toList(); + return StoreReadDto.fromEntity(store, imageDto); + }) .toList(); boolean hasNext = false; - return StoreReadResponse.fromEntity( - storeRead, - hasNext - ); + return StoreReadResponse.of(storeRead, hasNext); } @Override @@ -55,7 +62,12 @@ public StoreReadDto getStoreByStoreId(Long storeId) { Store store = storeRepository.findByStoreIdAndDeletedFalse(storeId) .orElseThrow(() -> new EntityNotFoundException(storeId + " store not found.")); - return StoreReadDto.fromEntity(store); + List images = storeImageRepository.findByStore(store); + List imageDto = images.stream() + .map(StoreImageUploadResponse::fromEntity) + .toList(); + + return StoreReadDto.fromEntity(store, imageDto); } @Override @@ -64,20 +76,20 @@ public StoreReadDto updateStore(Long storeId, StoreUpdateRequest request) { Store store = storeRepository.findByStoreIdAndDeletedFalse(storeId) .orElseThrow(() -> new EntityNotFoundException(storeId + " store not found.")); - if (request.getName() != null) - store.setName(request.getName()); - if (request.getLocation() != null) - store.setLocation(request.getLocation()); - if (request.getDescription() != null) - store.setDescription(request.getDescription()); - if (request.getStoreImageUrl() != null) - store.setStoreImageUrl(request.getStoreImageUrl()); - if (request.getIsActive() != null) - store.setIsActive(request.getIsActive()); + store.updateInfo( + request.getName(), + request.getLocation(), + request.getDescription() + ); Store updatedStore = storeRepository.save(store); - return StoreReadDto.fromEntity(updatedStore); + List images = storeImageRepository.findByStore(updatedStore); + List imageDto = images.stream() + .map(StoreImageUploadResponse::fromEntity) + .toList(); + + return StoreReadDto.fromEntity(updatedStore, imageDto); } @Override @@ -86,7 +98,7 @@ public String deleteStore(Long storeId) { Store store = storeRepository.findByStoreIdAndDeletedFalse(storeId) .orElseThrow(() -> new EntityNotFoundException(storeId + " store not found.")); - store.setDeleted(true); + store.markAsDeleted(); storeRepository.save(store); return "Store ID " + storeId + " 삭제되었습니다."; diff --git a/src/main/java/com/example/gtable/storeImage/controller/StoreImageController.java b/src/main/java/com/example/gtable/storeImage/controller/StoreImageController.java new file mode 100644 index 0000000..8b92e5a --- /dev/null +++ b/src/main/java/com/example/gtable/storeImage/controller/StoreImageController.java @@ -0,0 +1,56 @@ +package com.example.gtable.storeImage.controller; + +import java.util.List; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import com.example.gtable.global.api.ApiUtils; +import com.example.gtable.storeImage.dto.StoreImageUploadResponse; +import com.example.gtable.storeImage.service.StoreImageService; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/stores") +@RequiredArgsConstructor +public class StoreImageController { + + private final StoreImageService storeImageService; + + @PostMapping("/{storeId}/images") + public ResponseEntity uploadStoreImage( + @PathVariable Long storeId, + @RequestParam("files") List files, + @RequestParam(value = "types") List types + ) { + List response = storeImageService.saveAll(storeId, files, types); + return ResponseEntity + .status(HttpStatus.CREATED) + .body( + ApiUtils.success( + response + ) + ); + } + + @DeleteMapping("/image/{imageId}") + public ResponseEntity deleteStoreImage(@PathVariable Long imageId) { + storeImageService.delete(imageId); + return ResponseEntity + .status(HttpStatus.NO_CONTENT) + .body( + ApiUtils + .success( + "Store image deleted successfully." + ) + ); + } +} diff --git a/src/main/java/com/example/gtable/storeImage/dto/StoreImageUploadResponse.java b/src/main/java/com/example/gtable/storeImage/dto/StoreImageUploadResponse.java new file mode 100644 index 0000000..0fdfc3e --- /dev/null +++ b/src/main/java/com/example/gtable/storeImage/dto/StoreImageUploadResponse.java @@ -0,0 +1,22 @@ +package com.example.gtable.storeImage.dto; + +import com.example.gtable.storeImage.model.StoreImage; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class StoreImageUploadResponse { + private final Long id; + private final String imageUrl; + private final String type; + + public static StoreImageUploadResponse fromEntity(StoreImage storeImage) { + return StoreImageUploadResponse.builder() + .id(storeImage.getId()) + .imageUrl(storeImage.getImageUrl()) + .type(storeImage.getType()) + .build(); + } +} diff --git a/src/main/java/com/example/gtable/storeImage/model/StoreImage.java b/src/main/java/com/example/gtable/storeImage/model/StoreImage.java new file mode 100644 index 0000000..3224888 --- /dev/null +++ b/src/main/java/com/example/gtable/storeImage/model/StoreImage.java @@ -0,0 +1,43 @@ +package com.example.gtable.storeImage.model; + +import com.example.gtable.global.entity.BaseTimeEntity; +import com.example.gtable.store.model.Store; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +@Entity +@Table(name = "store_images") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@SuperBuilder +public class StoreImage extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "store_id") + private Store store; + + @Column(nullable = false, length = 500) + private String imageUrl; + + @Column(nullable = false, length = 500) + private String fileKey; + + @Column(length = 20) + private String type; +} diff --git a/src/main/java/com/example/gtable/storeImage/repository/StoreImageRepository.java b/src/main/java/com/example/gtable/storeImage/repository/StoreImageRepository.java new file mode 100644 index 0000000..e7db0a0 --- /dev/null +++ b/src/main/java/com/example/gtable/storeImage/repository/StoreImageRepository.java @@ -0,0 +1,15 @@ +package com.example.gtable.storeImage.repository; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import com.example.gtable.store.model.Store; +import com.example.gtable.storeImage.model.StoreImage; + +@Repository +public interface StoreImageRepository extends JpaRepository { + + List findByStore(Store store); +} diff --git a/src/main/java/com/example/gtable/storeImage/service/StoreImageService.java b/src/main/java/com/example/gtable/storeImage/service/StoreImageService.java new file mode 100644 index 0000000..92ccf1d --- /dev/null +++ b/src/main/java/com/example/gtable/storeImage/service/StoreImageService.java @@ -0,0 +1,68 @@ +package com.example.gtable.storeImage.service; + +import java.util.ArrayList; +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import com.example.gtable.global.s3.S3Service; +import com.example.gtable.store.model.Store; +import com.example.gtable.store.repository.StoreRepository; +import com.example.gtable.storeImage.dto.StoreImageUploadResponse; +import com.example.gtable.storeImage.model.StoreImage; +import com.example.gtable.storeImage.repository.StoreImageRepository; + +import jakarta.persistence.EntityNotFoundException; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class StoreImageService { + + private final StoreRepository storeRepository; + private final StoreImageRepository storeImageRepository; + private final S3Service s3Service; + + @Transactional + public List saveAll(Long storeId, List files, List types) { + if (files.size() != types.size()) { + throw new IllegalArgumentException("파일과 타입의 개수가 일치해야 합니다."); + } + + Store store = storeRepository.findById(storeId) + .orElseThrow(() -> new EntityNotFoundException("Store not found with id: " + storeId)); + + List imageUploadResponses = new ArrayList<>(); + for (int i = 0; i < files.size(); i++) { + S3Service.S3UploadResult uploadResult; + try { + uploadResult = s3Service.upload(storeId, files.get(i)).get(); + } catch (Exception e) { + throw new RuntimeException("S3 업로드 실패", e); + } + + StoreImage storeImage = StoreImage.builder() + .store(store) + .imageUrl(uploadResult.url()) + .fileKey(uploadResult.key()) + .type(types.get(i)) + .build(); + + storeImageRepository.save(storeImage); + imageUploadResponses.add(StoreImageUploadResponse.fromEntity(storeImage)); + } + + return imageUploadResponses; + } + + @Transactional + public void delete(Long storeImageId) { + StoreImage storeImage = storeImageRepository.findById(storeImageId) + .orElseThrow(() -> new EntityNotFoundException("StoreImage not found with id: " + storeImageId)); + + s3Service.delete(storeImage.getFileKey()); + storeImageRepository.delete(storeImage); + } +} diff --git a/src/main/java/com/example/gtable/token/controller/TokenController.java b/src/main/java/com/example/gtable/token/controller/TokenController.java new file mode 100644 index 0000000..1627480 --- /dev/null +++ b/src/main/java/com/example/gtable/token/controller/TokenController.java @@ -0,0 +1,49 @@ +package com.example.gtable.token.controller; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.example.gtable.global.security.jwt.JwtUtil; +import com.example.gtable.token.dto.AuthenticationResponse; +import com.example.gtable.token.dto.RefreshTokenRequest; +import com.example.gtable.token.service.TokenService; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/refresh-token") +@Slf4j +public class TokenController { + private final JwtUtil jwtUtil; + private final TokenService tokenService; + @PostMapping + public ResponseEntity refreshToken(@RequestBody RefreshTokenRequest request){ + String refreshToken = request.getRefreshToken(); + + // 리프레시 토큰 검증 + Long userId = jwtUtil.getUserId(refreshToken); + String role = jwtUtil.getRole(refreshToken); + + // 리프레시 토큰 유효성 검증 + if (tokenService.validateToken(refreshToken, userId)){ + // 유효한 토큰이라면, 새로운 accessToken, refreshToken 생성 + String newAccessToken = jwtUtil.createAccessToken("accessToken", userId, role, 30 * 60 * 1000L); + String newRefreshToken = jwtUtil.createRefreshToken("refreshToken", userId, 30 * 24 * 60 * 60 * 1000L); + + // DB에 새로운 refreshToken으로 교체 + tokenService.updateRefreshToken(userId, refreshToken, newRefreshToken); + + AuthenticationResponse authenticationResponse = new AuthenticationResponse(newAccessToken, refreshToken); + return ResponseEntity.ok().body(authenticationResponse); + + } + + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Invalid or expired refresh token"); + } +} diff --git a/src/main/java/com/example/gtable/token/dto/AuthenticationResponse.java b/src/main/java/com/example/gtable/token/dto/AuthenticationResponse.java new file mode 100644 index 0000000..1d8b270 --- /dev/null +++ b/src/main/java/com/example/gtable/token/dto/AuthenticationResponse.java @@ -0,0 +1,17 @@ +package com.example.gtable.token.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.ToString; + +@RequiredArgsConstructor +@Getter +@ToString(exclude = {"accessToken", "refreshToken"}) // 로깅 시 토큰 노출 방지 +public class AuthenticationResponse { + @JsonProperty("access_token") + private final String accessToken; + + @JsonProperty("refresh_token") + private final String refreshToken; +} \ No newline at end of file diff --git a/src/main/java/com/example/gtable/token/dto/RefreshTokenRequest.java b/src/main/java/com/example/gtable/token/dto/RefreshTokenRequest.java new file mode 100644 index 0000000..7966e52 --- /dev/null +++ b/src/main/java/com/example/gtable/token/dto/RefreshTokenRequest.java @@ -0,0 +1,12 @@ +package com.example.gtable.token.dto; + +import jakarta.validation.constraints.NotBlank; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class RefreshTokenRequest { + @NotBlank(message = "Refresh token은 필수입니다.") + private String refreshToken; +} diff --git a/src/main/java/com/example/gtable/token/entity/Token.java b/src/main/java/com/example/gtable/token/entity/Token.java new file mode 100644 index 0000000..30d407d --- /dev/null +++ b/src/main/java/com/example/gtable/token/entity/Token.java @@ -0,0 +1,54 @@ +package com.example.gtable.token.entity; + +import java.time.LocalDateTime; + +import com.example.gtable.user.entity.User; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "token") +@Getter +public class Token { + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long tokenId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column + private String refreshToken; + + private LocalDateTime expiredDate; + + @Builder + public Token(User user, String refreshToken, LocalDateTime expiredDate) { + this.user = user; + this.refreshToken = refreshToken; + this.expiredDate = expiredDate; + } + + // static method로 객체를 생성 - 생성 의도 파악 쉬웁 + public static Token toEntity(User user, String refreshToken, LocalDateTime expiredDate){ + return Token.builder() + .user(user) + .refreshToken(refreshToken) + .expiredDate(expiredDate) + .build(); + } + +} diff --git a/src/main/java/com/example/gtable/token/repository/TokenRepository.java b/src/main/java/com/example/gtable/token/repository/TokenRepository.java new file mode 100644 index 0000000..61a24d3 --- /dev/null +++ b/src/main/java/com/example/gtable/token/repository/TokenRepository.java @@ -0,0 +1,11 @@ +package com.example.gtable.token.repository; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.example.gtable.token.entity.Token; + +public interface TokenRepository extends JpaRepository { + Optional findByUserId(Long userId); +} diff --git a/src/main/java/com/example/gtable/token/service/TokenService.java b/src/main/java/com/example/gtable/token/service/TokenService.java new file mode 100644 index 0000000..4e0843e --- /dev/null +++ b/src/main/java/com/example/gtable/token/service/TokenService.java @@ -0,0 +1,66 @@ +package com.example.gtable.token.service; + +import java.time.LocalDateTime; +import java.util.Optional; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.example.gtable.global.security.exception.RefreshTokenNotFoundException; +import com.example.gtable.global.security.exception.TokenBadRequestException; +import com.example.gtable.global.security.jwt.JwtUtil; +import com.example.gtable.token.entity.Token; +import com.example.gtable.token.repository.TokenRepository; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Service +@RequiredArgsConstructor +@Slf4j +public class TokenService { + private final TokenRepository tokenRepository; + private final JwtUtil jwtUtil; + + @Transactional + public Boolean validateToken(String token, Long userId){ + // DB에서 해당 userId와 일치하는 리프레시토큰을 찾는다. + Optional savedToken = tokenRepository.findByUserId(userId); + + // DB에서 userId에 대응되는 리프레시토큰 없으면, 유효하지 않음 + if (savedToken.isEmpty()){ + log.info("여기에 걸렸니 ? -- 1 "); + return false; + } + + // 리프레시 토큰이 DB에 저장된 토큰과 일치하는지 확인 + if (!savedToken.get().getRefreshToken().equals(token)){ + log.info("여기에 걸렸니 ? -- 2 "); + return false; + } + + // 리프레시 토큰의 만료여부 확인 + if(jwtUtil.isExpired(token)){ + log.info("여기에 걸렸니 ? -- 3 "); + return false; // 만료된 토큰은 유효하지 않음 + } + + log.info("여기에 걸렸니 ? -- 4 "); + return true; // 모든 조건 만족 시, 유효한 토큰 + } + + @Transactional + public void updateRefreshToken(Long userId, String oldRefreshToken, String newRefreshToken){ + Token token = tokenRepository.findByUserId(userId) + .orElseThrow(RefreshTokenNotFoundException::new); // 404 + + if (!token.getRefreshToken().equals(oldRefreshToken)){ + throw new TokenBadRequestException(); // 400 + } + + // 기존 토큰 삭제 및 새 토큰 저장 + tokenRepository.delete(token); + Token newToken = Token.toEntity(token.getUser(), newRefreshToken, LocalDateTime.now().plusDays(30)); + tokenRepository.save(newToken); + } +} diff --git a/src/main/java/com/example/gtable/user/controller/UserController.java b/src/main/java/com/example/gtable/user/controller/UserController.java new file mode 100644 index 0000000..f675866 --- /dev/null +++ b/src/main/java/com/example/gtable/user/controller/UserController.java @@ -0,0 +1,35 @@ +package com.example.gtable.user.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.example.gtable.global.security.oauth2.dto.CustomOAuth2User; +import com.example.gtable.user.dto.UserResponseDto; +import com.example.gtable.user.entity.User; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + + +@RestController +@RequestMapping("/api/users") +@RequiredArgsConstructor +@Slf4j +public class UserController { + + // 로그인된 유저 정보를 확인하는 api + @GetMapping("/me") + public ResponseEntity getMyInfo(@AuthenticationPrincipal CustomOAuth2User customOAuth2User){ + User user = customOAuth2User.getUser(); + + UserResponseDto userResponseDto = UserResponseDto.builder() + .userId(user.getId()) + .role(user.getRole().getName()) + .build(); + return ResponseEntity.ok(userResponseDto); + } + +} diff --git a/src/main/java/com/example/gtable/user/dto/UserResponseDto.java b/src/main/java/com/example/gtable/user/dto/UserResponseDto.java new file mode 100644 index 0000000..d9f256b --- /dev/null +++ b/src/main/java/com/example/gtable/user/dto/UserResponseDto.java @@ -0,0 +1,18 @@ +package com.example.gtable.user.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import lombok.Builder; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Builder +@Getter +@RequiredArgsConstructor +public class UserResponseDto { + @JsonProperty("userId") + private final Long userId; + @JsonProperty("role") + private final String role; + +} diff --git a/src/main/java/com/example/gtable/user/entity/Role.java b/src/main/java/com/example/gtable/user/entity/Role.java new file mode 100644 index 0000000..3095eb9 --- /dev/null +++ b/src/main/java/com/example/gtable/user/entity/Role.java @@ -0,0 +1,27 @@ +package com.example.gtable.user.entity; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum Role { + USER("USER"), + ADMIN("ADMIN"); + + private final String name; + + // Role의 String name -> Role enum으로 변경 + public static Role fromString(String name) { + if (name == null || name.trim().isEmpty()) { + throw new IllegalArgumentException("Role name cannot be null or empty"); + } + + for (Role role : Role.values()){ + if (role.name.equalsIgnoreCase(name)){ + return role; + } + } + throw new IllegalArgumentException("Unknown role: " + name); + } +} diff --git a/src/main/java/com/example/gtable/user/entity/SocialType.java b/src/main/java/com/example/gtable/user/entity/SocialType.java new file mode 100644 index 0000000..90e5fad --- /dev/null +++ b/src/main/java/com/example/gtable/user/entity/SocialType.java @@ -0,0 +1,5 @@ +package com.example.gtable.user.entity; + +public enum SocialType { + KAKAO, NAVER, GOOGLE +} diff --git a/src/main/java/com/example/gtable/user/entity/User.java b/src/main/java/com/example/gtable/user/entity/User.java new file mode 100644 index 0000000..05b0783 --- /dev/null +++ b/src/main/java/com/example/gtable/user/entity/User.java @@ -0,0 +1,65 @@ +package com.example.gtable.user.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "users") +@Getter +public class User { + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) // 자동 생성 + private Long id; + + @Column(nullable = false, unique = true) + private String email; // 카카오 이메일 + + @Column(nullable = false) + private String nickname; + + @Column(nullable = false) + private String profileImage; + + @Enumerated(EnumType.STRING) + private SocialType socialType; + + @Enumerated(EnumType.STRING) + private Role role; + + @Builder + public User(String email, String nickname, String profileImage, SocialType socialType, Role role){ + this.email = email; + this.nickname = nickname; + this.profileImage = profileImage; + this.socialType = socialType; + this.role = role; + } + + public static User createUserWithId(Long userId, String email, String nickname, String profileImage, SocialType socialType, Role role){ + User user = User.builder() + .email(email) + .nickname(nickname) + .profileImage(profileImage) + .socialType(socialType) + .role(role) + .build(); + user.id = userId; + + return user; + } + + // User 도메인 관련 비즈니스 로직 (예: 닉네임 변경) + public void updateNickname(String nickname){ + this.nickname = nickname; + } +} diff --git a/src/main/java/com/example/gtable/user/repository/UserRepository.java b/src/main/java/com/example/gtable/user/repository/UserRepository.java new file mode 100644 index 0000000..46d699f --- /dev/null +++ b/src/main/java/com/example/gtable/user/repository/UserRepository.java @@ -0,0 +1,11 @@ +package com.example.gtable.user.repository; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.example.gtable.user.entity.User; + +public interface UserRepository extends JpaRepository { + Optional findByEmail(String email); +} diff --git a/src/main/java/com/example/gtable/user/service/UserService.java b/src/main/java/com/example/gtable/user/service/UserService.java new file mode 100644 index 0000000..94ac857 --- /dev/null +++ b/src/main/java/com/example/gtable/user/service/UserService.java @@ -0,0 +1,14 @@ +package com.example.gtable.user.service; + +import org.springframework.stereotype.Service; + +import com.example.gtable.user.repository.UserRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class UserService { + private final UserRepository userRepository; + +}