From d33c17b89aba73693563434b35d5bbbea25cfa79 Mon Sep 17 00:00:00 2001 From: Preta3418 Date: Tue, 5 Nov 2024 16:07:37 +0900 Subject: [PATCH 1/8] =?UTF-8?q?Refactor:=20=ED=81=B4=EB=9E=98=EC=8A=A4?= =?UTF-8?q?=EB=84=A4=EC=9E=84=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bean 충돌이 생겨서 클래스 이름을 좀 변경 했습니다 --- .../controller/FeedLikeController.kt} | 10 +++++----- .../feedLike/dto/FeedLikeDTO.kt | 7 +++++++ .../Like.kt => feedLike/entity/FeedLike.kt} | 8 ++------ .../repository/FeedLikeRepository.kt} | 8 ++++---- .../feedLike/service/FeedLikeService.kt | 8 ++++++++ .../service/FeedLikeServiceImpl.kt} | 20 +++++++++---------- .../feedInteraction/like/dto/LikeDTO.kt | 7 ------- .../like/service/LikeService.kt | 8 -------- 8 files changed, 36 insertions(+), 40 deletions(-) rename src/main/kotlin/org/tenten/bittakotlin/feedInteraction/{like/controller/LikeController.kt => feedLike/controller/FeedLikeController.kt} (63%) create mode 100644 src/main/kotlin/org/tenten/bittakotlin/feedInteraction/feedLike/dto/FeedLikeDTO.kt rename src/main/kotlin/org/tenten/bittakotlin/feedInteraction/{like/entity/Like.kt => feedLike/entity/FeedLike.kt} (73%) rename src/main/kotlin/org/tenten/bittakotlin/feedInteraction/{like/repository/LikeRepository.kt => feedLike/repository/FeedLikeRepository.kt} (58%) create mode 100644 src/main/kotlin/org/tenten/bittakotlin/feedInteraction/feedLike/service/FeedLikeService.kt rename src/main/kotlin/org/tenten/bittakotlin/feedInteraction/{like/service/LikeServiceImpl.kt => feedLike/service/FeedLikeServiceImpl.kt} (67%) delete mode 100644 src/main/kotlin/org/tenten/bittakotlin/feedInteraction/like/dto/LikeDTO.kt delete mode 100644 src/main/kotlin/org/tenten/bittakotlin/feedInteraction/like/service/LikeService.kt diff --git a/src/main/kotlin/org/tenten/bittakotlin/feedInteraction/like/controller/LikeController.kt b/src/main/kotlin/org/tenten/bittakotlin/feedInteraction/feedLike/controller/FeedLikeController.kt similarity index 63% rename from src/main/kotlin/org/tenten/bittakotlin/feedInteraction/like/controller/LikeController.kt rename to src/main/kotlin/org/tenten/bittakotlin/feedInteraction/feedLike/controller/FeedLikeController.kt index caedfcd..cf0219f 100644 --- a/src/main/kotlin/org/tenten/bittakotlin/feedInteraction/like/controller/LikeController.kt +++ b/src/main/kotlin/org/tenten/bittakotlin/feedInteraction/feedLike/controller/FeedLikeController.kt @@ -1,17 +1,17 @@ -package org.tenten.bittakotlin.feedInteraction.like.controller +package org.tenten.bittakotlin.feedInteraction.feedLike.controller import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.* -import org.tenten.bittakotlin.feedInteraction.like.dto.LikeDTO -import org.tenten.bittakotlin.feedInteraction.like.service.LikeService +import org.tenten.bittakotlin.feedInteraction.feedLike.dto.FeedLikeDTO +import org.tenten.bittakotlin.feedInteraction.feedLike.service.FeedLikeService @RestController @RequestMapping("/api/v1/feed/like") -class LikeController(private val likeService: LikeService) { +class FeedLikeController(private val likeService: FeedLikeService) { @PostMapping("/{feedId}") - fun toggleLike(@PathVariable feedId: Long, @RequestParam profileId: Long): ResponseEntity { + fun toggleLike(@PathVariable feedId: Long, @RequestParam profileId: Long): ResponseEntity { val likeDTO = likeService.toggleLike(feedId, profileId) return ResponseEntity.ok(likeDTO) } diff --git a/src/main/kotlin/org/tenten/bittakotlin/feedInteraction/feedLike/dto/FeedLikeDTO.kt b/src/main/kotlin/org/tenten/bittakotlin/feedInteraction/feedLike/dto/FeedLikeDTO.kt new file mode 100644 index 0000000..6b70070 --- /dev/null +++ b/src/main/kotlin/org/tenten/bittakotlin/feedInteraction/feedLike/dto/FeedLikeDTO.kt @@ -0,0 +1,7 @@ +package org.tenten.bittakotlin.feedInteraction.feedLike.dto + +data class FeedLikeDTO( + val feedId: Long?, + val profileId: Long?, + val isLiked: Boolean +) \ No newline at end of file diff --git a/src/main/kotlin/org/tenten/bittakotlin/feedInteraction/like/entity/Like.kt b/src/main/kotlin/org/tenten/bittakotlin/feedInteraction/feedLike/entity/FeedLike.kt similarity index 73% rename from src/main/kotlin/org/tenten/bittakotlin/feedInteraction/like/entity/Like.kt rename to src/main/kotlin/org/tenten/bittakotlin/feedInteraction/feedLike/entity/FeedLike.kt index 35581cc..e25fc79 100644 --- a/src/main/kotlin/org/tenten/bittakotlin/feedInteraction/like/entity/Like.kt +++ b/src/main/kotlin/org/tenten/bittakotlin/feedInteraction/feedLike/entity/FeedLike.kt @@ -1,14 +1,10 @@ -package org.tenten.bittakotlin.feedInteraction.like.entity +package org.tenten.bittakotlin.feedInteraction.feedLike.entity import jakarta.persistence.* -import lombok.AllArgsConstructor -import lombok.Builder -import lombok.Data -import lombok.NoArgsConstructor import org.tenten.bittakotlin.feed.entity.Feed import org.tenten.bittakotlin.profile.entity.Profile -data class Like( +data class FeedLike( @Id @GeneratedValue(strategy = GenerationType.IDENTITY) val id: Long? = null, diff --git a/src/main/kotlin/org/tenten/bittakotlin/feedInteraction/like/repository/LikeRepository.kt b/src/main/kotlin/org/tenten/bittakotlin/feedInteraction/feedLike/repository/FeedLikeRepository.kt similarity index 58% rename from src/main/kotlin/org/tenten/bittakotlin/feedInteraction/like/repository/LikeRepository.kt rename to src/main/kotlin/org/tenten/bittakotlin/feedInteraction/feedLike/repository/FeedLikeRepository.kt index 723ea7a..a7a3fa6 100644 --- a/src/main/kotlin/org/tenten/bittakotlin/feedInteraction/like/repository/LikeRepository.kt +++ b/src/main/kotlin/org/tenten/bittakotlin/feedInteraction/feedLike/repository/FeedLikeRepository.kt @@ -1,13 +1,13 @@ -package org.tenten.bittakotlin.feedInteraction.like.repository +package org.tenten.bittakotlin.feedInteraction.feedLike.repository import org.springframework.data.jpa.repository.JpaRepository import org.tenten.bittakotlin.feed.entity.Feed -import org.tenten.bittakotlin.feedInteraction.like.entity.Like +import org.tenten.bittakotlin.feedInteraction.feedLike.entity.FeedLike import org.tenten.bittakotlin.profile.entity.Profile import java.util.* -interface LikeRepository : JpaRepository { - fun findByFeedAndProfile(feed: Feed, profile: Profile): Optional +interface FeedLikeRepository : JpaRepository { + fun findByFeedAndProfile(feed: Feed, profile: Profile): Optional fun countByFeedAndLikedTrue(feed: Feed): Long } diff --git a/src/main/kotlin/org/tenten/bittakotlin/feedInteraction/feedLike/service/FeedLikeService.kt b/src/main/kotlin/org/tenten/bittakotlin/feedInteraction/feedLike/service/FeedLikeService.kt new file mode 100644 index 0000000..15b72ce --- /dev/null +++ b/src/main/kotlin/org/tenten/bittakotlin/feedInteraction/feedLike/service/FeedLikeService.kt @@ -0,0 +1,8 @@ +package org.tenten.bittakotlin.feedInteraction.feedLike.service + +import org.tenten.bittakotlin.feedInteraction.feedLike.dto.FeedLikeDTO + +interface FeedLikeService { + fun toggleLike(feedId: Long, profileId: Long): FeedLikeDTO + fun getLikeCount(feedId: Long): Long +} diff --git a/src/main/kotlin/org/tenten/bittakotlin/feedInteraction/like/service/LikeServiceImpl.kt b/src/main/kotlin/org/tenten/bittakotlin/feedInteraction/feedLike/service/FeedLikeServiceImpl.kt similarity index 67% rename from src/main/kotlin/org/tenten/bittakotlin/feedInteraction/like/service/LikeServiceImpl.kt rename to src/main/kotlin/org/tenten/bittakotlin/feedInteraction/feedLike/service/FeedLikeServiceImpl.kt index 508919c..fbc333e 100644 --- a/src/main/kotlin/org/tenten/bittakotlin/feedInteraction/like/service/LikeServiceImpl.kt +++ b/src/main/kotlin/org/tenten/bittakotlin/feedInteraction/feedLike/service/FeedLikeServiceImpl.kt @@ -1,31 +1,31 @@ -package org.tenten.bittakotlin.feedInteraction.like.service +package org.tenten.bittakotlin.feedInteraction.feedLike.service import jakarta.persistence.EntityNotFoundException import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import org.tenten.bittakotlin.feed.repository.FeedRepository -import org.tenten.bittakotlin.feedInteraction.like.dto.LikeDTO -import org.tenten.bittakotlin.feedInteraction.like.entity.Like -import org.tenten.bittakotlin.feedInteraction.like.repository.LikeRepository +import org.tenten.bittakotlin.feedInteraction.feedLike.dto.FeedLikeDTO +import org.tenten.bittakotlin.feedInteraction.feedLike.entity.FeedLike +import org.tenten.bittakotlin.feedInteraction.feedLike.repository.FeedLikeRepository import org.tenten.bittakotlin.profile.repository.ProfileRepository @Service -class LikeServiceImpl( - private val likeRepository: LikeRepository, +class FeedLikeServiceImpl( + private val likeRepository: FeedLikeRepository, private val feedRepository: FeedRepository, private val profileRepository: ProfileRepository -) : LikeService { +) : FeedLikeService { @Transactional - override fun toggleLike(feedId: Long, profileId: Long): LikeDTO { + override fun toggleLike(feedId: Long, profileId: Long): FeedLikeDTO { val feed = feedRepository.findById(feedId) .orElseThrow { EntityNotFoundException("Feed not found for id: $feedId") } val profile = profileRepository.findById(profileId) .orElseThrow { EntityNotFoundException("Profile not found for id: $profileId") } val like = likeRepository.findByFeedAndProfile(feed, profile).orElseGet { - val newLike = Like(feed = feed, profile = profile, liked = true) + val newLike = FeedLike(feed = feed, profile = profile, liked = true) likeRepository.save(newLike) newLike } @@ -33,7 +33,7 @@ class LikeServiceImpl( like.liked = !like.liked likeRepository.save(like) - return LikeDTO(feed.id, profile.id, like.liked) + return FeedLikeDTO(feed.id, profile.id, like.liked) } @Transactional(readOnly = true) diff --git a/src/main/kotlin/org/tenten/bittakotlin/feedInteraction/like/dto/LikeDTO.kt b/src/main/kotlin/org/tenten/bittakotlin/feedInteraction/like/dto/LikeDTO.kt deleted file mode 100644 index ee77b21..0000000 --- a/src/main/kotlin/org/tenten/bittakotlin/feedInteraction/like/dto/LikeDTO.kt +++ /dev/null @@ -1,7 +0,0 @@ -package org.tenten.bittakotlin.feedInteraction.like.dto - -data class LikeDTO( - val feedId: Long?, - val profileId: Long?, - val isLiked: Boolean -) \ No newline at end of file diff --git a/src/main/kotlin/org/tenten/bittakotlin/feedInteraction/like/service/LikeService.kt b/src/main/kotlin/org/tenten/bittakotlin/feedInteraction/like/service/LikeService.kt deleted file mode 100644 index 634fcf5..0000000 --- a/src/main/kotlin/org/tenten/bittakotlin/feedInteraction/like/service/LikeService.kt +++ /dev/null @@ -1,8 +0,0 @@ -package org.tenten.bittakotlin.feedInteraction.like.service - -import org.tenten.bittakotlin.feedInteraction.like.dto.LikeDTO - -interface LikeService { - fun toggleLike(feedId: Long, profileId: Long): LikeDTO - fun getLikeCount(feedId: Long): Long -} From 72855e82b0f83a7fa9e8363894c6637c33e8956c Mon Sep 17 00:00:00 2001 From: deveunhwa Date: Tue, 5 Nov 2024 16:09:05 +0900 Subject: [PATCH 2/8] =?UTF-8?q?Fix:=20=EB=B0=B0=ED=8F=AC=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=ED=8C=8C=EC=9D=BC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/CICD.yml | 26 ++++++++++++++++++++------ Dockerfile | 8 ++++++-- docker-compose.yml | 8 ++++---- 3 files changed, 30 insertions(+), 12 deletions(-) diff --git a/.github/workflows/CICD.yml b/.github/workflows/CICD.yml index 2cfbe8a..bff4eba 100644 --- a/.github/workflows/CICD.yml +++ b/.github/workflows/CICD.yml @@ -1,4 +1,4 @@ -name: Java CI/CD +name: Backend CI/CD Pipeline on: push: @@ -53,8 +53,12 @@ jobs: docker build -t ${{ secrets.DOCKER_USERNAME }}/bitta-kotlin:latest . docker push ${{ secrets.DOCKER_USERNAME }}/bitta-kotlin:latest + # application-prod.properties 빌드 후 제거 - 안전성 확보 + - name: Clean up application-prod.properties + run: rm ./src/main/resources/application-prod.properties + deploy: - if: github.repository == 'prgrms-be-devcourse/NBB1_2_3_Team10' && github.ref == 'refs/heads/release' + if: github.repository == 'prgrms-be-devcourse/NBB1_2_3_Team10' && github.ref == 'refs/heads/release' needs: build runs-on: ubuntu-latest permissions: @@ -71,11 +75,10 @@ jobs: username: ${{ secrets.EC2_USER }} key: ${{ secrets.EC2_SSH_KEY }} script: | - # Docker 로그인 및 최신 이미지 가져오기 echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_USERNAME }}" --password-stdin docker pull ${{ secrets.DOCKER_USERNAME }}/bitta-kotlin:latest - # blue/green 컨테이너 전환 + # Blue-Green 배포: blue와 green 컨테이너 전환 CURRENT_CONTAINER=$(docker ps --filter "name=backend-blue" -q) TARGET_CONTAINER="backend-green" [ -z "$CURRENT_CONTAINER" ] && TARGET_CONTAINER="backend-blue" @@ -83,9 +86,20 @@ jobs: # 새 컨테이너 실행 docker stop $TARGET_CONTAINER || true docker rm $TARGET_CONTAINER || true - docker run -d --name $TARGET_CONTAINER -p 8080:80 \ - -e NODE_ENV=production \ + docker run -d --name $TARGET_CONTAINER -p 8080:8080 \ + -e SPRING_PROFILES_ACTIVE=prod \ + -e AWS_ACCESS_KEY_ID=${{ secrets.AWS_ACCESS_KEY_ID }} \ + -e AWS_SECRET_ACCESS_KEY=${{ secrets.AWS_SECRET_ACCESS_KEY }} \ ${{ secrets.DOCKER_USERNAME }}/bitta-kotlin:latest # Nginx 리로드하여 트래픽 전환 sudo systemctl reload nginx + + # Blue-Green 설정 Docker Compose 실행 + - name: Deploy with Docker Compose on EC2 + run: | + ssh -o StrictHostKeyChecking=no ec2-user@${{ secrets.EC2_HOST }} + docker-compose down + docker-compose up -d" + env: + SSH_KEY: ${{ secrets.EC2_SSH_KEY }} diff --git a/Dockerfile b/Dockerfile index c4469d1..b7436a5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,12 @@ # Use OpenJDK 17 image -FROM openjdk:17 +FROM openjdk:17-jdk-alpine + +# Set working directory +WORKDIR /app # Copy the built jar file COPY build/libs/*.jar app.jar # Set the entry point to run the application -ENTRYPOINT ["java", "-jar", "-Dspring.profiles.active=prod", "/app.jar"] +EXPOSE 8080 +CMD ["java", "-jar", "-Dspring.profiles.active=prod", "/app.jar"] diff --git a/docker-compose.yml b/docker-compose.yml index dc69adc..fb54378 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,17 +3,17 @@ services: backend-blue: build: context: . - dockerfile: ./Dockerfile + dockerfile: Dockerfile ports: - "8081:8080" environment: - - SPRING_PROFILES_ACTIVE=production + - SPRING_PROFILES_ACTIVE=prod backend-green: build: context: . - dockerfile: ./Dockerfile + dockerfile: Dockerfile ports: - "8082:8080" environment: - - SPRING_PROFILES_ACTIVE=production + - SPRING_PROFILES_ACTIVE=prod From c3027971fc2904a2fae84d810ff80abebbd31560 Mon Sep 17 00:00:00 2001 From: deveunhwa Date: Tue, 5 Nov 2024 16:18:48 +0900 Subject: [PATCH 3/8] =?UTF-8?q?Fix:=20workflows=20blue-green=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/CICD.yml | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/.github/workflows/CICD.yml b/.github/workflows/CICD.yml index bff4eba..bea8bae 100644 --- a/.github/workflows/CICD.yml +++ b/.github/workflows/CICD.yml @@ -58,7 +58,7 @@ jobs: run: rm ./src/main/resources/application-prod.properties deploy: - if: github.repository == 'prgrms-be-devcourse/NBB1_2_3_Team10' && github.ref == 'refs/heads/release' + if: github.repository == 'prgrms-be-devcourse/NBB1_2_3_Team10' && github.ref == 'refs/heads/release' needs: build runs-on: ubuntu-latest permissions: @@ -97,9 +97,11 @@ jobs: # Blue-Green 설정 Docker Compose 실행 - name: Deploy with Docker Compose on EC2 - run: | - ssh -o StrictHostKeyChecking=no ec2-user@${{ secrets.EC2_HOST }} - docker-compose down - docker-compose up -d" - env: - SSH_KEY: ${{ secrets.EC2_SSH_KEY }} + uses: appleboy/ssh-action@v0.1.6 + with: + host: ${{ secrets.EC2_HOST }} + username: ${{ secrets.EC2_USER }} + key: ${{ secrets.EC2_SSH_KEY }} + script: | + docker-compose down + docker-compose up -d From f2e10fa5f4721a0e1c6115ee2051fa88a21d4400 Mon Sep 17 00:00:00 2001 From: juwon-code Date: Tue, 5 Nov 2024 16:29:06 +0900 Subject: [PATCH 4/8] =?UTF-8?q?Chore:=20=EA=B8=B0=EB=B3=B8=20=ED=94=84?= =?UTF-8?q?=EB=A1=9C=ED=95=84=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20URL=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기본 프로필 이미지 URL을 수호님 사진으로 대체합니다. Related to: prgrms-be-devcourse/NBB1_2_3_Team10#37 --- src/main/resources/application.properties | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 3ade8d4..428eb78 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -3,4 +3,7 @@ spring.application.name=bitta-kotlin # file size limit file.max.size.image=10485760 -file.max.size.video=31457280 \ No newline at end of file +file.max.size.video=31457280 + +# default profile image url +default.profile.image = https://project-bitta-s3-bucket.s3.ap-northeast-2.amazonaws.com/profile.jpg \ No newline at end of file From d5fe994e01bddfbbb9d21a0d3f18561d63712fab Mon Sep 17 00:00:00 2001 From: ghtndl Date: Tue, 5 Nov 2024 16:30:06 +0900 Subject: [PATCH 5/8] Feat: Member Swagger --- .../member/controller/MemberController.kt | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/src/main/kotlin/org/tenten/bittakotlin/member/controller/MemberController.kt b/src/main/kotlin/org/tenten/bittakotlin/member/controller/MemberController.kt index b65d44a..bc9f56e 100644 --- a/src/main/kotlin/org/tenten/bittakotlin/member/controller/MemberController.kt +++ b/src/main/kotlin/org/tenten/bittakotlin/member/controller/MemberController.kt @@ -1,5 +1,10 @@ package org.tenten.bittakotlin.member.controller +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.tags.Tag import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity import org.springframework.security.access.AccessDeniedException @@ -11,6 +16,7 @@ import org.tenten.bittakotlin.member.repository.MemberRepository import org.tenten.bittakotlin.member.service.MemberService import org.tenten.bittakotlin.security.jwt.JWTUtil +@Tag(name = "회원관리 API 컨트롤러", description = "회원과 관련된 RestAPI 제공 컨트롤러") @RestController @RequestMapping("/api/v1/member") class MemberController( @@ -20,6 +26,26 @@ class MemberController( ) { // 회원가입 + + @Operation( + summary = "회원가입", + description = "회원가입을 진행합니다.", + responses = [ + ApiResponse( + responseCode = "200", + description = "회원가입 성공", + content = [Content( + mediaType = "application/json", + schema = Schema(example = "MEMBER_SUCCESS_SIGN_UP") // 예시 값은 문자열로 처리 + )] + ), + ApiResponse( + responseCode = "400", + description = "회원가입 실패", + content = [Content()] + ) + ] + ) @PostMapping("/join") fun join(@RequestBody joinDTO: MemberRequestDTO.Join): ResponseEntity { memberService.join(joinDTO) @@ -27,6 +53,16 @@ class MemberController( } // 회원 정보 조회 + + @Operation( + summary = "회원 정보 조회", + description = "회원의 ID를 사용해 회원 정보를 조회합니다.", + responses = [ApiResponse( + responseCode = "200", + description = "회원 정보 조회 성공", + content = [Content(mediaType = "application/json")] + ), ApiResponse(responseCode = "404", description = "회원 정보 조회 실패", content = [Content()])] + ) @GetMapping("/{id}") fun read(@PathVariable id: Long): ResponseEntity { val memberInfo = memberService.read(id) From 2711ceb442009cc736eb2ade363c459718157460 Mon Sep 17 00:00:00 2001 From: juwon-code Date: Tue, 5 Nov 2024 16:32:28 +0900 Subject: [PATCH 6/8] =?UTF-8?q?Feat:=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EA=B4=80=EB=A0=A8=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B5=AC=ED=98=84=20=EB=B0=8F=20=ED=95=84=ED=84=B0?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 기본 프로필 이미지를 수호님 사진 URL로 설정. - 프로필 이미지 수정 및 삭제 기능을 구현. - 이미지를 저장할 때 확장자까지 함께 저장하도록 설정. - 로그인 및 로그아웃에서 회원 정보를 쿠키로 반환 및 삭제하도록 설정. Related to: prgrms-be-devcourse/NBB1_2_3_Team10#37 --- .../bittakotlin/media/dto/MediaResponseDto.kt | 6 ++ .../media/service/MediaServiceImpl.kt | 19 ++++- .../media/service/ProfileImageService.kt | 11 +++ .../media/service/ProfileImageServiceImpl.kt | 81 +++++++++++++++++++ .../bittakotlin/media/service/S3Service.kt | 4 + .../media/service/S3ServiceImpl.kt | 9 +++ .../bittakotlin/member/entity/Member.kt | 2 - .../profile/controller/ProfileController.kt | 20 +++++ .../bittakotlin/profile/entity/Profile.kt | 7 -- .../profile/service/ProfileService.kt | 5 ++ .../profile/service/ProfileServiceImpl.kt | 45 +++++++++-- .../security/config/SecurityConfig.kt | 6 +- .../security/jwt/CustomLogoutFilter.kt | 15 +++- .../bittakotlin/security/jwt/LoginFilter.kt | 23 +++++- 14 files changed, 229 insertions(+), 24 deletions(-) create mode 100644 src/main/kotlin/org/tenten/bittakotlin/media/service/ProfileImageService.kt create mode 100644 src/main/kotlin/org/tenten/bittakotlin/media/service/ProfileImageServiceImpl.kt diff --git a/src/main/kotlin/org/tenten/bittakotlin/media/dto/MediaResponseDto.kt b/src/main/kotlin/org/tenten/bittakotlin/media/dto/MediaResponseDto.kt index 3e0f7a9..a013f6b 100644 --- a/src/main/kotlin/org/tenten/bittakotlin/media/dto/MediaResponseDto.kt +++ b/src/main/kotlin/org/tenten/bittakotlin/media/dto/MediaResponseDto.kt @@ -14,4 +14,10 @@ class MediaResponseDto { val media: Media ) + + data class PublicUpload ( + val uploadUrl: String, + + val readUrl: String + ) } \ No newline at end of file diff --git a/src/main/kotlin/org/tenten/bittakotlin/media/service/MediaServiceImpl.kt b/src/main/kotlin/org/tenten/bittakotlin/media/service/MediaServiceImpl.kt index ab79c19..6f7b141 100644 --- a/src/main/kotlin/org/tenten/bittakotlin/media/service/MediaServiceImpl.kt +++ b/src/main/kotlin/org/tenten/bittakotlin/media/service/MediaServiceImpl.kt @@ -42,8 +42,8 @@ class MediaServiceImpl( } override fun upload(requestDto: MediaRequestDto.Upload, profile: Profile): MediaResponseDto.Upload { - val filename: String = UUID.randomUUID().toString() val filetype: MediaType = checkMimetype(requestDto.mimetype) + val filename: String = UUID.randomUUID().toString() + getExtension(requestDto.mimetype) val filesize: Int = checkFileSize(requestDto.filesize, filetype) val media: Media = mediaRepository.save(Media( @@ -94,6 +94,23 @@ class MediaServiceImpl( throw MediaException(MediaError.WRONG_MIME_TYPE) } + private fun getExtension(mimetype: String): String? { + val mimetypeMap = mapOf( + "image/jpeg" to "jpg", + "image/png" to "png", + "image/gif" to "gif", + "image/bmp" to "bmp", + "image/webp" to "webp", + "image/svg+xml" to "svg", + "video/mp4" to "mp4", + "video/webm" to "webm", + "video/ogg" to "ogg", + "video/x-msvideo" to "avi", + "video/x-matroska" to "mkv" + ) + return mimetypeMap[mimetype] + } + private fun checkFileSize(filesize: Int, type: MediaType): Int { val maxSize: Int = if (type == MediaType.IMAGE) { imageMaxSize diff --git a/src/main/kotlin/org/tenten/bittakotlin/media/service/ProfileImageService.kt b/src/main/kotlin/org/tenten/bittakotlin/media/service/ProfileImageService.kt new file mode 100644 index 0000000..f9000fb --- /dev/null +++ b/src/main/kotlin/org/tenten/bittakotlin/media/service/ProfileImageService.kt @@ -0,0 +1,11 @@ +package org.tenten.bittakotlin.media.service + +import org.tenten.bittakotlin.media.dto.MediaRequestDto +import org.tenten.bittakotlin.media.dto.MediaResponseDto +import org.tenten.bittakotlin.profile.entity.Profile + +interface ProfileImageService { + fun upload(requestDto: MediaRequestDto.Upload, profile: Profile): MediaResponseDto.PublicUpload + + fun delete(requestDto: MediaRequestDto.Delete) +} \ No newline at end of file diff --git a/src/main/kotlin/org/tenten/bittakotlin/media/service/ProfileImageServiceImpl.kt b/src/main/kotlin/org/tenten/bittakotlin/media/service/ProfileImageServiceImpl.kt new file mode 100644 index 0000000..99b8b4c --- /dev/null +++ b/src/main/kotlin/org/tenten/bittakotlin/media/service/ProfileImageServiceImpl.kt @@ -0,0 +1,81 @@ +package org.tenten.bittakotlin.media.service + +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import org.tenten.bittakotlin.media.constant.MediaError +import org.tenten.bittakotlin.media.constant.MediaType +import org.tenten.bittakotlin.media.dto.MediaRequestDto +import org.tenten.bittakotlin.media.dto.MediaResponseDto +import org.tenten.bittakotlin.media.entity.Media +import org.tenten.bittakotlin.media.exception.MediaException +import org.tenten.bittakotlin.media.repository.MediaRepository +import org.tenten.bittakotlin.profile.entity.Profile +import java.util.* + +@Service +class ProfileImageServiceImpl ( + private val mediaRepository: MediaRepository, + + private val s3Service: S3Service, + + @Value("\${file.max.size.image}") + private val maxsize: Int +) : ProfileImageService { + companion object { + private val logger: Logger = LoggerFactory.getLogger(ProfileImageServiceImpl::class.java) + } + + @Transactional + override fun upload(requestDto: MediaRequestDto.Upload, profile: Profile): MediaResponseDto.PublicUpload { + val filetype: MediaType = checkMimetype(requestDto.mimetype) + val filename: String = UUID.randomUUID().toString() + getExtension(requestDto.mimetype) + val filesize: Int = checkFileSize(requestDto.filesize) + + mediaRepository.save(Media( + filename = filename, + filetype = filetype, + filesize = filesize, + profile = profile + )) + + return s3Service.getPublicUploadUrl(filename, requestDto.mimetype) + } + + @Transactional + override fun delete(requestDto: MediaRequestDto.Delete) { + val filename: String = requestDto.filename + + mediaRepository.findByFilename(filename).orElseThrow { MediaException(MediaError.CANNOT_FOUND) } + } + + private fun getExtension(mimetype: String): String? { + val mimetypeMap = mapOf( + "image/jpeg" to "jpg", + "image/png" to "png", + "image/gif" to "gif", + "image/bmp" to "bmp", + "image/webp" to "webp", + "image/svg+xml" to "svg", + ) + return mimetypeMap[mimetype] + } + + private fun checkMimetype(mimetype: String): MediaType { + if (mimetype.matches(Regex("image/(jpeg|png|gif|bmp|webp|svg\\+xml)"))) { + return MediaType.IMAGE + } + + throw MediaException(MediaError.WRONG_MIME_TYPE) + } + + private fun checkFileSize(filesize: Int): Int { + if (filesize > maxsize) { + throw MediaException(MediaError.WRONG_FILE_SIZE) + } + + return filesize + } +} \ No newline at end of file diff --git a/src/main/kotlin/org/tenten/bittakotlin/media/service/S3Service.kt b/src/main/kotlin/org/tenten/bittakotlin/media/service/S3Service.kt index dbcc5b4..3b0ba7a 100644 --- a/src/main/kotlin/org/tenten/bittakotlin/media/service/S3Service.kt +++ b/src/main/kotlin/org/tenten/bittakotlin/media/service/S3Service.kt @@ -1,9 +1,13 @@ package org.tenten.bittakotlin.media.service +import org.tenten.bittakotlin.media.dto.MediaResponseDto + interface S3Service { fun getReadUrl(name: String): String fun getUploadUrl(name: String, contentType: String): String + fun getPublicUploadUrl(name: String, contentType: String): MediaResponseDto.PublicUpload + fun delete(filename: String): Unit } \ No newline at end of file diff --git a/src/main/kotlin/org/tenten/bittakotlin/media/service/S3ServiceImpl.kt b/src/main/kotlin/org/tenten/bittakotlin/media/service/S3ServiceImpl.kt index 0396fe9..9fa765a 100644 --- a/src/main/kotlin/org/tenten/bittakotlin/media/service/S3ServiceImpl.kt +++ b/src/main/kotlin/org/tenten/bittakotlin/media/service/S3ServiceImpl.kt @@ -5,7 +5,9 @@ import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Service import org.tenten.bittakotlin.media.constant.MediaError +import org.tenten.bittakotlin.media.dto.MediaResponseDto import org.tenten.bittakotlin.media.exception.MediaException +import software.amazon.awssdk.regions.Region import software.amazon.awssdk.services.s3.S3Client import software.amazon.awssdk.services.s3.model.HeadObjectRequest import software.amazon.awssdk.services.s3.model.NoSuchKeyException @@ -56,6 +58,13 @@ class S3ServiceImpl( return presignedPutRequest.url().toString() } + override fun getPublicUploadUrl(name: String, contentType: String): MediaResponseDto.PublicUpload { + return MediaResponseDto.PublicUpload( + uploadUrl = getUploadUrl(name, contentType), + readUrl = "https://${s3Bucket}.s3.${Region.AP_NORTHEAST_2}.amazonaws.com/$name" + ) + } + override fun delete(name: String): Unit { existsInBucket(name) diff --git a/src/main/kotlin/org/tenten/bittakotlin/member/entity/Member.kt b/src/main/kotlin/org/tenten/bittakotlin/member/entity/Member.kt index e0f7559..6feec6d 100644 --- a/src/main/kotlin/org/tenten/bittakotlin/member/entity/Member.kt +++ b/src/main/kotlin/org/tenten/bittakotlin/member/entity/Member.kt @@ -36,6 +36,4 @@ data class Member( @Column(nullable = false) var role: String? = null - - ) \ No newline at end of file diff --git a/src/main/kotlin/org/tenten/bittakotlin/profile/controller/ProfileController.kt b/src/main/kotlin/org/tenten/bittakotlin/profile/controller/ProfileController.kt index 791e40b..74f5bb1 100644 --- a/src/main/kotlin/org/tenten/bittakotlin/profile/controller/ProfileController.kt +++ b/src/main/kotlin/org/tenten/bittakotlin/profile/controller/ProfileController.kt @@ -4,6 +4,7 @@ import org.slf4j.Logger import org.slf4j.LoggerFactory import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.* +import org.tenten.bittakotlin.media.dto.MediaRequestDto import org.tenten.bittakotlin.profile.dto.ProfileDTO import org.tenten.bittakotlin.profile.service.ProfileServiceImpl @@ -31,6 +32,25 @@ class ProfileController( return ResponseEntity.ok(updatedProfile) } + @PutMapping("/image") + fun updateProfileImage(@RequestBody requestDto: MediaRequestDto.Upload): ResponseEntity> { + val uploadUrl = profileService.updateProfileImage(requestDto) + + return ResponseEntity.ok(mapOf( + "message" to "프로필 이미지를 수정했습니다.", + "uploadUrl" to uploadUrl + )) + } + + @DeleteMapping("/image") + fun deleteProfileImage(): ResponseEntity> { + profileService.deleteProfileImage() + + return ResponseEntity.ok(mapOf( + "message" to "프로필 이미지를 삭제했습니다." + )) + } + companion object { private val logger: Logger = LoggerFactory.getLogger(ProfileController::class.java) } diff --git a/src/main/kotlin/org/tenten/bittakotlin/profile/entity/Profile.kt b/src/main/kotlin/org/tenten/bittakotlin/profile/entity/Profile.kt index 8fd0cfe..556839e 100644 --- a/src/main/kotlin/org/tenten/bittakotlin/profile/entity/Profile.kt +++ b/src/main/kotlin/org/tenten/bittakotlin/profile/entity/Profile.kt @@ -6,7 +6,6 @@ import org.tenten.bittakotlin.apply.entity.Apply import org.tenten.bittakotlin.like.entity.Like import org.tenten.bittakotlin.member.entity.Member import org.tenten.bittakotlin.profile.constant.Job -import org.tenten.bittakotlin.scout.entity.ScoutRequest //data class 로 변경 @Entity @@ -39,10 +38,4 @@ class Profile( @OneToMany(mappedBy = "profile", fetch = FetchType.EAGER, cascade = [CascadeType.REMOVE], orphanRemoval = true) val like: List = mutableListOf(), - - @OneToMany(mappedBy = "sender", cascade = [CascadeType.ALL], orphanRemoval = true) - val sentScoutRequests: List = mutableListOf(), - - @OneToMany(mappedBy = "receiver", cascade = [CascadeType.ALL], orphanRemoval = true) - val receivedScoutRequests: List = mutableListOf() ) \ No newline at end of file diff --git a/src/main/kotlin/org/tenten/bittakotlin/profile/service/ProfileService.kt b/src/main/kotlin/org/tenten/bittakotlin/profile/service/ProfileService.kt index 4072f04..c6eea6e 100644 --- a/src/main/kotlin/org/tenten/bittakotlin/profile/service/ProfileService.kt +++ b/src/main/kotlin/org/tenten/bittakotlin/profile/service/ProfileService.kt @@ -1,5 +1,6 @@ package org.tenten.bittakotlin.profile.service +import org.tenten.bittakotlin.media.dto.MediaRequestDto import org.tenten.bittakotlin.member.entity.Member import org.tenten.bittakotlin.profile.dto.ProfileDTO import org.tenten.bittakotlin.profile.entity.Profile @@ -12,4 +13,8 @@ interface ProfileService { fun getByNickname(nickname: String): Profile fun getByPrincipal(): Profile + + fun updateProfileImage(requestDto: MediaRequestDto.Upload): String + + fun deleteProfileImage() } \ No newline at end of file diff --git a/src/main/kotlin/org/tenten/bittakotlin/profile/service/ProfileServiceImpl.kt b/src/main/kotlin/org/tenten/bittakotlin/profile/service/ProfileServiceImpl.kt index 6586ae0..f4875d7 100644 --- a/src/main/kotlin/org/tenten/bittakotlin/profile/service/ProfileServiceImpl.kt +++ b/src/main/kotlin/org/tenten/bittakotlin/profile/service/ProfileServiceImpl.kt @@ -3,8 +3,12 @@ package org.tenten.bittakotlin.profile.service import jakarta.persistence.EntityNotFoundException import org.slf4j.LoggerFactory import org.slf4j.Logger +import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional +import org.tenten.bittakotlin.media.dto.MediaRequestDto +import org.tenten.bittakotlin.media.dto.MediaResponseDto +import org.tenten.bittakotlin.media.service.ProfileImageService import org.tenten.bittakotlin.member.entity.Member import org.tenten.bittakotlin.member.repository.MemberRepository import org.tenten.bittakotlin.member.service.MemberService @@ -18,8 +22,13 @@ import org.tenten.bittakotlin.security.service.PrincipalProvider @Service class ProfileServiceImpl( private val profileRepository: ProfileRepository, - private val memberRepository: MemberRepository, - private val principalProvider: PrincipalProvider + + private val principalProvider: PrincipalProvider, + + private val profileImageService: ProfileImageService, + + @Value("\${default.profile.image}") + private val defaultProfileImageUrl: String ) : ProfileService { //Member 생성시 Profile 도 같이 생성 @@ -30,7 +39,7 @@ class ProfileServiceImpl( val profile = Profile( member = member, nickname = nickname, - profileUrl = null, + profileUrl = defaultProfileImageUrl, description = "This is a default profile.", job = null, socialMedia = null @@ -75,7 +84,6 @@ class ProfileServiceImpl( profile.description = profileDTO.description ?: profile.description profile.socialMedia = profileDTO.socialMedia ?: profile.socialMedia - profile.profileUrl = profileDTO.profileUrl ?: profile.profileUrl profile.job = profileDTO.job?.let { Job.valueOf(it) } ?: profile.job @@ -83,12 +91,39 @@ class ProfileServiceImpl( return toDto(updatedProfile) } + override fun updateProfileImage(requestDto: MediaRequestDto.Upload): String { + val profile = getByPrincipal() + + val responseDto: MediaResponseDto.PublicUpload = profileImageService.upload(requestDto, profile) + + profile.profileUrl = responseDto.readUrl + profileRepository.save(profile) + + return responseDto.uploadUrl + } + + override fun deleteProfileImage() { + val profile = getByPrincipal() + + if (profile.profileUrl == defaultProfileImageUrl) { + throw NoSuchElementException("기본 이미지는 삭제할 수 없습니다.") + } + + profileImageService.delete(MediaRequestDto.Delete( + filename = profile.profileUrl!!.substringAfterLast("/") + )) + + profile.profileUrl = defaultProfileImageUrl + profileRepository.save(profile) + } + private fun toDto(profile: Profile): ProfileDTO { return ProfileDTO( profileId = profile.id, nickname = profile.nickname, description = profile.description, - socialMedia = profile.socialMedia + socialMedia = profile.socialMedia, + profileUrl = profile.profileUrl ) } diff --git a/src/main/kotlin/org/tenten/bittakotlin/security/config/SecurityConfig.kt b/src/main/kotlin/org/tenten/bittakotlin/security/config/SecurityConfig.kt index 815d9a2..4cb0746 100644 --- a/src/main/kotlin/org/tenten/bittakotlin/security/config/SecurityConfig.kt +++ b/src/main/kotlin/org/tenten/bittakotlin/security/config/SecurityConfig.kt @@ -17,6 +17,7 @@ import org.springframework.security.web.authentication.logout.LogoutFilter import org.springframework.web.cors.CorsConfiguration import org.springframework.web.cors.CorsConfigurationSource import org.tenten.bittakotlin.member.repository.MemberRepository +import org.tenten.bittakotlin.profile.service.ProfileService import org.tenten.bittakotlin.security.jwt.CustomLogoutFilter import org.tenten.bittakotlin.security.jwt.JWTUtil import org.tenten.bittakotlin.security.jwt.LoginFilter @@ -27,7 +28,8 @@ import org.tenten.bittakotlin.security.repository.RefreshRepository class SecurityConfig( private val authenticationConfiguration: AuthenticationConfiguration, private val jwtUtil: JWTUtil, - private val refreshRepository: RefreshRepository + private val refreshRepository: RefreshRepository, + private val profileService: ProfileService ) { @Bean @@ -94,7 +96,7 @@ class SecurityConfig( http.addFilterBefore(JWTFilter(jwtUtil), LoginFilter::class.java) - val loginFilter = LoginFilter(authenticationManager(), jwtUtil, refreshRepository) + val loginFilter = LoginFilter(authenticationManager(), jwtUtil, refreshRepository, profileService) loginFilter.setFilterProcessesUrl("/api/member/login") http.addFilterAt(loginFilter, UsernamePasswordAuthenticationFilter::class.java) diff --git a/src/main/kotlin/org/tenten/bittakotlin/security/jwt/CustomLogoutFilter.kt b/src/main/kotlin/org/tenten/bittakotlin/security/jwt/CustomLogoutFilter.kt index 5710965..31a4a49 100644 --- a/src/main/kotlin/org/tenten/bittakotlin/security/jwt/CustomLogoutFilter.kt +++ b/src/main/kotlin/org/tenten/bittakotlin/security/jwt/CustomLogoutFilter.kt @@ -79,12 +79,19 @@ class CustomLogoutFilter( // Proceed with logout refreshRepository.deleteByRefresh(refresh) - val cookie = Cookie("refresh", null).apply { + + response.addCookie(getDiedCookie("refresh")) + response.addCookie(getDiedCookie("nickname")) + response.addCookie(getDiedCookie("profileUrl")) + + logger.info("Successfully logged out and deleted refresh token") + response.status = HttpServletResponse.SC_OK + } + + private fun getDiedCookie(name: String): Cookie { + return Cookie(name, null).apply { maxAge = 0 path = "/" } - response.addCookie(cookie) - logger.info("Successfully logged out and deleted refresh token") - response.status = HttpServletResponse.SC_OK } } \ No newline at end of file diff --git a/src/main/kotlin/org/tenten/bittakotlin/security/jwt/LoginFilter.kt b/src/main/kotlin/org/tenten/bittakotlin/security/jwt/LoginFilter.kt index bb6139c..e879260 100644 --- a/src/main/kotlin/org/tenten/bittakotlin/security/jwt/LoginFilter.kt +++ b/src/main/kotlin/org/tenten/bittakotlin/security/jwt/LoginFilter.kt @@ -13,7 +13,9 @@ import org.springframework.security.core.AuthenticationException import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter import org.springframework.util.StreamUtils import org.tenten.bittakotlin.member.dto.MemberRequestDTO -import org.tenten.bittakotlin.member.dto.MemberResponseDTO +import org.tenten.bittakotlin.profile.entity.Profile +import org.tenten.bittakotlin.profile.service.ProfileService + import org.tenten.bittakotlin.security.entity.RefreshEntity import org.tenten.bittakotlin.security.repository.RefreshRepository import java.io.IOException @@ -23,7 +25,8 @@ import java.util.* class LoginFilter( private val authenticationManager: AuthenticationManager, private val jwtUtil: JWTUtil, - private val refreshRepository: RefreshRepository + private val refreshRepository: RefreshRepository, + private val profileService: ProfileService ) : UsernamePasswordAuthenticationFilter() { override fun attemptAuthentication(request: HttpServletRequest, response: HttpServletResponse): Authentication { @@ -69,6 +72,12 @@ class LoginFilter( // 응답 설정 response.setHeader("access", access) response.addCookie(createCookie("refresh", refresh)) + + // 프로필 정보 설정 + val profile: Profile = profileService.getByPrincipal() + response.addCookie(createPublicCookie("nickname", profile.nickname)) + response.addCookie(createPublicCookie("profileUrl", profile.profileUrl!!)) + response.status = HttpStatus.OK.value() } @@ -97,8 +106,16 @@ class LoginFilter( return Cookie(key, value).apply { maxAge = 24 * 60 * 60 // secure = true - // path = "/" + path = "/" isHttpOnly = true } } + + private fun createPublicCookie(key: String, value: String): Cookie { + return Cookie(key, value).apply { + maxAge = 24 * 60 * 60 + path = "/" + isHttpOnly = false + } + } } \ No newline at end of file From db51d77ef6be7d0863242e441f8851725d0169a7 Mon Sep 17 00:00:00 2001 From: juwon-code <153498069+juwon-code@users.noreply.github.com> Date: Tue, 5 Nov 2024 17:04:56 +0900 Subject: [PATCH 7/8] Update SecurityConfig.kt --- .../org/tenten/bittakotlin/security/config/SecurityConfig.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/org/tenten/bittakotlin/security/config/SecurityConfig.kt b/src/main/kotlin/org/tenten/bittakotlin/security/config/SecurityConfig.kt index c5994fb..823d9e2 100644 --- a/src/main/kotlin/org/tenten/bittakotlin/security/config/SecurityConfig.kt +++ b/src/main/kotlin/org/tenten/bittakotlin/security/config/SecurityConfig.kt @@ -105,7 +105,7 @@ class SecurityConfig( http.addFilterBefore(JWTFilter(jwtUtil), LoginFilter::class.java) val loginFilter = LoginFilter(authenticationManager(), jwtUtil, refreshRepository, profileService) - loginFilter.setFilterProcessesUrl("/api/member/login") + loginFilter.setFilterProcessesUrl("/api/v1/member/login") http.addFilterAt(loginFilter, UsernamePasswordAuthenticationFilter::class.java) @@ -118,4 +118,4 @@ class SecurityConfig( return http.build() } -} \ No newline at end of file +} From e980708827e90cb13931871f23c1a133db0e5ffd Mon Sep 17 00:00:00 2001 From: juwon-code Date: Wed, 6 Nov 2024 11:35:50 +0900 Subject: [PATCH 8/8] =?UTF-8?q?Fix:=20=EC=8B=9C=ED=81=90=EB=A6=AC=ED=8B=B0?= =?UTF-8?q?=20=EA=B8=B4=EA=B8=89=20=ED=94=BD=EC=8A=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Related to: prgrms-be-devcourse/NBB1_2_3_Team10#40 --- .../feed/controller/FeedController.kt | 2 +- .../tenten/bittakotlin/feed/entity/Feed.kt | 2 +- .../feed/service/FeedMediaServiceImpl.kt | 5 +- .../feed/service/FeedServiceImpl.kt | 5 +- .../feedLike/controller/FeedLikeController.kt | 24 ---- .../feedLike/dto/FeedLikeDTO.kt | 7 - .../feedLike/entity/FeedLike.kt | 22 ---- .../feedLike/repository/FeedLikeRepository.kt | 13 -- .../feedLike/service/FeedLikeService.kt | 8 -- .../feedLike/service/FeedLikeServiceImpl.kt | 46 ------- .../tenten/bittakotlin/media/entity/Media.kt | 2 +- .../member/controller/MemberController.kt | 51 +++++++- .../member/dto/MemberResponseDTO.kt | 9 ++ .../member/service/MemberService.kt | 1 + .../member/service/MemberServiceImpl.kt | 30 ++++- .../profile/service/ProfileService.kt | 2 + .../profile/service/ProfileServiceImpl.kt | 5 + .../security/config/SecurityConfig.kt | 80 ++++-------- .../security/constant/TokenError.kt | 8 ++ .../security/controller/ReissueController.kt | 18 --- .../security/controller/TokenController.kt | 36 ++++++ .../security/dto/TokenRequestDto.kt | 9 ++ .../security/dto/TokenResponseDto.kt | 8 ++ .../security/entity/RefreshEntity.kt | 2 + .../security/exception/TokenException.kt | 14 ++ .../filter/JwtAuthenticationFilter.kt | 84 ++++++++++++ .../security/jwt/CustomLogoutFilter.kt | 97 -------------- .../bittakotlin/security/jwt/JWTFilter.kt | 72 ----------- .../bittakotlin/security/jwt/LoginFilter.kt | 121 ------------------ .../security/repository/RefreshRepository.kt | 6 +- .../security/service/ReissueService.kt | 9 -- .../security/service/ReissueServiceImpl.kt | 84 ------------ .../security/service/TokenService.kt | 10 ++ .../security/service/TokenServiceImpl.kt | 50 ++++++++ .../{jwt/JWTUtil.kt => util/JwtTokenUtil.kt} | 45 ++++--- 35 files changed, 372 insertions(+), 615 deletions(-) delete mode 100644 src/main/kotlin/org/tenten/bittakotlin/feedInteraction/feedLike/controller/FeedLikeController.kt delete mode 100644 src/main/kotlin/org/tenten/bittakotlin/feedInteraction/feedLike/dto/FeedLikeDTO.kt delete mode 100644 src/main/kotlin/org/tenten/bittakotlin/feedInteraction/feedLike/entity/FeedLike.kt delete mode 100644 src/main/kotlin/org/tenten/bittakotlin/feedInteraction/feedLike/repository/FeedLikeRepository.kt delete mode 100644 src/main/kotlin/org/tenten/bittakotlin/feedInteraction/feedLike/service/FeedLikeService.kt delete mode 100644 src/main/kotlin/org/tenten/bittakotlin/feedInteraction/feedLike/service/FeedLikeServiceImpl.kt create mode 100644 src/main/kotlin/org/tenten/bittakotlin/security/constant/TokenError.kt delete mode 100644 src/main/kotlin/org/tenten/bittakotlin/security/controller/ReissueController.kt create mode 100644 src/main/kotlin/org/tenten/bittakotlin/security/controller/TokenController.kt create mode 100644 src/main/kotlin/org/tenten/bittakotlin/security/dto/TokenRequestDto.kt create mode 100644 src/main/kotlin/org/tenten/bittakotlin/security/dto/TokenResponseDto.kt create mode 100644 src/main/kotlin/org/tenten/bittakotlin/security/exception/TokenException.kt create mode 100644 src/main/kotlin/org/tenten/bittakotlin/security/filter/JwtAuthenticationFilter.kt delete mode 100644 src/main/kotlin/org/tenten/bittakotlin/security/jwt/CustomLogoutFilter.kt delete mode 100644 src/main/kotlin/org/tenten/bittakotlin/security/jwt/JWTFilter.kt delete mode 100644 src/main/kotlin/org/tenten/bittakotlin/security/jwt/LoginFilter.kt delete mode 100644 src/main/kotlin/org/tenten/bittakotlin/security/service/ReissueService.kt delete mode 100644 src/main/kotlin/org/tenten/bittakotlin/security/service/ReissueServiceImpl.kt create mode 100644 src/main/kotlin/org/tenten/bittakotlin/security/service/TokenService.kt create mode 100644 src/main/kotlin/org/tenten/bittakotlin/security/service/TokenServiceImpl.kt rename src/main/kotlin/org/tenten/bittakotlin/security/{jwt/JWTUtil.kt => util/JwtTokenUtil.kt} (64%) diff --git a/src/main/kotlin/org/tenten/bittakotlin/feed/controller/FeedController.kt b/src/main/kotlin/org/tenten/bittakotlin/feed/controller/FeedController.kt index 064d109..b348773 100644 --- a/src/main/kotlin/org/tenten/bittakotlin/feed/controller/FeedController.kt +++ b/src/main/kotlin/org/tenten/bittakotlin/feed/controller/FeedController.kt @@ -46,7 +46,7 @@ class FeedController ( } @PostMapping - fun create(requestDto: FeedRequestDto.Create): ResponseEntity> { + fun create(@RequestBody requestDto: FeedRequestDto.Create): ResponseEntity> { return ResponseEntity.ok(mapOf( "message" to "피드를 성공적으로 등록했습니다.", "result" to feedService.save(requestDto) diff --git a/src/main/kotlin/org/tenten/bittakotlin/feed/entity/Feed.kt b/src/main/kotlin/org/tenten/bittakotlin/feed/entity/Feed.kt index 1b86fe8..364d6bf 100644 --- a/src/main/kotlin/org/tenten/bittakotlin/feed/entity/Feed.kt +++ b/src/main/kotlin/org/tenten/bittakotlin/feed/entity/Feed.kt @@ -30,7 +30,7 @@ data class Feed( @CreatedDate @Column(updatable = false, nullable = false) - val createdAt: LocalDateTime? = null, + var createdAt: LocalDateTime? = null, @LastModifiedDate @Column(updatable = true, nullable = false) diff --git a/src/main/kotlin/org/tenten/bittakotlin/feed/service/FeedMediaServiceImpl.kt b/src/main/kotlin/org/tenten/bittakotlin/feed/service/FeedMediaServiceImpl.kt index 658428e..7850761 100644 --- a/src/main/kotlin/org/tenten/bittakotlin/feed/service/FeedMediaServiceImpl.kt +++ b/src/main/kotlin/org/tenten/bittakotlin/feed/service/FeedMediaServiceImpl.kt @@ -4,6 +4,7 @@ import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import org.tenten.bittakotlin.feed.entity.Feed import org.tenten.bittakotlin.feed.entity.FeedMedia +import org.tenten.bittakotlin.feed.entity.key.FeedMediaId import org.tenten.bittakotlin.feed.repository.FeedMediaRepository import org.tenten.bittakotlin.media.dto.MediaRequestDto import org.tenten.bittakotlin.media.dto.MediaResponseDto @@ -22,10 +23,12 @@ class FeedMediaServiceImpl( uploadRequestDtos.forEach { uploadRequestDto -> val mediaResponseDto: MediaResponseDto.Upload = mediaService.upload(uploadRequestDto, profile) + val media = mediaResponseDto.media feedMediaRepository.save(FeedMedia( + id = FeedMediaId(feedId = feed.id!!, mediaId = media.id!!), feed = feed, - media = mediaResponseDto.media + media = media )) responseDto.add(MediaResponseDto.Read( diff --git a/src/main/kotlin/org/tenten/bittakotlin/feed/service/FeedServiceImpl.kt b/src/main/kotlin/org/tenten/bittakotlin/feed/service/FeedServiceImpl.kt index e63d104..f8c2d37 100644 --- a/src/main/kotlin/org/tenten/bittakotlin/feed/service/FeedServiceImpl.kt +++ b/src/main/kotlin/org/tenten/bittakotlin/feed/service/FeedServiceImpl.kt @@ -16,6 +16,7 @@ import org.tenten.bittakotlin.feed.dto.FeedRequestDto import org.tenten.bittakotlin.feed.dto.FeedResponseDto import org.tenten.bittakotlin.profile.entity.Profile import org.tenten.bittakotlin.profile.service.ProfileService +import java.time.LocalDateTime @Service @RequiredArgsConstructor @@ -80,7 +81,9 @@ class FeedServiceImpl( val feed: Feed = feedRepository.save(Feed( title = requestDto.title, content = requestDto.content, - profile = profile + profile = profile, + createdAt = LocalDateTime.now(), + updatedAt = LocalDateTime.now() )) return if (requestDto.medias != null) { diff --git a/src/main/kotlin/org/tenten/bittakotlin/feedInteraction/feedLike/controller/FeedLikeController.kt b/src/main/kotlin/org/tenten/bittakotlin/feedInteraction/feedLike/controller/FeedLikeController.kt deleted file mode 100644 index cf0219f..0000000 --- a/src/main/kotlin/org/tenten/bittakotlin/feedInteraction/feedLike/controller/FeedLikeController.kt +++ /dev/null @@ -1,24 +0,0 @@ -package org.tenten.bittakotlin.feedInteraction.feedLike.controller - -import org.springframework.http.ResponseEntity -import org.springframework.web.bind.annotation.* -import org.tenten.bittakotlin.feedInteraction.feedLike.dto.FeedLikeDTO -import org.tenten.bittakotlin.feedInteraction.feedLike.service.FeedLikeService - - -@RestController -@RequestMapping("/api/v1/feed/like") -class FeedLikeController(private val likeService: FeedLikeService) { - - @PostMapping("/{feedId}") - fun toggleLike(@PathVariable feedId: Long, @RequestParam profileId: Long): ResponseEntity { - val likeDTO = likeService.toggleLike(feedId, profileId) - return ResponseEntity.ok(likeDTO) - } - - @GetMapping("/{feedId}/count") - fun getLikeCount(@PathVariable feedId: Long): ResponseEntity { - val likeCount = likeService.getLikeCount(feedId) - return ResponseEntity.ok(likeCount) - } -} \ No newline at end of file diff --git a/src/main/kotlin/org/tenten/bittakotlin/feedInteraction/feedLike/dto/FeedLikeDTO.kt b/src/main/kotlin/org/tenten/bittakotlin/feedInteraction/feedLike/dto/FeedLikeDTO.kt deleted file mode 100644 index 6b70070..0000000 --- a/src/main/kotlin/org/tenten/bittakotlin/feedInteraction/feedLike/dto/FeedLikeDTO.kt +++ /dev/null @@ -1,7 +0,0 @@ -package org.tenten.bittakotlin.feedInteraction.feedLike.dto - -data class FeedLikeDTO( - val feedId: Long?, - val profileId: Long?, - val isLiked: Boolean -) \ No newline at end of file diff --git a/src/main/kotlin/org/tenten/bittakotlin/feedInteraction/feedLike/entity/FeedLike.kt b/src/main/kotlin/org/tenten/bittakotlin/feedInteraction/feedLike/entity/FeedLike.kt deleted file mode 100644 index e25fc79..0000000 --- a/src/main/kotlin/org/tenten/bittakotlin/feedInteraction/feedLike/entity/FeedLike.kt +++ /dev/null @@ -1,22 +0,0 @@ -package org.tenten.bittakotlin.feedInteraction.feedLike.entity - -import jakarta.persistence.* -import org.tenten.bittakotlin.feed.entity.Feed -import org.tenten.bittakotlin.profile.entity.Profile - -data class FeedLike( - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - val id: Long? = null, - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "feed_id", nullable = false) - var feed: Feed, - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "profile_id", nullable = false) - var profile: Profile, - - @Column(nullable = false) - var liked: Boolean = false -) \ No newline at end of file diff --git a/src/main/kotlin/org/tenten/bittakotlin/feedInteraction/feedLike/repository/FeedLikeRepository.kt b/src/main/kotlin/org/tenten/bittakotlin/feedInteraction/feedLike/repository/FeedLikeRepository.kt deleted file mode 100644 index a7a3fa6..0000000 --- a/src/main/kotlin/org/tenten/bittakotlin/feedInteraction/feedLike/repository/FeedLikeRepository.kt +++ /dev/null @@ -1,13 +0,0 @@ -package org.tenten.bittakotlin.feedInteraction.feedLike.repository - -import org.springframework.data.jpa.repository.JpaRepository -import org.tenten.bittakotlin.feed.entity.Feed -import org.tenten.bittakotlin.feedInteraction.feedLike.entity.FeedLike -import org.tenten.bittakotlin.profile.entity.Profile -import java.util.* - - -interface FeedLikeRepository : JpaRepository { - fun findByFeedAndProfile(feed: Feed, profile: Profile): Optional - fun countByFeedAndLikedTrue(feed: Feed): Long -} diff --git a/src/main/kotlin/org/tenten/bittakotlin/feedInteraction/feedLike/service/FeedLikeService.kt b/src/main/kotlin/org/tenten/bittakotlin/feedInteraction/feedLike/service/FeedLikeService.kt deleted file mode 100644 index 15b72ce..0000000 --- a/src/main/kotlin/org/tenten/bittakotlin/feedInteraction/feedLike/service/FeedLikeService.kt +++ /dev/null @@ -1,8 +0,0 @@ -package org.tenten.bittakotlin.feedInteraction.feedLike.service - -import org.tenten.bittakotlin.feedInteraction.feedLike.dto.FeedLikeDTO - -interface FeedLikeService { - fun toggleLike(feedId: Long, profileId: Long): FeedLikeDTO - fun getLikeCount(feedId: Long): Long -} diff --git a/src/main/kotlin/org/tenten/bittakotlin/feedInteraction/feedLike/service/FeedLikeServiceImpl.kt b/src/main/kotlin/org/tenten/bittakotlin/feedInteraction/feedLike/service/FeedLikeServiceImpl.kt deleted file mode 100644 index fbc333e..0000000 --- a/src/main/kotlin/org/tenten/bittakotlin/feedInteraction/feedLike/service/FeedLikeServiceImpl.kt +++ /dev/null @@ -1,46 +0,0 @@ -package org.tenten.bittakotlin.feedInteraction.feedLike.service - -import jakarta.persistence.EntityNotFoundException -import org.springframework.stereotype.Service -import org.springframework.transaction.annotation.Transactional -import org.tenten.bittakotlin.feed.repository.FeedRepository -import org.tenten.bittakotlin.feedInteraction.feedLike.dto.FeedLikeDTO -import org.tenten.bittakotlin.feedInteraction.feedLike.entity.FeedLike -import org.tenten.bittakotlin.feedInteraction.feedLike.repository.FeedLikeRepository -import org.tenten.bittakotlin.profile.repository.ProfileRepository - - -@Service -class FeedLikeServiceImpl( - private val likeRepository: FeedLikeRepository, - private val feedRepository: FeedRepository, - private val profileRepository: ProfileRepository -) : FeedLikeService { - - @Transactional - override fun toggleLike(feedId: Long, profileId: Long): FeedLikeDTO { - val feed = feedRepository.findById(feedId) - .orElseThrow { EntityNotFoundException("Feed not found for id: $feedId") } - val profile = profileRepository.findById(profileId) - .orElseThrow { EntityNotFoundException("Profile not found for id: $profileId") } - - val like = likeRepository.findByFeedAndProfile(feed, profile).orElseGet { - val newLike = FeedLike(feed = feed, profile = profile, liked = true) - likeRepository.save(newLike) - newLike - } - - like.liked = !like.liked - likeRepository.save(like) - - return FeedLikeDTO(feed.id, profile.id, like.liked) - } - - @Transactional(readOnly = true) - override fun getLikeCount(feedId: Long): Long { - val feed = feedRepository.findById(feedId) - .orElseThrow { EntityNotFoundException("Feed not found for id: $feedId") } - - return likeRepository.countByFeedAndLikedTrue(feed) - } -} \ No newline at end of file diff --git a/src/main/kotlin/org/tenten/bittakotlin/media/entity/Media.kt b/src/main/kotlin/org/tenten/bittakotlin/media/entity/Media.kt index 5e2499d..2b21679 100644 --- a/src/main/kotlin/org/tenten/bittakotlin/media/entity/Media.kt +++ b/src/main/kotlin/org/tenten/bittakotlin/media/entity/Media.kt @@ -28,7 +28,7 @@ data class Media ( @CreatedDate @Column(nullable = false, updatable = false) - val savedAt: LocalDateTime? = null, + var savedAt: LocalDateTime? = null, @ManyToOne @JoinColumn diff --git a/src/main/kotlin/org/tenten/bittakotlin/member/controller/MemberController.kt b/src/main/kotlin/org/tenten/bittakotlin/member/controller/MemberController.kt index bc9f56e..0dc0a16 100644 --- a/src/main/kotlin/org/tenten/bittakotlin/member/controller/MemberController.kt +++ b/src/main/kotlin/org/tenten/bittakotlin/member/controller/MemberController.kt @@ -5,6 +5,9 @@ import io.swagger.v3.oas.annotations.media.Content import io.swagger.v3.oas.annotations.media.Schema import io.swagger.v3.oas.annotations.responses.ApiResponse import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.springframework.http.HttpHeaders import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity import org.springframework.security.access.AccessDeniedException @@ -14,17 +17,55 @@ import org.tenten.bittakotlin.member.dto.MemberResponseDTO import org.tenten.bittakotlin.member.exception.MemberException import org.tenten.bittakotlin.member.repository.MemberRepository import org.tenten.bittakotlin.member.service.MemberService -import org.tenten.bittakotlin.security.jwt.JWTUtil +import org.tenten.bittakotlin.security.util.JwtTokenUtil @Tag(name = "회원관리 API 컨트롤러", description = "회원과 관련된 RestAPI 제공 컨트롤러") @RestController @RequestMapping("/api/v1/member") class MemberController( private val memberService: MemberService, - private val jwtUtil: JWTUtil, - private val memberRepository: MemberRepository + private val memberRepository: MemberRepository, + private val jwtTokenUtil: JwtTokenUtil ) { + @PostMapping("/login") + fun login(@RequestBody requestDto: MemberRequestDTO.Login): ResponseEntity> { + val responseDto: MemberResponseDTO.Login = memberService.login(requestDto) + + val headers = HttpHeaders().apply { + add(HttpHeaders.SET_COOKIE, getCookieString("accessToken", responseDto.accessToken, 3600, false)) + add(HttpHeaders.SET_COOKIE, getCookieString("refreshToken", responseDto.refreshToken, 604800, false)) + add(HttpHeaders.SET_COOKIE, getCookieString("profileId", responseDto.profileId.toString(), 604800, true)) + add(HttpHeaders.SET_COOKIE, getCookieString("profileUrl", responseDto.profileUrl, 604800, true)) + } + + val body = mapOf("message" to "로그인이 성공했습니다.") + + return ResponseEntity(body, headers, HttpStatus.OK) + } + + @PostMapping("/logout") + fun logout(request: HttpServletRequest, response: HttpServletResponse): ResponseEntity> { + val headers = HttpHeaders().apply { + add(HttpHeaders.SET_COOKIE, getCookieString("accessToken", null, 0, false)) + add(HttpHeaders.SET_COOKIE, getCookieString("refreshToken", null, 0, false)) + add(HttpHeaders.SET_COOKIE, getCookieString("profileId", null, 0, true)) + add(HttpHeaders.SET_COOKIE, getCookieString("profileUrl", null, 0, true)) + } + + val body = mapOf("message" to "로그아웃이 성공했습니다.") + + return ResponseEntity(body, headers, HttpStatus.OK) + } + + + private fun getCookieString(name: String, token: String?, ageMax: Int, isPublic: Boolean): String { + return buildString { + append("$name=$token; Path=/; Max-Age=$ageMax;") + if (!isPublic) append(" HttpOnly;") + } + } + // 회원가입 @Operation( @@ -77,7 +118,7 @@ class MemberController( @RequestHeader("access") token: String // JWT 토큰을 헤더에서 추출 ): ResponseEntity { // 현재 로그인한 사용자 username 추출 - val usernameFromToken = jwtUtil.getUsername(token) + val usernameFromToken = jwtTokenUtil.getUsername(token) // id로 회원 정보 조회 val member = memberRepository.findById(id) @@ -96,7 +137,7 @@ class MemberController( @DeleteMapping("/{id}") fun remove(@PathVariable id: Long, @RequestHeader("access") token: String): ResponseEntity { - val username = jwtUtil.getUsername(token) + val username = jwtTokenUtil.getUsername(token) val member = memberRepository.findById(id) .orElseThrow { IllegalArgumentException("Member not Found.") } if(member.username != username) { diff --git a/src/main/kotlin/org/tenten/bittakotlin/member/dto/MemberResponseDTO.kt b/src/main/kotlin/org/tenten/bittakotlin/member/dto/MemberResponseDTO.kt index c43ccb7..fd1628b 100644 --- a/src/main/kotlin/org/tenten/bittakotlin/member/dto/MemberResponseDTO.kt +++ b/src/main/kotlin/org/tenten/bittakotlin/member/dto/MemberResponseDTO.kt @@ -20,4 +20,13 @@ class MemberResponseDTO { val address: String ) + data class Login( + val accessToken: String, + + val refreshToken: String, + + val profileId: Long, + + val profileUrl: String + ) } \ No newline at end of file diff --git a/src/main/kotlin/org/tenten/bittakotlin/member/service/MemberService.kt b/src/main/kotlin/org/tenten/bittakotlin/member/service/MemberService.kt index 7507dac..05fd731 100644 --- a/src/main/kotlin/org/tenten/bittakotlin/member/service/MemberService.kt +++ b/src/main/kotlin/org/tenten/bittakotlin/member/service/MemberService.kt @@ -4,6 +4,7 @@ import org.tenten.bittakotlin.member.dto.MemberRequestDTO import org.tenten.bittakotlin.member.dto.MemberResponseDTO interface MemberService { + fun login(requestDto: MemberRequestDTO.Login): MemberResponseDTO.Login fun join(joinDTO: MemberRequestDTO.Join) // Join 기능 병합 diff --git a/src/main/kotlin/org/tenten/bittakotlin/member/service/MemberServiceImpl.kt b/src/main/kotlin/org/tenten/bittakotlin/member/service/MemberServiceImpl.kt index 016d298..1850547 100644 --- a/src/main/kotlin/org/tenten/bittakotlin/member/service/MemberServiceImpl.kt +++ b/src/main/kotlin/org/tenten/bittakotlin/member/service/MemberServiceImpl.kt @@ -10,7 +10,11 @@ import org.tenten.bittakotlin.member.dto.MemberResponseDTO import org.tenten.bittakotlin.member.entity.Member import org.tenten.bittakotlin.member.exception.MemberException import org.tenten.bittakotlin.member.repository.MemberRepository +import org.tenten.bittakotlin.profile.entity.Profile import org.tenten.bittakotlin.profile.service.ProfileService +import org.tenten.bittakotlin.security.dto.TokenRequestDto +import org.tenten.bittakotlin.security.service.TokenService +import org.tenten.bittakotlin.security.util.JwtTokenUtil @Service @RequiredArgsConstructor @@ -18,9 +22,31 @@ import org.tenten.bittakotlin.profile.service.ProfileService class MemberServiceImpl ( private val memberRepository: MemberRepository, private val bCryptPasswordEncoder: BCryptPasswordEncoder, - private val profileService: ProfileService - + private val profileService: ProfileService, + private val tokenService: TokenService ): MemberService { + override fun login(requestDto: MemberRequestDTO.Login): MemberResponseDTO.Login { + val member = memberRepository.findByUsername(requestDto.username) + ?: throw MemberException.BAD_CREDENTIAL.get() + + if (!bCryptPasswordEncoder.matches(requestDto.password, member.password)) { + throw MemberException.BAD_CREDENTIAL.get() + } + + val tokenResponseDto = tokenService.create(TokenRequestDto.Create( + username = member.username, + role = member.role!! + )) + + val profile: Profile = profileService.getByUsername(member.username) + + return MemberResponseDTO.Login( + accessToken = tokenResponseDto.accessToken, + refreshToken = tokenResponseDto.refreshToken, + profileId = profile.id!!, + profileUrl = profile.profileUrl!! + ) + } override fun join(joinDTO: MemberRequestDTO.Join) { val username = joinDTO.username diff --git a/src/main/kotlin/org/tenten/bittakotlin/profile/service/ProfileService.kt b/src/main/kotlin/org/tenten/bittakotlin/profile/service/ProfileService.kt index c6eea6e..549ba5f 100644 --- a/src/main/kotlin/org/tenten/bittakotlin/profile/service/ProfileService.kt +++ b/src/main/kotlin/org/tenten/bittakotlin/profile/service/ProfileService.kt @@ -14,6 +14,8 @@ interface ProfileService { fun getByPrincipal(): Profile + fun getByUsername(username: String): Profile + fun updateProfileImage(requestDto: MediaRequestDto.Upload): String fun deleteProfileImage() diff --git a/src/main/kotlin/org/tenten/bittakotlin/profile/service/ProfileServiceImpl.kt b/src/main/kotlin/org/tenten/bittakotlin/profile/service/ProfileServiceImpl.kt index f4875d7..ec27b58 100644 --- a/src/main/kotlin/org/tenten/bittakotlin/profile/service/ProfileServiceImpl.kt +++ b/src/main/kotlin/org/tenten/bittakotlin/profile/service/ProfileServiceImpl.kt @@ -137,6 +137,11 @@ class ProfileServiceImpl( .orElseThrow { NoSuchElementException() } } + override fun getByUsername(username: String): Profile { + return profileRepository.findByUsername(username) + .orElseThrow { NoSuchElementException() } + } + companion object { private val logger: Logger = LoggerFactory.getLogger(ProfileServiceImpl::class.java) } diff --git a/src/main/kotlin/org/tenten/bittakotlin/security/config/SecurityConfig.kt b/src/main/kotlin/org/tenten/bittakotlin/security/config/SecurityConfig.kt index 823d9e2..43e5e43 100644 --- a/src/main/kotlin/org/tenten/bittakotlin/security/config/SecurityConfig.kt +++ b/src/main/kotlin/org/tenten/bittakotlin/security/config/SecurityConfig.kt @@ -1,10 +1,8 @@ package org.tenten.bittakotlin.security.config -import JWTFilter import jakarta.servlet.http.HttpServletRequest import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration -import org.springframework.http.HttpMethod import org.springframework.security.authentication.AuthenticationManager import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration import org.springframework.security.config.annotation.web.builders.HttpSecurity @@ -12,25 +10,23 @@ import org.springframework.security.config.annotation.web.configuration.EnableWe import org.springframework.security.config.http.SessionCreationPolicy import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder import org.springframework.security.web.SecurityFilterChain -import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter -import org.springframework.security.web.authentication.logout.LogoutFilter +import org.springframework.security.web.authentication.www.BasicAuthenticationFilter import org.springframework.web.cors.CorsConfiguration import org.springframework.web.cors.CorsConfigurationSource -import org.tenten.bittakotlin.member.repository.MemberRepository -import org.tenten.bittakotlin.profile.service.ProfileService -import org.tenten.bittakotlin.security.jwt.CustomLogoutFilter -import org.tenten.bittakotlin.security.jwt.JWTUtil -import org.tenten.bittakotlin.security.jwt.LoginFilter -import org.tenten.bittakotlin.security.repository.RefreshRepository +import org.tenten.bittakotlin.security.filter.JwtAuthenticationFilter @Configuration @EnableWebSecurity class SecurityConfig( + private val jwtAuthenticationFilter: JwtAuthenticationFilter, + private val authenticationConfiguration: AuthenticationConfiguration, - private val jwtUtil: JWTUtil, - private val refreshRepository: RefreshRepository, - private val profileService: ProfileService ) { + private val publicUrls = arrayOf("/", "/api/v1/member/login", "/api/v1/member/logout", "/api/v1/member/join", "/api/v1/token", + "/swagger", "/swagger-ui.html", "/swagger-ui/**", "/api-docs", "/api-docs/**", "/v3/api-docs/**") + + private val signedUrls = arrayOf("/api/v1/member/{id}**", "/api/v1/job-post/**", "/job-post/**", "/api/v1/like/**", + "/api/v1/chat/**", "/api/v1/feed") @Bean @Throws(Exception::class) @@ -61,60 +57,28 @@ class SecurityConfig( }) } - // CSRF disable http.csrf { it.disable() } + .formLogin { it.disable() } + .httpBasic { it.disable() } + .authorizeHttpRequests { auth -> + auth + .requestMatchers(*publicUrls).permitAll() + .requestMatchers(*signedUrls).hasRole("USER") + .anyRequest().authenticated() + } + .sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) } + .addFilterBefore(jwtAuthenticationFilter, BasicAuthenticationFilter::class.java) - // Form 로그인 방식 disable - http.formLogin { it.disable() } - - // HTTP Basic 인증 방식 disable - http.httpBasic { it.disable() } - - http.authorizeHttpRequests { auth -> - auth - .requestMatchers( - "/", - "/api/v1/member/login", - "/member/login", - "/api/v1/member/join", - "/member/join", - "/api/v1/member/reissue").permitAll() - - .requestMatchers( - "/swagger", - "/swagger-ui.html", - "/swagger-ui/**", - "/api-docs", - "/api-docs/**", - "/v3/api-docs/**").permitAll() - .requestMatchers( - "/api/v1/member/{id}/**", - "member/{id}/**", - "/api/v1/job-post/**", - "/job-post/**", - "/api/v1/like/**").hasRole("USER") + /*http.addFilterAfter(JWTFilter(jwtUtil), LoginFilter::class.java) - .requestMatchers(HttpMethod.DELETE,"/api/v1/member/{id}").authenticated() - .requestMatchers(HttpMethod.PUT,"/api/v1/member/{id}").authenticated() - .requestMatchers("/api/v1/chat/**").authenticated() + val loginFilter = LoginFilter(authenticationManager(), jwtUtil, refreshRepository) - .anyRequest().authenticated() - } - - http.addFilterBefore(JWTFilter(jwtUtil), LoginFilter::class.java) - - val loginFilter = LoginFilter(authenticationManager(), jwtUtil, refreshRepository, profileService) loginFilter.setFilterProcessesUrl("/api/v1/member/login") http.addFilterAt(loginFilter, UsernamePasswordAuthenticationFilter::class.java) - http.addFilterBefore(CustomLogoutFilter(jwtUtil, refreshRepository), LogoutFilter::class.java) - - // 세션 설정 - http.sessionManagement { session -> - session.sessionCreationPolicy(SessionCreationPolicy.STATELESS) - } + http.addFilterBefore(CustomLogoutFilter(jwtUtil, refreshRepository), LogoutFilter::class.java)*/ return http.build() } diff --git a/src/main/kotlin/org/tenten/bittakotlin/security/constant/TokenError.kt b/src/main/kotlin/org/tenten/bittakotlin/security/constant/TokenError.kt new file mode 100644 index 0000000..14b534a --- /dev/null +++ b/src/main/kotlin/org/tenten/bittakotlin/security/constant/TokenError.kt @@ -0,0 +1,8 @@ +package org.tenten.bittakotlin.security.constant + +import org.springframework.http.HttpStatus + +enum class TokenError(val code: Int, val message: String) { + REFRESH_EXPIRED(HttpStatus.BAD_REQUEST.value(), "리프레시 토큰이 만료되었습니다."), + REFRESH_NOT_FOUND(HttpStatus.BAD_REQUEST.value(), "리프레시 토큰의 인증정보가 존재하지 않습니다.") +} \ No newline at end of file diff --git a/src/main/kotlin/org/tenten/bittakotlin/security/controller/ReissueController.kt b/src/main/kotlin/org/tenten/bittakotlin/security/controller/ReissueController.kt deleted file mode 100644 index 75b3cb2..0000000 --- a/src/main/kotlin/org/tenten/bittakotlin/security/controller/ReissueController.kt +++ /dev/null @@ -1,18 +0,0 @@ -package org.tenten.bittakotlin.security.controller - -import jakarta.servlet.http.HttpServletRequest -import jakarta.servlet.http.HttpServletResponse -import org.springframework.http.ResponseEntity -import org.springframework.web.bind.annotation.PostMapping -import org.springframework.web.bind.annotation.RestController -import org.tenten.bittakotlin.security.service.ReissueService - - -@RestController -class ReissueController(private val reissueService: ReissueService) { - - @PostMapping("/api/member/reissue") - fun reissue(request: HttpServletRequest, response: HttpServletResponse): ResponseEntity<*> { - return reissueService.reissueTokens(request, response) - } -} \ No newline at end of file diff --git a/src/main/kotlin/org/tenten/bittakotlin/security/controller/TokenController.kt b/src/main/kotlin/org/tenten/bittakotlin/security/controller/TokenController.kt new file mode 100644 index 0000000..5949ee0 --- /dev/null +++ b/src/main/kotlin/org/tenten/bittakotlin/security/controller/TokenController.kt @@ -0,0 +1,36 @@ +package org.tenten.bittakotlin.security.controller + +import jakarta.servlet.http.Cookie +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import org.tenten.bittakotlin.security.service.TokenService + +@RestController +@RequestMapping("/api/v1/token") +class TokenController( + private val tokenService: TokenService +) { + @PostMapping + fun reissue(request: HttpServletRequest, response: HttpServletResponse): ResponseEntity> { + val refreshToken = request.getHeader("Authority") + + val headers = HttpHeaders().apply { + add(HttpHeaders.SET_COOKIE, getCookieString("accessToken", tokenService.reissue(refreshToken) + , 3600)) + } + + val body = mapOf("message" to "토큰을 재발급했습니다.") + + return ResponseEntity(body, headers, HttpStatus.OK) + } + + private fun getCookieString(name: String, token: String?, ageMax: Int): String { + return "$name=$token; Path=/; Max-Age=$ageMax; HttpOnly;" + } +} \ No newline at end of file diff --git a/src/main/kotlin/org/tenten/bittakotlin/security/dto/TokenRequestDto.kt b/src/main/kotlin/org/tenten/bittakotlin/security/dto/TokenRequestDto.kt new file mode 100644 index 0000000..523074c --- /dev/null +++ b/src/main/kotlin/org/tenten/bittakotlin/security/dto/TokenRequestDto.kt @@ -0,0 +1,9 @@ +package org.tenten.bittakotlin.security.dto + +class TokenRequestDto { + data class Create( + val username: String, + + val role: String + ) +} diff --git a/src/main/kotlin/org/tenten/bittakotlin/security/dto/TokenResponseDto.kt b/src/main/kotlin/org/tenten/bittakotlin/security/dto/TokenResponseDto.kt new file mode 100644 index 0000000..4ee4e3b --- /dev/null +++ b/src/main/kotlin/org/tenten/bittakotlin/security/dto/TokenResponseDto.kt @@ -0,0 +1,8 @@ +package org.tenten.bittakotlin.security.dto + +class TokenResponseDto { + data class Create( + val accessToken: String, + val refreshToken: String + ) +} \ No newline at end of file diff --git a/src/main/kotlin/org/tenten/bittakotlin/security/entity/RefreshEntity.kt b/src/main/kotlin/org/tenten/bittakotlin/security/entity/RefreshEntity.kt index 5a1d54c..c6bd166 100644 --- a/src/main/kotlin/org/tenten/bittakotlin/security/entity/RefreshEntity.kt +++ b/src/main/kotlin/org/tenten/bittakotlin/security/entity/RefreshEntity.kt @@ -12,6 +12,8 @@ data class RefreshEntity( val id: Long? = null, val username: String, + val refresh: String, + val expiration: String ) \ No newline at end of file diff --git a/src/main/kotlin/org/tenten/bittakotlin/security/exception/TokenException.kt b/src/main/kotlin/org/tenten/bittakotlin/security/exception/TokenException.kt new file mode 100644 index 0000000..dc76be4 --- /dev/null +++ b/src/main/kotlin/org/tenten/bittakotlin/security/exception/TokenException.kt @@ -0,0 +1,14 @@ +package org.tenten.bittakotlin.security.exception + +import org.tenten.bittakotlin.security.constant.TokenError + +class TokenException ( + val code: Int, + + override val message: String +) : RuntimeException(message) { + constructor(tokenError: TokenError) : this( + code = tokenError.code, + message = tokenError.message + ) +} \ No newline at end of file diff --git a/src/main/kotlin/org/tenten/bittakotlin/security/filter/JwtAuthenticationFilter.kt b/src/main/kotlin/org/tenten/bittakotlin/security/filter/JwtAuthenticationFilter.kt new file mode 100644 index 0000000..728506d --- /dev/null +++ b/src/main/kotlin/org/tenten/bittakotlin/security/filter/JwtAuthenticationFilter.kt @@ -0,0 +1,84 @@ +package org.tenten.bittakotlin.security.filter + +import io.jsonwebtoken.ExpiredJwtException +import jakarta.servlet.FilterChain +import jakarta.servlet.ServletException +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.springframework.core.annotation.Order +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.Authentication +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.security.core.userdetails.UserDetails +import org.springframework.stereotype.Component +import org.springframework.util.AntPathMatcher +import org.springframework.web.filter.OncePerRequestFilter +import org.tenten.bittakotlin.security.service.CustomUserDetailsService +import org.tenten.bittakotlin.security.util.JwtTokenUtil +import java.io.IOException +import java.lang.Exception + +@Order(0) +@Component +class JwtAuthenticationFilter( + private val jwtTokenUtil: JwtTokenUtil, + + private val customUserDetailsService: CustomUserDetailsService +) : OncePerRequestFilter() { + private val publicUrls = arrayOf("/", "/api/v1/member/login", "/api/v1/member/join", "/api/v1/token", + "/swagger", "/swagger-ui.html", "/swagger-ui/**", "/api-docs", "/api-docs/**", "/v3/api-docs/**") + + private val pathMatcher = AntPathMatcher() + + @Throws(ServletException::class, IOException::class) + override fun doFilterInternal(request: HttpServletRequest, response: HttpServletResponse, filterChain: FilterChain) { + val requestUri = request.requestURI + + if (publicUrls.any { pathMatcher.match(it, requestUri) }) { + filterChain.doFilter(request, response) + return + } + + val accessToken: String? = request.getHeader("Authority") + + if (accessToken != null) { + try { + if (jwtTokenUtil.isExpired(accessToken)) { + response.status = HttpServletResponse.SC_UNAUTHORIZED + response.writer.print("Access token has expired.") + return + } + + val username: String = jwtTokenUtil.getUsername(accessToken) + val userDetails: UserDetails? = customUserDetailsService.loadUserByUsername(username) + + if (userDetails == null) { + response.status = HttpServletResponse.SC_UNAUTHORIZED + response.writer.print("No matching member exists.") + return + } + + logger.info("username=${userDetails.username}, authority${userDetails.authorities}") + + val authentication: Authentication = UsernamePasswordAuthenticationToken(userDetails, null, userDetails.authorities) + SecurityContextHolder.getContext().authentication = authentication + + logger.info("adsgassdgasdgasdg") + } catch (e: ExpiredJwtException) { + response.status = HttpServletResponse.SC_UNAUTHORIZED + response.writer.print("Access token has expired.") + return + } catch (e: Exception) { + response.status = HttpServletResponse.SC_UNAUTHORIZED + response.writer.print("Token authentication failed.") + return + } + } else { + response.status = HttpServletResponse.SC_UNAUTHORIZED + response.writer.print("The access token does not exist.") + return + } + + filterChain.doFilter(request, response) + } +} \ No newline at end of file diff --git a/src/main/kotlin/org/tenten/bittakotlin/security/jwt/CustomLogoutFilter.kt b/src/main/kotlin/org/tenten/bittakotlin/security/jwt/CustomLogoutFilter.kt deleted file mode 100644 index a483a22..0000000 --- a/src/main/kotlin/org/tenten/bittakotlin/security/jwt/CustomLogoutFilter.kt +++ /dev/null @@ -1,97 +0,0 @@ -package org.tenten.bittakotlin.security.jwt - -import io.jsonwebtoken.ExpiredJwtException -import jakarta.servlet.FilterChain -import jakarta.servlet.ServletException -import jakarta.servlet.ServletRequest -import jakarta.servlet.ServletResponse -import jakarta.servlet.http.Cookie -import jakarta.servlet.http.HttpServletRequest -import jakarta.servlet.http.HttpServletResponse -import org.aspectj.weaver.tools.cache.SimpleCacheFactory.path -import org.slf4j.LoggerFactory -import org.springframework.web.filter.GenericFilterBean -import org.tenten.bittakotlin.security.repository.RefreshRepository -import java.io.IOException - -class CustomLogoutFilter( - private val jwtUtil: JWTUtil, - private val refreshRepository: RefreshRepository -) : GenericFilterBean() { - - private val logger = LoggerFactory.getLogger(CustomLogoutFilter::class.java) - - @Throws(IOException::class, ServletException::class) - override fun doFilter(request: ServletRequest, response: ServletResponse, chain: FilterChain) { - doFilter(request as HttpServletRequest, response as HttpServletResponse, chain) - } - - @Throws(IOException::class, ServletException::class) - private fun doFilter(request: HttpServletRequest, response: HttpServletResponse, filterChain: FilterChain) { - val requestUri = request.requestURI - logger.info("Incoming request URI: $requestUri") - - if (!requestUri.matches("^/api/v1/member/logout$".toRegex())) { - logger.warn("Invalid logout request URI: $requestUri") - filterChain.doFilter(request, response) - return - } - - val requestMethod = request.method - if (requestMethod != "POST") { - logger.warn("Invalid request method: $requestMethod") - filterChain.doFilter(request, response) - return - } - - val refresh = request.cookies?.firstOrNull { it.name == "refresh" }?.value - if (refresh == null) { - logger.error("Refresh token is missing") - response.status = HttpServletResponse.SC_BAD_REQUEST - return - } - - try { - if (jwtUtil.isExpired(refresh)) { - logger.error("Refresh token is expired") - response.status = HttpServletResponse.SC_BAD_REQUEST - return - } - } catch (e: ExpiredJwtException) { - logger.error("ExpiredJwtException: ${e.message}") - response.status = HttpServletResponse.SC_BAD_REQUEST - return - } - - val category = jwtUtil.getCategory(refresh) - if (category != "refresh") { - logger.error("Invalid token category: $category") - response.status = HttpServletResponse.SC_BAD_REQUEST - return - } - - val isExist = refreshRepository.existsByRefresh(refresh) - if (!isExist) { - logger.error("Refresh token does not exist in the database") - response.status = HttpServletResponse.SC_BAD_REQUEST - return - } - - // Proceed with logout - refreshRepository.deleteByRefresh(refresh) - - response.addCookie(getDiedCookie("refresh")) - response.addCookie(getDiedCookie("nickname")) - response.addCookie(getDiedCookie("profileUrl")) - - logger.info("Successfully logged out and deleted refresh token") - response.status = HttpServletResponse.SC_OK - } - - private fun getDiedCookie(name: String): Cookie { - return Cookie(name, null).apply { - maxAge = 0 - path = "/" - } - } -} \ No newline at end of file diff --git a/src/main/kotlin/org/tenten/bittakotlin/security/jwt/JWTFilter.kt b/src/main/kotlin/org/tenten/bittakotlin/security/jwt/JWTFilter.kt deleted file mode 100644 index c62d706..0000000 --- a/src/main/kotlin/org/tenten/bittakotlin/security/jwt/JWTFilter.kt +++ /dev/null @@ -1,72 +0,0 @@ -import io.jsonwebtoken.ExpiredJwtException -import jakarta.servlet.FilterChain -import jakarta.servlet.ServletException -import jakarta.servlet.http.HttpServletRequest -import jakarta.servlet.http.HttpServletResponse -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken -import org.springframework.security.core.Authentication -import org.springframework.security.core.context.SecurityContextHolder -import org.springframework.web.filter.OncePerRequestFilter -import org.tenten.bittakotlin.member.entity.Member -import org.tenten.bittakotlin.member.repository.MemberRepository -import org.tenten.bittakotlin.security.dto.CustomUserDetails -import org.tenten.bittakotlin.security.jwt.JWTUtil -import java.io.IOException -import java.io.PrintWriter - -class JWTFilter(private val jwtUtil: JWTUtil) : OncePerRequestFilter() { - - @Throws(ServletException::class, IOException::class) - override fun doFilterInternal(request: HttpServletRequest, response: HttpServletResponse, filterChain: FilterChain) { - // 헤더에서 access키에 담긴 토큰을 꺼냄 - val accessToken = request.getHeader("access") - - // 토큰이 없다면 다음 필터로 넘김 - if (accessToken == null) { - filterChain.doFilter(request, response) - return - } - - // 토큰 만료 여부 확인, 만료시 다음 필터로 넘기지 않음 - try { - jwtUtil.isExpired(accessToken) - } catch (e: ExpiredJwtException) { - // response body - val writer: PrintWriter = response.writer - writer.print("access token expired") - - // response status code - response.status = HttpServletResponse.SC_UNAUTHORIZED - return - } - - // 토큰이 access인지 확인 (발급시 페이로드에 명시) - val category = jwtUtil.getCategory(accessToken) - - if (category != "access") { - // response body - val writer: PrintWriter = response.writer - writer.print("invalid access token") - - // response status code - response.status = HttpServletResponse.SC_UNAUTHORIZED - return - } - - // username, role 값을 획득 - val username = jwtUtil.getUsername(accessToken) - val role = jwtUtil.getRole(accessToken) - - val member = Member().apply { - this.username = username - this.role = role - } - - val customUserDetails = CustomUserDetails(member) - - val authToken = UsernamePasswordAuthenticationToken(customUserDetails, null, customUserDetails.authorities) - SecurityContextHolder.getContext().authentication = authToken - - filterChain.doFilter(request, response) - } -} \ No newline at end of file diff --git a/src/main/kotlin/org/tenten/bittakotlin/security/jwt/LoginFilter.kt b/src/main/kotlin/org/tenten/bittakotlin/security/jwt/LoginFilter.kt deleted file mode 100644 index e879260..0000000 --- a/src/main/kotlin/org/tenten/bittakotlin/security/jwt/LoginFilter.kt +++ /dev/null @@ -1,121 +0,0 @@ -package org.tenten.bittakotlin.security.jwt - -import com.fasterxml.jackson.databind.ObjectMapper -import jakarta.servlet.FilterChain -import jakarta.servlet.http.Cookie -import jakarta.servlet.http.HttpServletRequest -import jakarta.servlet.http.HttpServletResponse -import org.springframework.http.HttpStatus -import org.springframework.security.authentication.AuthenticationManager -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken -import org.springframework.security.core.Authentication -import org.springframework.security.core.AuthenticationException -import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter -import org.springframework.util.StreamUtils -import org.tenten.bittakotlin.member.dto.MemberRequestDTO -import org.tenten.bittakotlin.profile.entity.Profile -import org.tenten.bittakotlin.profile.service.ProfileService - -import org.tenten.bittakotlin.security.entity.RefreshEntity -import org.tenten.bittakotlin.security.repository.RefreshRepository -import java.io.IOException -import java.nio.charset.StandardCharsets -import java.util.* - -class LoginFilter( - private val authenticationManager: AuthenticationManager, - private val jwtUtil: JWTUtil, - private val refreshRepository: RefreshRepository, - private val profileService: ProfileService -) : UsernamePasswordAuthenticationFilter() { - - override fun attemptAuthentication(request: HttpServletRequest, response: HttpServletResponse): Authentication { - val loginDTO = try { - val objectMapper = ObjectMapper() - val inputStream = request.inputStream - val messageBody = StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8) - println(messageBody) - objectMapper.readValue(messageBody, MemberRequestDTO.Login::class.java) - } catch (e: IOException) { - throw RuntimeException(e) - } - - val username = loginDTO.username - val password = loginDTO.password - - println(username) - - val authToken = UsernamePasswordAuthenticationToken(username, password) - - return authenticationManager.authenticate(authToken) - } - - override fun successfulAuthentication( - request: HttpServletRequest, - response: HttpServletResponse, - chain: FilterChain, - authentication: Authentication - ) { - - // 유저 정보 - val username = authentication.name - val authorities = authentication.authorities - val role = authorities.iterator().next().authority - - // 토큰 생성 - val access = jwtUtil.createJwt("access", username, role, 600000L) - val refresh = jwtUtil.createJwt("refresh", username, role, 86400000L) - - // Refresh 토큰 저장 - addRefreshEntity(username, refresh, 86400000L) - - // 응답 설정 - response.setHeader("access", access) - response.addCookie(createCookie("refresh", refresh)) - - // 프로필 정보 설정 - val profile: Profile = profileService.getByPrincipal() - response.addCookie(createPublicCookie("nickname", profile.nickname)) - response.addCookie(createPublicCookie("profileUrl", profile.profileUrl!!)) - - response.status = HttpStatus.OK.value() - } - - override fun unsuccessfulAuthentication( - request: HttpServletRequest, - response: HttpServletResponse, - failed: AuthenticationException - ) { - response.status = 401 - } - - /**이 코드 손봐야할 수도 있음 */ - private fun addRefreshEntity(username: String, refresh: String, expiredMs: Long) { - val date = Date(System.currentTimeMillis() + expiredMs) - - val refreshEntity = RefreshEntity( - username = username, - refresh = refresh, - expiration = date.toString() - ) - - refreshRepository.save(refreshEntity) - } - - private fun createCookie(key: String, value: String): Cookie { - return Cookie(key, value).apply { - maxAge = 24 * 60 * 60 - // secure = true - path = "/" - isHttpOnly = true - } - } - - private fun createPublicCookie(key: String, value: String): Cookie { - return Cookie(key, value).apply { - maxAge = 24 * 60 * 60 - path = "/" - isHttpOnly = false - } - } -} \ No newline at end of file diff --git a/src/main/kotlin/org/tenten/bittakotlin/security/repository/RefreshRepository.kt b/src/main/kotlin/org/tenten/bittakotlin/security/repository/RefreshRepository.kt index d5a7628..b09824a 100644 --- a/src/main/kotlin/org/tenten/bittakotlin/security/repository/RefreshRepository.kt +++ b/src/main/kotlin/org/tenten/bittakotlin/security/repository/RefreshRepository.kt @@ -2,14 +2,18 @@ package org.tenten.bittakotlin.security.repository import jakarta.transaction.Transactional import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query import org.springframework.stereotype.Repository import org.tenten.bittakotlin.security.entity.RefreshEntity +import java.util.Optional @Repository interface RefreshRepository : JpaRepository { - fun existsByRefresh(refresh: String): Boolean @Transactional fun deleteByRefresh(refresh: String) + + @Query("SELECT r FROM RefreshEntity r WHERE r.refresh = :refresh") + fun findByRefresh(refresh: String): Optional } \ No newline at end of file diff --git a/src/main/kotlin/org/tenten/bittakotlin/security/service/ReissueService.kt b/src/main/kotlin/org/tenten/bittakotlin/security/service/ReissueService.kt deleted file mode 100644 index 69c9589..0000000 --- a/src/main/kotlin/org/tenten/bittakotlin/security/service/ReissueService.kt +++ /dev/null @@ -1,9 +0,0 @@ -package org.tenten.bittakotlin.security.service - -import jakarta.servlet.http.HttpServletRequest -import jakarta.servlet.http.HttpServletResponse -import org.springframework.http.ResponseEntity - -interface ReissueService { - fun reissueTokens(request: HttpServletRequest, response: HttpServletResponse): ResponseEntity<*> -} \ No newline at end of file diff --git a/src/main/kotlin/org/tenten/bittakotlin/security/service/ReissueServiceImpl.kt b/src/main/kotlin/org/tenten/bittakotlin/security/service/ReissueServiceImpl.kt deleted file mode 100644 index 27fd911..0000000 --- a/src/main/kotlin/org/tenten/bittakotlin/security/service/ReissueServiceImpl.kt +++ /dev/null @@ -1,84 +0,0 @@ -package org.tenten.bittakotlin.security.service - -import io.jsonwebtoken.ExpiredJwtException -import jakarta.servlet.http.Cookie -import jakarta.servlet.http.HttpServletRequest -import jakarta.servlet.http.HttpServletResponse -import org.springframework.http.HttpStatus -import org.springframework.http.ResponseEntity -import org.springframework.stereotype.Service -import org.tenten.bittakotlin.security.entity.RefreshEntity -import org.tenten.bittakotlin.security.jwt.JWTUtil -import org.tenten.bittakotlin.security.repository.RefreshRepository -import java.util.Date - -@Service -class ReissueServiceImpl( - private val jwtUtil: JWTUtil, - private val refreshRepository: RefreshRepository -) : ReissueService { - - override fun reissueTokens(request: HttpServletRequest, response: HttpServletResponse): ResponseEntity { - val refresh = extractRefreshToken(request) - if (refresh == null) { - return ResponseEntity("refresh token null", HttpStatus.BAD_REQUEST) - } - - try { - jwtUtil.isExpired(refresh) - } catch (e: ExpiredJwtException) { - return ResponseEntity("refresh token expired", HttpStatus.BAD_REQUEST) - } - - if (!isValidRefreshToken(refresh)) { - return ResponseEntity("invalid refresh token", HttpStatus.BAD_REQUEST) - } - - val username = jwtUtil.getUsername(refresh) - val role = jwtUtil.getRole(refresh) - - val newAccess = jwtUtil.createJwt("access", username, role, 600000L) - val newRefresh = jwtUtil.createJwt("refresh", username, role, 86400000L) - - updateRefreshToken(refresh, username, newRefresh) - setResponseTokens(response, newAccess, newRefresh) - - return ResponseEntity(HttpStatus.OK) - } - - private fun extractRefreshToken(request: HttpServletRequest): String? { - return request.cookies?.firstOrNull { it.name == "refresh" }?.value - } - - private fun isValidRefreshToken(refresh: String): Boolean { - val category = jwtUtil.getCategory(refresh) - return category == "refresh" && refreshRepository.existsByRefresh(refresh) - } - - private fun updateRefreshToken(oldRefresh: String, username: String, newRefresh: String) { - refreshRepository.deleteByRefresh(oldRefresh) - addRefreshEntity(username, newRefresh, 86400000L) - } - - private fun addRefreshEntity(username: String, refresh: String, expiredMs: Long) { - val date = Date(System.currentTimeMillis() + expiredMs) - val refreshEntity = RefreshEntity( - expiration = date.toString(), - refresh = refresh, - username = username - ) - refreshRepository.save(refreshEntity) - } - - private fun setResponseTokens(response: HttpServletResponse, access: String, refresh: String) { - response.setHeader("access", access) - response.addCookie(createCookie("refresh", refresh)) - } - - private fun createCookie(key: String, value: String): Cookie { - return Cookie(key, value).apply { - maxAge = 24 * 60 * 60 - isHttpOnly = true - } - } -} \ No newline at end of file diff --git a/src/main/kotlin/org/tenten/bittakotlin/security/service/TokenService.kt b/src/main/kotlin/org/tenten/bittakotlin/security/service/TokenService.kt new file mode 100644 index 0000000..0e006a8 --- /dev/null +++ b/src/main/kotlin/org/tenten/bittakotlin/security/service/TokenService.kt @@ -0,0 +1,10 @@ +package org.tenten.bittakotlin.security.service + +import org.tenten.bittakotlin.security.dto.TokenRequestDto +import org.tenten.bittakotlin.security.dto.TokenResponseDto + +interface TokenService { + fun create(requestDto: TokenRequestDto.Create): TokenResponseDto.Create + + fun reissue(refreshToken: String): String +} \ No newline at end of file diff --git a/src/main/kotlin/org/tenten/bittakotlin/security/service/TokenServiceImpl.kt b/src/main/kotlin/org/tenten/bittakotlin/security/service/TokenServiceImpl.kt new file mode 100644 index 0000000..5553493 --- /dev/null +++ b/src/main/kotlin/org/tenten/bittakotlin/security/service/TokenServiceImpl.kt @@ -0,0 +1,50 @@ +package org.tenten.bittakotlin.security.service + +import org.springframework.stereotype.Service +import org.tenten.bittakotlin.security.constant.TokenError +import org.tenten.bittakotlin.security.dto.TokenRequestDto +import org.tenten.bittakotlin.security.dto.TokenResponseDto +import org.tenten.bittakotlin.security.entity.RefreshEntity +import org.tenten.bittakotlin.security.exception.TokenException +import org.tenten.bittakotlin.security.repository.RefreshRepository +import org.tenten.bittakotlin.security.util.JwtTokenUtil +import java.util.Date + +@Service +class TokenServiceImpl( + private val refreshRepository: RefreshRepository, + + private val jwtTokenUtil: JwtTokenUtil +) : TokenService { + override fun create(requestDto: TokenRequestDto.Create): TokenResponseDto.Create { + val currentMills = System.currentTimeMillis() + + val sevenDaysAfter = Date(currentMills + 604800000) + + val accessToken = jwtTokenUtil.generateAccessToken(requestDto.username, requestDto.role, currentMills) + + val refreshToken = jwtTokenUtil.generateRefreshToken(currentMills) + + refreshRepository.save(RefreshEntity( + username = requestDto.username, + refresh = refreshToken, + expiration = sevenDaysAfter.toString() + )) + + return TokenResponseDto.Create( + accessToken = accessToken, + refreshToken = refreshToken + ) + } + + override fun reissue(refreshToken: String): String { + if (jwtTokenUtil.isExpired(refreshToken)) { + throw TokenException(TokenError.REFRESH_EXPIRED) + } + + val refresh = refreshRepository.findByRefresh(refreshToken) + .orElseThrow { TokenException(TokenError.REFRESH_NOT_FOUND) } + + return jwtTokenUtil.generateAccessToken(refresh.username, "ROLE_USER", System.currentTimeMillis()) + } +} \ No newline at end of file diff --git a/src/main/kotlin/org/tenten/bittakotlin/security/jwt/JWTUtil.kt b/src/main/kotlin/org/tenten/bittakotlin/security/util/JwtTokenUtil.kt similarity index 64% rename from src/main/kotlin/org/tenten/bittakotlin/security/jwt/JWTUtil.kt rename to src/main/kotlin/org/tenten/bittakotlin/security/util/JwtTokenUtil.kt index 581b374..439bbaa 100644 --- a/src/main/kotlin/org/tenten/bittakotlin/security/jwt/JWTUtil.kt +++ b/src/main/kotlin/org/tenten/bittakotlin/security/util/JwtTokenUtil.kt @@ -1,4 +1,4 @@ -package org.tenten.bittakotlin.security.jwt +package org.tenten.bittakotlin.security.util import io.jsonwebtoken.Jwts import org.springframework.beans.factory.annotation.Value @@ -8,58 +8,57 @@ import java.util.* import javax.crypto.SecretKey import javax.crypto.spec.SecretKeySpec - @Component -class JWTUtil(@Value("\${spring.jwt.secret}") secret: String) { +class JwtTokenUtil (@Value("\${spring.jwt.secret}") secret: String) { private val secretKey: SecretKey = SecretKeySpec( secret.toByteArray(StandardCharsets.UTF_8), Jwts.SIG.HS256.key().build().algorithm ) - fun getUsername(token: String): String { + fun isExpired(token: String): Boolean { return Jwts.parser() .verifyWith(secretKey) .build() .parseSignedClaims(token) .payload - .get("username", String::class.java) + .expiration + .before(Date()) } - fun getRole(token: String): String { + fun getUsername(token: String): String { return Jwts.parser() .verifyWith(secretKey) .build() .parseSignedClaims(token) .payload - .get("role", String::class.java) + .get("username", String::class.java) } - fun getCategory(token: String): String { + fun getRole(token: String): String { return Jwts.parser() .verifyWith(secretKey) .build() .parseSignedClaims(token) .payload - .get("category", String::class.java) + .get("role", String::class.java) } - fun isExpired(token: String): Boolean { - return Jwts.parser() - .verifyWith(secretKey) - .build() - .parseSignedClaims(token) - .payload - .expiration - .before(Date()) + fun generateAccessToken(username: String, role: String, currentMills: Long): String { + return Jwts.builder() + .claims(mapOf( + "username" to username, + "role" to role + )) + .issuedAt(Date(currentMills)) + .expiration(Date(currentMills + 3600000)) + .signWith(secretKey) + .compact() } - fun createJwt(category: String, username: String, role: String, expiredMs: Long): String { + fun generateRefreshToken(currentMills: Long): String { return Jwts.builder() - .claim("category", category) - .claim("username", username) - .claim("role", role) - .issuedAt(Date(System.currentTimeMillis())) - .expiration(Date(System.currentTimeMillis() + expiredMs)) + .issuedAt(Date(currentMills)) + .expiration(Date(currentMills + 604800000)) .signWith(secretKey) .compact() }