티스토리 뷰
작은 게시판 프로젝트를 진행하고 있다.
그 중에서 소소하게 비밀글 기능을 추가해서 맞는 비밀번호를 입력했을 때만 게시글 조회가 가능하도록 하는 기능을 추가하려고 했는데,
내 처음 계획으로는 대충 게시글 엔티티에 비밀번호 컬럼 하나 추가하고 대충 비교만 하면 되겠지? 싶은 생각으로 한두시간 정도면 끝낼 수 있는 작업으로 생각했었는데,,, 생각보다 훨씬 더 어려웠던 작업이었기에 기록으로 남겨본다.
사실 1인 프로젝트라 백엔드 + 프론트엔드를 동시에 다 만지려다보니 까다로웠던 것 같다. 풀스택 개발자여 아주
중요하지 않은 부분은 빠르게 넘어가고 핵심 작업만 다룬다!
작업 1. 게시글 생성 시 비밀글 유무 선택 / 암호화 저장
@Getter
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class Board extends AuditingFields {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(length = 50, nullable = false)
private String title;
@Column(length = 15, nullable = false)
private String editor;
@Column(columnDefinition = "TEXT", nullable = false)
private String content;
private String password; // 비밀글 조회 비밀번호 (null 유무로 비밀글 유무 체크)
...
}
(비밀글 유무는 Password 컬럼이 null인지 아닌지를 이용함)
<게시글 생성 시 사용할 PostDTO>
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED) // ObjectMapper(jackson) 는 NoArgs 생성자가 반드시 필요하다
@Getter // 리플렉션을 이용해 주입하기 때문에 @Setter 필요 없음
public class BoardPostDto {
...
private String password; // 비밀글 비밀번호
private Boolean secret; // 비밀글 유무
public Boolean getSecret() {
return secret != null; // secret 체크를 하지 않았을 경우 false / 이외 true
}
}
* 비밀글 유무 체크박스에 체크한 경우에만 비밀글 설정을 하기 위해 secret 필드를 추가 사용
** html 체크박스 특성 상 체크를 하지 않으면 값을 아예 보내지 않음(null)
-> get 메서드를 별도로 만들어 기본 값(false)을 넣어주면 된다!
<게시글 생성 html 일부>
<div class="input-group">
<div class="input-group-text">
<span class="me-2">비밀글 선택</span>
<input class="form-check-input mt-0" type="checkbox" name="secret" value="true">
</div>
<input type="password" class="form-control" id="password" name="password" placeholder="비밀글 비밀번호를 입력하세요...">
</div>
<응답 전용 DTO>
@Getter
@Builder
public class BoardResponse {
private final Long id;
private final String title;
...
private final boolean secret; // 비밀글 유무
public static BoardResponse from(Board board) {
...
return BoardResponse.builder()
.id(board.getId())
.title(board.getTitle())
...
.secret(board.getPassword() != null)
.build();
}
}
추가 작업) 클라이언트로부터 받은 비밀번호를 암호화 해서 저장하기
이전에 회원가입 기능 만들면서 사용하고 있던 BCryptPasswordEncoder (스프링 시큐리티가 제공) 를 사용
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
뷰 --> (평문/DTO) --> 컨트롤러 --> (평문/DTO) --> 서비스(암호화 && DTO -> 엔티티) --> 저장!
@Slf4j
@RequiredArgsConstructor
@Transactional
@Service
public class BoardService {
private final BoardRepository boardRepository;
private final BoardMapper boardMapper;
private final PasswordEncoder passwordEncoder; // BCryptEncoder
public Long create(BoardRequest.BoardPostDto boardPostDto, UserAccountDto userAccountDto) {
// (1) Dto -> Entity 변환
Board board = boardMapper.BoardPostDtoToBoardEntity(boardPostDto, userAccountDto, passwordEncoder);
// (2) Entity 저장
Board savedBoard = boardRepository.save(board);
...
// (4) 생성된 게시글의 ID 반환
return savedBoard.getId();
}
}
@Mapper(componentModel = "spring")
public interface BoardMapper {
// DTOs -> Board Entity
default Board BoardPostDtoToBoardEntity(BoardRequest.BoardPostDto boardPostDto, UserAccountDto userAccountDto, PasswordEncoder passwordEncoder) {
UserAccount userAccount = userAccountDtoToUserAccountEntity(userAccountDto);
// 비밀글 체크를 한 경우에만 비밀번호 설정 (암호화 후 저장)
String password = boardPostDto.getSecret() ? passwordEncoder.encode(boardPostDto.getPassword()) : null;
return Board.builder()
.title(boardPostDto.getTitle())
.editor(boardPostDto.getEditor())
.content(boardPostDto.getContent())
.password(password)
.userAccount(userAccount)
.build();
}
}
작업 2. 클라이언트로부터 받은 비밀번호를 검증하는 작업
이게 생각보다 난해했는데..
1. 일단 게시글을 클릭했을 때 해당 게시글이 비밀글인지/아닌지를 확인해야 하고
2. 해당 게시글이 비밀글이라면 비밀번호를 입력받는 페이지를 먼저 보여준 다음에
3, 검증에 실패하면 실패했다는 응답을 보내주고
4. 검증에 성공한 경우에만 게시글을 보여줘야 한다.
사실 CSR 방식으로 클라이언트 측에서 동적으로 페이지 요청을 두방 보내면 사실 별 어려움이 없었을 것 같은데
지금 본인은 혼자서 스프링부트와 타임리프를 이용해 SSR 방식으로 개발하고 있기 때문에 뭔가 새로운 방법을 고민했는데..
위와 같은 흐름으로 게시글 비밀번호 입력 페이지를 별도로 만들 수 밖에 없었다!
클라이언트에서 동적으로 페이지를 만드는게 아니다보니 한계가 있는 것 같은데 더 좋은 방법이 있는지 잘 모르겠다.
@GetMapping("/{boardId}")
public String boardDetail(@PathVariable Long boardId,
HttpSession session,
Model model) {
// (1) 해당 게시글이 비밀글인지 먼저 확인
boolean secretBoard = boardService.isSecretBoard(boardId);
// (2) 세션에 해당 게시글의 비밀번호 정보가 있는지 확인
String savedPassword = (String) session.getAttribute("secret_board_" + boardId);
// (3) 비밀글이면서 비밀번호 정보가 없이 GET 요청했다면 비밀번호 입력 페이지로 포워드
if(secretBoard && savedPassword == null) {
return "forward:/boards/" + boardId + "/auth";
}
// (4) 검증 통과 시 페이지 정상 응답
BoardWithRepliesResponseDto dto = boardService.readWithRepliesById(boardId, savedPassword);
model.addAttribute("board", dto);
model.addAttribute("replies", dto.getReplies());
return "board/board-detail";
}
@GetMapping("/{boardId}/auth")
public String getSecretBoardForm(@PathVariable Long boardId, Model model) {
boolean secretBoard = boardService.isSecretBoard(boardId);
if(!secretBoard) { // 해당 글이 비밀글이 아닌 경우 리다이렉트로 바로 보내버리기
return "redirect:/boards/" + boardId;
}
model.addAttribute("boardId", boardId);
return "board/board-secret-auth";
}
컨트롤러에서 해당 게시글이 비밀글인지 유무를 먼저 확인하고,
비밀글인 경우 세션에서 해당 게시글에 대한 비밀번호를 가지고 있는지 확인한 다음에 비밀번호 정보가 없다! 하면 비밀번호 입력 페이지로 "forward" 시켜버린다
그럼 클라이언트는 내가 /boards/{boardId} 페이지를 요청한 결과로 비밀번호 입력 페이지를 곧바로 응답받게 된다.
(URL 뒤에 /boards/{boardId}/auth 가 붙어있지 않음!)
비밀번호 입력 페이지에서도 다시 해당 게시글이 비밀글인지 유무를 중복 확인해서 URL으로 치고 들어온 경우에 대한 처리를 겸사겸사 진행. 비밀글이 아닌 경우에는 게시글 페이지로 바로 리다이렉트 시켜버리도록 했다.
* forward와 redirect는 다르다!!
- forward를 하게되면 요청 url이 그대로 남아있다
- 근데 redirect를 하게 되면 새로운 페이지로 다시 요청하기 때문에 새로운 url으로 아예 이동하게 된다
아무튼 이렇게 게시글을 클릭하면 비밀번호 입력 페이지를 먼저 띄우는 것까지 완료
이제 비밀번호를 입력 시 검증하는 RestController를 만든다
@Slf4j
@RequiredArgsConstructor
@Component
public class BoardPasswordValidator implements Validator {
private final BoardService boardService;
@Override
public boolean supports(Class<?> clazz) {
return BoardRequest.BoardPasswordDto.class.isAssignableFrom(clazz);
}
@Override
public void validate(Object target, Errors errors) {
BoardRequest.BoardPasswordDto boardPasswordDto = (BoardRequest.BoardPasswordDto) target;
boolean matchPassword
= boardService.isMatchSecretPassword(boardPasswordDto.getBoardId(), boardPasswordDto.getPassword());
if(!matchPassword) {
errors.rejectValue("password", "비밀번호 검증 오류", "비밀번호가 일치하지 않습니다");
}
}
}
@Slf4j
@RequiredArgsConstructor
@RequestMapping("/api/boards")
@RestController
public class BoardApiController {
private final BoardService boardService;
private final BoardPasswordValidator boardPasswordValidator;
@InitBinder("boardPasswordDto") // BoardPassword에 대해서만 검증해라!!
protected void initBinder(WebDataBinder dataBinder) {
dataBinder.addValidators(boardPasswordValidator);
}
...
@PostMapping("/{boardId}/auth")
public ResponseEntity<?> loginSecretBoard(@PathVariable Long boardId,
@Valid @RequestBody BoardRequest.BoardPasswordDto boardPasswordDto,
HttpServletRequest request) {
HttpSession session = request.getSession();
session.setAttribute("secret_board_" + boardId, boardPasswordDto.getPassword());
return ResponseEntity.ok(boardId);
}
}
검증에 통과한 경우 세션에다가 "secret_board_[게시글 번호]" 이름으로 비밀번호를 담아놓는다!
(근데 이래도 되나 모르겠다. 보안 이슈가 있을 것 같긴 한데)
이 세션에 담긴 정보를 가지고 다시 게시글 GET 요청을 하면, 세션에서 비밀번호를 꺼내와서 비밀번호가 일치하는지 다시 검증한 다음에 일치하는 경우 게시글 페이지를 보여주는 방식이다!
못 담은 내용들이 꽤 많은데 블로그에 다 적으려면 정말 한세월이 걸릴 것 같아 이정도만 쓰고 급하게 마무리..
자세한 사항은 이슈 및 커밋 내역을 참고!
https://github.com/eheh12321/MySimpleBoardService/issues/37
'웹 > Spring' 카테고리의 다른 글
@ModelAttribute 공식 문서를 읽어보자 (0) | 2022.11.23 |
---|---|
@WebMvcTest 에서 Spring Security 적용, 401/403 에러 해결하기 - csrf (4) | 2022.11.22 |
DTO는 대체 어디서 변환하는 것이 좋을까? (2) | 2022.10.27 |
@Valid를 이용한 회원가입 입력값 검증 - 1단계 (0) | 2022.10.19 |
Swagger (Springfox)를 이용해 스프링 프로젝트의 API 문서 만들어보기 (0) | 2022.09.08 |