해당 게시글은 점프 투 스프링부트 교재를 통한 개인 학습 용도이며 기초 세팅은 생략하였습니다. 자바 8, 스프링부트 2.7.7버전 입니다.
조회수 구현하기
조회수는 보통 게시판 목록 페이지, 즉 우리가 만든 question_list 템플릿에서 구현되어야 할 것이다. 우선 조회수가 보여질 장소를 마련해주자.
"조회수" 라는 것은 해당 글을 조회할 때마다 올라가야 할 것이다. 우리가 만든 게시판의 "글"은 Question이다. 즉 Question 엔티티에 조회수에 해당하는 필드가 있어야 할 것이다. 또한 조회수 자체도 리파지토리에 저장되어야 하기 때문에, 서비스에서 이를 저장하고 조회수가 증가하게 만들어야 할 것이다.
Question엔티티에 해당 필드(컬럼)을 추가해주었다. null은 불가능하며, 디폴트값은 0이다. 조회수는 0부터 시작할 것이다.
QuestionService.java
(...생략...)
public class QuestionService {
(...생략...)
public Question getQuestion(Integer id) {
Optional<Question> question = this.questionRepository.findById(id);
if (question.isPresent()) {
Question question1 = question.get();
question1.setView(question1.getView()+1);
this.questionRepository.save(question1);
return question1;
} else {
throw new DataNotFoundException("question not found");
}
}
기존의 getQuestion메서드를 위와 같이 변경했다. 처음의 getView()값은 0이다. Question엔티티의 question1 인스턴스에서 해당 객체의 id값이 조회될 때마다 조회수를 1씩 증가시키고, question1에서 조회수값을 증가시켰기 때문에 question1 객체를 리턴하게 하였다.
이후 기존의 템플릿에서 Question엔티티의 view컬럼을 가져오게 아래와같이 수정했다.
폼 태그의th:action속성을 삭제하면 CSRF 값이 자동으로 생성되지 않기 때문에 위와 같이 CSRF 값을 설정하기 위한 hidden 형태의 input 엘리먼트를 수동으로 추가하자.
폼 태그의 action 속성 없이 폼을 전송(submit)하면 폼의 action은 현재의 URL(브라우저에 표시되는 URL주소)을 기준으로 전송이 된다. 질문 등록시에 브라우저에 표시되는 URL은/question/create이기 때문에 POST로 폼 전송시 action 속성에/question/create가 설정이 되고, 질문 수정시에 브라우저에 표시되는 URL은/question/modify/2형태의 URL이기 때문에 POST로 폼 전송시 action 속성에/question/modify/2형태의 URL이 설정되는 것이다.
<td> ... </td>엘리먼트를 삽입하여 질문의 글쓴이를 표시했다. 작성자 정보 없이 저장된 이전의 질문들은 author 속성에 해당하는 데이터가 없으므로 author 속성의 값이 null이 아닌 경우만 표시하도록 했다. 그리고 테이블 내용을 가운데 정렬하도록 tr 엘리먼트에 text-center 클래스를 추가하고, 제목을 왼쪽 정렬하도록 text-start 클래스를 추가했다.
로그아웃 상태에서 질문 또는 답변을 등록하면 서버오류(500 오류)가 발생한다. 이 오류는 principal 객체가 널(null)값이라서 발생한 오류이다. principal 객체는 로그인을 해야만 생성되는 객체이기 때문이다.
이 문제를 해결하려면 principal 객체를 사용하는 메서드에@PreAuthorize("isAuthenticated()") 애너테이션을 사용해야 한다.@PreAuthorize("isAuthenticated()")애너테이션이 붙은 메서드는 로그인이 필요한 메서드를 의미한다.
@PreAuthorize("isAuthenticated()")애너테이션이 적용된 메서드가 로그아웃 상태에서 호출되면 로그인 페이지로 이동된다.
QuestionController를 다음과 같이 수정하자.
package com.example.board.question;
(... 생략 ...)
import org.springframework.security.access.prepost.PreAuthorize;
(... 생략 ...)
public class QuestionController {
(... 생략 ...)
@PreAuthorize("isAuthenticated()")
@GetMapping("/create")
public String questionCreate(QuestionForm questionForm) {
return "/question/question_form";
}
@PreAuthorize("isAuthenticated()")
@PostMapping("/create")
public String questionCreate(@Valid QuestionForm questionForm,
BindingResult bindingResult, Principal principal) {
(... 생략 ...)
}
}
로그인이 필요한 메서드들에@PreAuthorize("isAuthenticated()")애너테이션을 적용했다.
AnswerController도 다음과 같이 수정하자.
(... 생략 ...)
import org.springframework.security.access.prepost.PreAuthorize;
(... 생략 ...)
public class AnswerController {
(... 생략 ...)
@PreAuthorize("isAuthenticated()")
@PostMapping("/create/{id}")
public String createAnswer(Model model, @PathVariable("id") Integer id, @Valid AnswerForm answerForm,
BindingResult bindingResult, Principal principal) {
(... 생략 ...)
}
}
@PreAuthorize 애너테이션이 동작할 수 있도록 SecurityConfig를 다음과 같이 수정해야 한다.
SecurityConfig에 적용한@EnableMethodSecurity(prePostEnabled = true)설정은 QuestionController와 AnswerController에서 로그인 여부를 판별하기 위해 사용했던 @PreAuthorize 애너테이션을 사용하기 위해 반드시 필요하다.
이렇게 수정한후 로그아웃 상태에서 질문을 등록하거나 답변을 등록하면 자동으로 로그인 화면으로 이동하는 것을 확인할 수 있을 것이다.
로그인 하지 않은 상태에서 "질문 등록" 버튼을 누르면 "로그인" 화면으로 이동한다. 그리고 로그인을 진행하면 원래 하려고 했던 "질문 등록" 화면으로 이동한다. 이것은 로그인 후에 원래 하려고 했던 페이지로 리다이렉트 시키는 스프링 시큐리티의 기능이다.
disabled
작성한 글이 사라지는 문제를 해결하려면 로그아웃 상태에서는 아예 답변 작성을 못하게 막는 것이 좋을 것이다.
해당 문장은 [ localhost:8080/~~ ] URL이하 어떠한 주소가 달려도 모두에게 접근권한을 준다는 의미였다.
맞게 이해한 건지 확인을 위해 **을 빼고 들어가자 로그인창이 떳다.
관리자일 때만 해당 페이지에 들어가지게 만들고 싶었다.
(... 생략 ...)
http.authorizeHttpRequests().requestMatchers(
new AntPathRequestMatcher("/")).permitAll()
.antMatchers("/question/detail/**").hasRole("ADMIN")
.and()
.csrf().ignoringRequestMatchers(
new AntPathRequestMatcher("/h2-console/**"))
(... 생략 ...)
antMachers("해당 주소").hasRole("ADMIN") 코드를 작성하였다
성공한 것 같다
로그인을 했음에도 ADMIN이 아니기 때문에 페이지에 접근 불가능한 모습이다.
403 Forbidden
이 에러는 서버 자체 또는 서버에 있는 파일에 접근할 권한이 없을 경우에 발생한다. 서버에는 외부 접근을 제어하기 위한 수많은 권한 설정이 있고, 서버에서 설정해 둔 권한과 맞지 않는 접속 요청이 들어오면 접근을 거부하고 접근거부 코드를 반환하는데, 이 때 뜨는 것이 바로 403 Forbidden 에러다.
권한 설정 및 로그인, 로그아웃 그리고 403 Forbidden의 핸들링 처리를 보통 아래와 같이 한다고 한다.
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
// 페이지 권한 설정
.antMatchers("/admin/**").hasRole("ADMIN")
.antMatchers("/user/myinfo").hasRole("MEMBER")
.antMatchers("/**").permitAll()
.and() // 로그인 설정
.formLogin()
.loginPage("/user/login")
.defaultSuccessUrl("/user/login/result")
.permitAll()
.and() // 로그아웃 설정
.logout()
.logoutRequestMatcher(new AntPathRequestMatcher("/user/logout"))
.logoutSuccessUrl("/user/logout/result")
.invalidateHttpSession(true)
.and()
// 403 예외처리 핸들링
.exceptionHandling().accessDeniedPage("/user/denied");
}
}
스프링 시큐리티에서의 표현식은 아래와 같다.하나 유의할 점은 isAnonymous()의 경우 anonynous 인증 객체를 가진 상태이기 때문에, 일반 유저로 로그인하여 Role_USER 권한을 가진 경우 접근이 불가하다. 모든 사용자가 접근이 가능하도록 만들고 싶다면, permitAll을 해야한다.
해당 게시글은 점프 투 스프링부트 교재를 통한 개인 학습 용도이며 기초 세팅은 생략하였습니다. 자바 8, 스프링부트 2.7.7버전 입니다.
로그인 구현하기
회원 가입 단계에서 SITE_USER 테이블에 회원 정보를 저장했다. SITE_USER 테이블에 저장된 사용자명(사용자 ID)과 비밀번호로 로그인을 하려면 복잡한 단계를 거쳐야 한다.
하지만 스프링 시큐리티를 사용하면 이 단계를 보다 쉽게 진행할 수 있다.
로그인 URL 등록
먼저 스프링 시큐리티에 로그인 URL을 등록하자.
SecurityConfig.java
package com.example.board.practice;
(... 생략 ...)
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))
.and()
.formLogin()
.loginPage("/user/login")
.defaultSuccessUrl("/")
;
return http.build();
}
(... 생략 ...)
}
추가한and().formLogin().loginPage("/user/login").defaultSuccessUrl("/")은 스프링 시큐리티의 로그인 설정을 담당하는 부분으로 로그인 페이지의 URL은/user/login이고 로그인 성공시에 이동하는 디폴트 페이지는 루트 URL(/)임을 의미한다.
UserController
스프링 시큐리티에 로그인 URL을/user/login으로 설정했으므로 UserController에 해당 매핑을 추가해야 한다.
(... 생략 ...)
public class UserController {
(... 생략 ...)
@GetMapping("/login")
public String login() {
return "login_form";
}
}
package com.example.board.user;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
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.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor
@Service
public class UserSecurityService implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
Optional<SiteUser> _siteUser = this.userRepository.findByusername(username);
if (!_siteUser.isPresent()) {
throw new UsernameNotFoundException("사용자를 찾을 수 없습니다");
}
SiteUser siteUser = _siteUser.get();
List<GrantedAuthority> authorities = new ArrayList<>();
if ("admin".equals(username)) {
authorities.add(new SimpleGrantedAuthority(UserRole.ADMIN.getValue()));
} else {
authorities.add(new SimpleGrantedAuthority(UserRole.USER.getValue()));
}
return new User(siteUser.getUsername(), siteUser.getPassword(), authorities);
}
}
스프링 시큐리티에 등록하여 사용할 UserSecurityService는 스프링 시큐리티가 제공하는 UserDetailsService 인터페이스를 구현(implements)해야 한다. 스프링 시큐리티의 UserDetailsService는 loadUserByUsername 메서드를 구현하도록 강제하는 인터페이스이다. loadUserByUsername 메서드는 사용자명으로 비밀번호를 조회하여 리턴하는 메서드이다.
UserSecurityService는 스프링 시큐리티 로그인 처리의 핵심 부분이다.
조금 더 자세히 살펴보면, loadUserByUsername 메서드는 사용자명으로 SiteUser 객체를 조회하고 만약 사용자명에 해당하는 데이터가 없을 경우에는 UsernameNotFoundException 오류를 내게 했다. 그리고 사용자명이 "admin"인 경우에는 ADMIN 권한을 부여하고 그 이외의 경우에는 USER 권한을 부여했다. 그리고 사용자명, 비밀번호, 권한을 입력으로 스프링 시큐리티의 User 객체를 생성하여 리턴했다. 스프링 시큐리티는 loadUserByUsername 메서드에 의해 리턴된 User 객체의 비밀번호가 화면으로부터 입력 받은 비밀번호와 일치하는지를 검사하는 로직을 내부적으로 가지고 있다.
AuthenticationManager 빈을 생성했다. AuthenticationManager는 스프링 시큐리티의 인증을 담당한다. AuthenticationManager 빈 생성시 스프링의 내부 동작으로 인해 위에서 작성한 UserSecurityService와 PasswordEncoder가 자동으로 설정된다.
navbar.html
로그인 페이지에 진입할수 있는 로그인 링크를 navbar.html에 다음과 같이 추가하자.
package com.example.board.user;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor
@Service
public class UserService {
private final UserRepository userRepository;
public SiteUser create(String username, String email, String password) {
SiteUser user = new SiteUser();
user.setUsername(username);
user.setEmail(email);
BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
user.setPassword(passwordEncoder.encode(password));
this.userRepository.save(user);
return user;
}
}
User 서비스에는 User 리포지터리를 사용하여 User 데이터를 생성하는 create 메서드를 추가했다.
이 때 사용자의 비밀번호는 보안을 위해 반드시 암호화하여 저장해야 한다.
암호화를 위해 시큐리티의 BCryptPasswordEncoder 클래스를 사용하여 암호화하여 비밀번호를 저장했다.
BCryptPasswordEncoder는 BCrypt 해싱 함수(BCrypt hashing function)를 사용해서 비밀번호를 암호화한다.
하지만 이렇게 BCryptPasswordEncoder 객체를 직접 new로 생성하는 방식보다는 PasswordEncoder 빈(bean)으로 등록해서 사용하는 것이 좋다. 왜냐하면 암호화 방식을 변경하면 BCryptPasswordEncoder를 사용한 모든 프로그램을 일일이 찾아서 수정해야 하기 때문이다.
PasswordEncoder는 BCryptPasswordEncoder의 인터페이스이다.
PasswordEncoder 빈(bean)을 만드는 가장 쉬운 방법은 @Configuration이 적용된 SecurityConfig에 @Bean 메서드를 생성하는 것이다.
SecurityConfig를 수정하자.
(... 생략 ...)
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
(... 생략 ...)
@Configuration
@EnableWebSecurity
public class SecurityConfig {
(... 생략 ...)
@Bean
PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
이렇게 PasswordEncoder를 @Bean으로 등록하면 UserService도 다음과 같이 수정할수 있다.
package com.example.board.user;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor
@Service
public class UserService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
public SiteUser create(String username, String email, String password) {
SiteUser user = new SiteUser();
user.setUsername(username);
user.setEmail(email);
user.setPassword(passwordEncoder.encode(password));
this.userRepository.save(user);
return user;
}
}
BCryptPasswordEncoder 객체를 직접 생성하여 사용하지 않고 빈으로 등록한 PasswordEncoder 객체를 주입받아 사용하도록 수정했다.
@Size는 폼 유효성 검증시 문자열의 길이가 최소길이(min)와 최대길이(max) 사이에 해당하는지를 검증한다.
password1과 password2는 "비밀번호"와 "비밀번호확인"에 대한 속성이다.
로그인 할때는 비밀번호가 한번만 필요하지만 회원가입시에는 입력한 비밀번호가 정확한지 확인하기 위해 2개의 필드가 필요하다.
@Email은 해당 속성의 값이 이메일형식과 일치하는지를 검증한다.
회원가입 컨트롤러
UserController을 만들자.
파일 경로 : /practice/src/main/java/com/example/board/user/UserController.java
package com.example.board.user;
import javax.validation.Valid;
import org.springframework.stereotype.Controller;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor
@Controller
@RequestMapping("/user")
public class UserController {
private final UserService userService;
@GetMapping("/signup")
public String signup(UserCreateForm userCreateForm) {
return "signup_form";
}
@PostMapping("/signup")
public String signup(@Valid UserCreateForm userCreateForm, BindingResult bindingResult) {
if (bindingResult.hasErrors()) {
return "signup_form";
}
if (!userCreateForm.getPassword1().equals(userCreateForm.getPassword2())) {
bindingResult.rejectValue("password2", "passwordInCorrect",
"2개의 패스워드가 일치하지 않습니다.");
return "signup_form";
}
userService.create(userCreateForm.getUsername(),
userCreateForm.getEmail(), userCreateForm.getPassword1());
return "redirect:/";
}
}
/user/signupURL이 GET으로 요청되면 회원 가입을 위한 템플릿을 렌더링하고 POST로 요청되면 회원가입을 진행하도록 했다. 회원 가입시비밀번호1과비밀번호2가 동일한지를 검증하는 로직을 추가했다. 만약 2개의 값이 일치하지 않을 경우에는bindingResult.rejectValue를 사용하여 오류가 발생하게 했다.bindingResult.rejectValue의 각 파라미터는bindingResult.rejectValue(필드명, 오류코드, 에러메시지)를 의미하며 여기서 오류코드는 일단 "passwordInCorrect"로 정의했다.
@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 공격을 막기위해 사용함)
클릭재킹은 웹 페이지 기반의 공격 유형으로 공격자가 관련이 없는 페이지 위에 있는 투명한 계층에 공격할 페이지를 표시하여 사용자가 클릭을 하거나 내용을 입력하도록 유도하는 공격이다.