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