[Spring-plus] Spring Security를 이용한 회원가입
👨‍👧‍👦

[Spring-plus] Spring Security를 이용한 회원가입

Lecture
Framework
태그
dev
spring
public
완성
N
생성일
Mar 17, 2024 01:40 PM
LectureName
Spring

1. 스프링 시큐리티

스프링 시큐리티?
스프링 시큐리티는 스프링 기반의 애플리케이션에서 보안을 담당하는 프레임워크로서, 다음과 같은 기능을 제공합니다.
  • 인증(Authentication) : 사용자가 누구인지 확인하는 과정
  • 인가(Authorization) : 사용자에게 허용된 권한을 설정하는 과정
  • 다양한 인증 방식 지원 : 폼 인증, HTTP Basic/Digest 인증, OAuth2, LDAP 등
  • 사용자 정의 권한 처리 : 사용자가 가지는 권한을 세밀하게 관리 가능
  • 보안 설정 : HTTPS, 쿠키 보안 등 다양한 보안 설정 기능
 
스프링 시큐리티 설정
  1. Gradle 설정
//springSecurity implementation 'org.springframework.boot:spring-boot-starter-security'
  • 기본적으로 해당 기능을 추가하면 스프링에서 로그인을 구현한다.
  • 프롬포트에 userpassword가 출력된다.
  • 기본적으로 제공하는 기능을 오버라이딩 해서 커스터마이징 해주어야 한다.
 
 
  1. SpringSecurity 오버라이딩
@Configuration @EnableWebSecurity public class SecurityConfig extends WebSecurityConfigurerAdapter { @Autowired MemberService memberService; @Override protected void configure(HttpSecurity http) throws Exception{ } @Bean public PasswordEncoder passwordEncoder(){ return new BCryptPasswordEncoder(); } }
  • 현재는 모든 요청에 인증을 필요로 했지만 configure에 아무 동작도 하지 않으면 요청에 인증을 요구하지 않는다.
  • BCryptPasswordEncoder을 빈으로 등록해서 해시 함수를 이용하여 고객 비밀번호를 암호화하여 저장합니다.
  • 해당 페이지에서 스프링 시큐리티에 대한 설정을 한다고 보면 됩니다.
 
 

2. 로그인 커스터마이징 실습

등급 설정하기
public enum Role { USER, ADMIN }
  • 관리자, 유저를 구분합니다.
 
MemberDto 생성
회원가입 화면으로부터 넘어오는 정보를 담을 dto를 생성
package soti.shop.dto; import lombok.Getter; import lombok.Setter; import org.hibernate.validator.constraints.Length; import javax.validation.constraints.Email; import javax.validation.constraints.NotBlank; import javax.validation.constraints.NotEmpty; @Getter @Setter public class MemberFormDto { private String name; private String email; private String password; private String address; }
  • 이름, 이메일, 비밀번호, 주소 등을 담습니다.
 
Member 엔티티 생성
package soti.shop.entity; import lombok.Getter; import lombok.Setter; import lombok.ToString; import org.springframework.security.crypto.password.PasswordEncoder; import soti.shop.constant.Role; import soti.shop.dto.MemberFormDto; import soti.shop.dto.MemberFormDto; import javax.persistence.*; @Entity @Table(name = "member") @Getter @Setter @ToString public class Member { @Id @Column(name = "member_id") @GeneratedValue(strategy = GenerationType.AUTO) private Long id; private String name; @Column(unique = true) private String email; private String password; private String address; @Enumerated(EnumType.STRING) private Role role; public static Member createMember(MemberFormDto memberFromDto, PasswordEncoder passwordEncoder){ Member member = new Member(); member.setName(memberFromDto.getName()); member.setEmail(memberFromDto.getEmail()); member.setAddress(memberFromDto.getAddress()); String password = passwordEncoder.encode(memberFromDto.getPassword()); member.setPassword(password); member.setRole(Role.USER); return member; } }
  • 각각 컬럼별로 특징을 가지도록 했습니다.
  • id 같은 경우에는 ID 속성을
  • Email 같은 경우에는 중복이 되면 안되므로 unique 옵션을 주었습니다.
  • enum 같은 경우에는 사용할 때 기본적으로 순서가 저장됩니다. 하지만 enum의 순서가 바뀌면 문제가 될 수 있음으로 String으로 저장합니다.
  • 마지막으로 멤버 엔티티에 회원을 생성하는 메소드를 만들어서 관리를 합니다. 이렇게 하면 코드가 변경되어도 한군데만 수정하면 됩니다.
 
MemberRepository 생성
public interface MemberRepository extends JpaRepository<Member, Long> { Member findByEmail(String email); }
  • 이메일 중복 체크를 위해서 findByEmail 메서드를 생성합니다.
 
MemberService 생성
package soti.shop.service; import lombok.RequiredArgsConstructor; import lombok.Setter; 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 org.springframework.transaction.annotation.Transactional; import soti.shop.entity.Member; import soti.shop.repository.MemberRepository; @Service @Transactional @RequiredArgsConstructor public class MemberService implements UserDetailsService { private final MemberRepository memberRepository; public Member saveMember(Member member){ validateDuplicateMember(member); return memberRepository.save(member); } public void validateDuplicateMember(Member member){ Member findMember = memberRepository.findByEmail(member.getEmail()); if(findMember != null){ throw new IllegalStateException("이미 존재하는 회원입니다."); } } }
  • 중복 체크 로직을 넣어서 회원 가입 시 이메일 중복이 되지 않도록 합니다.
  • @Transactional 로직을 사용하면 로직을 처리하다가 에러가 발생하였다면 변경된 데이터를 로직을 수행하기 이전 상태로 콜백 시켜줍니다.
  • 비즈니스 로직을 담당하는 서비스 계층 클래스에 Transactional 어노테이션을 선언해 주었습니다.
 
테스트
package soti.shop.constant.service; import groovy.util.logging.Slf4j; import org.assertj.core.api.Assertions; 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 soti.shop.dto.MemberFormDto; import soti.shop.dto.MemberFormDto; import soti.shop.entity.Member; import soti.shop.service.MemberService; import static org.assertj.core.api.Assertions.assertThat; @SpringBootTest @Transactional @TestPropertySource(locations = "classpath:application-test.properties") @Slf4j class MemberServiceTest { @Autowired MemberService memberService; @Autowired PasswordEncoder passwordEncoder; public Member createMember(){ MemberFormDto memberFromDto = new MemberFormDto(); memberFromDto.setName("jalnik"); memberFromDto.setEmail("robin9901@naver.com"); memberFromDto.setAddress("광명시 하안동"); memberFromDto.setPassword("1234"); return Member.createMember(memberFromDto, passwordEncoder); } @Test @DisplayName("회원가입 테스트") public void saveMemberTest(){ Member member = createMember(); Member saveMember = memberService.saveMember(member); System.out.println("saveMember = " + saveMember); assertThat(saveMember).isEqualTo(member); } @Test @DisplayName("회원가입 중복 테스트") public void DuplicateMemberTest(){ Member member1 = createMember(); Member member2 = createMember(); Member saveMember1 = memberService.saveMember(member1); Throwable e = org.junit.jupiter.api.Assertions.assertThrows(IllegalStateException.class, () -> {memberService.saveMember(member2);}); assertThat(e.getMessage()).isEqualTo("이미 존재하는 회원입니다."); } }
 
 
회원가입 컨트롤러 작성
package soti.shop.controller; import lombok.Getter; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; 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 soti.shop.dto.MemberFormDto; import soti.shop.entity.Member; import soti.shop.service.MemberService; import javax.validation.Valid; @RequestMapping("/members") @Controller @RequiredArgsConstructor @Slf4j 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 newMember(@Valid MemberFormDto memberFromDto, BindingResult bindingResult, Model model){ //검증하려는 객체에 @Valid 붙이면 검증 가능 //검사 후 결과는 bindingResult에 담긴다. if(bindingResult.hasErrors()){ return "member/memberForm"; } try{ Member member = Member.createMember(memberFromDto, passwordEncoder); memberService.saveMember(member); } catch (IllegalStateException e){ model.addAttribute("errorMessage", e.getMessage()); log.error("error is {}",e.getMessage()); return "member/memberForm"; } return "redirect:/"; } }
  • 기본적으로 get방식으로 접근했을 때는 회원가입 폼을 로드하도록 한다
  • post 방식의 경우 파라미터에서 전달 받은 값을 memberFromDto 객체로 받고 검증한다.
  • 기본적으로 savemember 로직에서 실패할 시 IllegalStateException이 터짐으로 에러 핸들링 처리를 해당 에러로 처리한다.
  • 정상적으로 회원가입이 되었을 경우 /경로로 redirect 한다.
 
main controller
package soti.shop.controller; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; @Controller public class MainController { @GetMapping(value = "/") public String main(){ return "main"; } }
 
main.html
<!DOCTYPE html> <html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/layout1}"> <div layout:fragment="content"> <h1>this is main</h1> </div> </html>
 
MemberForm.html
<!DOCTYPE html> <html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/layout1}"> <!-- 사용자 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"> <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"> <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"> <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"> <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"> <button type="submit" class="btn btn-primary" style="">Submit</button> </div> <input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"> </form> </div> </html>
  • 현재 끝단에 hidden value로 csrf 토큰을 사용하도록 되어있다.
  • Spring security config page에서disable() 해주면 지워줘도 된다.
 
 
입력 값 검증
@Valid 어노테이션을 사용하면 입력 값 검증을 할 수 있습니다. 해당 설정을 하기 위해서 MemberFromDto를 수정해 보도록 합니다. 그전에 패키지를 추가합니다.
//springboot validation implementation 'org.springframework.boot:spring-boot-starter-validation'
이후 memberFromDto 클래스를 수정합니다.
package soti.shop.dto; @Getter @Setter public class MemberFormDto { @NotBlank(message = "이름은 필수 입력 값입니다.") private String name; @NotEmpty(message = "이메일은 필수 입력 값입니다.") @Email(message = "이메일 형식으로 입력해주세요.") private String email; @NotEmpty(message = "비밀번호는 필수 입력 값입니다.") @Length(min=8, max=16, message = "비밀번호는 8자 이상, 16자 이하로 입력해주세요") private String password; @NotEmpty(message = "주소는 필수 입력 값입니다.") private String address; }
  • 이렇게되면 파라미터에서 데이터가 넘어올 때 검증할 수 있습니다.