SpringBoot - 로그인과 회원가입

HI_DO·2024년 6월 18일
post-thumbnail

스프링 시큐리티

  • 스프링 기반 웹 애플리케이션의 인증과 권한을 담당하는 스프링의 하위 프레임워크이다
  • 인증(authenticate)은 로그인과 같은 사용자의 신원을 확인하는 프로세스를, 권한(authorize)은 인증된 사용자가 어떤 일을 할 수 있는지(어떤 접근 권한이 있는지)를 관리하는 것을 의미한다.
  • build.gradle에
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6'

시큐리티와 타임리프를 연결해주는 라이브러리 추가 후 refresh
-> 적용후 실행 결과

  • 스프링 시큐리티는 기본적으로 인증되지 않은 사용자가 웹서비스를 사용할 수 없게끔 만드는게 기본값이다.
  • 로그인을 하지 않아도 게시물을 조회할 수 있도록 설정을 변경해줘야 한다.
  • SecurityConfig.java 생성
package com.mysite.sbb;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
@Configuration
@EnableWebSecurity	// 스프링 시큐리티 필터가 스프링 필터체인에 등록이 된다.
public class SecurityConfig {
	@Bean
	SecurityFilterChain filterChain(HttpSecurity http) throws Exception{
		http.authorizeHttpRequests((AuthorizeHttpRequests) ->
		AuthorizeHttpRequests.requestMatchers(new AntPathRequestMatcher("/**")).permitAll());
		return http.build();
	}
}

@Configuration : 스프링의 환경 설정 파일
@EnableWebSECURITY : 모든 요청 URL이 스프링 시큐리티의 제어를 받도록 만드는 에너테이션
-> 이 에너테이션을 통해 시큐리티를 활성화시킬 수 있는데 내부적으로 SecurityFilterChain 클래스 동작하여 모든 요청 URL에 이 클래스 필터로 적용되어 URL별로 특별한 설정을 할 수 있다.

  • There was an unexpected error (type=Forbidden, status=403).
    Forbidden
    -> 스프링 시큐리티의 CSRF방어 기능에 의해 H2콘솔 접근이 거부된것.
    CSRF는 웹 보안 공격 중 하나로, 조작된 정보로 웹 사이트가 실행되도록 속이는 공격 기술이다.
    스프링 시큐리티는 이 공격을 방지하기 위해 CSRF 토큰을 세션을 통해 발행하고, 웹 페이지에서는 폼 전송시 해당 토큰을 함께 전송해서 실제 웹 페이지에서 작성한 데이터가 전달되는지를 검증한다.
    == 토큰이란 요청을 식별하고 검증하는데 사용하는 특수한 문자열 또는 값을 의미한다.
    == 세션이란 사용자의 상태를 유지하고 관리하는데 사용하는 기능이다.

<form action="/question/create" method="post"><input type="hidden" name="_csrf" value="SzbJ15G-MjZdJl7BGAw995aTGZxZJhiGCPmKbRKOXCOA2O8BegD_7qiHBgZwETulLCEJwK-iNP06Qi2raZjsXXbqaBq46dk1"/>

-> 스프링 시큐리티에 의해 CSRF토큰이 자동으로 생성된다.
== CSRF토큰은 서버에서 생성되는 임의의값으로 페이지 요청시 항상 다른 값으로 생성한다.
스프링 시큐리티는 이런 식으로 페이지에 CSRF 토큰을 발행하여 이 값이 다시 서버로 정확하게 들어오는지 확인하는 과정을 거친다. 만약 CSRF 토큰이 없거나 해커가 임의의 CSRF 토큰을 강제로 만들어 전송하면 스프링 시큐리티에 의해 차단된다.

  • SecurityConfig.java 수정
package com.mysite.sbb;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
@Configuration
@EnableWebSecurity   // 스프링 시큐리티 필터가 스프링 필터체인에 등록이 된다.
public class SecurityConfig {
   @Bean
   SecurityFilterChain filterChain(HttpSecurity http) throws Exception{
      http.authorizeHttpRequests((authorizeHttpRequests) ->
      authorizeHttpRequests.requestMatchers(new AntPathRequestMatcher("/**")).permitAll())
      .csrf(csrf -> csrf	//	/h2-console/로 시작하는 모든 URL은 CSRF 검증을 하지 않겠다.
            .ignoringRequestMatchers(new AntPathRequestMatcher("/h2-console/**")));    
      return http.build();
   }
}

  • H2화면이 프레임 구조로 작성되어있다.
  • H2레이아웃이 화면처럼 작업 영역이 나눠져 있다.
  • 스프링 시큐리티는 웹 사이트의 콘텐츠가 다른 사이트에 포함되지 않도록 하기 위해 X-Frame-Options 헤더의 기본값을 DENY로 사용하는데, 프레임 구조의 웹사이트는 이 헤더의 값을 DENY인 경우 오류가 발생한다.
    == 스프링 부트에서 X-Frame-Options 헤더는 클릭재킹 공격을 막기 위해 사용한다.
    클릭재킹 : 사용자의 의도와 다른 작업없이 수행되도록 속이는 보안 공격 기술
  • SecurityConfig.java 수정
package com.mysite.sbb;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.header.writers.frameoptions.XFrameOptionsHeaderWriter;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
@Configuration
@EnableWebSecurity   // 스프링 시큐리티 필터가 스프링 필터체인에 등록이 된다.
public class SecurityConfig {
   @Bean
   SecurityFilterChain filterChain(HttpSecurity http) throws Exception{
      http.authorizeHttpRequests((authorizeHttpRequests) ->
      authorizeHttpRequests.requestMatchers(new AntPathRequestMatcher("/**")).permitAll())
      .csrf(csrf -> csrf	//	/h2-console/로 시작하는 모든 URL은 CSRF 검증을 하지 않겠다.
            .ignoringRequestMatchers(new AntPathRequestMatcher("/h2-console/**")))
      .headers(headers -> headers
    		  .addHeaderWriter(new XFrameOptionsHeaderWriter(
    				  XFrameOptionsHeaderWriter.XFrameOptionsMode.SAMEORIGIN)));      
      return http.build();
   }
}
  • URL 요청시 X-Frame-Options 헤더를 DENY 대신 SAMEORIGIN 으로 설정하여 오류가 발생하지 않도록 정의
  • X-Frame-Options 헤더의 값으로 SAMEORIGIN을 설정하면 프레임에 포함된 웹 페이지가 동일한 사이트에서 제공할때만 사용이 허락된다.

회원가입 기능 구현

  • 회원 entity
    username, password, email
  • SiteUser.java 생성
package com.mysite.sbb.user;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import lombok.Getter;
import lombok.Setter;
@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;
}
  • UserRepository.java(interface) 생성
package com.mysite.sbb.user;
import org.springframework.data.jpa.repository.JpaRepository;
public interface UserRepository extends JpaRepository<SiteUser, Long>{
}
  • UserService.java 생성
package com.mysite.sbb.user;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor
@Service
public class UserService {
	private final UserRepository userRepository;
	public SiteUser create(String username, String email, String password) {
		SiteUser user = new SiteUser();
		user.setUsername(username);
		user.setEmail(email);
		// 스프링 시큐리티에서 BCrytPasswordEncoder를 사용
		// 해시함수를 사용하는데 비밀번호 같은 보안 정보를 안전하게 저장하고 검증할 때 사용하는 암호화 기술
		BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
		user.setPassword(passwordEncoder.encode(password));
		this.userRepository.save(user);
		return user;
	}
}
  • SecurityConfig.java
package com.mysite.sbb;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.header.writers.frameoptions.XFrameOptionsHeaderWriter;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
@Configuration
@EnableWebSecurity   // 스프링 시큐리티 필터가 스프링 필터체인에 등록이 된다.
public class SecurityConfig {
   @Bean
   SecurityFilterChain filterChain(HttpSecurity http) throws Exception{
      http.authorizeHttpRequests((authorizeHttpRequests) ->
      authorizeHttpRequests.requestMatchers(new AntPathRequestMatcher("/**")).permitAll())
      .csrf(csrf -> csrf	//	/h2-console/로 시작하는 모든 URL은 CSRF 검증을 하지 않겠다.
            .ignoringRequestMatchers(new AntPathRequestMatcher("/h2-console/**")))
      .headers(headers -> headers
    		  .addHeaderWriter(new XFrameOptionsHeaderWriter(
    				  XFrameOptionsHeaderWriter.XFrameOptionsMode.SAMEORIGIN)));   
      return http.build();
   }  
   @Bean	//Ioc(Inversion of Control)로 등록.
   PasswordEncoder passwordEncoder() {
	   return new BCryptPasswordEncoder();
   }
}
  • UserSerive.java 수정
package com.mysite.sbb.user;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor
@Service
public class UserService {	
	private final UserRepository userRepository;
	private final PasswordEncoder passwordEncoder;
	public SiteUser create(String username, String email, String password) {
		SiteUser user = new SiteUser();
		user.setUsername(username);
		user.setEmail(email);
		// 스프링 시큐리티에서 BCrytPasswordEncoder를 사용
		// 해시함수를 사용하는데 비밀번호 같은 보안 정보를 안전하게 저장하고 검증할 때 사용하는 암호화 기술
		user.setPassword(passwordEncoder.encode(password));
		this.userRepository.save(user);
		return user;
	}
}
  • UserCreateForm.java 생성
package com.mysite.sbb.user;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Size;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class UserCreateForm {
   @Size(min =3, max =25)
   @NotEmpty(message = "사용자 ID는 필수 항목입니다.")
   private String username;  
   @NotEmpty(message = "비밀번호는 필수 항목입니다.")
   private String password1;
   @NotEmpty(message = "비밀번호는 필수 항목입니다.")
   private String password2;
   @NotEmpty(message = "이메일은 필수 항목입니다.")
   @Email
   private String email;   
}
  • UserController.java 생성
package com.mysite.sbb.user;
import org.springframework.stereotype.Controller;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
@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";
      }
      userService.create(userCreateForm.getUsername(), userCreateForm.getEmail(), userCreateForm.getPassword1());     
      return "redirect:/";
   }
}
  • signup_form.html 생성
<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>
  • layout.html 수정
<!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: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>
   <!-- 기본 템플릿 안에 삽입될 내용 Start -->
   <th:block layout:fragment="content"></th:block>
   <!-- 기본 템플릿 안에 삽입될 내용 End -->
</body>
</html>
  • 실행 및 결과

로그인과 로그아웃

<a class="nav-link" sec:authorize="isAnonymous()" th:href="@{/user/login}">로그인</a>
<a class="nav-link" sec:authorize="isAuthenticated()" th:href="@{/user/logout}">로그아웃</a>
  • 로그인하지 않은 상태라면 sec:authorize="isAnonymous()" 가 '참'이 되어 '로그인'링크가 표시되고
    로그인한 상태라면 sec:authorize="isAuthenticated()" 가 '참'이 되어 '로그아웃'링크가 표시된다.
  • UserController.java 수정
package com.mysite.sbb.user;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Controller;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
@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";
      }
      try {
      userService.create(userCreateForm.getUsername(), userCreateForm.getEmail(), 
    		  userCreateForm.getPassword1());
      // 사용자 id 또는 이메일 주소가 이미 존재할 경우에
      } catch (DataIntegrityViolationException e) {
    	  e.printStackTrace();
    	  // 필드, 오류코드, 오류메시지
    	  bindingResult.reject("signupFailed", "이미 등록된 사용자입니다.");
    	  return "signup_form";
      } catch (Exception e) {
    	  e.printStackTrace();
    	  bindingResult.reject("signupFailed", e.getMessage());
    	  return "signup_form";
      }
      return "redirect:/";
   }
}
  • SecurityConfig.java 수정
package com.mysite.sbb;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.header.writers.frameoptions.XFrameOptionsHeaderWriter;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
@Configuration
@EnableWebSecurity   // 스프링 시큐리티 필터가 스프링 필터체인에 등록이 된다.
public class SecurityConfig {
   @Bean
   SecurityFilterChain filterChain(HttpSecurity http) throws Exception{
      http.authorizeHttpRequests((authorizeHttpRequests) ->
      authorizeHttpRequests.requestMatchers(new AntPathRequestMatcher("/**")).permitAll())
      .csrf(csrf -> csrf   // /h2-console/로 시작하는 모든 URL은 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   // Ioc(Inversion of Control)로 등록.
   PasswordEncoder passwordEncoder() {
      return new BCryptPasswordEncoder();
   }
}
 .formLogin(formLogin -> formLogin
    		  .loginPage("/user/login")
    		  .defaultSuccessUrl("/"));

-> .formLogin메서드는 스프링 시큐리티의 로그인 설정을 담당하는 메서드.
설정 내용은 로그인 페이지의 url은(/user/login)이고 로그인 성공시에 이동할 페이지를 루트url(/)임을 의미한다.

  • 시큐리티 cycle.
    시큐리티가 /user/login 주소 요청이 오면 낚아채서 로그인을 진행시킨다.
    로그인을 진행이 완료가 되면 시큐리티 session을 만들어준다.(Security ContextHolder)
    오브젝트 타입 => Authentication 타입 객체로 변환
    Authentication 안에 User 정보가 있어야 한다.
    User오브젝트 타입 => UserDetails 타입 객체
    즉, Security Session => Authentication => UserDetails(PrincipalDetails)
  • UserController.java 수정
package com.mysite.sbb.user;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Controller;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
@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";
      }
      try {
      userService.create(userCreateForm.getUsername(), userCreateForm.getEmail(), 
    		  userCreateForm.getPassword1());
      // 사용자 id 또는 이메일 주소가 이미 존재할 경우에
      } catch (DataIntegrityViolationException e) {
    	  e.printStackTrace();
    	  // 필드, 오류코드, 오류메시지
    	  bindingResult.reject("signupFailed", "이미 등록된 사용자입니다.");
    	  return "signup_form";
      } catch (Exception e) {
    	  e.printStackTrace();
    	  bindingResult.reject("signupFailed", e.getMessage());
    	  return "signup_form";
      }
      return "redirect:/";
   }   
   @GetMapping("/login")
   public String login() {
	   return "login_form";
   }
}
  • login_form.html 생성
<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>
<div th:if="${param.error}">
         <div class="alert alert-danger">
            사용자ID 또는 비밀번호를 확인해 주세요.
         </div>
      </div>

의 ${param.error}로 error 매개변수가 전달된다.
-> 스프링 시큐리티에 무엇을 기준으로 로그인해야 하는지 아직 설정하지 않았기 때문,
DB에서 사용자를 조회하는 서비스(UserSecurityService)를 만들고, 그 서비스를 스프링 시큐리티에 등록하는 방법

  • UserRepository.java 수정
package com.mysite.sbb.user;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface UserRepository extends JpaRepository<SiteUser, Long>{
	Optional<SiteUser> findByUsename(String username);
}
  • UserRole.java (enum) 생성
package com.mysite.sbb.user;
import lombok.Getter;
@Getter
public enum UserRole {
	ADMIN("ROLE_ADMIN"), USER("ROLE_USER");	
	UserRole(String value) {
		this.value=value;
	}	
	private String value;
}

-> 스프링 시큐리티는 인증뿐만 아니라 권한도 관리한다.
스프링 시큐리티는 사용자 인증 후에 사용자에게 부여할 권한과 관련된 처리가 필요하다
우리는 사용자가 로그인한 후, ADMIN인지 USER인지와 같은 권한을 부여해야 한다.

  • UserSecurityService.java 생성
package com.mysite.sbb.user;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor
@Service
public class UserSecurityService implements UserDetailsService {
   private final UserRepository userRepository;
// 시큐리티 session(내부 Authentication(내부 UserDetails))
   @Override
   public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
      Optional<SiteUser> _siteUser = this.userRepository.findByUsername(username);
      if (_siteUser.isEmpty()) {
         throw new UsernameNotFoundException("사용자를 찾을수 없습니다.");
      }
      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()));
      }
      // User 객체 생성, 이 객체는 스프링 시큐리티에서 사용하며 User 생성자에는 id, pw, 권한 리스트가 전달된다
      return new User(siteUser.getUsername(), siteUser.getPassword(), authorities);
   }
}
  • SecurityConfig.java 수정
package com.mysite.sbb;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.header.writers.frameoptions.XFrameOptionsHeaderWriter;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
@Configuration
@EnableWebSecurity   // 스프링 시큐리티 필터가 스프링 필터체인에 등록이 된다.
public class SecurityConfig {
   @Bean
   SecurityFilterChain filterChain(HttpSecurity http) throws Exception{
      http.authorizeHttpRequests((authorizeHttpRequests) ->
      authorizeHttpRequests.requestMatchers(new AntPathRequestMatcher("/**")).permitAll())
      .csrf(csrf -> csrf   // /h2-console/로 시작하는 모든 URL은 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   // Ioc(Inversion of Control)로 등록.
   PasswordEncoder passwordEncoder() {
      return new BCryptPasswordEncoder();
   }  
   @Bean	//스프링 시큐리티의 인증을 처리, UserSecurityServicePasswordEncoder를 내부적으로 사용하여
   // 인증과 권한을 부여 처리한다.
   AuthenticationManager authenticationManager(AuthenticationConfiguration
		   authenticationConfiguration) throws Exception{
	   return authenticationConfiguration.getAuthenticationManager();
   }
}
  • 실행 및 결과

  • SecurityConfig.java 수정
	package com.mysite.sbb;
	import org.springframework.context.annotation.Bean;
	import org.springframework.context.annotation.Configuration;
	import org.springframework.security.authentication.AuthenticationManager;
	import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
	import org.springframework.security.config.annotation.web.builders.HttpSecurity;
	import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
	import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
	import org.springframework.security.crypto.password.PasswordEncoder;
	import org.springframework.security.web.SecurityFilterChain;
	import org.springframework.security.web.header.writers.frameoptions.XFrameOptionsHeaderWriter;
	import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
	@Configuration
	@EnableWebSecurity   // 스프링 시큐리티 필터가 스프링 필터체인에 등록이 된다.
	public class SecurityConfig {	
	   @Bean
	   SecurityFilterChain filterChain(HttpSecurity http) throws Exception{
	      http.authorizeHttpRequests((authorizeHttpRequests) ->
	      authorizeHttpRequests.requestMatchers(new AntPathRequestMatcher("/**")).permitAll())
	      .csrf(csrf -> csrf   // /h2-console/로 시작하는 모든 URL은 CSRF 검증을 하지 않겠다.
	            .ignoringRequestMatchers(new AntPathRequestMatcher("/h2-console/**")))
	      .headers(headers -> headers
	            .addHeaderWriter(new XFrameOptionsHeaderWriter(
	               XFrameOptionsHeaderWriter.XFrameOptionsMode.SAMEORIGIN)))
	      .formLogin(formLogin -> formLogin
	    		  .loginPage("/user/login")
	    		  .defaultSuccessUrl("/"))
	      .logout(logout -> logout
	              .logoutRequestMatcher(new AntPathRequestMatcher("/user/logout"))	//로그아웃 url
	              .logoutSuccessUrl("/")
	              .invalidateHttpSession(true));	// 로그아웃하면 생성된 사용자의 세션도 삭제하도록 해라      
	      return http.build();
	   }	   
	   @Bean   // Ioc(Inversion of Control)로 등록.
	   PasswordEncoder passwordEncoder() {
	      return new BCryptPasswordEncoder();
	   }	   
	   @Bean	//스프링 시큐리티의 인증을 처리, UserSecurityServicePasswordEncoder를 내부적으로 사용하여
	   // 인증과 권한을 부여 처리한다.
	   AuthenticationManager authenticationManager(AuthenticationConfiguration
			   authenticationConfiguration) throws Exception{
		   return authenticationConfiguration.getAuthenticationManager();
	   }
	}
  • 실행 및 결과

  • 질문 또는 답변을 작성할 때 사용자 정보도 DB에 함께 저장되어야 한다.
    질문과 답변을 작성할 때 사용자는 반드시 로그인 되어 있어야 한다.
    그래야 누가 작성한 글인지 알 수 있고, 수정 및 삭제도 가능하기 때문이다.
  • Question.java 수정
package com.mysite.sbb.question;
import java.time.LocalDateTime;
import java.util.List;
import com.mysite.sbb.answer.Answer;
import com.mysite.sbb.user.SiteUser;
import jakarta.persistence.CascadeType;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.OneToMany;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
@Entity
public class Question {
   @Id
   @GeneratedValue(strategy = GenerationType.IDENTITY)
   private Integer id;
   @Column(length = 200)
   private String subject;
   @Column(columnDefinition = "TEXT")
   private String content;
   private LocalDateTime createDate;   
   @OneToMany(mappedBy = "question", cascade = CascadeType.REMOVE)
   private List<Answer> answerList;   // question.getAnswerList()   
   @ManyToOne
   private SiteUser author;
}
  • Answer.java 수정
package com.mysite.sbb.answer;
import java.time.LocalDateTime;
import com.mysite.sbb.question.Question;
import com.mysite.sbb.user.SiteUser;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.ManyToOne;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
@Entity
public class Answer {
   @Id  // 기본키. 각 데이터들을 구분하는 유효한 값.(중복 불가능)
   @GeneratedValue(strategy = GenerationType.IDENTITY)// 고유한 번호를 생성하는 방법.
   private Integer id;   
   // 열 이름 텍스트를 열 데이터로 넣을수 있다. 글자수를 제한할수 없는 경우에 사용.
   @Column(columnDefinition ="TEXT")
   private String content;   
   private LocalDateTime createDate;    
   // Question를 참조해야 하기 위해.
   @ManyToOne
   private Question question;   
   @ManyToOne
   private SiteUser author;
}
  • AnswerController.java 수정
package com.mysite.sbb.answer;
import java.security.Principal;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import com.mysite.sbb.question.Question;
import com.mysite.sbb.question.QuestionService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
@RequestMapping("/answer")
@RequiredArgsConstructor
@Controller
public class AnswerController {
   private final QuestionService questionService;
   private final AnswerService answerService;   
   @PostMapping("/create/{id}")
   public String createAnswer(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";
      }
      // TODO: 답변을 저장한다.
      this.answerService.create(question, answerForm.getContent());
      return String.format("redirect:/question/detail/%s", id);
   }
}
  public String createAnswer(Model model, @PathVariable("id") Integer id, 
    @Valid AnswerForm answerForm, BindingResult bindingResult, Principal principal)

-> Principal principal
현재 로그인한 사용자의 정보를 알려면 시큐리티가 제공하는 Principal 객체를 사용해야 한다
principal.getUsername()

  • UserService.java 수정
package com.mysite.sbb.user;
import java.util.Optional;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import com.mysite.sbb.DataNotFoundException;
import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor
@Service
public class UserService { 
   private final UserRepository userRepository;
   private final PasswordEncoder passwordEncoder; 
   public SiteUser create(String username, String email, String password) {
      SiteUser user = new SiteUser();
      user.setUsername(username);
      user.setEmail(email);
//      스프링시큐리티에서 BCryptPaswerdEncoder를 사용. 
      // 해시함수를 사용하는데 비밀번호 같은 보안 정보를 안전하게 저장하고 검증할때 사용하는 암호화 기술.
      user.setPassword(passwordEncoder.encode(password));
      this.userRepository.save(user);
      return user;
   }   
   public SiteUser getUser(String username) {
      Optional<SiteUser> siteUser = this.userRepository.findByUsername(username);
      if(siteUser.isPresent()) {
         return siteUser.get();               
      }else {
         throw new DataNotFoundException("siteuser not found");
      }
   }
}
  • AnswerService.java 수정
package com.mysite.sbb.answer;
import java.time.LocalDateTime;
import org.springframework.stereotype.Service;
import com.mysite.sbb.question.Question;
import com.mysite.sbb.user.SiteUser;
import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor
@Service
public class AnswerService {	
	private final AnswerRepository answerRepository;
	public void create(Question question, String content, SiteUser author) {
		Answer answer = new Answer();
		answer.setContent(content);
		answer.setCreateDate(LocalDateTime.now());
		answer.setQuestion(question);
		answer.setAuthor(author);
		this.answerRepository.save(answer);
	}
}
  • AnswerController.java 수정
package com.mysite.sbb.answer;
import java.security.Principal;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import com.mysite.sbb.question.Question;
import com.mysite.sbb.question.QuestionService;
import com.mysite.sbb.user.SiteUser;
import com.mysite.sbb.user.UserService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
@RequestMapping("/answer")
@RequiredArgsConstructor
@Controller
public class AnswerController {
   private final QuestionService questionService;
   private final AnswerService answerService;
   private final UserService userService;  
   @PostMapping("/create/{id}")
   public String createAnswer(Model model, @PathVariable("id") Integer id, 
         @Valid AnswerForm answerForm, BindingResult bindingResult, Principal principal) {
      Question question = this.questionService.getQuestion(id);
      SiteUser siteUser = this.userService.getUser(principal.getName());
      if(bindingResult.hasErrors()) {
    	  model.addAttribute("question", question);
    	  return "question_detail";
      }
      // TODO: 답변을 저장한다.
      this.answerService.create(question, answerForm.getContent(), siteUser);
      return String.format("redirect:/question/detail/%s", id);
   }
}
  • QuestionService.java 수정
package com.mysite.sbb.question;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import org.springframework.stereotype.Service;
import com.mysite.sbb.DataNotFoundException;
import com.mysite.sbb.user.SiteUser;
import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor
@Service
public class QuestionService {
   private final QuestionRepository questionRepository; 
   public List<Question> getList(){
      return this.questionRepository.findAll();
   }   
   public Question getQuestion(Integer id) {
      Optional<Question> question = this.questionRepository.findById(id);
      if(question.isPresent()) {
         return question.get();
      }else {
         throw new DataNotFoundException("question not found");
      }
   }
   public void create(String subject, String content, SiteUser user) {
      Question q = new Question();
      q.setSubject(subject);
      q.setContent(content);
      q.setCreateDate(LocalDateTime.now());
      q.setAuthor(user);
      this.questionRepository.save(q);
   }
}
  • QuestionController.java 수정
package com.mysite.sbb.question;
import java.security.Principal;
import java.util.List;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import com.mysite.sbb.answer.AnswerForm;
import com.mysite.sbb.user.SiteUser;
import com.mysite.sbb.user.UserService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
@RequestMapping("/question")
@RequiredArgsConstructor
@Controller
public class QuestionController {   
   private final QuestionService questionService;
   private final UserService userService;   
   @GetMapping("/list")
   public String list(Model model) {
      List<Question> questionList = this.questionService.getList();
      model.addAttribute("questionList", questionList);
      return "question_list";
   }   
   @GetMapping(value = "/detail/{id}")
   public String detail(Model model, @PathVariable("id") Integer id, AnswerForm answerForm) {
      Question question = this.questionService.getQuestion(id);
      model.addAttribute("question",question);
      return "question_detail";
   }   
   @GetMapping("/create")
   public String questionCreate(QuestionForm questionForm) {
      return "question_form";
   }
   @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";   // 질문 저장후 질문 목록으로 이동
   }
}
  • SbbAplicationTests.java 수정
package com.mysite.sbb;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import com.mysite.sbb.question.QuestionService;
@SpringBootTest
class SbbApplicationTests {
    @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, null);
        }
    }
}
  • 실행 및 결과

로그아웃 상태에서 답변을 달 경우


  • because "principal" is null 오류
    principal 객체는 로그인을 해야만 생성되는 객체인데 현재는 로그아웃 상태이므로 principal 객체에 값이 없어 오류가 발생
    -> 해결하려면Authenticated: 인증됨
    @PreAuthorize("isAuthenticated()") 로그인한 경우에만 실행한다.
    로그인한 사용자만 호출할 수 있다.
  • QuestionController.java 수정
package com.mysite.sbb.question;
import java.security.Principal;
import java.util.List;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import com.mysite.sbb.answer.AnswerForm;
import com.mysite.sbb.user.SiteUser;
import com.mysite.sbb.user.UserService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
@RequestMapping("/question")
@RequiredArgsConstructor
@Controller
public class QuestionController {   
   private final QuestionService questionService;
   private final UserService userService;
   @GetMapping("/list")
   public String list(Model model) {
      List<Question> questionList = this.questionService.getList();
      model.addAttribute("questionList", questionList);
      return "question_list";
   }   
   @GetMapping(value = "/detail/{id}")
   public String detail(Model model, @PathVariable("id") Integer id, AnswerForm answerForm) {
      Question question = this.questionService.getQuestion(id);
      model.addAttribute("question",question);
      return "question_detail";
   }
   @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";   // 질문 저장후 질문 목록으로 이동
   }
}
  • AnswerController.java 수정
package com.mysite.sbb.answer;
import java.security.Principal;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import com.mysite.sbb.question.Question;
import com.mysite.sbb.question.QuestionService;
import com.mysite.sbb.user.SiteUser;
import com.mysite.sbb.user.UserService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
@RequestMapping("/answer")
@RequiredArgsConstructor
@Controller
public class AnswerController {
   private final QuestionService questionService;
   private final AnswerService answerService;
   private final UserService userService;   
   @PreAuthorize("isAuthenticated()")
   @PostMapping("/create/{id}")
   public String createAnswer(Model model, @PathVariable("id") Integer id, 
         @Valid AnswerForm answerForm, BindingResult bindingResult, Principal principal) {
      Question question = this.questionService.getQuestion(id);
      SiteUser siteUser = this.userService.getUser(principal.getName());
      if(bindingResult.hasErrors()) {
    	  model.addAttribute("question", question);
    	  return "question_detail";
      }
      // TODO: 답변을 저장한다.
      this.answerService.create(question, answerForm.getContent(), siteUser);
      return String.format("redirect:/question/detail/%s", id);
   }
}
  • SecurityConfig 수정
	package com.mysite.sbb;	
	import org.springframework.context.annotation.Bean;
	import org.springframework.context.annotation.Configuration;
	import org.springframework.security.authentication.AuthenticationManager;
	import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
	import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
	import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
	import org.springframework.security.crypto.password.PasswordEncoder;
	import org.springframework.security.web.SecurityFilterChain;
	import org.springframework.security.web.header.writers.frameoptions.XFrameOptionsHeaderWriter;
	import org.springframework.security.web.util.matcher.AntPathRequestMatcher;	
	@Configuration
	@EnableWebSecurity  	// 스프링 시큐리티 필터가 스프링 필터체인에 등록이 된다.
	@EnableMethodSecurity(prePostEnabled = true)
	public class SecurityConfig {	
	   @Bean
	   SecurityFilterChain filterChain(HttpSecurity http) throws Exception{
	      http.authorizeHttpRequests((authorizeHttpRequests) ->
	      authorizeHttpRequests.requestMatchers(new AntPathRequestMatcher("/**")).permitAll())
	      .csrf(csrf -> csrf   // /h2-console/로 시작하는 모든 URL은 CSRF 검증을 하지 않겠다.
	            .ignoringRequestMatchers(new AntPathRequestMatcher("/h2-console/**")))
	      .headers(headers -> headers
	            .addHeaderWriter(new XFrameOptionsHeaderWriter(
	               XFrameOptionsHeaderWriter.XFrameOptionsMode.SAMEORIGIN)))
	      .formLogin(formLogin -> formLogin
	    		  .loginPage("/user/login")
	    		  .defaultSuccessUrl("/"))
	      .logout(logout -> logout
	              .logoutRequestMatcher(new AntPathRequestMatcher("/user/logout"))	//로그아웃 url
	              .logoutSuccessUrl("/")
	              .invalidateHttpSession(true));	// 로그아웃하면 생성된 사용자의 세션도 삭제하도록 해라	      
	      return http.build();
	   }	   
	   @Bean   // Ioc(Inversion of Control)로 등록.
	   PasswordEncoder passwordEncoder() {
	      return new BCryptPasswordEncoder();
	   }	   
	   @Bean	//스프링 시큐리티의 인증을 처리, UserSecurityServicePasswordEncoder를 내부적으로 사용하여
	   // 인증과 권한을 부여 처리한다.
	   AuthenticationManager authenticationManager(AuthenticationConfiguration
			   authenticationConfiguration) throws Exception{
		   return authenticationConfiguration.getAuthenticationManager();
	   }	
	}

-> @EnableMethodSecurity(prePostEnabled = true)
시큐리티의 보안기능을 활성화하고 @PreAuthorieze, @PostAuthorize을 사용할 수 있게 한다.
-> 질문 등록페이지에서는 사용자가 로그아웃 상태라면 아예 글을 작성할 수 없다.
하지만 답변 등록 페이지에서는 로그아웃 상태에서도 글을 작성할 수 있어서 답변을 작성한 후 등록버튼으 누르면 로그인 화면으로 이동된다.
이렇게 되면 문제가 사용자가 작성한 답변이 사라지는 경우가 있다.
이 문제를 해결하기 위한 방법으로 사용자가 로그아웃 상태인 경우 아예 답변 작성을 못하도록 막는 것으로 처리한다.

->

  • question_detail.html 수정
<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 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 th:text="${#temporals.format(answer.createDate, 'yyyy-MM-dd HH:mm')}"></div>      
            </div>
         </div>
         </div>
      </div>
   <!-- 답변 반복 끝 -->
   <!-- 답변 작성-->
   <form th:action="@{|/answer/create/${question.id}|}" th:object="${answerForm}" 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>			
         <input type="submit" value="답변 등록" class="btn btn-primary my-2"> </form>
   </div>
   </html>
      <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 태그에 disable 속성을 넣어 사용자가 화면에서 아예 입력하지 못하게 만든다.
sec:authorize="isAnonymous()" : 로그아웃 상태
sec:authorize="isAuthenticated()" : 로그인 상태

  • question_list.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 th:each="question, loop : ${questionList}">
         <td th:text="${loop.count}"></td>
         <td class="text-start">
                     <a th:href="@{|/question/detail/${question.id}|}"
                     th:text="${question.subject}"></a>
                  </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>
<a th:href="@{/question/create}" class = "btn btn-primary">질문 등록하기</a>
</div>
</html>
  • question_detail.html 수정
<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>
   <!-- 답변 반복 끝 -->
   <!-- 답변 작성-->
   <form th:action="@{|/answer/create/${question.id}|}" th:object="${answerForm}" 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>           
         <input type="submit" value="답변 등록" class="btn btn-primary my-2"> </form>
   </div>
   </html>
  • 실행 및 결과

    -> 작성자가 표시된다

질문 또는 답변을 작성한 후 글들을 수정하거나 삭제할 수 있어야 한다

  • Question.java 수정
package com.mysite.sbb.question;
import java.time.LocalDateTime;
import java.util.List;
import com.mysite.sbb.answer.Answer;
import com.mysite.sbb.user.SiteUser;
import jakarta.persistence.CascadeType;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.OneToMany;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
@Entity
public class Question {
   @Id
   @GeneratedValue(strategy = GenerationType.IDENTITY)
   private Integer id;
   @Column(length = 200)
   private String subject;
   @Column(columnDefinition = "TEXT")
   private String content;
   private LocalDateTime createDate;  
   @OneToMany(mappedBy = "question", cascade = CascadeType.REMOVE)
   private List<Answer> answerList;   // question.getAnswerList()   
   @ManyToOne
   private SiteUser author;  
   private LocalDateTime modifyDate;
}
  • Answer.java 수정
package com.mysite.sbb.answer;
import java.time.LocalDateTime;
import com.mysite.sbb.question.Question;
import com.mysite.sbb.user.SiteUser;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.ManyToOne;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
@Entity
public class Answer {
   @Id  // 기본키. 각 데이터들을 구분하는 유효한 값.(중복 불가능)
   @GeneratedValue(strategy = GenerationType.IDENTITY)// 고유한 번호를 생성하는 방법.
   private Integer id;   
   // 열 이름 텍스트를 열 데이터로 넣을수 있다. 글자수를 제한할수 없는 경우에 사용.
   @Column(columnDefinition ="TEXT")
   private String content;   
   private LocalDateTime createDate;     
   // Question를 참조해야 하기 위해.
   @ManyToOne
   private Question question;   
   @ManyToOne
   private SiteUser author;   
   private LocalDateTime modifyDate;
}
  • question_detail.html 수정
<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 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>    
   <!-- 답변 개수 표시 -->
   <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>
   <!-- 답변 반복 끝 -->
   <!-- 답변 작성-->
   <form th:action="@{|/answer/create/${question.id}|}" th:object="${answerForm}" 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>            
         <input type="submit" value="답변 등록" class="btn btn-primary my-2"> </form>
   </div>
   </html>

  • #authentication.getPrincipal().getUsername() == question.author.username
    #authentication.getPrincipal()은 타임리프에서 스프링 시큐리티와 함께 사용하는 표현식으로 현재 사용자가 인증되었다면 사용자 이름(id)을 알 수 있다. 로그인한 사용자와 글쓴이가 같다면 수정버튼이 보여라.
  • QuestionController.java 수정
package com.mysite.sbb.question;
import java.security.Principal;
import java.util.List;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.server.ResponseStatusException;
import com.mysite.sbb.answer.AnswerForm;
import com.mysite.sbb.user.SiteUser;
import com.mysite.sbb.user.UserService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
@RequestMapping("/question")
@RequiredArgsConstructor
@Controller
public class QuestionController {   
   private final QuestionService questionService;
   private final UserService userService;  
   @GetMapping("/list")
   public String list(Model model) {
      List<Question> questionList = this.questionService.getList();
      model.addAttribute("questionList", questionList);
      return "question_list";
   }  
   @GetMapping(value = "/detail/{id}")
   public String detail(Model model, @PathVariable("id") Integer id, AnswerForm answerForm) {
      Question question = this.questionService.getQuestion(id);
      model.addAttribute("question",question);
      return "question_detail";
   }   
   @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";   // 질문 저장후 질문 목록으로 이동
   }  
   @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";
   }
}
  • 실행 및 결과

    -> 수정 후 시간 변화
  • question_form.html 수정
<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="alert alert-danger" role="alert" th:if="${#fields.hasAnyErrors()}">
         <div th:each="err : ${#fields.allErrors()}" th:text="${err}" />
      </div>
      <div class="mb-3">
         <label for="subject" class="form-label">제목</label>
         <input type="text" th:field="*{subject}" id="subject" class="form-control">
      </div>
      <div class="mb-3">
         <label for="content" class="form-label">내용</label>
         <textarea th:field="*{content}" id="content" class="form-control" rows="10"></textarea>
      </div>
      <input type="submit" value="저장하기" class="btn btn-primary my-2">
   </form>
</div>
</html>
<form th:object="${questionForm}" method="post">
   <input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />

-> 기존에 있던 [form]의 action 속성을 삭제했다. action을 삭제하면 CSRF값이 자동으로 생성되지 않아서 CSRF값을 설정하기 위해 hidden 형태로 input 요소를 같이 추가한다
== CSRF값을 수동으로 추가해야 하는 이유는 스프링 시큐리티를 사용할때 CSRF 값이 반드시 필요하기 때문이다.

  • QuestionService.java 수정
package com.mysite.sbb.question;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import org.springframework.stereotype.Service;
import com.mysite.sbb.DataNotFoundException;
import com.mysite.sbb.user.SiteUser;
import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor
@Service
public class QuestionService {
   private final QuestionRepository questionRepository; 
   public List<Question> getList(){
      return this.questionRepository.findAll();
   }   
   public Question getQuestion(Integer id) {
      Optional<Question> question = this.questionRepository.findById(id);
      if(question.isPresent()) {
         return question.get();
      }else {
         throw new DataNotFoundException("question not found");
      }
   }
   public void create(String subject, String content, SiteUser user) {
      Question q = new Question();
      q.setSubject(subject);
      q.setContent(content);
      q.setCreateDate(LocalDateTime.now());
      q.setAuthor(user);
      this.questionRepository.save(q);
   }
   public void modify(Question question, String subject, String content) {
	   question.setSubject(subject);
	   question.setContent(content);
	   question.setModifyDate(LocalDateTime.now());
	   this.questionRepository.save(question);
   }
}
  • QuestionController.java 수정
package com.mysite.sbb.question;
import java.security.Principal;
import java.util.List;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.server.ResponseStatusException;
import com.mysite.sbb.answer.AnswerForm;
import com.mysite.sbb.user.SiteUser;
import com.mysite.sbb.user.UserService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
@RequestMapping("/question")
@RequiredArgsConstructor
@Controller
public class QuestionController {
   private final QuestionService questionService;
   private final UserService userService;   
   @GetMapping("/list")
   public String list(Model model) {
      List<Question> questionList = this.questionService.getList();
      model.addAttribute("questionList", questionList);
      return "question_list";
   }   
   @GetMapping(value = "/detail/{id}")
   public String detail(Model model, @PathVariable("id") Integer id, AnswerForm answerForm) {
      Question question = this.questionService.getQuestion(id);
      model.addAttribute("question",question);
      return "question_detail";
   }   
   @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";   // 질문 저장후 질문 목록으로 이동
   }   
   @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";
   }   
   @PreAuthorize("isAuthenticated()")
   @PostMapping("/modify/{id}")
   public String questionModify(@Valid QuestionForm questionForm, BindingResult bindingResult, Principal principal,
         @PathVariable("id") Integer id) {
      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);
   }
}
  • 실행 및 결과


삭제버튼 추가

  • question_detail.html 수정
<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 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>      
   <!-- 답변 개수 표시 -->
   <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>
   <!-- 답변 반복 끝 -->
   <!-- 답변 작성-->
   <form th:action="@{|/answer/create/${question.id}|}" th:object="${answerForm}" 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>
         <input type="submit" value="답변 등록" class="btn btn-primary my-2"> </form>
   </div>
   </html>

-> 로그인한 사용자가 자신이 작성한 질문을 삭제할 수 있도록 [삭제]버튼을 클릭하면 자바스크립트 임시링크의 코드가 실행되도록 구현했다.
[삭제]버튼은 [수정]버튼과 달리 href속성값을 javascript.void(0)로 설정해서 삭제를 실행할 url을 얻기위해 th:data-uri 속성을 추가한 뒤, [삭제] 버튼을 클릭하는 이벤트를 확인하기 위해 class 속성에 delete항목을 추가한 것
href에 삭제를 위한 url을 직접 사용하지 않고 이러한 방식을 사용한 이유는 [삭제]버튼을 클릭했을때 '삭제하시겠습니까?'와 같은 메시지와 함께 별도의 호가인 절차를 중간에 끼워 넣기 위해서이다.
(data-uri: 속성에 설정한 값은 클릭 이벤트 발생 시 자바스크립트 코드에서 this.dataset.uri를 사용하여 그 값을 알아서 실행할 수 있다.)
삭제 작업을 수행하는데 실제 url을 data_uri에 저장된다.
사용자가 링크를 클릭하면 자바스크립트 함수가 이 data-uri 속성에서 url을 가져와 ajax 호출 또는 다른 형태의 요청을 통해 페이지를 새로 고치지 않고 질문을 삭제할 수 있다.

<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;   // [확인]을 클릭했을 경우 삭제를 위한 url을 호출하기 위해 작성.
   };
     });
});
</script>
  • layout.html 수정
    <!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: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>
      <!-- 기본 템플릿 안에 삽입될 내용 Start -->
      <th:block layout:fragment="content"></th:block>
      <!-- 기본 템플릿 안에 삽입될 내용 End -->
      <!-- 자바스크립트 Start -->
         <th:block layout:fragment="script"></th:block>
         <!-- 자바스크립트 End -->
    </body>
    </html>
``` html
<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 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>
   <!-- 답변 개수 표시 -->
   <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>
   <!-- 답변 반복 끝 -->
   <!-- 답변 작성-->
   <form th:action="@{|/answer/create/${question.id}|}" th:object="${answerForm}" 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>          
         <input type="submit" value="답변 등록" class="btn btn-primary my-2"> </form>
   </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>
  • QuestionService.java 수정
package com.mysite.sbb.question;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import org.springframework.stereotype.Service;
import com.mysite.sbb.DataNotFoundException;
import com.mysite.sbb.user.SiteUser;
import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor
@Service
public class QuestionService {
   private final QuestionRepository questionRepository;   
   public List<Question> getList(){
      return this.questionRepository.findAll();
   }  
   public Question getQuestion(Integer id) {
      Optional<Question> question = this.questionRepository.findById(id);
      if(question.isPresent()) {
         return question.get();
      }else {
         throw new DataNotFoundException("question not found");
      }
   }
   public void create(String subject, String content, SiteUser user) {
      Question q = new Question();
      q.setSubject(subject);
      q.setContent(content);
      q.setCreateDate(LocalDateTime.now());
      q.setAuthor(user);
      this.questionRepository.save(q);
   }
   public void modify(Question question, String subject, String content) {
      question.setSubject(subject);
      question.setContent(content);
      question.setModifyDate(LocalDateTime.now());
      this.questionRepository.save(question);
   }
   public void delete(Question question) {
      this.questionRepository.delete(question);
   }
}
  • QuestionController.java 수정
package com.mysite.sbb.question;
import java.security.Principal;
import java.util.List;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.server.ResponseStatusException;
import com.mysite.sbb.answer.AnswerForm;
import com.mysite.sbb.user.SiteUser;
import com.mysite.sbb.user.UserService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
@RequestMapping("/question")
@RequiredArgsConstructor
@Controller
public class QuestionController {   
   private final QuestionService questionService;
   private final UserService userService;
   @GetMapping("/list")
   public String list(Model model) {
      List<Question> questionList = this.questionService.getList();
      model.addAttribute("questionList", questionList);
      return "question_list";
   }
   @GetMapping(value = "/detail/{id}")
   public String detail(Model model, @PathVariable("id") Integer id, AnswerForm answerForm) {
      Question question = this.questionService.getQuestion(id);
      model.addAttribute("question",question);
      return "question_detail";
   }   
   @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";   // 질문 저장후 질문 목록으로 이동
   }   
   @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";
   }   
   @PreAuthorize("isAuthenticated()")
   @PostMapping("/modify/{id}")
   public String questionModify(@Valid QuestionForm questionForm, BindingResult bindingResult, Principal principal,
         @PathVariable("id") Integer id) {
      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);
   }
   @PreAuthorize("isAuthenticated()")
   @GetMapping("/delete/{id}")
   public String questionDelete(Principal principal, @PathVariable("id") Integer id) {
      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:/";
   }
}
  • question_detail.html 수정
<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 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>      
   <!-- 답변 개수 표시 -->
   <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 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>
   <!-- 답변 반복 끝 -->
   <!-- 답변 작성-->
   <form th:action="@{|/answer/create/${question.id}|}" th:object="${answerForm}" 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>   
         <input type="submit" value="답변 등록" class="btn btn-primary my-2"> </form>
   </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>
  • AnswerService.java 수정
package com.mysite.sbb.answer;
import java.time.LocalDateTime;
import java.util.Optional;
import org.springframework.stereotype.Service;
import com.mysite.sbb.DataNotFoundException;
import com.mysite.sbb.question.Question;
import com.mysite.sbb.user.SiteUser;
import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor
@Service
public class AnswerService {
  private final AnswerRepository answerRepository;   
   public void create(Question question, String content, SiteUser author) {
      Answer answer = new Answer();
      answer.setContent(content);
      answer.setCreateDate(LocalDateTime.now());
      answer.setQuestion(question);
      answer.setAuthor(author);
      this.answerRepository.save(answer);
   }
   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);
   }
}
  • AnswerController.java 수정
package com.mysite.sbb.answer;
import java.security.Principal;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.server.ResponseStatusException;
import com.mysite.sbb.question.Question;
import com.mysite.sbb.question.QuestionService;
import com.mysite.sbb.user.SiteUser;
import com.mysite.sbb.user.UserService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
@RequestMapping("/answer")
@RequiredArgsConstructor
@Controller
public class AnswerController {
   private final QuestionService questionService;
   private final AnswerService answerService;
   private final UserService userService;   
   @PreAuthorize("isAuthenticated()")
   @PostMapping("/create/{id}")
   public String createAnswer(Model model, @PathVariable("id") Integer id,
         @Valid AnswerForm answerForm, BindingResult bindingResult, Principal principal) {
      Question question = this.questionService.getQuestion(id);
      SiteUser siteUser = this.userService.getUser(principal.getName());
      if(bindingResult.hasErrors()) {
         model.addAttribute("question",question);
         return "question_detail";
      }
      // TODO: 답변을 저장한다.
      this.answerService.create(question, answerForm.getContent(), siteUser);
      return String.format("redirect:/question/detail/%s", id);
   }
   @PreAuthorize("isAuthenticated()")
   @GetMapping("/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";
   }
}
  • answer_form.html 생성
<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>
  • AnswerController.java 수정
package com.mysite.sbb.answer;
import java.security.Principal;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.server.ResponseStatusException;
import com.mysite.sbb.question.Question;
import com.mysite.sbb.question.QuestionService;
import com.mysite.sbb.user.SiteUser;
import com.mysite.sbb.user.UserService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
@RequestMapping("/answer")
@RequiredArgsConstructor
@Controller
public class AnswerController {
   private final QuestionService questionService;
   private final AnswerService answerService;
   private final UserService userService;
   @PreAuthorize("isAuthenticated()")
   @PostMapping("/create/{id}")
   public String createAnswer(Model model, @PathVariable("id") Integer id,
         @Valid AnswerForm answerForm, BindingResult bindingResult, Principal principal) {
      Question question = this.questionService.getQuestion(id);
      SiteUser siteUser = this.userService.getUser(principal.getName());
      if(bindingResult.hasErrors()) {
         model.addAttribute("question",question);
         return "question_detail";
      }
      // TODO: 답변을 저장한다.
      this.answerService.create(question, answerForm.getContent(), siteUser);
      return String.format("redirect:/question/detail/%s", id);
   }
   @PreAuthorize("isAuthenticated()")
   @GetMapping("/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";
   }
   @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());
   }
}
  • question_detail.html 수정
<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 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>   
   <!-- 답변 개수 표시 -->
   <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 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}|}" th:object="${answerForm}" 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>            
         <input type="submit" value="답변 등록" class="btn btn-primary my-2"> </form>
   </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>
  • AnswerService.java 수정
package com.mysite.sbb.answer;
import java.time.LocalDateTime;
import java.util.Optional;
import org.springframework.stereotype.Service;
import com.mysite.sbb.DataNotFoundException;
import com.mysite.sbb.question.Question;
import com.mysite.sbb.user.SiteUser;
import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor
@Service
public class AnswerService {
   private final AnswerRepository answerRepository;   
   public void create(Question question, String content, SiteUser author) {
      Answer answer = new Answer();
      answer.setContent(content);
      answer.setCreateDate(LocalDateTime.now());
      answer.setQuestion(question);
      answer.setAuthor(author);
      this.answerRepository.save(answer);
   }
   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);
   }
   public void delete(Answer answer) {
      this.answerRepository.delete(answer);
   }
}
  • AnswerController.java 수정
package com.mysite.sbb.answer;
import java.security.Principal;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.server.ResponseStatusException;
import com.mysite.sbb.question.Question;
import com.mysite.sbb.question.QuestionService;
import com.mysite.sbb.user.SiteUser;
import com.mysite.sbb.user.UserService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
@RequestMapping("/answer")
@RequiredArgsConstructor
@Controller
public class AnswerController {
   private final QuestionService questionService;
   private final AnswerService answerService;
   private final UserService userService;   
   @PreAuthorize("isAuthenticated()")
   @PostMapping("/create/{id}")
   public String createAnswer(Model model, @PathVariable("id") Integer id,
         @Valid AnswerForm answerForm, BindingResult bindingResult, Principal principal) {
      Question question = this.questionService.getQuestion(id);
      SiteUser siteUser = this.userService.getUser(principal.getName());
      if(bindingResult.hasErrors()) {
         model.addAttribute("question",question);
         return "question_detail";
      }
      // TODO: 답변을 저장한다.
      this.answerService.create(question, answerForm.getContent(), siteUser);
      return String.format("redirect:/question/detail/%s", id);
   }
   @PreAuthorize("isAuthenticated()")
   @GetMapping("/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";
   }   
   @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(Principal principal, @PathVariable("id") Integer id) {
      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());
   }
}
  • 실행 및 결과
  • question_detail.html 수정
<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>
         <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>      
   <!-- 답변 개수 표시 -->
   <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}|}" th:object="${answerForm}" 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>            
         <input type="submit" value="답변 등록" class="btn btn-primary my-2"> </form>
   </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>
  • 실행 및 결과 (수정시간이 나옴)

파일 내보내는 방법




profile
하이도의 BackEnd 입문

0개의 댓글