인증 매커니즘

뾰족머리삼돌이·2024년 8월 13일

Spring Security

목록 보기
2/16

Username & Password

스프링 시큐리티에서 기본적으로 제공하는 인증 매커니즘으로 HttpServletRequest에서 정보를 얻어온다. 크게 3가지의 방법을 통해 UsernamePassword를 얻어낸다

Form Login

HTTP 형태로 제공되는 인증방법이다.

  1. 사용자가 인가되지 않은 리소스( 이미지의 /private ) 로 인증되지 않은 요청을 전송
  2. AuthorizationFilter에 의해 AccessDeniedException 예외 발생
  3. 인증되지 않은 사용자이기 때문에 ExceptionTranslationFilter에 의해 인증절차기 실행되며,
    LoginUrlAuthenticationEntryPoint의 인스턴스인 AuthenticationEntryPoint 에 표시된 로그인 화면( /login )으로 리다이렉트

  1. 사용자가 UsernamePassword를 포함한 인증 요청 전송
  2. UsernamePasswordAuthenticationFilter에서 인증정보를 바탕으로 UsernamePasswordAuthenticationToken를 생성
    이때, UsernamePasswordAuthenticationTokenAuthentication의 구현체다
  3. AuthenticationManager에서 UsernamePasswordAuthenticationToken을 검증
    6-1. 인증이 실패했다면, 인증실패 절차를 거치고, AuthenticationFailureHandler를 실행
    6-2. 인증이 성공했다면, 인증성공 절차를 거치고, AuthenticationSuccessHandler를 실행

DaoAuthenticationProvider

기본적으로 스프링 시큐리티는 username/password 인증에서 DaoAuthenticationProviderAuthenticationManager로 사용한다

DaoAuthenticationProviderUsernamePassword의 인증을 위해 UserDetailsServicePasswordEncoder를 활용한다.

  1. UserDetailsService에서 입력받은 Username을 이용하여 UserDetails를 얻어낸다.
  2. PasswordEncoder를 활용하여 앞에서 얻어낸 UserDetailsPassword와 입력받은 Password를 비교/검증한다.
  3. 인증에 성공했다면 UserDetailsAuthorities를 포함한 UsernamePasswordAuthenticationToken 타입의 Authentication를 반환한다.
  4. 비밀번호에 해당하는 Credentials 부분을 지우고 인증성공 이벤트를 발행한다.

Spring Security에서는 DaoAuthenticationProvider에서 UsernamePassword등의 사용자 정보를 찾기위해 UserDetailsService를 활용한다. 스프링 시큐리티에는 UserDetailsServicein-memory, JDBC, 그리고 caching 구현체를 제공한다.

HTTP Basic 인증

WWW-Authenticate 헤더를 통해 사용자 인증정보를 요구, 인증하는 방법이다.

  1. 사용자가 인가되지 않은 리소스( 이미지의 /private ) 로 인증되지 않은 요청을 전송
  2. AuthorizationFilter에 의해 AccessDeniedException 예외 발생
  3. 인증되지 않은 사용자이기 때문에 ExceptionTranslationFilter에 의해 인증절차기 실행되며,
    BasicAuthenticationEntryPoint의 인스턴스인 AuthenticationEntryPoint에 의해 WWW-Authenticate 헤더 전송

  1. 사용자가 username:password 형식으로 Base64 인코딩하여 Authorization 헤더에 담아 전송
  2. BasicAuthenticationFilter에서 인증정보를 바탕으로 UsernamePasswordAuthenticationToken를 생성
    이때, UsernamePasswordAuthenticationTokenAuthentication의 구현체다
  3. AuthenticationManager에서 UsernamePasswordAuthenticationToken을 검증
    6-1. 인증이 실패했다면, 인증실패 절차를 거치고, AuthenticationEntryPoint에서 WWW-Authenticate헤더 재전송
    6-2. 인증이 성공했다면, 인증성공 절차를 거치고, 다음 필터로 진행 및 남은 절차 수행
@Configuration
@EnableWebSecurity
public class SecurityConfiguration {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception{
        http.csrf(AbstractHttpConfigurer::disable)
                .authorizeHttpRequests(registry -> registry
                        .requestMatchers("/private").authenticated()
                        .anyRequest().permitAll()
                )
                .httpBasic(Customizer.withDefaults());


        return http.build();
    }

}

예를들어, 위와 같이 설정했을 경우에 아래의 방식으로 요청하면 인증이 통과된다

HTTP Digest 인증

최신 애플리케이션에서 사용해서는 안되는 인증방법이다.
비밀번호를 일반 텍스트 또는 MD5형식으로 저장해야 하므로 보안상의 문제가 있다.

HTTP Basic 인증의 약점을 해결하기위해 만들어진 인증방법으로 자격증명이 유선상에서 텍스트의 형태로 보내지지 않게 한다.

@Autowired
UserDetailsService userDetailsService;

DigestAuthenticationEntryPoint authenticationEntryPoint() {
	DigestAuthenticationEntryPoint result = new DigestAuthenticationEntryPoint();
	result.setRealmName("My App Realm");
	result.setKey("3028472b-da34-4501-bfd8-a355c42bdf92");
	return result;
}

DigestAuthenticationFilter digestAuthenticationFilter() {
	DigestAuthenticationFilter result = new DigestAuthenticationFilter();
	result.setUserDetailsService(userDetailsService);
	result.setAuthenticationEntryPoint(authenticationEntryPoint());
	return result;
}

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
	http
		// ...
		.exceptionHandling(e -> e.authenticationEntryPoint(authenticationEntryPoint()))
		.addFilter(digestAuthenticationFilter());
	return http.build();
}

Spring Security에서 필터나 EntryPoint를 제공하지 않으므로, 위 코드처럼 직접 작성해야한다.

저장 매커니즘

usernamepassword를 읽어들이는 매커니즘은 아래의 저장방식을 지원한다.

  1. In-Memory Authentication
  2. JDBC Authentication
  3. UserDetailsService
  4. LDAP Authentication

In-Memory Authentication

UserDetailsService의 구현체인 InMemoryUserDetailsManager에서 메모리에 저장된 username/password 기반 인증을 지원한다.

@Bean
public UserDetailsService users() {
    // The builder will ensure the passwords are encoded before saving in memory
    User.UserBuilder users = User.withDefaultPasswordEncoder();
    UserDetails user = users
            .username("user")
            .password("password")
            .roles("USER")
            .build();
    UserDetails admin = users
            .username("admin")
            .password("password")
            .roles("USER", "ADMIN")
            .build();
    return new InMemoryUserDetailsManager(user, admin);
}

위 코드처럼 코드로 메모리 상에 저장될 애플리케이션 유저들을 작성할 수 있다.

JDBC Authentication

UserDetailsService의 구현체인 JdbcDaoImpl 에서 JDBC를 활용한 username/password 기반 인증을 지원한다.
JdbcDaoImpl을 확장한 JdbcUserDetailsManager를 이용하여 UserDetails들을 관리할 수 있다.

@Bean
UserDetailsManager users(DataSource dataSource) {
    UserDetails user = User.builder()
            .username("user")
            .password("{bcrypt}$2a$10$GRLdNijSQMUvl/au9ofL.eDwmoohzzS7.rmNSJZ.0FxO/BTk76klW")
            .roles("USER")
            .build();
    UserDetails admin = User.builder()
            .username("admin")
            .password("{bcrypt}$2a$10$GRLdNijSQMUvl/au9ofL.eDwmoohzzS7.rmNSJZ.0FxO/BTk76klW")
            .roles("USER", "ADMIN")
            .build();
    JdbcUserDetailsManager users = new JdbcUserDetailsManager(dataSource);
    users.createUser(user);
    users.createUser(admin);
    return users;
}

위 코드와 같은 방법으로 DB에 유저들을 저장하도록 작성할 수 있다.

UserDetailsService

기본 제공되는 UserDetailsService외에 커스텀 UserDetailsService를 등록하여 사용할 수 있다.

@Bean
CustomUserDetailsService customUserDetailsService() {
	return new CustomUserDetailsService();
}

등록방법은 위 코드와 같고, 커스텀 구현체는 UserDetailsService를 상속하고 있어야한다.

인증 유지

SecurityContext를 저장하고 유지,관리하는데 관련된 인증 매커니즘이다.
사용자가 보호된 리소스를 대상으로 request를 전송하면 아래의 과정을 거친다.

GET / HTTP/1.1
Host: example.com
Cookie: SESSION=91470ce0-3f3c-455b-b7ad-079b02290f7b

HTTP/1.1 302 Found
Location: /login
  1. 인증되지 않은 사용자이므로, /login으로 리다이렉트
POST /login HTTP/1.1
Host: example.com
Cookie: SESSION=91470ce0-3f3c-455b-b7ad-079b02290f7b

username=user&password=password&_csrf=35942e65-a172-4cd4-a1d4-d16a51147b3e
  1. 사용자가 인증정보와 함께 인증요청
HTTP/1.1 302 Found
Location: /
Set-Cookie: SESSION=4c66e474-3f5a-43ed-8e48-cc1d8cb1d1c8; Path=/; HttpOnly; SameSite=Lax
  1. 사용자 정보를 인증하고 나면 session fixation attacks을 방지하기 위해 사용자와 새로운 세션 ID가 연결
GET / HTTP/1.1
Host: example.com
Cookie: SESSION=4c66e474-3f5a-43ed-8e48-cc1d8cb1d1c8
  1. 이후 요청부터는 세션이 살아있는 동안 세션 ID를 이용하여 사용자 인증

즉, 기본적으로 Spring Seucrity는 세션을 통해 사용자 인증정보를 관리하며, 향후 요청에서 재인증을 거치지 않는 다.


Spring SecuritySecurityContextRepository를 이용하여 향후 요청에 대한 사용자 인증을 진행한다.
기본적으로 사용되는 구현체는 DelegatingSecurityContextRepository 로 아래 클래스로 위임한다.

SecurityContextRepository는 일반적으로 HttpSession이다

  • HttpSessionSecurityContextRepository
    SecurityContextHttpSession에 저장한다. 즉, 비연속적인 요청에서 사용자의 인증 정보를 유지하는데 사용된다
    사용자와 후속요청들을 다른 방식으로 연관짓거나 아예 연관짓고 싶지 않은경우, SecurityContextRepository의 다른 구현체로 대체될 수 있다.
  • RequestAttributeSecurityContextRepository
    SecurityContextHTTP request의 속성(Attribute)으로 저장한다
    forward같은 디스패치 유형으로 발생한 단일 요청에서 SecurityContext를 유지할 수 있다.
    예를들어, 인증이후 에러발생으로 SecurityContext가 비워진 상황에서 오류페이지는 SecurityContext를 사용할 수 없지만, 이구현체를 이용하면 request 속성에 저장된 SecurityContext를 사용할 수 있다.

NullSecurityContextRepository
OAuth2와 같이 SecurityContextHttpSession에 저장할 필요가 없는 경우,
아무 작업도 하지않는 목적으로 사용한다.

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
	http
		// ...
		.securityContext((securityContext) -> securityContext
			.securityContextRepository(new DelegatingSecurityContextRepository(
				new RequestAttributeSecurityContextRepository(),
				new HttpSessionSecurityContextRepository()
			))
		);
	return http.build();
}

특정 SecurityContextRepository만 사용하는 것도 가능하지만, Spring Security 6 에서는 위 구성을 이용한다.
즉, 두 가지의 SecurityContextRepository를 모두 사용한다

SecurityContextPersistenceFilter( Deprecated )

SecurityContextRepository를 이용하여 request 간의 SecurityContext정보를 유지하는 역할을 한다.

  1. 인증이후, 남은 애플리케이션을 실행하기 전에 SecurityContextRepository에서 SecurityContext를 읽어들여 SecurityContextHolder에 저장한다.
  2. 요청 처리후, SecurityContext가 변경됐다면, SecurityContextHolder에서 SecurityContext를 읽어들여 SecurityContextRepository에 저장한다.
    SecurityContextPersistenceFilter를 사용할때, SecurityContextHolder를 설정하기만 해도 SecurityContextRepository를 사용하여 SecurityContext가 유지된다.

발생가능한 문제상황과 해결책

SecurityContextPersistenceFilter의 시퀀스에는 몇가지 문제점이 있다.

  1. SecurityContextPersistenceFilter 메서드가 완료되지 않았는데 response가 클라이언트에게 전달될 수 있다.
    예를들어, redirect에 의해 응답이 전송되었다면 이미 세션ID를 작성했기 때문에 HttpSession을 설정할 수 없다.
  2. SecurityContextPersistenceFilter가 완료되기 전에 클라이언트 인증이 성공하여, 다음 요청이 도착할 수 있다.
    이러한 경우, 두 번째 요청에 잘못된 인증이 발생할 수 있다.

이러한 문제를 방지하기 위해 SecurityContextPersistenceFilterHttpServletRequestHttpServletResponse를 Wrap하여 SecurityContext의 변경을 감지하고, 응답이 커밋되기 직전에 SecurityContext를 저장한다.

SecurityContextHolderFilter

SecurityContextRepository를 이용하여 request 간의 SecurityContext정보를 유지하는 역할을 한다.

  1. 인증이후, 남은 애플리케이션을 실행하기 전에 SecurityContextRepository에서 SecurityContext를 읽어들여 SecurityContextHolder에 저장한다.
  2. 남은 애플리케이션이 실행된다.

SecurityContextPersistenceFilter와의 차이점아직 저장되지 않은 SecurityContext만 읽어들인다는 점이다.
즉, SecurityContextHolderFilter를 사용한다는 것은 SecurityContext를 명시적으로 저장한다는 의미가 된다.

public SecurityFilterChain filterChain(HttpSecurity http) {
	http
		// ...
		.securityContext((securityContext) -> securityContext
			.requireExplicitSave(true)
		);
	return http.build();
}

위 설정 사용시, SecurityContextSecurityContextHolder에 저장하는 코드는 SecurityContextrequest간에 유지되어야 하는 상황에서 SecurityContextSecurityContextRepository에도 저장한다는 사실이 중요하다.

  • SecurityContextPersistenceFilter 에서의 SecurityContextSecurityContextHolder에 저장할 때
SecurityContextHolder.setContext(securityContext);
  • SecurityContextHolderFilter 에서의 SecurityContextSecurityContextHolder에 저장할 때
SecurityContextHolder.setContext(securityContext);
securityContextRepository.saveContext(securityContext, httpServletRequest, httpServletResponse);

인증 지속성과 세션 관리

HTTP 통신은 stateless하기 때문에 각 request 정보를 저장하지 않는다. 그렇다면 매 요청마다 인증된 유저인지 어떻게 관리하는 것일까?
이를 이해하기 위해서는 HttpSecurity에서의 requireExplicitSave를 이해하는 것이 중요하다.

public final class SecurityContextConfigurer<H extends HttpSecurityBuilder<H>>
		extends AbstractHttpConfigurer<SecurityContextConfigurer<H>, H> {

	private boolean requireExplicitSave = true;

	// ...

	public SecurityContextConfigurer<H> requireExplicitSave(boolean requireExplicitSave) {
		this.requireExplicitSave = requireExplicitSave;
		return this;
	}
    
// ...

}

세션관리의 컴포넌트 이해

여기서 소개하는 컴포넌트들은 SecurityContextHolderFilter, SecurityContextPersistenceFilter, SessionManagementFilter를 말한다.

SecurityContextPersistenceFilterSessionManagementFilter의 경우,
스프링 시큐리티 6 버전이 되면서 기본값으로 사용되지 않는다.
또한, 어떤 애플리케이션이든 SecurityContextHolderFilterSecurityContextPersistenceFilter는 둘 중 하나만 사용해야 한다.

SessionManagementFilter

일반적으로 Pre-Authentication이나 Remember-Me같은 non-interactive 인증 매커니즘에서 현재 request의 유저가 인증되었는지 결정하기 위하여 SecurityContextHolderSecurityContextRepository를 비교/확인한다
( form-login 처럼 인증 이후 redirect를 수행하는 경우, 인증 요청중에 필터가 호출되지 않으므로 감지되지 않는다 )

만약, repositorySecurityContext가 존재한다면 필터는 아무런 동작을 수행하지 않는다.
thread-local SecurityContext 에 익명이 아닌 Authentication 객체가 있다면, 앞선 필터에서 인증되었다고 간주하고, 설정된 SessionAuthenticationStrategy를 실행한다.


스프링 시큐리티 5 버전에서는 SessionManagementFilter를 이용하여 사용자가 방금 인증했는지 감지하고 SessionAuthenticationStrategy를 실행한다. 이는 일반적인 구성이라면 모든 request에서 HttpSession가 읽히는 문제점이 있다.

스프링 시큐리티 6 버전에서는 인증 매커니즘 자체가 SessionAuthenticationStrategy를 실행시키는 것이 기본 설정이다.
인증의 완료시점을 탐지할 이유가 없으므로, 모든 request에서 HttpSession가 읽힐 필요가 없다는 것을 의미한다.

만약, 스프링 시큐리티 6 버전을 사용한다면 sessionManagement의 아래 메서드들은 아래와 같이 대체된다.

  • sessionAuthenticationErrorUrl -> 인증 매커니즘의 AuthenticationFailureHandler 설정
  • sessionAuthenticationFailureHandler -> 인증 매커니즘의 AuthenticationFailureHandler 설정
  • sessionAuthenticationStrategy -> 인증 매커니즘의 SessionAuthenticationStrategy 설정

Authentication 저장위치 관리

기본 구성에서 SecurityContext의 저장 위치는 HTTP session 이다.
하지만, 아래와 같은 상황에서 저장위치를 변경하고 싶을 수 있다.

  • HttpSessionSecurityContextRepository 인스턴스의 private 새터들을 호출하고 싶은 경우
  • 수평적 확장을 위해 SecurityContext를 캐시나 DB에 저장하려는 경우

이런 상황이라면 먼저 SecurityContextRepository의 구현체를 생성하거나 HttpSessionSecurityContextRepository처럼 이미 존재하는 구현체를 HttpSecurity에 등록해야 한다.

예시

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) {
    SecurityContextRepository repo = new MyCustomSecurityContextRepository();
    http
        // ...
        .securityContext((context) -> context
            .securityContextRepository(repo)
        );
    return http.build();
}

수동으로 Authentication 저장

커스텀 필터, 혹은 Spring MVC의 컨트롤러에서 수동으로 Authentication을 저장할 수 있다.

private SecurityContextRepository securityContextRepository =
        new HttpSessionSecurityContextRepository();

@PostMapping("/login")
public void login(@RequestBody LoginRequest loginRequest, HttpServletRequest request, HttpServletResponse response) {
    UsernamePasswordAuthenticationToken token = UsernamePasswordAuthenticationToken.unauthenticated(
        loginRequest.getUsername(), loginRequest.getPassword());
    Authentication authentication = authenticationManager.authenticate(token);
    SecurityContext context = securityContextHolderStrategy.createEmptyContext();
    context.setAuthentication(authentication);
    securityContextHolderStrategy.setContext(context);
    securityContextRepository.saveContext(context, request, response);
}

class LoginRequest {

    private String username;
    private String password;

    // getters and setters
}

위 코드는 Spring MVC의 컨트롤러에 Authentication을 수동으로 저장하는 코드를 작성한 예시다.

  1. 입력받은 정보를 이용하여 UsernamePasswordAuthenticationToken를 생성
  2. authenticationManager로 인증을 수행하고 Authentication 객체 획득
  3. SecurityContext에 앞서 생성한 Authentication 객체 등록
  4. SecurityContextHolder에 앞서 생성한 SecurityContext 등록
  5. SecurityContextRepository에 앞서 생성한 SecurityContext 등록

Stateless Authentication 유지를 위한 설정

request간에 Authentication의 유지목적 HttpSession을 사용하지 않는 경우가 있다.

예를들어, HTTP Basic 인증의 경우 stateless 하기때문에 사용자는 매 요청마다 재인증을 해야한다.

이런경우, HttpSession을 사용하지 않도록하는 코드는 아래와 같다.

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) {
    http
        // ...
        .sessionManagement((session) -> session
            .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
        );
    return http.build();
}

위 설정은 SecurityContextRepositoryNullSecurityContextRepository을 사용하도록 지정하고, request가 세션에 저장되지 않도록 한다.

간혹, stateless 인증 매커니즘에서 세션에 authentication가 저장되도록 하고싶다면 HttpSessionSecurityContextRepository를 사용하면 된다.

@Bean
SecurityFilterChain web(HttpSecurity http) throws Exception {
    http
        // ...
        .httpBasic((basic) -> basic
            .addObjectPostProcessor(new ObjectPostProcessor<BasicAuthenticationFilter>() {
                @Override
                public <O extends BasicAuthenticationFilter> O postProcess(O filter) {
                    filter.setSecurityContextRepository(new HttpSessionSecurityContextRepository());
                    return filter;
                }
            })
        );

    return http.build();
}

위 코드는 httpBasic 인증에서 BasicAuthenticationFilter를 사용하여 SecurityContextRepository를 변경하도록 ObjectPostProcessor를 추가하는 코드이며, Bearer Token 인증같은 다른 인증 매커니즘에서도 사용할 수 있다.

Require Explicit Save

스프링 시큐리티 5 버전에서는 SecurityContextSecurityContextRepository에 저장하기 위한 기본동작으로 SecurityContextPersistenceFilter를 사용한다. 이는 HttpServletResponse이 커밋되기 직전과 SecurityContextPersistenceFilter 이전에 이뤄져야한다.

이 방법은 사용자의 요청이 완료되기 이전에 SecurityContext가 자동으로 유지되면 사용자에게 혼란을 줄 수 있고,
저장이 필요한지를 확인하기 위해 상태를 추적하는게 복잡해지기 때문에 SecurityContextRepository불필요한 write가 발생할 수도 있다.

위의 이유에서 SecurityContextPersistenceFilterdeprecated되고 SecurityContextHolderFilter로 대체되었다. 스프링 시큐리티 6 버전의 기본동작은 SecurityContextHolderFilterSecurityContextRepository에서 SecurityContext를 읽어온 뒤 SecurityContextHolder에 보관하는 것이다.

request간에 SecurityContext를 유지하고 싶다면 사용자가 SecurityContextRepository와 함께 SecurityContext를 명시적으로 저장해야한다. 이를통해, 모호성을 제거하고 필요에따라 SecurityContextRepositorywrite하기만 하면 성능이 향상된다.

public SecurityFilterChain filterChain(HttpSecurity http) {
	http
		// ...
		.securityContext((securityContext) -> securityContext
			.requireExplicitSave(true)
		);
	return http.build();
}

실제로는 위 설정을 통해 스프링 시큐리티가 SecurityContextHolderFilter를 사용하도록 할 수 있다.

Configuring Concurrent Session Control

form-based login을 위해 별도의 인증 필터를 사용하고 있다면 명시적으로 concurrent session control 설정을 해야한다

특정 유저가 애플리케이션에 로그인하는 동작 제약을 가하고 싶다면 다음 방법을 사용하면 된다.

@Bean
public HttpSessionEventPublisher httpSessionEventPublisher() {
    return new HttpSessionEventPublisher();
}

먼저, 세션 lifecycle 이벤트에 대한 Spring Security 업데이트를 유지하려면 Configuration 파일에 Listener를 추가해야 한다.

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) {
    http
        .sessionManagement(session -> session
            .maximumSessions(1)
        );
    return http.build();
}

위 코드를 설정하면 다른 환경( ex : 서로다른 브라우저 )에서의 로그인을 방지할 수 있다.


두 번째 로그인이 첫 번째 로그인을 무효화 시킨다( 앞선 로그인으로 얻은 세션 ID를 변경한다 )

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) {
    http
        .sessionManagement(session -> session
            .maximumSessions(1)
            .maxSessionsPreventsLogin(true)
        );
    return http.build();
}

두 번째 로그인을 막고싶다면 위 설정을 사용하면 된다.

form-based login이 사용되고 있다면 두 번째 로그인이 거부되어 사용자는 authentication-failure-url로 보내진다.
remember-me같은 non-interactive 매커니즘이라면 사용자의 두번째 인증에서 unauthorized 에러가 발생한다.
( 별도의 예외페이지로 보내는 설정도 가능하다 )

Detecting Timeouts

스프링 시큐리티는 세션ID를 사용하여 사용자 인증을 유지한다.
세션은 자체적으로 만료되므로 SecurityContext를 제거하기 위한 별도의 작업이 필요없다.
즉, 스프링 시큐리티는 세션만료를 감지하고 별도의 안내동작을 취할 수 있다.( 예를들어, 특정 endpointredirect시킬 수 있다 )

이와 관련된 설정은 아래와 같다

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) {
    http
        .sessionManagement(session -> session
            .invalidSessionUrl("/invalidSession")
        );
    return http.build();
}

이 방법을 사용하면 사용자가 로그아웃하고 브라우저를 종료하지 않은 채 다시 로그인하면 잘못된 오류를 보고할 수 있다
세션이 만료될 때, 세션쿠키가 지워지지 않기 때문이다

로그아웃 시, 세션쿠키가 삭제되게 하려면 아래 설정을 이용하면 된다

// 컨테이너 상관없이 Clear-Site-Data 헤더를 지원하면 동작
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) {
    http
        .logout((logout) -> logout
            .addLogoutHandler(new HeaderWriterLogoutHandler(new ClearSiteDataHeaderWriter(COOKIES)))
        );
    return http.build();
}
// 모든 서블릿 컨테이너에서 동작함을 보장하지 않음
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) {
    http
        .logout(logout -> logout
            .deleteCookies("JSESSIONID")
        );
    return http.build();
}

SecurityContextHolderStrategy

// 기존 코드
UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(
        loginRequest.getUsername(), loginRequest.getPassword());
Authentication authentication = this.authenticationManager.authenticate(token);
// ...
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(authentication);
SecurityContextHolder.setContext(context);

SecurityContextHolder에 직접 SecurityContext를 저장하는 위 코드는 몇 가지 예상치 못한 현상이 생길 수 있다.

컴포넌트가 SecurityContextHolder를 통해 SecurityContext에 정적으로 엑세스하는 경우 SecurityContextHolderStrategy를 지정하려는 여러 응용프로그램 컨텍스트가 있을 때, 경쟁상태를 유발할 수 있다
( SecurityContextHolder는 애플리케이션 컨텍스트가 아닌 클래스로더마다 하나의 strategy를 가지기 때문 )

이를 해결하기 위해 컴포넌트는 애플리케이션 컨텍스트에서 SecurityContextHolderStrategy를 연결할 수 있다
SecurityContext에 대한 정적인 엑세스 대신 동적인 엑세스 기회를 제공하므로 문제를 해결할 수 있다

// 수정된 코드
public class SomeClass {

    private final SecurityContextHolderStrategy securityContextHolderStrategy = SecurityContextHolder.getContextHolderStrategy();

    public void someMethod() {
        UsernamePasswordAuthenticationToken token = UsernamePasswordAuthenticationToken.unauthenticated(
                loginRequest.getUsername(), loginRequest.getPassword());
        Authentication authentication = this.authenticationManager.authenticate(token);
        // ...
        SecurityContext context = this.securityContextHolderStrategy.createEmptyContext();
        context.setAuthentication(authentication);
        this.securityContextHolderStrategy.setContext(context);
    }

}

Anonymous Authentication

일반적으로 허용되는 일부를 제외한 나머지 접근요소들은 "접근거부" 처리하는게 좋다.
즉, 애플리케이션의 로그인, 로그아웃 등을 제외한 나머지 요소들은 인증을 거쳐야하도록 구성하는게 좋다는 의미다. 이를 위해서는 인증되지 않은 사용자가 접근할 수 있는 항목에 대한 설정이 필요하다.

하지만, 인증된 사용자마다 다른 페이지를 표시해야하는 등의 이유에서 Anonymous Authentication이 필요할 수 있다.
익명으로 인증되었다는 말은 인증되지 않았다는 말과 사실상 동일하지만, 익명 인증은 접근 제어의 측면에서 편의성을 제공한다
또한, SecurityContextHolder에 항상 인증 객체가 존재하기에 접근 추적의 측면에서도 효과적으로 사용할 수 있다

Configuration

익명 인증은 HTTP configuration에서는 자동으로 지원되며 <anonymous> 로 관리할 수 있다.

익명 인증을 위해 3개의 클래스가 활용된다.

  • AnonymousAuthenticationToken
    Authentication의 구현체로 GrantedAuthority 인스턴스를 담고 있다
  • AnonymousAuthenticationProvider
    AnonymousAuthenticationToken의 검증에 사용된다
  • AnonymousAuthenticationFilter
    일반 인증 매너니즘 이후에 동작하며 SecurityContextHolder에 토큰을 저장한다

AuthenticationTrustResolver

ExceptionTranslationFilterAccessDeniedException 예외를 처리할 때 사용하는 인터페이스다.
인증 유형이 익명인 경우, 403 forbidden 대신 AuthenticationEntryPoint를 호출하여 정상적인 인증정보를 얻어오도록 한다. 만약 이러한 작업이 없다면, 사용자는 익명이지만 항상 인증된 것으로 처리되어 정상적인 인증절차를 수행할 수 없게된다

Spring MVC에서의 익명 인증

@GetMapping("/")
public String method(Authentication authentication) {
	if (authentication instanceof AnonymousAuthenticationToken) {
		return "anonymous"; // 호출되지 않음
	} else {
		return "not anonymous";
	}
}

Spring MVC에서 위 방식으로 Authentication을 얻어오면 익명유저인 경우 null이 반환된다
만약 익명 요청에 대해 Authentication을 얻어오고 싶다면 @CurrentSecurityContext를 사용해야한다

@GetMapping("/")
public String method(@CurrentSecurityContext SecurityContext context) {
	return context.getAuthentication().getName();
}

LogOut

스프링 시큐리티는 아무 설정을 하지않아도 GET /logoutPOST /logout에 대한 기본적인 구성을 제공한다.

GET로 로그아웃 요청을 하면 로그아웃 확인 페이지를 표시한다.
POST로 요청을 보내면 필요에따라 CSRF 토큰도 함께 전송한다

설정에서 CSRF 보호를 비활성화 했다면 로그아웃 확인 페이지없이 로그아웃된다.

만약 POST로 로그아웃 요청을 하게되면 LogoutHandler의 아래 연산이 실행된다

  • HTTP session 무효화( SecurityContextLogoutHandler )
  • SecurityContextHolderStrategy, SecurityContextRepository 초기화( SecurityContextLogoutHandler )
  • RememberMe 인증 초기화( TokenRememberMeServices / PersistentTokenRememberMeServices )
  • 저장된 CSRF 토큰 제거( CsrfLogoutHandler )
  • LogoutSuccessEvent 발행( LogoutSuccessEventPublishingLogoutHandler )

작업들이 수행되고나면 LogoutSuccessHandler에 의해 /login?logout로 redirect 된다.


만약 Spring MVC에 별도의 로그아웃 엔드포인트를 만들어놓은 경우, Spring Security가 요청을 처리한 후 MVC에서 작업하므로 이를 허용해줘야한다.

이를 위해서는 아래의 코드를 사용할 수 있다

http
    .authorizeHttpRequests((authorize) -> authorize
        .requestMatchers("/my/success/endpoint").permitAll()
        // ...
    )
    .logout((logout) -> logout.logoutSuccessUrl("/my/success/endpoint"))

하지만, 이 방법은 번거롭게도 두 번의 관련 설정을 필요로한다
따라서 자바 설정을 사용하고 있다면 아래 방법이 더 효과적인다

http
    .authorizeHttpRequests((authorize) -> authorize
        // ...
    )
    .logout((logout) -> logout
        .logoutSuccessUrl("/my/success/endpoint")
        .permitAll()
    )

만약 로그아웃에서 별도의 자원 관리가 필요하다면 LogoutHandler를 추가해주면 된다.

CookieClearingLogoutHandler cookies = new CookieClearingLogoutHandler("our-custom-cookie");
http
    .logout((logout) -> logout.addLogoutHandler(cookies))
   
// 또는

http
    .logout((logout) -> logout.deleteCookies("our-custom-cookie"))

주의사항으로 LogoutHandler에서 예외를 던지면 안된다

0개의 댓글