diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index b8e3de2..0ab0a89 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -2,7 +2,7 @@ name: Deploy to EC2 on: push: - branches: [ dev ] # dev 브랜치에 푸시될 때만 실행 + branches: [ dev ] jobs: deploy: diff --git a/src/main/java/pawparazzi/back/board/controller/BoardController.java b/src/main/java/pawparazzi/back/board/controller/BoardController.java index 22b8d89..176cf4b 100644 --- a/src/main/java/pawparazzi/back/board/controller/BoardController.java +++ b/src/main/java/pawparazzi/back/board/controller/BoardController.java @@ -1,18 +1,15 @@ package pawparazzi.back.board.controller; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; -import pawparazzi.back.board.dto.BoardCreateRequestDto; import pawparazzi.back.board.dto.BoardListResponseDto; import pawparazzi.back.board.dto.BoardDetailDto; -import pawparazzi.back.board.dto.BoardUpdateRequestDto; import pawparazzi.back.board.service.BoardService; -import pawparazzi.back.security.util.JwtUtil; +import pawparazzi.back.security.user.CustomUserDetails; import java.util.List; @@ -22,32 +19,21 @@ public class BoardController { private final BoardService boardService; - private final JwtUtil jwtUtil; - private final ObjectMapper objectMapper; /** * 게시물 등록 */ @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public ResponseEntity createBoard( - @RequestHeader("Authorization") String token, + @AuthenticationPrincipal CustomUserDetails userDetails, @RequestPart("userData") String userDataJson, @RequestPart(value = "mediaFiles", required = false) List mediaFiles, @RequestPart(value = "titleImage", required = false) MultipartFile titleImageFile, @RequestPart(value = "titleContent", required = false) String titleContent) { - Long memberId = jwtUtil.extractMemberId(token.replace("Bearer ", "")); + Long memberId = userDetails.getId(); - BoardCreateRequestDto requestDto; - try { - requestDto = objectMapper.readValue(userDataJson, BoardCreateRequestDto.class); - requestDto.setMediaFiles(mediaFiles); - requestDto.setTitleContent(titleContent); - } catch (JsonProcessingException e) { - return ResponseEntity.badRequest().body(null); - } - - BoardDetailDto response = boardService.createBoard(requestDto, memberId, titleImageFile); + BoardDetailDto response = boardService.createBoard(userDataJson, memberId, titleImageFile, mediaFiles, titleContent); return ResponseEntity.ok(response); } @@ -75,25 +61,16 @@ public ResponseEntity> getBoardList() { @PutMapping(value = "/{boardId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public ResponseEntity updateBoard( @PathVariable Long boardId, - @RequestHeader("Authorization") String token, + @AuthenticationPrincipal CustomUserDetails userDetails, @RequestPart("userData") String userDataJson, @RequestPart(value = "mediaFiles", required = false) List mediaFiles, @RequestPart(value = "titleImage", required = false) MultipartFile titleImageFile, @RequestPart(value = "titleContent", required = false) String titleContent) { - try { - Long memberId = jwtUtil.extractMemberId(token.replace("Bearer ", "")); - - BoardUpdateRequestDto requestDto = objectMapper.readValue(userDataJson, BoardUpdateRequestDto.class); - - requestDto.setTitleContent(titleContent); + Long memberId = userDetails.getId(); - BoardDetailDto updatedBoard = boardService.updateBoard(boardId, memberId, requestDto, mediaFiles, titleImageFile).join(); - - return ResponseEntity.ok(updatedBoard); - } catch (JsonProcessingException e) { - return ResponseEntity.badRequest().body(null); - } + BoardDetailDto updatedBoard = boardService.updateBoard(boardId, memberId, userDataJson, mediaFiles, titleImageFile, titleContent).join(); + return ResponseEntity.ok(updatedBoard); } /** @@ -109,11 +86,10 @@ public ResponseEntity> getBoardsByMember(@PathVariabl * 게시물 삭제 */ @DeleteMapping("/{boardId}") - public ResponseEntity deleteBoard(@PathVariable Long boardId, @RequestHeader("Authorization") String token) { - Long userId = jwtUtil.extractMemberId(token.replace("Bearer ", "")); + public ResponseEntity deleteBoard(@PathVariable Long boardId, @AuthenticationPrincipal CustomUserDetails userDetails) { + Long userId = userDetails.getId(); boardService.deleteBoard(boardId, userId).join(); - return ResponseEntity.noContent().build(); } } \ No newline at end of file diff --git a/src/main/java/pawparazzi/back/board/service/BoardService.java b/src/main/java/pawparazzi/back/board/service/BoardService.java index e91b861..eb1b09d 100644 --- a/src/main/java/pawparazzi/back/board/service/BoardService.java +++ b/src/main/java/pawparazzi/back/board/service/BoardService.java @@ -1,5 +1,7 @@ package pawparazzi.back.board.service; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.persistence.EntityNotFoundException; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -48,7 +50,16 @@ public class BoardService { * 게시물 등록 */ @Transactional - public BoardDetailDto createBoard(BoardCreateRequestDto requestDto, Long userId, MultipartFile titleImageFile) { + public BoardDetailDto createBoard(String userDataJson, Long userId, MultipartFile titleImageFile, List mediaFiles, String titleContent) { + BoardCreateRequestDto requestDto; + try { + requestDto = new ObjectMapper().readValue(userDataJson, BoardCreateRequestDto.class); + } catch (JsonProcessingException e) { + throw new IllegalArgumentException("Invalid JSON format", e); + } + requestDto.setMediaFiles(mediaFiles); + requestDto.setTitleContent(titleContent); + Member member = memberRepository.findById(userId) .orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다.")); @@ -131,10 +142,15 @@ private String getTitleImageUrl(MultipartFile titleImageFile, List uploa * 게시물 수정 */ @Transactional - public CompletableFuture updateBoard(Long boardId, Long userId, - BoardUpdateRequestDto requestDto, - List mediaFiles, - MultipartFile titleImageFile) { + public CompletableFuture updateBoard(Long boardId, Long userId, String userDataJson, List mediaFiles, MultipartFile titleImageFile, String titleContent) { + BoardUpdateRequestDto requestDto; + try { + requestDto = new ObjectMapper().readValue(userDataJson, BoardUpdateRequestDto.class); + } catch (JsonProcessingException e) { + throw new IllegalArgumentException("Invalid JSON format", e); + } + requestDto.setTitleContent(titleContent); + Board board = boardRepository.findById(boardId) .orElseThrow(() -> new EntityNotFoundException("게시물을 찾을 수 없습니다.")); diff --git a/src/main/java/pawparazzi/back/comment/controller/CommentController.java b/src/main/java/pawparazzi/back/comment/controller/CommentController.java index d4929bf..5a8ccc8 100644 --- a/src/main/java/pawparazzi/back/comment/controller/CommentController.java +++ b/src/main/java/pawparazzi/back/comment/controller/CommentController.java @@ -11,7 +11,9 @@ import pawparazzi.back.comment.dto.response.CommentListResponseDto; import pawparazzi.back.comment.service.CommentLikeService; import pawparazzi.back.comment.service.CommentService; -import pawparazzi.back.security.util.JwtUtil; +import pawparazzi.back.security.user.CustomUserDetails; +import org.springframework.security.core.annotation.AuthenticationPrincipal; + import java.util.Map; @@ -22,7 +24,6 @@ public class CommentController { private final CommentService commentService; private final CommentLikeService commentLikeService; - private final JwtUtil jwtUtil; /** * 댓글 작성 @@ -30,10 +31,10 @@ public class CommentController { @PostMapping("/{boardId}") public ResponseEntity createComment( @PathVariable Long boardId, - @RequestHeader("Authorization") String token, - @RequestBody @Valid CommentRequestDto requestDto) { // DTO 적용 + @AuthenticationPrincipal CustomUserDetails userDetails, + @RequestBody @Valid CommentRequestDto requestDto) { - Long memberId = jwtUtil.extractMemberId(token.replace("Bearer ", "")); + Long memberId = userDetails.getId(); CommentResponseDto response = commentService.createComment(boardId, memberId, requestDto); return ResponseEntity.ok(response); } @@ -44,10 +45,10 @@ public ResponseEntity createComment( @PutMapping("/{commentId}") public ResponseEntity updateComment( @PathVariable Long commentId, - @RequestHeader("Authorization") String token, + @AuthenticationPrincipal CustomUserDetails userDetails, @RequestBody Map request) { - Long memberId = jwtUtil.extractMemberId(token.replace("Bearer ", "")); + Long memberId = userDetails.getId(); String content = request.get("content"); CommentResponseDto response = commentService.updateComment(commentId, memberId, content); return ResponseEntity.ok(response); @@ -59,9 +60,9 @@ public ResponseEntity updateComment( @DeleteMapping("/{commentId}") public ResponseEntity> deleteComment( @PathVariable Long commentId, - @RequestHeader("Authorization") String token) { + @AuthenticationPrincipal CustomUserDetails userDetails) { - Long memberId = jwtUtil.extractMemberId(token.replace("Bearer ", "")); + Long memberId = userDetails.getId(); commentService.deleteComment(commentId, memberId); return ResponseEntity.ok(Map.of("message", "댓글이 삭제되었습니다.")); @@ -81,9 +82,9 @@ public ResponseEntity getComments(@PathVariable Long boa @PostMapping("/{commentId}/like") public ResponseEntity toggleCommentLike( @PathVariable Long commentId, - @RequestHeader("Authorization") String token) { + @AuthenticationPrincipal CustomUserDetails userDetails) { - Long memberId = jwtUtil.extractMemberId(token.replace("Bearer ", "")); + Long memberId = userDetails.getId(); CommentLikeResponseDto response = commentLikeService.toggleCommentLike(commentId, memberId); return ResponseEntity.ok(response); diff --git a/src/main/java/pawparazzi/back/comment/controller/ReplyController.java b/src/main/java/pawparazzi/back/comment/controller/ReplyController.java index 7514fa5..e50fe05 100644 --- a/src/main/java/pawparazzi/back/comment/controller/ReplyController.java +++ b/src/main/java/pawparazzi/back/comment/controller/ReplyController.java @@ -4,6 +4,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import pawparazzi.back.comment.dto.request.ReplyRequestDto; import pawparazzi.back.comment.dto.response.ReplyLikeResponseDto; import pawparazzi.back.comment.dto.response.ReplyLikesResponseDto; @@ -11,8 +12,7 @@ import pawparazzi.back.comment.dto.response.ReplyListResponseDto; import pawparazzi.back.comment.service.ReplyLikeService; import pawparazzi.back.comment.service.ReplyService; -import pawparazzi.back.security.util.JwtUtil; - +import pawparazzi.back.security.user.CustomUserDetails; import java.util.Map; @@ -23,7 +23,6 @@ public class ReplyController { private final ReplyService replyService; private final ReplyLikeService replyLikeService; - private final JwtUtil jwtUtil; /** * 대댓글 작성 @@ -31,10 +30,9 @@ public class ReplyController { @PostMapping("/{commentId}") public ResponseEntity createReply( @PathVariable Long commentId, - @RequestHeader("Authorization") String token, + @AuthenticationPrincipal CustomUserDetails userDetails, @RequestBody @Valid ReplyRequestDto requestDto) { - - Long memberId = jwtUtil.extractMemberId(token.replace("Bearer ", "")); + Long memberId = userDetails.getId(); ReplyResponseDto response = replyService.createReply(commentId, memberId, requestDto); return ResponseEntity.ok(response); } @@ -45,10 +43,9 @@ public ResponseEntity createReply( @PutMapping("/{replyId}") public ResponseEntity updateReply( @PathVariable Long replyId, - @RequestHeader("Authorization") String token, + @AuthenticationPrincipal CustomUserDetails userDetails, @RequestBody Map request) { - - Long memberId = jwtUtil.extractMemberId(token.replace("Bearer ", "")); + Long memberId = userDetails.getId(); String content = request.get("content"); ReplyResponseDto response = replyService.updateReply(replyId, memberId, content); return ResponseEntity.ok(response); @@ -60,11 +57,9 @@ public ResponseEntity updateReply( @DeleteMapping("/{replyId}") public ResponseEntity> deleteReply( @PathVariable Long replyId, - @RequestHeader("Authorization") String token) { - - Long memberId = jwtUtil.extractMemberId(token.replace("Bearer ", "")); + @AuthenticationPrincipal CustomUserDetails userDetails) { + Long memberId = userDetails.getId(); replyService.deleteReply(replyId, memberId); - return ResponseEntity.ok(Map.of("message", "대댓글이 삭제되었습니다.")); } @@ -76,18 +71,16 @@ public ResponseEntity getReplies(@PathVariable Long commen return ResponseEntity.ok(replyService.getRepliesByComment(commentId)); } - /** * 대댓글 좋아요 등록/삭제 (토글) */ @PostMapping("/{replyId}/like") public ResponseEntity toggleReplyLike( @PathVariable Long replyId, - @RequestHeader("Authorization") String token) { + @AuthenticationPrincipal CustomUserDetails userDetails) { - Long memberId = jwtUtil.extractMemberId(token.replace("Bearer ", "")); + Long memberId = userDetails.getId(); ReplyLikeResponseDto response = replyLikeService.toggleReplyLike(replyId, memberId); - return ResponseEntity.ok(response); } diff --git a/src/main/java/pawparazzi/back/comment/service/CommentLikeService.java b/src/main/java/pawparazzi/back/comment/service/CommentLikeService.java index b7be5f0..c2f8841 100644 --- a/src/main/java/pawparazzi/back/comment/service/CommentLikeService.java +++ b/src/main/java/pawparazzi/back/comment/service/CommentLikeService.java @@ -14,7 +14,6 @@ import pawparazzi.back.member.repository.MemberRepository; import java.util.List; -import java.util.Map; import java.util.stream.Collectors; @Service diff --git a/src/main/java/pawparazzi/back/follow/controller/FollowController.java b/src/main/java/pawparazzi/back/follow/controller/FollowController.java index 4dd2a33..ce5e7e3 100644 --- a/src/main/java/pawparazzi/back/follow/controller/FollowController.java +++ b/src/main/java/pawparazzi/back/follow/controller/FollowController.java @@ -1,6 +1,8 @@ package pawparazzi.back.follow.controller; +import io.jsonwebtoken.JwtException; import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import pawparazzi.back.follow.dto.FollowResponseDto; @@ -22,16 +24,24 @@ public class FollowController { public ResponseEntity follow( @PathVariable Long targetId, @RequestHeader("Authorization") String token){ - FollowResponseDto response = followService.follow(targetId, token); - return ResponseEntity.ok(response); + try { + FollowResponseDto response = followService.follow(targetId, token); + return ResponseEntity.ok(response); + } catch (JwtException e) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } } @DeleteMapping("/{targetId}") public ResponseEntity unfollow( @PathVariable Long targetId, @RequestHeader("Authorization") String token){ - UnfollowResponseDto response = followService.unfollow(targetId, token); - return ResponseEntity.ok(response); + try { + UnfollowResponseDto response = followService.unfollow(targetId, token); + return ResponseEntity.ok(response); + } catch (JwtException e) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } } @GetMapping("/followers/{targetId}") diff --git a/src/main/java/pawparazzi/back/likes/controller/LikeController.java b/src/main/java/pawparazzi/back/likes/controller/LikeController.java index c19c2d8..2afd27b 100644 --- a/src/main/java/pawparazzi/back/likes/controller/LikeController.java +++ b/src/main/java/pawparazzi/back/likes/controller/LikeController.java @@ -1,13 +1,15 @@ package pawparazzi.back.likes.controller; +import io.jsonwebtoken.JwtException; import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; import pawparazzi.back.likes.dto.LikeResponseDto; import pawparazzi.back.likes.dto.LikeToggleResponseDto; import pawparazzi.back.likes.service.LikeService; -import pawparazzi.back.security.util.JwtUtil; +import pawparazzi.back.security.user.CustomUserDetails; import java.util.List; import java.util.Map; @@ -18,7 +20,6 @@ public class LikeController { private final LikeService likeService; - private final JwtUtil jwtUtil; /** * 좋아요 등록/삭제 @@ -26,9 +27,9 @@ public class LikeController { @PostMapping("/{boardId}/like") public ResponseEntity toggleLike( @PathVariable Long boardId, - @RequestHeader("Authorization") String token) { + @AuthenticationPrincipal CustomUserDetails userDetails) { - Long memberId = jwtUtil.extractMemberId(token.replace("Bearer ", "")); + Long memberId = userDetails.getId(); LikeToggleResponseDto response = likeService.toggleLike(boardId, memberId); return ResponseEntity.ok(response); diff --git a/src/main/java/pawparazzi/back/member/controller/AuthController.java b/src/main/java/pawparazzi/back/member/controller/AuthController.java index 866b98e..6904e31 100644 --- a/src/main/java/pawparazzi/back/member/controller/AuthController.java +++ b/src/main/java/pawparazzi/back/member/controller/AuthController.java @@ -4,15 +4,17 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; -import pawparazzi.back.member.dto.JwtResponseDto; import pawparazzi.back.member.dto.KakaoUserDto; import pawparazzi.back.member.service.KakaoAuthService; import pawparazzi.back.member.service.MemberService; +import pawparazzi.back.member.entity.Member; import pawparazzi.back.security.util.JwtUtil; import java.net.URI; +import java.util.Map; @RestController @RequestMapping("/api/auth") @@ -50,19 +52,25 @@ public ResponseEntity redirectToKakaoLogin() { * 카카오 로그인 콜백 (인가 코드 → JWT 발급) */ @GetMapping("/kakao/callback") - public ResponseEntity kakaoLogin(@RequestParam String code) { + public ResponseEntity> kakaoLogin(@RequestParam String code) { try { String accessToken = kakaoAuthService.getAccessToken(code); KakaoUserDto kakaoUser = kakaoAuthService.getUserInfo(accessToken); - Long memberId = memberService.handleKakaoLogin(kakaoUser); - String jwtToken = jwtUtil.generateIdToken(memberId); + Member member = memberService.handleKakaoLogin(kakaoUser); - String frontendRedirectUrl = "http://localhost:8082/auth/success?token=" + jwtToken; + String jwtToken = jwtUtil.generateIdToken(member.getId()); + String refreshToken = memberService.generateOrUpdateRefreshToken(member); - HttpHeaders headers = new HttpHeaders(); - headers.setLocation(URI.create(frontendRedirectUrl)); - return ResponseEntity.status(302).headers(headers).build(); + Map tokenMap = Map.of( + "accessToken", jwtToken, + "refreshToken", refreshToken + ); + return ResponseEntity.ok(tokenMap); + + } catch (IllegalStateException e) { + log.error("카카오 인증 처리 중 오류 발생: {}", e.getMessage()); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); } catch (Exception e) { log.error("카카오 로그인 처리 중 오류 발생: {}", e.getMessage()); return ResponseEntity.internalServerError().build(); diff --git a/src/main/java/pawparazzi/back/member/controller/MemberController.java b/src/main/java/pawparazzi/back/member/controller/MemberController.java index c338e1a..99220df 100644 --- a/src/main/java/pawparazzi/back/member/controller/MemberController.java +++ b/src/main/java/pawparazzi/back/member/controller/MemberController.java @@ -2,14 +2,14 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; -import jakarta.servlet.http.HttpServletRequest; +import io.jsonwebtoken.JwtException; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; -import pawparazzi.back.S3.service.S3AsyncService; import pawparazzi.back.member.dto.request.LoginRequestDto; import pawparazzi.back.member.dto.request.SignUpRequestDto; import pawparazzi.back.member.dto.request.UpdateMemberRequestDto; @@ -19,7 +19,6 @@ import pawparazzi.back.member.service.MemberService; import pawparazzi.back.security.util.JwtUtil; -import java.io.IOException; import java.util.List; import java.util.Map; import java.util.concurrent.CompletableFuture; @@ -33,7 +32,6 @@ public class MemberController { private final MemberService memberService; private final ObjectMapper objectMapper; - /** * 회원 가입 */ @@ -60,8 +58,8 @@ public CompletableFuture> registerUser( */ @PostMapping("/login") public ResponseEntity> login(@Valid @RequestBody LoginRequestDto request) { - String token = memberService.login(request); - return ResponseEntity.ok(Map.of("token", token)); + Map tokenMap = memberService.login(request); + return ResponseEntity.ok(tokenMap); } /** @@ -70,7 +68,12 @@ public ResponseEntity> login(@Valid @RequestBody LoginReques @GetMapping("/me") public ResponseEntity getCurrentUser(@RequestHeader("Authorization") String token) { token = token.replace("Bearer ", ""); - Long memberId = jwtUtil.extractMemberId(token); + Long memberId; + try { + memberId = jwtUtil.extractMemberId(token); + } catch (JwtException e) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } Member member = memberService.findById(memberId); return ResponseEntity.ok(member); } @@ -85,7 +88,12 @@ public CompletableFuture> updateMember( @RequestPart(value = "userData", required = false) String userDataJson) { token = token.replace("Bearer ", ""); - Long memberId = jwtUtil.extractMemberId(token); + Long memberId; + try { + memberId = jwtUtil.extractMemberId(token); + } catch (JwtException e) { + return CompletableFuture.completedFuture(ResponseEntity.status(HttpStatus.UNAUTHORIZED).build()); + } try { UpdateMemberRequestDto request = (userDataJson == null || userDataJson.isBlank()) @@ -100,17 +108,21 @@ public CompletableFuture> updateMember( } /** - * 회원 탙퇴 + * 회원 탈퇴 */ @DeleteMapping("/delete") public ResponseEntity deleteMember(@RequestHeader("Authorization") String token) { token = token.replace("Bearer ", ""); - Long memberId = jwtUtil.extractMemberId(token); + Long memberId; + try { + memberId = jwtUtil.extractMemberId(token); + } catch (JwtException e) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } memberService.deleteMember(memberId); return ResponseEntity.ok("회원 탈퇴 완료"); } - /** * 전체 회원 목록 조회 API */ @@ -119,4 +131,29 @@ public ResponseEntity> getAllMembers() { List members = memberService.getAllMembers(); return ResponseEntity.ok(members); } + + /** + * 로그아웃 + */ + @PostMapping("/logout") + public ResponseEntity logout(@RequestHeader("Authorization") String accessToken, + @RequestBody Map body) { + String refreshToken = body.get("refreshToken"); + accessToken = accessToken.replace("Bearer ", ""); + Long memberId; + try { + memberId = jwtUtil.extractMemberId(accessToken); + } catch (JwtException e) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + memberService.logout(memberId, refreshToken); + return ResponseEntity.ok("로그아웃 완료"); + } + + @PostMapping("/reissue") + public ResponseEntity> reissue(@RequestBody Map request) { + String refreshToken = request.get("refreshToken"); + Map tokenMap = memberService.reissueAccessToken(refreshToken); + return ResponseEntity.ok(tokenMap); + } } \ No newline at end of file diff --git a/src/main/java/pawparazzi/back/member/entity/RefreshToken.java b/src/main/java/pawparazzi/back/member/entity/RefreshToken.java new file mode 100644 index 0000000..cfbb3f3 --- /dev/null +++ b/src/main/java/pawparazzi/back/member/entity/RefreshToken.java @@ -0,0 +1,29 @@ +package pawparazzi.back.member.entity; + +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +@Entity +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class RefreshToken { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false) + private Member member; + + @Column(nullable = false, length = 512) + private String token; + + @Column(nullable = false) + private LocalDateTime expiryDate; +} \ No newline at end of file diff --git a/src/main/java/pawparazzi/back/member/repository/RefreshTokenRepository.java b/src/main/java/pawparazzi/back/member/repository/RefreshTokenRepository.java new file mode 100644 index 0000000..d66fa3c --- /dev/null +++ b/src/main/java/pawparazzi/back/member/repository/RefreshTokenRepository.java @@ -0,0 +1,13 @@ +package pawparazzi.back.member.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import pawparazzi.back.member.entity.RefreshToken; +import pawparazzi.back.member.entity.Member; + +import java.util.Optional; + +public interface RefreshTokenRepository extends JpaRepository { + Optional findByMember(Member member); + Optional findByToken(String token); + void deleteByMember(Member member); +} \ No newline at end of file diff --git a/src/main/java/pawparazzi/back/member/service/MemberService.java b/src/main/java/pawparazzi/back/member/service/MemberService.java index 195ff1f..b76938b 100644 --- a/src/main/java/pawparazzi/back/member/service/MemberService.java +++ b/src/main/java/pawparazzi/back/member/service/MemberService.java @@ -19,16 +19,19 @@ import pawparazzi.back.member.dto.response.MemberResponseDto; import pawparazzi.back.member.dto.response.UpdateMemberResponseDto; import pawparazzi.back.member.entity.Member; +import pawparazzi.back.member.entity.RefreshToken; import pawparazzi.back.member.repository.MemberRepository; +import pawparazzi.back.member.repository.RefreshTokenRepository; import pawparazzi.back.security.util.JwtUtil; -import java.io.IOException; +import java.time.LocalDateTime; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.stream.Collectors; - +import java.time.temporal.ChronoUnit; @Service @RequiredArgsConstructor @@ -41,6 +44,7 @@ public class MemberService { private final BoardMongoRepository boardMongoRepository; private final S3AsyncService s3AsyncService; private final S3UploadUtil s3UploadUtil; + private final RefreshTokenRepository refreshTokenRepository; /** * 회원가입 @@ -72,19 +76,50 @@ public CompletableFuture registerUser(SignUpRequestDto request, MultipartF /** * 로그인 */ - public String login(LoginRequestDto request) { - Optional memberOptional = memberRepository.findByEmail(request.getEmail()); - if (memberOptional.isEmpty()) { + public Map login(LoginRequestDto request) { + Member member = memberRepository.findByEmail(request.getEmail()) + .orElseThrow(() -> new BadCredentialsException("이메일 또는 비밀번호가 잘못되었습니다.")); + + if (!passwordEncoder.matches(request.getPassword(), member.getPassword())) { throw new BadCredentialsException("이메일 또는 비밀번호가 잘못되었습니다."); } - Member member = memberOptional.get(); + String accessToken = jwtUtil.generateIdToken(member.getId()); + String refreshToken = generateOrUpdateRefreshToken(member); - if (!passwordEncoder.matches(request.getPassword(), member.getPassword())) { - throw new BadCredentialsException("이메일 또는 비밀번호가 잘못되었습니다."); + return Map.of( + "accessToken", accessToken, + "refreshToken", refreshToken + ); + } + + public String generateOrUpdateRefreshToken(Member member) { + Optional existingTokenOpt = refreshTokenRepository.findByMember(member); + String refreshToken; + + if (existingTokenOpt.isPresent()) { + RefreshToken existingToken = existingTokenOpt.get(); + long remainingDays = ChronoUnit.DAYS.between(LocalDateTime.now(), existingToken.getExpiryDate()); + + if (remainingDays <= 1) { + refreshToken = jwtUtil.generateRefreshToken(); + existingToken.setToken(refreshToken); + existingToken.setExpiryDate(jwtUtil.getRefreshTokenExpiryDate()); + refreshTokenRepository.save(existingToken); + } else { + refreshToken = existingToken.getToken(); + } + } else { + refreshToken = jwtUtil.generateRefreshToken(); + RefreshToken newToken = RefreshToken.builder() + .member(member) + .token(refreshToken) + .expiryDate(jwtUtil.getRefreshTokenExpiryDate()) + .build(); + refreshTokenRepository.save(newToken); } - return jwtUtil.generateIdToken(member.getId()); + return refreshToken; } /** @@ -118,6 +153,17 @@ public CompletableFuture updateMember(Long memberId, Up String pathPrefix = "profile_images/" + member.getNickName(); String defaultImageUrl = "https://default-image-url.com/default-profile.png"; + + if (newProfileImage == null || newProfileImage.isEmpty()) { + return CompletableFuture.completedFuture(new UpdateMemberResponseDto( + member.getId(), + member.getEmail(), + member.getNickName(), + member.getName(), + member.getProfileImageUrl() + )); + } + String oldProfileImageUrl = member.getProfileImageUrl(); // 새로운 프로필 이미지 업로드 @@ -203,11 +249,11 @@ public List getAllMembers() { * 카카오 로그인 회원 처리 */ @Transactional - public Long handleKakaoLogin(KakaoUserDto kakaoUser) { + public Member handleKakaoLogin(KakaoUserDto kakaoUser) { Optional existingMember = memberRepository.findByEmail(kakaoUser.getEmail()); if (existingMember.isPresent()) { - return existingMember.get().getId(); + return existingMember.get(); } else { String randomPassword = passwordEncoder.encode(UUID.randomUUID().toString()); @@ -219,7 +265,56 @@ public Long handleKakaoLogin(KakaoUserDto kakaoUser) { kakaoUser.getNickname() ); memberRepository.save(newMember); - return newMember.getId(); + return newMember; + } + } + + /** + * 로그아웃 + */ + @Transactional + public void logout(Long memberId, String refreshToken) { + RefreshToken savedToken = refreshTokenRepository.findByToken(refreshToken) + .orElseThrow(() -> new IllegalArgumentException("이미 만료되었거나 존재하지 않는 토큰입니다.")); + + if (!savedToken.getMember().getId().equals(memberId)) { + throw new SecurityException("토큰의 사용자 정보가 일치하지 않습니다."); + } + + refreshTokenRepository.delete(savedToken); + } + + + @Transactional + public Map reissueAccessToken(String refreshToken) { + RefreshToken savedToken = refreshTokenRepository.findByToken(refreshToken) + .orElseThrow(() -> new IllegalArgumentException("유효하지 않은 리프레시 토큰입니다.")); + + if (savedToken.getExpiryDate().isBefore(LocalDateTime.now())) { + refreshTokenRepository.delete(savedToken); + throw new IllegalArgumentException("리프레시 토큰이 만료되었습니다. 다시 로그인 해주세요."); + } + + Member member = savedToken.getMember(); + String newAccessToken = jwtUtil.generateIdToken(member.getId()); + + long remainingDays = ChronoUnit.DAYS.between(LocalDateTime.now(), savedToken.getExpiryDate()); + + if (remainingDays <= 1) { + // RefreshToken 재발급 + String newRefreshToken = jwtUtil.generateRefreshToken(); + savedToken.setToken(newRefreshToken); + savedToken.setExpiryDate(jwtUtil.getRefreshTokenExpiryDate()); + refreshTokenRepository.save(savedToken); + + return Map.of( + "accessToken", newAccessToken, + "refreshToken", newRefreshToken + ); + } else { + return Map.of( + "accessToken", newAccessToken + ); } } } \ No newline at end of file diff --git a/src/main/java/pawparazzi/back/pet/controller/PetController.java b/src/main/java/pawparazzi/back/pet/controller/PetController.java index 8f52f10..d3a9888 100644 --- a/src/main/java/pawparazzi/back/pet/controller/PetController.java +++ b/src/main/java/pawparazzi/back/pet/controller/PetController.java @@ -2,7 +2,9 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; +import io.jsonwebtoken.JwtException; import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -35,7 +37,12 @@ public CompletableFuture> registerPet( @RequestPart("petData") String petDataJson, @RequestPart(value = "petImage", required = false) MultipartFile petImage) { - Long userId = jwtUtil.extractMemberId(token.replace("Bearer ", "")); + Long userId; + try { + userId = jwtUtil.extractMemberId(token.replace("Bearer ", "")); + } catch (JwtException e) { + return CompletableFuture.completedFuture(ResponseEntity.status(HttpStatus.UNAUTHORIZED).build()); + } PetRegisterRequestDto registerDto; try { @@ -53,7 +60,12 @@ public CompletableFuture> registerPet( */ @GetMapping("/all") public ResponseEntity> getAllPets(@RequestHeader("Authorization") String token) { - Long userId = jwtUtil.extractMemberId(token.replace("Bearer ", "")); + Long userId; + try { + userId = jwtUtil.extractMemberId(token.replace("Bearer ", "")); + } catch (JwtException e) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } List pets = petService.getPetsByMember(userId); return ResponseEntity.ok(pets); } @@ -63,7 +75,12 @@ public ResponseEntity> getAllPets(@RequestHeader("Authoriza */ @GetMapping("/{petId}") public ResponseEntity getPet(@PathVariable Long petId, @RequestHeader("Authorization") String token) { - Long userId = jwtUtil.extractMemberId(token.replace("Bearer ", "")); + Long userId; + try { + userId = jwtUtil.extractMemberId(token.replace("Bearer ", "")); + } catch (JwtException e) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } return ResponseEntity.ok(petService.getPetById(petId, userId)); } @@ -77,7 +94,12 @@ public CompletableFuture> updatePet( @RequestPart(value = "petData", required = false) String petDataJson, @RequestPart(value = "petImage", required = false) MultipartFile petImage) { - Long userId = jwtUtil.extractMemberId(token.replace("Bearer ", "")); + Long userId; + try { + userId = jwtUtil.extractMemberId(token.replace("Bearer ", "")); + } catch (JwtException e) { + return CompletableFuture.completedFuture(ResponseEntity.status(HttpStatus.UNAUTHORIZED).build()); + } PetUpdateDto updateDto; try { @@ -100,7 +122,12 @@ public CompletableFuture>> deletePet( @PathVariable Long petId, @RequestHeader("Authorization") String token) { - Long userId = jwtUtil.extractMemberId(token.replace("Bearer ", "")); + Long userId; + try { + userId = jwtUtil.extractMemberId(token.replace("Bearer ", "")); + } catch (JwtException e) { + return CompletableFuture.completedFuture(ResponseEntity.status(HttpStatus.UNAUTHORIZED).build()); + } return petService.deletePet(petId, userId) .thenApply(ignored -> ResponseEntity.ok(Map.of("message", "반려동물이 삭제되었습니다."))); diff --git a/src/main/java/pawparazzi/back/security/config/SecurityConfig.java b/src/main/java/pawparazzi/back/security/config/SecurityConfig.java index 74b9710..6da69d8 100644 --- a/src/main/java/pawparazzi/back/security/config/SecurityConfig.java +++ b/src/main/java/pawparazzi/back/security/config/SecurityConfig.java @@ -1,5 +1,7 @@ package pawparazzi.back.security.config; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -27,6 +29,7 @@ public class SecurityConfig { private final JwtUtil jwtUtil; private final CustomUserDetailsService userDetailsService; + private final ObjectMapper objectMapper; @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { @@ -55,7 +58,19 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .requestMatchers(HttpMethod.DELETE, "/api/replies/**").authenticated() .anyRequest().authenticated()) // 나머지는 인증 필요 - .addFilterBefore(new JwtAuthenticationFilter(jwtUtil, userDetailsService), + .exceptionHandling(exception -> exception + .authenticationEntryPoint((request, response, authException) -> { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType("application/json"); + response.getWriter().write("{\"status\":401, \"message\":\"Unauthorized\"}"); + }) + .accessDeniedHandler((request, response, accessDeniedException) -> { + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + response.setContentType("application/json"); + response.getWriter().write("{\"status\":403, \"message\":\"Forbidden\"}"); + }) + ) + .addFilterBefore(new JwtAuthenticationFilter(jwtUtil, userDetailsService, objectMapper), UsernamePasswordAuthenticationFilter.class); return http.build(); diff --git a/src/main/java/pawparazzi/back/security/filter/JwtAuthenticationFilter.java b/src/main/java/pawparazzi/back/security/filter/JwtAuthenticationFilter.java index 2b8f0e8..03ca077 100644 --- a/src/main/java/pawparazzi/back/security/filter/JwtAuthenticationFilter.java +++ b/src/main/java/pawparazzi/back/security/filter/JwtAuthenticationFilter.java @@ -1,5 +1,8 @@ package pawparazzi.back.security.filter; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.JwtException; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; @@ -13,12 +16,14 @@ import pawparazzi.back.security.token.JwtAuthenticationToken; import java.io.IOException; +import java.util.Map; @RequiredArgsConstructor public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtUtil jwtUtil; private final CustomUserDetailsService userDetailsService; + private final ObjectMapper objectMapper; @Override protected void doFilterInternal(HttpServletRequest request, @@ -28,16 +33,30 @@ protected void doFilterInternal(HttpServletRequest request, if (token != null && token.startsWith("Bearer ")) { token = token.substring(7); - - if (jwtUtil.validateToken(token)) { - Long memberId = jwtUtil.extractMemberId(token); - UserDetails userDetails = userDetailsService.loadUserById(memberId); - - JwtAuthenticationToken authentication = - new JwtAuthenticationToken(userDetails, token, userDetails.getAuthorities()); - SecurityContextHolder.getContext().setAuthentication(authentication); + try { + if (jwtUtil.validateToken(token)) { + Long memberId = jwtUtil.extractMemberId(token); + UserDetails userDetails = userDetailsService.loadUserById(memberId); + + JwtAuthenticationToken authentication = + new JwtAuthenticationToken(userDetails, token, userDetails.getAuthorities()); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + } catch (ExpiredJwtException e) { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType("application/json"); + objectMapper.writeValue(response.getWriter(), + Map.of("status", 401, "message", "Access Token Expired")); + return; + } catch (JwtException e) { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType("application/json"); + objectMapper.writeValue(response.getWriter(), + Map.of("status", 401, "message", "Invalid JWT")); + return; } } + chain.doFilter(request, response); } } \ No newline at end of file diff --git a/src/main/java/pawparazzi/back/security/user/CustomUserDetails.java b/src/main/java/pawparazzi/back/security/user/CustomUserDetails.java index 13c361f..8c1211d 100644 --- a/src/main/java/pawparazzi/back/security/user/CustomUserDetails.java +++ b/src/main/java/pawparazzi/back/security/user/CustomUserDetails.java @@ -13,6 +13,10 @@ public class CustomUserDetails implements UserDetails { private final Member member; + public Long getId() { + return member.getId(); + } + public CustomUserDetails(Member member) { this.member = member; } diff --git a/src/main/java/pawparazzi/back/security/util/JwtUtil.java b/src/main/java/pawparazzi/back/security/util/JwtUtil.java index 50ba695..c65b8b9 100644 --- a/src/main/java/pawparazzi/back/security/util/JwtUtil.java +++ b/src/main/java/pawparazzi/back/security/util/JwtUtil.java @@ -6,7 +6,9 @@ import org.springframework.stereotype.Component; import java.security.Key; +import java.time.LocalDateTime; import java.util.Date; +import java.util.UUID; @Component public class JwtUtil { @@ -30,14 +32,28 @@ public String generateIdToken(Long memberId) { .compact(); } + public String generateRefreshToken() { + return UUID.randomUUID().toString(); + } + + public LocalDateTime getRefreshTokenExpiryDate() { + return LocalDateTime.now().plusDays(7); // 7일 + } + // 토큰에서 사용자 ID 추출 public Long extractMemberId(String token) { - return Long.parseLong(Jwts.parserBuilder() - .setSigningKey(key) - .build() - .parseClaimsJws(token) - .getBody() - .getSubject()); + try { + return Long.parseLong(Jwts.parserBuilder() + .setSigningKey(key) + .build() + .parseClaimsJws(token) + .getBody() + .getSubject()); + } catch (ExpiredJwtException e) { + throw new JwtException("Access Token Expired", e); + } catch (JwtException | IllegalArgumentException e) { + throw new JwtException("Invalid JWT", e); + } } // 토큰 유효성 검증 diff --git a/src/main/java/pawparazzi/back/walk/controller/WalkController.java b/src/main/java/pawparazzi/back/walk/controller/WalkController.java index 4be6668..5782edd 100644 --- a/src/main/java/pawparazzi/back/walk/controller/WalkController.java +++ b/src/main/java/pawparazzi/back/walk/controller/WalkController.java @@ -1,5 +1,6 @@ package pawparazzi.back.walk.controller; +import io.jsonwebtoken.JwtException; import lombok.RequiredArgsConstructor; import org.springframework.format.annotation.DateTimeFormat; import org.springframework.http.HttpStatus; @@ -31,9 +32,13 @@ public ResponseEntity createWalk( @RequestBody WalkRequestDto requestDto, @RequestHeader("Authorization") String token ) { - Long userId = jwtUtil.extractMemberId(token.replace("Bearer ", "")); - WalkResponseDto responseDto = walkService.createWalk(requestDto, userId); - return ResponseEntity.status(HttpStatus.CREATED).body(responseDto); + try { + Long userId = jwtUtil.extractMemberId(token.replace("Bearer ", "")); + WalkResponseDto responseDto = walkService.createWalk(requestDto, userId); + return ResponseEntity.status(HttpStatus.CREATED).body(responseDto); + } catch (JwtException e) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } } //산책 기록 조회 (산책 기록 아이디로) @@ -41,10 +46,12 @@ public ResponseEntity createWalk( public ResponseEntity getWalk( @PathVariable Long walkId, @RequestHeader("Authorization") String token) { - try{ + try { Long userId = jwtUtil.extractMemberId(token.replace("Bearer ", "")); WalkResponseDto responseDto = walkService.getWalkById(walkId, userId); return ResponseEntity.ok(responseDto); + } catch (JwtException e) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); } catch (NoSuchElementException e) { return ResponseEntity.notFound().build(); } @@ -55,10 +62,12 @@ public ResponseEntity getWalk( public ResponseEntity deleteWalk( @PathVariable Long walkId, @RequestHeader("Authorization") String token) { - try{ + try { Long userId = jwtUtil.extractMemberId(token.replace("Bearer ", "")); walkService.deleteWalk(walkId, userId); return ResponseEntity.noContent().build(); + } catch (JwtException e) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); } catch (NoSuchElementException e) { return ResponseEntity.notFound().build(); } @@ -73,6 +82,8 @@ public ResponseEntity> getWalkByPet( Long userId = jwtUtil.extractMemberId(token.replace("Bearer ", "")); List walks = walkService.getWalksByPetId(petId, userId); return ResponseEntity.ok(walks); + } catch (JwtException e) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); } catch (NoSuchElementException e) { return ResponseEntity.notFound().build(); } @@ -83,10 +94,12 @@ public ResponseEntity> getWalkByPet( public ResponseEntity> getWalkByPetDate( @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime date, @RequestHeader("Authorization") String token){ - try{ + try { Long userId = jwtUtil.extractMemberId(token.replace("Bearer ", "")); List walks = walkService.getWalksByDate(date, userId); return ResponseEntity.ok(walks); + } catch (JwtException e) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); } catch (NoSuchElementException e) { return ResponseEntity.notFound().build(); } @@ -102,6 +115,8 @@ public ResponseEntity> getWalkByPetAndDate( Long userId = jwtUtil.extractMemberId(token.replace("Bearer ", "")); List walks = walkService.getWalksByPetIdAndDate(petId, date, userId); return ResponseEntity.ok(walks); + } catch (JwtException e) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); } catch (NoSuchElementException e) { return ResponseEntity.notFound().build(); }