이 장에서는 Spring Security의 인증 프로세스와 그 구성 요소들에 대해 다룹니다. 인증은 애플리케이션에서 중요한 보안 요소로, 요청을 수행하는 엔터티가 누구인지를 식별하고, 그에 따라 적절한 권한을 부여하는 과정을 포함합니다. 다음은 이 장에서 다루는 주요 내용입니다.
1. 사용자 정의 AuthenticationProvider 구현
AuthenticationProvider는 인증 로직을 담당하는 구성 요소로, 사용자가 요청을 인증할지 여부를 판단하는 책임을 가집니다. Spring Security에서 인증 프로세스를 사용자 정의하려면 AuthenticationProvider를 구현해야 하며, 이 클래스에서 인증 조건과 지침을 정의합니다. 또한, 인증이 성공하거나 실패했을 때 어떤 작업을 수행할지도 결정합니다.
2. HTTP Basic 및 Form 기반 로그인 인증
- HTTP Basic 인증: 클라이언트는 요청 시 헤더에 사용자 이름과 비밀번호를 포함하여 인증을 수행합니다. 이 방식은 간단하지만, 보안적으로는 HTTPS와 함께 사용해야 안전합니다.
- Form 기반 로그인: 사용자 이름과 비밀번호를 입력받아 인증을 처리하는 방식으로, HTML 폼을 통해 인증 정보를 제출합니다. 이 방식은 일반적으로 웹 애플리케이션에서 가장 많이 사용됩니다.
3. SecurityContext의 이해 및 관리
SecurityContext는 현재 인증된 사용자에 대한 세부 정보를 관리합니다. 인증이 성공하면 사용자의 인증 정보는 SecurityContext에 저장되며, 애플리케이션은 이 정보를 사용하여 권한을 부여하고 요청을 처리합니다. SecurityContext는 보통 SecurityContextHolder에 저장되어 애플리케이션 내에서 접근할 수 있습니다.
4. 인증 흐름
- 인증되지 않은 요청: 요청을 수행한 사용자가 인증되지 않았으면, 응용 프로그램은 요청을 거부하고 HTTP 401 Unauthorized 상태를 클라이언트에 반환합니다.
- 인증된 요청: 요청을 수행한 사용자가 인증되면, 해당 요청에 대한 인증 정보는 SecurityContext에 저장되어, 후속 권한 부여 과정에 사용됩니다.
이 장에서는 특히 인증 로직을 담당하는 AuthenticationProvider와 인증된 요청에 대한 세부 정보를 관리하는 SecurityContext에 대해 집중적으로 설명합니다.
5. 인증 후 요청의 처리
성공적인 인증 후, SecurityContext가 어떻게 인증된 사용자의 정보를 저장하고 관리하는지에 대해 설명합니다. 인증된 정보는 애플리케이션 내에서 다양한 방식으로 활용되며, 이후의 권한 부여 과정에서 중요한 역할을 합니다.
6. 인증 옵션 커스터마이징
HTTP Basic 인증 방법을 커스터마이징하는 방법과 Form 기반 로그인 인증을 설정하는 방법을 배우게 됩니다. 이 과정에서는 기본적인 인증 방법들을 확장하고 애플리케이션에 맞게 조정하는 방법을 다룹니다.
이 장의 핵심은 Spring Security의 인증 흐름을 이해하고, AuthenticationProvider와 SecurityContext의 역할을 명확히 파악하는 것입니다. 인증 로직을 어떻게 구현하고 관리하는지, 또한 HTTP Basic 및 Form 기반 로그인을 어떻게 설정할 수 있는지에 대해 학습합니다.
6.1 AuthenticationProvider 이해하기
엔터프라이즈 애플리케이션에서는 기본적으로 사용자 이름과 비밀번호를 사용하는 인증 방식 외에 다양한 인증 시나리오를 처리해야 할 수 있습니다. 예를 들어, 사용자가 SMS로 받은 코드나 특정 애플리케이션에서 제공하는 코드를 통해 인증을 하거나, 파일에 저장된 특정 키를 사용해야 할 수도 있습니다. 또한, 지문 인증과 같은 방식도 필요할 수 있습니다.
이러한 다양한 인증 방법을 유연하게 처리하기 위해, 스프링 시큐리티는 AuthenticationProvider 계약을 제공합니다. 이를 통해 개발자는 자신만의 인증 로직을 정의할 수 있습니다.
이 섹션에서는 Authentication 인터페이스를 통해 인증 이벤트를 표현하는 방법과, AuthenticationProvider를 사용하여 사용자 정의 인증 로직을 구현하는 방법을 설명합니다.
6.1.1 절에서는 스프링 시큐리티가 인증 이벤트를 어떻게 표현하는지, 6.1.2 절에서는 AuthenticationProvider 계약에 대해 설명하며, 6.1.3 절에서는 AuthenticationProvider를 구현하여 사용자 정의 인증 로직을 작성하는 방법을 다룹니다.
따라서, 이 장에서는 다양한 인증 시나리오를 처리할 수 있도록 스프링 시큐리티의 구조와 AuthenticationProvider 사용법에 대해 다룹니다.
6.1.1 인증 중 요청을 표현하기
이 섹션에서는 스프링 시큐리티가 인증 과정에서 요청을 어떻게 처리하는지에 대해 설명합니다. 사용자 정의 인증 로직을 구현하려면, 먼저 인증 이벤트가 어떻게 설명되는지 이해하는 것이 중요합니다. 이를 통해 AuthenticationProvider를 어떻게 구현할 수 있는지에 대한 기반을 마련할 수 있습니다.
Authentication 인터페이스
Authentication 인터페이스는 인증 요청 이벤트를 표현하는 중요한 계약입니다. 이 인터페이스는 애플리케이션에 접근을 요청하는 사용자(Principal)의 세부 정보를 보유하고 있습니다. 또한, 인증 요청에 관련된 정보를 인증 과정 중 및 인증 후에 사용할 수 있습니다. 이 과정에서 사용자를 "principal"이라고 부릅니다.
스프링 시큐리티에서 Authentication 인터페이스는 자바 보안의 Principal 인터페이스를 확장하며, 이를 통해 다양한 인증 정보를 담을 수 있습니다. 이와 같은 확장은 다른 보안 프레임워크 및 애플리케이션과의 호환성도 제공합니다. 이렇게 유연한 구조 덕분에, 다른 인증 방식에서 스프링 시큐리티로의 전환이 용이해집니다.
Authentication 인터페이스의 주요 메소드
스프링 시큐리티의 Authentication 인터페이스는 다양한 정보를 제공합니다. 아래는 이 인터페이스에서 중요한 메소드들입니다:
- isAuthenticated(): 인증이 완료되었을 때 true를 반환하고, 인증 과정이 진행 중일 때는 false를 반환합니다.
- getCredentials(): 인증 과정에서 사용된 비밀번호나 기타 비밀 정보를 반환합니다.
- getAuthorities(): 인증된 요청에 대해 부여된 권한 목록을 반환합니다.
이 메소드들은 인증 과정에서 매우 중요한 역할을 하며, 후속 장에서 이 메소드들을 더 구체적으로 다룰 예정입니다.
Authentication 인터페이스 예시 (Listing 6.1)
public interface Authentication extends Principal, Serializable {
Collection<? extends GrantedAuthority> getAuthorities();
Object getCredentials();
Object getDetails();
Object getPrincipal();
boolean isAuthenticated();
void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException;
}
이 인터페이스는 Principal 인터페이스를 상속받아 사용자에 대한 기본 정보를 처리합니다. 또한 인증 상태 및 권한 정보를 추가로 다룰 수 있는 메소드들을 제공합니다. getAuthorities()는 인증된 사용자가 가진 권한들을, getCredentials()는 인증에 사용된 비밀번호나 기타 정보를 반환합니다. isAuthenticated() 메소드는 인증이 완료되었는지 여부를 알려주며, setAuthenticated() 메소드를 통해 인증 상태를 변경할 수 있습니다.
이와 같은 설계를 통해 스프링 시큐리티는 매우 유연하고 확장 가능한 인증 시스템을 제공하며, 다른 인증 방식과의 호환성도 지원합니다.
6.1.2 사용자 정의 인증 로직 구현
이 섹션에서는 사용자 정의 인증 로직을 구현하는 방법에 대해 다룹니다. 이를 위해 스프링 시큐리티에서 제공하는 AuthenticationProvider 인터페이스와 관련된 계약을 분석하고, 이 계약이 인증 로직을 어떻게 처리하는지 이해합니다. 사용자 정의 인증 제공자를 정의하는 과정은 6.1.3 절에서 코드 예제를 통해 구체적으로 다룰 예정입니다.
AuthenticationProvider 인터페이스
스프링 시큐리티에서 AuthenticationProvider는 인증 로직을 실제로 구현하는 핵심 컴포넌트입니다. 이 인터페이스의 기본 구현은 사용자를 찾는 역할을 UserDetailsService에 위임하고, 비밀번호 관리에는 PasswordEncoder를 사용합니다.
AuthenticationProvider 인터페이스는 다음과 같은 메소드를 제공합니다:
public interface AuthenticationProvider {
Authentication authenticate(Authentication authentication) throws AuthenticationException;
boolean supports(Class<?> authentication);
}
- authenticate(Authentication authentication): 인증을 수행하는 메소드입니다. 이 메소드는 Authentication 객체를 인수로 받아 인증된 Authentication 객체를 반환합니다. 이 메소드를 통해 인증 로직을 정의합니다.
- supports(Class<?> authentication): 주어진 인증 객체가 현재 제공자가 처리할 수 있는 유형인지 확인하는 메소드입니다.
인증 로직 구현하기
authenticate() 메소드 구현에 대해 몇 가지 중요한 사항을 다뤄봅니다:
- 인증 실패 시 예외 처리: 인증이 실패하면 AuthenticationException을 던져야 합니다.
- 지원하지 않는 인증 객체 처리: authenticate() 메소드가 처리할 수 없는 인증 객체를 받으면, null을 반환하여 인증을 거부할 수 있습니다. 이를 통해 다양한 인증 방식을 유연하게 처리할 수 있습니다.
- 인증 성공 후 반환: 인증이 성공하면, 인증된 Authentication 객체를 반환해야 합니다. 이 객체는 isAuthenticated()가 true를 반환하며, 인증된 엔터티의 모든 필요한 세부 정보를 포함해야 합니다. 인증된 객체에는 더 이상 비밀번호와 같은 민감한 정보가 포함되지 않도록 합니다.
supports() 메소드
supports(Class<?> authentication) 메소드는 현재 인증 제공자가 처리할 수 있는 인증 객체의 타입을 확인합니다. 이 메소드는 인증 객체의 타입을 체크하고, 해당 타입을 처리할 수 있는지 여부를 반환합니다. 예를 들어, AuthenticationProvider가 특정 인증 방식만 지원한다면, 이 메소드는 그 방식에 맞는 인증 객체일 경우 true를 반환하고, 그렇지 않으면 false를 반환합니다.
이 메소드는 인증 타입을 필터링하고, 인증 프로세스를 보다 유연하게 처리할 수 있도록 도와줍니다. 예를 들어, 지원하지 않는 인증 방식을 제공한 경우, 인증 프로세스를 거부하거나 다른 방법을 시도할 수 있습니다.
인증 관리자의 역할
Authentication Manager와 Authentication Provider는 협력하여 인증 요청을 유효하게 하거나 무효화하는 작업을 수행합니다. 이를 문에 잠금장치가 여러 종류의 카드나 키를 사용할 수 있는 경우와 비유할 수 있습니다. 잠금장치는 Authentication Manager로, 인증 제공자는 카드나 키를 확인하는 역할을 맡습니다.
- supports() 메소드가 true를 반환하면 인증 제공자는 인증을 처리할 준비가 되어 있다고 판단합니다.
- authenticate() 메소드는 실제 인증 과정을 처리하며, 인증이 성공하면 인증된 객체를 반환하고, 실패하면 AuthenticationException을 던집니다.
예시: 인증 실패 시
만약 인증 제공자가 인증 객체를 처리할 수 없거나 인증이 실패한 경우, 그 결과는 AuthenticationException으로 나타나며, 보통은 HTTP 응답 코드 401 Unauthorized로 반환됩니다. 이는 인증이 실패한 상황을 웹 애플리케이션에서 사용자에게 알리는 방법입니다.
- Authentication Manager가 제공된 인증 객체가 처리 가능한지 확인하고, 해당 객체를 처리할 수 있는 인증 제공자에게 인증을 위임하는 과정을 보여줍니다.
- 인증 제공자가 인증을 인식하지 않거나 인증을 거부하는 경우, AuthenticationException이 발생하고 결과적으로 401 Unauthorized HTTP 상태 코드가 반환되는 상황을 나타냅니다.
이처럼 AuthenticationProvider 인터페이스는 인증 프로세스의 유연성을 제공하며, 여러 인증 방식에 대해 세밀하게 제어할 수 있게 해줍니다.
6.1.3 사용자 정의 인증 로직 적용
이 섹션에서는 사용자 정의 인증 로직을 구현하는 방법을 설명합니다. 이 예제는 ssia-ch6-ex1 프로젝트에서 찾을 수 있으며, 6.1.1과 6.1.2에서 배운 Authentication 및 AuthenticationProvider 인터페이스를 실제로 적용하는 방법을 보여줍니다. 사용자 정의 인증 제공자를 구현하는 과정을 단계별로 설명합니다.
단계별 구현
- AuthenticationProvider 클래스 선언
- AuthenticationProvider 인터페이스를 구현하는 새로운 클래스를 선언합니다. 이 클래스는 인증 로직을 구현하는 데 사용됩니다.
- supports() 메소드 구현
- 새로 정의한 AuthenticationProvider가 어떤 Authentication 객체 유형을 지원하는지 명시하기 위해 supports() 메소드를 구현합니다. 예를 들어, UsernamePasswordAuthenticationToken만을 지원할 수 있습니다.
- authenticate() 메소드 구현
- 인증 로직을 실제로 구현하는 authenticate() 메소드를 작성합니다. 이 메소드는 인증 요청을 처리하고, 인증이 성공하면 인증된 Authentication 객체를 반환합니다.
- Spring Security에 등록
- 작성한 AuthenticationProvider 인스턴스를 Spring Security에 등록하여 사용하도록 합니다.
예제 코드
Listing 6.3: supports() 메소드 오버라이드
@Component
public class CustomAuthenticationProvider implements AuthenticationProvider {
@Override
public boolean supports(Class<?> authenticationType) {
return authenticationType.equals(UsernamePasswordAuthenticationToken.class);
}
}
위의 코드에서 CustomAuthenticationProvider는 UsernamePasswordAuthenticationToken만 지원하도록 정의되어 있습니다. 이 클래스는 @Component 어노테이션을 사용해 Spring의 관리 하에 놓입니다.
Listing 6.4: 인증 로직 구현
@Component
public class CustomAuthenticationProvider implements AuthenticationProvider {
private final UserDetailsService userDetailsService;
private final PasswordEncoder passwordEncoder;
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
String username = authentication.getName();
String password = authentication.getCredentials().toString();
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
if (passwordEncoder.matches(password, userDetails.getPassword())) {
return new UsernamePasswordAuthenticationToken(username, password, userDetails.getAuthorities());
} else {
throw new BadCredentialsException("Invalid username or password");
}
}
}
- authenticate() 메소드는 사용자 이름과 비밀번호를 받아 UserDetailsService를 사용하여 사용자를 찾고, PasswordEncoder를 통해 비밀번호를 검증합니다.
- 비밀번호가 일치하면 인증된 Authentication 객체를 반환하고, 일치하지 않으면 BadCredentialsException을 던집니다.
- 사용자가 제공한 인증 정보를 기반으로 UserDetailsService를 통해 사용자 정보를 로드합니다.
- 로드된 정보의 비밀번호와 사용자가 제공한 비밀번호를 PasswordEncoder로 비교합니다.
- 비밀번호가 일치하면 Authentication 객체를 반환하고, 불일치하면 AuthenticationException을 발생시킵니다.
Listing 6.5: AuthenticationProvider 등록
@Configuration
public class ProjectConfig {
private final AuthenticationProvider authenticationProvider;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.httpBasic(Customizer.withDefaults());
http.authenticationProvider(authenticationProvider);
http.authorizeHttpRequests(c -> c.anyRequest().authenticated());
return http.build();
}
}
위 코드에서 **AuthenticationProvider**를 Spring Security 설정에 추가합니다. SecurityFilterChain을 통해 HTTP 보안 구성을 설정하며, authenticationProvider를 등록하여 인증 로직을 적용합니다.
결론
이제 CustomAuthenticationProvider를 구현하여 사용자 정의 인증 로직을 Spring Security에 통합했습니다. 이 구현을 통해 인증 요청을 처리하고, 사용자 인증 방식에 맞춰 로직을 조정할 수 있습니다.
6.2 Using the SecurityContext
이 섹션에서는 보안 컨텍스트에 대해 논의합니다. 우리는 그것이 어떻게 작동하는지, 그것으로부터 데이터에 어떻게 접근하는지, 그리고 애플리케이션이 다양한 스레드 관련 시나리오에서 어떻게 그것을 관리하는지를 분석합니다. 이 섹션을 마치면, 여러분은 다양한 상황에 대한 보안 컨텍스트를 구성하는 방법을 알게 될 것입니다. 이 방법을 통해, 여러분은 보안 컨텍스트에 의해 저장된 인증된 사용자의 세부 정보를 사용하여 7장과 8장에서 권한 부여를 구성할 수 있습니다.
인증 과정이 끝난 후에 인증된 엔티티에 대한 세부 정보[UserDetails]가 필요할 것입니다. 예를 들어, 현재 인증된 사용자의 사용자 이름이나 권한을 참조해야 할 수도 있습니다. 인증 과정이 끝난 후에도 이 정보에 여전히 접근할 수 있을까요? AuthenticationManager가 인증 과정을 성공적으로 완료하면, 요청의 나머지 부분에 대해 Authentication 인스턴스를 저장합니다. Authentication 객체를 저장하는 인스턴스를 SecurityContext라고 합니다.
HttpSession은 서블릿 컨테이너에 의해 관리되는 세션 정보를 나타내며, 사용자의 브라우저 세션과 서버 사이의 상태 정보를 유지하는 메커니즘입니다. 그러나 HttpSession 객체 자체가 서블릿 컨텍스트(ServletContext)에 직접 저장되는 것은 아닙니다. 대신, HttpSession은 서블릿 컨테이너에 의해 생성되고 관리되며, 각각의 사용자 세션을 식별하기 위한 고유한 세션 ID를 사용합니다. 서블릿 컨텍스트(Servlet Context)는 애플리케이션의 전체 실행 환경에 대한 정보를 담고 있는 객체로, 웹 애플리케이션의 모든 서블릿, 필터, 리스너들이 공유하는 컨텍스트입니다. 서블릿 컨텍스트는 애플리케이션 레벨의 속성을 저장하거나 공유하기 위해 사용되며, 전체 애플리케이션에 걸쳐 단 하나만 존재합니다.
HttpSession은 사용자별로 생성되어, 사용자가 웹 애플리케이션과 상호작용하는 동안 사용자의 상태(예: 로그인 상태, 선호 설정 등)를 유지하는 데 사용됩니다. 서블릿 컨테이너는 클라이언트로부터 오는 각 요청을 해당 세션 ID로 매핑하여, 해당 사용자의 HttpSession 객체에 접근할 수 있도록 합니다.
요약하자면, HttpSession은 서블릿 컨텍스트에 저장되는 것이 아니라, 서블릿 컨테이너에 의해 관리되며 사용자별로 할당되며, 서블릿 컨텍스트는 애플리케이션 레벨의 정보를 공유하는 데 사용됩니다. HttpSession과 서블릿 컨텍스트는 서로 다른 목적으로 사용되며, 각각의 사용 사례와 범위가 있습니다.
스프링 시큐리티의 SecurityContext는 인증된 사용자의 정보를 담고 있는 객체로, 인증 과정을 통과한 사용자의 정보(Authentication 객체)를 저장합니다. SecurityContext의 라이프 사이클은 주로 사용자의 세션과 밀접하게 연결되어 있으며, 애플리케이션의 구성에 따라 다를 수 있습니다. 여기에는 몇 가지 주요 단계가 있습니다:
- 생성: 사용자가 처음으로 인증에 성공하면, 스프링 시큐리티는 해당 사용자의 Authentication 객체를 포함하는 새로운 SecurityContext를 생성합니다.
- 저장: 생성된 SecurityContext는 보통 HttpSession에 저장됩니다. 스프링 시큐리티는 SecurityContextHolder를 통해 현재 스레드의 SecurityContext에 접근할 수 있도록 관리합니다. 기본적으로, SecurityContextHolder는 스레드 로컬 저장소(ThreadLocal)를 사용하여 각 요청에 대한 SecurityContext를 유지합니다.
- 사용: 사용자가 애플리케이션 내에서 다양한 요청을 수행할 때, 스프링 시큐리티는 SecurityContextHolder에서 현재 SecurityContext를 조회하여 사용자의 인증 상태와 권한을 검사합니다. 이를 통해 보안 결정을 내리고, 사용자가 접근하려는 리소스에 대한 인가 처리를 수행합니다.
- 갱신: 사용자의 인증 정보가 변경되는 경우(예: 비밀번호 변경, 권한 변경 등), SecurityContext 내의 Authentication 객체도 갱신되어야 합니다. 이는 새로운 인증 과정을 통해 이루어질 수 있습니다.
- 종료: 사용자가 로그아웃을 하거나 세션이 만료되는 경우, SecurityContext는 세션에서 제거되며, 사용자는 더 이상 인증되지 않은 상태가 됩니다. 스프링 시큐리티는 사용자의 로그아웃 요청을 처리할 때, SecurityContextHolder에서 SecurityContext를 클리어하고, 사용자의 HttpSession을 무효화합니다.
SecurityContext의 라이프 사이클은 애플리케이션의 보안 요구사항과 구성에 따라 다르게 관리될 수 있습니다. 예를 들어, SecurityContextHolder의 저장 전략을 변경하여, SecurityContext의 범위를 스레드 로컬에서 전역 범위로 조정할 수 있습니다. 이는 SecurityContextHolder.setStrategyName() 메서드를 사용하여 설정할 수 있습니다.
웹 애플리케이션에서의 스레드 처리
웹 애플리케이션에서 현재 스레드는 클라이언트로부터 요청이 서버에 도달했을 때 서버(웹 서버 또는 애플리케이션 서버)에 의해 생성되고 할당된 스레드를 의미합니다. 웹 애플리케이션에서의 스레드 처리 방식은 서버의 구현에 따라 다를 수 있지만, 일반적인 웹 서버 또는 애플리케이션 서버는 다음과 같은 과정을 통해 요청을 처리합니다:
- 요청 수신: 클라이언트(브라우저, 모바일 앱 등)로부터 HTTP 요청이 서버에 도달합니다.
- 스레드 할당: 서버는 요청을 처리하기 위해 스레드 풀(thread pool)에서 사용 가능한 스레드를 선택하거나 새로운 스레드를 생성합니다. 이 스레드는 요청을 처리하는 데 필요한 모든 작업을 수행하게 됩니다.
- 요청 처리: 할당된 스레드는 요청에 대한 처리를 시작합니다. 이 과정에서 애플리케이션의 컨트롤러, 서비스, 데이터 접근 객체 등이 실행될 수 있습니다.
- SecurityContext 관리: 스프링 시큐리티를 사용하는 경우, 이 스레드 내에서 SecurityContextHolder를 통해 현재의 SecurityContext에 접근하게 됩니다. SecurityContext는 이 스레드 내에서 인증된 사용자의 정보(Authentication 객체)를 담고 있으며, 요청 처리 과정에서 보안 결정을 내리는 데 사용됩니다.
- 응답 생성 및 반환: 요청 처리가 완료되면, 스레드는 클라이언트에게 응답을 반환하고, 작업이 끝나면 스레드는 다음 요청을 처리하기 위해 다시 스레드 풀로 반환되거나 종료됩니다.
웹 서버 또는 애플리케이션 서버는 동시에 여러 요청을 처리할 수 있도록 설계되어 있으며, 각 요청은 일반적으로 별도의 스레드에서 독립적으로 처리됩니다. 이런 방식으로 서버는 고성능과 확장성을 제공할 수 있습니다. SecurityContextHolder의 기본 전략은 스레드-로컬(ThreadLocal) 저장소를 사용하여, 각 스레드가 자신만의 SecurityContext를 갖도록 하여 스레드 간의 보안 정보가 서로 영향을 주지 않도록 합니다.
HttpSession에 저장된 SecurityContext의 처리 과정
HttpSession에 저장된 SecurityContext를 요청을 처리하는 스레드에 할당하는 과정은 스프링 시큐리티의 필터 체인을 통해 이루어집니다. 이 과정은 대략 다음과 같이 진행됩니다:
- 요청 수신: 클라이언트로부터 웹 서버에 요청이 도달하면, 스프링 시큐리티의 필터 체인이 이 요청을 가로챕니다.
- SecurityContextPersistenceFilter: 스프링 시큐리티 필터 체인 중 SecurityContextPersistenceFilter가 매우 중요한 역할을 합니다. 이 필터는 요청이 들어올 때 HttpSession에서 SecurityContext를 로드하고, 요청이 처리되는 동안 사용될 현재 스레드의 SecurityContextHolder에 설정합니다.
- SecurityContextHolder 설정: SecurityContextPersistenceFilter는 HttpSession에서 SecurityContext를 찾아내고, 그것을 SecurityContextHolder에 설정함으로써, 요청을 처리하는 현재 스레드에서 이 SecurityContext를 사용할 수 있도록 합니다. 이를 통해 요청 처리 과정에서 인증된 사용자의 정보에 접근할 수 있게 됩니다.
- 요청 처리: 요청이 애플리케이션의 다른 컴포넌트로 전달되어 처리되는 동안, SecurityContextHolder를 통해 언제든지 현재 인증된 사용자의 SecurityContext에 접근할 수 있다.
6.2.1 SecurityContext를 관리하기 위한 보유 전략
SecurityContext를 관리하는 첫 번째 전략은 MODE_THREADLOCAL 전략입니다. 이 전략은 Spring Security에서 디폴트로 사용하는 설정으로, ThreadLocal을 사용하여 SecurityContext를 관리합니다. ThreadLocal은 각 스레드가 자신의 데이터를 독립적으로 보유할 수 있도록 도와주는 JDK에서 제공하는 기능입니다. 이 방식으로 각 요청은 자신만의 SecurityContext에 접근할 수 있으며, 다른 스레드의 ThreadLocal에 접근할 수 없습니다. 즉, 웹 애플리케이션에서는 각 요청이 자신의 SecurityContext만 볼 수 있습니다. 이는 일반적으로 백엔드 웹 애플리케이션에서 원하는 동작입니다.
각 요청(A, B, C)은 자신만의 스레드(T1, T2, T3)를 할당받습니다. 이 방식으로 각 요청은 자신만의 SecurityContext에 저장된 데이터를 볼 수 있습니다. 그러나 새 스레드가 생성되면(예: 비동기 메소드 호출 시), 새 스레드는 자신만의 SecurityContext를 가지게 되며, 부모 스레드(즉, 원래 요청을 처리한 스레드)의 정보는 새 스레드로 복사되지 않습니다.
참고: 이 설명은 전통적인 서블릿 애플리케이션에서 각 요청이 스레드에 연결되는 아키텍처에 해당합니다. 이 아키텍처는 리액티브 애플리케이션에는 적용되지 않으며, 리액티브 방식에 대한 보안은 17장에서 다루게 됩니다.
이 디폴트 전략은 명시적으로 구성할 필요가 없으며, 인증 과정이 끝난 후 필요할 때마다 SecurityContextHolder.getContext() 메서드를 사용하여 SecurityContext를 요청할 수 있습니다. 예를 들어, Listing 6.7에서는 애플리케이션의 엔드포인트에서 SecurityContext를 얻는 방법을 보여줍니다. SecurityContext는 인증된 엔티티에 대한 정보를 저장하는 Authentication 객체를 포함하고 있습니다.
각 요청은 자신만의 스레드를 가지고 있으며, 각 스레드는 자기 자신의 SecurityContext에만 접근할 수 있습니다. 새 스레드가 생성되면 부모 스레드의 세부 정보는 복사되지 않습니다.
Listing 6.7: SecurityContextHolder에서 SecurityContext 얻기
@GetMapping("/hello")
public String hello() {
SecurityContext context = SecurityContextHolder.getContext();
Authentication a = context.getAuthentication();
return "Hello, " + a.getName() + "!";
}
SecurityContext에서 인증 정보를 얻는 것은 엔드포인트 수준에서 매우 편리합니다. Spring은 메서드 파라미터로 직접 Authentication 객체를 주입할 수 있기 때문에, SecurityContextHolder 클래스를 매번 명시적으로 참조할 필요가 없습니다. Listing 6.8에서 소개된 방법은 더 간단하고 권장되는 방식입니다.
Listing 6.8: Spring이 Authentication 값을 메서드 파라미터로 주입
@GetMapping("/hello")
public String hello(Authentication a) { #A
return "Hello, " + a.getName() + "!";
}
#A Spring Boot는 현재 Authentication 객체를 메서드 파라미터로 주입합니다.
올바른 사용자로 엔드포인트를 호출하면 응답 본문에 사용자 이름이 포함됩니다. 예를 들어, curl 명령어를 사용하여 사용자 이름을 포함시킬 수 있습니다.
curl -u user:99ff79e3-8ca0-401c-a396-0a8625ab3bad http://localhost:8080/hello
응답:
Hello, user!
6.2.2 비동기 호출에 대한 보유 전략 사용
SecurityContext를 관리하는 디폴트 전략인 MODE_THREADLOCAL은 많은 경우에 충분히 유용합니다. 이 전략은 각 스레드에서 SecurityContext를 격리할 수 있는 기능을 제공하여, 보안 컨텍스트를 더 직관적이고 안전하게 관리할 수 있습니다. 하지만 이 방식이 적용되지 않는 경우도 존재합니다. 특히 요청당 여러 스레드를 처리해야 할 경우, 상황은 좀 더 복잡해집니다. 예를 들어, 엔드포인트를 비동기적으로 만들면 보안 컨텍스트의 처리가 달라질 수 있습니다.
비동기 메소드 호출 예시
다음과 같은 비동기 메소드를 실행하는 엔드포인트를 고려해봅시다. 이 메소드는 @Async 어노테이션을 사용하여 별도의 스레드에서 실행됩니다.
@GetMapping("/bye")
@Async #A
public void goodbye() {
SecurityContext context = SecurityContextHolder.getContext();
String username = context.getAuthentication().getName();
// do something with the username
}
#A @Async 어노테이션을 사용하면 이 메소드는 별도의 스레드에서 실행됩니다.
비동기 메소드를 실행하기 위해, 설정 클래스에 @EnableAsync 어노테이션을 추가해야 합니다.
@Configuration
@EnableAsync
public class ProjectConfig {
}
이 설정을 추가하면, @Async 어노테이션이 활성화되어 메소드가 비동기적으로 실행됩니다. 그러나 여기서 중요한 점은 **SecurityContext**를 사용하는 부분입니다. goodbye() 메소드에서 SecurityContextHolder.getContext()를 통해 보안 컨텍스트를 얻고 getAuthentication().getName()을 호출하려고 할 때, NullPointerException이 발생합니다. 이는 비동기 메소드가 별도의 스레드에서 실행되기 때문에, 원래 요청을 처리하던 스레드의 보안 컨텍스트가 새로운 스레드로 전달되지 않기 때문입니다.
NullPointerException 해결 방법
이 문제는 MODE_INHERITABLETHREADLOCAL 전략을 사용하여 해결할 수 있습니다. 이 전략을 사용하면, 보안 컨텍스트의 세부 정보가 원래 요청을 처리하던 스레드에서 비동기 메소드가 실행되는 새 스레드로 복사됩니다. 이 전략을 설정하는 방법은 다음과 같습니다:
- SecurityContextHolder.setStrategyName() 메서드를 호출하거나
- 시스템 속성 spring.security.strategy 를 사용하여 설정할 수 있습니다.
보유 전략 설정 예시
다음은 setStrategyName() 메서드를 사용하여 **SecurityContextHolder**의 보유 전략을 설정하는 예시입니다:
@Configuration
@EnableAsync
public class ProjectConfig {
@Bean
public InitializingBean initializingBean() {
return () -> SecurityContextHolder.setStrategyName(
SecurityContextHolder.MODE_INHERITABLETHREADLOCAL);
}
}
이 설정을 통해, 비동기 메소드가 실행될 때 Spring은 보안 컨텍스트를 올바르게 전파하고, Authentication 객체는 더 이상 null이 아닙니다.
주의 사항
MODE_INHERITABLETHREADLOCAL 전략은 프레임워크가 스레드를 생성할 때만 적용됩니다. 예를 들어, @Async 어노테이션을 사용하여 Spring이 스레드를 생성할 때는 보안 컨텍스트가 제대로 전파됩니다. 그러나 애플리케이션 코드에서 명시적으로 스레드를 생성하는 경우에는 이 전략이 제대로 작동하지 않습니다. 이런 경우에는 보안 컨텍스트가 새로 생성된 스레드에 전달되지 않으므로, 이 문제를 해결하는 방법은 6.2.4와 6.2.5에서 다루어질 예정입니다.
6.2.3 독립 실행형 애플리케이션에 대한 보유 전략 사용
애플리케이션의 모든 스레드가 동일한 보안 컨텍스트를 공유해야 하는 경우, MODE_GLOBAL 전략을 사용할 수 있습니다. 이 전략은 모든 스레드가 동일한 보안 컨텍스트에 접근하게 하며, 보안 컨텍스트가 전역적으로 공유됩니다. 이 방식은 웹 서버와 같은 백엔드 웹 애플리케이션에는 적합하지 않습니다. 왜냐하면 웹 애플리케이션에서는 각 요청마다 독립적인 스레드와 보안 컨텍스트를 사용하는 것이 좋기 때문입니다. 반면, 독립 실행형 애플리케이션에서는 이 전략이 유용할 수 있습니다.
MODE_GLOBAL 전략의 특징
- 모든 스레드가 동일한 보안 컨텍스트를 공유하므로, 각각의 스레드는 동일한 보안 정보를 접근할 수 있습니다.
- 하지만 여러 스레드가 동시에 같은 보안 컨텍스트에 접근하게 되면 경쟁 조건(race condition)이 발생할 수 있습니다. 따라서 동기화(synchronization)를 신경 써야 합니다.
- 웹 애플리케이션에서는 요청마다 독립적인 보안 컨텍스트를 사용하는 것이 일반적이기 때문에 이 전략은 권장되지 않습니다.
보유 전략 설정 예시
다음 코드에서는 SecurityContextHolder의 전략을 MODE_GLOBAL로 설정하는 방법을 보여줍니다. 이를 통해 모든 스레드가 동일한 보안 컨텍스트를 공유하게 됩니다.
@Bean
public InitializingBean initializingBean() {
return () -> SecurityContextHolder.setStrategyName(
SecurityContextHolder.MODE_GLOBAL);
}
이렇게 설정하면, 애플리케이션의 모든 스레드가 동일한 SecurityContext 객체에 접근하게 되며, 동시 접근에 대한 관리가 필요합니다.
주의사항
- 동기화 문제: 여러 스레드가 동일한 SecurityContext 객체에 접근할 수 있기 때문에, 동기화를 통해 경쟁 조건을 방지해야 합니다.
- 이 전략은 백엔드 웹 애플리케이션에는 적합하지 않으며, 독립 실행형 애플리케이션에서 유용할 수 있습니다.
6.2.4 DelegatingSecurityContextRunnable을 사용하여 보안 컨텍스트 전파
Spring Security는 기본적으로 MODE_THREADLOCAL, MODE_INHERITABLETHREADLOCAL, MODE_GLOBAL 세 가지 보안 컨텍스트 관리 모드를 제공하지만, 프레임워크가 관리하지 않는 자체 관리 스레드에서 실행되는 작업에는 보안 컨텍스트가 전파되지 않습니다. 이 경우에는 보안 컨텍스트 전파를 직접 관리해야 합니다.
이를 해결하기 위한 방법 중 하나는 DelegatingSecurityContextRunnable과 DelegatingSecurityContextCallable을 사용하는 것입니다. 이들은 각각 Runnable과 Callable 작업을 감싸는 데코레이터 클래스입니다. 새 스레드에서 실행될 때, 보안 컨텍스트가 복사되어 새 스레드에서 동일한 인증 정보를 사용할 수 있도록 보장합니다.
DelegatingSecurityContextRunnable과 DelegatingSecurityContextCallable
- DelegatingSecurityContextRunnable: 보안 컨텍스트를 새로운 스레드로 전파하는 Runnable을 데코레이트하는 클래스입니다. 반환 값이 없는 비동기 작업에 사용됩니다.
- DelegatingSecurityContextCallable: Callable 객체의 데코레이터로, 반환 값을 가지는 비동기 작업에서 보안 컨텍스트를 전파하는 데 사용됩니다.
이 두 클래스는 현재 스레드에서 사용되는 보안 컨텍스트를 새로운 스레드로 복사하여 실행됩니다.
예제 코드
- Callable 객체 정의 및 작업 실행:위 코드를 실행하면 새로운 스레드에서 Callable 작업이 실행되지만, 보안 컨텍스트가 복사되지 않아 NullPointerException이 발생할 수 있습니다.
- @GetMapping("/ciao") public String ciao() throws Exception { Callable<String> task = () -> { SecurityContext context = SecurityContextHolder.getContext(); return context.getAuthentication().getName(); }; // ExecutorService에 작업을 제출하여 비동기적으로 실행 ExecutorService e = Executors.newCachedThreadPool(); try { return "Ciao, " + e.submit(task).get() + "!"; } finally { e.shutdown(); } }
- DelegatingSecurityContextCallable을 사용한 보안 컨텍스트 전파:위 코드에서는 DelegatingSecurityContextCallable을 사용하여, 새로 생성된 스레드에서도 보안 컨텍스트가 제대로 전파되도록 합니다. 이제 보안 정보가 새 스레드에 전달되어 NullPointerException 없이 사용자 이름을 반환할 수 있습니다.
- @GetMapping("/ciao") public String ciao() throws Exception { Callable<String> task = () -> { SecurityContext context = SecurityContextHolder.getContext(); return context.getAuthentication().getName(); }; ExecutorService e = Executors.newCachedThreadPool(); try { // 작업을 DelegatingSecurityContextCallable로 감싸서 보안 컨텍스트를 전파 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!
요약
- DelegatingSecurityContextRunnable과 DelegatingSecurityContextCallable은 새로운 스레드에서 실행되는 작업에 대해 보안 컨텍스트를 전파하는 데 유용한 도구입니다.
- 이를 사용하면 프레임워크가 알지 못하는 자체 관리 스레드에서도 보안 컨텍스트를 유지할 수 있습니다.
6.2.5 DelegatingSecurityContextExecutorService를 사용하여 보안 컨텍스트 전파
프레임워크가 알지 못하는 상태에서 자체 관리 스레드를 생성하는 경우, 보안 컨텍스트의 세부 정보를 새 스레드로 전파하는 관리가 필요합니다. 이전 절에서는 DelegatingSecurityContextRunnable과 DelegatingSecurityContextCallable을 사용하여 작업 자체에서 보안 컨텍스트를 복사하는 방법을 설명했습니다. 그러나 또 다른 방법은 스레드 풀에서 전파를 관리하는 것입니다.
DelegatingSecurityContextExecutorService는 ExecutorService를 데코레이터하여, 새로 생성된 스레드가 작업을 실행하기 전에 보안 컨텍스트를 전파합니다. 이 방법은 스레드를 직접 제어하는 대신, 스레드 풀에서 전파를 처리할 수 있도록 도와줍니다.
DelegatingSecurityContextExecutorService
- DelegatingSecurityContextExecutorService는 ExecutorService 인터페이스를 구현하고, 그 기능을 확장하여, 작업이 실행되는 각 스레드에 보안 컨텍스트를 전파합니다.
- 이를 사용하면, 스레드 풀에서 생성된 스레드가 보안 컨텍스트를 자동으로 복사하고 사용할 수 있도록 할 수 있습니다.
예제 코드
- DelegatingSecurityContextExecutorService 사용:위 코드는 ExecutorService를 사용하여 비동기 작업을 제출하고, DelegatingSecurityContextExecutorService로 감싸서 보안 컨텍스트를 전파합니다.
- @GetMapping("/hola") public String hola() throws Exception { Callable<String> task = () -> { SecurityContext context = SecurityContextHolder.getContext(); return context.getAuthentication().getName(); }; // 기본 ExecutorService를 생성한 후, DelegatingSecurityContextExecutorService로 데코레이터 처리 ExecutorService e = Executors.newCachedThreadPool(); e = new DelegatingSecurityContextExecutorService(e); try { // 작업을 ExecutorService에 제출하고 결과를 가져옴 return "Hola, " + e.submit(task).get() + "!"; } finally { e.shutdown(); } }
- 테스트 및 결과 확인: curl 명령어로 엔드포인트를 호출하여 보안 컨텍스트가 제대로 전파되었는지 확인할 수 있습니다.응답 본문은 다음과 같이 표시됩니다:
- Hola, user!
- curl -u user:5a5124cc-060d-40b1-8aad-753d3da28dca http://localhost:8080/hola
Spring Security의 유틸리티 클래스
Spring Security는 보안 컨텍스트 전파를 위한 다양한 유틸리티 클래스를 제공합니다. 이 클래스들은 비동기 작업을 위한 DelegatingSecurityContextCallable, DelegatingSecurityContextRunnable 외에도, 스레드 풀에서 보안 컨텍스트를 전파하는 데 사용되는 DelegatingSecurityContextExecutor와 DelegatingSecurityContextExecutorService를 제공합니다.
클래스 설명
DelegatingSecurityContextExecutor | Executor 인터페이스를 구현하며, 보안 컨텍스트를 해당 풀에 의해 생성된 스레드로 전달하는 데 사용됩니다. |
DelegatingSecurityContextExecutorService | ExecutorService 인터페이스를 구현하며, 스레드 풀에 의해 생성된 스레드로 보안 컨텍스트를 전달하는 데 사용됩니다. |
DelegatingSecurityContextScheduledExecutorService | ScheduledExecutorService 인터페이스를 구현하며, 예약된 작업에서 보안 컨텍스트를 전달하는 데 사용됩니다. |
DelegatingSecurityContextRunnable | Runnable 인터페이스를 구현하며, 새 스레드에서 보안 컨텍스트를 전파하는 작업을 나타냅니다. |
DelegatingSecurityContextCallable | Callable 인터페이스를 구현하며, 새 스레드에서 보안 컨텍스트를 전파하고 응답을 반환하는 작업을 나타냅니다. |
요약
- DelegatingSecurityContextExecutorService는 ExecutorService를 데코레이터하여 새로 생성된 스레드로 보안 컨텍스트를 전파하는 기능을 제공합니다.
- Spring Security는 다양한 유틸리티 클래스를 제공하여, 비동기 작업과 스레드 풀에서 보안 컨텍스트를 효율적으로 전파할 수 있도록 지원합니다.
- 이 방법을 사용하면, 스레드를 생성할 때마다 보안 컨텍스트가 자동으로 전파되어, 인증 정보가 새 스레드에서 사용될 수 있게 됩니다.
6.3 HTTP Basic과 폼 기반 로그인 인증 이해하기
이전까지는 HTTP Basic 인증만을 사용했지만, 실제로 구현할 때는 다양한 인증 방법을 고려할 필요가 있습니다. HTTP Basic 인증은 매우 간단하여 예제나 개념 증명에는 유용하지만, 실제 애플리케이션에서 구현할 경우 보안상의 이유로 적합하지 않을 수 있습니다.
HTTP Basic 인증
- HTTP Basic 인증은 클라이언트가 서버에 요청을 보낼 때마다 Authorization 헤더에 사용자 이름과 비밀번호를 포함시켜 인증을 처리합니다.
- 장점: 구현이 간단하고, 서버와 클라이언트 간의 통신이 매우 직관적입니다.
- 단점: 사용자 이름과 비밀번호가 기본적으로 인코딩되지 않은 상태로 전송되기 때문에 보안에 취약합니다. 이 방식은 HTTPS와 함께 사용해야만 보안이 유지됩니다.
폼 기반 로그인 인증 (formLogin)
- 폼 기반 로그인 인증은 사용자가 로그인 폼을 통해 사용자 이름과 비밀번호를 제출하여 인증을 받는 방식입니다.
- 장점: HTTP Basic 인증보다 더 많은 사용자 인터페이스 제어가 가능하며, 사용자에게 더 나은 경험을 제공합니다. 보통 세션을 기반으로 인증이 이루어지기 때문에, 각 요청에 대해 로그인 정보를 반복적으로 보내지 않아도 됩니다.
- 단점: 폼을 구현해야 하며, 세션 관리가 필요합니다.
Spring Security에서의 설정
Spring Security는 HTTP Basic 인증과 폼 기반 로그인을 모두 지원합니다. formLogin()을 사용하여 폼 기반 로그인 방식을 설정할 수 있습니다.
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/login").permitAll() // 로그인 페이지는 모두 접근 가능
.anyRequest().authenticated() // 그 외 모든 요청은 인증된 사용자만 접근 가능
.and()
.formLogin() // 폼 기반 로그인 설정
.loginPage("/login") // 로그인 페이지 설정
.permitAll() // 로그인 페이지는 누구나 접근 가능
.and()
.logout() // 로그아웃 설정
.permitAll();
}
위 예제에서, formLogin()을 통해 폼 기반 로그인을 활성화하고, 로그인 페이지와 로그아웃 기능을 설정합니다. loginPage("/login")로 사용자 정의 로그인 페이지를 지정할 수 있습니다.
비교
- HTTP Basic 인증은 요청마다 사용자 정보를 보내므로 매번 인증을 해야 하지만, 폼 기반 로그인은 세션을 사용하여 한번 인증 후 여러 요청을 처리할 수 있습니다.
- HTTP Basic 인증은 세션을 사용하지 않으므로 상태가 없고, 인증 정보는 매번 HTTP 헤더에 담아서 전송해야 합니다. 이는 비효율적이고 보안상 취약할 수 있습니다.
- 폼 기반 로그인은 로그인 상태를 세션으로 관리하므로 사용자 경험이 더 좋고, 보안에 더 강력합니다. 세션 쿠키를 사용하여 인증 상태를 관리하며, 로그인 폼을 통해 보다 직관적으로 인증을 진행할 수 있습니다.
이러한 차이점을 바탕으로, 실세계 애플리케이션에서 적절한 인증 방법을 선택할 수 있습니다. 각 인증 방법의 장단점을 이해하고, 시스템 요구사항에 맞는 방법을 선택하는 것이 중요합니다.
6.3.1 HTTP Basic 인증 사용 및 설정
HTTP Basic 인증은 간단한 방식으로, 클라이언트가 서버에 요청을 보낼 때마다 사용자 이름과 비밀번호를 헤더에 포함시켜 인증을 처리합니다. 이 방법은 빠르고 간편하지만, 보다 복잡한 애플리케이션에서는 인증 과정에 대한 추가 설정이나 사용자 정의가 필요할 수 있습니다.
HTTP Basic 인증 설정하기
Spring Security에서는 httpBasic() 메서드를 사용하여 HTTP Basic 인증을 설정할 수 있습니다. 기본적인 설정은 간단하지만, 다양한 사용자 정의가 가능합니다. 예를 들어 인증 실패 시 반환할 응답 상태나 메시지를 변경하거나, 인증에 필요한 특정 설정을 추가할 수 있습니다.
기본 HTTP Basic 인증 설정 예제
@Configuration
public class ProjectConfig {
@Bean
public SecurityFilterChain configure(HttpSecurity http) throws Exception {
http.httpBasic(Customizer.withDefaults());
return http.build();
}
}
위 코드에서 http.httpBasic(Customizer.withDefaults())는 기본 HTTP Basic 인증을 활성화합니다. Customizer.withDefaults()는 기본 인증 방식의 설정을 그대로 사용하겠다는 의미입니다.
Realm 설정
httpBasic() 메서드를 사용하여 HTTP Basic 인증을 설정할 때, 인증 영역(realm)을 명시적으로 설정할 수 있습니다. 영역은 인증을 요구하는 보호된 공간을 의미합니다. 영역을 설정하면, 사용자가 인증을 시도할 때 해당 영역의 이름을 포함하여 인증을 요구합니다.
@Configuration
public class ProjectConfig {
@Bean
public SecurityFilterChain configure(HttpSecurity http) throws Exception {
http.httpBasic()
.realmName("MyAppRealm"); // 영역 설정
return http.build();
}
}
위 예제에서는 realmName("MyAppRealm")을 사용하여 인증이 요구되는 영역의 이름을 MyAppRealm으로 설정하고 있습니다. 이 설정은 사용자가 로그인 시 요청 헤더에 이 영역 이름을 포함하여 인증을 수행하도록 요구합니다.
HTTP Basic 인증 실패 처리
HTTP Basic 인증은 클라이언트가 사용자 이름과 비밀번호를 잘못 입력한 경우, 자동으로 401 Unauthorized 응답을 반환합니다. 이를 사용자 정의하려면 authenticationEntryPoint() 메서드를 사용하여 인증 실패 시 처리할 로직을 설정할 수 있습니다.
@Configuration
public class ProjectConfig {
@Bean
public SecurityFilterChain configure(HttpSecurity http) throws Exception {
http.httpBasic()
.authenticationEntryPoint((request, response, authException) -> {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Custom Unauthorized Message");
});
return http.build();
}
}
위 코드는 인증이 실패할 경우, 기본적으로 반환되는 401 Unauthorized 대신 "Custom Unauthorized Message"라는 사용자 정의 메시지를 응답 본문에 포함시켜 반환합니다. 이와 같은 방식으로 인증 실패 시 반환되는 응답을 커스터마이징할 수 있습니다.
HTTP Basic 인증의 주의사항
- 보안성: HTTP Basic 인증은 기본적으로 사용자 이름과 비밀번호를 인코딩하여 전송하지만, 이를 복호화하면 쉽게 알아낼 수 있기 때문에 HTTPS와 함께 사용해야만 안전합니다.
- 세션 관리: HTTP Basic 인증은 상태 없는(stateless) 방식이므로, 인증 상태를 서버에서 관리하지 않습니다. 각 요청마다 인증 정보를 헤더에 포함시켜야 하며, 세션 쿠키나 토큰 기반 인증 방법에 비해 관리가 복잡할 수 있습니다.
RFC 2617
HTTP Basic 인증에 대한 자세한 설명은 RFC 2617을 참고하면 됩니다. 이 RFC에서는 HTTP 인증 방법을 정의하고 있으며, realm 등의 설정에 대한 상세한 내용을 제공합니다.
이렇게 HTTP Basic 인증을 설정하고 사용자 정의하는 방법을 배우면, 다양한 시나리오에 맞춰 인증 방식을 효과적으로 처리할 수 있습니다.
"영역(realm)"에 대한 개념 설명
"영역(realm)"은 웹 인증에서 중요한 개념으로, 보호된 자원들을 그룹화하여 해당 그룹에 접근하려는 사용자에게 인증을 요구하는 단위입니다. 이를 통해 서버는 여러 보호된 자원을 분리하고, 각 자원에 대해 적절한 인증 정보를 요구할 수 있습니다. 예를 들어, 웹 사이트가 여러 섹션으로 나뉘어 있고 각 섹션이 다른 접근 수준을 요구할 때, 각 섹션은 별도의 "영역"으로 구분될 수 있습니다.
HTTP Basic 인증에서의 "영역" 사용
HTTP Basic 인증에서 realm은 클라이언트에게 어떤 영역에 대해 인증이 필요한지를 알려주는 역할을 합니다. 예를 들어, 서버가 WWW-Authenticate: Basic realm="MyRealm"과 같은 응답을 보낼 때, "MyRealm"은 해당 보호된 자원을 접근하기 위한 인증이 필요한 영역을 나타냅니다. 클라이언트는 이 정보를 바탕으로 적절한 자격 증명을 입력해야 합니다.
실제 예시
- 공개 영역: 등록이나 로그인 없이 모든 사용자가 접근할 수 있는 섹션.
- 사용자 영역: 로그인한 사용자만 접근할 수 있는 섹션, 예를 들어 사용자 프로필.
- 관리자 영역: 관리자만 접근할 수 있는 관리 섹션.
HTTP 응답에서의 WWW-Authenticate 헤더
WWW-Authenticate 헤더는 주로 인증이 필요한 리소스를 요청할 때 서버가 클라이언트에게 인증 정보를 요구하는데 사용됩니다. 401 Unauthorized 응답과 함께 이 헤더가 포함되며, 이를 통해 클라이언트는 인증이 필요한 영역에 대해 인증 정보를 제공해야 합니다.
200 OK 응답에서 WWW-Authenticate 헤더가 없는 이유
200 OK 응답은 인증이 성공적으로 이루어진 경우에 해당합니다. 인증이 필요한 리소스에 접근하려는 클라이언트가 올바른 인증 정보를 제공하면, 서버는 200 OK 응답을 보내며, 이때 WWW-Authenticate 헤더는 필요하지 않습니다. WWW-Authenticate 헤더는 인증이 실패했을 때, 즉 401 Unauthorized 상태에서만 사용됩니다.
인증 실패 시 응답 커스터마이징
스프링 시큐리티에서 인증 실패 시의 응답을 커스터마이즈하려면 AuthenticationEntryPoint 인터페이스를 구현해야 합니다. 이를 통해 인증 실패 시 특정 메시지나 응답을 추가할 수 있습니다. 예를 들어, 실패한 인증에 대해 401 Unauthorized 응답과 함께 추가적인 메시지를 헤더로 전달할 수 있습니다.
AuthenticationEntryPoint 구현 예시
public class CustomEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException)
throws IOException, ServletException {
response.addHeader("message", "Luke, I am your father!"); // 사용자 정의 메시지 추가
response.sendError(HttpStatus.UNAUTHORIZED.value()); // 401 Unauthorized 상태 설정
}
}
이렇게 구현된 AuthenticationEntryPoint는 인증 실패 시 서버가 응답에 message 헤더를 추가하고 401 Unauthorized 응답을 반환하도록 합니다.
설정 클래스 예시
@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();
}
위 설정에서는 HTTP Basic 인증 방식에서 "OTHER"라는 영역 이름을 설정하고, 인증 실패 시 CustomEntryPoint를 사용하여 커스터마이즈된 응답을 반환하도록 설정합니다.
cURL로 요청 시의 응답
curl -v http://localhost:8080/hello
위 명령으로 401 Unauthorized 응답을 확인할 수 있으며, 응답에 다음과 같은 추가 헤더가 포함됩니다:
< HTTP/1.1 401
< message: Luke, I am your father!
결론
realm은 인증을 요구하는 영역을 식별하는 중요한 개념으로, 다양한 보호된 자원에 대해 서로 다른 인증 요구 사항을 설정할 수 있게 해줍니다. 인증 실패 시 AuthenticationEntryPoint를 통해 응답을 커스터마이즈하고, 인증이 필요한 영역을 명확히 구분하여 보안을 강화할 수 있습니다.
6.3.2 폼 기반 로그인 구현
웹 애플리케이션에서 사용자가 자신의 인증 정보를 입력할 수 있는 로그인 폼을 제공하려면 폼 기반 로그인 방식이 매우 유용합니다. 이 방식은 서버 측에서 세션을 사용해 보안 컨텍스트를 관리하기 때문에 작은 애플리케이션에 적합합니다. 이 섹션에서는 폼 기반 로그인 인증을 설정하는 방법을 배웁니다.
1. 기본 폼 기반 로그인 구성
SecurityFilterChain의 HttpSecurity 객체를 사용하여 기본 인증 방식을 폼 기반 로그인으로 설정하려면 httpBasic() 대신 formLogin() 메소드를 사용합니다. 기본적인 설정 예시는 다음과 같습니다:
@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();
}
}
이 코드만으로 스프링 시큐리티는 기본 로그인 폼과 로그아웃 페이지를 자동으로 구성합니다. 로그인 페이지로 리디렉션되며, 성공적으로 로그인하면 애플리케이션의 기본 페이지로 이동합니다.
2. 홈 페이지 구성
사용자가 로그인한 후 리디렉션될 홈 페이지를 만들기 위해, 먼저 resources/static 폴더에 home.html 파일을 생성합니다. 그 후 컨트롤러에서 이 페이지를 렌더링하는 방법은 다음과 같습니다:
@Controller
public class HelloController {
@GetMapping("/home")
public String home() {
return "home.html";
}
}
@Controller 어노테이션을 사용하면, 스프링은 home.html 뷰를 렌더링하고 HTTP 응답으로 반환합니다. 이제 사용자가 /home 경로에 접근하면, 로그인 후 환영 메시지가 포함된 페이지가 표시됩니다.
3. 로그인 후 리디렉션 설정
로그인 성공 후 특정 페이지로 리디렉션하고 싶다면, formLogin() 메소드의 defaultSuccessUrl() 메소드를 사용해 기본 리디렉션 URL을 설정할 수 있습니다:
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http)
throws Exception {
http.formLogin(c -> c.defaultSuccessUrl("/home", true));
http.authorizeHttpRequests(c -> c.anyRequest().authenticated());
return http.build();
}
위 코드는 로그인 성공 후 /home 경로로 리디렉션되도록 설정합니다.
4. 인증 성공과 실패 핸들러
인증 성공과 실패 시 처리할 로직을 사용자 정의하려면, AuthenticationSuccessHandler와 AuthenticationFailureHandler를 구현합니다.
- 인증 성공 핸들러 예제:
@Component
public class CustomAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication)
throws IOException {
var authorities = authentication.getAuthorities();
var auth = authorities.stream().filter(a -> a.getAuthority().equals("read")).findFirst();
if (auth.isPresent()) {
response.sendRedirect("/home");
} else {
response.sendRedirect("/error");
}
}
}
- 인증 실패 핸들러 예제:
@Component
public class CustomAuthenticationFailureHandler implements AuthenticationFailureHandler {
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception)
throws IOException {
response.setHeader("failed", LocalDateTime.now().toString());
response.sendRedirect("/error");
}
}
이 핸들러들은 각각 인증 성공과 실패 시 특정 로직을 실행하며, SecurityFilterChain 설정에서 이를 등록합니다:
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http)
throws Exception {
http.formLogin(c ->
c.successHandler(authenticationSuccessHandler)
.failureHandler(authenticationFailureHandler)
);
http.authorizeHttpRequests(c -> c.anyRequest().authenticated());
return http.build();
}
5. 폼 기반 로그인과 HTTP Basic 인증 병용
폼 기반 로그인과 HTTP Basic 인증을 동시에 사용하려면 다음과 같이 설정할 수 있습니다:
@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 인증을 모두 지원하게 됩니다.
이렇게 폼 기반 로그인 방식과 인증 로직을 사용자 정의하면, 더 안전하고 직관적인 사용자 인증을 제공할 수 있습니다.
6.4 요약
- AuthenticationProvider는 사용자 정의 인증 로직을 구현하는 데 사용되는 컴포넌트입니다. 인증 로직의 각 책임을 분리하여 유지하는 것이 좋은 관행이며, 예를 들어 사용자 관리 작업은 UserDetailsService에 위임하고, 비밀번호 검증은 PasswordEncoder에 맡깁니다.
- SecurityContext는 인증이 성공한 후 인증된 사용자의 세부 정보를 유지하는 역할을 합니다. 보안 컨텍스트를 관리하는 방법으로 세 가지 전략이 있습니다:
- MODE_THREADLOCAL: 스레드 로컬에서 보안 정보를 관리합니다.
- MODE_INHERITABLETHREADLOCAL: 부모 스레드에서 자식 스레드로 보안 정보를 전달합니다.
- MODE_GLOBAL: 애플리케이션의 모든 스레드에서 보안 정보를 전역적으로 관리합니다.
- 스프링은 보안 컨텍스트를 관리하기 위한 여러 유틸리티 클래스를 제공합니다. 예를 들어, DelegatingSecurityContextRunnable, DelegatingSecurityContextCallable, DelegatingSecurityContextExecutor 등을 사용하여 스프링이 관리하지 않는 스레드에서 보안 컨텍스트를 처리할 수 있습니다.
- 폼 기반 로그인(formLogin() 메소드)은 로그인 폼과 로그아웃 옵션을 자동으로 구성하는 방식으로, 작은 웹 애플리케이션에서 사용하기 쉽습니다. 이 방식은 상당히 사용자 정의가 가능하며, HTTP 기본 인증과 함께 사용할 수도 있습니다.
이와 같이, 스프링 시큐리티는 다양한 인증 방식과 보안 컨텍스트 관리 기능을 제공하며, 이를 통해 더 안전하고 효율적인 애플리케이션 개발을 지원합니다.