ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [게시판 만들기] 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 구현

     
    CustomUserDetails

    public 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를 사용하며 테이블을 조인하는 작업이 더 힘들었던 것 같다. 피곤하다.. 얼른 자야겠다..