기존에 간단하게 만들었던 프로젝트에 로그인 기능을 추가해달라는 요구사항이 있으면 여러가지 방법이 있습니다. 대표적으로 Filter를 사용하여 DispatcherServlet이 요청 URL를 인터셉트 하기전에 세션을 관리하는 방식으로 인증을 처리하는 방법입니다. 하지만 이미 스프링에서는 쉬운지는 모르겠지만 더 간편하게 보안을 적용 할 수 있는 스프링 시큐리티라는 기능을 제공하고 있습니다. 오늘은 웹 환경에서 스프링 시큐리티가 어떻게 동작하고 인증 및 권한처리 방법에 대해서 포스팅하였습니다.

스프링 시큐리티란?

Spring Security는 스프링 기반의 어플레케이션의 보안(인증과 권한, 인가 등)을 담당하는 스프링 하위 프레임워크 입니다.

스프링 시큐리티 개념

스프링 시큐리티를 개념을 잡기 위해선은 아래 기본적인 용어는 알아두어야 한다고 생각합니다. 평상시에 많이 접하던 용어라 거부감은 딱히 없었습니다...ㅎㅎ

용어 정리

보안: 위험, 손실 , 범죄가 발생하지 않도록 방지하는 상태를 가리킵니다. 일반적으로 보안의 피해발생의 원인이 인간의 행위라는 점에서 안전이라는 개념과 구분됩니다.

접근주체(Principal): 보호된 리소스에 접근하는 대상

인증(Authentication) : 애플리케이션의 작업을 수행할 수 있는 주체(사용자)라고 주장할 수 있는것을 말합니다.

인증의 방식

  • 크리덴셜 기반 인증 : 사용자명과 비밀번호를 이용한 방식
  • 이중인증 : ATM을 이용할 때처럼 물리적인 카드와 사용자가 입력한 개인정보를 조합하는 방식
  • 하드웨어 인증 : 자동차 키와 같은 방식

인증과 인가
일반 사원인 김사원과 재무팀의 박부장은 사내 모든 직원들의 급여명세서를 열람하고 싶어한다. 먼저 시스템에 로그인을 하면 인증관리자(인증을 처리함)는 사용자가 제시한 정보를 통해 사용자가 누구인지를 확인한다. 다음으로, 접근결정 관리자는 사용자가 가지고 있는 역할을 가지고 급여명세서에 접근할 수 있는지 아닌지를 판별하게 된다.

인가(Authorize): 해당 리소스에 대해 접근 가능한 권한을 가지고 있는지 확인하는 과정(After Authentication)

권한: 권한은 인증된 주체가 어플리케이션의 동작을 수행할 수 있도록 허락되있는지를 결정하는것을 말합니다. 따라서 권한 승인이 필요한 부분으로 접근하려면 인증 과정을 통해 주체가 증명 되어야만 한다는 것을 의미합니다.

역할부여란?

인증을 통해서 인증된 주체를 하나 이상의 권한(역할)에 매핑하고 보호된 리소스에 대한 권한을 체크하는 것을 말한다.

필터

스프링 시큐리티에서 웹 애플리케이션에 주로 영향을 주는 방식은 일련의 ServletRequest 필터를 사용한 방식이다. 필터들이 애플리케이션에 대한 모든 요청을 감싸서 처리한다. 스프링 시큐리티에서 여러개의 필터들은 아래 그림과 같이 체인형태를 이루면서 동작한다. 자동 설정 옵션을 사용하면 10개의 스프링 시큐리티 필터가 자동으로 설정된다.

DelegatingFilterProxy

DelegatingFilterProxy는 스프링 시큐리티가 모든 애플리케이션 요청을 감싸게 해서 모든 요청에 보안이 적용되게 하는 서블릿필터이다.(스프링 프레임워크에서 제공) 스프링 프레임워크 기반의 웹 애플리케이션에서 서블릿 필터 라이프 사이클과 연계해 스프링 빈 의존성을 서블릿 필터에 바인딩하는데 사용합니다.
web.xml에 다음과 같은 설정을 해주면 애플리케이션의 모든 요청을 스프링 시큐리티가 감싸서 처리할 수 있게 됩니다.

<filter>
<filter-name>springSecurityFilterChain</filter-name>
<filter-class>org.springframework.web.filter.DelegatingFilterProxy</filter-class>
</filter>
<filter-mapping>
<filter-name>springSecurityFilterChain</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>

추가로 설명하자면
DelegatinFilterProxy는 filter-name으로 명시된 스프링 컨텍스트에서 빈으로 등록된 springSecurityFilterChain에게 실제 인증처리를 위임하기만 합니다. springSecurityFilterChain은 Filter 인터페이스를 구현한 여러 종류의 필터들을 List 객체에 가지고 있습니다.

스프링 시큐리티를 커스터마이징 하기 위해서는 SpringSecurity에서 제공하는 필터들에 대해 이해하는게 좋습니다.
SpringSecurityFilterChain은 아래 표와 같이 여러 종류의 필터들을 가지고 있습니다.

SpringSecurityFilterChain 종류

12

필터 체인의 제일 마지막에 위치한 FilterSecurityInterceptor는 앞에 지나온 모든 필터들의 정보를 토대로 최종 결정을 내립니다.

SpringSecurityFilterChain

다운로드

스프링 시큐리티 구조

스크린샷 2019-11-12 오후 8 27 23

Spring Security Process

아래 사진은 웹 기반 인증 요청을 처리하는 기본 프로세스를 그림으로 도식화 하였습니다.

스크린샷 2019-11-16 오전 12 01 52

가장 많이 사용하는 폼 기반의 인증방식에 대해서 살펴보겠습니다.
저희 웹 서비스를 가입한 사용자 A가 있다고 가정하겠습니다. 저희 웹 서비스는 회원가입을 통해 가입하여 리소스에 대한 해당 권한을 가진 사용자만이 웹 서버의 리소스에 접근할 수 있습니다.

  1. 먼저, A가 처음에 브라우저를 켜서 저희 웹 서버에서 제공하는 서비스를 이용하기 위해서 폼 기반의 인증요청을 보냅니다. 그러면 위의 표에 나와있듯이 맨 처음에 AbstractAuthenticationProcesFilter를 구현한 클래스(UsernamePasswordAuthenticationFilter)가 인증요청에 해당하는 URL을 감지하여 요청을 가로챕니다.

    public class UsernamePasswordAuthenticationFilter extends
         AbstractAuthenticationProcessingFilter {
  2. Request 객체에 사용자명과 패스워드를 획득 후에 UsernamePasswordAuthenticationTocken 객체에 해당 정보를 바인딩을 하게 됩니다.

UsernamePasswordAuthenticationTocken는 단순 사용자명과 패스워드를 담는 단순 도메인 객체입니다.

  1. UsernamePasswordAuthenticationFilter가 주입 받은 AuthenticationManager의 authenticate 메소드를 호출합니다. 이때 인자로 UsernamePasswordAuthenticationTocken을 반환합니다.
    AuthenticationManager는 스프링 시큐리티의 중요한 요소 중 하나이기 때문에 나중에 자세히 설명하겠습니다.

UsernamePasswordAuthenticationTocken은 Authentication을 상속했기 때문에 자바의 다형성으로 인해 반환이 가능합니다.

  1. AuthenticationManager는 사용자명/패스워드를 실질적으로 인증하는 역할을 맡고 있습니다. AuthenticationManager는 인터페이스이기 때문에 구현체인 ProviderManager를 사용합니다. 이 ProviderManager는 이 권한을 AuthenticationProvider 인터페이스에게 넘깁니다.

뭔가 복잡하지만.. 결국 인증 처리는 AuthenticationProvider를 통해서 처리한다고 생각하면 됩니다. 저희 개발자는 AuthenticationProvider의 구현체를 이용하여 인증 메커니즘을 커스터마이징하여 구현하면 되는 것입니다.

  1. 데이터베이스에서 사용자 정보를 가져올때는 AuthenticationManager는 UserDetailService에게 폼 기반의 인증을 요청한 주체(principal)로 부터 입력받은 사용자명을 파라미터로 받아서 실제 DB에서 정보를 가져와 UserDetails에 정보를 넣어서 반환하는 역할을 합니다.

AuthenticationProvider: 로그인 인증처리를 구현 (비밀 번호 대조)
UserDetailsService: 유저정보를 DB에서 조회하는 역할

위의 순서과 대략적인 스프링 시큐리티 처리 과정입니다. 일반적으로는 인증 관리자는 데이터베이스에 저장된 사용자 정보로 인증을 처리 합니다.

Spring Security 코드 리뷰

이제 간단하게 스프링 시큐리티를 코드를 작성하여 구현해보겠습니다.
가장 먼저 웹 환경에서 스프링 시큐리티를 적용하기 위해서 아래와 같은 클래스 파일을 작성하였습니다.

SpringSecurity config

//웹 보안 활성화 시키는 어노테이션
@EnableWebSecurity
@Slf4j
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    // AuthenticationProvider 커스터마이징한 구현체
    private final CustomerAuthenticationProvider customerAuthenticationProvider;

    // DB에서 사용자 정보를 가져오는 UserDetailService 구현체
    private final JpaUserService jpaUserService;

    @Override
    public void configure(HttpSecurity http) throws Exception {
      log.info("security config.......");


      // 웹과 관련된 다양한 보안 설정을 걸어주는 역할을 합니다.
      http.authorizeRequests().antMatchers("/guest/**").permitAll()
              .antMatchers("/board/list").permitAll()
              .antMatchers("/board").hasRole("BASIC");


      // authorizeRequests()는 시큐리티 처리에 HttpServletRequest를 이용합니다.
      http.authorizeRequests().antMatchers("/manager/**").hasRole("MANAGER");

      http.authorizeRequests().antMatchers("/admin/**").hasRole("ADMIN");

      // customize 한 로그인 페이지 설정
      http.formLogin().loginPage("/login");

      //특정 리소스에 대한 접근 권한이 존재하지 않을때 이동시킬 페이지 설정
      http.exceptionHandling().accessDeniedPage("/accessDenied");

      // 로그아웃시에 세션 무효화, 스프링 시큐리티는 사용자 정보를 HttpSession 방식으로 관리하기 때문에 브라우저가 완전히 종료되면, 로그인한 정보를 잃게 됩니다. 브라우저를 종료하지않을시에.. 로그아웃으로 무효화시킵니다.
      http.logout().logoutUrl("/logout").invalidateHttpSession(true);

    }

    // 패스워드 암호화를 위해 PasswordEncoder Bean으로 등록
    @Bean
    public PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }

    @Autowired
    public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception{
        // AuthenticationManagerBuilder는 다양한 인증 관리자를 생성해주는 객체입니다. 
        auth.authenticationProvider(customerAuthenticationProvider).userDetailsService(jpaUserService).passwordEncoder(passwordEncoder());
    }
}

@EnableWebSecurity

WebSecurityConfigurerAdapter는 세가지 configure() 메소드를 오버라이딩하고 동작을 설정하는 것으로 전반적인 웹 보안정책을 설정할 수 있습니다.

스크린샷 2019-11-12 오후 7 10 31

이 중에서 가장 많이 사용하는 객체가 HttpSecurity 입니다. 보안에 관련된 역할과 많은 책임을 가지고 있습니다.

AuthenticationManagerBuilder는 다양한 인증관리자를 생성해주는 빌더로 메모리 기반의 인증과 데이터베이스 인증 관리자 등 다양한 인증 관리자를 생성해주는 역할을 맡고 있습니다.

http.formLogin()을 이용하면 기본적으로 AbstractAuthenticationProcessingFilter를 구현한 UsernamePasswordAuthenticationFilter 구현체를 이용하게 됩니다.

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

참고로 UsernamePasswordAuthenticationFilter에서는 인증(로그인)요청에 대해서 기본적으로 GET 방식으로 인증 정보가 들어오는 것을 허용하지 않고 Http Method를 POST 방식으로만 지원하고 있습니다.

저 같은 경우에는 아래 인증관리자 빌더를 통해서 AuthenticationProvider와 UserDetailsService를 등록하였습니다. 위에서 언급한것 처럼 DB를 통한 인증처리 방식을 많이 사용하기 때문에 authenticationProvider(customerAuthenticationProvider)를 작성하지 않고 UserDetailsService()만 작성하여도 AuthenticationManager에 DaoAuthenticationProvider가 자동으로 연결됩니다.

auth.authenticationProvider(customerAuthenticationProvider).userDetailsService(jpaUserService).passwordEncoder(passwordEncoder());

AuthenticationProvider 구현

@Component
@RequiredArgsConstructor
public class CustomerAuthenticationProvider implements AuthenticationProvider {
    // UserDetailsService 구현체 
    private final JpaUserService jpaUserService;

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {

        String username = authentication.getName();
        UserDetails userDetails = null;

        try {
              userDetails = jpaUserService.loadUserByUsername(username);
        }catch (UserNotFoundException e){
            System.out.println(e.getMessage());
        }catch (BadCredentialsException e){
            System.out.println(e.getMessage());
        }catch (Exception e){
            System.out.println(e.getMessage());
        }

        return new UsernamePasswordAuthenticationToken(userDetails.getUsername(),userDetails.getPassword(),userDetails.getAuthorities());
    }

    @Override
    public boolean supports(Class<?> authentication) {
        return false;
    }
}

위의 코드가 실제로 인증을 처리해주는 AuthenticationProvider 구현체 입니다.
UserDetailsService 인터페이스를 구현한 JpaUserService 객체에게 사용자로 받은 사용자명으로 데이터베이스에 해당 사용자를 조회하여 존재하면 UserDetails에 담아서 AuthenticationProvider에게 제공해주는 역할을 하고 있습니다. 존재하지 않으면 예외를 처리하겠죠

UserDetails

public class JpaSecurityUser extends User {

    // 스프링 시큐리티에서는 권한정보를 ROLE + 권한명으로 리소스 접근승인을 판단합니다. 
    // 따라서 접두사인 PREFIX 변수에 필수로 적어줘야 권한을 확인하고 판별할 수 있습니다.
    private static final String ROLE_PREFIX = "ROLE_";

    private Member member;

    public JpaSecurityUser(Member member){
        // User 클래스를 상속하기 때문에 부모생성자는 필수로 호출해줘야 합니다.
        super(member.getUid(), member.getUpw(),makeGrantedAuthority(member.getRoles()));

        this.member = member;
    }

    public JpaSecurityUser(String username, String password, Collection<? extends GrantedAuthority> authorities){

        super(username, password, authorities);

    }

    // 접근제한자 private로 캡슐화를 통해서 해당 User 클래스에서 생성자로 필요한 사용자의 권한정보를 생성 합니다.  
    private static List<GrantedAuthority> makeGrantedAuthority(List<MemberRole> roles){

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

        roles.forEach(role -> list.add(new SimpleGrantedAuthority(ROLE_PREFIX + role.getRoleName())));

        return list;
    }

    public Member getMember() {
        return member;
    }
}

UserDetails는 인터페이스이기 때문에 실제로 DB에서 조회한 정보를 담기 위해서는 해당 인터페이스의 구현체를 만들어야 합니다. 하지만 우리의 넘사벽 프레임워크인 스프링은 이미 다 준비가 되어있죠.. User라는 클래스가 UserDetails를 구현해놨기 때문에 아래에 UserDetailsService 구현부에서 User를 반환해도 됩니다. 하지만 저는 위의 코드 처럼 나중에 추가적인 맴버변수(이메일, 생년월일)를 사용할 수도 있기 때문에 User 클래스를 확장한 JpaSecurity를 구현하여 Member 도메인을 필드로 가지도록 하였습니다.

public class JpaSecurityUser extends User{

}

사용자명을 파라미터로 받아서 DB에서 해당 유저의 정보를 조회하는 역할을 담당하는 UserDetailsService를 구현한 부분입니다. JPA를 사용하기 때문에 Repository를 이용하여 DB에 접근하였습니다.

UserDetailsService

@Service
@RequiredArgsConstructor
public class JpaUserService implements UserDetailsService {

    @Autowired
    private  final MemberRepository memberRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {

        JpaSecurityUser user = memberRepository.findById(username).filter(m -> m != null).map(m -> new JpaSecurityUser(m)).get();

        if(user == null){
            throw new UserNotFoundException("해당 계정이 존재하지 않습니다.");
        }

        return user;
    }
}

아래 개발자분들이 올려주신 블로그가 저에게 스프링 시큐리티 개념을 정립하는데 많은 도움을 주셨습니다.

출처: https://blog.naver.com/sipzirala/220979656224,https://javaiyagi.tistory.com/431

+ Recent posts