Spring Security多端登录互踢功能的Token与Session设计思路

Spring Security 多端登录互踢的 Token 与 Session 设计思路

大家好,今天我们来探讨一下 Spring Security 中多端登录互踢功能的设计思路,主要围绕 Token 和 Session 两种实现方式展开。多端登录互踢,指的是用户在一个设备登录后,如果在另一个设备登录,则前一个设备会被强制下线。这是一个常见的安全需求,可以有效防止账号被盗用。

一、需求分析与设计目标

在开始设计之前,我们需要明确需求和设计目标:

  • 互踢机制: 当用户在新的设备登录时,旧设备上的登录状态失效。
  • 并发控制: 防止多个设备同时登录。
  • 可扩展性: 设计方案应该易于扩展,方便未来添加新的功能或支持更多的认证方式。
  • 性能: 考虑在高并发场景下的性能表现。
  • 安全性: 防止 Token 伪造和 Session 劫持。

二、基于 Session 的实现方案

1. 设计思路

基于 Session 的实现思路相对简单,核心是利用 Spring Session 管理用户会话。我们可以为每个用户维护一个 Session 列表,当用户登录时,将新的 Session ID 添加到列表中。当用户在新的设备登录时,遍历该用户的 Session 列表,使所有旧的 Session 失效。

2. 关键组件

  • SessionRegistry: Spring Security 提供的 SessionRegistry 接口,用于管理用户的 Session 信息。我们可以使用它的实现类 ConcurrentSessionControlStrategy 来实现并发控制。
  • SessionInformation: 存储 Session 的信息,例如 Session ID、最后访问时间等。
  • HttpSessionListener: 监听 Session 的创建和销毁事件,用于更新用户的 Session 列表。

3. 代码实现

3.1 配置 Spring Security

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private UserDetailsService userDetailsService;

    @Autowired
    private SessionRegistry sessionRegistry;

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .authorizeRequests()
                .antMatchers("/login").permitAll()
                .anyRequest().authenticated()
                .and()
            .formLogin()
                .loginPage("/login")
                .defaultSuccessUrl("/home")
                .permitAll()
                .and()
            .logout()
                .permitAll()
                .and()
            .sessionManagement()
                .maximumSessions(1) // 允许每个用户最多拥有一个 Session
                .expiredUrl("/login?expired=true") // Session 过期后跳转的页面
                .sessionRegistry(sessionRegistry)
                .and()
            .csrf().disable(); // 禁用 CSRF,方便测试
    }

    @Bean
    public SessionRegistry sessionRegistry() {
        return new ConcurrentSessionRegistry();
    }

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

说明:

  • maximumSessions(1):配置允许每个用户最多拥有一个 Session,超过这个数量,旧的 Session 会被立即失效。
  • expiredUrl("/login?expired=true"):配置 Session 过期后跳转的页面。
  • sessionRegistry(sessionRegistry):配置 SessionRegistry,用于管理 Session 信息。
  • HttpSessionEventPublisher:用于发布 Session 事件,例如 Session 创建和销毁。

3.2 用户服务(UserDetailsService)

@Service
public class UserDetailsServiceImpl implements UserDetailsService {

    @Autowired
    private UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User user = userRepository.findByUsername(username)
                .orElseThrow(() -> new UsernameNotFoundException("User not found with username: " + username));

        return org.springframework.security.core.userdetails.User.builder()
                .username(user.getUsername())
                .password(user.getPassword())
                .roles("USER") // 示例角色
                .build();
    }
}

3.3 SessionRegistry 的使用

SessionRegistry 接口提供了一些方法来查询和管理 Session 信息,例如:

  • getAllPrincipals():获取所有已认证的用户。
  • getAllSessions(Object principal, boolean includeExpiredSessions):获取指定用户的 Session 列表。
  • getSessionInformation(String sessionId):获取指定 Session ID 的 Session 信息。
  • removeSessionInformation(String sessionId):移除指定 Session ID 的 Session 信息,使 Session 失效。

3.4 自定义 Session 管理(可选)

如果需要更细粒度的控制,可以自定义 Session 管理逻辑。例如,可以创建一个服务来管理用户的 Session 列表:

@Service
public class SessionManagementService {

    @Autowired
    private SessionRegistry sessionRegistry;

    public void invalidateUserSessions(String username) {
        List<SessionInformation> sessions = sessionRegistry.getAllPrincipals().stream()
                .filter(principal -> principal instanceof org.springframework.security.core.userdetails.User)
                .map(principal -> (org.springframework.security.core.userdetails.User) principal)
                .filter(user -> user.getUsername().equals(username))
                .flatMap(user -> sessionRegistry.getAllSessions(user, false).stream())
                .collect(Collectors.toList());

        for (SessionInformation session : sessions) {
            session.expireNow(); // 使 Session 立即失效
        }
    }
}

说明:

  • invalidateUserSessions(String username):使指定用户的所有 Session 失效。

4. 优缺点

优点:

  • 实现简单,易于理解。
  • 利用 Spring Security 提供的 Session 管理机制,减少了开发工作量。

缺点:

  • 依赖 Session,需要维护 Session 状态,增加了服务器的负担。
  • 不适合分布式环境,需要使用 Spring Session 将 Session 存储到共享存储中(例如 Redis)。

三、基于 Token 的实现方案

1. 设计思路

基于 Token 的实现思路是使用 JWT(JSON Web Token)来认证用户。当用户登录成功后,服务器生成一个 JWT 并返回给客户端。客户端在后续的请求中,将 JWT 放在请求头中发送给服务器。服务器验证 JWT 的有效性,如果有效,则允许访问受保护的资源。

为了实现多端登录互踢,我们需要维护一个 Token 列表,记录每个用户的有效 Token。当用户在新的设备登录时,生成新的 Token,并使旧的 Token 失效。

2. 关键组件

  • JWT: 用于认证用户的令牌。
  • TokenStore: 用于存储用户的 Token 列表。可以使用 Redis、数据库等作为存储介质。
  • AuthenticationFilter: 用于验证 JWT 的有效性,并设置 Spring Security 的 Authentication。

3. 代码实现

3.1 添加依赖

<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-api</artifactId>
    <version>0.11.5</version>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-impl</artifactId>
    <version>0.11.5</version>
    <scope>runtime</scope>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-jackson</artifactId>
    <version>0.11.5</version>
    <scope>runtime</scope>
</dependency>

3.2 JWT 工具类

@Component
public class JwtUtil {

    @Value("${jwt.secret}")
    private String secret;

    @Value("${jwt.expiration}")
    private long expiration;

    public String generateToken(String username) {
        Date now = new Date();
        Date expiryDate = new Date(now.getTime() + expiration);

        return Jwts.builder()
                .setSubject(username)
                .setIssuedAt(now)
                .setExpiration(expiryDate)
                .signWith(Keys.hmacShaKeyFor(secret.getBytes()), SignatureAlgorithm.HS512)
                .compact();
    }

    public String getUsernameFromToken(String token) {
        return Jwts.parserBuilder()
                .setSigningKey(Keys.hmacShaKeyFor(secret.getBytes()))
                .build()
                .parseClaimsJws(token)
                .getBody()
                .getSubject();
    }

    public boolean validateToken(String token) {
        try {
            Jwts.parserBuilder()
                    .setSigningKey(Keys.hmacShaKeyFor(secret.getBytes()))
                    .build()
                    .parseClaimsJws(token);
            return true;
        } catch (Exception e) {
            return false;
        }
    }
}

说明:

  • generateToken(String username):生成 JWT。
  • getUsernameFromToken(String token):从 JWT 中获取用户名。
  • validateToken(String token):验证 JWT 的有效性。

3.3 TokenStore 接口与实现

public interface TokenStore {
    void storeToken(String username, String token);
    void removeToken(String username, String token);
    List<String> getTokens(String username);
    void invalidateUserTokens(String username);
}

@Component
public class RedisTokenStore implements TokenStore {

    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    private static final String TOKEN_PREFIX = "token:";

    @Override
    public void storeToken(String username, String token) {
        redisTemplate.opsForList().rightPush(TOKEN_PREFIX + username, token);
    }

    @Override
    public void removeToken(String username, String token) {
        redisTemplate.opsForList().remove(TOKEN_PREFIX + username, 0, token);
    }

    @Override
    public List<String> getTokens(String username) {
        return redisTemplate.opsForList().range(TOKEN_PREFIX + username, 0, -1);
    }

    @Override
    public void invalidateUserTokens(String username) {
        redisTemplate.delete(TOKEN_PREFIX + username);
    }
}

说明:

  • storeToken(String username, String token):存储 Token。
  • removeToken(String username, String token):移除 Token。
  • getTokens(String username):获取用户的 Token 列表。
  • invalidateUserTokens(String username):使用户的所有 Token 失效。

3.4 AuthenticationFilter

@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    @Autowired
    private JwtUtil jwtUtil;

    @Autowired
    private UserDetailsService userDetailsService;

    @Autowired
    private TokenStore tokenStore;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        String authorizationHeader = request.getHeader("Authorization");

        if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) {
            String token = authorizationHeader.substring(7); // 去掉 "Bearer " 前缀

            if (jwtUtil.validateToken(token)) {
                String username = jwtUtil.getUsernameFromToken(token);
                List<String> validTokens = tokenStore.getTokens(username);

                if(validTokens != null && validTokens.contains(token)){ // Check if token is valid
                    UserDetails userDetails = userDetailsService.loadUserByUsername(username);
                    UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
                    authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                    SecurityContextHolder.getContext().setAuthentication(authentication);
                }
            }
        }

        filterChain.doFilter(request, response);
    }
}

说明:

  • 从请求头中获取 JWT。
  • 验证 JWT 的有效性。
  • 从 JWT 中获取用户名,并加载用户信息。
  • 创建 Authentication 对象,并设置到 SecurityContextHolder 中。
  • 增加token校验: 确认token是否在tokenStore中存在,防止过期token仍然能访问系统。

3.5 SecurityConfig 配置

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private JwtAuthenticationFilter jwtAuthenticationFilter;

    @Autowired
    private UserDetailsService userDetailsService;

    @Autowired
    private PasswordEncoder passwordEncoder;

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder);
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .csrf().disable()
            .authorizeRequests()
                .antMatchers("/login").permitAll()
                .anyRequest().authenticated()
                .and()
            .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 使用 JWT,不需要 Session
            .and()
            .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); // 添加 JWT 认证过滤器
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Override
    @Bean
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }
}

说明:

  • sessionCreationPolicy(SessionCreationPolicy.STATELESS):配置 Spring Security 不创建 Session。
  • addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class):添加 JWT 认证过滤器。

3.6 登录接口

@RestController
public class AuthController {

    @Autowired
    private AuthenticationManager authenticationManager;

    @Autowired
    private JwtUtil jwtUtil;

    @Autowired
    private TokenStore tokenStore;

    @PostMapping("/login")
    public ResponseEntity<?> login(@RequestBody LoginRequest loginRequest) {
        Authentication authentication = authenticationManager.authenticate(
                new UsernamePasswordAuthenticationToken(loginRequest.getUsername(), loginRequest.getPassword())
        );

        SecurityContextHolder.getContext().setAuthentication(authentication);

        String username = loginRequest.getUsername();
        String token = jwtUtil.generateToken(username);

        // 使旧的 Token 失效
        tokenStore.invalidateUserTokens(username);

        // 存储新的 Token
        tokenStore.storeToken(username, token);

        return ResponseEntity.ok(new JwtResponse(token));
    }
}

@Data
class LoginRequest {
    private String username;
    private String password;
}

@Data
class JwtResponse {
    private String token;

    public JwtResponse(String token) {
        this.token = token;
    }
}

说明:

  • 验证用户名和密码。
  • 生成 JWT。
  • 使旧的 Token 失效。
  • 存储新的 Token。

4. 优缺点

优点:

  • 无状态,易于扩展,适合分布式环境。
  • 客户端可以缓存 Token,减少服务器的负担。

缺点:

  • 实现相对复杂。
  • Token 失效需要额外的机制来处理。
  • Token 泄露会导致安全问题。

四、两种方案的对比

特性 基于 Session 基于 Token
状态 有状态 无状态
可扩展性 较差,需要共享 Session 良好,易于扩展
性能 较差,需要维护 Session 较好,客户端缓存 Token
安全性 依赖 Session 安全机制 依赖 JWT 安全机制
实现复杂度 简单 复杂
适用场景 单体应用,对性能要求不高 分布式应用,对性能要求高
互踢实现 通过 SessionRegistry 管理 Session,使失效 通过 TokenStore 管理 Token,使失效

五、总结

本文介绍了 Spring Security 中多端登录互踢功能的两种实现方案:基于 Session 和基于 Token。基于 Session 的方案实现简单,但依赖 Session,不适合分布式环境。基于 Token 的方案无状态,易于扩展,适合分布式环境,但实现相对复杂。

选择哪种方案取决于具体的应用场景和需求。如果应用是单体的,对性能要求不高,可以选择基于 Session 的方案。如果应用是分布式的,对性能要求高,可以选择基于 Token 的方案。

无论选择哪种方案,都需要考虑安全性问题,例如防止 Token 伪造和 Session 劫持。希望这篇文章能够帮助大家更好地理解 Spring Security 中多端登录互踢功能的实现思路。

结束语

选择合适的互踢方案需要结合实际场景,平衡安全性、性能和复杂度。理解不同方案的优缺点,才能做出最佳决策。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注