<nav th:fragment="navbarFragment" class="navbar navbar-expand-lg navbar-light bg-light border-bottom">
<div class="container-fluid">
<a class="navbar-brand" href="/">SBB</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent"
aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item">
<a class="nav-link" href="#">로그인</a>
</li>
</ul>
</div>
</div>
</nav>
<!-- Bootstrap JS -->
<script th:src="@{/bootstrap.min.js}"></script>
<!-- 기본 템플릿 안에 삽입될 내용 End --> 여기 밑에 추가
<!doctype html>
<html lang="ko">
<head>
<!-- Required meta tags -->
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<!-- Bootstrap CSS -->
<link rel="stylesheet" type="text/css" th:href="@{/bootstrap.min.css}">
<!-- sbb CSS -->
<link rel="stylesheet" type="text/css" th:href="@{/style.css}">
<title>Hello, sbb!</title>
</head>
<body>
<!-- 네비게이션바 -->
<nav th:replace="~{navbar :: navbarFragment}"></nav>
<!-- 기본 템플릿 안에 삽입될 내용 Start -->
<th:block layout:fragment="content"></th:block>
<!-- 기본 템플릿 안에 삽입될 내용 End -->
<!-- Bootstrap JS -->
<script th:src="@{/bootstrap.min.js}"></script>
</body>
</html>
org.springframework.data.domain.Page : 페이징을 위한 클래스
org.springframework.data.domain.PageRequest :
현재 페이지와 한 페이지에 보여 줄 게시물 개수 등을 설정하여 페이징 요청을 하는 클래스
org.springframework.data.domain.Pageable : 페이징을 처리하는 인터페이스
@SpringBootTest
class PracticeApplicationTests {
@Autowired
private QuestionService questionService;
@Test
void testJpa() {
for (int i = 1; i <= 300; i++) {
String subject = String.format("테스트데이터:[%03d]", i);
String content = "내용무";
this.questionService.create(subject, content);
}
}
}
반복문 돌려서 글 300개 만들기
public interface QuestionRepository extends JpaRepository<Question, Integer> {
Question findBySubject(String subject);
Question findBySubjectAndContent(String subject, String content);
List<Question> findBySubjectLike(String subject);
Page<Question> findAll(Pageable pageable);
}
pageable 객체를 입력 받아서 Page<Question> 타입 객체를 리턴하는 findall 메서드
public Page<Question> getList(int page) {
Pageable pageable = PageRequest.of(page, 10);
return this.questionRepository.findAll(pageable);
}
질문 목록을 조회하는 getList 메서드를 변경
정수 타입의 페이지 번호를 입력받아 해당 페이지의 Page 객체를 리턴하도록 변경
Pageable 객체를 생성할 때 사용한 PageRequest.of(page,10);
page는 조회할 페이지의 번호이고 10은 한 페이지에 보여 줄 게시물의 개수를 의미
이렇게하면 데이터 전체를 조회하지 않고
해당 페이지에 데이터만 조회하도록 쿼리가 변경
그래서 1페이지에 10개의 게시글을 보여주겠다 라는 뜻인듯??
@GetMapping("/list")
public String list(Model model, @RequestParam(value = "page", defaultValue = "0") int page) {
Page<Question> paging = this.questionService.getList(page);
model.addAttribute("paging", paging);
return "question_list";
}
http://localhost:8080/question/list?page=0 와 같이 GET 방식으로 요청된 URL에서
page 값을 가져오기 위해 list 메서드의 매개변수로
@RequestParam(value="page", defaultValue="0") int page 가 추가
URL에 매개변수로 page가 전달되지 않은 경우 기본값은 0이 되도록 설정
? 와 & 기호를 사용한다. 첫 번째 파라미터는 ? 기호를 사용하고 그 이후 추가되는 값은 & 기호를 사용한다.| 속성 | 설명 |
|---|---|
paging.isEmpty | 페이지 존재 여부를 의미한다(게시물이 있으면 false, 없으면 true). |
paging.totalElements | 전체 게시물 개수를 의미한다. |
paging.totalPages | 전체 페이지 개수를 의미한다. |
paging.size | 페이지당 보여 줄 게시물 개수를 의미한다. |
paging.number | 현재 페이지 번호를 의미한다. |
paging.hasPrevious | 이전 페이지의 존재 여부를 의미한다. |
paging.hasNext | 다음 페이지의 존재 여부를 의미한다. |
<html layout:decorate="~{layout}">
<div layout:fragment="content" class="container my-3">
<table class="table">
<thead class="table-dark">
<tr>
<th>번호</th>
<th>제목</th>
<th>작성일시</th>
</tr>
</thead>
<tbody>
<tr th:each="question, loop : ${paging}">
<td th:text="${loop.count}"></td>
<td>
<a th:href="@{|/question/detail/${question.id}|}"
th:text="${question.subject}"></a>
</td>
<td th:text="${#temporals.format(question.createDate,
'yyyy-MM-dd HH:mm')}"></td>
</tr>
</tbody>
</table>
<a th:href="@{/question/create}" class="btn btn-primary">질문 등록하기</a>
</div>
</html>
loop : ${questionList} 에서 paging 으로 변경
버튼이 없어서 도메인으로 넘어가야됨 버튼 생성 ㄱㄱ
<html layout:decorate="~{layout}">
<div layout:fragment="content" class="container my-3">
<table class="table">
(... 생략 ...)
</table>
<!-- 페이징처리 시작 -->
<div th:if="${!paging.isEmpty()}">
<ul class="pagination justify-content-center">
<li class="page-item" th:classappend="${!paging.hasPrevious} ? 'disabled'">
<a class="page-link"
th:href="@{|?page=${paging.number-1}|}">
<span>이전</span>
</a>
</li>
<li th:each="page: ${#numbers.sequence(0, paging.totalPages-1)}"
th:classappend="${page == paging.number} ? 'active'"
class="page-item">
<a th:text="${page}" class="page-link" th:href="@{|?page=${page}|}"></a>
</li>
<li class="page-item" th:classappend="${!paging.hasNext} ? 'disabled'">
<a class="page-link" th:href="@{|?page=${paging.number+1}|}">
<span>다음</span>
</a>
</li>
</ul>
</div>
<!-- 페이징처리 끝 -->
<a th:href="@{/question/create}" class="btn btn-primary">질문 등록하기</a>
</div>
</html>
닫히는 테이블 밑에 페이징 처리하기
| 페이징 기능 관련 주요 코드 | 설명 |
|---|---|
th:classappend="${!paging.hasPrevious} ? 'disabled'" | 이전 페이지가 없으면 '이전' 링크를 비활성화한다. |
th:classappend="${!paging.hasNext} ? 'disabled'" | 다음 페이지가 없으면 '다음' 링크를 비활성화한다. |
| `th:href="@{ | ?page=${paging.number-1} |
| `th:href="@{ | ?page=${paging.number+1} |
th:each="page: ${#numbers.sequence(0, paging.totalPages-1)}" | 0부터 전체 페이지 수만큼 이 요소를 반복하여 생성한다. 이때 현재 순번을 page 변수에 대입한다. |
th:classappend="${page == paging.number} ? 'active'" | 반복 구간 내에서 해당 페이지가 현재 페이지와 같은 경우 active 클래스를 적용한다. |
<!-- 페이징처리 시작 -->
<div th:if="${!paging.isEmpty()}">
<ul class="pagination justify-content-center">
<li class="page-item" th:classappend="${!paging.hasPrevious} ? 'disabled'">
<a class="page-link"
th:href="@{|?page=${paging.number-1}|}">
<span>이전</span>
</a>
</li>
<li th:each="page: ${#numbers.sequence(0, paging.totalPages-1)}"
th:if="${page >= paging.number-5 and page <= paging.number+5}"
th:classappend="${page == paging.number} ? 'active'"
class="page-item">
<a th:text="${page}" class="page-link" th:href="@{|?page=${page}|}"></a>
</li>
<li class="page-item" th:classappend="${!paging.hasNext} ? 'disabled'">
<a class="page-link" th:href="@{|?page=${paging.number+1}|}">
<span>다음</span>
</a>
</li>
</ul>
</div>
<!-- 페이징처리 끝 -->
public Page<Question> getList(int page) {
List<Sort.Order> sorts = new ArrayList<>();
sorts.add(Sort.Order.desc("createDate"));
Pageable pageable = PageRequest.of(page, 10, Sort.by(sorts));
return this.questionRepository.findAll(pageable);
}
최신순으로 데이터 조회하는 방법은 게시물을 역순으로 조회하기
PageRequest.of 메서드의 3번째 매개변수에 Sort 객체를 전달해야함
작성 일시 (createDate) 를 역순 (Desc) 으로 조회하려면 Sort.Order.desc("createDate)
작성 일시 외에 정렬 조건을 추가하고 싶다 :
sort.addsorts 리스트에 추가
여기서 쓰인 desc는 내림차순, asc는 오름차순
게시물 번호 =
전체 게시물 개수 - (현재 페이지 * 페이지당 게시물 개수) - 나열 인덱스
게시물 번호 = 50 - (2 * 10) - 1
= 50 - 20 - 1
= 29
즉, 현재 페이지에서 2번째로 보이는 게시물은 전체 게시물 번호로 29번
<html layout:decorate="~{layout}">
<div layout:fragment="content" class="container my-3">
<table class="table">
<thead class="table-dark">
(... 생략 ...)
</thead>
<tbody>
<tr th:each="question, loop : ${paging}">
<td th:text="${paging.getTotalElements - (paging.number * paging.size) - loop.index}"></td>
<td>
<a th:href="@{|/question/detail/${question.id}|}" th:text="${question.subject}"></a>
</td>
<td th:text="${#temporals.format(question.createDate, 'yyyy-MM-dd HH:mm')}"></td>
</tr>
</tbody>
</table>
(... 생략 ...)
</div>
</html>
추가된 것
<td th:text="${paging.getTotalElements - (paging.number * paging.size) - loop.index}"></td>
<html layout:decorate="~{layout}">
<div layout:fragment="content" class="container my-3">
<table class="table">
<thead class="table-dark">
<tr>
<th>번호</th>
<th>제목</th>
<th>작성일시</th>
</tr>
</thead>
<tbody>
<tr th:each="question, loop : ${paging}">
<td th:text="${paging.getTotalElements - (paging.number * paging.size) - loop.index}"></td>
<td>
<a th:href="@{|/question/detail/${question.id}|}" th:text="${question.subject}"></a>
<span class="text-danger small ms-2"
th:if="${#lists.size(question.answerList) > 0}"
th:text="${#lists.size(question.answerList)}">
</span>
</td>
<td th:text="${#temporals.format(question.createDate, 'yyyy-MM-dd HH:mm')}"></d>
</tr>
</tbody>
</table>
<!-- 페이징처리 시작 -->
<div th:if="${!paging.isEmpty()}">
<ul class="pagination justify-content-center">
<li class="page-item" th:classappend="${!paging.hasPrevious} ? 'disabled'">
<a class="page-link"
th:href="@{|?page=${paging.number-1}|}">
<span>이전</span>
</a>
</li>
<li th:each="page: ${#numbers.sequence(0, paging.totalPages-1)}"
th:if="${page >= paging.number-5 and page <= paging.number+5}"
th:classappend="${page == paging.number} ? 'active'"
class="page-item">
<a th:text="${page}" class="page-link" th:href="@{|?page=${page}|}"></a>
</li>
<li class="page-item" th:classappend="${!paging.hasNext} ? 'disabled'">
<a class="page-link" th:href="@{|?page=${paging.number+1}|}">
<span>다음</span>
</a>
</li>
</ul>
</div>
<!-- 페이징처리 끝 -->
<a th:href="@{/question/create}" class="btn btn-primary">질문 등록하기</a>
</div>
</html>
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6'
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests((authorizeHttpRequests) -> authorizeHttpRequests
.requestMatchers(new AntPathRequestMatcher("/**")).permitAll())
;
return http.build();
}
}
@Configration : 스프링의 환경 설정 파일임을 의미
@EnableWebSecurity : 모든 요청 URL이 스프링 시큐이리트이 제어를 받도록 만듬,
스프링 시큐리티를 활성화 하는 역할
SecurityFilterChain 이게 동작하면서 모든 요청 URL에 이 클래스가 필터로 적용
URL별로 특별한 설정을 할 수 있음
세부 설정은 @Bean 을 통해 SecurityFilterChain 빈을 생성하여 설정
http
.authorizeHttpRequests((authorizeHttpRequests) -> authorizeHttpRequests
.requestMatchers(new AntPathRequestMatcher("/**")).permitAll())
;
인증되지 않은 모든 페이지의 요청을 허락한다는 의미
따라서 로그인하지 않더라고 모든 페이지에 접근할 수 있음
CSRF는 웹 보안 공격 중 하나
조작된 정보로 웹 사이트가 실행되도록 속이는 공격 기술
이걸 방지하기 위해 스프링 시큐리티가 CSRF 토큰을 세션을 통해 발행함
웹 페이지에선 폼 전송 시에 해당 토큰과 함께 전송하여
실제 웹 페이지에서 작성한 데이터가 전달되는지 검증함
- 토큰 : 요청을 식별하고 검증하는 데 사용하는 특수한 문자열 또는 값
- 세션 : 사용자의 상태를 유지하고 관리하는 데 사용하는 기능
스프링 시큐리티가 CSRF 토큰을 발행해
이 값이 다른 서버로 정확하게 들어오는지 확인하는 과정을 거침
만약 토큰이 없거나 임의적으로 강제로 만들어 전송한다면 스프링 시큐리티가 차단함
.csrf((csrf) -> csrf
.ignoringRequestMatchers(new AntPathRequestMatcher("/h2-console/**")))
h2-console 로 들어가기 위해선 필요함
.headers((headers) -> headers
.addHeaderWriter(new XFrameOptionsHeaderWriter(
XFrameOptionsHeaderWriter.XFrameOptionsMode.SAMEORIGIN)))
@Getter
@Setter
@Entity
public class SiteUser {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true)
private String username;
private String password;
@Column(unique = true)
private String email;
}
unique = true << 이걸로 값이 중복되지 않게 저장해줌
public interface UserRepository extends JpaRepository<SiteUser, Long> {
}
SiteUser 의 기본 키 타입은 Long
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
public SiteUser create(String username, String password, String email) {
SiteUser user = new SiteUser();
user.setUsername(username);
user.setPassword(password);
user.setEmail(email);
BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
user.setPassword(passwordEncoder.encode(password));
return this.userRepository.save(user);
}
}
서비스에는 리포지터리를 사용하여 회원 데이터를 생성하는 create 메서드 추가
user의 비밀번호는 보안을 위해 반드시 암호화하여 저장해야 함
스프링 시큐리티의 BCryptPasswordEncoder 클래스를 사용해 암호화하여 저장
객체를 직접 new로 생성하는 방식보단
PassswordEncoder 객체를 빈으로 등록해 사용하는 것이 좋음
왜냐? 암호화 방식을 변경하면 BCryptPasswordEncoder 를 사용한 프로그램은
일일이 찾아다니며 수정해야함
@Getter
@Setter
public class UserCreateForm {
@Size(min = 3, max = 25)
@NotEmpty("사용자 ID는 필수항목입니다.")
private String username;
@NotEmpty("비밀번호는 필수항목입니다.")
private String password1;
@NotEmpty("비밀번호 확인은 필수항목입니다.")
private String password2;
@NotEmpty("이메일은 필수항목입니다.")
private String email;
}
username = 입력받는 데이터를 3 ~ 25여야 한다는 조건을 설정
@Size : 문자열의 길이가 최소 길이 (min) 와 최대 길이 (max) 사이에 해당하는지 검증
password1, password2 = 비밀번호와 비밀번호 확인에 대한 속성
로그인할 때 비밀번호가 한 번만 필요하지만 회원가입 시에는 입력한 비밀번호가
정확한지 확인하기 위해 2개의 필드가 필요하므로 이와 같이 작성
email 속성엔 @Email 어노테이션 적용
@Email : 해당 속성의 값이 이메일 형식과 일치하는지 검증
@RequiredArgsConstructor
@Controller
@RequestMapping("/user")
public class UserController {
private final UserService userService;
@GetMapping("/signup")
public String signup(UserCreateForm userCreateForm) {
return "signup_form";
}
@PostMapping("/signup")
public String signup(@Valid UserCreateForm userCreateForm, BindingResult bindingResult) {
if (bindingResult.hasErrors()) {
return "signup_form";
}
if (!userCreateForm.getPassword1().equals(userCreateForm.getPassword2())) {
bindingResult.rejectValue("password2", passwordInCorrect, "2개의 패스워드가 일치하지 않습니다.");
return "signup_form";
}
this.userService.create(userCreateForm.getUsername(), userCreateForm.getPassword1(), userCreateForm.getEmail());
return "redirect:/";
}
}
/user/signup URL이 GET으로 요청되면 가입을 위해 템플릿을 렌더링,
POST로 요청되면 회원 가입을 진행하도록 했음
회원 가입시 password1이 password2가 같은지 검증하는 조건문을 추가
만약 2개의 값이 서로 일치하지 않다면
bindingResult.rejectValue를 사용해
입력 받은 2개의 비밀번호가 일치하지 않다는 오류를 발생
bindingResult.rejectValue(필드명, 오류 코드, 오류 메시지)
userService.create 메서드를 사용해 사용자로부터 전달받은 데이터를 저장
<html layout:decorate="~{layout}">
<div layout:fragment="content" class="container my-3">
<div class="my-3 border-bottom">
<div>
<h4>회원가입</h4>
</div>
</div>
<form th:action="@{/user/signup}" th:object="${userCreateForm}" method="post">
<div th:replace="~{form_errors :: formErrorsFragment}"></div>
<div class="mb-3">
<label for="username" class="form-label">사용자ID</label>
<input type="text" th:field="*{username}" class="form-control">
</div>
<div class="mb-3">
<label for="password1" class="form-label">비밀번호</label>
<input type="password" th:field="*{password1}" class="form-control">
</div>
<div class="mb-3">
<label for="password2" class="form-label">비밀번호 확인</label>
<input type="password" th:field="*{password2}" class="form-control">
</div>
<div class="mb-3">
<label for="email" class="form-label">이메일</label>
<input type="email" th:field="*{email}" class="form-control">
</div>
<button type="submit" class="btn btn-primary">회원가입</button>
</form>
</div>
</html>
<nav th:fragment="navbarFragment" class="navbar navbar-expand-lg navbar-light bg-light border-bottom">
<div class="container-fluid">
<a class="navbar-brand" href="/">SBB</a>
(... 생략 ...)
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item">
<a class="nav-link" href="#">로그인</a>
</li>
<li class="nav-item">
<a class="nav-link" th:href="@{/user/signup}">회원가입</a>
</li>
</ul>
</div>
</div>
</nav>
<li class="nav-item">
<a class="nav-link" th:href="@{/user/signup}">회원가입</a>
</li>
로그인 아래에 넣으면 됨
try {
this.userService.create(userCreateForm.getUsername(), userCreateForm.getPassword1(), userCreateForm.getEmail());
} catch (DataIntegrityViolationException e) {
e.printStackTrace();
bindingResult.reject("signupError", "이미 가입된 사용자입니다.");
return "signup_form";
} catch (Exception e) {
e.printStackTrace();
bindingResult.reject("signupFailed", e.getMessage());
return "signup_form";
}
return "redirect:/";
}
}
ID 또는 이메일 주소가 이미 존재할 경우 DataIntegrityViolationException 예외 발생
'이미 가입된 사용자입니다' 가 화면에 표시되게 했음
그 밖에 예외들은 해당 예외에 관한 구체적인 오류 메세지를 출력하도록
e.Getmessage() 를 사용
여기서 bindingResult.reject(오류 코드, 오류 메시지) 는
UserCreateForm의 검증에 의한 오류 외에 일반적인 오류를 발생시킬 때 사용
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests((authorizeHttpRequests) -> authorizeHttpRequests
.requestMatchers(new AntPathRequestMatcher("/**")).permitAll())
.csrf((csrf) -> csrf
.ignoringRequestMatchers(new AntPathRequestMatcher("/h2-console/**")))
.headers((headers) -> headers
.addHeaderWriter(new XFrameOptionsHeaderWriter(
XFrameOptionsHeaderWriter.XFrameOptionsMode.SAMEORIGIN)))
.formLogin((formLogin) -> formLogin
.loginPage("/user/login")
.defaultSuccessUrl("/"))
;
return http.build();
}
@Bean
public BCryptPasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
.formlogin 메서드는 로그인 설정을 담당하는 부분
설정 내용은 로그인 페이지 URL = /user/login 성공 시 이동할 페이지 URL = /
@GetMapping("/login")
public String login() {
return "login_form";
}
<html layout:decorate="~{layout}">
<div layout:fragment="content" class="container my-3">
<form th:action="@{/user/login}" method="post">
<div th:if="${param.error}">
<div class="alert alert-danger">
사용자ID 또는 비밀번호를 확인해 주세요.
</div>
</div>
<div class="mb-3">
<label for="username" class="form-label">사용자ID</label>
<input type="text" name="username" id="username" class="form-control">
</div>
<div class="mb-3">
<label for="password" class="form-label">비밀번호</label>
<input type="password" name="password" id="password" class="form-control">
</div>
<button type="submit" class="btn btn-primary">로그인</button>
</form>
</div>
</html>
public interface UserRepository extends JpaRepository<SiteUser, Long> {
Optional<SiteUser> findByUsername(String username);
}
UserSecurityService는 사용자 ID를 조회하는 기능이 필요
SiteUser 엔티티를 조회하는 findByusername 메서드를 리포지터리에 추가
@Getter
public enum UserRole {
ADMIN("ROLE_ADMIN"),
USER("ROLE_USER");
UserRole(String value) {
this.value = value;
}
private final String value;
}
UserRole = enum 자료형으로 작성
관리자를 의미하는 ADMIN , 사용자를 의미하는 USER 상수 생성
ADMIN = 'ROLE_ADMIN', USER = 'ROLE_USER' 값 부여
UserRole의 ADMIN, USER 상수는 값을 변경할 필요가 없어서 @SETTER 안씀
@RequiredArgsConstructor
@Service
public class UserSecurityService implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
Optional<SiteUser> _siteUser = this.userRepository.findByUsername(username);
if (_siteUser.isEmpty()) {
throw new UsernameNotFoundException("User not found");
}
SiteUser siteUser = _siteUser.get();
List<GrantedAuthority> authorities = new ArrayList<>();
if ("admin".equals(username)) {
authorities.add(new SimpleGrantedAuthority(UserRole.ADMIN.getValue()));
} else {
authorities.add(new SimpleGrantedAuthority(UserRole.USER.getValue()));
}
return new User(siteUser.getUsername(), siteUser.getPassword(), authorities);
}
}
로그인 시 사용할 UserSecurityService는 UserDetailsService 인터페이스를 구현해야 함
스프링 시큐리티의 UserDetailsService는
loadUserByUsername 메서드를 구현하도록 강제하는 인터페이스
loadUserByUsername 메서드는 사용자명 (username)으로
스프링 시큐리티의 사용자 (User) 객체를 조회하여 리턴하는 메서드
loadUserByUsername = 사용자명으로 SiteUser 객체 조회
사용자명에 해당하는 데이터가 없다? = UsernameNotFoundException 발생
사용자명이 'admin' = ADMIN 권한 (ROLE_ADMIN) 부여 그 이외 USER 권한 부여
그 후 USER 객체를 생성해 반환하는데,
스프링 시큐리티에서 사용하며 User 생성자에는 사용자명, 비밀번호, 권한 리스트가 전달
참고로,
스프링 시큐리티는 loadUserByUsername 메서드에 의해 리턴된 User 객체의
비밀번호가 사용자로부터 입력받은 비밀번호와 일치하는지를 검사하는 기능을
내부에 가지고 있다.
@Bean
AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
return authenticationConfiguration.getAuthenticationManager();
}
로그인 기능 완성을 위해 빈 생성
AuthenticationManager는 스프링 시큐리티의 인증을 처리
AuthenticationManager는 사용자 인증 시 앞에서 작성한 UserSecurityService와 PasswordEncoder를 내부적으로 사용하여 인증과 권한 부여 프로세스를 처리
<nav th:fragment="navbarFragment" class="navbar navbar-expand-lg navbar-light bg-light border-bottom">
<div class="container-fluid">
<a class="navbar-brand" href="/">SBB</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent"
aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item">
<a class="nav-link" sec:authorize="isAnonymous()" th:href="@{/user/login}">로그인</a>
<a class="nav-link" sec:authorize="isAuthenticated()" th:href="@{/user/logout}">로그아웃</a>
</li>
<li class="nav-item">
<a class="nav-link" th:href="@{/user/signup}">회원가입</a>
</li>
</ul>
</div>
</div>
</nav>
.logout((logout) -> logout
.logoutRequestMatcher(new AntPathRequestMatcher("/user/logout"))
.logoutSuccessUrl("/")
.invalidateHttpSession(true))
@ManyToOne
private SiteUser author;
서버와 DB를 관리하는 컨트롤러와 서비스 (또는 리포지터리) 에도
관련 내용을 업데이트 해야함
@PostMapping("/create/{id}")
public String create(Model model, @PathVariable("id") Integer id,
@Valid AnswerForm answerForm, BindingResult bindingResult, Principal principal) {
Question question = this.questionService.getQuestion(id);
if (bindingResult.hasErrors()) {
model.addAttribute("question", question);
return "question_detail";
}
this.answerService.create(question, answerForm.getContent());
return String.format("redirect:/question/detail/%s", id);
}
로그인한 사용자의 정보를 알려면 Principal 객체를 사용해야 함
createAnswer 메서드에 Principal 객체를 매개변수로 지정하는 작업까지만 해두자
pricipal.getName() 을 호출하면 현재 로그인한 사용자의 사용자명을 알 수 있음
public SiteUser getUser(String username) {
Optional<SiteUser> siteUser = this.userRepository.findByUsername(username);
if (siteUser.isPresent()) {
return siteUser.get();
} else {
throw new DataNotFoundException("User not found");
}
}
getUser 메서드 = userRepository의 findByusername 메서드로 쉽게 만듬
사용자명에 해당하는 데이터가 없을 경우 예외 발생
public void create(Question question, String content, SiteUser author) {
Answer answer = new Answer();
answer.setQuestion(question);
answer.setContent(content);
answer.setAuthor(author);
answer.setCreateDate(LocalDateTime.now());
this.answerRepository.save(answer);
}
작성자 추가
@Controller
@RequestMapping("/answer")
@RequiredArgsConstructor
public class AnswerController {
private final QuestionService questionService;
private final AnswerService answerService;
private final UserService userService;
@PostMapping("/create/{id}")
public String create(Model model, @PathVariable("id") Integer id,
@Valid AnswerForm answerForm, BindingResult bindingResult, Principal principal) {
Question question = this.questionService.getQuestion(id);
SiteUser siteUser = userService.getUser(principal.getName());
if (bindingResult.hasErrors()) {
model.addAttribute("question", question);
return "question_detail";
}
this.answerService.create(question, answerForm.getContent(), siteUser);
return String.format("redirect:/question/detail/%s", id);
}
}
principal 객체로 사용자명 획득 후
사용자명을 통해 SiteUser 객체로 답변을 등록할 때 사용
public void create(String subject, String content, SiteUser user) {
Question q = new Question();
q.setSubject(subject);
q.setContent(content);
q.setAuthor(user);
q.setCreateDate(LocalDateTime.now());
this.questionRepository.save(q);
}
QuestionService도 업데이트
@PreAuthorize("isAuthenticated()")
@GetMapping("/create")
public String questionCreate(QuestionForm questionForm) {
return "question_form";
}
@PreAuthorize("isAuthenticated()")
@PostMapping("/create")
public String questionCreate(@Valid QuestionForm questionForm,
BindingResult bindingResult, Principal principal) {
if (bindingResult.hasErrors()) {
return "question_form";
}
SiteUser siteUser = this.userService.getUser(principal.getName());
this.questionService.create(questionForm.getSubject(), questionForm.getContent(), siteUser);
return "redirect:/question/list";
}
QuestionController도 똑같이 업데이트
Pricipal 객체가 null이라 발생한 오류를
@PreAuthorize("isAuthenticated()") 어노테이션으로 해결하기
저게 붙으면 로그인한 경우에만 실행됨
해당 메서드는 로그인한 사용자만 호출 가능
저게 붙은 메서드는 로그아웃 상태에서 호출이 되면 로그인 페이지로 강제 이동
@Controller
@RequestMapping("/answer")
@RequiredArgsConstructor
public class AnswerController {
private final QuestionService questionService;
private final AnswerService answerService;
private final UserService userService;
@PreAuthorize("isAuthenticated()")
@PostMapping("/create/{id}")
public String create(Model model, @PathVariable("id") Integer id,
@Valid AnswerForm answerForm, BindingResult bindingResult, Principal principal) {
Question question = this.questionService.getQuestion(id);
SiteUser siteUser = userService.getUser(principal.getName());
if (bindingResult.hasErrors()) {
model.addAttribute("question", question);
return "question_detail";
}
this.answerService.create(question, answerForm.getContent(), siteUser);
return String.format("redirect:/question/detail/%s", id);
}
}
얘도 추가
@EnableMethodSecurity(prePostEnabled = true)
이 어노테이션 추가
이걸 적용한 이유는 Controller 안에 로그인 여부를 판별할 때 사용한
@PreAuthorize 어노테이션을 사용하기 위해 반드시 필요한 설정
<form th:action="@{|/answer/create/${question.id}|}" method="post" class="my-3">
<div th:replace="~{form_errors :: formErrorsFragment}"></div>
<textarea sec:authorize="isAnonymous()" disabled th:field="*{content}" class="form-control" rows="10"></textarea>
<textarea sec:authorize="isAuthenticated()" th:field="*{content}" class="form-control" rows="10"></textarea>
<textarea name="content" id="content" rows="10" class="form-control"></textarea>
<input type="submit" value="답변등록" class="btn btn-primary my-2">
</form>
</div>
</html>
답변 작성 아래에 붙여넣기
<html layout:decorate="~{layout}">
<div layout:fragment="content" class="container my-3">
<table class="table">
<thead class="table-dark">
<tr class="text-center">
<th>번호</th>
<th style="width:50%">제목</th>
<th>글쓴이</th>
<th>작성일시</th>
</tr>
</thead>
<tbody>
<tr class="text-center" th:each="question, loop : ${paging}">
<td th:text="${paging.getTotalElements - (paging.number * paging.size) - loop.index}"></td>
<td class="text-start">
<a th:href="@{|/question/detail/${question.id}|}" th:text="${question.subject}"></a>
<span class="text-danger small ms-2" th:if="${#lists.size(question.answerList) > 0}"
th:text="${#lists.size(question.answerList)}">
</span>
</td>
<td><span th:if="${question.author != null}" th:text="${question.author.username}"></span></td>
<td th:text="${#temporals.format(question.createDate, 'yyyy-MM-dd HH:mm')}"></td>
</tr>
</tbody>
</table>
<html layout:decorate="~{layout}">
<div layout:fragment="content" class="container my-3">
<!-- 질문 -->
<h2 class="border-bottom py-2" th:text="${question.subject}"></h2>
<div class="card my-3">
<div class="card-body">
<div class="card-text" style="white-space: pre-line;" th:text="${question.content}"></div>
<div class="d-flex justify-content-end">
<div class="badge bg-light text-dark p-2 text-start">
<div class="mb-2">
<span th:if="${question.author != null}" th:text="${question.author.username}"></span>
</div>
<div th:text="${#temporals.format(question.createDate, 'yyyy-MM-dd HH:mm')}"></div>
</div>
</div>
</div>
</div>
<!-- 답변의 갯수 표시 -->
<h5 class="border-bottom my-3 py-2"
th:text="|${#lists.size(question.answerList)}개의 답변이 있습니다.|"></h5>
<!-- 답변 반복 시작 -->
<div class="card my-3" th:each="answer : ${question.answerList}">
<div class="card-body">
<div class="card-text" style="white-space: pre-line;" th:text="${answer.content}"></div>
<div class="d-flex justify-content-end">
<div class="badge bg-light text-dark p-2 text-start">
<div class="mb-2">
<span th:if="${answer.author != null}" th:text="${answer.author.username}"></span>
</div>
<div th:text="${#temporals.format(answer.createDate, 'yyyy-MM-dd HH:mm')}"></div>
</div>
</div>
</div>
</div>
<!-- 답변 반복 끝 -->
private LocalDateTime modifyDate;
둘 다 추가하기
<html layout:decorate="~{layout}">
<div layout:fragment="content" class="container my-3">
<!-- 질문 -->
<h2 class="border-bottom py-2" th:text="${question.subject}"></h2>
<div class="card my-3">
<div class="card-body">
<div class="card-text" style="white-space: pre-line;" th:text="${question.content}"></div>
<div class="d-flex justify-content-end">
<div class="badge bg-light text-dark p-2 text-start">
<div class="mb-2">
<span th:if="${question.author != null}" th:text="${question.author.username}"></span>
</div>
<div th:text="${#temporals.format(question.createDate, 'yyyy-MM-dd HH:mm')}"></div>
</div>
</div>
<div class="my-3">
<a th:href="@{|/question/modify/${question.id}|}" class="btn btn-sm btn-outline-secondary"
sec:authorize="isAuthenticated()"
th:if="${question.author != null and #authentication.getPrincipal().getUsername() == question.author.username}"
th:text="수정"></a>
</div>
</div>
@PreAuthorize("isAuthenticated()")
@GetMapping("/modify/{id}")
public String questionModify(QuestionForm questionForm, @PathVariable("id") Integer id, Principal principal) {
Question question = this.questionService.getQuestion(id);
if ((!question.getAuthor().getUsername().equals(principal.getName()))) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "수정권한이 없습니다.");
}
questionForm.setSubject(question.getSubject());
questionForm.setContent(question.getContent());
return "question_form";
}
questionmodify = 로그인한 사용자와 질문의 작성자가 동일하지 않을 경우 오류 발생
수정할 질문의 제목과 내용을 보여주기 위해
questionForm 객체에 id 값으로 조회한 질문의 제목과 내용의 값을 담아 템플릿으로 전달
이 과정이 없으면 질문 수정 화면에 제목, 내용의 값이 채워지지 않아있음
<html layout:decorate="~{layout}">
<div layout:fragment="content" class="container">
<h5 class="my-3 border-bottom pb-2">질문등록</h5>
<form th:object="${questionForm}" method="post">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
<div th:replace="~{form_errors :: formErrorsFragment}"></div>
<div class="mb-3">
<label for="subject" class="form-label">제목</label>
<input type="text" th:field="*{subject}" class="form-control">
</div>
<div class="mb-3">
<label for="content" class="form-label">내용</label>
<textarea th:field="*{content}" class="form-control" rows="10"></textarea>
</div>
<input type="submit" value="저장하기" class="btn btn-primary my-2">
</form>
</div>
</html>
public void modify(Question question, String subject, String content) {
question.setSubject(subject);
question.setContent(content);
question.setModifyDate(LocalDateTime.now());
this.questionRepository.save(question);
}
@PreAuthorize("isAuthenticated()")
@PostMapping("/modify/{id}")
public String questionModify(@Valid QuestionForm questionForm, BindingResult bindingResult,
@PathVariable("id") Integer id, Principal principal) {
if (bindingResult.hasErrors()) {
return "question_form";
}
Question question = this.questionService.getQuestion(id);
if (!question.getAuthor().getUsername().equals(principal.getName())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "수정권한이 없습니다.");
}
this.questionService.modify(question, questionForm.getSubject(), questionForm.getContent());
return String.format("redirect:/question/detail/%s", + id);
}
questionModify 메서드는 questionForm의 데이터를 검증하고
로그인한 사용자와 수정하려는 질문의 작성자가 동일한지 검증
검증을 통과하면 modify 메서드를 호출하여 질문 데이터를 수정
수정이 완료되면 질문 상세 화면으로 리다이렉트
<div class="my-3">
<a th:href="@{|/question/modify/${question.id}|}" class="btn btn-sm btn-outline-secondary"
sec:authorize="isAuthenticated()"
th:if="${question.author != null and #authentication.getPrincipal().getUsername() == question.author.username}"
th:text="수정"></a>
<a href="javascript:void(0);" th:data-uri="@{|/question/delete/${question.id}|}"
class="delete btn btn-sm btn-outline-secondary" sec:authorize="isAuthenticated()"
th:if="${question.author != null and #authentication.getPrincipal().getUsername() == question.author.username}"
th:text="삭제"></a>
</div>
</div>
<script type='text/javascript'>
const delete_elements = document.getElementsByClassName("delete");
Array.from(delete_elements).forEach(function(element) {
element.addEventListener('click', function() {
if(confirm("정말로 삭제하시겠습니까?")) {
location.href = this.dataset.uri;
};
});
});
</script>
<html>
<head>
(... 생략 ...)
</head>
<body>
(... 생략 ...)
<!-- 이곳에 추가 -->
</body>
</html>
</body> 태그 바로 위에 삽입하는 게 좋음
<!doctype html>
<html lang="ko">
<head>
<!-- Required meta tags -->
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<!-- Bootstrap CSS -->
<link rel="stylesheet" type="text/css" th:href="@{/bootstrap.min.css}">
<!-- sbb CSS -->
<link rel="stylesheet" type="text/css" th:href="@{/style.css}">
<title>Hello, sbb!</title>
</head>
<body>
<!-- 네비게이션바 -->
<nav th:replace="~{navbar :: navbarFragment}"></nav>
<!-- 기본 템플릿 안에 삽입될 내용 Start -->
<th:block layout:fragment="content"></th:block>
<!-- 기본 템플릿 안에 삽입될 내용 End -->
<!-- Bootstrap JS -->
<script th:src="@{/bootstrap.min.js}"></script>
<!-- 자바스크립트 Start -->
<th:block layout:fragment="script"></th:block>
<!-- 자바스크립트 End -->
</body>
</html>
</div>
<script layout:fragment="script" type='text/javascript'>
const delete_elements = document.getElementsByClassName("delete");
Array.from(delete_elements).forEach(function(element) {
element.addEventListener('click', function() {
if(confirm("정말로 삭제하시겠습니까?")) {
location.href = this.dataset.uri;
};
});
});
</script>
</html>
html 위에 붙여넣기
public void delete(Question question) {
this.questionRepository.delete(question);
}
@PreAuthorize("isAuthenticated()")
@PostMapping("/delete/{id}")
public String questionDelete(@PathVariable("id") Integer id, Principal principal) {
Question question = this.questionService.getQuestion(id);
if (!question.getAuthor().getUsername().equals(principal.getName())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "삭제권한이 없습니다.");
}
this.questionService.delete(question);
return "redirect:/";
}
삭제 버튼을 클릭하면 URL로 전달 받은 id 값을 사용해 Question 데이터를 조회한 후,
사용자와 질문 작성자가 동일한 경우 앞서 작성한 서비스를 이용해 질문을 삭제하게 함
질문을 삭제한 후에는 질문 목록 화면으로 돌아갈 수 있게 했음
<div th:text="${#temporals.format(answer.createDate, 'yyyy-MM-dd HH:mm')}"></div>
</div>
</div>
<div class="my-3">
<a th:href="@{|/answer/modify/${answer.id}|}" class="btn btn-sm btn-outline-secondary"
sec:authorize="isAuthenticated()"
th:if="${answer.author != null and #authentication.getPrincipal().getUsername() == answer.author.username}"
th:text="수정"></a>
</div>
</div>
</div>
<!-- 답변 반복 끝 -->
public Answer getAnswer(Integer id) {
Optional<Answer> answer = this.answerRepository.findById(id);
if (answer.isPresent()) {
return answer.get();
} else {
throw new DataNotFoundException("Answer not found");
}
}
public void modify(Answer answer, String content) {
answer.setContent(content);
answer.setModifyDate(LocalDateTime.now());
this.answerRepository.save(answer);
}
답변을 조회하는 getAnswer 메서드, 답변 내용을 수정하는 modify 메서드 추가
@PreAuthorize("isAuthenticated()")
@PostMapping("/modify/{id}")
public String answerModify(AnswerForm answerForm, @PathVariable("id") Integer id, Principal principal) {
Answer answer = this.answerService.getAnswer(id);
if (!answer.getAuthor().getUsername().equals(principal.getName())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "수정권한이 없습니다.");
}
answerForm.setContent(answer.getContent());
return "answer_form";
}
answerModify 메서드 추가
DB에서 답변 ID를 통해 조회한 답변 데이터의 내용(content)을
AnswerForm 객체에 대입해 answer_form 템플릿에서 사용할 수 있게 했음
<html layout:decorate="~{layout}">
<div layout:fragment="content" class="container">
<h5 class="my-3 border-bottom pb-2">답변 수정</h5>
<form th:object="${answerForm}" method="post">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
<div th:replace="~{form_errors :: formErrorsFragment}"></div>
<div class="mb-3">
<label for="content" class="form-label">내용</label>
<textarea th:field="*{content}" class="form-control" rows="10"></textarea>
</div>
<input type="submit" value="저장하기" class="btn btn-primary my-2">
</form>
</div>
</html>
(... 생략 ...)
<!-- 답변 반복 시작 -->
<div class="card my-3" th:each="answer : ${question.answerList}">
<div class="card-body">
(... 생략 ...)
<div class="my-3">
<a th:href="@{|/answer/modify/${answer.id}|}" class="btn btn-sm btn-outline-secondary"
sec:authorize="isAuthenticated()"
th:if="${answer.author != null and #authentication.getPrincipal().getUsername() == answer.author.username}"
th:text="수정"></a>
<a href="javascript:void(0);" th:data-uri="@{|/answer/delete/${answer.id}|}"
class="delete btn btn-sm btn-outline-secondary" sec:authorize="isAuthenticated()"
th:if="${answer.author != null and #authentication.getPrincipal().getUsername() == answer.author.username}"
th:text="삭제"></a>
</div>
</div>
</div>
<!-- 답변 반복 끝 -->
(... 생략 ...)
public void delete(Answer answer) {
this.answerRepository.delete(answer);
}
@PreAuthorize("isAuthenticated()")
@GetMapping("/delete/{id}")
public String answerDelete(@PathVariable("id") Integer id, Principal principal) {
Answer answer = this.answerService.getAnswer(id);
if (!answer.getAuthor().getUsername().equals(principal.getName())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "삭제 권한이 없습니다.");
}
this.answerService.delete(answer);
return String.format("redirect:/question/detail/%s", answer.getQuestion().getId());
}
(... 생략 ...)
<!-- 질문 -->
<h2 class="border-bottom py-2" th:text="${question.subject}"></h2>
<div class="card my-3">
<div class="card-body">
<div class="card-text" style="white-space: pre-line;" th:text="${question.content}"></div>
<div class="d-flex justify-content-end">
<div th:if="${question.modifyDate != null}" class="badge bg-light text-dark p-2 text-start mx-3">
<div class="mb-2">modified at</div>
<div th:text="${#temporals.format(question.modifyDate, 'yyyy-MM-dd HH:mm')}"></div>
</div>
<div class="badge bg-light text-dark p-2 text-start">
<div class="mb-2">
<span th:if="${question.author != null}" th:text="${question.author.username}"></span>
</div>
<div th:text="${#temporals.format(question.createDate, 'yyyy-MM-dd HH:mm')}"></div>
</div>
</div>
(... 생략 ...)
</div>
</div>
(... 생략 ...)
<!-- 답변 반복 시작 -->
<div class="card my-3" th:each="answer : ${question.answerList}">
<div class="card-body">
<div class="card-text" style="white-space: pre-line;" th:text="${answer.content}"></div>
<div class="d-flex justify-content-end">
<div th:if="${answer.modifyDate != null}" class="badge bg-light text-dark p-2 text-start mx-3">
<div class="mb-2">modified at</div>
<div th:text="${#temporals.format(answer.modifyDate, 'yyyy-MM-dd HH:mm')}"></div>
</div>
<div class="badge bg-light text-dark p-2 text-start">
<div class="mb-2">
<span th:if="${answer.author != null}" th:text="${answer.author.username}"></span>
</div>
<div th:text="${#temporals.format(answer.createDate, 'yyyy-MM-dd HH:mm')}"></div>
</div>
</div>
(... 생략 ...)
</div>
</div>
<!-- 답변 반복 끝 -->
(... 생략 ...)
@ManyToMany
Set<SiteUser> voter;
<html layout:decorate="~{layout}">
<div layout:fragment="content" class="container my-3">
<!-- 질문 -->
<h2 class="border-bottom py-2" th:text="${question.subject}"></h2>
<div class="card my-3">
<div class="card-body">
<div class="card-text" style="white-space: pre-line;" th:text="${question.content}"></div>
<div class="d-flex justify-content-end">
<div th:if="${question.modifyDate != null}" class="badge bg-light text-dark p-2 text-start mx-3">
<div class="mb-2">modified at</div>
<div th:text="${#temporals.format(question.modifyDate, 'yyyy-MM-dd HH:mm')}"></div>
</div>
<div class="badge bg-light text-dark p-2 text-start">
<div class="mb-2">
<span th:if="${question.author != null}" th:text="${question.author.username}"></span>
</div>
<div th:text="${#temporals.format(question.createDate, 'yyyy-MM-dd HH:mm')}"></div>
</div>
</div>
<div class="my-3">
<a href="javascript:void(0);" class="recommend btn btn-sm btn-outline-secondary"
th:data-uri="@{|/question/vote/${question.id}|}">
추천
<span class="badge rounded-pill bg-success" th:text="${#lists.size(question.voter)}"></span>
</a>
<a th:href="@{|/question/modify/${question.id}|}" class="btn btn-sm btn-outline-secondary"
sec:authorize="isAuthenticated()"
th:if="${question.author != null and #authentication.getPrincipal().getUsername() == question.author.username}"
th:text="수정"></a>
<a href="javascript:void(0);" th:data-uri="@{|/question/delete/${question.id}|}"
class="delete btn btn-sm btn-outline-secondary" sec:authorize="isAuthenticated()"
th:if="${question.author != null and #authentication.getPrincipal().getUsername() == question.author.username}"
th:text="삭제"></a>
</div>
</div>
<!-- 답변의 갯수 표시 -->
<h5 class="border-bottom my-3 py-2"
th:text="|${#lists.size(question.answerList)}개의 답변이 있습니다.|"></h5>
<!-- 답변 반복 시작 -->
<div class="card my-3" th:each="answer : ${question.answerList}">
<div class="card-body">
<div class="card-text" style="white-space: pre-line;" th:text="${answer.content}"></div>
<div class="d-flex justify-content-end">
<div th:if="${answer.modifyDate != null}" class="badge bg-light text-dark p-2 text-start mx-3">
<div class="mb-2">modified at</div>
<div th:text="${#temporals.format(answer.modifyDate, 'yyyy-MM-dd HH:mm')}"></div>
</div>
<div class="badge bg-light text-dark p-2 text-start">
<div class="mb-2">
<span th:if="${answer.author != null}" th:text="${answer.author.username}"></span>
</div>
<div th:text="${#temporals.format(answer.createDate, 'yyyy-MM-dd HH:mm')}"></div>
</div>
</div>
<div class="my-3">
<a th:href="@{|/answer/modify/${answer.id}|}" class="btn btn-sm btn-outline-secondary"
sec:authorize="isAuthenticated()"
th:if="${answer.author != null and #authentication.getPrincipal().getUsername() == answer.author.username}"
th:text="수정"></a>
<a href="javascript:void(0);" th:data-uri="@{|/answer/delete/${answer.id}|}"
class="delete btn btn-sm btn-outline-secondary" sec:authorize="isAuthenticated()"
th:if="${answer.author != null and #authentication.getPrincipal().getUsername() == answer.author.username}"
th:text="삭제"></a>
</div>
</div>
</div>
<!-- 답변 반복 끝 -->
<!-- 답변 작성 -->
<form th:action="@{|/answer/create/${question.id}|}" method="post" class="my-3">
<div th:replace="~{form_errors :: formErrorsFragment}"></div>
<textarea sec:authorize="isAnonymous()" disabled th:field="*{content}" class="form-control" rows="10"></textarea>
<textarea sec:authorize="isAuthenticated()" th:field="*{content}" class="form-control" rows="10"></textarea>
<textarea name="content" id="content" rows="10" class="form-control"></textarea>
<input type="submit" value="답변등록" class="btn btn-primary my-2">
</form>
</div>
</div>
<script layout:fragment="script" type='text/javascript'>
const delete_elements = document.getElementsByClassName("delete");
Array.from(delete_elements).forEach(function(element) {
element.addEventListener('click', function() {
if(confirm("정말로 삭제하시겠습니까?")) {
location.href = this.dataset.uri;
};
});
});
const recommend_elements = document.getElementsByClassName("recommend");
Array.from(recommend_elements).forEach(function(element) {
element.addEventListener('click', function() {
if(confirm("정말로 추천하시겠습니까?")) {
location.href = this.dataset.uri;
};
});
});
</script>
</html>
public void vote(Question question, SiteUser siteUser) {
question.getVoter().add(siteUser);
this.questionRepository.save(question);
}
@PreAuthorize("isAuthenticated()")
@GetMapping("/vote/{id}")
public String questionVote(@PathVariable("id") Integer id, Principal principal) {
Question question = this.questionService.getQuestion(id);
SiteUser siteUser = this.userService.getUser(principal.getName());
this.questionService.vote(question, siteUser);
return String.format("redirect:/question/detail/%s", id);
}
<!-- 답변 반복 시작 -->
<div class="card my-3" th:each="answer : ${question.answerList}">
<div class="card-body">
(... 생략 ...)
<div class="my-3">
<a href="javascript:void(0);" class="recommend btn btn-sm btn-outline-secondary"
th:data-uri="@{|/answer/vote/${answer.id}|}">
추천
<span class="badge rounded-pill bg-success" th:text="${#lists.size(answer.voter)}"></span>
</a>
<a th:href="@{|/answer/modify/${answer.id}|}" class="btn btn-sm btn-outline-secondary"
sec:authorize="isAuthenticated()"
th:if="${answer.author != null and #authentication.getPrincipal().getUsername() == answer.author.username}"
th:text="수정"></a>
<a href="javascript:void(0);" th:data-uri="@{|/answer/delete/${answer.id}|}"
class="delete btn btn-sm btn-outline-secondary" sec:authorize="isAuthenticated()"
th:if="${answer.author != null and #authentication.getPrincipal().getUsername() == answer.author.username}"
th:text="삭제"></a>
</div>
</div>
</div>
<!-- 답변 반복 끝 -->
public void vote(Answer answer, SiteUser siteUser) {
answer.getVoter().add(siteUser);
this.answerRepository.save(answer);
}
@PreAuthorize("isAuthenticated()")
@GetMapping("/vote/{id}")
public String answerVote(Principal principal, @PathVariable("id") Integer id) {
Answer answer = this.answerService.getAnswer(id);
SiteUser siteUser = this.userService.getUser(principal.getName());
this.answerService.vote(answer, siteUser);
return String.format("redirect:/question/detail/%s", answer.getQuestion().getId());
}
<!-- 답변 반복 시작 -->
<div class="card my-3" th:each="answer : ${question.answerList}">
<a th:id="|answer_${answer.id}|"></a>
<div class="card-body">
@Controller
@RequestMapping("/answer")
@RequiredArgsConstructor
public class AnswerController {
private final QuestionService questionService;
private final AnswerService answerService;
private final UserService userService;
@PreAuthorize("isAuthenticated()")
@PostMapping("/create/{id}")
public String answerCreate(Model model, @PathVariable("id") Integer id,
@Valid AnswerForm answerForm, BindingResult bindingResult, Principal principal) {
Question question = this.questionService.getQuestion(id);
SiteUser siteUser = userService.getUser(principal.getName());
if (bindingResult.hasErrors()) {
model.addAttribute("question", question);
return "question_detail";
}
Answer answer = this.answerService.create(question, answerForm.getContent(), siteUser);
return String.format("redirect:/question/detail/%s#answer_%s", answer.getQuestion().getId(), answer.getId());
}
@PreAuthorize("isAuthenticated()")
@PostMapping("/modify/{id}")
public String answerModify(@Valid AnswerForm answerForm, BindingResult bindingResult,
@PathVariable("id") Integer id, Principal principal) {
if (bindingResult.hasErrors()) {
return "answer_form";
}
Answer answer = this.answerService.getAnswer(id);
if (!answer.getAuthor().getUsername().equals(principal.getName())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "수정권한이 없습니다.");
}
this.answerService.modify(answer, answerForm.getContent());
return String.format("redirect:/question/detail/%s#answer_%s", answer.getQuestion().getId(), answer.getId());
}
@PreAuthorize("isAuthenticated()")
@GetMapping("/delete/{id}")
public String answerDelete(@PathVariable("id") Integer id, Principal principal) {
Answer answer = this.answerService.getAnswer(id);
if (!answer.getAuthor().getUsername().equals(principal.getName())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "삭제권한이 없습니다.");
}
this.answerService.delete(answer);
return String.format("redirect:/question/detail/%s", answer.getQuestion().getId());
}
@PreAuthorize("isAuthenticated()")
@GetMapping("/vote/{id}")
public String answerVote(@PathVariable("id") Integer id, Principal principal) {
Answer answer = this.answerService.getAnswer(id);
SiteUser siteUser = this.userService.getUser(principal.getName());
this.answerService.vote(answer, siteUser);
return String.format("redirect:/question/detail/%s#answer_%s", answer.getQuestion().getId(), answer.getId());
}
}
return String.format("redirect:/question/detail/%s#answer_%s", answer.getQuestion().getId(), answer.getId());
마지막에 리다이렉트 해주는 걸 바꿔야됨 그래야 앵커 기능을 사용 가능