-
[게시판 만들기] 2️⃣ Spring Security 적용Spring Framework 2024. 5. 12. 02:58
https://github.com/jngsngjn/board-study
GitHub - jngsngjn/board-study: 게시판을 만들어 봅시다!
게시판을 만들어 봅시다! Contribute to jngsngjn/board-study development by creating an account on GitHub.
github.com
인증, 인가 구현 목표
- USER, ADMIN 권한 설정
- 회원가입 후 로그인을 통해 인증된 사용자만 글쓰기 가능
- 비인증 사용자는 게시글 조회만 가능
- USER는 자신이 작성한 게시글만 수정 및 삭제 가능
1. User 엔티티와 권한 설정
User
@Data @Entity public class User { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Integer userId; private String loginId; private String password; private String name; private String email; @Enumerated(EnumType.STRING) private Role role; }
Role
스프링 시큐리티에서 권한을 설정할 때는 ROLE 접두사를 붙여 주어야 한다.public enum Role { ROLE_ADMIN, ROLE_USER }
2. SecurityFilterChain 등록
@Configuration @EnableWebSecurity public class SecurityConfig { @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.authorizeHttpRequests(auth -> auth .requestMatchers("/", "/index.html", "/css/**", "/js/**").permitAll() // 글쓰기, 삭제, 수정 -> 인증된 사용자만 .requestMatchers("/boards/write", "/boards/{boardId}/delete", "/boards/{boardId}/edit").authenticated() // 게시판 페이지, 특정 게시글, 회원가입 -> 모든 사용자 .requestMatchers("/boards", "/boards/{boardId}", "/register/**").permitAll() .anyRequest().authenticated() ); http.formLogin(auth -> auth // 로그인 페이지 경로 (GET) .loginPage("/login") // 로그인 처리 경로 (POST) .loginProcessingUrl("/login") // 로그인 성공 시 경로 (GET) .defaultSuccessUrl("/boards", true) .permitAll() ); http.logout(auth -> auth // 로그아웃 경로 (POST) .logoutUrl("/logout") .logoutSuccessUrl("/boards") .permitAll(false) ); return http.build(); } @Bean public BCryptPasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } }
3. 회원가입
UserService@Service @Transactional public class UserService { private final UserRepository userRepository; private final BCryptPasswordEncoder passwordEncoder; public UserService(UserRepository userRepository, BCryptPasswordEncoder passwordEncoder) { this.userRepository = userRepository; this.passwordEncoder = passwordEncoder; } public boolean register(RegisterForm registerForm) { if (userRepository.existsByLoginId(registerForm.getLoginId()) || userRepository.existsByEmail(registerForm.getEmail())) { return false; } User user = new User(); user.setLoginId(registerForm.getLoginId()); user.setPassword(passwordEncoder.encode(registerForm.getPassword())); user.setName(registerForm.getName()); user.setEmail(registerForm.getEmail()); user.setRole(registerForm.getLoginId().equals("admin") ? ROLE_ADMIN : ROLE_USER); userRepository.save(user); return true; } }
4. UserDetails, UserDetailsService 구현
CustomUserDetailspublic class CustomUserDetails implements UserDetails { private final User user; public CustomUserDetails(User user) { this.user = user; } // 현재 인증된 사용자의 이름 조회 public String getName() { return user.getName(); } // 현재 인증된 사용자의 아이디(PK) 조회 public Integer getUserId() { return user.getUserId(); } // 현재 인증된 사용자가 ADMIN ? public boolean isAdmin() { return getAuthorities().stream() .anyMatch(auth -> auth.getAuthority().equals("ROLE_ADMIN")); } // 권한 정보 반환 @Override public Collection<? extends GrantedAuthority> getAuthorities() { Collection<GrantedAuthority> collection = new ArrayList<>(); collection.add(new GrantedAuthority() { @Override public String getAuthority() { return user.getRole().toString(); } }); return collection; } @Override public String getPassword() { return user.getPassword(); } // 로그인 아이디 반환 @Override public String getUsername() { return user.getLoginId(); } @Override public boolean isAccountNonExpired() { return true; } @Override public boolean isAccountNonLocked() { return true; } @Override public boolean isCredentialsNonExpired() { return true; } @Override public boolean isEnabled() { return true; } }
CustomUserDetailsService@Service public class CustomUserDetailsService implements UserDetailsService { private final UserRepository userRepository; public CustomUserDetailsService(UserRepository userRepository) { this.userRepository = userRepository; } @Override public UserDetails loadUserByUsername(String loginId) throws UsernameNotFoundException { User user = userRepository.findByLoginId(loginId); if (user == null) { throw new UsernameNotFoundException("User not found with username: " + loginId); } return new CustomUserDetails(user); } }
5. 인가 작업
특정 게시글 조회
- 모든 사용자(비인증 사용자 포함)가 조회할 수 있어야 한다.
- 비인증 사용자는 수정, 삭제할 수 없다.
- USER는 자신이 작성한 글만 수정 및 삭제할 수 있다.
- ADMIN은 모든 글을 수정 및 삭제할 수 있다.
BoardController
@Controller @RequestMapping("/boards") public class BoardController { private final BoardService boardService; public BoardController(BoardService boardService) { this.boardService = boardService; } // 특정 게시글 조회 @GetMapping("/{boardId}") public String boardOne(@PathVariable Integer boardId, Model model, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : #this") CustomUserDetails userDetails) { BoardOne board = boardService.findBoardOne(boardId); if (board == null) { return "redirect:/boards"; } // 조회수 증가 boardService.viewBoardOne(boardId); model.addAttribute("board", board); model.addAttribute("authentication", userDetails); return "boardOne"; } }
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : #this") CustomUserDetails userDetails
- @AuthenticationPrincipal : 현재 인증된 사용자의 정보를 userDetails에 넣어준다.
- (expression = "#this == 'anonymousUser' ? null : #this") : 현재 사용자가 비인증 사용자이면 userDetails를 null로 설정한다.
boardOne.html<html lang="ko" xmlns:th="http://www.thymeleaf.org" xmlns:sec="http://www.thymeleaf.org/extras/spring-security"> <head> <meta charset="UTF-8"> <title>Document</title> </head> <body> <h2 th:text="${board.title}"></h2> <p>내용: <span th:text="${board.content}"></span></p> <p>작성일: <span th:text="${board.createdDate}"></span></p> <p>수정일: <span th:text="${board.modifiedDate}"></span></p> <p>조회수: <span th:text="${board.viewCount}"></span></p> <div th:if="${authentication != null && (authentication.userId == board.authorId || authentication.isAdmin())}"> <a th:href="@{/boards/{boardId}/edit(boardId=${board.boardId})}">수정</a> <form th:action="@{/boards/{boardId}/delete(boardId=${board.boardId})}" method="post"> <input type="hidden" name="_method" value="delete" /> <input type="hidden" name="_csrf" th:value="${_csrf.token}"/> <button type="submit">삭제</button> </form> </div> <a href="/boards">뒤로 가기</a> </body> </html>
- authentication != null : 비인증 사용자 → 수정 및 삭제 숨김
- (authentication.userId == board.authorId || authentication.isAdmin()) : 게시글 작성자 또는 ADMIN → 수정 및 삭제 가능
6. 로그인 사용자 환영 메시지
BoardController
@Controller @RequestMapping("/boards") public class BoardController { private final BoardService boardService; public BoardController(BoardService boardService) { this.boardService = boardService; } // 게시판 페이지 조회 @GetMapping public String boardPage(@RequestParam(defaultValue = "1") int page, @AuthenticationPrincipal CustomUserDetails userDetails, Model model) { model.addAttribute("boardList", boardService.findBoardList(page - 1)); // 로그인한 사용자일 때만 if (userDetails != null) { model.addAttribute("name", userDetails.getName()); } return "board"; } }
board.html<h2>게시판</h2> <h2 th:if="${name != null}"><span th:text="${name}"></span>님 환영합니다.</h2>
7. 로그아웃
- 미인증 사용자 → 로그인을 보이게
- 로그인 사용자 → 로그아웃을 보이게
board.html
<a href="/login" th:if="${name == null}">로그인</a> <form th:unless="${name == null}" th:action="@{/logout}" method="post"> <input type="submit" value="로그아웃"/> </form>
- th:unless="${name == null}" : name이 null이 아닌 경우에만 렌더링
8. 게시글 작성 개선
BoardContorller
- Board 테이블에 작성자(User)의 PK를 저장하기 위해 현재 인증 사용자의 정보를 얻어와서 값을 설정한 뒤 Service단에 넘겨준다.
@Controller @RequestMapping("/boards") public class BoardController { private final BoardService boardService; public BoardController(BoardService boardService) { this.boardService = boardService; } // 글쓰기 @PostMapping("/write") public String write(@ModelAttribute BoardForm boardForm, @AuthenticationPrincipal CustomUserDetails userDetails) { boardForm.setAuthorId(userDetails.getUserId()); boardService.writeBoard(boardForm); return "redirect:/boards"; } }
BoardService@Service @Transactional public class BoardService { private final BoardRepository boardRepository; public BoardService(BoardRepository boardRepository) { this.boardRepository = boardRepository; } // 글 쓰기 public void writeBoard(BoardForm boardForm) { Board board = new Board(); board.setTitle(boardForm.getTitle()); board.setContent(boardForm.getContent()); board.setAuthorId(boardForm.getAuthorId()); boardRepository.save(board); } }
9. 게시판 페이지 개선
board.html- 게시글 번호를 자동 증가하는 boardId가 아닌 현재 게시글 수만큼 증가하도록 변경
- 테이블 조인을 통해 작성자를 User 테이블의 login_id로 설정
<table> <thead> <tr> <th>번호</th> <th>제목</th> <th>작성자</th> <th>작성일</th> <th>조회수</th> </tr> </thead> <tbody> <tr th:each="board, iterStat : ${boardList}"> <td> <a th:href="@{/boards/{boardId}(boardId=${board.boardId})}" th:text="${(boardList.number * boardList.size) + iterStat.count}"></a> </td> <td> <a th:href="@{/boards/{boardId}(boardId=${board.boardId})}" th:text="${board.title}"></a> </td> <td th:text="${board.loginId}"></td> <td th:text="${board.createdDate}"></td> <td th:text="${board.viewCount}"></td> </tr> </tbody> </table>
Spring Security를 깊이 있게 학습하지 않고 적용해서 그런지 이 작업도 시간이 꽤 오래 걸렸다.. 구현 목표도 굉장히 단순했음에도 불구하고 어려웠다. 그래도 혼자 구현해 보는 과정 속에서 이것저것 시도해보며 인가 작업에 조금이나마 스스로 알게 된 것 같아 나름대로 의미 있는 시간이었다고 생각한다. 개인적으로 이번 작업은 시큐리티보다 JPA를 사용하며 테이블을 조인하는 작업이 더 힘들었던 것 같다. 피곤하다.. 얼른 자야겠다..'Spring Framework' 카테고리의 다른 글
[Spring Security] 로그인 실패 시 계정 잠금 (0) 2024.05.16 [게시판 만들기] 5️⃣ 이메일 인증 처리 (1) 2024.05.14 [게시판 만들기] 4️⃣ AJAX를 통한 검증 처리 (1) 2024.05.13 [게시판 만들기] 3️⃣ CSS 적용하기 (1) 2024.05.13 [게시판 만들기] 1️⃣ 설계와 CRUD 구현 (2) 2024.05.10