对于当前登录用户,我们其实经常有获取它的一些简单的基本信息的需求,如果次次都要查数据库,其实会造成性能浪费,也很麻烦。而且,在某些接口中,并不要求参数中传值一些当前用户的信息。
举个例子:日记网站--更新当前登录用户某天日记的接口。
对于业务需求来说,我的前端会认为(较合理)我在更新我自己的日记,我为什么要给你传我自己的用户编码,你应当知道,这样才对啊。
况且更新当前用户信息的时候其实也是不能让前端传id的,因为会被攻击,比如我知道了另外一个人的用户id,就可以通过篡改id来修改其他人的信息了。一般来说,我们会在后台维护一个全局的缓存信息,但是在更新用户的时候需要同步更新缓存,当前用户的信息都是禁止前端传的,有风险。
那么,为了实现这种需求,我们要怎么做呢?
我想过如下的几种操作。
(1)把当前用户信息压进jwt,然后调用JwtUtil中的解析方法获取需求的claim信息;因为jwt中,本身就可以封装一些基本信息,通常我们都会封进去用户id,然后在鉴权的时候拿到它作为key,比对redis中的token,达到鉴权认证的功能;
(2)把用户信息放进redis,在用户登录的时候,除了把token塞进redis以外,同时把用户基本信息塞进redis,然后每次使用时都去redis查;
(3)把用户信息放进threadLocal中,使用时调用它的静态方法,可以在后台代码中直接获取。
我一开始尝试了这样的操作,(1)+(3)搭配。因为每次调用接口的时候都会走jwtFilter的认证服务,在那里会比对token的正确性与时效性,那么,我去取出jwt中封装的用户信息直接塞进ThreadLocal,在这次线程调用的范围内,可以保障之后所涉及的步骤里,都可以通过它的静态方法,获取到封装在jwt中的用户信息。但是很快,这种方案就被我腰斩了,因为jwt中,事实上还是不要附带太多用户信息比较好,其一,暴露用户资料,其二,如果我直接从jwt中解析其中附带的信息,很可能会因为用户更新了资料而token尚未更新造成数据不一致的问题。因此,我选择的是结合(2)+(3)的方案。
完整的过程如下:
(1)登录成功时,我会在redis中缓存两条数据,拼接前缀1/后缀1+id组成的key和token的value(带过期时间),拼接前缀2/后缀2+id组成的key和用户基本信息组成的value(不设置过期时间);
(2)更新用户信息时,同时更新用户信息的redis缓存数据;
(3)调用接口时,获取前端塞进请求头header传来的token,比对正确性与时效性。如果正确直接放行;如果过期,则用该token获取用户id,拼接前缀1/后缀1+id,去redis中查找为该key值的token,如果匹配上,则更新token,塞入header返回前端,让它更新,如果不匹配则返回401错误;如果token不正确,直接返回401错误;
(4)接(3)细节,如果正确,则用该token获取用户id,拼接前缀2/后缀2+id,去redis查找为该key值的用户信息,塞进ThreadLocal,可供这次线程调用过程中的任何地方使用该全局信息;如果过期,则刷新redis中的token的时候,同时刷新用户信息(如果有人手改数据库,没有走接口update,这里给了一个调整强一致性的保障。)
(5)其实尽管这么处理了,因为缓存的缘故,总可能出现数据不一致的情况,但是我们保障不可同时登陆同一个账号,更新操作最终一致性,不通过前端传值用户id直接篡改数据库,在一定程度上保障了速度与安全的比例。
简单的jwt的设计(设计中属性只有token):
public class JWTToken implements AuthenticationToken {
// 密钥
private String token;
public JWTToken(String token) {
this.token = token;
}
@Override
public Object getPrincipal() {
return token;
}
@Override
public Object getCredentials() {
return token;
}
}
把userNum和userName封装进jwt中(claim中可以增加很多很多):
public static String sign(UserInfoVo userInfoVo) {
try {
//token过期时间
Date date = new Date(System.currentTimeMillis() + (Long.parseLong(tokenExpireTime) * 60 * 1000));
//密码MD5加密
Algorithm algorithm = Algorithm.HMAC256(userInfoVo.getPassword());
// usernum信息
//删掉.withClaim("userName", userInfoVo.getUserName()),只在token中带上userNum(用户id)就可以了
return JWT.create()
.withClaim("userNum", userInfoVo.getUserNum()).withExpiresAt(date).sign(algorithm);
} catch (Exception e) {
log.error("生成签名异常:{}", e);
return null;
}
}
获取userNum的方法(同理可以获取其他信息):
public static String getUserNum(String token) {
try {
DecodedJWT jwt = JWT.decode(token);
return jwt.getClaim("userNum").asString();
} catch (JWTDecodeException e) {
log.error("获取token中用户编码信息异常:{}", e);
return null;
}
}
threadLocal线程变量工具
@Slf4j
public class SessionLocal {
private static ThreadLocal<UserInfoVo> local = new ThreadLocal<UserInfoVo>();
/**
* 设置用户信息
*
* @param userInfo
*/
public static void setUserInfo( UserInfoVo userInfo )
{
local.set(userInfo) ;
log.info("存入用户信息:{}",userInfo);
}
/**
* 获取登录用户信息
*
* @return
*/
public static UserInfoVo getUserInfo()
{
log.info("当前线程id:{}",Thread.currentThread().getName());
return local.get();
}
/**
* 删除掉储存的用户信息
*/
public static void remove(){
local.remove();
}
}
redis存储数据:
@Override
public void addTokenToRedis(String userNum, String jwtTokenStr) {
//userNum是唯一的。
String key = CommonConstant.JWT_TOKEN + userNum ;
//集合类型不存在设置每一个key的过期时间,所以其实最后还是只能用string类型。
//redisTemplate.opsForHash().put("token",key,jwtTokenStr);
redisTemplate.opsForValue().set(key, jwtTokenStr, refreshJwtTokenExpireTime, TimeUnit.MINUTES);
}
@Override
public void addUserInfoToRedis(String userNum, UserInfoVo userInfoVo) {
String key = CommonConstant.USER_SIMPLE_INFO + userNum ;
redisTemplate.opsForValue().set(key,userInfoVo);
}
登录接口:
/**
* 登陆
* shiro+jwt登录。
*
*/
@PostMapping("/login")
@ApiOperation(value = "登录", notes = "用户登录接口")
@ApiResponses({
@ApiResponse(code = 80000,message = "登录失败",response = ApiResult.class),
@ApiResponse(code = 80001,message = "用户名或密码错误",response = ApiResult.class)
})
public ApiResult login(@RequestBody UserInfoDto userInfoDto) {
try {
String userName=userInfoDto.getUserName();
UserInfoVo userInfoVo = userMapper.selectByUserName(userName);
if(null == userInfoVo || !userInfoVo.getPassword().equals(userInfoDto.getPassword())){
return ApiResult.buildFail(AUTH_LOGIN_PARAM.getCode(), AUTH_LOGIN_PARAM.getDesc());
} else {
String tokenStr = JWTUtil.sign(userInfoVo);
//相当于存入token的时候,同时存入了用户的基本信息在redis里面,然后之后在redis没有过期的时候,可以直接去redis里面拿,不用解析token,也不用threadLocal。
//用户信息在有修改的时候要更新一次。
userService.addTokenToRedis(userInfoVo.getUserNum(),tokenStr);
userService.addUserInfoToRedis(userInfoVo.getUserNum(),userInfoVo);
return ApiResult.buildSuccessNormal("登录成功",tokenStr);
}
} catch (Exception e) {
log.info("登录失败,参数:{},异常:{}",userInfoDto,e);
return ApiResult.buildFail(AUTH_LOGIN.getCode(), AUTH_LOGIN.getDesc());
}finally {
//日志存储
LogAgent.log(LogActiveProjectEnums.GEMINI,LogActiveTypeEnums.SYSTEM,userMapper.selectByUserName(userInfoDto.getUserName()).getUserNum(),LogActiveNameEnums.LOG_LOGIN,"登录");
}
}
jwt的鉴权功能:
@Override
protected boolean onAccessDenied(ServletRequest servletRequest, ServletResponse servletResponse) throws Exception {
//获取请求头token,鉴权是否已经登录
AuthenticationToken token = this.createToken(servletRequest, servletResponse);
if (token.getPrincipal() == null) {
handler401(servletResponse, AUTH_ANONYMOUS.getCode(), AUTH_ANONYMOUS.getDesc());
return false;
} else {
try {
this.getSubject(servletRequest, servletResponse).login(token);
//如果token有效,那么直接获取缓存中的userInfo信息
String userNum = JWTUtil.getUserNum(token.getPrincipal().toString());
String key = CommonConstant.USER_SIMPLE_INFO + userNum;
UserInfoVo userInfoVo = (UserInfoVo) redisTemplate.opsForValue().get(key);
//用户信息塞入SessionLocal
SessionLocal.setUserInfo(userInfoVo);
return true;
} catch (Exception e) {
String msg = e.getMessage();
//token错误
if (msg.contains("incorrect")) {
handler401(servletResponse, AUTH_ANONYMOUS.getCode(), msg);
return false;
//token过期
} else if (msg.contains("expired")) {
//尝试刷新token
if (this.refreshToken(servletRequest, servletResponse)) {
return true;
} else {
handler401(servletResponse, AUTH_ANONYMOUS.getCode(), "token已过期,请重新登录");
return false;
}
}
handler401(servletResponse, AUTH_ANONYMOUS.getCode(), msg);
return false;
}
}
}
//获取header中的token
@Override
protected AuthenticationToken createToken(ServletRequest servletRequest, ServletResponse servletResponse) {
HttpServletRequest request = (HttpServletRequest) servletRequest;
String token = request.getHeader("Authorization");
return new JWTToken(token);
}
//更新token
private boolean refreshToken(ServletRequest servletRequest, ServletResponse servletResponse) {
HttpServletRequest request = (HttpServletRequest) servletRequest;
HttpServletResponse response = (HttpServletResponse) servletResponse;
//获取header,tokenStr
String oldToken = request.getHeader("Authorization");
String userNum = JWTUtil.getUserNum(oldToken);
String key = CommonConstant.JWT_TOKEN + userNum;
String keyU = CommonConstant.USER_SIMPLE_INFO +userNum;
//获取redis tokenStr和缓存中的用户信息
String redisToken = (String) redisTemplate.opsForValue().get(key);
if (redisToken != null) {
//如果token存在且token等于当前redis中的token,则刷新token时间
if (oldToken.equals(redisToken)) {
UserInfoVo vo = this.userMapper.selectByUserNum(userNum);
//重写生成token(刷新)
String newTokenStr = JWTUtil.sign(vo);
JWTToken jwtToken = new JWTToken(newTokenStr);
userService.addTokenToRedis(userNum, newTokenStr);
userService.addUserInfoToRedis(userNum,vo);
//放进threadLocal
SessionLocal.setUserInfo(vo);
SecurityUtils.getSubject().login(jwtToken);
response.setHeader("Authorization", newTokenStr);
return true;
}
}
return false;
}
在例子【更新日记】的需求中,运行到更新接口时,无需前端传无意义值或调用数据库,直接使用线程变量获取用户信息
@Override
public ApiResult<TextRecordVo> writeRecord(TextRecordDto textRecordDto) throws Exception {
//获取用户信息
UserInfoVo userInfoVo = SessionLocal.getUserInfo();
textRecordDto.setUserNum(userInfoVo.getUserNum());
……
return ApiResult.successMsg("成功创建/更新日记");
}
网友评论