@Configuration은 스프링의 환경설정 파일임을 의미하는 애너테이션이다. 여기서는 스프링 시큐리티의 설정을 위해 사용되었다. @EnableWebSecurity는 모든 요청 URL이 스프링 시큐리티의 제어를 받도록 만드는 애너테이션이다.
@EnableWebSecurity 애너테이션을 사용하면 내부적으로 SpringSecurityFilterChain이 동작하여 URL 필터가 적용된다.
스프링 시큐리티의 세부 설정은 SecurityFilterChain 빈을 생성하여 설정할 수 있다. 다음 문장은 모든 인증되지 않은 요청을 허락한다는 의미이다. 따라서 로그인을 하지 않더라도 모든 페이지에 접근할 수 있다.
http.authorizeHttpRequests().requestMatchers(
new AntPathRequestMatcher("/**")).permitAll();
H2 콘솔
하지만 스프링 시큐리티를 적용하면 H2 콘솔 로그인시 403 Forbidden 오류가 발생한다.
403 Forbidden 오류가 발생하는 이유는 스프링 시큐리티를 적용하면 CSRF 기능이 동작하기 때문이다.
CSRF(cross site request forgery)는 웹 사이트 취약점 공격을 방지를 위해 사용하는 기술이다. 스프링 시큐리티가 CSRF 토큰 값을 세션을 통해 발행하고 웹 페이지에서는 폼 전송시에 해당 토큰을 함께 전송하여 실제 웹 페이지에서 작성된 데이터가 전달되는지를 검증하는 기술이다.
스프링 시큐리티는 CSRF 토큰의 값이 정확한지 검증하는 과정을 거친다. (만약 CSRF 값이 없거나 해커가 임의의 CSRF 값을 강제로 만들어 전송하는 악의적인 URL 요청은 스프링 시큐리티에 의해 블록킹 될 것이다.)
그런데 H2 콘솔은 이와 같은 CSRF 토큰을 발행하는 기능이 없기 때문에 위와 같은 403 오류가 발생하는 것이다.
H2 콘솔은 스프링과 상관없는 일반 애플리케이션이다.
스프링 시큐리티가 CSRF 처리시 H2 콘솔은 예외로 처리할 수 있도록 설정 파일을 수정하자.
SecurityConfig.java
package com.example.board;
import org.springframework.security.web.header.writers.frameoptions.XFrameOptionsHeaderWriter;
(... 생략 ...)
public class SecurityConfig {
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests().requestMatchers(
new AntPathRequestMatcher("/**")).permitAll()
.and()
.csrf().ignoringRequestMatchers(
new AntPathRequestMatcher("/h2-console/**"))
.and()
.headers()
.addHeaderWriter(new XFrameOptionsHeaderWriter(
XFrameOptionsHeaderWriter.XFrameOptionsMode.SAMEORIGIN))
;
return http.build();
}
}
and()- http 객체의 설정을 이어서 할 수 있게 하는 메서드이다.
csrf().ignoringRequestMatchers(new AntPathRequestMatcher("/h2-console/**"))-/h2-console/로 시작하는 URL은 CSRF 검증을 하지 않는다는 설정이다.
URL 요청시X-Frame-Options헤더값을sameorigin으로 설정하여 오류가 발생하지 않도록 했다.
X-Frame-Options헤더의 값으로 sameorigin을 설정하면 frame에 포함된 페이지가 페이지를 제공하는 사이트와 동일한 경우에는 계속 사용할 수 있다.
스프링 시큐리티는 사이트의 콘텐츠가 다른 사이트에 포함되지 않도록 하기 위해 X-Frame-Options 헤더값을 사용하여 이를 방지한다. (clickjacking 공격을 막기위해 사용함)
클릭재킹은 웹 페이지 기반의 공격 유형으로 공격자가 관련이 없는 페이지 위에 있는 투명한 계층에 공격할 페이지를 표시하여 사용자가 클릭을 하거나 내용을 입력하도록 유도하는 공격이다.
질문 목록을 조회하는 getList 메서드를 위와 같이 변경했다. getList 메서드는 정수 타입의 페이지번호를 입력받아 해당 페이지의 질문 목록을 리턴하는 메서드로 변경했다. Pageable 객체를 생성할때 사용한PageRequest.of(page, 10)에서 page는 조회할 페이지의 번호이고 10은 한 페이지에 보여줄 게시물의 갯수를 의미한다. 이렇게 하면 데이터 전체를 조회하지 않고 해당 페이지의 데이터만 조회하도록 쿼리가 변경된다.
getList 메서드의 입출력 구조가 변경되었으므로 QuestionController도 수정해야 한다.
항상 홈 페이지로 이동해 주는 로고를 가장 왼쪽에 배치했고, 오른쪽에는 '로그인' 링크를 추가했다 (추후 구현)
layout.html을 수정하자.
<!doctype html>
<html lang="ko">
(... 생략 ...)
<body>
<!-- 네비게이션바 -->
<nav th:replace="~{navbar :: navbarFragment}"></nav>
<!-- 기본 템플릿 안에 삽입될 내용 Start -->
<th:block layout:fragment="content"></th:block>
<!-- 기본 템플릿 안에 삽입될 내용 End -->
</body>
</html>
navbar.html을 통해 내비게이션 바를 분리 한 뒤th:replace를 사용하여 포함시켰다.
정상 적용된 모습이지만 부트스트랩의 반응형 기능에 의해 로그인 버튼이 햄버거 메뉴 버튼에 숨겨져있는 상황이다.
해당 기능을 사용하기 위해 부트스트랩 자바스크립트 파일(bootstrap.min.js)을 layout.html 파일에 포함시켜야한다.
붙여 넣을 위치: /practice/src/main/resources/static/bootstrap.min.js
추가한 자바스크립트 파일을 사용할 수 있도록 layout.html의 하단에 코드를 추가하였다.
<!doctype html>
<html lang="ko">
(... 생략 ...)
<!-- 기본 템플릿 안에 삽입될 내용 Start -->
<th:block layout:fragment="content"></th:block>
<!-- 기본 템플릿 안에 삽입될 내용 End -->
<!-- Bootstrap JS -->
<script th:src="@{/bootstrap.min.js}"></script>
</body>
</html>
QuestionController에서 해당 서비스의 메서드를 활용할 수 있게 컨트롤러에도 메서드를 추가했다
POST 방식으로 요청한/question/createURL을 처리하기 위해 @PostMapping 애너테이션을 지정한 questionCreate 메서드를 추가했다. 메서드명은 @GetMapping시 사용했던 questionCreate 메서드명과 동일하게 사용할 수 있다.
(단, 매개변수의 형태가 다른 경우에 가능하다. - 메서드 오버로딩)
questonCreate 메서드는 화면에서 입력한 제목(subject)과 내용(content)을 매개변수로 받는다.
이 때 질문 등록 템플릿에서 필드 항목으로 사용했던 subject, content의 이름과 동일하게 해야 함에 주의하자.
QuestionService로 질문 데이터를 저장하는 코드를 작성하였다.
이렇게 수정하고 질문을 작성하고 저장하면 잘 동작하는 것을 확인할 수 있을 것이다.
폼 클래스
화면에서 전달받은 입력 값을 검증하기위해 Spring Boot Validation 라이브러리를 build.gradle 파일에 추가했다.
@NotEmpty는 해당 값이 Null 또는 빈 문자열("")을 허용하지 않음을 의미한다.
그리고 여기에 사용된 message 속성은 검증이 실패할 경우 화면에 표시할 오류 메시지이다.
@Size(max=200)은 최대 길이가 200 바이트를 넘으면 안된다는 의미이다.
이와 같이 설정하면 길이가 200 byte 보다 큰 제목이 입력되면 오류가 발생할 것이다.
content 속성 역시 @NotEmpty로 빈 값을 허용하지 않도록 했다.
컨트롤러
QuestionForm을 컨트롤러에서 사용할 수 있도록 컨트롤러를 수정하자.
(... 생략 ...)
import jakarta.validation.Valid;
import org.springframework.validation.BindingResult;
(... 생략 ...)
public class QuestionController {
(... 생략 ...)
@PostMapping("/create")
public String questionCreate(@Valid QuestionForm questionForm, BindingResult bindingResult) {
if (bindingResult.hasErrors()) {
return "/question/question_form";
}
this.questionService.create(questionForm.getSubject(), questionForm.getContent());
return "redirect:/question/list";
}
}
questionCreate 메서드의 매개변수를 subject, content 대신 QuestionForm 객체로 변경했다.
subject, content 항목을 지닌 폼이 전송되면 QuestionForm의 subject, content 속성이 자동으로 바인딩 된다.
이것은 스프링 프레임워크의 바인딩 기능이다.
QuestionForm 매개변수 앞에 @Valid 애너테이션을 적용했다.
@Valid 애너테이션을 적용하면 QuestionForm의 @NotEmpty, @Size 등으로 설정한 검증 기능이 동작한다.
그리고 이어지는 BindingResult 매개변수는 @Valid 애너테이션으로 인해 검증이 수행된 결과를 의미하는 객체이다.
BindingResult 매개변수는 항상 @Valid 매개변수 바로 뒤에 위치해야 한다. 매개변수의 위치가 정확하지 않다면 @Valid만 적용이 되어 입력값 검증 실패 시 400 오류가 발생한다.
questionCreate 메서드는bindResult.hasErrors()를 호출하여
오류가 있는 경우에는 다시 폼을 작성하는 화면을 렌더링하게 했고 오류가 없을 경우에만 질문 등록이 진행되도록 했다.
아무런 값도 입력하지 말고 "저장하기" 버튼을 눌러보자. QuestionForm의 @NotEmpty에 의해 Validation이 실패하여 다시 질문 등록 화면에 머물러 있을 것이다. 하지만 QuestionForm에 설정한 "제목은 필수항목입니다." 와 같은 오류 메시지는 보이지 않는다.
표준 HTML 문서의 구조는 위의 예처럼 html, head, body 엘리먼트가 있어야 하며, CSS 파일은 head 엘리먼트 안에 링크 되어야 한다. 또한 head 엘리먼트 안에는 meta, title 엘리먼트 등이 포함되어야 한다.
템플릿 상속
앞에서 작성한 질문 목록, 질문 상세 템플릿을 표준 HTML 구조가 되도록 수정해 보자. 그런데 템플릿 파일들을 모두 표준 HTML 구조로 변경하면 body 엘리먼트 바깥 부분(head 엘리먼트 등)은 모두 같은 내용으로 중복된다. 그러면 CSS 파일 이름이 변경되거나 새로운 CSS 파일이 추가될 때마다 모든 템플릿 파일을 일일이 수정해야 한다.
타임리프는 이런 중복의 불편함을 해소하기 위해 템플릿 상속 기능을 제공한다. 템플릿 상속은 기본 틀이 되는 템플릿을 먼저 작성하고 다른 템플릿에서 그 템플릿을 상속해 사용하는 방법이다.
layout.html
표준 HTML 구조의 기본 틀이 되는layout.html템플릿을 작성하자
파일 경로 : /practice/src/main/resources/templates/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}">
<!-- practice CSS -->
<link rel="stylesheet" type="text/css" th:href="@{/style.css}">
<title>게시판 만들기 연습</title>
</head>
<body>
<!-- 기본 템플릿 안에 삽입될 내용 Start -->
<th:block layout:fragment="content"></th:block>
<!-- 기본 템플릿 안에 삽입될 내용 End -->
</body>
</html>
layout.html템플릿은 모든 템플릿이 상속해야 하는 템플릿으로 표준 HTML 문서의 기본 틀이된다.
body 엘리먼트 안의<th:block layout:fragment="content"></th:block>부분이 바로layout.html을 상속한 템플릿에서 개별적으로 구현해야 하는 영역이 된다. 즉,layout.html템플릿을 상속하면<th:block layout:fragment="content"></th:block>영역에 해당되는 부분만 작성해도 표준 HTML 문서가 되는 것이다.