From 49aad7412da9b669e912bdf33c54041bed05f721 Mon Sep 17 00:00:00 2001 From: jin2304 Date: Sat, 21 Mar 2026 16:41:14 +0900 Subject: [PATCH 01/22] =?UTF-8?q?feat:=20=EB=A0=88=EA=B1=B0=EC=8B=9C=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=A0=95=EB=A6=AC=20(=ED=8C=A8=ED=82=A4?= =?UTF-8?q?=EC=A7=80=20=EC=A3=BC=EC=84=9D=20=EC=B2=98=EB=A6=AC=20=EB=B0=8F?= =?UTF-8?q?=20=EB=A7=A4=ED=8D=BC=20=EB=B9=84=ED=99=9C=EC=84=B1=ED=99=94)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - board, comment, likes 관련 서비스, DAO, 도메인, DTO 클래스 패키지 전체 주석 처리 - MyBatis Mapper XML(board, comment, likes) 전체 비활성화 및 중첩 주석 오류 수정 --- .../board/controller/BoardController.java | 408 ++++++++---------- .../com/web/SearchWeb/board/dao/BoardDao.java | 4 +- .../SearchWeb/board/dao/MybatisBoardDao.java | 272 ++++++------ .../com/web/SearchWeb/board/domain/Board.java | 56 +-- .../com/web/SearchWeb/board/dto/BoardDto.java | 34 +- .../SearchWeb/board/service/BoardService.java | 272 ++++++------ .../controller/CommentApiController.java | 228 +++++----- .../web/SearchWeb/comment/dao/CommentDao.java | 52 +-- .../comment/dao/MybatisCommentDao.java | 138 +++--- .../web/SearchWeb/comment/domain/Comment.java | 44 +- .../web/SearchWeb/comment/dto/CommentDto.java | 26 +- .../dto/UpdateUserProfileCommentDto.java | 32 +- .../comment/service/CommentService.java | 220 +++++----- .../com/web/SearchWeb/likes/dao/LikesDao.java | 22 +- .../SearchWeb/likes/dao/MybatisLikesDao.java | 104 ++--- .../SearchWeb/likes/service/LikesService.java | 120 +++--- src/main/resources/mapper/board-mapper.xml | 44 +- src/main/resources/mapper/comment-mapper.xml | 24 +- src/main/resources/mapper/likes-mapper.xml | 14 +- 19 files changed, 1035 insertions(+), 1079 deletions(-) diff --git a/src/main/java/com/web/SearchWeb/board/controller/BoardController.java b/src/main/java/com/web/SearchWeb/board/controller/BoardController.java index b0ca477..11f64f1 100644 --- a/src/main/java/com/web/SearchWeb/board/controller/BoardController.java +++ b/src/main/java/com/web/SearchWeb/board/controller/BoardController.java @@ -1,218 +1,190 @@ -package com.web.SearchWeb.board.controller; - -import com.web.SearchWeb.aop.OwnerCheck; -import com.web.SearchWeb.board.domain.Board; -import com.web.SearchWeb.board.dto.BoardDto; -import com.web.SearchWeb.board.service.BoardService; -import com.web.SearchWeb.likes.service.LikesService; -import com.web.SearchWeb.bookmark.dto.BoardBookmarkCheckDto; -import com.web.SearchWeb.bookmark.dto.BookmarkDto; -import com.web.SearchWeb.bookmark.service.BookmarkService; -import com.web.SearchWeb.member.dto.CustomOAuth2User; -import com.web.SearchWeb.member.dto.CustomUserDetails; -import com.web.SearchWeb.member.service.MemberService; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.security.core.userdetails.UserDetails; -import org.springframework.security.oauth2.core.user.OAuth2User; -import org.springframework.stereotype.Controller; -import org.springframework.ui.Model; -import org.springframework.web.bind.annotation.*; - -import java.util.HashMap; -import java.util.Map; - -/** - * BoardController (Legacy - PostgreSQL) - * - * 게시판 및 게시글 관련 기능을 처리하는 컨트롤러 - */ -@Controller -public class BoardController { - - private final BoardService boardservice; - private final MemberService memberservice; - private final LikesService likesService; - private final BookmarkService bookmarkService; - - @Autowired - public BoardController(BoardService boardservice, MemberService memberservice, LikesService likesService, BookmarkService bookmarkService) { - this.boardservice = boardservice; - this.memberservice = memberservice; - this.likesService = likesService; - this.bookmarkService = bookmarkService; - } - - - - - /** - * 게시글 페이지 - */ - @GetMapping("/board") - public String boardPage() { - return "board/board"; // HTML 껍데기만 반환 (JS가 데이터 로딩) - } - - - /** - * 게시글 생성 - */ - @PostMapping("/board/{memberId}/post") - public String insertBoard(@PathVariable Long memberId, BoardDto boardDto){ - int result = boardservice.insertBoard(memberId, boardDto); - return "redirect:/board"; - } - - - /** - * 게시글 단일 조회 - */ - @GetMapping("/board/{boardId}") - public String boardDetail(@PathVariable Long boardId, @AuthenticationPrincipal Object currentUser, Model model){ - Map boardData = boardservice.selectBoard(boardId); - Board board = (Board) boardData.get("board"); - String[] hashtagsList = (String[]) boardData.get("hashtagsList"); - - model.addAttribute("board", board); - model.addAttribute("hashtagsList", hashtagsList); - - // 사용자가 로그인된 상태라면, 좋아요 여부를 확인하여 모델에 추가 - if (currentUser != null && !"anonymousUser".equals(currentUser)) { - Long memberId; - if(currentUser instanceof UserDetails) { - // 일반 로그인 사용자 처리 - memberId = ((CustomUserDetails) currentUser).getMemberId(); - } - else if(currentUser instanceof OAuth2User) { - // 소셜 로그인 사용자 처리 - memberId = ((CustomOAuth2User) currentUser).getMemberId(); - } else { - return "redirect:/error"; - } - - boolean isLiked = likesService.isLiked(boardId, memberId); - int isBookmarked = bookmarkService.isBookmarked(boardId, memberId); - model.addAttribute("isLiked", isLiked); - model.addAttribute("isBookmarked", isBookmarked); - } - - - return "board/boardDetail"; - } - - - /** - * 페이징된 게시글 목록 조회 - * - 검색어, 최신순/인기순, 게시글타입 - * - 스크롤 방식으로 페이징 지원 - * - 클라이언트(JS)에서 스크롤 이벤트 발생 시 요청 - */ - @ResponseBody - @GetMapping("api/boards") - public Map getBoards(@RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "10") int size, - @RequestParam(defaultValue = "newest") String sort, - @RequestParam(required = false) String query, - @RequestParam(defaultValue = "all") String postType) { - return boardservice.selectBoardPage(page, size, sort, query, postType); - } - - - /** - * 게시글 수정 - */ - @PostMapping("/board/{boardId}/update") - @OwnerCheck(idParam = "boardId", service = "boardService") - public String updateBoard(@PathVariable Long boardId, BoardDto boardDto){ - boardservice.updateBoard(boardId, boardDto); - return "redirect:/board/{boardId}"; - } - - - /** - * 게시글 삭제 - */ - @PostMapping("/board/{boardId}/delete") - @OwnerCheck(idParam = "boardId", service = "boardService") - public String deleteBoard(@PathVariable Long boardId) { - boardservice.deleteBoard(boardId); - return "redirect:/board"; - } - - - /** - * 게시글 좋아요 - */ - @PostMapping("/board/{boardId}/like") - @ResponseBody - public Map toggleLike(@PathVariable Long boardId, @AuthenticationPrincipal Object currentUser) { - - Map response = new HashMap<>(); - - // 로그인 되지 않은 경우 - if (currentUser == null || "anonymousUser".equals(currentUser)) { - response.put("error", true); - response.put("redirectUrl", "/login"); - return response; - } - - // 로그인 된 경우 - Long memberId; - if(currentUser instanceof UserDetails) { - // 일반 로그인 사용자 처리 - memberId = ((CustomUserDetails) currentUser).getMemberId(); - } - else if(currentUser instanceof OAuth2User) { - // 소셜 로그인 사용자 처리 - memberId = ((CustomOAuth2User) currentUser).getMemberId(); - } else { - response.put("error", true); - return response; - } - - boolean isLiked = likesService.toggleLike(boardId, memberId); - response.put("isLiked", isLiked); - return response; - } - - - /** - * 북마크 추가 (게시글에서 추가) - */ - @PostMapping(value ="/board/{boardId}/bookmark/{memberId}") - public ResponseEntity> toggleBookmark( - @PathVariable final Long boardId, - @PathVariable final Long memberId, - @RequestBody BookmarkDto bookmarkDto, - @AuthenticationPrincipal Object currentUser){ - Map response = new HashMap<>(); - - // 로그인 되지 않은 경우 - if (currentUser == null || "anonymousUser".equals(currentUser)) { - return ResponseEntity - .status(HttpStatus.UNAUTHORIZED) - .body(response); // 401 Unauthorized 응답 - } - - // 사용자가 이미 해당 게시글을 북마크했는지 확인 - BoardBookmarkCheckDto checkDto = new BoardBookmarkCheckDto(memberId, boardId); - int bookmarkExists = bookmarkService.checkBoardBookmark(checkDto); - - if (bookmarkExists == 0) { - // 북마크가 안 되어 있으면 북마크 추가 - bookmarkService.insertBookmarkForBoard(boardId, bookmarkDto); - boardservice.incrementBookmarkCount(boardId); // 북마크 추가 시 게시글의 북마크 수 증가 - response.put("action", "bookmarked"); - } else { - // 이미 북마크가 되어 있으면 북마크 해제 - bookmarkService.deleteBookmarkBoard(checkDto); - boardservice.decrementBookmarkCount(boardId); // 북마크 해제 시 게시글의 북마크 수 감소 - response.put("action", "unbookmarked"); - } - return ResponseEntity.ok(response); - } - -} +// package com.web.SearchWeb.board.controller; + +// import com.web.SearchWeb.aop.OwnerCheck; +// import com.web.SearchWeb.board.domain.Board; +// import com.web.SearchWeb.board.dto.BoardDto; +// import com.web.SearchWeb.board.service.BoardService; +// import com.web.SearchWeb.likes.service.LikesService; +// import com.web.SearchWeb.bookmark.dto.BoardBookmarkCheckDto; +// import com.web.SearchWeb.bookmark.dto.BookmarkDto; +// import com.web.SearchWeb.bookmark.service.BookmarkService; +// import com.web.SearchWeb.config.jwt.JwtMemberPrincipal; +// import com.web.SearchWeb.member.service.MemberService; +// import org.springframework.beans.factory.annotation.Autowired; +// import org.springframework.http.HttpStatus; +// import org.springframework.http.ResponseEntity; +// import org.springframework.security.core.annotation.AuthenticationPrincipal; +// import org.springframework.stereotype.Controller; +// import org.springframework.ui.Model; +// import org.springframework.web.bind.annotation.*; + +// import java.util.HashMap; +// import java.util.Map; + +// /** +// * BoardController (Legacy - PostgreSQL) +// * +// * 게시판 및 게시글 관련 기능을 처리하는 컨트롤러 +// */ +// @Controller +// public class BoardController { + +// private final BoardService boardservice; +// private final MemberService memberservice; +// private final LikesService likesService; +// private final BookmarkService bookmarkService; + +// @Autowired +// public BoardController(BoardService boardservice, MemberService memberservice, LikesService likesService, BookmarkService bookmarkService) { +// this.boardservice = boardservice; +// this.memberservice = memberservice; +// this.likesService = likesService; +// this.bookmarkService = bookmarkService; +// } + + + + +// /** +// * 게시글 페이지 +// */ +// @GetMapping("/board") +// public String boardPage() { +// return "board/board"; // HTML 껍데기만 반환 (JS가 데이터 로딩) +// } + + +// /** +// * 게시글 생성 +// */ +// @PostMapping("/board/{memberId}/post") +// public String insertBoard(@PathVariable Long memberId, BoardDto boardDto){ +// int result = boardservice.insertBoard(memberId, boardDto); +// return "redirect:/board"; +// } + + +// /** +// * 게시글 단일 조회 +// */ +// @GetMapping("/board/{boardId}") +// public String boardDetail(@PathVariable Long boardId, @AuthenticationPrincipal JwtMemberPrincipal principal, Model model){ +// Map boardData = boardservice.selectBoard(boardId); +// Board board = (Board) boardData.get("board"); +// String[] hashtagsList = (String[]) boardData.get("hashtagsList"); + +// model.addAttribute("board", board); +// model.addAttribute("hashtagsList", hashtagsList); + +// // 사용자가 로그인된 상태라면, 좋아요 여부를 확인하여 모델에 추가 +// if (principal != null) { +// Long memberId = principal.memberId(); +// boolean isLiked = likesService.isLiked(boardId, memberId); +// int isBookmarked = bookmarkService.isBookmarked(boardId, memberId); +// model.addAttribute("isLiked", isLiked); +// model.addAttribute("isBookmarked", isBookmarked); +// } + + +// return "board/boardDetail"; +// } + + +// /** +// * 페이징된 게시글 목록 조회 +// * - 검색어, 최신순/인기순, 게시글타입 +// * - 스크롤 방식으로 페이징 지원 +// * - 클라이언트(JS)에서 스크롤 이벤트 발생 시 요청 +// */ +// @ResponseBody +// @GetMapping("api/boards") +// public Map getBoards(@RequestParam(defaultValue = "0") int page, +// @RequestParam(defaultValue = "10") int size, +// @RequestParam(defaultValue = "newest") String sort, +// @RequestParam(required = false) String query, +// @RequestParam(defaultValue = "all") String postType) { +// return boardservice.selectBoardPage(page, size, sort, query, postType); +// } + + +// /** +// * 게시글 수정 +// */ +// @PostMapping("/board/{boardId}/update") +// @OwnerCheck(idParam = "boardId", service = "boardService") +// public String updateBoard(@PathVariable Long boardId, BoardDto boardDto){ +// boardservice.updateBoard(boardId, boardDto); +// return "redirect:/board/{boardId}"; +// } + + +// /** +// * 게시글 삭제 +// */ +// @PostMapping("/board/{boardId}/delete") +// @OwnerCheck(idParam = "boardId", service = "boardService") +// public String deleteBoard(@PathVariable Long boardId) { +// boardservice.deleteBoard(boardId); +// return "redirect:/board"; +// } + + +// /** +// * 게시글 좋아요 +// */ +// @PostMapping("/board/{boardId}/like") +// @ResponseBody +// public Map toggleLike(@PathVariable Long boardId, @AuthenticationPrincipal JwtMemberPrincipal principal) { + +// Map response = new HashMap<>(); + +// // 로그인 되지 않은 경우 +// if (principal == null) { +// response.put("error", true); +// response.put("redirectUrl", "/login"); +// return response; +// } + +// boolean isLiked = likesService.toggleLike(boardId, principal.memberId()); +// response.put("isLiked", isLiked); +// return response; +// } + + +// /** +// * 북마크 추가 (게시글에서 추가) +// */ +// @PostMapping(value ="/board/{boardId}/bookmark/{memberId}") +// public ResponseEntity> toggleBookmark( +// @PathVariable final Long boardId, +// @PathVariable final Long memberId, +// @RequestBody BookmarkDto bookmarkDto, +// @AuthenticationPrincipal JwtMemberPrincipal principal){ +// Map response = new HashMap<>(); + +// // 로그인 되지 않은 경우 +// if (principal == null) { +// return ResponseEntity +// .status(HttpStatus.UNAUTHORIZED) +// .body(response); // 401 Unauthorized 응답 +// } + +// // 사용자가 이미 해당 게시글을 북마크했는지 확인 +// BoardBookmarkCheckDto checkDto = new BoardBookmarkCheckDto(memberId, boardId); +// int bookmarkExists = bookmarkService.checkBoardBookmark(checkDto); + +// if (bookmarkExists == 0) { +// // 북마크가 안 되어 있으면 북마크 추가 +// bookmarkService.insertBookmarkForBoard(boardId, bookmarkDto); +// boardservice.incrementBookmarkCount(boardId); // 북마크 추가 시 게시글의 북마크 수 증가 +// response.put("action", "bookmarked"); +// } else { +// // 이미 북마크가 되어 있으면 북마크 해제 +// bookmarkService.deleteBookmarkBoard(checkDto); +// boardservice.decrementBookmarkCount(boardId); // 북마크 해제 시 게시글의 북마크 수 감소 +// response.put("action", "unbookmarked"); +// } +// return ResponseEntity.ok(response); +// } + +// } diff --git a/src/main/java/com/web/SearchWeb/board/dao/BoardDao.java b/src/main/java/com/web/SearchWeb/board/dao/BoardDao.java index b90cd40..16de068 100644 --- a/src/main/java/com/web/SearchWeb/board/dao/BoardDao.java +++ b/src/main/java/com/web/SearchWeb/board/dao/BoardDao.java @@ -1,3 +1,4 @@ +/* package com.web.SearchWeb.board.dao; import com.web.SearchWeb.board.domain.Board; @@ -53,4 +54,5 @@ List selectBoardPage(@Param("offset") int offset, //게시글 댓글 수 감소 int decrementCommentCount(Long boardId); -} \ No newline at end of file +} +*/ \ No newline at end of file diff --git a/src/main/java/com/web/SearchWeb/board/dao/MybatisBoardDao.java b/src/main/java/com/web/SearchWeb/board/dao/MybatisBoardDao.java index d094760..62b865a 100644 --- a/src/main/java/com/web/SearchWeb/board/dao/MybatisBoardDao.java +++ b/src/main/java/com/web/SearchWeb/board/dao/MybatisBoardDao.java @@ -1,139 +1,139 @@ -package com.web.SearchWeb.board.dao; - -import com.web.SearchWeb.board.domain.Board; -import com.web.SearchWeb.board.dto.BoardDto; -import org.apache.ibatis.session.SqlSession; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Repository; - -import java.util.List; - -@Repository -public class MybatisBoardDao implements BoardDao{ +// package com.web.SearchWeb.board.dao; + +// import com.web.SearchWeb.board.domain.Board; +// import com.web.SearchWeb.board.dto.BoardDto; +// import org.apache.ibatis.session.SqlSession; +// import org.springframework.beans.factory.annotation.Autowired; +// import org.springframework.stereotype.Repository; + +// import java.util.List; + +// @Repository +// public class MybatisBoardDao implements BoardDao{ - private final BoardDao mapper; - - @Autowired - public MybatisBoardDao(SqlSession sqlSession) { - //세션을 통해 mapper 컨테이너에서 mapper 객체를 꺼내 씀 - mapper = sqlSession.getMapper(BoardDao.class); - } +// private final BoardDao mapper; + +// @Autowired +// public MybatisBoardDao(SqlSession sqlSession) { +// //세션을 통해 mapper 컨테이너에서 mapper 객체를 꺼내 씀 +// mapper = sqlSession.getMapper(BoardDao.class); +// } - /** - * 게시글 생성 - */ - public int insertBoard(Long memberId, BoardDto boardDto) { - return mapper.insertBoard(memberId, boardDto); - } - - - /** - * 페이징된 게시글 목록 조회 - */ - @Override - public List selectBoardPage(int offset, int size, String sort, String query, String postType) { - return mapper.selectBoardPage(offset, size, sort, query, postType); - } - - /** - * 게시글 총 페이지 - */ - @Override - public int countBoardList(String query, String postType) { - return mapper.countBoardList(query, postType); - } - - - /** - * 게시글 목록 조회(회원번호로 조회) - */ - public List selectBoardListByMemberId(Long memberId) { - return mapper.selectBoardListByMemberId(memberId); - } - - - /** - * 게시글 단일 조회 - */ - public Board selectBoard(Long boardId) { - return mapper.selectBoard(boardId); - } - - - /** - * 게시글 수정 - */ - public int updateBoard(Long boardId, BoardDto boardDto){ - return mapper.updateBoard(boardId, boardDto); - } - - - /** - * 게시글 수정(회원정보 수정) - */ - public int updateBoardProfile(Long boardId, String job, String major){ - return mapper.updateBoardProfile(boardId, job, major); - } - - - /** - * 게시글 삭제 - */ - public int deleteBoard(Long boardId) { - return mapper.deleteBoard(boardId); - } - - - /** - * 게시글 북마크 수 수정 - */ - @Override - public int updateBookmarkCount(Long boardId, int bookmarkCount) { - return mapper.updateBookmarkCount(boardId, bookmarkCount); - } - - - /** - * 게시글 조회수 증가 - */ - public int incrementViewCount(Long boardId) { - return mapper.incrementViewCount(boardId); - } - - - /** - * 게시글 좋아요 증가 - */ - @Override - public int incrementLikeCount(Long boardId) { - return mapper.incrementLikeCount(boardId); - } - - - /** - * 게시글 좋아요 감소 - */ - @Override - public int decrementLikeCount(Long boardId) { - return mapper.decrementLikeCount(boardId); - } - - - /** - * 게시글 댓글 수 증가 - */ - @Override - public int incrementCommentCount(Long boardId) { - return mapper.incrementCommentCount(boardId); - } - - - /** - * 게시글 댓글 수 감소 - */ - @Override - public int decrementCommentCount(Long boardId) { - return mapper.decrementCommentCount(boardId); - } -} +// /** +// * 게시글 생성 +// */ +// public int insertBoard(Long memberId, BoardDto boardDto) { +// return mapper.insertBoard(memberId, boardDto); +// } + + +// /** +// * 페이징된 게시글 목록 조회 +// */ +// @Override +// public List selectBoardPage(int offset, int size, String sort, String query, String postType) { +// return mapper.selectBoardPage(offset, size, sort, query, postType); +// } + +// /** +// * 게시글 총 페이지 +// */ +// @Override +// public int countBoardList(String query, String postType) { +// return mapper.countBoardList(query, postType); +// } + + +// /** +// * 게시글 목록 조회(회원번호로 조회) +// */ +// public List selectBoardListByMemberId(Long memberId) { +// return mapper.selectBoardListByMemberId(memberId); +// } + + +// /** +// * 게시글 단일 조회 +// */ +// public Board selectBoard(Long boardId) { +// return mapper.selectBoard(boardId); +// } + + +// /** +// * 게시글 수정 +// */ +// public int updateBoard(Long boardId, BoardDto boardDto){ +// return mapper.updateBoard(boardId, boardDto); +// } + + +// /** +// * 게시글 수정(회원정보 수정) +// */ +// public int updateBoardProfile(Long boardId, String job, String major){ +// return mapper.updateBoardProfile(boardId, job, major); +// } + + +// /** +// * 게시글 삭제 +// */ +// public int deleteBoard(Long boardId) { +// return mapper.deleteBoard(boardId); +// } + + +// /** +// * 게시글 북마크 수 수정 +// */ +// @Override +// public int updateBookmarkCount(Long boardId, int bookmarkCount) { +// return mapper.updateBookmarkCount(boardId, bookmarkCount); +// } + + +// /** +// * 게시글 조회수 증가 +// */ +// public int incrementViewCount(Long boardId) { +// return mapper.incrementViewCount(boardId); +// } + + +// /** +// * 게시글 좋아요 증가 +// */ +// @Override +// public int incrementLikeCount(Long boardId) { +// return mapper.incrementLikeCount(boardId); +// } + + +// /** +// * 게시글 좋아요 감소 +// */ +// @Override +// public int decrementLikeCount(Long boardId) { +// return mapper.decrementLikeCount(boardId); +// } + + +// /** +// * 게시글 댓글 수 증가 +// */ +// @Override +// public int incrementCommentCount(Long boardId) { +// return mapper.incrementCommentCount(boardId); +// } + + +// /** +// * 게시글 댓글 수 감소 +// */ +// @Override +// public int decrementCommentCount(Long boardId) { +// return mapper.decrementCommentCount(boardId); +// } +// } diff --git a/src/main/java/com/web/SearchWeb/board/domain/Board.java b/src/main/java/com/web/SearchWeb/board/domain/Board.java index c536608..b83c6d3 100644 --- a/src/main/java/com/web/SearchWeb/board/domain/Board.java +++ b/src/main/java/com/web/SearchWeb/board/domain/Board.java @@ -1,30 +1,30 @@ -package com.web.SearchWeb.board.domain; +// package com.web.SearchWeb.board.domain; -import lombok.Getter; -import lombok.Setter; -import lombok.ToString; +// import lombok.Getter; +// import lombok.Setter; +// import lombok.ToString; -/** - * Board 도메인 (Legacy) - * - Member 테이블과 FK 관계 (member_id BIGINT) - */ -@Getter -@Setter -@ToString -public class Board { - private Long boardId; // 게시글 ID (PK) - private Long memberMemberId; // 작성자 ID (FK to member - BIGINT) - private String nickname; // 작성자 닉네임 - private String job; // 직업 - private String major; // 전공 - private String url; // 참조 URL (선택) - private String title; // 제목 - private String summary; // 요약 - private String description; // 본문 내용 - private String hashtags; // 해시태그 - private int likesCount; // 좋아요 수 - private int commentsCount; // 댓글 수 - private int bookmarksCount; // 북마크 수 - private int viewsCount; // 조회수 - private String createdDate; // 작성일 -} +// /** +// * Board 도메인 (Legacy) +// * - Member 테이블과 FK 관계 (member_id BIGINT) +// */ +// @Getter +// @Setter +// @ToString +// public class Board { +// private Long boardId; // 게시글 ID (PK) +// private Long memberMemberId; // 작성자 ID (FK to member - BIGINT) +// private String nickname; // 작성자 닉네임 +// private String job; // 직업 +// private String major; // 전공 +// private String url; // 참조 URL (선택) +// private String title; // 제목 +// private String summary; // 요약 +// private String description; // 본문 내용 +// private String hashtags; // 해시태그 +// private int likesCount; // 좋아요 수 +// private int commentsCount; // 댓글 수 +// private int bookmarksCount; // 북마크 수 +// private int viewsCount; // 조회수 +// private String createdDate; // 작성일 +// } diff --git a/src/main/java/com/web/SearchWeb/board/dto/BoardDto.java b/src/main/java/com/web/SearchWeb/board/dto/BoardDto.java index a16959b..d73ca37 100644 --- a/src/main/java/com/web/SearchWeb/board/dto/BoardDto.java +++ b/src/main/java/com/web/SearchWeb/board/dto/BoardDto.java @@ -1,19 +1,19 @@ -package com.web.SearchWeb.board.dto; +// package com.web.SearchWeb.board.dto; -import lombok.Getter; -import lombok.Setter; -import lombok.ToString; +// import lombok.Getter; +// import lombok.Setter; +// import lombok.ToString; -@Getter -@Setter -@ToString -public class BoardDto { - private String nickname; // 작성자 닉네임 - private String job; // 직업 - private String major; // 전공 - private String url; // 참조 URL - private String title; // 제목 - private String summary; // 요약 - private String description; // 본문 내용 - private String hashtags; // 해시태그 -} +// @Getter +// @Setter +// @ToString +// public class BoardDto { +// private String nickname; // 작성자 닉네임 +// private String job; // 직업 +// private String major; // 전공 +// private String url; // 참조 URL +// private String title; // 제목 +// private String summary; // 요약 +// private String description; // 본문 내용 +// private String hashtags; // 해시태그 +// } diff --git a/src/main/java/com/web/SearchWeb/board/service/BoardService.java b/src/main/java/com/web/SearchWeb/board/service/BoardService.java index 8885f01..e15d0b5 100644 --- a/src/main/java/com/web/SearchWeb/board/service/BoardService.java +++ b/src/main/java/com/web/SearchWeb/board/service/BoardService.java @@ -1,137 +1,137 @@ -package com.web.SearchWeb.board.service; +// package com.web.SearchWeb.board.service; -import com.web.SearchWeb.board.dao.BoardDao; -import com.web.SearchWeb.board.domain.Board; -import com.web.SearchWeb.board.dto.BoardDto; -import com.web.SearchWeb.comment.service.CommentService; -import com.web.SearchWeb.likes.service.LikesService; -import com.web.SearchWeb.member.service.MemberService; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Service; - -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -@Service -public class BoardService { - - private final BoardDao boardDao; - private final MemberService memberService; - private final LikesService likesService; - private final CommentService commentService; - - @Autowired - public BoardService(BoardDao boardDao, MemberService memberService, LikesService likesService, CommentService commentService) { - this.boardDao = boardDao; - this.memberService = memberService; - this.likesService = likesService; - this.commentService = commentService; - } - - - /** - * 게시글 생성 - */ - public int insertBoard(Long memberId, BoardDto boardDto) { - return boardDao.insertBoard(memberId, boardDto); - } - - - /** - * 페이징된 게시글 목록 조회 - */ - public Map selectBoardPage(int page, int size, String sort, String query, String postType) { - int offset = page * size; - int totalCount = boardDao.countBoardList(query, postType); - List boards = boardDao.selectBoardPage(offset, size, sort, query, postType); - - List hashtagsList = new ArrayList<>(); - for (Board board : boards) { - //해시태그 추가 - String[] hashtagsArray = board.getHashtags() != null ? board.getHashtags().split(" ") : new String[0]; - hashtagsList.add(hashtagsArray); - } - - Map result = new HashMap<>(); - result.put("boards", boards); - result.put("hashtagsList", hashtagsList); - result.put("hasNext", offset + size < totalCount); - return result; - } - - - - /** - * 게시글 단일 조회 - */ - public Map selectBoard(Long boardId) { - - // 조회수 증가 - boardDao.incrementViewCount(boardId); - - Board board = boardDao.selectBoard(boardId); // 단일 Board 객체를 가져옵니다. - - // 해시태그를 분리하여 리스트에 추가합니다. - String[] hashtagsList = board.getHashtags() != null ? board.getHashtags().split(" ") : new String[0]; - - Map result = new HashMap<>(); - result.put("board", board); - result.put("hashtagsList", hashtagsList); - - return result; - } - - - /** - * 게시글 수정 - */ - public int updateBoard(Long boardId, BoardDto boardDto){ - return boardDao.updateBoard(boardId, boardDto); - } - - - /** - * 게시글 삭제 - */ - public int deleteBoard(Long boardId) { - return boardDao.deleteBoard(boardId); - } - - - /** - * 게시글 북마크 수 증가 - */ - public void incrementBookmarkCount(Long boardId) { - Board board = boardDao.selectBoard(boardId); - if (board != null) { - boardDao.updateBookmarkCount(boardId, board.getBookmarksCount() + 1); - } else { - throw new IllegalArgumentException("Invalid board ID"); - } - } - - - /** - * 게시글 북마크 수 감소 - */ - public void decrementBookmarkCount(Long boardId) { - Board board = boardDao.selectBoard(boardId); - if (board != null) { - boardDao.updateBookmarkCount(boardId, board.getBookmarksCount() - 1); - } else { - throw new IllegalArgumentException("Invalid board ID"); - } - } - - - /** - * 게시글 소유자(작성자) 조회 - */ - public Long findMemberIdByBoardId(Long boardId) { - Board board = boardDao.selectBoard(boardId); - if (board == null) throw new IllegalArgumentException("게시글이 존재하지 않습니다."); - return board.getMemberMemberId(); - } -} +// import com.web.SearchWeb.board.dao.BoardDao; +// import com.web.SearchWeb.board.domain.Board; +// import com.web.SearchWeb.board.dto.BoardDto; +// import com.web.SearchWeb.comment.service.CommentService; +// import com.web.SearchWeb.likes.service.LikesService; +// import com.web.SearchWeb.member.service.MemberService; +// import org.springframework.beans.factory.annotation.Autowired; +// import org.springframework.stereotype.Service; + +// import java.util.ArrayList; +// import java.util.HashMap; +// import java.util.List; +// import java.util.Map; + +// @Service +// public class BoardService { + +// private final BoardDao boardDao; +// private final MemberService memberService; +// private final LikesService likesService; +// private final CommentService commentService; + +// @Autowired +// public BoardService(BoardDao boardDao, MemberService memberService, LikesService likesService, CommentService commentService) { +// this.boardDao = boardDao; +// this.memberService = memberService; +// this.likesService = likesService; +// this.commentService = commentService; +// } + + +// /** +// * 게시글 생성 +// */ +// public int insertBoard(Long memberId, BoardDto boardDto) { +// return boardDao.insertBoard(memberId, boardDto); +// } + + +// /** +// * 페이징된 게시글 목록 조회 +// */ +// public Map selectBoardPage(int page, int size, String sort, String query, String postType) { +// int offset = page * size; +// int totalCount = boardDao.countBoardList(query, postType); +// List boards = boardDao.selectBoardPage(offset, size, sort, query, postType); + +// List hashtagsList = new ArrayList<>(); +// for (Board board : boards) { +// //해시태그 추가 +// String[] hashtagsArray = board.getHashtags() != null ? board.getHashtags().split(" ") : new String[0]; +// hashtagsList.add(hashtagsArray); +// } + +// Map result = new HashMap<>(); +// result.put("boards", boards); +// result.put("hashtagsList", hashtagsList); +// result.put("hasNext", offset + size < totalCount); +// return result; +// } + + + +// /** +// * 게시글 단일 조회 +// */ +// public Map selectBoard(Long boardId) { + +// // 조회수 증가 +// boardDao.incrementViewCount(boardId); + +// Board board = boardDao.selectBoard(boardId); // 단일 Board 객체를 가져옵니다. + +// // 해시태그를 분리하여 리스트에 추가합니다. +// String[] hashtagsList = board.getHashtags() != null ? board.getHashtags().split(" ") : new String[0]; + +// Map result = new HashMap<>(); +// result.put("board", board); +// result.put("hashtagsList", hashtagsList); + +// return result; +// } + + +// /** +// * 게시글 수정 +// */ +// public int updateBoard(Long boardId, BoardDto boardDto){ +// return boardDao.updateBoard(boardId, boardDto); +// } + + +// /** +// * 게시글 삭제 +// */ +// public int deleteBoard(Long boardId) { +// return boardDao.deleteBoard(boardId); +// } + + +// /** +// * 게시글 북마크 수 증가 +// */ +// public void incrementBookmarkCount(Long boardId) { +// Board board = boardDao.selectBoard(boardId); +// if (board != null) { +// boardDao.updateBookmarkCount(boardId, board.getBookmarksCount() + 1); +// } else { +// throw new IllegalArgumentException("Invalid board ID"); +// } +// } + + +// /** +// * 게시글 북마크 수 감소 +// */ +// public void decrementBookmarkCount(Long boardId) { +// Board board = boardDao.selectBoard(boardId); +// if (board != null) { +// boardDao.updateBookmarkCount(boardId, board.getBookmarksCount() - 1); +// } else { +// throw new IllegalArgumentException("Invalid board ID"); +// } +// } + + +// /** +// * 게시글 소유자(작성자) 조회 +// */ +// public Long findMemberIdByBoardId(Long boardId) { +// Board board = boardDao.selectBoard(boardId); +// if (board == null) throw new IllegalArgumentException("게시글이 존재하지 않습니다."); +// return board.getMemberMemberId(); +// } +// } diff --git a/src/main/java/com/web/SearchWeb/comment/controller/CommentApiController.java b/src/main/java/com/web/SearchWeb/comment/controller/CommentApiController.java index b209644..4433181 100644 --- a/src/main/java/com/web/SearchWeb/comment/controller/CommentApiController.java +++ b/src/main/java/com/web/SearchWeb/comment/controller/CommentApiController.java @@ -1,123 +1,105 @@ -package com.web.SearchWeb.comment.controller; - -import com.web.SearchWeb.aop.OwnerCheck; -import com.web.SearchWeb.comment.domain.Comment; -import com.web.SearchWeb.comment.dto.CommentDto; -import com.web.SearchWeb.comment.service.CommentService; -import com.web.SearchWeb.member.dto.CustomOAuth2User; -import com.web.SearchWeb.member.dto.CustomUserDetails; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.security.core.userdetails.UserDetails; -import org.springframework.security.oauth2.core.user.OAuth2User; -import org.springframework.ui.Model; -import org.springframework.web.bind.annotation.*; - -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -/** - * CommentApiController (Legacy - PostgreSQL) - */ -@RestController -public class CommentApiController { - - private final CommentService commentService; - - @Autowired - public CommentApiController(CommentService commentService) { - this.commentService = commentService; - } - - - - /** - * 게시글 댓글 생성 - */ - @PostMapping("board/{boardId}/comment") - public ResponseEntity> insertComment(@PathVariable Long boardId, - @AuthenticationPrincipal Object currentUser, - @RequestBody CommentDto commentDto){ - Map response = new HashMap<>(); - - // 로그인 되지 않은 경우 - if (currentUser == null || "anonymousUser".equals(currentUser)) { - return ResponseEntity - .status(HttpStatus.UNAUTHORIZED) - .body(response); // 401 Unauthorized 응답 - } - - // 로그인 된 경우 (loginId 사용) - String loginId; - if(currentUser instanceof UserDetails) { - // 일반 로그인 사용자 처리 - loginId = ((CustomUserDetails) currentUser).getUsername(); - } - else if(currentUser instanceof OAuth2User) { - // 소셜 로그인 사용자 처리 - loginId = ((CustomOAuth2User) currentUser).getLoginId(); - } else { - return ResponseEntity - .status(HttpStatus.FORBIDDEN) - .body(response); // 403 Forbidden 응답 - } - - commentService.insertComment(boardId, loginId, commentDto); - - response.put("success", true); - return ResponseEntity.ok(response); // 200 OK 응답 - } - - - /** - * 게시글 댓글 목록 조회 - */ - @GetMapping("board/{boardId}/comments") - public ResponseEntity> selectComments(@PathVariable Long boardId, Model model){ - List comments = commentService.selectComments(boardId); - return ResponseEntity.ok(comments); - } - - - /** - * 게시글 댓글 단일 조회 - */ - @GetMapping("board/{boardId}/comment/{commentId}") - @OwnerCheck(idParam = "commentId", service = "commentService") - public ResponseEntity selectComment(@PathVariable Long commentId){ - Comment comment = commentService.selectComment(commentId); - return ResponseEntity.ok(comment); - } - - - /** - * 게시글 댓글 수정 - */ - @PutMapping("board/{boardId}/comments/{commentId}") - @OwnerCheck(idParam = "commentId", service = "commentService") - public ResponseEntity> updateComment(@PathVariable Long boardId, - @PathVariable Long commentId, - @RequestBody CommentDto commentDto){ - Map response = new HashMap<>(); - commentService.updateComment(commentId, commentDto); - response.put("success", true); - return ResponseEntity.ok(response); // 200 OK 응답 - } - - - /** - * 게시글 댓글 삭제 - */ - @DeleteMapping("board/{boardId}/comments/{commentId}") - @OwnerCheck(idParam = "commentId", service = "commentService") - public ResponseEntity> deleteComment(@PathVariable Long boardId, - @PathVariable Long commentId){ - Map response = new HashMap<>(); - commentService.deleteComment(boardId, commentId); - response.put("success", true); - return ResponseEntity.ok(response); // 200 OK 응답 - } -} +// package com.web.SearchWeb.comment.controller; + +// import com.web.SearchWeb.aop.OwnerCheck; +// import com.web.SearchWeb.comment.domain.Comment; +// import com.web.SearchWeb.comment.dto.CommentDto; +// import com.web.SearchWeb.comment.service.CommentService; +// import com.web.SearchWeb.config.jwt.JwtMemberPrincipal; +// import org.springframework.beans.factory.annotation.Autowired; +// import org.springframework.http.HttpStatus; +// import org.springframework.http.ResponseEntity; +// import org.springframework.security.core.annotation.AuthenticationPrincipal; +// import org.springframework.ui.Model; +// import org.springframework.web.bind.annotation.*; + +// import java.util.HashMap; +// import java.util.List; +// import java.util.Map; + +// /** +// * CommentApiController (Legacy - PostgreSQL) +// */ +// @RestController +// public class CommentApiController { + +// private final CommentService commentService; + +// @Autowired +// public CommentApiController(CommentService commentService) { +// this.commentService = commentService; +// } + + + +// /** +// * 게시글 댓글 생성 +// */ +// @PostMapping("board/{boardId}/comment") +// public ResponseEntity> insertComment(@PathVariable Long boardId, +// @AuthenticationPrincipal JwtMemberPrincipal principal, +// @RequestBody CommentDto commentDto){ +// Map response = new HashMap<>(); + +// // 로그인 되지 않은 경우 +// if (principal == null) { +// return ResponseEntity +// .status(HttpStatus.UNAUTHORIZED) +// .body(response); // 401 Unauthorized 응답 +// } + +// commentService.insertComment(boardId, principal.memberId(), commentDto); + +// response.put("success", true); +// return ResponseEntity.ok(response); // 200 OK 응답 +// } + + +// /** +// * 게시글 댓글 목록 조회 +// */ +// @GetMapping("board/{boardId}/comments") +// public ResponseEntity> selectComments(@PathVariable Long boardId, Model model){ +// List comments = commentService.selectComments(boardId); +// return ResponseEntity.ok(comments); +// } + + +// /** +// * 게시글 댓글 단일 조회 +// */ +// @GetMapping("board/{boardId}/comment/{commentId}") +// @OwnerCheck(idParam = "commentId", service = "commentService") +// public ResponseEntity selectComment(@PathVariable Long commentId){ +// Comment comment = commentService.selectComment(commentId); +// return ResponseEntity.ok(comment); +// } + + +// /** +// * 게시글 댓글 수정 +// */ +// @PutMapping("board/{boardId}/comments/{commentId}") +// @OwnerCheck(idParam = "commentId", service = "commentService") +// public ResponseEntity> updateComment(@PathVariable Long boardId, +// @PathVariable Long commentId, +// @RequestBody CommentDto commentDto){ +// Map response = new HashMap<>(); +// commentService.updateComment(commentId, commentDto); +// response.put("success", true); +// return ResponseEntity.ok(response); // 200 OK 응답 +// } + + +// /** +// * 게시글 댓글 삭제 +// */ +// @DeleteMapping("board/{boardId}/comments/{commentId}") +// @OwnerCheck(idParam = "commentId", service = "commentService") +// public ResponseEntity> deleteComment(@PathVariable Long boardId, +// @PathVariable Long commentId){ +// Map response = new HashMap<>(); +// commentService.deleteComment(boardId, commentId); +// response.put("success", true); +// return ResponseEntity.ok(response); // 200 OK 응답 +// } +// } diff --git a/src/main/java/com/web/SearchWeb/comment/dao/CommentDao.java b/src/main/java/com/web/SearchWeb/comment/dao/CommentDao.java index ef5b28f..d9ce4fa 100644 --- a/src/main/java/com/web/SearchWeb/comment/dao/CommentDao.java +++ b/src/main/java/com/web/SearchWeb/comment/dao/CommentDao.java @@ -1,36 +1,36 @@ -package com.web.SearchWeb.comment.dao; +// package com.web.SearchWeb.comment.dao; -import com.web.SearchWeb.comment.domain.Comment; -import com.web.SearchWeb.comment.dto.CommentDto; -import com.web.SearchWeb.comment.dto.UpdateUserProfileCommentDto; +// import com.web.SearchWeb.comment.domain.Comment; +// import com.web.SearchWeb.comment.dto.CommentDto; +// import com.web.SearchWeb.comment.dto.UpdateUserProfileCommentDto; -import java.util.List; +// import java.util.List; -/** - * CommentDao Interface (Legacy - PostgreSQL) - */ -public interface CommentDao { - //게시글 댓글 생성 - int insertComment(Comment comment); +// /** +// * CommentDao Interface (Legacy - PostgreSQL) +// */ +// public interface CommentDao { +// //게시글 댓글 생성 +// int insertComment(Comment comment); - //게시글 댓글 목록 조회 - List selectComments(Long boardId); +// //게시글 댓글 목록 조회 +// List selectComments(Long boardId); - //회원번호로 게시글 댓글 목록 조회 (Long for member FK) - List selectCommentsByMemberId(Long memberId); +// //회원번호로 게시글 댓글 목록 조회 (Long for member FK) +// List selectCommentsByMemberId(Long memberId); - //게시글 댓글 단일 조회 - Comment selectComment(Long commentId); +// //게시글 댓글 단일 조회 +// Comment selectComment(Long commentId); - //게시글 댓글 수정 - int updateComment(Long commentId, CommentDto commentDto); +// //게시글 댓글 수정 +// int updateComment(Long commentId, CommentDto commentDto); - //게시글 댓글 사용자 프로필 수정 - int updateCommentUserProfile(UpdateUserProfileCommentDto commentDto); +// //게시글 댓글 사용자 프로필 수정 +// int updateCommentUserProfile(UpdateUserProfileCommentDto commentDto); - //게시글 댓글 삭제 - int deleteComment(Long commentId); +// //게시글 댓글 삭제 +// int deleteComment(Long commentId); - //게시글 댓글 수 조회 - int countComments(Long boardId); -} \ No newline at end of file +// //게시글 댓글 수 조회 +// int countComments(Long boardId); +// } \ No newline at end of file diff --git a/src/main/java/com/web/SearchWeb/comment/dao/MybatisCommentDao.java b/src/main/java/com/web/SearchWeb/comment/dao/MybatisCommentDao.java index 0094552..034cff3 100644 --- a/src/main/java/com/web/SearchWeb/comment/dao/MybatisCommentDao.java +++ b/src/main/java/com/web/SearchWeb/comment/dao/MybatisCommentDao.java @@ -1,91 +1,91 @@ -package com.web.SearchWeb.comment.dao; +// package com.web.SearchWeb.comment.dao; -import com.web.SearchWeb.comment.domain.Comment; -import com.web.SearchWeb.comment.dto.CommentDto; -import com.web.SearchWeb.comment.dto.UpdateUserProfileCommentDto; -import org.apache.ibatis.session.SqlSession; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Repository; +// import com.web.SearchWeb.comment.domain.Comment; +// import com.web.SearchWeb.comment.dto.CommentDto; +// import com.web.SearchWeb.comment.dto.UpdateUserProfileCommentDto; +// import org.apache.ibatis.session.SqlSession; +// import org.springframework.beans.factory.annotation.Autowired; +// import org.springframework.stereotype.Repository; -import java.util.List; +// import java.util.List; -/** - * MybatisCommentDao (Legacy - PostgreSQL) - */ -@Repository -public class MybatisCommentDao implements CommentDao{ +// /** +// * MybatisCommentDao (Legacy - PostgreSQL) +// */ +// @Repository +// public class MybatisCommentDao implements CommentDao{ - private final CommentDao mapper; +// private final CommentDao mapper; - @Autowired - public MybatisCommentDao(SqlSession sqlSession) { - //세션을 통해 mapper 컨테이너에서 mapper 객체를 꺼내 씀 - mapper = sqlSession.getMapper(CommentDao.class); - } +// @Autowired +// public MybatisCommentDao(SqlSession sqlSession) { +// //세션을 통해 mapper 컨테이너에서 mapper 객체를 꺼내 씀 +// mapper = sqlSession.getMapper(CommentDao.class); +// } - /** - * 게시글 댓글 생성 - */ - @Override - public int insertComment(Comment comment) { - return mapper.insertComment(comment); - } +// /** +// * 게시글 댓글 생성 +// */ +// @Override +// public int insertComment(Comment comment) { +// return mapper.insertComment(comment); +// } - /** - * 게시글 댓글 목록 조회 - */ - public List selectComments(Long boardId){ - return mapper.selectComments(boardId); - } +// /** +// * 게시글 댓글 목록 조회 +// */ +// public List selectComments(Long boardId){ +// return mapper.selectComments(boardId); +// } - /** - * 회원번호로 게시글 댓글 목록 조회 - */ - public List selectCommentsByMemberId(Long memberId){ - return mapper.selectCommentsByMemberId(memberId); - } +// /** +// * 회원번호로 게시글 댓글 목록 조회 +// */ +// public List selectCommentsByMemberId(Long memberId){ +// return mapper.selectCommentsByMemberId(memberId); +// } - /** - * 게시글 댓글 단일 조회 - */ - public Comment selectComment(Long commentId){ - return mapper.selectComment(commentId); - } +// /** +// * 게시글 댓글 단일 조회 +// */ +// public Comment selectComment(Long commentId){ +// return mapper.selectComment(commentId); +// } - /** - * 게시글 댓글 수정 - */ - public int updateComment(Long commentId, CommentDto commentDto) { - return mapper.updateComment(commentId, commentDto); - } +// /** +// * 게시글 댓글 수정 +// */ +// public int updateComment(Long commentId, CommentDto commentDto) { +// return mapper.updateComment(commentId, commentDto); +// } - /** - * 게시글 댓글 사용자 프로필 수정 - */ - public int updateCommentUserProfile(UpdateUserProfileCommentDto commentDto) { - return mapper.updateCommentUserProfile(commentDto); - } +// /** +// * 게시글 댓글 사용자 프로필 수정 +// */ +// public int updateCommentUserProfile(UpdateUserProfileCommentDto commentDto) { +// return mapper.updateCommentUserProfile(commentDto); +// } - /** - * 게시글 댓글 삭제 - */ - public int deleteComment(Long commentId){ - return mapper.deleteComment(commentId); - } +// /** +// * 게시글 댓글 삭제 +// */ +// public int deleteComment(Long commentId){ +// return mapper.deleteComment(commentId); +// } - /** - * 게시글 댓글 수 조회 - */ - public int countComments(Long boardId) { - return mapper.countComments(boardId); - } -} \ No newline at end of file +// /** +// * 게시글 댓글 수 조회 +// */ +// public int countComments(Long boardId) { +// return mapper.countComments(boardId); +// } +// } \ No newline at end of file diff --git a/src/main/java/com/web/SearchWeb/comment/domain/Comment.java b/src/main/java/com/web/SearchWeb/comment/domain/Comment.java index a8a6f8f..5329920 100644 --- a/src/main/java/com/web/SearchWeb/comment/domain/Comment.java +++ b/src/main/java/com/web/SearchWeb/comment/domain/Comment.java @@ -1,24 +1,24 @@ -package com.web.SearchWeb.comment.domain; +// package com.web.SearchWeb.comment.domain; -import lombok.Getter; -import lombok.Setter; -import lombok.ToString; +// import lombok.Getter; +// import lombok.Setter; +// import lombok.ToString; -/** - * Comment 도메인 (Legacy) - * - PostgreSQL comment 테이블과 매핑 - * - Member, Board 테이블과 FK 관계 - */ -@Getter -@Setter -@ToString -public class Comment { - private Long commentId; // comment_id (BIGINT) - private Long boardBoardId; // board_board_id (FK to board) - private Long memberMemberId; // member_member_id (FK to member - BIGINT) - private String memberNickname; // member_nickname - private String memberJob; // member_job - private String memberMajor; // member_major - private String content; // content - private String createdDate; // created_date -} +// /** +// * Comment 도메인 (Legacy) +// * - PostgreSQL comment 테이블과 매핑 +// * - Member, Board 테이블과 FK 관계 +// */ +// @Getter +// @Setter +// @ToString +// public class Comment { +// private Long commentId; // comment_id (BIGINT) +// private Long boardBoardId; // board_board_id (FK to board) +// private Long memberMemberId; // member_member_id (FK to member - BIGINT) +// private String memberNickname; // member_nickname +// private String memberJob; // member_job +// private String memberMajor; // member_major +// private String content; // content +// private String createdDate; // created_date +// } diff --git a/src/main/java/com/web/SearchWeb/comment/dto/CommentDto.java b/src/main/java/com/web/SearchWeb/comment/dto/CommentDto.java index 00e1b73..8dd89d4 100644 --- a/src/main/java/com/web/SearchWeb/comment/dto/CommentDto.java +++ b/src/main/java/com/web/SearchWeb/comment/dto/CommentDto.java @@ -1,15 +1,15 @@ -package com.web.SearchWeb.comment.dto; +// package com.web.SearchWeb.comment.dto; -import lombok.Getter; -import lombok.Setter; -import lombok.ToString; +// import lombok.Getter; +// import lombok.Setter; +// import lombok.ToString; -@Getter -@Setter -@ToString -public class CommentDto { - private Long board_boardId; - private Long member_memberId; - private String member_nickname; - private String content; -} +// @Getter +// @Setter +// @ToString +// public class CommentDto { +// private Long board_boardId; +// private Long member_memberId; +// private String member_nickname; +// private String content; +// } diff --git a/src/main/java/com/web/SearchWeb/comment/dto/UpdateUserProfileCommentDto.java b/src/main/java/com/web/SearchWeb/comment/dto/UpdateUserProfileCommentDto.java index 9e451ec..9836de3 100644 --- a/src/main/java/com/web/SearchWeb/comment/dto/UpdateUserProfileCommentDto.java +++ b/src/main/java/com/web/SearchWeb/comment/dto/UpdateUserProfileCommentDto.java @@ -1,19 +1,19 @@ -package com.web.SearchWeb.comment.dto; +// package com.web.SearchWeb.comment.dto; -/** - * 사용자 프로필 변경 시 댓글 정보 업데이트용 DTO - * - * PostgreSQL 호환 - Long commentId - */ -public record UpdateUserProfileCommentDto( - Long commentId, - String nickname, - String job, - String major -) { +// /** +// * 사용자 프로필 변경 시 댓글 정보 업데이트용 DTO +// * +// * PostgreSQL 호환 - Long commentId +// */ +// public record UpdateUserProfileCommentDto( +// Long commentId, +// String nickname, +// String job, +// String major +// ) { - public static UpdateUserProfileCommentDto of(Long commentId, String nickname, String job, String major) { - return new UpdateUserProfileCommentDto(commentId, nickname, job, major); - } +// public static UpdateUserProfileCommentDto of(Long commentId, String nickname, String job, String major) { +// return new UpdateUserProfileCommentDto(commentId, nickname, job, major); +// } -} +// } diff --git a/src/main/java/com/web/SearchWeb/comment/service/CommentService.java b/src/main/java/com/web/SearchWeb/comment/service/CommentService.java index dc4860d..9640f3a 100644 --- a/src/main/java/com/web/SearchWeb/comment/service/CommentService.java +++ b/src/main/java/com/web/SearchWeb/comment/service/CommentService.java @@ -1,110 +1,110 @@ -package com.web.SearchWeb.comment.service; - -import com.web.SearchWeb.board.dao.BoardDao; -import com.web.SearchWeb.comment.dao.CommentDao; -import com.web.SearchWeb.comment.domain.Comment; -import com.web.SearchWeb.comment.dto.CommentDto; -import com.web.SearchWeb.member.domain.Member; -import com.web.SearchWeb.member.service.MemberService; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.List; - -/** - * CommentService (Legacy - PostgreSQL) - */ -@Service -public class CommentService { - - private final CommentDao commentdao; - private final MemberService memberService; - private final BoardDao boardDao; - - @Autowired - public CommentService(CommentDao commentdao, MemberService memberService, BoardDao boardDao) { - this.commentdao = commentdao; - this.memberService = memberService; - this.boardDao = boardDao; - } - - - /** - * 게시글 댓글 생성 - */ - @Transactional - public int insertComment(Long boardId, String loginId, CommentDto commentDto){ - // 댓글 추가 - Member member = memberService.findByLoginId(loginId); - Comment comment = new Comment(); - comment.setBoardBoardId(boardId); - comment.setMemberMemberId(member.getMemberId()); - comment.setMemberNickname(member.getNickName()); - comment.setMemberJob(member.getJob()); - comment.setMemberMajor(member.getMajor()); - comment.setContent(commentDto.getContent()); - int result = commentdao.insertComment(comment); - - //게시글 댓글 수 증가 - boardDao.incrementCommentCount(boardId); - - return result; - } - - - /** - * 게시글 댓글 목록 조회 - */ - public List selectComments(Long boardId){ - return commentdao.selectComments(boardId); - } - - - /** - * 게시글 댓글 단일 조회 - */ - public Comment selectComment(Long commentId){ - return commentdao.selectComment(commentId); - } - - - /** - * 게시글 댓글 수정 - */ - public int updateComment(Long commentId, CommentDto commentDto){ - return commentdao.updateComment(commentId, commentDto); - } - - - /** - * 게시글 댓글 삭제 - */ - @Transactional - public int deleteComment(Long boardId, Long commentId){ - // 댓글 삭제 - int result = commentdao.deleteComment(commentId); - - //게시글 댓글 수 감소 - boardDao.decrementCommentCount(boardId); - return result; - } - - - /** - * 게시글 댓글 수 조회 - */ - public int getCommentCount(Long boardId) { - return commentdao.countComments(boardId); - } - - - /** - * 댓글 소유자(작성자) 조회 - */ - public Long findMemberIdByCommentId(Long commentId) { - Comment comment = commentdao.selectComment(commentId); - if (comment == null) throw new IllegalArgumentException("게시글이 존재하지 않습니다."); - return comment.getMemberMemberId(); - } -} +// package com.web.SearchWeb.comment.service; + +// import com.web.SearchWeb.board.dao.BoardDao; +// import com.web.SearchWeb.comment.dao.CommentDao; +// import com.web.SearchWeb.comment.domain.Comment; +// import com.web.SearchWeb.comment.dto.CommentDto; +// import com.web.SearchWeb.member.domain.Member; +// import com.web.SearchWeb.member.service.MemberService; +// import org.springframework.beans.factory.annotation.Autowired; +// import org.springframework.stereotype.Service; +// import org.springframework.transaction.annotation.Transactional; + +// import java.util.List; + +// /** +// * CommentService (Legacy - PostgreSQL) +// */ +// @Service +// public class CommentService { + +// private final CommentDao commentdao; +// private final MemberService memberService; +// private final BoardDao boardDao; + +// @Autowired +// public CommentService(CommentDao commentdao, MemberService memberService, BoardDao boardDao) { +// this.commentdao = commentdao; +// this.memberService = memberService; +// this.boardDao = boardDao; +// } + + +// /** +// * 게시글 댓글 생성 +// */ +// @Transactional +// public int insertComment(Long boardId, Long memberId, CommentDto commentDto){ +// // 댓글 추가 +// Member member = memberService.findByMemberId(memberId); +// Comment comment = new Comment(); +// comment.setBoardBoardId(boardId); +// comment.setMemberMemberId(memberId); +// comment.setMemberNickname(member.getNickName()); +// comment.setMemberJob(member.getJob()); +// comment.setMemberMajor(member.getMajor()); +// comment.setContent(commentDto.getContent()); +// int result = commentdao.insertComment(comment); + +// //게시글 댓글 수 증가 +// boardDao.incrementCommentCount(boardId); + +// return result; +// } + + +// /** +// * 게시글 댓글 목록 조회 +// */ +// public List selectComments(Long boardId){ +// return commentdao.selectComments(boardId); +// } + + +// /** +// * 게시글 댓글 단일 조회 +// */ +// public Comment selectComment(Long commentId){ +// return commentdao.selectComment(commentId); +// } + + +// /** +// * 게시글 댓글 수정 +// */ +// public int updateComment(Long commentId, CommentDto commentDto){ +// return commentdao.updateComment(commentId, commentDto); +// } + + +// /** +// * 게시글 댓글 삭제 +// */ +// @Transactional +// public int deleteComment(Long boardId, Long commentId){ +// // 댓글 삭제 +// int result = commentdao.deleteComment(commentId); + +// //게시글 댓글 수 감소 +// boardDao.decrementCommentCount(boardId); +// return result; +// } + + +// /** +// * 게시글 댓글 수 조회 +// */ +// public int getCommentCount(Long boardId) { +// return commentdao.countComments(boardId); +// } + + +// /** +// * 댓글 소유자(작성자) 조회 +// */ +// public Long findMemberIdByCommentId(Long commentId) { +// Comment comment = commentdao.selectComment(commentId); +// if (comment == null) throw new IllegalArgumentException("게시글이 존재하지 않습니다."); +// return comment.getMemberMemberId(); +// } +// } diff --git a/src/main/java/com/web/SearchWeb/likes/dao/LikesDao.java b/src/main/java/com/web/SearchWeb/likes/dao/LikesDao.java index 43677c8..fc8073c 100644 --- a/src/main/java/com/web/SearchWeb/likes/dao/LikesDao.java +++ b/src/main/java/com/web/SearchWeb/likes/dao/LikesDao.java @@ -1,15 +1,15 @@ -package com.web.SearchWeb.likes.dao; +// package com.web.SearchWeb.likes.dao; -public interface LikesDao { - // 게시글 좋아요 상태 확인 - Boolean isLikedByMember(Long boardId, Long memberId); +// public interface LikesDao { +// // 게시글 좋아요 상태 확인 +// Boolean isLikedByMember(Long boardId, Long memberId); - // 게시글 좋아요 추가 - int likeBoard(Long boardId, Long memberId); +// // 게시글 좋아요 추가 +// int likeBoard(Long boardId, Long memberId); - // 게시글 좋아요 취소 - int unlikeBoard(Long boardId, Long memberId); +// // 게시글 좋아요 취소 +// int unlikeBoard(Long boardId, Long memberId); - // 게시글 좋아요 수 조회 - int countLikes(Long boardId); -} +// // 게시글 좋아요 수 조회 +// int countLikes(Long boardId); +// } diff --git a/src/main/java/com/web/SearchWeb/likes/dao/MybatisLikesDao.java b/src/main/java/com/web/SearchWeb/likes/dao/MybatisLikesDao.java index 11bb941..89d0756 100644 --- a/src/main/java/com/web/SearchWeb/likes/dao/MybatisLikesDao.java +++ b/src/main/java/com/web/SearchWeb/likes/dao/MybatisLikesDao.java @@ -1,52 +1,52 @@ -package com.web.SearchWeb.likes.dao; - -import org.apache.ibatis.session.SqlSession; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Repository; - -@Repository -public class MybatisLikesDao implements LikesDao { - - private final LikesDao mapper; - - @Autowired - public MybatisLikesDao(SqlSession sqlSession) { - //세션을 통해 mapper 컨테이너에서 mapper 객체를 꺼내 씀 - mapper = sqlSession.getMapper(LikesDao.class); - } - - /** - * 게시글 좋아요 상태 확인 - */ - @Override - public Boolean isLikedByMember(Long boardId, Long memberId) { - return mapper.isLikedByMember(boardId, memberId); - } - - - /** - * 게시글 좋아요 추가 - */ - @Override - public int likeBoard(Long boardId, Long memberId) { - return mapper.likeBoard(boardId, memberId); - } - - - /** - * 게시글 좋아요 취소 - */ - @Override - public int unlikeBoard(Long boardId, Long memberId) { - return mapper.unlikeBoard(boardId, memberId); - } - - - /** - * 게시글 좋아요 수 조회 - */ - @Override - public int countLikes(Long boardId) { - return mapper.countLikes(boardId); - } -} \ No newline at end of file +// package com.web.SearchWeb.likes.dao; + +// import org.apache.ibatis.session.SqlSession; +// import org.springframework.beans.factory.annotation.Autowired; +// import org.springframework.stereotype.Repository; + +// @Repository +// public class MybatisLikesDao implements LikesDao { + +// private final LikesDao mapper; + +// @Autowired +// public MybatisLikesDao(SqlSession sqlSession) { +// //세션을 통해 mapper 컨테이너에서 mapper 객체를 꺼내 씀 +// mapper = sqlSession.getMapper(LikesDao.class); +// } + +// /** +// * 게시글 좋아요 상태 확인 +// */ +// @Override +// public Boolean isLikedByMember(Long boardId, Long memberId) { +// return mapper.isLikedByMember(boardId, memberId); +// } + + +// /** +// * 게시글 좋아요 추가 +// */ +// @Override +// public int likeBoard(Long boardId, Long memberId) { +// return mapper.likeBoard(boardId, memberId); +// } + + +// /** +// * 게시글 좋아요 취소 +// */ +// @Override +// public int unlikeBoard(Long boardId, Long memberId) { +// return mapper.unlikeBoard(boardId, memberId); +// } + + +// /** +// * 게시글 좋아요 수 조회 +// */ +// @Override +// public int countLikes(Long boardId) { +// return mapper.countLikes(boardId); +// } +// } \ No newline at end of file diff --git a/src/main/java/com/web/SearchWeb/likes/service/LikesService.java b/src/main/java/com/web/SearchWeb/likes/service/LikesService.java index bd49209..2961289 100644 --- a/src/main/java/com/web/SearchWeb/likes/service/LikesService.java +++ b/src/main/java/com/web/SearchWeb/likes/service/LikesService.java @@ -1,60 +1,60 @@ -package com.web.SearchWeb.likes.service; - -import com.web.SearchWeb.board.dao.BoardDao; -import com.web.SearchWeb.likes.dao.LikesDao; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -@Service -public class LikesService { - - private final LikesDao likesDao; - private final BoardDao boardDao; - - @Autowired - public LikesService(LikesDao likesDao, BoardDao boardDao) { - this.likesDao = likesDao; - this.boardDao = boardDao; - } - - - /** - * 게시글 좋아요 상태 확인 - */ - public boolean isLiked(Long boardId, Long memberId) { - Boolean isLiked = likesDao.isLikedByMember(boardId, memberId); - return Boolean.TRUE.equals(isLiked); - } - - - /** - * 게시글 좋아요 추가/취소 - */ - @Transactional - public boolean toggleLike(Long boardId, Long memberId) { - //게시글 좋아요 상태 확인 - Boolean isLiked = likesDao.isLikedByMember(boardId, memberId); - - if (isLiked == null || Boolean.FALSE.equals(isLiked)) { - // 좋아요가 안 되어 있다면, 좋아요 추가 - likesDao.likeBoard(boardId, memberId); - boardDao.incrementLikeCount(boardId); // likes_count + 1 - return true; - } else { - // 좋아요가 이미 되어 있다면, 좋아요 취소 - likesDao.unlikeBoard(boardId, memberId); - boardDao.decrementLikeCount(boardId); // likes_count - 1 - return false; - } - } - - - /** - * 게시글 좋아요 수 조회 - */ - public int getLikeCount(Long boardId) { - return likesDao.countLikes(boardId); - } - -} \ No newline at end of file +// package com.web.SearchWeb.likes.service; + +// import com.web.SearchWeb.board.dao.BoardDao; +// import com.web.SearchWeb.likes.dao.LikesDao; +// import org.springframework.beans.factory.annotation.Autowired; +// import org.springframework.stereotype.Service; +// import org.springframework.transaction.annotation.Transactional; + +// @Service +// public class LikesService { + +// private final LikesDao likesDao; +// private final BoardDao boardDao; + +// @Autowired +// public LikesService(LikesDao likesDao, BoardDao boardDao) { +// this.likesDao = likesDao; +// this.boardDao = boardDao; +// } + + +// /** +// * 게시글 좋아요 상태 확인 +// */ +// public boolean isLiked(Long boardId, Long memberId) { +// Boolean isLiked = likesDao.isLikedByMember(boardId, memberId); +// return Boolean.TRUE.equals(isLiked); +// } + + +// /** +// * 게시글 좋아요 추가/취소 +// */ +// @Transactional +// public boolean toggleLike(Long boardId, Long memberId) { +// //게시글 좋아요 상태 확인 +// Boolean isLiked = likesDao.isLikedByMember(boardId, memberId); + +// if (isLiked == null || Boolean.FALSE.equals(isLiked)) { +// // 좋아요가 안 되어 있다면, 좋아요 추가 +// likesDao.likeBoard(boardId, memberId); +// boardDao.incrementLikeCount(boardId); // likes_count + 1 +// return true; +// } else { +// // 좋아요가 이미 되어 있다면, 좋아요 취소 +// likesDao.unlikeBoard(boardId, memberId); +// boardDao.decrementLikeCount(boardId); // likes_count - 1 +// return false; +// } +// } + + +// /** +// * 게시글 좋아요 수 조회 +// */ +// public int getLikeCount(Long boardId) { +// return likesDao.countLikes(boardId); +// } + +// } \ No newline at end of file diff --git a/src/main/resources/mapper/board-mapper.xml b/src/main/resources/mapper/board-mapper.xml index 6ae6ec3..26c5c71 100644 --- a/src/main/resources/mapper/board-mapper.xml +++ b/src/main/resources/mapper/board-mapper.xml @@ -1,11 +1,11 @@ - + + - + @@ -25,14 +25,14 @@ - + INSERT INTO board (member_member_id, url, title, summary, description, hashtags) VALUES (#{memberId}, #{boardDto.url}, #{boardDto.title}, #{boardDto.summary}, #{boardDto.description}, #{boardDto.hashtags}) - + - + - + - + - + UPDATE board SET @@ -115,7 +115,7 @@ - + UPDATE board SET @@ -126,14 +126,14 @@ - + DELETE FROM board WHERE board_id = #{boardId} - + UPDATE board SET bookmarks_count = #{bookmarkCount} @@ -141,7 +141,7 @@ - + UPDATE board SET views_count = views_count + 1 @@ -149,7 +149,7 @@ - + UPDATE board SET likes_count = likes_count + 1 @@ -157,7 +157,7 @@ - + UPDATE board SET likes_count = likes_count - 1 @@ -165,15 +165,15 @@ - + UPDATE board SET comments_count = comments_count + 1 WHERE board_id = #{boardId} - + - + UPDATE board SET comments_count = comments_count - 1 @@ -181,4 +181,4 @@ - \ No newline at end of file + --> \ No newline at end of file diff --git a/src/main/resources/mapper/comment-mapper.xml b/src/main/resources/mapper/comment-mapper.xml index 05760f4..446bd5c 100644 --- a/src/main/resources/mapper/comment-mapper.xml +++ b/src/main/resources/mapper/comment-mapper.xml @@ -1,11 +1,11 @@ - + + - + @@ -18,14 +18,14 @@ - + INSERT INTO comment (board_board_id, member_member_id, member_nickname, member_job, member_major, content) VALUES (#{boardBoardId}, #{memberMemberId}, #{memberNickname}, #{memberJob}, #{memberMajor}, #{content}) - + - + - + - + UPDATE comment SET content = #{commentDto.content} @@ -57,7 +57,7 @@ - + UPDATE comment SET @@ -68,17 +68,17 @@ - + DELETE FROM comment WHERE comment_id = #{commentId} - + - \ No newline at end of file + --> \ No newline at end of file diff --git a/src/main/resources/mapper/likes-mapper.xml b/src/main/resources/mapper/likes-mapper.xml index a56519c..7b9e68a 100644 --- a/src/main/resources/mapper/likes-mapper.xml +++ b/src/main/resources/mapper/likes-mapper.xml @@ -1,11 +1,11 @@ - + + - + - + INSERT INTO likes (board_board_id, member_member_id, is_liked) VALUES (#{boardId}, #{memberId}, TRUE) @@ -21,7 +21,7 @@ - + UPDATE likes SET is_liked = FALSE @@ -30,11 +30,11 @@ - + - \ No newline at end of file + --> \ No newline at end of file From 5c7ad5520b0aa510a673a7589398c17acfb5d8d0 Mon Sep 17 00:00:00 2001 From: jin2304 Date: Sun, 29 Mar 2026 23:20:46 +0900 Subject: [PATCH 02/22] =?UTF-8?q?docs:=20=EC=95=84=ED=82=A4=ED=85=8D?= =?UTF-8?q?=EC=B2=98=20=EB=B0=8F=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=8B=9C?= =?UTF-8?q?=EB=82=98=EB=A6=AC=EC=98=A4=20=EB=AC=B8=EC=84=9C(02,=2003)=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EB=B6=88=ED=95=84=EC=9A=94=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=A0=9C=EC=99=B8=20(.gitignore)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 9 + docs/Backend/02. jwt-oauth2-architecture.md | 865 +++++++++++ docs/Backend/03. jwt-oauth2-test-scenarios.md | 1281 +++++++++++++++++ 3 files changed, 2155 insertions(+) create mode 100644 docs/Backend/02. jwt-oauth2-architecture.md create mode 100644 docs/Backend/03. jwt-oauth2-test-scenarios.md diff --git a/.gitignore b/.gitignore index bda49dc..22faf30 100644 --- a/.gitignore +++ b/.gitignore @@ -45,5 +45,14 @@ out/ .claude/ CLAUDE.md +### OMC ### +.omc/ + ### HTTP Tests ### *.http + +### Temporary Files ### +.gradle-user/ +status.txt +status_utf8.txt +docs/Backend/memo/ diff --git a/docs/Backend/02. jwt-oauth2-architecture.md b/docs/Backend/02. jwt-oauth2-architecture.md new file mode 100644 index 0000000..7832bfc --- /dev/null +++ b/docs/Backend/02. jwt-oauth2-architecture.md @@ -0,0 +1,865 @@ +# JWT/OAuth2 소셜 로그인 아키텍처 가이드 + +> **브랜치**: `feat/SW-65` +> **대상**: 세션 기반 인증 → JWT + OAuth2 Stateless 아키텍처 전환 +> **최종 수정**: 2026-03-22 + +--- + +## 목차 + +1. [전체 구조 한눈에 보기](#1-전체-구조-한눈에-보기) +2. [시나리오별 인증 플로우](#2-시나리오별-인증-플로우) +3. [백엔드 핵심 코드](#3-백엔드-핵심-코드) +4. [프론트엔드 핵심 코드](#4-프론트엔드-핵심-코드) +5. [보안 메커니즘](#5-보안-메커니즘) +6. [설정 파일 가이드](#6-설정-파일-가이드) +7. [파일 맵](#7-파일-맵) + +--- + +## 1. 전체 구조 한눈에 보기 + +### 인증 방식 비교 + +| 항목 | 변경 전 (세션) | 변경 후 (JWT) | +|------|---------------|--------------| +| 인증 상태 저장 | 서버 세션 (메모리) | Access Token (클라이언트 메모리) | +| 세션 유지 | JSESSIONID 쿠키 | Refresh Token (HttpOnly 쿠키) | +| 로그인 방식 | 폼 로그인 + OAuth2 | **OAuth2 소셜 로그인만** | +| 서버 확장 | 세션 공유 필요 (Redis 등) | Stateless (제약 없음) | +| 로그아웃 | 세션 삭제 | DB에서 Refresh Token 삭제 | + +### 토큰 구조 + +```mermaid +block-beta + columns 2 + + block:access["Access Token (JWT)"]:2 + a1["저장: 프론트엔드 메모리 (Zustand)"] + a2["만료: 30분 (설정 가능)"] + a3["용도: API 요청 시 Authorization 헤더"] + a4["내용: memberId(sub), role(claim)"] + end + + block:refresh["Refresh Token (JWT)"]:2 + r1["저장: HttpOnly 쿠키 + SHA-256 해시(DB)"] + r2["만료: 14일 (설정 가능)"] + r3["용도: Access Token 만료 시 재발급"] + r4["내용: memberId(sub)만 포함"] + end + + style access fill:#e8f5e9,stroke:#2e7d32 + style refresh fill:#e3f2fd,stroke:#1565c0 +``` + +### 핵심 컴포넌트 관계도 + +```mermaid +graph TB + Browser["🌐 브라우저"] + + subgraph OAuth2["OAuth2 소셜 로그인 플로우"] + direction TB + CookieRepo["HttpCookieOAuth2
AuthorizationRequestRepository
state를 쿠키에 저장"] + MemberService["CustomOAuth2MemberService
소셜 응답 파싱
→ 회원가입/로그인
"] + SuccessHandler["OAuth2SuccessHandler
RT 발급 → 쿠키 설정
→ 리다이렉트
"] + FailureHandler["OAuth2FailureHandler
에러 메시지
→ /login 리다이렉트
"] + end + + subgraph JwtFilter["JWT 인증 필터"] + direction TB + Filter["JwtAuthenticationFilter
Bearer 토큰 파싱 → 검증"] + Provider["JwtUtils
토큰 생성/파싱/검증"] + EntryPoint["JwtAuthenticationEntryPoint
401 JSON 응답"] + end + + subgraph AuthAPI["인증 API"] + direction TB + Controller["AuthController"] + Service["AuthServiceImpl
토큰 발급/갱신/로그아웃"] + DAO["RefreshTokenDao
DB 접근 (FOR UPDATE)"] + end + + Social["☁️ 소셜 제공자
Google / Naver / Kakao"] + DB[("🗄️ PostgreSQL
refresh_token")] + + Browser -->|"/oauth2/authorization/
{provider}"| CookieRepo + CookieRepo --> Social + Social -->|"콜백"| MemberService + MemberService -->|"성공"| SuccessHandler + MemberService -->|"실패"| FailureHandler + SuccessHandler -->|"302 + RT 쿠키"| Browser + + Browser -->|"/api/** (Bearer Token)"| Filter + Filter --> Provider + Filter -->|"인증 실패"| EntryPoint + + Browser -->|"/api/auth/refresh
(RT 쿠키)"| Controller + Browser -->|"/api/auth/logout
(RT 쿠키)"| Controller + Controller --> Service + Service --> DAO + DAO --> DB + + style Browser fill:#fff3e0,stroke:#e65100,stroke-width:2px + style Social fill:#f3e5f5,stroke:#7b1fa2 + style DB fill:#e8eaf6,stroke:#283593 + style OAuth2 fill:#fce4ec,stroke:#c62828,color:#c62828 + style JwtFilter fill:#e8f5e9,stroke:#2e7d32,color:#2e7d32 + style AuthAPI fill:#e3f2fd,stroke:#1565c0,color:#1565c0 +``` + +--- + +## 2. 시나리오별 인증 플로우 + +### 시나리오 1: OAuth2 소셜 로그인 (최초 진입) + +> 사용자가 "구글로 로그인" 버튼을 클릭하고, 인증 완료 후 대시보드로 이동하기까지의 전체 흐름 + +```mermaid +sequenceDiagram + actor User as 사용자 + participant FE as 프론트엔드
(Next.js) + participant SB as Spring Boot + participant DB as PostgreSQL + participant Social as 소셜 제공자
(Google) + + Note over User,Social: 1단계: OAuth2 인증 + + User->>FE: "구글로 로그인" 클릭 + FE->>SB: GET /oauth2/authorization/google + + Note over SB: state 생성 → 쿠키에 저장 (3분)
[HttpCookieOAuth2AuthorizationRequestRepository] + + SB->>Social: 302 → 구글 인증 페이지 + Social->>User: 로그인 및 권한 동의 화면 + User->>Social: 동의 + Social->>SB: 인증 코드와 함께 콜백 + + Note over SB,DB: 2단계: 회원 처리 + + Note over SB: CustomOAuth2MemberService.loadUser()
• 소셜 응답 파싱 (GoogleResponse)
• loginId: "google{providerId}" + SB->>DB: 회원 조회 (by loginId) + DB-->>SB: 신규 → 회원가입 / 기존 → 정보 업데이트 + + Note over SB,DB: 3단계: 토큰 발급 + + Note over SB: OAuth2SuccessHandler + SB->>DB: 기존 RT 삭제 (고아 정리) + SB->>SB: Refresh Token 생성 (JWT) + SB->>DB: SHA-256 해시 후 저장 + SB->>SB: 임시 쿠키 삭제 + SB->>FE: 302 → /auth/callback
+ Set-Cookie: refreshToken (HttpOnly) + + Note over FE,DB: 4단계: Access Token 획득 + + FE->>FE: AuthCallbackPage → initialize() + FE->>SB: POST /api/auth/refresh (RT 쿠키 포함) + SB->>DB: SELECT ... FOR UPDATE + SB->>DB: rotatedAt 설정 + 새 RT 저장 + SB-->>FE: { accessToken } + Set-Cookie: 새 RT + + FE->>SB: GET /api/auth/member (Bearer AT) + SB-->>FE: { data: { memberId, name, email, role } } + + FE->>FE: isAuthenticated = true + FE->>User: /my-links 대시보드 표시 +``` + +> 💡 +> **백엔드 개발자를 위한 'OAuth2 리다이렉트' 심층 이해** +> +> **1. 왜 백엔드가 구글에 직접 요청하지 않고 브라우저를 이동시키나요?** +> - **보안(Security)**: 사용자의 구글 비밀번호가 우리 서버에 절대 노출되지 않도록 하기 위함입니다. 사용자는 오직 구글 서버와 직접 통신(브라우저를 통해)하여 본인을 인증합니다. +> +> **2. 브라우저 리다이렉트(302 Redirect)의 역할** +> - 우리 서버는 브라우저에게 `"302 Redirect"` 상태 코드와 함께 구글 로그인 주소를 보냅니다. +> - 브라우저는 이 명령을 받자마자 **자동으로 구글 로그인 페이지로 접속**합니다. (주소창이 구글로 바뀜) +> - 즉, 브라우저는 우리 서버와 구글 사이를 오가며 **사용자 본인임을 증명하는 통로** 역할을 합니다. +> +> **3. 코드(Code) vs 토큰(Token) 흐름 구분** +> - **사용자 브라우저 경로**: 로그인 과정에서 보안을 위해 일회용 **'코드(Authorization Code)'**만 전달받습니다. (보안 노출 위험 최소화) +> - **서버 대 서버(Server-to-Server) 경로**: 백엔드는 브라우저가 들고 온 코드를 가로채서, 구글 서버와 직접 통신하여 실제 **'토큰(Access Token)'**으로 교환합니다. +> - 결과적으로, 중요한 토큰과 사용자 개인정보는 브라우저를 거치지 않고 **구글 서버 ↔ 우리 서버** 사이에서만 안전하게 오고 가게 됩니다. + +**핵심 포인트:** +- OAuth2 성공 시 **Access Token은 즉시 발급하지 않음** → Refresh Token만 쿠키에 설정 +- 프론트엔드가 `/api/auth/refresh`를 호출하여 Access Token을 받아오는 구조 +- 이유: OAuth2 리다이렉트 응답에서는 JSON body를 줄 수 없고, 쿠키나 URL 파라미터로만 전달 가능. Access Token을 URL에 노출하는 것은 보안 위험이므로 별도 요청으로 분리 + +--- + +### 시나리오 2: 페이지 새로고침 시 인증 복구 + +> 브라우저를 새로고침하면 Zustand 메모리가 초기화됨. Refresh Token 쿠키로 로그인 상태를 복구하는 과정 + +```mermaid +sequenceDiagram + participant FE as 프론트엔드 + participant SB as Spring Boot + + FE->>FE: F5 새로고침
Zustand 초기화
accessToken = null + + Note over FE: AuthProvider 마운트
→ authStore.initialize() + + FE->>SB: POST /api/auth/refresh
(Cookie: refreshToken) + SB-->>FE: { accessToken } + Set-Cookie: 새 RT + + FE->>SB: GET /api/auth/member
(Authorization: Bearer AT) + SB-->>FE: { data: { memberId, ... } } + + FE->>FE: isAuthenticated = true
isInitializing = false +``` + +**핵심 포인트:** +- `AuthProvider`가 앱 최상위에서 `initialize()`를 호출 +- `initializePromise` 싱글톤으로 중복 호출 방지 (AuthProvider + AuthCallbackPage 동시 마운트 대응) +- Refresh Token 쿠키가 없거나 만료되면 → `isAuthenticated = false` → `/login`으로 리다이렉트 + + + + +> 💡 +> **백엔드 개발자를 위한 '새로고침' 인증 복구 요약** +> +> **1. 핵심 이유 (왜 필요한가?)** +> - **백엔드**: Stateless 방식이라 서버 세션이 없습니다. (브라우저를 기억하지 않음) +> - **프론트엔드**: `Access Token`을 자바스크립트 변수(Zustand)에 담고 있는데, **새로고침(F5) 시 이 변수가 초기화**되어 증발합니다. +> +> **2. 해결 방법 (비상용 열쇠)** +> - 새로고침해도 사라지지 않는 **쿠키(HttpOnly Refresh Token)**를 활용합니다. +> +> **3. 인증 복구 흐름** +> 1. **F5 (새로고침)**: 프론트엔드 메모리가 비워짐 (`accessToken = null`). +> 2. **인증 복구 시작**: 앱이 켜지는 순간(`Mount`) 쿠키 속의 `Refresh Token`을 실어 백엔드에 갱신 요청을 보냅니다. +> 3. **백엔드 확인**: 백엔드가 쿠키를 검증하고 새로운 `Access Token`을 발급합니다. +> 4. **상태 복구**: 프론트엔드가 받은 토큰을 다시 메모리에 저장하여 로그인 상태를 유지합니다. +> +> **※ 참고 (마운트란?):** 리액트 컴포넌트 객체가 생성되어 실제 화면에 배치되는 시점으로, 스프링의 **`@PostConstruct`** 호출 타이밍과 유사합니다. + +--- + +### 시나리오 3: API 요청 중 Access Token 만료 + +> Access Token이 만료된 상태에서 API를 호출하면 자동으로 갱신하고 재시도하는 과정 + +```mermaid +sequenceDiagram + participant FE as fetchClient + participant SB as Spring Boot + + FE->>SB: GET /api/bookmarks
(Bearer: 만료된 AT) + Note over SB: JwtAuthenticationFilter
→ ExpiredJwtException + SB-->>FE: 401 Unauthorized + + Note over FE: 401 감지 + AT 존재
→ refreshAccessToken() + + FE->>SB: POST /api/auth/refresh
(Cookie: refreshToken) + SB-->>FE: { accessToken: "새토큰" } + + Note over FE: Zustand에 새 AT 저장 + + FE->>SB: GET /api/bookmarks (재시도)
(Bearer: 새 AT) + SB-->>FE: { bookmarks: [...] } +``` + +**핵심 포인트:** +- `fetchClient`가 **401을 자동 감지**하여 토큰 갱신 후 원래 요청을 재시도 (1회만) +- 여러 API가 동시에 401을 받으면 `refreshPromise` 공유로 갱신 요청 1회만 발생 +- 갱신 실패(Refresh Token도 만료) → `authStore.logout()` → `/login` 이동 + +> 💡 **백엔드 개발자를 위한 프론트엔드 로직 상세 (`fetchClient.ts`)** +> +> 1. **401 감지 (`response.status === 401`)**: +> - 모든 API 요청은 공통 함수인 `fetchClient`를 통과합니다. 여기서 응답 코드가 401인지를 실시간으로 체크합니다. +> 2. **AT 존재 조건 (`&& accessToken`)**: +> - "이미 AT를 가지고 있던 사용자(로그인했던 사용자)"인 경우에만 갱신을 시도합니다. +> - **이유**: 비로그인 사용자가 권한 없는 API에 접근했을 때 발생하는 401과 구분하여, 불필요한 무한 갱신 루프를 방지하기 위함입니다. +> 3. **투명한 재시도 (Transparent Retry)**: +> - 사용자가 모르게 `POST /api/auth/refresh`를 먼저 호출해 토큰을 갈아 끼운 후, **실패했던 원래 API를 똑같은 파라미터로 다시 호출**합니다. +> - 결과적으로 사용자는 끊김 없는 서비스를 경험하게 됩니다. +> +> - **관련 프론트 코드**: `frontend/src/lib/api/fetchClient.ts` 내의 `fetchClient` 함수와 `refreshAccessToken` 함수 + +--- + +### 시나리오 4: Refresh Token Rotation과 Grace Period + +> 토큰 갱신 시 보안을 위해 기존 토큰을 무효화하고, 네트워크 지연에 대응하는 과정 + +#### Case 1: 정상 갱신 + +```mermaid +sequenceDiagram + participant C as 클라이언트 + participant S as 서버 + participant DB as DB + + C->>S: refresh(RT-A) + S->>DB: SELECT RT-A FOR UPDATE + S->>DB: UPDATE rotatedAt = NOW() + S->>DB: INSERT 새 RT-B (해시) + S-->>C: { AT, RT-B } +``` + +> 💡 **백엔드 로직 상세 (Case 1: 정상 갱신)** +> 1. **요청 (RT-A 제출)**: 클라이언트가 쿠키에 담긴 `RT-A`를 실어 `/api/auth/refresh`로 요청합니다. +> 2. **검증 및 락 (Lock 획득)**: JWT 유효성을 검토하고, DB에서 **비관적 락(`FOR UPDATE`)**으로 해당 토큰을 조회해 동시성(Race Condition) 문제를 방지합니다. +> 3. **판단 (`rotated_at == null`)**: DB 레코드의 `rotated_at`이 비어있으면 "최초 갱신 시도"인 정상 상황으로 판단합니다. +> 4. **회전 (Rotation)**: `RT-A`를 즉시 삭제하지 않고 `rotated_at`에 현재 시각을 기록하여 '사용됨' 도장을 찍습니다. (이때부터 10초간의 **Grace Period**가 시작됩니다.) +> 5. **발급 (RT-B 생성)**: 새로운 `Access Token`과 `Refresh Token(RT-B)`을 생성하고, `RT-B`를 DB에 새 로우로 삽입합니다. +> 6. **응답**: 새 토큰 쌍을 클라이언트에게 전달하며, 이후 클라이언트는 `RT-B`를 사용하게 됩니다. + +#### Case 2: Grace Period 내 재사용 (네트워크 지연) + +```mermaid +sequenceDiagram + participant C as 클라이언트 + participant S as 서버 + participant DB as DB + + Note over C,S: [T+0초] 첫 번째 요청 시도 + C->>S: refresh(RT-A) + S->>DB: rotatedAt = NOW() + + Note right of C: ⚠️ 응답을 받지 못해 재시도 결정 + S--x C: (응답 지연 또는 유실됨) + + Note over C,S: [T+3초] 중복 요청 (결과 미수신) + C->>S: refresh(RT-A) + + S->>DB: rotatedAt 확인 → 10초 이내 (Grace Period) + S->>DB: RT-A 즉시 삭제 (추가 재사용 금지) + S-->>C: { AT, RT-C } (성공 수신!) + + Note over C,S: [T+7초] 3번째 시도 (이미 삭제됨) + C->>S: refresh(RT-A) + S->>DB: RT-A 조회 → 없음 (이미 삭제됨) + S--xC: AUTH_REFRESH_TOKEN_NOT_FOUND +``` + +> 💡 **백엔드 로직 상세 (Case 2: Grace Period 내 재사용)** +> +> **1. 상황 가정 (왜 발생하나요?)** +> - **네트워크 지연/유실**: 클라이언트가 요청을 보냈으나 응답 수신 직전 연결이 끊겨 재시도하는 경우입니다. +> - **멀티탭 동시 요청**: 사용자가 여러 탭을 띄워놓은 상태에서 토큰이 만료되면, **모든 탭에서 동시에 갱신 요청**을 보낼 수 있습니다. 이때 먼저 도착한 요청이 토큰을 회전시키더라도, 뒤이어 도착한 다른 탭의 요청(이미 사용된 토큰)을 정상 처리해 주어야 합니다. +> - **기타**: 프론트엔드(`fetchClient`)에서 결과 미수신 등으로 인해 똑같은 `RT-A`로 재시도 요청을 보내는 모든 경우를 포함합니다. +> +> **2. 백엔드의 판단 (Grace Period 로직)** +> - **조회/상태 확인**: "이미 한 번 갱신에 쓰였던(`rotated_at != null`) 토큰이네?" +> - **시간 계산**: "하지만 갱신된 지 겨우 3초(10초 이내)뿐이라 **정당한 재시도**로 확실시되네." +> - **코드 로직**: `if (rotatedAt.plusSeconds(10).isAfter(now))` +> +> **3. 처리 결과 (딱 한 번 더 기회 주기)** +> - **새 토큰 발급**: 사용자 편의를 위해 새로운 토큰 쌍(`RT-C`)을 한 번 더 발급합니다. +> - **즉시 삭제 (보안)**: 악용 방지를 위해 기존 `RT-A`는 **DB에서 즉시 삭제**하여 추가 재사용을 차단합니다. +> +> **4. 만약 이 로직이 없다면?** +> - 지하철이나 엘리베이터 등 네트워크가 불안정한 환경에서 사용자가 시시때때로 로그아웃되어 로그인 페이지로 쫓겨나는 열악한 UX를 경험하게 됩니다. + +#### Case 3: Grace Period 초과 (토큰 탈취 의심) + +```mermaid +sequenceDiagram + participant A as 공격자 + participant S as 서버 + participant DB as DB + + Note over A,S: T+15초: 탈취된 토큰 사용 + A->>S: refresh(RT-A) + S->>DB: rotatedAt 확인 → 10초 초과 + S--xA: AUTH_REFRESH_TOKEN_NOT_FOUND +``` + +#### Case 4: 동시 요청 제어 (DB 비관적 락) + +```mermaid +sequenceDiagram + participant R1 as 요청 1 + participant R2 as 요청 2 + participant DB as DB + + R1->>DB: SELECT RT-A FOR UPDATE (락 획득) + R2->>DB: SELECT RT-A FOR UPDATE (락 획득 대기...) + + Note over R1,DB: 요청 1 처리 중 (Transaction) + R1->>DB: UPDATE (rotatedAt 설정) + INSERT (새 토큰) + R1->>DB: COMMIT (락 해제) + + Note over R2,DB: 요청 2 진행 (락 획득 성공) + DB-->>R2: RT-A (이미 rotatedAt != null 인 상태) + Note over R2: Case 2: Grace Period 로직으로 이동 +``` + +> 💡 **백엔드 로직 상세 (Case 4: 동시 요청 제어)** +> +> **1. 동시성 문제의 발생** +> - 아주 찰나의 순간에 똑같은 `RT-A`로 두 개의 요청(R1, R2)이 들어올 때 발생합니다. (예: 멀티탭 환경에서 모든 탭이 동시에 갱신을 시도하는 경우) +> +> **2. 비관적 락 (`FOR UPDATE`)의 역할** +> - **R1**: 먼저 DB 로우에 접근하여 **배타적 락(Exclusive Lock)**을 획득합니다. +> - **R2**: R1의 트랜잭션이 끝나기 전까지 동일한 로우에 대한 조회를 시도하며 **대기(Wait)** 상태에 빠집니다. +> +> **3. 락 보호 아래의 안전한 갱신** +> - **R1의 작업**: 락을 쥐고 있는 상태에서 안전하게 `UPDATE`(기존 토큰 무효화)와 `INSERT`(신규 토큰 저장)를 수행한 후 커밋합니다. +> - **R2의 작업**: R1의 커밋이 종료되면 비로소 락을 얻고 조회를 완료합니다. 이때 이미 **`rotated_at`이 기록된 결과**를 받게 되므로, 자연스럽게 **Case 2(Grace Period 내 재사용)** 로직으로 분기되어 안전하게 처리됩니다. + +--- + +**핵심 포인트:** +- **Rotation**: 매 갱신마다 새 Refresh Token 발급 → 탈취된 토큰의 유효 시간 최소화 +- **Grace Period (10초)**: 네트워크 지연으로 인한 정당한 중복 요청 허용 (1회만) +- **FOR UPDATE**: DB 수준 비관적 락으로 동시 갱신 요청의 경합 조건 방지 + +--- + +### 시나리오 5: 로그아웃 + +> 사용자가 로그아웃 버튼을 클릭하여 서버와 클라이언트 모두에서 인증 정보를 제거하는 과정 + +```mermaid +sequenceDiagram + actor User as 사용자 + participant FE as 프론트엔드 + participant SB as Spring Boot + participant DB as DB + + User->>FE: 로그아웃 클릭 + FE->>FE: authStore.logout() + + FE->>SB: POST /api/auth/logout
(Cookie: refreshToken) + + Note over SB: 1단계: Refresh Token 무효화 + SB->>SB: 원본 토큰 SHA-256 해시화 + SB->>DB: DELETE FROM refresh_token
WHERE token_hash = ? + + Note over SB: 2단계: 브라우저 쿠키 제거 + SB->>SB: Set-Cookie: refreshToken=""
(maxAge=0, httpOnly=true) + SB-->>FE: 200 OK + + Note over FE: 3단계: 클라이언트 메모리 초기화 + FE->>FE: authStore 초기화
(accessToken=null, isAuthenticated=false) + FE->>User: /login 페이지로 이동 +``` + +> 💡 +> **백엔드 개발자를 위한 '로그아웃' 처리 요약** +> +> **1. 핵심 이유 (왜 이렇게 처리하는가?)** +> - **Stateless 인증**: 서버는 클라이언트의 상태를 기억하지 않으므로, 더 이상 유효하지 않은 `Refresh Token`을 서버 DB(무효화 목록)에서 명시적으로 삭제해야 합니다. +> - **보안 강화**: 브라우저에 남은 `HttpOnly` 쿠키를 강제로 만료(`Max-Age=0`)시켜 클라이언트 측의 접근 권한도 즉시 회수합니다. +> +> **2. 백엔드 처리 상세 (`AuthController.java`)** +> - **쿠키 추출**: `@CookieValue`를 통해 브라우저가 보낸 `refreshToken`을 읽습니다. +> - **DB 삭제**: 해당 토큰을 해시화하여 DB에서 삭제(`deleteByTokenHash`)합니다. 이제 이 토큰으로는 더 이상 Access Token을 갱신할 수 없습니다. +> - **쿠키 만료**: 응답 헤더에 `Set-Cookie`를 빈 값(`""`)과 `maxAge(0)`으로 설정하여 브라우저가 쿠키를 삭제하도록 명령합니다. +> +> **3. 프론트엔드와의 협업** +> - **메모리 삭제**: 프론트엔드는 자바스크립트 변수(`accessToken`)에 담긴 인증 정보를 지워야 합니다. +> - **강제 진행**: 네트워크 오류 등으로 서버 요청이 실패하더라도, 사용자가 로그아웃을 눌렀다면 클라이언트 상태는 무조건 초기화하여 보안 사고를 예방합니다 (`finally` 블록 활용). + +**핵심 포인트:** +- **서버/네트워크 오류 대응**: 서버 응답과 무관하게(또는 실패하더라도) 클라이언트 상태는 반드시 초기화하여 보안 유지 +- **DB 무효화**: DB에서 `Refresh Token`을 삭제하여, 이후 해당 토큰이 탈취되더라도 Access Token 발급 시도 원천 차단 +- **쿠키 제거**: `HttpOnly` 쿠키는 자바스크립트가 지울 수 없으므로, 반드시 서버가 `maxAge=0` 응답을 주어야 함 + +--- + +## 3. 백엔드 핵심 코드 + +### 3.1 SecurityConfig — 필터 체인 설정 + +> `config/security/SecurityConfig.java` + +Spring Security의 전체 보안 규칙을 정의하는 핵심 설정 클래스. + +``` +필터 체인 순서: + JwtAuthenticationFilter → UsernamePasswordAuthenticationFilter → ... → AuthorizationFilter + ↑ (우리가 추가한 필터) +``` + +**주요 설정:** + +| 설정 | 값 | 이유 | +|------|---|------| +| CSRF | 비활성화 | JWT Bearer 토큰 방식은 CSRF 공격 대상 아님 | +| 세션 | STATELESS | JWT 사용, 서버 메모리 점유 안 함 | +| 폼 로그인 | 비활성화 | 소셜 로그인만 사용 | +| `/api/auth/refresh`, `/api/auth/logout` | permitAll | 토큰 없이 접근 필요 | +| `/api/**` | authenticated | 그 외 모든 API 인증 필수 | + +--- + +### 3.2 JwtAuthenticationFilter — 매 요청마다 토큰 검증 + +> `config/jwt/JwtAuthenticationFilter.java` + +모든 HTTP 요청을 가로채서 JWT를 검증하는 필터. `OncePerRequestFilter` 상속으로 요청당 1회만 실행. + +**동작 방식:** +1. `Authorization: Bearer {token}` 헤더에서 토큰 추출 +2. 토큰이 없으면 → 필터 통과 (공개 API는 접근 가능) +3. 토큰이 있으면 → `JwtUtils.validateToken()` 검증 +4. 유효하면 → `JwtMemberPrincipal` 생성 후 `SecurityContext`에 저장 +5. 만료/위변조 → request attribute에 에러 코드 저장, 필터 통과 +6. 이후 `JwtAuthenticationEntryPoint`가 에러 코드를 읽어 401 JSON 응답 + +**왜 예외를 던지지 않고 attribute에 저장하는가?** +- 필터에서 예외를 던지면 Spring Security의 ExceptionTranslationFilter가 처리 +- 세밀한 에러 메시지(만료 vs 위변조)를 전달하기 위해 attribute 방식 사용 + +--- + +### 3.3 JwtUtils — 토큰 생성과 검증 + +> `config/jwt/JwtUtils.java` + +JWT 토큰의 생성, 파싱, 검증을 담당하는 유틸리티 클래스. + +| 메서드 | 역할 | +|--------|------| +| `generateAccessToken(memberId, role)` | Access Token 생성 (sub: memberId, claim: role) | +| `generateRefreshToken(memberId)` | Refresh Token 생성 (sub: memberId만) | +| `parseAccessToken(token)` | 토큰 파싱 → `JwtMemberPrincipal(memberId, role)` 반환 | +| `validateToken(token)` | 서명 + 만료 검증. 실패 시 예외 throw (void 반환) | +| `extractMemberId(token)` | 토큰에서 memberId 추출 | + +**서명 알고리즘:** HMAC-SHA256 (대칭키, `jwt.secret` 프로퍼티에서 주입) + +--- + +### 3.4 AuthController — 인증 API 엔드포인트 + +> `auth/controller/AuthController.java` + +| 엔드포인트 | 메서드 | 인증 | 역할 | +|-----------|--------|------|------| +| `POST /api/auth/refresh` | `refresh()` | 불필요 | Refresh Token으로 새 토큰 쌍 발급 | +| `POST /api/auth/logout` | `logout()` | 불필요 | 토큰 삭제 + 쿠키 제거 | +| `GET /api/auth/member` | `getAuthenticatedMemberInfo()` | **필요** | 현재 사용자 정보 반환 | + +**Refresh Token 쿠키 설정:** +``` +httpOnly=true ← JavaScript 접근 차단 (XSS 방어) +secure={프로필별} ← local: false, prod: true (HTTPS 전용) +sameSite=Lax ← 외부 사이트에서의 쿠키 전송 제한 (CSRF 방어) +path=/api/auth ← 이 경로의 요청에만 쿠키 포함 (노출 최소화) +maxAge={14일} ← Refresh Token 만료와 동일 +``` + +--- + +### 3.5 AuthServiceImpl — 토큰 비즈니스 로직 + +> `auth/service/AuthServiceImpl.java` + +**`refresh()` 메서드 — 핵심 로직 상세:** + +```java +// 1. JWT 자체 검증 (서명, 만료) +jwtUtils.validateToken(refreshToken); + +// 2. DB에서 해시로 조회 (비관적 락으로 동시성 제어) +String hashedToken = SecurityUtils.hashToken(refreshToken); +RefreshToken storedRefreshToken = refreshTokenDao.findByTokenHashForUpdate(hashedToken); + +// 3. DB 만료 확인 +if (storedRefreshToken.getExpiresAt().isBefore(Instant.now())) { /* 삭제 후 에러 */ } + +// 4. Grace Period 처리 +if (storedRefreshToken.getRotatedAt() != null) { + if (storedRefreshToken.getRotatedAt().plusSeconds(10).isAfter(Instant.now())) { + // 유예 기간 내 재사용 → 기존 토큰 즉시 삭제, 새 토큰 발급 (1회만) + } else { + // 유예 기간 초과 → 에러 (탈취 의심) + } +} + +// 5. 첫 갱신: rotatedAt 설정 후 새 토큰 발급 +refreshTokenDao.updateRotatedAt(hashedToken); +return issueTokens(memberId, role); +``` + +**`issueRefreshToken()` 메서드 — OAuth2 로그인 후 호출:** +1. 기존 회원의 모든 Refresh Token 삭제 (고아 토큰 정리) +2. 새 Refresh Token 생성 +3. SHA-256 해시 후 DB 저장 +4. 원본 토큰 반환 (쿠키에 저장될 값) + +--- + +### 3.6 OAuth2 로그인 처리 클래스 + +#### CustomOAuth2MemberService — 소셜 응답 파싱 및 회원 처리 + +> `member/service/CustomOAuth2MemberService.java` + +Spring Security의 `DefaultOAuth2UserService`를 확장. 소셜 제공자에서 사용자 정보를 받아온 후 호출됨. + +**처리 흐름:** +1. `registrationId`로 소셜 제공자 판별 (naver / google / kakao) +2. 제공자별 `OAuth2Response` 구현체 생성 (각 API 응답 포맷 파싱) +3. 회원 고유 ID 생성: `{provider}{providerId}` (예: `google123456789`) +4. DB 조회 → 신규면 회원가입, 기존이면 정보 업데이트 +5. `CustomOAuth2Member` 반환 (Spring Security의 Principal로 사용) + +#### OAuth2SuccessHandler — 로그인 성공 후 처리 + +> `config/jwt/OAuth2SuccessHandler.java` + +1. `CustomOAuth2Member`에서 `memberId` 추출 +2. `AuthService.issueRefreshToken()` 호출 +3. Refresh Token을 HttpOnly 쿠키에 설정 +4. `redirect_uri` 쿠키 검증 (`isAuthorizedRedirectUri` — 스킴+호스트+포트 3중 비교) +5. 임시 OAuth2 쿠키 삭제 +6. 프론트엔드 `/auth/callback`으로 리다이렉트 + +#### HttpCookieOAuth2AuthorizationRequestRepository — STATELESS + OAuth2 호환 + +> `config/security/HttpCookieOAuth2AuthorizationRequestRepository.java` + +**왜 필요한가?** +- OAuth2 Authorization Code 플로우는 `state` 파라미터를 저장해야 함 (CSRF 방지) +- 기본 구현(`HttpSessionOAuth2AuthorizationRequestRepository`)은 서버 세션에 저장 +- STATELESS 정책에서는 세션이 없으므로 **쿠키에 저장하는 커스텀 구현**이 필요 + +| 시점 | 메서드 | 역할 | +|------|--------|------| +| 소셜 로그인 시작 | `saveAuthorizationRequest()` | 인증 요청 정보를 쿠키에 직렬화하여 저장 (3분 만료) | +| 소셜 인증 콜백 | `loadAuthorizationRequest()` | 쿠키에서 인증 요청 정보를 역직렬화하여 반환 | +| 인증 완료 후 | `removeAuthorizationRequestCookies()` | 임시 쿠키 삭제 | + +--- + +### 3.7 에러 처리 + +#### AuthErrorCode — 인증 도메인 에러 코드 + +> `auth/error/AuthErrorCode.java` + +| 코드 | HTTP 상태 | 설명 | +|------|-----------|------| +| `AUTH_UNAUTHORIZED` | 401 | 인증이 필요한 요청 | +| `AUTH_TOKEN_EXPIRED` | 401 | Access/Refresh Token 만료 | +| `AUTH_INVALID_TOKEN` | 401 | 토큰 위변조 또는 파싱 실패 | +| `AUTH_REFRESH_TOKEN_NOT_FOUND` | 401 | DB에 Refresh Token 없음 | +| `AUTH_ACCESS_DENIED` | 403 | 접근 권한 없음 | +| `AUTH_INVALID_REDIRECT_URI` | 400 | 허용되지 않은 리다이렉트 URI | +| `AUTH_UNSUPPORTED_PROVIDER` | 400 | 지원하지 않는 소셜 제공자 | + +#### JwtAuthenticationEntryPoint — 인증 실패 JSON 응답 + +> `config/jwt/JwtAuthenticationEntryPoint.java` + +`JwtAuthenticationFilter`가 request attribute에 저장한 에러 코드를 읽어서 JSON 응답: +```json +{ "code": "A002", "message": "토큰이 만료되었습니다.", "success": false } +``` + +--- + +## 4. 프론트엔드 핵심 코드 + +### 4.1 authStore.ts — 전역 인증 상태 (Zustand) + +> `frontend/src/lib/store/authStore.ts` + +| 상태 | 타입 | 설명 | +|------|------|------| +| `accessToken` | `string \| null` | API 인증용 토큰 (메모리에만 존재) | +| `isAuthenticated` | `boolean` | 로그인 여부 | +| `isInitializing` | `boolean` | 초기화 진행 여부 (로딩 표시용) | +| `member` | `Member \| null` | 사용자 프로필 정보 | +| `authError` | `string \| null` | 서버/네트워크 오류 메시지 | + +| 액션 | 역할 | +|------|------| +| `initialize()` | `/api/auth/refresh` → `/api/auth/member` 순서로 호출하여 로그인 상태 복구 | +| `logout()` | `/api/auth/logout` 호출 후 상태 초기화 | +| `setAccessToken(token)` | 토큰 갱신 시 새 토큰 저장 | + +**`initializePromise` 패턴:** +```typescript +let initializePromise: Promise | null = null; + +// AuthProvider와 AuthCallbackPage가 동시에 initialize() 호출해도 +// 실제 API 요청은 1회만 발생 +initialize: async () => { + if (initializePromise) return initializePromise; + initializePromise = (async () => { /* ... */ })(); + return initializePromise; +} +``` + +--- + +### 4.2 fetchClient.ts — API 클라이언트 + +> `frontend/src/lib/api/fetchClient.ts` + +모든 API 호출을 감싸는 래퍼. 인증 토큰 주입과 자동 갱신을 담당. + +**핵심 기능:** + +1. **자동 토큰 주입**: `Authorization: Bearer {accessToken}` 헤더 추가 +2. **401 자동 갱신**: 만료 시 `/api/auth/refresh` 호출 후 원래 요청 재시도 (1회) +3. **동시 갱신 방지**: `refreshPromise` 공유로 여러 401이 동시에 발생해도 갱신 1회 +4. **갱신 실패 시 로그아웃**: `authStore.logout()` → `/login` 이동 + +``` +요청 ──→ 성공(200) ──→ 응답 반환 + │ + └──→ 실패(401) ──→ refreshAccessToken() ──┬──→ 성공 ──→ 원래 요청 재시도 + │ + └──→ 실패 ──→ logout() → /login +``` + +--- + +### 4.3 AuthProvider.tsx — 인증 초기화 및 라우트 가드 + +> `frontend/src/components/providers/AuthProvider.tsx` + +앱 최상위에서 인증 상태를 초기화하고, 미인증 사용자의 접근을 제어. + +**역할:** +1. 앱 시작 시 `authStore.initialize()` 호출 (1회) +2. 초기화 중 → 로딩 스피너 표시 +3. 초기화 완료 후: + - 미인증 + 보호 경로 → `/login` 리다이렉트 + - 인증 완료 → 자식 컴포넌트 렌더링 + +**공개 경로:** `/`, `/login`, `/auth/callback` (접두사 매칭) + +--- + +### 4.4 AuthCallbackPage — OAuth2 콜백 처리 + +> `frontend/src/app/auth/callback/page.tsx` + +OAuth2 인증 완료 후 Spring Boot가 리다이렉트하는 페이지. + +**동작:** +1. 마운트 시 `authStore.initialize()` 호출 (`useRef`로 중복 방지) +2. `initializePromise` 싱글톤으로 AuthProvider와의 이중 호출 방지 +3. 인증 성공 → `/my-links` 이동 +4. 인증 실패 → `/login` 이동 + +--- + +## 5. 보안 메커니즘 + +### 토큰 저장 전략 + +| 토큰 | 저장 위치 | 접근 가능 | XSS 노출 | CSRF 노출 | +|------|----------|----------|---------|---------| +| Access Token | Zustand (메모리) | JavaScript | O (단, 새로고침 시 소멸) | X | +| Refresh Token | HttpOnly 쿠키 | 서버만 | X | △ (SameSite=Lax로 완화) | +| Refresh Token 해시 | PostgreSQL DB | 서버만 | X | X | + +### 공격 방어 매트릭스 + +| 공격 유형 | 방어 수단 | +|----------|----------| +| **XSS** | Refresh Token: HttpOnly 쿠키 (JS 접근 불가). Access Token: 메모리 저장 (localStorage보다 안전) | +| **CSRF** | SameSite=Lax 쿠키 + Bearer 토큰 방식 (쿠키가 자동 전송되지 않는 구조) | +| **토큰 탈취** | Refresh Token Rotation (매번 새 토큰) + Grace Period (재사용 1회 제한) + DB 즉시 무효화 | +| **DB 유출** | SHA-256 해시 저장 (원본 토큰 복원 불가) | +| **동시 요청 공격** | SELECT ... FOR UPDATE 비관적 락 + @Transactional | +| **Open Redirect** | OAuth2 redirect_uri 검증 (스킴+호스트+포트 3중 비교) | +| **세션 고정** | STATELESS (세션 자체가 없음) | +| **OAuth2 CSRF** | state 파라미터 쿠키 저장 + 콜백 시 검증 | +| **멀티 디바이스 보호** | 현재 단일 세션 강제 (Login 시 기존 RT 전체 삭제). 향후 최대 세션 수 제한(FIFO) 방식으로 확장 가능. | + +> **📝 향후 확장 고려사항: 멀티 디바이스 로그인 정책** +> +> **1. 현재 동작 (보안 우선 단일 세션)** +> - 사용자가 소셜 로그인 시, `AuthServiceImpl.issueRefreshToken`에서 `refreshTokenDao.deleteByMemberId(memberId)`를 호출하여 **해당 사용자의 기존 모든 Refresh Token을 즉시 무효화**합니다. +> - 이는 고아 토큰(사용되지 않는 토큰)의 누적을 방지하고, 타 기기에서의 부정 사용 가능성을 원천 차단하는 가장 안전한 방식입니다. +> +> **2. 향후 확장: 멀티 디바이스 지원 (최대 세션 수 제한)** +> - PC, 모바일, 태블릿 등 여러 환경에서 동시 로그인을 유지하려면 다음과 같은 개편이 필요합니다. +> - **세션 카운팅**: 전체 삭제 대신 사용자의 현재 유효 세션 수를 확인합니다. +> - **FIFO 삭제**: 설정된 상한선(예: 3~5개)을 초과할 때만 가장 오래된(또는 만료 시점이 가장 빠른) 세션을 삭제합니다. +> - **기기 식별**: `User-Agent` 등을 이용해 기기별 정보를 기록하면 사용자가 설정에서 기기를 직접 관리(선택적 로그아웃)할 수도 있습니다. +> + +--- + +## 6. 설정 파일 가이드 + +### 환경변수 (필수) + +| 변수명 | 설명 | 예시 | +|--------|------|------| +| `JWT_SECRET` | JWT 서명 비밀키 (Base64, 256bit 이상) | `dGhpcyBpcyBhIHNlY3JldCBrZXkgZm9yIGp3dA==` | +| `JWT_ACCESS_TOKEN_EXPIRY` | Access Token 만료 (ms) | `1800000` (30분) | +| `JWT_REFRESH_TOKEN_EXPIRY` | Refresh Token 만료 (ms) | `1209600000` (14일) | + +### 프로필별 설정 차이 + +| 설정 | local | dev | prod | +|------|-------|-----|------| +| `app.cookie.secure` | `false` | `${DEV_COOKIE_SECURE:false}` | `${PROD_COOKIE_SECURE:true}` | +| `app.oauth2.redirect-uri` | `http://localhost:3000/auth/callback` | 환경변수 | 환경변수 | + +--- + +## 7. 파일 맵 + +### 읽는 순서 추천 + +> 처음 코드를 파악할 때는 아래 순서로 읽으면 흐름이 자연스럽습니다. + +**1단계: 설정과 진입점** (전체 구조 파악) +``` +config/security/SecurityConfig.java ← 필터 체인, URL 접근 규칙, OAuth2 설정 +config/jwt/JwtProperties.java ← JWT 설정값 (secret, expiry, path) +``` + +**2단계: JWT 인증 흐름** (매 요청마다 실행) +``` +config/jwt/JwtAuthenticationFilter.java ← 요청 가로채기 → 토큰 검증 +config/jwt/JwtUtils.java ← 토큰 생성, 파싱, 검증 로직 +config/jwt/JwtMemberPrincipal.java ← 토큰에서 추출한 사용자 정보 +config/jwt/JwtAuthenticationEntryPoint.java ← 인증 실패 시 JSON 응답 +``` + +**3단계: OAuth2 소셜 로그인** (로그인 시 1회 실행) +``` +member/service/CustomOAuth2MemberService.java ← 소셜 응답 파싱 → 회원가입/로그인 +member/dto/CustomOAuth2Member.java ← OAuth2 Principal 구현체 +member/dto/Response/OAuth2Response.java ← 소셜 제공자별 응답 인터페이스 +config/jwt/OAuth2SuccessHandler.java ← 성공: 토큰 발급 + 리다이렉트 +config/jwt/OAuth2FailureHandler.java ← 실패: 에러 메시지 + 리다이렉트 +config/security/HttpCookieOAuth2AuthorizationRequestRepository.java ← STATELESS + OAuth2 호환 +config/security/CookieUtils.java ← 쿠키 직렬화/역직렬화 유틸 +``` + +**4단계: 토큰 관리 비즈니스 로직** +``` +auth/controller/AuthController.java ← API 엔드포인트 (refresh, logout, member) +auth/service/AuthServiceImpl.java ← 핵심 로직 (토큰 발급, 갱신, Grace Period) +auth/dao/RefreshTokenDao.java ← DB 접근 인터페이스 +auth/domain/RefreshToken.java ← 토큰 엔티티 +auth/error/AuthErrorCode.java ← 에러 코드 정의 +config/security/SecurityUtils.java ← 사용자 ID 추출, 토큰 해싱 유틸 +``` + +**5단계: 프론트엔드 인증** +``` +frontend/src/lib/store/authStore.ts ← 전역 인증 상태 (Zustand) +frontend/src/lib/api/fetchClient.ts ← API 클라이언트 (자동 토큰 갱신) +frontend/src/components/providers/AuthProvider.tsx ← 인증 초기화 + 라우트 가드 +frontend/src/app/auth/callback/page.tsx ← OAuth2 콜백 처리 페이지 +``` + +**6단계: DB/설정** +``` +resources/mapper/refresh-token-mapper.xml ← MyBatis SQL (CRUD + FOR UPDATE) +resources/sql/V001__create_refresh_token.sql ← Flyway 마이그레이션 +resources/application-{profile}.properties ← 프로필별 설정 +``` diff --git a/docs/Backend/03. jwt-oauth2-test-scenarios.md b/docs/Backend/03. jwt-oauth2-test-scenarios.md new file mode 100644 index 0000000..efe3908 --- /dev/null +++ b/docs/Backend/03. jwt-oauth2-test-scenarios.md @@ -0,0 +1,1281 @@ +# JWT + OAuth2 인증 시스템 테스트 시나리오 + +## 목적 +본 문서는 SearchWeb의 JWT + OAuth2 인증 시스템에 대한 QA 검증용 테스트 시나리오를 정의한다. +추후 JUnit 테스트 코드 작성 시 가이드로도 활용한다. + +## 대상 시스템 개요 + +### 대상 컴포넌트 (15개) +| # | 컴포넌트 | 패키지 | 역할 | +|---|---------|--------|------| +| 1 | AuthController | auth.controller | 인증 API 엔드포인트 (refresh, logout, member info) | +| 2 | AuthServiceImpl | auth.service | 토큰 발급/갱신/로그아웃 비즈니스 로직 | +| 3 | RefreshTokenCleanupScheduler | auth.service | 만료/고아 토큰 정리 스케줄러 (매일 3AM) | +| 4 | RefreshTokenDao (MybatisRefreshTokenDao) | auth.dao | Refresh Token DB 접근 (MyBatis) | +| 5 | JwtUtils | config.jwt | Access/Refresh Token 생성, 파싱, 검증 (HMAC-SHA256) | +| 6 | JwtAuthenticationFilter | config.jwt | Bearer 토큰 추출 및 SecurityContext 설정 | +| 7 | JwtAuthenticationEntryPoint | config.jwt | 인증 실패 시 401 응답 처리 | +| 8 | OAuth2SuccessHandler | config.jwt | OAuth2 성공 후 RefreshToken 발급 및 쿠키 설정 | +| 9 | OAuth2FailureHandler | config.jwt | OAuth2 실패 후 에러 리다이렉트 | +| 10 | SecurityConfig | config.security | 필터 체인, 권한 설정, CORS/CSRF | +| 11 | SecurityUtils | config.security | memberId 추출, 토큰 해싱 (SHA-256) | +| 12 | CookieUtils | config.security | 쿠키 CRUD, 직렬화/역직렬화 | +| 13 | HttpCookieOAuth2AuthorizationRequestRepository | config.security | OAuth2 상태 쿠키 관리 (세션 없이 상태 유지) | +| 14 | CustomOAuth2MemberService | member.service | 소셜 로그인 회원 조회/가입/업데이트 | +| 15 | OwnerCheckAspect | aop | @OwnerCheck AOP 기반 리소스 소유권 인가 | + +### 에러 코드 레퍼런스 +| Code | HTTP Status | Message | 발생 위치 | +|------|------------|---------|----------| +| A001 | 401 | 인증이 필요합니다 | JwtAuthenticationEntryPoint (기본값) | +| A002 | 401 | 토큰이 만료되었습니다 | JwtAuthenticationFilter (ExpiredJwtException) | +| A003 | 401 | 유효하지 않은 토큰입니다 | JwtAuthenticationFilter (JwtException / IllegalArgumentException) | +| A004 | 401 | Refresh Token이 없습니다 | AuthController, AuthServiceImpl (토큰 미존재/만료/재사용 초과) | +| A005 | 403 | 접근 권한이 없습니다 | OwnerCheckAspect (리소스 소유권 불일치) | +| A006 | 400 | 유효하지 않은 리다이렉트 주소입니다 | OAuth2SuccessHandler (Open Redirect 차단) | +| A007 | 400 | 지원하지 않는 소셜 로그인 제공자입니다 | CustomOAuth2MemberService | + +### API 응답 형식 +```json +{ + "code": "200", + "message": "success", + "data": { ... } +} +``` + +--- + +## 🛠️ E2E 시나리오 HTTP 테스트 가이드 + +본 문서의 **Part A (S1~S5 시나리오)**에 대한 HTTP 테스트는 실제 실행 가능한 전용 `.http` 파일로 분리되어 관리됩니다. + +👉 **테스트 파일 위치:** [`src/test/api-test/jwt-oauth2-e2e.http`](../../src/test/api-test/jwt-oauth2-e2e.http) + +**[사용 방법]** +1. 테스트 파일을 IntelliJ HTTP Client 또는 VS Code REST Client에서 엽니다. +2. 파일 최상단의 **[필독] 테스트 세션 연동 가이드**를 따라 브라우저 쿠키와 토큰 변수를 설정합니다. +3. 문서에 기재된 시나리오 번호 (예: `[S1-Step 1]`)를 테스트 파일에서 찾아 순서대로 실행 버튼(▶️)을 클릭합니다. + +--- + +--- + +## 목차 + +### Part A: 시나리오 기반 E2E 테스트 +0. [시나리오 기반 E2E 테스트](#시나리오-기반-e2e-테스트) + - [S1: OAuth2 소셜 로그인 (최초 진입)](#s1-oauth2-소셜-로그인-최초-진입) + - [S2: 페이지 새로고침 시 인증 복구](#s2-페이지-새로고침-시-인증-복구) + - [S3: API 요청 중 Access Token 만료](#s3-api-요청-중-access-token-만료) + - [S4: Refresh Token Rotation과 Grace Period](#s4-refresh-token-rotation과-grace-period) + - [S5: 로그아웃](#s5-로그아웃) + +### Part B: 컴포넌트별 상세 테스트 +1. [그룹 1: Token Refresh](#그룹-1-token-refresh) +2. [그룹 2: Logout](#그룹-2-logout) +3. [그룹 3: 인증 정보 조회](#그룹-3-인증-정보-조회) +4. [그룹 4: JWT 필터 & 인가](#그룹-4-jwt-필터--인가) +5. [그룹 5: OAuth2 소셜 로그인](#그룹-5-oauth2-소셜-로그인) +6. [그룹 6: 보안 & 인프라](#그룹-6-보안--인프라) +7. [부록: 컴포넌트 크로스레퍼런스](#부록-컴포넌트-크로스레퍼런스) + +--- + +## 시나리오 기반 E2E 테스트 + +> 아키텍처 문서([02. jwt-oauth2-architecture.md](./02.%20jwt-oauth2-architecture.md))에 정의된 5개 인증 시나리오를 기반으로 한 End-to-End 테스트 케이스. +> 프론트엔드 → 백엔드 → DB 전체 플로우를 검증한다. + +### 요약 표 +| ID | 시나리오 | 주요 플로우 | 우선순위 | 관련 상세 케이스 | +|----|---------|-----------|---------|---------------| +| S1 | OAuth2 소셜 로그인 (최초 진입) | 구글 로그인 → 회원 처리 → 토큰(RT/AT) 획득 | Critical | AUTH-034, AUTH-037, AUTH-041, AUTH-001, AUTH-018 | +| S2 | 페이지 새로고침 시 인증 복구 | 화면 새로고침 → 쿠키 확인 → 로그인 상태 복구 | High | AUTH-001, AUTH-018 | +| S3 | API 요청 중 Access Token 만료 | 요청 만료(401) → 자동 갱신 → 즉시 재시도 | High | AUTH-026, AUTH-001, AUTH-024 | +| S4 | Refresh Token Rotation + Grace Period | 보안 회전(RTR) → 10초 유예 허용 → 도난 집중 감시 | Critical | AUTH-001~004, AUTH-011 | +| S5 | 로그아웃 | 로그아웃 → DB 기록 파기 → 쿠키/메모리 초기화 | High | AUTH-013, AUTH-017 | + +--- + +### S1: OAuth2 소셜 로그인 (최초 진입) + +> **시나리오 요약:** +> 1. **로그인 페이지(`/login`):** "구글로 로그인" 버튼 클릭 +> 2. **구글 인증 서버:** 계정 선택 및 본인 인증 완료 +> 3. **백엔드 처리:** 인증 성공 및 **'임시 열쇠(RT)'** 쿠키 발급 +> 4. **로그인 처리 중(`/auth/callback`):** 앱 구동 및 서버에 `refresh` 요청 (임시 RT 전송) +> 5. **열쇠 교체 및 입장권 수령: 새로운 **'정식 열쇠(RT)'로 교체(Rotation) 및 '입장권(AT)'** 발급 완료** +> 6. **내 링크 페이지 `/my-links`:** 대시보드 진입 및 서비스 이용 시작 + +#### S1-01: Google OAuth2 최초 로그인 전체 플로우 (신규 회원) + +| 항목 | 내용 | +|------|------| +| **ID** | S1-01 | +| **컴포넌트** | HttpCookieOAuth2AuthorizationRequestRepository → Google OAuth2 → CustomOAuth2MemberService → OAuth2SuccessHandler → AuthController.refresh() → AuthController.getAuthenticatedMemberInfo() | +| **우선순위** | Critical | +| **사전조건** | DB에 해당 Google 계정의 회원이 존재하지 않음 | +| **테스트 데이터** | Google OAuth2 테스트 계정 | + +**실행 단계 및 검증:** + +| Step | 요청 | 기대 결과 | 검증 포인트 | +|------|------|----------|------------| +| 1. 인가 요청 | `GET /oauth2/authorization/google` | 302 → Google 인증 페이지 | `oauth2_auth_request` 쿠키 생성 (180초 만료) | +| 2. 사용자 동의 | Google에서 동의 후 콜백 | `GET /login/oauth2/code/google?code=...&state=...` | state 파라미터가 쿠키의 값과 일치 | +| 3. 회원 처리 | (서버 내부) CustomOAuth2MemberService.loadUser() | 신규 회원 DB 삽입 | member 테이블에 loginId="google{providerId}" 레코드 생성 | +| 4. 토큰 발급 | (서버 내부) OAuth2SuccessHandler | 302 → /auth/callback + Set-Cookie | refreshToken 쿠키: HttpOnly, SameSite=Lax, Path=/api/auth | +| 5. AT 획득 | `POST /api/auth/refresh` (RT 쿠키 포함) | 200 + accessToken | DB에 새 RT 해시 저장, 기존 RT의 rotated_at 갱신 | +| 6. 회원 정보 | `GET /api/auth/member` (Bearer AT) | 200 + MemberInfo | memberId, name, email, role 반환 | + +**▶️ 테스트 실행 가이드:** +👉 `jwt-oauth2-e2e.http` 파일의 **[S1-Step 1]** 부터 **[S1-Step 6]** 까지 순서대로 실행하세요. +*(💡 참고: 실제 프론트엔드 환경은 로그인 직후 자동으로 1회 `/api/auth/refresh`를 수행하여 입장권(AT)을 받지만, 이 테스트 파일에서는 원활한 변수 연동과 단계별 검증을 위해 해당 과정을 수동으로 분리해 두었습니다.)* + +**DB 검증:** +- member 테이블: `loginId="google{providerId}"`, `role="ROLE_USER"` 레코드 존재 +- refresh_token 테이블: `member_id=1`, `rotated_at` 갱신됨, 새 토큰 해시 삽입됨 +- 임시 쿠키 (`oauth2_auth_request`, `redirect_uri`) 삭제됨 + +--- + +#### S1-02: OAuth2 로그인 (기존 회원 — 정보 업데이트) + +| 항목 | 내용 | +|------|------| +| **ID** | S1-02 | +| **컴포넌트** | CustomOAuth2MemberService → OAuth2SuccessHandler | +| **우선순위** | High | +| **사전조건** | DB에 해당 loginId("google{providerId}")로 회원이 이미 존재 | +| **테스트 데이터** | Google 계정 (이름/이메일이 기존과 다르게 변경된 상태) | +| **실행 단계** | S1-01과 동일한 OAuth2 플로우 실행 | +| **기대 결과** | SocialjoinProcess 대신 **updateSocialMember** 호출. DB의 email/name이 최신 정보로 업데이트됨. 기존 RT 삭제 후 새 RT 발급 | + +--- + +#### S1-03: OAuth2 로그인 실패 — 쿠키 만료 (3분 초과) + +| 항목 | 내용 | +|------|------| +| **ID** | S1-03 | +| **컴포넌트** | HttpCookieOAuth2AuthorizationRequestRepository → OAuth2FailureHandler | +| **우선순위** | High | +| **사전조건** | OAuth2 인가 요청 후 3분(180초) 이상 경과하여 oauth2_auth_request 쿠키 만료 | +| **테스트 데이터** | 만료된 oauth2_auth_request 쿠키 | +| **실행 단계** | 인가 요청 → 3분 이상 대기 → OAuth2 콜백 도착 | +| **기대 결과** | **302 리다이렉트** `/login?error=true&message=인증 요청을 찾을 수 없습니다...` (authorization_request_not_found) | + + +**▶️ 테스트 실행 가이드:** +👉 `jwt-oauth2-e2e.http` 파일의 **[S1-Step 1]** 부터 **[S1-Step 3]** 까지 실행하여 갱신 실패 케이스를 확인하세요. + + +--- + +### S2: 페이지 새로고침 시 인증 복구 + +> **시나리오 요약:** +> 1. **현재 페이지:** 사용자의 새로고침(F5) 수행 +> 2. **메모리 초기화:** 기존 입장권(AT) 증발 및 앱 재실행 +> 3. **로그인 확인:** 브라우저 쿠키에서 **'재로그인용 열쇠(RT)'** 감지 +> 4. **서버 요청:** 서버에 `refresh` 요청 및 기존 열쇠 전송 +> 5. **토큰 갱신:** **새 입장권(AT)** 수령 및 **새 열쇠(RT)** 쿠키 교체 (Rotation) +> 6. **상태 복구:** 로그인 상태 유지 및 기존 화면 데이터 재호출 완료 + +#### S2-01: 새로고침 후 인증 복구 성공 + +| 항목 | 내용 | +|------|------| +| **ID** | S2-01 | +| **컴포넌트** | (FE) AuthProvider.initialize() → AuthController.refresh() → AuthController.getAuthenticatedMemberInfo() | +| **우선순위** | High | +| **사전조건** | 사용자가 이미 로그인된 상태 (유효한 refreshToken 쿠키 존재) | +| **테스트 데이터** | 유효한 refreshToken 쿠키 | + +**실행 단계 및 검증:** + +| Step | 동작 | 기대 결과 | 검증 포인트 | +|------|------|----------|------------| +| 1. 새로고침 | F5 → Zustand 초기화 (accessToken=null) | AuthProvider 마운트 시 initialize() 호출 | - | +| 2. 토큰 갱신 | `POST /api/auth/refresh` (RT 쿠키 자동 전송) | 200 + 새 accessToken | Set-Cookie에 새 RT 포함 | +| 3. 회원 정보 | `GET /api/auth/member` (Bearer 새 AT) | 200 + MemberInfo | isAuthenticated=true 설정 | +| 4. 화면 표시 | 대시보드 렌더링 | /my-links 페이지 정상 표시 | isInitializing=false | + +**▶️ 테스트 실행 가이드:** +👉 `jwt-oauth2-e2e.http` 파일의 **[S2-Step 2]** 부터 **[S2-Step 3]** 까지 순서대로 실행하세요. + +--- + +#### S2-02: 새로고침 — RT 쿠키 만료 시 로그인 페이지 이동 + +| 항목 | 내용 | +|------|------| +| **ID** | S2-02 | +| **컴포넌트** | (FE) AuthProvider.initialize() → AuthController.refresh() | +| **우선순위** | High | +| **사전조건** | refreshToken 쿠키가 만료되어 브라우저가 쿠키를 전송하지 않음 | +| **테스트 데이터** | 만료된 refreshToken 쿠키 (브라우저에서 자동 삭제) | + +**실행 단계 및 검증:** + +| Step | 동작 | 기대 결과 | 검증 포인트 | +|------|------|----------|------------| +| 1. 새로고침 | F5 → initialize() 호출 | - | - | +| 2. 토큰 갱신 시도 | `POST /api/auth/refresh` (쿠키 없음) | 401 A004 | "Refresh Token이 없습니다" | +| 3. 로그아웃 처리 | (FE) authStore.logout() | isAuthenticated=false | /login 페이지로 리다이렉트 | + +--- + +### S3: API 요청 중 Access Token 만료 + +> **시나리오 요약:** +> 1. **서비스 이용 중:** 입장권(AT) 만료 상태로 API 호출 +> 2. **인증 거절:** 서버로부터 401(Unauthorized) 응답 수신 +> 3. **자동 가로채기:** 통신 모듈(`fetchClient`)이 에러를 감지하고 요청 일시 중단 +> 4. **토큰 교환:** 쿠키의 **열쇠(RT)**를 보내 서버로부터 **새 입장권(AT)** 수령 +> 5. **요청 재시도:** 새 입장권으로 기존 API 요청 자동 재전송 +> 6. **정상 응답:** 작업 성공 및 사용자 중단 없는 서비스 이용 지속 + +#### S3-01: AT 만료 → 자동 갱신 → 원래 요청 재시도 성공 + +| 항목 | 내용 | +|------|------| +| **ID** | S3-01 | +| **컴포넌트** | (FE) fetchClient → JwtAuthenticationFilter → AuthController.refresh() → 원래 API | +| **우선순위** | High | +| **사전조건** | 유효한 RT 쿠키 존재, AT가 만료된 상태 | +| **테스트 데이터** | 만료된 accessToken, 유효한 refreshToken 쿠키 | + +**실행 단계 및 검증:** + +| Step | 요청 | 기대 결과 | 검증 포인트 | +|------|------|----------|------------| +| 1. API 호출 | `GET /api/bookmarks` (Bearer: 만료된 AT) | 401 A002 | JwtAuthenticationFilter → ExpiredJwtException | +| 2. 자동 갱신 | (FE) `POST /api/auth/refresh` (RT 쿠키) | 200 + 새 AT | Zustand에 새 AT 저장 | +| 3. 재시도 | `GET /api/bookmarks` (Bearer: 새 AT) | 200 + 북마크 목록 | 사용자에게 끊김 없는 경험 | + +**▶️ 테스트 실행 가이드:** +👉 `jwt-oauth2-e2e.http` 파일의 **[S3-Step 1]** 부터 **[S3-Step 3]** 까지 순서대로 실행하세요. + +--- + +#### S3-02: AT 만료 → 자동 갱신 실패 (RT도 만료) → 로그아웃 + +| 항목 | 내용 | +|------|------| +| **ID** | S3-02 | +| **컴포넌트** | (FE) fetchClient → AuthController.refresh() | +| **우선순위** | High | +| **사전조건** | AT 만료, RT 쿠키도 만료됨 | +| **테스트 데이터** | 만료된 accessToken, 만료된/없는 refreshToken | + +**실행 단계 및 검증:** + +| Step | 동작 | 기대 결과 | 검증 포인트 | +|------|------|----------|------------| +| 1. API 호출 | `GET /api/bookmarks` (Bearer: 만료 AT) | 401 A002 | - | +| 2. 갱신 시도 | `POST /api/auth/refresh` (쿠키 없음/만료) | 401 A004 | Refresh Token 없음 | +| 3. 강제 로그아웃 | (FE) authStore.logout() | isAuthenticated=false | /login 리다이렉트 | + +--- + +#### S3-03: 동시 다중 API 401 → 갱신 요청 1회만 발생 + +| 항목 | 내용 | +|------|------| +| **ID** | S3-03 | +| **컴포넌트** | (FE) fetchClient.refreshPromise 싱글톤 | +| **우선순위** | Medium | +| **사전조건** | AT 만료 상태에서 여러 API 동시 호출 | +| **테스트 데이터** | 만료된 AT, 유효한 RT, 동시 3개 API 호출 | + +**실행 단계 및 검증:** + +| Step | 동작 | 기대 결과 | 검증 포인트 | +|------|------|----------|------------| +| 1. 동시 호출 | GET /api/bookmarks, GET /api/folders, GET /api/tags (모두 만료 AT) | 3개 모두 401 | - | +| 2. 갱신 | `POST /api/auth/refresh` | **1회만 호출** | refreshPromise 공유로 중복 방지 | +| 3. 재시도 | 3개 API 모두 새 AT로 재시도 | 3개 모두 200 OK | 모든 대기 중인 요청이 새 AT 사용 | + +--- + +### S4: Refresh Token Rotation과 Grace Period + +> **시나리오 요약:** +> 1. **열쇠 교체:** 토큰 갱신 시마다 **새로운 열쇠(RT)** 발급 및 기존 열쇠 폐기 +> 2. **지연 대비:** 통신 장애를 고려해 **직전 열쇠도 10초간** 유효성 인정 (Grace Period) +> 3. **중복 요청:** 10초 이내 재사용 시 **새로운 입장권(AT)** 재발급 완료 +> 4. **이상 감지:** 10초 초과 혹은 중복 사용 횟수 초과 시 즉시 거부 +> 5. **도난 차단:** 도난 의심 토큰 발견 시 해당 사용자의 모든 세션 강제 종료 +> 6. **보안 강화:** 주기적인 열쇠 교체 및 유예 시간을 통한 안정적 세션 관리 + +#### S4-01: Case 1 — 정상 Rotation (첫 사용) + +| 항목 | 내용 | +|------|------| +| **ID** | S4-01 | +| **컴포넌트** | AuthController.refresh() → AuthServiceImpl.refresh() (rotatedAt==null 분기) | +| **우선순위** | Critical | +| **사전조건** | 유효한 RT-A 쿠키, DB에 rotated_at=NULL | +| **테스트 데이터** | RT-A: 미사용 Refresh Token | + +**실행 단계 및 검증:** + +| Step | 동작 | 기대 결과 | DB 상태 | +|------|------|----------|---------| +| 1 | `POST /api/auth/refresh` (Cookie: RT-A) | 200 + {AT, RT-B} | RT-A: rotated_at = NOW() | +| 2 | RT-B 쿠키 수신 | Set-Cookie: RT-B | 새 RT-B 해시 삽입됨 | + +**▶️ 테스트 실행 가이드:** +👉 `jwt-oauth2-e2e.http` 파일의 **[S4-Case 1] 정상 Rotation (토큰 교체)** 블록을 실행하세요. + +**DB 검증:** +```sql +-- RT-A: rotated_at이 갱신됨 +SELECT rotated_at FROM refresh_token WHERE token_hash = SHA256('RT-A'); +-- → NOT NULL (현재 시간) + +-- RT-B: 새로 삽입됨 +SELECT * FROM refresh_token WHERE token_hash = SHA256('RT-B'); +-- → 존재, rotated_at = NULL +``` + +--- + +#### S4-02: Case 2 — Grace Period 내 재사용 (네트워크 지연) + +| 항목 | 내용 | +|------|------| +| **ID** | S4-02 | +| **컴포넌트** | AuthServiceImpl.refresh() (rotatedAt != null, 10초 이내 분기) | +| **우선순위** | Critical | +| **사전조건** | S4-01 완료 후 10초 이내 | +| **테스트 데이터** | RT-A (이미 회전됨, rotated_at이 10초 이내) | + +**실행 단계 및 검증:** + +| Step | 동작 | 기대 결과 | DB 상태 | +|------|------|----------|---------| +| 1 (T+0s) | S4-01 실행 (RT-A → RT-B) | 성공 | RT-A: rotated_at = T | +| 2 (T+3s) | `POST /api/auth/refresh` (Cookie: RT-A) — 응답 미수신으로 재시도 | 200 + {AT, RT-C} | RT-A: **DB에서 삭제됨** | +| 3 (T+7s) | `POST /api/auth/refresh` (Cookie: RT-A) — 3번째 시도 | **401 A004** | RT-A 해시가 DB에 없음 | + +**핵심 검증:** +- Step 2에서 RT-A가 DB에서 **즉시 삭제**됨 (deleteByTokenHash) → 재사용 1회만 허용 +- Step 3에서 동일 토큰 3번째 사용 시도는 DB 조회 실패로 거부 + +**▶️ 테스트 실행 가이드:** +👉 `jwt-oauth2-e2e.http` 파일의 **[S4-Case 2] Grace Period (10초) 내 재사용 시도** 블록을 실행하세요. (Case 1 직후 10초 이내 실행) + +--- + +#### S4-03: Case 3 — Grace Period 초과 (토큰 탈취 의심) + +| 항목 | 내용 | +|------|------| +| **ID** | S4-03 | +| **컴포넌트** | AuthServiceImpl.refresh() (rotatedAt != null, 10초 초과 분기) | +| **우선순위** | Critical | +| **사전조건** | S4-01 완료 후 10초 초과 경과 | +| **테스트 데이터** | RT-A (rotated_at이 10초 초과) | + +**실행 단계 및 검증:** + +| Step | 동작 | 기대 결과 | +|------|------|----------| +| 1 (T+0s) | S4-01 실행 (RT-A 회전) | 성공 | +| 2 (T+15s) | `POST /api/auth/refresh` (Cookie: RT-A) | **401 A004** | + +**▶️ 테스트 실행 가이드:** +👉 `jwt-oauth2-e2e.http` 파일의 **[S4-Case 3] Grace Period 초과 (10초 후 재사용 시도)** 블록을 실행하세요. (Case 1, 2 실행 후 10초 이상 대기 후 실행) + +**보안 의미:** 10초를 넘긴 재사용은 네트워크 지연이 아닌 **토큰 탈취**로 간주하여 즉시 거부. + +--- + +#### S4-04: Case 4 — 동시 요청 제어 (DB 비관적 락) + +| 항목 | 내용 | +|------|------| +| **ID** | S4-04 | +| **컴포넌트** | AuthServiceImpl.refresh(), RefreshTokenDao.findByTokenHashForUpdate() | +| **우선순위** | High | +| **사전조건** | 유효한 RT-A, 멀티탭 환경에서 동시 갱신 | +| **테스트 데이터** | 동일 RT-A로 2개 동시 요청 (R1, R2) | + +**실행 단계 및 검증:** + +| Step | R1 (먼저 도착) | R2 (약간 늦게 도착) | +|------|---------------|-------------------| +| 1 | `SELECT ... FOR UPDATE` → **락 획득** | `SELECT ... FOR UPDATE` → **대기** | +| 2 | rotatedAt = NOW(), INSERT RT-B, **COMMIT** | (대기 중...) | +| 3 | 200 + {AT, RT-B} 응답 | 락 획득 → RT-A 조회 (rotatedAt != null) | +| 4 | - | Grace Period 로직 → 200 + {AT, RT-C} (또는 시간 초과 시 401) | + +**핵심 검증:** +- `FOR UPDATE`로 인해 두 요청이 동시에 같은 토큰을 처리하지 않음 +- R2는 R1의 트랜잭션 완료 후 이미 회전된 상태의 RT-A를 만남 → Case 2 (Grace Period) 로직으로 자연스럽게 분기 + +--- + +### S5: 로그아웃 + +> **시나리오 요약:** +> 1. **서비스 화면:** 사용자의 로그아웃 버튼 클릭 +> 2. **로그아웃 요청:** 서버에 `POST /api/auth/logout` 호출 +> 3. **서버 파기:** DB 내 **'열쇠(RT) 기록'** 즉시 삭제 +> 4. **쿠키 제거:** 브라우저 쿠키 삭제 명령 및 **물리적 제거** 완료 +> 5. **클라이언트 정리:** 메모리 내 입장권(AT) 및 인증 상태 초기화 +> 6. **페이지 이동:** `/login` 화면으로 리다이렉트 및 접근 차단 확인 + +#### S5-01: 정상 로그아웃 전체 플로우 + +| 항목 | 내용 | +|------|------| +| **ID** | S5-01 | +| **컴포넌트** | (FE) authStore.logout() → AuthController.logout() → AuthServiceImpl.logout() | +| **우선순위** | High | +| **사전조건** | 사용자가 로그인된 상태 (유효한 AT + RT 쿠키) | +| **테스트 데이터** | 유효한 refreshToken 쿠키 | + +**실행 단계 및 검증:** + +| Step | 동작 | 기대 결과 | 검증 포인트 | +|------|------|----------|------------| +| 1. 로그아웃 요청 | `POST /api/auth/logout` (Cookie: RT) | 200 OK | - | +| 2. 서버 처리 | RT → SHA-256 해시 → DB DELETE | 토큰 레코드 삭제 | refresh_token 테이블에서 해당 해시 없음 | +| 3. 쿠키 제거 | Set-Cookie: refreshToken=""; maxAge=0 | 브라우저 쿠키 삭제 | HttpOnly, SameSite=Lax 유지 | +| 4. 클라이언트 | (FE) accessToken=null, isAuthenticated=false | /login 이동 | Zustand 스토어 초기화 | + +**▶️ 테스트 실행 가이드:** +👉 `jwt-oauth2-e2e.http` 파일의 **[S5-Step 1] 로그아웃 실행** 블록을 실행하세요. + +**DB 검증:** +```sql +-- 로그아웃 후 토큰 삭제 확인 +SELECT * FROM refresh_token WHERE token_hash = SHA256('로그아웃한_토큰'); +-- → 0 rows (삭제됨) +``` + +--- + +#### S5-02: 로그아웃 후 토큰 재사용 불가 검증 + +| 항목 | 내용 | +|------|------| +| **ID** | S5-02 | +| **컴포넌트** | AuthController.logout() → AuthController.refresh() | +| **우선순위** | High | +| **사전조건** | S5-01 완료 상태 | +| **테스트 데이터** | S5-01에서 사용한 refreshToken | + +**실행 단계 및 검증:** + +| Step | 동작 | 기대 결과 | +|------|------|----------| +| 1 | `POST /api/auth/logout` (Cookie: RT) | 200 OK (로그아웃 성공) | +| 2 | `POST /api/auth/refresh` (Cookie: 동일 RT) | **401 A004** (DB에서 삭제됨) | +| 3 | `GET /api/bookmarks` (Bearer: 이전 AT) | **401 A002** (AT 만료 시) 또는 정상 (AT 미만료 시) | + +**보안 의미:** 로그아웃으로 RT가 DB에서 삭제되므로, AT가 만료되면 더 이상 갱신할 수 없어 완전한 세션 종료가 보장됨. + +**▶️ 테스트 실행 가이드:** +👉 `jwt-oauth2-e2e.http` 파일의 **[S5-Step 2] 로그아웃 후 다시 토큰 갱신 시도** 블록을 실행하세요. + +--- + +### 시나리오 ↔ 상세 케이스 매핑 + +| E2E 시나리오 | 관련 상세 테스트 케이스 (Part B) | +|-------------|-------------------------------| +| S1-01 | AUTH-034, AUTH-037, AUTH-038, AUTH-039, AUTH-041, AUTH-042, AUTH-001, AUTH-018 | +| S1-02 | AUTH-035 | +| S1-03 | AUTH-044, AUTH-055 | +| S2-01 | AUTH-001, AUTH-018 | +| S2-02 | AUTH-007 | +| S3-01 | AUTH-026, AUTH-001, AUTH-024 | +| S3-02 | AUTH-026, AUTH-007 | +| S3-03 | (FE 전용, 백엔드 단일 AUTH-001) | +| S4-01 | AUTH-001 | +| S4-02 | AUTH-002, AUTH-003 | +| S4-03 | AUTH-004 | +| S4-04 | AUTH-011 | +| S5-01 | AUTH-013 | +| S5-02 | AUTH-017 | + +--- + +## 그룹 1: Token Refresh + +`POST /api/auth/refresh` — Refresh Token 쿠키를 사용하여 Access Token을 갱신하는 플로우. + +### 요약 표 +| ID | 시나리오 | 요청 | 기대 응답 | 검증 포인트 | +|----|---------|------|----------|------------| +| AUTH-001 | 정상 갱신 (첫 사용) | POST /api/auth/refresh + 유효한 쿠키 | 200 + 새 토큰 | accessToken 반환, 쿠키 갱신, DB rotated_at 갱신 | +| AUTH-002 | Grace Period 내 재사용 (10초 이내) | POST /api/auth/refresh + 이미 회전된 토큰 | 200 + 새 토큰 | 기존 토큰 DB 삭제, 새 토큰 발급 (1회만 허용) | +| AUTH-003 | Grace Period 재사용 후 3번째 사용 | POST /api/auth/refresh + 삭제된 토큰 | 401 A004 | 이미 삭제된 토큰이므로 실패 | +| AUTH-004 | Grace Period 초과 재사용 (10초 초과) | POST /api/auth/refresh + 회전 후 10초 초과 토큰 | 401 A004 | 보안 위반 감지, 거부 | +| AUTH-005 | 만료된 Refresh Token | POST /api/auth/refresh + 만료 JWT | 401 A002 | "토큰이 만료되었습니다" | +| AUTH-006 | 위변조된 Refresh Token | POST /api/auth/refresh + 서명 변조 쿠키 | 401 A003 | "유효하지 않은 토큰입니다" | +| AUTH-007 | 쿠키 없이 요청 | POST /api/auth/refresh (쿠키 없음) | 401 A004 | "Refresh Token이 없습니다" | +| AUTH-008 | DB에 없는 토큰 해시 | POST /api/auth/refresh + 유효한 JWT이나 DB 미존재 | 401 A004 | 토큰 해시 조회 실패 | +| AUTH-009 | 만료된 DB 레코드 | POST /api/auth/refresh + expiresAt < NOW | 401 A002 | DB 레코드 만료 체크 | +| AUTH-010 | 삭제된 회원의 토큰 | POST /api/auth/refresh + 삭제된 회원 | 401 A001 | getMemberRole → member null → AUTH_UNAUTHORIZED | +| AUTH-011 | 동시 갱신 요청 | 2개 동시 POST /api/auth/refresh (동일 토큰) | 1개 성공, 1개 실패 | Pessimistic Lock (FOR UPDATE) 동작 검증 | +| AUTH-012 | 새 로그인 후 이전 토큰 사용 | POST /api/auth/refresh + 이전 세션 토큰 | 401 A004 | issueRefreshToken → deleteByMemberId로 이미 삭제 | + +### 상세 케이스 + +--- + +#### AUTH-001: 정상 토큰 갱신 (첫 사용) + +| 항목 | 내용 | +|------|------| +| **ID** | AUTH-001 | +| **컴포넌트** | AuthController.refresh(), AuthServiceImpl.refresh(), JwtUtils | +| **우선순위** | High | +| **사전조건** | 회원(memberId=1)이 OAuth2 로그인 완료. DB에 유효한 refresh_token 레코드 존재 (rotated_at=NULL, expires_at > NOW()) | +| **테스트 데이터** | refreshToken: 유효한 JWT (memberId=1, 미만료, 서명 정상) | +| **실행 단계** | POST /api/auth/refresh (Cookie: refreshToken=...) | +| **기대 결과** | 200 OK, 새 accessToken 반환, 새 refreshToken 쿠키 설정, DB에서 기존 토큰의 rotated_at이 현재 시간으로 갱신됨 | + + + +**DB 검증:** +- 기존 refresh_token 레코드의 `rotated_at`이 NULL → 현재 시간으로 갱신됨 +- 새 refresh_token 레코드가 삽입됨 (token_hash = SHA-256(new_token)) + +--- + +#### AUTH-002: Grace Period 내 재사용 (10초 이내) + +| 항목 | 내용 | +|------|------| +| **ID** | AUTH-002 | +| **컴포넌트** | AuthServiceImpl.refresh() (lines 98-103) | +| **우선순위** | High | +| **사전조건** | AUTH-001 이후 상태. DB에 rotated_at != NULL이고 (NOW() - rotated_at < 10초)인 refresh_token 레코드 존재 | +| **테스트 데이터** | refreshToken: AUTH-001에서 사용한 (회전된) 동일 토큰 | +| **실행 단계** | AUTH-001 성공 직후 10초 이내에 동일 토큰으로 POST /api/auth/refresh | +| **기대 결과** | 200 OK, 새 토큰 발급. 기존 rotated 토큰이 DB에서 **삭제**(deleteByTokenHash). 재사용 1회만 허용 | + + + +**DB 검증:** +- 기존 rotated 토큰 레코드가 DB에서 **삭제됨** (deleteByTokenHash) +- 새 refresh_token 레코드 삽입됨 + +--- + +#### AUTH-003: Grace Period 재사용 후 3번째 사용 시도 + +| 항목 | 내용 | +|------|------| +| **ID** | AUTH-003 | +| **컴포넌트** | AuthServiceImpl.refresh() (line 87-88) | +| **우선순위** | High | +| **사전조건** | AUTH-002 이후 상태. AUTH-001에서 사용한 원본 토큰은 AUTH-002에서 DB 삭제됨 | +| **테스트 데이터** | refreshToken: AUTH-001/002에서 사용한 동일 원본 토큰 (3번째 사용 시도) | +| **실행 단계** | AUTH-002 성공 이후 동일 토큰으로 POST /api/auth/refresh | +| **기대 결과** | 401, A004 에러. 토큰 해시가 DB에 없으므로 실패 | + + + +--- + +#### AUTH-004: Grace Period 초과 재사용 (10초 초과, 보안 위반) + +| 항목 | 내용 | +|------|------| +| **ID** | AUTH-004 | +| **컴포넌트** | AuthServiceImpl.refresh() (lines 104-106) | +| **우선순위** | Critical | +| **사전조건** | AUTH-001 이후 상태. rotated_at != NULL이고 (NOW() - rotated_at > 10초) | +| **테스트 데이터** | refreshToken: 10초 전에 회전된 토큰 | +| **실행 단계** | AUTH-001 성공 후 **10초 이상 대기** 후 동일 토큰으로 POST /api/auth/refresh | +| **기대 결과** | 401, A004 에러. 토큰 재사용 공격 감지 | + + + +--- + +#### AUTH-005: 만료된 Refresh Token + +| 항목 | 내용 | +|------|------| +| **ID** | AUTH-005 | +| **컴포넌트** | AuthServiceImpl.refresh(), JwtUtils.validateToken() | +| **우선순위** | High | +| **사전조건** | refreshToken의 JWT exp 클레임이 현재 시간 이전 | +| **테스트 데이터** | refreshToken: 만료된 JWT (exp < NOW) | +| **실행 단계** | POST /api/auth/refresh + 만료 쿠키 | +| **기대 결과** | 401, A002 에러 | + + + +--- + +#### AUTH-006: 위변조된 Refresh Token + +| 항목 | 내용 | +|------|------| +| **ID** | AUTH-006 | +| **컴포넌트** | AuthServiceImpl.refresh(), JwtUtils.validateToken() | +| **우선순위** | Critical | +| **사전조건** | 없음 | +| **테스트 데이터** | refreshToken: 서명이 변조된 JWT (payload 변경 후 서명 불일치) | +| **실행 단계** | POST /api/auth/refresh + 변조 쿠키 | +| **기대 결과** | 401, A003 에러 | + + + +--- + +#### AUTH-007: 쿠키 없이 요청 + +| 항목 | 내용 | +|------|------| +| **ID** | AUTH-007 | +| **컴포넌트** | AuthController.refresh() (line 47) | +| **우선순위** | High | +| **사전조건** | 없음 | +| **테스트 데이터** | 요청에 refreshToken 쿠키 없음 | +| **실행 단계** | POST /api/auth/refresh (쿠키 헤더 없음) | +| **기대 결과** | 401, A004 에러 | + + + +--- + +#### AUTH-008: DB에 없는 토큰 해시 + +| 항목 | 내용 | +|------|------| +| **ID** | AUTH-008 | +| **컴포넌트** | AuthServiceImpl.refresh() (line 87-88) | +| **우선순위** | Medium | +| **사전조건** | 없음 | +| **테스트 데이터** | refreshToken: 유효한 JWT (서명 정상, 미만료)이나 DB에 해당 해시 없음 | +| **실행 단계** | POST /api/auth/refresh + 유효하지만 DB에 없는 토큰 | +| **기대 결과** | 401, A004 에러 | + +--- + +#### AUTH-009: 만료된 DB 레코드 (expiresAt < NOW) + +| 항목 | 내용 | +|------|------| +| **ID** | AUTH-009 | +| **컴포넌트** | AuthServiceImpl.refresh() (lines 92-95) | +| **우선순위** | Medium | +| **사전조건** | DB에 refresh_token 레코드 존재하나 expires_at < NOW() | +| **테스트 데이터** | refreshToken: JWT는 유효하나 DB 레코드의 expires_at이 과거 시간 | +| **실행 단계** | POST /api/auth/refresh | +| **기대 결과** | 401, A002 에러. DB 만료 체크에서 걸림 | + +--- + +#### AUTH-010: 삭제된 회원의 토큰으로 갱신 시도 + +| 항목 | 내용 | +|------|------| +| **ID** | AUTH-010 | +| **컴포넌트** | AuthServiceImpl.refresh() → getMemberRole() (lines 120-126) | +| **우선순위** | Medium | +| **사전조건** | DB에 refresh_token 레코드 존재, 그러나 해당 member가 soft-deleted (deleted_at IS NOT NULL) | +| **테스트 데이터** | refreshToken: 유효한 JWT, memberId가 삭제된 회원 | +| **실행 단계** | POST /api/auth/refresh | +| **기대 결과** | 401, A001 에러. findByMemberId → null → AUTH_UNAUTHORIZED | + +--- + +#### AUTH-011: 동시 갱신 요청 (Pessimistic Lock) + +| 항목 | 내용 | +|------|------| +| **ID** | AUTH-011 | +| **컴포넌트** | AuthServiceImpl.refresh(), RefreshTokenDao.findByTokenHashForUpdate() | +| **우선순위** | High | +| **사전조건** | DB에 유효한 refresh_token 레코드 존재 | +| **테스트 데이터** | 동일한 refreshToken으로 2개 동시 요청 | +| **실행 단계** | 2개의 POST /api/auth/refresh를 동시에 전송 (동일 토큰) | +| **기대 결과** | 1개 요청 성공 (200), 다른 요청은 FOR UPDATE 락에 의해 블록 후 실패 (토큰 상태 변경됨) | + +**DB 검증:** +- `findByTokenHashForUpdate`의 `SELECT ... FOR UPDATE`가 동시성을 제어 +- 첫 번째 요청이 rotated_at을 갱신하면, 두 번째 요청은 이미 회전된 토큰을 만남 + +--- + +#### AUTH-012: 새 로그인 후 이전 토큰으로 갱신 시도 + +| 항목 | 내용 | +|------|------| +| **ID** | AUTH-012 | +| **컴포넌트** | AuthServiceImpl.issueRefreshToken() → deleteByMemberId(), AuthServiceImpl.refresh() | +| **우선순위** | High | +| **사전조건** | 회원이 OAuth2 재로그인하여 issueRefreshToken이 호출됨 → deleteByMemberId로 기존 토큰 모두 삭제 | +| **테스트 데이터** | refreshToken: 재로그인 전에 발급된 이전 세션 토큰 | +| **실행 단계** | POST /api/auth/refresh + 이전 세션 토큰 | +| **기대 결과** | 401, A004 에러. deleteByMemberId로 이미 삭제되어 DB 조회 실패 | + +--- + +## 그룹 2: Logout + +`POST /api/auth/logout` — Refresh Token 삭제 및 쿠키 제거. + +### 요약 표 +| ID | 시나리오 | 요청 | 기대 응답 | 검증 포인트 | +|----|---------|------|----------|------------| +| AUTH-013 | 정상 로그아웃 | POST /api/auth/logout + 유효한 쿠키 | 200 OK | DB 토큰 삭제, 쿠키 maxAge=0 | +| AUTH-014 | 이미 삭제된 토큰으로 로그아웃 | POST /api/auth/logout + DB에 없는 토큰 | 200 OK | 에러 없이 처리 (멱등성) | +| AUTH-015 | 쿠키 없이 로그아웃 | POST /api/auth/logout (쿠키 없음) | 200 OK | refreshToken 파라미터 null, 삭제 스킵 | +| AUTH-016 | 빈 문자열 쿠키로 로그아웃 | POST /api/auth/logout + 빈 쿠키값 | 200 OK | isBlank() 체크 → 삭제 스킵, 쿠키만 제거 | +| AUTH-017 | 로그아웃 후 refresh 시도 | POST /api/auth/logout → POST /api/auth/refresh | 401 A004 | 토큰 삭제 후 갱신 불가 | + +### 상세 케이스 + +--- + +#### AUTH-013: 정상 로그아웃 + +| 항목 | 내용 | +|------|------| +| **ID** | AUTH-013 | +| **컴포넌트** | AuthController.logout() (lines 72-85), AuthServiceImpl.logout() | +| **우선순위** | High | +| **사전조건** | DB에 유효한 refresh_token 레코드 존재 | +| **테스트 데이터** | refreshToken: 유효한 JWT 쿠키 | +| **실행 단계** | POST /api/auth/logout (Cookie: refreshToken=...) | +| **기대 결과** | 200 OK, DB에서 토큰 해시로 레코드 삭제, Set-Cookie로 refreshToken maxAge=0 | + +**HTTP 요청:** +```http +POST /api/auth/logout HTTP/1.1 +Host: localhost:8080 +Cookie: refreshToken=eyJhbGciOiJIUzI1NiJ9... +``` + +**HTTP 응답 (성공):** +```http +HTTP/1.1 200 OK +Set-Cookie: refreshToken=; HttpOnly; SameSite=Lax; Path=/api/auth; Max-Age=0 + +{ + "code": "200", + "message": "success", + "data": null +} +``` + +**DB 검증:** +- refresh_token 테이블에서 해당 token_hash 레코드 삭제됨 + +--- + +#### AUTH-016: 빈 문자열 쿠키로 로그아웃 + +| 항목 | 내용 | +|------|------| +| **ID** | AUTH-016 | +| **컴포넌트** | AuthController.logout() (line 76, isBlank() 체크) | +| **우선순위** | Low | +| **사전조건** | 없음 | +| **테스트 데이터** | refreshToken 쿠키 값이 빈 문자열 ("") | +| **실행 단계** | POST /api/auth/logout (Cookie: refreshToken=) | +| **기대 결과** | 200 OK. isBlank() 체크에 의해 DB 삭제 스킵, 쿠키만 maxAge=0으로 제거 | + +--- + +#### AUTH-017: 로그아웃 후 refresh 시도 + +| 항목 | 내용 | +|------|------| +| **ID** | AUTH-017 | +| **컴포넌트** | AuthController.logout() → AuthController.refresh() | +| **우선순위** | High | +| **사전조건** | AUTH-013 완료 상태 | +| **테스트 데이터** | refreshToken: AUTH-013에서 로그아웃한 토큰 | +| **실행 단계** | 1. POST /api/auth/logout (성공) → 2. POST /api/auth/refresh (동일 토큰) | +| **기대 결과** | Step 2에서 401, A004 에러. DB에서 이미 삭제됨 | + +--- + +## 그룹 3: 인증 정보 조회 + +`GET /api/auth/member` — 현재 인증된 회원 정보를 조회. + +### 요약 표 +| ID | 시나리오 | 요청 | 기대 응답 | 검증 포인트 | +|----|---------|------|----------|------------| +| AUTH-018 | 유효한 토큰으로 조회 | GET /api/auth/member + Bearer 토큰 | 200 + MemberInfo | memberId, name, email, role 반환 | +| AUTH-019 | 만료된 토큰으로 조회 | GET /api/auth/member + 만료 토큰 | 401 A002 | "토큰이 만료되었습니다" | +| AUTH-020 | 토큰 없이 조회 | GET /api/auth/member (헤더 없음) | 401 A001 | "인증이 필요합니다" | +| AUTH-021 | 위변조 토큰으로 조회 | GET /api/auth/member + 변조 토큰 | 401 A003 | "유효하지 않은 토큰입니다" | +| AUTH-022 | 삭제된 회원 토큰 | GET /api/auth/member + 삭제된 회원 ID 토큰 | 에러 | 회원 없음 에러 | +| AUTH-023 | anonymousUser 접근 | SecurityUtils.extractMemberId(anonymousUser) | 401 A001 | anonymousUser 체크 → AUTH_UNAUTHORIZED | + +### 상세 케이스 + +--- + +#### AUTH-018: 유효한 Access Token으로 회원 정보 조회 + +| 항목 | 내용 | +|------|------| +| **ID** | AUTH-018 | +| **컴포넌트** | AuthController.getAuthenticatedMemberInfo() (lines 92-97), SecurityUtils.extractMemberId() | +| **우선순위** | High | +| **사전조건** | 회원(memberId=1) 존재, 유효한 Access Token 보유 | +| **테스트 데이터** | accessToken: 유효한 JWT (sub=1, role=ROLE_USER, 미만료) | +| **실행 단계** | GET /api/auth/member (Authorization: Bearer {accessToken}) | +| **기대 결과** | 200 OK, MemberInfo(memberId, name, email, role) 반환 | + + + +--- + +#### AUTH-023: anonymousUser Principal 접근 + +| 항목 | 내용 | +|------|------| +| **ID** | AUTH-023 | +| **컴포넌트** | SecurityUtils.extractMemberId() (lines 26-28) | +| **우선순위** | Medium | +| **사전조건** | 인증되지 않은 요청이 Spring Security의 AnonymousAuthenticationFilter를 통과한 상태 | +| **테스트 데이터** | Authentication.getName() == "anonymousUser" | +| **실행 단계** | SecurityUtils.extractMemberId()에 anonymousUser principal 전달 | +| **기대 결과** | AUTH_UNAUTHORIZED (A001) 예외 발생 | + +--- + +## 그룹 4: JWT 필터 & 인가 + +JwtAuthenticationFilter, JwtAuthenticationEntryPoint, SecurityConfig, OwnerCheckAspect 관련 시나리오. + +### 요약 표 +| ID | 시나리오 | 요청 | 기대 응답 | 검증 포인트 | +|----|---------|------|----------|------------| +| AUTH-024 | 유효한 Bearer 토큰 → 보호된 API | GET /api/bookmarks + Bearer | 200 OK | SecurityContext에 JwtMemberPrincipal 설정 | +| AUTH-025 | 토큰 없이 보호된 API | GET /api/bookmarks (헤더 없음) | 401 A001 | EntryPoint 기본값 | +| AUTH-026 | 만료 토큰 → 보호된 API | GET /api/bookmarks + 만료 Bearer | 401 A002 | ExpiredJwtException 경로 | +| AUTH-027 | 서명 위변조 토큰 | GET /api/bookmarks + 변조 Bearer | 401 A003 | JwtException 경로 | +| AUTH-028 | 구조적 비정상 토큰 | GET /api/bookmarks + 비Base64 문자열 | 401 A003 | IllegalArgumentException 경로 | +| AUTH-029 | 토큰 없이 공개 API | GET /api/auth/refresh | 허용 | 필터 통과, 인가 필터에서 허용 | +| AUTH-030 | "Bearer " 접두사 없는 토큰 | GET /api/bookmarks + Authorization: {token} | 401 A001 | 토큰 추출 실패 → 인증 없이 통과 → 인가에서 차단 | +| AUTH-031 | 빈 Authorization 헤더 | GET /api/bookmarks + Authorization: | 401 A001 | 토큰 없음 처리 | +| AUTH-032 | @OwnerCheck — 본인 리소스 | DELETE /api/bookmarks/1 (본인) | 200 OK | 소유권 일치 → 정상 처리 | +| AUTH-033 | @OwnerCheck — 타인 리소스 | DELETE /api/bookmarks/1 (타인) | 403 A005 | 소유권 불일치 → AUTH_ACCESS_DENIED | + +### 상세 케이스 + +--- + +#### AUTH-024: 유효한 Bearer 토큰으로 보호된 API 접근 + +| 항목 | 내용 | +|------|------| +| **ID** | AUTH-024 | +| **컴포넌트** | JwtAuthenticationFilter (lines 31-58), SecurityConfig | +| **우선순위** | High | +| **사전조건** | 회원(memberId=1) 존재, 유효한 Access Token 보유 | +| **테스트 데이터** | accessToken: 유효한 JWT (sub=1, role=ROLE_USER) | +| **실행 단계** | GET /api/bookmarks (Authorization: Bearer {accessToken}) | +| **기대 결과** | 200 OK. Filter가 토큰 파싱 → JwtMemberPrincipal → SecurityContext 설정 → 인가 통과 | + + + +--- + +#### AUTH-027: 서명 위변조 토큰 (JwtException 경로) + +| 항목 | 내용 | +|------|------| +| **ID** | AUTH-027 | +| **컴포넌트** | JwtAuthenticationFilter (line 52, JwtException catch) | +| **우선순위** | Critical | +| **사전조건** | 없음 | +| **테스트 데이터** | accessToken: JWT payload는 정상이나 서명이 다른 secret key로 생성됨 | +| **실행 단계** | GET /api/bookmarks (Authorization: Bearer {tampered_token}) | +| **기대 결과** | 401, A003 에러. request.setAttribute("exception", AUTH_INVALID_TOKEN) | + + + +--- + +#### AUTH-028: 구조적으로 잘못된 토큰 (IllegalArgumentException 경로) + +| 항목 | 내용 | +|------|------| +| **ID** | AUTH-028 | +| **컴포넌트** | JwtAuthenticationFilter (line 50, IllegalArgumentException catch) | +| **우선순위** | Medium | +| **사전조건** | 없음 | +| **테스트 데이터** | accessToken: "not-a-valid-jwt-at-all" (비Base64, JWT 구조 아님) | +| **실행 단계** | GET /api/bookmarks (Authorization: Bearer not-a-valid-jwt-at-all) | +| **기대 결과** | 401, A003 에러. 동일한 AUTH_INVALID_TOKEN이나 다른 예외 경로 (IllegalArgumentException) | + +--- + +#### AUTH-029: 토큰 없이 공개 API 접근 + +| 항목 | 내용 | +|------|------| +| **ID** | AUTH-029 | +| **컴포넌트** | JwtAuthenticationFilter, SecurityConfig (permitAll 설정) | +| **우선순위** | High | +| **사전조건** | 없음 | +| **테스트 데이터** | 없음 (토큰 없이 요청) | +| **실행 단계** | POST /api/auth/refresh 또는 GET /oauth2/authorization/naver | +| **기대 결과** | 정상 허용. Filter는 토큰 없으면 SecurityContext 설정 없이 통과, permitAll 엔드포인트이므로 인가도 통과 | + + + +--- + +#### AUTH-032: @OwnerCheck — 본인 리소스 접근 (정상) + +| 항목 | 내용 | +|------|------| +| **ID** | AUTH-032 | +| **컴포넌트** | OwnerCheckAspect | +| **우선순위** | High | +| **사전조건** | 회원(memberId=1) 인증 완료, bookmarkId=10의 소유자가 memberId=1 | +| **테스트 데이터** | accessToken: memberId=1, 요청 대상: bookmarkId=10 (본인 소유) | +| **실행 단계** | DELETE /api/bookmarks/10 (Authorization: Bearer {token_memberId_1}) | +| **기대 결과** | 200 OK. AOP가 소유권 확인 후 정상 진행 | + +--- + +#### AUTH-033: @OwnerCheck — 타인 리소스 접근 (403 A005) + +| 항목 | 내용 | +|------|------| +| **ID** | AUTH-033 | +| **컴포넌트** | OwnerCheckAspect (line 56) | +| **우선순위** | Critical | +| **사전조건** | 회원(memberId=2) 인증 완료, bookmarkId=10의 소유자가 memberId=1 | +| **테스트 데이터** | accessToken: memberId=2, 요청 대상: bookmarkId=10 (타인 소유) | +| **실행 단계** | DELETE /api/bookmarks/10 (Authorization: Bearer {token_memberId_2}) | +| **기대 결과** | 403, A005 에러. AuthException.of(AUTH_ACCESS_DENIED) 발생 | + + + +--- + +## 그룹 5: OAuth2 소셜 로그인 + +OAuth2 인가 → 콜백 → 회원 처리 → 토큰 발급 → 리다이렉트 전체 플로우. + +### 요약 표 +| ID | 시나리오 | 트리거 | 기대 결과 | 검증 포인트 | +|----|---------|--------|----------|------------| +| AUTH-034 | Naver 로그인 (신규 회원) | OAuth2 콜백 (Naver) | 302 redirect + 쿠키 | SocialjoinProcess, RefreshToken 쿠키 | +| AUTH-035 | Google 로그인 (기존 회원) | OAuth2 콜백 (Google) | 302 redirect + 쿠키 | updateSocialMember, RefreshToken 쿠키 | +| AUTH-036 | Kakao 로그인 (nullable 필드) | OAuth2 콜백 (Kakao) | 302 redirect + 쿠키 | nullable email/nickname 처리 | +| AUTH-037 | OAuth2 인가 시 쿠키 저장 | GET /oauth2/authorization/naver | 302 to Naver | oauth2_auth_request 쿠키 (180초) | +| AUTH-038 | OAuth2 인가 시 redirect_uri 저장 | GET /oauth2/authorization/naver?redirect_uri=... | 302 to Naver | redirect_uri 쿠키 저장 | +| AUTH-039 | OAuth2 콜백 state 검증 성공 | OAuth2 콜백 + 유효한 state | 정상 처리 | 쿠키 역직렬화 성공 | +| AUTH-040 | OAuth2 콜백 state 불일치 | OAuth2 콜백 + 변조된 쿠키 | 실패 | 보안: CSRF 방지 | +| AUTH-041 | OAuth2 성공 후 쿠키 설정 | OAuth2 성공 핸들러 | 302 + Set-Cookie | HttpOnly, SameSite=Lax, Path=/api/auth | +| AUTH-042 | OAuth2 성공 후 기존 토큰 삭제 | OAuth2 성공 핸들러 | 기존 토큰 삭제 | deleteByMemberId 호출 | +| AUTH-043 | redirect_uri 쿠키 없을 때 | OAuth2 성공 + redirect_uri 쿠키 없음 | 기본 URL로 redirect | oauth2RedirectUri 설정값 사용 | +| AUTH-044 | authorization_request_not_found | OAuth2 콜백 + 쿠키 만료 | 302 /login?error=true | 빈번한 프로덕션 에러 | +| AUTH-045 | OAuth2 일반 실패 | OAuth2 콜백 + 인가 에러 | 302 /login?error=true | 에러 메시지 전달 | +| AUTH-046 | 지원하지 않는 provider | CustomOAuth2MemberService | 400 A007 | 미지원 provider 예외 | +| AUTH-047 | Open Redirect 차단 | OAuth2 성공 + 악의적 redirect_uri | 400 A006 | URI 검증 실패 | + +### 상세 케이스 + +--- + +#### AUTH-034: Naver 로그인 성공 (신규 회원) + +| 항목 | 내용 | +|------|------| +| **ID** | AUTH-034 | +| **컴포넌트** | CustomOAuth2MemberService.loadUser() (lines 58-114), OAuth2SuccessHandler | +| **우선순위** | High | +| **사전조건** | Naver OAuth2 인증 성공, 해당 loginId("naver{providerId}")로 회원이 존재하지 않음 | +| **테스트 데이터** | Naver OAuth2User: name="홍길동", email="hong@naver.com", providerId="12345" | +| **실행 단계** | OAuth2 콜백 → CustomOAuth2MemberService.loadUser() → SocialjoinProcess() | +| **기대 결과** | 신규 회원 DB 삽입 (loginId="naver12345"), RefreshToken 발급, 302 redirect to frontend callback | + +**플로우 검증:** +1. member 테이블에 loginId="naver12345" 레코드 삽입됨 +2. refresh_token 테이블에 새 토큰 해시 삽입됨 +3. Set-Cookie: refreshToken=...; HttpOnly; SameSite=Lax; Path=/api/auth +4. 302 Redirect to `{oauth2RedirectUri}` (예: http://localhost:3000/auth/callback) + +--- + +#### AUTH-036: Kakao 로그인 (nullable email/nickname) + +| 항목 | 내용 | +|------|------| +| **ID** | AUTH-036 | +| **컴포넌트** | CustomOAuth2MemberService, KakaoResponse | +| **우선순위** | Medium | +| **사전조건** | Kakao OAuth2 인증 성공, email/nickname이 null (Kakao 정책상 선택적 동의) | +| **테스트 데이터** | Kakao OAuth2User: email=null, nickname=null, providerId="67890" | +| **실행 단계** | OAuth2 콜백 → CustomOAuth2MemberService.loadUser() | +| **기대 결과** | null 필드를 빈 문자열 또는 기본값으로 처리, 회원 가입/업데이트 정상 완료 | + +--- + +#### AUTH-041: OAuth2 성공 후 RefreshToken 쿠키 설정 검증 + +| 항목 | 내용 | +|------|------| +| **ID** | AUTH-041 | +| **컴포넌트** | OAuth2SuccessHandler.onAuthenticationSuccess() (lines 52-78) | +| **우선순위** | High | +| **사전조건** | OAuth2 인증 성공 | +| **테스트 데이터** | 인증된 CustomOAuth2Member | +| **실행 단계** | OAuth2 성공 핸들러 실행 | +| **기대 결과** | RefreshToken 쿠키 설정 확인 | + +**Set-Cookie 검증 항목:** +| 속성 | 기대값 | 이유 | +|------|--------|------| +| HttpOnly | true | XSS 방지: JavaScript에서 접근 불가 | +| SameSite | Lax | CSRF 방지: 크로스 사이트 POST 차단 | +| Path | /api/auth | 스코프 제한: 인증 API에만 전송 | +| Secure | true (prod) / false (local) | HTTPS 강제 (운영 환경) | +| Max-Age | refreshTokenExpiry / 1000 | 만료 시간 | + +--- + +#### AUTH-043: redirect_uri 쿠키 없을 때 기본값 폴백 + +| 항목 | 내용 | +|------|------| +| **ID** | AUTH-043 | +| **컴포넌트** | OAuth2SuccessHandler.determineTargetUrl() (lines 99-100) | +| **우선순위** | High | +| **사전조건** | OAuth2 인증 성공, redirect_uri 쿠키가 존재하지 않음 | +| **테스트 데이터** | Cookie에 redirect_uri 없음 | +| **실행 단계** | OAuth2 성공 핸들러 → determineTargetUrl() | +| **기대 결과** | application.properties의 `app.oauth2.redirect-uri` 설정값으로 리다이렉트 (예: http://localhost:3000/auth/callback) | + +--- + +#### AUTH-044: OAuth2 실패 — authorization_request_not_found + +| 항목 | 내용 | +|------|------| +| **ID** | AUTH-044 | +| **컴포넌트** | OAuth2FailureHandler (lines 29-31), HttpCookieOAuth2AuthorizationRequestRepository, CookieUtils | +| **우선순위** | High | +| **사전조건** | OAuth2 인가 요청 쿠키(oauth2_auth_request)가 만료됨 (180초 초과) 또는 없음 | +| **테스트 데이터** | OAuth2 콜백 도착 시 oauth2_auth_request 쿠키 부재 | +| **실행 단계** | OAuth2 provider에서 콜백 도착, 그러나 쿠키가 이미 만료 | +| **기대 결과** | **302 리다이렉트** (JSON 아님!) to /login?error=true&message=인증+요청을+찾을+수+없습니다... | + +**Null-propagation chain:** +1. `CookieUtils.deserialize()` → 쿠키 없거나 파싱 실패 → `null` 반환 +2. `HttpCookieOAuth2AuthorizationRequestRepository.loadAuthorizationRequest()` → `null` 반환 +3. Spring Security → `authorization_request_not_found` 에러 발생 +4. `OAuth2FailureHandler` → 사용자 친화적 메시지로 교체 → 302 리다이렉트 + +**참고:** 이 에러는 프로덕션에서 가장 빈번하게 발생하는 OAuth2 에러 (사용자가 인가 페이지에서 3분 이상 체류 시 쿠키 만료) + +--- + +#### AUTH-047: Open Redirect 시도 차단 (A006) + +| 항목 | 내용 | +|------|------| +| **ID** | AUTH-047 | +| **컴포넌트** | OAuth2SuccessHandler.isAuthorizedRedirectUri() (lines 107-126) | +| **우선순위** | Critical | +| **사전조건** | OAuth2 인증 성공, redirect_uri 쿠키에 악의적 URL 포함 | +| **테스트 데이터** | redirect_uri: "https://evil.com/callback" 또는 "//evil.com" (protocol-relative) | +| **실행 단계** | OAuth2 성공 핸들러 → determineTargetUrl() → isAuthorizedRedirectUri() 검증 | +| **기대 결과** | 400, A006 에러 (AUTH_INVALID_REDIRECT_URI). 허용된 도메인과 불일치 | + +**테스트 데이터 변형:** +| redirect_uri | 기대 결과 | 이유 | +|-------------|----------|------| +| `https://evil.com/callback` | A006 차단 | 허용되지 않은 도메인 | +| `//evil.com` | A006 차단 | Protocol-relative URL | +| `http://localhost:3000/auth/callback` | 허용 | 설정된 oauth2RedirectUri와 일치 | + +--- + +## 그룹 6: 보안 & 인프라 + +토큰 해싱, 쿠키 보안 속성, 스케줄러, 교차 컴포넌트 상호작용 검증. + +### 요약 표 +| ID | 시나리오 | 대상 | 기대 결과 | 검증 포인트 | +|----|---------|------|----------|------------| +| AUTH-048 | SHA-256 해시 저장 | SecurityUtils.hashToken() | 해시값 저장 | DB에 평문 토큰 없음 | +| AUTH-049 | HttpOnly 쿠키 | CookieUtils | JavaScript 접근 불가 | XSS 방지 | +| AUTH-050 | SameSite=Lax | CookieUtils | 크로스 사이트 POST 차단 | CSRF 방지 | +| AUTH-051 | Secure 속성 (환경별) | CookieUtils | prod=true, local=false | HTTPS 강제 | +| AUTH-052 | Path=/api/auth | CookieUtils | 인증 API에만 전송 | 스코프 제한 | +| AUTH-053 | 스케줄러 토큰 정리 | RefreshTokenCleanupScheduler | 만료+고아 토큰 삭제 | 단일 SQL OR절 | +| AUTH-054 | 10초~60초 중간 상태 | AuthServiceImpl + Scheduler | 사용 불가, DB 존재 | Grace Period vs 정리 간격 | +| AUTH-055 | CookieUtils null 전파 | CookieUtils → Repository → FailureHandler | authorization_request_not_found | 교차 컴포넌트 | + +### 상세 케이스 + +--- + +#### AUTH-048: Refresh Token SHA-256 해시 저장 검증 + +| 항목 | 내용 | +|------|------| +| **ID** | AUTH-048 | +| **컴포넌트** | SecurityUtils.hashToken(), AuthServiceImpl | +| **우선순위** | Critical | +| **사전조건** | OAuth2 로그인 성공 후 RefreshToken 발급됨 | +| **테스트 데이터** | 발급된 refreshToken 원본 값 | +| **실행 단계** | DB의 refresh_token 테이블에서 token_hash 컬럼 조회 | +| **기대 결과** | token_hash 값이 원본 토큰과 다름 (SHA-256 해시). 원본 토큰을 SHA-256으로 해시한 값과 일치 | + +**검증 방법:** +```sql +SELECT token_hash FROM refresh_token WHERE member_id = 1; +-- 결과: "a3f2b8c..." (64자 hex string) +-- 원본 토큰 "eyJhbG..." 과 다름 +-- SHA-256("eyJhbG...") == "a3f2b8c..." 확인 +``` + +--- + +#### AUTH-051: 쿠키 Secure 속성 (환경별) + +| 항목 | 내용 | +|------|------| +| **ID** | AUTH-051 | +| **컴포넌트** | OAuth2SuccessHandler, CookieUtils, application-{profile}.properties | +| **우선순위** | High | +| **사전조건** | 환경별 설정: `app.cookie.secure` | +| **테스트 데이터** | local: `app.cookie.secure=false`, prod: `app.cookie.secure=true` | +| **실행 단계** | 각 환경에서 OAuth2 로그인 후 Set-Cookie 헤더 확인 | +| **기대 결과** | local: Secure 플래그 없음 (HTTP 허용), prod: Secure 플래그 있음 (HTTPS만 전송) | + +--- + +#### AUTH-053: RefreshTokenCleanupScheduler — 만료 토큰 + 고아 회전 토큰 동시 정리 + +| 항목 | 내용 | +|------|------| +| **ID** | AUTH-053 | +| **컴포넌트** | RefreshTokenCleanupScheduler (lines 23-27), RefreshTokenDao.deleteExpired(), refresh-token-mapper.xml (lines 41-45) | +| **우선순위** | High | +| **사전조건** | DB에 다음 레코드 존재: (1) expires_at < NOW()인 만료 토큰, (2) rotated_at이 1분 이상 경과한 고아 토큰 | +| **테스트 데이터** | 만료 토큰 2개 + 고아 회전 토큰 1개 + 유효한 토큰 1개 | +| **실행 단계** | cleanupExpiredTokens() 스케줄러 메서드 실행 (또는 cron 트리거: 매일 03:00) | +| **기대 결과** | 만료 토큰 2개 + 고아 토큰 1개 삭제, 유효한 토큰 1개 유지 | + +**SQL 동작:** +```sql +DELETE FROM refresh_token +WHERE expires_at < NOW() + OR (rotated_at IS NOT NULL AND rotated_at < NOW() - INTERVAL '1 minute') +``` + +**참고:** 두 조건은 **단일 SQL의 OR절**로 처리됨 (별도 메서드 아님). `deleteExpired()` 한 번의 호출로 두 유형 모두 정리. + +--- + +#### AUTH-054: 10초~60초 중간 상태 검증 + +| 항목 | 내용 | +|------|------| +| **ID** | AUTH-054 | +| **컴포넌트** | AuthServiceImpl (Grace Period 10초), RefreshTokenCleanupScheduler (정리 1분) | +| **우선순위** | Medium | +| **사전조건** | AUTH-001로 토큰 회전 후 10초 초과 ~ 60초 미만 경과 | +| **테스트 데이터** | rotated_at이 20초 전인 refresh_token 레코드 | +| **실행 단계** | 1. 해당 토큰으로 POST /api/auth/refresh 시도 → 2. DB에서 레코드 존재 여부 확인 | +| **기대 결과** | Step 1: 401 A004 (Grace Period 10초 초과). Step 2: DB에 레코드 **아직 존재** (스케줄러 정리 1분 미도달) | + +**참고:** 10초(코드 `AuthServiceImpl.java:100`)와 60초(SQL `refresh-token-mapper.xml:44`)는 **의도적 차이**: +- 10초: 네트워크 지연 등을 고려한 토큰 재사용 허용 윈도우 +- 60초: 스케줄러 정리 버퍼 (10초 Grace Period 이후에도 약간의 여유를 두고 삭제) + +--- + +#### AUTH-055: CookieUtils.deserialize() 실패 → null 전파 (교차 컴포넌트) + +| 항목 | 내용 | +|------|------| +| **ID** | AUTH-055 | +| **컴포넌트** | CookieUtils (line 103), HttpCookieOAuth2AuthorizationRequestRepository, OAuth2FailureHandler | +| **우선순위** | Medium | +| **사전조건** | oauth2_auth_request 쿠키가 변조되어 역직렬화 불가 | +| **테스트 데이터** | oauth2_auth_request 쿠키 값을 잘못된 Base64/JSON으로 설정 | +| **실행 단계** | OAuth2 provider에서 콜백 도착 | +| **기대 결과** | 302 리다이렉트 /login?error=true | + +**Null-propagation chain:** +``` +CookieUtils.deserialize(corruptedCookie) + → catch Exception → return null + → HttpCookieOAuth2AuthorizationRequestRepository.loadAuthorizationRequest() + → Optional.empty → return null + → Spring Security: authorization_request_not_found + → OAuth2FailureHandler: 302 redirect /login?error=true&message=... +``` + +**AUTH-044와의 관계:** AUTH-044는 "쿠키 만료/부재"로 동일한 결과에 도달. AUTH-055는 "쿠키 존재하나 내용 변조"로 CookieUtils의 null 반환 경로를 구체적으로 검증. + +--- + +## 부록: 컴포넌트 크로스레퍼런스 + +추후 JUnit 테스트 코드 작성 시 참조용. 각 컴포넌트에 대응하는 테스트 시나리오 ID 목록. + +| # | 컴포넌트 | 관련 시나리오 | JUnit 테스트 클래스 (추후) | +|---|---------|-------------|-------------------------| +| 1 | AuthController | AUTH-001~012, AUTH-013~017, AUTH-018~022 | AuthControllerTest | +| 2 | AuthServiceImpl | AUTH-001~012, AUTH-013~014, AUTH-042 | AuthServiceImplTest | +| 3 | RefreshTokenCleanupScheduler | AUTH-053, AUTH-054 | RefreshTokenCleanupSchedulerTest | +| 4 | RefreshTokenDao (MybatisRefreshTokenDao) | AUTH-001, AUTH-008, AUTH-011, AUTH-053 | RefreshTokenDaoTest | +| 5 | JwtUtils | AUTH-001, AUTH-005, AUTH-006 | JwtUtilsTest | +| 6 | JwtAuthenticationFilter | AUTH-024~031 | JwtAuthenticationFilterTest | +| 7 | JwtAuthenticationEntryPoint | AUTH-020, AUTH-025, AUTH-026 | JwtAuthenticationEntryPointTest | +| 8 | OAuth2SuccessHandler | AUTH-034~035, AUTH-041~043, AUTH-047 | OAuth2SuccessHandlerTest | +| 9 | OAuth2FailureHandler | AUTH-044, AUTH-045 | OAuth2FailureHandlerTest | +| 10 | SecurityConfig | AUTH-024, AUTH-029 | SecurityConfigTest | +| 11 | SecurityUtils | AUTH-018, AUTH-023, AUTH-048 | SecurityUtilsTest | +| 12 | CookieUtils | AUTH-041, AUTH-049~052, AUTH-055 | CookieUtilsTest | +| 13 | HttpCookieOAuth2AuthorizationRequestRepository | AUTH-037~040, AUTH-055 | HttpCookieOAuth2AuthRepoTest | +| 14 | CustomOAuth2MemberService | AUTH-034~036, AUTH-046 | CustomOAuth2MemberServiceTest | +| 15 | OwnerCheckAspect | AUTH-032, AUTH-033 | OwnerCheckAspectTest | + +### 에러 코드 커버리지 매핑 + +| Code | 시나리오 ID | +|------|-----------| +| A001 | AUTH-010, AUTH-020, AUTH-023, AUTH-025, AUTH-030, AUTH-031 | +| A002 | AUTH-005, AUTH-009, AUTH-019, AUTH-026 | +| A003 | AUTH-006, AUTH-021, AUTH-027, AUTH-028 | +| A004 | AUTH-003, AUTH-004, AUTH-007, AUTH-008, AUTH-012, AUTH-017 | +| A005 | AUTH-033 | +| A006 | AUTH-047 | +| A007 | AUTH-046 | + +### 추후 JUnit 전환 시 테스트 계층 매핑 + +| 문서 그룹 | JUnit 테스트 클래스 | Unit Test | Integration Test | E2E Test | +|----------|-------------------|-----------|-----------------|----------| +| 1. Token Refresh | AuthServiceImplTest, JwtUtilsTest | JwtUtils 토큰 생성/검증, AuthServiceImpl.refresh() mock | MockMvc POST /api/auth/refresh + TestDB | 전체 refresh 플로우 | +| 2. Logout | AuthServiceImplTest | AuthServiceImpl.logout() mock | MockMvc POST /api/auth/logout + TestDB | 로그아웃 후 refresh 실패 | +| 3. 인증 정보 조회 | SecurityUtilsTest | SecurityUtils.extractMemberId() | MockMvc GET /api/auth/member + JWT | - | +| 4. JWT 필터 & 인가 | JwtAuthenticationFilterTest, OwnerCheckAspectTest | Filter 단독 doFilterInternal() | SecurityFilterChain MockMvc 전체 | 인증 필요 API 호출 | +| 5. OAuth2 | CustomOAuth2MemberServiceTest | loadUser() mock | OAuth2 콜백 시뮬레이션 | (외부 의존성으로 E2E 제외) | +| 6. 보안 & 인프라 | SecurityUtilsTest, RefreshTokenCleanupSchedulerTest | hashToken(), 스케줄러 단독 | TestDB + 스케줄러 실행 | - | + +--- + + From f3386b34171cf55423ead94d2ce5434d66b8a71f Mon Sep 17 00:00:00 2001 From: jin2304 Date: Sun, 29 Mar 2026 23:24:22 +0900 Subject: [PATCH 03/22] =?UTF-8?q?refactor:=20=EC=82=AC=EC=9A=A9=ED=95=98?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EB=8A=94=20comment,=20likes,=20main,=20?= =?UTF-8?q?=EB=A0=88=EA=B1=B0=EC=8B=9C=20=EC=9D=B8=EC=A6=9D=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EC=A0=95=EB=A6=AC=20=EB=B0=8F=20mapper=20=EB=B0=B1?= =?UTF-8?q?=EC=97=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/CommentApiController.java | 105 ---------- .../web/SearchWeb/comment/dao/CommentDao.java | 36 ---- .../comment/dao/MybatisCommentDao.java | 91 --------- .../web/SearchWeb/comment/domain/Comment.java | 24 --- .../web/SearchWeb/comment/dto/CommentDto.java | 15 -- .../dto/UpdateUserProfileCommentDto.java | 19 -- .../comment/service/CommentService.java | 110 ----------- .../com/web/SearchWeb/likes/dao/LikesDao.java | 15 -- .../SearchWeb/likes/dao/MybatisLikesDao.java | 52 ----- .../SearchWeb/likes/service/LikesService.java | 60 ------ .../main/controller/MainController.java | 78 -------- .../com/web/SearchWeb/main/dao/MainDao.java | 20 -- .../SearchWeb/main/dao/MybatisMainDao.java | 39 ---- .../web/SearchWeb/main/domain/Website.java | 24 --- .../SearchWeb/main/service/MainService.java | 20 -- .../main/service/MainServiceImpl.java | 52 ----- .../member/dto/CustomOAuth2User.java | 89 --------- .../member/dto/CustomUserDetails.java | 90 --------- .../web/SearchWeb/member/dto/MemberDto.java | 39 ---- .../service/CustomOAuth2UserService.java | 113 ----------- .../service/CustomerUserDetailService.java | 39 ---- .../resources/mapper/board-mapper.xml.bak | 183 ++++++++++++++++++ .../resources/mapper/comment-mapper.xml.bak | 84 ++++++++ .../resources/mapper/likes-mapper.xml.bak | 40 ++++ src/main/resources/mapper/main-mapper.xml.bak | 34 ++++ src/main/resources/templates/member/join.html | 63 ------ .../resources/templates/member/login.html | 100 ---------- 27 files changed, 341 insertions(+), 1293 deletions(-) delete mode 100644 src/main/java/com/web/SearchWeb/comment/controller/CommentApiController.java delete mode 100644 src/main/java/com/web/SearchWeb/comment/dao/CommentDao.java delete mode 100644 src/main/java/com/web/SearchWeb/comment/dao/MybatisCommentDao.java delete mode 100644 src/main/java/com/web/SearchWeb/comment/domain/Comment.java delete mode 100644 src/main/java/com/web/SearchWeb/comment/dto/CommentDto.java delete mode 100644 src/main/java/com/web/SearchWeb/comment/dto/UpdateUserProfileCommentDto.java delete mode 100644 src/main/java/com/web/SearchWeb/comment/service/CommentService.java delete mode 100644 src/main/java/com/web/SearchWeb/likes/dao/LikesDao.java delete mode 100644 src/main/java/com/web/SearchWeb/likes/dao/MybatisLikesDao.java delete mode 100644 src/main/java/com/web/SearchWeb/likes/service/LikesService.java delete mode 100644 src/main/java/com/web/SearchWeb/main/controller/MainController.java delete mode 100644 src/main/java/com/web/SearchWeb/main/dao/MainDao.java delete mode 100644 src/main/java/com/web/SearchWeb/main/dao/MybatisMainDao.java delete mode 100644 src/main/java/com/web/SearchWeb/main/domain/Website.java delete mode 100644 src/main/java/com/web/SearchWeb/main/service/MainService.java delete mode 100644 src/main/java/com/web/SearchWeb/main/service/MainServiceImpl.java delete mode 100644 src/main/java/com/web/SearchWeb/member/dto/CustomOAuth2User.java delete mode 100644 src/main/java/com/web/SearchWeb/member/dto/CustomUserDetails.java delete mode 100644 src/main/java/com/web/SearchWeb/member/dto/MemberDto.java delete mode 100644 src/main/java/com/web/SearchWeb/member/service/CustomOAuth2UserService.java delete mode 100644 src/main/java/com/web/SearchWeb/member/service/CustomerUserDetailService.java create mode 100644 src/main/resources/mapper/board-mapper.xml.bak create mode 100644 src/main/resources/mapper/comment-mapper.xml.bak create mode 100644 src/main/resources/mapper/likes-mapper.xml.bak create mode 100644 src/main/resources/mapper/main-mapper.xml.bak delete mode 100644 src/main/resources/templates/member/join.html delete mode 100644 src/main/resources/templates/member/login.html diff --git a/src/main/java/com/web/SearchWeb/comment/controller/CommentApiController.java b/src/main/java/com/web/SearchWeb/comment/controller/CommentApiController.java deleted file mode 100644 index 4433181..0000000 --- a/src/main/java/com/web/SearchWeb/comment/controller/CommentApiController.java +++ /dev/null @@ -1,105 +0,0 @@ -// package com.web.SearchWeb.comment.controller; - -// import com.web.SearchWeb.aop.OwnerCheck; -// import com.web.SearchWeb.comment.domain.Comment; -// import com.web.SearchWeb.comment.dto.CommentDto; -// import com.web.SearchWeb.comment.service.CommentService; -// import com.web.SearchWeb.config.jwt.JwtMemberPrincipal; -// import org.springframework.beans.factory.annotation.Autowired; -// import org.springframework.http.HttpStatus; -// import org.springframework.http.ResponseEntity; -// import org.springframework.security.core.annotation.AuthenticationPrincipal; -// import org.springframework.ui.Model; -// import org.springframework.web.bind.annotation.*; - -// import java.util.HashMap; -// import java.util.List; -// import java.util.Map; - -// /** -// * CommentApiController (Legacy - PostgreSQL) -// */ -// @RestController -// public class CommentApiController { - -// private final CommentService commentService; - -// @Autowired -// public CommentApiController(CommentService commentService) { -// this.commentService = commentService; -// } - - - -// /** -// * 게시글 댓글 생성 -// */ -// @PostMapping("board/{boardId}/comment") -// public ResponseEntity> insertComment(@PathVariable Long boardId, -// @AuthenticationPrincipal JwtMemberPrincipal principal, -// @RequestBody CommentDto commentDto){ -// Map response = new HashMap<>(); - -// // 로그인 되지 않은 경우 -// if (principal == null) { -// return ResponseEntity -// .status(HttpStatus.UNAUTHORIZED) -// .body(response); // 401 Unauthorized 응답 -// } - -// commentService.insertComment(boardId, principal.memberId(), commentDto); - -// response.put("success", true); -// return ResponseEntity.ok(response); // 200 OK 응답 -// } - - -// /** -// * 게시글 댓글 목록 조회 -// */ -// @GetMapping("board/{boardId}/comments") -// public ResponseEntity> selectComments(@PathVariable Long boardId, Model model){ -// List comments = commentService.selectComments(boardId); -// return ResponseEntity.ok(comments); -// } - - -// /** -// * 게시글 댓글 단일 조회 -// */ -// @GetMapping("board/{boardId}/comment/{commentId}") -// @OwnerCheck(idParam = "commentId", service = "commentService") -// public ResponseEntity selectComment(@PathVariable Long commentId){ -// Comment comment = commentService.selectComment(commentId); -// return ResponseEntity.ok(comment); -// } - - -// /** -// * 게시글 댓글 수정 -// */ -// @PutMapping("board/{boardId}/comments/{commentId}") -// @OwnerCheck(idParam = "commentId", service = "commentService") -// public ResponseEntity> updateComment(@PathVariable Long boardId, -// @PathVariable Long commentId, -// @RequestBody CommentDto commentDto){ -// Map response = new HashMap<>(); -// commentService.updateComment(commentId, commentDto); -// response.put("success", true); -// return ResponseEntity.ok(response); // 200 OK 응답 -// } - - -// /** -// * 게시글 댓글 삭제 -// */ -// @DeleteMapping("board/{boardId}/comments/{commentId}") -// @OwnerCheck(idParam = "commentId", service = "commentService") -// public ResponseEntity> deleteComment(@PathVariable Long boardId, -// @PathVariable Long commentId){ -// Map response = new HashMap<>(); -// commentService.deleteComment(boardId, commentId); -// response.put("success", true); -// return ResponseEntity.ok(response); // 200 OK 응답 -// } -// } diff --git a/src/main/java/com/web/SearchWeb/comment/dao/CommentDao.java b/src/main/java/com/web/SearchWeb/comment/dao/CommentDao.java deleted file mode 100644 index d9ce4fa..0000000 --- a/src/main/java/com/web/SearchWeb/comment/dao/CommentDao.java +++ /dev/null @@ -1,36 +0,0 @@ -// package com.web.SearchWeb.comment.dao; - -// import com.web.SearchWeb.comment.domain.Comment; -// import com.web.SearchWeb.comment.dto.CommentDto; -// import com.web.SearchWeb.comment.dto.UpdateUserProfileCommentDto; - -// import java.util.List; - -// /** -// * CommentDao Interface (Legacy - PostgreSQL) -// */ -// public interface CommentDao { -// //게시글 댓글 생성 -// int insertComment(Comment comment); - -// //게시글 댓글 목록 조회 -// List selectComments(Long boardId); - -// //회원번호로 게시글 댓글 목록 조회 (Long for member FK) -// List selectCommentsByMemberId(Long memberId); - -// //게시글 댓글 단일 조회 -// Comment selectComment(Long commentId); - -// //게시글 댓글 수정 -// int updateComment(Long commentId, CommentDto commentDto); - -// //게시글 댓글 사용자 프로필 수정 -// int updateCommentUserProfile(UpdateUserProfileCommentDto commentDto); - -// //게시글 댓글 삭제 -// int deleteComment(Long commentId); - -// //게시글 댓글 수 조회 -// int countComments(Long boardId); -// } \ No newline at end of file diff --git a/src/main/java/com/web/SearchWeb/comment/dao/MybatisCommentDao.java b/src/main/java/com/web/SearchWeb/comment/dao/MybatisCommentDao.java deleted file mode 100644 index 034cff3..0000000 --- a/src/main/java/com/web/SearchWeb/comment/dao/MybatisCommentDao.java +++ /dev/null @@ -1,91 +0,0 @@ -// package com.web.SearchWeb.comment.dao; - - -// import com.web.SearchWeb.comment.domain.Comment; -// import com.web.SearchWeb.comment.dto.CommentDto; -// import com.web.SearchWeb.comment.dto.UpdateUserProfileCommentDto; -// import org.apache.ibatis.session.SqlSession; -// import org.springframework.beans.factory.annotation.Autowired; -// import org.springframework.stereotype.Repository; - -// import java.util.List; - -// /** -// * MybatisCommentDao (Legacy - PostgreSQL) -// */ -// @Repository -// public class MybatisCommentDao implements CommentDao{ - -// private final CommentDao mapper; - -// @Autowired -// public MybatisCommentDao(SqlSession sqlSession) { -// //세션을 통해 mapper 컨테이너에서 mapper 객체를 꺼내 씀 -// mapper = sqlSession.getMapper(CommentDao.class); -// } - - -// /** -// * 게시글 댓글 생성 -// */ -// @Override -// public int insertComment(Comment comment) { -// return mapper.insertComment(comment); -// } - - -// /** -// * 게시글 댓글 목록 조회 -// */ -// public List selectComments(Long boardId){ -// return mapper.selectComments(boardId); -// } - - -// /** -// * 회원번호로 게시글 댓글 목록 조회 -// */ -// public List selectCommentsByMemberId(Long memberId){ -// return mapper.selectCommentsByMemberId(memberId); -// } - - -// /** -// * 게시글 댓글 단일 조회 -// */ -// public Comment selectComment(Long commentId){ -// return mapper.selectComment(commentId); -// } - - -// /** -// * 게시글 댓글 수정 -// */ -// public int updateComment(Long commentId, CommentDto commentDto) { -// return mapper.updateComment(commentId, commentDto); -// } - - -// /** -// * 게시글 댓글 사용자 프로필 수정 -// */ -// public int updateCommentUserProfile(UpdateUserProfileCommentDto commentDto) { -// return mapper.updateCommentUserProfile(commentDto); -// } - - -// /** -// * 게시글 댓글 삭제 -// */ -// public int deleteComment(Long commentId){ -// return mapper.deleteComment(commentId); -// } - - -// /** -// * 게시글 댓글 수 조회 -// */ -// public int countComments(Long boardId) { -// return mapper.countComments(boardId); -// } -// } \ No newline at end of file diff --git a/src/main/java/com/web/SearchWeb/comment/domain/Comment.java b/src/main/java/com/web/SearchWeb/comment/domain/Comment.java deleted file mode 100644 index 5329920..0000000 --- a/src/main/java/com/web/SearchWeb/comment/domain/Comment.java +++ /dev/null @@ -1,24 +0,0 @@ -// package com.web.SearchWeb.comment.domain; - -// import lombok.Getter; -// import lombok.Setter; -// import lombok.ToString; - -// /** -// * Comment 도메인 (Legacy) -// * - PostgreSQL comment 테이블과 매핑 -// * - Member, Board 테이블과 FK 관계 -// */ -// @Getter -// @Setter -// @ToString -// public class Comment { -// private Long commentId; // comment_id (BIGINT) -// private Long boardBoardId; // board_board_id (FK to board) -// private Long memberMemberId; // member_member_id (FK to member - BIGINT) -// private String memberNickname; // member_nickname -// private String memberJob; // member_job -// private String memberMajor; // member_major -// private String content; // content -// private String createdDate; // created_date -// } diff --git a/src/main/java/com/web/SearchWeb/comment/dto/CommentDto.java b/src/main/java/com/web/SearchWeb/comment/dto/CommentDto.java deleted file mode 100644 index 8dd89d4..0000000 --- a/src/main/java/com/web/SearchWeb/comment/dto/CommentDto.java +++ /dev/null @@ -1,15 +0,0 @@ -// package com.web.SearchWeb.comment.dto; - -// import lombok.Getter; -// import lombok.Setter; -// import lombok.ToString; - -// @Getter -// @Setter -// @ToString -// public class CommentDto { -// private Long board_boardId; -// private Long member_memberId; -// private String member_nickname; -// private String content; -// } diff --git a/src/main/java/com/web/SearchWeb/comment/dto/UpdateUserProfileCommentDto.java b/src/main/java/com/web/SearchWeb/comment/dto/UpdateUserProfileCommentDto.java deleted file mode 100644 index 9836de3..0000000 --- a/src/main/java/com/web/SearchWeb/comment/dto/UpdateUserProfileCommentDto.java +++ /dev/null @@ -1,19 +0,0 @@ -// package com.web.SearchWeb.comment.dto; - -// /** -// * 사용자 프로필 변경 시 댓글 정보 업데이트용 DTO -// * -// * PostgreSQL 호환 - Long commentId -// */ -// public record UpdateUserProfileCommentDto( -// Long commentId, -// String nickname, -// String job, -// String major -// ) { - -// public static UpdateUserProfileCommentDto of(Long commentId, String nickname, String job, String major) { -// return new UpdateUserProfileCommentDto(commentId, nickname, job, major); -// } - -// } diff --git a/src/main/java/com/web/SearchWeb/comment/service/CommentService.java b/src/main/java/com/web/SearchWeb/comment/service/CommentService.java deleted file mode 100644 index 9640f3a..0000000 --- a/src/main/java/com/web/SearchWeb/comment/service/CommentService.java +++ /dev/null @@ -1,110 +0,0 @@ -// package com.web.SearchWeb.comment.service; - -// import com.web.SearchWeb.board.dao.BoardDao; -// import com.web.SearchWeb.comment.dao.CommentDao; -// import com.web.SearchWeb.comment.domain.Comment; -// import com.web.SearchWeb.comment.dto.CommentDto; -// import com.web.SearchWeb.member.domain.Member; -// import com.web.SearchWeb.member.service.MemberService; -// import org.springframework.beans.factory.annotation.Autowired; -// import org.springframework.stereotype.Service; -// import org.springframework.transaction.annotation.Transactional; - -// import java.util.List; - -// /** -// * CommentService (Legacy - PostgreSQL) -// */ -// @Service -// public class CommentService { - -// private final CommentDao commentdao; -// private final MemberService memberService; -// private final BoardDao boardDao; - -// @Autowired -// public CommentService(CommentDao commentdao, MemberService memberService, BoardDao boardDao) { -// this.commentdao = commentdao; -// this.memberService = memberService; -// this.boardDao = boardDao; -// } - - -// /** -// * 게시글 댓글 생성 -// */ -// @Transactional -// public int insertComment(Long boardId, Long memberId, CommentDto commentDto){ -// // 댓글 추가 -// Member member = memberService.findByMemberId(memberId); -// Comment comment = new Comment(); -// comment.setBoardBoardId(boardId); -// comment.setMemberMemberId(memberId); -// comment.setMemberNickname(member.getNickName()); -// comment.setMemberJob(member.getJob()); -// comment.setMemberMajor(member.getMajor()); -// comment.setContent(commentDto.getContent()); -// int result = commentdao.insertComment(comment); - -// //게시글 댓글 수 증가 -// boardDao.incrementCommentCount(boardId); - -// return result; -// } - - -// /** -// * 게시글 댓글 목록 조회 -// */ -// public List selectComments(Long boardId){ -// return commentdao.selectComments(boardId); -// } - - -// /** -// * 게시글 댓글 단일 조회 -// */ -// public Comment selectComment(Long commentId){ -// return commentdao.selectComment(commentId); -// } - - -// /** -// * 게시글 댓글 수정 -// */ -// public int updateComment(Long commentId, CommentDto commentDto){ -// return commentdao.updateComment(commentId, commentDto); -// } - - -// /** -// * 게시글 댓글 삭제 -// */ -// @Transactional -// public int deleteComment(Long boardId, Long commentId){ -// // 댓글 삭제 -// int result = commentdao.deleteComment(commentId); - -// //게시글 댓글 수 감소 -// boardDao.decrementCommentCount(boardId); -// return result; -// } - - -// /** -// * 게시글 댓글 수 조회 -// */ -// public int getCommentCount(Long boardId) { -// return commentdao.countComments(boardId); -// } - - -// /** -// * 댓글 소유자(작성자) 조회 -// */ -// public Long findMemberIdByCommentId(Long commentId) { -// Comment comment = commentdao.selectComment(commentId); -// if (comment == null) throw new IllegalArgumentException("게시글이 존재하지 않습니다."); -// return comment.getMemberMemberId(); -// } -// } diff --git a/src/main/java/com/web/SearchWeb/likes/dao/LikesDao.java b/src/main/java/com/web/SearchWeb/likes/dao/LikesDao.java deleted file mode 100644 index fc8073c..0000000 --- a/src/main/java/com/web/SearchWeb/likes/dao/LikesDao.java +++ /dev/null @@ -1,15 +0,0 @@ -// package com.web.SearchWeb.likes.dao; - -// public interface LikesDao { -// // 게시글 좋아요 상태 확인 -// Boolean isLikedByMember(Long boardId, Long memberId); - -// // 게시글 좋아요 추가 -// int likeBoard(Long boardId, Long memberId); - -// // 게시글 좋아요 취소 -// int unlikeBoard(Long boardId, Long memberId); - -// // 게시글 좋아요 수 조회 -// int countLikes(Long boardId); -// } diff --git a/src/main/java/com/web/SearchWeb/likes/dao/MybatisLikesDao.java b/src/main/java/com/web/SearchWeb/likes/dao/MybatisLikesDao.java deleted file mode 100644 index 89d0756..0000000 --- a/src/main/java/com/web/SearchWeb/likes/dao/MybatisLikesDao.java +++ /dev/null @@ -1,52 +0,0 @@ -// package com.web.SearchWeb.likes.dao; - -// import org.apache.ibatis.session.SqlSession; -// import org.springframework.beans.factory.annotation.Autowired; -// import org.springframework.stereotype.Repository; - -// @Repository -// public class MybatisLikesDao implements LikesDao { - -// private final LikesDao mapper; - -// @Autowired -// public MybatisLikesDao(SqlSession sqlSession) { -// //세션을 통해 mapper 컨테이너에서 mapper 객체를 꺼내 씀 -// mapper = sqlSession.getMapper(LikesDao.class); -// } - -// /** -// * 게시글 좋아요 상태 확인 -// */ -// @Override -// public Boolean isLikedByMember(Long boardId, Long memberId) { -// return mapper.isLikedByMember(boardId, memberId); -// } - - -// /** -// * 게시글 좋아요 추가 -// */ -// @Override -// public int likeBoard(Long boardId, Long memberId) { -// return mapper.likeBoard(boardId, memberId); -// } - - -// /** -// * 게시글 좋아요 취소 -// */ -// @Override -// public int unlikeBoard(Long boardId, Long memberId) { -// return mapper.unlikeBoard(boardId, memberId); -// } - - -// /** -// * 게시글 좋아요 수 조회 -// */ -// @Override -// public int countLikes(Long boardId) { -// return mapper.countLikes(boardId); -// } -// } \ No newline at end of file diff --git a/src/main/java/com/web/SearchWeb/likes/service/LikesService.java b/src/main/java/com/web/SearchWeb/likes/service/LikesService.java deleted file mode 100644 index 2961289..0000000 --- a/src/main/java/com/web/SearchWeb/likes/service/LikesService.java +++ /dev/null @@ -1,60 +0,0 @@ -// package com.web.SearchWeb.likes.service; - -// import com.web.SearchWeb.board.dao.BoardDao; -// import com.web.SearchWeb.likes.dao.LikesDao; -// import org.springframework.beans.factory.annotation.Autowired; -// import org.springframework.stereotype.Service; -// import org.springframework.transaction.annotation.Transactional; - -// @Service -// public class LikesService { - -// private final LikesDao likesDao; -// private final BoardDao boardDao; - -// @Autowired -// public LikesService(LikesDao likesDao, BoardDao boardDao) { -// this.likesDao = likesDao; -// this.boardDao = boardDao; -// } - - -// /** -// * 게시글 좋아요 상태 확인 -// */ -// public boolean isLiked(Long boardId, Long memberId) { -// Boolean isLiked = likesDao.isLikedByMember(boardId, memberId); -// return Boolean.TRUE.equals(isLiked); -// } - - -// /** -// * 게시글 좋아요 추가/취소 -// */ -// @Transactional -// public boolean toggleLike(Long boardId, Long memberId) { -// //게시글 좋아요 상태 확인 -// Boolean isLiked = likesDao.isLikedByMember(boardId, memberId); - -// if (isLiked == null || Boolean.FALSE.equals(isLiked)) { -// // 좋아요가 안 되어 있다면, 좋아요 추가 -// likesDao.likeBoard(boardId, memberId); -// boardDao.incrementLikeCount(boardId); // likes_count + 1 -// return true; -// } else { -// // 좋아요가 이미 되어 있다면, 좋아요 취소 -// likesDao.unlikeBoard(boardId, memberId); -// boardDao.decrementLikeCount(boardId); // likes_count - 1 -// return false; -// } -// } - - -// /** -// * 게시글 좋아요 수 조회 -// */ -// public int getLikeCount(Long boardId) { -// return likesDao.countLikes(boardId); -// } - -// } \ No newline at end of file diff --git a/src/main/java/com/web/SearchWeb/main/controller/MainController.java b/src/main/java/com/web/SearchWeb/main/controller/MainController.java deleted file mode 100644 index 5e03323..0000000 --- a/src/main/java/com/web/SearchWeb/main/controller/MainController.java +++ /dev/null @@ -1,78 +0,0 @@ -package com.web.SearchWeb.main.controller; - - -import com.web.SearchWeb.main.domain.Website; -import com.web.SearchWeb.main.service.MainService; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Controller; -import org.springframework.ui.Model; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestParam; - -import java.util.List; - - -/** - * - * 코드 작성자: - * -서진영(jin2304) - * - * 코드 설명: - * -/main :Searchweb 서비스에 대해 소개하는 페이지. - * -/mainList :MainController는 카테로리별로 웹사이트 목록을 보여주거나, 검색을 통해 특정 웹사이트 검색이 가능하다. - * - * - * 코드 주요 기능: - * -/main :Searchweb 서비스 소개 페이지 - * -/mainList :카테고리별 웹사이트 목록 조회, 검색어 기반으로 웹사이트 목록 조회 - * - * - * 코드 작성일: - * -2024.07.05 ~ 2024.08.08 - * - */ - -@Controller -public class MainController { - - private final MainService mainService; - - @Autowired - public MainController(MainService mainService) { - this.mainService = mainService; - } - - - /** - * 메인페이지(서비스 소개 페이지) - */ - @GetMapping("/main") - public String main(){ - return "index"; - } - - - /** - * 카테고리별 웹사이트 목록 조회 및 검색 기능 - */ - @GetMapping("/mainList") - public String mainList(@RequestParam(value = "category", defaultValue = "All") String category, - @RequestParam(value = "query", required = false) String query, Model model) { - - List list; - if (query != null && !query.isEmpty()) { //검색쿼리가 존재한다면 - list = mainService.getListByQuery(query); - model.addAttribute("list", list); - model.addAttribute("query", query); - model.addAttribute("resultCount", list.size()); - } else { //카테고리가 존재한다면 - list = mainService.getListByCategory(category); - model.addAttribute("list", list); - } - return "main/mainList"; - } - - - - -} diff --git a/src/main/java/com/web/SearchWeb/main/dao/MainDao.java b/src/main/java/com/web/SearchWeb/main/dao/MainDao.java deleted file mode 100644 index d97812c..0000000 --- a/src/main/java/com/web/SearchWeb/main/dao/MainDao.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.web.SearchWeb.main.dao; - -import com.web.SearchWeb.main.domain.Website; - -import java.util.List; - -public interface MainDao { - - //웹사이트 조회 - Website selectWebsite(Long websiteId); - - //카테고리별 웹사이트 목록 조회 - List getListByCategory(String category); - - //검색어 기반으로 웹사이트 목록 조회 - List getListByQuery(String query); - - //웹사이트 상세보기 - Website getDetail(); -} diff --git a/src/main/java/com/web/SearchWeb/main/dao/MybatisMainDao.java b/src/main/java/com/web/SearchWeb/main/dao/MybatisMainDao.java deleted file mode 100644 index bc0b831..0000000 --- a/src/main/java/com/web/SearchWeb/main/dao/MybatisMainDao.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.web.SearchWeb.main.dao; - -import com.web.SearchWeb.main.domain.Website; -import org.apache.ibatis.session.SqlSession; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Repository; - -import java.util.List; - -@Repository -public class MybatisMainDao implements MainDao { - - private MainDao mapper; - - @Autowired - public MybatisMainDao(SqlSession sqlSession) { - mapper = sqlSession.getMapper(MainDao.class); - } - - @Override - public Website selectWebsite(Long websiteId) { - return mapper.selectWebsite(websiteId); - } - - @Override - public List getListByCategory(String category) { - return mapper.getListByCategory(category); - } - - @Override - public List getListByQuery(String query) { - return mapper.getListByQuery(query); - } - - @Override - public Website getDetail() { - return null; - } -} diff --git a/src/main/java/com/web/SearchWeb/main/domain/Website.java b/src/main/java/com/web/SearchWeb/main/domain/Website.java deleted file mode 100644 index 73b6be3..0000000 --- a/src/main/java/com/web/SearchWeb/main/domain/Website.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.web.SearchWeb.main.domain; - - -import lombok.Getter; -import lombok.Setter; -import lombok.ToString; - - -/** - * Website 도메인 - */ -@Getter -@Setter -@ToString -public class Website { - private Long websiteId; // website_id - private String name; // name - private String koreanName; // korean_name - private String description; // description - private String url; // url - private String category; // category - private String subcategory; // subcategory - private Long viewCount; // view_count -} diff --git a/src/main/java/com/web/SearchWeb/main/service/MainService.java b/src/main/java/com/web/SearchWeb/main/service/MainService.java deleted file mode 100644 index 24bb9ac..0000000 --- a/src/main/java/com/web/SearchWeb/main/service/MainService.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.web.SearchWeb.main.service; - -import com.web.SearchWeb.main.domain.Website; - -import java.util.List; - -public interface MainService { - - //웹사이트 조회 - Website selectWebsite(Long websiteId); - - //카테고리별 웹사이트 목록 조회 - List getListByCategory(String category); - - //검색어 기반으로 웹사이트 목록 조회 - List getListByQuery(String query); - - //웹사이트 상세보기 - Website getDetail(); -} diff --git a/src/main/java/com/web/SearchWeb/main/service/MainServiceImpl.java b/src/main/java/com/web/SearchWeb/main/service/MainServiceImpl.java deleted file mode 100644 index faa7b7f..0000000 --- a/src/main/java/com/web/SearchWeb/main/service/MainServiceImpl.java +++ /dev/null @@ -1,52 +0,0 @@ -package com.web.SearchWeb.main.service; - -import com.web.SearchWeb.main.dao.MainDao; -import com.web.SearchWeb.main.domain.Website; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Service; - -import java.util.List; - -@Service -public class MainServiceImpl implements MainService { - - private final MainDao mainDao; - - @Autowired - public MainServiceImpl(MainDao mainDao) { - this.mainDao = mainDao; - } - - /** - * 웹사이트 조회 - */ - @Override - public Website selectWebsite(Long websiteId) { - return mainDao.selectWebsite(websiteId); - } - - /** - * 카테고리별 웹사이트 목록 조회 - */ - public List getListByCategory(String category) { - return mainDao.getListByCategory(category); - } - - - /** - * 검색어 기반으로 웹사이트 목록 조회 - */ - @Override - public List getListByQuery(String query) { - return mainDao.getListByQuery(query); - } - - - /** - * 웹사이트 상세보기 - */ - @Override - public Website getDetail() { - return null; - } -} diff --git a/src/main/java/com/web/SearchWeb/member/dto/CustomOAuth2User.java b/src/main/java/com/web/SearchWeb/member/dto/CustomOAuth2User.java deleted file mode 100644 index 8f40500..0000000 --- a/src/main/java/com/web/SearchWeb/member/dto/CustomOAuth2User.java +++ /dev/null @@ -1,89 +0,0 @@ -package com.web.SearchWeb.member.dto; - - -import com.web.SearchWeb.member.dto.Response.OAuth2Response; -import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.oauth2.core.user.OAuth2User; - -import java.util.ArrayList; -import java.util.Collection; -import java.util.Map; - - - -/** - * CustomOAuth2User 클래스 - * - * 코드 작성자: - * - 서진영(jin2304) - * - * 코드 설명: - * -CustomOAuth2User는 소셜 로그인 요청 시, 사용자 정보를 담아 Spring Security가 인증 과정에서 사용할 수 있도록 제공하는 클래스. - * -Spring Security의 OAuth2User 인터페이스를 구현하여 소셜 서비스에서 가져온 사용자 정보를 표현. - * -반환된 CustomOAuth2User 객체는 인증 객체(Authentication)를 생성하고, SecurityContext에 저장하여 인증 상태를 관리하는 데 사용됨. - * - * 코드 주요 기능: - * -getAttributes(): 현재 비어 있음 -> OAuth2User 인터페이스의 요구사항을 충족하기 위해 메서드가 존재하지만, 실제 사용하지 않으므로 빈 맵 반환 - * -getAuthorities(): 사용자 권한 반환. - * -getName(): 소셜 서비스에서 제공한 사용자 이름 반환. - * -getMemberId(): 데이터베이스에 저장된 사용자 고유 ID 반환. - * -getLoginId(): 소셜 서비스 이름과 사용자 ID를 조합한 고유 식별값 반환. - * - * 클래스 주요 필드: - * -OAuth2Response oAuth2Response: 소셜 서비스에서 가져온 사용자 정보. - * -String role: Searchweb 서비스에 부여한 사용자 권한(Role). - * -Long memberId: 데이터베이스에 저장된 사용자 고유 ID. - * - * 소셜 서비스 지원 플랫폼: - * -naver, google, kakao - * - * 코드 작성일: - * -2024.12.27 ~ 2024.12.27 - */ - -public class CustomOAuth2User implements OAuth2User { - - private final OAuth2Response oAuth2Response; - private final String role; - private final Long memberId; - - public CustomOAuth2User(OAuth2Response oAuth2Response, String role, Long memberId) { - this.oAuth2Response = oAuth2Response; - this.role = role; - this.memberId = memberId; - } - - @Override - public Map getAttributes() { - return Map.of(); - } - - @Override - public Collection getAuthorities() { - Collection collection = new ArrayList<>(); - collection.add(new GrantedAuthority() { - @Override - public String getAuthority() { - return role; - } - }); - return collection; - } - - @Override - public String getName() { - return oAuth2Response.getName(); - } - - - public Long getMemberId() { - return memberId; - } - - - public String getLoginId() { - return oAuth2Response.getProvider() + oAuth2Response.getProviderId(); - } - - -} \ No newline at end of file diff --git a/src/main/java/com/web/SearchWeb/member/dto/CustomUserDetails.java b/src/main/java/com/web/SearchWeb/member/dto/CustomUserDetails.java deleted file mode 100644 index 875e4d5..0000000 --- a/src/main/java/com/web/SearchWeb/member/dto/CustomUserDetails.java +++ /dev/null @@ -1,90 +0,0 @@ -package com.web.SearchWeb.member.dto; - - -import com.web.SearchWeb.member.domain.Member; -import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.core.userdetails.UserDetails; - -import java.util.ArrayList; -import java.util.Collection; - - - -/** - * CustomUserDetails 클래스 - * - * 로그인 검증 로직 - */ -public class CustomUserDetails implements UserDetails { - - private Member findUser; - - public CustomUserDetails(Member findUser) { - this.findUser = findUser; - } - - - /** - * 사용자의 특정 권한 확인 - */ - @Override - public Collection getAuthorities() { - - Collection collection = new ArrayList<>(); - collection.add(new GrantedAuthority() { - @Override - public String getAuthority() { - return findUser.getRole(); - } - }); - - return collection; - } - - /** - * 로그인 아이디 반환 (Spring Security의 username 역할) - */ - @Override - public String getUsername() { - return findUser.getLoginId(); - } - - /** - * 비밀번호 찾기 - */ - @Override - public String getPassword() { - return findUser.getPasswordHash(); - } - - - /** - * 회원 ID 반환 - */ - public Long getMemberId() { - return findUser.getMemberId(); - } - - - - //임시 설정 - @Override - public boolean isAccountNonExpired() { - return true; - } - - @Override - public boolean isAccountNonLocked() { - return !"blocked".equals(findUser.getStatus()); - } - - @Override - public boolean isCredentialsNonExpired() { - return true; - } - - @Override - public boolean isEnabled() { - return findUser.getDeletedAt() == null; - } -} diff --git a/src/main/java/com/web/SearchWeb/member/dto/MemberDto.java b/src/main/java/com/web/SearchWeb/member/dto/MemberDto.java deleted file mode 100644 index f8b2b2a..0000000 --- a/src/main/java/com/web/SearchWeb/member/dto/MemberDto.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.web.SearchWeb.member.dto; - - -import jakarta.validation.constraints.Email; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.Size; -import lombok.Getter; -import lombok.Setter; -import lombok.ToString; - -/** - * 회원가입 요청 DTO - */ -@Getter -@Setter -@ToString -public class MemberDto { - @NotBlank(message = "아이디는 필수 입력 값입니다.") - @Size(min = 4, max = 30, message = "아이디는 4자 이상 30자 이하로 입력해주세요.") - private String loginId; - - @NotBlank(message = "비밀번호는 필수 입력 값입니다.") - @Size(min = 4, max = 30, message = "비밀번호는 4자 이상 30자 이하로 입력해주세요.") - private String password; - - @NotBlank(message = "비밀번호 확인은 필수 입력 값입니다.") - private String confirmPassword; - - @Size(max = 20, message = "이름은 20자 이하로 입력해주세요.") - private String memberName; - - @Size(max = 20, message = "닉네임은 20자 이하로 입력해주세요.") - private String nickName; - - @Email(message = "올바른 이메일 형식이어야 합니다.") - private String email; - - private String role; -} diff --git a/src/main/java/com/web/SearchWeb/member/service/CustomOAuth2UserService.java b/src/main/java/com/web/SearchWeb/member/service/CustomOAuth2UserService.java deleted file mode 100644 index 99f7e60..0000000 --- a/src/main/java/com/web/SearchWeb/member/service/CustomOAuth2UserService.java +++ /dev/null @@ -1,113 +0,0 @@ -package com.web.SearchWeb.member.service; - -import com.web.SearchWeb.member.dao.MemberDao; -import com.web.SearchWeb.member.domain.Member; -import com.web.SearchWeb.member.dto.CustomOAuth2User; -import com.web.SearchWeb.member.dto.Response.GoogleResponse; -import com.web.SearchWeb.member.dto.Response.KakaoResponse; -import com.web.SearchWeb.member.dto.Response.NaverResponse; -import com.web.SearchWeb.member.dto.Response.OAuth2Response; -import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; -import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; -import org.springframework.security.oauth2.core.OAuth2AuthenticationException; -import org.springframework.security.oauth2.core.user.OAuth2User; -import org.springframework.stereotype.Service; - - -/** - * CustomOAuth2UserService 클래스 - * - * 코드 작성자: - * -서진영(jin2304) - * - * 코드 설명: - * -소셜 로그인 요청 시, Spring Security의 DefaultOAuth2UserService를 상속받아 사용자 정보를 로드하고, - * 회원가입 및 로그인 로직을 처리하는 서비스 클래스. - * -loadUser() 메서드를 재정의하여 소셜 서비스(naver, google, kakao 등)에 맞는 사용자 정보 처리 및 인증 수행. - -소셜 서비스에서 가져온 사용자 정보를 반환하여 Spring Security가 이 정보를 기반으로 로그인 검증(Authentication) 수행. - * -로그인 검증에 성공하면, 인증 객체(Authentication)를 생성하고 SecurityContext에 저장하여 로그인 상태를 유지. - * - * 코드 주요 기능: - * -loadUser(): 소셜 로그인 시 호출되며, 사용자 정보를 로드하고 회원가입 및 로그인을 처리. - * -회원가입 및 로그인 처리: - * 1) 새로운 사용자: 데이터베이스에 사용자 정보 삽입 및 고유 ID 생성. - * 2) 기존 사용자: 사용자 정보를 업데이트. - * - * 소셜 서비스 지원 플랫폼: - * -naver, google, kakao - * - * 예외 처리: - * -지원하지 않는 소셜 플랫폼 요청 시 null 반환. - * - * 코드 작성일: - * -2024.12.26 ~ 2024.12.26 - */ -@Service -public class CustomOAuth2UserService extends DefaultOAuth2UserService { - - private final MemberDao memberDao; - - public CustomOAuth2UserService(MemberDao memberDao) { - this.memberDao = memberDao; - } - - - - @Override - public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { - // 부모 클래스의 메서드를 호출하여 소셜 서비스에서 사용자 정보 로드 - OAuth2User oAuth2User = super.loadUser(userRequest); - // 요청한 소셜 서비스 이름 추출 - String registrationId = userRequest.getClientRegistration().getRegistrationId(); - OAuth2Response oAuth2Response = null; - - //소셜 서비스에 따라 응답 객체 처리 - if(registrationId.equals("naver")){ - oAuth2Response = new NaverResponse(oAuth2User.getAttributes()); - } - else if(registrationId.equals("google")){ - oAuth2Response = new GoogleResponse(oAuth2User.getAttributes()); - } - else if(registrationId.equals("kakao")){ - oAuth2Response = new KakaoResponse(oAuth2User.getAttributes()); - } - else{ - return null; - } - - - /* 회원가입 및 로그인 로직 */ - // 사용자 고유 식별값 생성 (소셜 서비스 이름 + 소셜 서비스 사용자 ID) - String loginId = oAuth2Response.getProvider() + oAuth2Response.getProviderId(); - // 기존 사용자 정보 조회 - Member existMember = memberDao.findByLoginId(loginId); - // 기본 사용자 역할 설정 - String role = "ROLE_USER"; - Long memberId; - - // 사용자가 존재하지않으면 회원가입 - if(existMember == null) { - Member member = new Member(); - member.setLoginId(loginId); - member.setPasswordHash("1111"); // 소셜 로그인은 비밀번호 불필요하지만 NOT NULL 제약 대응 - member.setMemberName(oAuth2Response.getName() != null ? oAuth2Response.getName() : "Unknown"); // member_name (NOT NULL) - member.setNickName("닉네임"); // 닉네임 임시 설정 - member.setEmail(oAuth2Response.getEmail()); - member.setRole(role); - memberDao.SocialjoinProcess(member); - memberId = member.getMemberId(); - }// 사용자가 이미 존재한다면 업데이트 - else{ - existMember.setLoginId(loginId); - existMember.setEmail(oAuth2Response.getEmail()); - role = existMember.getRole(); - memberId = existMember.getMemberId(); - memberDao.updateSocialMember(memberId, existMember); - } - - // 반환된 CustomOAuth2User는 Spring Security가 인증 객체 생성 및 로그인 검증에 사용 - return new CustomOAuth2User(oAuth2Response, role, memberId); - } - - -} diff --git a/src/main/java/com/web/SearchWeb/member/service/CustomerUserDetailService.java b/src/main/java/com/web/SearchWeb/member/service/CustomerUserDetailService.java deleted file mode 100644 index 8a792e6..0000000 --- a/src/main/java/com/web/SearchWeb/member/service/CustomerUserDetailService.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.web.SearchWeb.member.service; - - -import com.web.SearchWeb.member.dao.MybatisMemberDao; -import com.web.SearchWeb.member.domain.Member; -import com.web.SearchWeb.member.dto.CustomUserDetails; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.security.core.userdetails.UserDetails; -import org.springframework.security.core.userdetails.UserDetailsService; -import org.springframework.security.core.userdetails.UsernameNotFoundException; -import org.springframework.stereotype.Service; - -/** - * CustomerUserDetailService - * - * Spring Security 사용자 인증 서비스 - */ -@Service -public class CustomerUserDetailService implements UserDetailsService { - - MybatisMemberDao mybatisMemberDao; - - @Autowired - public CustomerUserDetailService(MybatisMemberDao mybatisMemberDao) { - this.mybatisMemberDao = mybatisMemberDao; - } - - - - @Override - public UserDetails loadUserByUsername(String loginId) throws UsernameNotFoundException { - Member findUser = mybatisMemberDao.findByLoginId(loginId); - if(findUser != null){ - //spring security에 전달해서 검증 - return new CustomUserDetails(findUser); - } - return null; - } -} diff --git a/src/main/resources/mapper/board-mapper.xml.bak b/src/main/resources/mapper/board-mapper.xml.bak new file mode 100644 index 0000000..977082e --- /dev/null +++ b/src/main/resources/mapper/board-mapper.xml.bak @@ -0,0 +1,183 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + INSERT INTO board (member_member_id, url, title, summary, description, hashtags) + VALUES (#{memberId}, #{boardDto.url}, #{boardDto.title}, #{boardDto.summary}, #{boardDto.description}, #{boardDto.hashtags}) + + + + + + + + + + + + + + + + + + + + + + UPDATE board + SET + url = #{boardDto.url}, + title = #{boardDto.title}, + summary = #{boardDto.summary}, + description = #{boardDto.description}, + hashtags = #{boardDto.hashtags} + WHERE board_id = #{boardId} + + + + + + UPDATE board + SET + job = #{job}, + major = #{major} + WHERE board_id = #{boardId} + + + + + + + DELETE FROM board + WHERE board_id = #{boardId} + + + + + + UPDATE board + SET bookmarks_count = #{bookmarkCount} + WHERE board_id = #{boardId} + + + + + + UPDATE board + SET views_count = views_count + 1 + WHERE board_id = #{boardId} + + + + + + UPDATE board + SET likes_count = likes_count + 1 + WHERE board_id = #{boardId} + + + + + + UPDATE board + SET likes_count = likes_count - 1 + WHERE board_id = #{boardId} + + + + + UPDATE board + SET comments_count = comments_count + 1 + WHERE board_id = #{boardId} + + + + + + UPDATE board + SET comments_count = comments_count - 1 + WHERE board_id = #{boardId} + + + + \ No newline at end of file diff --git a/src/main/resources/mapper/comment-mapper.xml.bak b/src/main/resources/mapper/comment-mapper.xml.bak new file mode 100644 index 0000000..446bd5c --- /dev/null +++ b/src/main/resources/mapper/comment-mapper.xml.bak @@ -0,0 +1,84 @@ + \ No newline at end of file diff --git a/src/main/resources/mapper/likes-mapper.xml.bak b/src/main/resources/mapper/likes-mapper.xml.bak new file mode 100644 index 0000000..7b9e68a --- /dev/null +++ b/src/main/resources/mapper/likes-mapper.xml.bak @@ -0,0 +1,40 @@ + \ No newline at end of file diff --git a/src/main/resources/mapper/main-mapper.xml.bak b/src/main/resources/mapper/main-mapper.xml.bak new file mode 100644 index 0000000..0d2ec76 --- /dev/null +++ b/src/main/resources/mapper/main-mapper.xml.bak @@ -0,0 +1,34 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/templates/member/join.html b/src/main/resources/templates/member/join.html deleted file mode 100644 index 19b14aa..0000000 --- a/src/main/resources/templates/member/join.html +++ /dev/null @@ -1,63 +0,0 @@ - - - - - 회원가입 - - - - - - -
-
-

회원가입

- - - -
-
- - - -
-
-
- - -
-
-
- - -
-
- -
- - -
-
- - - - -
-
-
- - -
- - diff --git a/src/main/resources/templates/member/login.html b/src/main/resources/templates/member/login.html deleted file mode 100644 index 89d6658..0000000 --- a/src/main/resources/templates/member/login.html +++ /dev/null @@ -1,100 +0,0 @@ - - - - - 로그인 - - - - - - - - - - -
-
- - - - - - -

로그인

- - -
-
- - -
-
- - -
- - -
-

아이디 또는 비밀번호가 잘못되었습니다.

-
- - - - - -
- - -

- 계정이 없으신가요? 회원가입 -

- - -
-
-
-
-
-
- 간편하게 로그인 -
-
- -
- - - Google - - - - - Naver - - - - - Kakao - -
- -
-
-
- - -
- - From 9e592e6f71f5190b6f073e65fcddf328ebfaceed Mon Sep 17 00:00:00 2001 From: jin2304 Date: Sun, 29 Mar 2026 23:28:28 +0900 Subject: [PATCH 04/22] =?UTF-8?q?feat:=20Spring=20Security,=20JWT=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EB=B0=8F=20Refresh=20Token=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98=20Auth=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=EC=B6=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 7 +- .../web/SearchWeb/SearchWebApplication.java | 5 + .../auth/controller/AuthController.java | 125 ++++++++++++ .../auth/controller/dto/AuthResponses.java | 29 +++ .../auth/dao/MybatisRefreshTokenDao.java | 74 +++++++ .../SearchWeb/auth/dao/RefreshTokenDao.java | 27 +++ .../SearchWeb/auth/domain/RefreshToken.java | 21 ++ .../SearchWeb/auth/error/AuthErrorCode.java | 25 +++ .../SearchWeb/auth/error/AuthException.java | 20 ++ .../SearchWeb/auth/service/AuthService.java | 25 +++ .../auth/service/AuthServiceImpl.java | 160 +++++++++++++++ .../service/RefreshTokenCleanupScheduler.java | 28 +++ .../jwt/JwtAuthenticationEntryPoint.java | 48 +++++ .../config/jwt/JwtAuthenticationFilter.java | 71 +++++++ .../config/jwt/JwtMemberPrincipal.java | 21 ++ .../SearchWeb/config/jwt/JwtProperties.java | 19 ++ .../web/SearchWeb/config/jwt/JwtUtils.java | 119 ++++++++++++ .../config/jwt/OAuth2FailureHandler.java | 64 ++++++ .../config/jwt/OAuth2SuccessHandler.java | 134 +++++++++++++ .../config/security/CookieUtils.java | 107 ++++++++++ .../config/security/CurrentMemberId.java | 14 ++ .../CurrentMemberIdArgumentResolver.java | 35 ++++ .../CustomAuthenticationFailureHandler.java | 26 --- .../CustomAuthenticationSuccessHandler.java | 27 --- ...eOAuth2AuthorizationRequestRepository.java | 80 ++++++++ .../config/security/SecurityConfig.java | 183 +++++++++++------- .../config/security/SecurityUtils.java | 65 +++++-- .../config/security/WebMvcConfig.java | 22 +++ src/main/resources/application-dev.properties | 4 + .../resources/application-local.properties | 4 + .../resources/application-prod.properties | 4 + src/main/resources/application.properties | 12 +- src/main/resources/db/init_postgres.sql | 16 ++ .../resources/mapper/refresh-token-mapper.xml | 53 +++++ 34 files changed, 1530 insertions(+), 144 deletions(-) create mode 100644 src/main/java/com/web/SearchWeb/auth/controller/AuthController.java create mode 100644 src/main/java/com/web/SearchWeb/auth/controller/dto/AuthResponses.java create mode 100644 src/main/java/com/web/SearchWeb/auth/dao/MybatisRefreshTokenDao.java create mode 100644 src/main/java/com/web/SearchWeb/auth/dao/RefreshTokenDao.java create mode 100644 src/main/java/com/web/SearchWeb/auth/domain/RefreshToken.java create mode 100644 src/main/java/com/web/SearchWeb/auth/error/AuthErrorCode.java create mode 100644 src/main/java/com/web/SearchWeb/auth/error/AuthException.java create mode 100644 src/main/java/com/web/SearchWeb/auth/service/AuthService.java create mode 100644 src/main/java/com/web/SearchWeb/auth/service/AuthServiceImpl.java create mode 100644 src/main/java/com/web/SearchWeb/auth/service/RefreshTokenCleanupScheduler.java create mode 100644 src/main/java/com/web/SearchWeb/config/jwt/JwtAuthenticationEntryPoint.java create mode 100644 src/main/java/com/web/SearchWeb/config/jwt/JwtAuthenticationFilter.java create mode 100644 src/main/java/com/web/SearchWeb/config/jwt/JwtMemberPrincipal.java create mode 100644 src/main/java/com/web/SearchWeb/config/jwt/JwtProperties.java create mode 100644 src/main/java/com/web/SearchWeb/config/jwt/JwtUtils.java create mode 100644 src/main/java/com/web/SearchWeb/config/jwt/OAuth2FailureHandler.java create mode 100644 src/main/java/com/web/SearchWeb/config/jwt/OAuth2SuccessHandler.java create mode 100644 src/main/java/com/web/SearchWeb/config/security/CookieUtils.java create mode 100644 src/main/java/com/web/SearchWeb/config/security/CurrentMemberId.java create mode 100644 src/main/java/com/web/SearchWeb/config/security/CurrentMemberIdArgumentResolver.java delete mode 100644 src/main/java/com/web/SearchWeb/config/security/CustomAuthenticationFailureHandler.java delete mode 100644 src/main/java/com/web/SearchWeb/config/security/CustomAuthenticationSuccessHandler.java create mode 100644 src/main/java/com/web/SearchWeb/config/security/HttpCookieOAuth2AuthorizationRequestRepository.java create mode 100644 src/main/java/com/web/SearchWeb/config/security/WebMvcConfig.java create mode 100644 src/main/resources/mapper/refresh-token-mapper.xml diff --git a/build.gradle b/build.gradle index 061391c..0bd9a7a 100644 --- a/build.gradle +++ b/build.gradle @@ -26,16 +26,19 @@ repositories { dependencies { implementation 'org.springframework.boot:spring-boot-starter-security' - implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-aop' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.3' - implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' implementation 'me.paulschwarz:spring-dotenv:4.0.0' implementation 'org.jsoup:jsoup:1.17.2' + + // JWT + implementation 'io.jsonwebtoken:jjwt-api:0.12.6' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6' implementation platform('org.springframework.ai:spring-ai-bom:1.1.2') implementation 'org.springframework.ai:spring-ai-starter-model-openai' implementation 'org.springframework.ai:spring-ai-starter-model-google-genai' diff --git a/src/main/java/com/web/SearchWeb/SearchWebApplication.java b/src/main/java/com/web/SearchWeb/SearchWebApplication.java index fac5dce..c0a953c 100644 --- a/src/main/java/com/web/SearchWeb/SearchWebApplication.java +++ b/src/main/java/com/web/SearchWeb/SearchWebApplication.java @@ -1,9 +1,14 @@ package com.web.SearchWeb; +import com.web.SearchWeb.config.jwt.JwtProperties; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication +@EnableConfigurationProperties(JwtProperties.class) +@EnableScheduling public class SearchWebApplication { public static void main(String[] args) { diff --git a/src/main/java/com/web/SearchWeb/auth/controller/AuthController.java b/src/main/java/com/web/SearchWeb/auth/controller/AuthController.java new file mode 100644 index 0000000..9c392c9 --- /dev/null +++ b/src/main/java/com/web/SearchWeb/auth/controller/AuthController.java @@ -0,0 +1,125 @@ +package com.web.SearchWeb.auth.controller; + +import com.web.SearchWeb.auth.controller.dto.AuthResponses; +import com.web.SearchWeb.auth.service.AuthService; +import com.web.SearchWeb.config.common.ApiResponse; +import com.web.SearchWeb.auth.error.AuthErrorCode; +import com.web.SearchWeb.auth.error.AuthException; +import com.web.SearchWeb.config.jwt.JwtProperties; +import com.web.SearchWeb.config.security.CurrentMemberId; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseCookie; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.time.Duration; + +/** + * 인증 관련 API를 처리하는 컨트롤러. + * - Refresh Token을 이용한 토큰 재발급 + * - 로그아웃 처리 + * - 현재 로그인된 사용자 정보 조회 등의 기능을 제공 + */ +@RestController +@RequestMapping("/api/auth") +@Slf4j +@RequiredArgsConstructor +public class AuthController { + + private final AuthService authService; + private final JwtProperties jwtProperties; + + @Value("${app.cookie.secure}") + private boolean cookieSecure; + + /** + * Refresh Token으로 새 Access Token 발급 + * - Cookie에서 refreshToken 읽기 + * - 새 토큰 쌍 발급 (rotation) + * - 새 refreshToken을 쿠키로, accessToken을 body로 반환 + */ + @PostMapping("/refresh") + public ResponseEntity> refresh( + HttpServletRequest request, + @CookieValue(name = "refreshToken", required = false) String refreshToken) { + + Cookie[] cookies = request.getCookies(); + int cookieCount = cookies == null ? 0 : cookies.length; + log.debug("[RefreshController] request cookies count: {}", cookieCount); + + if (refreshToken == null || refreshToken.isBlank()) { + log.warn("[RefreshController] refreshToken cookie missing or blank"); + throw AuthException.of(AuthErrorCode.AUTH_REFRESH_TOKEN_NOT_FOUND); + } + + log.debug("[RefreshController] refreshToken cookie received, prefix: {}", + refreshToken.substring(0, Math.min(12, refreshToken.length()))); + + AuthResponses.TokenPair tokenPair = authService.refresh(refreshToken); + + ResponseCookie cookie = createRefreshTokenCookie( + tokenPair.getRefreshToken(), + Duration.ofMillis(jwtProperties.refreshTokenExpiry()).getSeconds()); + + AuthResponses.AccessToken response = AuthResponses.AccessToken.builder() + .accessToken(tokenPair.getAccessToken()) + .build(); + + return ResponseEntity.ok() + .header("Set-Cookie", cookie.toString()) + .body(ApiResponse.success(response)); + } + + + /** + * 로그아웃 + * - Cookie에서 refreshToken 읽기 + * - DB에서 삭제 + * - 쿠키 삭제 + */ + @PostMapping("/logout") + public ResponseEntity> logout( + @CookieValue(name = "refreshToken", required = false) String refreshToken) { + + if (refreshToken != null && !refreshToken.isBlank()) { + authService.logout(refreshToken); + } + + ResponseCookie clearCookie = createRefreshTokenCookie("", 0); + + return ResponseEntity.ok() + .header("Set-Cookie", clearCookie.toString()) + .body(ApiResponse.success(null)); + } + + + /** + * 현재 인증된 사용자 정보 조회 + * - Bearer Access Token에서 memberId 추출 + */ + @PreAuthorize("isAuthenticated()") + @GetMapping("/member") + public ResponseEntity> getAuthenticatedMemberInfo(@CurrentMemberId Long memberId) { + AuthResponses.MemberInfo memberInfo = authService.getAuthenticatedMemberInfo(memberId); + return ResponseEntity.ok(ApiResponse.success(memberInfo)); + } + + + /** + * Refresh Token 쿠키 생성 + */ + private ResponseCookie createRefreshTokenCookie(String token, long maxAgeSeconds) { + return ResponseCookie.from("refreshToken", token) + .httpOnly(true) // 자바스크립트 접근 방지 (XSS 보호) + .secure(cookieSecure) // 프로필별 설정 (local=false, prod=true) + .sameSite("Lax") // CSRF 보호 + .path(jwtProperties.refreshTokenPath()) // 쿠키 사용 범위 제한 + .maxAge(maxAgeSeconds) // 쿠키 만료 시간 + .build(); + } +} diff --git a/src/main/java/com/web/SearchWeb/auth/controller/dto/AuthResponses.java b/src/main/java/com/web/SearchWeb/auth/controller/dto/AuthResponses.java new file mode 100644 index 0000000..d666eeb --- /dev/null +++ b/src/main/java/com/web/SearchWeb/auth/controller/dto/AuthResponses.java @@ -0,0 +1,29 @@ +package com.web.SearchWeb.auth.controller.dto; + +import lombok.Builder; +import lombok.Getter; + +public class AuthResponses { + + @Getter + @Builder + public static class TokenPair { + private final String accessToken; + private final String refreshToken; + } + + @Getter + @Builder + public static class AccessToken { + private final String accessToken; + } + + @Getter + @Builder + public static class MemberInfo { + private final Long memberId; + private final String name; + private final String email; + private final String role; + } +} diff --git a/src/main/java/com/web/SearchWeb/auth/dao/MybatisRefreshTokenDao.java b/src/main/java/com/web/SearchWeb/auth/dao/MybatisRefreshTokenDao.java new file mode 100644 index 0000000..81e0b62 --- /dev/null +++ b/src/main/java/com/web/SearchWeb/auth/dao/MybatisRefreshTokenDao.java @@ -0,0 +1,74 @@ +package com.web.SearchWeb.auth.dao; + +import com.web.SearchWeb.auth.domain.RefreshToken; +import lombok.RequiredArgsConstructor; +import org.apache.ibatis.session.SqlSession; +import org.springframework.stereotype.Repository; + +/** + * 리프레시 토큰 데이터 접근 객체(DAO) 인터페이스 + */ +@Repository +@RequiredArgsConstructor +public class MybatisRefreshTokenDao implements RefreshTokenDao { + + private final SqlSession sqlSession; + private static final String NAMESPACE = "com.web.SearchWeb.auth.dao.RefreshTokenDao."; + + /** + * 새로운 리프레시 토큰 저장 + */ + @Override + public void insertRefreshToken(RefreshToken refreshToken) { + + sqlSession.insert(NAMESPACE + "insertRefreshToken", refreshToken); + } + + /** + * 토큰 해시값으로 리프레시 토큰 조회 + */ + @Override + public RefreshToken findByTokenHash(String tokenHash) { + return sqlSession.selectOne(NAMESPACE + "findByTokenHash", tokenHash); + } + + /** + * 토큰 해시값으로 리프레시 토큰 조회 (비관적 락 적용) + */ + @Override + public RefreshToken findByTokenHashForUpdate(String tokenHash) { + return sqlSession.selectOne(NAMESPACE + "findByTokenHashForUpdate", tokenHash); + } + + /** + * 특정 토큰 해시값 삭제 + */ + @Override + public void deleteByTokenHash(String tokenHash) { + sqlSession.delete(NAMESPACE + "deleteByTokenHash", tokenHash); + } + + /** + * 특정 회원 ID의 모든 토큰 삭제 (로그아웃 등) + */ + @Override + public void deleteByMemberId(Long memberId) { + sqlSession.delete(NAMESPACE + "deleteByMemberId", memberId); + } + + /** + * 만료된 토큰 일괄 삭제 + */ + @Override + public void deleteExpired() { + sqlSession.delete(NAMESPACE + "deleteExpired"); + } + + /** + * 토큰 로테이션 시 갱신 시간 업데이트 + */ + @Override + public void updateRotatedAt(String tokenHash) { + sqlSession.update(NAMESPACE + "updateRotatedAt", tokenHash); + } +} diff --git a/src/main/java/com/web/SearchWeb/auth/dao/RefreshTokenDao.java b/src/main/java/com/web/SearchWeb/auth/dao/RefreshTokenDao.java new file mode 100644 index 0000000..c38d01d --- /dev/null +++ b/src/main/java/com/web/SearchWeb/auth/dao/RefreshTokenDao.java @@ -0,0 +1,27 @@ +package com.web.SearchWeb.auth.dao; + +import com.web.SearchWeb.auth.domain.RefreshToken; + +// 리프레시 토큰 데이터 접근 객체(DAO) 인터페이스 +public interface RefreshTokenDao { + // 새로운 리프레시 토큰 저장 + void insertRefreshToken(RefreshToken refreshToken); + + // 토큰 해시값으로 리프레시 토큰 조회 + RefreshToken findByTokenHash(String tokenHash); + + // 토큰 해시값으로 리프레시 토큰 조회 (비관적 락 적용) + RefreshToken findByTokenHashForUpdate(String tokenHash); + + // 특정 토큰 해시값 삭제 + void deleteByTokenHash(String tokenHash); + + // 특정 회원 ID의 모든 토큰 삭제 (로그아웃 등) + void deleteByMemberId(Long memberId); + + // 만료된 토큰 일괄 삭제 + void deleteExpired(); + + // 토큰 로테이션 시 갱신 시간 업데이트 + void updateRotatedAt(String tokenHash); +} diff --git a/src/main/java/com/web/SearchWeb/auth/domain/RefreshToken.java b/src/main/java/com/web/SearchWeb/auth/domain/RefreshToken.java new file mode 100644 index 0000000..3645a45 --- /dev/null +++ b/src/main/java/com/web/SearchWeb/auth/domain/RefreshToken.java @@ -0,0 +1,21 @@ +package com.web.SearchWeb.auth.domain; + +import lombok.Builder; +import lombok.Getter; + +import java.time.Instant; + +/** + * 리프레시 토큰(Refresh Token) 도메인 클래스 + * - 사용자 인증 유지를 위한 리프레시 토큰의 정보를 관리. + */ +@Getter +@Builder +public class RefreshToken { + private Long id; // 고유 식별자 (PK) + private Long memberId; // 이 토큰을 소유한 회원의 ID + private String tokenHash; // 보안을 위해 해싱된 토큰 값 + private Instant expiresAt; // 토큰의 만료 일시 + private Instant createdAt; // 토큰이 최초로 생성된 일시 + private Instant rotatedAt; // 토큰이 재발급(Rotation)된 일시 +} diff --git a/src/main/java/com/web/SearchWeb/auth/error/AuthErrorCode.java b/src/main/java/com/web/SearchWeb/auth/error/AuthErrorCode.java new file mode 100644 index 0000000..a52f1d3 --- /dev/null +++ b/src/main/java/com/web/SearchWeb/auth/error/AuthErrorCode.java @@ -0,0 +1,25 @@ +package com.web.SearchWeb.auth.error; + +import com.web.SearchWeb.config.exception.ErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +/** + * 인증 관련 에러 코드를 정의하는 ENUM입니다. + */ +@Getter +@RequiredArgsConstructor +public enum AuthErrorCode implements ErrorCode { + AUTH_UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "A001", "인증이 필요합니다."), + AUTH_TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED, "A002", "토큰이 만료되었습니다."), + AUTH_INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "A003", "유효하지 않은 토큰입니다."), + AUTH_REFRESH_TOKEN_NOT_FOUND(HttpStatus.UNAUTHORIZED, "A004", "Refresh Token이 없습니다."), + AUTH_ACCESS_DENIED(HttpStatus.FORBIDDEN, "A005", "접근 권한이 없습니다."), + AUTH_INVALID_REDIRECT_URI(HttpStatus.BAD_REQUEST, "A006", "유효하지 않은 리다이렉트 주소입니다."), + AUTH_UNSUPPORTED_PROVIDER(HttpStatus.BAD_REQUEST, "A007", "지원하지 않는 소셜 로그인 제공자입니다."); + + private final HttpStatus status; + private final String code; + private final String message; +} diff --git a/src/main/java/com/web/SearchWeb/auth/error/AuthException.java b/src/main/java/com/web/SearchWeb/auth/error/AuthException.java new file mode 100644 index 0000000..5174548 --- /dev/null +++ b/src/main/java/com/web/SearchWeb/auth/error/AuthException.java @@ -0,0 +1,20 @@ +package com.web.SearchWeb.auth.error; + +import com.web.SearchWeb.config.exception.BusinessException; +import com.web.SearchWeb.config.exception.ErrorCode; +import lombok.Getter; + +/** + * 인증 도메인에서 발생하는 예외를 처리하는 클래스입니다. + */ +@Getter +public class AuthException extends BusinessException { + + private AuthException(ErrorCode errorCode) { + super(errorCode); + } + + public static AuthException of(ErrorCode errorCode) { + return new AuthException(errorCode); + } +} diff --git a/src/main/java/com/web/SearchWeb/auth/service/AuthService.java b/src/main/java/com/web/SearchWeb/auth/service/AuthService.java new file mode 100644 index 0000000..021d96d --- /dev/null +++ b/src/main/java/com/web/SearchWeb/auth/service/AuthService.java @@ -0,0 +1,25 @@ +package com.web.SearchWeb.auth.service; + +import com.web.SearchWeb.auth.controller.dto.AuthResponses; + +/** + * 인증 관련 비즈니스 로직을 처리하는 서비스 인터페이스. + * - JWT 토큰 발급, 갱신, 로그아웃 및 사용자 정보 조회 처리 + */ +public interface AuthService { + + // 토큰 발급 (Access + Refresh) + AuthResponses.TokenPair issueTokens(Long memberId, String role); + + // OAuth2 로그인 성공 후 Refresh Token만 발급 (Access Token은 이후 /refresh로 발급) + String issueRefreshToken(Long memberId); + + // 토큰 갱신 + AuthResponses.TokenPair refresh(String refreshToken); + + // 로그아웃 + void logout(String refreshToken); + + // 인증된 회원 정보 조회 + AuthResponses.MemberInfo getAuthenticatedMemberInfo(Long memberId); +} diff --git a/src/main/java/com/web/SearchWeb/auth/service/AuthServiceImpl.java b/src/main/java/com/web/SearchWeb/auth/service/AuthServiceImpl.java new file mode 100644 index 0000000..67cbd78 --- /dev/null +++ b/src/main/java/com/web/SearchWeb/auth/service/AuthServiceImpl.java @@ -0,0 +1,160 @@ +package com.web.SearchWeb.auth.service; + +import com.web.SearchWeb.auth.controller.dto.AuthResponses; +import com.web.SearchWeb.auth.dao.RefreshTokenDao; +import com.web.SearchWeb.auth.domain.RefreshToken; +import com.web.SearchWeb.auth.error.AuthErrorCode; +import com.web.SearchWeb.auth.error.AuthException; +import com.web.SearchWeb.config.jwt.JwtUtils; +import com.web.SearchWeb.member.dao.MemberDao; +import com.web.SearchWeb.member.domain.Member; +import com.web.SearchWeb.config.security.SecurityUtils; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; + +/** + * 인증 관련 비즈니스 로직을 처리하는 서비스. + * - JWT 토큰 발급, 갱신, 로그아웃 및 사용자 정보 조회 처리 + */ +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional +public class AuthServiceImpl implements AuthService { + + private final JwtUtils jwtUtils; + private final RefreshTokenDao refreshTokenDao; + private final MemberDao memberDao; + private final com.web.SearchWeb.config.jwt.JwtProperties jwtProperties; + + + /** + * 토큰 발급 (Access/Refresh Token 생성 및 저장) + */ + @Override + public AuthResponses.TokenPair issueTokens(Long memberId, String role) { + String accessToken = jwtUtils.generateAccessToken(memberId, role); + String refreshToken = createAndSaveRefreshToken(memberId); + + return AuthResponses.TokenPair.builder() + .accessToken(accessToken) + .refreshToken(refreshToken) + .build(); + } + + + /** + * OAuth2 로그인 성공 후 Refresh Token만 발급 + * - 기존 토큰 정리 후 신규 발급 (고아 토큰 방지) + * - Access Token은 이후 프론트엔드의 /refresh 호출로 발급 + */ + @Override + public String issueRefreshToken(Long memberId) { + // 기존 토큰 정리 (같은 사용자가 반복 로그인 시 고아 토큰 누적 방지) + refreshTokenDao.deleteByMemberId(memberId); + String token = createAndSaveRefreshToken(memberId); + return token; + } + + + /** + * Refresh Token 생성 및 DB 저장 (공통 로직) + */ + private String createAndSaveRefreshToken(Long memberId) { + String refreshToken = jwtUtils.generateRefreshToken(memberId); + String tokenHash = SecurityUtils.hashToken(refreshToken); + refreshTokenDao.insertRefreshToken(RefreshToken.builder() + .memberId(memberId) + .tokenHash(tokenHash) + .expiresAt(Instant.now().plusMillis(jwtProperties.refreshTokenExpiry())) + .build()); + return refreshToken; + } + + + /** + * 토큰 갱신 (Refresh Token 검증 및 재발급) + */ + @Override + public AuthResponses.TokenPair refresh(String refreshToken) { + // 1. 토큰 유효성 검증 (실패 시 JwtException 발생) + jwtUtils.validateToken(refreshToken); + + // 2. DB에서 해시로 조회 (비관적 락 적용하여 동시성 제어) + String hashedToken = SecurityUtils.hashToken(refreshToken); + RefreshToken storedRefreshToken = refreshTokenDao.findByTokenHashForUpdate(hashedToken); + if (storedRefreshToken == null) { + log.warn("[Refresh] DB에서 토큰을 찾을 수 없음 — 해시(앞8자리): {}", hashedToken.substring(0, 8)); + throw AuthException.of(AuthErrorCode.AUTH_REFRESH_TOKEN_NOT_FOUND); + } + + // 3. 만료 확인 + if (storedRefreshToken.getExpiresAt().isBefore(Instant.now())) { + refreshTokenDao.deleteByTokenHash(hashedToken); + throw AuthException.of(AuthErrorCode.AUTH_TOKEN_EXPIRED); + } + + // 4. 기존 토큰 갱신/삭제 처리 (Grace Period 도입) + if (storedRefreshToken.getRotatedAt() != null) { + // 이미 한 번 갱신된 토큰인 경우, 유예 기간(예: 10초) 확인 + if (storedRefreshToken.getRotatedAt().plusSeconds(10).isAfter(Instant.now())) { + // 유예 기간 내 재사용 시, 신규 토큰 발급 후 기존 rotated 토큰 즉시 삭제 (재사용 1회만 허용) + refreshTokenDao.deleteByTokenHash(hashedToken); + return issueTokens(storedRefreshToken.getMemberId(), getMemberRole(storedRefreshToken.getMemberId())); + } + // 유예 기간 지남 -> 이미 사용된 토큰으로 간주하여 에러 처리 + log.warn("[Refresh] Grace Period 초과 — rotatedAt: {}, now: {}", storedRefreshToken.getRotatedAt(), Instant.now()); + throw AuthException.of(AuthErrorCode.AUTH_REFRESH_TOKEN_NOT_FOUND); + } + + // 처음 갱신 시도 시 rotated_at 설정 (즉시 삭제하지 않음) + refreshTokenDao.updateRotatedAt(hashedToken); + + // 5. 새 토큰 쌍 발급 + Long memberId = storedRefreshToken.getMemberId(); + return issueTokens(memberId, getMemberRole(memberId)); + } + + /** + * 회원의 권한(Role)을 조회하는 내부 메서드 + */ + private String getMemberRole(Long memberId) { + Member member = memberDao.findByMemberId(memberId); + if (member == null) { + throw AuthException.of(AuthErrorCode.AUTH_UNAUTHORIZED); + } + return member.getRole(); + } + + + /** + * 로그아웃 (Refresh Token 삭제) + */ + @Override + public void logout(String refreshToken) { + String hashedToken = SecurityUtils.hashToken(refreshToken); + refreshTokenDao.deleteByTokenHash(hashedToken); + } + + + /** + * 인증된 회원의 정보 조회 + */ + @Override + public AuthResponses.MemberInfo getAuthenticatedMemberInfo(Long memberId) { + Member member = memberDao.findByMemberId(memberId); + if (member == null) { + throw AuthException.of(AuthErrorCode.AUTH_UNAUTHORIZED); + } + return AuthResponses.MemberInfo.builder() + .memberId(member.getMemberId()) + .name(member.getMemberName()) + .email(member.getEmail()) + .role(member.getRole()) + .build(); + } +} diff --git a/src/main/java/com/web/SearchWeb/auth/service/RefreshTokenCleanupScheduler.java b/src/main/java/com/web/SearchWeb/auth/service/RefreshTokenCleanupScheduler.java new file mode 100644 index 0000000..b955918 --- /dev/null +++ b/src/main/java/com/web/SearchWeb/auth/service/RefreshTokenCleanupScheduler.java @@ -0,0 +1,28 @@ +package com.web.SearchWeb.auth.service; + +import com.web.SearchWeb.auth.dao.RefreshTokenDao; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +/** + * 만료된 Refresh Token을 주기적으로 DB에서 정리하는 스케줄러 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class RefreshTokenCleanupScheduler { + + private final RefreshTokenDao refreshTokenDao; + + /** + * 매일 새벽 3시에 만료된 Refresh Token 삭제 + */ + @Scheduled(cron = "0 0 3 * * *") + public void cleanupExpiredTokens() { + log.info("[Scheduler] 만료된 Refresh Token 정리 시작"); + refreshTokenDao.deleteExpired(); + log.info("[Scheduler] 만료된 Refresh Token 정리 완료"); + } +} diff --git a/src/main/java/com/web/SearchWeb/config/jwt/JwtAuthenticationEntryPoint.java b/src/main/java/com/web/SearchWeb/config/jwt/JwtAuthenticationEntryPoint.java new file mode 100644 index 0000000..61493b9 --- /dev/null +++ b/src/main/java/com/web/SearchWeb/config/jwt/JwtAuthenticationEntryPoint.java @@ -0,0 +1,48 @@ +package com.web.SearchWeb.config.jwt; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.web.SearchWeb.config.common.ApiResponse; +import com.web.SearchWeb.auth.error.AuthErrorCode; +import com.web.SearchWeb.config.exception.ErrorCode; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.MediaType; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +/** + * 사용자가 인증 없이 보호된 리소스에 접근하려 할 때(401 Unauthorized) 호출되는 엔트리 포인트 + * -JWT 토큰이 없거나 유효하지 않은 경우의 처리를 담당 + */ +@Component +public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + /** + * 인증 예외가 발생했을 때 실행되는 메서드 + * -클라이언트에게 401 상태 코드와 함께 JSON 형태의 에러 메시지를 반환 + */ + @Override + public void commence(HttpServletRequest request, + HttpServletResponse response, + AuthenticationException authException) throws IOException { + // Filter에서 설정한 예외 코드가 있는지 확인 + ErrorCode errorCode = (ErrorCode) request.getAttribute("exception"); + + // 예외 코드가 없으면 기본 인증 실패 코드 사용 (토큰이 아예 없는 경우 등) + if (errorCode == null) { + errorCode = AuthErrorCode.AUTH_UNAUTHORIZED; + } + + response.setStatus(errorCode.getStatus().value()); // 에러 코드에 정의된 상태 코드 설정 + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding("UTF-8"); + + ApiResponse errorResponse = ApiResponse.fail(errorCode); + response.getWriter().write(objectMapper.writeValueAsString(errorResponse)); + } +} diff --git a/src/main/java/com/web/SearchWeb/config/jwt/JwtAuthenticationFilter.java b/src/main/java/com/web/SearchWeb/config/jwt/JwtAuthenticationFilter.java new file mode 100644 index 0000000..a69e846 --- /dev/null +++ b/src/main/java/com/web/SearchWeb/config/jwt/JwtAuthenticationFilter.java @@ -0,0 +1,71 @@ +package com.web.SearchWeb.config.jwt; + +import com.web.SearchWeb.auth.error.AuthErrorCode; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.JwtException; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.List; + +/** + * 모든 요청마다 JWT 토큰을 검사하는 필터 + * - 토큰이 유효하면 → SecurityContext에 인증 정보 저장 (이후 컨트롤러에서 사용자 식별 가능) + * - 토큰이 없으면 → 아무것도 안 하고 통과 (공개 API는 정상, 보호 API는 EntryPoint에서 401 처리) + * - 토큰이 만료/위변조면 → 에러코드를 request에 심어서 EntryPoint에 전달 + */ +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtUtils jwtUtils; + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + // 1. 요청 헤더에서 토큰 추출 (없으면 null) + String token = extractToken(request); + + try { + // 2. 토큰이 있고 유효하면 → 인증 정보를 SecurityContext에 저장, 없으면 아무것도 안 하고 통과 + if (token != null) { + jwtUtils.validateToken(token); + JwtMemberPrincipal principal = jwtUtils.parseAccessToken(token); + List authorities = List.of(new SimpleGrantedAuthority(principal.role())); + UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken(principal, null, authorities); + SecurityContextHolder.getContext().setAuthentication(auth); + } + } catch (ExpiredJwtException e) { + // 3-1. 토큰 만료 → Context 클리어 후 에러코드 저장 + SecurityContextHolder.clearContext(); + request.setAttribute("exception", AuthErrorCode.AUTH_TOKEN_EXPIRED); + } catch (JwtException | IllegalArgumentException e) { + // 3-2. 토큰 위변조/파싱 실패 → Context 클리어 후 에러코드 저장 + SecurityContextHolder.clearContext(); + request.setAttribute("exception", AuthErrorCode.AUTH_INVALID_TOKEN); + } + + // 4. 다음 필터로 진행 (인증 성공/실패 관계없이 항상 호출, 토큰이 없거나 유효하지 않아도 다음 필터로 넘김) + // permitAll() 설정된 공개 API의 접근을 보장하고, 보호된 API는 뒷단의 AuthorizationFilter에서 차단하기 위함 + filterChain.doFilter(request, response); + } + + /** + * "Authorization: Bearer {token}" 헤더에서 토큰 문자열만 추출 + */ + private String extractToken(HttpServletRequest request) { + String bearer = request.getHeader("Authorization"); + if (bearer != null && bearer.startsWith("Bearer ")) { + return bearer.substring(7); + } + return null; + } +} diff --git a/src/main/java/com/web/SearchWeb/config/jwt/JwtMemberPrincipal.java b/src/main/java/com/web/SearchWeb/config/jwt/JwtMemberPrincipal.java new file mode 100644 index 0000000..82b3012 --- /dev/null +++ b/src/main/java/com/web/SearchWeb/config/jwt/JwtMemberPrincipal.java @@ -0,0 +1,21 @@ +package com.web.SearchWeb.config.jwt; + +import java.security.Principal; + +/** + * JWT에서 추출한 사용자 정보를 담는 Principal 구현체. + * + * @param memberId 사용자의 고유 식별자 (ID) + * @param role 사용자의 권한 정보 + */ +public record JwtMemberPrincipal(Long memberId, String role) implements Principal { + + /** + * principal의 식별자 이름을 반환. + * 여기서는 memberId를 문자열로 반환하여 식별자로 사용. + */ + @Override + public String getName() { + return String.valueOf(memberId); + } +} diff --git a/src/main/java/com/web/SearchWeb/config/jwt/JwtProperties.java b/src/main/java/com/web/SearchWeb/config/jwt/JwtProperties.java new file mode 100644 index 0000000..8d4e9e4 --- /dev/null +++ b/src/main/java/com/web/SearchWeb/config/jwt/JwtProperties.java @@ -0,0 +1,19 @@ +package com.web.SearchWeb.config.jwt; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Positive; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.validation.annotation.Validated; + +/** + * JWT 관련 설정 값을 관리하는 프로퍼티 클래스 + * -application.properties에서 'jwt' 접두어로 시작하는 설정값들을 매핑받습니다. + */ +@Validated +@ConfigurationProperties(prefix = "jwt") +public record JwtProperties( + @NotBlank String secret, // JWT 서명에 사용할 비밀 키 + @Positive long accessTokenExpiry, // Access Token 만료 시간 (밀리초 단위) + @Positive long refreshTokenExpiry, // Refresh Token 만료 시간 (밀리초 단위) + @NotBlank String refreshTokenPath // Refresh Token 쿠키 경로 (보안을 위해 /api/auth로 한정) +) {} diff --git a/src/main/java/com/web/SearchWeb/config/jwt/JwtUtils.java b/src/main/java/com/web/SearchWeb/config/jwt/JwtUtils.java new file mode 100644 index 0000000..7f22317 --- /dev/null +++ b/src/main/java/com/web/SearchWeb/config/jwt/JwtUtils.java @@ -0,0 +1,119 @@ +package com.web.SearchWeb.config.jwt; + +import io.jsonwebtoken.*; +import io.jsonwebtoken.security.Keys; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.util.Base64; +import java.util.Date; +import java.util.UUID; + +/** + * JWT(JSON Web Token) 생성 및 유효성 검증을 담당하는 컴포넌트 + */ +@Slf4j +@Component +public class JwtUtils { + + private final SecretKey secretKey; + private final long accessTokenExpiry; + private final long refreshTokenExpiry; + + public JwtUtils(JwtProperties properties) { + // Base64로 인코딩된 시크릿 키를 디코딩하여 HMAC-SHA 키로 변환 + byte[] keyBytes = Base64.getDecoder().decode(properties.secret()); + this.secretKey = Keys.hmacShaKeyFor(keyBytes); + this.accessTokenExpiry = properties.accessTokenExpiry(); + this.refreshTokenExpiry = properties.refreshTokenExpiry(); + } + + /** + * Access Token 생성 + * @param memberId 사용자 식별 ID + * @param role 사용자 권한 (예: ROLE_USER) + * @return 생성된 Access Token 문자열 + */ + + public String generateAccessToken(Long memberId, String role) { + Date now = new Date(); + return Jwts.builder() + .id(UUID.randomUUID().toString()) // 무작위 ID 추가로 동일 시간 생성 시 중복 방지 + .subject(String.valueOf(memberId)) // 토큰 제목 (사용자 ID) + .claim("role", role) // 사용자 권한 클레임 추가 + .issuedAt(now) // 발행 시간 + .expiration(new Date(now.getTime() + accessTokenExpiry)) // 만료 시간 + .signWith(secretKey) // 서명 + .compact(); + } + + /** + * Refresh Token 생성 + * @param memberId 사용자 식별 ID + * @return 생성된 Refresh Token 문자열 + */ + public String generateRefreshToken(Long memberId) { + Date now = new Date(); + return Jwts.builder() + .id(UUID.randomUUID().toString()) // 무작위 ID 추가로 동일 시간 생성 시 중복 방지 + .subject(String.valueOf(memberId)) + .issuedAt(now) + .expiration(new Date(now.getTime() + refreshTokenExpiry)) + .signWith(secretKey) + .compact(); + } + + /** + * Access Token을 파싱하여 사용자 정보(Principal) 추출 + * @param token Access Token + * @return 사용자 ID와 권한이 담긴 JwtMemberPrincipal 객체 + */ + public JwtMemberPrincipal parseAccessToken(String token) { + Claims claims = parseClaims(token); + Long memberId = Long.parseLong(claims.getSubject()); + String role = claims.get("role", String.class); + return new JwtMemberPrincipal(memberId, role); + } + + /** + * 토큰에서 사용자 식별 ID(Subject) 추출 + * @param token JWT 토큰 + * @return 사용자 ID + */ + public Long extractMemberId(String token) { + Claims claims = parseClaims(token); + return Long.parseLong(claims.getSubject()); + } + + /** + * 토큰의 유효성 검증 + * @param token 검증할 JWT 토큰 + * @throws ExpiredJwtException 토큰이 만료된 경우 + * @throws JwtException 토큰이 유효하지 않은 경우 + */ + public void validateToken(String token) { + try { + parseClaims(token); + } catch (ExpiredJwtException e) { + log.debug("만료된 JWT 토큰: {}", e.getMessage()); + throw e; + } catch (JwtException | IllegalArgumentException e) { + log.debug("유효하지 않은 JWT 토큰: {}", e.getMessage()); + throw e; + } + } + + /** + * 토큰에서 Claims(데이터) 추출 + * @param token JWT 토큰 + * @return 파싱된 Claims 객체 + */ + private Claims parseClaims(String token) { + return Jwts.parser() + .verifyWith(secretKey) // 검증 키 설정 + .build() // 파서 객체 생성 (JJWT 0.12+) + .parseSignedClaims(token) // 토큰 해석 및 검증 + .getPayload(); // 데이터 본문(Claims) 추출 + } +} diff --git a/src/main/java/com/web/SearchWeb/config/jwt/OAuth2FailureHandler.java b/src/main/java/com/web/SearchWeb/config/jwt/OAuth2FailureHandler.java new file mode 100644 index 0000000..c7d985a --- /dev/null +++ b/src/main/java/com/web/SearchWeb/config/jwt/OAuth2FailureHandler.java @@ -0,0 +1,64 @@ +package com.web.SearchWeb.config.jwt; + +import com.web.SearchWeb.config.security.HttpCookieOAuth2AuthorizationRequestRepository; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler; +import org.springframework.stereotype.Component; +import org.springframework.web.util.UriComponentsBuilder; + +import java.io.IOException; + +/** + * OAuth2 인증 실패 처리를 담당하는 핸들러 클래스. + * 예외 발생 시 사용자를 프론트엔드 로그인 페이지로 리다이렉트하고 에러 메시지를 전달함. + */ +@Component +@RequiredArgsConstructor +public class OAuth2FailureHandler extends SimpleUrlAuthenticationFailureHandler { + + // 인증 요청 시 생성된 쿠키(State 등) 관리 및 삭제를 위한 저장소 + private final HttpCookieOAuth2AuthorizationRequestRepository authorizationRequestRepository; + + // 프론트엔드 리다이렉트 기본 URI (application.yml 설정 값) + @Value("${app.oauth2.redirect-uri}") + private String oauth2RedirectUri; + + /** + * OAuth2 인증 실패 시 호출되는 메서드. + * + * @param request HTTP 요청 + * @param response HTTP 응답 + * @param exception 발생한 인증 예외 + * @throws IOException 입출력 예외 + */ + @Override + public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException { + // 예외 객체에서 에러 메시지 추출 + String errorMessage = exception.getLocalizedMessage(); + + // 특정 에러(쿠키 만료/실종) 발생 시 사용자 친화적인 메시지로 변환 + // 주로 소셜 로그인 창을 장시간 방치한 뒤 시도할 때 발생함 + if (errorMessage.contains("authorization_request_not_found")) { + errorMessage = "인증 요청 정보가 만료되었습니다. 다시 로그인해주세요."; + } + + // 실패 후 이동할 대상 URL 생성 + // 프론트엔드 /login 페이지로 에러 정보와 함께 리다이렉트 + String targetUrl = UriComponentsBuilder.fromUriString(oauth2RedirectUri) + .replacePath("/login") + .queryParam("error", true) + .queryParam("message", errorMessage) + .encode() + .build().toUriString(); + + // 인증 과정에서 사용된 임시 쿠키(State, Redirect URI 등) 삭제 + authorizationRequestRepository.removeAuthorizationRequestCookies(request, response); + + // 설정된 타겟 URL로 리다이렉트 수행 + getRedirectStrategy().sendRedirect(request, response, targetUrl); + } +} \ No newline at end of file diff --git a/src/main/java/com/web/SearchWeb/config/jwt/OAuth2SuccessHandler.java b/src/main/java/com/web/SearchWeb/config/jwt/OAuth2SuccessHandler.java new file mode 100644 index 0000000..f4338f9 --- /dev/null +++ b/src/main/java/com/web/SearchWeb/config/jwt/OAuth2SuccessHandler.java @@ -0,0 +1,134 @@ +package com.web.SearchWeb.config.jwt; + +import com.web.SearchWeb.auth.error.AuthErrorCode; +import com.web.SearchWeb.auth.error.AuthException; +import com.web.SearchWeb.auth.service.AuthService; +import com.web.SearchWeb.config.security.CookieUtils; +import com.web.SearchWeb.config.security.HttpCookieOAuth2AuthorizationRequestRepository; +import com.web.SearchWeb.member.dto.CustomOAuth2Member; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseCookie; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; +import org.springframework.stereotype.Component; + +import jakarta.servlet.http.Cookie; +import org.springframework.util.StringUtils; + +import java.io.IOException; +import java.net.URI; +import java.time.Duration; +import java.util.Optional; + +/** + * OAuth2 로그인 성공 시 호출되는 핸들러. + * 인증 성공 후 JWT 리프레시 토큰을 발급하고 쿠키에 저장한 뒤, 프론트엔드로 리다이렉트합니다. + */ +@Component +@RequiredArgsConstructor +public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler { + + private final AuthService authService; + private final JwtProperties jwtProperties; + private final HttpCookieOAuth2AuthorizationRequestRepository authorizationRequestRepository; + + @Value("${app.oauth2.redirect-uri}") + private String oauth2RedirectUri; + + @Value("${app.cookie.secure}") + private boolean cookieSecure; + + /** + * OAuth2 인증 성공 시 실행되는 메서드 + * 1. 회원 ID 추출 + * 2. Refresh Token 생성 및 해시화하여 DB 저장 + * 3. Refresh Token을 쿠키에 설정 + * 4. 프론트엔드 콜백 URL로 리다이렉트 + */ + @Override + public void onAuthenticationSuccess(HttpServletRequest request, + HttpServletResponse response, + Authentication authentication) throws IOException { + CustomOAuth2Member oAuth2Member = (CustomOAuth2Member) authentication.getPrincipal(); + Long memberId = oAuth2Member.getMemberId(); + + // Refresh Token 발급 (고아 토큰 정리 + 생성 + DB 저장을 AuthService에 위임) + String refreshToken = authService.issueRefreshToken(memberId); + + // 💡 [E2E 테스트용 임시 로그] 터미널 콘솔에서 복사해서 .http 파일에 붙여넣으세요. + System.out.println("\n" + "=".repeat(80)); + System.out.println("[E2E TEST] Generated Refresh Token:"); + System.out.println(refreshToken); + System.out.println("=".repeat(80) + "\n"); + + // Refresh Token Cookie 설정 + ResponseCookie cookie = ResponseCookie.from("refreshToken", refreshToken) + .httpOnly(true) + .secure(cookieSecure) + .sameSite("Lax") + .path(jwtProperties.refreshTokenPath()) + .maxAge(Duration.ofMillis(jwtProperties.refreshTokenExpiry())) + .build(); + response.addHeader("Set-Cookie", cookie.toString()); + + // 4. 리다이렉트 경로 결정 (쿠키 확인 및 검증) + String targetUrl = determineTargetUrl(request, response, authentication); + + // 인증 요청 임시 쿠키 삭제 (Stateless 유지) + authorizationRequestRepository.removeAuthorizationRequestCookies(request, response); + + // 프론트엔드 콜백 URL로 redirect + response.sendRedirect(targetUrl); + } + + /** + * 쿠키에 저장된 redirect_uri를 읽어오고 검증합니다. + */ + @Override + protected String determineTargetUrl(HttpServletRequest request, HttpServletResponse response, Authentication authentication) { + // 1. 쿠키에서 redirect_uri를 찾음 + Optional redirectUri = CookieUtils.getCookie(request, HttpCookieOAuth2AuthorizationRequestRepository.REDIRECT_URI_PARAM_COOKIE_NAME) + .map(Cookie::getValue); + + // 2. 만약 쿠키(redirect_uri)가 존재한다면 검증 후 반환 + if (redirectUri.isPresent()) { + String url = redirectUri.get(); + if (isAuthorizedRedirectUri(url)) { + return url; + } + throw AuthException.of(AuthErrorCode.AUTH_INVALID_REDIRECT_URI); + } + + // 3. 쿠키가 아예 존재하지 않는 경우 기본 주소(프론트엔드 콜백)로 이동 + return oauth2RedirectUri; + } + + /** + * 리다이렉트 URI가 허용된 도메인인지 확인합니다. (Open Redirect 방지) + */ + private boolean isAuthorizedRedirectUri(String uri) { + if (!StringUtils.hasText(uri)) { + return false; + } + + // 상대 경로는 같은 도메인이므로 허용 + if (uri.startsWith("/") && !uri.startsWith("//")) { + return true; + } + + // 절대 경로인 경우 설정된 프론트엔드 URI와 호스트/포트가 같은지 확인 + try { + URI clientRedirectUri = URI.create(oauth2RedirectUri); + URI targetUri = URI.create(uri); + + return clientRedirectUri.getScheme().equalsIgnoreCase(targetUri.getScheme()) && + clientRedirectUri.getHost().equalsIgnoreCase(targetUri.getHost()) && + clientRedirectUri.getPort() == targetUri.getPort(); + } catch (Exception e) { + return false; + } + } +} diff --git a/src/main/java/com/web/SearchWeb/config/security/CookieUtils.java b/src/main/java/com/web/SearchWeb/config/security/CookieUtils.java new file mode 100644 index 0000000..b1bc2bb --- /dev/null +++ b/src/main/java/com/web/SearchWeb/config/security/CookieUtils.java @@ -0,0 +1,107 @@ +package com.web.SearchWeb.config.security; +import lombok.extern.slf4j.Slf4j; + + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseCookie; +import org.springframework.security.jackson2.SecurityJackson2Modules; + +import java.util.Base64; +import java.util.Optional; + +/** + * 쿠키 조작 유틸리티 클래스 + */ +@Slf4j +public class CookieUtils { + + // JSON 처리를 위한 Jackson ObjectMapper (Thread-safe 하여 정적 필드로 공유 가능) + private static final ObjectMapper objectMapper = new ObjectMapper(); + + /** + * [정적 초기화 블록] 클래스 로딩 시 단 한 번 실행됨 + * Spring Security의 복잡한 객체(OAuth2AuthorizationRequest 등)를 JSON으로 올바르게 변환하기 위해 필요한 모듈들을 자동으로 찾아 등록함 + */ + static { + objectMapper.registerModules(SecurityJackson2Modules.getModules(CookieUtils.class.getClassLoader())); + } + + // 쿠키 조회 (이름 기준) + public static Optional getCookie(HttpServletRequest request, String name) { + Cookie[] cookies = request.getCookies(); + if (cookies != null) { + for (Cookie cookie : cookies) { + if (cookie.getName().equals(name)) { + return Optional.of(cookie); + } + } + } + return Optional.empty(); + } + + // 쿠키 추가 (HttpOnly, SameSite=Lax 적용) + public static void addCookie(HttpServletResponse response, String name, String value, int maxAge) { + addCookie(response, name, value, maxAge, false); + } + + // 쿠키 추가 (Secure 속성 선택 가능) + public static void addCookie(HttpServletResponse response, String name, String value, int maxAge, boolean secure) { + ResponseCookie cookie = ResponseCookie.from(name, value) + .path("/") + .httpOnly(true) + .secure(secure) + .sameSite("Lax") + .maxAge(maxAge) + .build(); + response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString()); + } + + // 쿠키 삭제 (만료시간 0 설정) + public static void deleteCookie(HttpServletRequest request, HttpServletResponse response, String name) { + Cookie[] cookies = request.getCookies(); + if (cookies != null) { + for (Cookie cookie : cookies) { + if (cookie.getName().equals(name)) { + ResponseCookie deleteCookie = ResponseCookie.from(name, "") + .path("/") + .maxAge(0) + .httpOnly(true) + .build(); + response.addHeader(HttpHeaders.SET_COOKIE, deleteCookie.toString()); + } + } + } + } + + // 객체 -> JSON 문자열 -> Base64 URL Safe 인코딩 (쿠키에 저장 가능한 문자열로 변환) + public static String serialize(Object object) { + try { + // Jackson을 사용하여 객체를 JSON 문자열로 변환 + String json = objectMapper.writeValueAsString(object); + // 해당 JSON을 Base64로 인코딩하여 바이너리 데이터를 텍스트화함 + return Base64.getUrlEncoder().encodeToString(json.getBytes()); + } catch (Exception e) { + log.error("직렬화 실패: {}", e.getMessage()); + throw new RuntimeException("직렬화 실패", e); + } + } + + + // Base64 문자열 -> JSON 데이터 -> 자바 객체 (쿠키 값을 다시 객체로 복원) + public static T deserialize(Cookie cookie, Class cls) { + try { + // 1. Base64 디코딩 수행 + byte[] data = Base64.getUrlDecoder().decode(cookie.getValue()); + // 2. 디코딩된 JSON 데이터를 Jackson으로 읽어서 클래스 객체로 변환 + return objectMapper.readValue(data, cls); + } catch (Exception e) { + // 역직렬화 도중 에러가 나더라도 서비스를 중단시키지 않고 null을 반환 + log.error("쿠키 역직렬화 실패: {}", e.getMessage()); + return null; + } + } +} diff --git a/src/main/java/com/web/SearchWeb/config/security/CurrentMemberId.java b/src/main/java/com/web/SearchWeb/config/security/CurrentMemberId.java new file mode 100644 index 0000000..0479092 --- /dev/null +++ b/src/main/java/com/web/SearchWeb/config/security/CurrentMemberId.java @@ -0,0 +1,14 @@ +package com.web.SearchWeb.config.security; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * 현재 로그인한 사용자의 memberId를 컨트롤러 파라미터로 주입받기 위한 커스텀 어노테이션 + */ +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +public @interface CurrentMemberId { +} diff --git a/src/main/java/com/web/SearchWeb/config/security/CurrentMemberIdArgumentResolver.java b/src/main/java/com/web/SearchWeb/config/security/CurrentMemberIdArgumentResolver.java new file mode 100644 index 0000000..c7be2b3 --- /dev/null +++ b/src/main/java/com/web/SearchWeb/config/security/CurrentMemberIdArgumentResolver.java @@ -0,0 +1,35 @@ +package com.web.SearchWeb.config.security; + +import org.springframework.core.MethodParameter; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +/** + * @CurrentMemberId가 붙은 컨트롤러 파라미터를 처리하는 resolver. + * - SecurityContext에서 인증 정보를 꺼내 현재 로그인 사용자 ID를 만들어 넣어준다. + */ +public class CurrentMemberIdArgumentResolver implements HandlerMethodArgumentResolver { + + @Override + public boolean supportsParameter(MethodParameter parameter) { + // 1. 해당 파라미터 지원 여부 확인 + // @CurrentMemberId가 붙은 Long/long 타입 파라미터만 처리 + return parameter.hasParameterAnnotation(CurrentMemberId.class) + && (Long.class.equals(parameter.getParameterType()) || long.class.equals(parameter.getParameterType())); + } + + @Override + public Object resolveArgument(MethodParameter parameter, + ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, + WebDataBinderFactory binderFactory) { + // 2. 파라미터에 주입할 값 생성 + // SecurityContext에서 현재 로그인한 사용자의 ID(memberId) 추출 + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + return SecurityUtils.extractMemberId(authentication); + } +} diff --git a/src/main/java/com/web/SearchWeb/config/security/CustomAuthenticationFailureHandler.java b/src/main/java/com/web/SearchWeb/config/security/CustomAuthenticationFailureHandler.java deleted file mode 100644 index 67dc636..0000000 --- a/src/main/java/com/web/SearchWeb/config/security/CustomAuthenticationFailureHandler.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.web.SearchWeb.config.security; - -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import org.springframework.security.core.AuthenticationException; -import org.springframework.security.web.authentication.AuthenticationFailureHandler; -import org.springframework.stereotype.Component; - -import java.io.IOException; - -@Component -public class CustomAuthenticationFailureHandler implements AuthenticationFailureHandler { - - @Override - public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException { - // 기본 오류 메시지 설정 - String errorMessage = "아이디 또는 비밀번호가 잘못되었습니다."; - - //세션에 에러 메시지 저장 - request.getSession().setAttribute("error", errorMessage); - - // 로그인 페이지로 리다이렉트하며, 쿼리 파라미터로 오류를 표시 - response.sendRedirect("/login?error=true"); - } -} diff --git a/src/main/java/com/web/SearchWeb/config/security/CustomAuthenticationSuccessHandler.java b/src/main/java/com/web/SearchWeb/config/security/CustomAuthenticationSuccessHandler.java deleted file mode 100644 index a6eb006..0000000 --- a/src/main/java/com/web/SearchWeb/config/security/CustomAuthenticationSuccessHandler.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.web.SearchWeb.config.security; - -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import org.springframework.security.core.Authentication; -import org.springframework.security.web.authentication.AuthenticationSuccessHandler; -import org.springframework.stereotype.Component; - -import java.io.IOException; - -@Component -public class CustomAuthenticationSuccessHandler implements AuthenticationSuccessHandler { - - @Override - public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException { - // 리다이렉트 URL 설정 - String redirectUrl = request.getParameter("redirect"); - - if (redirectUrl != null && !redirectUrl.isEmpty()) { - // 리디렉트 파라미터가 있을 경우 그 URL로 리다이렉트 - response.sendRedirect(redirectUrl); - } else { - // 없을 경우 기본 페이지로 이동 - response.sendRedirect("/mainList"); - } - } -} \ No newline at end of file diff --git a/src/main/java/com/web/SearchWeb/config/security/HttpCookieOAuth2AuthorizationRequestRepository.java b/src/main/java/com/web/SearchWeb/config/security/HttpCookieOAuth2AuthorizationRequestRepository.java new file mode 100644 index 0000000..693f176 --- /dev/null +++ b/src/main/java/com/web/SearchWeb/config/security/HttpCookieOAuth2AuthorizationRequestRepository.java @@ -0,0 +1,80 @@ +package com.web.SearchWeb.config.security; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.oauth2.client.web.AuthorizationRequestRepository; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +/** + * OAuth2 인증 요청 정보를 세션 대신 쿠키에 저장하는 클래스 + * - STATELESS 정책 유지를 위해 서버 메모리(세션) 사용 배제 + * - 로그인 요청 시 생성되는 정보(state, redirect_uri 등)를 브라우저 쿠키에 보관 + */ +@Component +public class HttpCookieOAuth2AuthorizationRequestRepository implements AuthorizationRequestRepository { + + public static final String OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME = "oauth2_auth_request"; // 인증 요청 정보 보관 쿠키명 + public static final String REDIRECT_URI_PARAM_COOKIE_NAME = "redirect_uri"; // 로그인 후 이동할 리다이렉트 경로 쿠키명 + private static final int COOKIE_EXPIRE_SECONDS = 180; // 쿠키 유효 기간 (3분, 인증 과정용) + + @Value("${app.cookie.secure}") + private boolean cookieSecure; + + /** + * [인증 요청 정보 저장] + * - 시점: 사용자가 "소셜 로그인" 버튼 클릭 직후 (구글/네이버 등으로 리다이렉트 되기 전) + * - 역할: 보안 검증을 위한 state 값 등을 쿠키에 저장 + */ + @Override + public void saveAuthorizationRequest(OAuth2AuthorizationRequest authorizationRequest, HttpServletRequest request, HttpServletResponse response) { + if (authorizationRequest == null) { + CookieUtils.deleteCookie(request, response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME); + CookieUtils.deleteCookie(request, response, REDIRECT_URI_PARAM_COOKIE_NAME); + return; + } + + // 인증 요청 정보 쿠키에 저장 + CookieUtils.addCookie(response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME, CookieUtils.serialize(authorizationRequest), COOKIE_EXPIRE_SECONDS, cookieSecure); + + // 로그인 성공 후 최종 이동할 경로(redirect_uri)가 파라미터로 넘어왔다면 별도 쿠키에 보관 + String redirectUriAfterLogin = request.getParameter(REDIRECT_URI_PARAM_COOKIE_NAME); + if (StringUtils.hasText(redirectUriAfterLogin)) { + CookieUtils.addCookie(response, REDIRECT_URI_PARAM_COOKIE_NAME, redirectUriAfterLogin, COOKIE_EXPIRE_SECONDS, cookieSecure); + } + } + + /** + * [쿠키에서 정보 읽기] + * - 시점: 사용자가 소셜 인증을 마치고 우리 서버(콜백 주소)로 돌아왔을 때 + * - 역할: 쿠키에 저장했던 정보를 읽어와 현재 응답의 state 값과 비교 검증 + */ + @Override + public OAuth2AuthorizationRequest loadAuthorizationRequest(HttpServletRequest request) { + return CookieUtils.getCookie(request, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME) + .map(cookie -> CookieUtils.deserialize(cookie, OAuth2AuthorizationRequest.class)) + .orElse(null); + } + + /** + * [인증 요청 정보 삭제 준비] + * - 시점: 시큐리티 내부에서 인증 처리가 완료되기 직전 + * - 역할: 저장소에서 정보를 꺼내오는 용도로 사용됨 + */ + @Override + public OAuth2AuthorizationRequest removeAuthorizationRequest(HttpServletRequest request, HttpServletResponse response) { + return this.loadAuthorizationRequest(request); + } + + /** + * [임시 쿠키 최종 삭제] + * - 시점: 성공 핸들러(SuccessHandler) 또는 실패 핸들러(FailureHandler) 가 실행될 때 + * - 역할: 인증이 완전히 끝났으므로 브라우저에 남은 임시 쿠키들을 삭제 + */ + public void removeAuthorizationRequestCookies(HttpServletRequest request, HttpServletResponse response) { + CookieUtils.deleteCookie(request, response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME); + CookieUtils.deleteCookie(request, response, REDIRECT_URI_PARAM_COOKIE_NAME); + } +} diff --git a/src/main/java/com/web/SearchWeb/config/security/SecurityConfig.java b/src/main/java/com/web/SearchWeb/config/security/SecurityConfig.java index 3903b80..cfcd7f0 100644 --- a/src/main/java/com/web/SearchWeb/config/security/SecurityConfig.java +++ b/src/main/java/com/web/SearchWeb/config/security/SecurityConfig.java @@ -1,120 +1,155 @@ package com.web.SearchWeb.config.security; -import com.web.SearchWeb.member.service.CustomOAuth2UserService; +import com.web.SearchWeb.config.jwt.JwtAuthenticationEntryPoint; +import com.web.SearchWeb.config.jwt.JwtAuthenticationFilter; +import com.web.SearchWeb.config.jwt.JwtUtils; +import com.web.SearchWeb.config.jwt.OAuth2FailureHandler; +import com.web.SearchWeb.config.jwt.OAuth2SuccessHandler; +import com.web.SearchWeb.member.service.CustomOAuth2MemberService; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +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.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.net.URI; +import java.util.List; @Configuration @EnableWebSecurity +@EnableMethodSecurity +@RequiredArgsConstructor public class SecurityConfig { - private final CustomOAuth2UserService customOAuth2UserService; - - public SecurityConfig(CustomOAuth2UserService customOAuth2UserService) { - this.customOAuth2UserService = customOAuth2UserService; - } + private final CustomOAuth2MemberService customOAuth2MemberService; + private final JwtUtils jwtUtils; + private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; + private final OAuth2SuccessHandler oAuth2SuccessHandler; + private final OAuth2FailureHandler oAuth2FailureHandler; + private final HttpCookieOAuth2AuthorizationRequestRepository cookieAuthorizationRequestRepository; + @Value("${app.oauth2.redirect-uri}") + private String oauth2RedirectUri; @Bean - public SecurityFilterChain filterChain(HttpSecurity http) throws Exception{ - + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { - /** - * 페이지 별 권한설정 - */ + // JWT 기반 Stateless 인증 — 서버 세션을 사용하지 않는다. http - .authorizeHttpRequests((auth) -> auth - //.requestMatchers("/loginProc").permitAll() - //.requestMatchers("/boardEnroll").authenticated() - .requestMatchers("/admin").hasRole("ADMIN") - //.requestMatchers("/admin").hasAuthority("ROLE_ADMIN") - //.requestMatchers("/my/**").hasAnyRole("ROLE_ADMIN", "ROLE_USER") - //.anyRequest().authenticated() - .anyRequest().permitAll() - ); + .sessionManagement(session -> session + .sessionCreationPolicy(SessionCreationPolicy.STATELESS)); - - /** - * 로그인 설정 - */ + // CSRF: Stateless + 쿠키 SameSite=Lax 조합이므로 비활성화 http - .formLogin((auth) -> auth - .loginPage("/login") - .loginProcessingUrl("/loginProc") //Spring Security가 제공하는 기본 인증 필터(UsernamePasswordAuthenticationFilter)를 통해 자동으로 로그인 요청을 처리 - .usernameParameter("loginId") // 로그인 폼에서 아이디 입력 필드의 name 속성값 (기본: username -> loginId로 변경) - .successHandler(customAuthenticationSuccessHandler()) // 커스텀 성공 핸들러 추가 - .failureHandler(customAuthenticationFailureHandler()) // 커스텀 실패 핸들러 추가 - .permitAll() - ); - + .csrf(csrf -> csrf.disable()); - /** - * 로그아웃 설정 - */ + // HTTP Basic 인증 비활성화 http - .logout((auth)->auth - .logoutUrl("/logout") - .logoutSuccessUrl("/")// 로그아웃 성공 후 메인페이지로 이동 - ); - - + .httpBasic(basic -> basic.disable()); - /** - * 다중 로그인 설정 - */ + // Form 로그인 비활성화 (JWT + OAuth2 로 대체) http - .sessionManagement((auth) -> auth - .maximumSessions(1) // 하나의 아이디에 대한 다중 로그인 허용 개수 - .maxSessionsPreventsLogin(true)); // 다중 로그인 개수를 초과하였을 경우 처리 방법 - true : 초과시 새로운 로그인 차단, - false : 초과시 기존 세션 하나 삭제 + .formLogin(form -> form.disable()); - - /** - * csrf 설정 - */ + // 로컬 개발에서 프론트(3000)가 백엔드(8080)로 직접 인증 요청을 보낼 때, refreshToken 쿠키를 함께 전송할 수 있도록 CORS 를 활성화. + // 운영에서 Nginx 단일 origin 구성 시 CORS 는 불필요해진다 (Phase 3). http - .csrf((auth) -> auth.disable()); - + .cors(Customizer.withDefaults()); - /** - * HTTP Basic 인증 비활성화 - */ + // 페이지 별 권한 설정 http - .httpBasic((basic) -> basic.disable()); + .authorizeHttpRequests(auth -> auth + // 인증 없이 접근 가능한 엔드포인트 + .requestMatchers("/api/auth/refresh", "/api/auth/logout").permitAll() + .requestMatchers("/oauth2/**", "/login/oauth2/**").permitAll() + // 인증 필요 엔드포인트 + .requestMatchers("/api/tags/**").authenticated() + .requestMatchers("/api/bookmarks/**").authenticated() + .requestMatchers("/api/link-analysis/**").authenticated() + .requestMatchers("/api/folders/**").authenticated() + .requestMatchers("/api/auth/member").authenticated() + // 역할 기반 접근 제어 + .requestMatchers("/admin").hasRole("ADMIN") + .anyRequest().permitAll() + ); + // 인증 실패(401) 시 JSON 에러 응답을 반환하는 EntryPoint + http + .exceptionHandling(ex -> ex + .authenticationEntryPoint(jwtAuthenticationEntryPoint)); - /** - * 소셜 로그인 설정 - */ + // OAuth2 소셜 로그인 설정 http - .oauth2Login((oauth2) -> oauth2 - .loginPage("/login") // 사용자 정의 소셜 로그인 페이지 URL 설정 - .defaultSuccessUrl("/mainList") // 로그인 성공 후 이동할 URL - .userInfoEndpoint((userInfoEndpointConfig) -> userInfoEndpointConfig - .userService(customOAuth2UserService))); // 소셜 로그인 인증 완료 후, 인증 서버에서 가져온 사용자 정보를 회원가입/로그인 하는 커스텀 서비스 설정. - + .oauth2Login(oauth2 -> oauth2 + // 소셜 로그인 시작 시 인증 요청 정보를 쿠키에 임시 저장할 저장소 설정 + .authorizationEndpoint(endpoint -> endpoint + .authorizationRequestRepository(cookieAuthorizationRequestRepository)) + // 로그인 성공 후 사용자 정보(이름, 이메일 등)를 가져와서 처리할 서비스 설정 + .userInfoEndpoint(userInfo -> userInfo + .userService(customOAuth2MemberService)) + .successHandler(oAuth2SuccessHandler) + .failureHandler(oAuth2FailureHandler)); + + // JWT 필터를 UsernamePasswordAuthenticationFilter 앞에 삽입 + http + .addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class); return http.build(); } @Bean - public BCryptPasswordEncoder bCryptPasswordEncoder() { - return new BCryptPasswordEncoder(); + public JwtAuthenticationFilter jwtAuthenticationFilter() { + return new JwtAuthenticationFilter(jwtUtils); } @Bean - public CustomAuthenticationFailureHandler customAuthenticationFailureHandler() { - return new CustomAuthenticationFailureHandler(); + public BCryptPasswordEncoder bCryptPasswordEncoder() { + return new BCryptPasswordEncoder(); } + /** + * CORS 설정 + * - 프론트엔드에서 우리 서버로 데이터를 요청해도 되는지를 설정하는 CORS 설정 + * - credentials: true 이므로 allowedOrigins 에 와일드카드(*) 사용 불가 + * - app.oauth2.redirect-uri 에서 origin 을 추출하여 환경별로 자동 적용 + */ @Bean - public CustomAuthenticationSuccessHandler customAuthenticationSuccessHandler() { - return new CustomAuthenticationSuccessHandler(); + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + configuration.setAllowCredentials(true); // 쿠키 및 인증 헤더 허용 + configuration.setAllowedOrigins(List.of(extractOrigin(oauth2RedirectUri))); // 허용할 프론트엔드 도메인 설정 + configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")); // 어떤 HTTP 메서드를 허용할지 설정 + configuration.setAllowedHeaders(List.of("Authorization", "Content-Type", "Cookie")); // 어떤 헤더를 포함해야되는 설정 + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + // CORS 규칙을 적용할 경로 지정 + source.registerCorsConfiguration("/api/**", configuration); + source.registerCorsConfiguration("/oauth2/**", configuration); + source.registerCorsConfiguration("/login/oauth2/**", configuration); + return source; } -} \ No newline at end of file + // 전체 주소에서 도메인(Origin)만 추출하는 도우미 메서드 (예: http://localhost:3000) + private String extractOrigin(String uriString) { + URI uri = URI.create(uriString); + StringBuilder origin = new StringBuilder(uri.getScheme()) + .append("://") + .append(uri.getHost()); + if (uri.getPort() != -1) { + origin.append(":").append(uri.getPort()); + } + return origin.toString(); + } +} diff --git a/src/main/java/com/web/SearchWeb/config/security/SecurityUtils.java b/src/main/java/com/web/SearchWeb/config/security/SecurityUtils.java index 4a4ab92..061516d 100644 --- a/src/main/java/com/web/SearchWeb/config/security/SecurityUtils.java +++ b/src/main/java/com/web/SearchWeb/config/security/SecurityUtils.java @@ -1,38 +1,75 @@ package com.web.SearchWeb.config.security; -import com.web.SearchWeb.config.exception.BusinessException; -import com.web.SearchWeb.config.exception.CommonErrorCode; -import com.web.SearchWeb.member.dto.CustomOAuth2User; -import com.web.SearchWeb.member.dto.CustomUserDetails; +import com.web.SearchWeb.auth.error.AuthErrorCode; +import com.web.SearchWeb.auth.error.AuthException; +import com.web.SearchWeb.config.jwt.JwtMemberPrincipal; +import com.web.SearchWeb.member.dto.CustomOAuth2Member; import org.springframework.security.core.Authentication; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; + +/** + * 보안 관련 공통 유틸리티 클래스 + * - 인증 객체에서 사용자 정보 추출 및 데이터 보안용 해싱 기능 제공 + */ public final class SecurityUtils { private SecurityUtils() { } + /** + * Authentication 객체에서 현재 로그인된 사용자의 memberId 추출 + * + * @param authentication 인증 객체 + * @return 사용자의 고유 ID (memberId) + * @throws AuthException 미인증 또는 유효하지 않은 사용자일 경우 발생 + */ public static Long extractMemberId(Authentication authentication) { + // 인증 객체 부재 또는 미인증 시 접근 거부 if (authentication == null || !authentication.isAuthenticated()) { - // throw BusinessException.from(CommonErrorCode.UNAUTHORIZED); - return 1L; // 테스트용 임시 우회 + throw AuthException.of(AuthErrorCode.AUTH_UNAUTHORIZED); } Object principal = authentication.getPrincipal(); + // 익명 사용자(미로그인 상태) 접근 거부 if ("anonymousUser".equals(principal)) { - // throw BusinessException.from(CommonErrorCode.UNAUTHORIZED); - return 1L; // 테스트용 임시 우회 + throw AuthException.of(AuthErrorCode.AUTH_UNAUTHORIZED); } - if (principal instanceof CustomUserDetails userDetails) { - return userDetails.getMemberId(); + // 일반 JWT 로그인 Principal 처리 + if (principal instanceof JwtMemberPrincipal jwt) { + return jwt.memberId(); } - if (principal instanceof CustomOAuth2User oauth2User) { - return oauth2User.getMemberId(); + // OAuth2 로그인 Principal 처리 + if (principal instanceof CustomOAuth2Member oauth2Member) { + return oauth2Member.getMemberId(); } - // throw BusinessException.from(CommonErrorCode.UNAUTHORIZED); - return 1L; // 테스트용 임시 우회 + // 알 수 없는 Principal 타입일 경우 + throw AuthException.of(AuthErrorCode.AUTH_UNAUTHORIZED); + } + + /** + * SHA-256 알고리즘을 사용한 문자열 해싱 + * 리프레시 토큰 원본 대신 해싱값을 DB에 저장하여 보안 강화 목적으로 사용 + * + * @param input 해싱할 원본 문자열 + * @return SHA-256 해싱된 16진수 문자열 + */ + public static String hashToken(String input) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(input.getBytes(StandardCharsets.UTF_8)); + // 바이트 배열을 16진수 문자열로 변환 + return HexFormat.of().formatHex(hash); + } catch (NoSuchAlgorithmException e) { + // SHA-256 알고리즘 미지원 환경일 경우 RuntimeException 래핑 + throw new RuntimeException("SHA-256 algorithm not available", e); + } } } diff --git a/src/main/java/com/web/SearchWeb/config/security/WebMvcConfig.java b/src/main/java/com/web/SearchWeb/config/security/WebMvcConfig.java new file mode 100644 index 0000000..76eac4d --- /dev/null +++ b/src/main/java/com/web/SearchWeb/config/security/WebMvcConfig.java @@ -0,0 +1,22 @@ +package com.web.SearchWeb.config.security; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import java.util.List; + + +/** + * Spring MVC 확장 설정. + * - 컨트롤러 파라미터를 커스텀 방식으로 해석하는 resolver를 등록. + */ +@Configuration +public class WebMvcConfig implements WebMvcConfigurer { + + @Override + public void addArgumentResolvers(List resolvers) { + // @CurrentMemberId가 붙은 파라미터를 만나면 현재 로그인 사용자 ID를 주입. + resolvers.add(new CurrentMemberIdArgumentResolver()); + } +} diff --git a/src/main/resources/application-dev.properties b/src/main/resources/application-dev.properties index 824cffd..9d0798d 100644 --- a/src/main/resources/application-dev.properties +++ b/src/main/resources/application-dev.properties @@ -3,6 +3,10 @@ spring.datasource.url=${DEV_DB_URL} spring.datasource.username=${DEV_DB_USERNAME} spring.datasource.password=${DEV_DB_PASSWORD} +## Dev App settings +app.oauth2.redirect-uri=${DEV_FRONTEND_URL:http://localhost:3000}/auth/callback +app.cookie.secure=${DEV_COOKIE_SECURE:false} + ## Dev OAUTH2 spring.security.oauth2.client.registration.naver.redirect-uri=${CLOUD_NAVER_OAUTH_REDIRECT_URI} spring.security.oauth2.client.registration.google.redirect-uri=${CLOUD_GOOGLE_OAUTH_REDIRECT_URI} diff --git a/src/main/resources/application-local.properties b/src/main/resources/application-local.properties index b9a56af..1aeeebd 100644 --- a/src/main/resources/application-local.properties +++ b/src/main/resources/application-local.properties @@ -8,6 +8,10 @@ spring.security.oauth2.client.registration.naver.redirect-uri=${LOCAL_NAVER_OAUT spring.security.oauth2.client.registration.google.redirect-uri=${LOCAL_GOOGLE_OAUTH_REDIRECT_URI} spring.security.oauth2.client.registration.kakao.redirect-uri=${LOCAL_KAKAO_OAUTH_REDIRECT_URI} +## LOCAL App settings +app.oauth2.redirect-uri=http://localhost:3000/auth/callback +app.cookie.secure=false + ## LOCAL AI Settings spring.ai.enabled.openai=false spring.ai.enabled.gemini=false diff --git a/src/main/resources/application-prod.properties b/src/main/resources/application-prod.properties index d5e782f..aad031b 100644 --- a/src/main/resources/application-prod.properties +++ b/src/main/resources/application-prod.properties @@ -3,6 +3,10 @@ spring.datasource.url=${PROD_DB_URL} spring.datasource.username=${PROD_DB_USERNAME} spring.datasource.password=${PROD_DB_PASSWORD} +## Prod App settings +app.oauth2.redirect-uri=${PROD_FRONTEND_URL:http://localhost:3000}/auth/callback +app.cookie.secure=${PROD_COOKIE_SECURE:true} + ## Prod OAUTH2 spring.security.oauth2.client.registration.naver.redirect-uri=${CLOUD_NAVER_OAUTH_REDIRECT_URI} spring.security.oauth2.client.registration.google.redirect-uri=${CLOUD_GOOGLE_OAUTH_REDIRECT_URI} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 856bc1b..be0e0b3 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -15,7 +15,7 @@ logging.level.com.web.SearchWeb=DEBUG spring.output.ansi.enabled=ALWAYS -##### Oauth2 session login settings ##### +##### OAuth2 settings ##### #naver settings #registration spring.security.oauth2.client.registration.naver.client-name=naver @@ -86,3 +86,13 @@ spring.ai.groq.api-key=${GROQ_API_KEY} spring.ai.groq.chat.options.model=${GROQ_CHAT_MODEL} spring.ai.groq.chat.options.temperature=${GROQ_CHAT_TEMPERATURE} +##### JWT settings ##### +# jwt.secret: JWT 서명에 사용할 비밀 키 (HS256 알고리즘 사용 시 최소 32바이트 이상의 문자열 권장) +jwt.secret=${JWT_SECRET} +# jwt.access-token-expiry: Access Token 만료 시간 (밀리초 단위, 예: 3600000 = 1시간) +jwt.access-token-expiry=${JWT_ACCESS_TOKEN_EXPIRY} +# jwt.refresh-token-expiry: Refresh Token 만료 시간 (밀리초 단위, 예: 604800000 = 7일) +jwt.refresh-token-expiry=${JWT_REFRESH_TOKEN_EXPIRY} +# jwt.refresh-token-path: Refresh Token 쿠키 경로 (보안을 위해 /api/auth로 한정) +jwt.refresh-token-path=/api/auth + diff --git a/src/main/resources/db/init_postgres.sql b/src/main/resources/db/init_postgres.sql index bbeff75..007154c 100644 --- a/src/main/resources/db/init_postgres.sql +++ b/src/main/resources/db/init_postgres.sql @@ -76,6 +76,18 @@ CREATE TABLE IF NOT EXISTS "member" ( CONSTRAINT ck_member_status CHECK (status in ('active','blocked')) ); +CREATE TABLE IF NOT EXISTS "refresh_token" ( + "id" bigint GENERATED ALWAYS AS IDENTITY NOT NULL, + "member_id" bigint NOT NULL, + "token_hash" varchar(64) NOT NULL, + "expires_at" timestamptz NOT NULL, + "created_at" timestamptz DEFAULT now() NOT NULL, + "rotated_at" timestamptz, + CONSTRAINT pk_refresh_token PRIMARY KEY ("id"), + CONSTRAINT uq_refresh_token_hash UNIQUE ("token_hash"), + CONSTRAINT fk_refresh_token_member_id FOREIGN KEY ("member_id") REFERENCES "member"("member_id") ON DELETE CASCADE +); + CREATE TABLE IF NOT EXISTS "member_folder" ( "member_folder_id" int GENERATED ALWAYS AS IDENTITY NOT NULL, "owner_member_id" bigint NOT NULL, @@ -543,6 +555,10 @@ CREATE INDEX IF NOT EXISTS idx_member_status ON "member" ("status"); CREATE INDEX IF NOT EXISTS idx_member_created_at ON "member" ("created_at"); CREATE INDEX IF NOT EXISTS idx_member_deleted_at ON "member" ("deleted_at"); +CREATE INDEX IF NOT EXISTS idx_refresh_token_member ON "refresh_token" ("member_id"); +CREATE INDEX IF NOT EXISTS idx_refresh_token_hash ON "refresh_token" ("token_hash"); +CREATE INDEX IF NOT EXISTS idx_refresh_token_expires ON "refresh_token" ("expires_at"); + CREATE INDEX IF NOT EXISTS idx_member_folder_owner ON "member_folder" ("owner_member_id"); CREATE INDEX IF NOT EXISTS idx_member_folder_parent ON "member_folder" ("parent_folder_id"); CREATE UNIQUE INDEX IF NOT EXISTS uq_member_folder_parent_name diff --git a/src/main/resources/mapper/refresh-token-mapper.xml b/src/main/resources/mapper/refresh-token-mapper.xml new file mode 100644 index 0000000..e0177b0 --- /dev/null +++ b/src/main/resources/mapper/refresh-token-mapper.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + INSERT INTO refresh_token (member_id, token_hash, expires_at, created_at) + VALUES (#{memberId}, #{tokenHash}, #{expiresAt}, NOW()) + + + + + + + + DELETE FROM refresh_token WHERE token_hash = #{tokenHash} + + + + DELETE FROM refresh_token WHERE member_id = #{memberId} + + + + DELETE FROM refresh_token + WHERE expires_at < NOW() + OR (rotated_at IS NOT NULL AND rotated_at < NOW() - INTERVAL '1 minute') + + + + UPDATE refresh_token + SET rotated_at = NOW() + WHERE token_hash = #{tokenHash} + + + From 46fc9e3fde1d0cf7d03f291d34337368b4f3966f Mon Sep 17 00:00:00 2001 From: jin2304 Date: Sun, 29 Mar 2026 23:31:30 +0900 Subject: [PATCH 05/22] =?UTF-8?q?refactor:=20Member=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=20=EC=97=B0=EB=8F=99,=20=EC=86=8C=EC=9C=A0=EA=B6=8C?= =?UTF-8?q?=20=ED=99=95=EC=9D=B8(OwnerCheckAspect)=20=EB=B0=8F=20=ED=8F=B4?= =?UTF-8?q?=EB=8D=94=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=ED=8C=8C=EB=9D=BC?= =?UTF-8?q?=EB=AF=B8=ED=84=B0=EB=AA=85(memberId)=20=EB=A6=AC=ED=8C=A9?= =?UTF-8?q?=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../web/SearchWeb/aop/OwnerCheckAspect.java | 53 ++------ .../controller/BookmarkApiController.java | 98 ++------------ .../bookmark/service/BookmarkService.java | 8 +- .../bookmark/service/BookmarkServiceImpl.java | 22 +-- .../controller/MemberFolderController.java | 38 +++--- .../folder/dao/MemberFolderJpaDao.java | 4 +- .../folder/service/MemberFolderService.java | 14 +- .../service/MemberFolderServiceImpl.java | 46 +++---- .../controller/LinkAnalysisController.java | 25 +--- .../member/controller/MemberController.java | 93 ------------- .../web/SearchWeb/member/dao/MemberDao.java | 9 +- .../member/dao/MybatisMemberDao.java | 14 +- .../web/SearchWeb/member/domain/Member.java | 6 + .../member/dto/CustomOAuth2Member.java | 78 +++++++++++ .../member/dto/Response/OAuth2Response.java | 2 +- .../member/error/MemberErrorCode.java | 16 +++ .../member/error/MemberException.java | 17 +++ .../service/CustomOAuth2MemberService.java | 125 ++++++++++++++++++ .../member/service/MemberService.java | 5 - .../member/service/MemberServiceImpl.java | 41 ++---- src/main/resources/mapper/member-mapper.xml | 16 +-- 21 files changed, 341 insertions(+), 389 deletions(-) delete mode 100644 src/main/java/com/web/SearchWeb/member/controller/MemberController.java create mode 100644 src/main/java/com/web/SearchWeb/member/dto/CustomOAuth2Member.java create mode 100644 src/main/java/com/web/SearchWeb/member/error/MemberErrorCode.java create mode 100644 src/main/java/com/web/SearchWeb/member/error/MemberException.java create mode 100644 src/main/java/com/web/SearchWeb/member/service/CustomOAuth2MemberService.java diff --git a/src/main/java/com/web/SearchWeb/aop/OwnerCheckAspect.java b/src/main/java/com/web/SearchWeb/aop/OwnerCheckAspect.java index 510d941..08da204 100644 --- a/src/main/java/com/web/SearchWeb/aop/OwnerCheckAspect.java +++ b/src/main/java/com/web/SearchWeb/aop/OwnerCheckAspect.java @@ -1,9 +1,10 @@ package com.web.SearchWeb.aop; -import com.web.SearchWeb.board.service.BoardService; -import com.web.SearchWeb.comment.service.CommentService; -import com.web.SearchWeb.member.dto.CustomOAuth2User; -import com.web.SearchWeb.member.dto.CustomUserDetails; +// import com.web.SearchWeb.board.service.BoardService; +// import com.web.SearchWeb.comment.service.CommentService; +import com.web.SearchWeb.auth.error.AuthErrorCode; +import com.web.SearchWeb.auth.error.AuthException; +import com.web.SearchWeb.config.security.SecurityUtils; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -11,7 +12,6 @@ import org.aspectj.lang.annotation.Aspect; import org.aspectj.lang.annotation.Before; import org.aspectj.lang.reflect.MethodSignature; -import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; @@ -29,8 +29,8 @@ @RequiredArgsConstructor public class OwnerCheckAspect { - private final BoardService boardService; - private final CommentService commentService; +// private final BoardService boardService; +// private final CommentService commentService; /** * @OwnerCheck 어노테이션이 붙은 메서드 실행 전, @@ -41,9 +41,8 @@ public void validateOwner(JoinPoint joinPoint, OwnerCheck ownerCheck) { // 접근 검증 대상 리소스의 ID 추출 Long targetId = extractTargetIdFromParams(joinPoint, ownerCheck.idParam()); - // 현재 로그인한 사용자의 memberId 추출 - Authentication auth = validateAuthenticatedUser(); - Long currentUserId = extractMemberId(auth); + // 현재 로그인한 사용자의 memberId 추출 (SecurityUtils를 통한 인증 확인 및 ID 추출 통합) + Long currentUserId = SecurityUtils.extractMemberId(SecurityContextHolder.getContext().getAuthentication()); // 서비스 이름에 따라 리소스 작성자 memberId 조회 Long ownerId = findOwnerIdByServiceName(ownerCheck.service(), targetId); @@ -51,7 +50,7 @@ public void validateOwner(JoinPoint joinPoint, OwnerCheck ownerCheck) { // 현재 사용자와 리소스 소유자 검증 if (!Objects.equals(currentUserId, ownerId)) { log.warn("접근 거부: 사용자 ID {} ≠ 소유자 ID {}", currentUserId, ownerId); - throw new SecurityException("소유자만 접근 가능합니다."); + throw AuthException.of(AuthErrorCode.AUTH_ACCESS_DENIED); } } @@ -60,11 +59,6 @@ public void validateOwner(JoinPoint joinPoint, OwnerCheck ownerCheck) { /** * 접근 검증 대상이 되는 리소스의 ID를 파라미터 이름(idParam)을 통해 찾아 Long으로 반환 - * ex) @OwnerCheck(idParam = "boardId", ...) -> 메서드의 boardId 값을 찾아 사용 - * - * @param joinPoint 현재 실행된 메서드의 실행 정보 - * @param idParam 검증 대상 리소스 ID의 파라미터 이름 (예: "boardId" 문자열) - * @return 접근 검증 대상이 되는 리소스의 ID 값 */ private Long extractTargetIdFromParams(JoinPoint joinPoint, String idParam) { Object[] args = joinPoint.getArgs(); // 메서드 실제 인자 값 배열 @@ -82,33 +76,12 @@ private Long extractTargetIdFromParams(JoinPoint joinPoint, String idParam) { } - // SecurityContext 에서 인증된 사용자 반환 - private Authentication validateAuthenticatedUser() { - Authentication auth = SecurityContextHolder.getContext().getAuthentication(); - if (auth == null || !auth.isAuthenticated() || "anonymousUser".equals(auth.getPrincipal())) { - log.error("인증 실패: SecurityContext에 인증된 사용자가 없습니다."); - throw new SecurityException("로그인된 사용자만 접근 가능합니다."); - } - return auth; - } - - - // 인증 객체에서 현재 로그인한 사용자 memberId 추출 (Long 반환) - private Long extractMemberId(Authentication auth) { - Object principal = auth.getPrincipal(); - if (principal instanceof CustomUserDetails u) return u.getMemberId(); - if (principal instanceof CustomOAuth2User u) return u.getMemberId(); - log.error("인증 실패: 사용자 객체 타입이 예상과 다름. principal = {}", principal.getClass().getName()); - throw new SecurityException("인증 정보가 없습니다."); - } - - // 서비스 이름에 따라 해당 리소스의 작성자 조회 private Long findOwnerIdByServiceName(String service, Long targetId) { return switch (service) { - case "boardService" -> boardService.findMemberIdByBoardId(targetId); - case "commentService" -> commentService.findMemberIdByCommentId(targetId); - case "memberService" -> targetId; // memberService의 경우 targetId가 곧 memberId +// case "boardService" -> boardService.findMemberIdByBoardId(targetId); +// case "commentService" -> commentService.findMemberIdByCommentId(targetId); + case "memberService" -> targetId; default -> { log.error("지원하지 않는 서비스명 '{}'", service); throw new IllegalArgumentException("지원하지 않는 서비스명입니다."); diff --git a/src/main/java/com/web/SearchWeb/bookmark/controller/BookmarkApiController.java b/src/main/java/com/web/SearchWeb/bookmark/controller/BookmarkApiController.java index ef7dae3..d22b95e 100644 --- a/src/main/java/com/web/SearchWeb/bookmark/controller/BookmarkApiController.java +++ b/src/main/java/com/web/SearchWeb/bookmark/controller/BookmarkApiController.java @@ -5,14 +5,10 @@ import com.web.SearchWeb.bookmark.domain.Bookmark; import com.web.SearchWeb.bookmark.service.BookmarkService; import com.web.SearchWeb.config.common.ApiResponse; -import com.web.SearchWeb.member.dto.CustomOAuth2User; -import com.web.SearchWeb.member.dto.CustomUserDetails; +import com.web.SearchWeb.config.security.CurrentMemberId; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.security.core.userdetails.UserDetails; -import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.web.bind.annotation.*; import java.util.List; @@ -38,20 +34,8 @@ public BookmarkApiController(BookmarkService bookmarkService) { */ @PostMapping public ResponseEntity> insertBookmark( - @AuthenticationPrincipal Object currentUser, + @CurrentMemberId Long memberId, @RequestBody BookmarkRequests.CreateDto request) { - - // TODO: AOP 처리 - // 로그인 되지 않은 경우 (임시 바이패스: 1L 사용) - Long memberId; - if (currentUser == null || "anonymousUser".equals(currentUser)) { - memberId = 1L; - } else { - memberId = getMemberId(currentUser); - if (memberId == null) { - return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); - } - } Long bookmarkId = bookmarkService.insertBookmark( memberId, @@ -74,21 +58,9 @@ public ResponseEntity> insertBookmark( */ @GetMapping("/{bookmarkId}") public ResponseEntity> selectBookmark( - @AuthenticationPrincipal Object currentUser, + @CurrentMemberId Long memberId, @PathVariable Long bookmarkId) { - // TODO: AOP 처리 - // 로그인 되지 않은 경우 (임시 바이패스: 1L 사용) - Long memberId; - if (currentUser == null || "anonymousUser".equals(currentUser)) { - memberId = 1L; - } else { - memberId = getMemberId(currentUser); - if (memberId == null) { - return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); - } - } - Bookmark bookmark = bookmarkService.selectBookmark(memberId, bookmarkId); return ResponseEntity.ok(ApiResponse.success(bookmark)); } @@ -99,21 +71,9 @@ public ResponseEntity> selectBookmark( */ @GetMapping public ResponseEntity>> selectBookmarkList( - @AuthenticationPrincipal Object currentUser, + @CurrentMemberId Long memberId, @ModelAttribute BookmarkRequests.SearchDto searchDto) { - // TODO: AOP 처리 - // 로그인 되지 않은 경우 (임시 바이패스: 1L 사용) - Long memberId; - if (currentUser == null || "anonymousUser".equals(currentUser)) { - memberId = 1L; - } else { - memberId = getMemberId(currentUser); - if (memberId == null) { - return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); - } - } - List bookmarks = bookmarkService.selectBookmarkList(searchDto.toCommand(memberId)); return ResponseEntity.ok(ApiResponse.success(bookmarks)); } @@ -124,22 +84,10 @@ public ResponseEntity>> selectBookmarkList( */ @PutMapping("/{bookmarkId}") public ResponseEntity> updateBookmark( - @AuthenticationPrincipal Object currentUser, + @CurrentMemberId Long memberId, @PathVariable Long bookmarkId, @RequestBody BookmarkRequests.UpdateDto request) { - // TODO: AOP 처리 - // 로그인 되지 않은 경우 (임시 바이패스: 1L 사용) - Long memberId; - if (currentUser == null || "anonymousUser".equals(currentUser)) { - memberId = 1L; - } else { - memberId = getMemberId(currentUser); - if (memberId == null) { - return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); - } - } - Long updatedBookmarkId = bookmarkService.updateBookmark( memberId, bookmarkId, @@ -158,21 +106,9 @@ public ResponseEntity> updateBookmark( */ @DeleteMapping("/{bookmarkId}") public ResponseEntity> deleteBookmark( - @AuthenticationPrincipal Object currentUser, + @CurrentMemberId Long memberId, @PathVariable Long bookmarkId) { - - // TODO: AOP 처리 - // 로그인 되지 않은 경우 (임시 바이패스: 1L 사용) - Long memberId; - if (currentUser == null || "anonymousUser".equals(currentUser)) { - memberId = 1L; - } else { - memberId = getMemberId(currentUser); - if (memberId == null) { - return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); - } - } - + Long deletedId = bookmarkService.deleteBookmark(memberId, bookmarkId); return ResponseEntity.ok(ApiResponse.success(deletedId)); } @@ -182,14 +118,7 @@ public ResponseEntity> deleteBookmark( * 북마크 확인 */ @GetMapping("/check") - public ResponseEntity checkBookmark(@AuthenticationPrincipal Object currentUser, @RequestParam String url) { - // 로그인 되지 않은 경우 (임시 바이패스: 1L 사용) - Long memberId; - if (currentUser == null || "anonymousUser".equals(currentUser)) { - memberId = 1L; - } else { - memberId = getMemberId(currentUser); - } + public ResponseEntity checkBookmark(@CurrentMemberId Long memberId, @RequestParam String url) { // 북마크 존재 여부 확인 boolean exists = bookmarkService.checkBookmarkExistsByUrl(memberId, url); @@ -206,15 +135,4 @@ public ResponseEntity> analyzeUrl(@RequestParam String url) } - /** - * 현재 사용자의 memberId 추출 - */ - private Long getMemberId(Object currentUser) { - if (currentUser instanceof UserDetails) { - return ((CustomUserDetails) currentUser).getMemberId(); - } else if (currentUser instanceof OAuth2User) { - return ((CustomOAuth2User) currentUser).getMemberId(); - } - return null; - } } diff --git a/src/main/java/com/web/SearchWeb/bookmark/service/BookmarkService.java b/src/main/java/com/web/SearchWeb/bookmark/service/BookmarkService.java index 594fd2e..08fa037 100644 --- a/src/main/java/com/web/SearchWeb/bookmark/service/BookmarkService.java +++ b/src/main/java/com/web/SearchWeb/bookmark/service/BookmarkService.java @@ -2,8 +2,8 @@ import com.web.SearchWeb.bookmark.domain.Bookmark; import com.web.SearchWeb.bookmark.domain.Link; -import com.web.SearchWeb.bookmark.dto.BoardBookmarkCheckDto; -import com.web.SearchWeb.bookmark.dto.BookmarkDto; +// import com.web.SearchWeb.bookmark.dto.BoardBookmarkCheckDto; +// import com.web.SearchWeb.bookmark.dto.BookmarkDto; import com.web.SearchWeb.bookmark.service.command.BookmarkSearchCommand; import java.util.List; @@ -36,8 +36,9 @@ Long updateBookmark(Long memberId, Long bookmarkId, Long memberFolderId, String // URL로부터 페이지 제목 추출 String extractTitle(String url); - // ========== Legacy Board-Bookmark Methods ========== + // ========== Legacy Board-Bookmark Methods (Commented Out) ========== + /* //게시글 북마크 확인 int checkBoardBookmark(BoardBookmarkCheckDto checkDto); @@ -49,4 +50,5 @@ Long updateBookmark(Long memberId, Long bookmarkId, Long memberFolderId, String //게시글 북마크 삭제 int deleteBookmarkBoard(BoardBookmarkCheckDto checkDto); + */ } diff --git a/src/main/java/com/web/SearchWeb/bookmark/service/BookmarkServiceImpl.java b/src/main/java/com/web/SearchWeb/bookmark/service/BookmarkServiceImpl.java index 6c3ab38..d370a9c 100644 --- a/src/main/java/com/web/SearchWeb/bookmark/service/BookmarkServiceImpl.java +++ b/src/main/java/com/web/SearchWeb/bookmark/service/BookmarkServiceImpl.java @@ -3,8 +3,6 @@ import com.web.SearchWeb.bookmark.dao.BookmarkDao; import com.web.SearchWeb.bookmark.domain.Bookmark; import com.web.SearchWeb.bookmark.domain.Link; -import com.web.SearchWeb.bookmark.dto.BoardBookmarkCheckDto; -import com.web.SearchWeb.bookmark.dto.BookmarkDto; import com.web.SearchWeb.bookmark.service.command.BookmarkSearchCommand; import lombok.extern.slf4j.Slf4j; @@ -332,47 +330,33 @@ private void processAndCreateTags(Long bookmarkId, Long memberId, String tags) { } - // ========== Legacy Board-Bookmark Methods ========== + // ========== Legacy Board-Bookmark Methods (Commented Out) ========== - /** - * 게시글 북마크 확인 (Legacy) - * TODO: 새 스키마에서는 board-bookmark 관계가 없음. 현재는 0 반환 - */ + /* @Override public int checkBoardBookmark(BoardBookmarkCheckDto checkDto) { // 새 스키마에 board-bookmark 테이블이 없으므로 항상 0 반환 return 0; } - /** - * 게시글 북마크 여부 확인 (for boardDetail) - * TODO: 새 스키마에서는 board-bookmark 관계가 없음. 현재는 0 반환 - */ @Override public int isBookmarked(Long boardId, Long memberId) { // 새 스키마에 board-bookmark 테이블이 없으므로 항상 0 반환 return 0; } - /** - * 게시글 북마크 추가 (Legacy) - * TODO: 새 스키마에서는 board-bookmark 관계가 없음. 현재는 0 반환 - */ @Override public int insertBookmarkForBoard(Long boardId, BookmarkDto bookmarkDto) { // 새 스키마에 board-bookmark 테이블이 없으므로 아무 작업 안함 return 0; } - /** - * 게시글 북마크 삭제 (Legacy) - * TODO: 새 스키마에서는 board-bookmark 관계가 없음. 현재는 0 반환 - */ @Override public int deleteBookmarkBoard(BoardBookmarkCheckDto checkDto) { // 새 스키마에 board-bookmark 테이블이 없으므로 아무 작업 안함 return 0; } + */ diff --git a/src/main/java/com/web/SearchWeb/folder/controller/MemberFolderController.java b/src/main/java/com/web/SearchWeb/folder/controller/MemberFolderController.java index 6475278..eac1a64 100644 --- a/src/main/java/com/web/SearchWeb/folder/controller/MemberFolderController.java +++ b/src/main/java/com/web/SearchWeb/folder/controller/MemberFolderController.java @@ -1,7 +1,7 @@ package com.web.SearchWeb.folder.controller; import com.web.SearchWeb.config.common.ApiResponse; -import com.web.SearchWeb.config.security.SecurityUtils; +import com.web.SearchWeb.config.security.CurrentMemberId; import com.web.SearchWeb.folder.controller.dto.MemberFolderRequests; import com.web.SearchWeb.folder.controller.dto.MemberFolderResponses; import com.web.SearchWeb.folder.domain.MemberFolder; @@ -12,7 +12,6 @@ import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -31,10 +30,9 @@ public class MemberFolderController { // 생성 (201 Created 응답) @PostMapping - public ResponseEntity> create(Authentication authentication, @Valid @RequestBody MemberFolderRequests.Create req) { - Long loginId = SecurityUtils.extractMemberId(authentication); + public ResponseEntity> create(@CurrentMemberId Long memberId, @Valid @RequestBody MemberFolderRequests.Create req) { Long folderId = memberFolderService.create( - loginId, + memberId, req.parentFolderId, req.folderName, req.description @@ -46,20 +44,18 @@ public ResponseEntity> create(Authentication authentication, @ // 폴더 정보 단건 조회 @GetMapping("/{folderId}") - public ResponseEntity> get(Authentication authentication, @PathVariable Long folderId) { - Long loginId = SecurityUtils.extractMemberId(authentication); - MemberFolder folder = memberFolderService.get(loginId, folderId); + public ResponseEntity> get(@CurrentMemberId Long memberId, @PathVariable Long folderId) { + MemberFolder folder = memberFolderService.get(memberId, folderId); return ResponseEntity.ok(ApiResponse.success(MemberFolderResponses.from(folder))); } // 루트 폴더 조회 @GetMapping("/owners/{ownerMemberId}/root") public ResponseEntity>> listRoot( - Authentication authentication, + @CurrentMemberId Long memberId, @PathVariable Long ownerMemberId ) { - Long loginId = SecurityUtils.extractMemberId(authentication); - List responses = memberFolderService.listRootFolders(loginId, ownerMemberId) + List responses = memberFolderService.listRootFolders(memberId, ownerMemberId) .stream() .map(MemberFolderResponses::from) .collect(Collectors.toList()); @@ -70,12 +66,11 @@ public ResponseEntity>> listRoot( // 하위 폴더 조회 @GetMapping("/owners/{ownerMemberId}/children/{parentFolderId}") public ResponseEntity>> listChildren( - Authentication authentication, + @CurrentMemberId Long memberId, @PathVariable Long ownerMemberId, @PathVariable Long parentFolderId ) { - Long loginId = SecurityUtils.extractMemberId(authentication); - List responses = memberFolderService.listChildren(loginId, ownerMemberId, parentFolderId) + List responses = memberFolderService.listChildren(memberId, ownerMemberId, parentFolderId) .stream() .map(MemberFolderResponses::from) .collect(Collectors.toList()); @@ -85,27 +80,24 @@ public ResponseEntity>> listChildren( // 수정 (200 OK) @PutMapping("/{folderId}") - public ResponseEntity> update(Authentication authentication, @PathVariable Long folderId, + public ResponseEntity> update(@CurrentMemberId Long memberId, @PathVariable Long folderId, @Valid @RequestBody MemberFolderRequests.Update req) { - Long loginId = SecurityUtils.extractMemberId(authentication); - memberFolderService.update(loginId, folderId, req.folderName, req.description); + memberFolderService.update(memberId, folderId, req.folderName, req.description); return ResponseEntity.ok(ApiResponse.success(null)); } // 이동(부모 변경) @PutMapping("/{folderId}/move") - public ResponseEntity> move(Authentication authentication, @PathVariable Long folderId, + public ResponseEntity> move(@CurrentMemberId Long memberId, @PathVariable Long folderId, @Valid @RequestBody MemberFolderRequests.Move req) { - Long loginId = SecurityUtils.extractMemberId(authentication); - memberFolderService.move(loginId, folderId, req.newParentFolderId); + memberFolderService.move(memberId, folderId, req.newParentFolderId); return ResponseEntity.ok(ApiResponse.success(null)); } // 삭제 @DeleteMapping("/{folderId}") - public ResponseEntity> delete(Authentication authentication, @PathVariable Long folderId) { - Long loginId = SecurityUtils.extractMemberId(authentication); - memberFolderService.delete(loginId, folderId); + public ResponseEntity> delete(@CurrentMemberId Long memberId, @PathVariable Long folderId) { + memberFolderService.delete(memberId, folderId); return ResponseEntity.ok(ApiResponse.success(null)); } } diff --git a/src/main/java/com/web/SearchWeb/folder/dao/MemberFolderJpaDao.java b/src/main/java/com/web/SearchWeb/folder/dao/MemberFolderJpaDao.java index 5807f15..fdafe18 100644 --- a/src/main/java/com/web/SearchWeb/folder/dao/MemberFolderJpaDao.java +++ b/src/main/java/com/web/SearchWeb/folder/dao/MemberFolderJpaDao.java @@ -17,7 +17,7 @@ public interface MemberFolderJpaDao extends JpaRepository { boolean existsByParentFolderId(Long parentFolderId); - boolean existsByOwnerMemberIdAndParentFolderIdAndFolderName(Long loginId, Long parentFolderId, String normalizedFolderName); + boolean existsByOwnerMemberIdAndParentFolderIdAndFolderName(Long ownerMemberId, Long parentFolderId, String normalizedFolderName); - boolean existsByOwnerMemberIdAndParentFolderIdIsNullAndFolderName(Long loginId, String normalizedFolderName); + boolean existsByOwnerMemberIdAndParentFolderIdIsNullAndFolderName(Long ownerMemberId, String normalizedFolderName); } diff --git a/src/main/java/com/web/SearchWeb/folder/service/MemberFolderService.java b/src/main/java/com/web/SearchWeb/folder/service/MemberFolderService.java index 745801e..073047d 100644 --- a/src/main/java/com/web/SearchWeb/folder/service/MemberFolderService.java +++ b/src/main/java/com/web/SearchWeb/folder/service/MemberFolderService.java @@ -6,17 +6,17 @@ public interface MemberFolderService { - Long create(Long loginId, Long parentFolderId, String folderName, String description); + Long create(Long memberId, Long parentFolderId, String folderName, String description); - MemberFolder get(Long loginId, Long memberFolderId); + MemberFolder get(Long memberId, Long memberFolderId); - List listRootFolders(Long loginId, Long ownerMemberId); + List listRootFolders(Long memberId, Long ownerMemberId); - List listChildren(Long loginId, Long ownerMemberId, Long parentFolderId); + List listChildren(Long memberId, Long ownerMemberId, Long parentFolderId); - void update(Long loginId, Long memberFolderId, String folderName, String description); + void update(Long memberId, Long memberFolderId, String folderName, String description); - void move(Long loginId, Long memberFolderId, Long newParentFolderId); + void move(Long memberId, Long memberFolderId, Long newParentFolderId); - void delete(Long loginId, Long memberFolderId); + void delete(Long memberId, Long memberFolderId); } diff --git a/src/main/java/com/web/SearchWeb/folder/service/MemberFolderServiceImpl.java b/src/main/java/com/web/SearchWeb/folder/service/MemberFolderServiceImpl.java index 9e26338..98d8324 100644 --- a/src/main/java/com/web/SearchWeb/folder/service/MemberFolderServiceImpl.java +++ b/src/main/java/com/web/SearchWeb/folder/service/MemberFolderServiceImpl.java @@ -19,7 +19,7 @@ public class MemberFolderServiceImpl implements MemberFolderService { @Override @Transactional - public Long create(Long loginId, Long parentFolderId, String folderName, String description) { + public Long create(Long memberId, Long parentFolderId, String folderName, String description) { String normalizedFolderName = normalizeFolderName(folderName); String normalizedDescription = normalizeDescription(description); @@ -30,14 +30,14 @@ public Long create(Long loginId, Long parentFolderId, String folderName, String .orElseThrow(() -> new FolderException(FolderErrorCode.FOLDER_NOT_FOUND)); // 2. 부모 폴더 소유자 검증 - if (!parentFolder.getOwnerMemberId().equals(loginId)) { + if (!parentFolder.getOwnerMemberId().equals(memberId)) { throw new FolderException(FolderErrorCode.FOLDER_FORBIDDEN); } // 3. 같은 부모 아래 동일 이름 폴더 중복 검증 boolean exists = memberFolderJpaRepository .existsByOwnerMemberIdAndParentFolderIdAndFolderName( - loginId, parentFolderId, normalizedFolderName + memberId, parentFolderId, normalizedFolderName ); if (exists) { @@ -47,7 +47,7 @@ public Long create(Long loginId, Long parentFolderId, String folderName, String // 4. 루트 폴더일 때 동일 이름 중복 검증 boolean exists = memberFolderJpaRepository .existsByOwnerMemberIdAndParentFolderIdIsNullAndFolderName( - loginId, normalizedFolderName + memberId, normalizedFolderName ); if (exists) { @@ -56,7 +56,7 @@ public Long create(Long loginId, Long parentFolderId, String folderName, String } MemberFolder folder = MemberFolder.builder() - .ownerMemberId(loginId) + .ownerMemberId(memberId) .parentFolderId(parentFolderId) .folderName(normalizedFolderName) .description(normalizedDescription) @@ -92,22 +92,22 @@ private String normalizeDescription(String description) { @Override @Transactional(readOnly = true) - public MemberFolder get(Long loginId, Long memberFolderId) { - return getOwnedFolder(loginId, memberFolderId); + public MemberFolder get(Long memberId, Long memberFolderId) { + return getOwnedFolder(memberId, memberFolderId); } @Override @Transactional(readOnly = true) - public List listRootFolders(Long loginId, Long ownerMemberId) { - validateOwner(loginId, ownerMemberId); + public List listRootFolders(Long memberId, Long ownerMemberId) { + validateOwner(memberId, ownerMemberId); return memberFolderJpaRepository.findAllByOwnerMemberIdAndParentFolderIdIsNull(ownerMemberId); } @Override @Transactional(readOnly = true) - public List listChildren(Long loginId, Long ownerMemberId, Long parentFolderId) { - validateOwner(loginId, ownerMemberId); - MemberFolder parentFolder = getOwnedFolder(loginId, parentFolderId); + public List listChildren(Long memberId, Long ownerMemberId, Long parentFolderId) { + validateOwner(memberId, ownerMemberId); + MemberFolder parentFolder = getOwnedFolder(memberId, parentFolderId); if (!parentFolder.getOwnerMemberId().equals(ownerMemberId)) { throw new FolderException(FolderErrorCode.FOLDER_FORBIDDEN); } @@ -116,8 +116,8 @@ public List listChildren(Long loginId, Long ownerMemberId, Long pa @Override @Transactional - public void update(Long loginId, Long memberFolderId, String folderName, String description) { - MemberFolder folder = getOwnedFolder(loginId, memberFolderId); + public void update(Long memberId, Long memberFolderId, String folderName, String description) { + MemberFolder folder = getOwnedFolder(memberId, memberFolderId); String resolvedFolderName = folderName == null ? folder.getFolderName() @@ -148,8 +148,8 @@ public void update(Long loginId, Long memberFolderId, String folderName, String @Override @Transactional - public void move(Long loginId, Long memberFolderId, Long newParentFolderId) { - MemberFolder folder = getOwnedFolder(loginId, memberFolderId); + public void move(Long memberId, Long memberFolderId, Long newParentFolderId) { + MemberFolder folder = getOwnedFolder(memberId, memberFolderId); if ((folder.getParentFolderId() == null && newParentFolderId == null) || (folder.getParentFolderId() != null && folder.getParentFolderId().equals(newParentFolderId))) { @@ -161,7 +161,7 @@ public void move(Long loginId, Long memberFolderId, Long newParentFolderId) { throw new FolderException(FolderErrorCode.INVALID_FOLDER_MOVE); } - MemberFolder newParentFolder = getOwnedFolder(loginId, newParentFolderId); + MemberFolder newParentFolder = getOwnedFolder(memberId, newParentFolderId); if (!newParentFolder.getOwnerMemberId().equals(folder.getOwnerMemberId())) { throw new FolderException(FolderErrorCode.FOLDER_FORBIDDEN); @@ -192,8 +192,8 @@ public void move(Long loginId, Long memberFolderId, Long newParentFolderId) { @Override @Transactional - public void delete(Long loginId, Long memberFolderId) { - getOwnedFolder(loginId, memberFolderId); + public void delete(Long memberId, Long memberFolderId) { + getOwnedFolder(memberId, memberFolderId); if (memberFolderJpaRepository.existsByParentFolderId(memberFolderId) || bookmarkDao.existsActiveBookmarkInFolder(memberFolderId)) { @@ -203,16 +203,16 @@ public void delete(Long loginId, Long memberFolderId) { memberFolderJpaRepository.deleteById(memberFolderId); } - private MemberFolder getOwnedFolder(Long loginId, Long memberFolderId) { + private MemberFolder getOwnedFolder(Long memberId, Long memberFolderId) { MemberFolder folder = memberFolderJpaRepository.findById(memberFolderId) .orElseThrow(() -> new FolderException(FolderErrorCode.FOLDER_NOT_FOUND)); - validateOwner(loginId, folder.getOwnerMemberId()); + validateOwner(memberId, folder.getOwnerMemberId()); return folder; } - private void validateOwner(Long loginId, Long ownerMemberId) { - if (!ownerMemberId.equals(loginId)) { + private void validateOwner(Long memberId, Long ownerMemberId) { + if (!ownerMemberId.equals(memberId)) { throw new FolderException(FolderErrorCode.FOLDER_FORBIDDEN); } } diff --git a/src/main/java/com/web/SearchWeb/linkanalysis/controller/LinkAnalysisController.java b/src/main/java/com/web/SearchWeb/linkanalysis/controller/LinkAnalysisController.java index e0528d0..3ac8c40 100644 --- a/src/main/java/com/web/SearchWeb/linkanalysis/controller/LinkAnalysisController.java +++ b/src/main/java/com/web/SearchWeb/linkanalysis/controller/LinkAnalysisController.java @@ -1,18 +1,14 @@ package com.web.SearchWeb.linkanalysis.controller; import com.web.SearchWeb.config.common.ApiResponse; +import com.web.SearchWeb.config.security.CurrentMemberId; import com.web.SearchWeb.linkanalysis.controller.dto.LinkAnalysisRequests; import com.web.SearchWeb.linkanalysis.controller.dto.LinkAnalysisResponses; import com.web.SearchWeb.linkanalysis.domain.LinkAnalysisResult; import com.web.SearchWeb.linkanalysis.service.LinkAnalysisService; -import com.web.SearchWeb.member.dto.CustomOAuth2User; -import com.web.SearchWeb.member.dto.CustomUserDetails; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; -import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.security.core.userdetails.UserDetails; -import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -38,10 +34,9 @@ public class LinkAnalysisController { */ @PostMapping("/analyze") public ResponseEntity> analyze( - @AuthenticationPrincipal Object currentUser, + @CurrentMemberId Long memberId, @Valid @RequestBody LinkAnalysisRequests.Analyze request) { - Long memberId = getMemberId(currentUser); // 인증 사용자 ID 추출 LinkAnalysisResult result = linkAnalysisService.analyze(memberId, request.url); LinkAnalysisResponses.Result response = toResponse(result); // 도메인 → DTO 변환 @@ -70,20 +65,4 @@ private LinkAnalysisResponses.Result toResponse(LinkAnalysisResult result) { : null) .build(); } - - /** - * 인증 사용자 객체에서 회원 ID 추출 - * - UserDetails: 일반 로그인 - * - OAuth2User: 소셜 로그인 - */ - private Long getMemberId(Object currentUser) { - if (currentUser instanceof UserDetails) { - return ((CustomUserDetails) currentUser).getMemberId(); - } else if (currentUser instanceof OAuth2User) { - return ((CustomOAuth2User) currentUser).getMemberId(); - } - - // SecurityUtils의 정책과 동일하게 1L 반환 (테스트용) - return 1L; - } } diff --git a/src/main/java/com/web/SearchWeb/member/controller/MemberController.java b/src/main/java/com/web/SearchWeb/member/controller/MemberController.java deleted file mode 100644 index 8984805..0000000 --- a/src/main/java/com/web/SearchWeb/member/controller/MemberController.java +++ /dev/null @@ -1,93 +0,0 @@ -package com.web.SearchWeb.member.controller; - - - -import com.web.SearchWeb.member.dto.MemberDto; -import com.web.SearchWeb.member.service.MemberService; -import jakarta.validation.Valid; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Controller; -import org.springframework.ui.Model; -import org.springframework.validation.BindingResult; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.servlet.mvc.support.RedirectAttributes; - -/** - * - * 코드 작성자: 서진영(jin2304) - * 코드 설명 :회원가입 및 로그인 기능을 담당하는 컨트롤러 - * 코드 주요 기능: 회원가입, 로그인 - * - */ -@Controller -public class MemberController { - - MemberService memberService; - - @Autowired - public MemberController(MemberService memberService) { - this.memberService = memberService; - } - - - - /** - * 회원가입 페이지 - */ - @GetMapping("/join") - public String join(Model model) { - if (!model.containsAttribute("memberDto")) { // RedirectAttributes를 통해 전달된 "memberDto"가 모델에 포함되어 있는지 확인 - model.addAttribute("memberDto", new MemberDto()); - } - return "member/join"; - } - - - /** - * 회원가입 - */ - @PostMapping("/joinProc") - public String joinProcess(@Valid MemberDto member, BindingResult bindingResult, RedirectAttributes redirectAttributes) { - - /** 유효성 검사 **/ - // 빈 문자열 검사, 사이즈 체크 등 MemberDto에 정의된 유효성 검사 수행 - if (bindingResult.hasErrors()) { - redirectAttributes.addFlashAttribute("org.springframework.validation.BindingResult.memberDto", bindingResult); - redirectAttributes.addFlashAttribute("memberDto", member); - return "redirect:/join"; // (PRG 패턴 적용)유효성 검사에 실패하면 회원가입 페이지로 다시 이동 - } - - // 사용자 아이디 중복 확인 - if (memberService.findByLoginId(member.getLoginId()) != null) { - bindingResult.rejectValue("loginId", "error.member", "이미 존재하는 아이디입니다."); - redirectAttributes.addFlashAttribute("org.springframework.validation.BindingResult.memberDto", bindingResult); - redirectAttributes.addFlashAttribute("memberDto", member); - return "redirect:/join"; - } - - //비밀번호 확인 - if (!memberService.isPasswordMatching(member)) { - bindingResult.rejectValue("confirmPassword", "error.member", "입력된 비밀번호와 일치하지 않습니다."); - redirectAttributes.addFlashAttribute("org.springframework.validation.BindingResult.memberDto", bindingResult); - redirectAttributes.addFlashAttribute("memberDto", member); - return "redirect:/join"; - } - - // 회원가입 성공 처리 - memberService.joinProcess(member); - redirectAttributes.addFlashAttribute("message", "회원가입이 성공적으로 완료되었습니다."); - return "redirect:/login"; - } - - - /** - * 로그인 페이지 - */ - @GetMapping("/login") - public String login() { - return "member/login"; - } - - -} \ No newline at end of file diff --git a/src/main/java/com/web/SearchWeb/member/dao/MemberDao.java b/src/main/java/com/web/SearchWeb/member/dao/MemberDao.java index 6a50a63..6eabda5 100644 --- a/src/main/java/com/web/SearchWeb/member/dao/MemberDao.java +++ b/src/main/java/com/web/SearchWeb/member/dao/MemberDao.java @@ -1,16 +1,11 @@ package com.web.SearchWeb.member.dao; - import com.web.SearchWeb.member.domain.Member; -import com.web.SearchWeb.member.dto.MemberDto; import com.web.SearchWeb.member.dto.MemberUpdateDto; public interface MemberDao { - //회원가입 - public void joinProcess(MemberDto member); - //소셜 회원가입 - public void SocialjoinProcess(Member member); + public void insertSocialMember(Member member); //회원번호로 찾기 public Member findByMemberId(Long memberId); @@ -22,5 +17,5 @@ public interface MemberDao { public int updateMember(Long memberId, MemberUpdateDto memberUpdateDto); //소셜 회원 수정 - public int updateSocialMember(Long memberId, Member member); + public int updateSocialIdentity(Long memberId, Member member); } diff --git a/src/main/java/com/web/SearchWeb/member/dao/MybatisMemberDao.java b/src/main/java/com/web/SearchWeb/member/dao/MybatisMemberDao.java index 708c696..4f84a0c 100644 --- a/src/main/java/com/web/SearchWeb/member/dao/MybatisMemberDao.java +++ b/src/main/java/com/web/SearchWeb/member/dao/MybatisMemberDao.java @@ -2,7 +2,6 @@ import com.web.SearchWeb.member.domain.Member; -import com.web.SearchWeb.member.dto.MemberDto; import com.web.SearchWeb.member.dto.MemberUpdateDto; import org.apache.ibatis.session.SqlSession; import org.springframework.beans.factory.annotation.Autowired; @@ -21,17 +20,12 @@ public MybatisMemberDao(SqlSession sqlSession) { } - @Override - public void joinProcess(MemberDto member) { - mapper.joinProcess(member); - } - /** * 소셜 회원가입 */ @Override - public void SocialjoinProcess(Member member) { - mapper.SocialjoinProcess(member); + public void insertSocialMember(Member member) { + mapper.insertSocialMember(member); } /** @@ -64,7 +58,7 @@ public int updateMember(Long memberId, MemberUpdateDto memberUpdateDto) { * 소셜 회원 수정 */ @Override - public int updateSocialMember(Long memberId, Member member) { - return mapper.updateSocialMember(memberId, member); + public int updateSocialIdentity(Long memberId, Member member) { + return mapper.updateSocialIdentity(memberId, member); } } diff --git a/src/main/java/com/web/SearchWeb/member/domain/Member.java b/src/main/java/com/web/SearchWeb/member/domain/Member.java index 9e48ae4..3e4238c 100644 --- a/src/main/java/com/web/SearchWeb/member/domain/Member.java +++ b/src/main/java/com/web/SearchWeb/member/domain/Member.java @@ -2,12 +2,18 @@ import com.web.SearchWeb.common.domain.BaseEntity; +import lombok.AllArgsConstructor; import lombok.Getter; +import lombok.NoArgsConstructor; import lombok.Setter; import lombok.ToString; +import lombok.experimental.SuperBuilder; @Getter @Setter +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor @ToString(callSuper = true) public class Member extends BaseEntity { private Long memberId; // 회원 고유 ID (PK) diff --git a/src/main/java/com/web/SearchWeb/member/dto/CustomOAuth2Member.java b/src/main/java/com/web/SearchWeb/member/dto/CustomOAuth2Member.java new file mode 100644 index 0000000..27a2e92 --- /dev/null +++ b/src/main/java/com/web/SearchWeb/member/dto/CustomOAuth2Member.java @@ -0,0 +1,78 @@ +package com.web.SearchWeb.member.dto; + + +import com.web.SearchWeb.member.dto.Response.OAuth2Response; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.oauth2.core.user.OAuth2User; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Map; + + + +/** + * CustomOAuth2Member 클래스 + * + * 코드 작성자: + * - 서진영(jin2304) + * + * 코드 설명: + * - 소셜 로그인 시 사용자 정보를 담아 Spring Security 인증 과정에서 사용되는 클래스. + * - OAuth2User 인터페이스를 구현하여 각 소셜 서비스의 사용자 정보 규격화. + * - 인증 후 SecurityContext 내에 저장되어 사용자 상태 관리에 사용됨. + * + * 코드 주요 기능: + * -getAttributes(): 현재 비어 있음 -> OAuth2User 인터페이스의 요구사항을 충족하기 위해 메서드가 존재하지만, 실제 사용하지 않으므로 빈 맵 반환 + * -getAuthorities(): 사용자 권한 반환. + * -getName(): 소셜 서비스에서 제공한 사용자 이름 반환. + * -getMemberId(): 데이터베이스에 저장된 사용자 고유 ID 반환. + * -getLoginId(): 소셜 서비스 이름과 사용자 ID를 조합한 고유 식별값 반환. + * + * 주요 필드: + * - oAuth2Response: 소셜 서비스별 사용자 정보 (naver, google, kakao 등). + * - role: 서비스 내에서 부여된 권한. + * - memberId: DB 저장용 고유 ID. + */ +public class CustomOAuth2Member implements OAuth2User { + + private final OAuth2Response oAuth2Response; + private final String role; + private final Long memberId; + + public CustomOAuth2Member(OAuth2Response oAuth2Response, String role, Long memberId) { + this.oAuth2Response = oAuth2Response; + this.role = role; + this.memberId = memberId; + } + + @Override + public Map getAttributes() { + return Map.of(); + } + + @Override + public Collection getAuthorities() { + Collection collection = new ArrayList<>(); + // 람다식을 사용하여 권한 반환 로직 간소화 + collection.add(() -> role); + return collection; + } + + @Override + public String getName() { + return oAuth2Response.getName(); + } + + + public Long getMemberId() { + return memberId; + } + + + public String getLoginId() { + return oAuth2Response.getProvider() + oAuth2Response.getProviderId(); + } + + +} \ No newline at end of file diff --git a/src/main/java/com/web/SearchWeb/member/dto/Response/OAuth2Response.java b/src/main/java/com/web/SearchWeb/member/dto/Response/OAuth2Response.java index 414d034..1013e98 100644 --- a/src/main/java/com/web/SearchWeb/member/dto/Response/OAuth2Response.java +++ b/src/main/java/com/web/SearchWeb/member/dto/Response/OAuth2Response.java @@ -18,7 +18,7 @@ * -getName(): 소셜 서비스 사용자 이름을 반환. * * 인터페이스 사용 목적: - * -CustomOAuth2UserService에서 소셜 서비스 응답 객체를 통합적으로 처리하고, + * -CustomOAuth2MemberService에서 소셜 서비스 응답 객체를 통합적으로 처리하고, * 서비스 로직에서 소셜 서비스 종류와 관계없이 일관된 방식으로 사용자 정보를 활용하기 위함. * -객체지향의 다형성을 활용하여 구현체의 종류와 관계없이 일관된 방식으로 사용자 정보를 처리 가능. * -새로운 소셜 로그인 서비스가 추가되더라도 인터페이스 구현 클래스만 작성하면 확장 가능. diff --git a/src/main/java/com/web/SearchWeb/member/error/MemberErrorCode.java b/src/main/java/com/web/SearchWeb/member/error/MemberErrorCode.java new file mode 100644 index 0000000..e6dc474 --- /dev/null +++ b/src/main/java/com/web/SearchWeb/member/error/MemberErrorCode.java @@ -0,0 +1,16 @@ +package com.web.SearchWeb.member.error; + +import com.web.SearchWeb.config.exception.ErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum MemberErrorCode implements ErrorCode { + MEMBER_SOCIAL_LOGIN_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "M001", "소셜 회원 처리 중 오류가 발생했습니다."); + + private final HttpStatus status; + private final String code; + private final String message; +} diff --git a/src/main/java/com/web/SearchWeb/member/error/MemberException.java b/src/main/java/com/web/SearchWeb/member/error/MemberException.java new file mode 100644 index 0000000..c390662 --- /dev/null +++ b/src/main/java/com/web/SearchWeb/member/error/MemberException.java @@ -0,0 +1,17 @@ +package com.web.SearchWeb.member.error; + +import com.web.SearchWeb.config.exception.BusinessException; +import com.web.SearchWeb.config.exception.ErrorCode; +import lombok.Getter; + +@Getter +public class MemberException extends BusinessException { + + private MemberException(ErrorCode errorCode) { + super(errorCode); + } + + public static MemberException of(ErrorCode errorCode) { + return new MemberException(errorCode); + } +} diff --git a/src/main/java/com/web/SearchWeb/member/service/CustomOAuth2MemberService.java b/src/main/java/com/web/SearchWeb/member/service/CustomOAuth2MemberService.java new file mode 100644 index 0000000..8e680ea --- /dev/null +++ b/src/main/java/com/web/SearchWeb/member/service/CustomOAuth2MemberService.java @@ -0,0 +1,125 @@ +package com.web.SearchWeb.member.service; + +import com.web.SearchWeb.auth.error.AuthErrorCode; +import com.web.SearchWeb.member.dao.MemberDao; +import com.web.SearchWeb.member.domain.Member; +import com.web.SearchWeb.member.dto.CustomOAuth2Member; +import com.web.SearchWeb.member.dto.Response.GoogleResponse; +import com.web.SearchWeb.member.dto.Response.KakaoResponse; +import com.web.SearchWeb.member.dto.Response.NaverResponse; +import com.web.SearchWeb.member.dto.Response.OAuth2Response; +import com.web.SearchWeb.member.error.MemberErrorCode; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Service; + +import java.util.Objects; +import java.util.UUID; + +/** + * 소셜 로그인(OAuth2) 사용자 정보 처리 및 DB 동기화 서비스. + * -DefaultOAuth2UserService를 상속받아 소셜 서비스로부터 받은 유저 데이터를 가공함. + */ +@Service +public class CustomOAuth2MemberService extends DefaultOAuth2UserService { + + private final MemberDao memberDao; + + public CustomOAuth2MemberService(MemberDao memberDao) { + this.memberDao = memberDao; + } + + + /** + * OAuth2 로그인 성공 시 호출되어 사용자 정보를 로드하고 DB와 동기화함. + */ + @Override + public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { + // 부모 클래스의 메서드를 호출하여 기본적인 사용자 정보를 가져옴 + OAuth2User oAuth2User = super.loadUser(userRequest); + String registrationId = userRequest.getClientRegistration().getRegistrationId(); + OAuth2Response oAuth2Response = null; + + if ("naver".equals(registrationId)) { + oAuth2Response = new NaverResponse(oAuth2User.getAttributes()); + } + else if ("google".equals(registrationId)) { + oAuth2Response = new GoogleResponse(oAuth2User.getAttributes()); + } + else if ("kakao".equals(registrationId)) { + oAuth2Response = new KakaoResponse(oAuth2User.getAttributes()); + } + else { + throw new OAuth2AuthenticationException( + new OAuth2Error("unsupported_provider"), AuthErrorCode.AUTH_UNSUPPORTED_PROVIDER.getMessage() + ); + } + + // 로그인 ID 생성 (제공자 + 소셜 고유 ID 조합) + String loginId = oAuth2Response.getProvider() + oAuth2Response.getProviderId(); + String socialName = oAuth2Response.getName() != null ? oAuth2Response.getName() : "Unknown"; + String socialEmail = oAuth2Response.getEmail(); + + // 기존 가입 여부 확인 + Member existMember = memberDao.findByLoginId(loginId); + String role = "ROLE_USER"; + Long memberId; + + // 사용자가 존재하지 않으면 회원가입하고, 사용자가 이미 존재한다면 업데이트 + if (existMember == null) { + Member member = Member.builder() + .email(socialEmail) + .loginId(loginId) + .passwordHash(UUID.randomUUID().toString()) // 소셜 로그인이므로 더미 패스워드 설정 + .memberName(socialName) + .role(role) + .build(); + + try { + // 소셜 회원가입 + memberDao.insertSocialMember(member); + existMember = member; + } catch (DataIntegrityViolationException e) { + // 동시 가입 시도로 인한 예외 발생 시 다시 조회 + existMember = memberDao.findByLoginId(loginId); + if (existMember == null) { + throw new OAuth2AuthenticationException( + new OAuth2Error("social_login_failed"), MemberErrorCode.MEMBER_SOCIAL_LOGIN_FAILED.getMessage() + ); + } + // 소셜 제공자 정보가 실제로 바뀐 경우에만 최소 필드만 동기화(변경감지) + syncSocialIdentityIfChanged(existMember, socialName, socialEmail); + } + } + else { + // 소셜 제공자 정보가 실제로 바뀐 경우에만 최소 필드만 동기화(변경감지) + syncSocialIdentityIfChanged(existMember, socialName, socialEmail); + } + + role = existMember.getRole(); + memberId = existMember.getMemberId(); + + // 인증 객체로 사용할 CustomOAuth2Member 반환 + return new CustomOAuth2Member(oAuth2Response, role, memberId); + } + + + /** + * 소셜 서비스의 최신 정보(이름, 이메일)와 기존 DB 정보를 비교하여 변경된 경우에만 업데이트를 수행 + */ + private void syncSocialIdentityIfChanged(Member existMember, String socialName, String socialEmail) { + boolean memberNameChanged = !Objects.equals(existMember.getMemberName(), socialName); + boolean emailChanged = !Objects.equals(existMember.getEmail(), socialEmail); + + if (memberNameChanged || emailChanged) { + existMember.setMemberName(socialName); + existMember.setEmail(socialEmail); + // 변경된 정보 DB 반영 + memberDao.updateSocialIdentity(existMember.getMemberId(), existMember); + } + } +} diff --git a/src/main/java/com/web/SearchWeb/member/service/MemberService.java b/src/main/java/com/web/SearchWeb/member/service/MemberService.java index 6a8c31c..f168e83 100644 --- a/src/main/java/com/web/SearchWeb/member/service/MemberService.java +++ b/src/main/java/com/web/SearchWeb/member/service/MemberService.java @@ -2,12 +2,9 @@ import com.web.SearchWeb.member.domain.Member; -import com.web.SearchWeb.member.dto.MemberDto; import com.web.SearchWeb.member.dto.MemberUpdateDto; public interface MemberService { - //회원가입 - public void joinProcess(MemberDto member); //회원번호로 찾기 public Member findByMemberId(Long memberId); @@ -15,8 +12,6 @@ public interface MemberService { //로그인 아이디로 찾기 public Member findByLoginId(String loginId); - //비밀번호 확인 - public boolean isPasswordMatching(MemberDto memberDto); //회원 수정 public int updateMember(Long memberId, MemberUpdateDto memberUpdateDto); diff --git a/src/main/java/com/web/SearchWeb/member/service/MemberServiceImpl.java b/src/main/java/com/web/SearchWeb/member/service/MemberServiceImpl.java index 488a425..187441f 100644 --- a/src/main/java/com/web/SearchWeb/member/service/MemberServiceImpl.java +++ b/src/main/java/com/web/SearchWeb/member/service/MemberServiceImpl.java @@ -1,13 +1,12 @@ package com.web.SearchWeb.member.service; -import com.web.SearchWeb.board.dao.BoardDao; -import com.web.SearchWeb.comment.dao.CommentDao; -import com.web.SearchWeb.comment.domain.Comment; -import com.web.SearchWeb.comment.dto.UpdateUserProfileCommentDto; +// import com.web.SearchWeb.board.dao.BoardDao; +// import com.web.SearchWeb.comment.dao.CommentDao; +// import com.web.SearchWeb.comment.domain.Comment; +// import com.web.SearchWeb.comment.dto.UpdateUserProfileCommentDto; import com.web.SearchWeb.member.dao.MemberDao; import com.web.SearchWeb.member.domain.Member; -import com.web.SearchWeb.member.dto.MemberDto; import com.web.SearchWeb.member.dto.MemberUpdateDto; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; @@ -22,28 +21,16 @@ public class MemberServiceImpl implements MemberService{ private final MemberDao memberDao; - private final BoardDao boardDao; - private final CommentDao commentDao; + // private final BoardDao boardDao; + // private final CommentDao commentDao; private final BCryptPasswordEncoder bCryptPasswordEncoder; @Autowired - public MemberServiceImpl(MemberDao memberDao, BCryptPasswordEncoder bCryptPasswordEncoder, BoardDao boardDao, CommentDao commentDao) { + public MemberServiceImpl(MemberDao memberDao, BCryptPasswordEncoder bCryptPasswordEncoder/*, BoardDao boardDao, CommentDao commentDao*/) { this.memberDao = memberDao; this.bCryptPasswordEncoder = bCryptPasswordEncoder; - this.boardDao = boardDao; - this.commentDao = commentDao; - - } - - - /** - * 회원가입 - */ - public void joinProcess(MemberDto member) { - // 비밀번호 암호화 및 사용자 저장 로직 - member.setPassword(bCryptPasswordEncoder.encode(member.getPassword())); - member.setRole("ROLE_USER"); - memberDao.joinProcess(member); + // this.boardDao = boardDao; + // this.commentDao = commentDao; } @@ -63,14 +50,6 @@ public Member findByLoginId(String loginId){ } - /** - * 비밀번호 확인 - */ - public boolean isPasswordMatching(MemberDto memberDto) { - return memberDto.getPassword().equals(memberDto.getConfirmPassword()); - } - - /** * 회원 수정 */ @@ -79,6 +58,7 @@ public boolean isPasswordMatching(MemberDto memberDto) { public int updateMember(Long memberId, MemberUpdateDto memberUpdateDto) { int result = memberDao.updateMember(memberId, memberUpdateDto); + /* // 회원 정보 수정이 성공했을 경우, 게시글 댓글의 회원정보 업데이트 if (result == 1) { List comments = commentDao.selectCommentsByMemberId(memberId); @@ -94,6 +74,7 @@ public int updateMember(Long memberId, MemberUpdateDto memberUpdateDto) { commentDao.updateCommentUserProfile(commentDto); } } + */ return result; } } \ No newline at end of file diff --git a/src/main/resources/mapper/member-mapper.xml b/src/main/resources/mapper/member-mapper.xml index fc653d4..3f9df17 100644 --- a/src/main/resources/mapper/member-mapper.xml +++ b/src/main/resources/mapper/member-mapper.xml @@ -28,15 +28,8 @@ - - - INSERT INTO member (login_id, password_hash, member_name, nick_name, email, role) - VALUES (#{loginId}, #{password}, #{memberName}, #{nickName}, #{email}, #{role}) - - - - + INSERT INTO member (login_id, password_hash, member_name, nick_name, role, email) VALUES (#{loginId}, #{passwordHash}, #{memberName}, #{nickName}, #{role}, #{email}) @@ -73,13 +66,10 @@ - + UPDATE member SET - login_id = #{member.loginId}, - job = #{member.job}, - major = #{member.major}, - summary = #{member.summary}, + member_name = #{member.memberName}, email = #{member.email}, updated_at = now() WHERE member_id = #{memberId} From 8544adb7ac147159ea11689fdf293f566f4de510 Mon Sep 17 00:00:00 2001 From: jin2304 Date: Sun, 29 Mar 2026 23:33:57 +0900 Subject: [PATCH 06/22] =?UTF-8?q?feat:=20=ED=94=84=EB=A1=A0=ED=8A=B8?= =?UTF-8?q?=EC=97=94=EB=93=9C=20AuthProvider=20=EC=A0=81=EC=9A=A9=20?= =?UTF-8?q?=EB=B0=8F=20fetchClient=20JWT=20=EC=9D=B8=EC=A6=9D=20=ED=9D=90?= =?UTF-8?q?=EB=A6=84=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/next.config.ts | 5 +- frontend/src/app/auth/callback/page.tsx | 44 ++++++ frontend/src/app/layout.tsx | 13 +- frontend/src/app/login/page.tsx | 4 +- frontend/src/app/my-links/page.tsx | 5 +- .../components/dialogs/CreateFolderDialog.tsx | 7 +- .../src/components/dialogs/SaveLinkDialog.tsx | 16 +- frontend/src/components/layout/Sidebar.tsx | 33 ++++- .../src/components/my-links/RightPanel.tsx | 7 +- .../src/components/providers/AuthProvider.tsx | 55 +++++++ frontend/src/lib/api/fetchClient.ts | 82 +++++++++-- frontend/src/lib/api/folderApi.ts | 5 +- frontend/src/lib/api/tagApi.ts | 5 +- frontend/src/lib/auth/currentUser.ts | 3 - frontend/src/lib/config/backend.ts | 24 +++ frontend/src/lib/store/authStore.ts | 138 ++++++++++++++++++ 16 files changed, 399 insertions(+), 47 deletions(-) create mode 100644 frontend/src/app/auth/callback/page.tsx create mode 100644 frontend/src/components/providers/AuthProvider.tsx delete mode 100644 frontend/src/lib/auth/currentUser.ts create mode 100644 frontend/src/lib/config/backend.ts create mode 100644 frontend/src/lib/store/authStore.ts diff --git a/frontend/next.config.ts b/frontend/next.config.ts index 2c55c21..30a4ee1 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -1,7 +1,10 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - // [BACKEND_CONNECT] 백엔드 API 프록시 설정 (CORS 해결) + // [BACKEND_CONNECT] 백엔드 API 프록시 설정 + // 인증 관련 엔드포인트(/api/auth/*)는 프론트엔드에서 백엔드로 직접 호출한다. + // (buildBackendUrl 참고) Set-Cookie 가 프록시를 거치며 누락되는 문제를 방지하기 위함. + // 아래 rewrite 는 인증 외 일반 API 용이며, Phase 3(Nginx 단일 origin) 도입 시 제거된다. async rewrites() { return [ { diff --git a/frontend/src/app/auth/callback/page.tsx b/frontend/src/app/auth/callback/page.tsx new file mode 100644 index 0000000..0e76b25 --- /dev/null +++ b/frontend/src/app/auth/callback/page.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { useEffect, useRef } from 'react'; +import { useRouter } from 'next/navigation'; +import { useAuthStore } from '@/lib/store/authStore'; + +/** + * OAuth 로그인 완료 후 백엔드가 리디렉션하는 콜백 페이지 + * - initialize()는 AuthProvider가 단독 호출 — 여기서는 결과만 구독 + * - 초기화 완료 후 인증 성공 여부에 따라 분기 + */ +export default function AuthCallbackPage() { + const router = useRouter(); + const initialize = useAuthStore((s) => s.initialize); + const isInitializing = useAuthStore((s) => s.isInitializing); + const isAuthenticated = useAuthStore((s) => s.isAuthenticated); + const initialized = useRef(false); + + // 컴포넌트 마운트 시 초기화 실행 + // - authStore.initialize() 내부에서 이미 진행 중인 Promise가 있다면 재사용하므로 안전함 + useEffect(() => { + if (!initialized.current) { + initialize(); + initialized.current = true; + } + }, [initialize]); + + useEffect(() => { + // 초기화가 끝날 때까지 대기 + if (isInitializing) return; + + // 인증 성공 여부에 따라 페이지 이동 + router.replace(isAuthenticated ? '/my-links' : '/login'); + }, [isInitializing, isAuthenticated, router]); + + return ( +
+
+
+

로그인 처리 중...

+
+
+ ); +} diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index e877158..6b35da6 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -4,6 +4,7 @@ import "./globals.css"; import { AppLayout } from "@/components/layout/AppLayout"; import { SaveLinkDialog } from "@/components/dialogs/SaveLinkDialog"; import { CreateFolderDialog } from "@/components/dialogs/CreateFolderDialog"; +import { AuthProvider } from "@/components/providers/AuthProvider"; import { QueryProvider } from "@/components/providers/QueryProvider"; @@ -31,11 +32,13 @@ export default function RootLayout({ - - {children} - - - + + + {children} + + + + diff --git a/frontend/src/app/login/page.tsx b/frontend/src/app/login/page.tsx index 8f01455..729ae53 100644 --- a/frontend/src/app/login/page.tsx +++ b/frontend/src/app/login/page.tsx @@ -3,6 +3,7 @@ import React from "react"; import NextLink from "next/link"; import { Input } from "@/components/ui/input"; +import { buildBackendUrl } from "@/lib/config/backend"; export default function LoginPage() { return ( @@ -137,7 +138,8 @@ export default function LoginPage() {
{/* Google Sign In - Purple Gradient Background */} - diff --git a/frontend/src/components/my-links/RightPanel.tsx b/frontend/src/components/my-links/RightPanel.tsx index 54d6831..a2d547b 100644 --- a/frontend/src/components/my-links/RightPanel.tsx +++ b/frontend/src/components/my-links/RightPanel.tsx @@ -4,7 +4,7 @@ import { useFolders } from '@/lib/api/folderApi'; import { useBookmarks, useDeleteBookmark, useUpdateBookmark } from '@/lib/api/bookmarkApi'; import { useTags } from '@/lib/api/tagApi'; import { useFolderStore } from '@/lib/store/folderStore'; -import { TEMP_MEMBER_ID } from '@/lib/auth/currentUser'; +import { useAuthStore } from '@/lib/store/authStore'; import type { BookmarkResponse } from '@/lib/types/bookmark'; /** @@ -447,10 +447,11 @@ export function RightPanel() { // --- Central State (Zustand) | 중앙 상태 관리 --- const { rightPanelOpen } = useUIStore(); // 패널 오픈 여부 const selectedFolderId = useFolderStore((s) => s.selectedFolderId); // 현재 선택된 폴더 ID + const memberId = useAuthStore((s) => s.member?.memberId); // --- API Data Fetching (React Query) | 서버 데이터 조회 --- - const { data: myFolders, isLoading: isFoldersLoading } = useFolders(TEMP_MEMBER_ID); // 폴더 목록 - const { data: tagsData } = useTags(TEMP_MEMBER_ID); // 전체 태그 목록 + const { data: myFolders, isLoading: isFoldersLoading } = useFolders(memberId); // 폴더 목록 + const { data: tagsData } = useTags(memberId); // 전체 태그 목록 // --- Local UI State | UI 전용 로컬 상태 --- const [selectedTags, setSelectedTags] = useState([]); // 선택된 필터 태그 diff --git a/frontend/src/components/providers/AuthProvider.tsx b/frontend/src/components/providers/AuthProvider.tsx new file mode 100644 index 0000000..da7b720 --- /dev/null +++ b/frontend/src/components/providers/AuthProvider.tsx @@ -0,0 +1,55 @@ +'use client'; + +import { useEffect } from 'react'; +import { useRouter, usePathname } from 'next/navigation'; +import { useAuthStore } from '@/lib/store/authStore'; + +// 로그인 없이 접근 가능한 공개 경로 +const PUBLIC_PATHS = ['/', '/login', '/auth/callback']; + +/** + * 인증 초기화 및 라우트 가드를 담당하는 Provider + * - 앱 최초 진입 시 initialize()를 호출해 로그인 상태를 복구 + * - 초기화 완료 후 미인증 상태이면 보호된 경로에서 /login으로 리디렉션 + */ +export function AuthProvider({ children }: { children: React.ReactNode }) { + const initialize = useAuthStore((s) => s.initialize); + const isInitializing = useAuthStore((s) => s.isInitializing); + const isAuthenticated = useAuthStore((s) => s.isAuthenticated); + const authError = useAuthStore((s) => s.authError); + const router = useRouter(); + const pathname = usePathname(); + + // 앱 마운트 시 단 한 번 실행 — 로그인 상태 복구 + useEffect(() => { + initialize(); + }, [initialize]); + + // 초기화 완료 후 미인증 + 보호 라우트 접근 시 로그인 페이지로 이동 + useEffect(() => { + if (isInitializing) return; // 아직 초기화 중이면 판단 보류 + const isPublicPath = PUBLIC_PATHS.some((path) => + path === '/' ? pathname === '/' : pathname.startsWith(path), + ); + if (!isAuthenticated && !isPublicPath) { + // 서버/네트워크 오류 메시지가 있으면 로그인 페이지에서 보여주기 위해 저장 + if (authError) { + sessionStorage.setItem('authError', authError); + } + router.replace('/login'); + } + }, [isInitializing, isAuthenticated, pathname, authError, router]); + + if (isInitializing) { + return ( +
+
+
+

로딩 중...

+
+
+ ); + } + + return <>{children}; +} diff --git a/frontend/src/lib/api/fetchClient.ts b/frontend/src/lib/api/fetchClient.ts index 95091d7..be2e773 100644 --- a/frontend/src/lib/api/fetchClient.ts +++ b/frontend/src/lib/api/fetchClient.ts @@ -1,25 +1,91 @@ import type { ApiResponse } from '@/lib/types/apiResponse'; +import { buildBackendUrl } from '@/lib/config/backend'; +import { useAuthStore } from '@/lib/store/authStore'; + +// 여러 API 가 동시에 401 을 받아도 refresh 는 1회만 보내도록 Promise 를 공유한다. +let refreshPromise: Promise | null = null; + +/** + * Refresh Token(쿠키)으로 새 Access Token을 발급받아 스토어에 저장한다. + * 동시 호출 시 첫 번째 Promise를 재사용해 중복 요청을 방지한다. + */ +async function refreshAccessToken(): Promise { + if (refreshPromise) return refreshPromise; // 이미 진행 중이면 같은 Promise 반환 + refreshPromise = (async () => { + try { + // refreshToken 쿠키는 백엔드가 직접 읽어야 하므로 refresh 만은 백엔드 직통으로 보낸다. + const response = await fetch(buildBackendUrl('/api/auth/refresh'), { + method: 'POST', + credentials: 'include', + }); + if (!response.ok) return false; + const json = await response.json(); + useAuthStore.getState().setAccessToken(json.data.accessToken); + return true; + } catch { + return false; + } finally { + refreshPromise = null; // 완료 후 초기화해 다음 갱신 가능하게 + } + })(); + return refreshPromise; +} /** * API 통신용 제네릭 Fetch 클라이언트 + * - 모든 요청에 Access Token 자동 주입 + * - 401 응답 시 토큰 갱신 후 원래 요청을 1회 재시도 + * - 갱신 실패 시 로그아웃 후 로그인 페이지로 이동 */ export async function fetchClient( url: string, options?: RequestInit, ): Promise { const headers = new Headers(options?.headers); - if (!headers.has('Content-Type')) { + if (!headers.has('Content-Type') && !(options?.body instanceof FormData)) { headers.set('Content-Type', 'application/json'); } - // 기본 설정 (인증 포함, JSON 형식) + // 스토어에 저장된 Access Token을 Authorization 헤더에 주입 + const { accessToken } = useAuthStore.getState(); + if (accessToken) { + headers.set('Authorization', `Bearer ${accessToken}`); + } + + // 일반 API 는 현재 구조를 유지해 상대경로로 호출한다. const response = await fetch(url, { - credentials: 'include', ...options, + credentials: 'include', headers, }); - // 2xx 외 응답 처리 (에러) + // 401 수신 → 토큰 갱신 시도 → 성공하면 원래 요청 재시도 (1회) + if (response.status === 401 && accessToken) { + const refreshed = await refreshAccessToken(); + if (refreshed) { + const newToken = useAuthStore.getState().accessToken; + headers.set('Authorization', `Bearer ${newToken}`); + const retryResponse = await fetch(url, { + ...options, + credentials: 'include', + headers, + }); + return parseResponse(retryResponse); + } + // 갱신도 실패 → 상태 초기화 후 로그인 페이지로 강제 이동 + await useAuthStore.getState().logout(); + if (typeof window !== 'undefined') { + window.location.href = '/login'; + } + throw new Error('인증이 만료되었습니다.'); + } + + return parseResponse(response); +} + +/** HTTP 응답을 파싱해 데이터 또는 에러를 반환한다. */ +async function parseResponse(response: Response): Promise { + // 2xx 외 HTTP 오류 처리 if (!response.ok) { const text = await response.text().catch(() => ''); let message = `HTTP ${response.status}`; @@ -32,22 +98,18 @@ export async function fetchClient( throw new Error(message); } - // 204 No Content 및 빈 응답 처리 (text 변환) + // 204 No Content 또는 빈 응답 const text = await response.text(); - - // 빈 본문 통과 if (!text) { return undefined as unknown as T; } - // JSON 파싱 const json: ApiResponse = JSON.parse(text); - // API 비즈니스 에러 예외 처리 + // HTTP는 200이지만 비즈니스 로직 에러인 경우 (success: false) if (!json.success) { throw new Error(json.error?.message ?? '알 수 없는 에러'); } - // 데이터 반환 return json.data; } diff --git a/frontend/src/lib/api/folderApi.ts b/frontend/src/lib/api/folderApi.ts index 58db4a7..b599db2 100644 --- a/frontend/src/lib/api/folderApi.ts +++ b/frontend/src/lib/api/folderApi.ts @@ -41,10 +41,11 @@ async function deleteFolder(folderId: number): Promise { * [조회 Hook] 특정 사용자의 루트 폴더 목록을 가져옵니다. * @param ownerMemberId 사용자 ID */ -export function useFolders(ownerMemberId: number) { +export function useFolders(ownerMemberId: number | undefined) { return useQuery({ queryKey: ['folders', 'root', ownerMemberId], - queryFn: () => fetchRootFolders(ownerMemberId), + queryFn: () => fetchRootFolders(ownerMemberId!), + enabled: !!ownerMemberId, }); } diff --git a/frontend/src/lib/api/tagApi.ts b/frontend/src/lib/api/tagApi.ts index f4c24cc..9626c35 100644 --- a/frontend/src/lib/api/tagApi.ts +++ b/frontend/src/lib/api/tagApi.ts @@ -29,10 +29,11 @@ async function createTag(data: CreateTagRequest): Promise { /** * [조회 Hook] 특정 사용자의 태그 목록을 가져옵니다. */ -export function useTags(ownerMemberId: number) { +export function useTags(ownerMemberId: number | undefined) { return useQuery({ queryKey: ['tags', ownerMemberId], - queryFn: () => fetchTags(ownerMemberId), + queryFn: () => fetchTags(ownerMemberId!), + enabled: !!ownerMemberId, }); } diff --git a/frontend/src/lib/auth/currentUser.ts b/frontend/src/lib/auth/currentUser.ts deleted file mode 100644 index 30f5768..0000000 --- a/frontend/src/lib/auth/currentUser.ts +++ /dev/null @@ -1,3 +0,0 @@ -// 임시 하드코딩된 회원 ID -// 추후 인증 시스템 구현 시 실제 세션/토큰에서 추출하는 훅으로 교체 -export const TEMP_MEMBER_ID = 1; diff --git a/frontend/src/lib/config/backend.ts b/frontend/src/lib/config/backend.ts new file mode 100644 index 0000000..19c0192 --- /dev/null +++ b/frontend/src/lib/config/backend.ts @@ -0,0 +1,24 @@ +// 로컬 개발 환경: 인증 요청을 Spring 백엔드로 직접 전송 +// Next.js rewrite 시 refreshToken 쿠키 누락 방지 목적 + +// 1. 백엔드 서버 기본 주소 결정 (환경 변수 우선, 로컬 개발 시 8080 기본값) +const rawBackendOrigin = + process.env.NEXT_PUBLIC_BACKEND_ORIGIN ?? + (process.env.NODE_ENV === 'development' ? 'http://localhost:8080' : ''); + +// 2. 주소 끝 슬래시(/) 제거: 경로 중복 방지 +const normalizedBackendOrigin = rawBackendOrigin.replace(/\/+$/, ''); + +export function buildBackendUrl(path: string): string { + // 이미 절대 경로(http://...)인 경우 그대로 반환 + if (/^https?:\/\//.test(path)) { + return path; + } + + // 3. 경로 시작 부분 슬래시(/) 추가 (정규화) + const normalizedPath = path.startsWith('/') ? path : `/${path}`; + + // 4. 백엔드 주소 유무에 따라 절대 경로 또는 상대 경로 반환 + // 리버스 프록시 등을 이용한 단일 오리진 환경 대응용 + return normalizedBackendOrigin ? `${normalizedBackendOrigin}${normalizedPath}` : normalizedPath; +} diff --git a/frontend/src/lib/store/authStore.ts b/frontend/src/lib/store/authStore.ts new file mode 100644 index 0000000..05b2db4 --- /dev/null +++ b/frontend/src/lib/store/authStore.ts @@ -0,0 +1,138 @@ +import { create } from 'zustand'; +import { buildBackendUrl } from '@/lib/config/backend'; + +/** + * 전역 인증 상태 관리 스토어 (Zustand) + * - Access Token 및 사용자 정보 저장 + * - 앱 초기화 시 토큰 갱신 및 내 정보 조회 담당 + */ + +/** 사용자 정보 인터페이스 */ +interface Member { + memberId: number; + name: string; + email: string; + role: string; +} + +/** 인증 상태 및 액션 인터페이스 */ +interface AuthState { + accessToken: string | null; // API 인증용 액세스 토큰 + isAuthenticated: boolean; // 로그인 여부 + isInitializing: boolean; // 초기화(로딩) 진행 여부 + member: Member | null; // 사용자 프로필 정보 + authError: string | null; // 인증 실패 사유 — 서버/네트워크 오류 시에만 저장, 단순 만료는 null + + setAccessToken: (token: string) => void; + setMember: (member: Member) => void; + logout: () => Promise; + initialize: () => Promise; +} + +// Strict Mode / 중복 마운트에서도 refresh 요청이 한 번만 돌도록 잡아주는 싱글톤 Promise. +let initializePromise: Promise | null = null; + +export const useAuthStore = create((set, get) => ({ + accessToken: null, + isAuthenticated: false, + isInitializing: true, + member: null, + authError: null, + + setAccessToken: (token: string) => set({ accessToken: token }), + + setMember: (member: Member) => set({ member }), + + // 로그아웃: 인증 관련 상태를 모두 초기화 및 서버 세션 제거 + logout: async () => { + try { + // 서버에 로그아웃 요청 (HttpOnly 쿠키의 Refresh Token 삭제 목적) + // 인증 쿠키는 백엔드가 발급/삭제하므로 로그아웃도 백엔드 직통으로 보낸다. + await fetch(buildBackendUrl('/api/auth/logout'), { + method: 'POST', + credentials: 'include', + }); + } catch (error) { + console.error('Logout error:', error); + } finally { + // 요청 성공 여부와 상관없이 클라이언트 상태는 초기화 + set({ + accessToken: null, + isAuthenticated: false, + member: null, + authError: null, + }); + } + }, + + /** + * 앱 시작 시 호출 — 쿠키의 Refresh Token으로 로그인 상태를 복구한다. + * 중복 호출 방지를 위해 싱글톤 Promise 패턴(initializePromise)을 사용한다. + */ + initialize: async () => { + // 이미 진행 중인 초기화가 있으면 같은 Promise 재사용 (동시 호출 방지) + if (initializePromise) return initializePromise; + // 이미 인증 완료 상태면 재초기화 불필요 (Strict Mode 이중 실행 방지) + if (get().isAuthenticated) return; + + initializePromise = (async () => { + set({ isInitializing: true }); + try { + // 1단계: Refresh Token(HttpOnly 쿠키)으로 새 Access Token 발급 + // refreshToken 은 HttpOnly 쿠키라 JS 에서 읽지 못하므로, + // 백엔드 /refresh 호출로만 새 access token 을 복구할 수 있다. + const refreshResponse = await fetch(buildBackendUrl('/api/auth/refresh'), { + method: 'POST', + credentials: 'include', // 쿠키를 요청에 포함 + }); + + if (!refreshResponse.ok) { + // 401/403 → 정상적인 만료 또는 미로그인 → 메시지 없이 조용히 로그인 페이지로 + // 5xx → 서버 문제 → 사용자에게 안내 메시지 표시 + const authError = + refreshResponse.status >= 500 + ? '일시적인 문제가 발생했습니다. 잠시 후 다시 시도해주세요.' + : null; + set({ isAuthenticated: false, isInitializing: false, accessToken: null, authError }); + return; + } + + const refreshData = await refreshResponse.json(); + const accessToken = refreshData.data.accessToken; + set({ accessToken }); + + // 2단계: 발급된 Access Token으로 내 정보 조회 + // access token 을 받은 직후 회원 정보까지 조회해야 + // "복구 완료된 로그인 상태"로 스토어를 채울 수 있다. + const memberResponse = await fetch(buildBackendUrl('/api/auth/member'), { + headers: { Authorization: `Bearer ${accessToken}` }, + credentials: 'include', + }); + + if (memberResponse.ok) { + const memberData = await memberResponse.json(); + set({ + member: memberData.data, + isAuthenticated: true, + authError: null, + }); + } else { + // 내 정보 조회 실패 → 인증 불완전으로 처리 + set({ isAuthenticated: false, accessToken: null }); + } + } catch { + // fetch 자체가 실패한 경우 → 네트워크 단절 등 + set({ + isAuthenticated: false, + accessToken: null, + authError: '인터넷 연결을 확인해주세요.', + }); + } finally { + set({ isInitializing: false }); + initializePromise = null; + } + })(); + + return initializePromise; + }, +})); From 2cb12732340ba1c936fbb9acdbebe62a4b033223 Mon Sep 17 00:00:00 2001 From: jin2304 Date: Sun, 29 Mar 2026 23:56:38 +0900 Subject: [PATCH 07/22] =?UTF-8?q?chore:=20=EB=A0=88=EA=B1=B0=EC=8B=9C=20?= =?UTF-8?q?=EB=B0=B1=EC=97=85=20=ED=8C=8C=EC=9D=BC=20=EC=99=B8=EB=B6=80=20?= =?UTF-8?q?=EA=B2=BD=EB=A1=9C=EB=A1=9C=20=EC=9D=B4=EB=8F=99=20=EB=B0=8F=20?= =?UTF-8?q?=EC=B5=9C=EC=A2=85=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/mapper/board-mapper.xml | 184 ------------------ .../resources/mapper/board-mapper.xml.bak | 183 ----------------- src/main/resources/mapper/comment-mapper.xml | 84 -------- .../resources/mapper/comment-mapper.xml.bak | 84 -------- src/main/resources/mapper/likes-mapper.xml | 40 ---- .../resources/mapper/likes-mapper.xml.bak | 40 ---- src/main/resources/mapper/main-mapper.xml | 34 ---- src/main/resources/mapper/main-mapper.xml.bak | 34 ---- 8 files changed, 683 deletions(-) delete mode 100644 src/main/resources/mapper/board-mapper.xml delete mode 100644 src/main/resources/mapper/board-mapper.xml.bak delete mode 100644 src/main/resources/mapper/comment-mapper.xml delete mode 100644 src/main/resources/mapper/comment-mapper.xml.bak delete mode 100644 src/main/resources/mapper/likes-mapper.xml delete mode 100644 src/main/resources/mapper/likes-mapper.xml.bak delete mode 100644 src/main/resources/mapper/main-mapper.xml delete mode 100644 src/main/resources/mapper/main-mapper.xml.bak diff --git a/src/main/resources/mapper/board-mapper.xml b/src/main/resources/mapper/board-mapper.xml deleted file mode 100644 index 26c5c71..0000000 --- a/src/main/resources/mapper/board-mapper.xml +++ /dev/null @@ -1,184 +0,0 @@ - \ No newline at end of file diff --git a/src/main/resources/mapper/board-mapper.xml.bak b/src/main/resources/mapper/board-mapper.xml.bak deleted file mode 100644 index 977082e..0000000 --- a/src/main/resources/mapper/board-mapper.xml.bak +++ /dev/null @@ -1,183 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - INSERT INTO board (member_member_id, url, title, summary, description, hashtags) - VALUES (#{memberId}, #{boardDto.url}, #{boardDto.title}, #{boardDto.summary}, #{boardDto.description}, #{boardDto.hashtags}) - - - - - - - - - - - - - - - - - - - - - - UPDATE board - SET - url = #{boardDto.url}, - title = #{boardDto.title}, - summary = #{boardDto.summary}, - description = #{boardDto.description}, - hashtags = #{boardDto.hashtags} - WHERE board_id = #{boardId} - - - - - - UPDATE board - SET - job = #{job}, - major = #{major} - WHERE board_id = #{boardId} - - - - - - - DELETE FROM board - WHERE board_id = #{boardId} - - - - - - UPDATE board - SET bookmarks_count = #{bookmarkCount} - WHERE board_id = #{boardId} - - - - - - UPDATE board - SET views_count = views_count + 1 - WHERE board_id = #{boardId} - - - - - - UPDATE board - SET likes_count = likes_count + 1 - WHERE board_id = #{boardId} - - - - - - UPDATE board - SET likes_count = likes_count - 1 - WHERE board_id = #{boardId} - - - - - UPDATE board - SET comments_count = comments_count + 1 - WHERE board_id = #{boardId} - - - - - - UPDATE board - SET comments_count = comments_count - 1 - WHERE board_id = #{boardId} - - - - \ No newline at end of file diff --git a/src/main/resources/mapper/comment-mapper.xml b/src/main/resources/mapper/comment-mapper.xml deleted file mode 100644 index 446bd5c..0000000 --- a/src/main/resources/mapper/comment-mapper.xml +++ /dev/null @@ -1,84 +0,0 @@ - \ No newline at end of file diff --git a/src/main/resources/mapper/comment-mapper.xml.bak b/src/main/resources/mapper/comment-mapper.xml.bak deleted file mode 100644 index 446bd5c..0000000 --- a/src/main/resources/mapper/comment-mapper.xml.bak +++ /dev/null @@ -1,84 +0,0 @@ - \ No newline at end of file diff --git a/src/main/resources/mapper/likes-mapper.xml b/src/main/resources/mapper/likes-mapper.xml deleted file mode 100644 index 7b9e68a..0000000 --- a/src/main/resources/mapper/likes-mapper.xml +++ /dev/null @@ -1,40 +0,0 @@ - \ No newline at end of file diff --git a/src/main/resources/mapper/likes-mapper.xml.bak b/src/main/resources/mapper/likes-mapper.xml.bak deleted file mode 100644 index 7b9e68a..0000000 --- a/src/main/resources/mapper/likes-mapper.xml.bak +++ /dev/null @@ -1,40 +0,0 @@ - \ No newline at end of file diff --git a/src/main/resources/mapper/main-mapper.xml b/src/main/resources/mapper/main-mapper.xml deleted file mode 100644 index 0d2ec76..0000000 --- a/src/main/resources/mapper/main-mapper.xml +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/main/resources/mapper/main-mapper.xml.bak b/src/main/resources/mapper/main-mapper.xml.bak deleted file mode 100644 index 0d2ec76..0000000 --- a/src/main/resources/mapper/main-mapper.xml.bak +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - - - - - - - - \ No newline at end of file From e71bc18d7348cf05207aadb966029484a9fcaf6b Mon Sep 17 00:00:00 2001 From: jin2304 Date: Thu, 2 Apr 2026 00:17:09 +0900 Subject: [PATCH 08/22] =?UTF-8?q?feat(security):=20JWT=20=EC=BD=94?= =?UTF-8?q?=EC=96=B4=20=EB=A1=9C=EC=A7=81=20=EA=B0=95=ED=99=94=20=EB=B0=8F?= =?UTF-8?q?=20=EB=B3=B4=EC=95=88=20=ED=95=84=ED=84=B0=20=EC=98=88=EC=99=B8?= =?UTF-8?q?=20=EC=B2=98=EB=A6=AC=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - JwtUtils 내 토큰 유효성 검사 및 클레임 추출 로직 엄격화 - JwtAuthenticationFilter와 EntryPoint를 통한 인증 실패 예외의 통합 처리 구조 마련 - SecurityUtils 유틸리티 연동으로 코드 중복 제거 및 보안 설정 최적화 --- .../jwt/JwtAuthenticationEntryPoint.java | 4 ++- .../config/jwt/JwtAuthenticationFilter.java | 8 +++--- .../SearchWeb/config/jwt/JwtProperties.java | 12 +++++++-- .../web/SearchWeb/config/jwt/JwtUtils.java | 26 ++++++++++++------- .../config/security/SecurityConfig.java | 19 ++++++-------- .../config/security/SecurityUtils.java | 4 +++ 6 files changed, 47 insertions(+), 26 deletions(-) diff --git a/src/main/java/com/web/SearchWeb/config/jwt/JwtAuthenticationEntryPoint.java b/src/main/java/com/web/SearchWeb/config/jwt/JwtAuthenticationEntryPoint.java index 61493b9..861e461 100644 --- a/src/main/java/com/web/SearchWeb/config/jwt/JwtAuthenticationEntryPoint.java +++ b/src/main/java/com/web/SearchWeb/config/jwt/JwtAuthenticationEntryPoint.java @@ -1,6 +1,7 @@ package com.web.SearchWeb.config.jwt; import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; import com.web.SearchWeb.config.common.ApiResponse; import com.web.SearchWeb.auth.error.AuthErrorCode; import com.web.SearchWeb.config.exception.ErrorCode; @@ -18,9 +19,10 @@ * -JWT 토큰이 없거나 유효하지 않은 경우의 처리를 담당 */ @Component +@RequiredArgsConstructor public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { - private final ObjectMapper objectMapper = new ObjectMapper(); + private final ObjectMapper objectMapper; /** * 인증 예외가 발생했을 때 실행되는 메서드 diff --git a/src/main/java/com/web/SearchWeb/config/jwt/JwtAuthenticationFilter.java b/src/main/java/com/web/SearchWeb/config/jwt/JwtAuthenticationFilter.java index a69e846..a82558e 100644 --- a/src/main/java/com/web/SearchWeb/config/jwt/JwtAuthenticationFilter.java +++ b/src/main/java/com/web/SearchWeb/config/jwt/JwtAuthenticationFilter.java @@ -62,9 +62,11 @@ protected void doFilterInternal(HttpServletRequest request, * "Authorization: Bearer {token}" 헤더에서 토큰 문자열만 추출 */ private String extractToken(HttpServletRequest request) { - String bearer = request.getHeader("Authorization"); - if (bearer != null && bearer.startsWith("Bearer ")) { - return bearer.substring(7); + String authHeader = request.getHeader("Authorization"); + // 대소문자 구분 없이 "Bearer "로 시작하는지 확인 + if (authHeader != null && authHeader.regionMatches(true, 0, "Bearer ", 0, 7)) { + String token = authHeader.substring(7).trim(); + return token.isEmpty() ? null : token; } return null; } diff --git a/src/main/java/com/web/SearchWeb/config/jwt/JwtProperties.java b/src/main/java/com/web/SearchWeb/config/jwt/JwtProperties.java index 8d4e9e4..ea3af6e 100644 --- a/src/main/java/com/web/SearchWeb/config/jwt/JwtProperties.java +++ b/src/main/java/com/web/SearchWeb/config/jwt/JwtProperties.java @@ -1,7 +1,9 @@ package com.web.SearchWeb.config.jwt; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.validation.annotation.Validated; @@ -12,8 +14,14 @@ @Validated @ConfigurationProperties(prefix = "jwt") public record JwtProperties( - @NotBlank String secret, // JWT 서명에 사용할 비밀 키 + @NotBlank + @Size(min = 44, message = "jwt.secret은 최소 44자 이상이어야 합니다. (32바이트 이상의 키를 Base64로 인코딩한 값)") + String secret, // JWT 서명에 사용할 비밀 키 (HS256: 32바이트 -> Base64 44자) + @Positive long accessTokenExpiry, // Access Token 만료 시간 (밀리초 단위) @Positive long refreshTokenExpiry, // Refresh Token 만료 시간 (밀리초 단위) - @NotBlank String refreshTokenPath // Refresh Token 쿠키 경로 (보안을 위해 /api/auth로 한정) + + @NotBlank + @Pattern(regexp = "^/.*", message = "jwt.refresh-token-path는 반드시 '/'로 시작해야 합니다.") + String refreshTokenPath // Refresh Token 쿠키 경로 (보안을 위해 /api/auth로 한정) ) {} diff --git a/src/main/java/com/web/SearchWeb/config/jwt/JwtUtils.java b/src/main/java/com/web/SearchWeb/config/jwt/JwtUtils.java index 7f22317..573e41b 100644 --- a/src/main/java/com/web/SearchWeb/config/jwt/JwtUtils.java +++ b/src/main/java/com/web/SearchWeb/config/jwt/JwtUtils.java @@ -6,6 +6,7 @@ import org.springframework.stereotype.Component; import javax.crypto.SecretKey; +import java.time.Instant; import java.util.Base64; import java.util.Date; import java.util.UUID; @@ -35,7 +36,6 @@ public JwtUtils(JwtProperties properties) { * @param role 사용자 권한 (예: ROLE_USER) * @return 생성된 Access Token 문자열 */ - public String generateAccessToken(Long memberId, String role) { Date now = new Date(); return Jwts.builder() @@ -49,17 +49,18 @@ public String generateAccessToken(Long memberId, String role) { } /** - * Refresh Token 생성 - * @param memberId 사용자 식별 ID - * @return 생성된 Refresh Token 문자열 + * Refresh Token 생성 (회전된 토큰을 같은 값으로 재생성하기 위한 오버로드) */ - public String generateRefreshToken(Long memberId) { - Date now = new Date(); + public String generateRefreshToken(Long memberId, String sessionId, int version, Instant issuedAt, Instant expiresAt) { + // 랜덤 UUID 대신 회전에 필요한 메타데이터를 고정값으로 사용해 + // 동일한 후속 refresh token을 다시 만들어낼 수 있게 한다. return Jwts.builder() - .id(UUID.randomUUID().toString()) // 무작위 ID 추가로 동일 시간 생성 시 중복 방지 + .id(sessionId + ":" + version) .subject(String.valueOf(memberId)) - .issuedAt(now) - .expiration(new Date(now.getTime() + refreshTokenExpiry)) + .claim("sid", sessionId) + .claim("ver", version) + .issuedAt(Date.from(issuedAt)) + .expiration(Date.from(expiresAt)) .signWith(secretKey) .compact(); } @@ -73,6 +74,13 @@ public JwtMemberPrincipal parseAccessToken(String token) { Claims claims = parseClaims(token); Long memberId = Long.parseLong(claims.getSubject()); String role = claims.get("role", String.class); + + // role 클레임 누락 시 에러 발생 + if (role == null || role.isBlank()) { + log.error("JWT 토큰에 권한 정보(role)가 누락되었습니다. memberId: {}", memberId); + throw new JwtException("JWT 토큰에 권한 정보가 누락되었습니다."); + } + return new JwtMemberPrincipal(memberId, role); } diff --git a/src/main/java/com/web/SearchWeb/config/security/SecurityConfig.java b/src/main/java/com/web/SearchWeb/config/security/SecurityConfig.java index cfcd7f0..790b35d 100644 --- a/src/main/java/com/web/SearchWeb/config/security/SecurityConfig.java +++ b/src/main/java/com/web/SearchWeb/config/security/SecurityConfig.java @@ -67,21 +67,18 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .cors(Customizer.withDefaults()); - // 페이지 별 권한 설정 + // 페이지 별 권한 설정 (Whitelist 기반) http .authorizeHttpRequests(auth -> auth - // 인증 없이 접근 가능한 엔드포인트 + // 1. 인증 없이 접근 가능한 엔드포인트 (WhiteList) .requestMatchers("/api/auth/refresh", "/api/auth/logout").permitAll() .requestMatchers("/oauth2/**", "/login/oauth2/**").permitAll() - // 인증 필요 엔드포인트 - .requestMatchers("/api/tags/**").authenticated() - .requestMatchers("/api/bookmarks/**").authenticated() - .requestMatchers("/api/link-analysis/**").authenticated() - .requestMatchers("/api/folders/**").authenticated() - .requestMatchers("/api/auth/member").authenticated() - // 역할 기반 접근 제어 - .requestMatchers("/admin").hasRole("ADMIN") - .anyRequest().permitAll() + + // 2. 관리자 전용 엔드포인트 + .requestMatchers("/admin/**").hasRole("ADMIN") + + // 3. 그 외 모든 요청 (기본 정책): 인증 필요 (보안 강화) + .anyRequest().authenticated() ); // 인증 실패(401) 시 JSON 에러 응답을 반환하는 EntryPoint diff --git a/src/main/java/com/web/SearchWeb/config/security/SecurityUtils.java b/src/main/java/com/web/SearchWeb/config/security/SecurityUtils.java index 061516d..56bf9c5 100644 --- a/src/main/java/com/web/SearchWeb/config/security/SecurityUtils.java +++ b/src/main/java/com/web/SearchWeb/config/security/SecurityUtils.java @@ -62,6 +62,10 @@ public static Long extractMemberId(Authentication authentication) { * @return SHA-256 해싱된 16진수 문자열 */ public static String hashToken(String input) { + if (input == null || input.isBlank()) { + throw AuthException.of(AuthErrorCode.AUTH_INTERNAL_ERROR); + } + try { MessageDigest digest = MessageDigest.getInstance("SHA-256"); byte[] hash = digest.digest(input.getBytes(StandardCharsets.UTF_8)); From 8d5092190bdb713c2eeccf201489ceb3e30a5648 Mon Sep 17 00:00:00 2001 From: jin2304 Date: Thu, 2 Apr 2026 00:22:23 +0900 Subject: [PATCH 09/22] =?UTF-8?q?feat(security):=20OAuth2=20=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D=20=EC=84=B1=EA=B3=B5/=EC=8B=A4=ED=8C=A8=20=ED=95=B8?= =?UTF-8?q?=EB=93=A4=EB=9F=AC=20=EA=B3=A0=EB=8F=84=ED=99=94=20=EB=B0=8F=20?= =?UTF-8?q?=EC=BF=A0=ED=82=A4=20=EB=A7=A4=ED=95=91=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - OAuth2SuccessHandler 내 안전한 토큰 전달 및 리다이렉트 흐름 구축 - 인증 실패 시 상세 에러 로깅 및 전용 예외 핸들러 인터페이스 개선 - 권한 부여 요청(Authorization Request) 정보 보관용 쿠키 유틸리티 최적화 --- .../config/jwt/OAuth2FailureHandler.java | 16 ++++++--- .../config/jwt/OAuth2SuccessHandler.java | 34 +++++++++++-------- .../config/security/CookieUtils.java | 17 +++++----- ...eOAuth2AuthorizationRequestRepository.java | 5 +-- 4 files changed, 42 insertions(+), 30 deletions(-) diff --git a/src/main/java/com/web/SearchWeb/config/jwt/OAuth2FailureHandler.java b/src/main/java/com/web/SearchWeb/config/jwt/OAuth2FailureHandler.java index c7d985a..f040ac2 100644 --- a/src/main/java/com/web/SearchWeb/config/jwt/OAuth2FailureHandler.java +++ b/src/main/java/com/web/SearchWeb/config/jwt/OAuth2FailureHandler.java @@ -4,6 +4,7 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.security.core.AuthenticationException; import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler; @@ -16,6 +17,7 @@ * OAuth2 인증 실패 처리를 담당하는 핸들러 클래스. * 예외 발생 시 사용자를 프론트엔드 로그인 페이지로 리다이렉트하고 에러 메시지를 전달함. */ +@Slf4j @Component @RequiredArgsConstructor public class OAuth2FailureHandler extends SimpleUrlAuthenticationFailureHandler { @@ -37,13 +39,17 @@ public class OAuth2FailureHandler extends SimpleUrlAuthenticationFailureHandler */ @Override public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException { - // 예외 객체에서 에러 메시지 추출 - String errorMessage = exception.getLocalizedMessage(); + String errorMessage; + String rawMessage = exception.getLocalizedMessage(); - // 특정 에러(쿠키 만료/실종) 발생 시 사용자 친화적인 메시지로 변환 - // 주로 소셜 로그인 창을 장시간 방치한 뒤 시도할 때 발생함 - if (errorMessage.contains("authorization_request_not_found")) { + // 특정 에러에 대해서만 사용자 친화적인 메시지 제공 + if (rawMessage != null && rawMessage.contains("authorization_request_not_found")) { errorMessage = "인증 요청 정보가 만료되었습니다. 다시 로그인해주세요."; + } else { + // 기본 메시지로 내부 정보 노출 방지 + errorMessage = "소셜 로그인에 실패했습니다. 다시 시도해주세요."; + // 디버깅을 위해 서버 로그에는 원본 메시지 기록 + log.warn("OAuth2 인증 실패: {}", rawMessage); } // 실패 후 이동할 대상 URL 생성 diff --git a/src/main/java/com/web/SearchWeb/config/jwt/OAuth2SuccessHandler.java b/src/main/java/com/web/SearchWeb/config/jwt/OAuth2SuccessHandler.java index f4338f9..48029c2 100644 --- a/src/main/java/com/web/SearchWeb/config/jwt/OAuth2SuccessHandler.java +++ b/src/main/java/com/web/SearchWeb/config/jwt/OAuth2SuccessHandler.java @@ -9,6 +9,7 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.ResponseCookie; import org.springframework.security.core.Authentication; @@ -27,6 +28,7 @@ * OAuth2 로그인 성공 시 호출되는 핸들러. * 인증 성공 후 JWT 리프레시 토큰을 발급하고 쿠키에 저장한 뒤, 프론트엔드로 리다이렉트합니다. */ +@Slf4j @Component @RequiredArgsConstructor public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler { @@ -52,19 +54,27 @@ public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException { + String targetUrl; + try { + // 1. 리다이렉트 URI 결정 및 검증 (실패 시 예외 발생) + targetUrl = determineTargetUrl(request, response, authentication); + } finally { + // 2. 성공/실패 여부와 상관없이 무조건 임시 쿠키 삭제 (보안 강화) + authorizationRequestRepository.removeAuthorizationRequestCookies(request, response); + } + + // 3. 검증 통과 시에만 토큰 발급 (실패 시 이 아래는 실행되지 않음) CustomOAuth2Member oAuth2Member = (CustomOAuth2Member) authentication.getPrincipal(); Long memberId = oAuth2Member.getMemberId(); - - // Refresh Token 발급 (고아 토큰 정리 + 생성 + DB 저장을 AuthService에 위임) String refreshToken = authService.issueRefreshToken(memberId); - // 💡 [E2E 테스트용 임시 로그] 터미널 콘솔에서 복사해서 .http 파일에 붙여넣으세요. - System.out.println("\n" + "=".repeat(80)); - System.out.println("[E2E TEST] Generated Refresh Token:"); - System.out.println(refreshToken); - System.out.println("=".repeat(80) + "\n"); + // 보안: 원본 토큰 노출 방지를 위해 마스킹 처리된 로그만 남김 + if (log.isDebugEnabled()) { + log.debug("OAuth2 인증 성공: memberId={}, Refresh Token(일부)={}...", + memberId, refreshToken.substring(0, 10)); + } - // Refresh Token Cookie 설정 + // 4. Refresh Token Cookie 설정 ResponseCookie cookie = ResponseCookie.from("refreshToken", refreshToken) .httpOnly(true) .secure(cookieSecure) @@ -74,13 +84,7 @@ public void onAuthenticationSuccess(HttpServletRequest request, .build(); response.addHeader("Set-Cookie", cookie.toString()); - // 4. 리다이렉트 경로 결정 (쿠키 확인 및 검증) - String targetUrl = determineTargetUrl(request, response, authentication); - - // 인증 요청 임시 쿠키 삭제 (Stateless 유지) - authorizationRequestRepository.removeAuthorizationRequestCookies(request, response); - - // 프론트엔드 콜백 URL로 redirect + // 5. 프론트엔드 콜백 URL로 redirect response.sendRedirect(targetUrl); } diff --git a/src/main/java/com/web/SearchWeb/config/security/CookieUtils.java b/src/main/java/com/web/SearchWeb/config/security/CookieUtils.java index b1bc2bb..1d5c803 100644 --- a/src/main/java/com/web/SearchWeb/config/security/CookieUtils.java +++ b/src/main/java/com/web/SearchWeb/config/security/CookieUtils.java @@ -10,6 +10,7 @@ import org.springframework.http.ResponseCookie; import org.springframework.security.jackson2.SecurityJackson2Modules; +import java.nio.charset.StandardCharsets; import java.util.Base64; import java.util.Optional; @@ -78,30 +79,30 @@ public static void deleteCookie(HttpServletRequest request, HttpServletResponse } // 객체 -> JSON 문자열 -> Base64 URL Safe 인코딩 (쿠키에 저장 가능한 문자열로 변환) - public static String serialize(Object object) { + public static Optional serialize(Object object) { try { // Jackson을 사용하여 객체를 JSON 문자열로 변환 String json = objectMapper.writeValueAsString(object); - // 해당 JSON을 Base64로 인코딩하여 바이너리 데이터를 텍스트화함 - return Base64.getUrlEncoder().encodeToString(json.getBytes()); + // 해당 JSON을 UTF-8 바이트로 변환 후 Base64로 인코딩하여 바이너리 데이터를 텍스트화함 + return Optional.of(Base64.getUrlEncoder().encodeToString(json.getBytes(StandardCharsets.UTF_8))); } catch (Exception e) { log.error("직렬화 실패: {}", e.getMessage()); - throw new RuntimeException("직렬화 실패", e); + return Optional.empty(); } } // Base64 문자열 -> JSON 데이터 -> 자바 객체 (쿠키 값을 다시 객체로 복원) - public static T deserialize(Cookie cookie, Class cls) { + public static Optional deserialize(Cookie cookie, Class cls) { try { // 1. Base64 디코딩 수행 byte[] data = Base64.getUrlDecoder().decode(cookie.getValue()); // 2. 디코딩된 JSON 데이터를 Jackson으로 읽어서 클래스 객체로 변환 - return objectMapper.readValue(data, cls); + return Optional.ofNullable(objectMapper.readValue(data, cls)); } catch (Exception e) { - // 역직렬화 도중 에러가 나더라도 서비스를 중단시키지 않고 null을 반환 + // 역직렬화 도중 에러가 나더라도 서비스를 중단시키지 않고 empty를 반환 log.error("쿠키 역직렬화 실패: {}", e.getMessage()); - return null; + return Optional.empty(); } } } diff --git a/src/main/java/com/web/SearchWeb/config/security/HttpCookieOAuth2AuthorizationRequestRepository.java b/src/main/java/com/web/SearchWeb/config/security/HttpCookieOAuth2AuthorizationRequestRepository.java index 693f176..e4e527e 100644 --- a/src/main/java/com/web/SearchWeb/config/security/HttpCookieOAuth2AuthorizationRequestRepository.java +++ b/src/main/java/com/web/SearchWeb/config/security/HttpCookieOAuth2AuthorizationRequestRepository.java @@ -37,7 +37,8 @@ public void saveAuthorizationRequest(OAuth2AuthorizationRequest authorizationReq } // 인증 요청 정보 쿠키에 저장 - CookieUtils.addCookie(response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME, CookieUtils.serialize(authorizationRequest), COOKIE_EXPIRE_SECONDS, cookieSecure); + CookieUtils.serialize(authorizationRequest) + .ifPresent(s -> CookieUtils.addCookie(response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME, s, COOKIE_EXPIRE_SECONDS, cookieSecure)); // 로그인 성공 후 최종 이동할 경로(redirect_uri)가 파라미터로 넘어왔다면 별도 쿠키에 보관 String redirectUriAfterLogin = request.getParameter(REDIRECT_URI_PARAM_COOKIE_NAME); @@ -54,7 +55,7 @@ public void saveAuthorizationRequest(OAuth2AuthorizationRequest authorizationReq @Override public OAuth2AuthorizationRequest loadAuthorizationRequest(HttpServletRequest request) { return CookieUtils.getCookie(request, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME) - .map(cookie -> CookieUtils.deserialize(cookie, OAuth2AuthorizationRequest.class)) + .flatMap(cookie -> CookieUtils.deserialize(cookie, OAuth2AuthorizationRequest.class)) .orElse(null); } From bcd182c9549aa5c1d68b752bf7751ea1b4cab7bb Mon Sep 17 00:00:00 2001 From: jin2304 Date: Thu, 2 Apr 2026 01:29:48 +0900 Subject: [PATCH 10/22] =?UTF-8?q?feat(auth):=20=EB=A6=AC=ED=94=84=EB=A0=88?= =?UTF-8?q?=EC=8B=9C=20=ED=86=A0=ED=81=B0=20=EB=B3=B4=EC=95=88=20=EA=B3=A0?= =?UTF-8?q?=EB=8F=84=ED=99=94=20-=20=EB=A9=B1=EB=93=B1=EC=84=B1=20?= =?UTF-8?q?=EB=B3=B4=EC=9E=A5=20=EB=B0=8F=20=EC=84=B8=EC=85=98=EB=B3=84=20?= =?UTF-8?q?=EB=B2=84=EC=A0=84=20=EA=B4=80=EB=A6=AC=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 기존 RTR 체계에 세션 ID 및 버전 관리 방식을 도입하여 보안성 강화 - 멱등성(Idempotency) 보장: 유예 기간(Grace Period) 내 중복 요청 시 직전 발급 토큰을 재반환하여 네트워크 불안정 대응 - 토큰 영속성 레이어(MyBatis DAO/Mapper) 및 만료 토큰 정리 스케줄러 통합 --- .../auth/controller/AuthController.java | 6 +- .../auth/dao/MybatisRefreshTokenDao.java | 21 +- .../auth/service/AuthServiceImpl.java | 239 ++++++++++++------ .../service/RefreshTokenCleanupScheduler.java | 2 +- .../web/SearchWeb/member/domain/Member.java | 2 +- .../service/CustomOAuth2MemberService.java | 2 - .../resources/application-prod.properties | 2 +- src/main/resources/db/init_postgres.sql | 5 + .../resources/mapper/refresh-token-mapper.xml | 44 +++- 9 files changed, 234 insertions(+), 89 deletions(-) diff --git a/src/main/java/com/web/SearchWeb/auth/controller/AuthController.java b/src/main/java/com/web/SearchWeb/auth/controller/AuthController.java index 9c392c9..bd39ec9 100644 --- a/src/main/java/com/web/SearchWeb/auth/controller/AuthController.java +++ b/src/main/java/com/web/SearchWeb/auth/controller/AuthController.java @@ -57,8 +57,7 @@ public ResponseEntity> refresh( throw AuthException.of(AuthErrorCode.AUTH_REFRESH_TOKEN_NOT_FOUND); } - log.debug("[RefreshController] refreshToken cookie received, prefix: {}", - refreshToken.substring(0, Math.min(12, refreshToken.length()))); + log.debug("[RefreshController] refreshToken cookie received"); AuthResponses.TokenPair tokenPair = authService.refresh(refreshToken); @@ -71,6 +70,9 @@ public ResponseEntity> refresh( .build(); return ResponseEntity.ok() + // 보안 조치: Access Token이 포함된 응답이 브라우저나 중간 프록시(CDN 등)에 저장되지 않도록 설정 + .header("Cache-Control", "no-store, no-cache, must-revalidate") + .header("Pragma", "no-cache") // HTTP 1.0 하위 호환성 (구형 브라우저 대응) .header("Set-Cookie", cookie.toString()) .body(ApiResponse.success(response)); } diff --git a/src/main/java/com/web/SearchWeb/auth/dao/MybatisRefreshTokenDao.java b/src/main/java/com/web/SearchWeb/auth/dao/MybatisRefreshTokenDao.java index 81e0b62..62a175e 100644 --- a/src/main/java/com/web/SearchWeb/auth/dao/MybatisRefreshTokenDao.java +++ b/src/main/java/com/web/SearchWeb/auth/dao/MybatisRefreshTokenDao.java @@ -5,6 +5,9 @@ import org.apache.ibatis.session.SqlSession; import org.springframework.stereotype.Repository; +import java.util.HashMap; +import java.util.Map; + /** * 리프레시 토큰 데이터 접근 객체(DAO) 인터페이스 */ @@ -20,7 +23,6 @@ public class MybatisRefreshTokenDao implements RefreshTokenDao { */ @Override public void insertRefreshToken(RefreshToken refreshToken) { - sqlSession.insert(NAMESPACE + "insertRefreshToken", refreshToken); } @@ -40,6 +42,17 @@ public RefreshToken findByTokenHashForUpdate(String tokenHash) { return sqlSession.selectOne(NAMESPACE + "findByTokenHashForUpdate", tokenHash); } + /** + * 세션 ID + 버전으로 후속 토큰 조회 + */ + @Override + public RefreshToken findBySessionIdAndVersion(String sessionId, int version) { + Map params = new HashMap<>(); + params.put("sessionId", sessionId); + params.put("version", version); + return sqlSession.selectOne(NAMESPACE + "findBySessionIdAndVersion", params); + } + /** * 특정 토큰 해시값 삭제 */ @@ -65,10 +78,10 @@ public void deleteExpired() { } /** - * 토큰 로테이션 시 갱신 시간 업데이트 + * 토큰 로테이션 시 후속 버전 / grace 정보 저장 */ @Override - public void updateRotatedAt(String tokenHash) { - sqlSession.update(NAMESPACE + "updateRotatedAt", tokenHash); + public int markRotated(RefreshToken refreshToken) { + return sqlSession.update(NAMESPACE + "markRotated", refreshToken); } } diff --git a/src/main/java/com/web/SearchWeb/auth/service/AuthServiceImpl.java b/src/main/java/com/web/SearchWeb/auth/service/AuthServiceImpl.java index 67cbd78..b307a39 100644 --- a/src/main/java/com/web/SearchWeb/auth/service/AuthServiceImpl.java +++ b/src/main/java/com/web/SearchWeb/auth/service/AuthServiceImpl.java @@ -6,19 +6,23 @@ import com.web.SearchWeb.auth.error.AuthErrorCode; import com.web.SearchWeb.auth.error.AuthException; import com.web.SearchWeb.config.jwt.JwtUtils; +import com.web.SearchWeb.config.security.SecurityUtils; import com.web.SearchWeb.member.dao.MemberDao; import com.web.SearchWeb.member.domain.Member; -import com.web.SearchWeb.config.security.SecurityUtils; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.UUID; /** - * 인증 관련 비즈니스 로직을 처리하는 서비스. - * - JWT 토큰 발급, 갱신, 로그아웃 및 사용자 정보 조회 처리 + * 인증 관련 비즈니스 로직을 처리하는 서비스 구현체. + * - JWT(Access/Refresh Token) 발급 및 로테이션 관리. + * - 세션 ID와 버전 번호를 이용해 토큰 재사용 및 복제(Cloning) 방지. + * - Grace Period(유예 기간)를 통한 안정적인 토큰 갱신 보장. */ @Slf4j @Service @@ -26,6 +30,7 @@ @Transactional public class AuthServiceImpl implements AuthService { + private static final long REFRESH_TOKEN_GRACE_SECONDS = 10L; // 토큰 로테이션 시 중복 요청(Race Condition)을 허용할 유예 시간 (초 단위) private final JwtUtils jwtUtils; private final RefreshTokenDao refreshTokenDao; private final MemberDao memberDao; @@ -33,106 +38,111 @@ public class AuthServiceImpl implements AuthService { /** - * 토큰 발급 (Access/Refresh Token 생성 및 저장) - */ - @Override - public AuthResponses.TokenPair issueTokens(Long memberId, String role) { - String accessToken = jwtUtils.generateAccessToken(memberId, role); - String refreshToken = createAndSaveRefreshToken(memberId); - - return AuthResponses.TokenPair.builder() - .accessToken(accessToken) - .refreshToken(refreshToken) - .build(); - } - - - /** - * OAuth2 로그인 성공 후 Refresh Token만 발급 - * - 기존 토큰 정리 후 신규 발급 (고아 토큰 방지) - * - Access Token은 이후 프론트엔드의 /refresh 호출로 발급 + * OAuth2 로그인 성공 후 Refresh Token 최초 발급 + * - 보안을 위해 동일 회원의 기존 모든 토큰을 무효화(삭제)한 뒤 새로 발급. */ @Override public String issueRefreshToken(Long memberId) { - // 기존 토큰 정리 (같은 사용자가 반복 로그인 시 고아 토큰 누적 방지) + // 신규 로그인 시도 - 기존 토큰 정리 후 새 세션 시 refreshTokenDao.deleteByMemberId(memberId); - String token = createAndSaveRefreshToken(memberId); - return token; - } - - - /** - * Refresh Token 생성 및 DB 저장 (공통 로직) - */ - private String createAndSaveRefreshToken(Long memberId) { - String refreshToken = jwtUtils.generateRefreshToken(memberId); - String tokenHash = SecurityUtils.hashToken(refreshToken); - refreshTokenDao.insertRefreshToken(RefreshToken.builder() - .memberId(memberId) - .tokenHash(tokenHash) - .expiresAt(Instant.now().plusMillis(jwtProperties.refreshTokenExpiry())) - .build()); - return refreshToken; + return createAndSaveNewSessionRefreshToken(memberId); } /** - * 토큰 갱신 (Refresh Token 검증 및 재발급) + * Refresh Token 갱신 + * 1. 토큰 유효성 검증 + * 2. DB 존재 여부 및 만료 확인 + * 3. 이미 회전된 토큰인 경우 Grace Period 내 멱등성 응답 처리 + * 4. 처음 갱신 시도인 경우 로테이션 수행 및 상태 기록 */ @Override public AuthResponses.TokenPair refresh(String refreshToken) { - // 1. 토큰 유효성 검증 (실패 시 JwtException 발생) + // 1. 토큰 서명 및 유효성 검증 jwtUtils.validateToken(refreshToken); - // 2. DB에서 해시로 조회 (비관적 락 적용하여 동시성 제어) + // 2. DB에서 토큰 해시로 조회 (비관적 락을 통한 동시성 확보) String hashedToken = SecurityUtils.hashToken(refreshToken); RefreshToken storedRefreshToken = refreshTokenDao.findByTokenHashForUpdate(hashedToken); if (storedRefreshToken == null) { - log.warn("[Refresh] DB에서 토큰을 찾을 수 없음 — 해시(앞8자리): {}", hashedToken.substring(0, 8)); + log.warn("[Refresh] 존재하지 않거나 무효화된 토큰 요청. 해시: {}", shortHash(hashedToken)); throw AuthException.of(AuthErrorCode.AUTH_REFRESH_TOKEN_NOT_FOUND); } - // 3. 만료 확인 - if (storedRefreshToken.getExpiresAt().isBefore(Instant.now())) { + Instant now = Instant.now(); + // 3. 만료 시간 체크 + if (storedRefreshToken.getExpiresAt().isBefore(now)) { + log.warn("[Refresh] 기간 만료된 토큰. 해시: {}", shortHash(hashedToken)); refreshTokenDao.deleteByTokenHash(hashedToken); throw AuthException.of(AuthErrorCode.AUTH_TOKEN_EXPIRED); } - // 4. 기존 토큰 갱신/삭제 처리 (Grace Period 도입) - if (storedRefreshToken.getRotatedAt() != null) { - // 이미 한 번 갱신된 토큰인 경우, 유예 기간(예: 10초) 확인 - if (storedRefreshToken.getRotatedAt().plusSeconds(10).isAfter(Instant.now())) { - // 유예 기간 내 재사용 시, 신규 토큰 발급 후 기존 rotated 토큰 즉시 삭제 (재사용 1회만 허용) - refreshTokenDao.deleteByTokenHash(hashedToken); - return issueTokens(storedRefreshToken.getMemberId(), getMemberRole(storedRefreshToken.getMemberId())); + // 4. 로테이션 이력 확인 (Grace Period 처리) + if (storedRefreshToken.getReplacedByVersion() != null) { + // [분기 A] 이미 한 번 사용되어 회전된 토큰인 경우 (중복 요청 상황) + if (storedRefreshToken.getGraceUntil() != null && !storedRefreshToken.getGraceUntil().isBefore(now)) { + log.debug("[Refresh] Grace Period 감지 - 기존 발급된 후속 토큰을 재전달합니다."); + + if (!hasText(storedRefreshToken.getSessionId())) { + log.error("[Refresh] 크리티컬: 세션 식별 정보가 손실되었습니다."); + throw AuthException.of(AuthErrorCode.AUTH_REFRESH_TOKEN_NOT_FOUND); + } + + // 부모 토큰 기록을 토대로 이미 발급되었던 후속 토큰(Successor)을 다시 찾아 반환 + RefreshToken successor = refreshTokenDao.findBySessionIdAndVersion( + storedRefreshToken.getSessionId(), + storedRefreshToken.getReplacedByVersion() + ); + + if (successor == null) { + log.warn("[Refresh] 재전달할 후속 토큰을 찾지 못했습니다."); + throw AuthException.of(AuthErrorCode.AUTH_REFRESH_TOKEN_NOT_FOUND); + } + + return buildTokenPair(successor); } - // 유예 기간 지남 -> 이미 사용된 토큰으로 간주하여 에러 처리 - log.warn("[Refresh] Grace Period 초과 — rotatedAt: {}, now: {}", storedRefreshToken.getRotatedAt(), Instant.now()); + + // 유예 기간을 초과한 재사용 시도는 실제 보안 위협으로 간주 + log.warn("[Refresh] 유효하지 않은 재사용 시도(중복갱신 시도) 거절. (ID: {})", storedRefreshToken.getId()); throw AuthException.of(AuthErrorCode.AUTH_REFRESH_TOKEN_NOT_FOUND); } - // 처음 갱신 시도 시 rotated_at 설정 (즉시 삭제하지 않음) - refreshTokenDao.updateRotatedAt(hashedToken); - - // 5. 새 토큰 쌍 발급 - Long memberId = storedRefreshToken.getMemberId(); - return issueTokens(memberId, getMemberRole(memberId)); - } + // [분기 B] 처음으로 수행되는 정상적인 로테이션 + // 5. 새 버전의 후속 토큰 생성 + String sessionId = hasText(storedRefreshToken.getSessionId()) + ? storedRefreshToken.getSessionId() + : UUID.randomUUID().toString(); // 하위 호환을 위한 폴백 + int currentVersion = storedRefreshToken.getVersion() != null ? storedRefreshToken.getVersion() : 1; + + // 다음 버전(Successor) 발급 + RefreshToken successor = createAndSaveRefreshToken( + storedRefreshToken.getMemberId(), + sessionId, + currentVersion + 1 + ); + + // 6. DB의 부모 토큰 상태를 '회전됨'으로 원자적 업데이트 + // WHERE replaced_by_version IS NULL 조건을 통해 최초 요청자만 성공함 보장 + int updatedCount = refreshTokenDao.markRotated(RefreshToken.builder() + .tokenHash(hashedToken) + .sessionId(sessionId) + .rotatedAt(now) + .replacedByVersion(successor.getVersion()) // "내 다음 버전은 이거야" 라고 기록 + .graceUntil(now.plusSeconds(REFRESH_TOKEN_GRACE_SECONDS)) // 유예 기간 설정 + .build()); - /** - * 회원의 권한(Role)을 조회하는 내부 메서드 - */ - private String getMemberRole(Long memberId) { - Member member = memberDao.findByMemberId(memberId); - if (member == null) { - throw AuthException.of(AuthErrorCode.AUTH_UNAUTHORIZED); + if (updatedCount == 0) { + // 동시에 여러 요청이 왔으나 간발의 차로 첫 번째 요청이 아닌 경우 + log.warn("[Refresh] 동시성 충돌 감지 - 이미 상태가 업데이트되었습니다."); + throw AuthException.of(AuthErrorCode.AUTH_REFRESH_TOKEN_NOT_FOUND); } - return member.getRole(); - } + return buildTokenPair(successor); + } + /** - * 로그아웃 (Refresh Token 삭제) + * 로그아웃 처리 (해당 리프레시 토큰 즉시 무효화) */ @Override public void logout(String refreshToken) { @@ -142,7 +152,7 @@ public void logout(String refreshToken) { /** - * 인증된 회원의 정보 조회 + * 인증된 사용자의 프로필 정보 조회 */ @Override public AuthResponses.MemberInfo getAuthenticatedMemberInfo(Long memberId) { @@ -157,4 +167,89 @@ public AuthResponses.MemberInfo getAuthenticatedMemberInfo(Long memberId) { .role(member.getRole()) .build(); } + + + + + // ========================================== + // 내부 헬퍼 메서드 (Private Helper Methods) + // ========================================== + + /** + * 새로운 세션 ID와 버전 1로 리프레시 토큰 생성 및 저장 + */ + private String createAndSaveNewSessionRefreshToken(Long memberId) { + RefreshToken refreshToken = createAndSaveRefreshToken(memberId, UUID.randomUUID().toString(), 1); + return toRefreshTokenValue(refreshToken); + } + + /** + * Refresh Token 생성 및 DB에 저장하고 JWT 문자열을 생성하는 핵심 로직 + */ + private RefreshToken createAndSaveRefreshToken(Long memberId, String sessionId, int version) { + // 하위 밀리초를 절삭하여 JWT 서명 생성 시 일관성 유지 + Instant issuedAt = Instant.now().truncatedTo(ChronoUnit.MILLIS); + Instant expiresAt = issuedAt.plusMillis(jwtProperties.refreshTokenExpiry()); + + // sessionId, version, 발급/만료 시각을 고정하여 JWT 생성 (나중에 같은 후속 토큰 값을 재발급 가능) + String refreshTokenValue = jwtUtils.generateRefreshToken(memberId, sessionId, version, issuedAt, expiresAt); + String tokenHash = SecurityUtils.hashToken(refreshTokenValue); + + RefreshToken refreshToken = RefreshToken.builder() + .memberId(memberId) + .sessionId(sessionId) + .version(version) + .tokenHash(tokenHash) + .createdAt(issuedAt) + .expiresAt(expiresAt) + .build(); + + refreshTokenDao.insertRefreshToken(refreshToken); + return refreshToken; + } + + /** + * 도메인 개체(RefreshToken) 정보를 기반으로 JWT 토큰 문자열 재생성 + */ + private String toRefreshTokenValue(RefreshToken refreshToken) { + return jwtUtils.generateRefreshToken( + refreshToken.getMemberId(), + refreshToken.getSessionId(), + refreshToken.getVersion(), + refreshToken.getCreatedAt(), + refreshToken.getExpiresAt() + ); + } + + /** + * 후속 토큰 정보를 기반으로 응답용 토큰 쌍(Pair) 구성 + */ + private AuthResponses.TokenPair buildTokenPair(RefreshToken refreshToken) { + Long memberId = refreshToken.getMemberId(); + String role = getMemberRole(memberId); + + return AuthResponses.TokenPair.builder() + .accessToken(jwtUtils.generateAccessToken(memberId, role)) + .refreshToken(toRefreshTokenValue(refreshToken)) + .build(); + } + + /** + * 회원의 권한(Role) 조회 + */ + private String getMemberRole(Long memberId) { + Member member = memberDao.findByMemberId(memberId); + if (member == null) { + throw AuthException.of(AuthErrorCode.AUTH_UNAUTHORIZED); + } + return member.getRole(); + } + + private boolean hasText(String value) { + return value != null && !value.isBlank(); + } + + private String shortHash(String hashedToken) { + return hashedToken.substring(0, Math.min(8, hashedToken.length())); + } } diff --git a/src/main/java/com/web/SearchWeb/auth/service/RefreshTokenCleanupScheduler.java b/src/main/java/com/web/SearchWeb/auth/service/RefreshTokenCleanupScheduler.java index b955918..58e0ae8 100644 --- a/src/main/java/com/web/SearchWeb/auth/service/RefreshTokenCleanupScheduler.java +++ b/src/main/java/com/web/SearchWeb/auth/service/RefreshTokenCleanupScheduler.java @@ -19,7 +19,7 @@ public class RefreshTokenCleanupScheduler { /** * 매일 새벽 3시에 만료된 Refresh Token 삭제 */ - @Scheduled(cron = "0 0 3 * * *") + @Scheduled(cron = "0 0 3 * * *", zone = "Asia/Seoul") public void cleanupExpiredTokens() { log.info("[Scheduler] 만료된 Refresh Token 정리 시작"); refreshTokenDao.deleteExpired(); diff --git a/src/main/java/com/web/SearchWeb/member/domain/Member.java b/src/main/java/com/web/SearchWeb/member/domain/Member.java index 3e4238c..cd2d48b 100644 --- a/src/main/java/com/web/SearchWeb/member/domain/Member.java +++ b/src/main/java/com/web/SearchWeb/member/domain/Member.java @@ -14,7 +14,7 @@ @SuperBuilder @NoArgsConstructor @AllArgsConstructor -@ToString(callSuper = true) +@ToString(callSuper = true, exclude = {"passwordHash"}) public class Member extends BaseEntity { private Long memberId; // 회원 고유 ID (PK) private String email; // 이메일 (로그인/알림용, Unique) diff --git a/src/main/java/com/web/SearchWeb/member/service/CustomOAuth2MemberService.java b/src/main/java/com/web/SearchWeb/member/service/CustomOAuth2MemberService.java index 8e680ea..0d077ee 100644 --- a/src/main/java/com/web/SearchWeb/member/service/CustomOAuth2MemberService.java +++ b/src/main/java/com/web/SearchWeb/member/service/CustomOAuth2MemberService.java @@ -91,8 +91,6 @@ else if ("kakao".equals(registrationId)) { new OAuth2Error("social_login_failed"), MemberErrorCode.MEMBER_SOCIAL_LOGIN_FAILED.getMessage() ); } - // 소셜 제공자 정보가 실제로 바뀐 경우에만 최소 필드만 동기화(변경감지) - syncSocialIdentityIfChanged(existMember, socialName, socialEmail); } } else { diff --git a/src/main/resources/application-prod.properties b/src/main/resources/application-prod.properties index aad031b..c2db3c2 100644 --- a/src/main/resources/application-prod.properties +++ b/src/main/resources/application-prod.properties @@ -4,7 +4,7 @@ spring.datasource.username=${PROD_DB_USERNAME} spring.datasource.password=${PROD_DB_PASSWORD} ## Prod App settings -app.oauth2.redirect-uri=${PROD_FRONTEND_URL:http://localhost:3000}/auth/callback +app.oauth2.redirect-uri=${PROD_FRONTEND_URL}/auth/callback app.cookie.secure=${PROD_COOKIE_SECURE:true} ## Prod OAUTH2 diff --git a/src/main/resources/db/init_postgres.sql b/src/main/resources/db/init_postgres.sql index 007154c..2d40e88 100644 --- a/src/main/resources/db/init_postgres.sql +++ b/src/main/resources/db/init_postgres.sql @@ -79,10 +79,14 @@ CREATE TABLE IF NOT EXISTS "member" ( CREATE TABLE IF NOT EXISTS "refresh_token" ( "id" bigint GENERATED ALWAYS AS IDENTITY NOT NULL, "member_id" bigint NOT NULL, + "session_id" varchar(36) NOT NULL, + "version" integer NOT NULL DEFAULT 1, "token_hash" varchar(64) NOT NULL, "expires_at" timestamptz NOT NULL, "created_at" timestamptz DEFAULT now() NOT NULL, "rotated_at" timestamptz, + "replaced_by_version" integer, + "grace_until" timestamptz, CONSTRAINT pk_refresh_token PRIMARY KEY ("id"), CONSTRAINT uq_refresh_token_hash UNIQUE ("token_hash"), CONSTRAINT fk_refresh_token_member_id FOREIGN KEY ("member_id") REFERENCES "member"("member_id") ON DELETE CASCADE @@ -556,6 +560,7 @@ CREATE INDEX IF NOT EXISTS idx_member_created_at ON "member" ("created_at"); CREATE INDEX IF NOT EXISTS idx_member_deleted_at ON "member" ("deleted_at"); CREATE INDEX IF NOT EXISTS idx_refresh_token_member ON "refresh_token" ("member_id"); +CREATE INDEX IF NOT EXISTS idx_refresh_token_session_version ON "refresh_token" ("session_id", "version"); CREATE INDEX IF NOT EXISTS idx_refresh_token_hash ON "refresh_token" ("token_hash"); CREATE INDEX IF NOT EXISTS idx_refresh_token_expires ON "refresh_token" ("expires_at"); diff --git a/src/main/resources/mapper/refresh-token-mapper.xml b/src/main/resources/mapper/refresh-token-mapper.xml index e0177b0..76ec8e4 100644 --- a/src/main/resources/mapper/refresh-token-mapper.xml +++ b/src/main/resources/mapper/refresh-token-mapper.xml @@ -6,30 +6,58 @@ + + + + - INSERT INTO refresh_token (member_id, token_hash, expires_at, created_at) - VALUES (#{memberId}, #{tokenHash}, #{expiresAt}, NOW()) + INSERT INTO refresh_token ( + member_id, + session_id, + version, + token_hash, + expires_at, + created_at + ) + VALUES ( + #{memberId}, + #{sessionId}, + #{version}, + #{tokenHash}, + #{expiresAt}, + #{createdAt} + ) + + DELETE FROM refresh_token WHERE token_hash = #{tokenHash} @@ -44,10 +72,14 @@ OR (rotated_at IS NOT NULL AND rotated_at < NOW() - INTERVAL '1 minute') - + UPDATE refresh_token - SET rotated_at = NOW() + SET session_id = #{sessionId}, + rotated_at = #{rotatedAt}, + replaced_by_version = #{replacedByVersion}, + grace_until = #{graceUntil} WHERE token_hash = #{tokenHash} + AND replaced_by_version IS NULL From 7a18ae094cbf7f7b1625a1823d45e6763252cddf Mon Sep 17 00:00:00 2001 From: jin2304 Date: Thu, 2 Apr 2026 01:30:26 +0900 Subject: [PATCH 11/22] =?UTF-8?q?feat(frontend):=20=EC=9E=90=EB=8F=99=20?= =?UTF-8?q?=ED=86=A0=ED=81=B0=20=EA=B0=B1=EC=8B=A0=20=EA=B8=B0=EB=8A=A5?= =?UTF-8?q?=EC=9D=B4=20=ED=86=B5=ED=95=A9=EB=90=9C=20API=20=ED=81=B4?= =?UTF-8?q?=EB=9D=BC=EC=9D=B4=EC=96=B8=ED=8A=B8=20=EB=B0=8F=20=EC=A0=84?= =?UTF-8?q?=EC=97=AD=20=EC=83=81=ED=83=9C=20=EA=B4=80=EB=A6=AC=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fetchClient 인터셉터를 구축하여 401 에러(만료) 시 조용한 자동 토큰 갱신 수행 - Zustand 기반 authStore 연동으로 앱 전역의 인증 세션 동기화 로직 강화 - AuthProvider 내 초기 인증 확인 및 세션 유지 로직 최적화 - (Backport) 리프레시 토큰 DAO 인터페이스 최신화 반영 --- .../src/components/providers/AuthProvider.tsx | 19 +++++--- frontend/src/lib/api/fetchClient.ts | 5 ++- frontend/src/lib/store/authStore.ts | 43 ++++++++++++++++--- .../SearchWeb/auth/dao/RefreshTokenDao.java | 7 ++- 4 files changed, 58 insertions(+), 16 deletions(-) diff --git a/frontend/src/components/providers/AuthProvider.tsx b/frontend/src/components/providers/AuthProvider.tsx index da7b720..6b4ed05 100644 --- a/frontend/src/components/providers/AuthProvider.tsx +++ b/frontend/src/components/providers/AuthProvider.tsx @@ -20,6 +20,12 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const router = useRouter(); const pathname = usePathname(); + // 1. 현재 경로가 공개 경로인지 판별 (렌더링 시점과 useEffect 양쪽에서 활용) + const isPublicPath = PUBLIC_PATHS.some((path) => { + if (path === '/') return pathname === '/'; + return pathname === path || pathname.startsWith(`${path}/`); + }); + // 앱 마운트 시 단 한 번 실행 — 로그인 상태 복구 useEffect(() => { initialize(); @@ -27,20 +33,18 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { // 초기화 완료 후 미인증 + 보호 라우트 접근 시 로그인 페이지로 이동 useEffect(() => { - if (isInitializing) return; // 아직 초기화 중이면 판단 보류 - const isPublicPath = PUBLIC_PATHS.some((path) => - path === '/' ? pathname === '/' : pathname.startsWith(path), - ); + if (isInitializing) return; + if (!isAuthenticated && !isPublicPath) { - // 서버/네트워크 오류 메시지가 있으면 로그인 페이지에서 보여주기 위해 저장 if (authError) { sessionStorage.setItem('authError', authError); } router.replace('/login'); } - }, [isInitializing, isAuthenticated, pathname, authError, router]); + }, [isInitializing, isAuthenticated, pathname, authError, router, isPublicPath]); - if (isInitializing) { + // 2. 초기화 중이거나, 미인증 사용자가 보호된 경로에 있는 동안은 '로딩 중' 표시 (콘텐츠 노출 방지) + if (isInitializing || (!isAuthenticated && !isPublicPath)) { return (
@@ -51,5 +55,6 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { ); } + // 인증된 사용자만 실제 콘텐츠를 볼 수 있음 return <>{children}; } diff --git a/frontend/src/lib/api/fetchClient.ts b/frontend/src/lib/api/fetchClient.ts index be2e773..d806171 100644 --- a/frontend/src/lib/api/fetchClient.ts +++ b/frontend/src/lib/api/fetchClient.ts @@ -22,7 +22,10 @@ async function refreshAccessToken(): Promise { const json = await response.json(); useAuthStore.getState().setAccessToken(json.data.accessToken); return true; - } catch { + } catch (error) { + if (process.env.NODE_ENV === 'development') { + console.error('[Token Refresh Error]:', error); + } return false; } finally { refreshPromise = null; // 완료 후 초기화해 다음 갱신 가능하게 diff --git a/frontend/src/lib/store/authStore.ts b/frontend/src/lib/store/authStore.ts index 05b2db4..9fede70 100644 --- a/frontend/src/lib/store/authStore.ts +++ b/frontend/src/lib/store/authStore.ts @@ -32,6 +32,9 @@ interface AuthState { // Strict Mode / 중복 마운트에서도 refresh 요청이 한 번만 돌도록 잡아주는 싱글톤 Promise. let initializePromise: Promise | null = null; +let latestInitId = 0; // 최신 초기화 요청 식별자: 로그아웃 시 진행 중인 초기화를 무효화하기 위해 사용 +let initializePromiseId = 0; // 현재 실행 중인 Promise의 ID: logout 후에도 stale Promise 재사용을 방지 + export const useAuthStore = create((set, get) => ({ accessToken: null, isAuthenticated: false, @@ -45,6 +48,10 @@ export const useAuthStore = create((set, get) => ({ // 로그아웃: 인증 관련 상태를 모두 초기화 및 서버 세션 제거 logout: async () => { + latestInitId++; // 로그아웃 즉시 식별자를 증가시켜 현재 진행 중인 initialize 응답이 나중에 돌아오더라도 상태(isAuthenticated 등)를 다시 써버리는 것을 막음. + initializePromise = null; // 기존 Promise 즉시 무효화 (stale Promise 재사용 방지) + initializePromiseId = 0; + try { // 서버에 로그아웃 요청 (HttpOnly 쿠키의 Refresh Token 삭제 목적) // 인증 쿠키는 백엔드가 발급/삭제하므로 로그아웃도 백엔드 직통으로 보낸다. @@ -61,6 +68,7 @@ export const useAuthStore = create((set, get) => ({ isAuthenticated: false, member: null, authError: null, + isInitializing: false, }); } }, @@ -68,13 +76,21 @@ export const useAuthStore = create((set, get) => ({ /** * 앱 시작 시 호출 — 쿠키의 Refresh Token으로 로그인 상태를 복구한다. * 중복 호출 방지를 위해 싱글톤 Promise 패턴(initializePromise)을 사용한다. + * logout 호출 시 진행 중인 초기화를 무효화하고 stale write를 방지한다. */ initialize: async () => { - // 이미 진행 중인 초기화가 있으면 같은 Promise 재사용 (동시 호출 방지) - if (initializePromise) return initializePromise; // 이미 인증 완료 상태면 재초기화 불필요 (Strict Mode 이중 실행 방지) if (get().isAuthenticated) return; + // 진행 중인 Promise가 있고, 그게 현재 버전이면 재사용 (동시 호출 방지) + if (initializePromise && initializePromiseId === latestInitId) { + return initializePromise; + } + + // 현재 요청의 고유 식별자 저장 + const myInitId = latestInitId; + initializePromiseId = myInitId; + initializePromise = (async () => { set({ isInitializing: true }); try { @@ -86,6 +102,9 @@ export const useAuthStore = create((set, get) => ({ credentials: 'include', // 쿠키를 요청에 포함 }); + // 응답이 왔을 때, 그 사이에 로그아웃이 호출되었는지(ID가 바뀌어있는지) 체크 + if (myInitId !== latestInitId) return; + if (!refreshResponse.ok) { // 401/403 → 정상적인 만료 또는 미로그인 → 메시지 없이 조용히 로그인 페이지로 // 5xx → 서버 문제 → 사용자에게 안내 메시지 표시 @@ -99,6 +118,9 @@ export const useAuthStore = create((set, get) => ({ const refreshData = await refreshResponse.json(); const accessToken = refreshData.data.accessToken; + + // 토큰 저장 전 다시 한 번 체크 (json 파싱 시간 등 고려) + if (myInitId !== latestInitId) return; set({ accessToken }); // 2단계: 발급된 Access Token으로 내 정보 조회 @@ -109,27 +131,36 @@ export const useAuthStore = create((set, get) => ({ credentials: 'include', }); + // 회원 정보 조회 응답 후 최종 체크 + if (myInitId !== latestInitId) return; + if (memberResponse.ok) { const memberData = await memberResponse.json(); set({ member: memberData.data, isAuthenticated: true, authError: null, + isInitializing: false, }); } else { // 내 정보 조회 실패 → 인증 불완전으로 처리 - set({ isAuthenticated: false, accessToken: null }); + set({ isAuthenticated: false, accessToken: null, isInitializing: false }); } } catch { - // fetch 자체가 실패한 경우 → 네트워크 단절 등 + // 에러 발생 시에도 최종 체크 후 상태 변경 + if (myInitId !== latestInitId) return; set({ isAuthenticated: false, accessToken: null, authError: '인터넷 연결을 확인해주세요.', + isInitializing: false, }); } finally { - set({ isInitializing: false }); - initializePromise = null; + // 본인의 작업이 유효할 때만 Promise 정리 (stale Promise 재사용 방지) + if (initializePromiseId === myInitId) { + initializePromise = null; + initializePromiseId = 0; + } } })(); diff --git a/src/main/java/com/web/SearchWeb/auth/dao/RefreshTokenDao.java b/src/main/java/com/web/SearchWeb/auth/dao/RefreshTokenDao.java index c38d01d..d55d63b 100644 --- a/src/main/java/com/web/SearchWeb/auth/dao/RefreshTokenDao.java +++ b/src/main/java/com/web/SearchWeb/auth/dao/RefreshTokenDao.java @@ -13,6 +13,9 @@ public interface RefreshTokenDao { // 토큰 해시값으로 리프레시 토큰 조회 (비관적 락 적용) RefreshToken findByTokenHashForUpdate(String tokenHash); + // 세션 ID + 버전으로 후속 토큰 조회 + RefreshToken findBySessionIdAndVersion(String sessionId, int version); + // 특정 토큰 해시값 삭제 void deleteByTokenHash(String tokenHash); @@ -22,6 +25,6 @@ public interface RefreshTokenDao { // 만료된 토큰 일괄 삭제 void deleteExpired(); - // 토큰 로테이션 시 갱신 시간 업데이트 - void updateRotatedAt(String tokenHash); + // 토큰 로테이션 시 후속 버전 / grace 정보 저장 + int markRotated(RefreshToken refreshToken); } From 94b67ad840cca84d80af405bf944a3f2260032d2 Mon Sep 17 00:00:00 2001 From: jin2304 Date: Thu, 2 Apr 2026 01:30:54 +0900 Subject: [PATCH 12/22] =?UTF-8?q?refactor(frontend):=20=EC=8B=A0=EA=B7=9C?= =?UTF-8?q?=20=EC=9D=B8=EC=A6=9D=20=EB=AA=A8=EB=8D=B8=EC=97=90=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98=ED=95=9C=20API=20=EB=AA=A8=EB=93=88=20=EA=B3=A0?= =?UTF-8?q?=EB=8F=84=ED=99=94=20=EB=B0=8F=20UI=20=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=95=84=EC=9B=83=20=EC=A1=B0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - folderApi, tagApi 등 개별 API 모듈의 사용자 식별자(memberId) 바인딩 리팩토링 - Sidebar 및 쿼리 관리(QueryProvider) 내 인증 상태 기반 렌더링 최적화 - 백엔드 연동 환경 설정(backend.ts) 최신화 --- frontend/src/components/layout/Sidebar.tsx | 7 +++++-- frontend/src/components/providers/QueryProvider.tsx | 11 +++++++++++ frontend/src/lib/api/folderApi.ts | 7 +++++-- frontend/src/lib/api/tagApi.ts | 7 +++++-- frontend/src/lib/config/backend.ts | 8 ++++++++ 5 files changed, 34 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index c71e2c1..f775bb0 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -50,8 +50,11 @@ export function Sidebar() {