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 中多端登录互踢功能的实现思路。
结束语
选择合适的互踢方案需要结合实际场景,平衡安全性、性能和复杂度。理解不同方案的优缺点,才能做出最佳决策。