본문 바로가기
Spring/Spring Framework

스프링 시큐리티 (회원가입+ 로그인 /로그아웃)

by chogigang 2023. 3. 4.

 

애플리케이션을 만들기 위해서는 보통 인증/인가 등의 보안이 필요합니다. 스프링 시큐리티는 스프링 기반의 애플리케이션을 위한 보안 솔루션을 제공합니다.

 

애플리케이션의 보안에서 중요한 두가지 영역은 '인증'과 '인가' 입니다.

 

웹에서 인증이란 해당 리소스에 대해서 작업을 수행할 수 있는 주체인지 확인하는 것입니다. 예를 들어 어떤 커뮤니티에서

게이산의 글을 보는 것은 로그인을 하지 않아도 되지만, 댓글을 작성하려면 로그인을 해야 합니다.

댓글을 달기 위해서는 로그인이라는 인증 절차를 거쳐야합니다.

 

인가는 인증 과정 이후에 일어납니다. 커뮤니티를 관리하는 관리자 페이지에 접근하는 URL을 입력했을 때 해당 URL은 커뮤니티의 관리자만 접근할 수 있어야 합니다. 이때 접근하는 사용자가 해당 URL에 대해서 인가된 회원인지를 검사하는 것입니다. 인가된 유저라면 해당 URL에 대한 권한이 있기 때문에 접근이 가능합니다.

 

 

예제들은 쇼핑몰을 기준으로 작성할 겁니다. 

아주 기본만 다루고 있으며

현제 작성한 것보다 훨씬 더 많이 시큐리티를 공부해야 합니다.

 

 

참고 서적

http://www.yes24.com/Product/Goods/103453774

 

의존성 추가입니다.

 

 

그래들

implementation 'org.springframework.boot:spring-boot-starter-security'
 
 

 

메이븐

<dependency>
   <groupId>org.springframework.boot</groupId>
   <artifactId>spring-boot-starter-security</artifactId>
</dependency>

 

 

스프링 시큐리티 설정

 

SecurityConfig 소스를 작성합시다. 현재는 모든 요청에 인증을 필요로 하지만  Security Confing.java의 configure  메서드에 설정을 추가하지 않으면 요청에 인증을 요구하지 않습니다.

 

package com.shop.confing;

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.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

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

    }
@Bean
    public PasswordEncoder passwordEncoder(){

        return new BCryptPasswordEncoder();
    }
}

 

  • webSecurityConfigureaAdapter를 상속받는 클래스에 @EnableWebSecurity 어노테이션을 선언하면 SpringSecurityFilterChain 자동으로 포함됩니다. WebSecurityConfigurerAdapter를 상속받아서 메소드 오버라이딩을 통해 보안 설정을 커스터마이징 할 수 있습니다.
  • http 요청에 대한 보안을 설정합니다.  페이지 권한 설정, 로그인 페이지 설정,로그아웃 메소드 등에 대한 설정을 작성합니다. 뒤에 예제에서 설정 추가 방법을 알아볼 수 있습니다.
  • 비밀번호를 데이터베이스에 그대로 저장했을 경우, 데이터베이스가 해킹당하면 고객의 회원 정보가 그대로 노출됩니다. 이를 해결하기 위해 BCryptPasswordEncodr의 해시 함수를 이용하여 비밀번호를 암호화하여 저장합니다.               BCryptPasswordEncoder를 빈으로 등록하여 사용하겠습니다.

 

회원가입 기능 구현 

 

 

각각의 멤버는 일반 유저인지, 아니면 관리자인지 구분할 수 있는 역할(Role) 이 있어야 합니다. 이를 위해서 코드를 작성합

니다.

 

Role

package com.shop.constant;

public enum Role {
    USER,ADMIN

}

 

회원 가입 화면으로부터 넘어오는 가입정보를 담을 dto를 생성하겠습니다.

 

MemverFormDto

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 {

    
    private String name;

    
private String email;

  
    private  String password;

  
private  String address;






}

 

그 후 회원 정보를 저장하는 Member 엔티티를 만들어야 합니다 관리할 회원 정보는 이름, 이메일, 비밀번호, 주소, 역할입니다.

 

 

Member(엔티티)

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;

    @Column(unique = true)
    private String email;

    private  String password;

    private String address;

    @Enumerated(EnumType.STRING)
    private Role role;

    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.USER);
      return member;

    }


}

 

  1. 회원은 이메일을 통해 유일하게 구분해야 하기 때문에, 동일한 값이 데이터베이스에 들어올 수 없도록 unique 속성을 지정합니다.
  2. 자바의 enum 타입을 엔티티의 속성으로 지정할 수 있습니다. Enum을 사용할 때 기본적으로 순서가 저장되는데, enum의 순서가 바뀔 경우 문제가 발생할 수 있도록 "EnumType.STRING" 옵션을 사용해서 String으로 저장하기를 권장합니다.
  3. Member 엔티티를 생성하는 메소드입니다. Member 엔티티에 회원을 생성하는 메소드를 만들어서 관리를 한다면 코드가 변경되더라도 한 군데만 수정하면 되는 이점이 있습니다.
  4. 스프링 시큐리티 설정 클래스에 등록한 BCryPaswordEncoder Bean을 파라미터로 넘겨서 비밀번호를 암호화합니다.

 

Member 엔티티를 데이터베이스에 저장할 수 있도록 MemberRepository를 만듭시다.

 

MemberRepository

 

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);

}

 

 

MemberService 클래스를 작성합니다.

 

 

 

MemberService

@SpringBootTest
@Transactional
@TestPropertySource(locations = "classpath:application-test.properties")
public class MemberServiceTest {

@Autowired
    MemberService memberService;

@Autowired
    PasswordEncoder passwordEncoder;


public Member createMember(){
    MemberFormDto memberFormDto = new MemberFormDto();
    memberFormDto.setEmail("test@email.com");
    memberFormDto.setName("홍길동");
    memberFormDto.setAddress("서울시 마포구 합정동");
    memberFormDto.setPassword("1234");
    return Member.createMember(memberFormDto,passwordEncoder);

}
    @Test
    @DisplayName("회원가입 테스트")
    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());
}
@Test
    @DisplayName("중복 회원 가입 테스트")
    public void saveDuplicateMemberTest(){
    Member member1 = createMember();
    Member member2 = createMember();
    memberService.saveMember(member1);

    Throwable e = assertThrows(IllegalStateException.class,() -> {
        memberService.saveMember(member2);});
    assertEquals("이미 가입된 회원입니다.",e.getMessage());
}


}
  1. 비즈니스 로직을 담당하는 서비스계층 클래스에@Transactional 어노테이션을 선언합니다. 로직을 처리하다가 에러가 발생하였다면, 변경된 데이터를 로직을 수행하기 이전 상태로 콜백 시켜줍니다.
  2. 빈을 주입하는 방법으로는 @Autowired 어노테이션을 이용하거나, 필드 주입(Setter 주입), 생성자 주입을 이용하는 방법이 있습니다.@RequiredArgsConstructor 어노테이션은 final이나 @NonNull이 붙은 필드에 생성자를 생성해줍니다.빈에 생성자가 1개이고 생성자의 파라미터 타입이 빈으로 등록이 가능하다면 @Autowired 어노테이션 없이 의존성 주입이 가능합니다.
  3. 이미 가입된 회원의 경우 IllegalStateException 예외를 발생시킵니다.

 

 

테스트 코드를 작성하는 거 까지 티스토리를 작성하면 길이가 너무 길어지니 테스트 코드 설명은  생략하겠습니다.

 

 

 

 

회원가입 페이지 작성

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

 

 

memberForm.html (html 파일은 부트스트랩을 사용했습니다.)

 

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
      xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
      layout:decorate="~{layouts/layout1}">
<meta charset="UTF-8">



<!-- 사용자 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>

 

 

 

 

  1. 회원 가입 시 실패했다면 에러 메시지를 경고창을 이용해서 보여줍니다.
  2. 스프링 시큐리티를 사용할 경우 기본적으로 CSRF(Cross Site Request Forgery)를 방어하기 위해 모든 POST 방식의 데이터 전송에는 CSRF 토큰 값이 있어야 합니다. CSRF 토큰은 실제 서버에서 허용한 요청이 맞는지 확인하기 위한 토큰입니다. 사용자의 세션에 임의의 값을 지정하여 요청마다 그 값을 포함하여 전송하면 서버에서 세션에 저장된 값과 요청이 온 값이 일치하는지 확인하여 CSRF를 방어합니다.

 

CSRF

 CSRF(Cross Site Request Forgery)란 사이트 간 위조 요청으로 사용자가 자신의 의지와 상관없이 해커가 의도한 대로 수정, 등록, 삭제 등의 행위를 웹사이트 요청하게 하는 공격을 말합니다.

 

 

Member Controller

 

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 {
    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){
    Member member =Member.createMember(memberFormDto,passwordEncoder);
    memberService.saveMember(member);
    return "redirect:/";
    }
}

 

 

회원가입 후 메인 페이지로 갈 수 있도록 MainController를 하나 만들어 봅니다.

 

MainController

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

}

 

 

Main.html

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
      xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
      layout:decorate="~{layouts/layout1}">
<meta charset="UTF-8">

<head>

    <title>Title</title>
</head>
<body>
<div layout:fragment="content">

<h1> 메인페이지입니다.</h1>

</div>
</body>
</html>

 

 

회원 가입 처리하기 

 

유효한 값인지 판단하는 소스가 여러 군데 흩어지면 관리하기가 힘듭니다. 자바 빈 밸리데이션을 이용하면 객체의 값을 

효율적으로 검증할 수 있습니다. 빈 검증 어노테이션을 몇 가지 살펴보겠습니다.

 

java.validation 어노테이션 예시

어노테이션 설명
@NotEmpty NULL 체크 및 문자열의 경우 길이 0인지 검사
@NotBlank NULL 체크 및 문자열의 경우 길이 0 및 빈 문자열(" ") 검사
@Length(min= , max=) 최소, 최대 길이 검사
@Email 이메일 형식인지 검사
@Max(숫자) 지정한 값보다 작은지 검사
@Min 지정한 값보다 큰지 검사
@Null 값이 NULL인지 검사
@NotNull 값이 NULL이 아닌지 검사

 

 

DTO에 가서 유효성을 검증할 클래스 필드에 어노테이션을 선언합니다.

 

 

MemberFormDTO

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;






}

 

 

회원 가입이 성공하면 메인 페이로 리다이렉트 시켜주고, 회원 정보 검증 및 중복회원 가입 조건에 의해 실패한다면 다시 

회원 가입 페이지로 돌아가 실패 이유를 화면에 출력해 주겠습니다.

 

 

 

MemberController

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


}

 

  1. 검증하려는 객체의 앞에 @Valid 어노테이션을 선언하고,파라미터로 bindingResult 객체를 추가합니다. 검사후 결과는 bindingResult에 담아줍니다. bindingResult.hasErrors()를 호출하여 에러가 있다면 회원 가입 페이지로 이동합니다.
  2. 회원 가입 시 주우복 회원 가입 예외가 발생하면 에러 메시지를 뷰로 전달합니다.

유효하지 않은 회원 가입 정보를 입력 후 서버로 전송하면 해당 이유를 화면에서 보여줍니다.

 

 

 

 

회원 가입이 정상적으로 이루어졌다면 메인 페이지로 이동합니다.

 

 

 

 

실제 데이터 베이스를 확인합시다.

 

 

 

 

 

 

로그인/로그아웃 구현

 

 

1.UserDetailsService

  • userDetailService 인터페이스는 데이터베이스에서 회원 정보를 가져오는 역할을 담당합니다.
  • loadUserByUsername()메소드가 존재하며, 회원 정보를 조회하여 사용자의 정보와 권한을 갖는                              UserDetails 인터페이스를 반환합니다.

2.UserDetail

스프링 시큐리티에서 회원의 정보를 담기 위해서 사용하는 인터페이스는 userDetails입니다. 이 인터페이스를 직접 구현하거나 스프링 시큐리티에서 제공하는 User클래스를 사용합니다. User클래스는 UserDetails 인터페이스를 구현하고 있는

클래스입니다.

 

 

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

 

 

MemberService

package com.shop.service;

import com.shop.entity.Member;
import com.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);
            }


            //이미 가입된 회원인 경우 IllegalStateException 예외 발생 시키는 메소드
            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();

    }
}
  1. MemberService가 UserDetailsService를 구현합니다.
  2. UserDetailsService 인터페이스의 loadUserByUsername() 메서드를 오버라이딩합니다. 로그인할 유저의 email을      파라미터로 전달받습니다.
  3. UserDetail을 구현하고 있는 User 객체를 반환해 줍니다. User 객체를 생성하기 위해 생성자로 회원의 이메일, 비밀번호, role을 파라미터 넘겨줍니다.

 

package com.shop.confing;

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.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.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("/")//
        ;

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

}
  1. 로그인 페이지 URL을 설정합니다.
  2. 로그인 성공 시 이동할 URL을 설정합니다.
  3. 로그인 시 사용할 파라미터 이름을 email을 지정합니다.
  4. 로그인 실패 시 이동할 URL을 설정합니다.
  5. 로그아웃 URL을 서설 정합니다.
  6. 로그아웃 성공 시 이동할 URL을 설정합니다.
  7. Spring Security에서 인증은 AuthenticationManager를 통해 이루어지며 AuthenticationManagerBuilder가 AuthenticationManager를 생성합니다. userDetailService를 구현하고 있는 객체로 memberService를 지정해 주며, 비밀번호 암호화를 위해 passwordEncoder를 지정합니다.

 

이제 로그인 페이지를 만들어야 합니다. 로그인 페이지에서는 회원의 아이디와 비밀번호를 입력하는 입력란과 회원 가입을 하지 않았을 경우 회원가입 페이지로 이동할 수 있는 버튼을 만들었습니다.

 

MemberLoginForm

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

      layout:decorate="~{layouts/layout1}">
<meta charset="UTF-8">
<!-- 사용자 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에 로직을 구현해야 합니다. 또 로그인 실패 시 

"아이디 또는 비밀번호를 확인해 주세요"라는 메시지를 담아서 로그인 페이지로 보내려고 합니다.

 

 

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


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

 

를 추가해서 

 

 

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

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


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

}

 

 

 

 

 

로그인이 성공하였다면 메인 페이지로 이동하도록 설정하였기 때문에 메인 페이지 화면이 출력될 것입니다.

 

 

 

화면을 이용하지 않고 Spring Security를 테스트하는 방법을 알아봅시다.

 

의존성 추가

 

메이븐

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

 

 그래들

testImplementation 'org.springframework.security:spring-security-test'

        implementation 'junit:junit:4.12'

 

 

로그인 테스트 코드입니다.

 

 

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 javax.transaction.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")
                        .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());
    }
}

 

 

 

로그인 성공
로그인 실패

 

로그인 상태라면 '로그아웃'이라는 메뉴가 나타나야 로그인된 상태임을 알 수 있고, 다른 아이디로 로그인하려면 현재 계정으로부터 로그아웃하고, 다시 로그인해야 합니다. 상품 등록 메뉴의 경우 관리자만 상품을 등록할 수 있도록 노출돼야 합니다. 이를 도와주는 라이브러리로 'thymeleaf-extras-springsecurity5가 있습니다.

 

 

 

메이븐

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

 

그래들

implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity5'

 

 

header.html

 

 

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
      xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
<meta charset="UTF-8">

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

 

  1. Spring Security 태그를 사용하기 위해서 네임스페이스를 추가합니다.
  2. 관리자 계정(ADMIN ROLE)으로 로그인한 경우 상품 등록, 상품 관리 메뉴를 보여줍니다
  3. 장바구니와 구매이력 페이지의 경우 로그인(인증) 했을 경우에만 보여주도록 합니다.
  4. 로그인하지 않은 상태이면 로그인 메뉴를 보여줍니다.
  5. 로그인한 상태이면 로그아웃 메뉴를 보여줍니다.

 

페이지 권한 설정하기

 

마지막으로 페이지 접근 권한을 설정해야 합니다 상품 등록 페이지의 경우 ADMIN 계정만 접근이 가능하고 일반 USER 계정은 접근을 할 수 없도록 설정을 추가해야 합니다.

 

ItemForm.html

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
      xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
      layout:decorate="~{layouts/layout1}">

<meta charset="UTF-8">

<div layout:fragment="content">

  <h1>상품등록 페이지입니다.</h1>


</div>

</html>

 

 

ItemController

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" 에러를 발생시키고 나머지 경우는 로그인 페이지로 리다이렉트 시켜줍니다.

 

CustomAuthenticationEntryPoint

package com.shop.confing;

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

    }

 

SecurityConfig

package com.shop.confing;

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.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.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("/")
        ;

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

}

 

  1. 시큐리티 처리에 httpServletRequest를 이용한다는 것을 의미합니다.
  2. permitAll()을 통해 모든 사용자가 인증(로그인) 없이 해당 경로에 접근할 수 있도록 설정합니다. 메인 페이지, 회원 관련 URL, 뒤에서 만들 상품 페이지, 상품 이미지를 불러오는 경로가 이에 해당합니다.
  3. /admin으로 시작하는 경로는 해당 계정이 ADMIN Role일 경우에만 접근 가능하도록 설정합니다.
  4. 설정해 준 경로를 제외한 나머지 경로들은 모두 인증을 요구하도록 설정합니다.
  5. 인증되지 않은 사용자가 리소스에 접근하였을 때 수행되는 핸들러를 등록합니다.
  6. static 디렉터리의 하위 파일은 인증을 무시하도록 설정합니다.

 

현재 회원 가입 시 권한을 USER로 생성하므로, 로그인 후 'https://localhost/admin/item/new라는 상품 등록 ADMIN 페이지에 접근하려고 하면 403 forbidden 에러가 나타납니다.

 

관리자 회원의 경우 따로 회원 가입 페이지가 있어야 하지만 예제에서는 회원 가입 시 ADMIN으로 계정 생성 후 페이지에 접근이 가능한지만 살펴볼 겁니다.

 

 

Member

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;

    @Column(unique = true)
    private String email;

    private  String password;

    private String address;

    @Enumerated(EnumType.STRING)
    private Role role;

    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);//<USER ,ADMIN 둘줄 하나 설정
      return member;

    }

 

 

 

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

관리자 계정으로 로그인하였기 때문에 "상품등록", "상품관리" 메뉴도 내비게이션 바에 노출되는 것을 볼 수 있습니다.

 

 

현재 로그인된 사용자의 Role에 따라 상품 등록 페이지에 접근이 가능한지 테스트 코드로 작성을 할 겁니다.

 

 

 

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

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

}

 

 

 

 

 

 

사용된 패키지들입니다.