-
[백견불야일타] 4장 스프링 시큐리티를 이용한 회원 가입 및 로그인SpringBoot/쇼핑몰 프로젝트 with JPA 2022. 9. 7. 01:30
4.1 스프링 시큐리티란 ?
- 스프링 기반의 애플리케이션을 위한 보안 솔루션 제공
4.2 스프링 시큐리티 설정
pom.xml
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency>
- 의존성 추가
- 스프링 시큐리티를 추가하였다면 앞으로 모든 요청은 인증을 필요로 함
http://localhost/thymeleaf/ex07
- 스프링 시큐리티에서 제공하는 로그인 페이지
- 기본적으로 제공하는 ID는 user, 비밀번호는 실행할 때 마다 콘솔창에 출력
- localhost/logout
- 로그아웃 기능도 제공
com.shop.config.SecurityConfig.java
package com.shop.config; import com.shop.service.MemberService; import org.springframework.beans.factory.annotation.Autowired; 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.util.matcher.AntPathRequestMatcher; @Configuration @EnableWebSecurity public class SecurityConfig { @Autowired MemberService memberService; @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.formLogin() .loginPage("/members/login") .defaultSuccessUrl("/") .usernameParameter("email") .failureUrl("/members/login/error") .and() .logout() .logoutRequestMatcher(new AntPathRequestMatcher("/members/logout")) .logoutSuccessUrl("/") ; http.authorizeRequests() .mvcMatchers("/css/**", "/js/**", "/img/**").permitAll() .mvcMatchers("/", "/members/**", "/item/**", "/images/**").permitAll() .mvcMatchers("/admin/**").hasRole("ADMIN") .anyRequest().authenticated() ; http.exceptionHandling() .authenticationEntryPoint(new CustomAuthenticationEntryPoint()) ; return http.build(); } @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } }
- 비밀번호를 DB에 그대로 저장했을 경우, 데이터베이스가 해킹당하면 고객의 회원정보가 그대로 노출되기 때문에 이를 해결하기 위해 BCryptPasswordEncoder의 해시 함수를 이용하여 비밀번호를 암호화하여 저장한다.
- BCryptPasswordEncoder를 빈으로 등록하여 사용
4.3 회원 가입 기능 구현
com.shop.constant.Role.java
package com.shop.constant; public enum Role { USER, ADMIN }
- 각각의 멤버가 일반 유저인지, 관리자인지 구분할 수 있는 Role이 있어야함
com.shop.dto.MemberFormDto.java
package com.shop.dto; import lombok.Getter; import lombok.Setter; @Getter @Setter public class MemberFormDto { private String name; private String email; private String password; private String address; }
- 회원 가입 화면으로부터 넘어오는 가입정보를 담을 dto를 생성
com.shop.entity.Member.java
package com.shop.entity; import com.shop.constant.Role; import com.shop.dto.MemberFormDto; import lombok.Getter; import lombok.Setter; import lombok.ToString; import org.springframework.security.crypto.password.PasswordEncoder; 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; // 이메일을 통해 유일하게 구분해야 하기 때문에 동일한 값이 들어올 수 없게 unique 설정 @Column(unique = true) private String email; private String password; private String address; //Enum 사용시 기본적으로 순서가 저장되는데, enum의 순서가 바뀔 경우 문제가 발생할 수 있으므로 EnumType.String옵션을 사용해서 //String으로 저장하기를 권장 @Enumerated(EnumType.STRING) private Role role; // Member 엔티티를 생성하는 코드 public static Member createMember(MemberFormDto memberFormDto, PasswordEncoder passwordEncoder){ Member member = new Member(); member.setName(memberFormDto.getName()); member.setEmail(memberFormDto.getEmail()); member.setAddress(member.getAddress()); // 스프링 시큐리티 설정 클래스에 등록한 BcryptPasswordEncoder Bean을 파라미터로 넘겨서 비밀번호를 암호화 String password = passwordEncoder.encode(memberFormDto.getPassword()); member.setPassword(password); member.setRole(Role.USER); return member; } }
- public static Member createMember(MemberFormDto memberFormDto, PasswordEncoder passwordEncoder) Member 엔티티에 회원을 생성하는 메소드를 만들어서 관리하면 코드가 변경되더라도 한 군데만 수정하면 되는 이점이 있음
@Column
- 문자열을 저장하는 VARCHAR 타입은 길이를 설정할 수 있고, 테이블에 데이터를 넣을 때 데이터가 항상 존재해야 하는 Not Null 조건 등이 있다. 이 어노테이션의 속성을 이용하면 테이블에 매핑되는 컬럼의 이름, 문자열의 최대 저장 길이 등 다양한 제약 조건 등을 추가 할 수 있다.
@Entity
- 클래스의 상단에 입력하면 JPA에 엔티티 클래스라는 것을 알려줌
- 반드시 기본키를 가져야 함
- @Id 어노테이션을 이용하여 id 멤버 변수를 기본키로 설정
- @GeneratedValue 어노테이션을 통한 기본키를 생성하는 전략은 총 4가지가 있다
- GenerationType.AUTO - JPA 구현체가 자동으로 생성 전략 결정
- GenerationType.IDENTITY - 기본키 생성을 데이터베이스에 위임 (MySQL -> AUTO_INCREMENT 사용)
- GenerationType.SEQUENCE - 데이터베이스 시퀀스 오브젝트를 이용한 기본키 생성. @SequenceGenerator를 사용하여 시퀀스 등록 필요
- GenerationType.TABLE - 키 생서용 테이블 사용. @TableGenerator 필요
com.shop.repository.MemberRepository.java
package com.shop.repository; import com.shop.entity.Member; import org.springframework.data.jpa.repository.JpaRepository; public interface MemberRepository extends JpaRepository<Member, Long> { Member findByEmail(String email); }
- Member 엔티티를 데이터베이스에 저장할 수 있도록 MemberRepository를 만듬
- JpaRepository<엔티티클래스명, PK키>
- 회원 가입 시 중복된 회원이 있는지 검사하기 위해 이메일로 회원을 검사할 수 있도록 쿼리 메소드 작성
com.shop.service.MemberService.java
package com.shop.Service; import com.shop.entity.Member; import com.shop.repository.MemberRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Service @Transactional @RequiredArgsConstructor public class MemberService { private final MemberRepository memberRepository; public Member saveMember(Member member){ validateDuplicateMember(member); return memberRepository.save(member); } // 이미 가입된 회원의 경우 IllegalStateException 예외 발생시킴 private void validateDuplicateMember(Member member){ Member findMember = memberRepository.findByEmail((member.getEmail())); if(findMember != null){ throw new IllegalStateException("이미 가입된 회원입니다"); } } }
- @Service - 서비스단 구성
- @Transactional - 비즈니스 로직을 담당하는 서비스 계층 클래스에 선언. 로직을 처리하다가 에러가 발생하였다면, 변경된 데이터를 로직을 수행하기 이전 상태로 콜백 시켜줌
- 빈을 주입하는 방법으로는 @Autowired 어노테이션을 이용하거나, 필드 주입(Setter 주입), 생성자 주입을 이용하는 방법이 있다. @RequiredArgsConstructor 어노테이션은 final이나 @NonNull이 붙은 필드에 생성자를 생성해줌. 빈에 생성자가 1개이고 생성자의 파라미터 타입이 빈으로 등록이 가능하다면 @Autowired 어노테이션 없이 의존성 주입 가능
com.shop.service.MemberServiceTest.java
package com.shop.service; import com.shop.Service.MemberService; import com.shop.dto.MemberFormDto; import com.shop.entity.Member; 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.assertEquals; @SpringBootTest // 테스트 클래스에 선언할 경우 테스트 실행 후 롤백 처리됨. 이를 통해 같은 메소드를 반복적으로 테스트가능 @Transactional @TestPropertySource(locations = "classpath:application-test.properties") class MemberServiceTest { @Autowired MemberService memberService; @Autowired PasswordEncoder passwordEncoder; // 회원 정보를 입력한 Member 엔티티를 만드는 메소드를 작성 public Member createMember(){ MemberFormDto memberFormDto = new MemberFormDto(); memberFormDto.setEmail("test@email.com"); memberFormDto.setName("홍길동"); memberFormDto.setAddress("1234"); memberFormDto.setPassword("1234"); return Member.createMember(memberFormDto, passwordEncoder); } @Test @DisplayName("회원가입 테스트") // junit의 Assertions 클래스의 asserEquals 메소드를 이용하여 저장하려고 요청했던 값과 실제 저장된 데이터를 비교 // 첫 번째 파라미터에는 기대 값, 두 번째 파라미터에는 실제로 저장된 값을 넣어줌 public void saveMemberTest(){ Member member = createMember(); Member savedMember = memberService.saveMember(member); assertEquals(member.getEmail(), savedMember.getEmail()); assertEquals(member.getName(), savedMember.getName()); assertEquals(member.getAddress(), savedMember.getAddress()); assertEquals(member.getPassword(), savedMember.getPassword()); assertEquals(member.getRole(), savedMember.getRole()); } }
- 회원가입 기능이 정상적으로 동작하는지 테스트 코드 작성
- 중복된 이메일로 회원 가입 시도 할 경우 "이미 가입된 회원입니다" 라는 에러 메세지를 출력해주는 테스트 코드 작성
- 회원 가입 로직이 변경되더라도 작성해둔 테스트를 실행하여 빠르게 테스트 및 검증이 가능
resources/templates/member/memberForm.html
<!DOCTYPE html> <html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layouts/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>
- 3장 Thymeleaf 에서 사용했던 부트스트랩 사용, 홈페이지의 에젱 Forms에 나와있는 코드를 변형하여 사용
- <input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"> 스프링 시큐리티를 사용할 경우 기본적으로 CSRF(Cross Site Request Forgery)를 방어하기 위해 모든 POST 방식의 데이터 전송에는 CSRF 토큰 값이 있어야 한다. CSRF 토큰은 실제 서버에서 허용한 요청이 맞는지 확인하기 위한 토큰이다. 사용자의 세션에 임의의 값을 저장하여 요청마다 그 값을 포함하여 전송하면 서버에서 세션에 저장된 값과 요청이 온 값이 일치하는지 확인하여 CSRF를 방어한다.
- CSRF(Cross Site Request Forgery) - 사이트간 위조 요청으로 사용자가 자신의 의지와 상관없이 해커가 의도한 대로 수정, 등록 삭제 등의 행위를 웹사이트 요청하게 하는 공격
com.shop.controller.MemberController.java
package com.shop.controller; import com.shop.dto.MemberFormDto; import com.shop.entity.Member; import com.shop.service.MemberService; import lombok.RequiredArgsConstructor; 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; @RequestMapping("/members") @Controller @RequiredArgsConstructor public class MemberController { private final MemberService memberService; private final PasswordEncoder passwordEncoder; @GetMapping(value ="/new") // 회원 가입 페이지로 이동할 수 있도록 MemberController 클래스에 메소드를 작성 public String memberForm(Model model){ // model 객체를 이용해 뷰에 전달한 데이터를 key, value 구조로 넣어줌 model.addAttribute("memberFormDto", new MemberFormDto()); return "member/memberForm"; } @PostMapping(value ="/new") public String memberForm(MemberFormDto memberFormDto){ Member member = Member.createMember(memberFormDto, passwordEncoder); memberService.saveMember(member); return "redirect:/"; } }
- @RequestMapping - 클라이언트 요청에 대해서 어떤 컨트롤러가 처리할지 매핑하는 어노테이션 // url에 "/members" 경로로 오는 요청을 MemberController가 처리하도록 함 @RequestMapping(value = "/members")
- @GetMapping - 컨트롤러 클래스에 @GetMapping 어노테이션을 이용해 클라이언트의 요청을 처리할 URL을 매핑한다. 현재는 서버의 루트로 오는 요청을 처리할 수 있도록 value="/"로 선언
com.shop.controller.MainController.java
package com.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"; } }
- 회원 가입 후 메인 페지이로 갈 수 있도록 소스 작성
resources/templates/main.html
<!DOCTYPE html> <html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layouts/layout1}"> <div layout:fragment="content"> <h1>메인 페이지</h1> </div>
pom.xml
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency>
- 회원 가입 페이지에서 서버로 넘어오는 값을 검증하기 위해 의존성 추가
- 유효한 값인지 판단하는 소스가 여러 군데 흩어지면 관리하기가 힘들다. 자바 빈 벨리데이션을 이용하면 객체의 값을 효율적으로 검증할 수 있다.
빈 검증 어노테이션
- @NotEmpty - NULL 체크 및 문자열의 경우 길이 0인지 검사
- @NotBlank - NULL 체크 및 문자열의 경우 길이 0 및 빈 문자열(" ") 검사
- @Length(min=, max=) - 최소, 최대 길이 검사
- @Email - 이메일 형식인지 검사
- @Max(숫자) - 지정한 값보다 작은지 검사
- @Min(숫자) - 지정한 값보다 큰지 검사
- @Null - 값이 NULL 인지 검사
- @NoutNull - 값이 NULL이 아닌지 검사
com.shop.dto.MemberFormDto.java
package com.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 { @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; }
- 유효성을 검증할 클래스의 필드에 어노테이션 선언
com.shop.controller.MemberController.java
@PostMapping(value ="/new") // 검증하려는 객체 앞에 @Valid 어노테이션 선언하고, 파라미터로 bindingResult 객체를 추가, 검사 후 결과는 bindingResult에 담아준다 public String newMember(@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:/"; }
- 회원가입이 성공하면 메인 페인지로 리다이렉트 시켜주고, 회원 정보 검증 및 중복회원 가입 조건에 의해 실패한다면 다시 회원 가입 페이지로 돌아가 실패 이유를 화면에 출력
4.4 로그인/로그아웃 구현하기
UserDetailsService
- UserDetailsService 인터페이스는 데이터베이스에서 회원 정보를 가져오는 역할을 담당
- loadUserByUsername() 메소드가 존재하며, 회원 정보를 조회하여 사용자의 정보와 권한을 갖는 UserDetails 인터페이스를 반환
- 스프링 시큐리티에서 UserDetailService를 구현하고 있는 클래스를 통해 로그인 기능을 구현한다고 생각하면 됨
UserDetail
- 스프링 시큐리티에서 회원의 정보를 담기 위해서 사용하는 인터페이스는 UserDetails이다. 이 인터페이스를 직접 구현하거나 스프링 시큐리티에서 제공하는 User 클래스를 사용한다. User 클래스는 UserDetails 인터페이스를 구현하고 있는 클래스임
com.shop.service.MemberService.java
package com.shop.service; import com.shop.entity.Member; import com.shop.repository.MemberRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; 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; @Service @Transactional @RequiredArgsConstructor // MemberService가 UserDetailsService를 구현 public class MemberService implements UserDetailsService { 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("이미 가입된 회원입니다."); } } // UserDetailsService 인터페이스의 loadUserByUsername() 메소드를 오버라이딩, 로그인 할 유저의 email을 파라미터로 전달받음 @Override public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { Member member = memberRepository.findByEmail(email); if(member == null){ throw new UsernameNotFoundException(email); } // UserDetail을 구현하고 있는 User 객체를 반환해줌. U // User 객체를 생성하기 위해서 생성자로 회원의 이메일, 비밀번호 role을 파라미터 넘겨줌 return User.builder() .username(member.getEmail()) .password(member.getPassword()) .roles(member.getRole().toString()) .build(); } }
- 로그인 기능 구현을 위해 기존에 만들었던 MemberService가 UserDetailsService를 구현
com.shop.config.SecurityConfig.java
package com.shop.config; import com.shop.service.MemberService; import org.springframework.beans.factory.annotation.Autowired; 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.util.matcher.AntPathRequestMatcher; @Configuration @EnableWebSecurity public class SecurityConfig { @Autowired MemberService memberService; @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.formLogin() .loginPage("/members/login") // 로그인 페이지 URL 설정 .defaultSuccessUrl("/") // 로그인 성공 시 이동할 URL 설정 .usernameParameter("email") // 로그인 시 사용할 파라미터 이름으로 email 지정 .failureUrl("/members/login/error") // 로그인 실패 시 이동할 URL 설정 .and() .logout() .logoutRequestMatcher(new AntPathRequestMatcher("/members/logout")) // 로그아웃 URL 설정 .logoutSuccessUrl("/") // 로그아웃 성공 시 이동할 URL 설정 ; http.authorizeRequests() .mvcMatchers("/css/**", "/js/**", "/img/**").permitAll() .mvcMatchers("/", "/members/**", "/item/**", "/images/**").permitAll() .mvcMatchers("/admin/**").hasRole("ADMIN") .anyRequest().authenticated() ; http.exceptionHandling() .authenticationEntryPoint(new CustomAuthenticationEntryPoint()) ; return http.build(); } @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } }
resource/templates/member/memberLoginForm.html
<!DOCTYPE html> <html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layouts/layout1}"> <!-- 사용자 CSS 추가 --> <th:block layout:fragment="css"> <style> .error { color: #bd2130; } </style> </th:block> <div layout:fragment="content"> <form role="form" method="post" action="/members/login"> <div class="form-group"> <label th:for="email">이메일주소</label> <input type="email" name="email" class="form-control" placeholder="이메일을 입력해주세요"> </div> <div class="form-group"> <label th:for="password">비밀번호</label> <input type="password" name="password" id="password" class="form-control" placeholder="비밀번호 입력"> </div> <p th:if="${loginErrorMsg}" class="error" th:text="${loginErrorMsg}"></p> <button class="btn btn-primary">로그인</button> <button type="button" class="btn btn-primary" onClick="location.href='/members/new'">회원가입</button> <input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"> </form> </div> </html>
- 로그인 페이지
- 회원의 아이디와 비밀번호를 입력하는 입력란과 회원 가입을 하지 않았을 경우 회원 가입 페이지로 이동할 수 있는 버튼을 만듬
com.shop.controller.MemberController.java
@GetMapping(value = "/login") public String loginMember(){ return "/member/memberLoginForm"; } @GetMapping(value = "/login/error") public String loginError(Model model){ model.addAttribute("loginErrorMsg", "아이디 또는 비밀번호를 확인해주세요"); return "/member/memberLoginForm"; }
- 로그인 페이지로 이동할 수 있도록 로직 구현
- 로그인 실패 시 "아이디 또는 비밀번호를 확인해주세요" 라는 메시지를 담아서 로그인 페이지로 보냄
pom.xml
<dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-test</artifactId> <scope>test</scope> <version>${spring-security.version}</version> </dependency>
- 화면을 이용하지 않고 Spring Security를 테스트하는 방법
- spring-security-test 의존성 추가
com.shop.controller.MemberControllerTest.java
package com.shop.controller; import com.shop.dto.MemberFormDto; import com.shop.entity.Member; import com.shop.service.MemberService; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers; import org.springframework.test.context.TestPropertySource; import org.springframework.test.web.servlet.MockMvc; import org.springframework.transaction.annotation.Transactional; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestBuilders.formLogin; @SpringBootTest @AutoConfigureMockMvc @Transactional @TestPropertySource(locations = "classpath:application-test.properties") public class MemberControllerTest { @Autowired private MemberService memberService; @Autowired private MockMvc mockMvc; @Autowired PasswordEncoder passwordEncoder; // 로그인 예제 진행을 위해서 로그인 전 회원을 등록하는 메소드를 만들어줌 public Member createMember(String email, String password){ MemberFormDto memberFormDto = new MemberFormDto(); memberFormDto.setEmail(email); memberFormDto.setName("홍길동"); memberFormDto.setAddress("서울시 마포구 합정동"); memberFormDto.setPassword(password); Member member = Member.createMember(memberFormDto, passwordEncoder); return memberService.saveMember(member); } @Test @DisplayName("로그인 성공 테스트") public void loginSuccessTest() throws Exception{ String email = "test@email.com"; String password = "1234"; this.createMember(email, password); mockMvc.perform(formLogin().userParameter("email") // 회원 가입 메소드를 실행 후 가입된 회원 정보로 로그인이 되는지 테스트 진행 // userParameter()를 이용해 이메일을 아이디로 세팅하고 로그인 URL에 요청 .loginProcessingUrl("/members/login") .user(email).password(password)) // 로그인이 성공하여 인증되었다면 테스트 코드가 통과 .andExpect(SecurityMockMvcResultMatchers.authenticated()); } }
- MockMvc 테스트를 위해 @AutoConfigureMockMvc 어노테이션 선언
- MockMvc 클래스를 이용해 실제 객체와 비슷하지만 테스트에 필요한 기능만 가지는 가짜 객체, 웹 브라우저에서 요청을 하는 것처럼 테스트할 수 있음
com.shop.controller.MemberControllerTest.java
@Test @DisplayName("로그인 실패 테스트") public void loginFailTest() throws Exception{ String email = "test@email.com"; String password = "1234"; this.createMember(email, password); mockMvc.perform(formLogin().userParameter("email") // 회원 가입 메소드를 실행 후 가입된 회원 정보로 로그인이 되는지 테스트 진행 // userParameter()를 이용해 이메일을 아이디로 세팅하고 로그인 URL에 요청 .loginProcessingUrl("/members/login") .user(email).password("12345")) // 회원가입은 정상적으로 진행하였지만 회원가입 시 입력한 비밀번호가 아닌 다른 비밀번호로 // 로그인을 시도하여 인증되지 않은 결과 값이 출력되어 테스트가 통과 .andExpect(SecurityMockMvcResultMatchers.unauthenticated()); }
- 회원가입은 정상적으로 진행했지만, 비밀번호를 잘못 입력하여 인증되지 않은 결과값이 나오는 코드
pom.xml
<dependency> <groupId>org.thymeleaf.extras</groupId> <artifactId>thymeleaf-extras-springsecurity5</artifactId> </dependency>
- 현재 상태로는 로그인을 해도 메뉴바에 로그인이라는 메뉴가 나타난다. 로그인 상태라면 '로그아웃' 이라는 메뉴가 나타나야 로그인 된 상태임을 알 수 있고, 다른 아이디로 로그인하려면 현재 계정로부터 로그아웃하고, 다시 로그인을 해야 한다. 상품 등록 메뉴의 경우에는 관리자만 상품을 등록 할 수 있도록 노출돼야 한다.
- 이를 도와주는 라이브러리인 'thymeleaf-extras-springsecurity5' 의존성을 추가한다.
resources/templates/fragments/header.html
<!DOCTYPE html> <!--Spring Security 태그를 사용하기 위해 네임스페이스 추가--> <html xmlns:th="http://www.thymeleaf.org" xmlns:sec="http://www.thymeleaf.org/extras/spring-security"> <div th:fragment="header"> <nav class="navbar navbar-expand-sm bg-primary navbar-dark"> <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarTogglerDemo03" aria-controls="navbarTogglerDemo03" aria-expanded="false" aria-label="Toggle navigation"> <span class="navbar-toggler-icon"></span> </button> <a class="navbar-brand" href="/">Shop</a> <div class="collapse navbar-collapse" id="navbarTogglerDemo03"> <ul class="navbar-nav mr-auto mt-2 mt-lg-0"> <!--관리자 계정(ADMIN ROLE)으로 로그인한 경우 상품 등록, 상품 관리 메뉴를 보여줌--> <li class="nav-item" sec:authorize="hasAnyAuthority('ROLE_ADMIN')"> <a class="nav-link" href="/admin/item/new">상품 등록</a> </li> <!--관리자 계정(ADMIN ROLE)으로 로그인한 경우 상품 등록, 상품 관리 메뉴를 보여줌--> <li class="nav-item" sec:authorize="hasAnyAuthority('ROLE_ADMIN')"> <a class="nav-link" href="/admin/items">상품 관리</a> </li> <!--장바구니와 구매이력 페이지의 경우 로그인(인증) 했을 경우에만 보여주도록 함--> <li class="nav-item" sec:authorize="isAuthenticated()"> <a class="nav-link" href="/cart">장바구니</a> </li> <!--장바구니와 구매이력 페이지의 경우 로그인(인증) 했을 경우에만 보여주도록 함--> <li class="nav-item" sec:authorize="isAuthenticated()"> <a class="nav-link" href="/orders">구매이력</a> </li> <!--로그인하지 않은 상태이면 로그인 메뉴를 보여줌--> <li class="nav-item" sec:authorize="isAnonymous()"> <a class="nav-link" href="/members/login">로그인</a> </li> <!--로그인한 상태이면 로그아웃 메뉴를 보여줌--> <li class="nav-item" sec:authorize="isAuthenticated()"> <a class="nav-link" href="/members/logout">로그아웃</a> <!--1--> </li> </ul> <form class="form-inline my-2 my-lg-0" th:action="@{/}" method="get"> <input name="searchQuery" class="form-control mr-sm-2" type="search" placeholder="Search" aria-label="Search"> <button class="btn btn-outline-success my-2 my-sm-0" type="submit">Search</button> </form> </div> </nav> </div> </html>
4.5 페이지 권한 설정하기
- 상품 등록 페이지의 경우 ADMIN 계정만 접근이 가능하고, 일반 USER 계정은 접근을 할 수 없도록 설정 추가
resources/templates/item/itemForm.html
<!DOCTYPE html> <html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layouts/layout1}"> <div layout:fragment="content"> <h1>상품등록 페이지입니다.</h1> </div> </html>
- ADMIN 계정만 접근할 수 있는 상품 등록 페이지
com.shop.controller.ItemController.java
package com.shop.controller; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; @Controller public class ItemController { @GetMapping(value = "/admin/item/new") public String itemForm(){ return "/item/itemForm"; } }
- 상품 등록 페이지에 접근할 수 있도록 하는 코드
- ajax의 경우 http request header에 XMLHttpRequest라는 값이 세팅되어 요청이 오는데, 인증되지 않은 사용자가 ajax로 리소스를 요청할 경우 "Unauthorized" 에러를 발생시키고 나머지 경우는 로그인 페이지로 리다이렉트 시켜줌
com.shop.config.SecurityConfig.java
package com.shop.config; import com.shop.service.MemberService; import org.springframework.beans.factory.annotation.Autowired; 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.util.matcher.AntPathRequestMatcher; @Configuration @EnableWebSecurity public class SecurityConfig { @Autowired MemberService memberService; @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.formLogin() .loginPage("/members/login") // 로그인 페이지 URL 설정 .defaultSuccessUrl("/") // 로그인 성공 시 이동할 URL 설정 .usernameParameter("email") // 로그인 시 사용할 파라미터 이름으로 email 지정 .failureUrl("/members/login/error") // 로그인 실패 시 이동할 URL 설정 .and() .logout() .logoutRequestMatcher(new AntPathRequestMatcher("/members/logout")) // 로그아웃 URL 설정 .logoutSuccessUrl("/") // 로그아웃 성공 시 이동할 URL 설정 ; // 시큐리티 처리에 HttpServletRequest를 이용한다는 것을 의미 http.authorizeRequests() .mvcMatchers("/css/**", "/js/**", "/img/**").permitAll() // permitAll()을 통해 모든 사용자가 인증(로그인)없이 ㅐ당 경로에 접근할 수 있도록 설정 // 메인 페이지, 회원 관련 URL, 뒤에서 만들 상품 상세 페이지, 상품 이미지를 불러오는 경로가 이에 해당 .mvcMatchers("/", "/members/**", "/item/**", "/images/**").permitAll() // /admin으로 시작하는 경로는 해당 계정이 ADMIN role 일 경우에만 접근 가능하도록 설정 .mvcMatchers("/admin/**").hasRole("ADMIN") // 위에서 설정해준 경로를 제외한 나머지 경로들은 모드 인증을 요구하도록 설정 .anyRequest().authenticated() ; // 인증되지 않은 사용자가 리소스에 접근하였을 때 수행되는 핸들러를 등록 http.exceptionHandling() .authenticationEntryPoint(new CustomAuthenticationEntryPoint()) ; return http.build(); } @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } }
- 현재 회원 가입 시 권한을 USER로 생성하므로, 로그인 후 상품 등록 ADMIN 페이지에 접그나려고 하면 403 Forbidden 에러가 나타남
com.shop.entity.Member.java
public static Member createMember(MemberFormDto memberFormDto, PasswordEncoder passwordEncoder){ Member member = new Member(); member.setName(memberFormDto.getName()); member.setEmail(memberFormDto.getEmail()); member.setAddress(memberFormDto.getAddress()); String password = passwordEncoder.encode(memberFormDto.getPassword()); member.setPassword(password); member.setRole(Role.ADMIN); return member; }
- User Role로 생성하던 권한을 ADMIN Role 로 생성하도록 수정
com.shop.controller.ItemControllerTest.java
package com.shop.controller; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.context.TestPropertySource; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @SpringBootTest @AutoConfigureMockMvc @TestPropertySource(locations = "classpath:application-test.properties") public class ItemControllerTest { @Autowired MockMvc mockMvc; @Test @DisplayName("상품 등록 페이지 권한 테스트") // 현재 회원의 이름이 admin이고, role이 ADMIN인 유저가 로그인된 상태로 테스트를 할 수 있도록 해주는 어노테이션 @WithMockUser(username = "admin", roles = "ADMIN") public void itemFormTest() throws Exception { // 상품 등록 페이지에 요청을 get 요청을 보냅니다. mockMvc.perform(MockMvcRequestBuilders.get("/admin/item/new")) // 요청과 응답 메시지를 확인 할 수 있도록 콘솔창에 출력 .andDo(print()) // 응답 상태 코드가 정상인지 확인 .andExpect(status().isOk()); } }
- 현재 로그인된 사용자의 Role에 따라 상품 등록 페이지에 접근이 가능한지 테스트 코드
com.shop.controller.ItemControllerTest.java
@Test @DisplayName("상품 등록 페이지 일반 회원 접근 테스트") // 현재 인증된 사용자의 Role을 USER로 세팅 @WithMockUser(username = "admin", roles = "USER") public void itemFormNotAdminTest() throws Exception { // 상품 등록 페이지에 요청을 get 요청을 보냅니다. mockMvc.perform(MockMvcRequestBuilders.get("/admin/item/new")) // 요청과 응답 메시지를 확인 할 수 있도록 콘솔창에 출력 .andDo(print()) // 상품 등록 페이지 진입 요청 시 Forbidden 예외가 발생하면 테스트가 성공적으로 통과 .andExpect(status().isForbidden()); }
- 로그인된 사용자의 Role이 일반 USER면, 상품 등록 페이지에 접근이 안 되는지 테스트하는 코드
'SpringBoot > 쇼핑몰 프로젝트 with JPA' 카테고리의 다른 글
[백견불야일타] 6장 상품 등록 및 조회하기 (0) 2022.10.08 [백견불야일타] 5장 연간 관계 매핑 (1) 2022.09.20 [백견불야일타] 3장 Thymeleaf 학습하기 (0) 2022.08.26 [백견불야일타] 2장 Spring Data JPA (0) 2022.08.19 [백견불여일타] 1장 개발환경구축 (0) 2022.08.16