目录
回顾一下JWT
基于JWT的认证流程
- 用户在浏览器中输入用户名和密码,服务器通过密码校验后生成一个token并保存到数据库
- 前端获取到token,存储到cookie或者local storage中,在后续的请求中都将带有这个token信息进行访问
- 服务器获取token值,通过查找数据库判断当前token是否有效
安全性
- JWT的payload使用的是base64编码的,因此在JWT中不能存储敏感数据。
- 不同于session的信息是存在服务端的,session相对来说更安全。
- 如果在JWT中存储了敏感信息,可以解码出来非常的不安全
性能
- 经过编码之后JWT将非常长,cookie的限制大小一般是4k,cookie很可能放不下,所以JWT一般放在local storage里面。
- 并且用户在系统中的每一次http请求都会把JWT携带在Header里面,HTTP请求的Header可能比Body还要大。
- 而sessionId只是很短的一个字符串,因此使用JWT的HTTP请求比使用session的开销大得多
一次性
无状态是JWT的特点,但也导致了这个问题,JWT是一次性的。想修改里面的内容,就必须签发一个新的JWT
- 无法废弃 - 一旦签发一个JWT,在到期之前就会始终有效,无法中途废弃。- 若想废弃,一种常用的处理手段是结合redis。
- 续签 - 如果使用JWT做会话管理,传统的cookie续签方案一般都是框架自带的,session有效期30分钟,30分钟内如果有访问,有效期被刷新至30分钟。- 一样的道理,要改变JWT的有效时间,就要签发新的JWT。- 最简单的一种方式是每次请求刷新JWT,即每个HTTP请求都返回一个新的JWT。- 这个方法不仅暴力不优雅,而且每次请求都要做JWT的加密解密,会带来性能问题。- 另一种方法是在redis中单独为每个JWT设置过期时间,每次访问时刷新JWT的过期时间
Token过期
- 想一下,当你正在用电脑录入信息或者抢购东西时,突然弹出登录信息已过期提示…
影响
当一个token过期时,可能会带来以下影响:
- 无效使用:过期的token无法再被使用,因此持有者无法通过该token进行交易、合约执行或获取相关服务。
- 安全性提升:过期的token不再有效,可以防止被盗或滥用。这有助于确保账户和资金的安全。
- 用户体验降低:如果用户忘记或不知道token已经过期,他们可能会尝试使用无效的token,导致交易失败或无法访问所需的服务。这可能会降低用户的体验和满意度。
- 重新认证:一旦token过期,用户可能需要重新进行身份验证或获取新的有效token,以继续使用相关服务。这可能会增加一些额外的步骤和麻烦。
- 数据或权益的丢失:某些情况下,过期的token可能与特定的数据或权益相关联。如果没有及时处理过期token,用户可能会失去对这些数据或权益的访问。这可能会对用户的个人或商业利益产生负面影响。
- 违约或合同终止:在某些情况下,过期token可能与某些合同或协议的有效性相关。一旦token过期,可能会导致违约或合同的终止。
总的来说,过期的token可能会导致无效使用、安全性提升、用户体验降低、重新认证、数据或权益的丢失以及违约或合同终止等影响。因此,对于token的持有者和相关服务提供商来说,管理和处理过期token是很重要的。
解决
智障思路
- token时间长点?避免不了突然失效的情景
- token永不过期?不安全,家被偷了都没发现
分析
- 上述两种情况必然都不可行,现在问题明确为:token需要设置过期时间,但是时间总有上限。
- 因此问题的解决点就是如何自动延长token的时间,那么也有两种思路 1. token定时检查续期:也就是在快过期时自动续期,比如还剩半小时的时候,检测到时间不足自动续期2. 双token。生成两个token:accessToken(验证)和refreshToken(刷新) - 验证token过期时间短些,刷新token设置长一点的过期时间;- 接口请求调用验证token,验证token过期后,如果有刷新token并且没过期,生成一个验证token返回给前端,后续调用新的验证token即可。
token定时检查续期
思路分析
- jwt工具类中生成的token中不带有过期时间,token的过期时间由redis进行管理
- 用户通过认证后,生成token,并保存到redis中(两份数据) - 数据1:用户ID作为key,token作为值- 数据2:token作为key,用户信息作为值
- 登出时将对应的key删除即可
- 更新用户密码时需要重新生成新的token,并将新的token返回给前端,由前端更新保存在local storage中的token,同时更新存储在redis中的token,这样实现可以避免用户重新登陆,用户体验感不至于太差。当然也可以让用户更新密码后自动跳转到重新登录页面。
- 拦截器中主要做两件事,一是对token进行校验,二是判断token是否需要进行续期 token校验: - 判断id对应的token是否不存在,不存在则token过期- 若token存在则比较token是否一致,保证同一时间只有一个用户操作- token自动续期: 为了不频繁操作redis,只有当离过期时间只有30分钟时才更新过期时间
大致代码
if(RedisUtil.getExpireTime(user.getId())<1*60*30){RedisUtil.set(userTokenDTO.getId(), token);
log.error("token即将过期,更新token信息, id :{}, 用户ID :{}", user.getId(), token);}
问题
- 该方案确实完成了自动续期,也可以及时增加时间
- 但是比如设置的是不足半小时自动续期,那我如果是在剩余35分钟的时候,触发了一次请求,下一次操作是四十分钟后了,这个时候已经过期了
- 而由于上次检测到该token是四十分钟之前,导致没有及时续期,登录信息已过期
双token【重点】
思路分析
- 登录成功以后,后端返回 access_token 和 refresh_token,客户端缓存此两种token;
- 使用 access_token 请求接口资源,成功则调用成功;如果token超时,客户端携带 refresh_token 调用token刷新接口获取新的 access_token;
- 后端接受刷新token的请求后,检查 refresh_token 是否过期。如果过期,拒绝刷新,客户端收到该状态后,跳转到登录页;如果未过期,生成新的 access_token 返回给客户端。
- 客户端携带新的 access_token 重新调用上面的资源接口。
- 客户端退出登录或修改密码后,注销旧的token,使 access_token 和 refresh_token 失效,同时清空客户端的 access_token 和 refresh_toke。
补充
在实际的生产环境中,为了保证系统的安全性,你可能需要考虑到以下几点:
- Token也可以在服务端保存一份,比如存到Redis中,并对前端传来的token与redis中的比较,这样可以实现服务端主动让token失效,比如从redis删除token即可。
- 考虑到用户的session状态,当用户退出登录或者修改密码后,需要把保存在服务端的refresh token删除或者置为无效。
- 应用 HTTPS 协议以保护你的 token 不被截获。
- 使用黑名单机制,当用户的 token 被盗或者用户退出登录后,你可以把这个 token 添加到黑名单中,防止它再次被用于请求。
- 考虑到服务的可用性,你可能需要把 token 保存在像Redis这样的内存数据库中,以提升性能。
微信网页授权是通过OAuth2.0机制实现的,也使用了双token方案。
微信网页授权方案
- 用户在第三方应用的网页上完成微信授权以后,第三方应用可以获得 code(授权码)。code的超时时间为10分钟,一个code只能成功换取一次access_token即失效。
- 第三方应用通过code获取网页授权凭证access_token和刷新凭证 refresh_token。
- access_token是调用授权关系接口的调用凭证,由于access_token有效期(2个小时)较短,当access_token超时后,可以使用refresh_token进行刷新。
- refresh_token拥有较长的有效期(30天),当refresh_token失效的后,需要用户重新授权。
实现
1.依赖
<!-- JSON 解析器和生成器 --><dependency><groupId>com.alibaba</groupId><artifactId>fastjson</artifactId><version>1.2.83</version></dependency><dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt</artifactId><version>0.9.1</version></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-redis</artifactId></dependency>
... 其他省略了
2.配置
# JWT ??jwt:secret: Y28Ijg521FgN31ZgpD1hZpOYd8fTMrZwNcMgds+D91I= # ????expire:1800# token???? S 30??refreshExpire:86400# token???? S 1?spring:redis:host: localhost
port:6379password:123456database:14# 省略其他数据库、mybatis...配置
3.拦截器及配置
packagecom.kgc.interceptor;importcom.kgc.pojo.User;importcom.kgc.utils.JwtTools;importlombok.extern.slf4j.Slf4j;importorg.springframework.stereotype.Component;importorg.springframework.util.StringUtils;importorg.springframework.web.servlet.HandlerInterceptor;importjavax.annotation.Resource;importjavax.servlet.http.HttpServletRequest;importjavax.servlet.http.HttpServletResponse;/**
* JWTInterceptor是一个拦截器,用于验证请求头中的JWT令牌是否有效。
* 当有请求进入时,该拦截器会首先从请求头中获取令牌,并尝试验证其有效性。
* 如果令牌验证成功,则放行请求;否则,拦截请求并返回相应的错误信息。
* @author: zjl
* @datetime: 2024/5/31
* @desc: 复兴Java,我辈义不容辞
*/@Component@Slf4jpublicclassJWTInterceptorimplementsHandlerInterceptor{@ResourceprivateJwtTools jwtTools;@OverridepublicbooleanpreHandle(HttpServletRequest request,HttpServletResponse response,Object handler){// 获取tokenString token = request.getHeader("Authorization");//tokenif(StringUtils.isEmpty(token)){
token = request.getParameter("Authorization");//token}if(StringUtils.isEmpty(token)){// 只是简单DEMO,这里直接返回false,可以自己进行添加
log.error("token 不能为空!");returnfalse;}// 判断token是否超时if(jwtTools.isTokenExpired(token)){
log.error("token 已失效!");returnfalse;}// 判断 token 是否已在黑名单if(jwtTools.checkBlacklist(token)){
log.error("token 已被加入黑名单!");returnfalse;}// 获取用户信息User user = jwtTools.getUserToken(token);// 通过用户信息去判断用户状态,等业务returntrue;}}
packagecom.kgc.config;/**
* @author: zjl
* @datetime: 2024/5/31
* @desc: 复兴Java,我辈义不容辞
*/importcom.kgc.interceptor.JWTInterceptor;importorg.springframework.context.annotation.Configuration;importorg.springframework.web.servlet.config.annotation.InterceptorRegistry;importorg.springframework.web.servlet.config.annotation.WebMvcConfigurer;importjavax.annotation.Resource;/**
* InterceptorConfig 是一个配置类,用于添加拦截器。
* 在这个类中,我们可以配置需要拦截的接口路径以及排除不需要拦截的接口路径。
* 在这个例子中,我们添加了JWTInterceptor拦截器来对请求进行token验证,
* 并设置了"/user/test"接口需要进行验证,而"/user/login"接口则被排除在验证之外,即所有用户都放行登录接口。
*/@ConfigurationpublicclassInterceptorConfigimplementsWebMvcConfigurer{@ResourceprivateJWTInterceptor jwtInterceptor;/**
* 添加拦截器配置
*/@OverridepublicvoidaddInterceptors(InterceptorRegistry registry){
registry.addInterceptor(jwtInterceptor).addPathPatterns("/user/*")// 对/user下其他接口进行token验证.excludePathPatterns("/user/login");// 所有用户都放行登录接口}}
4.其他类
实体类
@Data@NoArgsConstructor@ToString@AllArgsConstructorpublicclassUser{privateint id;privateString userCode;privateString userName;privateString userPassword;privateString phone;}
service
@Service@Slf4jpublicclassUserService{@ResourceprivateUserMapper userMapper;publicUserlogin(String userCode,String userPassword){User user = userMapper.selectUserByUserCode(userCode);if(user!=null&& userPassword.equals(user.getUserPassword())){return user;}returnnull;}}
mapper
publicinterfaceUserMapper{@Select("SELECT * FROM SMBMS_USER WHERE USERCODE=#{userCode}")UserselectUserByUserCode(String userCode);}
封装返回结果
@DatapublicclassResult<T>{privateInteger code;privateString msg;privateT data;publicResult(ResultTypeEnum resultTypeEnum,T data){this.code = resultTypeEnum.getCode();this.msg = resultTypeEnum.getMsg();this.data = data;}publicResult(ResultTypeEnum resultTypeEnum){this.code = resultTypeEnum.getCode();this.msg = resultTypeEnum.getMsg();}}
@Getter@AllArgsConstructorpublicenumResultTypeEnum{SUCCESS(200,"请求处理成功!"),LOGINFAIL(0,"登录失败!"),UN_AUTHORIZED(401,"未授权"),NOT_FOUND(404,"无法找到资源"),NOT_ALLOWED(405,"禁止请求该资源"),PARAMS_NOT_NULL(406,"参数缺失,请检查参数!"),PARAMS_NOT_VALID(407,"参数校验失败,请检查参数!"),VALID_ERROR(407,"参数校验失败,请检查参数!"),OPERATION_TYPE_ERROR(408,"操作类型错误"),TOKEN_IS_NULL(10001,"token 不能为空"),TOKEN_INVALID(10002,"token 已失效"),TOKEN_BLACKLIST(10003,"token 已被加入黑名单"),USER_STATE_DISABLE(10004,"用户已被禁用,请联系管理员"),USER_STATE_DELETE(10005,"用户已被删除,请联系管理员"),FAIL(9001,"请求处理失败!");privateInteger code;privateString msg;}
常量类
publicclassConstants{/**
* 黑名单redis储存前缀
*/publicstaticfinalStringTOKEN_BLACKLIST_PREFIX="blacklist_";publicstaticfinalStringMAC="mac";publicstaticfinalStringOS_NAME="os.name";}
redis工具类
@ComponentpublicclassRedisKeyUtil{privatestaticStringRedisTemplate redisTemplate;@AutowiredpublicvoidsetRedisTemplate(StringRedisTemplate redisTemplate){RedisKeyUtil.redisTemplate = redisTemplate;}/**
* 是否存在key
*
* @param key
* @return
*/publicstaticBooleanhasKey(String key){return redisTemplate.hasKey(key);}}
@ComponentpublicclassRedisStringUtil{privatestaticStringRedisTemplate redisTemplate;@AutowiredpublicvoidsetRedisTemplate(StringRedisTemplate redisTemplate){RedisStringUtil.redisTemplate = redisTemplate;}/** -------------------string相关操作--------------------- *//**
* 设置指定 key 的值
*
* @param key
* @param value
*/publicstaticvoidset(String key,String value){
redisTemplate.opsForValue().set(key, value);}/**
* 获取指定 key 的值
*
* @param key
* @return
*/publicstaticStringget(String key){return redisTemplate.opsForValue().get(key);}/**
* 将值 value 关联到 key ,并将 key 的过期时间设为 timeout
*
* @param key
* @param value
* @param timeout 过期时间
* @param unit 时间单位, 天:TimeUnit.DAYS 小时:TimeUnit.HOURS 分钟:TimeUnit.MINUTES
* 秒:TimeUnit.SECONDS 毫秒:TimeUnit.MILLISECONDS
*/publicstaticvoidsetEx(String key,String value,long timeout,TimeUnit unit){
redisTemplate.opsForValue().set(key, value, timeout, unit);}}
5.token映射类
@DatapublicclassUserToken{privateString accessToken;privateString refreshToken;}
6.jwt工具类
packagecom.kgc.utils;/**
* @author: zjl
* @datetime: 2024/5/31
* @desc: 复兴Java,我辈义不容辞
*/importcom.alibaba.fastjson.JSONObject;importcom.kgc.dto.UserToken;importcom.kgc.pojo.User;importcom.kgc.utils.redis.RedisKeyUtil;importcom.kgc.utils.redis.RedisStringUtil;importio.jsonwebtoken.Claims;importio.jsonwebtoken.Jwts;importio.jsonwebtoken.SignatureAlgorithm;importorg.springframework.beans.factory.annotation.Value;importorg.springframework.stereotype.Component;importjava.util.Date;importjava.util.concurrent.TimeUnit;@ComponentpublicclassJwtTools{@Value("${jwt.secret}")privateString secret;@Value("${jwt.expire}")privateInteger tokenExpire;@Value("${jwt.refreshExpire}")privateInteger refreshExpire;/**
* 创建 刷新令牌 与 访问令牌 关联关系
*
* @param userToken
* @param refreshTokenExpireDate
*/publicvoidtokenAssociation(UserToken userToken,Date refreshTokenExpireDate){Long time =(refreshTokenExpireDate.getTime()-System.currentTimeMillis())/1000+100;RedisStringUtil.setEx(userToken.getRefreshToken(), userToken.getAccessToken(), time,TimeUnit.SECONDS);}/**
* 根据 刷新令牌 获取 访问令牌
*
* @param refreshToken
*/publicStringgetAccessTokenByRefresh(String refreshToken){Object value =RedisStringUtil.get(refreshToken);return value ==null?null:String.valueOf(value);}/**
* 添加至黑名单
*
* @param token
* @param expireTime
*/publicvoidaddBlacklist(String token,Date expireTime){Long expireTimeLong =(expireTime.getTime()-System.currentTimeMillis())/1000+100;RedisStringUtil.setEx(getBlacklistPrefix(token),"1", expireTimeLong,TimeUnit.SECONDS);}/**
* 校验是否存在黑名单
*
* @param token
* @return true 存在 false不存在
*/publicBooleancheckBlacklist(String token){returnRedisKeyUtil.hasKey(getBlacklistPrefix(token));}/**
* 获取黑名单前缀
* @param token
* @return
*/publicStringgetBlacklistPrefix(String token){returnConstants.TOKEN_BLACKLIST_PREFIX+ token;}/**
* 获取 token 信息
* @return
*/publicUserTokencreateToekns(User user){Date nowDate =newDate();Date accessTokenExpireDate =newDate(nowDate.getTime()+ tokenExpire *1000);Date refreshTokenExpireDate =newDate(nowDate.getTime()+ refreshExpire *1000);UserToken userToken =newUserToken();
userToken.setAccessToken(createToken(user, nowDate, accessTokenExpireDate));
userToken.setRefreshToken(createToken(user, nowDate, refreshTokenExpireDate));// 创建 刷新令牌 与 访问令牌 关联关系tokenAssociation(userToken, refreshTokenExpireDate);return userToken;}/**
* 生成token
* @return
*/publicStringcreateToken(User user,Date nowDate,Date expireDate){returnJwts.builder().setHeaderParam("typ","JWT").setSubject(JSONObject.toJSONString(user)).setIssuedAt(nowDate).setExpiration(expireDate).signWith(SignatureAlgorithm.HS512, secret).compact();}/**
* 获取 token 中注册信息
*
* @param token
* @return
*/publicClaimsgetTokenClaim(String token){try{returnJwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody();}catch(Exception e){returnnull;}}/**
* 验证 token 是否过期失效
*
* @param token
* @return true 过期 false 未过期
*/publicBooleanisTokenExpired(String token){try{returngetExpirationDate(token).before(newDate());}catch(Exception e){returntrue;}}/**
* 获取 token 失效时间
*
* @param token
* @return
*/publicDategetExpirationDate(String token){returngetTokenClaim(token).getExpiration();}/**
* 获取 token 发布时间
*
* @param token
* @return
*/publicDategetIssuedAtDate(String token){returngetTokenClaim(token).getIssuedAt();}/**
* 获取用户信息
*
* @param token
* @return
*/publicUsergetUserToken(String token){String subject =getTokenClaim(token).getSubject();User user =JSONObject.parseObject(subject,User.class);return user;}/**
* 获取用户名
* @param token
* @return
*/publicStringgetUserName(String token){User user =getUserToken(token);return user.getUserName();}/**
* 获取用户Id
*
* @param token
* @return
*/publicintgetUserId(String token){User user =getUserToken(token);return user.getId();}}
7.controller类
packagecom.kgc.controller;importcom.kgc.dto.UserToken;importcom.kgc.pojo.User;importcom.kgc.service.UserService;importcom.kgc.utils.JwtTools;importcom.kgc.vo.Result;importcom.kgc.vo.ResultTypeEnum;importlombok.extern.slf4j.Slf4j;importorg.springframework.util.StringUtils;importorg.springframework.web.bind.annotation.PathVariable;importorg.springframework.web.bind.annotation.PostMapping;importorg.springframework.web.bind.annotation.RequestMapping;importorg.springframework.web.bind.annotation.RestController;importjavax.annotation.Resource;/**
* @author: zjl
* @datetime: 2024/5/31
* @desc: 复兴Java,我辈义不容辞
*/@RestController@Slf4j@RequestMapping("/user")publicclassUserController{@ResourceprivateUserService userService;@ResourceprivateJwtTools jwtTools;/**
* 登录
* @return
*/@RequestMapping("/login")publicResult<UserToken>login(String userCode,String userPassword){User user = userService.login(userCode, userPassword);if(user==null){returnnewResult<>(ResultTypeEnum.LOGINFAIL);}// 生成TokenUserToken userToken = jwtTools.createToekns(user);returnnewResult<>(ResultTypeEnum.SUCCESS, userToken);}@RequestMapping("/test")publicStringtest(){return"test";}/**
* 刷新令牌
* @param refreshToken
* @return
*/@RequestMapping("/refreshToken/{refreshToken}")publicResult<UserToken>refreshToken(@PathVariable("refreshToken")String refreshToken){// 判断token是否超时if(jwtTools.isTokenExpired(refreshToken)){returnnewResult<>(ResultTypeEnum.TOKEN_INVALID);}// 刷新令牌 放入黑名单
jwtTools.addBlacklist(refreshToken, jwtTools.getExpirationDate(refreshToken));// 访问令牌 放入黑名单String odlAccessToken = jwtTools.getAccessTokenByRefresh(refreshToken);if(!StringUtils.isEmpty(odlAccessToken)){
jwtTools.addBlacklist(odlAccessToken, jwtTools.getExpirationDate(odlAccessToken));}// 生成新的 访问令牌 和 刷新令牌User user = jwtTools.getUserToken(refreshToken);// 生成TokenUserToken userToken = jwtTools.createToekns(user);returnnewResult<>(ResultTypeEnum.TOKEN_INVALID, userToken);}/**
* 登出
* @return
*/@PostMapping("/logOut/{token}")publicResultlogOut(@PathVariable("token")String token){// 放入黑名单
jwtTools.addBlacklist(token, jwtTools.getExpirationDate(token));returnnewResult<>(ResultTypeEnum.SUCCESS);}/**
* 注销
* @return
*/@PostMapping("/logOff/{token}")publicResultlogOff(@PathVariable("token")String token){// 修改用户状态// 放入黑名单
jwtTools.addBlacklist(token, jwtTools.getExpirationDate(token));returnnewResult<>(ResultTypeEnum.SUCCESS);}}
8.测试
- 登录(认证通过不通过的)
- 访问test接口(带不带token的,带正确不正确token的)
- 刷新接口,要带正确token
总结
token自动续期方式优点缺点token定时检查续期方便实现,只需后端更改即可存在未及时续期情况双token验证效率更高,适用的特殊情况更多需要前后端协调更改
双token流程图
版权归原作者 苏生Susheng 所有, 如有侵权,请联系我们删除。