Spring boot & JPA

Spring Boot - 쇼핑몰 프로젝트 04 (스프링 시큐리티를 이용한 회원 가입 및 로그인 - 2)

록's 2023. 4. 5. 12:46
728x90
반응형

 

https://rogi221.tistory.com/172

 

Spring Boot - 쇼핑몰 프로젝트 04 (스프링 시큐리티를 이용한 회원 가입 및 로그인 - 1)

스프링 시큐리티를 이용한 회원 가입 및 로그인 스프링 시큐리티 소개 애플리케이션을 만들기 위해서는 보통 인증/인가 등의 보안이 필요 웹에서 인증이란 해당 리소스에 대해서 작업을 수행할

rogi221.tistory.com


스프링 시큐리티를 이용한 회원 가입 및 로그인 - 2

 

 

로그인/로그아웃 구현

 

로그인/로그아웃 기능 구현하기

MemberService.java

// MemberService.java

@Service
@Transactional
@RequiredArgsConstructor
public class MemberService implements UserDetailsService {

	... 생략 ...

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

 

 

 

SecurityConfig.java

// 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.authentication.builders.AuthenticationManagerBuilder;
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;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    MemberService memberService;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.formLogin()
                .loginPage("/members/login")
                .defaultSuccessUrl("/")
                .usernameParameter("email")
                .failureUrl("/members/login/error")
                .and()
                .logout()
                .logoutRequestMatcher(new AntPathRequestMatcher
                        ("/members/logout"))
                .logoutSuccessUrl("/");
    }
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Override
    protected  void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(memberService)
                .passwordEncoder(passwordEncoder());
    }
}

 

 

 

 

resources/templates/member/memberLoginForm.html

// 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>

 

 

MemberController.java

// 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.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;


@RequestMapping("/members")
@Controller
@RequiredArgsConstructor
public class MemberController {

	... 생략 ...

    @GetMapping(value = "/login")
    public String loginMember() {
        return "/member/memberLoginForm";
    }

    @GetMapping(value ="/login/error")
    public String loginError(Model model){
        model.addAttribute("loginErrorMsg", "아이디 또는 비밀번호를 확인해주세요");
        return "/member/memberLoginForm";
    }
}

 

  • 로그인 페이지(localhost/member/login) url로 이동

 

 

 

 

로그인 테스트하기

pom.xml

// pom.xml

... 생략 ...

		<dependency>
			<groupId>org.springframework.security</groupId>
			<artifactId>spring-security-test</artifactId>
			<scope>test</scope>
			<version>${spring-security.version}</version>
		</dependency>
        
... 생략 ...

 

 

 

MemberControllerTest.java

// 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.autoconfigure.AutoConfiguration;
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 javax.transaction.Transactional;

import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestBuilders.formLogin;
@SpringBootTest
@AutoConfigureMockMvc
@Transactional
@TestPropertySource(locations="classpath:application-test.properties")
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")
                .loginProcessingUrl("/members/login")
                .user(email).password(password))
                .andExpect(SecurityMockMvcResultMatchers.authenticated());
    }
}

 

 

// 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")
                        .loginProcessingUrl("/members/login")
                        .user(email).password("12345"))
                        .andExpect(SecurityMockMvcResultMatchers.unauthenticated());
    }

}

 

 

 

 

 

로그인/로그아웃 화면 연동하기

 

pom.xml

... 생략 ...

<dependency>
    <groupId>org.thymeleaf.extras</groupId>
    <artifactId>thymeleaf-extras-springsecurity5</artifactId>
</dependency>

... 생략 ...

 

 

resources/templates/fragments/header.html

// header.html

<!DOCTYPE html>
<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">
                    <li class="nav-item"
                                sec:authorize="hasAnyAuthority('ROLE_ADMIN')">	<< 수정
                        <a class="nav-link" href="/admin/item/new">상품 등록</a>
                    </li>
                    <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>
                    </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>

로그인후 화면
로그아웃 한 화면

 

 

 

 

페이지 권한 설정

 

페이지 권한 설정하기

resources/templates/item/itemForm.html

// itemForm.html

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
      xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">

<div layout:fragment="content">
  
    <h1>상품등록 페이지입니다.</h1>
  
</div>
</html>

 

 

com.shop.controller.ItemController.java

// 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";
    }
}

 

 

com.shop.config.CustomAuthenticationEntryPoint.java

// CustomAuthenticationEntryPoint.java

package com.shop.config;


import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

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.java

// SecurityConfig.java

package com.shop.config;

	... 기존 임포트 생략 ...

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

	... 생략 ...

    @Override
    protected void configure(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("/", "/memvers/**","/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/**");
    }
}

 

  • 현재 회원 가입 시 권한을 USER로 생성하므로, 로그인 후 ‘http://localhost/admin/item/new’라는 상품 등록 ADMIN 페이지에 접근하려고 하면 403 Forbidden에러 발생

 

 

 

com.shop.entity.Member.java

// Member.java

package com.shop.entity;

... 기존 임포트 생략 ...

@Entity
@Table(name="member")
@Getter
@Setter
@ToString
public class Member {

   ... 생략 ...
   
    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;
    }
}

 

  • 다시 회원 가입 진행 및 로그인 후 상품 등록 페이지 접근 시 정상적으로 화면이 나오는 것을 볼 수 있음

 

 

 

유저 접근 권한 테스트

com.shop.controller.ItemControllerTest.java

// ItemController.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("상품 등록 페이지 권한 테스트")
    @WithMockUser(username = "admin", roles= "ADMIN")
    public void itemFormTest() throws Exception {
        mockMvc.perform(MockMvcRequestBuilders.get("/admin/item/new"))
                .andDo(print())
                .andExpect(status().isOk());
    }
}

 

 

com.shop.controller.ItemControllerTest.java

// ItemControllerTest.java

... 생략 ...

    @Test
    @DisplayName("상품 등록 페이지 일반 회원 접근 테스트")
    @WithMockUser(username = "user", roles = "USER")
    public void itemFormNotAdminTest() throws Exception {
        mockMvc.perform(MockMvcRequestBuilders.get("/admin/item/new"))
                .andDo(print())
                .andExpect(status().isForbidden());
    }
}

 

728x90
반응형