Spring Framework

[게시판 만들기] 2️⃣ Spring Security 적용

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