elevne's Study Note
Spring Boot 복습 (5) 본문
이번에는 Spring Boot 에서 회원가입, 로그인 등을 도와주는 Spring Security 를 사용해보았다. Spring Security 는 Spring 기반 Application 의 인증(Authenticate)과 권한(Authorization)을 담당하는 Spring 의 하위 프레임워크이다. 여기서 인증(Authentication)은 로그인을 의미, 권한(Authorization)은 인증된 사용자가 어떤 것을 할 수 있는지를 의미한다. 우선 이를 사용하기 위해서 build.gradle 파일을 수정해주었다.
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'org.springframework.boot:spring-boot-starter-web'
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'com.mysql:mysql-connector-j'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
runtimeOnly 'com.h2database:h2'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6:3.1.1.RELEASE'
}
Spring Security 와 이와 관련된 Thymeleaf 라이브러리를 사용하도록 설정한 것이다. 이를 설치하고 서버를 재시작하면 아래와 같은 로그인 화면이 나타나게 된다.
Spring Security 는 위와 같이 인증되지 않은 사용자는 서비스를 사용할 수 없게끔 한다. 하지만 만들고 있는 게시판은 로그인 없이도 게시물 조회는 가능해야 한다. 이를 위해 SecurityConfig.java 파일을 작성한다.
package com.springboot.study.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
httpSecurity.authorizeHttpRequests().requestMatchers(
new AntPathRequestMatcher("/**")).permitAll();
return httpSecurity.build();
}
}
@Configuration 어노테이션은 해당 클래스가 Spring 의 환경설정 파일임을 의미하는 어노테이션이며 @EnableWebSecurity 는 모든 요청 URL 이 Spring Security 의 제어를 받도록 만드는 어노테이션이다. @EnableWebSecurity 어노테이션을 사용하면 내부적으로 SpringSecurityFilterChain 이 동작하여 URL 필터가 적용된다고 한다.
Spring Security 의 세부설정은 SecurityFilterChain Bean 을 생성하여 설정할 수 있다. 위 코드는 모든 인증되지 않은 요청을 허락한다는 의미로, 로그인을 하지 않더라도 모든 페이지에 접근할 수 있게끔 한다.
다른 페이지에는 잘 접근되지만 h2 console 로그인 시 403 에러가 뜨는 것을 확인할 수 있다. 이는 Spring Security 를 적용하면 CSRF 기능이 동작하기 때문이다. CSRF(Cross Site Request Forgery)는 웹 사이트 취약점 공격 방지를 위해 사용되는 기술로, Spring Security 가 CSRF 토큰 값을 세션을 통해 발행, 웹 페이지에서는 폼 전송시에 해당 토큰을 함께 전송하여 실제 웹 페이지에 작성된 데이터가 전달되는지를 검증하는 기술이다.
질문 등록 화면의 소스를 확인해보면 위와 같은 input 태그가 form 안에 생성된 것을 확인할 수 있다. Spring Security 에 의해 위와 같은 CSRF 토큰이 자동으로 생성된 것이다. CSRF 값이 없거나 해커가 임의의 CSRF 값을 강제로 만들어 전송하는 요청은 Spring Security 에 의해 막아지는 것이다. H2 콘솔은 Spring 과 상관 없는 일반 어플리케이션으로, 위와 같이 CSRF 토큰을 발행하는 기능이 없기 때문에 403 오류가 발생하는 것이다. Spring Security 가 CSRF 처리 시 H2 콘솔은 예외로 처리할 수 있도록 아래와 같이 Config 파일을 수정해주면 된다.
package com.springboot.study.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
httpSecurity.authorizeHttpRequests().requestMatchers(
new AntPathRequestMatcher("/**")).permitAll()
.and()
.csrf().ignoringRequestMatchers(
new AntPathRequestMatcher("/h2-console/**"));
return httpSecurity.build();
}
}
위에서 and 는 httpSecurity 객체의 설정을 이어서 할 수 있게끔 해주는 메서드, csrf().ignoringRequestMatchers() 는 인자로 들어오는 경로로 시작하는 URL 은 CSRF 검증을 하지 않게끔 하는 메서드이다. 다시 실행해보면 아래와 같은 화면이 나타난다.
위와 같이 화면에 아무것도 표시되지 않는 이유는 H2 콘솔의 화면이 frame 구조로 작성되었기 때문이라고 한다. Spring Security 는 사이트의 콘텐츠가 다른 사이트에 포함되지 않도록 하기 위해 X-Frame-Options 헤더값을 사용하여 이를 방지한다. (Clickjacking 공격 방지) 이를 해결하기 위해 Config 파일을 다시 한 번 아래와 같이 수정해준다.
package com.springboot.study.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.header.writers.frameoptions.XFrameOptionsHeaderWriter;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
httpSecurity.authorizeHttpRequests().requestMatchers(
new AntPathRequestMatcher("/**")).permitAll()
.and()
.csrf().ignoringRequestMatchers(
new AntPathRequestMatcher("/h2-console/**"))
.and()
.headers().addHeaderWriter(
new XFrameOptionsHeaderWriter(
XFrameOptionsHeaderWriter.XFrameOptionsMode.SAMEORIGIN
)
);
return httpSecurity.build();
}
}
위처럼 URL 요청 시 X-Frame-Options 헤더값을 Same Origin 으로 설정하여 오류가 발생하지 않도록 할 수 있다. Same Origin 으로 설정하면 frame 에 포함된 페이지가 페이지를 제공하는 사이트와 동일한 경우에는 계속 사용할 수 있는 것이다.
드디어 해결된 것을 확인할 수 있었다.
그 다음으로는 회원가입 기능을 만들었다. 회원정보를 위한 Entity, Repository, Service 를 아래와 같이 작성해주었다.
package com.springboot.study.entity;
import lombok.Data;
import javax.persistence.*;
@Data
@Entity
public class SiteUser {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true)
private String username;
private String password;
@Column(unique = true)
private String email;
}
package com.springboot.study.repository;
import com.springboot.study.entity.SiteUser;
import org.springframework.data.jpa.repository.JpaRepository;
public interface UserRepository extends JpaRepository<SiteUser, Long> {
}
package com.springboot.study.service;
import com.springboot.study.entity.SiteUser;
import com.springboot.study.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
@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;
}
}
우선 UserService 에 새로운 사용자 데이터를 추가하는 create 메서드를 작성하였다. 사용자의 비밀번호는 보안을 위해 반드시 암호화되어 저장해야 하는데, 이 때 BCryptPasswordEncoder 클래스를 사용하여 암호화하여 비밀번호를 저장한다. 위와 같이 BCryptPasswordEncoder 을 new 로 생성하는 방식보다는 아래와 같이 Bean 으로 등록하여 사용하는 것이 더욱 편리하다. SecurityConfig 파일 하단에 아래 코드를 추가해주었다.
@Bean
PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
그 다음 UserService 도 아래와 같이 다시 작성해주었다.
package com.springboot.study.service;
import com.springboot.study.entity.SiteUser;
import com.springboot.study.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
@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;
}
}
기능은 만들어졌으니 데이터를 받을 Form 클래스가 필요했다. 아래와 같이 UserCreateForm 을 작성해주었다.
package com.springboot.study.form;
import lombok.Data;
import javax.validation.constraints.Email;
import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.Size;
@Data
public class UserCreateForm {
@Size(min = 1, max = 25)
@NotEmpty(message = "사용자ID는 필수항목입니다.")
private String username;
@NotEmpty(message = "비밀번호는 필수항목입니다.")
private String password1;
@NotEmpty(message = "비밀번호는 필수항목입니다.")
private String password2;
@NotEmpty(message = "이메일은 필수항목입니다.")
@Email
private String email;
}
위에서 사용된 @Email 어노테이션은 해당 속성의 값이 email 형식과 일치하는지를 검증해준다. Form 을 작성한 뒤 Controller 을 작성해주었다.
package com.springboot.study.controller;
import com.springboot.study.form.UserCreateForm;
import com.springboot.study.service.UserService;
import lombok.RequiredArgsConstructor;
import org.apache.catalina.User;
import org.springframework.dao.DataIntegrityViolationException;
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 javax.validation.Valid;
@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", "PASSWORD INCORRECT", "2개의 패스워드가 일치하지 않습니다.");
return "signup_form";
}
try {
userService.create(userCreateForm.getUsername(),
userCreateForm.getEmail(), userCreateForm.getPassword1());
} catch(DataIntegrityViolationException e) {
e.printStackTrace();
bindingResult.reject("signupFailed", "이미 등록된 사용자입니다.");
return "signup_form";
} catch(Exception e) {
e.printStackTrace();
bindingResult.reject("signupFailed", e.getMessage());
return "signup_form";
}
return "redirect:/";
}
}
위 Controller 에서는 bindingResult.rejectValue 메서드를 사용해보았다. 각 파라미터는 필드명, 오류코드, 에러메시지를 의미한다. 회원가입 템플릿으로 설정한 signup_form 은 아래와 같이 작성하였다.
<html>
<div class="container my-3">
<div class="my-3 border-bottom">
<div>
<h4>회원가입</h4>
</div>
</div>
<form th:action="@{/user/signup}" th:object="${userCreateForm}" method="post">
<div class="mb-3">
<label class="form-label">사용자ID</label>
<input type="text" th:field="*{username}" class="form-control">
</div>
<div class="mb-3">
<label class="form-label">비밀번호</label>
<input type="password" th:field="*{password1}" class="form-control">
</div>
<div class="mb-3">
<label class="form-label">비밀번호 확인</label>
<input type="password" th:field="*{password2}" class="form-control">
</div>
<div class="mb-3">
<label class="form-label">이메일</label>
<input type="email" th:field="*{email}" class="form-control">
</div>
<button type="submit" class="btn btn-primary">회원가입</button>
</form>
</div>
</html>
회원가입을 진행하고 DB 에서 데이터를 확인해보면 잘 들어가있는 것을 확인할 수 있다.
위 단계를 통해 회원 정보를 저장하는데 성공하였다. SITE_USER 테이블에 저장된 사용자 ID 와 비밀번호로 로그인을 하귀 위해서는 복잡한 과정을 거쳐야 하지만, Spring Security 를 통해 비교적 쉽게 진행해볼 수 있다. 우선 로그인 URL 을 Config 에 등록해준다.
package com.springboot.study.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.header.writers.frameoptions.XFrameOptionsHeaderWriter;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
httpSecurity.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 httpSecurity.build();
}
@Bean
PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
추가한 .and().formLogin()~ 코드는 Sprnig Security 의 로그인 설정을 담당하며 로그인 페이지의 URL 은 "/user/login", 로그인 성공 시 이동 URL 은 "/" 으로 설정해주었다.
그 다음으로는 Controller 에 Login 요청에 대한 매핑을 추가하여 "/user/login" 으로 접속 시 로그인 페이지가 뜨게끔 하였다. 로그인 기능은 아래와 같이 순차적으로 작성되었다.
UserRepository
package com.springboot.study.repository;
import com.springboot.study.entity.SiteUser;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface UserRepository extends JpaRepository<SiteUser, Long> {
Optional<SiteUser> findByUsername(String username);
}
UserRole
package com.springboot.study.entity;
import lombok.Getter;
@Getter
public enum UserRole {
ADMIN("ROLE_ADMIN"),
USER("ROLE_USER");
UserRole(String value){
this.value = value;
}
private String value;
}
Spring Security 는 인증 뿐만 아니라 권한도 관리한다. 인증 후에 사용자에게 부여할 권한을 설정해줄 수 있는 것이다. UserRole 은 Enum 으로 작성된다. ADMIN 은 "ROLE_ADMIN", USER 은 "ROLE_USER" 이라는 값을 가지도록 했다. 또한, 이는 상수 자료이기에 @Setter 없이 @Getter 만 사용하였다.
UserSecurityService
package com.springboot.study.service;
import com.springboot.study.entity.SiteUser;
import com.springboot.study.entity.UserRole;
import com.springboot.study.repository.UserRepository;
import lombok.RequiredArgsConstructor;
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 java.util.ArrayList;
import java.util.List;
import java.util.Optional;
@RequiredArgsConstructor
@Service
public class UserSecurityService implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
Optional<SiteUser> _sieteUser = userRepository.findByUsername(username);
if (!_sieteUser.isPresent()){
throw new UsernameNotFoundException("CANNOT FINE USER");
}
SiteUser siteUser = _sieteUser.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);
}
}
Spring Security 에 등록하여 사용할 UserSecurityService 는 Spring Security 에서 제공하는 UserDetailsService 인터페이스를 implement 해야한다. UserDetailsService 는 loadUserByUsername 메서드를 구현하도록 강제하는 메서드라고 한다. 이는 Spring Security 로그인 처리의 핵심 부분으로, 사용자명으로 비밀번호를 조회하여 리턴하는 메서드이다. loadUserByUsername 메서드는 사용자명으로 SiteUser 객체를 조회하고 만약 사용자명에 해당하는 데이터가 없을 경우에는 UsernameNotFoundException 오류를 발생시키도록 하였고 사용자명이 "admin" 인 경우에는 ADMIN 권한, 그 외의 경우에는 USER 권한을 부여하였다. 사용자명, 비밀번호, 권한을 입력으로 Spring Security 의 User 객체를 생성하여 리턴한다. Spring Security 는 loadUserByusername 메서드에 의해 리턴된 User 객체의 비밀번호가 화면으로부터 입력받은 비밀번호와 일치하는지를 검사하는 로직을 내부적으로 가지고 있다고 한다.
Spring Security 에 UserSecuriyService 를 아래와 같이 등록해준다. (+ 로그아웃 기능도 구현한다)
package com.springboot.study.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.header.writers.frameoptions.XFrameOptionsHeaderWriter;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
httpSecurity.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("/")
.and()
.logout().logoutRequestMatcher(new AntPathRequestMatcher("/user/logout"))
.logoutSuccessUrl("/").invalidateHttpSession(true);
return httpSecurity.build();
}
@Bean
PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
return authenticationConfiguration.getAuthenticationManager();
}
}
위에서 생성한 AuthenticationManager 은 Spring Security 의 인증을 담당하며, 이를 Bean 생성할 경우 내부적으로 위에서 작성한 UserSecurityService 와 PasswordEncoder 로 설정을 한다고 한다.
모든 기능이 잘 동작하는 것을 확인할 수 있다.
Reference:
'Backend > Spring' 카테고리의 다른 글
Spring Boot Project (1) (0) | 2023.06.06 |
---|---|
Spring Boot 복습 (6) (0) | 2023.04.10 |
Spring Boot 복습 (4) (0) | 2023.04.08 |
Spring Boot 복습 (3) (0) | 2023.04.07 |
Spring Boot 복습 (2) (0) | 2023.04.05 |