본문 바로가기

IT/Spring

[Spring Boot] Spring Security 적용하기

Spring Security 란?

Spring Security는 스프링 기반의 어플리케이션 보안을 담당하는 프레임워크입니다. Spring Security를 사용하면 사용자 인증, 권한, 보안처리를 간단하지만 강력하게 구현 할 수 있습니다.

Spring Boot + Hibernate + SpringSecurity + thymeleaf + mariadb를 이용해 간단한 회원 가입 및 로그인 기능을 구현해보겠습니다.

pom.xml

    ...
    <version>2.1.9.RELEASE</version>
    ...
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

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

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

        
        <dependency>
            <groupId>org.mariadb.jdbc</groupId>
            <artifactId>mariadb-java-client</artifactId>
        </dependency>
        
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-thymeleaf</artifactId>
        </dependency>
        
<!--        thymeleaf에서 Security 명령어를 사용하기 위해 포함-->
        <dependency>
            <groupId>org.thymeleaf.extras</groupId>
            <artifactId>thymeleaf-extras-springsecurity5</artifactId>
        </dependency>
    </dependencies>
    ...

 


application.yml

spring :
    datasource :
        url : jdbc:mariadb://localhost:3306/local_toy
        username : tester
        password : 1234
    jpa :     
        hibernate :
            ddl-auto : update  

Spring Security 설정하기

Spring Security는 FilterChainProxy라는 이름으로 내부에 여러 Filter들이 동작하고 있습니다.

Spring Security는 Filter를 이용해서 기본적인 기능을 간단하게 구현할 수 있습니다. 

설정은 WebSecurityConfigurerAdapter 클래스를 상속받아 오버라이딩하는 방식으로 진행할 수 있습니다.

(Spring Security Filter 및 동작 설명을 알고 싶다면 아래 링크로 이동해주세요.)

2020/02/07 - [IT/Spring] - [Spring Boot] Spring Security의 동작

참조 https://spring.io/guides/topicals/spring-security-architecture#_web_security

클라이언트가 서버에 데이터를 요청하면 DispatcherServlet에 전달되기 이전에 여러 ServletFilter를 거칩니다.

이때 Spring Security에 등록했었던 Filter를 이용해 사용자 보안 관련된 처리를 진행하는데, 연결된 여러개의 Filter들로 구성 되어있어서 FilterChain 이라고 부릅니다.

SpringConfig.java

import com.example.demo.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.method.configuration.EnableGlobalMethodSecurity;
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
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private MemberService memberService;

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Override
    public void configure(WebSecurity web) throws Exception {
        web.ignoring().antMatchers("/css/**", "/js/**", "/img/**", "/lib/**");
    }

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

        http.authorizeRequests()
                .antMatchers("/member/**").authenticated()
                .antMatchers("/admin/**").authenticated()
                .antMatchers("/**").permitAll();

        http.formLogin()
                .loginPage("/login")
                .defaultSuccessUrl("/")
                .permitAll();

        http.logout()
                .logoutRequestMatcher(new AntPathRequestMatcher("/logout"))
                .logoutSuccessUrl("/login")
                .invalidateHttpSession(true);

        http.exceptionHandling()
                .accessDeniedPage("/denied");
    }

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

}

Spring Securiry 설정 활성화하기

@Configuration 
해당 클래스를 Configuration으로 등록합니다.
@EnableWebSecurity 
Spring Security를 활성화 시킵니다.
@EnableGlobalMethodSecurity(prePostEnabled = true)
Controller에서 특정 페이지에 특정 권한이 있는 유저만 접근을 허용할 경우 @PreAuthorize 어노테이션을 사용하는데, 해당 어노테이션에 대한 설정을 활성화시키는 어노테이션입니다. (필수는 아닙니다.)

MemberService

로그인 요청 시, 입력된 유저 정보와 DB의 회원정보를 비교해 인증된 사용자인지 체크하는 로직이 정의되어있습니다.

BCryptPasswordEncoder

비밀번호를 복호화/암호화하는 로직이 담긴 객체를 Bean으로 등록합니다.


WebSecurity, HttpSecurity, AuthAuthenticationManagerBuilder configure 설정

오버라이딩configure 메소드들에 대해 설명하겠습니다.

WebSecurity

WebSecurity는 FilterChainProxy를 생성하는 필터입니다. 다양한 Filter 설정을 적용할 수 있습니다.

web.ignoring().antMatchers("/css/**", "/js/**", "/img/**", "/lib/**");  

위 설정을 통해 Spring Security에서 해당 요청은 인증 대상에서 제외시킵니다.

HttpSecurity

HttpSecurity를 통해 HTTP 요청에 대한 보안을 설정할 수 있습니다.

http.authorizeRequests()
    .antMatchers("/member/**").authenticated()
    .antMatchers("/admin/**").authenticated()
    .antMatchers("/**").permitAll();

http 요청에 대해서 모든 사용자가 /** 경로로 요청할 수 있지만, /member/** , /admin/** 경로는 인증된 사용자만 요청이 가능합니다. 

authorizeRequests()
HttpServletRequest 요청 URL에 따라 접근 권한을 설정합니다. 
antMatchers("pathPattern")
요청 URL 경로 패턴을 지정합니다.
authenticated()
인증된 유저만 접근을 허용합니다.
permitAll()
모든 유저에게 접근을 허용합니다.
anonymous()
인증되지 않은 유저만 허용합니다.
denyAll()
모든 유저에 대해 접근을 허용하지 않습니다.

로그인 설정을 진행합니다.

http.formLogin()
    .loginPage("/login")
    .defaultSuccessUrl("/")
    .permitAll();

 

formLogin()
form Login 설정을 진행합니다.
loginPage("path")
커스텀 로그인 페이지 경로와 로그인 인증 경로를 등록합니다.
defaultSuccessUrl("path")
로그인 인증을 성공하면 이동하는 페이지를 등록합니다.

로그아웃 설정을 진행합니다.

http.logout()
    .logoutRequestMatcher(new AntPathRequestMatcher("/logout"))
    .logoutSuccessUrl("/login")
    .invalidateHttpSession(true);
logout()
로그아웃 설정을 진행합니다.
logoutRequestMatcher(new AntPathRequestMatcher("path"))
로그아웃 경로를 지정합니다.
logoutSuccessUrl("/path")
로그아웃 성공 시 이동할 경로를 지정합니다.
invalidateHttpSession(true)
로그아웃 성공 시 세션을 제거합니다.

권한이 없는 사용자가 접근했을 경우 이동할 경로를 지정합니다.

http.exceptionHandling()
    .accessDeniedPage("/denied");

AuthenticationManagerBuilder

AuthenticationManager를 생성합니다. AuthenticationManager는 사용자 인증을 담당합니다.

auth.userDetailsService(service)org.springframework.security.core.userdetails.UserDetailsService 인터페이스를 구현한 Service를 넘겨야합니다. 

auth.userDetailsService(memberService).passwordEncoder(passwordEncoder());

로그인 인증을 위한 회원 정보 체크 로직

비밀번호를 체크하는 로직을 구현하려면 UserDetailsService 인터페이스를 구현한 클래스가 필요합니다.

해당 기능을 구현하기 위해 Member Entity와 Repository, Service 등을 구현해보겠습니다.

Member.java

import java.time.LocalDateTime;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.Table;

@Entity
@Table(name = "tb_member")
public class Member {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer id;

    @Column(length = 255, nullable = false)
    private String name;

    @Column(length = 255, nullable = false, unique = true)
    private String account;

    @Column(length = 255, nullable = false)
    private String password;

    @Column(name = "last_access_dt")
    private LocalDateTime lastAccessDt;

    @Column(name = "reg_dt")
    private LocalDateTime regDt;

    public Member() {
    }

    public Member(Integer id, String name, String account, String password) {
        this.id = id;
        this.name = name;
        this.account = account;
        this.password = password;
    }
    
    //getter,setter 생략
}

 

MemberTO.java

import com.example.demo.model.entity.Member;
import java.time.LocalDateTime;

public class MemberTO {

    private Integer id;

    private String name;

    private String account;

    private String password;

    private LocalDateTime lastAccessDt;

    private LocalDateTime regDt;

    public Member toEntity() {
        return new Member(id, name, account, password);
    }
//getter,setter 생략
}

 

MemberDao.java

import com.example.demo.model.entity.Member;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;

public interface MemberDao extends JpaRepository<Member, Integer> {
    Optional<Member> findByAccount(String account);
}

 

MemberService.java

import com.example.demo.model.TO.MemberTO;
import org.springframework.security.core.userdetails.UserDetailsService;

public interface MemberService extends UserDetailsService {
    Integer save(MemberTO memberTO);
}

 

MemberServiceImpl.java

import com.example.demo.dao.MemberDao;
import com.example.demo.model.TO.MemberTO;
import com.example.demo.model.entity.Member;
import com.example.demo.service.MemberService;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class MemberServiceImpl implements MemberService {

    @Autowired
    private MemberDao memberDao;

    @Override
    public UserDetails loadUserByUsername(String account) throws UsernameNotFoundException {
        Optional<Member> memberEntityWrapper = memberDao.findByAccount(account);
        Member memberEntity = memberEntityWrapper.orElse(null);

        List<GrantedAuthority> authorities = new ArrayList<>();

        authorities.add(new SimpleGrantedAuthority("ROLE_MEMBER"));

        return new User(memberEntity.getAccount(), memberEntity.getPassword(), authorities);
    }

    @Transactional
    @Override
    public Integer save(MemberTO memberTO) {
        Member member = memberTO.toEntity();
        member.setLastAccessDt(LocalDateTime.now());
        member.setRegDt(LocalDateTime.now());

        // 비밀번호 암호화
        BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
        member.setPassword(passwordEncoder.encode(member.getPassword()));
        return memberDao.save(member).getId();
    }
}

loadUserByUsernae 메소드는 입력한 account를 이용해 회원을 조회합니다. 그리고 회원 정보와 권한 정보가 담긴 User 클래스를 반환합니다. (User 클래스는 UserDetails 인터페이스를 구현하고 있습니다.)

비밀번호 인증은 SpringSecurity의 AuthenticationProvider 객체에서 진행합니다. 직접 커스텀해서 비밀번호 인증 로직을 구현할 수 있지만, 이번에는 기본적으로 지원하는 AuthenticationProvide를 사용하겠습니다.   

 

HomeController.java

import com.example.demo.model.TO.MemberTO;
import com.example.demo.service.MemberService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;

@Controller
@RequestMapping(value = "/")
public class HomeController {

    @Autowired
    private MemberService memberService;

    @GetMapping("/")
    public String homeView() {
        return "pages/home";
    }

    @GetMapping("/login")
    public String loginView() {
        return "pages/login";
    }

    @GetMapping("/signup")
    public String signupView() {
        return "pages/signup";
    }

    @PostMapping("/signup")
    public String signup(MemberTO memberTO) {
        memberService.save(memberTO);
        return "redirect:/login";
    }

    @PreAuthorize("hasRole('ROLE_MEMBER')")
    @GetMapping("/member/info")
    public String userInfoView() {
        return "pages/user_info";
    }

    @PreAuthorize("hasRole('ROLE_ADMIN')")
    @GetMapping("/admin")
    public String adminView() {
        return "pages/admin";
    }

    @GetMapping("/denied")
    public String deniedView() {
        return "pages/denied";
    }
}

"src/main/resources/templates/pages/" 경로에 html 파일을 저장해주세요.

home.html - 메인화면

<!DOCTYPE html>
<html xmlns:th="http://www.w3.org/1999/xhtml" 
      xmlns:sec="http://www.w3.org/1999/xhtml">
    <head>
        <meta charset="UTF-8">
        <title>Home</title>
    </head>
    <body>
        <h1>Home Page</h1>
        <hr>
        <div>
            <a sec:authorize="isAnonymous()" th:href="@{/login}">로그인</a>
            <a sec:authorize="isAuthenticated()" th:href="@{/logout}">로그아웃</a>
            <a sec:authorize="isAnonymous()" th:href="@{/signup}">회원가입</a>
        </div>
        <div>
            <a th:href="@{/member/info}">내 정보</a>
            <a th:href="@{/admin}">관리자</a>
        </div>
    </body>
</html>

 

login.html - 로그인 화면

<!DOCTYPE html>
<html xmlns:th="http://www.w3.org/1999/xhtml">
    <head>
        <meta charset="UTF-8">
        <title>로그인 페이지</title>
    </head>
    <body>
        <h1>로그인</h1>
        <hr>
        <form th:action="@{/login}" method="post">
            <input type="text" name="username" placeholder="account를 입력해주세요.">
            <input type="password" name="password" placeholder="password를 입력해주세요.">
            <button type="submit">로그인</button>
        </form>
    </body>
</html>

form 요청을 할 경우 csrf 토큰을 함께 보내야합니다. 하지만 th:action으로 요청을 할 경우 자동으로 생성해줍니다.

<input type="hidden" name="${_csrf.parameterName}" value="${_csrf.token}">

th:action으로 요청하지 않을 경우 form태그 아래에 위와 같은 input 태그를 입력해야합니다.

 

signup.html - 회원가입 화면

<!DOCTYPE html>
<html xmlns:th="http://www.w3.org/1999/xhtml">
    <head>
        <meta charset="UTF-8">
        <title>회원가입 페이지</title>
    </head>
    <body>
        <h1>회원 가입</h1>
        <hr>

        <form th:action="@{/signup}" method="post">
            <input type="text" name="account" placeholder="account 입력">
            <input type="text" name="name" placeholder="user name 입력">
            <input type="password" name="password" placeholder="password 입력">
            <button type="submit">가입하기</button>
        </form>
    </body>
</html>

 

user_info.html - 내 정보 화면

<!DOCTYPE html>
<html xmlns:th="http://www.w3.org/1999/xhtml">
    <head>
        <meta charset="UTF-8">
        <title>내 정보</title>
    </head>
    <body>
        <h1>내 정보</h1>
        <hr>
        <span sec:authentication="name"></span> 님 반갑습니다.
        <div sec:authentication="principal.authorities"></div>

        <a th:href="@{/}">홈으로 가기</a>
    </body>
</html>

 

admin.html - 어드민 화면

<!DOCTYPE html>
<html xmlns:th="http://www.w3.org/1999/xhtml">
    <head>
        <meta charset="UTF-8">
        <title>admin</title>
    </head>
    <body>
        <h1>admin</h1>
        <hr>
    </body>
</html>

 

denied.html - 권한 거부 화면

<!DOCTYPE html>
<html xmlns:th="http://www.w3.org/1999/xhtml">
    <head>
        <meta charset="UTF-8">
        <title>접근 거부</title>
    </head>
    <body>
        <h1>접근 불가 페이지입니다.</h1>
        <hr>
        <a th:href="@{/}">홈으로 가기</a>
    </body>
</html>

결과 화면

관리자 페이지 진입 시


참조

https://victorydntmd.tistory.com/328