Spring Boot - 쇼핑몰 프로젝트 04 (스프링 시큐리티를 이용한 회원 가입 및 로그인 - 2)
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());
}
}