1. 개요

Spring Security 프레임워크는 매우 유연하고 강력한 인증 지원을 제공합니다 . 사용자 식별과 함께 일반적으로 사용자 로그아웃 이벤트를 처리하고 경우에 따라 일부 사용자 지정 로그아웃 동작을 추가하려고 합니다. 이러한 사용 사례 중 하나는 사용자 캐시를 무효화하거나 인증된 세션을 닫는 것입니다.

바로 이러한 목적을 위해 Spring은 LogoutHandler 인터페이스를 제공하며 이 예제에서는 사용자 정의 로그아웃 핸들러를 구현하는 방법을 살펴보겠습니다.

2. 로그아웃 요청 처리

사용자를 로그인하는 모든 웹 애플리케이션은 언젠가는 로그아웃해야 합니다. Spring Security 핸들러는 일반적으로 로그아웃 프로세스를 제어합니다 . 기본적으로 로그아웃 처리에는 두 가지 방법이 있습니다. 앞으로 살펴보겠지만 그 중 하나는 LogoutHandler 인터페이스를 구현하는 것입니다.

2.1. LogoutHandler 인터페이스

LogoutHandler 인터페이스 의 정의는 다음과 같습니다.

public interface LogoutHandler {
    void logout(HttpServletRequest request, HttpServletResponse response,Authentication authentication);
}

애플리케이션에 필요한 만큼 로그아웃 핸들러를 추가할 수 있습니다. 구현을 위한 한 가지 요구 사항은 예외가 발생하지 않는다는 것입니다 . 핸들러 작업이 로그아웃 시 애플리케이션 상태를 중단해서는 안 되기 때문입니다.

예를 들어 처리기 중 하나가 일부 캐시 정리를 수행할 수 있으며 해당 메서드는 성공적으로 완료되어야 합니다. 사용방법(예제) 예제에서는 이 사용 사례를 정확히 보여줍니다.

2.2. LogoutSuccessHandler 인터페이스

반면에 예외를 사용하여 사용자 로그아웃 전략을 제어할 수 있습니다. 이를 위해 LogoutSuccessHandler 인터페이스와 onLogoutSuccess 메서드가 있습니다. 이 메서드는 사용자 리디렉션을 적절한 대상으로 설정하기 위해 예외를 발생시킬 수 있습니다.

또한 LogoutSuccessHandler 유형 을 사용하는 경우 여러 핸들러를 추가할 수 없으므로 애플리케이션에 가능한 구현은 하나만 있습니다. 일반적으로 말하자면 로그아웃 전략의 마지막 지점인 것으로 밝혀졌다.

3. LogoutHandler 인터페이스 실습

이제 간단한 웹 애플리케이션을 만들어 로그아웃 처리 프로세스를 시연해 보겠습니다. 데이터베이스에서 불필요한 적중을 피하기 위해 사용자 데이터를 검색하는 몇 가지 간단한 캐싱 논리를 구현할 것입니다.

샘플 애플리케이션에 대한 데이터베이스 연결 속성이 포함된 application.properties 파일 부터 시작하겠습니다 .

spring.datasource.url=jdbc:postgresql://localhost:5432/test
spring.datasource.username=test
spring.datasource.password=test
spring.jpa.hibernate.ddl-auto=create

3.1. 웹 애플리케이션 설정

다음 으로 로그인 목적 및 데이터 검색에 사용할 간단한 사용자 엔터티를 추가합니다. 보시다시피 User 클래스는 데이터베이스의 users 테이블에 매핑됩니다 .

@Entity
@Table(name = "users")
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer id;

    @Column(unique = true)
    private String login;

    private String password;

    private String role;

    private String language;

    // standard setters and getters
}

애플리케이션의 캐싱 목적을 위해 ConcurrentHashMap 을 내부적으로 사용하여 사용자를 저장하는 캐시 서비스를 구현합니다.

@Service
public class UserCache {
    @PersistenceContext
    private EntityManager entityManager;

    private final ConcurrentMap<String, User> store = new ConcurrentHashMap<>(256);
}

이 서비스를 사용하여 데이터베이스에서 사용자 이름(로그인)으로 사용자를 검색하고 Map에 내부적으로 저장할 수 있습니다.

public User getByUserName(String userName) {
    return store.computeIfAbsent(userName, k -> 
      entityManager.createQuery("from User where login=:login", User.class)
        .setParameter("login", k)
        .getSingleResult());
}

또한 상점에서 사용자를 퇴거시킬 수 있습니다. 나중에 살펴보겠지만 이것은 로그아웃 처리기에서 호출할 기본 작업이 됩니다.

public void evictUser(String userName) {
    store.remove(userName);
}

사용자 데이터 및 언어 정보를 검색하기 위해 표준 Spring Controller 를 사용합니다 .

@Controller
@RequestMapping(path = "/user")
public class UserController {

    private final UserCache userCache;

    public UserController(UserCache userCache) {
        this.userCache = userCache;
    }

    @GetMapping(path = "/language")
    @ResponseBody
    public String getLanguage() {
        String userName = UserUtils.getAuthenticatedUserName();
        User user = userCache.getByUserName(userName);
        return user.getLanguage();
    }
}

3.2. 웹 Security 구성

애플리케이션에서 초점을 맞출 두 가지 간단한 작업인 로그인과 로그아웃이 있습니다. 먼저 사용자가 기본 HTTP 인증을 사용하여 인증할 수 있도록 MVC 구성 클래스를 설정해야 합니다 .

@Configuration
@EnableWebSecurity
public class MvcConfiguration {

    @Autowired
    private CustomLogoutHandler logoutHandler;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.httpBasic()
            .and()
                .authorizeRequests()
                    .antMatchers(HttpMethod.GET, "/user/**")
                    .hasRole("USER")
            .and()
                .logout()
                    .logoutUrl("/user/logout")
                    .addLogoutHandler(logoutHandler)
                    .logoutSuccessHandler(new HttpStatusReturningLogoutSuccessHandler(HttpStatus.OK))
                    .permitAll()
            .and()
                .csrf()
                    .disable()
                .formLogin()
                    .disable();
        return http.build();
    }

    // further configuration
}

위의 구성에서 주목해야 할 중요한 부분은 addLogoutHandler 메소드입니다. 로그아웃 처리가 끝나면 CustomLogoutHandler 를 전달하고 트리거합니다 . 나머지 설정은 HTTP 기본 인증을 미세 조정합니다.

3.3. 사용자 지정 로그아웃 처리기

마지막으로 가장 중요한 것은 필요한 사용자 캐시 정리를 처리하는 사용자 정의 로그아웃 처리기를 작성하는 것입니다.

@Service
public class CustomLogoutHandler implements LogoutHandler {

    private final UserCache userCache;

    public CustomLogoutHandler(UserCache userCache) {
        this.userCache = userCache;
    }

    @Override
    public void logout(HttpServletRequest request, HttpServletResponse response, 
      Authentication authentication) {
        String userName = UserUtils.getAuthenticatedUserName();
        userCache.evictUser(userName);
    }
}

보시다시피 로그아웃 방법을 재정의하고 사용자 캐시에서 지정된 사용자를 간단히 제거합니다.

4. 통합 테스팅

이제 기능을 테스트해 보겠습니다. 먼저 캐시가 의도한 대로 작동하는지 확인해야 합니다. 즉 , 승인된 사용자를 내부 저장소로 로드합니다 .

@Test
public void whenLogin_thenUseUserCache() {
    assertThat(userCache.size()).isEqualTo(0);

    ResponseEntity<String> response = restTemplate.withBasicAuth("user", "pass")
        .getForEntity(getLanguageUrl(), String.class);

    assertThat(response.getBody()).contains("english");

    assertThat(userCache.size()).isEqualTo(1);

    HttpHeaders requestHeaders = new HttpHeaders();
    requestHeaders.add("Cookie", response.getHeaders()
        .getFirst(HttpHeaders.SET_COOKIE));

    response = restTemplate.exchange(getLanguageUrl(), HttpMethod.GET, 
      new HttpEntity<String>(requestHeaders), String.class);
    assertThat(response.getBody()).contains("english");

    response = restTemplate.exchange(getLogoutUrl(), HttpMethod.GET, 
      new HttpEntity<String>(requestHeaders), String.class);
    assertThat(response.getStatusCode()
        .value()).isEqualTo(200);
}

우리가 수행한 작업을 이해하기 위해 단계를 분해해 보겠습니다.

  • 먼저 캐시가 비어 있는지 확인합니다.
  • 다음으로 withBasicAuth 메소드 를 통해 사용자를 인증합니다.
  • 이제 검색된 사용자 데이터 및 언어 값을 확인할 수 있습니다.
  • 결과적으로 사용자가 이제 캐시에 있어야 함을 확인할 수 있습니다.
  • 다시 말하지만 언어 Endpoints을 누르고 세션 쿠키를 사용하여 사용자 데이터를 확인합니다.
  • 마지막으로 사용자 로그아웃을 확인합니다.

두 번째 테스트에서는 로그아웃할 때 사용자 캐시가 정리 되는지 확인합니다 . 로그아웃 처리기가 호출되는 순간입니다.

@Test
public void whenLogout_thenCacheIsEmpty() {
    assertThat(userCache.size()).isEqualTo(0);

    ResponseEntity<String> response = restTemplate.withBasicAuth("user", "pass")
        .getForEntity(getLanguageUrl(), String.class);

    assertThat(response.getBody()).contains("english");

    assertThat(userCache.size()).isEqualTo(1);

    HttpHeaders requestHeaders = new HttpHeaders();
    requestHeaders.add("Cookie", response.getHeaders()
        .getFirst(HttpHeaders.SET_COOKIE));

    response = restTemplate.exchange(getLogoutUrl(), HttpMethod.GET, 
      new HttpEntity<String>(requestHeaders), String.class);
    assertThat(response.getStatusCode()
        .value()).isEqualTo(200);

    assertThat(userCache.size()).isEqualTo(0);

    response = restTemplate.exchange(getLanguageUrl(), HttpMethod.GET, 
      new HttpEntity<String>(requestHeaders), String.class);
    assertThat(response.getStatusCode()
        .value()).isEqualTo(401);
}

다시, 단계별로:

  • 이전과 마찬가지로 캐시가 비어 있는지 확인하는 것으로 시작합니다.
  • 그런 다음 사용자를 인증하고 사용자가 캐시에 있는지 확인합니다.
  • 다음으로 로그아웃을 수행하고 사용자가 캐시에서 제거되었는지 확인합니다.
  • 마지막으로 언어 Endpoints에 도달하려는 시도는 401 HTTP 무단 응답 코드로 나타납니다.

5. 결론

이 예제에서는 Spring의 LogoutHandler 인터페이스 를 사용하여 사용자 캐시에서 사용자를 제거하기 위한 사용자 정의 로그아웃 핸들러를 구현하는 방법을 배웠습니다 .

언제나처럼 기사의 전체 소스 코드는 GitHub에서 확인할 수 있습니다 .

Security footer banner