스프링수업 13일차

하파타카·2022년 4월 12일
0

SpringBoot수업

목록 보기
13/23

한 일

쇼핑몰 프로젝트

  • 시큐리티
  • 허가
  • 인증
  • 로그인
  • 로그아웃
  • 순서변경시 csrf위조방지
  • 실수 바로잡기

쇼핑몰 프로젝트 마무리


시큐리티 (Security)


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객체를 RegistratinControllerprivate 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>

csrf

관리자 페이지에서 카테고리 순서를 드래그로 변경 시 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();


관리자 페이지에서 순서변경을 하고 나면

위 사진처럼 홈페이지의 카테고리도 순서가 변경됨을 확인가능.



실수 바로잡기

  1. 회원가입시 비밀번호와 비밀번호 확인이 달라도 가입됨

=> 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";가 빠져있었음.


  1. 로그인 안됨
    유저 로그인시 에러발생.
    User데이터형에서 Admin데이터형으로 convert가 안된다는 로그가 뜸

=> AdminRepository인터페이스에서 상속받을 때 JpaRepository<Admin, Integer>를 상속받아야하는데 JpaRepository<User, Integer>를 상속받아 에러가 발생.

주의! : 구현 시 어떤 객체를 상속받아야하는지 한번 더 확인할것.


  1. 권한 에러
    403권한에러로 관리자 페이지로 진입이 안되는 문제 발생.

=> 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")로 작성하여 관리자페이지에 진입이 안된거였음.

주의! : 권한을 주는 메서드 작성시 정확하게 확인 후 넘어갈 것.

profile
천 리 길도 가나다라부터

0개의 댓글

관련 채용 정보