Add user, post 게시판 기능 추가

This commit is contained in:
Daeil Choi
2023-02-02 18:01:03 +09:00
parent 8a6acc9dfc
commit 41c2dc134a
29 changed files with 711 additions and 90 deletions

View File

@@ -0,0 +1,9 @@
package com.example.springsecuritystudy.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
@Configuration
@EnableJpaAuditing
public class AuditorConfig {
}

View File

@@ -0,0 +1,16 @@
package com.example.springsecuritystudy.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.ViewControllerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class MvcConfig implements WebMvcConfigurer {
public void addViewControllers(ViewControllerRegistry registry) {
registry.addViewController("/").setViewName("index");
registry.addViewController("/home").setViewName("index");
registry.addViewController("/admin").setViewName("admin/index");
registry.addViewController("/login").setViewName("login");
}
}

View File

@@ -1,4 +1,4 @@
package com.example.springsecuritystudy;
package com.example.springsecuritystudy.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

View File

@@ -1,15 +1,18 @@
package com.example.springsecuritystudy;
package com.example.springsecuritystudy.config;
import org.springframework.context.annotation.Bean;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer;
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.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import com.example.springsecuritystudy.user.UserRepository;
import lombok.RequiredArgsConstructor;
@@ -18,24 +21,37 @@ import lombok.RequiredArgsConstructor;
public class SecurityConfig {
private final PasswordEncoder passwordEncoder;
private final UserRepository userRepository;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(authorize -> authorize
.antMatchers("/", "/example").permitAll()
.antMatchers("/user").hasRole("USER")
.authorizeHttpRequests(auth -> auth
.antMatchers("/", "/home", "/signup", "/example").permitAll()
.antMatchers("/post").hasRole("USER")
.antMatchers("/admin").hasRole("ADMIN")
.anyRequest().authenticated()
)
.httpBasic(Customizer.withDefaults())
.formLogin(Customizer.withDefaults())
.logout()
.logoutSuccessUrl("/login")
;
.formLogin(form -> form
.loginPage("/login")
.defaultSuccessUrl("/")
.permitAll()
)
.logout(logout -> logout
.deleteCookies("remove")
.invalidateHttpSession(false)
.logoutRequestMatcher(new AntPathRequestMatcher("/logout"))
.logoutSuccessUrl("/")
);
return http.build();
}
@Bean
public WebSecurityCustomizer webSecurityCustomizer() {
return web -> web.ignoring().antMatchers("/css/**", "/js/**", "/h2-console/**");
}
@Bean
public UserDetailsService users() {
UserDetails user = User.withUsername("user")
@@ -46,6 +62,10 @@ public class SecurityConfig {
.password(passwordEncoder.encode("admin"))
.roles("ADMIN")
.build();
return new InMemoryUserDetailsManager(user, admin);
UserDetails tester = User.withUsername("test")
.password(passwordEncoder.encode("test"))
.roles("ADMIN", "USER")
.build();
return new InMemoryUserDetailsManager(user, admin, tester);
}
}

View File

@@ -1,21 +1,11 @@
package com.example.springsecuritystudy;
package com.example.springsecuritystudy.controller;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class HomeController {
@GetMapping("/user")
public String user() {
return "user";
}
@GetMapping("/admin")
public String admin() {
return "admin";
}
public class SampleController {
@GetMapping("/example")
public String example(Model model) {

View File

@@ -0,0 +1,32 @@
package com.example.springsecuritystudy.model;
import java.time.LocalDateTime;
import javax.persistence.Column;
import javax.persistence.EntityListeners;
import javax.persistence.MappedSuperclass;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Getter;
import lombok.ToString;
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
@Getter
@ToString(callSuper = true)
public abstract class BaseTimeEntity {
@JsonFormat(timezone = "Asia/Seoul")
@CreatedDate
@Column(nullable = false, updatable = false)
private LocalDateTime createdAt;
@JsonFormat(timezone = "Asia/Seoul")
@LastModifiedDate
private LocalDateTime updatedAt;
}

View File

@@ -0,0 +1,46 @@
package com.example.springsecuritystudy.post;
import javax.persistence.Entity;
import javax.persistence.EnumType;
import javax.persistence.Enumerated;
import javax.persistence.FetchType;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;
import javax.persistence.JoinColumn;
import javax.persistence.Lob;
import javax.persistence.ManyToOne;
import javax.persistence.Table;
import com.example.springsecuritystudy.model.BaseTimeEntity;
import com.example.springsecuritystudy.user.User;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
@Entity
@Table
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Post extends BaseTimeEntity {
@Id
@GeneratedValue
private Long id;
private String title;
@Lob
private String content;
@Enumerated(EnumType.STRING)
private PostStatus status;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "USER_ID")
private User user;
public Post(String title, String content, User user) {
this.title = title;
this.content = content;
this.status = PostStatus.Y;
this.user = user;
}
}

View File

@@ -0,0 +1,42 @@
package com.example.springsecuritystudy.post;
import java.security.Principal;
import java.util.List;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import lombok.RequiredArgsConstructor;
@Controller
@RequiredArgsConstructor
@RequestMapping("/post")
public class PostController {
private final PostService postService;
@GetMapping
public String findByPost(Principal principal, Model model) {
List<Post> posts = postService.findByUserName(principal.getName());
model.addAttribute("posts", posts);
return "post/index";
}
@PostMapping
public String savePost(@ModelAttribute PostDto postDto, Principal principal) {
postService.savePost(principal.getName(), postDto.getTitle(), postDto.getContent());
return "redirect:post";
}
@DeleteMapping
public String deletePost(@RequestParam Long id, Principal principal) {
postService.deletePost(principal.getName(), id);
return "redirect:post";
}
}

View File

@@ -0,0 +1,12 @@
package com.example.springsecuritystudy.post;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class PostDto {
private String title;
private String content;
}

View File

@@ -0,0 +1,14 @@
package com.example.springsecuritystudy.post;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import com.example.springsecuritystudy.user.User;
public interface PostRepository extends JpaRepository<Post, Long> {
List<Post> findByUserAndStatus(User user, PostStatus status);
Post findByIdAndUser(Long id, User user);
}

View File

@@ -0,0 +1,43 @@
package com.example.springsecuritystudy.post;
import java.util.List;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.example.springsecuritystudy.user.User;
import com.example.springsecuritystudy.user.UserRepository;
import lombok.RequiredArgsConstructor;
@Service
@RequiredArgsConstructor
public class PostService {
private final UserRepository userRepository;
private final PostRepository postRepository;
@Transactional(readOnly = true)
public List<Post> findByUserName(String username) {
User user = getUser(username);
return postRepository.findByUserAndStatus(user, PostStatus.Y);
}
public Post savePost(String username, String title, String content) {
User user = getUser(username);
return postRepository.save(new Post(title, content, user));
}
public void deletePost(String username, Long id) {
User user = getUser(username);
Post post = postRepository.findByIdAndUser(id, user);
postRepository.delete(post);
}
private User getUser(String username) {
return userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("유저가 없습니다."));
}
}

View File

@@ -0,0 +1,6 @@
package com.example.springsecuritystudy.post;
public enum PostStatus {
Y,
N
}

View File

@@ -0,0 +1,61 @@
package com.example.springsecuritystudy.user;
import java.util.Collection;
import java.util.Collections;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;
import javax.persistence.Table;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
@Entity
@Table(name = "users")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class User implements UserDetails {
@Id
@GeneratedValue
private Long id;
private String username;
private String password;
private String authority;
public User(String username, String password, String authority) {
this.username = username;
this.password = password;
this.authority = authority;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return Collections.singleton((GrantedAuthority) () -> authority);
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}

View File

@@ -0,0 +1,28 @@
package com.example.springsecuritystudy.user;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import lombok.RequiredArgsConstructor;
@Controller
@RequiredArgsConstructor
@RequestMapping("/signup")
public class UserController {
private final UserService userService;
@GetMapping
public String signupView() {
return "signup";
}
@PostMapping
public String signup(@ModelAttribute UserDto userDto) {
userService.signup(userDto.getUsername(), userDto.getPassword());
return "redirect:login";
}
}

View File

@@ -0,0 +1,15 @@
package com.example.springsecuritystudy.user;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Getter
@Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class UserDto {
private String username;
private String password;
}

View File

@@ -0,0 +1,9 @@
package com.example.springsecuritystudy.user;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByUsername(String username);
}

View File

@@ -0,0 +1,35 @@
package com.example.springsecuritystudy.user;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
public User signup(String username, String password) {
if (userRepository.findByUsername(username).isPresent()) {
throw new RuntimeException("이미 등록된 유저입니다.");
}
return userRepository.save(new User(username, passwordEncoder.encode(password), "ROLE_USER"));
}
public User signupAdmin(String username, String password) {
if (userRepository.findByUsername(username).isPresent()) {
throw new RuntimeException("이미 등록된 Admin유저입니다.");
}
return userRepository.save(new User(username, passwordEncoder.encode(password), "ROLE_ADMIN"));
}
public User findByUsername(String username) {
return userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("존재하지 않는 유저입니다."));
}
}

View File

@@ -0,0 +1,15 @@
spring:
h2:
console:
enabled: true
path: /h2-console
datasource:
driver-class-name: org.h2.Driver
url: jdbc:h2:mem:security-test;
username: sa
password:
jpa:
database-platform: org.hibernate.dialect.H2Dialect
show-sql: true

View File

@@ -1,14 +1,14 @@
server:
port: 8080
devtools:
livereload:
mvc:
hiddenmethod:
filter:
enabled: true
restart:
enabled: true
thymeleaf:
cache: false
logging:
level:
root: info
# org.springframework.security: debug
org.springframework.web: debug
sql: error
org.springframework.security: debug

View File

@@ -0,0 +1,32 @@
.form-signin {
max-width: 330px;
padding: 15px;
margin: 0 auto;
}
.form-signin .form-signin-heading,
.form-signin .checkbox {
margin-bottom: 10px;
}
.form-signin .checkbox {
font-weight: 400;
}
.form-signin .form-control {
position: relative;
box-sizing: border-box;
height: auto;
padding: 10px;
font-size: 16px;
}
.form-signin .form-control:focus {
z-index: 2;
}
.form-signin input[type="email"] {
margin-bottom: -1px;
border-bottom-right-radius: 0;
border-bottom-left-radius: 0;
}
.form-signin input[type="password"] {
margin-bottom: 10px;
border-top-left-radius: 0;
border-top-right-radius: 0;
}

View File

@@ -1,18 +0,0 @@
<!DOCTYPE HTML>
<html
lang="ko"
xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/extras/spring-security"
>
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Spring-Security-Study</title>
</head>
<body>
<h1>Admin</h1>
<a href="/">홈으로</a>
</body>
</html>

View File

@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html
lang="ko"
xmlns:th="http://www.thymeleaf.org"
>
<head th:insert="fragments.html::header"></head>
<body>
<header th:insert="fragments.html::nav"></header>
<div class="container">
<h1>관리자 페이지</h1>
<p>당신은 관리자입니다.</p>
</div>
</body>
</html>

View File

@@ -0,0 +1,88 @@
<!DOCTYPE html>
<html
lang="ko"
xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/extras/spring-security"
>
<!-- header -->
<head th:fragment="header">
<meta charset="UTF-8">
<title>스프링 시큐리티 학습용</title>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css"
>
<link
rel="stylesheet"
href="https://use.fontawesome.com/releases/v5.0.6/css/all.css"
>
</head>
<body>
<!-- navigation bar -->
<div th:fragment="nav">
<nav class="navbar navbar-expand-lg navbar-dark bg-primary">
<div class="container-fluid">
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item">
<p
class="nav-link active"
sec:authorize="isAuthenticated()"
sec:authentication="name">
</p>
</li>
<li class="nav-item">
<a class="nav-link active" th:href="@{/}"></a>
</li>
<li class="nav-item">
<a
class="nav-link active"
sec:authorize="hasAnyRole('ROLE_ADMIN')"
th:href="@{/admin}"
>
관리자 페이지
</a>
</li>
<li class="nav-item">
<a
class="nav-link active"
sec:authorize="hasAnyRole('ROLE_USER')"
th:href="@{/post}"
>
게시글
</a>
</li>
<li class="nav-item">
<a
class="nav-link active"
sec:authorize="!isAuthenticated()"
th:href="@{/login}"
>
로그인
</a>
</li>
<li class="nav-item">
<a
class="nav-link active"
sec:authorize="!isAuthenticated()"
th:href="@{/signup}"
>
회원가입
</a>
</li>
<li class="nav-item">
<a
class="nav-link active"
sec:authorize="isAuthenticated()"
th:href="@{/logout}"
>
로그아웃
</a>
</li>
</ul>
</div>
</div>
</nav>
</div>
</body>
</html>

View File

@@ -1,26 +1,15 @@
<!DOCTYPE HTML>
<!DOCTYPE html>
<html
lang="ko"
xmlns:th="http://www.thymeleaf.org"
xmlns:th="https://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/extras/spring-security"
>
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Spring-Security-Study</title>
<style>
a {
display: block;
}
</style>
</head>
<head th:insert="fragments.html::header"></head>
<body>
<h1>Spring Security</h1>
<a href="/example">example 페이지 바로가기</a>
<a href="/user">user 페이지 바로가기</a>
<a href="/admin">admin 페이지 바로가기</a>
<a href="/logout">로그아웃</a>
<header th:insert="fragments.html::nav"></header>
<div class="container">
<h1>안녕, 스프링 시큐리티</h1>
<h3>개인 보안 노트 서비스</h3>
</div>
</body>
</html>

View File

@@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<link rel="stylesheet" type="text/css" href="css/signin.css">
<head th:insert="fragments.html::header"></head>
</head>
<body>
<header th:insert="fragments.html::nav"></header>
<div class="container">
<form class="form-signin" method="post" th:action="@{/login}">
<h2 class="form-signin-heading">로그인</h2>
<p>
<label for="username" class="sr-only">Username</label>
<input type="text" id="username" name="username" class="form-control" placeholder="Username" required autofocus>
</p>
<p>
<label for="password" class="sr-only">Password</label>
<input type="password" id="password" name="password" class="form-control" placeholder="Password" required>
</p>
<button class="btn btn-lg btn-primary btn-block" type="submit">로그인</button>
</form>
</div>
</body>
</html>

View File

@@ -0,0 +1,90 @@
<!DOCTYPE html>
<html
lang="ko"
xmlns:th="http://www.thymeleaf.org"
>
<head>
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script
src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.bundle.min.js"></script>
<head th:insert="fragments.html::header"></head>
</head>
<body>
<header th:insert="fragments.html::nav"></header>
<!-- 개인 user만 접근할 수 있는 페이지 -->
<div class="container">
<h1>게시글</h1>
<!-- Button trigger modal -->
<button
type="button"
class="btn btn-primary"
data-bs-toggle="modal"
data-bs-target="#newPostModal"
data-bs-whatever="@mdo">
새 글 쓰기
</button>
<!-- Modal -->
<div
class="modal fade"
id="newPostModal"
tabindex="-1"
aria-labelledby="newPostModalLabel"
aria-hidden="true"
>
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="newPostModalLabel">새 글 쓰기</h5>
<button
type="button"
class="btn-close"
data-bs-dismiss="modal"
aria-label="Close">
</button>
</div>
<form
th:action="@{/post}"
method="post"
>
<div class="modal-body">
<div class="mb-3">
<label for="title" class="col-form-label">제목</label>
<input type="text" class="form-control" id="title" name="title">
</div>
<div class="mb-3">
<label for="content" class="col-form-label">내용</label>
<textarea class="form-control" id="content" name="content"></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">종료</button>
<button type="submit" class="btn btn-primary">저장</button>
</div>
</form>
</div>
</div>
</div>
<div class="row">
<div class="col-lg-8 col-lg-offset-2 col-md-10 col-md-offset-1">
<div class="border border-dark" th:each="post : ${posts}">
<h2 th:text="${post.title}"></h2>
<div>
<p th:text="${post.content}"></p>
<form th:action="@{/post}" th:method="delete">
<input type="hidden" name="id" th:value="${post.id}">
<span style="margin: 10px 0px;">Posted by
<strong th:if="${post.user}" th:text="${post.user.username}"></strong> on
<strong th:text="${#temporals.format(post.createdAt, 'yyyy-MM-dd')}"></strong>
</span>
<button type="submit" class="btn btn-secondary">삭제</button>
</form>
</div>
</div>
</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,27 @@
<!DOCTYPE HTML>
<html
lang="ko"
xmlns:th="http://www.thymeleaf.org"
>
<head>
<link rel="stylesheet" type="text/css" href="css/signin.css">
<head th:insert="fragments.html::header"></head>
</head>
<body>
<header th:insert="fragments.html::nav"></header>
<div class="container">
<form class="form-signin" method="post" th:action="@{/signup}">
<h2 class="form-signin-heading">회원가입</h2>
<p>
<lable for="username" class="sr-only">Username</lable>
<input type="text" id="username" name="username" class="form-control" placeholder="Username" required autofocus>
</p>
<p>
<lable for="password" class="sr-only">Password</lable>
<input type="password" id="password" name="password" class="form-control" placeholder="Password" required>
</p>
<button class="btn btn-lg btn-primary btn-block" type="submit">회원가입</button>
</form>
</div>
</body>
</html>

View File

@@ -1,18 +0,0 @@
<!DOCTYPE HTML>
<html
lang="ko"
xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/extras/spring-security"
>
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Spring-Security-Study</title>
</head>
<body>
<h1>User</h1>
<a href="/">홈으로</a>
</body>
</html>

View File

@@ -16,7 +16,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@SpringBootTest
class HomeControllerTest {
class SampleControllerTest {
@Autowired
private WebApplicationContext applicationContext;