前言
由于刚刚学习了SpringSecurity+JWT的登录认证,就想着配套地实现一下登出逻辑。一开始看教程都说是很简单,直接在配置类声明一下logout()就行了,但是我怎么试都不成,而且有时候还访问不了登出接口。后面想了想,找到的教程都是将用户的认证状态保存在SpringSecurity里,把保存的用户凭证删除就是一个最简单的登出逻辑。但是呢,我使用的是JWT的方式认证,不会在SpringSecurity中保存用户凭证,即服务端是无状态的,所以这个方法就行不通了。于是,我就去找了其他合适的方法。根治JWT的登出方法有黑名单校验、版本号校验、过期时间校验、token副本校验等,我这里采用的是token副本校验的方式
token副本校验
将用户登陆时的JWT在服务端缓存保存一份(如保存在Redis中),每次用户发出请求时都需要校验缓存中是否存在对应副本,没有则拒绝请求,有就正常访问。登出逻辑就是在用户发出登出请求时,将保存的副本删除。
具体实现
登出逻辑需要实现两个接口,LogoutHandler和LogoutSuccessHandler。在我的理解里,前者是实现主要登出逻辑的地方,后者是用作提示和记录日志的地方。
自定义LogoutHandler
@Slf4j
@Component
public class CustomLogoutHandler implements LogoutHandler {
@Autowired
private JwtTokenProvider jwtTokenProvider;
@Autowired
private RedisUtils redisUtils;
@Override
public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
// 从 request 获取 JWT token
String token = getTokenFromRequest(request);
// 校验 token
if (StringUtils.hasText(token) && jwtTokenProvider.validateToken(token)) {
// 从 token 获取 username
String username = jwtTokenProvider.getUsername(token);
log.info("username: {} is offline now", username);
// TODO 删除token逻辑
String key = RedisKeyUtils.initKey(RedisKeyUtils.JWT, username);
redisUtils.del(key);
}
}
// 从请求中读取token
private String getTokenFromRequest(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7, bearerToken.length());
}
return null;
}
}
这里的RedisKeyUtils是我自定义的一个工具类,用于统一Redis中的键。如果只是测试的话,可以自定义token在Redis中的key,我这个工具类写的不是很好,就不提供了。至于RedisUtils一搜一大把,下面是我常用的一个
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit;
/**
*
* @author 王赛超 基于spring和redis的redisTemplate工具类 针对所有的hash 都是以h开头的方法 针对所有的Set 都是以s开头的方法 不含通用方法 针对所有的List 都是以l开头的方法
*/
@Component
@RequiredArgsConstructor
public class RedisUtils {
private final RedisTemplate<String, Object> redisTemplate;
// =============================common============================
/**
* 指定缓存失效时间
*
* @param key
* 键
* @param time
* 时间(秒)
* @return
*/
public boolean expire(String key, long time) {
try {
if (time > 0) {
redisTemplate.expire(key, time, TimeUnit.SECONDS);
}
return true;
} catch (Exception e) {
log.error(key, e);
return false;
}
}
/**
* 根据key 获取过期时间
*
* @param key
* 键 不能为null
* @return 时间(秒) 返回0代表为永久有效
*/
public long getExpire(String key) {
return redisTemplate.getExpire(key, TimeUnit.SECONDS);
}
/**
* 判断key是否存在
*
* @param key
* 键
* @return true 存在 false不存在
*/
public boolean hasKey(String key) {
try {
return redisTemplate.hasKey(key);
} catch (Exception e) {
log.error(key, e);
return false;
}
}
/**
* 删除缓存
*
* @param key
* 可以传一个值 或多个
*/
@SuppressWarnings("unchecked")
public void del(String... key) {
if (key != null && key.length > 0) {
if (key.length == 1) {
redisTemplate.delete(key[0]);
} else {
redisTemplate.delete((Collection<String>) CollectionUtils.arrayToList(key));
}
}
}
// ============================String=============================
/**
* 普通缓存获取
*
* @param key
* 键
* @return 值
*/
public Object get(String key) {
return key == null ? null : redisTemplate.opsForValue().get(key);
}
/**
* 普通缓存放入
*
* @param key
* 键
* @param value
* 值
* @return true成功 false失败
*/
public boolean set(String key, Object value) {
try {
redisTemplate.opsForValue().set(key, value);
return true;
} catch (Exception e) {
log.error(key, e);
return false;
}
}
/**
* 普通缓存放入并设置时间
*
* @param key
* 键
* @param value
* 值
* @param time
* 时间(秒) time要大于0 如果time小于等于0 将设置无限期
* @return true成功 false 失败
*/
public boolean set(String key, Object value, long time) {
try {
if (time > 0) {
redisTemplate.opsForValue().set(key, value, time, TimeUnit.SECONDS);
} else {
set(key, value);
}
return true;
} catch (Exception e) {
log.error(key, e);
return false;
}
}
/**
* 递增 适用场景: https://blog.csdn.net/y_y_y_k_k_k_k/article/details/79218254 高并发生成订单号,秒杀类的业务逻辑等。。
*
* @param key
* 键
* @param delta
* 要增加几(大于0)
* @return
*/
public long incr(String key, long delta) {
if (delta < 0) {
throw new RuntimeException("递增因子必须大于0");
}
return redisTemplate.opsForValue().increment(key, delta);
}
/**
* 递减
*
* @param key
* 键
* @param delta
* 要减少几(小于0)
* @return
*/
public long decr(String key, long delta) {
if (delta < 0) {
throw new RuntimeException("递减因子必须大于0");
}
return redisTemplate.opsForValue().increment(key, -delta);
}
// ================================Map=================================
/**
* HashGet
*
* @param key
* 键 不能为null
* @param item
* 项 不能为null
* @return 值
*/
public Object hget(String key, String item) {
return redisTemplate.opsForHash().get(key, item);
}
/**
* 获取hashKey对应的所有键值
*
* @param key
* 键
* @return 对应的多个键值
*/
public Map<Object, Object> hmget(String key) {
return redisTemplate.opsForHash().entries(key);
}
/**
* HashSet
*
* @param key
* 键
* @param map
* 对应多个键值
* @return true 成功 false 失败
*/
public boolean hmset(String key, Map<String, Object> map) {
try {
redisTemplate.opsForHash().putAll(key, map);
return true;
} catch (Exception e) {
log.error(key, e);
return false;
}
}
/**
* HashSet 并设置时间
*
* @param key
* 键
* @param map
* 对应多个键值
* @param time
* 时间(秒)
* @return true成功 false失败
*/
public boolean hmset(String key, Map<String, Object> map, long time) {
try {
redisTemplate.opsForHash().putAll(key, map);
if (time > 0) {
expire(key, time);
}
return true;
} catch (Exception e) {
log.error(key, e);
return false;
}
}
/**
* 向一张hash表中放入数据,如果不存在将创建
*
* @param key
* 键
* @param item
* 项
* @param value
* 值
* @return true 成功 false失败
*/
public boolean hset(String key, String item, Object value) {
try {
redisTemplate.opsForHash().put(key, item, value);
return true;
} catch (Exception e) {
log.error(key, e);
return false;
}
}
/**
* 向一张hash表中放入数据,如果不存在将创建
*
* @param key
* 键
* @param item
* 项
* @param value
* 值
* @param time
* 时间(秒) 注意:如果已存在的hash表有时间,这里将会替换原有的时间
* @return true 成功 false失败
*/
public boolean hset(String key, String item, Object value, long time) {
try {
redisTemplate.opsForHash().put(key, item, value);
if (time > 0) {
expire(key, time);
}
return true;
} catch (Exception e) {
log.error(key, e);
return false;
}
}
/**
* 删除hash表中的值
*
* @param key
* 键 不能为null
* @param item
* 项 可以使多个 不能为null
*/
public void hdel(String key, Object... item) {
redisTemplate.opsForHash().delete(key, item);
}
/**
* 判断hash表中是否有该项的值
*
* @param key
* 键 不能为null
* @param item
* 项 不能为null
* @return true 存在 false不存在
*/
public boolean hHasKey(String key, String item) {
return redisTemplate.opsForHash().hasKey(key, item);
}
/**
* hash递增 如果不存在,就会创建一个 并把新增后的值返回
*
* @param key
* 键
* @param item
* 项
* @param by
* 要增加几(大于0)
* @return
*/
public double hincr(String key, String item, double by) {
return redisTemplate.opsForHash().increment(key, item, by);
}
/**
* hash递减
*
* @param key
* 键
* @param item
* 项
* @param by
* 要减少记(小于0)
* @return
*/
public double hdecr(String key, String item, double by) {
return redisTemplate.opsForHash().increment(key, item, -by);
}
// ============================set=============================
/**
* 根据key获取Set中的所有值
*
* @param key
* 键
* @return
*/
public Set<Object> sGet(String key) {
try {
return redisTemplate.opsForSet().members(key);
} catch (Exception e) {
log.error(key, e);
return null;
}
}
/**
* 根据value从一个set中查询,是否存在
*
* @param key
* 键
* @param value
* 值
* @return true 存在 false不存在
*/
public boolean sHasKey(String key, Object value) {
try {
return redisTemplate.opsForSet().isMember(key, value);
} catch (Exception e) {
log.error(key, e);
return false;
}
}
/**
* 将数据放入set缓存
*
* @param key
* 键
* @param values
* 值 可以是多个
* @return 成功个数
*/
public long sSet(String key, Object... values) {
try {
return redisTemplate.opsForSet().add(key, values);
} catch (Exception e) {
log.error(key, e);
return 0;
}
}
/**
* 将set数据放入缓存
*
* @param key
* 键
* @param time
* 时间(秒)
* @param values
* 值 可以是多个
* @return 成功个数
*/
public long sSetAndTime(String key, long time, Object... values) {
try {
Long count = redisTemplate.opsForSet().add(key, values);
if (time > 0)
expire(key, time);
return count;
} catch (Exception e) {
log.error(key, e);
return 0;
}
}
/**
* 获取set缓存的长度
*
* @param key
* 键
* @return
*/
public long sGetSetSize(String key) {
try {
return redisTemplate.opsForSet().size(key);
} catch (Exception e) {
log.error(key, e);
return 0;
}
}
/**
* 移除值为value的
*
* @param key
* 键
* @param values
* 值 可以是多个
* @return 移除的个数
*/
public long setRemove(String key, Object... values) {
try {
Long count = redisTemplate.opsForSet().remove(key, values);
return count;
} catch (Exception e) {
log.error(key, e);
return 0;
}
}
// ============================zset=============================
/**
* 根据key获取Set中的所有值
*
* @param key
* 键
* @return
*/
public Set<Object> zSGet(String key) {
try {
return redisTemplate.opsForSet().members(key);
} catch (Exception e) {
log.error(key, e);
return null;
}
}
/**
* 根据value从一个set中查询,是否存在
*
* @param key
* 键
* @param value
* 值
* @return true 存在 false不存在
*/
public boolean zSHasKey(String key, Object value) {
try {
return redisTemplate.opsForSet().isMember(key, value);
} catch (Exception e) {
log.error(key, e);
return false;
}
}
public Boolean zSSet(String key, Object value, double score) {
try {
return redisTemplate.opsForZSet().add(key, value, 2);
} catch (Exception e) {
log.error(key, e);
return false;
}
}
/**
* 将set数据放入缓存
*
* @param key
* 键
* @param time
* 时间(秒)
* @param values
* 值 可以是多个
* @return 成功个数
*/
public long zSSetAndTime(String key, long time, Object... values) {
try {
Long count = redisTemplate.opsForSet().add(key, values);
if (time > 0)
expire(key, time);
return count;
} catch (Exception e) {
log.error(key, e);
return 0;
}
}
/**
* 获取set缓存的长度
*
* @param key
* 键
* @return
*/
public long zSGetSetSize(String key) {
try {
return redisTemplate.opsForSet().size(key);
} catch (Exception e) {
log.error(key, e);
return 0;
}
}
/**
* 移除值为value的
*
* @param key
* 键
* @param values
* 值 可以是多个
* @return 移除的个数
*/
public long zSetRemove(String key, Object... values) {
try {
Long count = redisTemplate.opsForSet().remove(key, values);
return count;
} catch (Exception e) {
log.error(key, e);
return 0;
}
}
// ===============================list=================================
/**
* 获取list缓存的内容
*
* @取出来的元素 总数 end-start+1
*
* @param key
* 键
* @param start
* 开始 0 是第一个元素
* @param end
* 结束 -1代表所有值
* @return
*/
public List<Object> lGet(String key, long start, long end) {
try {
return redisTemplate.opsForList().range(key, start, end);
} catch (Exception e) {
log.error(key, e);
return null;
}
}
/**
* 获取list缓存的长度
*
* @param key
* 键
* @return
*/
public long lGetListSize(String key) {
try {
return redisTemplate.opsForList().size(key);
} catch (Exception e) {
log.error(key, e);
return 0;
}
}
/**
* 通过索引 获取list中的值
*
* @param key
* 键
* @param index
* 索引 index>=0时, 0 表头,1 第二个元素,依次类推;index<0时,-1,表尾,-2倒数第二个元素,依次类推
* @return
*/
public Object lGetIndex(String key, long index) {
try {
return redisTemplate.opsForList().index(key, index);
} catch (Exception e) {
log.error(key, e);
return null;
}
}
/**
* 将list放入缓存
*
* @param key
* 键
* @param value
* 值
* @return
*/
public boolean lSet(String key, Object value) {
try {
redisTemplate.opsForList().rightPush(key, value);
return true;
} catch (Exception e) {
log.error(key, e);
return false;
}
}
/**
* 将list放入缓存
*
* @param key
* 键
* @param value
* 值
* @param time
* 时间(秒)
* @return
*/
public boolean lSet(String key, Object value, long time) {
try {
redisTemplate.opsForList().rightPush(key, value);
if (time > 0)
expire(key, time);
return true;
} catch (Exception e) {
log.error(key, e);
return false;
}
}
/**
* 将list放入缓存
*
* @param key
* 键
* @param value
* 值
* @return
*/
public boolean lSet(String key, List<Object> value) {
try {
redisTemplate.opsForList().rightPushAll(key, value);
return true;
} catch (Exception e) {
log.error(key, e);
return false;
}
}
/**
* 将list放入缓存
*
* @param key
* 键
* @param value
* 值
* @param time
* 时间(秒)
* @return
*/
public boolean lSet(String key, List<Object> value, long time) {
try {
redisTemplate.opsForList().rightPushAll(key, value);
if (time > 0)
expire(key, time);
return true;
} catch (Exception e) {
log.error(key, e);
return false;
}
}
/**
* 根据索引修改list中的某条数据
*
* @param key
* 键
* @param index
* 索引
* @param value
* 值
* @return
*/
public boolean lUpdateIndex(String key, long index, Object value) {
try {
redisTemplate.opsForList().set(key, index, value);
return true;
} catch (Exception e) {
log.error(key, e);
return false;
}
}
/**
* 移除N个值为value
*
* @param key
* 键
* @param count
* 移除多少个
* @param value
* 值
* @return 移除的个数
*/
public long lRemove(String key, long count, Object value) {
try {
Long remove = redisTemplate.opsForList().remove(key, count, value);
return remove;
} catch (Exception e) {
log.error(key, e);
return 0;
}
}
}
自定义LogoutSuccessHandler
@Component
public class CustomLogoutSuccessHandler implements LogoutSuccessHandler {
@Override
public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
// 这里可以实现记录日志的逻辑
responseJsonWriter(response, Result.success("退出成功", null));
}
private static void responseJsonWriter(HttpServletResponse response, Result result) throws IOException {
response.setStatus(HttpServletResponse.SC_OK);
response.setCharacterEncoding("utf-8");
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
ObjectMapper objectMapper = new ObjectMapper();
String resBody = objectMapper.writeValueAsString(result);
PrintWriter printWriter = response.getWriter();
printWriter.print(resBody);
printWriter.flush();
printWriter.close();
}
}
配置自定义登出逻辑
// 配置自定义登出逻辑
.logout(logout -> logout
.logoutUrl("/logout")
.addLogoutHandler(logoutHandler)
.logoutSuccessHandler(logoutSuccessHandler)
)
完整代码
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Autowired
private JwtAuthenticationFilter jwtAuthenticationFilter;
@Autowired
private CustomUserDetailsService userDetailsService;
@Autowired
private AccessDeniedHandlerImpl accessDeniedHandler;
@Autowired
private AuthenticationEntryPointImpl authenticationEntryPoint;
@Autowired
private LogoutHandler logoutHandler;
@Autowired
private LogoutSuccessHandler logoutSuccessHandler;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
// 登录与授权
http
.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(authz -> authz
.requestMatchers("/login").permitAll() // 开放登录接口
.requestMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.userDetailsService(userDetailsService)
.formLogin(form -> form.disable()) // 禁用默认表单
// 配置自定义登出逻辑
.logout(logout -> logout
.logoutUrl("/logout")
.addLogoutHandler(logoutHandler)
.logoutSuccessHandler(logoutSuccessHandler)
)
// 自定义异常处理
.exceptionHandling(e -> e
.authenticationEntryPoint(authenticationEntryPoint)
.accessDeniedHandler(accessDeniedHandler)
);
return http.build();
}
@Bean
public AuthenticationManager authenticationManager(
UserDetailsService userDetailsService,
PasswordEncoder passwordEncoder) {
DaoAuthenticationProvider authenticationProvider = new DaoAuthenticationProvider();
authenticationProvider.setUserDetailsService(userDetailsService);
authenticationProvider.setPasswordEncoder(passwordEncoder);
return new ProviderManager(authenticationProvider);
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
这里用到其他组件在我前面的文章有提到过,有需要的可以去看下这篇文章
Springboot3+SpringSecurity+JWT+MySQL实现前后端分离的登录认证与授权-CSDN博客
保存token副本
完成了登出逻辑之后,我们还需要在登陆时保存一下token副本,以完成后续用户请求校验的及登出。
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, UserInfo> implements IUserService {
@Autowired
private AuthenticationManager authenticationManager;
@Autowired
private JwtTokenProvider jwtTokenProvider;
@Autowired
private RedisUtils redisUtils;
@Override
public String login(LoginDTO dto) {
// 登录授权
Authentication authenticate = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
dto.getUsername(),
dto.getPassword()
)
);
// 将登录用户信息交给SpringSecurity管理
SecurityContextHolder.getContext().setAuthentication(authenticate);
// 利用用户授权信息生成JWT令牌
String token = jwtTokenProvider.generateToken(authenticate);
// TODO 存储token
String key = RedisKeyUtils.initKey(RedisKeyUtils.JWT, dto.getUsername());
// 设置默认有效期为7天
redisUtils.set(key,token,60*60*24*7);
return token;
}
使用postman测试

至此,我们就实现了SpringSecurity + JWT的登出逻辑。
总结
要实现token副本校验的登出逻辑,需要:
-
保存一份登陆时的token副本
-
实现
LogoutHandler,在用户发出登出请求时清token副本 -
实现
LogoutSuccessHandler,记录用户登出日志 -
在配置类中配置自定义的登出逻辑
附加:请求时校验token副本
// TODO 判断缓存中是否存在对应token
String key = RedisKeyUtils.initKey(RedisKeyUtils.JWT, username);
String tokenInCache = (String) redisUtils.get(key);
// 如果两个token不相同,则token无效
if (!tokenInCache.equals(token)){
throw new RuntimeException("token已失效");
}
完整代码
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Autowired
private JwtTokenProvider jwtTokenProvider;
@Autowired
private UserDetailsService userDetailsService;
@Autowired
private RedisUtils redisUtils;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
// 从 request 获取 JWT token
String token = getTokenFromRequest(request);
// 校验 token
if(StringUtils.hasText(token) && jwtTokenProvider.validateToken(token)){
// 从 token 获取 username
String username = jwtTokenProvider.getUsername(token);
// TODO 判断缓存中是否存在对应token
String key = RedisKeyUtils.initKey(RedisKeyUtils.JWT, username);
String tokenInCache = (String) redisUtils.get(key);
// 如果两个token不相同,则token无效
if (!tokenInCache.equals(token)){
throw new RuntimeException("token已失效");
}
// 加载与 token 关联的用户
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(
userDetails,
null,
userDetails.getAuthorities()
);
authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
}
filterChain.doFilter(request, response);
}
private String getTokenFromRequest(HttpServletRequest request){
String bearerToken = request.getHeader("Authorization");
if(StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")){
return bearerToken.substring(7, bearerToken.length());
}
return null;
}
}
这个过滤器所处的生命周期和LogoutHandler以及LogoutSuccessHandler不同,如果执行了LogoutSuccessHandler,那么此次请求就已结束,该过滤器就不会执行。
-
参考文章:jwt退出登录的解决方案-CSDN博客

2575

被折叠的 条评论
为什么被折叠?



