일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | ||||||
2 | 3 | 4 | 5 | 6 | 7 | 8 |
9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 | 17 | 18 | 19 | 20 | 21 | 22 |
23 | 24 | 25 | 26 | 27 | 28 |
- 멋사 10기
- 기사 제목 크롤링
- 멋사12
- 멋사10기
- 멋쟁이사자처럼11기
- 멋사 서류평가
- 멋사11기
- 코딩동아리
- 파이썬 크롤링
- 백엔드
- 크롤링
- 멋쟁이사자처럼대학
- 멋쟁이 사자처럼
- 멋쟁이사자처럼10기
- 멋쟁이사자처럼 서류
- 파이썬
- 디스코드봇
- 멋사 합격
- 깃허브
- 멋사
- 멋사 면접
- ㅏㄴ
- IT동아리
- 알림봇
- discord
- django
- 웹동아리
- 멋쟁이사자처럼
- 멋사 서류
- API
- Today
- Total
ACHO.pk devlog
[Springboot] 스프링 시큐리티를 이용한 로그인/로그아웃 본문
1. UserDetailsService
- UsersDetailService 인터페이스는 데이터베이스에서 회원 정보를 가져오는 역할을 담당한다.
- loadUsersByUsername() 메소드가 존재하며, 회원 정보를 조회하여 사용자의 정보와 권한을 갖는 UsersDetails 인터페이스를 반환한다.
스프링 시큐리티에서 UsersDetailService를 구현하고 있는 클래스를 통해 로그인 기능을 구현한다고 보면 된다.
2. UserDetail
스프링 시큐리티에서 회원의 정보를 담기 위해 사용하는 인터페이스는 UserDetails이다. 이 인터페이스를 직접 구현하거나 스프링 시큐리티에서 제공하는 User 클래스를 사용한다. User 클래스는 UserDetails 인터페이스를 구현하고 있는 클래스이다.
3. 로그인/로그아웃 구현하기
로그인 기능 구현을 위해 기존에 만들었던 MemberService가 UserDetailsService를 구현한다.
MemberService
package com.shop.shop.service;
import com.shop.shop.entity.Member;
import com.shop.shop.repository.MemberRepository;
import lombok.RequiredArgsConstructor;
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 javax.transaction.Transactional;
@Service
@Transactional
@RequiredArgsConstructor
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("이미 가입된 회원입니다.");
}
}
@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();
}
}
▹MemberService가 UserDetailsService를 구현한다.
public class MemberService implements UserDetailsService {
▹UserDetailsService 인터페이스의 loadUserByUsername() 메소드를 오버라이딩한다. 로그인할 유저의 email을 파라미터로 전달받는다.
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException
▹UserDetail을 구현하고 있는 User 객체를 반환해준다. User 객체를 생성하기 위해서 생성자로 회원의 이메일, 비밀번호, role을 파라미터가 넘겨준다.
return User.builder()
SecurityConfig
package com.shop.shop.config;
import com.shop.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.SecurityFilterChain;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter{
@Autowired
MemberService memberService;
@Override
public 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());
}
}
▹로그인 페이지 URL을 설정한다.
.loginPage("/members/login")
▹로그인 성공 시 이동할 URL을 설정한다.
.defaultSuccessUrl("/")
▹ 로그인 시 사용할 파라미터 이름으로 email을 지정한다.
.usernameParameter("email")
▹로그인 실패 시 이동할 URL을 설정한다.
.failureUrl("/members/login/error")
▹로그아웃 URL을 설정한다.
.logoutRequestMatcher(new AntPathRequestMatcher("/members/logout"))
▹로그아웃 성공 시 이동할 URL을 설정한다.
.logoutSuccessUrl("/")
▹Spring Security에서 인증은 AuthenticationManager를 통헤 이루어지며 AuthenticationManagerBuilder가 AuthenticationManager를 생성한다. userDetailService를 구현하고 있는 객체로 memberService를 지정해주며, 비밀번호 암호화를 위해 passwordEncoder를 지정한다.
protected void configure(AuthenticationManagerBuilder auth) throws Exception
.passwordEncoder(passwordEncoder());
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>
MemberController에 로그인 페이지로 이동할 수 있도록 로직을 구현하면 된다.
또한 로그인 실패 시 "아이디 또는 비밀번호를 확인해주세요" 라는 메시지를 담아서 로그인 페이지에 보낸다.
MemberController
package com.shop.shop.controller;
import com.shop.shop.dto.MemberFormDto;
import com.shop.shop.entity.Member;
import com.shop.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 {
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:/";
}
@GetMapping(value = "/login")
public String loginMember(){
return "/member/memberLoginForm";
}
@GetMapping(value = "/login/error")
public String loginError(Model model){
model.addAttribute("loginErrorMsg", "아이디 또는 비밀번호를 확인해주세요");
return "/member/memberLoginForm";
}
}
4. 화면을 이용하지 않고 Spring Security를 테스트
화면을 이용하지 않고 Spring Security를 테스트하는 방법을 알아보자.
pom.xml에 의존성을 추가해준다.
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
<version>${spring-security.version}</version>
</dependency>
로그인 테스트를 위해 test 패키지에 MemberControllerTest 클래스를 작성한다.
MemberController에서 윈도우 기준 Ctrl + Shift + T 누르면 된다.
package com.shop.shop.controller;
import com.shop.shop.dto.MemberFormDto;
import com.shop.shop.entity.Member;
import com.shop.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 javax.transaction.Transactional;
import static org.junit.jupiter.api.Assertions.*;
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());
}
@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());
}
}
▹MockMvc 테스트를 위해 @AutoConfigureMockMvc 어노테이션을 선언한다.
@AutoConfigureMockMvc
▹MockMvc 클래스를 이용해 실제 객체와 비슷하지만 테스트에 필요한 기능만 가지는 가짜 객체이다. MockMvc 객체를 이용하면 웹 브라우저에서 요청을 하는 것처럼 테스트를 할 수 있다.
private MockMvc mockMvc;
▹로그인 예제 진행을 위해서 로그인 전 회원을 등록하는 메소드를 만들어준다.
public Member createMember(String email, String password)
▹회원 가입 메소드를 실행 후 가입된 회원 정보로 로그인이 되는지 테스트를 진행한다. userParameter()를 이용하여 이메일을 아이디로 세팅하고 로그인 URL에 요청한다.
.loginProcessingUrl("/members/login")
▹로그인이 성공하여 인증됐다면 테스트 코드가 통과한다.
.andExpect(SecurityMockMvcResultMatchers.authenticated());
▹회원가입은 정상적으로 진행했지만 회원가입 시 입력한 비밀번호가 아닌 다른 비밀번호로 로그인을 시도하여 인증되지 않은 결과 값이 출력되어 테스트가 통과한다.
.andExpect(SecurityMockMvcResultMatchers.unauthenticated());
결과
5. 페이지 권한 설정하기
마지막으로 페이지 접근 권한을 설정하는 방법을 알아보자. 상품 등록 페이지의 경우 ADMIN 계정만 접근이 가능하고, 일반 USER 계정은 접근을 할 수 없도록 설정을 추가한다.
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>
상품 등록 페이지에 접근할 수 있도록 ItemController 클래스를 작성한다.
ItemController
package com.shop.shop.controller;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
@RequiredArgsConstructor
public class ItemController {
@GetMapping(value = "/admin/item/new")
public String itemForm() {
return "item/itemForm";
}
}
ajax의 경우 http request header에 XMLHttpRequest 라는 값이 세팅되어 요청이 오는데, 인증되지 않은 사용자가 ajax로 리소스를 요청할 경우 "Unauthorized" 에러를 발생시키고 나머지 경우는 로그인 페이지로 리다이렉트 시켜준다.
config > CustomAuthenticationEntryPoint.java
package com.shop.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 {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized");
}
}
config > SecurityConfig.java
package com.shop.shop.config;
import com.shop.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.builders.WebSecurity;
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.SecurityFilterChain;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter{
@Autowired
MemberService memberService;
@Override
public 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("/css/**", "/js/**", "/img/**").permitAll()
.mvcMatchers("/", "/members/**", "/item/**", "/images/**").permitAll()
.mvcMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
;
http.exceptionHandling()
.authenticationEntryPoint(new CustomAuthenticationEntryPoint())
;
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception{
auth.userDetailsService(memberService)
.passwordEncoder(passwordEncoder());
}
@Override
public void configure(WebSecurity web) throws Exception{
web.ignoring().antMatchers("/css/**", "/js/**", "/img/**");
}
}
▹시큐리티 처리에 HttpServiceRequset를 이용한다는 것을 의미한다.
http.authorizeRequests()
▹permitAll()을 통해 모든 사용자가 인증(로그인)없이 해당 경로에 접근할 수 있도록 설정한다. 메인 페이지, 회원 관련 URL, 뒤에서 만들 상품 상세 페이지, 상품 이미지를 불러오는 경로가 이에 해당한다.
mvcMatchers("/", "/members/**", "/item/**", "/images/**").permitAll()
▹/admin 으로 시작하는 경로는 해당 계정이 ADMIN ROLE일 경우에만 접근 가능하도록 설정한다.
.mvcMatchers("/admin/**").hasRole("ADMIN")
▹위에서 설정해준 경로를 제외한 나머지 경로들은 모두 인증을 요구하도록 설정한다.
.anyRequest().authenticated()
▹인증되지 않은 사용자가 리소스에 접근하였을 때 수행되는 핸들러를 등록한다.
.authenticationEntryPoint(new CustomAuthenticationEntryPoint())
▹static 디렉토리 하위 파일은 인증을 무시하도록 설정한다.
web.ignoring().antMatchers("/css/**", "/js/**", "/img/**");
entity > Member.java
Member 엔티티 생성시 User Role로 생성하게 하도록 권한을 준다.
member.setRole(Role.USER);
6. 상품 등록 페이지에 접근 가능 테스트 코드
현재 로그인된 사용자의 Role에 따라 상품 등록 페이지에 접근이 가능한지 테스트 코드를 작성한다.
package com.shop.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.junit.jupiter.api.Assertions.*;
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")
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());
}
@Test
@DisplayName("상품 등록 페이지 일반 회원 접근 테스트")
@WithMockUser(username = "user", roles = "USER")
public void itemFormNotAdminTest() throws Exception{
mockMvc.perform(MockMvcRequestBuilders.get("/admin/item/new"))
.andDo(print())
.andExpect(status().isForbidden());
}
}
▹현재 회원의 이름이 admin이고, role이 ADMIN인 유저가 로그인된 상태로 테스트를 할 수 있도록 해주는 어노테이션이다.
@WithMockUser(username = "admin", roles = "ADMIN")
▹상품 등록 페이지에 get 요청을 보낸다.
mockMvc.perform(MockMvcRequestBuilders.get("/admin/item/new"))
▹요청과 응답 메시지를 확인할 수 있도록 콘솔창에 출력한다.
.andDo(print())
▹응답 상태 코드가 정상인지 확인한다.
.andExpect(status().isOk());
▹현재 인증된 사용자의 Role을 USER로 세팅한다.
@WithMockUser(username = "user", roles = "USER")
▹상품 등록 페이지 진입 요청 시 Forbidden 예외가 발생하면 테스트가 성공적으로 통과한다.
.andExpect(status().isForbidden());
결과
'프레임워크 > Springboot' 카테고리의 다른 글
[Springboot] 상품 등록하기 (0) | 2023.03.12 |
---|---|
[Springboot] 연관 관계 매핑 (0) | 2023.03.06 |
[Springboot] 스프링 시큐리티를 이용한 회원가입 (0) | 2023.02.23 |
[Springboot-쇼핑몰프로젝트] Thymeleaf 학습하기 (0) | 2023.02.17 |
[Springboot-쇼핑몰프로젝트] 환경 구축과 Mysql 연동 (0) | 2023.02.04 |