同一个账户在在不同地方登录,别的地方提示下线了。
概述
其实就是以用户最后一次登录的为准。其他登录的地方全部提示:你已经下线,是否重新登录。从而保护你的操作信息是安全的。
实现原理
随机产生一个uuid,然后设置一个键key,比如下面两个key中的一个,然后再设置UserBo,也要将这个uuid回传会前端
然后编写前端传给后端所要经过的拦截器
如果该用户在另一处登录会覆盖redis中的tokenuuid,如果你还是从前端向后端传原来的tokenuuid,拦截器中和redis中的tokenuuid比较,发现不相同,报出异常在异地登录。
只能一个地方登录
key = sys:login:+userid
如果同设备互斥
key = sys:login:“+pc+”:“+userid
具体实现
AdminRedisKeyManager
package com.pug.zixun.config.redis;
public interface AdminRedisKeyManager {
// 登录token续期使用的key
String USER_LOGIN_TOKEN_KEY = "pug:user:login:token:";
// 下线使用的rediskey
String USER_LOGIN_LOGOUT_KEY = "pug:user:logout:";
// 续期返回的新的token的key
String RESPONSE_AUTH_TOKEN = "x-auth-token";
//指定token的claim的key的名字
String PUG_USER_ID = "pug_user_id";
// header中userid 接口校验和线下使用
String TOKEN_USERID_NAME = "token_userid";
// header中token 接口校验使用
String TOKEN_NAME = "token";
// header中token_uuid 下线使用
String TOKEN_UUID_NAME = "token_uuid";
}
JwtService
package com.pug.zixun.config.jwt;
import com.auth0.jwt.JWT;
import com.auth0.jwt.JWTVerifier;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.exceptions.TokenExpiredException;
import com.auth0.jwt.interfaces.DecodedJWT;
import com.pug.zixun.common.enums.AdminUserResultEnum;
import com.pug.zixun.common.ex.PugValidatorException;
import com.pug.zixun.common.utils.date.TmDateUtil;
import com.pug.zixun.common.utils.fn.asserts.Vsserts;
import com.pug.zixun.config.redis.AdminRedisKeyManager;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.Date;
import java.util.concurrent.TimeUnit;
// 问题1:别人工具类 static方法,方便进行操作和获取。
// 问题2:为什么要让上面容器管理,因为我要考虑把这里常量用配置文件管理,甚至用统一配置中心。所以
@Component
public class JwtService implements AdminRedisKeyManager {
//jwt 私钥 注意这里一定要保密,不能泄露,否则就会被别人通过程序伪造
@Value("${pug.jwt.key}")
private String KEY = "pugadmin123456";
//指定作者
@Value("${pug.jwt.author}")
private String AUTHOR = "xiexiangban";
// token的私有前缀
@Value("${pug.jwt.prefix}")
private String PUG_TOKEN_PREFIX = "pugbear ";
// 续期时间
@Value("${pug.jwt.period}")
private Long period = 30L;
// 1 秒
private Long ONE_SECOND = 1000L;
// 1 分钟
private Long ONE_MINIUTE = ONE_SECOND * 60;
// token 30分钟过期
private Long TOKEN_EXPIRE_TIME = ONE_MINIUTE * period;
@Autowired
private StringRedisTemplate stringRedisTemplate;
/**
* 创建token
*
* @param userId
* @return
*/
public String createToken(Long userId) {
// 1:确定token加密签名的算法和密钥
Algorithm algorithm = Algorithm.HMAC256(KEY);
// 2: 创建token
String token = JWT.create()
// 指定作者
.withIssuer(AUTHOR)
// 指定用户id即可,不要去放完整的用户对象信息。因为生成token太长,
// 为什么就放一个id,因为后续开发我们会把解析的用户id,去db或redis查一遍。保证实时性。
// 方便以后对平台的一些恶意分子直接拉黑, 就会生效。
//PUG_USER_ID="pug_user_id"
.withClaim(PUG_USER_ID, userId)
// 签发时间
.withIssuedAt(new Date())
// 指定token的过期时间
.withExpiresAt(new Date(System.currentTimeMillis() + TOKEN_EXPIRE_TIME))
// 签名返回
.sign(algorithm);
return token;
}
/**
* 校验 token
*
* @param token
* @return
*/
public boolean verify(String token) {
try {
// 1:确定token加密签名的算法和密钥
Algorithm algorithm = Algorithm.HMAC256(KEY);
// 2 : 获取token的校验对象
JWTVerifier verifier = JWT.require(algorithm)
.withIssuer(AUTHOR)
.build();
// 3: 开始校验,如果校验通过DecodedJWT.如果token是伪造或者失效的,就会出现异常。
DecodedJWT jwt = verifier.verify(token);
return true;
} catch (Exception ex) {
return false;
}
}
/**
* token 自动续期
* @param token
* @param userId
* @return
*/
public boolean refreshTokenRedis(String token, String userId, HttpServletResponse response){
// Redis双倍缓存key
String tokenKey = USER_LOGIN_TOKEN_KEY + token;
String cacheToken = stringRedisTemplate.opsForValue().get(tokenKey);
if(Vsserts.isEmpty(cacheToken)){
return false;
}
try {
// 把自己校验一次,如果自己能通过,说明token还没有过期
// 1:确定token加密签名的算法和密钥
Algorithm algorithm = Algorithm.HMAC256(KEY);
// 2 : 获取token的校验对象
JWTVerifier verifier = JWT.require(algorithm)
.withIssuer(AUTHOR)
.build();
// 3: 开始校验,如果校验通过DecodedJWT.如果token是伪造或者失效的,就会出现异常。
verifier.verify(token);
}catch (TokenExpiredException tokenExpiredException){
// 如果过期了。redis还能找到。说明还可以继续激活使用
if (stringRedisTemplate.hasKey(tokenKey)) {
// 生成新的token
String newToken = this.createToken(new Long(userId));
stringRedisTemplate.opsForValue().set(tokenKey,newToken,TOKEN_EXPIRE_TIME * 2, TimeUnit.MILLISECONDS);
return true;
}
}catch ( Exception ex){
throw new PugValidatorException(AdminUserResultEnum.TOKEN_ERROR_STATUS);
}
return true;
}
/**
* 签发时间续期
*
* @param token
* @param tokenUserId
* @param response
*/
public void refreshToken(String token, Long tokenUserId, HttpServletResponse response) {
// token续期
// 获取token的签发时间 --------第一种写法
Date signTokenTime = this.getTokenIssuedTime(token);
int diffminutes = TmDateUtil.diffminutes(signTokenTime,new Date());
// 开始刷新token 10的含义是:旧的token还剩下10分钟,在最后的这10分钟范围内去续期,
// 假设你的token存活时间(TOKEN_EXPIRE_TIME=30)。那么久是久的token存活20分钟,在20分钟以后时间内都是续期时间点。
Long period = TOKEN_EXPIRE_TIME - 10;
if(diffminutes >= period ){
// 续期,重新生成一个新的token
String newToken = this.createToken(tokenUserId);
// 通过response的头部输出token,然后前台通过reponse获取
response.setHeader(RESPONSE_AUTH_TOKEN, newToken);
}
}
/**
* 过期时间续期
*
* @param token
* @param tokenUserId
* @param response
*/
public void refreshToken2(String token, Long tokenUserId, HttpServletResponse response) {
// token续期
// 获取token的签发时间 --------第一种写法
Date expireTime = this.getTokenExpireTime(token);
// 假设过期时间是 30分钟,用过期时间减去当前时间:30 29 28 27 20...10
int diffminutes = TmDateUtil.diffminutes(new Date(),expireTime);
// 如果时间以及过去了20分钟,到最后十分钟的时候就开始续期 10 9 8 7
if(diffminutes <= 10 ){
// 续期,重新生成一个新的token
String newToken = this.createToken(tokenUserId);
// 通过response的头部输出token,然后前台通过reponse获取
response.setHeader(RESPONSE_AUTH_TOKEN, newToken);
}
}
/**
* @param token
* @return
*/
public Long getTokenUserId(String token) {
try {
// 1:确定token加密签名的算法和密钥
Algorithm algorithm = Algorithm.HMAC256(KEY);
// 2 : 获取token的校验对象
JWTVerifier verifier = JWT.require(algorithm)
.withIssuer(AUTHOR)
.build(); //Reusable verifier instance
// 3: 开始校验,如果校验通过DecodedJWT.如果token是伪造或者失效的,就会出现异常。
DecodedJWT jwt = verifier.verify(token);
return jwt.getClaim(PUG_USER_ID).asLong();
} catch (Exception ex) {
throw new PugValidatorException(AdminUserResultEnum.TOKEN_ERROR);
}
}
/**
* 根据token 获取签发时间
* @param token
* @return
*/
public Date getTokenIssuedTime(String token){
try {
// 1:确定token加密签名的算法和密钥
Algorithm algorithm = Algorithm.HMAC256(KEY);
// 2 : 获取token的校验对象
JWTVerifier verifier = JWT.require(algorithm)
.withIssuer(AUTHOR)
.build(); //Reusable verifier instance
// 3: 开始校验,如果校验通过DecodedJWT.如果token是伪造或者失效的,就会出现异常。
DecodedJWT jwt = verifier.verify(token);
return jwt.getIssuedAt();
} catch (Exception ex) {
throw new PugValidatorException(AdminUserResultEnum.TOKEN_ERROR);
}
}
/**
* 获取过期时间
* @param token
* @return
*/
public Date getTokenExpireTime(String token){
try {
// 1:确定token加密签名的算法和密钥
Algorithm algorithm = Algorithm.HMAC256(KEY);
// 2 : 获取token的校验对象
JWTVerifier verifier = JWT.require(algorithm)
.withIssuer(AUTHOR)
.build(); //Reusable verifier instance
// 3: 开始校验,如果校验通过DecodedJWT.如果token是伪造或者失效的,就会出现异常。
DecodedJWT jwt = verifier.verify(token);
return jwt.getExpiresAt();
} catch (Exception ex) {
throw new PugValidatorException(AdminUserResultEnum.TOKEN_ERROR);
}
}
/**
* 获取请求头的token
*
* @param request
* @return
*/
public String getToken(HttpServletRequest request) {
String token = request.getHeader(TOKEN_NAME);
if (Vsserts.isEmpty(token)) {
return null;
}
if (!token.startsWith(PUG_TOKEN_PREFIX)) {
throw new PugValidatorException(AdminUserResultEnum.TOKEN_ERROR_STATUS);
}
// 截取前缀
token = token.substring(PUG_TOKEN_PREFIX.length());
// 返回
return token;
}
/**
* 获取请求头的token的用户ID
*
* @param request
* @return
*/
public String getTokenUserId(HttpServletRequest request) {
String tokenUserId = request.getHeader(TOKEN_USERID_NAME);
if (Vsserts.isEmpty(tokenUserId)) {
return null;
}
// 返回
return tokenUserId;
}
/**
* 登录使用,双倍时间
* @param token
*/
public void redisToken(String token){
// Jwt和redis的续期双倍时间
String tokenKey = USER_LOGIN_TOKEN_KEY + token;
// 记住,在redis的单位默认是 秒,也就是说这个tokenkey是双倍,时间是30分钟,双倍也就是60分钟 转换成秒 3600秒
stringRedisTemplate.opsForValue().set(tokenKey, token, TOKEN_EXPIRE_TIME * 2, TimeUnit.MILLISECONDS);
}
}
IJwtBlackService
package com.pug.zixun.config.redis;
public interface IJwtBlackService {
String BLACK_STRING_KEY = "blacklist:string";
Long BLACK_EXPIRE_TIME = 30L;
String BLACK_LIST_KEY = "blacklist:set";
//添加黑白名单
void addBlackList(String token);
// 2: 判断当前用户是否在黑名单中
boolean isBlackList(String token);
// 4: 删除黑名单
boolean removeBlackList(String token);
}
AdminUserResultEnum
采用枚举
package com.pug.zixun.common.enums;
public interface AdminResultInterface {
Integer getCode();
String getMsg();
}
package com.pug.zixun.common.enums;
public enum AdminUserResultEnum implements AdminResultInterface{
USER_NULL_ERROR(100601, "用户不存在"),
USER_SERVER_ERROR(100602, "服务出现故障"),
USER_INPUT_USERNAME_ERROR(100603, "用户名或密码输入有误"),
TOKEN_ERROR(100604, "token expired"),
TOKEN_NOT_FOUND(100605, "token not found"),
USER_FORBIDDEN_ERROR(100606, "用户异常,请联系管理员"),
USER_NAME_NOT_EMPTY(100607, "账号不能是空"),
USER_PWD_NOT_EMPTY(100607, "密码不能为空"),
USER_LOGIN_UUID_EMPTY(100608, "会话过期了..."),
TOKEN_ERROR_STATUS(100609, "token无效"),
USER_LOGIN_SAME(100610, "你已经在别地方登录了");
private Integer code;
private String msg;
AdminUserResultEnum(Integer code, String msg) {
this.code = code;
this.msg = msg;
}
@Override
public Integer getCode() {
return code;
}
@Override
public String getMsg() {
return msg;
}
}
UserVo
package com.pug.zixun.vo;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class UserVo extends ParentVo implements java.io.Serializable {
// id
private Long id;
// 用户姓名
private String username;
// 密码
private String password;
// 验证码
private String code;
// 验证码的UUID
private String uuid;
// token
private String token;
}
UserServiceImpl,IUserService
package com.pug.zixun.service.user;
import com.baomidou.mybatisplus.extension.service.IService;
import com.pug.zixun.domain.User;
import com.pug.zixun.vo.UserVo;
public interface IUserService extends IService<User> {
/**
* 登录
* @param userVo
* @return
*/
User login(UserVo userVo);
}
package com.pug.zixun.service.user;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.pug.zixun.domain.User;
import com.pug.zixun.mapper.UserMapper;
import com.pug.zixun.vo.UserVo;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@Service
@Slf4j
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {
/**
* 登录
*
* @param userVo
* @return
*/
@Override
public User login(UserVo userVo) {
LambdaQueryWrapper<User> lambdaQueryWrapper = new LambdaQueryWrapper<>();
lambdaQueryWrapper.eq(User::getUsername, userVo.getUsername());
User user = this.getOne(lambdaQueryWrapper);
return user;
}
}
MD5Utils
package com.pug.zixun.common.utils.pwd;
import java.math.BigInteger;
import java.security.MessageDigest;
public class MD5Util {
public MD5Util() {
}
public static String md5(String str) {
try {
MessageDigest md5 = MessageDigest.getInstance("MD5");
md5.update(str.getBytes("UTF-8"));
return bytesToHex(md5.digest());
} catch (Exception var2) {
throw new RuntimeException(var2);
}
}
public static String md5slat(String str) {
//MD5加盐
return MD5Util.md5(MD5Util.md5("kuangstudy" + str + "202102170318!!!"));
}
public static String bytesToHex(byte[] bytes) {
BigInteger bigInt = new BigInteger(1, bytes);
String hashtext;
for (hashtext = bigInt.toString(16); hashtext.length() < 32; hashtext = "0" + hashtext) {
}
return hashtext;
}
public static void main(String[] args) {
System.out.println(md5slat("123456"));
}
}
UserBo
返回给前端的格式
package com.pug.zixun.bo;
import com.pug.zixun.domain.User;
import lombok.Data;
@Data
public class UserBo implements java.io.Serializable {
// 接口校验使用
private String token;
// 下线使用
private String tokenUuid;
// 登录的用户信息
private User user;
}
* PassportLoginController
1 : 在登录产生一个随机的uuid放入缓存中
package com.pug.zixun.controller.login;
import com.pug.zixun.bo.UserBo;
import com.pug.zixun.common.enums.AdminUserResultEnum;
import com.pug.zixun.common.ex.PugValidatorException;
import com.pug.zixun.common.utils.pwd.MD5Util;
import com.pug.zixun.config.BaseController;
import com.pug.zixun.config.jwt.JwtService;
import com.pug.zixun.config.validator.PugAssert;
import com.pug.zixun.domain.User;
import com.pug.zixun.service.user.IUserService;
import com.pug.zixun.vo.UserVo;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import java.util.UUID;
@RestController
@Slf4j
public class PassportLoginController extends BaseController {
@Autowired
private IUserService userService;
@Autowired
private JwtService jwtService;
@Autowired
private RedisTemplate redisTemplate;
/**
* 登录
*
* @param userVo
* @return
*/
@PostMapping("/login/toLogin")
public UserBo logined(@RequestBody UserVo userVo) {
// 这里有校验,spring-validator框架来完成 或者用断言 或者用自己封装的
PugAssert.isEmptyEx(userVo.getUsername(), AdminUserResultEnum.USER_NAME_NOT_EMPTY);
PugAssert.isEmptyEx(userVo.getPassword(), AdminUserResultEnum.USER_PWD_NOT_EMPTY);
// 根据用户名称查询用户信息
User dbLoginUser = userService.login(userVo);
PugAssert.isNullEx(dbLoginUser, AdminUserResultEnum.USER_NULL_ERROR);
// 用户输入的密码,加盐处理不容易被破解
String inputPwd = MD5Util.md5slat(userVo.getPassword());
// 如果输入密码和数据库密码不一致
boolean isLogin = dbLoginUser.getPassword().equalsIgnoreCase(inputPwd);
// 如果输入的账号和有误,isLogin=false.注意isFalseEx在里面取反的,所以会抛出异常
PugAssert.isFalseEx(isLogin,AdminUserResultEnum.USER_INPUT_USERNAME_ERROR);
UserBo userBo = new UserBo();
// 根据用户生成token
String token = jwtService.createToken(dbLoginUser.getId());
userBo.setToken(token);
// 注意把一些敏感信息全部清空返回
dbLoginUser.setPassword(null);
userBo.setUser(dbLoginUser);
// 登录挤下线
String tokenUuid = UUID.randomUUID().toString();
//获取用户id设置key
String tokenUuidKey = "pug:user:login:"+dbLoginUser.getId();
redisTemplate.opsForValue().set(tokenUuidKey,tokenUuid);
userBo.setTokenUuid(tokenUuid);
return userBo;
}
}
* PassportLoginInterceptor
2:编写下线拦截器
package com.pug.zixun.config.interceptor;
import com.pug.zixun.common.anno.IgnoreToken;
import com.pug.zixun.common.enums.AdminUserResultEnum;
import com.pug.zixun.common.ex.PugValidatorException;
import com.pug.zixun.common.utils.fn.asserts.Vsserts;
import com.pug.zixun.config.jwt.JwtService;
import com.pug.zixun.config.validator.PugAssert;
import com.pug.zixun.domain.User;
import com.pug.zixun.local.UserThreadLocal;
import com.pug.zixun.service.user.IUserService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.lang.reflect.Method;
import java.util.UUID;
/**
* 挤下线使用
*/
@Component
@Slf4j
public class PassportLogoutInterceptor implements HandlerInterceptor {
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
/*********************这里是用户输入的信息********************/
// 获取用户传递过来的tokenuuid
String tokenUuid = request.getHeader("token_uuid");
// 如果没有获取到,说明没有登录
PugAssert.isEmptyEx(tokenUuid,AdminUserResultEnum.USER_LOGIN_UUID_EMPTY);
// *******************从redis获取uuid********************/
String tokenUserId = request.getHeader("token_userid");
String tokenUuidKey = "pug:user:login:"+tokenUserId;
String cacheUuid = stringRedisTemplate.opsForValue().get(tokenUuidKey);
// 如果没有获取到,说明没有登录
PugAssert.isEmptyEx(tokenUuid,AdminUserResultEnum.USER_LOGIN_UUID_EMPTY);
// *******************比较********************/
// 如果你当前访问的uuid和缓存的uuid不同,就说明你在别的地方登录了。
if(!tokenUuid.equalsIgnoreCase(cacheUuid)){
throw new PugValidatorException(AdminUserResultEnum.USER_LOGIN_SAME);
}
return true;
}
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
UserThreadLocal.remove();
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
UserThreadLocal.remove();
}
}
WebMvcConfiguration
3.注册和设置规则
package com.pug.zixun.config.mvc;
import com.pug.zixun.config.interceptor.PassportLoginInterceptor;
import com.pug.zixun.config.interceptor.PassportLogoutInterceptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebMvcConfiguration implements WebMvcConfigurer {
@Autowired
private PassportLogoutInterceptor passportLogoutInterceptor;
@Autowired
private PassportLoginInterceptor passportLoginInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 下线拦截器
registry.addInterceptor(passportLogoutInterceptor).addPathPatterns("/admin/**")
.excludePathPatterns("/admin/login/**");
// 设置passportlogin的规则。以/admin开头的所有请求都要进行token校验
registry.addInterceptor(passportLoginInterceptor).addPathPatterns("/admin/**")
.excludePathPatterns("/admin/login/**");
}
}
测试
先登录,返回前端userbo。获得token,tokenUuid,user属性
然后通过得到的属性开始测试
debug发现cacheUuid和tokenUuid相等
返回成功
重新登录后,更新了redis的uuid,所以抛出异常