본문으로 바로가기

스프링 JWT 적용

category Flutter 2024. 11. 20. 14:50

BoardResponse

package com.tenco.blog_jpa_step4.board;

import com.tenco.blog_jpa_step4.reply.Reply;
import com.tenco.blog_jpa_step4.user.User;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;

import java.util.ArrayList;
import java.util.List;

public class BoardResponse {

    @Getter
    @Setter
    public static class DTO {
        private int id;
        private String title;
        private String content;

        // 게시글의 기본 정보를 담은 생성자
        public DTO(Board board) {
            this.id = board.getId();
            this.title = board.getTitle();
            this.content = board.getContent();
        }
    }

    // 게시글 상세보기 화면을 위한 DTO 클래스
    @Getter
    @Setter
    @ToString
    public static class DetailDTO {
        private int id;
        private String title;
        private String content;
        private int userId;
        private String username; // 게시글 작성자 이름
        private boolean isOwner; // 현재 사용자가 작성자인지 여부
        private List<ReplyDTO> replies = new ArrayList<>(); // 댓글 목록

        // 게시글 상세 정보를 담은 생성자
        public DetailDTO(Board board, User sessionUser) {
            this.id = board.getId();
            this.title = board.getTitle();
            this.content = board.getContent();
            this.userId = board.getUser().getId();
            this.username = board.getUser().getUsername(); // join 해서 가져왔음
            this.isOwner = false;
            if(sessionUser != null){
                if(sessionUser.getId() == userId) isOwner = true;
            }

            // 게시글의 댓글 목록을 ReplyDTO로 변환하여 설정
            for (Reply reply : board.getReplies()) {
                this.replies.add(new ReplyDTO(reply, sessionUser));
            }
            //this.replies = board.getReplies().stream().map(reply -> new ReplyDTO(reply, sessionUser)).toList();
        }

        @Getter
        @Setter
        public static class ReplyDTO {
            private int id;
            private String comment;
            private int userId; // 댓글 작성자 아이디
            private String username; // 댓글 작성자 이름
            private boolean isOwner; // 현재 사용자가 댓글 작성자인지 여부

            // 댓글의 기본 정보를 담은 생성자
            public ReplyDTO(Reply reply, User sessionUser) {
                this.id = reply.getId(); // lazy loading 발동
                this.comment = reply.getComment();
                this.userId = reply.getUser().getId();
                this.username = reply.getUser().getUsername(); // lazy loading 발동 (in query)
                this.isOwner = sessionUser != null && sessionUser.getId().equals(userId);
            }
        }
    }

    // 게시글 목록보기 화면을 위한 DTO 클래스
    @Getter
    @Setter
    public static class ListDTO {
        private int id;
        private String title;

        // 게시글의 기본 정보를 담은 생성자
        public ListDTO(Board board) {
            this.id = board.getId();
            this.title = board.getTitle();
        }
    }
}

BoardController

 

package com.tenco.blog_jpa_step4.board;

import com.auth0.jwt.exceptions.TokenExpiredException;
import com.tenco.blog_jpa_step4.commom.utils.ApiUtil;
import com.tenco.blog_jpa_step4.commom.utils.Define;
import com.tenco.blog_jpa_step4.commom.utils.JwtUtil;
import com.tenco.blog_jpa_step4.user.User;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;

/**
 * BoardController 블로그 게시글과 관련된 HTTP 요청을 처리하는 REST 컨트롤러 클래스입니다.
 */
@Slf4j
@RequiredArgsConstructor
@RestController // @Controller -> @RestController로 변경
public class BoardController {

    private final BoardService boardService; // BoardService 주입

    /**
     * 게시글 목록 조회 처리 메서드
     * 요청 주소: **GET <http://localhost:8080/api/boards**>
     *
     * @return 게시글 목록 DTO 리스트
     */
    @GetMapping({"/boards", "/"})
    public ResponseEntity<List<BoardResponse.ListDTO>> getAllBoards() {
        List<BoardResponse.ListDTO> boardList = boardService.getAllBoards();
        return ResponseEntity.ok(boardList);
    }

    /**
     * 게시글 상세보기 처리 메서드
     * 요청 주소: **GET <http://localhost:8080/boards/{id}**>
     *
     * @param id 게시글의 ID
     * @param request HTTP 요청 객체
     * @return 게시글 상세보기 DTO
     */
    @GetMapping("/boards/{id}")
    public ResponseEntity<ApiUtil<BoardResponse.DetailDTO>> getBoardDetail(@PathVariable(name = "id") Integer id, HttpServletRequest request) {
        User sessionUser = null;

        // api 경로가 아니기 때문에 JWT 확인 해야 함
        String authorizationHeader = request.getHeader(Define.AUTHORIZATION);
        if (authorizationHeader != null && authorizationHeader.startsWith(Define.BEARER)) {
            String token = authorizationHeader.replace(Define.BEARER, "");
            try {
                sessionUser = JwtUtil.verify(token);
            } catch (TokenExpiredException e) {
                return ResponseEntity.status(401).body(new ApiUtil<>(401, "토큰이 만료되었습니다. 다시 로그인해주세요."));
            } catch (Exception e) {
                return ResponseEntity.status(401).body(new ApiUtil<>(401, "유효하지 않은 토큰입니다."));
            }
        }

        // 게시글 상세보기 로직
        BoardResponse.DetailDTO boardDetail = boardService.getBoardDetails(id, sessionUser);
        return ResponseEntity.ok(new ApiUtil<>(boardDetail));
    }

    /**
     * 게시글 작성 처리 메서드
     * 요청 주소: POST <http://localhost:8080/api/boards>
     * @param dto     게시글 작성 요청 DTO
     * @return 작성된 게시글 DTO
     */
    @PostMapping("/api/boards")
    public ResponseEntity<?> createBoard(@RequestBody BoardRequest.SaveDTO reqDTO, HttpServletRequest request) {
        User sessionUser = (User) request.getAttribute(Define.SESSION_USER); // 인터셉터에서 설정한 사용자 정보 가져오기

        // 게시글 작성 서비스 호출
        BoardResponse.DTO savedBoard = boardService.createBoard(reqDTO, sessionUser);
        return ResponseEntity.ok(new ApiUtil<>(savedBoard));
    }

    /**
     * 게시글 수정 처리 메서드
     * 요청 주소: PUT <http://localhost:8080/api/boards/{id}>
     * @param id        수정할 게시글의 ID
     * @param updateDTO 수정된 데이터를 담은 DTO
     * @return 수정된 게시글 DTO
     */
    @PutMapping("/api/boards/{id}")
    public ResponseEntity<?> updateBoard(@PathVariable(name = "id") Integer id,
                                         BoardRequest.UpdateDTO updateDTO, HttpServletRequest request) {
        User sessionUser = (User) request.getAttribute(Define.SESSION_USER); // 인터셉터에서 설정한 사용자 정보 가져오기

        // 인증 사용자 여부 검사
        if (sessionUser == null) {
            return ResponseEntity.status(401).build(); // 인증되지 않은 경우 401 반환
        }

        // 게시글 수정 서비스 호출
        BoardResponse.DTO updatedBoard = boardService.updateBoard(id, sessionUser.getId(), updateDTO);
        return ResponseEntity.ok(new ApiUtil<>(updatedBoard));
    }

    /**
     * 게시글 삭제 처리 메서드
     * 요청 주소: **DELETE <http://localhost:8080/api/boards/{id}**>
     * @param id      삭제할 게시글의 ID
     * @return 성공적으로 삭제된 경우 204 No Content 응답
     */
    @DeleteMapping("/api/boards/{id}")
    public ResponseEntity<?> deleteBoard(@PathVariable(name = "id") Integer id, HttpServletRequest request) {
        User sessionUser = (User) request.getAttribute(Define.SESSION_USER); // 인터셉터에서 설정한 사용자 정보 가져오기

        // 세션 유효성 검증
        if (sessionUser == null) {
            return ResponseEntity.status(401).build(); // 인증되지 않은 경우 401 반환
        }

        // 게시글 삭제 서비스 호출
        boardService.deleteBoard(id, sessionUser.getId());
        // 삭제 후 응답
        return ResponseEntity.ok(new ApiUtil<>(null));
    }
}

BoardService

package com.tenco.blog_jpa_step4.board;

import com.tenco.blog_jpa_step4.commom.errors.Exception403;
import com.tenco.blog_jpa_step4.commom.errors.Exception404;
import com.tenco.blog_jpa_step4.user.User;
import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;

import java.util.List;

@RequiredArgsConstructor
@Service // 서비스 계층으로 등록
public class BoardService {

    private final BoardJPARepository boardJPARepository;

    /**
     * 게시글 목록 조회 서비스
     * @return 게시글 목록의 DTO 리스트
     */
    public List<BoardResponse.ListDTO> getAllBoards() {
        Sort sort = Sort.by(Sort.Direction.DESC, "id");
        List<Board> boards = boardJPARepository.findAll(sort);
        return boards.stream().map(BoardResponse.ListDTO::new).toList();
    }

    /**
     * 게시글 상세 조회 서비스
     * @param boardId 조회할 게시글의 ID
     * @param sessionUser 현재 세션 사용자 정보
     * @return 게시글 상세 정보의 DTO
     */
    // 메서드 종료까지 영속성 컨텍스 즉 connection 열어 있음
    // @Transactional 없는 경우 오류 발생 (LazyInitializationException)
    @Transactional
    public BoardResponse.DetailDTO getBoardDetails(int boardId, User sessionUser) {
        Board board = boardJPARepository.findByIdJoinUser(boardId)
                .orElseThrow(() -> new Exception404("게시글을 찾을 수 없습니다"));

        BoardResponse.DetailDTO boardDetail = new BoardResponse.DetailDTO(board, sessionUser);
        System.out.println(boardDetail.toString());
        return boardDetail;
    }

    /**
     * 게시글 작성 서비스
     * @param reqDTO 게시글 작성 요청 DTO
     * @param sessionUser 현재 세션 사용자 정보
     * @return 작성된 게시글의 DTO
     */
    @Transactional
    public BoardResponse.DTO createBoard(BoardRequest.SaveDTO reqDTO, User sessionUser) {
        Board savedBoard = boardJPARepository.save(reqDTO.toEntity(sessionUser));
        return new BoardResponse.DTO(savedBoard);
    }

    /**
     * 게시글 수정 서비스
     * @param boardId 수정할 게시글의 ID
     * @param sessionUserId 현재 세션 사용자 ID
     * @param reqDTO 수정된 게시글 정보의 DTO
     * @return 수정된 게시글의 DTO
     * @throws Exception404 게시글을 찾을 수 없는 경우 발생
     * @throws Exception403 권한이 없는 사용자가 수정하려는 경우 발생
     */
    @Transactional
    public BoardResponse.DTO updateBoard(int boardId, int sessionUserId, BoardRequest.UpdateDTO reqDTO) {
        Board board = boardJPARepository.findById(boardId)
                .orElseThrow(() -> new Exception404("게시글을 찾을 수 없습니다"));

        if (sessionUserId != board.getUser().getId()) {
            throw new Exception403("게시글을 수정할 권한이 없습니다");
        }

        board.setTitle(reqDTO.getTitle());
        board.setContent(reqDTO.getContent());
        return new BoardResponse.DTO(board);
    }

    /**
     * 게시글 삭제 서비스
     * @param boardId 삭제할 게시글의 ID
     * @param sessionUserId 현재 세션 사용자 ID
     * @throws Exception404 게시글을 찾을 수 없는 경우 발생
     * @throws Exception403 권한이 없는 사용자가 삭제하려는 경우 발생
     */
    @Transactional
    public void deleteBoard(int boardId, int sessionUserId) {
        Board board = boardJPARepository.findById(boardId)
                .orElseThrow(() -> new Exception404("게시글을 찾을 수 없습니다"));

        if (sessionUserId != board.getUser().getId()) {
            throw new Exception403("게시글을 삭제할 권한이 없습니다");
        }

        boardJPARepository.deleteById(boardId);
    }
}

BoardRepository

package com.tenco.blog_jpa_step4.board;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import java.util.Optional;

// BoardJPARepository는 Board 엔티티에 대한 CRUD 기능을 제공한다.
public interface BoardJPARepository extends JpaRepository<Board, Integer> {

    // JPQL - Fetch JOIN 사용
    // 커스텀 쿼리 메서드: Board와 User를 조인하여 특정 Board 조회
    @Query("select b from Board b join fetch b.user u where b.id = :id")
    Optional<Board> findByIdJoinUser(@Param("id") int id);
}

UserController

package com.tenco.blog_v3.board;

import com.tenco.blog_jpa_step4.commom.errors.Exception403;
import com.tenco.blog_jpa_step4.commom.errors.Exception404;
import com.tenco.blog_jpa_step4.commom.utils.ApiUtil;
import com.tenco.blog_jpa_step4.commom.utils.Define;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

/**
 * UserController 사용자(User)와 관련된 HTTP 요청을 처리하는 컨트롤러 계층입니다.
 */
@Slf4j
@RestController
@RequiredArgsConstructor
public class UserController {

    private final UserService userService; // UserService 주입

    // 회원 정보 조회 -- > 추후 api/users/{id} 로 수정
    @GetMapping("/api/users/{id}")
    public ResponseEntity<ApiUtil<UserResponse.DTO>> userinfo(@PathVariable(name = "id") Integer id,
                                                              HttpServletRequest request) {
        // 인터셉터에서 설정한 사용자 정보를 가져오기
        User sessionUser = (User) request.getAttribute(Define.SESSION_USER);

        if (sessionUser == null) {
            throw new Exception404("사용자를 찾을 수 없습니다."); // 사용자가 존재하지 않는 경우 예외 던지기
        }

        UserResponse.DTO resDTO = userService.findUserById(sessionUser.getId());
        return ResponseEntity.ok(new ApiUtil<>(resDTO));
    }

    /**
     * 사용자 정보 수정 요청 처리
     *
     * @param id      수정할 사용자 ID
     * @param reqDTO  수정된 사용자 정보 DTO
     * @return 수정된 사용자 정보의 DTO
     */
    @PutMapping("/api/users/{id}")
    public ResponseEntity<ApiUtil<UserResponse.DTO>> updateUser(@PathVariable int id,
                                                                @RequestBody UserRequest.UpdateDTO reqDTO,
                                                                HttpServletRequest request) {

        // 인터셉터에서 설정한 사용자 정보를 가져오기
        User sessionUser = (User) request.getAttribute(Define.SESSION_USER);

        if (sessionUser == null) {
            throw new Exception404("사용자를 찾을 수 없습니다."); // 사용자가 존재하지 않는 경우 예외 던지기
        }

        if (sessionUser.getId() != id) {
            throw new Exception403("해당 사용자를 수정할 권한이 없습니다."); // 권한 없음 예외 던지기
        }

        UserResponse.DTO resDTO = userService.updateUser(id, reqDTO, sessionUser);
        return ResponseEntity.ok(new ApiUtil<>(resDTO));
    }

    @PostMapping("/join")
    public ResponseEntity<ApiUtil<UserResponse.DTO>> join(@RequestBody UserRequest.JoinDTO reqDTO) {
        UserResponse.DTO resDTO = userService.signUp(reqDTO);
        return ResponseEntity.ok(new ApiUtil<>(resDTO));
    }

    @PostMapping("/login")
    public ResponseEntity<?> login(@RequestBody UserRequest.LoginDTO reqDTO) {
        String jwt = userService.signIn(reqDTO);
        return ResponseEntity.ok()
                // 반드시 주의!!! Bearer 문자열 뒤에 반드시 한칸에 공백을 넣어 주세요 ~~
                .header("Authorization", "Bearer " + jwt)
                .body(new ApiUtil<>(null));
    }

    @GetMapping("/logout")
    public ResponseEntity<?> logout(HttpSession session) {
        session.invalidate();
        return ResponseEntity.ok(new ApiUtil<>(null));
    }
}

BoardService

package com.tenco.blog_v3.board;

import com.tenco.blog_jpa_step4.commom.errors.Exception400;
import com.tenco.blog_jpa_step4.commom.errors.Exception401;
import com.tenco.blog_jpa_step4.commom.errors.Exception404;
import com.tenco.blog_jpa_step4.commom.utils.JwtUtil;
import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

import java.util.Optional;

@RequiredArgsConstructor
@Service // 서비스 계층으로 등록 및 IoC
public class UserService {

    private final UserJPARepository userJPARepository;

    /**
     * 회원 정보 조회 서비스
     *
     * @param id 조회할 사용자 ID
     * @return 조회된 사용자 객체의 DTO
     * @throws Exception404 사용자를 찾을 수 없는 경우 발생
     */
    public UserResponse.DTO findUserById(int id){
        User user = userJPARepository.findById(id)
                .orElseThrow(() -> new Exception404("회원정보를 찾을 수 없습니다"));
        return new UserResponse.DTO(user);
    }

    /**
     * 회원가입 서비스
     *
     * @param reqDTO 회원가입 요청 DTO
     * @return 회원가입 완료된 사용자 정보의 DTO
     * @throws Exception400 중복된 유저네임인 경우 발생
     */
    @Transactional // 트랜잭션 관리
    public UserResponse.DTO signUp(UserRequest.JoinDTO reqDTO) {
        // 1. 유저네임 중복검사 (DB 연결이 필요한 것은 Controller 에서 작성하지 말자)
        Optional<User> userOP = userJPARepository.findByUsername(reqDTO.getUsername());

        if (userOP.isPresent()) {
            throw new Exception400("중복된 유저네임입니다");
        }
        // 2. 회원가입
        User savedUser = userJPARepository.save(reqDTO.toEntity());
        return new UserResponse.DTO(savedUser);
    }

    /**
     * 로그인 서비스
     *
     * @throws Exception401 인증 실패 시 발생
     */
    // 리턴 타입 변경
    public String signIn(UserRequest.LoginDTO reqDTO) {
        User user = userJPARepository.findByUsernameAndPassword(reqDTO.getUsername(), reqDTO.getPassword())
                .orElseThrow(() -> new Exception401("인증되지 않았습니다"));


        // session.setAttribute("sessionUser", user); // 세션에 사용자 정보 저장
        // jwt 문자열 반환 처리
        return JwtUtil.create(user); // 로그인 시 이메일 정보 제외
    }

    /**
     * 회원 정보 수정 서비스
     *
     * @param id 수정할 사용자 ID
     * @param reqDTO 수정된 사용자 정보 DTO
     * @return 수정된 사용자 객체의 DTO
     * @throws Exception404 사용자를 찾을 수 없는 경우 발생
     */
    @Transactional // 트랜잭션 관리
    public UserResponse.DTO updateUser(int id, UserRequest.UpdateDTO reqDTO,  User sessionUser) {
        // 1. 사용자 조회 및 예외 처리
        User user = userJPARepository.findById(sessionUser.getId())
                .orElseThrow(() -> new Exception404("회원정보를 찾을 수 없습니다"));
        // 2. 사용자 정보 수정
        user.setPassword(reqDTO.getPassword());
        user.setEmail(reqDTO.getEmail());
        // 더티 체킹을 통해 변경 사항이 자동으로 반영됩니다.

        return new UserResponse.DTO(user);
    }
}