
- 스프링 기반 웹 애플리케이션의 인증과 권한을 담당하는 스프링의 하위 프레임워크이다
- 인증(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>
- http://localhost:8080/user/login?error
.
-> 로그인 실패시 매개변수로 error가 전달되는 것은 스프링 시큐리티의 규칙이다.- login_form.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 //스프링 시큐리티의 인증을 처리, UserSecurityService와 PasswordEncoder를 내부적으로 사용하여 // 인증과 권한을 부여 처리한다. 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 //스프링 시큐리티의 인증을 처리, UserSecurityService와 PasswordEncoder를 내부적으로 사용하여 // 인증과 권한을 부여 처리한다. 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 //스프링 시큐리티의 인증을 처리, UserSecurityService와 PasswordEncoder를 내부적으로 사용하여 // 인증과 권한을 부여 처리한다. 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>
- 실행 및 결과 (수정시간이 나옴)