쇼핑몰 프로젝트
쇼핑몰 프로젝트 마무리
pom.xml에 spring security 추가 > maven업데이트
적용 시 접근하면 로그인 화면부터 나타남.
id는 user, pw는 콘솔로그에 있음.
- application.properties -
# security id, pw setting
spring.security.user.name=user
spring.security.user.password=pass
편의상 임시 id와 pw를 설정.
새 클래스 생성
- SecurityConfig -
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
// 시큐리티는 1. 인증(로그인) 2. 허가(role에 따른 허용가능한 범위)
@Override
protected void configure(HttpSecurity http) throws Exception {
// 허가(role에 따른 허용가능한 범위)
http.authorizeHttpRequests()
// .antMatchers("/category/**").hasAnyAuthority("USER", "ADMIN") // 카테고리는 USER, ADMIN 접근가능
// .antMatchers("/admin/**").hasAnyAuthority("ADMIN") // 관리자 폴더는 ADMIN만
.antMatchers("/").permitAll(); // 누구나 접근 가능
}
}
편의상 허가에 대한 제한은 주석을 해두어 풀어둠.
=> 쇼핑몰 접근 시 로그인 필요없이 누구나 사용가능.
시큐리티 적용 순서:
1. 상속WebSecurityConfigurerAdapter
2. 어노테이션@EnableWebSecurity
적용
CREATE TABLE IF NOT EXISTS users (
id int not null auto_increment,
username VARCHAR(45) not null,
password VARCHAR(255) not null,
email VARCHAR(45) not null,
phone_number VARCHAR(45) not null,
PRIMARY KEY (id)
);
CREATE TABLE IF NOT EXISTS admin (
id int not null auto_increment,
username VARCHAR(45) not null,
password VARCHAR(255) not null,
PRIMARY KEY (id)
);
DB에 user와 admin 테이블 생성
User 클래스 생성
- User -
@Entity
@Table(name="users") // DB의 users테이블과 매핑
@Data
public class User implements UserDetails {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY) // DB서 자동생성
private int id;
private String username;
private String password;
private String email;
@Column(name = "phone_number") // DB의 테이블과 이름이 다르므로 설정해줌
private String phoneNumber;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
// 권한 목록을 리턴 (user 권한)
return Arrays.asList(new SimpleGrantedAuthority("ROLE_USER"));
}
@Override
public boolean isAccountNonExpired() {
// 계정이 만료되었는가?
return false; // 만료안됨
}
@Override
public boolean isAccountNonLocked() {
// 계정이 잠겨있는가?
return false; // 잠기지 않음
}
@Override
public boolean isCredentialsNonExpired() {
// 비밀번호가 만료되었는가?
return false; // 만료안됨
}
@Override
public boolean isEnabled() {
// 사용가능한 계정인가?
return false; // 사용가능
}
}
UserDetails
를 구현하여 사용.
상속 시 클래스이름에 빨간줄이 뜨는데, Add 해주면 자동으로 overrride된 메서드들이 나타남.
Collection<? extends GrantedAuthority>
에서는 권한을 줄 수 있음.
List객체이므로 여러 사용자에 대한 권한을 설정할 수 있으나 여기서는 우선 USER에 대한 권한설정만 해줌.
Admin 클래스도 같은 패키지에 생성하여 작성.
- Admin -
@Entity
@Table(name="admin") // DB의 users테이블과 매핑
@Data
public class Admin implements UserDetails {
private static final long serialVersionUID = 2L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY) // DB서 자동생성
private int id;
private String username;
private String password;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
// 권한 목록을 리턴 (admin 권한)
return Arrays.asList(new SimpleGrantedAuthority("ROLE_ADMIN"));
}
// 아래 override 메서드들은 User클래스와 동일
}
- User -
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY) // DB서 자동생성
private int id;
@NotBlank
@Size(min = 2, message = "유저이름은 2자 이상")
private String username;
@NotBlank
@Size(min = 4, message = "패스워드는 4자 이상")
private String password;
@Transient // 실제 테이블에는 없고 패스워드 확인 시 사용
private String confirmPassword;
@Email(message = "이메일 양식에 맞게 입력하시오")
private String email;
@Column(name = "phone_number") // DB의 테이블과 이름이 다르므로 설정해줌
@Size(min = 6, message = "전화번호는 6자리 이상")
private String phoneNumber;
@Transient
: 실제 테이블에 데이터는 없으나 클래스에 필드만 있음. 여기서는 패스워드를 한번 확인하기 위해 사용함.
각 패키지에 새 클래스, 인터페이스 생성
- RegistratinController -
@Controller
@RequestMapping("/register")
public class RegistratinController {
// UserRepository, 패스워드 암호화 필요
@Autowired
private UserRepository userRepo;
@Autowired
private PasswordEncoder passwordEncoder;
@GetMapping
public String register(User user) {
return "register"; // 가입하기 화면 view 보여주기
}
}
/register
로 접근 시 가입하기 뷰로 이동함.
- SecurityConfig -
@EnableWebSecurity
@Configuration // 클래스 내에 등록할 객체또는 메서드가 있음을 알림
public class SecurityConfig extends WebSecurityConfigurerAdapter {
// 시큐리티는 1. 인증(로그인) 2. 허가(role에 따른 허용가능한 범위)
@Bean // 이 메서드를 spring에 빈(객체 메서드)으로 등록
public PasswordEncoder encoder() {
return new BCryptPasswordEncoder(); // 패스워드 인코더 객체
}
// ...생략...
}
여기서 얻은 encoder객체를 RegistratinController
의 private PasswordEncoder
에서 @Autowired
를 통해 자동으로 등록함.
- register.html -
<main role="main" class="container-fluid mt-5">
<div class="row">
<div th:replace="/fragments/categories :: categories"></div>
<div class="col"></div>
<div class="col-6">
<div class="display-4">가입하기</div>
<form method="post" th:object="${user}" th:action="@{/register}">
<div th:if="${#fields.hasErrors('*')}" class="alert alert-danger">입력 내용을 확인해주세요</div>
<div class="form-group">
<label for="">유저이름</label>
<input type="text" class="form-control" th:field="*{username}" placeholder="유저이름" />
<span class="error" th:if="${#fields.hasErrors('username')}" th:errors="*{username}"></span>
</div>
<div class="form-group">
<label for="">비밀번호</label>
<input type="password" class="form-control" th:field="*{password}" placeholder="비밀번호" />
<span class="error" th:if="${#fields.hasErrors('password')}" th:errors="*{password}"></span>
</div>
<div class="form-group">
<label for="">비밀번호 확인</label>
<input type="password" class="form-control" th:field="*{confirmPassword}" placeholder="비밀번호 확인" />
<span class="error" th:if="${passwordNotMatch}">패스워드가 틀립니다.</span>
</div>
<div class="form-group">
<label for="">이메일</label>
<input type="email" class="form-control" th:field="*{email}" placeholder="이메일" />
<span class="error" th:if="${#fields.hasErrors('email')}" th:errors="*{email}"></span>
</div>
<div class="form-group">
<label for="">전화 번호</label>
<input type="text" class="form-control" th:field="*{phoneNumber}" placeholder="전화번호" />
<span class="error" th:if="${#fields.hasErrors('phoneNumber')}" th:errors="*{phoneNumber}"></span>
</div>
<button class="btn btn-danger mb-5">가입하기</button>
</form>
</div>
<div class="col"></div>
</div>
</main>
리턴할 뷰 만들기.
비밀번호 확인의 경우 다른 input창과는 달리 fielde의 hasErrors가 만들어준 에러메시지가 아닌 직접 입력해둔 메시지가 나타나도록 해줌.
http://localhost:8080/register
- RegistratinController -
@PostMapping
public String register(@Valid User user, BindingResult bindingResult, Model model) {
// 1. 유효성 검사 불통과시 되돌아감
if (bindingResult.hasErrors()) {
return "register";
}
// 2. 패스워드 확인 불통과시 되돌아감
if (!user.getPassword().equals(user.getConfirmPassword())) {
model.addAttribute("passwordNotMatch", "패스워드 확인이 틀림");
return "register";
}
// 3. 패스워드를 암호화하여 입력
user.setPassword(passwordEncoder.encode(user.getPassword()));
// 4. DB에 새 유저 저장
userRepo.save(user);
return "redirect:/login"; // 가입을 마치면 로그인 페이지로
}
http://localhost:8080/register
가입에 성공하면 다른 정보와 함께 암호화된 pw가 DB에 저장됨
- SecurityConfig -
@Autowired
private UserDetailsService userDetailsService;
// 생략...
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
// 인증 메서드 구현. 인증을 위해 userDetailsService로 유저의 정보(username, password, role등)를 받음
auth
.userDetailsService(userDetailsService)
.passwordEncoder(encoder());
}
- UserDetailsServiceImpl -
@Service
public class UserDetailsServiceImpl implements UserDetailsService{
@Autowired
private UserRepository userRepo;
@Autowired
private AdminRepository adminRepo;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// 유저DB에서 필요한 유저(또는 관리자)의 정보를 읽어온다. (입력 parameter는 username)
User user = userRepo.findByUsername(username);
Admin admin = adminRepo.findByUsername(username);
if (admin != null) return admin; // 먼저 관리자가 있으면 관리자로 리턴
if (user != null) return user; // 유저가 있으면 유저로 리턴
throw new UsernameNotFoundException("유저 " + username + "이 없습니다.");
}
}
SecurityConfig클래스
에 생성해둔 UserDetailsService
에 대해 구현이 필요.
username을 통해 사용자(유저 혹은 관리자)의 정보를 읽어옴.
=> 로그인에 필요한 과정(DB에 저장된 패스워드를 비교)임.
이 과정은 SecurityConfig클래스의 인증 메서드에 사용됨.
- UserRepository -
public interface UserRepository extends JpaRepository<User, Integer> {
User findByUsername(String username);
}
- AdminRepository -
public interface AdminRepository extends JpaRepository<Admin, Integer> {
Admin findByUsername(String username);
}
- PageController -
@GetMapping("/login")
public String login(Model model) {
return "login";
}
/login
주소로 접근 시 로그인 페이지로 이동
- login.html -
<div class="col-6">
<div class="display-4">로그인</div>
<div th:if="${param.error}">유저이름이나 패스워드가 틀립니다.</div>
<form method="post" th:action="@{/login}">
<div class="form-group">
<label for="">유저이름</label>
<input type="text" class="form-control" name="username" id="username" placeholder="유저이름" />
</div>
<div class="form-group">
<label for="">비밀번호</label>
<input type="password" class="form-control" name="password" id="password" placeholder="비밀번호" />
</div>
<button class="btn btn-danger">로그인</button>
<a class="btn btn-info" th:href="@{/register}">가입하기</a>
</form>
</div>
- SecurityConfig -
@Override
protected void configure(HttpSecurity http) throws Exception {
// 허가(role에 따른 허용가능한 범위)
http.authorizeHttpRequests()
.antMatchers("/category/**").hasAnyRole("USER", "ADMIN") // 카테고리는 USER, ADMIN 접근가능
.antMatchers("/admin/**").hasAnyRole("ADMIN") // 관리자 폴더는 ADMIN만
.antMatchers("/").permitAll() // 누구나 접근 가능
.and()
.formLogin().loginPage("/login") // 인증 로그인 페이지 주소
.and()
.logout().logoutSuccessUrl("/") // 로그아웃 후 홈으로 이동
.and()
.exceptionHandling().accessDeniedPage("/"); // 예외 발생 시 홈으로 이동
}
관리자는 가입하기화면이 따로 없으므로 DB에 직접 입력하여 로그인테스트.
pw는 유저의 암호화된 정보를 복사하여 사용함.
- Common -
// 모델에 추가
@ModelAttribute
public void sharedData(Model model, HttpSession session, Principal principal) {
if (principal != null) { // 인증된 상태
model.addAttribute("principal", principal.getName()); // username을 전달
}
// 생략
}
모든 페이지에서 인증이 되었는지를 체크해야 하므로 Common에 작성.
- nav.html -
<nav th:fragment="nav-front" class="navbar navbar-expand-md navbar-dark bg-dark">
<a class="navbar-brand" th:href="@{/}">쇼핑몰🎁</a>
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarShop" aria-controls="navbarShop" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarShop">
<ul class="navbar-nav mr-auto">
<li class="nav-item active" th:each="page : ${cpages}">
<a class="nav-link" th:if="${page.slug != 'home'}" th:href="@{'/' + ${page.slug}}" th:text="${page.title}"></a>
</li>
</ul>
<!-- 로그인 정보가 없으면 가입하기/로그인 표시 -->
<ul class="navbar-nav mr-auto" th:if="${principal == null}">
<li class="nav-item active">
<a class="nav-link" th:href="@{'/register'}" th:text="가입하기"></a>
</li>
<li class="nav-item active">
<a class="nav-link" th:href="@{'/login'}" th:text="로그인"></a>
</li>
</ul>
<!-- 로그인 정보가 있으면 로그아웃(시큐리티) 표시 -->
<form th:if="${ ? != null}" th:action="@{/logout}" method="post">
<span class="text-white" th:text="${'💦하이, ' + principal}"></span>
<button class="btn btn-secondary ml-2">로그아웃</button>
</form>
</nav>
<nav th:fragment="nav-admin" class="navbar navbar-expand-md navbar-dark bg-dark">
<a class="navbar-brand" th:href="@{/}">관리자⛑</a>
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarShop" aria-controls="navbarShop" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarShop">
<ul class="navbar-nav mr-auto">
<li class="nav-item active">
<a class="nav-link" th:href="@{/admin/pages}">Pages</a>
</li>
<li class="nav-item active">
<a class="nav-link" th:href="@{/admin/categories}">Categories</a>
</li>
<li class="nav-item active">
<a class="nav-link" th:href="@{/admin/products}">Products</a>
</li>
</ul>
<!-- 로그인 정보가 있으면 로그아웃(시큐리티) 표시 -->
<form th:if="${ ? != null}" th:action="@{/logout}" method="post">
<span class="text-white" th:text="${'관리자⛑, ' + principal}"></span>
<button class="btn btn-secondary ml-2">로그아웃</button>
</form>
</div>
</nav>
관리자 페이지에서 카테고리 순서를 드래그로 변경 시 crsf때문에 에러발생 -> csrf방지설정을 통해 순서를 변경해도 적용되도록 수정.
- head.html -
관리자 head태그에만 추가(유저가 사용할 head태그에는 필요없음)
<meta id="_csrf" name="_csrf" th:content="${_csrf.token}">
<meta id="_csrf_header" name="_csrf_header" th:content="${_csrf.headerName}">
위의 두 index.html에 csrf위조방지 script 추가
=> 페이지와 카테고리는 순서변경이 가능하므로 csrf위조방지 script가 필요한 것.
// csrf위조방지
let token = $("meta[name='_csrf']").attr('content');
let header = $("meta[name='_csrf_header']").attr('content');
$(document).ajaxSend(function (e, xhr, options) {
xhr.setRequestHeader(header, token);
});
- Common -
// cpages에 모든 페이지들을 담아서 순서대로 전달
List<Page> cpages = pageRepo.findAllByOrderBySortingAsc();
List<Category> categories = categoryRepo.findAllByOrderBySortingAsc();
관리자 페이지에서 순서변경을 하고 나면
위 사진처럼 홈페이지의 카테고리도 순서가 변경됨을 확인가능.
=> RegistratinController
의 가입을 진행할 때 유효성을 검사하는 항목에서 특정 조건(password와 confirmPassword가 다를때)을 만족하면 아래 코드를 무시하고 /register
로 리턴되어야 하는데 리턴하는 부분이 빠져있었음.
@PostMapping
public String register(@Valid User user, BindingResult bindingResult, Model model) {
// 1. 유효성 검사 불통과시 되돌아감
if (bindingResult.hasErrors()) {
return "register";
}
// 2. 패스워드 확인 불통과시 되돌아감
if (!user.getPassword().equals(user.getConfirmPassword())) {
model.addAttribute("passwordNotMatch", "패스워드 확인이 틀림");
return "register";
}
// 3. 패스워드를 암호화하여 입력
user.setPassword(passwordEncoder.encode(user.getPassword()));
// 4. DB에 새 유저 저장
userRepo.save(user);
return "redirect:/login"; // 가입을 마치면 로그인 페이지로
}
'2. 패스워드 확인 불통과시 되돌아감' 에서
return "register";
가 빠져있었음.
=> AdminRepository
인터페이스에서 상속받을 때 JpaRepository<Admin, Integer>
를 상속받아야하는데 JpaRepository<User, Integer>
를 상속받아 에러가 발생.
주의! : 구현 시 어떤 객체를 상속받아야하는지 한번 더 확인할것.
=> SecurityConfig
클래스의 권한을 주는 부분에서 메서드를 작성할때 실수함.
@Override
protected void configure(HttpSecurity http) throws Exception {
// 허가(role에 따른 허용가능한 범위)
http.authorizeHttpRequests()
.antMatchers("/category/**").hasAnyRole("USER", "ADMIN") // 카테고리는 USER, ADMIN 접근가능
.antMatchers("/admin/**").hasAnyRole("ADMIN") // 관리자 폴더는 ADMIN만
.antMatchers("/").permitAll() // 누구나 접근 가능
.and()
.formLogin().loginPage("/login") // 인증 로그인 페이지 주소
.and()
.logout().logoutSuccessUrl("/") // 로그아웃 후 홈으로 이동
.and()
.exceptionHandling().accessDeniedPage("/"); // 예외 발생 시 홈으로 이동
}
.antMatchers("/admin/**").hasAnyRole("ADMIN")
이 부분에서 .hasAnyAuthority("ADMIN")
로 작성하여 관리자페이지에 진입이 안된거였음.
주의! : 권한을 주는 메서드 작성시 정확하게 확인 후 넘어갈 것.