1. 概要

Spring Securityフレームワークは、認証に対して非常に柔軟で強力なサポートを提供します。 通常、ユーザーIDとともに、ユーザーログアウトイベントを処理し、場合によっては、カスタムログアウト動作を追加する必要があります。 そのようなユースケースの1つは、ユーザーキャッシュを無効にしたり、認証されたセッションを閉じたりする場合です。

この目的のために、Springは LogoutHandler インターフェースを提供します。このチュートリアルでは、独自のカスタムログアウトハンドラーを実装する方法を見ていきます。

2. ログアウト要求の処理

ユーザーをログインさせるすべてのWebアプリケーションは、いつかユーザーをログアウトする必要があります。 Spring Securityハンドラーは通常、ログアウトプロセスを制御します。 基本的に、ログアウトを処理する方法は2つあります。 これから説明するように、そのうちの1つはLogoutHandlerインターフェースを実装しています。

2.1. LogoutHandlerインターフェース

LogoutHandlerインターフェースの定義は次のとおりです。

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

アプリケーションに必要な数のログアウトハンドラーを追加することができます。 実装の1つの要件は、例外がスローされないことです。 これは、ハンドラーアクションがログアウト時にアプリケーションの状態を壊してはならないためです。

たとえば、ハンドラーの1つがキャッシュのクリーンアップを実行し、そのメソッドが正常に完了する必要があります。 チュートリアルの例では、まさにこのユースケースを示します。

2.2. LogoutSuccessHandlerインターフェース

一方、例外を使用してユーザーのログアウト戦略を制御することもできます。 このために、LogoutSuccessHandlerインターフェイスとonLogoutSuccessメソッドがあります。 このメソッドは、ユーザーのリダイレクトを適切な宛先に設定するための例外を発生させる場合があります。

さらに、 LogoutSuccessHandlerタイプを使用する場合、複数のハンドラーを追加することはできないため、アプリケーションの実装は1つしかありません。 一般的に言って、それがログアウト戦略の最後のポイントであることがわかります。

3. LogoutHandlerインターフェースの実際

それでは、ログアウト処理プロセスを示す簡単なWebアプリケーションを作成しましょう。 データベースへの不要なヒットを回避するために、ユーザーデータを取得するためのいくつかの単純なキャッシュロジックを実装します。

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. Webアプリケーションのセットアップ

次に、ログインとデータ取得に使用する単純なUserエンティティを追加します。 ご覧のとおり、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);
}

このサービスを使用すると、データベースからユーザー名(ログイン)でユーザーを取得し、マップの内部に保存できます。

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
@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. Webセキュリティ構成

アプリケーションで焦点を当てる2つの簡単なアクション、ログインとログアウトがあります。 まず、ユーザーが基本HTTP認証を使用して認証できるようにMVC構成クラスを設定する必要があります。

@Configuration
@EnableWebSecurity
public class MvcConfiguration extends WebSecurityConfigurerAdapter {

    @Autowired
    private CustomLogoutHandler logoutHandler;

    @Override
    protected void configure(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();
    }

    // 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);
    }
}

ご覧のとおり、 logout メソッドをオーバーライドし、指定されたユーザーをユーザーキャッシュから削除します。

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メソッドを使用してユーザーを認証します
  • これで、取得したユーザーデータと言語値を確認できます
  • したがって、ユーザーがキャッシュ内にいる必要があることを確認できます
  • ここでも、言語エンドポイントにアクセスし、セッションCookieを使用して、ユーザーデータを確認します。
  • 最後に、ユーザーのログアウトを確認します

2番目のテストでは、ログアウト時にユーザーキャッシュがクリーンアップされることを確認します。 これは、ログアウトハンドラーが呼び出される瞬間です。

@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);
}

繰り返しますが、ステップバイステップ:

  • 前と同じように、キャッシュが空であることを確認することから始めます
  • 次に、ユーザーを認証し、ユーザーがキャッシュにあることを確認します
  • 次に、ログアウトを実行し、ユーザーがキャッシュから削除されたことを確認します
  • 最後に、言語エンドポイントをヒットしようとすると、401HTTP不正応答コードが発生します

5. 結論

このチュートリアルでは、SpringのLogoutHandlerインターフェイスを使用してユーザーキャッシュからユーザーを削除するためのカスタムログアウトハンドラーを実装する方法を学びました。

いつものように、記事の完全なソースコードは、GitHubから入手できます。