diff --git a/build.gradle b/build.gradle index a7fd3e706..6a80ab3c8 100644 --- a/build.gradle +++ b/build.gradle @@ -27,6 +27,8 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-security' + compileOnly 'org.projectlombok:lombok' runtimeOnly 'com.h2database:h2' runtimeOnly 'com.mysql:mysql-connector-j' @@ -37,10 +39,21 @@ dependencies { // bcrypt implementation 'at.favre.lib:bcrypt:0.10.2' + // querydsl + implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta' + annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta" + annotationProcessor "jakarta.annotation:jakarta.annotation-api" + annotationProcessor "jakarta.persistence:jakarta.persistence-api" + // jwt compileOnly group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.5' runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.5' runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.5' + + + // AWS S3 + implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE' + } tasks.named('test') { diff --git a/src/main/java/org/example/expert/aop/AdminAccessLoggingAspect.java b/src/main/java/org/example/expert/aop/AdminAccessLoggingAspect.java index c90e8c792..ee6234e79 100644 --- a/src/main/java/org/example/expert/aop/AdminAccessLoggingAspect.java +++ b/src/main/java/org/example/expert/aop/AdminAccessLoggingAspect.java @@ -4,8 +4,8 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.aspectj.lang.JoinPoint; -import org.aspectj.lang.annotation.After; import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.annotation.Before; import org.springframework.stereotype.Component; import java.time.LocalDateTime; @@ -18,7 +18,7 @@ public class AdminAccessLoggingAspect { private final HttpServletRequest request; - @After("execution(* org.example.expert.domain.user.controller.UserController.getUser(..))") + @Before("execution(* org.example.expert.domain.user.controller.UserAdminController.changeUserRole(..))") public void logAfterChangeUserRole(JoinPoint joinPoint) { String userId = String.valueOf(request.getAttribute("userId")); String requestUrl = request.getRequestURI(); diff --git a/src/main/java/org/example/expert/config/AuthUserArgumentResolver.java b/src/main/java/org/example/expert/config/AuthUserArgumentResolver.java index db00211de..228eef2cf 100644 --- a/src/main/java/org/example/expert/config/AuthUserArgumentResolver.java +++ b/src/main/java/org/example/expert/config/AuthUserArgumentResolver.java @@ -19,9 +19,9 @@ public boolean supportsParameter(MethodParameter parameter) { boolean hasAuthAnnotation = parameter.getParameterAnnotation(Auth.class) != null; boolean isAuthUserType = parameter.getParameterType().equals(AuthUser.class); - // @Auth 어노테이션과 AuthUser 타입이 함께 사용되지 않은 경우 예외 발생 + // @AuthenticationPrincipal 어노테이션과 AuthUser 타입이 함께 사용되지 않은 경우 예외 발생 if (hasAuthAnnotation != isAuthUserType) { - throw new AuthException("@Auth와 AuthUser 타입은 함께 사용되어야 합니다."); + throw new AuthException("@AuthenticationPrincipal와 AuthUser 타입은 함께 사용되어야 합니다."); } return hasAuthAnnotation; @@ -40,7 +40,7 @@ public Object resolveArgument( Long userId = (Long) request.getAttribute("userId"); String email = (String) request.getAttribute("email"); UserRole userRole = UserRole.of((String) request.getAttribute("userRole")); - - return new AuthUser(userId, email, userRole); + String nickname = (String) request.getAttribute("nickname"); + return new AuthUser(userId, email, userRole, nickname); } } diff --git a/src/main/java/org/example/expert/config/FilterConfig.java b/src/main/java/org/example/expert/config/FilterConfig.java deleted file mode 100644 index 34cb4088a..000000000 --- a/src/main/java/org/example/expert/config/FilterConfig.java +++ /dev/null @@ -1,22 +0,0 @@ -package org.example.expert.config; - -import lombok.RequiredArgsConstructor; -import org.springframework.boot.web.servlet.FilterRegistrationBean; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -@Configuration -@RequiredArgsConstructor -public class FilterConfig { - - private final JwtUtil jwtUtil; - - @Bean - public FilterRegistrationBean jwtFilter() { - FilterRegistrationBean registrationBean = new FilterRegistrationBean<>(); - registrationBean.setFilter(new JwtFilter(jwtUtil)); - registrationBean.addUrlPatterns("/*"); // 필터를 적용할 URL 패턴을 지정합니다. - - return registrationBean; - } -} diff --git a/src/main/java/org/example/expert/config/JwtFilter.java b/src/main/java/org/example/expert/config/JwtFilter.java deleted file mode 100644 index 03908abe1..000000000 --- a/src/main/java/org/example/expert/config/JwtFilter.java +++ /dev/null @@ -1,94 +0,0 @@ -package org.example.expert.config; - -import io.jsonwebtoken.Claims; -import io.jsonwebtoken.ExpiredJwtException; -import io.jsonwebtoken.MalformedJwtException; -import io.jsonwebtoken.UnsupportedJwtException; -import jakarta.servlet.FilterConfig; -import jakarta.servlet.*; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.example.expert.domain.user.enums.UserRole; - -import java.io.IOException; - -@Slf4j -@RequiredArgsConstructor -public class JwtFilter implements Filter { - - private final JwtUtil jwtUtil; - - @Override - public void init(FilterConfig filterConfig) throws ServletException { - Filter.super.init(filterConfig); - } - - @Override - public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { - HttpServletRequest httpRequest = (HttpServletRequest) request; - HttpServletResponse httpResponse = (HttpServletResponse) response; - - String url = httpRequest.getRequestURI(); - - if (url.startsWith("/auth")) { - chain.doFilter(request, response); - return; - } - - String bearerJwt = httpRequest.getHeader("Authorization"); - - if (bearerJwt == null) { - // 토큰이 없는 경우 400을 반환합니다. - httpResponse.sendError(HttpServletResponse.SC_BAD_REQUEST, "JWT 토큰이 필요합니다."); - return; - } - - String jwt = jwtUtil.substringToken(bearerJwt); - - try { - // JWT 유효성 검사와 claims 추출 - Claims claims = jwtUtil.extractClaims(jwt); - if (claims == null) { - httpResponse.sendError(HttpServletResponse.SC_BAD_REQUEST, "잘못된 JWT 토큰입니다."); - return; - } - - UserRole userRole = UserRole.valueOf(claims.get("userRole", String.class)); - - httpRequest.setAttribute("userId", Long.parseLong(claims.getSubject())); - httpRequest.setAttribute("email", claims.get("email")); - httpRequest.setAttribute("userRole", claims.get("userRole")); - - if (url.startsWith("/admin")) { - // 관리자 권한이 없는 경우 403을 반환합니다. - if (!UserRole.ADMIN.equals(userRole)) { - httpResponse.sendError(HttpServletResponse.SC_FORBIDDEN, "관리자 권한이 없습니다."); - return; - } - chain.doFilter(request, response); - return; - } - - chain.doFilter(request, response); - } catch (SecurityException | MalformedJwtException e) { - log.error("Invalid JWT signature, 유효하지 않는 JWT 서명 입니다.", e); - httpResponse.sendError(HttpServletResponse.SC_UNAUTHORIZED, "유효하지 않는 JWT 서명입니다."); - } catch (ExpiredJwtException e) { - log.error("Expired JWT token, 만료된 JWT token 입니다.", e); - httpResponse.sendError(HttpServletResponse.SC_UNAUTHORIZED, "만료된 JWT 토큰입니다."); - } catch (UnsupportedJwtException e) { - log.error("Unsupported JWT token, 지원되지 않는 JWT 토큰 입니다.", e); - httpResponse.sendError(HttpServletResponse.SC_BAD_REQUEST, "지원되지 않는 JWT 토큰입니다."); - } catch (Exception e) { - log.error("Internal server error", e); - httpResponse.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); - } - } - - @Override - public void destroy() { - Filter.super.destroy(); - } -} diff --git a/src/main/java/org/example/expert/config/JwtUtil.java b/src/main/java/org/example/expert/config/JwtUtil.java index 07e0a2c7c..230bec1b8 100644 --- a/src/main/java/org/example/expert/config/JwtUtil.java +++ b/src/main/java/org/example/expert/config/JwtUtil.java @@ -34,7 +34,7 @@ public void init() { key = Keys.hmacShaKeyFor(bytes); } - public String createToken(Long userId, String email, UserRole userRole) { + public String createToken(Long userId, String email, UserRole userRole, String nickname) { Date date = new Date(); return BEARER_PREFIX + @@ -42,6 +42,7 @@ public String createToken(Long userId, String email, UserRole userRole) { .setSubject(String.valueOf(userId)) .claim("email", email) .claim("userRole", userRole) + .claim("nickname", nickname) .setExpiration(new Date(date.getTime() + TOKEN_TIME)) .setIssuedAt(date) // 발급일 .signWith(key, signatureAlgorithm) // 암호화 알고리즘 diff --git a/src/main/java/org/example/expert/config/QueryDslConfig.java b/src/main/java/org/example/expert/config/QueryDslConfig.java new file mode 100644 index 000000000..3b9b1d76e --- /dev/null +++ b/src/main/java/org/example/expert/config/QueryDslConfig.java @@ -0,0 +1,20 @@ +package org.example.expert.config; + +import com.querydsl.jpa.JPQLTemplates; +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +class QueryDslConfig { + + @PersistenceContext + private EntityManager em; + + @Bean + public JPAQueryFactory jpaQueryFactory() { + return new JPAQueryFactory(JPQLTemplates.DEFAULT, em); + } +} \ No newline at end of file diff --git a/src/main/java/org/example/expert/config/WebConfig.java b/src/main/java/org/example/expert/config/WebConfig.java deleted file mode 100644 index adff06b82..000000000 --- a/src/main/java/org/example/expert/config/WebConfig.java +++ /dev/null @@ -1,19 +0,0 @@ -package org.example.expert.config; - -import lombok.RequiredArgsConstructor; -import org.springframework.context.annotation.Configuration; -import org.springframework.web.method.support.HandlerMethodArgumentResolver; -import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; - -import java.util.List; - -@Configuration -@RequiredArgsConstructor -public class WebConfig implements WebMvcConfigurer { - - // ArgumentResolver 등록 - @Override - public void addArgumentResolvers(List resolvers) { - resolvers.add(new AuthUserArgumentResolver()); - } -} diff --git a/src/main/java/org/example/expert/config/aws/S3Config.java b/src/main/java/org/example/expert/config/aws/S3Config.java new file mode 100644 index 000000000..651fd630f --- /dev/null +++ b/src/main/java/org/example/expert/config/aws/S3Config.java @@ -0,0 +1,27 @@ +package org.example.expert.config.aws; + +import com.amazonaws.auth.AWSStaticCredentialsProvider; +import com.amazonaws.auth.BasicAWSCredentials; +import com.amazonaws.services.s3.AmazonS3Client; +import com.amazonaws.services.s3.AmazonS3ClientBuilder; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class S3Config { + @Value("${S3_ACCESS}") + private String accessKey; + @Value("${S3_SECRET}") + 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(); + } +} \ No newline at end of file diff --git a/src/main/java/org/example/expert/domain/Attachment/controller/AttachmentController.java b/src/main/java/org/example/expert/domain/Attachment/controller/AttachmentController.java new file mode 100644 index 000000000..bc836e578 --- /dev/null +++ b/src/main/java/org/example/expert/domain/Attachment/controller/AttachmentController.java @@ -0,0 +1,44 @@ +package org.example.expert.domain.Attachment.controller; + +import lombok.RequiredArgsConstructor; +import org.example.expert.domain.Attachment.dto.AttachmentResponseDto; +import org.example.expert.domain.Attachment.service.AttachmentService; +import org.example.expert.domain.common.dto.AuthUser; +import org.example.expert.domain.common.dto.ResponseDto; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.util.List; + +@RestController +@RequestMapping("/attachments") +@RequiredArgsConstructor +public class AttachmentController { + private final AttachmentService attachmentService; + @PostMapping + public ResponseEntity>> uploadAttachments( + @AuthenticationPrincipal AuthUser authUser, + @RequestPart List files + ) throws IOException { + List responseDto = attachmentService.uploadAttachments(authUser, files); + return ResponseEntity.status(HttpStatus.CREATED) + .body(ResponseDto.of(HttpStatus.CREATED, "파일 업로드가 완료되었습니다.", responseDto)); + } + + @DeleteMapping("/{attachment_id}") + public ResponseEntity> deleteAttachment( + @AuthenticationPrincipal AuthUser authUser, + @PathVariable("attachment_id") Long attachmentId + ) { + attachmentService.deleteAttachment(authUser, attachmentId); + return ResponseEntity.status(HttpStatus.OK) + .body(ResponseDto.of(200, "성공적으로 삭제되었습니다.")); + } + + + +} diff --git a/src/main/java/org/example/expert/domain/Attachment/dto/AttachmentResponseDto.java b/src/main/java/org/example/expert/domain/Attachment/dto/AttachmentResponseDto.java new file mode 100644 index 000000000..b1f3561a4 --- /dev/null +++ b/src/main/java/org/example/expert/domain/Attachment/dto/AttachmentResponseDto.java @@ -0,0 +1,15 @@ +package org.example.expert.domain.Attachment.dto; + +import lombok.Getter; +import org.example.expert.domain.Attachment.entity.Attachment; + +@Getter +public class AttachmentResponseDto { + private Long id; + private String url; + + public AttachmentResponseDto(Attachment attachment) { + this.id = attachment.getId(); + this.url = attachment.getUrl(); + } +} diff --git a/src/main/java/org/example/expert/domain/Attachment/entity/Attachment.java b/src/main/java/org/example/expert/domain/Attachment/entity/Attachment.java new file mode 100644 index 000000000..461874939 --- /dev/null +++ b/src/main/java/org/example/expert/domain/Attachment/entity/Attachment.java @@ -0,0 +1,26 @@ +package org.example.expert.domain.Attachment.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@NoArgsConstructor +@AllArgsConstructor +@Getter +@Entity +@Table(name = "attachment") +public class Attachment { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "attach_id") + private Long id; + + private String url; + + public Attachment(String url) { + this.url = url; + } + +} \ No newline at end of file diff --git a/src/main/java/org/example/expert/domain/Attachment/repository/AttachmentRepository.java b/src/main/java/org/example/expert/domain/Attachment/repository/AttachmentRepository.java new file mode 100644 index 000000000..278c9cf60 --- /dev/null +++ b/src/main/java/org/example/expert/domain/Attachment/repository/AttachmentRepository.java @@ -0,0 +1,7 @@ +package org.example.expert.domain.Attachment.repository; + +import org.example.expert.domain.Attachment.entity.Attachment; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface AttachmentRepository extends JpaRepository { +} diff --git a/src/main/java/org/example/expert/domain/Attachment/service/AttachmentService.java b/src/main/java/org/example/expert/domain/Attachment/service/AttachmentService.java new file mode 100644 index 000000000..b64790a70 --- /dev/null +++ b/src/main/java/org/example/expert/domain/Attachment/service/AttachmentService.java @@ -0,0 +1,97 @@ +package org.example.expert.domain.Attachment.service; + +import com.amazonaws.services.s3.AmazonS3Client; +import com.amazonaws.services.s3.model.ObjectMetadata; +import lombok.RequiredArgsConstructor; +import org.example.expert.domain.Attachment.dto.AttachmentResponseDto; +import org.example.expert.domain.Attachment.entity.Attachment; +import org.example.expert.domain.Attachment.repository.AttachmentRepository; +import org.example.expert.domain.common.dto.AuthUser; +import org.example.expert.domain.common.exception.InvalidRequestException; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AttachmentService { + private final AmazonS3Client amazonS3Client; + private final AttachmentRepository attachmentRepository; + + @Value("${cloud.aws.s3.bucket}") + private String bucket; + + + @Transactional + public List uploadAttachments(AuthUser authUser, List files) throws IOException { + + List responseDtos = new ArrayList<>(); + + List supportedFileTypes = List.of("image/jpeg", "image/png", "application/pdf", "test/csv"); + + + for(MultipartFile file : files) { + + // 파일 형식 검사 + if(!supportedFileTypes.contains(file.getContentType())) { + throw new InvalidRequestException("지원되지 않는 파일 형식입니다: " + file.getContentType()); + } + + // 업로드된 파일의 원래 이름 가져옴, 이 이름은 S3 버킷에 저장될 파일 이름으로 사용됨. + String fileName = file.getOriginalFilename(); + + // 파일이 S3애 업로드된 후 접근할 수 있는 URL 생성, S3 버킷에서 파일에 접근할 수 있는 경로. + String fileUrl = "https://" + bucket + ".s3.ap-northeast-2.amazonaws.com/" + fileName; + + // 파일에 대한 메타데이터를 담을 ObjectMetadata 객체 생성, S3에 파일을 업로드할 때 메타데이터를 함께 전달해야함. + ObjectMetadata metadata = new ObjectMetadata(); + + /* + Meta data란? + - 데이터에 관한 데이터 + - 파일 이름, 파일 크기(5MB), 파일 형식(MIME 타입), 생성 날짜, 마지막 수정 날짜, 저작자 등 + + */ + + // 파일의 MIME 타입(image/jpeg, application/pdf)을 메타데이터에 설정, S3에 올바른 파일 형식이 저장됨. + metadata.setContentType(file.getContentType()); + + // metadata에 파일 크기 설정, S3에서 파일의 크기를 추적하는 데 사용. + metadata.setContentLength(file.getSize()); + + // amazonS3Client를 사용해서 파일을 S3 버킷에 업로드. 버킷 이름, 파일 이름, 파일의 입력스트림(파일 내용), metadata + amazonS3Client.putObject(bucket, fileName, file.getInputStream(), metadata); + + Attachment attachment = new Attachment(fileUrl); + Attachment savedAttachment = attachmentRepository.save(attachment); + responseDtos.add(new AttachmentResponseDto(savedAttachment)); + } + + return responseDtos; + + } + + + @Transactional + public void deleteAttachment(AuthUser authUser, Long attachmentId) { + Attachment attachment = attachmentRepository.findById(attachmentId) + .orElseThrow(() -> new InvalidRequestException("해당 첨부파일을 찾을 수 없습니다.")); + + + // 첨부파일의 URL에서 실제 파일 이름을 추출한다. + // attachment.getUrl() : 첨부파일이 저장된 S3의 URL 반환. + // substring(attachment.getUrl().lastIndexOf("/") + 1) : URL의 마지막 슬래시 뒤에 오는 파일 이름 부분을 잘라냄. + String fileName = attachment.getUrl().substring(attachment.getUrl().lastIndexOf("/") + 1); + + // AWS S3 버킷에서 해당 파일을 삭제, S3 클라이언트를 통해 지정된 bucket에서 추출한 fileName에 해당하는 파일을 제거. + amazonS3Client.deleteObject(bucket, fileName); + + attachmentRepository.delete(attachment); + } +} diff --git a/src/main/java/org/example/expert/domain/auth/controller/AuthController.java b/src/main/java/org/example/expert/domain/auth/controller/AuthController.java index 32d943d0a..882b337c5 100644 --- a/src/main/java/org/example/expert/domain/auth/controller/AuthController.java +++ b/src/main/java/org/example/expert/domain/auth/controller/AuthController.java @@ -7,6 +7,7 @@ import org.example.expert.domain.auth.dto.response.SigninResponse; import org.example.expert.domain.auth.dto.response.SignupResponse; import org.example.expert.domain.auth.service.AuthService; +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.RestController; @@ -18,12 +19,14 @@ public class AuthController { private final AuthService authService; @PostMapping("/auth/signup") - public SignupResponse signup(@Valid @RequestBody SignupRequest signupRequest) { - return authService.signup(signupRequest); + public ResponseEntity signup(@RequestBody SignupRequest signupRequest) { + String bearerToken = authService.signup(signupRequest); + return ResponseEntity.ok().header("Authorization", bearerToken).build(); } @PostMapping("/auth/signin") - public SigninResponse signin(@Valid @RequestBody SigninRequest signinRequest) { - return authService.signin(signinRequest); + public ResponseEntity signin(@RequestBody SigninRequest signinRequest) { + String bearerToken = authService.signin(signinRequest); + return ResponseEntity.ok().header("Authorization", bearerToken).build(); } } diff --git a/src/main/java/org/example/expert/domain/auth/dto/request/SignupRequest.java b/src/main/java/org/example/expert/domain/auth/dto/request/SignupRequest.java index cdb103690..084e64b34 100644 --- a/src/main/java/org/example/expert/domain/auth/dto/request/SignupRequest.java +++ b/src/main/java/org/example/expert/domain/auth/dto/request/SignupRequest.java @@ -17,4 +17,6 @@ public class SignupRequest { private String password; @NotBlank private String userRole; + @NotBlank + private String nickname; } diff --git a/src/main/java/org/example/expert/domain/auth/service/AuthService.java b/src/main/java/org/example/expert/domain/auth/service/AuthService.java index a662239dc..7003c67e1 100644 --- a/src/main/java/org/example/expert/domain/auth/service/AuthService.java +++ b/src/main/java/org/example/expert/domain/auth/service/AuthService.java @@ -25,7 +25,7 @@ public class AuthService { private final JwtUtil jwtUtil; @Transactional - public SignupResponse signup(SignupRequest signupRequest) { + public String signup(SignupRequest signupRequest) { if (userRepository.existsByEmail(signupRequest.getEmail())) { throw new InvalidRequestException("이미 존재하는 이메일입니다."); @@ -38,16 +38,15 @@ public SignupResponse signup(SignupRequest signupRequest) { User newUser = new User( signupRequest.getEmail(), encodedPassword, - userRole + userRole, + signupRequest.getNickname() ); User savedUser = userRepository.save(newUser); - String bearerToken = jwtUtil.createToken(savedUser.getId(), savedUser.getEmail(), userRole); - - return new SignupResponse(bearerToken); + return jwtUtil.createToken(savedUser.getId(), savedUser.getEmail(), userRole, savedUser.getNickname()); } - public SigninResponse signin(SigninRequest signinRequest) { + public String signin(SigninRequest signinRequest) { User user = userRepository.findByEmail(signinRequest.getEmail()).orElseThrow( () -> new InvalidRequestException("가입되지 않은 유저입니다.")); @@ -56,8 +55,6 @@ public SigninResponse signin(SigninRequest signinRequest) { throw new AuthException("잘못된 비밀번호입니다."); } - String bearerToken = jwtUtil.createToken(user.getId(), user.getEmail(), user.getUserRole()); - - return new SigninResponse(bearerToken); + return jwtUtil.createToken(user.getId(), user.getEmail(), user.getUserRole(), user.getNickname()); } } diff --git a/src/main/java/org/example/expert/domain/comment/controller/CommentController.java b/src/main/java/org/example/expert/domain/comment/controller/CommentController.java index 51264b12e..f0a6885d4 100644 --- a/src/main/java/org/example/expert/domain/comment/controller/CommentController.java +++ b/src/main/java/org/example/expert/domain/comment/controller/CommentController.java @@ -9,6 +9,7 @@ import org.example.expert.domain.common.annotation.Auth; import org.example.expert.domain.common.dto.AuthUser; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; import java.util.List; @@ -21,7 +22,7 @@ public class CommentController { @PostMapping("/todos/{todoId}/comments") public ResponseEntity saveComment( - @Auth AuthUser authUser, + @AuthenticationPrincipal AuthUser authUser, @PathVariable long todoId, @Valid @RequestBody CommentSaveRequest commentSaveRequest ) { diff --git a/src/main/java/org/example/expert/domain/comment/repository/CommentRepository.java b/src/main/java/org/example/expert/domain/comment/repository/CommentRepository.java index 3c97b95dc..ecb21ce56 100644 --- a/src/main/java/org/example/expert/domain/comment/repository/CommentRepository.java +++ b/src/main/java/org/example/expert/domain/comment/repository/CommentRepository.java @@ -9,6 +9,6 @@ public interface CommentRepository extends JpaRepository { - @Query("SELECT c FROM Comment c JOIN c.user WHERE c.todo.id = :todoId") + @Query("SELECT c FROM Comment c JOIN FETCH c.user WHERE c.todo.id = :todoId") List findByTodoIdWithUser(@Param("todoId") Long todoId); } diff --git a/src/main/java/org/example/expert/domain/common/dto/AuthUser.java b/src/main/java/org/example/expert/domain/common/dto/AuthUser.java index 7f4bc52e1..8082fb399 100644 --- a/src/main/java/org/example/expert/domain/common/dto/AuthUser.java +++ b/src/main/java/org/example/expert/domain/common/dto/AuthUser.java @@ -1,18 +1,27 @@ package org.example.expert.domain.common.dto; import lombok.Getter; +import lombok.extern.slf4j.Slf4j; import org.example.expert.domain.user.enums.UserRole; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import java.util.Collection; +import java.util.List; + +@Slf4j @Getter public class AuthUser { private final Long id; private final String email; - private final UserRole userRole; + private final Collection authorities; + private final String nickname; - public AuthUser(Long id, String email, UserRole userRole) { - this.id = id; + public AuthUser(Long userId, String email, UserRole role, String nickname) { + this.id = userId; this.email = email; - this.userRole = userRole; + this.authorities = List.of(new SimpleGrantedAuthority(role.name())); + this.nickname = nickname; } } diff --git a/src/main/java/org/example/expert/domain/common/dto/ResponseDto.java b/src/main/java/org/example/expert/domain/common/dto/ResponseDto.java new file mode 100644 index 000000000..547a9493e --- /dev/null +++ b/src/main/java/org/example/expert/domain/common/dto/ResponseDto.java @@ -0,0 +1,61 @@ +package org.example.expert.domain.common.dto; + + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public class ResponseDto{ + public int statusCode; + public String message; + public T data; + + + // 사용 예시 +// @GetMapping("/{storeId}") +// public ResponseEntity> getStore(@PathVariable("storeId") Long storeId) { +// GetStoreResponseDto responseDto = storeService.getStore(storeId); +// return ResponseEntity.status(HttpStatus.OK) +// .body(ResponseDto.of(200, "성공적으로 조회되었습니다.", responseDto)); +// } + + + public static ResponseDto of(int statusCode, String message, T data) { + return new ResponseDto(statusCode, message, data); + } + + public static ResponseDto of(int statusCode, T data) { + return new ResponseDto(statusCode, "", data); + } + + public static ResponseDto of(int statusCode, String message) { + return new ResponseDto(statusCode, message, null); + } + + public static ResponseDto of(int statusCode) { + return new ResponseDto(statusCode, "", null); + } + + public static ResponseDto of(HttpStatus statusCode, String message, T data) { + return new ResponseDto(statusCode.value(), message, data); + } + + public static ResponseDto of(HttpStatus statusCode, T data) { + return new ResponseDto(statusCode.value(), "", data); + } + + public static ResponseDto of(HttpStatus statusCode, String message) { + return new ResponseDto(statusCode.value(), message, null); + } + + public static ResponseDto of(HttpStatus statusCode) { + return new ResponseDto(statusCode.value(), "", null); + } +} diff --git a/src/main/java/org/example/expert/domain/manager/controller/ManagerController.java b/src/main/java/org/example/expert/domain/manager/controller/ManagerController.java index 327b6452b..66f3a4971 100644 --- a/src/main/java/org/example/expert/domain/manager/controller/ManagerController.java +++ b/src/main/java/org/example/expert/domain/manager/controller/ManagerController.java @@ -9,6 +9,7 @@ import org.example.expert.domain.manager.dto.response.ManagerSaveResponse; import org.example.expert.domain.manager.service.ManagerService; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; import java.util.List; @@ -21,7 +22,7 @@ public class ManagerController { @PostMapping("/todos/{todoId}/managers") public ResponseEntity saveManager( - @Auth AuthUser authUser, + @AuthenticationPrincipal AuthUser authUser, @PathVariable long todoId, @Valid @RequestBody ManagerSaveRequest managerSaveRequest ) { @@ -35,7 +36,7 @@ public ResponseEntity> getMembers(@PathVariable long todoI @DeleteMapping("/todos/{todoId}/managers/{managerId}") public void deleteManager( - @Auth AuthUser authUser, + @AuthenticationPrincipal AuthUser authUser, @PathVariable long todoId, @PathVariable long managerId ) { diff --git a/src/main/java/org/example/expert/domain/todo/controller/TodoController.java b/src/main/java/org/example/expert/domain/todo/controller/TodoController.java index eed1a1b46..0be14b668 100644 --- a/src/main/java/org/example/expert/domain/todo/controller/TodoController.java +++ b/src/main/java/org/example/expert/domain/todo/controller/TodoController.java @@ -2,25 +2,32 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; -import org.example.expert.domain.common.annotation.Auth; import org.example.expert.domain.common.dto.AuthUser; import org.example.expert.domain.todo.dto.request.TodoSaveRequest; +import org.example.expert.domain.todo.dto.response.TodoProjectionDto; import org.example.expert.domain.todo.dto.response.TodoResponse; import org.example.expert.domain.todo.dto.response.TodoSaveResponse; import org.example.expert.domain.todo.service.TodoService; import org.springframework.data.domain.Page; +import org.springframework.format.annotation.DateTimeFormat; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.*; +import java.time.LocalDate; + @RestController @RequiredArgsConstructor +@Transactional(readOnly = true) public class TodoController { private final TodoService todoService; + @Transactional @PostMapping("/todos") public ResponseEntity saveTodo( - @Auth AuthUser authUser, + @AuthenticationPrincipal AuthUser authUser, @Valid @RequestBody TodoSaveRequest todoSaveRequest ) { return ResponseEntity.ok(todoService.saveTodo(authUser, todoSaveRequest)); @@ -29,9 +36,23 @@ public ResponseEntity saveTodo( @GetMapping("/todos") public ResponseEntity> getTodos( @RequestParam(defaultValue = "1") int page, - @RequestParam(defaultValue = "10") int size + @RequestParam(defaultValue = "10") int size, + @RequestParam(required = false) String weather, + @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate startDate, + @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate endDate + ) { + return ResponseEntity.ok(todoService.getTodos(page, size, weather, startDate, endDate)); + } + + @GetMapping("/todos/search/projections") + public ResponseEntity> searchTodos( + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "10") int size, + @RequestParam(required = false) String keyword, + @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate createdAt, + @RequestParam(required = false) String nickname ) { - return ResponseEntity.ok(todoService.getTodos(page, size)); + return ResponseEntity.ok(todoService.searchTodos(page, size, keyword, createdAt, nickname)); } @GetMapping("/todos/{todoId}") diff --git a/src/main/java/org/example/expert/domain/todo/dto/response/TodoProjectionDto.java b/src/main/java/org/example/expert/domain/todo/dto/response/TodoProjectionDto.java new file mode 100644 index 000000000..33e1e445c --- /dev/null +++ b/src/main/java/org/example/expert/domain/todo/dto/response/TodoProjectionDto.java @@ -0,0 +1,13 @@ +package org.example.expert.domain.todo.dto.response; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class TodoProjectionDto { + private final String title; + private final Long mangerCount; + private final Long commentCount; + +} \ No newline at end of file diff --git a/src/main/java/org/example/expert/domain/todo/dto/response/TodoSearchResponse.java b/src/main/java/org/example/expert/domain/todo/dto/response/TodoSearchResponse.java new file mode 100644 index 000000000..3b12534d7 --- /dev/null +++ b/src/main/java/org/example/expert/domain/todo/dto/response/TodoSearchResponse.java @@ -0,0 +1,17 @@ +package org.example.expert.domain.todo.dto.response; + +import lombok.Getter; + +@Getter +public class TodoSearchResponse { + + private String title; + private int assigneeCount; + private int commentCount; + + public TodoSearchResponse(String title, int assigneeCount, int commentCount) { + this.title = title; + this.assigneeCount = assigneeCount; + this.commentCount = commentCount; + } +} diff --git a/src/main/java/org/example/expert/domain/todo/entity/Todo.java b/src/main/java/org/example/expert/domain/todo/entity/Todo.java index b4efcced1..5a289e40e 100644 --- a/src/main/java/org/example/expert/domain/todo/entity/Todo.java +++ b/src/main/java/org/example/expert/domain/todo/entity/Todo.java @@ -7,6 +7,7 @@ import org.example.expert.domain.common.entity.Timestamped; import org.example.expert.domain.manager.entity.Manager; import org.example.expert.domain.user.entity.User; +import org.hibernate.annotations.BatchSize; import java.util.ArrayList; import java.util.List; @@ -28,9 +29,10 @@ public class Todo extends Timestamped { private User user; @OneToMany(mappedBy = "todo", cascade = CascadeType.REMOVE) + @BatchSize(size=10) private List comments = new ArrayList<>(); - @OneToMany(mappedBy = "todo") + @OneToMany(mappedBy = "todo", cascade = CascadeType.ALL) private List managers = new ArrayList<>(); public Todo(String title, String contents, String weather, User user) { diff --git a/src/main/java/org/example/expert/domain/todo/repository/TodoQueryRepository.java b/src/main/java/org/example/expert/domain/todo/repository/TodoQueryRepository.java new file mode 100644 index 000000000..08a858d7b --- /dev/null +++ b/src/main/java/org/example/expert/domain/todo/repository/TodoQueryRepository.java @@ -0,0 +1,17 @@ +package org.example.expert.domain.todo.repository; + +import org.example.expert.domain.todo.dto.response.TodoProjectionDto; +import org.example.expert.domain.todo.entity.Todo; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.time.LocalDate; + +public interface TodoQueryRepository { + Todo findByIdWithUserByDsl(Long todoId); + + + Page searchTodos(Pageable pageable, String keyword, LocalDate createdAt, String nickname); + + +} diff --git a/src/main/java/org/example/expert/domain/todo/repository/TodoQueryRepositoryImpl.java b/src/main/java/org/example/expert/domain/todo/repository/TodoQueryRepositoryImpl.java new file mode 100644 index 000000000..bad40111f --- /dev/null +++ b/src/main/java/org/example/expert/domain/todo/repository/TodoQueryRepositoryImpl.java @@ -0,0 +1,113 @@ +package org.example.expert.domain.todo.repository; + +import com.querydsl.core.types.Projections; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.core.types.dsl.Wildcard; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.example.expert.domain.todo.dto.response.TodoProjectionDto; +import org.example.expert.domain.todo.entity.Todo; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +import static org.example.expert.domain.comment.entity.QComment.comment; +import static org.example.expert.domain.manager.entity.QManager.manager; +import static org.example.expert.domain.todo.entity.QTodo.todo; +import static org.example.expert.domain.user.entity.QUser.user; + +@Slf4j +@Repository +@RequiredArgsConstructor +public class TodoQueryRepositoryImpl implements TodoQueryRepository { + + private final JPAQueryFactory queryFactory; + @Override + public Todo findByIdWithUserByDsl(Long todoId) { + return queryFactory + .select(todo) + .from(todo) + .where( + todoIdEq(todoId) + ).fetchOne(); + } + @Override + public Page searchTodos(Pageable pageable, String keyword, LocalDate createdAt, String nickname) { + List todos = queryFactory + .select(Projections.constructor(TodoProjectionDto.class, + todo.title, + manager.countDistinct().as("managerCount"), + comment.countDistinct().as("commentCount") + )) + .from(todo) + .leftJoin(todo.managers, manager) + .leftJoin(manager.user, user) + .leftJoin(todo.comments, comment) + .where( + allConditions(keyword, createdAt, nickname) + ) + .groupBy(todo.id) + .orderBy(todo.createdAt.desc()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + Long total = queryFactory + .select(Wildcard.count) + .from(todo) + .leftJoin(todo.managers, manager) + .leftJoin(manager.user, user) + .where( + allConditions(keyword, createdAt, nickname) + ) + .fetchOne(); + + return new PageImpl<>(todos, pageable, total); + } + + private BooleanExpression allConditions(String keyword, LocalDate createdAt, String nickname) { + BooleanExpression condition = todo.isNotNull(); + + if (keyword != null && !keyword.isEmpty()) { + condition = condition.and(titleContains(keyword)); + } + + if (createdAt != null) { + condition = condition.and(createdAtEq(createdAt)); + } + + if (nickname != null && !nickname.isEmpty()) { + condition = condition.and(nicknameContains(nickname)); + } + + return condition; + } + + private BooleanExpression titleContains(String keyword) { + return todo.title.containsIgnoreCase(keyword); + } + + private BooleanExpression createdAtEq(LocalDate createdAt) { + LocalDateTime startOfDay = createdAt.atStartOfDay(); + LocalDateTime endOfDay = createdAt.atTime(23, 59, 59); + + return todo.createdAt.between(startOfDay, endOfDay); + } + + private BooleanExpression nicknameContains(String nickname) { + return manager.user.nickname.containsIgnoreCase(nickname); + } + + private BooleanExpression todoIdEq(Long todoId) { + return todoId != null ? todo.id.eq(todoId) : null; + } + +} + + diff --git a/src/main/java/org/example/expert/domain/todo/repository/TodoRepository.java b/src/main/java/org/example/expert/domain/todo/repository/TodoRepository.java index a3e4e0749..dd3f6f07a 100644 --- a/src/main/java/org/example/expert/domain/todo/repository/TodoRepository.java +++ b/src/main/java/org/example/expert/domain/todo/repository/TodoRepository.java @@ -7,15 +7,26 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import java.time.LocalDateTime; import java.util.Optional; -public interface TodoRepository extends JpaRepository { +public interface TodoRepository extends JpaRepository, TodoQueryRepository { + + @Query("SELECT t FROM Todo t WHERE " + + "(:weather IS NULL OR t.weather = :weather) AND " + + "(:startDate IS NULL OR t.modifiedAt >= :startDate) AND " + + "(:endDate IS NULL OR t.modifiedAt <= :endDate) " + + "ORDER BY t.modifiedAt DESC") + Page findTodosByWeatherAndModifiedDate( + @Param("weather") String weather, + @Param("startDate") LocalDateTime startDate, + @Param("endDate") LocalDateTime endDate, + Pageable pageable); - @Query("SELECT t FROM Todo t LEFT JOIN FETCH t.user u ORDER BY t.modifiedAt DESC") - Page findAllByOrderByModifiedAtDesc(Pageable pageable); @Query("SELECT t FROM Todo t " + "LEFT JOIN t.user " + "WHERE t.id = :todoId") Optional findByIdWithUser(@Param("todoId") Long todoId); + } diff --git a/src/main/java/org/example/expert/domain/todo/service/TodoService.java b/src/main/java/org/example/expert/domain/todo/service/TodoService.java index 922991ce7..7c48d1439 100644 --- a/src/main/java/org/example/expert/domain/todo/service/TodoService.java +++ b/src/main/java/org/example/expert/domain/todo/service/TodoService.java @@ -3,8 +3,8 @@ import lombok.RequiredArgsConstructor; import org.example.expert.client.WeatherClient; import org.example.expert.domain.common.dto.AuthUser; -import org.example.expert.domain.common.exception.InvalidRequestException; import org.example.expert.domain.todo.dto.request.TodoSaveRequest; +import org.example.expert.domain.todo.dto.response.TodoProjectionDto; import org.example.expert.domain.todo.dto.response.TodoResponse; import org.example.expert.domain.todo.dto.response.TodoSaveResponse; import org.example.expert.domain.todo.entity.Todo; @@ -17,6 +17,9 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDate; +import java.time.LocalDateTime; + @Service @RequiredArgsConstructor @Transactional(readOnly = true) @@ -47,10 +50,18 @@ public TodoSaveResponse saveTodo(AuthUser authUser, TodoSaveRequest todoSaveRequ ); } - public Page getTodos(int page, int size) { + public Page getTodos(int page, int size, String weather, LocalDate startDate, LocalDate endDate) { Pageable pageable = PageRequest.of(page - 1, size); - Page todos = todoRepository.findAllByOrderByModifiedAtDesc(pageable); + LocalDateTime startOfDay = (startDate != null) ? startDate.atStartOfDay() : null; + LocalDateTime endOfDay = (endDate != null) ? endDate.atStartOfDay().plusDays(1) : null; + + Page todos = todoRepository.findTodosByWeatherAndModifiedDate( + weather != null ? weather : null, + startOfDay, + endOfDay, + pageable + ); return todos.map(todo -> new TodoResponse( todo.getId(), @@ -63,9 +74,15 @@ public Page getTodos(int page, int size) { )); } + public Page searchTodos(int page, int size, String keyword, LocalDate createdAt, String nickname) { + Pageable pageable = PageRequest.of(page-1, size); + return todoRepository.searchTodos(pageable, keyword, createdAt, nickname); + + } + + public TodoResponse getTodo(long todoId) { - Todo todo = todoRepository.findByIdWithUser(todoId) - .orElseThrow(() -> new InvalidRequestException("Todo not found")); + Todo todo = todoRepository.findByIdWithUserByDsl(todoId); User user = todo.getUser(); @@ -79,4 +96,5 @@ public TodoResponse getTodo(long todoId) { todo.getModifiedAt() ); } + } diff --git a/src/main/java/org/example/expert/domain/user/controller/UserController.java b/src/main/java/org/example/expert/domain/user/controller/UserController.java index bb1ef7a95..3e8633a22 100644 --- a/src/main/java/org/example/expert/domain/user/controller/UserController.java +++ b/src/main/java/org/example/expert/domain/user/controller/UserController.java @@ -7,6 +7,7 @@ import org.example.expert.domain.user.dto.response.UserResponse; import org.example.expert.domain.user.service.UserService; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; @RestController @@ -21,7 +22,7 @@ public ResponseEntity getUser(@PathVariable long userId) { } @PutMapping("/users") - public void changePassword(@Auth AuthUser authUser, @RequestBody UserChangePasswordRequest userChangePasswordRequest) { + public void changePassword(@AuthenticationPrincipal AuthUser authUser, @RequestBody UserChangePasswordRequest userChangePasswordRequest) { userService.changePassword(authUser.getId(), userChangePasswordRequest); } } diff --git a/src/main/java/org/example/expert/domain/user/entity/User.java b/src/main/java/org/example/expert/domain/user/entity/User.java index 30a0cc54f..b970932f3 100644 --- a/src/main/java/org/example/expert/domain/user/entity/User.java +++ b/src/main/java/org/example/expert/domain/user/entity/User.java @@ -20,21 +20,24 @@ public class User extends Timestamped { private String password; @Enumerated(EnumType.STRING) private UserRole userRole; + private String nickname; - public User(String email, String password, UserRole userRole) { + public User(String email, String password, UserRole userRole, String nickname) { this.email = email; this.password = password; this.userRole = userRole; + this.nickname = nickname; } - private User(Long id, String email, UserRole userRole) { + private User(Long id, String email, UserRole userRole, String nickname) { this.id = id; this.email = email; this.userRole = userRole; + this.nickname = nickname; } public static User fromAuthUser(AuthUser authUser) { - return new User(authUser.getId(), authUser.getEmail(), authUser.getUserRole()); + return new User(authUser.getId(), authUser.getEmail(), UserRole.valueOf(authUser.getAuthorities().iterator().next().getAuthority()), authUser.getNickname()); } public void changePassword(String password) { diff --git a/src/main/java/org/example/expert/domain/user/enums/UserRole.java b/src/main/java/org/example/expert/domain/user/enums/UserRole.java index 6fe177896..b73ec861e 100644 --- a/src/main/java/org/example/expert/domain/user/enums/UserRole.java +++ b/src/main/java/org/example/expert/domain/user/enums/UserRole.java @@ -1,16 +1,27 @@ package org.example.expert.domain.user.enums; -import org.example.expert.domain.common.exception.InvalidRequestException; - +import lombok.Getter; +import lombok.RequiredArgsConstructor; import java.util.Arrays; +@Getter +@RequiredArgsConstructor public enum UserRole { - ADMIN, USER; + + ROLE_USER(Authority.USER), + ROLE_ADMIN(Authority.ADMIN); + + private final String userRole; public static UserRole of(String role) { return Arrays.stream(UserRole.values()) .filter(r -> r.name().equalsIgnoreCase(role)) .findFirst() - .orElseThrow(() -> new InvalidRequestException("유효하지 않은 UerRole")); + .orElseThrow(() -> new IllegalArgumentException("유효하지 않은 UserRole")); + } + + public static class Authority { + public static final String USER = "ROLE_USER"; + public static final String ADMIN = "ROLE_ADMIN"; } } diff --git a/src/main/java/org/example/expert/security/JwtAuthenticationToken.java b/src/main/java/org/example/expert/security/JwtAuthenticationToken.java new file mode 100644 index 000000000..a0c37537b --- /dev/null +++ b/src/main/java/org/example/expert/security/JwtAuthenticationToken.java @@ -0,0 +1,26 @@ +package org.example.expert.security; + + +import org.example.expert.domain.common.dto.AuthUser; +import org.springframework.security.authentication.AbstractAuthenticationToken; + +public class JwtAuthenticationToken extends AbstractAuthenticationToken { + + private final AuthUser authUser; + + public JwtAuthenticationToken(AuthUser authUser) { + super(authUser.getAuthorities()); + this.authUser = authUser; + setAuthenticated(true); + } + + @Override + public Object getCredentials() { + return null; + } + + @Override + public Object getPrincipal() { + return authUser; + } +} diff --git a/src/main/java/org/example/expert/security/JwtSecurityFilter.java b/src/main/java/org/example/expert/security/JwtSecurityFilter.java new file mode 100644 index 000000000..455b4527a --- /dev/null +++ b/src/main/java/org/example/expert/security/JwtSecurityFilter.java @@ -0,0 +1,89 @@ +package org.example.expert.security; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +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.NonNull; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.example.expert.config.JwtUtil; +import org.example.expert.domain.common.dto.AuthUser; +import org.example.expert.domain.user.enums.UserRole; +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 JwtSecurityFilter extends OncePerRequestFilter { + + private final JwtUtil jwtUtil; + + @Override + protected void doFilterInternal( + HttpServletRequest httpRequest, + @NonNull HttpServletResponse httpResponse, + @NonNull FilterChain chain + ) throws ServletException, IOException { + + String requestURI = httpRequest.getRequestURI(); + + // /auth 경로는 필터링하지 않음 + if (requestURI.startsWith("/auth")) { + chain.doFilter(httpRequest, httpResponse); + return; + } + + String authorizationHeader = httpRequest.getHeader("Authorization"); + + if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) { + String jwt = jwtUtil.substringToken(authorizationHeader); + try { + Claims claims = jwtUtil.extractClaims(jwt); + Long userId = Long.valueOf(claims.getSubject()); + String email = claims.get("email", String.class); + + UserRole userRole = UserRole.of(claims.get("userRole", String.class)); + String nickname = claims.get("nickname", String.class); + + if (SecurityContextHolder.getContext().getAuthentication() == null) { + AuthUser authUser = new AuthUser(userId, email, userRole, nickname); + + JwtAuthenticationToken authenticationToken = new JwtAuthenticationToken(authUser); + authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(httpRequest)); + SecurityContextHolder.getContext().setAuthentication(authenticationToken); + } + + // 관리자 권한이 필요한 경로 검증 + if (requestURI.startsWith("/admin") && !UserRole.ROLE_ADMIN.equals(userRole)) { + log.warn("Unauthorized admin access attempt by user: {}", email); + httpResponse.sendError(HttpServletResponse.SC_FORBIDDEN, "관리자 권한이 필요합니다."); + return; + } + + } catch (SecurityException | MalformedJwtException e) { + log.error("Invalid JWT signature, 유효하지 않는 JWT 서명 입니다.", e); + httpResponse.sendError(HttpServletResponse.SC_UNAUTHORIZED, "유효하지 않는 JWT 서명입니다."); + } catch (ExpiredJwtException e) { + log.error("Expired JWT token, 만료된 JWT token 입니다.", e); + httpResponse.sendError(HttpServletResponse.SC_UNAUTHORIZED, "만료된 JWT 토큰입니다."); + } catch (UnsupportedJwtException e) { + log.error("Unsupported JWT token, 지원되지 않는 JWT 토큰 입니다.", e); + httpResponse.sendError(HttpServletResponse.SC_BAD_REQUEST, "지원되지 않는 JWT 토큰입니다."); + } catch (Exception e) { + log.error("Internal server error", e); + httpResponse.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); + } + } + chain.doFilter(httpRequest, httpResponse); + } +} diff --git a/src/main/java/org/example/expert/security/SecurityConfig.java b/src/main/java/org/example/expert/security/SecurityConfig.java new file mode 100644 index 000000000..11b8262bb --- /dev/null +++ b/src/main/java/org/example/expert/security/SecurityConfig.java @@ -0,0 +1,42 @@ +package org.example.expert.security; + +import lombok.RequiredArgsConstructor; +import org.example.expert.domain.user.enums.UserRole; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +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.servletapi.SecurityContextHolderAwareRequestFilter; + +@Configuration +@RequiredArgsConstructor +@EnableWebSecurity +@EnableMethodSecurity(securedEnabled = true) +public class SecurityConfig { + + private final JwtSecurityFilter jwtSecurityFilter; + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + return http + .csrf(AbstractHttpConfigurer::disable) + .sessionManagement(session -> session + .sessionCreationPolicy(SessionCreationPolicy.STATELESS) // SessionManagementFilter, SecurityContextPersistenceFilter + ) + .addFilterBefore(jwtSecurityFilter, SecurityContextHolderAwareRequestFilter.class) + .formLogin(AbstractHttpConfigurer::disable) // UsernamePasswordAuthenticationFilter, DefaultLoginPageGeneratingFilter 비활성화 + .anonymous(AbstractHttpConfigurer::disable) // AnonymousAuthenticationFilter 비활성화 + .httpBasic(AbstractHttpConfigurer::disable) // BasicAuthenticationFilter 비활성화 + .logout(AbstractHttpConfigurer::disable) // LogoutFilter 비활성화 + .authorizeHttpRequests(auth -> auth + .requestMatchers("/auth/signin", "/auth/signup").permitAll() + .requestMatchers("/test").hasAuthority(UserRole.Authority.ADMIN) + .anyRequest().authenticated() + ) + .build(); + } +} \ No newline at end of file diff --git a/src/test/java/org/example/expert/domain/todo/controller/TodoControllerTest.java b/src/test/java/org/example/expert/domain/todo/controller/TodoControllerTest.java index 737193874..79c741468 100644 --- a/src/test/java/org/example/expert/domain/todo/controller/TodoControllerTest.java +++ b/src/test/java/org/example/expert/domain/todo/controller/TodoControllerTest.java @@ -35,7 +35,8 @@ class TodoControllerTest { // given long todoId = 1L; String title = "title"; - AuthUser authUser = new AuthUser(1L, "email", UserRole.USER); + String nickname = "nickname1"; + AuthUser authUser = new AuthUser(1L, "email", UserRole.ROLE_USER, nickname); User user = User.fromAuthUser(authUser); UserResponse userResponse = new UserResponse(user.getId(), user.getEmail()); TodoResponse response = new TodoResponse( @@ -69,9 +70,9 @@ class TodoControllerTest { // then mockMvc.perform(get("/todos/{todoId}", todoId)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.status").value(HttpStatus.OK.name())) - .andExpect(jsonPath("$.code").value(HttpStatus.OK.value())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.status").value(HttpStatus.BAD_REQUEST.name())) + .andExpect(jsonPath("$.code").value(HttpStatus.BAD_REQUEST.value())) .andExpect(jsonPath("$.message").value("Todo not found")); } }