본문 바로가기
🛠 백엔드/쇼핑몰 클론코딩

[4] Spring Security

by meteorfish 2022. 12. 30.
728x90

1. Spring Security

  • 인증, 인가를 위해 사용
  • 인가는 인증 과정 이후, 관리자 페이지에 일반 유저 접근하지 못하는 것 등이 포함

2. Security 설정

https://hhseong.tistory.com/173

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {

    }

    @Bean
    public PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }
}
  • WebSecurityConfigurerAdapter : 오버라이딩해서 보안 설정을 커스터마이징

3. 회원 가입

  • 회원 정보를 저장하는 Member 엔티티 생성
  • 회원 가입 양식을 저장하는 MemberFormDto 생성
  • MEmber엔티티를 DB에 저장할 MemberRepository 생성하고, findByEmail()을 만든다. (중복회원 검사시 사용)
  • Repository에 저장하는 메서드를 가진 MemberService를 만든다.
-> MemberService
@Service
@Transactional
@RequiredArgsConstructor // final이나 @NonNull이 붙은 필드에 생성자를 생성
public class MemberService implements UserDetailsService {

    //@Autowired 없이도 의존 주입가능!
    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("이미 가입된 회원입니다.");
        }
    }
  • 정상 작동하는 지 MemberServiceTest를 한다. (회원가입과 중복회원방지가 되는지 확인)
  • 로직을 만들었기 때문에 이를 매핑할 MemberController를 만든다.
  • 회원 가입을 위한 페이지를 만든다 (생략)

    CSRF란?

    사이트간 위조 요청으로 해커가 CRUD 행위를 웹사이트 요청하게 하는 공격
    이를 막기 위해 CSRF 토큰을 이용해 서버가 허용한 요청이 맞는지 확인한다.

  • 회원가입 후 메인페이지로 이동하는 redirect를 만든다.

이제 회원 가입 시, 중복을 막기 위해 Validation을 이용한다.
Gradle에서 Validation 사용하기

javax.validation의 Annotation들

위 Annotation을 이용해서 각 칼럼의 값을 설정한다.

-> MemberFormDto

@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;
}

이제 본격적으로 @Valid를 이용한 중복을 막기위해 컨트롤러를 설정한다.

-> MemberController

@RequestMapping("/members")
@Controller
@RequiredArgsConstructor
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 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:/";
    }
}
  • @RequiredArgsConstructor : final이나 @NonNull이 붙은 필드에 생성자를 생성해줌.
  • 검증하려는 객체에 @Valid를 붙이고, 검사후 결과를 저장할 BindingResult를 파라미터로 받는다. hasError() 시, 회원 가입 페이지로 이동.

4-11] 회원 가입 시 데이터 검증 결과


4. 로그인/회원가입

  • UserDetailsService 인터페이스 : DB에서 회원 정보를 가져오는 역할

    • loadUserByUsername() : 회원정보를 조회하여 사용자의 정보와 권한을 갖는 UserDetails 인터페이스 반환
  • UserDetails : 회원 정보를 담기 위해 사용하는 인터페이스

    • 위 인터페이스 직접 구현 혹은 Security가 제공하는 User 클래스 사용

위 인터페이스를 이용해서 요청받은 이메일과 같은 회원을 DB에서 조회하고 반환해보자.

-> MemberService

    @Override
    public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {

        Member member = memberRepository.findByEmail(email);

        if(member == null){
            throw new UsernameNotFoundException(email);
        }

        return User.builder()
                .username(member.getEmail())
                .password(member.getPassword())
                .roles(member.getRole().toString())
                .build();
    }
  • loadUserByUsername() : 유저의 정보를 불러와서 UserDetails로 리턴(UserDetailsService를 구현한 User 객체를 반환함.)
  • 시작은 builder(), 마무리는 build()

로그인 및 로그아웃 시 사용할 URL 및 인증 설정을 합니다.

-> SecurityConfig

    @Override
    protected void configure(HttpSecurity http) throws Exception { // http 요청에 대한 보안 설정
        http.formLogin()
                .loginPage("/members/login")
                .defaultSuccessUrl("/")
                .usernameParameter("email")
                .failureUrl("/members/login/error")
                .and()
                .logout()
                .logoutRequestMatcher(new AntPathRequestMatcher("/members/logout"))
                .logoutSuccessUrl("/")
        ;
    }


    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(memberService)
                .passwordEncoder(passwordEncoder());
    }
  • loginPage : 로그인 페이지 URL 설정
  • defaultSuccessUrl // 로그인 성공 시 이동할 URL
  • usernameParameter : 로그인 시 사용할 파라미터 이름 설정
  • failureUrl : 실패 시 이동할 URL
  • logoutRequestMatcher(new AntPathRequestMatcher("/members/logout")) :로그아웃 페이지 URL 설정
  • logoutSuccessUrl("/") : 로그아웃 성공 시 이동할 URL
  • configure() : 스프링 시큐리티의 인증은 AuthenticationManager로 이루어짐 (AuthenticationManagerBuilder 는 AuthenticationManager을 만드는 객체)
  • userDetailsService를 구현하는 객체로 memberService 지정하고, passwordEncoder를 지정

로그인과 로그아웃 페이지를 생성하고, 컨트롤러에 매핑시킨다.
로그인 실패


테스트 코드를 작성해서 로그인과 로그아웃이 정상작동하는지 점검한다.
먼저 test코드용 스프링 시큐리티를 dependency 추가한다.
https://mvnrepository.com/artifact/org.springframework.security/spring-security-test

MemberControllerTest에 로그인이 정상적으로 작동하는지 테스트 해보자

# 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")
                .loginProcessingUrl("/members/login")
                .user(email).password("12345"))
                .andExpect(SecurityMockMvcResultMatchers.unauthenticated());
    }
  • MockMvc : 실제 객체와 비슷하지만 테스트에 필요한 기능만 가지는 가짜 객체
    이를 통해 웹 브라우저에서 요청을 하는 것처럼 테스트 가능
  • perform() : MockMvc 실행
  • andExpect : 인증 완료시 테스트 코드를 통과시킴


5. 페이지 권한 설정하기

ADMIN 계정만 접근 가능한 상품 등록 페이지를 만들어보자
HTML을 생성 후 컨트롤러에 매핑한다.

ajax란?

빠르게 동작하는 동적인 웹 페이지를 만들기 위한 개발 기법의 하나입니다.
Ajax는 웹 페이지 전체를 다시 로딩하지 않고도, 웹 페이지의 일부분만을 갱신할 수 있습니다.
즉 Ajax를 이용하면 백그라운드 영역에서 서버와 통신하여, 그 결과를 웹 페이지의 일부분에만 표시할 수 있습니다.
( 이때 사용하는 객체가 XMLHttpRequest )

  • ajax의 경우 http request header에 XMLHttpRequest라는 값이 세팅되어 요청이 옮
  • 인증되지 않은 사용자가 ajax로 리소스 요청 시, Unauthorized 에러가 발생시키고 나머지는 로그인 페이지로 리다이렉트 함.
# CustomAuthenticationEntryPoint
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
        if("XMLHttpRequest".equals(request.getHeader("x-requested-with"))){
            response.sendError(HttpServletResponse.SC_UNAUTHORIZED,"Unauthorized");
        } else{
            response.sendRedirect("/members/login");
        }
    }
}
# SecurityConfig
    @Override
    protected void configure(HttpSecurity http) throws Exception { // http 요청에 대한 보안 설정
        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())
        ;
    }

    @Override
    public void configure(WebSecurity web) throws Exception {
        web.ignoring().antMatchers("/css/**","/js/**","/img/**"); // static의 하위 파일은 인증을 무시하도록 설정
    }
  • authorizeRequests() : 시큐리티 처리에 httpServletRequest를 이용
  • permitAll : 모든 사용자가 인증없이 해당 경로에 접근할 수 있도록 설정
  • hasRole() : ADMIN만 접근할 수 있도록 설정
  • .anyRequest().authenticated() : 설정한 경로를 제외한 나머지 경로는 모두 인증을 요구하도록 설정

ADMIN으로 등록된 회원이 없기 때문에 회원 가입시 Role을 ADMIN으로 설정 후 회원가입을 하면 성공적으로 로그인 된다.

이제 테스트 코드를 작성해서 정상적으로 작동되는지 점검하자

# ItemControllerTest
@SpringBootTest
@AutoConfigureMockMvc
@TestPropertySource(locations = "classpath:application-test.properties")
class ItemControllerTest {

    @Autowired
    MockMvc mockMvc;

    @Test
    @DisplayName("상품 등록 페이지 권한 테스트")
    @WithMockUser(username="admin", roles = "ADMIN")
    public void itemFormTest() throws Exception{
        mockMvc.perform(MockMvcRequestBuilders.get("/admin/item/new")) // 상품등록 페이지에 GET 요청 보냄
                .andDo(print()) // 요청과 응답 메시지를 콘솔에 출력
                .andExpect(status().isOk()); // 응답 상태 코드가 정상인지 확인하고 맞으면 테스트 통과
    }

    @Test
    @DisplayName("상품 등록 페이지 일반 회원 접근 테스트")
    @WithMockUser(username="user", roles = "USER")
    public void itemFormNotAdminTest() throws Exception{
        mockMvc.perform(MockMvcRequestBuilders.get("/admin/item/new"))
                .andDo(print())
                .andExpect(status().isForbidden()); // 응답 상태 코드가 Forbidden 예외인지 확인하고 맞으면 테스트 통과
    }

}
728x90

'🛠 백엔드 > 쇼핑몰 클론코딩' 카테고리의 다른 글

[2] JPA  (0) 2023.01.08
[7] Order  (0) 2023.01.07
[6] Item 등록 및 조회  (0) 2023.01.04
[5] 연관매핑  (0) 2022.12.30
[3] Thymeleaf  (0) 2022.12.25