스프링수업 5일차

한 일

  • 메모리 인증에서 DB를 통한 인증으로 변경하기
  • 뷰 추가하기, 비밀번호 암호화 확인 - 로그인
  • 에러 페이지 수정
  • 로그인 페이지 수정(메뉴에 로그인 기능 추가)
  • 업데이트, 삭제 버튼 추가하기
  • 타임리프 기본문법

메모리 인증에서 DB를 통한 인증으로 변경하기

DB에 user_accounts 테이블 생성

use pma;
# 사용자번호(자동생성), 유저네임(ID), 이메일, 패스워드, 롤(일반, 관리자), 사용가능한 계정인지 확인(가입중, 탈퇴 등)-기본값true
CREATE TABLE IF NOT EXISTS user_accounts (
    user_id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
    username VARCHAR(255) NOT NULL,
    email VARCHAR(255) NOT NULL,
    password VARCHAR(255) NOT NULL,
    role VARCHAR(255),
    enabled BOOLEAN DEFAULT TRUE
);


새 클래스 UserAccount 생성

- UserAccount -

@Entity		// 테이블과 매핑되는 클래스임을 알림
@Table(name= "userAccounts")		// 매핑할 DB의 테이블명과 클래스의 이름이 다를경우 name속성을 통해 지정
public class UserAccount {
	@Id
	@GeneratedValue(strategy = GenerationType.IDENTITY)	// DB에서 자동생성(AUTO_INCREMENT)하도록 설정
	@Column(name= "user_id")		// 테이블의 column명과 다르므로 name속성을 통해 매핑시킴
	private long userId;
	
	@Column(name= "username")
	private String userName;
	
	private String email;
	private String password;
	private String role;
	private Boolean enabled;
    // getset매서드 자동생성 (생략)
}

- SecurityConfiguration -
메모리에서 지정했던 4일차의 방식을 수정함.
DB의 데이터를 가져와 검사 후 암호화까지 거침.

// 시큐리티 설정을 위해서는 1) WebSecurityConfigurerAdapter를 상속받아야하며 2) 어노테이션 EnableWebSecurity가 필요  
@EnableWebSecurity
@Configuration
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
	
	@Autowired
	private DataSource dataSource;	// DB객체
	
	@Autowired
	private BCryptPasswordEncoder bCryptPasswordEncoder; // 패스워드 인코딩 객체
	
	// 3) 인증	
	@Override
	protected void configure(AuthenticationManagerBuilder auth) throws Exception {
		// DB에서 가져온 유저, 비밀번호, roles(허용범위 = 유저, 관리자)을 검사함
		auth.jdbcAuthentication()
			.usersByUsernameQuery("select username,password,enabled from user_accounts where username = ? ")	// username과 password를 검색
			.authoritiesByUsernameQuery("select username, role from user_accounts where username = ? ")			// 해당 username에 대한 role을 검색
			.dataSource(dataSource)		// DB에서 가져온 데이터를 사용
			.passwordEncoder(bCryptPasswordEncoder);		// 패스워드 인코딩 객체를 통해 검사. DB에 저장된 암호화 된 패스워드를 다시 디코딩 하는 기능도 포함함.
	}
	
	// 4) 허가
	@Override
	protected void configure(HttpSecurity http) throws Exception {	
		http.authorizeHttpRequests()
			.antMatchers("/projects/new").hasRole("ADMIN")		// 관리자의 허용범위 지정(새 프로젝트, 새 직원 추가 가능)
			.antMatchers("/projects/save").hasRole("ADMIN")
			.antMatchers("/employees/new").hasRole("ADMIN") 
			.antMatchers("/employees/save").hasRole("ADMIN")
			.antMatchers("/employees").authenticated()			// 인증된 유저(로그인 한 모든 유저)에게만 허용
			.antMatchers("/projects").authenticated()
			.antMatchers("/").permitAll()						// 그 외 페이지는 인증과 관계없이 모두에게 열람허용
			.and().formLogin()
			.and().exceptionHandling().accessDeniedPage("/"); //예외 발생시 기본페이지로
		// 시큐리티에서는 기본적으로 csrf방지가 적용됨
//		http.csrf().disable();	
		// csrf룰(사용자가 의도치않은 요청)에 걸리는 부분에 대해 막아줌(/save실행시 redirect로 요청을 보내게 되어있기때문) 												
	}
}


WebConfig 클래스 생성

- WebConfig -

@Configuration
public class WebConfig implements WebMvcConfigurer {
	// BCryptPasswordEncoder 를 사용해 비밀번호를 암호화. 객체는 bean으로 등록.
	@Bean
	public BCryptPasswordEncoder passwordEncoder() {
		return new BCryptPasswordEncoder();
	}	
}

@Configuration: 설정파일을 만들기 위한 어노테이션 혹은 Bean을 등록하기 위한 어노테이션.

테스트를 하려면 새로운 user를 만들어줘야 함.

테스트를 위해 SecurityController 생성

- SecurityController -

@Controller
public class SecurityController {
	// 암호화는 유저를 저장할때, 유저를 인증할 때 모두 필요함.
	@Autowired
	UserAccountRepository userRepo;	// 검사할때 암호화를 위한 객체
	
	@Autowired
	BCryptPasswordEncoder bEncoder; // 인증할때 암호화를 위한 객체
	
	// 가입하기 화면 표시
	@GetMapping("/register")
	public String register(Model model) {
		UserAccount userAccount = new UserAccount();	// 빈 유저 객체 생성
		model.addAttribute("userAccount", userAccount); // 아래 화면에 매핑함 => 내용을 입력하면 객체로 받으면 됨
		return "security/register";
	}
	
	@PostMapping("/register/save")
	public String saveUser(Model model, UserAccount user) {
		user.setPassword(bEncoder.encode(user.getPassword())); // 인코더를 사용해 비밀번호를 암호화하여 저장
		userRepo.save(user);

		return "redirect:/";
	}
}


인터페이스 UserAccountRepository 생성

- UserAccountRepository -

public interface UserAccountRepository extends CrudRepository<UserAccount, Long>{
}

뷰 추가하기, 비밀번호 암호화 확인 - 로그인

뷰 추가


templates/security 폴더생성 > 다운로드받은 register.html 추가

실행하여 테스트.
http://localhost:8080/register

위 화면이 뜨면 정상.

register.html에서 타임리프문법을 사용하여 작성하면 csrf토큰을 직접 사용하지 않아도 자동으로 csrf방지가 적용됨.
타임리프와 csrf토큰을 모두 사용하지 않을 경우 에러발생.

@PostMapping("/register/save")
public String saveUser(Model model, UserAccount user) {
	user.setPassword(bEncoder.encode(user.getPassword())); // 인코더를 사용해 비밀번호를 암호화하여 저장
	userRepo.save(user);
	return "redirect:/";
}

비밀번호를 암호화하는 과정.
비밀번호는 저장할때와 불러올때 모두 암호화가 필요함.

http://localhost:8080/register 로 접속하여 임의의 데이터를 기입한 후 가입하기 진행.

가입 한 후 DB를 확인해보면 비밀번호는 암호화 된 상태로 입력되었음을 확인가능.

스프링 시큐리티에대한 참고

role 추가, 로그인

확인을 위해 DB를 직접 수정.

http://localhost:8080/logout 으로 로그아웃 후
다시 로그인 http://localhost:8080/login

- UserAccount -
생성자를 추가하여 가입 시 role과 enabled의 기본값을 지정해줌.

// 빈 유저 객체 생성 시 role은 user, enable은 ture로 설정
public UserAccount() {
	this.role = "ROLE_USER";
	this.enabled = true;
}


USER용 계정 새로 가입


USER용 계정 kuj0829로 로그인 시 관리자에게만 허용된 기능이 동작하지않음.
=> 새 직원 추가, 새 프로젝트 추가기능 안됨.

SecurityConfiguration
설명을 위해 코드의 일부를 가져옴.

@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
	// DB에서 가져온 유저, 비밀번호, roles(허용범위 = 유저, 관리자)을 검사함
	auth.jdbcAuthentication()
		.usersByUsernameQuery("select username,password,enabled from user_accounts where username = ? ")	// username과 password를 검색
		.authoritiesByUsernameQuery("select username, role from user_accounts where username = ? ")			// 해당 username에 대한 role을 검색
		.dataSource(dataSource)		// DB에서 가져온 데이터를 사용
		.passwordEncoder(bCryptPasswordEncoder);	// 패스워드 인코딩 객체를 통해 검사. DB에 저장된 암호화 된 패스워드를 다시 디코딩 하는 기능도 포함함.
}

passwordEncoder(bCryptPasswordEncoder)
=> bCryptPasswordEncoder 이 객체가 패스워드의 인코딩과 디코딩을 모두 해줌.
즉, 가입할때 암호화와 가입 후 DB에 저장된 암호화 된 패스워드를 디코딩하여 가져와 사용자가 로그인 할 때 입력한 패스워드와 비교하게 됨.


에러 페이지 수정

에러코드
403: 접근 권한(허가)에 대한 오류 발생
404: 요청한 페이지가 존재하지 않음
500: 페이지 내부에러

페이지를 추가하기 전 화이트라벨에러(스프링의 기본에러화면)페이지 설정을 false로 바꿈

- application.properties -

# MySQL DB 설정
spring.datasource.url=jdbc:mysql://localhost:3306/pma?useSSL=false&serverTimezone=Asia/Seoul
spring.datasource.username=root
spring.datasource.password=1234

# jpa에서 sql을 사용할때마다 log로 콘솔에 출력
spring.jpa.show-sql=true

# 초기 테스트용 (자동으로 table생성 및 수정/삭제)	=> 실제 사용 시 none
spring.sql.init.mode=never
spring.jpa.hibernate.ddl-auto=none

# 로그 레벨 설정
logging.level.root=warn
logging.level.com.myapp.pma=debug
logging.level.org.springframework=info

# 로그 파일 설정 app.log 파일에 로그내용 저장
logging.file.name=app.log

# 에러페이지 커스텀을 위해 기본에러페이지 false처리
server.error.whitelabel.enabled=false


templates에 폴더를 만들어 다운받은 페이지를 넣어줌


새 클래스 생성

- AppErrorController -

// 에러 페이지 컨트롤러는 에러 컨트롤러를 구현해야 함
@Controller
public class AppErrorController implements ErrorController {
	
	// 에러 발생 시 주소가 /error로 들어옴
	@GetMapping("/error")
	public String handleError(HttpServletRequest request) {
		// 에러 상태 코드 확인
		Object status = request.getAttribute(RequestDispatcher.ERROR_STATUS_CODE);
		
		if(status != null) {	// 에러가 맞으면
			Integer statusCode = Integer.valueOf(status.toString()); // 403, 403, 500 식의 코드로 바꿈
			
			if(statusCode == HttpStatus.NOT_FOUND.value()) {
				return "errorpages/404";
			}
			else if(statusCode == HttpStatus.INTERNAL_SERVER_ERROR.value()) {
				return "errorpages/500";
			}
			else if(statusCode == HttpStatus.FORBIDDEN.value()) {
				return "errorpages/403";
			}
		}
		// 위의 에러상태에 해당되지 않을 겨우 이동하는 페이지
		return "errorpages/error";
	}
}

- SecurityConfiguration -
'4) 허가' 부분 코드 일부수정

// 4) 허가
@Override
protected void configure(HttpSecurity http) throws Exception {	
	http.authorizeHttpRequests()
		.antMatchers("/projects/new").hasRole("ADMIN")		// 관리자의 허용범위 지정(새 프로젝트, 새 직원 추가 가능)
		.antMatchers("/projects/save").hasRole("ADMIN")
		.antMatchers("/employees/new").hasRole("ADMIN") 
		.antMatchers("/employees/save").hasRole("ADMIN")
		.antMatchers("/employees").authenticated()			// 인증된 유저(로그인 한 모든 유저)에게만 허용
		.antMatchers("/projects").authenticated()
		.antMatchers("/","/**").permitAll()						// 그 외 페이지는 인증과 관계없이 모두에게 열람허용
		.and().formLogin();
//			.and().exceptionHandling().accessDeniedPage("/"); //예외 발생시 기본페이지로
	// 시큐리티에서는 기본적으로 csrf방지가 적용됨
//		http.csrf().disable();	
	// csrf룰(사용자가 의도치않은 요청)에 걸리는 부분에 대해 막아줌(/save실행시 redirect로 요청을 보내게 되어있기때문) 												
}

작성되지 않은 페이지(http://localhost:8080/aaaaa 등) 요청시 404에러가 발생하며 404.html을 보이도록 작성함.

아래 사진처럼 커스텀에러페이지가 출력되면 정상.

404 요청한 페이지가 존재하지 않음


403 접근 권한(허가)에 대한 오류 발생


로그인 페이지 수정

- SecurityConfiguration -
로그인 폼 경로수정

// 4) 허가
@Override
protected void configure(HttpSecurity http) throws Exception {	
	http.authorizeRequests()
		.antMatchers("/projects/new").hasRole("ADMIN")		// 관리자의 허용범위 지정(새 프로젝트, 새 직원 추가 가능)
		.antMatchers("/projects/save").hasRole("ADMIN")
		.antMatchers("/employees/new").hasRole("ADMIN") 
		.antMatchers("/employees/save").hasRole("ADMIN")
		.antMatchers("/employees").authenticated()			// 인증된 유저(로그인 한 모든 유저)에게만 허용
		.antMatchers("/projects").authenticated()
		.antMatchers("/","/**").permitAll()						// 그 외 페이지는 인증과 관계없이 모두에게 열람허용
		.and().formLogin(form -> form.loginPage("/login").permitAll())	// 커스텀 로그인 페이지 추가
		.logout().logoutRequestMatcher(new AntPathRequestMatcher("/logout")); 	// 로그아웃 추가
}

http.authorizeRequests()로 바뀌었으니 참고.
formLogin()은 스프링이 기본적으로 제공하는 로그인페이지. 커스텀으로 설정하고 싶으면 ()안에 원하는 페이지를 지정하는 코드를 작성.
logout()도 비슷하나, 여기서는 페이지를 따로 두지않고 바로 로그아웃기능을 수행하도록 작성하였음.


사진의 경로에 다운받은 html파일을 넣는다.

스프링 시큐리티와 csrf에 대한 내용은 스프링부트 수업4일차 마지막의 csrf란? 참고.

- login.html -
csrf와 관련된 form태그의 내용만 가져옴. 보고 참고할것.

<form autocomplete="off" th:action="@{/login}" method="post">
  <div class="form-group">
    <div class="my-3">
      <input type="text" name="username" placeholder="유저이름" class="form-control" />
    </div>
  </div>
  <div class="form-group">
    <div class="my3-3">
      <input type="password" name="password" placeholder="패스워드" class="form-control" />
    </div>
  </div>
  <!-- <input type="hidden" name="_csrf" th:value="${_csrf.token}"> -->
  <div class="form-group">
    <div class="d-grid my-3">
      <button type="submit" class="btn btn-primary">로그인</button>
      <a th:href="@{/}" class="btn btn-success mt-2">돌아가기</a>
    </div>
  </div>
  <!-- <span th:utext="${successMessage}"></span> -->
</form>


새 클래스 LoginController 생성

- LoginController -

@Controller
public class LoginController {
	@GetMapping("/login")
	String login() {
		return "login";
	}
}

http://localhost:8080/logout 접속 시 바로 로그아웃 후 로그인페이지로 이동하도록 되어있음.
http://localhost:8080/login

변경된 로그인 페이지

로그인기능을 메뉴에 추가

- layouts.html -
메뉴바 우측에 가입하기, 로그인 버튼 추가

<ul class="navbar-nav">
  <li class="nav-item">
    <a class="nav-link active" th:href="@{/employees}">Employee</a>
  </li>
  <li class="nav-item">
    <a class="nav-link active" th:href="@{/projects}">Project</a>
  </li>
</ul>
<ul class="navbar-nav ms-auto me-3">
  <li class="nav-item">
    <a class="nav-link active" th:href="@{/register}">가입하기</a>
  </li>
  <li class="nav-item">
    <a class="nav-link active" th:href="@{/login}">로그인</a>
  </li>
</ul>

ms-auto 클래스명을 추가하면 부트스트랩에 의해 우측정렬됨
me-3 은 부트스트랩으로 우측margin을 3만큼 준 것


새 클래스 Common 생성

- Common -

@ControllerAdvice	// 모든 컨트롤러에 적용 (즉, 모든 주소에 적용됨)
public class Common {
	// 각 컨트롤러가 화면(view)에 보내는 모델 객체에 적용
	public void sharedData(Model model, Principal principal) {

		// Principal은 시큐리티 인증 시 인증된 유저정보를 담고있는 객체이다
		if (principal != null) {
			model.addAttribute("principal", principal.getName());	// 인증 유저의 username을 전달
		}
	}
}

@ControllerAdvice은 작성한 내용을 모든 컨트롤러에 적용시킨다.
=> 그래서 일반 컨트롤러에 작성하지않고 Common이라는 클래스를 따로 만들어 작성한 것.
여기서는 모든 페이지에 동일하게 인증된 유저의 이름을 전달하는 용도로 쓰였다.

@ModelAttribute은 각 컨트롤러가 화면(view)에 보내는 모델 객체에 얹어서 함께 보내준다고 생각하면 됨

Principal 객체는 스프링 시큐리티에서 인증을 마친 유저의 정보를 저장함.
여기서는 getName메서드를 통해 name값을 저장한 상태.

인증이 안되었으면 값은 null이므로 if문에 걸려 이름을 보내지않음.

- layouts.html -
위에서 작성한 ul태그가 끝난 바로 뒤에 추가.

<!-- principal은 넘겨받은 user의 이름임 -->
<form th:if="${principal != null}" th:action="@{logout}">
  <span class="text-white" th:text="${'Hi, ' + principal}"></span>
  <button class="btn btn-secondary">로그아웃</button>
</form>


로그인 하기 전

로그인 한 이후


업데이트, 삭제 버튼 추가하기

- list-employees.html -
table 부분수정

<table class="table table-hover">
  <thead class="table-dark">
    <tr>
      <th></th>
      <th>이름</th>
      <th>이메일</th>
      <th>Action</th>
    </tr>
  </thead>
  <tbody>
    <!-- 타임리프의 반복문 -->
    <tr th:each="emp : ${empList}">
      <td th:text="${emp.lastName}"></td>
      <td th:text="${emp.firstName}"></td>
      <td th:text="${emp.email}"></td>
      <td>
        <a th:href=@{/employees/update(id=${emp.employeeId})} class="btn btn-outline-info btn-sm">수정</a>
        <a th:href=@{/employees/delete(id=${emp.employeeId})} class="btn btn-outline-danger btn-sm"
                onclick="if((!confirm('정말로 삭제할까요?'))) return false">삭제</a>
      </td>
    </tr>
  </tbody>
</table>

th:href=@{/employees/update(id=${emp.employeeId})}
=> url창에서 parameter로 표시됨.
'href:/employees/update?id=값' 을 타임리프 문법으로 작성한 것.
적용예) href:/employees/update?id=1 : 1번 id가 parameter를 통해 넘어가는 것.

- EmployeeController -
createProject 메서드 다음 새 메서드 작성

@GetMapping("/update")
public String displayEmployeeUpdateForm(@RequestParam("id") long id, Model model) {
	// id로 DB에서 업데이트할 직원을 찾아서 화면에 표시하기
	Employee employee = employeeService.findByEmployeeId(id); // DB에서 찾기
	model.addAttribute("employee", employee);
	return "employees/new-employee";
}

- EmployeeService -

public Employee findByEmployeeId(long id) {
	return employeeRepository.findByEmployeeId(id);
}

- EmployeeRepository -
인터페이스에 추가

// 새로 만든 쿼리문. id입력해서 직원찾기. DB에서 검색하는 column명과 findBy'EmployeeId' 따옴표 안의 이름이 같아야함 
Employee findByEmployeeId(Long id);

id로 직원 DB를 검색해 찾음.
세부 코드는 인터페이스를 구현하는곳에서 작성.
findByEmployeeId의 findBy이후는 DB에서 검색할 테이블명과 일치시켜야함.
작명규칙 상 EmployeeId는 DB에서 employee_id로 인식됨.

=> findByEmployeeId메서드 규칙 실수하기 쉬우니 주의. 순서나 철자가 틀리면 안됨.
만약 DB에서 찾는 column명이 project_id라면 메서드명은 findByprojectId 가 됨.


직원리스트에서 수정 선택 시

해당 직원의 정보를 검색해 수정페이지로 이동


타임리프 기본문법

*추후 개별포스팅으로 분리예정.

th:href="@{/경로}"
@{} 는 경로를 나타냄. 이동할때 사용.

th:text="Hi,+principal"{'Hi, ' + principal}"

타임리프 기본문법 참고 1

타임리프 기본문법 참고 2

타임리프에서 url을 표기하는 방법 - 강사님 블로그

좋은 웹페이지 즐겨찾기