ch06 Implementing authenticaiton

2024. 2. 26. 20:38Spring Security

이 장에서는 다음을 다룹니다:

  • 사용자 정의 AuthenticationProvider를 사용하여 인증 로직 구현
  • HTTP Basic 및 form 기반 로그인 인증 방법 사용
  • SecurityContext 구성 요소의 이해 및 관리

3장과 4장에서는 인증 흐름에서 작동하는 몇 가지 구성 요소를 다뤘습니다. 우리는 UserDetails를 설명하는 프로토타입을 정의하는 방법과 Spring Security에서 사용자를 설명하는 방법을 논의했습니다. 그런 다음, UserDetails를 사용하여 UserDetailsService 및 UserDetailsManager 계약이 작동하는 방법과 이를 구현하는 방법을 배웠습니다. 우리는 또한 이러한 인터페이스의 주요 구현을 논의하고 예제에서 사용했습니다. 마지막으로, PasswordEncoder가 비밀번호를 관리하는 방법과 사용하는 방법, 그리고 Spring Security crypto 모듈(SSCM)과 그것의 암호화기 및 키 생성기에 대해 배웠습니다.

그러나 AuthenticationProvider 계층은 인증 로직을 담당합니다. AuthenticationProvider에서는 요청을 인증할지 여부를 결정하는 조건과 지침을 찾을 수 있습니다. 이 책임을 AuthenticationProvider에 위임하는 구성 요소는 HTTP 필터 계층에서 요청을 받는 AuthenticationManager입니다. 이에 대해서는 5장에서 논의했습니다. 이 장에서는 인증 프로세스를 살펴보겠습니다. 이 프로세스에는 두 가지 가능한 결과가 있습니다.

  • 요청을 수행하는 엔터티가 인증되지 않았습니다. 사용자가 인식되지 않으며, 응용 프로그램은 권한 부여 프로세스로 위임하지 않고 요청을 거부합니다. 보통 이 경우에는 클라이언트로 다시 전송된 응답 상태가 HTTP 401 Unauthorized입니다.
  • 요청을 수행하는 엔터티가 인증되었습니다. 요청자에 대한 details는 응용 프로그램이 이를 사용하여 권한을 부여할 수 있도록 저장됩니다. 이 장에서 알게 되겠지만, SecurityContext는 현재 인증된 요청에 대한 details를 담당합니다.

 

그림 6.1 스프링 시큐리티에서의 인증 흐름. 이 과정은 애플리케이션이 요청을 하는 사람을 어떻게 식별하는지를 정의합니다. 

 

이 그림에서, 이번 장에서 논의된 컴포넌트들이 음영 처리되어 있습니다. 여기서, AuthenticationProvider는 이 과정에서 인증 로직을 구현하며, SecurityContext는 인증된 요청에 대한 세부 정보를 저장합니다.

 

이 장에서는 인증 흐름의 나머지 부분들(그림 6.1의 음영 처리된 상자들)을 다룹니다. 그 후, 7장과 8장에서는 인증 과정에 이어 HTTP 요청에서 어떻게 권한 부여가 작동하는지 배울 것입니다. 우선, AuthenticationProvider 인터페이스를 어떻게 구현하는지에 대해 논의해야 합니다. 스프링 시큐리티가 인증 과정에서 요청을 어떻게 이해하는지 알아야 합니다.

인증 요청을 어떻게 표현하는지 명확하게 설명하기 위해, 우리는 Authentication 인터페이스로 시작할 것입니다. 이것에 대해 논의하고 나면, 성공적인 인증 후 요청의 Details가 어떻게 처리되는지 관찰할 수 있습니다. 성공적인 인증 후에는 SecurityContext 인터페이스와 스프링 시큐리티가 이를 어떻게 관리하는지에 대해 논의할 수 있습니다. 이 장의 마지막 부분에 가까워지면, HTTP Basic 인증 방법을 어떻게 커스터마이즈하는지 배울 것입니다. 우리의 애플리케이션에서 사용할 수 있는 또 다른 인증 옵션인 Form 기반 로그인에 대해서도 논의할 것입니다.

 

6.1 Understanding the AuthenticationProvider

엔터프라이즈 애플리케이션에서는 사용자 이름과 비밀번호를 기반으로 한 디폴트 인증 구현이 적용되지 않는 상황을 마주칠 수 있습니다. 또한, 인증과 관련하여 애플리케이션은 여러 시나리오의 구현을 요구할 수 있습니다(그림 6.2 참조). 예를 들어, 사용자가 SMS 메시지로 받은 코드나 특정 애플리케이션이 표시하는 코드를 사용하여 자신임을 증명할 수 있도록 할 수 있습니다. 또는, 사용자가 파일에 저장된 특정 종류의 키를 제공해야 하는 인증 시나리오를 구현해야 할 수도 있습니다. 사용자의 지문 표현을 사용하여 인증 로직을 구현해야 할 수도 있습니다. 프레임워크의 목적은 이러한 필요한 시나리오를 구현할 수 있을 만큼 충분히 유연해야 합니다.

그림 6.2 애플리케이션의 경우, 다양한 방식으로 인증을 구현할 필요가 있을 수 있습니다. 대부분의 경우 사용자 이름과 비밀번호로 충분하지만, 일부 경우에는 사용자 인증 시나리오가 더 복잡할 수 있습니다.

 

프레임워크는 일반적으로 가장 많이 사용되는 구현들의 세트를 제공하지만, 물론 모든 가능한 옵션을 다룰 수는 없습니다. 스프링 시큐리티의 관점에서, AuthenticationProvider 계약을 사용하여 어떤 사용자 정의 인증 로직이든 정의할 수 있습니다. 이 섹션에서는 Authentication 인터페이스를 구현하여 인증 이벤트를 표현하는 방법을 배우고, AuthenticationProvider를 사용하여 사용자 정의 인증 로직을 생성합니다.

우리의 목표를 달성하기 위해:

  • 6.1.1 절에서는 스프링 시큐리티가 인증 이벤트를 어떻게 표현하는지 분석합니다.
  • 6.1.2 절에서는 인증 로직을 담당하는 AuthenticationProvider 계약에 대해 논의합니다.
  • 6.1.3 절에서는 예제에서 AuthenticationProvider 계약을 구현함으로써 사용자 정의 인증 로직을 작성합니다.

6.1.1 Representing the request during authentication

이 섹션에서는 스프링 시큐리티가 인증 과정 중 요청을 어떻게 이해하는지에 대해 논의합니다. 사용자 정의 인증 로직을 구현하기 전에 이 부분을 다루는 것이 중요합니다. 6.1.2 절에서 배우게 될 것처럼, 사용자 정의 AuthenticationProvider를 구현하기 위해서는 먼저 인증 이벤트 자체를 어떻게 설명하는지 이해해야 합니다. 이 섹션에서는 인증을 대표하는 계약을 살펴보고 알아야 할 메소드들에 대해 논의합니다. Authentication은 동일한 이름의 과정에 관여하는 필수 인터페이스 중 하나입니다. Authentication 인터페이스는 인증 요청 이벤트를 대표하며 애플리케이션에 접근을 요청하는 엔터티의 Details를 보유합니다. 인증 요청 이벤트에 관련된 정보를 인증 과정 중과 후에 사용할 수 있습니다. 애플리케이션에 접근을 요청하는 사용자를 "principal"이라고 합니다. 어떤 앱에서든 자바 보안을 사용해 본 적이 있다면, 자바 보안에서 Principal이라는 이름의 인터페이스가 같은 개념을 대표한다는 것을 배웠을 것입니다. 스프링 시큐리티의 Authentication 인터페이스는 이 계약을 확장합니다(그림 6.3 참조).

그림 6.3 Authentication 인터페이스는 Principal 인터페이스를 상속 받았습니다. Authentication은 비밀번호가 필요하거나 인증 요청에 대해 더 많은 Details을 명시할 수 있는 가능성과 같은 요구 사항을 추가합니다. 권한 목록과 같은 이러한 Details 중 일부는 스프링 시큐리티 특유의 것입니다.

 

스프링 시큐리티의 Authentication 계약은 principal를 대표할 뿐만 아니라 인증 과정이 완료되었는지, 그리고 권한의 집합에 대한 정보도 추가합니다. 이 계약이 자바 보안의 Principal 계약을 확장하도록 설계된 사실은 다른 프레임워크와 애플리케이션의 구현과의 호환성 측면에서 플러스입니다. 이러한 유연성은 다른 방식으로 인증을 구현한 애플리케이션에서 스프링 시큐리티로 더 쉽게 이전할 수 있게 합니다. 다음 목록에서 Authentication 인터페이스의 설계에 대해 더 알아봅시다.

 

Listing 6.1 The Authentication interface as declared in Spring Security

public interface Authentication extends Principal, Serializable {
     Collection<? extends GrantedAuthority> getAuthorities();
     Object getCredentials();
     Object getDetails();
     Object getPrincipal();
     boolean isAuthenticated();
     void setAuthenticated(boolean isAuthenticated) 
     throws IllegalArgumentException;
}

 

현재로서는 이 계약의 메소드 중 배워야 할 것은 다음과 같습니다:

  • isAuthenticated() — 인증 과정이 종료되면 true를 반환하고, 인증 과정이 진행 중이라면 false를 반환합니다.
  • getCredentials() — 인증 과정에서 사용된 비밀번호나 기타 비밀 정보를 반환합니다.
  • getAuthorities() — 인증된 요청에 부여된 권한의 집합을 반환합니다.

Authentication 계약의 다른 메소드들이 나중에 살펴볼 구현에 적합하면, 이러한 메소드들을 후속 장에서 논의할 것입니다.

 

6.1.2 Implementing custom authentication logic

이 섹션에서는 사용 인증 로직을 구현하는 방법에 대해 논의합니다. 이 책임과 관련된 스프링 시큐리티 계약을 분석하여 그 정의를 이해합니다. 이러한 세부 사항을 바탕으로, 6.1.3 절에서 코드 예제를 사용하여 사용자 정의 인증 로직을 구현합니다.

스프링 시큐리티의 AuthenticationProvider는 인증 로직을 담당합니다. AuthenticationProvider 인터페이스의 디폴트 구현은 사용자를 찾는 역할을 UserDetailsService에 위임합니다. 또한 인증 과정에서 비밀번호 관리를 위해 PasswordEncoder를 사용합니다. 다음 목록은 애플리케이션에 대한 사용자 정의 인증 제공자를 정의하기 위해 구현해야 하는 AuthenticationProvider의 정의를 제공합니다.

 

Listing 6.2 The AuthenticationProvider interface

public interface AuthenticationProvider {
     Authentication authenticate(Authentication authentication) 
     throws AuthenticationException;
     boolean supports(Class<?> authentication);
}

 

AuthenticationProvider의 역할은 Authentication 계약과 강하게 연결되어 있습니다. authenticate() 메소드는 Authentication 객체를 파라미터로 받아서 Authentication 객체를 리턴합니다. 우리는 인증 로직을 정의하기 위해 authenticate() 메소드를 구현합니다. authenticate() 메소드를 구현해야 하는 방법을 세 가지 요점으로 요약할 수 있습니다:

  • authenticate 메소드는 인증이 실패하면 AuthenticationException을 던져야 합니다.
  • authenticate 메소드가 AuthenticationProvider 구현에서 지원하지 않는 인증 객체를 받으면, 메소드는 null을 반환해야 합니다. 이 방법으로, 우리는 HTTP 필터 수준에서 분리된 여러 인증 유형을 사용할 가능성을 가집니다.
  • authenticate 메소드는 완전히 인증된 객체를 대표하는 Authentication 인스턴스를 반환해야 합니다. 이 인스턴스에 대해 isAuthenticated() 메소드는 true를 반환하며 인증된 엔터티에 대한 모든 필요한 Details를 포함합니다. 일반적으로, 애플리케이션은 이 인스턴스에서 비밀번호와 같은 민감한 데이터를 제거합니다. 성공적인 인증 후에는 비밀번호가 더 이상 필요하지 않으며 이러한 세부 정보를 유지하는 것은 원치 않는 눈에 노출될 수 있습니다.

AuthenticationProvider 인터페이스의 두 번째 메소드는 supports(Class<?> authentication)입니다. 현재 AuthenticationProvider가 Authentication 객체로 제공된 타입을 지원하는 경우 이 메소드를 true를 반환하도록 구현할 수 있습니다. 이 메소드가 객체에 대해 true를 반환하더라도 authenticate() 메소드가 요청을 null을 반환하여 거부할 가능성이 여전히 있음을 주의하세요. 스프링 시큐리티는 더 유연하게 설계되어 있으며 요청의 Details에 기반하여 인증 요청을 거부할 수 있는 AuthenticationProvider를 구현할 수 있도록 합니다.

 

Authentication Manager와 Authentication Provider가 인증 요청을 유효하게 하거나 무효화하기 위해 협력하는 방식에 대한 비유는 문에 더 복잡한 잠금장치를 설치하는 것과 같습니다. 이 잠금장치는 카드나 구식 물리적 열쇠를 사용하여 열 수 있습니다(그림 6.4 참조). 잠금장치 자체가 문을 열지 결정하는 Authentication Manager입니다. 그 결정을 내리기 위해, 카드를 검증하는 방법을 알고 있는 Authentication Provider와 물리적 열쇠를 확인하는 방법을 아는 다른 인증 제공자에게 위임합니다. 문을 열기 위해 카드를 제시하면, 물리적 열쇠만을 다루는 인증 제공자는 이런 종류의 인증을 모른다고 불만을 표합니다. 하지만 다른 제공자는 이런 종류의 인증을 지원하고 문에 유효한 카드인지 확인합니다. 이것이 실제로 supports() 메소드의 목적입니다.

인증 유형을 테스트하는 것 외에도, 스프링 시큐리티는 유연성을 위해 한 단계 더 추가합니다. 문의 잠금장치는 여러 종류의 카드를 인식할 수 있습니다. 이 경우, 카드를 제시하면 Authentication Provider 중 하나가 "이것을 카드로 이해하지만, 내가 검증할 수 있는 카드 유형이 아니다!"라고 말할 수 있습니다. 이것은 supports()가 true를 반환하지만 authenticate()가 null을 반환할 때 발생합니다.

 

그림 6.4 Authentication Manager는 사용 가능한 Authentication Provider 중 하나에게 위임합니다. Authentication Provider는 제공된 인증 유형을 지원하지 않을 수 있습니다. 반면, 객체 유형을 지원한다면, 그 특정 객체를 어떻게 인증해야 할지 모를 수도 있습니다. 인증은 평가되며, 요청이 올바른지 여부를 판단할 수 있는 Authentication ProviderAuthentication Manager에게 응답합니다.

 

그림 6.5는 인증 제공자 객체 중 하나가 인증을 인식하지만 유효하지 않다고 결정하는 대체 시나리오를 보여줍니다. 이 경우의 결과는 웹 앱의 HTTP 응답에서 401 Unauthorized HTTP 상태로 나타나는 AuthenticationException이 될 것입니다.

그림 6.5 어떤 인증 제공자 객체도 인증을 인식하지 못하거나 그 중 어느 하나라도 이를 거부하는 경우, 결과는 AuthenticationException입니다.

 

6.1.3 Applying custom authentication logic

이 섹션에서는 사용자 정의 인증 로직을 구현합니다. 이 예제는 프로젝트 ssia-ch6-ex1에서 찾을 수 있습니다. 이 예제를 통해 섹션 6.1.1과 6.1.2에서 배운 Authentication 및 AuthenticationProvider 인터페이스에 대해 배운 내용을 적용합니다.
리스팅 6.3과 6.4에서, 우리는 사용자 정의 AuthenticationProvider를 구현하는 방법에 대한 예제를 단계별로 구축합니다. 이 단계들은 또한 그림 6.5에 제시되어 있으며, 다음을 따릅니다:
1. AuthenticationProvider 계약을 구현하는 클래스를 선언합니다.
2. 새로운 AuthenticationProvider가 지원하는 Authentication 객체의 종류를 결정합니다.
3. 우리가 정의하는 AuthenticationProvider에 의해 지원되는 인증 유형을 명시하기 위해 supports(Class<?> c) 메소드를 구현합니다.
4. authenticate(Authentication a) 메소드를 구현하여 인증 로직을 구현합니다.
5. 새로운 AuthenticationProvider 구현의 인스턴스를 Spring Security에 등록합니다.

 

Listing 6.3 Overriding the supports() method of the AuthenticationProvider

@Component
public class CustomAuthenticationProvider 
     implements AuthenticationProvider {
     // Omitted code
     @Override
     public boolean supports(Class<?> authenticationType) {
     	return authenticationType
     		.equals(UsernamePasswordAuthenticationToken.class);
     }
}

 

리스팅 6.3에서, 우리는 AuthenticationProvider 인터페이스를 구현하는 새로운 클래스를 정의합니다. 우리는 이 클래스를 @Component로 표시하여 이 타입의 인스턴스가 Spring에 의해 관리되는 컨텍스트에 있도록 합니다. 그런 다음, 이 AuthenticationProvider가 지원하는 Authentication 인터페이스 구현의 종류를 결정해야 합니다. 그것은 authenticate() 메소드에 파라미터로 제공될 것으로 예상되는 유형에 따라 달라집니다. 만약 우리가 인증 필터 레벨에서 아무 것도 사용자 정의 코드를 구현하지 않는다면(5장에서 논의된 대로), 그 클래스는 UsernamePasswordAuthenticationToken으로 유형이 정의됩니다. 이 클래스는 Authentication 인터페이스의 구현체이며, 사용자 이름과 비밀번호를 가진 표준 인증 요청을 대표합니다.
이 정의를 통해, 우리는 AuthenticationProvider가 특정 종류의 키를 지원하도록 만들었습니다. 우리가 AuthenticationProvider의 범위를 명시한 후에, 우리는 다음 리스팅에서 보여주는 것처럼 authenticate() 메소드를 오버라이딩하여 인증 로직을 구현합니다.

 

Listing 6.4 Implementing the authentication logic

@Component
public class CustomAuthenticationProvider 
 implements AuthenticationProvider {
     private final UserDetailsService userDetailsService;
     private final PasswordEncoder passwordEncoder;
     // Omitted constructor
     @Override
     public Authentication authenticate(Authentication authentication) {
         String username = authentication.getName();
         String password = authentication.getCredentials().toString();
         UserDetails u = userDetailsService.loadUserByUsername(username);
         if (passwordEncoder.matches(password, u.getPassword())) {
         	return new UsernamePasswordAuthenticationToken(
         				username, 
         				password, 
         				u.getAuthorities()); 	#A
         } else {
         	throw new BadCredentialsException
         		("Something went wrong!"); 		#B
         }
     }
     // Omitted code
}

#A 비밀번호가 일치하면 필요한 Details를 가진 Authentication 계약의 구현체를 반환합니다.
#B 비밀번호가 일치하지 않으면 AuthenticationException 타입의 예외를 발생시킵니다. BadCredentialsException은 AuthenticationException에서 상속됩니다.

 

리스팅 6.4의 로직은 단순하며, 그림 6.6은 이 로직을 시각적으로 보여줍니다. 우리는 UserDetails를 얻기 위해 UserDetailsService 구현을 사용(이 구체의 loadUserByUsername를 호출)합니다. 사용자가 존재하지 않으면, loadUserByUsername() 메소드는 AuthenticationException을 발생시켜야 합니다. 이 경우, 인증 과정은 멈추고, HTTP 필터는 응답 상태를 HTTP 401 Unauthorized로 설정합니다. 사용자 이름이 존재한다면, 우리는 컨텍스트의 PasswordEncoder의 matches() 메소드를 사용하여 사용자의 비밀번호를 추가로 확인할 수 있습니다. 비밀번호가 일치하지 않으면, 다시 AuthenticationException이 발생해야 합니다. 비밀번호가 올바른 경우, AuthenticationProvider는 요청에 대한 세부 정보를 포함하는 "인증됨"으로 표시된 Authentication 인스턴스를 반환합니다.

그림 6.6 AuthenticationProvider에 의해 구현된 custom 인증 흐름. 인증 요청을 검증하기 위해, AuthenticationProvider는 제공된 UserDetailsService의 구현을 사용하여 사용자 세부 정보를 로드하고, 비밀번호가 일치하면 PasswordEncoder로 비밀번호를 검증합니다. 사용자가 존재하지 않거나 비밀번호가 올바르지 않은 경우, AuthenticationProvider는 AuthenticationException을 발생시킵니다.

 

새로운 AuthenticationProvider 구현을 플러그인하기 위해, 우리는 SecurityFilterChain 빈을 정의합니다. 이는 다음 리스팅에서 보여주고 있습니다.

 

Listing 6.5 Registering the AuthenticationProvider in the configuration class

@Configuration
public class ProjectConfig {
     private final AuthenticationProvider authenticationProvider;
     // Omitted constructor
     @Bean
     public SecurityFilterChain securityFilterChain(HttpSecurity http) 
     throws Exception {
         http.httpBasic(Customizer.withDefaults());
         http.authenticationProvider(authenticationProvider);
         http.authorizeHttpRequests(c -> c.anyRequest().authenticated());
         return http.build(); 
 	 }
 	// Omitted code
}

 

참고: 리스팅 6.5에서, 저는 AuthenticationProvider 인터페이스로 선언된 필드를 사용한 의존성 주입을 사용합니다. Spring은 AuthenticationProvider를 인터페이스(추상화된 것)로 인식합니다. 하지만 Spring은 그 특정 인터페이스에 대한 구현체의 인스턴스를 자신의 컨텍스트에서 찾아야 한다는 것을 알고 있습니다. 우리의 경우, 구현체는 @Component 어노테이션을 사용하여 Spring 컨텍스트에 추가한 CustomAuthenticationProvider의 인스턴스입니다. 의존성 주입에 대한 복습을 원하신다면, 저의 다른 책인 "Spring Start Here"(Manning, 2021)를 읽어보시길 추천합니다.

 

다 됐습니다! 여러분은 AuthenticationProvider의 구현을 성공적으로 맞춤 설정했습니다. 이제 여러분은 필요한 곳에서 여러분의 애플리케이션에 대한 인증 로직을 맞춤 설정할 수 있습니다.

 

 

6.2 Using the SecurityContext

이 섹션에서는 보안 컨텍스트에 대해 논의합니다. 우리는 그것이 어떻게 작동하는지, 그것으로부터 데이터에 어떻게 접근하는지, 그리고 애플리케이션이 다양한 스레드 관련 시나리오에서 어떻게 그것을 관리하는지를 분석합니다. 이 섹션을 마치면, 여러분은 다양한 상황에 대한 보안 컨텍스트를 구성하는 방법을 알게 될 것입니다. 이 방법을 통해, 여러분은 보안 컨텍스트에 의해 저장된 인증된 사용자의 세부 정보를 사용하여 7장과 8장에서 권한 부여를 구성할 수 있습니다.

인증 과정이 끝난 후에 인증된 엔티티에 대한 세부 정보[UserDetails]가 필요할 것입니다. 예를 들어, 현재 인증된 사용자의 사용자 이름이나 권한을 참조해야 할 수도 있습니다. 인증 과정이 끝난 후에도 이 정보에 여전히 접근할 수 있을까요? AuthenticationManager가 인증 과정을 성공적으로 완료하면, 요청의 나머지 부분에 대해 Authentication 인스턴스를 저장합니다. Authentication 객체를 저장하는 인스턴스를 Security Context라고 합니다.

그림 6.7 인증에 성공한 후, AuthenticationFilter는 인증된 엔티티의 세부 정보를 보안 컨텍스트에 저장합니다. 거기에서, 요청에 매핑된 액션을 구현하는 컨트롤러는 필요할 때 이러한 Details에 접근할 수 있습니다.

HttpSession은 서블릿 컨테이너에 의해 관리되는 세션 정보를 나타내며, 사용자의 브라우저 세션과 서버 사이의 상태 정보를 유지하는 메커니즘입니다. 그러나 HttpSession 객체 자체가 서블릿 컨텍스트(ServletContext)에 직접 저장되는 것은 아닙니다. 대신, HttpSession은 서블릿 컨테이너에 의해 생성되고 관리되며, 각각의 사용자 세션을 식별하기 위한 고유한 세션 ID를 사용합니다. 서블릿 컨텍스트(Servlet Context)는 애플리케이션의 전체 실행 환경에 대한 정보를 담고 있는 객체로, 웹 애플리케이션의 모든 서블릿, 필터, 리스너들이 공유하는 컨텍스트입니다. 서블릿 컨텍스트는 애플리케이션 레벨의 속성을 저장하거나 공유하기 위해 사용되며, 전체 애플리케이션에 걸쳐 단 하나만 존재합니다.
HttpSession은 사용자별로 생성되어, 사용자가 웹 애플리케이션과 상호작용하는 동안 사용자의 상태(예: 로그인 상태, 선호 설정 등)를 유지하는 데 사용됩니다. 서블릿 컨테이너는 클라이언트로부터 오는 각 요청을 해당 세션 ID로 매핑하여, 해당 사용자의 HttpSession 객체에 접근할 수 있도록 합니다.
요약하자면, HttpSession은 서블릿 컨텍스트에 저장되는 것이 아니라, 서블릿 컨테이너에 의해 관리되고 사용자별로 할당되며, 서블릿 컨텍스트는 애플리케이션 레벨의 정보를 공유하는 데 사용됩니다. HttpSession과 서블릿 컨텍스트는 서로 다른 목적으로 사용되며, 각각의 사용 사례와 범위가 있습니다.
스프링 시큐리티의 SecurityContext는 인증된 사용자의 정보를 담고 있는 객체로, 인증 과정을 통과한 사용자의 정보(Authentication 객체)를 저장합니다. SecurityContext의 라이프 사이클은 주로 사용자의 세션과 밀접하게 연결되어 있으며, 애플리케이션의 구성에 따라 다를 수 있습니다. 여기에는 몇 가지 주요 단계가 있습니다:
1. 생성: 사용자가 처음으로 인증에 성공하면, 스프링 시큐리티는 해당 사용자의 Authentication 객체를 포함하는 새로운 SecurityContext를 생성합니다.
2. 저장: 생성된 SecurityContext는 보통 HttpSession`에 저장됩니다. 스프링 시큐리티는 SecurityContextHolder를 통해 현재 스레드의 SecurityContext에 접근할 수 있도록 관리합니다. 기본적으로, SecurityContextHolder는 스레드 로컬 저장소(ThreadLocal)를 사용하여 각 요청에 대한 SecurityContext를 유지합니다.
3. 사용: 사용자가 애플리케이션 내에서 다양한 요청을 수행할 때, 스프링 시큐리티는 SecurityContextHolder에서 현재 SecurityContext를 조회하여 사용자의 인증 상태와 권한을 검사합니다. 이를 통해 보안 결정을 내리고, 사용자가 접근하려는 리소스에 대한 인가 처리를 수행합니다.
4. 갱신: 사용자의 인증 정보가 변경되는 경우(예: 비밀번호 변경, 권한 변경 등), SecurityContext 내의 Authentication 객체도 갱신되어야 합니다. 이는 새로운 인증 과정을 통해 이루어질 수 있습니다.
5. 종료: 사용자가 로그아웃을 하거나 세션이 만료되는 경우, SecurityContext는 세션에서 제거되며, 사용자는 더 이상 인증되지 않은 상태가 됩니다. 스프링 시큐리티는 사용자의 로그아웃 요청을 처리할 때, SecurityContextHolder에서 SecurityContext를 클리어하고, 사용자의 HttpSession을 무효화합니다.

SecurityContext의 라이프 사이클은 애플리케이션의 보안 요구사항과 구성에 따라 다르게 관리될 수 있습니다. 예를 들어, SecurityContextHolder의 저장 전략을 변경하여, SecurityContext의 범위를 스레드 로컬에서 전역 범위로 조정할 수 있습니다. 이는 SecurityContextHolder.setStrategyName() 메서드를 사용하여 설정할 수 있습니다.

 

웹 애플리케이션에서 현재 스레드는 클라이언트로부터 요청이 서버에 도착했을 때 서버(웹 서버 또는 애플리케이션 서버)에 의해 생성되고 할당된 스레드를 의미합니다. 웹 애플리케이션에서의 스레드 처리 방식은 서버의 구현에 따라 다를 수 있지만, 일반적인 웹 서버 또는 애플리케이션 서버는 다음과 같은 과정을 통해 요청을 처리합니다:
1. 요청 수신: 클라이언트(브라우저, 모바일 앱 등)로부터 HTTP 요청이 서버에 도달합니다.
2. 스레드 할당: 서버는 요청을 처리하기 위해 스레드 풀(thread pool)에서 사용 가능한 스레드를 선택하거나 새로운 스레드를 생성합니다. 이 스레드는 요청을 처리하는 데 필요한 모든 작업을 수행하게 됩니다.
3. 요청 처리: 할당된 스레드는 요청에 대한 처리를 시작합니다. 이 과정에서 애플리케이션의 컨트롤러, 서비스, 데이터 접근 객체 등이 실행될 수 있습니다.
4. SecurityContext 관리: 스프링 시큐리티를 사용하는 경우, 이 스레드 내에서 SecurityContextHolder를 통해 현재의 SecurityContext에 접근하게 됩니다. SecurityContext는 이 스레드 내에서 인증된 사용자의 정보(Authentication 객체)를 담고 있으며, 요청 처리 과정에서 보안 결정을 내리는 데 사용됩니다.
5. 응답 생성 및 반환: 요청 처리가 완료되면, 스레드는 클라이언트에게 응답을 반환하고, 작업이 끝나면 스레드는 다음 요청을 처리하기 위해 다시 스레드 풀로 반환되거나 종료됩니다.
웹 서버 또는 애플리케이션 서버는 동시에 여러 요청을 처리할 수 있도록 설계되어 있으며, 각 요청은 일반적으로 별도의 스레드에서 독립적으로 처리됩니다. 이런 방식으로 서버는 고성능과 확장성을 제공할 수 있습니다. SecurityContextHolder의 기본 전략은 스레드-로컬(ThreadLocal) 저장소를 사용하여, 각 스레드가 자신만의 SecurityContext를 갖도록 하여 스레드 간의 보안 정보가 서로 영향을 주지 않도록 합니다.
HttpSession에 저장된 SecurityContext를 요청을 처리하는 스레드에 할당하는 과정은 스프링 시큐리티의 필터 체인을 통해 이루어집니다. 이 과정은 대략 다음과 같이 진행됩니다:
1. 요청 수신: 클라이언트로부터 웹 서버에 요청이 도착하면, 스프링 시큐리티의 필터 체인이 이 요청을 가로챕니다.
2. SecurityContextPersistenceFilter: 스프링 시큐리티 필터 체인 중 SecurityContextPersistenceFilter가 매우 중요한 역할을 합니다. 이 필터는 요청이 들어올 때 HttpSession에서 SecurityContext를 로드하고, 요청이 처리되는 동안 사용될 현재 스레드의 SecurityContextHolder에 설정합니다.
3. SecurityContextHolder 설정: SecurityContextPersistenceFilter는 HttpSession에서 `SecurityContext`를 찾아내고, 그것을 SecurityContextHolder에 설정함으로써, 요청을 처리하는 현재 스레드에서 이 SecurityContext를 사용할 수 있도록 합니다. 이를 통해 요청 처리 과정에서 인증된 사용자의 정보에 접근할 수 있게 됩니다.
4. 요청 처리: 요청이 애플리케이션의 다른 컴포넌트로 전달되어 처리되는 동안, SecurityContextHolder를 통해 언제든지 현재 인증된 사용자의 SecurityContext에 접근할 수 있습니다.
5. 응답과 함께 종료 처리: 요청 처리가 완료되고 응답이 클라이언트로 반환된 후, SecurityContextPersistenceFilter는 현재 스레드에서 사용된 SecurityContextHttpSession`에 다시 저장할 수 있습니다(변경사항이 있을 경우). 그리고 요청 처리가 완전히 끝난 후에는 SecurityContextHolder를 클리어하여, 다음 요청 처리 시 스레드에 이전 사용자의 정보가 남아 있지 않도록 합니다.
이 과정을 통해, HttpSession에 저장된 SecurityContext는 요청을 처리하는 스레드에 임시로 할당되어 사용되고, 요청 처리가 완료되면 해당 정보는 다시 HttpSession에 저장되거나 스레드에서 제거됩니다. 이러한 방식으로 스프링 시큐리티는 웹 애플리케이션에서의 상태 유지와 보안 컨텍스트 관리를 효과적으로 수행할 수 있습니다.

 

Spring Security의 SecurityContext는 SecurityContext 인터페이스에 의해 설명됩니다. 다음 리스팅에서 이 인터페이스를 정의합니다.

 

Listing 6.6 The SecurityContext interface

public interface SecurityContext extends Serializable {
     Authentication getAuthentication();
     void setAuthentication(Authentication authentication);
}

 

위 인터페이스 정의에서 볼 수 있듯이, SecurityContext의 주요 역할은 Authentication 객체를 저장하는 것입니다. 그러나 SecurityContext 자체는 어떻게 관리될까요? Spring Security는 관리자 역할을 하는 객체와 함께 SecurityContext를 관리하기 위한 세 가지 전략을 제공합니다. 이것은 SecurityContextHolder라고 불립니다:

  • MODE_THREADLOCAL—각 스레드가 SecurityContext에 자신의 Details를 저장할 수 있게 합니다. thread-per-request 애플리케이션에서, 각 요청이 개별 스레드를 가지므로 이는 일반적인 접근 방식입니다
  • MODE_INHERITABLETHREADLOCAL—MODE_THREADLOCAL과 유사하지만, 비동기 메소드의 경우 SecurityContext를 다음 스레드로 복사하도록 Spring Security에 지시합니다. 이 방법을 통해, @Async 메소드를 실행하는 새로운 스레드가 보안 컨텍스트를 상속받는다고 할 수 있습니다. @Async 어노테이션은 별도의 스레드에서 어노테이션이 붙은 메소드를 호출하도록 Spring에 지시하는 데 사용됩니다.
  • MODE_GLOBAL—애플리케이션의 모든 스레드가 동일한 보안 컨텍스트 인스턴스를 볼 수 있게 합니다.

Spring Security에 의해 제공되는 SecurityContext 관리를 위한 이 세 가지 전략 외에도, 이 섹션에서는 Spring에 알려지지 않은 자체 스레드를 정의할 때 무슨 일이 발생하는지에 대해서도 논의합니다. 배우게 될 것처럼, 이러한 경우에는 보안 컨텍스트에서 새 스레드로 Details를 명시적으로 복사해야 합니다. Spring Security는 Spring의 Context에 없는 객체를 자동으로 관리할 수 없지만, 이를 위한 몇 가지 훌륭한 유틸리티 클래스를 제공합니다.

 

6.2.1 Using a holding strategy for the security context

SecurityContext를 관리하기 위한 첫 번째 전략은 MODE_THREADLOCAL 전략입니다. 이 전략은 Spring Security가 SecurityContext를 관리하기 위해 사용하는 디폴트 설정입니다. 이 전략을 사용할 때, Spring Security는 ThreadLocal을 사용하여 SecurityContext를 관리합니다. ThreadLocal은 JDK에 의해 제공된 구현으로, 데이터의 컬렉션처럼 작동하지만 애플리케이션의 각 스레드가 컬렉션의 자신만의 부분에 저장된 데이터만 볼 수 있도록 합니다. 이 방식으로, 각 요청은 자신의 SecurityContext에 접근할 수 있습니다. 어떤 스레드도 다른 스레드의 ThreadLocal에 접근할 수 없습니다. 이는 웹 애플리케이션에서 각 요청이 자신의 SecurityContext만 볼 수 있다는 것을 의미합니다. 우리는 이것이 일반적으로 백엔드 웹 애플리케이션에서 원하는 것이라고 할 수 있습니다.
그림 6.8은 이 기능의 개요를 제공합니다. 각 요청(A, B, C)은 자신만의 할당된 스레드(T1, T2, T3)를 가집니다. 이 방식으로, 각 요청은 자신의 SecurityContext에 저장된 Details만 볼 수 있습니다. 그러나 이는 비동기 메소드가 호출될 때와 같이 새 스레드가 생성되면, 새 스레드도 자신만의 SecurityContext를 가지게 된다는 것을 의미합니다. 부모 스레드(요청의 원래 스레드)의 세부 정보는 새 스레드의 보안 컨텍스트로 복사되지 않습니다.

참고: 여기서 우리는 각 요청이 스레드에 연결된 전통적인 서블릿 애플리케이션에 대해 논의합니다. 이 아키텍처는 각 요청이 자신만의 스레드가 할당된 전통적인 서블릿 애플리케이션에만 적용됩니다. 이는 리액티브 애플리케이션에는 적용되지 않습니다. 우리는 17장에서 리액티브 접근 방식에 대한 보안을 자세히 논의할 것입니다.

 

SecurityContext를 관리하기 위한 디폴트 전략인 이 과정은 명시적으로 구성할 필요가 없습니다. 인증 과정이 끝난 후 필요한 곳에서는 항상 static getContext() 메소드를 사용하여 홀더로부터 SecurityContext를 요청하기만 하면 됩니다. 6.7번 예제에서는 애플리케이션의 엔드포인트 중 하나에서 SecurityContext를 얻는 방법을 찾을 수 있습니다. SecurityContext 에서는 인증된 엔터티에 대한 Details를 저장하는 Authentication 객체를 추가로 얻을 수 있습니다. 이 섹션에서 논의하는 예제는 프로젝트 ssia-ch6-ex2의 일부로 찾을 수 있습니다.

그림 6.8 각 요청은 화살표로 표시된 자신만의 스레드를 가지고 있습니다. 각 스레드는 자신의 SecurityContext Detaiss에만 접근할 수 있습니다. 새 스레드가 생성될 때(@Async 메소드에 의해 예를 들면), 부모 스레드의 세부 정보는 복사되지 않습니다.

 

Listing 6.7 Obtaining the SecurityContext from the SecurityContextHolder

@GetMapping("/hello")
public String hello() {
     SecurityContext context = SecurityContextHolder.getContext();
     Authentication a = context.getAuthentication();
     return "Hello, " + a.getName() + "!";
}

 

컨텍스트에서 인증 정보를 얻는 것은 엔드포인트 수준에서 더욱 편리합니다. 왜냐하면 Spring이 메소드 파라미터로 직접 주입할 수 있기 때문입니다. 매번 SecurityContextHolder 클래스를 명시적으로 참조할 필요가 없습니다. 다음 목록에서 제시된 이 방법이 더 낫습니다.

 

Listing 6.8 Spring injects Authentication value in the parameter of the method

@GetMapping("/hello")
public String hello(Authentication a) { #A
 	return "Hello, " + a.getName() + "!";
}

#A Spring Boot injects the current Authentication in the method parameter.

 

올바른 사용자로 엔드포인트를 호출할 때, 응답 본문에는 사용자 이름이 포함됩니다. 예를 들어,

curl -u user:99ff79e3-8ca0-401c-a396-0a8625ab3bad 
http://localhost:8080/hello
Hello, user!

 

6.2.2 Using a holding strategy for asynchronous calls

SecurityContext를 관리하기 위한 디폴트 전략을 고수하는 것은 쉽습니다. 그리고 많은 경우에 이것만으로도 충분합니다. MODE_THREADLOCAL은 각 스레드에 대한 보안 컨텍스트를 격리할 수 있는 능력을 제공하며, 보안 컨텍스트를 이해하고 관리하기 더 자연스럽게 만듭니다. 하지만 이것이 적용되지 않는 경우도 있습니다. 

요청당 여러 스레드를 다뤄야 할 경우 상황은 더 복잡해집니다. 엔드포인트를 비동기적으로 만들 경우 무슨 일이 일어나는지 살펴보세요. 메소드를 실행하는 스레드는 더 이상 요청을 서비스하는 같은 스레드가 아닙니다. 다음 리스팅에 제시된 것과 같은 엔드포인트를 생각해보세요.

 

Listing 6.9 An @Async method served by a different thread

@GetMapping("/bye")
@Async #A
public void goodbye() {
     SecurityContext context = SecurityContextHolder.getContext();
     String username = context.getAuthentication().getName();
     // do something with the username
}

#A Being @Async, the method is executed on a separate thread.

 

@Async 어노테이션의 기능을 활성화하기 위해, 다음과 같이 @EnableAsync 어노테이션이 적용된 설정 클래스도 생성했습니다:

@Configuration
@EnableAsync
public class ProjectConfig {
}

 

때때로 기사나 포럼에서 주요 클래스에 configuration 어노테이션을 배치하는 것을 볼 수 있습니다. 예를 들어, @EnableAsync 어노테이션을 Spring Boot 애플리케이션의 main 클래스에 직접 사용하는 예제를 찾을 수 있습니다. 이 접근법은 @SpringBootApplication 어노테이션을 main 클래스에 적용하고, 이는 @Configuration 특성을 포함하기 때문에 기술적으로 정확합니다. 하지만 실제 애플리케이션에서는 역할을 분리하려고 하며, main 클래스를 configuration 클래스로 사용하는 일은 없습니다. 이 책의 예제들을 가능한 한 명확하게 만들기 위해, 실제 시나리오에서 찾을 수 있는 것처럼 이러한 어노테이션[@ EnableAsync ]을 @Configuration 클래스에 배치하는 것을 선호합니다.

 

지금 코드를 그대로 사용하면 Authentication에서 이름을 가져오는 line에서 NullPointerException이 발생합니다.

String username = context.getAuthentication().getName()

 

이것은 이제 보안 컨텍스트를 상속받지 않는 다른 스레드에서 goodbye 메서드가 실행되기 때문입니다. 이러한 이유로, Authorization 객체가 null이며, 제시된 코드의 컨텍스트에서 NullPointerException을 발생시킵니다. 이 경우, MODE_INHERITABLETHREADLOCAL 전략을 사용하여 문제를 해결할 수 있습니다. 이는 SecurityContextHolder.setStrategyName() 메서드를 호출하거나 시스템 속성 spring.security.strategy를 사용하여 설정할 수 있습니다. 이 전략을 설정함으로써, 프레임워크는 요청의 원래 스레드의 세부 정보를 비동기 메서드의 새로 생성된 스레드로 복사하도록 알게 됩니다(그림 6.9 참조).

그림 6.9 MODE_INHERITABLETHREADLOCAL을 사용할 때, 프레임워크는 요청의 원래 스레드에서 새 스레드의 보안 컨텍스트로 보안 컨텍스트 세부 정보를 복사합니다.

 

다음 리스팅은 setStrategyName() 메서드를 호출하여 보안 컨텍스트 관리 전략을 설정하는 방법을 제시합니다.

 

Listing 6.10 Using InitializingBean to set SecurityContextHolder mode

@Configuration
@EnableAsync
public class ProjectConfig {
     @Bean
     public InitializingBean initializingBean() {
     	return () -> SecurityContextHolder.setStrategyName(
     		SecurityContextHolder.MODE_INHERITABLETHREADLOCAL);
     }
}

 

엔드포인트를 호출하면 이제 Spring에 의해 보안 컨텍스트가 다음 스레드로 올바르게 전파됨을 관찰할 수 있습니다. 또한, Authentication은 더 이상 null이 아닙니다.

 

이 방법은 프레임워크 자체가 스레드를 생성하는 경우(예를 들어, @Async 메소드의 경우)에만 작동합니다. 여러분의 코드가 스레드를 생성하는 경우, MODE_INHERITABLETHREADLOCAL 전략을 사용하더라도 동일한 문제에 직면하게 됩니다. 이는 프레임워크가 여러분의 코드가 생성하는 스레드에 대해 알지 못하기 때문에 발생합니다. 이러한 경우의 문제를 해결하는 방법에 대해서는 6.2.4절과 6.2.5절에서 논의할 예정입니다.

 

6.2.3 Using a holding strategy for standalone applications

만약 애플리케이션의 모든 스레드가 공유하는 보안 컨텍스트가 필요하다면, 전략을 MODE_GLOBAL로 변경할 수 있습니다(그림 6.10 참조). 이 전략은 웹 서버에는 적합하지 않으며, 애플리케이션의 일반적인 구조에 맞지 않습니다. 백엔드 웹 애플리케이션은 받은 요청을 독립적으로 관리하기 때문에, 모든 요청에 대해 하나의 컨텍스트 대신 요청별로 보안 컨텍스트를 분리하는 것이 좋습니다. 그러나 이는 독립 실행형 애플리케이션에는 좋은 사용 사례가 될 수 있습니다.

그림 6.10에서 보안 컨텍스트 관리 전략으로 MODE_GLOBAL을 사용하면, 모든 스레드가 동일한 보안 컨텍스트에 접근합니다. 이는 모든 스레드가 같은 데이터에 접근하고 그 정보를 변경할 수 있음을 의미합니다. 이 때문에 경쟁 조건이 발생할 수 있으며, 동기화에 신경 써야 합니다.

 

다음 코드 스니펫에서 보듯이, MODE_INHERITABLETHREADLOCAL과 같은 방식으로 전략을 변경할 수 있습니다. SecurityContextHolder.setStrategyName() 메소드나 시스템 프로퍼티 spring.security.strategy를 사용할 수 있습니다.

@Bean
public InitializingBean initializingBean() {
     return () -> SecurityContextHolder.setStrategyName(
     SecurityContextHolder.MODE_GLOBAL);
}

 

또한, SecurityContext는 스레드에 안전하지 않습니다. 따라서 이 전략에서는 애플리케이션의 모든 스레드가 SecurityContext 객체에 접근할 수 있으므로, 동시 접근을 관리해야 합니다.

 

6.2.4 Forwarding the security context with DelegatingSecurityContextRunnable

Spring Security는 MODE_THREADLOCAL, MODE_INHERITABLETHREADLOCAL, MODE_GLOBAL 세 가지 모드를 제공하여 보안 컨텍스트를 관리할 수 있습니다. 기본적으로, 프레임워크는 요청의 스레드에 대한 보안 컨텍스트만을 제공하며, 이 보안 컨텍스트는 해당 스레드에서만 접근 가능합니다. 하지만 프레임워크는 새로 생성된 스레드(예: 비동기 메서드의 경우)를 관리하지 않습니다. 이러한 상황에서는 보안 컨텍스트의 관리 모드를 명시적으로 다르게 설정해야 합니다. 그러나 여전히 하나의 특별한 경우가 있습니다: 프레임워크가 알지 못하는 상태에서 코드가 새 스레드를 시작할 때 어떻게 되는가? 때때로 우리는 이를 '자체 관리 스레드'라고 부릅니다. 왜냐하면 이는 프레임워크가 아닌 우리가 관리하기 때문입니다. 이 섹션에서는 새로 생성된 스레드에 보안 컨텍스트를 전파하는 데 도움이 되는 Spring Security가 제공하는 몇 가지 유틸리티 도구를 적용합니다.
SecurityContextHolder의 특정 전략이 자체 관리 스레드에 대한 해결책을 제공하지는 않습니다. 이 경우에는 보안 컨텍스트 전파를 직접 관리해야 합니다. 이를 위한 한 가지 해결책은 DelegatingSecurityContextRunnable을 사용하여 별도의 스레드에서 실행하려는 작업을 데코레이트하는 것입니다. DelegatingSecurityContextRunnable은 Runnable을 확장합니다. 반환 값이 예상되지 않는 작업의 실행을 따를 때 이를 사용할 수 있습니다. 리턴 값이 있는 경우에는 Callable<T> 대안인 DelegatingSecurityContextCallable<T>를 사용할 수 있습니다. 이 두 클래스는 다른 Runnable이나 Callable처럼 비동기적으로 실행되는 작업을 나타냅니다. 더욱이, 이들은 작업을 실행하는 스레드의 현재 보안 컨텍스트를 복사하여 보장합니다. 그림 6.11에서 보여주듯이, 이 객체들은 원래 작업을 데코레이트하고 새 스레드에 보안 컨텍스트를 복사합니다.

그림 6.11 DelegatingSecurityContextCallable은 Callable 객체의 데코레이터로 설계되었습니다. 이러한 객체를 구성할 때, 애플리케이션이 비동기적으로 실행하는 호출 가능한 작업을 제공합니다. DelegatingSecurityContextCallable은 보안 컨텍스트의 세부 정보를 새 스레드로 복사한 다음 작업을 실행합니다

 

리스팅 6.11은 DelegatingSecurityContextCallable의 사용을 보여줍니다. Callable 객체를 선언하는 간단한 엔드포인트 메서드를 정의하는 것으로 시작해 보겠습니다. Callable 작업은 현재 보안 컨텍스트에서 사용자 이름을 반환합니다.

 

Listing 6.11 Defining a Callable object and executing it as a task on a separate thread

@GetMapping("/ciao")
public String ciao() throws Exception {
     Callable<String> task = () -> {
     SecurityContext context = SecurityContextHolder.getContext();
     return context.getAuthentication().getName();
     };

     // Omitted code
}

 

예제를 계속하여 작업을 ExecutorService에 제출합니다. 실행의 응답은 엔드포인트에 의해 응답 본문으로 검색되어 반환됩니다.

 

Listing 6.12 Defining an ExecutorService and submitting the task

@GetMapping("/ciao")
public String ciao() throws Exception {
     Callable<String> task = () -> {
     	SecurityContext context = SecurityContextHolder.getContext();
     	return context.getAuthentication().getName();
     };
     ExecutorService e = Executors.newCachedThreadPool();
     try {
     	return "Ciao, " + e.submit(task).get() + "!";
     } finally {
     	e.shutdown();
     }
}

애플리케이션을 그대로 실행하면 NullPointerException 이상을 얻을 수 없습니다. 새롭게 생성된 스레드에서 callable 작업을 실행할 때, 인증이 더 이상 존재하지 않고 보안 컨텍스트가 비어 있습니다.

 

이 문제를 해결하기 위해, DelegatingSecurityContextCallable로 작업을 데코레이트합니다. 이는 현재 컨텍스트를 새 스레드에 제공하는데, 이 목록에 의해 제공됩니다.

 

Listing 6.13 Running the task decorated by DelegatingSecurityContextCallable

@GetMapping("/ciao")
public String ciao() throws Exception {
     Callable<String> task = () -> {
     	SecurityContext context = SecurityContextHolder.getContext();
     	return context.getAuthentication().getName();
     };
     ExecutorService e = Executors.newCachedThreadPool();
     try {
     	var contextTask = new DelegatingSecurityContextCallable<>(task);
     	return "Ciao, " + e.submit(contextTask).get() + "!";
     } finally {
     	e.shutdown();
     }
}

 

이제 엔드포인트를 호출하면, 작업이 실행되는 스레드로 스프링이 보안 컨텍스트를 전파했다는 것을 관찰할 수 있습니다:

curl -u user:2eb3f2e8-debd-420c-9680-48159b2ff905
http://localhost:8080/ciao

 

이 호출에 대한 응답 본문은

Ciao, user!

 

6.2.5 Forwarding the security context with DelegatingSecurityContextExecutorService

우리 코드가 프레임워크에 알리지 않고 시작하는 스레드를 다룰 때, 시큐리티 컨텍스트의 Details을 다음 스레드로 전파하는 것을 관리해야 합니다. 6.2.4 절에서, task 자체를 사용하여 보안 컨텍스트의 Details을 복사하는 기술을 적용했습니다. Spring Security는 DelegatingSecurityContextRunnable과 DelegatingSecurityContextCallable 같은 훌륭한 유틸리티 클래스를 제공합니다. 이 클래스들은 비동기적으로 실행하는 작업을 꾸미고, 보안 컨텍스트에서 Details 을 복사하는 책임도 집니다. 그래서 귀하의 구현이 새로 생성된 스레드에서 그 Details 에 접근할 수 있습니다. 하지만 우리는 새 스레드로 보안 컨텍스트 전파를 다루는 두 번째 옵션이 있으며, 이는 작업 자체에서가 아닌 스레드 풀에서 전파를 관리하는 것입니다. 이 절에서는 Spring Security가 제공하는 더 많은 훌륭한 유틸리티 클래스를 사용하여 이 기술을 적용하는 방법을 배웁니다.
작업을 꾸미는 대안은 특정 타입의 Executor를 사용하는 것입니다. 다음 예에서, 작업은 간단한 Callable<T>로 남아 있지만, 스레드는 여전히 보안 컨텍스트를 관리합니다. 보안 컨텍스트의 전파는 DelegatingSecurityContextExecutorService라고 불리는 구현이 ExecutorService를 데코레이터함으로써 발생합니다. DelegatingSecurityContextExecutorService는 또한 보안 컨텍스트 전파를 관리하는데, 그것은 그림 6.12에서 제시되었습니다.

그림 6.12 DelegatingSecurityContextExecutorService는 ExecutorService를 데코레이터하고 태스크를 제출하기 전에 보안 컨텍스트 Details을 다음 스레드로 전파합니다.

 

리스팅 6.14의 코드는 태스크를 제출할 때 시큐리티 컨텍스트의 Details을 전파하도록 DelegatingSecurityContextExecutorService를 사용하여 ExecutorService를 데코레이터하는 방법을 보여줍니다.

 

Listing 6.14 Propagating the SecurityContext

@GetMapping("/hola")
public String hola() throws Exception {
     Callable<String> task = () -> {
     	SecurityContext context = SecurityContextHolder.getContext();
     	return context.getAuthentication().getName();
     };
     ExecutorService e = Executors.newCachedThreadPool();
     e = new DelegatingSecurityContextExecutorService(e);
     try {
     	return "Hola, " + e.submit(task).get() + "!";
     } finally {
     	e.shutdown();
     }
}

엔드포인트를 호출하여 DelegatingSecurityContextExecutorService가 보안 컨텍스트를 올바르게 위임했는지 테스트하세요:

curl -u user:5a5124cc-060d-40b1-8aad-753d3da28dca 
http://localhost:8080/hola

 

The response body for this call is

Hola, user!

 

참고로 보안 컨텍스트의 동시성 지원과 관련된 클래스 중에서, 테이븥 6.1에 제시된 것들을 인지하고 있는 것이 좋습니다.

 

스프링은 여러분이 스레드를 생성할 때 보안 컨텍스트를 관리하기 위해 애플리케이션에서 사용할 수 있는 다양한 유틸리티 클래스의 구현을 제공합니다. 6.2.4 절에서는 DelegatingSecurityContextCallable을 구현했습니다. 이 절에서는 DelegatingSecurityContextExecutorService를 사용합니다. 예약된 작업에 대한 보안 컨텍스트 전파를 구현해야 하는 경우, 스프링 시큐리티가 DelegatingSecurityContextScheduledExecutorService라는 이름의 데코레이터를 제공한다는 소식에 기뻐할 것입니다. 이 메커니즘은 이 절에서 소개한 DelegatingSecurityContextExecutorService와 유사하지만, ScheduledExecutorService를 꾸며 예약된 작업을 다룰 수 있게 합니다.

추가적으로, 더 많은 유연성을 위해, 스프링 시큐리티는 DelegatingSecurityContextExecutor라고 불리는 더 추상적인 버전의 데코레이터를 제공합니다. 이 클래스는 스레드 풀의 계층 구조에서 가장 추상적인 계약인 Executor를 직접 꾸밉니다. 언어가 제공하는 선택지 중 어느 것으로든 스레드 풀의 구현을 교체할 수 있기를 원할 때 애플리케이션의 설계에 이를 선택할 수 있습니다.

 

Table 6.1 Objects responsible for delegating the security context to a separate thread 

Class Description
DelegatingSecurityContextExecutor Executor 인터페이스를 구현하며, 보안 컨텍스트를 해당 풀에 의해 생성된 스레드로 전달하는 기능으로 Executor 객체를 데코레이터하도 설계되었습니다.
DelegatingSecurityContextExecutorService ExecutorService 인터페이스를 구현하며, 해당 풀에 의해 생성된 스레드로 보안 컨텍스트를 전달하는 기능으로 ExecutorService 객체를 데코레이터하도 설계되었습니다.
DelegatingSecurityContextScheduledExecutorService ScheduledExecutorService 인터페이스를 구현하며, 해당 풀에 의해 생성된 스레드로 보안 컨텍스트를 전달하는 기능으로 ScheduledExecutorService 객체를 데코레이 설계되었습니다.
DelegatingSecurityContextRunnable Runnable 인터페이스를 구현하며 응답을 반환하지 않고 다른 스레드에서 실행되는 작업을 나타냅니다. 일반적인 Runnable을 넘어서, 새 스레드에서 사용할 보안 컨텍스트를 전파할 수도 있습니다.
DelegatingSecurityContextCallable Callable 인터페이스를 구현하며, 다른 스레드에서 실행되고 결국 응답을 반환할 작업을 나타냅니다. 일반적인 Callable을 넘어서, 새 스레드에서 사용할 보안 컨텍스트를 전파할 수도 있습니다.

 

 

6.3 Understanding HTTP Basic and form-based login authentications

지금까지 우리는 인증 방법으로 HTTP Basic만을 사용해 왔지만, 이 책을 통해 다른 가능성들도 있다는 것을 알게 될 것입니다. HTTP Basic 인증 방법은 간단하므로 예제 및 시연 목적이나 개념 증명에는 탁월한 선택입니다. 하지만 같은 이유로, 실제로 구현해야 할 모든 실세계 시나리오에 적합하지 않을 수 있습니다.

이 섹션에서는 HTTP Basic과 관련된 더 많은 설정을 배우게 됩니다. 또한, formLogin이라는 새로운 인증 방법을 발견하게 됩니다. 이 책의 나머지 부분에서는 다양한 종류의 아키텍처와 잘 맞는 다른 인증 방법들에 대해 논의할 것입니다. 이를 비교함으로써 인증에 대한 최선의 관행과 안티 패턴을 이해하게 될 것입니다.

 

6.3.1 Using and configuring HTTP Basic

HTTP Basic이 기본 인증 방법이라는 것을 알고 있으며, 3장의 여러 예제에서 그 작동 방식을 관찰했습니다. 이 절에서는 이 인증 방법의 설정에 관한 더 많은 세부 사항을 추가합니다. 이론적 시나리오에서, HTTP Basic 인증이 제공하는 기본값은 훌륭합니다. 하지만 좀 더 복잡한 애플리케이션에서는 이러한 설정 중 일부를 사용자 정의할 필요성을 발견할 수 있습니다. 예를 들어, 인증 과정이 실패했을 경우에 대한 특정 로직을 구현하고자 할 수 있습니다. 이 경우 클라이언트에게 돌려보내는 응답에 일부 값을 설정해야 할 필요도 있을 수 있습니다. 그러므로 이러한 경우를 실제 예제와 함께 고려하여 이를 구현하는 방법을 이해해 봅시다. 다음과 같이 이 방법을 명시적으로 설정하는 방법을 다시 한번 지적하고 싶습니다. 이 예제는 프로젝트 ssia-ch6-ex3에서 찾을 수 있습니다.

 

Listing 6.15 Setting the HTTP Basic authentication method

@Configuration
public class ProjectConfig {
     @Bean
     public SecurityFilterChain configure(HttpSecurity http) 
     	throws Exception {

     		http.httpBasic(Customizer.withDefaults());
     		return http.build();
     }
}

 

HttpSecurity 인스턴스의 httpBasic() 메소드를 Customizer 타입의 파라미터와 함께 호출할 수 있습니다. 이 파라미터를 사용하여 인증 방법과 관련된 몇 가지 설정을 구성할 수 있습니다. 예를 들어, 리스팅 6.16에 나와 있는 것처럼 영역(realm) 이름을 설정할 수 있습니다. 영역을 특정 인증 방법을 사용하는 보호 공간으로 생각할 수 있습니다. 완전한 설명을 위해서는 RFC 2617을 참조하십시오. [https://tools.ietf.org/html/rfc2617](https://tools.ietf.org/html/rfc2617)에서 확인할 수 있습니다.


"영역(realm)"은 웹 인증에서 사용되는 용어로, 보호되어야 하는 자원들의 그룹을 식별합니다. 웹 서버는 다양한 영역을 설정하여 각각의 영역에 대해 별도의 인증 요구를 할 수 있습니다. 사용자가 특정 영역에 접근하려고 할 때, 그 영역에 접근하기 위해 유효한 사용자 인증 정보(일반적으로 사용자 이름과 비밀번호)를 제공해야 합니다. 영역은 "보호 공간"으로도 설명될 수 있는데, 이는 특정 인증 방법을 사용하여 접근을 제어하는 자원의 집합을 의미합니다. 예를 들어, 웹 사이트 내에 관리자 페이지와 사용자 페이지가 있다면, 이 두 페이지는 서로 다른 영역으로 설정될 수 있으며, 각각 다른 사용자 인증을 요구할 수 있습니다. RFC 2617(현재는 RFC 7235에 의해 대체됨)은 HTTP 인증 표준을 정의하며, 이 표준은 웹 서버가 클라이언트의 요청에 대해 인증을 요구할 수 있는 메커니즘을 설명합니다. 영역 이름은 클라이언트에게 인증이 필요한 특정 영역이나 자원을 알리는 데 사용되며, 클라이언트는 이 정보를 바탕으로 적절한 자격 증명을 제공할 수 있습니다.
웹 서버에서 "영역(realm)"의 예를 들자면, 웹 사이트가 여러 섹션으로 구성되어 있고 각 섹션이 다른 수준의 접근 권한을 요구하는 경우입니다. 예를 들어, 웹 사이트에는 다음과 같은 세 가지 주요 영역이 있을 수 있습니다:
1. 공개 영역(Public Realm): 이 영역은 등록이나 로그인 없이 모든 방문자가 접근할 수 있는 웹 사이트의 공개 섹션입니다. 예를 들어, 홈페이지, 블로그 게시물, 제품 설명 페이지 등이 여기에 해당됩니다.
2. 사용자 영역(User Realm): 등록된 사용자만이 접근할 수 있는 영역으로, 사용자 프로필, 사용자 설정, 개인 메시지 등과 같이 보다 개인화된 콘텐츠나 서비스에 접근할 수 있게 합니다. 이 영역에 접근하기 위해서는 사용자 이름과 비밀번호를 사용한 로그인이 필요합니다.
3. 관리자 영역(Admin Realm): 웹 사이트의 관리자나 특정 관리 권한을 가진 사용자만이 접근할 수 있는 영역입니다. 이 영역에는 사이트 관리 도구, 사용자 관리, 콘텐츠 관리 시스템(CMS) 설정, 로그 파일 접근 권한 등이 포함될 수 있습니다. 관리자 영역에 접근하기 위해서는 더 높은 수준의 인증이 필요할 수 있습니다.

각 영역은 웹 서버에 의해 별도로 정의되며, 서버는 해당 영역에 접근을 시도하는 사용자에게 적절한 인증을 요구합니다. 이를 통해 웹 사이트는 다양한 사용자 그룹에 대해 맞춤형 접근 권한과 콘텐츠를 제공할 수 있습니다.

스프링 부트 기반 웹 애플리케이션이 `WWW-Authenticate` 헤더에 `Basic realm="Realm"`으로 응답을 보냈다면, 이는 해당 웹 애플리케이션이 HTTP Basic 인증 방식을 사용하고 있으며, 인증이 필요한 리소스에 접근하려 했던 클라이언트가 유효한 인증 정보 없이 요청을 보냈을 때의 상황입니다. 여기서 `"Realm"`은 클라이언트에게 인증을 요구하는 영역의 이름을 나타냅니다. `Basic realm="Realm"`에서 `"Realm"` 부분은 서버 측에서 설정한 영역의 이름으로, 이 영역 이름은 서버가 관리하는 리소스에 대한 접근 제어를 위해 구분하는 데 사용됩니다. 클라이언트(예: 웹 브라우저)는 이 정보를 사용자에게 보여주고, 사용자가 해당 영역에 접근하기 위해 필요한 사용자 이름과 비밀번호를 입력하도록 요청할 수 있습니다. 스프링 부트와 스프링 시큐리티를 사용하는 경우, 개발자는 `SecurityConfigurer`를 통해 보안 설정을 커스터마이즈할 수 있으며, 이 과정에서 특정 영역 이름을 설정할 수 있습니다. 설정하지 않은 경우, 스프링 시큐리티는 디폴트 영역 이름을 사용할 수 있습니다. 따라서 `WWW-Authenticate` 헤더에 `Basic realm="Realm"`이 포함된 응답은 서버가 클라이언트에게 해당 영역에 대한 유효한 인증 정보를 제공하라고 요청하는 것입니다. 이는 인증이 필요한 리소스에 대한 접근 시도가 있었음을 나타내며, 401 Unauthorized 상태 코드와 함께 전송됩니다.

.

다음과 같이 성공적인 http 전송이 이루어지면,

위처럼 WWW-Authenticate 헤더를 볼 수가 없습니다

그러한 이유는?

HTTP `200 OK` 응답 코드를 반환할 때 `WWW-Authenticate` 헤더를 보내지 않는 것은 정상적인 행동입니다. `WWW-Authenticate` 헤더는 주로 `401 Unauthorized` 응답과 함께 사용되어, 클라이언트에게 해당 리소스에 접근하기 위해 필요한 인증 방식을 알려주는 용도로 사용됩니다.

클라이언트가 올바른 인증 정보를 제공하여 성공적으로 리소스에 접근했을 때, 서버는 `200 OK` 응답 코드와 함께 요청된 리소스를 반환합니다. 이 경우, 클라이언트는 이미 성공적으로 인증을 완료했기 때문에, `WWW-Authenticate` 헤더를 추가로 보낼 필요가 없습니다. 이 헤더는 클라이언트가 인증에 실패했거나 인증이 필요한 경우에 서버가 어떤 인증 방식을 요구하는지 클라이언트에게 알릴 목적으로 사용됩니다.

따라서, 웹 애플리케이션 서버가 `200 OK` 응답 코드를 반환하면서 `WWW-Authenticate` 헤더를 보내지 않는 것은, 요청이 성공적으로 처리되었고 추가적인 인증 정보가 필요하지 않음을 의미합니다. 이는 HTTP 프로토콜의 정상적인 동작 방식에 부합합니다.

 

"영역(realm)"이란 개념은 주로 웹 인증에서 사용되며, 서버 상의 보호된 자원 그룹을 식별하는 데 사용됩니다. 클라이언트가 이러한 보호된 자원에 접근하려 할 때, 서버는 해당 영역에 대한 인증을 요구합니다. 여기에는 실제 웹 애플리케이션에서 영역을 사용하는 몇 가지 예시가 있습니다:

### 1. 기업 내부 네트워크
기업이 내부 문서와 시스템에 대한 접근을 제어해야 할 때, "직원 영역"을 설정할 수 있습니다. 이 영역에 접근하려는 사용자는 직원으로서의 인증 정보(예: 사용자명과 비밀번호)를 제공해야 합니다. 이를 통해 기업은 중요한 문서와 시스템이 안전하게 보호되도록 할 수 있습니다.

### 2. 온라인 은행 서비스
은행 웹 사이트는 "고객 영역"을 설정하여 고객의 개인 및 금융 정보에 대한 접근을 제어할 수 있습니다. 고객이 이 영역에 접근하기 위해서는 안전한 인증 과정을 거쳐야 합니다. 이 과정에서는 일반적으로 사용자 이름, 비밀번호, 그리고 때로는 이차 인증 요소가 사용됩니다.

### 3. 멤버십 기반의 콘텐츠 플랫폼
멤버십 기반의 콘텐츠 플랫폼은 "회원 영역"을 설정하여 비회원이 접근할 수 없는 프리미엄 콘텐츠를 제공할 수 있습니다. 사용자가 회원 영역에 접근하려면, 유효한 멤버십 계정 정보를 제공해야 합니다.

### 4. 관리자 인터페이스
웹 사이트의 관리자 인터페이스는 "관리자 영역"으로 설정될 수 있으며, 이 영역에 접근할 수 있는 사용자는 사이트의 콘텐츠를 관리하고 설정을 변경할 수 있는 권한을 가진 사용자로 제한됩니다. 관리자 인증은 일반 사용자 인증보다 더 엄격한 보안 요구 사항을 가질 수 있습니다.

각각의 경우에서 영역(realm)은 특정 자원 그룹에 대한 접근을 인증하기 위한 메커니즘으로 작용합니다. 웹 서버는 `WWW-Authenticate` 응답 헤더를 통해 인증 실패 시 클라이언트에게 어떤 영역에 대한 인증이 필요한지 알립니다.

 


Listing 6.16 Configuring the realm name for the response of failed authentications

@Bean
public SecurityFilterChain configure(HttpSecurity http) 
 	throws Exception {
 		http.httpBasic(c -> {
 			c.realmName("OTHER");
 			c.authenticationEntryPoint(new CustomEntryPoint());
 		});
 		http.authorizeHttpRequests(c -> c.anyRequest().authenticated());
 		return http.build();
}

 

리스팅 6.16은 realm 이름을 변경하는 예를 제시합니다. 사용된 람다 표현식은 사실상  Customizer<HttpBasicConfigurer<HttpSecurity>> 타입의 객체입니다. HttpBasicConfigurer<HttpSecurity> 타입의 매개변수를 사용하여 realmName() 메소드를 호출하고 영역 이름을 변경할 수 있습니다. cURL을 -v 플래그와 함께 사용하면, 실제로 영역 이름이 변경된 상세한 HTTP 응답을 얻을 수 있습니다. 하지만, HTTP 응답 상태가 200 OK가 아닌 401 Unauthorized일 때만 응답에서 WWW-Authenticate 헤더를 찾을 수 있다는 점에 유의해야 합니다. 다음은 cURL 호출입니다:

curl -v http://localhost:8080/hello

 

The response of the call is

/
...
< WWW-Authenticate: Basic realm="OTHER"
...

 

또한, customizer를 사용하면 인증 실패 시 응답을 사용자 정의할 수 있습니다. 클라이언트가 인증 실패하는 경우 특정한 응답을 기대하는 경우 이 작업이 필요할 수 있습니다. 하나 이상의 헤더를 추가하거나 제거해야 할 수도 있습니다. 또는 애플리케이션이 클라이언트에게 민감한 데이터(웹 어플리케이션 에러)를 노출하지 않도록 본문을 필터링하는 로직을 가질 수도 있습니다.

참고: 시스템 외부로 노출되는 데이터에 대해 항상 주의를 기울이십시오. 가장 흔한 실수 중 하나로 (OWASP 상위 10대 취약점 - https://owasp.org/www-project-top-ten/ 에도 포함되어 있습니다) 민감한 데이터를 노출시키는 것이 있습니다. 인증 실패에 대해 애플리케이션이 클라이언트에게 보내는 세부 정보를 다룰 때는 항상 기밀 정보를 공개할 위험성이 있습니다.

 

인증 실패에 대한 응답을 사용자 정의하기 위해, 우리는 `AuthenticationEntryPoint`를 구현할 수 있습니다. 이의 `commence()` 메소드는 `HttpServletRequest`, `HttpServletResponse`, 그리고 인증 실패를 일으킨  `AuthenticationException`을 받습니다. 6.17 리스트는 응답에 헤더를 추가하고 HTTP 상태를 401 Unauthorized로 설정하는 방법을 보여주는 `AuthenticationEntryPoint`의 구현 방법을 시연합니다.

NOTE It’s a little bit ambiguous that the name of the AuthenticationEntryPoint interface doesn’t reflect its usage on authentication failure. In the Spring Security architecture, this is used directly by a component called ExceptionTranslationManager, which handles any AccessDeniedException and AuthenticationException thrown within the filter chain. You can view the ExceptionTranslationManager as a bridge between Java exceptions and HTTP responses.

 

Listing 6.17 Implementing an AuthenticationEntryPoint

public class CustomEntryPoint 
 implements AuthenticationEntryPoint {
     @Override
     public void commence(
        HttpServletRequest httpServletRequest, 
        HttpServletResponse httpServletResponse, 
        AuthenticationException e) 
            throws IOException, ServletException {
                httpServletResponse
                    .addHeader("message", "Luke, I am your father!");
                httpServletResponse
                    .sendError(HttpStatus.UNAUTHORIZED.value());
     }
}

 

그런 다음 설정 클래스에서 HTTP Basic 방식으로 CustomEntryPoint를 등록할 수 있습니다. 다음 리스트는 CustomEntryPoint을 위한 설정 클래스를 제시합니다.

 

Listing 6.18 Setting the custom AuthenticationEntryPoint

@Bean
public SecurityFilterChain configure(HttpSecurity http) 
     throws Exception {
     http.httpBasic(c -> {
     	c.realmName("OTHER");
     	c.authenticationEntryPoint(new CustomEntryPoint());
     });
     http.authorizeHttpRequests().anyRequest().authenticated();
     return http.build();
}

 

이제 인증이 실패하는 엔드포인트에 요청을 하면, 응답에서 새로 추가된 헤더를 찾을 수 있어야 합니다.

curl -v http://localhost:8080/hello

 

The response of the call is

...
< HTTP/1.1 401
< Set-Cookie: JSESSIONID=459BAFA7E0E6246A463AD19B07569C7B; Path=/; 
HttpOnly
< message: Luke, I am your father!
...

 

6.3.2 Implementing authentication with form-based login

웹 애플리케이션을 개발할 때, 사용자가 자신의 인증 정보를 입력할 수 있는 사용자 친화적인 로그인 폼을 제공하고 싶을 것입니다. 또한, 인증된 사용자가 로그인 한 후 웹 페이지를 자유롭게 탐색하고 로그아웃 할 수 있기를 원할 것입니다. 작은 웹 애플리케이션의 경우, 폼 기반 로그인 방식을 이용할 수 있습니다. 이 섹션에서는 이 인증 방법을 애플리케이션에 적용하고 구성하는 방법을 배웁니다. 이를 달성하기 위해, 우리는 폼 기반 로그인을 사용하는 작은 웹 애플리케이션을 작성합니다. 그림 6.13은 우리가 구현할 흐름을 설명합니다. 이 섹션의 예제들은 프로젝트 ssia-ch6-ex4의 일부입니다.

참고: 이 방법을 작은 웹 애플리케이션에 연결하는 이유는, 이 방식을 통해 서버 측 세션을 사용하여 보안 컨텍스트를 관리하기 때문입니다. 수평 확장성이 필요한 더 큰 애플리케이션의 경우, 보안 컨텍스트를 관리하기 위해 서버 측 세션을 사용하는 것은 바람직하지 않습니다. OAuth 2를 다룰 때, 12장부터 15장까지 이러한 측면을 더 자세히 논의할 것입니다.

그림 6.13 폼 기반 로그인 사용하기. 인증되지 않은 사용자는 자신의 인증 정보를 사용하여 인증할 수 있는 폼으로 리디렉션됩니다. 애플리케이션이 사용자를 인증하면, 그들은 애플리케이션의 홈페이지로 리디렉션됩니다.

 

SecurityFilterChain Bean의 HttpSecurity 객체를 사용하여 인증 방법을 Form 기반 로그인으로 변경하려면, httpBasic() 대신 HttpSecurity 파라미터의 formLogin() 메소드를 호출합니다. 다음 리스트는 이 변경을 제시합니다.

 

Listing 6.19 Changing the authentication method to a form-based login

@Configuration
public class ProjectConfig {
     @Bean
     public SecurityFilterChain securityFilterChain(HttpSecurity http) 
     throws Exception {
     	http.formLogin(Customizer.withDefaults());
     	http.authorizeHttpRequests(c -> c.anyRequest().authenticated());
     	return http.build();
     }
}

 

이 최소한의 설정만으로도, 스프링 시큐리티는 이미 프로젝트를 위한 로그인 폼과 로그아웃 페이지를 구성했습니다. 애플리케이션을 시작하고 브라우저로 접근하면 로그인 페이지로 리디렉션되어야 합니다(그림 6.14 참조).

그림 6.14 formLogin() 메소드를 사용할 때 스프링 시큐리티에 의해 자동 구성된 기본 로그인 페이지입니다.

 

UserDetailsService를 등록하지 않는 한, 디폴트로 제공되는 자격 증명을 사용하여 로그인할 수 있습니다. 2장에서 배운 것처럼, 이것은 사용자 이름 "user"와 애플리케이션 시작 시 콘솔에 출력되는 UUID 비밀번호입니다. 성공적인 로그인 후, 다른 페이지가 정의되어 있지 않기 때문에 기본 오류 페이지로 리디렉션됩니다. 애플리케이션은 이전 예제에서 마주친 것과 동일한 인증 아키텍처에 의존합니다. 그러므로, 그림 6.14가 보여주듯이, 애플리케이션의 홈페이지를 위한 컨트롤러를 구현해야 합니다. 차이점은 간단한 JSON 형식의 응답 대신, 브라우저가 우리의 웹 페이지로 해석할 수 있는 HTML을 엔드포인트가 반환하길 원한다는 것입니다. 이 때문에, 컨트롤러에서 정의된 액션의 실행 후 파일에서 뷰가 렌더링되는 스프링 MVC 흐름을 따르기로 결정했습니다. 그림 6.15는 애플리케이션의 홈페이지를 렌더링하기 위한 스프링 MVC 흐름을 제시합니다.

 

그림 6.15 스프링 MVC 흐름의 간단한 표현입니다. 디스패처는 주어진 경로, 이 경우 /home, 에 연결된 컨트롤러 액션을 찾습니다. 컨트롤러 액션을 실행한 후, 뷰가 렌더링되고 응답이 클라이언트에게 다시 보내집니다.

 

애플리케이션에 간단한 페이지를 추가하려면, 먼저 프로젝트의 resources/static 폴더에 HTML 파일을 생성해야 합니다. 이 파일을 home.html이라고 합니다. 내부에는 나중에 브라우저에서 찾을 수 있는 일부 텍스트를 입력합니다. 그냥 제목(예를 들어, <h1>Welcome</h1>)을 추가할 수 있습니다. HTML 페이지를 생성한 후, 컨트롤러는 경로에서 뷰로의 매핑을 정의해야 합니다. 다음 리스트는 컨트롤러 클래스에서 home.html 페이지의 액션 메소드 정의를 제시합니다.

 

Listing 6.20 Defining the action method of the controller for the home.html page

@Controller
public class HelloController {
     @GetMapping("/home")
     public String home() {
     	return "home.html";
     }
}

 

HelloController 클래스에 적용된 어노테이션이 @RestController가 아니라 단순한 @Controller라는 점을 명심하세요. 이 때문에, 스프링은 메소드에 의해 반환된 값을 HTTP 응답으로 보내지 않습니다. 대신, home.html 이름을 가진 뷰를 찾아서 렌더링합니다. 이제 /home 경로에 접근하려고 하면, 먼저 로그인하고 싶은지 물어봅니다. 성공적인 로그인 후, 환영 메시지가 나타나는 홈페이지로 리디렉션됩니다. 이제 /logout 경로에 접근할 수 있으며, 이는 로그아웃 페이지로 리디렉션되어야 합니다(그림 6.16 참조).

 

그림 6.16 폼 기반 로그인 인증 방식에 대해 스프링 시큐리티가 구성한 로그아웃 페이지입니다.

 

사용자가 로그인하지 않고 특정 경로에 접근하려고 시도하면, 사용자는 자동으로 로그인 페이지로 리디렉션됩니다. 성공적인 로그인 후, 애플리케이션은 사용자를 원래 접근하려고 시도했던 경로로 다시 리디렉션합니다. 해당 경로가 존재하지 않는 경우, 애플리케이션은 기본 오류 페이지를 표시합니다. formLogin() 메소드는 우리가 사용자 정의 작업을 할 수 있게 하는 FormLoginConfigurer<HttpSecurity> 타입의 객체를 반환합니다. 예를 들어, 다음 리스트에 표시된 것처럼 defaultSuccessUrl() 메소드를 호출함으로써 이를 수행할 수 있습니다.

 

Listing 6.21 Setting a default success URL for the login form

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) 
     throws Exception {
     http.formLogin(c -> c.defaultSuccessUrl("/home", true));

     http.authorizeHttpRequests(c -> c.anyRequest().authenticated());
     return http.build();
}

 

이와 관련하여 더 깊이 파고들 필요가 있다면, `AuthenticationSuccessHandler`와 `AuthenticationFailureHandler` 객체를 사용하는 것은 더 상세한 사용자 정의 접근 방식을 제공합니다. 이 인터페이스들은 인증에 대해 실행되는 로직을 적용할 수 있는 객체를 구현하게 해줍니다. 성공적인 인증에 대한 로직을 사용자 정의하고 싶다면, `AuthenticationSuccessHandler`를 정의할 수 있습니다. `onAuthenticationSuccess()` 메소드는 servlet request, servlet response, 그리고 `Authentication` 객체를 파라미터의 아규먼트로 받습니다. 리스트 6.22에서, 로그인한 사용자의 부여된 권한에 따라 다른 리디렉션을 수행하기 위해 `onAuthenticationSuccess()` 메소드를 구현하는 예를 찾을 수 있습니다.

 

Listing 6.22 Implementing an AuthenticationSuccessHandler

@Component
public class CustomAuthenticationSuccessHandler 
 implements AuthenticationSuccessHandler {
     @Override
     public void onAuthenticationSuccess(
     	HttpServletRequest httpServletRequest, 
     	HttpServletResponse httpServletResponse, 
     	Authentication authentication) 
     		throws IOException {

     		var authorities = authentication.getAuthorities();
     		var auth = 
     			authorities.stream()
     				.filter(a -> a.getAuthority().equals("read"))
     				.findFirst(); 				#A
                    
     		if (auth.isPresent()) { 			#B
     			httpServletResponse
     				.sendRedirect("/home");
     		} else {
     			httpServletResponse
     				.sendRedirect("/error");
     		}
     }
}

#A "read" 권한이 존재하지 않는 경우  empty Optional 객체를 반환합니다.
#B "read" 권한이 존재하는 경우, /home으로 리디렉션합니다.

 

`findFirst` 메소드는 스트림에서 첫 번째 요소를 선택하는 기능을 합니다. 이 경우, 사용자의 권한(`authorities`) 목록에서 "read" 권한을 가진 첫 번째 요소를 찾습니다. 만약 해당 권한이 존재한다면, 이 메소드는 `Optional` 객체를 반환하는데, 이 객체는 그 권한을 담고 있습니다. `Optional` 객체는 해당 값이 존재할 수도 있고 아닐 수도 있는 상황을 처리하기 위한 자바 8의 기능입니다. 여기서 `auth.isPresent()`를 통해 "read" 권한의 존재 여부를 확인한 후, 존재한다면 사용자를 `/home`으로 리디렉션하고, 그렇지 않다면 `/error`로 리디렉션합니다.

 

실제 시나리오에서는 클라이언트가 인증 실패의 경우 특정 형식의 응답을 기대하는 상황이 있습니다. 그들은 401 Unauthorized와 다른 HTTP 상태 코드를 기대하거나 응답 본문에 추가 정보를 원할 수 있습니다. 애플리케이션에서 발견한 가장 전형적인 경우는 "Request Id"를 보내는 것입니다. 이 Request Id는 여러 시스템 간에 요청을 추적하기 위해 사용되는 고유한 값을 가지며, 애플리케이션은 인증 실패의 경우 응답 본문에 이를 전송할 수 있습니다. 또 다른 상황은 응답을 정화하여 애플리케이션이 시스템 외부로 민감한 데이터를 노출하지 않도록 하는 것입니다. 당신은 단순히 이벤트를 로깅하여 추가 조사를 위해 인증 실패에 대한 사용자 정의 로직을 정의하고 싶을 수 있습니다.

 

인증 실패 시 애플리케이션이 실행하는 로직을 사용자 정의하고 싶다면, `AuthenticationFailureHandler` 구현을 통해 비슷하게 할 수 있습니다. 예를 들어, 인증이 실패할 때마다 특정 헤더를 추가하고 싶다면, 6.23 리스트에 표시된 것처럼 할 수 있습니다. 물론, 여기서 어떤 로직이든 구현할 수 있습니다. `AuthenticationFailureHandler`의 경우, `onAuthenticationFailure()`는 요청, 응답, 그리고 `Authentication` 객체를 받습니다.

 

Listing 6.23 Implementing an AuthenticationFailureHandler

@Component
public class CustomAuthenticationFailureHandler 
 implements AuthenticationFailureHandler {
     @Override
     public void onAuthenticationFailure(
     	HttpServletRequest httpServletRequest, 
     	HttpServletResponse httpServletResponse, 
     	AuthenticationException e) {

         try {
             httpServletResponse.setHeader("failed", 
             	LocalDateTime.now().toString());
             httpServletResponse.sendRedirect("/error");

         } catch (IOException ex) {
         	throw new RuntimeException(ex);
         } 
     }
}

 

이 두 객체를 사용하기 위해서는 formLogin() 메소드에 의해 반환된 FormLoginConfigurer 객체의 configure() 메소드에 등록해야 합니다. 다음 리스트는 이를 수행하는 방법을 보여줍니다.

 

Listing 6.24 Registering the handler objects in the configuration class

@Configuration
public class ProjectConfig {
     private final CustomAuthenticationSuccessHandler 
                            authenticationSuccessHandler;
     private final CustomAuthenticationFailureHandler 
                            authenticationFailureHandler;
    // Omitted constructor
    @Bean
    public UserDetailsService uds() {
     	var uds = new InMemoryUserDetailsManager();
     	uds.createUser(
     		User.withDefaultPasswordEncoder()
     			.username("john")
     			.password("12345")
     			.authorities("read")
     			.build()
     		);
            
     	uds.createUser(
     		User.withDefaultPasswordEncoder()
     			.username("bill")
     			.password("12345")
     			.authorities("write")
     			.build()
     	);
     	return uds;
    }
    
    @Bean
    public SecurityFilterChain configure(HttpSecurity http) 
     throws Exception {
     
     http.formLogin(c ->
 		c.successHandler(authenticationSuccessHandler)
 			.failureHandler(authenticationFailureHandler)
 	);
 	http.authorizeHttpRequests(c -> c.anyRequest().authenticated());
 		return http.build(); 
 	}
}

 

현재, 적절한 사용자 이름과 비밀번호를 사용하여 HTTP Basic 인증으로 /home 경로에 접근하려고 시도하면, 상태 코드가 HTTP 302 Found인 응답을 받게 됩니다. 이 응답 상태 코드는 애플리케이션이 리디렉션을 시도하고 있다고 알려주는 방식입니다. 올바른 사용자 이름과 비밀번호를 제공했더라도, 이를 고려하지 않고 대신 formLogin 메소드가 요청한 대로 로그인 폼으로 보내려고 시도할 것입니다. 그러나, 다음 리스트와 같이 설정을 변경하여 HTTP Basic 인증과 폼 기반 로그인 방법을 모두 지원하도록 할 수 있습니다.

 

Listing 6.25 Using form-based login and HTTP Basic together

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) 
 	throws Exception {
 	http.formLogin(c ->
 			c.successHandler(authenticationSuccessHandler)
 			 .failureHandler(authenticationFailureHandler)
 	);
 	http.httpBasic(Customizer.withDefaults());
 	http.authorizeHttpRequests(c -> c.anyRequest().authenticated());
 	return http.build();
}

 

이제 /home 경로는 폼 기반 로그인과 HTTP Basic 인증 방법 모두로 접근할 수 있습니다:

curl -u user:cdd430f6-8ebc-49a6-9769-b0f3ce571d19 
	http://localhost:8080/home

호출의 응답은

<h1>Welcome</h1>

 

 

6.4 요약

  • AuthenticationProvider는 사용자 정의 인증 로직을 구현할 수 있게 해주는 컴포넌트입니다.
  • 사용자 정의 인증 로직을 구현할 때, 책임을 분리하여 유지하는 것이 좋은 관행입니다. 사용자 관리의 경우, AuthenticationProvider는 UserDetailsService에 위임하고, 비밀번호 검증의 책임은 PasswordEncoder에 위임합니다.
  • SecurityContext는 성공적인 인증 후 인증된 엔티티에 대한 세부 정보를 유지합니다.
  • 보안 컨텍스트를 관리하기 위한 세 가지 전략을 사용할 수 있습니다: MODE_THREADLOCAL, MODE_INHERITABLETHREADLOCAL, 그리고 MODE_GLOBAL. 다른 스레드에서 보안 컨텍스트 세부 정보에 접근하는 방식은 선택한 모드에 따라 다릅니다.
  • shared thread local 모드를 사용할 때, 스프링이 관리하는 스레드에만 적용된다는 것을 기억하세요. 프레임워크는 스프링이 관리하지 않는 스레드에 대한 보안 컨텍스트를 복사하지 않습니다.
  • 스프링 시큐리티는 코드에 의해 생성된 스레드를 관리하기 위한 훌륭한 유틸리티 클래스를 제공합니다. 스프링이 인식하지 못하는 스레드에 대해 SecurityContext를 관리하려면, 다음을 사용할 수 있습니다:
      -- DelegatingSecurityContextRunnable
      -- DelegatingSecurityContextCallable
      -- DelegatingSecurityContextExecutor
  • 스프링 시큐리티는 폼 기반 로그인 인증 방법, formLogin()을 사용하여 로그인 폼과 로그아웃 옵션을 자동 구성합니다. 이는 작은 웹 애플리케이션을 개발할 때 사용하기 쉽습니다.
  •  formLogin 인증 방법은 매우 사용자 정의가 가능합니다. 또한, 이러한 인증 방식을 HTTP 기본 방식과 함께 사용할 수 있습니다.