Spring Boot结合Redis和Token来实现同一个账户同时只能有一处在线的方案
以下具体步骤:
步骤1:添加依赖
首先,在你的pom.xml
中添加必要的依赖。
<dependencies>
<!-- Spring Boot Starter Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Spring Boot Starter Security -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- Spring Boot Starter Data Redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- JWT -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
<!-- Redisson -->
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.13.6</version>
</dependency>
</dependencies>
步骤 2: 配置Redis
在application.properties
中配置Redis连接:
# application.properties
spring.redis.host=localhost
spring.redis.port=6379
# 根据需要添加其他配置,如密码、数据库索引等
步骤 3: 创建JWT工具类
这个类将负责生成和验证JWT。
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
public class JwtTokenUtil {
private String secretKey;
// 构造函数,初始化密钥
public JwtTokenUtil(String secretKey) {
this.secretKey = secretKey;
}
// 生成JWT的方法
public String generateToken(Map<String, Object> claims) {
return Jwts.builder()
.setClaims(claims) // 设置claims
.setExpiration(new Date(System.currentTimeMillis() + 60 * 60 * 1000)) // 设置1小时后过期
.signWith(SignatureAlgorithm.HS512, secretKey) // 使用HS512算法和密钥签名
.compact(); // 生成token
}
// 验证JWT的方法
public boolean validateToken(String token, String username) {
try {
final Claims claims = extractClaims(token);
return claims != null && claims.getSubject().equals(username) && !isTokenExpired(claims.getExpiration());
} catch (Exception e) {
// 如果token无效,会抛出异常
return false;
}
}
// 提取JWT中的Claims的方法
public Claims extractClaims(String token) {
return Jwts.parser()
.setSigningKey(secretKey)
.parseClaimsJws(token)
.getBody();
}
// 辅助方法:检查token是否过期
private boolean isTokenExpired(Date expiration) {
return expiration.before(new Date());
}
}
步骤 4: 配置Spring Security
你需要配置Spring Security以使用JWT进行身份验证,并添加自定义的过滤器来处理JWT。
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private JwtTokenUtil jwtTokenUtil;
// 其他Bean和方法...
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.csrf().disable()
.authorizeRequests()
.antMatchers("/login", "/register").permitAll()
.anyRequest().authenticated()
.and()
.addFilterBefore(new JwtAuthenticationTokenFilter(jwtTokenUtil), UsernamePasswordAuthenticationFilter.class);
// 其他配置...
}
}
步骤 5: 创建JWT认证过滤器
这个过滤器将检查每个请求中的JWT,并设置Spring Security的SecurityContextHolder
。
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenUtil jwtTokenUtil;
public JwtAuthenticationFilter(JwtTokenUtil jwtTokenUtil) {
this.jwtTokenUtil = jwtTokenUtil;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
// 从请求头中提取JWT
String jwt = extractJwtFromRequestHeader(request);
// 验证JWT
if (jwt != null && jwtTokenUtil.validateToken(jwt)) {
// 如果JWT有效,则提取用户名
String username = jwtTokenUtil.extractUsernameFromToken(jwt);
// 创建一个UsernamePasswordAuthenticationToken(虽然这里没有密码,但我们可以将其视为预认证的用户名)
// 注意:在实际应用中,你可能需要自定义Authentication类来存储更多信息
Authentication authentication = new UsernamePasswordAuthenticationToken(username, null, null);
// 设置SecurityContextHolder
SecurityContextHolder.getContext().setAuthentication(authentication);
}
// 继续过滤器链
chain.doFilter(request, response);
}
// 辅助方法:从请求头中提取JWT
private String extractJwtFromRequestHeader(HttpServletRequest request) {
String header = request.getHeader("Authorization");
if (header == null || !header.startsWith("Bearer ")) {
return null;
}
return header.substring(7);
}
}
步骤 6: 创建会话管理器
这个管理器将使用Redis来跟踪用户的会话。
@Component
public class SessionManager {
@Autowired
private RedisTemplate<String, String> redisTemplate;
public void login(String username, String tokenId) {
// 将username和tokenId存储到Redis中
// ...
}
public boolean checkSessionExists(String username, String tokenId) {
// 检查Redis中是否已存在该用户的session
// ...
}
}
步骤 7: 整合JWT认证和会话管理
在JwtAuthenticationTokenFilter
中,除了验证JWT之外,还需要调用SessionManager
来检查会话是否已存在。
// 在doFilterInternal方法中
// ...
String jwt = extractToken(request);
if (jwt != null && jwtTokenUtil.validateToken(jwt)) {
Claims claims = jwtTokenUtil.extractClaims(jwt);
String username = claims.getSubject();
if (!sessionManager.checkSessionExists(username, jwt)) {
// 处理会话冲突
// ...
}
步骤 8: 处理会话冲突
在JwtAuthenticationTokenFilter
的doFilterInternal
方法中,当检测到会话冲突时(即同一个用户名已有一个活动的会话),你可以决定是踢出旧会话还是拒绝新会话。以下是一个简单的示例,展示了如何拒绝新会话并返回一个错误响应给客户端。
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
String jwt = extractTokenFromRequest(request);
if (jwt != null && jwtTokenUtil.validateToken(jwt)) {
Claims claims = jwtTokenUtil.extractClaims(jwt);
String username = claims.getSubject();
// 检查会话是否已存在
if (!sessionManager.checkSessionExists(username, jwt)) {
// 标记当前会话为有效
sessionManager.login(username, jwt);
// 设置认证信息到SecurityContextHolder
// ...
chain.doFilter(request, response);
} else {
// 会话冲突处理
response.setStatus(HttpServletResponse.SC_CONFLICT);
response.getWriter().write("Session conflict: User is already logged in from another session.");
return;
}
}
// 如果没有JWT或JWT无效,可能需要进行其他处理(如重定向到登录页面)
// ...
chain.doFilter(request, response);
}
// 辅助方法,用于从请求中提取JWT
private String extractTokenFromRequest(HttpServletRequest request) {
// 从请求头、Cookie或URL参数中提取JWT
// 示例:从HTTP头Authorization字段中提取
String bearerToken = request.getHeader("Authorization");
if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
步骤 9: 登出处理
当用户登出时,你需要从Redis中删除该用户的会话记录。这可以通过在登出控制器中调用SessionManager
的相应方法来实现。
@RestController
@RequestMapping("/api/auth")
public class AuthController {
@Autowired
private SessionManager sessionManager;
// 其他方法...
@PostMapping("/logout")
public ResponseEntity<?> logout(@RequestHeader(value = "Authorization", required = false) String jwt) {
if (jwt != null && jwt.startsWith("Bearer ")) {
jwt = jwt.substring(7);
Claims claims = jwtTokenUtil.extractClaims(jwt);
String username = claims.getSubject();
// 假设你有一个方法来获取与JWT关联的特定标识符(如tokenId)
// String tokenId = ...; // 从JWT的Claims中获取
// 从Redis中删除会话
sessionManager.logout(username, /* tokenId 如果需要的话 */);
// 返回成功响应
return ResponseEntity.ok("Logged out successfully");
}
// 如果没有提供JWT或其他错误情况
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Invalid JWT for logout");
}
// 注意:上面的logout方法假设你可以从JWT中提取出足够的信息来唯一标识会话。
// 在实际应用中,你可能需要更复杂的逻辑来确保正确地跟踪和识别会话。
}
总结下设计思路
1. JWT生成与验证
-
生成JWT:在用户登录时,服务器验证用户名和密码后,生成一个包含用户信息的JWT,并将其发送给客户端。JWT中通常包含用户ID、过期时间等信息。
-
验证JWT:客户端在每次请求时,将JWT放在请求头中发送给服务器。服务器使用JWT密钥验证JWT的有效性和完整性。
2. Redis会话管理
-
存储Token标识:在服务器验证JWT后,可以提取JWT中的用户ID(或其他唯一标识符),并将这个标识符与当前JWT的Token(或其哈希值)作为键值对存储在Redis中。
-
这个键可以设置为用户的唯一会话标识符,如
user:session:userId
。 -
检查会话唯一性:在每次请求验证JWT后,检查Redis中是否已存在该用户ID的其他Token。如果存在,则说明该用户已在其他地方登录,此时可以选择:
-
强制登出旧会话(删除Redis中的旧Token)。
-
拒绝新会话的请求,提示用户已在其他地方登录。
-
-
更新Redis:如果当前会话是有效的,更新Redis中的Token为最新的,以确保会话的时效性。