본문 바로가기
코딩 공부/web & Java

[Spring] Spring Security

by 현장 2023. 10. 26.

Spring Security

Spring 기반의 애플리케이션의 보안(인증과 권한, 인가 등)을 담당하는 스프링 하위 프레임워크로  Spring Security는 인증과 권한에 대한 부분을 Filter 흐름에 따라 처리하고 있습니다. 

Filter는 Dispatcher Servlet으로 가기 전에 적용되므로 가장 먼저 URL 요청을 받지만, Interceptor는 Dispatcher와 Controller 사이에 위치한다는 점에서 적용 시기의 차이가 있습니다.

 

특징

  • 인증 및 권한 부여에 대한 포괄적이고 확장 가능한 지원
  • 세션 고정, 클릭재킹, 크로스 사이트 요청 위조 등과 같은 공격으로부터 보호
  • 서블릿 API 통합
  • Spring Web MVC와의 선택적 통합
  • etc

 

인증(Authorizatoin)과 인가(Authentication)

인증(Authentication) 해당 사용자가 본인이 맞는지를 확인하는 절차
인가(Authorization) 인증된 사용자가 요청한 자원에 접근 가능한지를 결정하는 절차
접근 주체(Principal) 보호받는 Resource에 접근하는 대상
비밀번호(Credential) Resource에 접근하는 대상의 비밀번호

Spring Security는 기본적으로 인증 절차를 거친 후에 인가 절차를 진행하게 되며, 인가 과정에서 해당 리소스에 대한 접근 권한이 있는지 확인을 하게 된다. Spring Security에서는 이러한 인증과 인가를 위해 Principal을 아이디로, Credential을 비밀번호로 사용하는 Credential 기반의 인증 방식을 사용합니다.

 

필터(Filter)

스프링 시큐리티는 서블릿의 필터를 기반으로 동작합니다.

 

일반적으로 사용자가 서버로 요청을 보내면, DispatcherServlet이라는 서블릿이 하나의 HttpServeletRequest를 받아서 요청을 처리하고 HttpServletResponse 응답을 클라이언트로 보냅니다.

 

그런데, 하나 이상의 필터가 포함된다면, 클라이언트에서 보낸 요청이 서블릿으로 전달되기 전에 필터를 거치게 됩니다. 이때 사용자가 애플리케이션에 하나의 요청을 보내면, 컨테이너는 하나의 필터 체인(FilterChain)을 생성하며 필터 체인에는 필터와 서블릿이 들어 있습니다.

 

필터의 역할
필터 체인 내부의 필터는 말 그대로 '필터'의 역할을 하며 클라이언트에서 보낸 요청이 다음 필터나 서블릿에 전달되지 않도록 걸러냅니다. 이를 통해 요청이 다음으로 전달되게 할 경우에는 HttpServletRequest나 HttpServletResponse의 내용을 변경하여 가공된 데이터를 다음 필터나 서블릿에 전달할 수 있습니다.

 

1. DelegatingFilterProxy

사용자의 요청이 서블릿에 전달되어 자원에 접근하기 전에, Spring Security 필터의 생명주기를 이용해서 인증과 권한 작업을 수행합니다. 서블릿 필터의 생명주기 시점에서 스프링 시큐리티에서 동작하는 인증과 권한 작업을 수행해야 하는데, 서블릿 컨테이너에서는 스프링 컨테이너에 등록된 빈을 인식할 수 없습니다.

 

따라서  Spring Security에서 DelegatingFilterProxy라는 서블릿 필터의 구현체를 제공하여 서플릿 메커니즘을 통해 서블릿 필터로 등록 가능하게 하며 스프링에 등록된 빈을 가져와 의존성 주입을 할 수 있습니다.

즉, 서블릿 컨테이너의 생명주기와 스프링의 ApplicationContext사이를 연결하는 다리 역할을 합니다.

 

2. FilterChainProxy

위에서 설명한 DelegatingFilterProxy내부에는 FliterChainProxy가 들어있습니다. FliterChainProxy는 DelegatingFilterProxy를 통해 받은 요청과 응답을 Spring Security 필터 체인에 전다라고 작업을 위임하는 역할을 합니다.

 

DelegatingFilterProxy에서 바로 SecurityFilterChain을 실행시킬 수도 있지만, 중간에 FilterChainProxy를 두는 이유
바로 서블릿을 지원하는 시작점 역할을 하기 위해서로 만약 서블릿에서 문제가 발생한다면 FilterChainProxy의 문제라는 것을 바로 알 수 있습니다. 또한 어떤 필터 체인에게 작업을 위임할지 결정할 수도 있습니다.

 

3. SecurityFilterChain

SecurityFilterChain은 인증을 처리하는 여러 개의 시큐리티 필터를 담는 필터 체인으로 필터 체인 프록시를 통해 서블릿 필터와 연결되고 어떤 시큐리티 필터를 통해 인증을 수행할지 결정하는 역할을 합니다.

 

4. SecurityFilters

시큐리티 필터는 요청을 스프링 시큐리티 메커니즘에 따라 처리하는 필터시큐리티의 핵심 기능을 수행하는 지점입니다. 또한 시큐리티 필터들에는 순서가 존재합니다

 


아키텍쳐

  1. 유저가 로그인 요청합니다. (Http Request)
  2. AuthenticationFilter에서 UsernamePasswordAuthentication Token을 생성해 AuthenticationManager에 전달합니다.
  3. AuthenticationManager은 등록된 AuthenticationProvider들을 조회하여 인증을 요구합니다.
  4. AuthenticationProvider은 UserDetailService를 통해 입력받은 아이디에 대한 사용자 정보를 User(DB)에서 조회합니다.
  5. User에 로그인 요청한 정보가 있는 경우 UserDetails로 꺼내서 유저 session 생성합니다.
  6. 인증이 성공된 UsernameAuthenticationToken을 생성하여 AuthenticationManager로 반환합니다.
  7. AuthenticationManager 은 UsernameAuthenticationToken을 AuthenticationFilter로 전달합니다.
  8. AuthenticationFilter 은 전달받은 UsernameAuthentication을 LoginSuccessHandler로 전송하고, spring security 인메모리 세션저장소인 SecurityContextHolder에 저장합니다.
  9. 유저에게 session ID와 응답을 내려줍니다.

 

1. AuthenticationFilter

Spring Security는 연결된 필터를 가지고 있고 모든 Request는 인증과 인가를 위해 이 필터를 통과합니다.

SecurityContext에 사용자의 세션 ID가 있는지 확인하고 세션 ID가 없으면 다음 로직을 수행하고 인증을 성공한 경우 인증된 Authentication 객체를 SecurityContext에 저장 후 AuthenticationSuccessHandler를 실행합니다. 하지만 인증이 실패할 경우 AuthenticationFailureHandle를 실행하게 됩니다.

 

- Authentication 객체란

public interface Authentication extends Principal, Serializable {
    // 현재 사용자의 권한 목록을 가져옴
    Collection<? extends GrantedAuthority> getAuthorities();
    
    // credentials(주로 비밀번호)을 가져옴
    Object getCredentials();
    
    Object getDetails();
    
    // Principal 객체를 가져옴.
    Object getPrincipal();
    
    // 인증 여부를 가져옴
    boolean isAuthenticated();
    
    // 인증 여부를 설정함
    void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException;
}

현재 접근하는 주체의 정보와 권한을 담는 인터페이스입니다. Authentication 객체는 Security Context에 저장되며, SecurityContextHolder를 통해 SecurityContext에 접근하고, SecurityContext를 통해 Authentication에 접근할 수 있습니다.

 

2. UsernamePasswordAuthenticationToken

public class UsernamePasswordAuthenticationToken extends AbstractAuthenticationToken {
    // 주로 사용자의 ID에 해당함
    private final Object principal;
    // 주로 사용자의 PW에 해당함
    private Object credentials;
    
    // 인증 완료 전의 객체 생성
    public UsernamePasswordAuthenticationToken(Object principal, Object credentials) {
		super(null);
		this.principal = principal;
		this.credentials = credentials;
		setAuthenticated(false);
	}
    
    // 인증 완료 후의 객체 생성
    public UsernamePasswordAuthenticationToken(Object principal, Object credentials,
			Collection<? extends GrantedAuthority> authorities) {
		super(authorities);
		this.principal = principal;
		this.credentials = credentials;
		super.setAuthenticated(true); // must use super, as we override
	}
}


public abstract class AbstractAuthenticationToken implements Authentication, CredentialsContainer {
}

Authetication을 implements 한 AbstractAuthenticationToken의 하위 클래스로, User의 ID가 Principal 역할을 하고, Password가 Credential의 역할을 합니다. UsernamePasswordAuthenticationToken의 첫 번째 생성자는 인증 전 객체를 생성하고, 두 번째 생성자는 인증이 완료된 객체를 생성합니다.

 

3. AuthenticationManager

public interface AuthenticationManager {
	Authentication authenticate(Authentication authentication) 
		throws AuthenticationException;
}

Authentication을 만들고 인증을 처리하는 interface입니다. 로그인 시 인자로 받은 Authentication을 Provider를 통해 유효한지 처리하여 Authentication 객체를 리턴합니다.

 

- ProviderManager

public class ProviderManager implements AuthenticationManager, MessageSourceAware,
InitializingBean {
    public List<AuthenticationProvider> getProviders() {
		return providers;
	}
    public Authentication authenticate(Authentication authentication)
			throws AuthenticationException {
		Class<? extends Authentication> toTest = authentication.getClass();
		AuthenticationException lastException = null;
		Authentication result = null;
		boolean debug = logger.isDebugEnabled();
        //for문으로 모든 provider를 순회하여 처리하고 result가 나올 때까지 반복한다.
		for (AuthenticationProvider provider : getProviders()) {
            ....
			try {
				result = provider.authenticate(authentication);

				if (result != null) {
					copyDetails(authentication, result);
					break;
				}
			}
			catch (AccountStatusException e) {
				prepareException(e, authentication);
				// SEC-546: Avoid polling additional providers if auth failure is due to
				// invalid account status
				throw e;
			}
            ....
		}
		throw lastException;
	}
}

AuthenticationManager의 구현체로 사용자 요청을 인증에 필요한 AuthenticationProvider를 살펴보고 전달된 인증 객체를 기반으로 사용자 인증 시도합니다.

 

4. AuthenticationProvider

public interface AuthenticationProvider {

	// 인증 전의 Authenticaion 객체를 받아서 인증된 Authentication 객체를 반환
    Authentication authenticate(Authentication var1) throws AuthenticationException;

    boolean supports(Class<?> var1);
    
}

실제 인증에 대한 부분을 처리하는 인터페이스로, 인증 전의 Authentication객체를 받아서 인증이 완료된 객체를 반환하는 역할을 합니다.

 

5. UserDetailsService

public interface UserDetailsService {

    UserDetails loadUserByUsername(String var1) throws UsernameNotFoundException;

}

DB에서 유저 정보를 가져오는 역할로 loadUserByUsername() 메서드를 통해 DB에서  UserDetails(유저 정보) 객체를 반환하는 단 하나의 메서드를 가지고 있습니다.

 

- 커스텀해서 사용하고 싶다면 해당 interface를 implements 받아서 loadUserByUsername() 메서드를 구현하면 됨

 

6. UserDetails

public interface UserDetails extends Serializable {

    Collection<? extends GrantedAuthority> getAuthorities();

    String getPassword();

    String getUsername();

    boolean isAccountNonExpired();

    boolean isAccountNonLocked();

    boolean isCredentialsNonExpired();

    boolean isEnabled();
    
}

사용자의 정보를 담는 인터페이스로 Authentication객체를 구현한 UsernamePasswordAuthenticationToken을 생성하기 위해 사용됩니다.

메소드 설명
getAuthorities() 계정의 권한 목록을 리턴
getPassword() 계정의 비밀번호 리턴
getUsername() 계정의 고유한 값 리턴
isAccountNonExpired() 계정의 만료 여부 리턴
isAccountNonLocked() 계정의 잠김 여부 리턴
isCredentialsNonExpired() 비밀번호 만료 여부 리턴
isEnabled() 계정의 활성화 여부 리턴

 

7. SecurityContextHolder

https://waynestalk.com/en/spring-security-architecture-explained-en/

SecurityContext를 현재 스레드와 연결시켜주는 역할로, 스프링 시큐리티는 같은 스레드의 애플리케이션 내 어디서든 SecurityContextHolder의 인증 정보를 확인 가능하도록 구현되어 있는데 이 개념을 ThreadLocal이라고 합니다.

- SecurityContext

public interface SecurityContext extends Serializable {
    Authentication getAuthentication();

    void setAuthentication(Authentication authentication);
}

Authentication의 정보를 가지고 있는 인터페이스로, SecurityContextHolder.getContext()를 통해 얻을 수 있습니다.

 

예시코드

@Configuration
public class SpringSecurityConfiguration {
    @Bean
    public InMemoryUserDetailsManager createUserDetailMamager() {
        Function<String, String> passwordEncoder
                = input -> passwordEncoder().encode(input);
		
        // 유저 생성
        UserDetails userDetails = User.builder()
                .passwordEncoder(passwordEncoder) // PasswordEncoder로 위에서 선언한 
                .username("hyeon")                // 함수 passwordEncoder 방식으로 설정
                .password("123456")
                .roles("USER", "ADMIN")
                .build();
        return new InMemoryUserDetailsManager(userDetails);
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}
@Controller
@SessionAttributes("name")
public class WelcomeController {

	// 코드 생략
	
   private String getLoggedinUsername() {
        Authentication authentication
                = SecurityContextHolder.getContext().getAuthentication(); // 인증된 사용자의 정보를 가져온다.

        return authentication.getName(); // 인증된 사용자의 이름을 가져온다.
    }
}

- Builder란

Builder를 통해서 User객체를 생성하고 주어진 .username이라던지 .password와 같은 인터페이스를 정해줌으로써 쉽게 객체를 생성해 주도록 해줍니다.

withDefaultPasswordEncoder가 도태된 이유
최신 버전의 Spring Security에서는 보안 상의 이유로 기본 비밀번호 저장 및 검증 방식이 개선되었고, 비밀번호 저장 시에 명시적으로 암호화가 필요하게 변경되었습니다. 이 변경을 통해 개발자가 보안 표준을 따르고 더 강력한 보안 관행을 따를 수 있도록 하는 데 도움이 됩니다.
따라서 withDefaultPasswordEncoder 메서드 대신에, 사용자의 비밀번호를 저장할 때 명시적인 암호화 메서드(예: BCryptPasswordEncoder)를 사용하도록 권장됩니다.

 

Reference