Spring Security自定义注销处理程序

2023/05/17

1. 概述

Spring Security框架为身份验证提供了非常灵活和强大的支持。与用户标识一起,我们通常需要处理用户注销事件,并且在某些情况下添加一些自定义注销行为。其中一个可能的用例是使用户缓存无效或关闭经过身份验证的会话。

为此,Spring提供了LogoutHandler接口,在本教程中,我们将了解如何实现我们自己的自定义注销处理程序。

2. 处理注销请求

每个登录用户的Web应用程序都必须在某一天将其注销。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接口实践

现在,让我们创建一个简单的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来存储User:

@Service
public class UserCache {
    private final ConcurrentMap<String, User> store = new ConcurrentHashMap<>(256);
    
    @PersistenceContext
    private EntityManager entityManager;
}

使用此服务,我们可以通过用户名(登录名)从数据库中检索用户并将其存储在Map内部:

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

此外,可以将用户从Map从移除。正如我们稍后将看到的,这将是我们将从注销处理程序调用的主要操作:

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

为了检索用户数据和language信息,我们将使用标准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 Web安全配置

我们将在应用程序中关注两个简单的操作-登录和注销。首先,我们需要设置MVC配置类以允许用户使用基本HTTP认证进行身份验证:

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

从上面的配置中需要注意的重要部分是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. 集成测试

现在让我们测试一下功能。首先,我们需要验证缓存是否按预期工作-也就是说,它将经过的授权用户加载到其Map存储中

@ExtendWith(SpringExtension.class)
@SpringBootTest(classes = { CustomLogoutApplication.class }, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@SqlGroup({ 
      @Sql(value = "classpath:customlogouthandler/before.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD), 
      @Sql(value = "classpath:customlogouthandler/after.sql", executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD) })
@TestPropertySource(locations="classpath:customlogouthandler/application.properties")
class CustomLogoutHandlerIntegrationTest {
    @Autowired
    private TestRestTemplate restTemplate;

    @Autowired
    UserCache userCache;

    @LocalServerPort
    private int port;

    @Test
    void whenLogin_thenUseUserCache() {
        // User cache should be empty on start
        assertThat(userCache.size()).isEqualTo(0);

        // Request using first login
        ResponseEntity<String> response = restTemplate.withBasicAuth("user", "pass")
                .getForEntity(getLanguageUrl(), String.class);

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

        // User cache must contain the user
        assertThat(userCache.size()).isEqualTo(1);

        // Getting the session cookie
        HttpHeaders requestHeaders = new HttpHeaders();
        requestHeaders.add("Cookie", response.getHeaders()
                .getFirst(HttpHeaders.SET_COOKIE));

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

        // Logging out using the session cookies
        response = restTemplate.exchange(getLogoutUrl(), HttpMethod.GET, new HttpEntity<String>(requestHeaders), String.class);
        assertThat(response.getStatusCode()
                .value()).isEqualTo(200);
    }

    private String getLanguageUrl() {
        return "http://localhost:" + port + "/user/language";
    }

    private String getLogoutUrl() {
        return "http://localhost:" + port + "/user/logout";
    }
}

让我们逐一介绍这些步骤,以了解我们做了什么:

  • 首先,我们检查缓存是否为空
  • 接下来,我们通过withBasicAuth方法对用户进行身份验证
  • 现在我们可以验证检索到的用户数据和language值
  • 因此,我们可以验证用户现在必须在缓存中
  • 同样,我们通过访问language端点并使用会话cookie来检查用户数据
  • 最后,我们验证注销用户

在第二个测试中,我们将验证注销时用户缓存是否已清除。这是我们的注销处理程序将被调用的时刻:

@Test
void whenLogout_thenCacheIsEmpty() {
    // User cache should be empty on start
    assertThat(userCache.size()).isEqualTo(0);

    // Request using first login
    ResponseEntity<String> response = restTemplate.withBasicAuth("user", "pass")
          .getForEntity(getLanguageUrl(), String.class);

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

    // User cache must contain the user
    assertThat(userCache.size()).isEqualTo(1);

    // Getting the session cookie
    HttpHeaders requestHeaders = new HttpHeaders();
    requestHeaders.add("Cookie", response.getHeaders()
          .getFirst(HttpHeaders.SET_COOKIE));

    // Logging out using the session cookies
    response = restTemplate.exchange(getLogoutUrl(), HttpMethod.GET, new HttpEntity<String>(requestHeaders), String.class);
    assertThat(response.getStatusCode()
          .value()).isEqualTo(200);

    // User cache must be empty now
    // this is the reaction on custom logout filter execution
    assertThat(userCache.size()).isEqualTo(0);

    // Assert unauthorized request
    response = restTemplate.exchange(getLanguageUrl(), HttpMethod.GET, new HttpEntity<String>(requestHeaders), String.class);
    assertThat(response.getStatusCode()
          .value()).isEqualTo(401);
}
  • 和以前一样,我们首先检查缓存是否为空
  • 然后我们对用户进行身份验证并检查用户是否在缓存中
  • 接下来,我们执行注销并检查用户是否已从缓存中删除
  • 最后,尝试访问language端点的结果是401响应码

5. 总结

在本教程中,我们学习了如何使用Spring的LogoutHandler接口实现自定义注销处理程序,用于从用户缓存中逐出用户。

与往常一样,本教程的完整源代码可在GitHub上获得。

Show Disqus Comments

Post Directory

扫码关注公众号:Taketoday
发送 290992
即可立即永久解锁本站全部文章