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

Spring Security 란?

Spring Security는 스프링 기반의 어플리케이션 보안을 담당하는 프레임워크이다.

Spring Security를 사용하면 사용자 인증, 권한, 보안처리를 간단하지만 강력하게 구현 할 수 있다. 

Filter 기반으로 동작하기 때문에 Spring MVC와 분리되어 동작한다. 

Spring Security를 이해하기 위해서는 먼저 보안관련 용어를 숙지해야 한다.

접근 주체(Principal)
보안 시스템이 작동되고 있는 애플리케이션에 접근하는 유저
인증(Authentication)
접근한 유저를 식별하고, 애플리케이션에 접근할 수 있는지 검사
인가(Authorize)
인증된 유저가 애플리케이션의 기능을 이용할 수 있는지 검사

SecurityFilterChain

일반적으로 브라우저가 서버에 데이터를 요청하면 DispatcherServlet에 전달되기 이전에 여러 ServletFilter를 거친다.

이때 Spring Security에서 등록했었던 Filter를 이용해 사용자 보안 관련된 처리를 진행한다.

Spring Security와 관련된 Filter들은 연결된 여러 Filter들로 구성되어있다. 이 때문에 Chain이라는 표현을 사용하고 있다.

SecurityFilterChain 상세

 

SecurityContextPersistenceFilter
SecurityContextRepository에서 SecurityContext를 가져와 유저 Authentication에 접근 할 수 있게 한다.
LogoutFilter
로그아웃 요청을 처리한다.
UsernamePasswordAuthenticationFilter 
ID와 Password를 사용하는 Form 기반 유저 인증을 처리한다. 
DefaultLoginPageGeneratingFilter
커스텀 로그인 페이지를 지정하지 않았을 경우 Default Login Page를 반환한다.
AnonymousAuthenticationFilter
이 필터가 호출되는 시점까지 사용자 정보가 인증되지 않았다면 익명 사용자 토큰을 반환한다.
ExceptionTranslationFilter
필터 체인 내에서 발생되는 모든 예외(AccessDeniedException, AuthenticationException...)를 처리한다.
FilterSecurityInterceptor
권한부여와 관련한 결정을 AccessDecisionManager에게 위임해 권한부여 결정 및 접근 제어를 처리한다.
RequestCacheAwareFilter
로그인 성공 후, 이전 요청 정보를 재구성하기 위해 사용한다.
SessionManagementFilter
로그인 이후 인증된 사용자인지 확인하거나 설정된 Session 메커니즘에 따라 작업을 수행한다. (동시 로그인 확인 등...)
BasicAuthenticationFilter 
HTTP 요청의 인증 헤더를 처리하여 결과를 SecurityContextHolder에 저장한다. (HttpBasic 방식) 
RememberMeAuthenticationFilter
세션이 사라지거나 만료 되더라도 쿠키 또는 DB를 사용하여 저장된 토큰 기반으로 인증을 처리하는 필터

Spring Security 동작

1. AuthenticationFilter (UsernamePasswordAuthenticationFilter)는 사용자의 요청을 가로챈다. 그리고 인증이 필요한 요청이라면 사용자의 JSESSIONID가 Security Context에 있는지 판단한다. 없으면 로그인 페이지로 이동시킨다

로그인 페이지에서 요청이 온 경우라면 로그인 페이지에서 입력받은 username과 password를 이용해 UsernamePasswordAuthenticationToken을 만든다.  그리고 UsernamePasswordAuthenticationToken 정보가 유효한 계정인지 판단하기 위해 AuthenticationManager로 전달한다.


2. AuthenticationManager 인터페이스의 구현체는 ProviderManger이고  AuthencationProvider에게 비밀번호 인증 로직 책임을 넘긴다. (AuthencationProvider는 개발자가 직접 커스텀해서 비밀번호 인증로직을 직접 구현할 수 있다.)

3. AuthencationProvider는 UserDetailsService를 실행해 비밀번호 인증 로직을 처리한다.

UserDetailsService는 DB에 저장된 회원의 비밀번호와 비교해 일치하면 UserDetails 인터페이스를 구현한 객체를 반환하는데, UserDetailsService는 인터페이스이며 UserDetailsService를 구현한 서비스를 직접 개발해야한다.

4. 인증 로직이 완료되면 AuthenticationManager는 Authentication를 반환하며, 결과적으로 SecurityContext에 사용자 인증 정보가 저장된다.

5. 인증 과정이 끝났으면 AuthenticationFilter에게 인증에 대한 성공 유무를 전달하고
성공하면 AuthenticationSuccessHandler를 호출하고 실패하면 AuthenticationFailureHandler를 호출한다.


참조

https://sjh836.tistory.com/165?category=680970

https://okky.kr/article/382738

https://tramyu.github.io/java/spring/spring-security/

+ Recent posts