게시판 Lv4 - Spring Security 적용

박영준·2023년 7월 8일
1

Spring

목록 보기
39/58

Spring Lv4 과제

Spring Security 만 적용한 것이므로, API 명세서와 ERD 는 생략한다

1. 기능 요구 사항

  1. 회원 가입 API

    • username, password를 Client에서 전달받기
    • username은 최소 4자 이상, 10자 이하이며 알파벳 소문자(a~z), 숫자(0~9)로 구성되어야 한다.
    • password는 최소 8자 이상, 15자 이하이며 알파벳 대소문자(a~z, A~Z), 숫자(0~9)로 구성되어야 한다.
    • DB에 중복된 username이 없다면 회원을 저장하고 Client 로 성공했다는 메시지, 상태코드 반환하기
    • 회원 권한 부여하기 (ADMIN, USER) - ADMIN 회원은 모든 게시글, 댓글 수정 / 삭제 가능
  2. 로그인 API

    • username, password를 Client에서 전달받기
    • DB에서 username을 사용하여 저장된 회원의 유무를 확인하고 있다면 password 비교하기
    • 로그인 성공 시, 로그인에 성공한 유저의 정보와 JWT를 활용하여 토큰을 발급하고,
      발급한 토큰을 Header에 추가하고 성공했다는 메시지, 상태코드 와 함께 Client에 반환하기
  3. 댓글 작성 API

    • 토큰을 검사하여, 유효한 토큰일 경우에만 댓글 작성 가능
    • 선택한 게시글의 DB 저장 유무를 확인하기
    • 선택한 게시글이 있다면 댓글을 등록하고 등록된 댓글 반환하기
  4. 댓글 수정 API

    • 토큰을 검사한 후, 유효한 토큰이면서 해당 사용자가 작성한 댓글만 수정 가능
    • 선택한 댓글의 DB 저장 유무를 확인하기
    • 선택한 댓글이 있다면 댓글 수정하고 수정된 댓글 반환하기
  5. 댓글 삭제 API

    • 토큰을 검사한 후, 유효한 토큰이면서 해당 사용자가 작성한 댓글만 삭제 가능
    • 선택한 댓글의 DB 저장 유무를 확인하기
    • 선택한 댓글이 있다면 댓글 삭제하고 Client 로 성공했다는 메시지, 상태코드 반환하기
  6. 예외 처리

    • 토큰이 필요한 API 요청에서 토큰을 전달하지 않았거나 정상 토큰이 아닐 때는 "토큰이 유효하지 않습니다." 라는 에러메시지와 statusCode: 400을 Client에 반환하기
    • 토큰이 있고, 유효한 토큰이지만 해당 사용자가 작성한 게시글/댓글이 아닌 경우에는 “작성자만 삭제/수정할 수 있습니다.”라는 에러메시지와 statusCode: 400을 Client에 반환하기
    • DB에 이미 존재하는 username으로 회원가입을 요청한 경우 "중복된 username 입니다." 라는 에러메시지와 statusCode: 400을 Client에 반환하기
    • 로그인 시, 전달된 username과 password 중 맞지 않는 정보가 있다면 "회원을 찾을 수 없습니다."라는 에러메시지와 statusCode: 400을 Client에 반환하기
  7. 전체 게시글 목록 조회 API

    • 제목, 작성자명(username), 작성 내용, 작성 날짜를 조회하기
    • 작성 날짜 기준 내림차순으로 정렬하기
    • 각각의 게시글에 등록된 모든 댓글을 게시글과 같이 Client에 반환하기
    • 댓글은 작성 날짜 기준 내림차순으로 정렬하기
  8. 게시글 작성 API

    • 토큰을 검사하여, 유효한 토큰일 경우에만 게시글 작성 가능 ⇒ Spring Security를 사용하여 토큰 검사 및 인증하기!
    • 제목, 작성자명(username), 작성 내용을 저장하고
    • 저장된 게시글을 Client 로 반환하기
  9. 선택한 게시글 조회 API

    • 선택한 게시글의 제목, 작성자명(username), 작성 날짜, 작성 내용을 조회하기
      (검색 기능이 아닙니다. 간단한 게시글 조회만 구현해주세요.)
    • 선택한 게시글에 등록된 모든 댓글을 선택한 게시글과 같이 Client에 반환하기
    • 댓글은 작성 날짜 기준 내림차순으로 정렬하기
  10. 선택한 게시글 수정 API

    • 토큰을 검사한 후, 유효한 토큰이면서 해당 사용자가 작성한 게시글만 수정 가능 ⇒ Spring Security를 사용하여 토큰 검사 및 인증하기!
    • 제목, 작성 내용을 수정하고 수정된 게시글을 Client 로 반환하기
  11. 선택한 게시글 삭제 API

    • 토큰을 검사한 후, 유효한 토큰이면서 해당 사용자가 작성한 게시글만 삭제 가능 ⇒ Spring Security를 사용하여 토큰 검사 및 인증하기!
    • 선택한 게시글을 삭제하고 Client 로 성공했다는 메시지, 상태코드 반환하기

2. 구현

1) controller

UserController 는 Lv3 과 동일하다

BoardController

게시글 전체/선택 조회를 제외하고, 작성/수정/삭제에는 @AuthenticationPrincipal 가 사용되었다.

	// 게시글 작성
    @PostMapping("/board")
    public ResponseEntity<BoardResponseDto> createBoard(@RequestBody BoardRequestDto requestDto, @AuthenticationPrincipal UserDetailsImpl userDetails) {
        return ResponseEntity.ok(boardService.createBoard(requestDto, userDetails.getUser()));
    }
    
    ...
    
    // 게시글 수정
    @PutMapping("/board/{boardId}")
    public ResponseEntity<BoardResponseDto> updateBoard(@PathVariable Long boardId, @RequestBody BoardRequestDto requestDto, @AuthenticationPrincipal UserDetailsImpl userDetails) {
        return ResponseEntity.ok(boardService.updateBoard(boardId, requestDto, userDetails.getUser()));
    }

    // 게시글 삭제
    @DeleteMapping("/board/{boardId}")
    public ResponseEntity<MsgResponseDto> deleteBoard(@PathVariable Long boardId, @AuthenticationPrincipal UserDetailsImpl userDetails) {
        return ResponseEntity.ok(boardService.deleteBoard(boardId, userDetails.getUser()));
    }

@AuthenticationPrincipal

  • Authentication 의 Principal 에 저장된 UserDetailsImpl 을 가져올 수 있다.
  • UserDetailsImpl 에 저장된 인증된 사용자인 User 객체를 사용할 수 있다.

UserController

	// 댓글 작성
    @PostMapping("/{boardId}")
    public ResponseEntity<CommentResponseDto> createComment(@PathVariable Long boardId, @RequestBody CommentRequestDto commentRequestDto, @AuthenticationPrincipal UserDetailsImpl userDetails) {
        return ResponseEntity.ok(commentService.createComment(boardId, commentRequestDto, userDetails.getUser()));
    }

    // 댓글 수정
    @PutMapping("/{boardId}/{cmtId}")
    public ResponseEntity<CommentResponseDto> updateComment(@PathVariable Long boardId, @PathVariable Long cmtId, @RequestBody CommentRequestDto commentRequestDto, @AuthenticationPrincipal UserDetailsImpl userDetails) {
        return ResponseEntity.ok(commentService.updateComment(boardId, cmtId, commentRequestDto, userDetails.getUser()));
    }

    // 댓글 삭제
    @DeleteMapping("/{boardId}/{cmtId}")
    public ResponseEntity<MsgResponseDto> deleteComment(@PathVariable Long boardId, @PathVariable Long cmtId, @AuthenticationPrincipal UserDetailsImpl userDetails) {
        return ResponseEntity.ok(commentService.deleteComment(boardId, cmtId, userDetails.getUser()));
    }
  • BoardController 처럼 @AuthenticationPrincipal 이 적용되었다.

2) Dto

User Entity 에 구현해둔 정규 표현식을 SignupRequestDto 로 옮겼고, 댓글 작성에 commenta (댓글 내용) 만 입력하도록 수정했다.
이를 제외하고는 Lv3 과 동일하다

3) Service

Service 에서 JWT 호출 및 검증 부분은 모두 빠지고, 다른 패키지에 구현해두었다.

BoardService

	// 게시글 수정
    @Transactional
    public BoardResponseDto updateBoard(Long boardId, BoardRequestDto requestDto, User user) {

        // 게시글이 있는지 & 사용자의 권한 확인
        Board board = userService.findByBoardIdAndUser(boardId, user);

        board.update(requestDto);

        List<CommentResponseDto> commentList = new ArrayList<>();
        for (Comment comment : board.getCommentList()) {
            commentList.add(new CommentResponseDto(comment));
        }

        return new BoardResponseDto(board, commentList);
    }

    // 게시글 삭제
    public MsgResponseDto deleteBoard(Long boardId, User user) {

        // 게시글이 있는지 & 사용자의 권한 확인
        Board board = userService.findByBoardIdAndUser(boardId, user);

        boardRepository.delete(board);

        return new MsgResponseDto("게시글을 삭제했습니다.", HttpStatus.OK.value());
    }
  • Lv3 에서와 달리,
    • 사용자의 권한을 확인하는 로직을 UserService 로 옮겼다.
    • Spring Security 를 사용하므로, 더이상 username 일치 여부를 비교할 필요가 없기 때문에 이 부분을 삭제했다.

CommentService

	// 댓글 수정
    @Transactional
    public CommentResponseDto updateComment(Long boardId, Long cmtId, CommentRequestDto commentRequestDto, User user) {

        // 게시글이 있는지
        Board board = boardRepository.findById(boardId).orElseThrow(
                () -> new CustomException(NOT_FOUND_BOARD)
        );

        // 댓글이 있는지 & 사용자의 권한 확인
        Comment comment = userService.findByCmtIdAndUser(cmtId, user);

        comment.update(commentRequestDto);

        return new CommentResponseDto(comment);
    }

    // 댓글 삭제
    public MsgResponseDto deleteComment(Long boardId, Long cmtId, User user) {

        // 게시글이 있는지
        Board board = boardRepository.findById(boardId).orElseThrow (
                () -> new CustomException(NOT_FOUND_BOARD)
        );

        // 댓글이 있는지 & 사용자의 권한 확인
        Comment comment = userService.findByCmtIdAndUser(cmtId, user);

        commentRepository.delete(comment);

        return new MsgResponseDto("댓글을 삭제했습니다.", HttpStatus.OK.value());
    }

UserService

로그인은 Lv3 과 동일하다

(1)

    // 회원 가입
    public void signup(SignupRequestDto requestDto) {
        String username = requestDto.getUsername();
        String password = passwordEncoder.encode(requestDto.getPassword());

        // 회원 중복 확인
        Optional<User> checkUsername = userRepository.findByUsername(username);
        if (checkUsername.isPresent()) {
            throw new CustomException(DUPLICATED_USERNAME);
        }

        // 사용자 ROLE 확인 (admin = true 일 경우 아래 코드 수행)
        UserRoleEnum role = UserRoleEnum.USER;
        if (requestDto.isAdmin()) {
            if (!ADMIN_TOKEN.equals(requestDto.getAdminToken())) {
                throw new CustomException(NOT_MATCH_ADMIN_TOKEN);
            }

            role = UserRoleEnum.ADMIN;
        }

        // 사용자 등록 (admin = false 일 경우 아래 코드 수행)
        User user = new User(username, password, role);
        userRepository.save(user);
    }
  • 비밀번호 암호화를 추가했다.
    • 해당 유저가 회원가입 및 로그인 시에는 자신이 입력한 비밀번호를 넣으면 되지만,
    • DB 에는 암호화된 비밀번호로 저장되어 있다.

(2)

	// 사용자의 권한 확인 - 게시글
    Board findByBoardIdAndUser(Long boardId, User user) {
        Board board;

        // ADMIN
        if (user.getRole().equals(UserRoleEnum.ADMIN)) {
            board = boardRepository.findById(boardId).orElseThrow(
                    () -> new CustomException(NOT_FOUND_BOARD)
            );
        // USER
        } else {
            board = boardRepository.findByIdAndUserId(boardId, user.getId()).orElseThrow (
                    () -> new CustomException(NOT_FOUND_BOARD_OR_AUTHORIZATION)
            );
        }

        return board;
    }

    // 사용자의 권한 확인 - 댓글
    Comment findByCmtIdAndUser(Long cmtId, User user) {
        Comment comment;

        // ADMIN
        if (user.getRole().equals(UserRoleEnum.ADMIN)) {
            comment = commentRepository.findById(cmtId).orElseThrow(
                    () -> new CustomException(NOT_FOUND_COMMENT)
            );
        // USER
        } else {
            comment = commentRepository.findByIdAndUserId(cmtId, user.getId()).orElseThrow (
                    () -> new CustomException(NOT_FOUND_COMMENT_OR_AUTHORIZATION)
            );
        }

        return comment;
    }
  • BoardService 와 CommentService 에서 사용자의 권한을 확인하기 위한 메서드다.

4) Repository

Lv3 과 동일하다

5) Entity

UserRoleEnum 을 제외하곤 Lv3 과 동일하다

UserRoleEnum

public enum UserRoleEnum {
    USER(Authority.USER),  // 사용자 권한
    ADMIN(Authority.ADMIN);  // 관리자 권한

    private final String authority;

    UserRoleEnum(String authority) {
        this.authority = authority;
    }

    public String getAuthority() {
        return this.authority;
    }

    public static class Authority {
        public static final String USER = "ROLE_USER";
        public static final String ADMIN = "ROLE_ADMIN";
    }
}
  • 해당 Enum 클래스에 USER 와 ADMIN 만 입력해두었으나,
    getAuthority 메서드를 통해 UserDetails 에서 권한에 대한 정보를 받도록 변경했다.

6) Timestamped

Lv3 과 동일하다

7) config, jwt, security

WebSecurityConfig,
JwtUtil, JwtAuthenticationFilter, JwtAuthorizationFilter,
UserDetailsImpl, UserDetailsServiceImpl 에 대한 내용은 아래 링크에 정리해두었다.
참고: Spring Security - JWT 방식

Lv3 까지는 Service 단에서 각 기능마다 메서드를 호출해서 JWT 토큰을 검증했으나 Lv4 에서는 Spring Security 가 인증/인가를 해주므로 그럴 필요가 없어졌다.

9) exception

ErrorCode 를 제외하곤 Lv3 과 동일하다

ErrorCode

@Getter
@RequiredArgsConstructor
public enum ErrorCode {

    // 토큰 전달 하지 않은 경우, 정상 토큰이 아닌 경우
    INVALID_TOKEN(HttpStatus.BAD_REQUEST, "토큰이 유효하지 않습니다."),

    // 토큰이 있으며 유효한 토큰이나, 해당 사용자의 게시글/댓글이 아닌 경우 (즉, 해당 사용자의 토큰이 아닌 경우)
    NOT_FOUND_COMMENT_OR_AUTHORIZATION(HttpStatus.BAD_REQUEST, "댓글을 찾을 수 없거나 작성자만 삭제/수정할 수 있습니다."),
    NOT_FOUND_BOARD_OR_AUTHORIZATION(HttpStatus.BAD_REQUEST, "게시글을 찾을 수 없거나 작성자만 삭제/수정할 수 있습니다."),

    // DB 에 이미 존재하는 username 으로 회원가입 요청한 경우
    DUPLICATED_USERNAME(HttpStatus.BAD_REQUEST, "중복된 username 입니다"),

    // 로그인 시, username 과 password 중 일치하지 않는 정보가 있을 경우
    NOT_MATCH_INFORMATION(HttpStatus.BAD_REQUEST, "회원을 찾을 수 없습니다."),

    // DB 에 해당 게시글이 존재하지 않는 경우
    NOT_FOUND_BOARD(HttpStatus.BAD_REQUEST, "게시글을 찾을 수 없습니다."),

    // DB 에 해당 댓글이 존재하지 않는 경우
    NOT_FOUND_COMMENT(HttpStatus.BAD_REQUEST, "댓글을 찾을 수 없습니다."),

    // admin 계정으로 회원가입 시, ADMIN_TOKEN 과 일치하지 않을 경우
    NOT_MATCH_ADMIN_TOKEN(HttpStatus.BAD_REQUEST, "관리자 암호가 일치하지 않습니다.");

    private final HttpStatus httpStatus;
    private final String message;
}

각 상황에 맞춘 에러 코드를 전달하도록 수정이 있었다.

profile
개발자로 거듭나기!

1개의 댓글

comment-user-thumbnail
2023년 11월 25일

안녕하세요! 좋은 자료 감사합니다!
다름이 아니라 위 내용이랑 작성자분 깃허브에 있는 코드 참고해서 현재 회원 및 게시글 등록 연결을 구현하고 있습니다.
하지만 Postman으로 회원가입, 로그인 이후 게시글을 등록하려 하면 java.lang.NullPointerException: Cannot invoke "MyUserDetails.getMember()" because "myUserDetails" is null이라는 에러가 뜨는데, 디버깅해보니 MyUserDetails.getMember()가 anonymousMember라고 뜹니다. 이에 대한 해결책을 알 수 있을까요?ㅠㅠ

답글 달기