해당 의존성을 추가하고 라이브러리를 받아옵니다.
//build.gradle
dependencies {
....
implementation 'org.springframework.boot:spring-boot-starter-security'
testImplementation 'org.springframework.security:spring-security-test'
...
}
애플리케이션을 실행하고 기존에 작성했던 localhost/thymeleaf/ex URL에 접근했을 때, 스프링 시큐리티에서 제공하는 로그인 페이지로 이동됩니다.
스프링 시큐리티에서 기본적으로 제공하는 아이디는 user이고, 비밀번호는 애플리케이션을 실행할 때마다 콘솔창에 출력해서 보여줍니다.
스프링 시큐리티를 추가하는 것만으로도 모든 요청이 인증을 필요로 하게 됩니다. 하지만 매번 스프링 시큐리티 콘솔창에서 비밀번호를 찾아 입력할 수 없기 때문에 회원 가입 기능과 각 페이지마다 필요한 권한처리를 하도록 하겠습니다.
설정을 관리할 config
라는 패키지를 만들고 스프링시큐리티를 설정을 처리할 SecurityConfig
클래스를 만들겠습니다.
🐢
Ctrl + O
단축키를 입력하시면 Override 가능한 메서드 목록을 확인하여 손쉽게 구현할 수 있습니다.
package me.jincrates.gobook.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.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
@EnableWebSecurity
: WebSecurityConfigurerAdapter를 상속받는 클래스에 해당 어노테이션을 선언하면 SpringSecurityFilterChain
이 자동으로 포함됩니다.void configure(HttpSecurity http)
: http 요청에 대한 보안을 설정합니다.PasswordEncoder passwordEncoder()
: 비밀번호를 그대로 저장하지 않고 BCryptPasswordEncoder
의 해시 함수를 이용하여 암호화처리합니다.회원 도메인을 관리하기 위해 domain
패키지 하위에 members
패키지를 생성하고 각각의 멤버가 일반 유저인지 관리자인지를 구분할 MemberRole enum
을 생성하겠습니다.
//me/jincrates/gobook/domain/members/MemberRole.java
package me.jincrates.gobook.domain.members;
public enum MemberRole {
USER, ADMIN
}
다음은 회원가입 화면으로부터 넘어오는 가입정보를 담을 dto를 생성하고 회원정보를 저장하는 Member 엔티티
를 만들겠습니다. 관리할 회원 정보는 이름, 이메일, 비밀번호, 주소, 역할입니다. dto로 전달받은 값이 유효한지도 검증하겠습니다.
//build.gradle
dependencies {
....
implementation 'org.springframework.boot:spring-boot-starter-validation'
...
}
package me.jincrates.gobook.web.dto;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.hibernate.validator.constraints.Length;
import javax.validation.constraints.Email;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotEmpty;
@NoArgsConstructor
@Getter
public class MemberFormDto {
@NotBlank(message = "이름은 필수 입력 값입니다.")
private String name;
@NotEmpty(message = "이메일은 필수 입력 값입니다.")
@Email(message = "이메일 형식으로 입력해주세요.")
private String email;
@NotEmpty(message = "비밀번호는 필수 입력 값입니다.")
@Length(min = 4, max = 16, message = "비밀번호는 4자 이상, 16자 이하로 입력해주세요.")
private String password;
@NotEmpty(message = "주소는 필수 입력 값입니다.")
private String address;
@Builder
public MemberFormDto(String name, String email, String password, String address) {
this.name = name;
this.email = email;
this.password = password;
this.address = address;
}
}
package me.jincrates.gobook.domain.members;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import me.jincrates.gobook.web.dto.MemberFormDto;
import org.springframework.security.crypto.password.PasswordEncoder;
import javax.persistence.*;
@NoArgsConstructor
@Getter
@Table(name = "member")
@Entity
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
@Column(name = "member_id")
private Long id;
private String name;
@Column(unique = true)
private String email;
private String password;
private String address;
@Enumerated(EnumType.STRING)
private MemberRole role;
@Builder
public Member(String name, String email, String password, String address, MemberRole role) {
this.name = name;
this.email = email;
this.password = password;
this.address = address;
this.role = role;
}
public static Member createMember(MemberFormDto memberFormDto, PasswordEncoder passwordEncoder) {
Member member = Member.builder()
.name(memberFormDto.getName())
.email(memberFormDto.getEmail())
.address(memberFormDto.getAddress())
.password(passwordEncoder.encode(memberFormDto.getPassword())) //암호화처리
.role(MemberRole.USER)
.build();
return member;
}
}
Member 엔티티를 만들었다면 데이터베이스에 저장할 수 있도록 MemberRepository
를 만들겠습니다.
package me.jincrates.gobook.domain.members;
import org.springframework.data.jpa.repository.JpaRepository;
public interface MemberRepository extends JpaRepository<Member, Long> {
Member findByEmail(String email);
}
Member findByEmail(String email)
: 회원 가입시 중복된 회원이 있는지 검사하기 위해 이메일로 회원을 검사하는 메소드를 작성합니다.gobook 패키지 아래 service
패키지를 만들고 MemberService
클래스를 작성합니다.
package me.jincrates.gobook.service;
import lombok.RequiredArgsConstructor;
import me.jincrates.gobook.domain.members.Member;
import me.jincrates.gobook.domain.members.MemberRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@RequiredArgsConstructor
@Transactional
@Service
public class MemberService {
private final MemberRepository memberRepository;
public Member saveMember(Member member) {
validateDuplicateMember(member);
return memberRepository.save(member);
}
private void validateDuplicateMember(Member member) {
Member findMember = memberRepository.findByEmail(member.getEmail());
if (findMember != null) {
throw new IllegalStateException("이미 가입된 회원입니다.");
}
}
}
@Transactional
: 로직을 처리하다가 에러가 발생하면, 변경된 데이터를 로직을 수행하기 이전 상태로 콜백 시켜줍니다.@RequiredArgsConstructor
: final
이나 @NonNull
이 붙은 필드에 생성자를 생성해줍니다. 빈에 생성자가 1개이고 생성자의 파라미터 타입이 빈으로 등록이 가능하다면 @Autowired
어노테이션이 없이 의존성 주입이 가능합니다.//src/test/java/me/jincrates/gobook/service/MemberServiceTest.java
package me.jincrates.gobook.service;
import me.jincrates.gobook.domain.members.Member;
import me.jincrates.gobook.domain.members.MemberRole;
import me.jincrates.gobook.web.dto.MemberFormDto;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.test.context.TestPropertySource;
import org.springframework.transaction.annotation.Transactional;
import static org.junit.jupiter.api.Assertions.*;
@SpringBootTest
@Transactional
@TestPropertySource(properties = {"spring.config.location=classpath:application-test.yml"})
public class MemberServiceTest {
@Autowired
MemberService memberService;
@Autowired
PasswordEncoder passwordEncoder;
public Member createMember() {
MemberFormDto memberFormDto = MemberFormDto.builder()
.email("test@email.com")
.name("테스트")
.address("서울시 강서구")
.password("1111")
.build();
return Member.createMember(memberFormDto, passwordEncoder);
}
@Test
@DisplayName("회원가입 테스트")
public void saveMemberTest() {
Member member = createMember();
Member savedMember = memberService.saveMember(member);
assertEquals(member.getEmail(), savedMember.getEmail());
}
}
web 패키지 안에 MemberController
를 작성합니다. 회원가입이 성공하면 메인 페이지로 리다이렉드 시켜주고, 회원 정보 검증 및 중복회원 가입 조건에 의해 실패한다면 다시 회원 가입 페이지로 돌아가 실패 이유를 화면에 출력하도록 하겠습니다.
package me.jincrates.gobook.web;
import lombok.RequiredArgsConstructor;
import me.jincrates.gobook.domain.members.Member;
import me.jincrates.gobook.service.MemberService;
import me.jincrates.gobook.web.dto.MemberFormDto;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
@RequiredArgsConstructor
@RequestMapping("/members")
@Controller
public class MemberController {
private final MemberService memberService;
private final PasswordEncoder passwordEncoder;
@GetMapping(value = "/new")
public String memberForm(Model model) {
model.addAttribute("memberFormDto", new MemberFormDto());
return "member/memberForm";
}
@PostMapping(value = "new")
public String memberForm(@Valid MemberFormDto memberFormDto, BindingResult bindingResult, Model model) {
if (bindingResult.hasErrors()) {
return "member/memberForm";
}
try {
Member member = Member.createMember(memberFormDto, passwordEncoder);
memberService.saveMember(member);
} catch (IllegalStateException e) {
model.addAttribute("errorMessage", e.getMessage());
return "member/memberFOrm";
}
return "redirect:/";
}
}
@Valid 어노테이션
: 객체의 유효성을 검증합니다.Controller에서 작성한 메소드 리턴값에 맞게 경로를 지정합니다. 기본적으로 resources
하위인 templatese
를 바라보기 때문에 하위에 member
폴더를 만들고 그 하위에 MemberForm.html
을 작성했습니다. 부트스트랩 Checkout 예제를 통해 더 우아하게 꾸밀 수 있습니다.(https://getbootstrap.kr/docs/5.1/examples/checkout/)
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layouts/default}">
<!-- 사용자 CSS 추가 -->
<th:block layout:fragment="css">
<style>
.fieldError {
color: #bd2130;
}
</style>
</th:block>
<!-- 사용자 스크립트 추가 -->
<th:block layout:fragment="script">
<script th:inline="javascript">
//회원가입 실패시 에러 메시지 출력
$(document).ready(function(){
var errorMessage = [[${errorMessage}]];
if(errorMessage != null){
alert(errorMessage);
}
});
</script>
</th:block>
<div layout:fragment="content">
<form action="/members/new" role="form" method="post" th:object="${memberFormDto}">
<div class="form-group py-2">
<label th:for="name">이름</label>
<input type="text" th:field="*{name}" class="form-control" placeholder="이름을 입력해주세요">
<p th:if="${#fields.hasErrors('name')}" th:errors="*{name}" class="fieldError">Incorrect data</p>
</div>
<div class="form-group py-2">
<label th:for="email">이메일주소</label>
<input type="email" th:field="*{email}" class="form-control" placeholder="이메일을 입력해주세요">
<p th:if="${#fields.hasErrors('email')}" th:errors="*{email}" class="fieldError">Incorrect data</p>
</div>
<div class="form-group py-2">
<label th:for="password">비밀번호</label>
<input type="password" th:field="*{password}" class="form-control" placeholder="비밀번호 입력">
<p th:if="${#fields.hasErrors('password')}" th:errors="*{password}" class="fieldError">Incorrect data</p>
</div>
<div class="form-group py-2">
<label th:for="address">주소</label>
<input type="text" th:field="*{address}" class="form-control" placeholder="주소를 입력해주세요">
<p th:if="${#fields.hasErrors('address')}" th:errors="*{address}" class="fieldError">Incorrect data</p>
</div>
<div style="text-align:center" class="py-3">
<button type="submit" class="btn btn-outline-dark">Submit</button>
<button type="button" class="btn btn-outline-dark" onclick="history.back();">Cancel</button>
</div>
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
</form>
</div>
</html>
${_csrf.token}
: 스프링 시큐리티를 사용할 경우 기본적으로 CSRF(Cross Site Request Forgery)
를 방어하기 위해 모든 POST 방식의 데이터 전송에는 CSRF 토큰 값이 있어야 합니다. CSRF 토큰은 실제 서버에서 요청이 맞는지 확인하기 위한 토큰입니다. 사용자의 세션에 임의의 값을 저장하여 요청마다 그 값을 포함하여 전송하면 서버에서 세션에 저장된 값과 요청이 온 값이 일치하는지 확인하여 CSRF를 방어합니다.메인 페이지로 이동할 수 있도록 MainController
를 생성하고 main.html
페이지를 작성합니다.
package me.jincrates.gobook.web;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class MainController {
@GetMapping(value = "/")
public String main() {
return "main";
}
}
<!DOCTYPE html>
<html xmlns:th="http//www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layouts/default}">
<div layout:fragment="content">
<h1>메인페이지입니다.</h1>
</div>
</html>
쓰다보니 너무 길어져서 로그인 및 로그아웃은 다음으로 넘기겠습니다.
좋은 글 감사합니다. 저도 따라 구현해봤는데 자꾸 @PostMapping 하는 부분에서 member 정보가 안넘어오는 NullPointerError가 나더라구요. 어떻게 하면 해결할 수 있을까요..?