本篇文章主要是 springboot 整合 jwt 实现用户的登录认证与授权,并且还有单用户共享 token、单设备登录、多设备登录、同端互斥登录与临时 token 等。git 地址:点击前往
阅读需要熟悉 spring security 与 jwt 相关知识,也可前往链接学习:
Spring Security入门
十分钟学会JWT
一、准备阶段
1、表
1.用户表
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
-- ----------------------------
-- Table structure for sys_user
-- ----------------------------
DROP TABLE IF EXISTS `sys_user`;
CREATE TABLE `sys_user` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`account` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '账号',
`user_name` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '用户名',
`password` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '用户密码',
`last_login_time` datetime(0) NULL DEFAULT NULL COMMENT '上一次登录时间',
`enabled` tinyint(1) NULL DEFAULT 1 COMMENT '账号是否可用。默认为1(可用)',
`account_not_expired` tinyint(1) NULL DEFAULT 1 COMMENT '是否过期。默认为1(没有过期)',
`account_not_locked` tinyint(1) NULL DEFAULT 1 COMMENT '账号是否锁定。默认为1(没有锁定)',
`credentials_not_expired` tinyint(1) NULL DEFAULT 1 COMMENT '证书(密码)是否过期。默认为1(没有过期)',
`create_time` datetime(0) NULL DEFAULT NULL COMMENT '创建时间',
`update_time` datetime(0) NULL DEFAULT NULL COMMENT '修改时间',
`deleted` tinyint(1) NULL DEFAULT 0,
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 3 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '用户表' ROW_FORMAT = Dynamic;
-- ----------------------------
-- Records of sys_user
-- ----------------------------
INSERT INTO `sys_user` VALUES (1, 'zhangsan', '张三', '$2a$10$47lsFAUlWixWG17Ca3M/r.EPJVIb7Tv26ZaxhzqN65nXVcAhHQM4i', '2019-09-04 20:25:36', 1, 1, 1, 1, '2019-08-29 06:28:36', '2019-09-04 20:25:36', 0);
INSERT INTO `sys_user` VALUES (2, 'lisi', '李四', '$2a$10$uSLAeON6HWrPbPCtyqPRj.hvZfeM.tiVDZm24/gRqm4opVze1cVvC', '2019-09-05 00:07:12', 1, 1, 1, 1, '2019-08-29 06:29:24', '2019-09-05 00:07:12', 0);
SET FOREIGN_KEY_CHECKS = 1;
2.角色表
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
-- ----------------------------
-- Table structure for sys_role
-- ----------------------------
DROP TABLE IF EXISTS `sys_role`;
CREATE TABLE `sys_role` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键id',
`role_code` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '角色代码',
`role_name` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '角色名',
`role_description` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '角色说明',
`create_time` datetime(0) NULL DEFAULT NULL,
`update_time` datetime(0) NULL DEFAULT NULL,
`deleted` tinyint(1) NULL DEFAULT 0,
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 3 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '用户角色表' ROW_FORMAT = Dynamic;
-- ----------------------------
-- Records of sys_role
-- ----------------------------
INSERT INTO `sys_role` VALUES (1, 'admin', '管理员', '管理员,拥有所有权限', '2023-12-11 10:15:29', NULL, 0);
INSERT INTO `sys_role` VALUES (2, 'user', '普通用户', '普通用户,拥有部分权限', '2023-12-11 10:15:31', NULL, 0);
SET FOREIGN_KEY_CHECKS = 1;
3.权限表
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
-- ----------------------------
-- Table structure for sys_permission
-- ----------------------------
DROP TABLE IF EXISTS `sys_permission`;
CREATE TABLE `sys_permission` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键id',
`permission_code` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '权限code',
`permission_name` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '权限名',
`create_time` datetime(0) NULL DEFAULT NULL,
`update_time` datetime(0) NULL DEFAULT NULL,
`deleted` tinyint(1) NULL DEFAULT 0,
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 5 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '权限表' ROW_FORMAT = Dynamic;
-- ----------------------------
-- Records of sys_permission
-- ----------------------------
INSERT INTO `sys_permission` VALUES (1, 'create_user', '创建用户', '2023-12-11 10:17:23', NULL, 0);
INSERT INTO `sys_permission` VALUES (2, 'query_user', '查看用户', '2023-12-11 10:17:25', NULL, 0);
INSERT INTO `sys_permission` VALUES (3, 'delete_user', '删除用户', '2023-12-11 10:17:27', NULL, 0);
INSERT INTO `sys_permission` VALUES (4, 'modify_user', '修改用户', '2023-12-11 10:17:30', NULL, 0);
SET FOREIGN_KEY_CHECKS = 1;
4.用户角色关系表
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
-- ----------------------------
-- Table structure for sys_user_role_relation
-- ----------------------------
DROP TABLE IF EXISTS `sys_user_role_relation`;
CREATE TABLE `sys_user_role_relation` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键id',
`user_id` int(11) NULL DEFAULT NULL COMMENT '用户id',
`role_id` int(11) NULL DEFAULT NULL COMMENT '角色id',
`create_time` datetime(0) NULL DEFAULT NULL,
`update_time` datetime(0) NULL DEFAULT NULL,
`deleted` tinyint(1) NULL DEFAULT 0,
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 3 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '用户角色关联关系表' ROW_FORMAT = Dynamic;
-- ----------------------------
-- Records of sys_user_role_relation
-- ----------------------------
INSERT INTO `sys_user_role_relation` VALUES (1, 1, 1, '2023-12-11 10:18:23', NULL, 0);
INSERT INTO `sys_user_role_relation` VALUES (2, 2, 2, '2023-12-11 10:18:26', NULL, 0);
SET FOREIGN_KEY_CHECKS = 1;
5.角色权限关系表
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
-- ----------------------------
-- Table structure for sys_role_permission_relation
-- ----------------------------
DROP TABLE IF EXISTS `sys_role_permission_relation`;
CREATE TABLE `sys_role_permission_relation` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键id',
`role_id` int(11) NULL DEFAULT NULL COMMENT '角色id',
`permission_id` int(11) NULL DEFAULT NULL COMMENT '权限id',
`create_time` datetime(0) NULL DEFAULT NULL,
`update_time` datetime(0) NULL DEFAULT NULL,
`deleted` tinyint(1) NULL DEFAULT 0,
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 7 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '角色-权限关联关系表' ROW_FORMAT = Dynamic;
-- ----------------------------
-- Records of sys_role_permission_relation
-- ----------------------------
INSERT INTO `sys_role_permission_relation` VALUES (1, 1, 1, '2023-12-11 10:19:19', NULL, 0);
INSERT INTO `sys_role_permission_relation` VALUES (2, 1, 2, '2023-12-11 10:19:21', NULL, 0);
INSERT INTO `sys_role_permission_relation` VALUES (3, 1, 3, '2023-12-11 10:19:23', NULL, 0);
INSERT INTO `sys_role_permission_relation` VALUES (4, 1, 4, '2023-12-11 10:19:25', NULL, 0);
INSERT INTO `sys_role_permission_relation` VALUES (5, 2, 1, '2023-12-11 10:19:29', NULL, 0);
INSERT INTO `sys_role_permission_relation` VALUES (6, 2, 2, '2023-12-11 10:19:31', NULL, 0);
SET FOREIGN_KEY_CHECKS = 1;
对应的实体类可前往我的 git 的 pojo.entity 包 里查看,这里因篇幅原因就不贴出来了
2、项目框架
1.maven 依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.4.2</version>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.18</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.83</version>
</dependency>
<!-- 这两个依赖后续再引入 -->
<!-- <dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency> -->
2.配置
spring:
application:
name: security_jwt_demo
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/security_jwt_demo?serverTimezone=Asia/Shanghai
username: root
password: 123456
redis:
host: localhost
port: 6379
database: 3
server:
port: 8080
servlet:
context-path: /v1/api
mybatis-plus:
mapper-locations: classpath:/mapper/*Mapper.xml
typeAliasesPackage: club.gggd.security_jwt_demo.pojo
global-config:
id-type: 0
field-strategy: 1
db-column-underline: true
refresh-mapper: true
logic-delete-value: 0
logic-not-delete-value: 1
sql-parser-cache: true
configuration:
map-underscore-to-camel-case: true
cache-enabled: false
jwt:
#JWT存储的请求头
requestHeader: Authorization
#JWT加解密使用的密钥
secret: symx.club
#JWT的有效时间(60*60*24*7)
expiration: 604800
#JWT负载中的开头
tokenStartWith: 'Bearer '
3、其他类
1.跨域配置类
@Configuration(proxyBeanMethods = false)
@EnableWebMvc
public class ConfigurerAdapter implements WebMvcConfigurer {
@Bean
public CorsFilter corsFilter() {
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
CorsConfiguration config = new CorsConfiguration();
// 允许cookies跨域
config.setAllowCredentials(true);
// #允许向该服务器提交请求的URI,*表示全部允许,在SpringMVC中,如果设成*,会自动转成当前请求头中的Origin
config.addAllowedOriginPattern("*");
// #允许访问的头信息,*表示全部
config.addAllowedHeader("*");
// 预检请求的缓存时间(秒),即在这个时间段里,对于相同的跨域请求不会再预检了
config.setMaxAge(18000L);
// 允许提交请求的方法类型,*表示全部允许
config.addAllowedMethod("OPTIONS");
config.addAllowedMethod("HEAD");
config.addAllowedMethod("GET");
config.addAllowedMethod("PUT");
config.addAllowedMethod("POST");
config.addAllowedMethod("DELETE");
config.addAllowedMethod("PATCH");
source.registerCorsConfiguration("/**", config);
return new CorsFilter(source);
}
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
// 静态资源过滤,需同时在WebSecurityConfig放行
}
}
2.Redis 序列化配置
@Configuration
public class RedisConfig {
@Bean
@SuppressWarnings("all")
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory){
//使用 <String, Object>类型
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(factory);
//序列化配置
Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class);
ObjectMapper om = new ObjectMapper();
om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
om.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
jackson2JsonRedisSerializer.setObjectMapper(om);
//String 的序列化
StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();
//key 采用String 的序列化方式
template.setKeySerializer(stringRedisSerializer);
//hash 的key 也采用String 的序列化方式
template.setHashKeySerializer(stringRedisSerializer);
//value 序列化方式采用jackson
template.setValueSerializer(jackson2JsonRedisSerializer);
//hash 的value序列化方式采用Jackson
template.setHashValueSerializer(jackson2JsonRedisSerializer);
template.afterPropertiesSet();
return template;
}
}
3.实体类基类
@Data
public class BaseEntity {
@TableField("create_time")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date createTime;
@TableField("update_time")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date updateTime;
@TableField("deleted")
@TableLogic
private Integer deleted;
}
4.自定义异常
@Data
public class BusinessException extends RuntimeException{
private int code;
private String message;
public BusinessException(){}
public BusinessException(ResultCode resultCode) {
this.code = resultCode.getCode();
this.message = resultCode.getMessage();
}
public BusinessException(int code, String mgs) {
this.code = code;
this.message = mgs;
}
public BusinessException(String mgs) {
this.code = 400;
this.message = mgs;
}
}
@Data
public class TokenException extends RuntimeException{
public TokenException(String message) {
super(message);
}
public TokenException(ResultCode resultCode) {
super(resultCode.getMessage());
}
}
5.返回状态码
@Getter
@NoArgsConstructor
@AllArgsConstructor
public enum ResultCode {
SUCCESS(200, "成功"),
BAD_REQUEST(400, "请求错误"),
UNAUTHORIZED(401, "用户未认证,请登录"),
FORBIDDEN(403, "禁止访问"),
NOT_FOUND(404, "内容未找到"),
METHOD_NOT_ALLOW(405, "不支持的方法"),
SERVER_ERROR(500, "服务器内部发生错误"),
/* 参数错误 */
PARAM_NOT_VALID(1001, "参数无效"),
PARAM_IS_BLANK(1002, "参数为空"),
PARAM_TYPE_ERROR(1003, "参数类型错误"),
PARAM_NOT_COMPLETE(1004, "参数缺失"),
/* 用户错误 */
USER_NOT_LOGIN(2001, "用户未登录"),
USER_ACCOUNT_EXPIRED(2002, "账号已过期"),
USER_CREDENTIALS_ERROR(2003, "密码错误"),
USER_CREDENTIALS_EXPIRED(2004, "密码过期"),
USER_ACCOUNT_DISABLE(2005, "账号不可用"),
USER_ACCOUNT_LOCKED(2006, "账号被锁定"),
USER_ACCOUNT_NOT_EXIST(2007, "账号不存在"),
USER_ACCOUNT_ALREADY_EXIST(2008, "账号已存在"),
USER_ACCOUNT_USE_BY_OTHERS(2009, "账号下线"),
USER_NOT_FOUND(2010, "用户不存在"),
TOKEN_INVALID(3001, "token无效"),
TOKEN_TIMEOUT(3002, "token已过期"),
CAPTCHA_ERR(4001, "验证码错误");
/**
* 返回码
*/
private Integer code;
/**
* 返回信息
*/
private String message;
}
6.统一返回体
@Data
@NoArgsConstructor
@AllArgsConstructor
public class WebResult<T> {
private Boolean success;
private Integer code;
private String message;
private T data;
public static WebResult success() {
WebResult result = new WebResult();
result.success = true;
result.code = 200;
result.message = "成功";
return result;
}
public static WebResult success(String message) {
WebResult result = new WebResult();
result.success = true;
result.code = 200;
result.message = message;
return result;
}
public static WebResult success(Object data) {
WebResult result = new WebResult();
result.success = true;
result.code = 200;
result.message = "成功";
result.data = data;
return result;
}
public static WebResult error() {
WebResult result = new WebResult();
result.success = false;
result.code = 400;
result.message = "失败";
return result;
}
public static WebResult error(String message) {
WebResult result = new WebResult();
result.success = false;
result.code = 400;
result.message = message;
return result;
}
public static WebResult error(int code, String message) {
WebResult result = new WebResult();
result.success = false;
result.code = code;
result.message = message;
return result;
}
public static WebResult error(ResultCode resultCode) {
WebResult result = new WebResult();
result.success = false;
result.code = resultCode.getCode();
result.message = resultCode.getMessage();
return result;
}
}
7.全局异常捕获
@ControllerAdvice
@Slf4j
public class GlobalExceptionHandle {
/**
* 拦截业务异常
* @param e
* @param request
* @param response
* @return
*/
@ResponseBody
@ExceptionHandler(BusinessException.class)
public WebResult handlerBusinessException(Exception e, HttpServletRequest request, HttpServletResponse response) {
log.error("业务异常信息:{}", e.getMessage());
e.printStackTrace();
// 不同异常返回不同状态码
WebResult result = WebResult.error(((BusinessException) e).getCode(), e.getMessage());
return result;
}
@ResponseBody
@ExceptionHandler(MethodArgumentNotValidException.class)
public WebResult handleMethodArgumentNotValidException(MethodArgumentNotValidException e, HttpServletRequest request, HttpServletResponse response) {
log.error("请求参数异常:{}", e.getMessage());
List<ObjectError> allErrors = e.getBindingResult().getAllErrors();
// 不同异常返回不同状态码
WebResult result = WebResult.error(ResultCode.BAD_REQUEST.getCode(), allErrors.get(0).getDefaultMessage());
return result;
}
/**
* 处理其他异常
* @param e
* @param request
* @param response
* @return
*/
@ResponseBody
@ExceptionHandler(Exception.class)
public WebResult handleException(Exception e, HttpServletRequest request, HttpServletResponse response){
log.error("根异常信息:{}", e.getMessage());
e.printStackTrace();
// 不同异常返回不同状态码
WebResult result = WebResult.error(ResultCode.SERVER_ERROR.getCode(), ResultCode.SERVER_ERROR.getMessage());
return result;
}
}
8.Redis 工具类
网上有很多,也可以用我的:Redis工具类
二、开始整合
首先把上面 maven 依赖中的 spring security 和 jwt 依赖导入。
然后写一个 jwt 工具类:
@Data
@Component
// 引入配置类中jwt相关配置
@ConfigurationProperties("jwt")
public class JwtUtil {
private String requestHeader;
private String secret;
private int expiration;
private String tokenStartWith;
@Autowired
private RedisUtil redisUtil;
/**
* 获取用户名
* @return
*/
public String getAccountByToken(String token) {
Claims claims = getClaimsByToken(token);
return claims != null ? claims.getSubject() : null;
}
/**
* 获取过期时间
* @param token
* @return Date
*/
public Date getExpiredByToken(String token) {
Claims claims = getClaimsByToken(token);
return claims != null ? claims.getExpiration() : null;
}
/**
* 获取Claims
* @param token
* @return
*/
private Claims getClaimsByToken(String token) {
Claims claims;
try {
claims = Jwts.parser()
.setSigningKey(secret)
.parseClaimsJws(token)
.getBody();
} catch (Exception e) {
// 签名不一致异常
if (e instanceof SignatureException) {
throw new TokenException(ResultCode.TOKEN_INVALID);
}
// token过期异常
if (e instanceof ExpiredJwtException) {
throw new TokenException(ResultCode.TOKEN_TIMEOUT);
}
// 如果都不是上面的则弹出token无效异常
throw new TokenException(ResultCode.TOKEN_INVALID);
}
return claims;
}
/**
* 计算过期时间
* @return
*/
private Date generateExpired() {
return new Date(System.currentTimeMillis() + expiration * 1000);
}
/**
* 判断 Token 是否过期
* @param token
* @return
*/
private Boolean isTokenExpired(String token) {
Date expirationDate = getExpiredByToken(token);
return expirationDate.before(new Date());
}
/**
* 生成 Token
* @param user 用户信息
* @return
*/
public String generateToken(SysUser user) {
String token = Jwts.builder()
.setSubject(user.getAccount())
.setExpiration(generateExpired())
.signWith(SignatureAlgorithm.HS512, secret)
.compact();
String key = "login:" + user.getAccount() + ":" + token;
redisUtil.set(key, token, expiration);
return token;
}
/**
* 验证 Token
* @param token
* @return
*/
public Boolean validateToken(String token) {
String account = getAccountByToken(token);
String key = "login:" + account+ ":" + token;
Object data = redisUtil.get(key);
String redisToken = data == null ? null : data.toString();
return StrUtil.isNotEmpty(token) && !isTokenExpired(token) && token.equals(redisToken);
}
/**
* 移除 Token
* @param token
*/
public void removeToken(String token) {
String account = getAccountByToken(token);
String key = "login:" + account+ ":" + token;
redisUtil.del(key);
delUserDetail(account);
}
/**
* 获取token
* @param request
* @return
*/
public String getToken(HttpServletRequest request) {
String auth = request.getHeader(requestHeader);
if (StrUtil.isBlank(auth)) {
return null;
}
String token = auth.replace(tokenStartWith, "");
return token;
}
/**
* 退出登录
*/
public void logout(HttpServletRequest request) {
String token = getToken(request);
removeToken(token);
}
/**
* 获取userDetail
* @param token
* @return
*/
public JwtUser getUserDetail(String token) {
String account = getAccountByToken(token);
String s = (String) redisUtil.get("user:userDetail:" + account);
JwtUser jwtUser = JSON.parseObject(s, JwtUser.class);
return jwtUser;
}
/**
* 删除userDetail
* @param account
*/
public void delUserDetail(String account) {
redisUtil.del("user:userDetail:" + account);
}
}
1、登录接口
首先用户需要进行登录,然后后台生成一个 token 返回给前端,之后前端就需要在请求头带上这个 token,否则会根据 Spring Security 的 配置判断用户是否可以访问这个接口。
在登录前需要先实现 UserDetailsService 接口,这个接口是登录的重要部分,用于构造并保存登录用户信息,而 UserDetails 接口则是用来记录用户信息的类,我们需要写一个类来实现 UserDetails 接口,这个类主要是一些用户的基本信息,以及权限等。
@Getter
@AllArgsConstructor
public class JwtUser implements UserDetails {
private final Integer id;
private final String account;
private final String userName;
@JsonIgnore
private final String password;
@JsonIgnore
private final Collection<SimpleGrantedAuthority> authorities;
private final boolean enabled;
@JsonIgnore
@Override
public boolean isAccountNonExpired() {
return true;
}
@JsonIgnore
@Override
public boolean isAccountNonLocked() {
return true;
}
@JsonIgnore
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@JsonIgnore
@Override
public String getPassword() {
return password;
}
@Override
public String getUsername() {
return account;
}
@Override
public boolean isEnabled() {
return enabled;
}
public Collection getRoles() {
return authorities.stream().map(GrantedAuthority::getAuthority).collect(Collectors.toSet());
}
}
UserDetails 写完了,接下来就是 UserDetailsService 的实现了,实现这个接口,在登录时会调用这个接口,将用户信息存到 Redis 中。userService.getAuthList(user.getId())方法后面有说。
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
@Autowired
private ApplicationContext applicationContext;
@Autowired
private RedisUtil redisUtil;
@Override
public UserDetails loadUserByUsername(String account) throws UsernameNotFoundException {
// 获取用户信息
UserService userService = (UserService) applicationContext.getBean("userServiceImpl");
SysUser user = userService.getUserInfo(account);
if (user == null) {
throw new BusinessException(ResultCode.USER_NOT_FOUND);
}
// 组装成userDetails
JwtUser jwtUser = new JwtUser(
user.getId(),
user.getAccount(),
user.getUserName(),
user.getPassword(),
userService.getAuthList(user.getId()),
user.getEnabled() == 1 ? true : false
);
// 存入Redis
String s = JSON.toJSONString(jwtUser);
redisUtil.set("user:userDetail:" + user.getAccount(), s);
return jwtUser;
}
}
接下来就是登录接口了,下面是一个简单的登录接口:
@PostMapping("/login")
public WebResult login(@RequestBody @Validated LoginVo vo) {
// 解密密码,所有的密码都需要进行一个加密,不能明文传输,我这里就不写了
// 判断验证码,写死,不要学
if (!"1234".equals(vo.getCode())) {
return WebResult.error("验证码错误");
}
String token = userService.login(vo);
return WebResult.success((Object) token);
}
login 方法:
public String login(LoginVo vo) {
// 判断用户
LambdaQueryWrapper<SysUser> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(SysUser::getAccount, vo.getAccount());
SysUser user = userMapper.selectOne(wrapper);
Optional.ofNullable(user).orElseThrow(() -> new BusinessException(ResultCode.USER_NOT_FOUND));
boolean matches = passwordEncoder.matches(vo.getPassword(), user.getPassword());
if (!matches) {
throw new BusinessException(ResultCode.USER_CREDENTIALS_ERROR);
}
// 组装Authentication,在这里会调用到UserDetailsService的loadUserByUsername方法
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(vo.getAccount(), vo.getPassword());
Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);
SecurityContextHolder.getContext().setAuthentication(authentication);
// 生成token
String token = jwtUtil.generateToken(user);
return token;
}
用户进行登录后,后台就会判断验证码,密码是否正确,然后通过 Spring Security 的loadUserByUsername 方法保存用户信息,这样我们就可以通过 token 解析到用户账号,然后拿到用户信息,就不需要实时查询数据库了。
2、jwt 过滤器
用户登录后,后台会生成一个 token 返回给前端,至此前端每次进行请求都需要带上这个 token,这样后台才能确认到究竟是哪个用户进行访问。而接下来就是要进行 token 的解析了,通过解析 token,我们可以验证 token 是否正确和构造出当前请求的用户,然后将其保存到 Spring Security 的SecurityContext 中去。
@Slf4j
public class JwtFilter extends BasicAuthenticationFilter{
private final JwtUtil jwtUtil;
public JwtFilter(AuthenticationManager authenticationManager, JwtUtil jwtUtil) {
super(authenticationManager);
this.jwtUtil = jwtUtil;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
// 获取token
String token = jwtUtil.getToken(request);
// 如果token为空,则进入下一个过滤器,因为有些接口是允许匿名访问的
if (token == null) {
chain.doFilter(request, response);
return;
}
String account = jwtUtil.getAccountByToken(token);
if (account != null && SecurityContextHolder.getContext().getAuthentication() == null && jwtUtil.validateToken(token)) {
// 将用户信息存入SecurityContext
UserDetails userDetails = jwtUtil.getUserDetail(token);
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
} else {
jwtUtil.removeToken(token);
}
chain.doFilter(request, response);
}
}
3、认证失败过滤器
当用户带上 token 请求某个接口时,会经过 jwt 过滤器对 token 进行一个验证,假如验证不通过,例如有人篡改了 token 或者 token 过期了,就会执行认证失败过滤器,这个过滤器的作用主要是提示前端该用户登录过期了需要重新登录,需要实现接口AuthenticationEntryPoint。
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException authException) throws IOException {
// 当用户尝试访问安全的REST资源而不提供任何凭据时,将调用此方法发送401 响应
WebResult result = WebResult.error(ResultCode.UNAUTHORIZED);
response.setContentType("text/json;charset=utf-8");
response.getWriter().write(JSON.toJSONString(result));
}
}
4、无权限访问过滤器
有些接口可能需要某些特定的权限才能访问,这时候如果用户没有这个权限但仍然访问了这个接口,这时候我们就需要进行一个拦截并且返回提示消息,就需要到了无权限访问过滤器了,这个过滤器同样需要实现一个接口AccessDeniedHandler
@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException {
//当用户在没有授权的情况下访问受保护的REST资源时,将调用此方法发送403 Forbidden响应
WebResult result = WebResult.error(ResultCode.FORBIDDEN);
response.setContentType("text/json;charset=utf-8");
response.getWriter().write(JSON.toJSONString(result));
}
}
需要注意的是,如果要实现接口的权限控制,需要在 Spring Security 的配置类中加上注解@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
,这个注解允许接口级别的权限控制,稍后我会贴出完整的配置类,这里稍作了解即可。加上注解后,我们就可以在接口方法上加上@PreAuthorize
注解进行接口级的权限判断了。
一般的项目中都会有一个超级管理员角色,这个角色掌握所有的权限,如果仅通过 Spring Security 自带的注解,那么每个注解上都需要加上超级管理员,这样也是非常麻烦的,所以我们可以自己写一个验证权限的类,在里面把管理员给放行。
@Service(value = "el")
public class ElPermissionHandle {
@Autowired
private UserService userService;
@Autowired
private UserProvider userProvider;
public Boolean check(String ...permissions){
// 获取当前用户的所有权限
Integer userId = userProvider.getUserId();
List<String> list = userService.getAuthList(userId).stream().map(GrantedAuthority::getAuthority).collect(Collectors.toList());
return list.contains("admin") || Arrays.stream(permissions).anyMatch(list::contains);
}
}
获取用户权限的方法userService.getAuthList(userId)
public List<SimpleGrantedAuthority> getAuthList(Integer userId) {
// 获取该用户所有权限
List<UserPermission> list = userMapper.getUserPermissionList(userId);
Set<String> permission = list.stream().map(UserPermission::getPermissionCode).collect(Collectors.toSet());
// 获取该用户所有角色
List<UserRoleDto> userRoleList = roleMapper.getUserRoleList(userId);
for (UserRoleDto ur : userRoleList) {
permission.add(ur.getRoleCode());
}
List<SimpleGrantedAuthority> authorities = permission.stream().map(SimpleGrantedAuthority::new).collect(Collectors.toList());
return authorities;
}
以及 SQL:
<select id="getUserPermissionList" parameterType="integer" resultType="UserPermission">
SELECT
u.id userId,
u.account,
p.permission_code permissionCode
FROM
sys_user u
INNER JOIN sys_user_role_relation ur ON ur.user_id = u.id AND ur.deleted = 0
INNER JOIN sys_role_permission_relation rp ON rp.role_id = ur.role_id AND rp.deleted = 0
INNER JOIN sys_permission p ON p.id = rp.permission_id AND p.deleted = 0
WHERE
u.deleted = 0
AND u.id = #{userId}
</select>
<select id="getUserRoleList" parameterType="integer" resultType="UserRoleDto">
SELECT
ur.user_id userId,
r.role_code roleCode
FROM
sys_user_role_relation ur
INNER JOIN sys_role r ON r.id = ur.role_id AND r.deleted = 0
WHERE
ur.deleted = 0
AND ur.user_id = #{userId}
</select>
使用的话如下例子:
@GetMapping("/security")
@PreAuthorize("@el.check('create_user')")
public WebResult getSecurityInfo() {
return WebResult.success("获取成功");
}
最后还有一点要注意的,这也是我做的时候遇到的坑,就是一切都配置好了之后发现没有权限时直接抛出了异常AccessDeniedException,而没有走我配置的过滤器,后面才发现原来是被全局异常捕获给捕获到了,所以我们需要在GlobalExceptionHandle 这个类中先捕获到AccessDeniedException 异常,再把异常继续抛出去让无权限访问过滤器给处理掉。
/**
* 解决被全局异常捕获的问题
* @param e
* @throws AccessDeniedException
*/
@ExceptionHandler(AccessDeniedException.class)
public void accessDeniedException(AccessDeniedException e) throws AccessDeniedException {
throw e;
}
5、用户工具类
很多情况下我们都只需要到用户 id 或者用户账号,所以可以写一个工具类来获取当前请求的用户信息。
@Component
public class UserProvider {
/**
* 获取当前用户信息
* @return
*/
public JwtUser curUser() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
JwtUser jwtUser = (JwtUser) authentication.getPrincipal();
return jwtUser;
}
/**
* 获取用户id
* @return
*/
public Integer getUserId() {
return curUser().getId();
}
/**
* 获取用户账号
* @return
*/
public String getAccount() {
return curUser().getAccount();
}
}
6、整合配置
最后将上面的配置都整合到 Spring Security 配置类中
@Configuration(proxyBeanMethods = false)
@EnableWebSecurity
// 加上该注解可实现接口级的权限认证
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private JwtUtil jwtUtil;
@Autowired
private JwtAccessDeniedHandler jwtAccessDeniedHandler;
@Autowired
private JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
@Autowired
private CorsFilter corsFilter;
@Autowired
private UserDetailsServiceImpl userDetailsService;
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
//配置认证方式
auth.userDetailsService(userDetailsService);
}
@Override
protected void configure(HttpSecurity http) throws Exception {
//http相关的配置,包括登入登出、异常处理、会话管理等
http.csrf().disable()
// 跨域过滤器
.addFilterBefore(corsFilter, UsernamePasswordAuthenticationFilter.class)
// jwt过滤器
.addFilter(jwtFilter())
// 授权异常
.exceptionHandling()
.authenticationEntryPoint(jwtAuthenticationEntryPoint)
.accessDeniedHandler(jwtAccessDeniedHandler)
// 防止iframe 造成跨域
.and()
.headers()
.frameOptions()
.disable()
// 不创建会话
.and()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
// 放行与拦截接口
.and().authorizeRequests()
// 放行登录接口
.antMatchers("/auth/login").permitAll()
// 拦截其余接口
.anyRequest().authenticated();
}
/**
* 设置密码加密方式,密码必须要加密保存
* @return
*/
@Bean
public BCryptPasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
/**
* token过滤器
* @return
*/
private JwtFilter jwtFilter() throws Exception{
return new JwtFilter(authenticationManager(), jwtUtil);
}
/**
* 去除 ROLE_ 前缀
* @return
*/
@Bean
GrantedAuthorityDefaults grantedAuthorityDefaults() {
return new GrantedAuthorityDefaults("");
}
}
三、其他功能的实现
上面只是整合了 Spring Security 和 JWT,已经可以实现简单的登录了,但是实际上可能业务没那么简单,而是需要不同的登录功能,比如有些项目只允许一个设备端登录,在某个设备登录后之前登录的设备就要下线了,我这里也做了几个简单的登录类型:单设备登录,多设备登录和同端互斥登录。
- 单设备登录:只允许一个设备登录,后登录的会把前登录的给踢掉
- 多设备登录:允许多个设备同时登录,不做限制
- 同端互斥登录:同一种设备类型只允许一次登录,类似 QQ,电脑和安卓可以同时登录,但是不允许两台电脑同时登录
在开始之前,我们需要确认一下要如何保存 token 信息,我们需要把 token 信息保存在 redis,需要通过 redis 来确认用户是否登录,下面是我设计的一种 token 保存方式。
- session 信息:这里主要是保存这个账号下的所有 token 的,key 为
login:session:account
- token 信息:这里是保存这个 token 所关联的账号,
key 为 login:token:token
两者结构如下:
session 信息的结构,主要是保存了这个账号下的所有 token ,以及登录的设备和 token 到期时间:
token 信息的结构:
通过这个结构,我们可以根据账号来获取到这个账号下的所有 token,也可以仅根据 token 拿到这个 token 所对应的账号。接下来所谓的各种登录方式也就是将这些 token 按规定删除就可以实现各种类型的登录了。
1、准备
1.配置文件
login:
# 是否共享token,仅当login-model为multi时有效
is-share: false
# 登录模式,分为alone,multi和same-device-exclusion三种
login-model: alone
2.登录模式枚举
@Getter
@NoArgsConstructor
@AllArgsConstructor
public enum LoginModel {
ALONE("alone", "单设备登录"),
MULTI("multi", "多设备登录"),
SAME_DEVICE_EXCLUSION("same-device-exclusion", "同端互斥登录");
private String value;
private String desc;
}
3.设备类型枚举
@Getter
@AllArgsConstructor
public enum DeviceType {
DEFAULT("DEFAULT", "默认设备"),
ANDROID("ANDROID", "安卓设备"),
IOS("IOS", "苹果设备"),
PC("PC", "Windows设备");
private String value;
private String desc;
}
4.token 常量
// 这个是保存到redis里key的前缀
@Data
public class TokenConstant {
public static final String TOKEN = "login:token:";
public static final String SESSION = "login:session:";
}
5.token 信息
@Data
public class TokenInfo {
/**
* key的值
*/
private String id;
/**
* 创建时间
*/
private Long createTime;
/**
* token集合
*/
private List<TokenSign> tokenSignList;
}
@Data
public class TokenSign {
/**
* token
*/
private String token;
/**
* 设备
*/
private String device;
/**
* 有效截止期
*/
private Long deadline;
}
6.UserAgent 工具类
public class UserAgentUtil {
/**
* 获取当前请求的User-Agent
* @return
*/
private static String getUserAgent() {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attributes != null) {
HttpServletRequest request = attributes.getRequest();
String header = request.getHeader("User-Agent");
return header;
}
return null;
}
/**
* 获取当前请求的设备
* @return
*/
public static String getDevice() {
String userAgent = getUserAgent();
if (userAgent == null) {
return DeviceType.DEFAULT.getValue();
}
if (userAgent.toLowerCase().contains(DeviceType.ANDROID.getValue())) {
return DeviceType.ANDROID.getValue();
}
if (userAgent.toLowerCase().contains("iphone") || userAgent.toLowerCase().contains("ipad")) {
return DeviceType.IOS.getValue();
}
if (userAgent.toLowerCase().contains("windows nt") && !userAgent.toLowerCase().contains("windows phone")) {
return DeviceType.PC.getValue();
}
return DeviceType.DEFAULT.getValue();
}
}
2、功能实现
接下来就是对登录模式的实现了,主要就是在用户登录时判断登录模式,根据登录模式的不同实现不同的登录效果即可。
下面的改动都是在 JwtUtil 类中的改动。
1.引入配置
@Value("${login.is-share}")
private Boolean isShare;
@Value("${login.login-model}")
private String loginModel;
2.功能实现
1.原方法改造
因为登录方式变了,所以我们需要重新改造之前的生成 token,验证 token 与移除 token 方法
因为生成 token 要改动的内容比较多,所以先说一下验证 token 与移除 token 方法
验证 token,这个改动不大,只是改了下 key:
public Boolean validateToken(String token) {
// 调用这个方法主要是用来验证签名和有效期
getClaimsByToken(token);
String key = TokenConstant.TOKEN + token;
return StrUtil.isNotEmpty(token) && !isTokenExpired(token) && redisUtil.hasKey(key);
}
移除 token,相比于之前,现在需要把 session 和 token 都要同时移除:
public void removeToken(String token) {
// 获取session信息
String account = getAccountByToken(token);
String key = TokenConstant.TOKEN + token;
TokenInfo tokenInfo = (TokenInfo) redisUtil.get(TokenConstant.SESSION + account);
// 删除该token
tokenInfo.getTokenSignList().removeIf(e -> e.getToken().equals(token));
redisUtil.set(TokenConstant.SESSION + account, tokenInfo);
redisUtil.del(key);
// 如果此时该账号没有token了,则删除UserDetails
if (CollectionUtils.isEmpty(tokenInfo.getTokenSignList())) {
delUserDetail(account);
}
}
最后是生成 token,这个是关键,相比与之前,就是根据不同登录方式来执行不同的方法:
public String generateToken(SysUser user) {
String token = Jwts.builder()
.setSubject(user.getAccount())
.setExpiration(generateExpired())
.signWith(SignatureAlgorithm.HS512, secret)
.compact();
String device = UserAgentUtil.getDevice();
// 判断登录模式
if (LoginModel.ALONE.getValue().equals(loginModel)) {
// 单设备登录
aloneLogin(token, user.getAccount(), device);
} else if (LoginModel.MULTI.getValue().equals(loginModel)) {
// 多设备登录
String newToken = multiLogin(token, user.getAccount(), device);
return newToken;
} else {
// 同端互斥登录
sameDeviceExclusionLogin(token, user.getAccount(), device);
}
return token;
}
2.通用方法
在编写登录模式的方法之前,先写好一些通用的方法
- 获取用户 session 信息:因为 session 信息包含了这个用户的所有 token,所以我们可以写一个方法用来获取,需要注意的是,因为 session 只是一个键值对,所以不能设置有效期,我们需要手动比对 token 的有效期,把过期的删掉
- 设置token和session到redis:这里主要是把当前的 token 和更新后的 token 都重新保存到 redis 中
/**
* 获取用户session信息
* @param account
* @return
*/
private TokenInfo getSessionInfo(String account) {
String key = TokenConstant.SESSION + account;
TokenInfo tokenInfo = (TokenInfo) redisUtil.get(key);
if (tokenInfo == null) {
tokenInfo = new TokenInfo();
tokenInfo.setId(key);
tokenInfo.setCreateTime(System.currentTimeMillis());
List<TokenSign> tokenSignList = new ArrayList<>();
tokenInfo.setTokenSignList(tokenSignList);
}
// 删除过期token
long cur = System.currentTimeMillis();
tokenInfo.getTokenSignList().removeIf(e -> e.getDeadline() < cur);
return tokenInfo;
}
/**
* 设置token和session到redis
* @param token
* @param device
* @param account
* @param sessionInfo
*/
private void setTokenAndSessionHandle(String token, String device, String account, TokenInfo sessionInfo) {
// 将token存入
redisUtil.set(TokenConstant.TOKEN + token, account);
// 组装新的token
TokenSign tokenSign = new TokenSign();
tokenSign.setToken(token);
tokenSign.setDevice(device);
tokenSign.setDeadline(System.currentTimeMillis() + expiration * 1000);
sessionInfo.getTokenSignList().add(tokenSign);
// 把session存入
redisUtil.set(TokenConstant.SESSION + account, sessionInfo);
}
3.新方法
1. 单设备登录
单设备登录需要删除原来的 token,并保存新的 token
/**
* 单设备登录
* @param token
* @param account
* @param device
*/
private void aloneLogin(String token, String account, String device) {
// 拿到当前用户的session信息
TokenInfo sessionInfo = getSessionInfo(account);
List<TokenSign> tokenSignList = sessionInfo.getTokenSignList();
// token不为空时清空所有token
if (CollectionUtil.isNotEmpty(tokenSignList)) {
for (TokenSign t : tokenSignList) {
redisUtil.del(TokenConstant.TOKEN + t.getToken());
}
tokenSignList.clear();
}
setTokenAndSessionHandle(token, device, account, sessionInfo);
}
2.多设备登录
多设备登录需要注意是否是共享 token
/**
* 多设备登录
* @param token
* @param account
* @param device
* @return token
*/
private String multiLogin(String token, String account, String device) {
// 拿到当前用户的session信息
TokenInfo sessionInfo = getSessionInfo(account);
List<TokenSign> tokenSignList = sessionInfo.getTokenSignList();
// 如果是共享token且session里面存在token
if (isShare && CollectionUtil.isNotEmpty(tokenSignList)) {
// 则直接返回原来的token
return tokenSignList.get(0).getToken();
}
// 没有开启共享token,或开启了共享token但是是第一次登录
setTokenAndSessionHandle(token, device, account, sessionInfo);
return token;
}
3.同端互斥登录
需要注意同一个设备只允许一个登录
/**
* 同端互斥登录
* @param token
* @param account
* @param device
*/
private void sameDeviceExclusionLogin(String token, String account, String device) {
TokenInfo sessionInfo = getSessionInfo(account);
List<TokenSign> tokenSignList = sessionInfo.getTokenSignList();
// 判断是否存在当前设备的登录信息
Iterator<TokenSign> iterator = tokenSignList.iterator();
while (iterator.hasNext()) {
TokenSign next = iterator.next();
if (next.getDevice().equals(device)) {
// 存在则删除
redisUtil.del(TokenConstant.TOKEN + next.getToken());
iterator.remove();
break;
}
}
// 录入当前登录信息
setTokenAndSessionHandle(token, device, account, sessionInfo);
}
4.踢出用户
踢出用户本质上就是把 redis 里面相应的 token 删除就可以了,这个踢出用户的方法我们可以写在 UserProvider 中。
/**
* 踢出该账号的所有登录信息
* @param account
*/
public void kickOutUserByAccount(String account) {
// 拿到session信息
TokenInfo sessionInfo = (TokenInfo) redisUtil.get(TokenConstant.SESSION + account);
if (sessionInfo != null) {
List<TokenSign> tokenSignList = sessionInfo.getTokenSignList();
// 删除全部token
for (TokenSign t : tokenSignList) {
redisUtil.del(TokenConstant.TOKEN + t.getToken());
}
// 删除session里的token
tokenSignList.clear();
redisUtil.set(TokenConstant.SESSION + account, sessionInfo);
}
}
/**
* 踢出该token的登录信息
* @param token
*/
public void kickOutUserByToken(String token) {
// 拿到账号
String account = (String) redisUtil.get(TokenConstant.TOKEN + token);
// 拿到session
TokenInfo sessionInfo = (TokenInfo) redisUtil.get(TokenConstant.SESSION + account);
List<TokenSign> tokenSignList = sessionInfo.getTokenSignList();
// 删除session中的token
tokenSignList.removeIf(e -> e.getToken().equals(token));
// 删除token
redisUtil.del(TokenConstant.TOKEN + token);
redisUtil.set(TokenConstant.SESSION + account, sessionInfo);
}
到这里就结束了,大家可以自己测试一下,有什么不懂了可以去 git 查看源码:点击前往