目录
- 回顾一下JWT
- 基于JWT的认证流程
- 安全性
- 性能
- 一次性
- Token过期
- 影响
- 解决
- 智障思路
- 分析
- token定时检查续期
- 思路分析
- 大致代码
- 问题
- 双token【重点】
- 思路分析
- 补充
- 微信网页授权方案
- 实现
- 1.依赖
- 2.配置
- 3.拦截器及配置
- 4.其他类
- 5.token映射类
- 6.jwt工具类
- 7.controller类
- 8.测试
- 总结
- 双token流程图
回顾一下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的时间,那么也有两种思路
- token定时检查续期:也就是在快过期时自动续期,比如还剩半小时的时候,检测到时间不足自动续期
- 双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: 6379
password: 123456
database: 14
# 省略其他数据库、mybatis...配置
3.拦截器及配置
package com.kgc.interceptor;
import com.kgc.pojo.User;
import com.kgc.utils.JwtTools;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
/**
* JWTInterceptor是一个拦截器,用于验证请求头中的JWT令牌是否有效。
* 当有请求进入时,该拦截器会首先从请求头中获取令牌,并尝试验证其有效性。
* 如果令牌验证成功,则放行请求;否则,拦截请求并返回相应的错误信息。
* @author: zjl
* @datetime: 2024/5/31
* @desc: 复兴Java,我辈义不容辞
*/
@Component
@Slf4j
public class JWTInterceptor implements HandlerInterceptor {
@Resource
private JwtTools jwtTools;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
// 获取token
String token = request.getHeader("Authorization"); //token
if (StringUtils.isEmpty(token)) {
token = request.getParameter("Authorization"); //token
}
if (StringUtils.isEmpty(token)) {
// 只是简单DEMO,这里直接返回false,可以自己进行添加
log.error("token 不能为空!");
return false;
}
// 判断token是否超时
if (jwtTools.isTokenExpired(token)) {
log.error("token 已失效!");
return false;
}
// 判断 token 是否已在黑名单
if (jwtTools.checkBlacklist(token)) {
log.error("token 已被加入黑名单!");
return false;
}
// 获取用户信息
User user = jwtTools.getUserToken(token);
// 通过用户信息去判断用户状态,等业务
return true;
}
}
package com.kgc.config;
/**
* @author: zjl
* @datetime: 2024/5/31
* @desc: 复兴Java,我辈义不容辞
*/
import com.kgc.interceptor.JWTInterceptor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import javax.annotation.Resource;
/**
* InterceptorConfig 是一个配置类,用于添加拦截器。
* 在这个类中,我们可以配置需要拦截的接口路径以及排除不需要拦截的接口路径。
* 在这个例子中,我们添加了JWTInterceptor拦截器来对请求进行token验证,
* 并设置了"/user/test"接口需要进行验证,而"/user/login"接口则被排除在验证之外,即所有用户都放行登录接口。
*/
@Configuration
public class InterceptorConfig implements WebMvcConfigurer {
@Resource
private JWTInterceptor jwtInterceptor;
/**
* 添加拦截器配置
*/
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(jwtInterceptor)
.addPathPatterns("/user/*") // 对/user下其他接口进行token验证
.excludePathPatterns("/user/login"); // 所有用户都放行登录接口
}
}
4.其他类
实体类
@Data
@NoArgsConstructor
@ToString
@AllArgsConstructor
public class User {
private int id;
private String userCode;
private String userName;
private String userPassword;
private String phone;
}
service
@Service
@Slf4j
public class UserService {
@Resource
private UserMapper userMapper;
public User login(String userCode,String userPassword){
User user = userMapper.selectUserByUserCode(userCode);
if(user!=null && userPassword.equals(user.getUserPassword())){
return user;
}
return null;
}
}
mapper
public interface UserMapper {
@Select("SELECT * FROM SMBMS_USER WHERE USERCODE=#{userCode}")
User selectUserByUserCode(String userCode);
}
封装返回结果
@Data
public class Result<T> {
private Integer code;
private String msg;
private T data;
public Result(ResultTypeEnum resultTypeEnum, T data) {
this.code = resultTypeEnum.getCode();
this.msg = resultTypeEnum.getMsg();
this.data = data;
}
public Result(ResultTypeEnum resultTypeEnum) {
this.code = resultTypeEnum.getCode();
this.msg = resultTypeEnum.getMsg();
}
}
@Getter
@AllArgsConstructor
public enum ResultTypeEnum {
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, "请求处理失败!");
private Integer code;
private String msg;
}
常量类
public class Constants {
/**
* 黑名单redis储存前缀
*/
public static final String TOKEN_BLACKLIST_PREFIX = "blacklist_";
public static final String MAC = "mac";
public static final String OS_NAME = "os.name";
}
redis工具类
@Component
public class RedisKeyUtil {
private static StringRedisTemplate redisTemplate;
@Autowired
public void setRedisTemplate(StringRedisTemplate redisTemplate) {
RedisKeyUtil.redisTemplate = redisTemplate;
}
/**
* 是否存在key
*
* @param key
* @return
*/
public static Boolean hasKey(String key) {
return redisTemplate.hasKey(key);
}
}
@Component
public class RedisStringUtil {
private static StringRedisTemplate redisTemplate;
@Autowired
public void setRedisTemplate(StringRedisTemplate redisTemplate) {
RedisStringUtil.redisTemplate = redisTemplate;
}
/** -------------------string相关操作--------------------- */
/**
* 设置指定 key 的值
*
* @param key
* @param value
*/
public static void set(String key, String value) {
redisTemplate.opsForValue().set(key, value);
}
/**
* 获取指定 key 的值
*
* @param key
* @return
*/
public static String get(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
*/
public static void setEx(String key, String value, long timeout, TimeUnit unit) {
redisTemplate.opsForValue().set(key, value, timeout, unit);
}
}
5.token映射类
@Data
public class UserToken {
private String accessToken;
private String refreshToken;
}
6.jwt工具类
package com.kgc.utils;
/**
* @author: zjl
* @datetime: 2024/5/31
* @desc: 复兴Java,我辈义不容辞
*/
import com.alibaba.fastjson.JSONObject;
import com.kgc.dto.UserToken;
import com.kgc.pojo.User;
import com.kgc.utils.redis.RedisKeyUtil;
import com.kgc.utils.redis.RedisStringUtil;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.util.Date;
import java.util.concurrent.TimeUnit;
@Component
public class JwtTools {
@Value("${jwt.secret}")
private String secret;
@Value("${jwt.expire}")
private Integer tokenExpire;
@Value("${jwt.refreshExpire}")
private Integer refreshExpire;
/**
* 创建 刷新令牌 与 访问令牌 关联关系
*
* @param userToken
* @param refreshTokenExpireDate
*/
public void tokenAssociation(UserToken userToken, Date refreshTokenExpireDate) {
Long time = (refreshTokenExpireDate.getTime() - System.currentTimeMillis()) / 1000 + 100;
RedisStringUtil.setEx(userToken.getRefreshToken(), userToken.getAccessToken(), time, TimeUnit.SECONDS);
}
/**
* 根据 刷新令牌 获取 访问令牌
*
* @param refreshToken
*/
public String getAccessTokenByRefresh(String refreshToken) {
Object value = RedisStringUtil.get(refreshToken);
return value == null ? null : String.valueOf(value);
}
/**
* 添加至黑名单
*
* @param token
* @param expireTime
*/
public void addBlacklist(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不存在
*/
public Boolean checkBlacklist(String token) {
return RedisKeyUtil.hasKey(getBlacklistPrefix(token));
}
/**
* 获取黑名单前缀
* @param token
* @return
*/
public String getBlacklistPrefix(String token) {
return Constants.TOKEN_BLACKLIST_PREFIX + token;
}
/**
* 获取 token 信息
* @return
*/
public UserToken createToekns(User user) {
Date nowDate = new Date();
Date accessTokenExpireDate = new Date(nowDate.getTime() + tokenExpire * 1000);
Date refreshTokenExpireDate = new Date(nowDate.getTime() + refreshExpire * 1000);
UserToken userToken = new UserToken();
userToken.setAccessToken(createToken(user, nowDate, accessTokenExpireDate));
userToken.setRefreshToken(createToken(user, nowDate, refreshTokenExpireDate));
// 创建 刷新令牌 与 访问令牌 关联关系
tokenAssociation(userToken, refreshTokenExpireDate);
return userToken;
}
/**
* 生成token
* @return
*/
public String createToken(User user, Date nowDate, Date expireDate) {
return Jwts.builder()
.setHeaderParam("typ", "JWT")
.setSubject(JSONObject.toJSONString(user))
.setIssuedAt(nowDate)
.setExpiration(expireDate)
.signWith(SignatureAlgorithm.HS512, secret)
.compact();
}
/**
* 获取 token 中注册信息
*
* @param token
* @return
*/
public Claims getTokenClaim(String token) {
try {
return Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody();
} catch (Exception e) {
return null;
}
}
/**
* 验证 token 是否过期失效
*
* @param token
* @return true 过期 false 未过期
*/
public Boolean isTokenExpired(String token) {
try {
return getExpirationDate(token).before(new Date());
}catch (Exception e){
return true;
}
}
/**
* 获取 token 失效时间
*
* @param token
* @return
*/
public Date getExpirationDate(String token) {
return getTokenClaim(token).getExpiration();
}
/**
* 获取 token 发布时间
*
* @param token
* @return
*/
public Date getIssuedAtDate(String token) {
return getTokenClaim(token).getIssuedAt();
}
/**
* 获取用户信息
*
* @param token
* @return
*/
public User getUserToken(String token) {
String subject = getTokenClaim(token).getSubject();
User user = JSONObject.parseObject(subject, User.class);
return user;
}
/**
* 获取用户名
* @param token
* @return
*/
public String getUserName(String token) {
User user = getUserToken(token);
return user.getUserName();
}
/**
* 获取用户Id
*
* @param token
* @return
*/
public int getUserId(String token) {
User user = getUserToken(token);
return user.getId();
}
}
7.controller类
package com.kgc.controller;
import com.kgc.dto.UserToken;
import com.kgc.pojo.User;
import com.kgc.service.UserService;
import com.kgc.utils.JwtTools;
import com.kgc.vo.Result;
import com.kgc.vo.ResultTypeEnum;
import lombok.extern.slf4j.Slf4j;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.annotation.Resource;
/**
* @author: zjl
* @datetime: 2024/5/31
* @desc: 复兴Java,我辈义不容辞
*/
@RestController
@Slf4j
@RequestMapping("/user")
public class UserController {
@Resource
private UserService userService;
@Resource
private JwtTools jwtTools;
/**
* 登录
* @return
*/
@RequestMapping("/login")
public Result<UserToken> login(String userCode,String userPassword) {
User user = userService.login(userCode, userPassword);
if(user==null){
return new Result<>(ResultTypeEnum.LOGINFAIL);
}
// 生成Token
UserToken userToken = jwtTools.createToekns(user);
return new Result<>(ResultTypeEnum.SUCCESS, userToken);
}
@RequestMapping("/test")
public String test() {
return "test";
}
/**
* 刷新令牌
* @param refreshToken
* @return
*/
@RequestMapping("/refreshToken/{refreshToken}")
public Result<UserToken> refreshToken(@PathVariable("refreshToken") String refreshToken) {
// 判断token是否超时
if (jwtTools.isTokenExpired(refreshToken)) {
return new Result<>(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);
// 生成Token
UserToken userToken = jwtTools.createToekns(user);
return new Result<>(ResultTypeEnum.TOKEN_INVALID, userToken);
}
/**
* 登出
* @return
*/
@PostMapping("/logOut/{token}")
public Result logOut(@PathVariable("token") String token) {
// 放入黑名单
jwtTools.addBlacklist(token, jwtTools.getExpirationDate(token));
return new Result<>(ResultTypeEnum.SUCCESS);
}
/**
* 注销
* @return
*/
@PostMapping("/logOff/{token}")
public Result logOff(@PathVariable("token") String token) {
// 修改用户状态
// 放入黑名单
jwtTools.addBlacklist(token, jwtTools.getExpirationDate(token));
return new Result<>(ResultTypeEnum.SUCCESS);
}
}
8.测试
- 登录(认证通过不通过的)
- 访问test接口(带不带token的,带正确不正确token的)
- 刷新接口,要带正确token
总结
token自动续期方式 | 优点 | 缺点 |
---|---|---|
token定时检查续期 | 方便实现,只需后端更改即可 | 存在未及时续期情况 |
双token验证 | 效率更高,适用的特殊情况更多 | 需要前后端协调更改 |