功能简述:JWT+Redis实现单点登录功能的同时,也实现同一个账号只能在一台设备上登录,实现方式并非是建立长连接,因为长连接是比较消耗系统性能的。这里只是简单的redis方式实现。
什么是单点登录?
单点登录的英文名叫做:Single Sign On(简称SSO)。
在最开始的单体架构(或者说单系统)当中,所有的代码都放在一个项目当中,传统的登录流程是
用户登录—>登录校验(校验用户名密码)—>将用户名等信息放入session当中—>成功登录。
这样就可以从session当中获取用户信息来判断是否登录或者登录人是谁
后来,我们为了合理利用资源和降低耦合性,于是把单系统拆分成多个子系统。如果继续使用传统的登录方式。会产生什么问题呢?简单举个例子
我们都知道session是存在于服务器当中的,假如有两个服务 订单服务和支付服务,分别部署在服务器A和服务器B,在订单服务当中,用户进行了登录,服务器A保存了用户的登录信息,用户进行下单访问服务器A,获取用户信息生成订单,然后支付再访问服务器B,这时候,服务器B是没有方法获取到用户信息的。
这样肯定是不行的,当然session共享可以解决这个问题,但是session共享也有许多弊端,许多公司基本不会使用,而是使用主流的JWT做单点登录
简单来说,单点登录就是在多个系统中,用户只需一次登录,各个系统即可感知该用户已经登录。
单点登录流程:
用户登录—>登录校验—>根据用户信息生成token—>响应token给页面—>前端将token放入cookie
校验:将cookie信息放在请求头—>对token进行验证—>得到用户信息
介绍完了单点登录,废话不多说,上代码
pom文件:
<dependencies> <!--jwt起步依赖--> <dependency> <groupId>com.auth0</groupId> <artifactId>java-jwt</artifactId> <version>3.4.0</version> </dependency> <!-- https://mvnrepository.com/artifact/eu.bitwalker/UserAgentUtils --> <dependency> <groupId>eu.bitwalker</groupId> <artifactId>UserAgentUtils</artifactId> <version>1.21</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-tomcat</artifactId> </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> <!--MyBatis-Plus代码生成器需要的依赖,开始--> <!-- 持久层 --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.1.0</version> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-generator</artifactId> <version>3.1.0</version> </dependency> <!--模板引擎--> <dependency> <groupId>org.apache.velocity</groupId> <artifactId>velocity-engine-core</artifactId> <version>2.1</version> </dependency> <!--MyBatis-Plus代码生成器需要的依赖,结束--> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <!-- https://mvnrepository.com/artifact/com.alibaba/fastjson --> <dependency> <groupId>com.alibaba</groupId> <artifactId>fastjson</artifactId> <version>1.2.41</version> </dependency> <!--rabbitmq--> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-amqp</artifactId> </dependency> </dependencies>
yml文件:
server: port: 8081spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/test?useUnicode=true&characterEncoding=UTF-8&useSSL=false&serverTimezone=Asia/Shanghai&zeroDateTimeBehavior=CONVERT_TO_NULL username: root password: root redis: database: 0 host: 127.0.0.1 port: 6379 jedis: pool: max-active: 100 max-idle: 10 max-wait: 100000 timeout: 5000 rabbitmq: host: 127.0.0.1 port: 15672 username: guest password: guestmybatis-plus: mapper-locations: classpath:mapper/*.xml
先在config包下配置两个类
RedisConfig:
@Configuration@ConditionalOnClass(RedisOperations.class)@EnableConfigurationProperties(RedisProperties.class)public class RedisConfig { @Bean @ConditionalOnMissingBean(name = "redisTemplate") public RedisTemplate<Object, Object> redisTemplate( RedisConnectionFactory redisConnectionFactory) { RedisTemplate<Object, Object> template = new RedisTemplate<>(); //使用fastjson序列化 FastJsonRedisSerializer fastJsonRedisSerializer = new FastJsonRedisSerializer(Object.class); // value值的序列化采用fastJsonRedisSerializer template.setValueSerializer(fastJsonRedisSerializer); template.setHashValueSerializer(fastJsonRedisSerializer); // key的序列化采用StringRedisSerializer template.setKeySerializer(new StringRedisSerializer()); template.setHashKeySerializer(new StringRedisSerializer()); template.setConnectionFactory(redisConnectionFactory); return template; } @Bean @ConditionalOnMissingBean(StringRedisTemplate.class) public StringRedisTemplate stringRedisTemplate( RedisConnectionFactory redisConnectionFactory) { StringRedisTemplate template = new StringRedisTemplate(); template.setConnectionFactory(redisConnectionFactory); return template; }}
全局拦截器:InterceptorConfig
@Configurationpublic class InterceptorConfig implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(authenticationInterceptor()) .addPathPatterns("/**"); } @Bean public AuthenticationInterceptor authenticationInterceptor() { return new AuthenticationInterceptor(); }}
UserController:
@RestController@RequestMapping("/users")public class UsersController { @Resource private UsersService usersService; @Resource TokenService tokenService; @Resource private RedisUtil redisUtil; //登录 @PassToken @PostMapping("/login") public Object login(@RequestBody Users user, HttpServletRequest request){ JSONObject jsonObject=new JSONObject(); //根据用户名查询用户信息 Users userForBase=usersService.findByUsername(user); String ipAddr = IpUtils.getIpAddr(request); if(userForBase==null){ jsonObject.put("message","登录失败,用户不存在"); return jsonObject; }else { if (!userForBase.getPassword().equals(user.getPassword())){ jsonObject.put("message","登录失败,密码错误"); return jsonObject; }else { String token = tokenService.getToken(userForBase); String key = RedisPreEnum.JWT_TOKEN_PRE.getPre()+userForBase.getId(); Set<String> keys = redisUtil.keys(key+"*"); if (CollectionUtils.isEmpty(keys)){ redisUtil.set(key+ipAddr,userForBase,RedisPreEnum.JWT_TOKEN_PRE.getExpired()); }else { //请空之前的key for (String k:keys) { redisUtil.del(k); } //重新设置key redisUtil.set(key+ipAddr,userForBase,RedisPreEnum.JWT_TOKEN_PRE.getExpired()); } jsonObject.put("token", token); jsonObject.put("user", userForBase); return jsonObject; } } } @GetMapping("/getMessage") public String getMessage(){ return "你已通过验证"; }}
TokenService:
@Componentpublic class TokenService { private final static String SIGN = ""; public String getToken(Users user) { Calendar instance = Calendar.getInstance(); instance.add(Calendar.DATE,1); String token=""; token= JWT.create().withAudience(String.valueOf(user.getId())) .withExpiresAt(instance.getTime()) .sign(Algorithm.HMAC256(user.getPassword())); return token; } public String verifyToken(String token){ return null; }}
RedisUtil:
https://blog.csdn.net/weixin_43412919/article/details/122050884?spm=1001.2014.3001.5501
IpUtils:
package com.utils;import eu.bitwalker.useragentutils.UserAgent;import javax.servlet.http.HttpServletRequest;public class IpUtils { //客户端类型 手机、电脑、平板 //UserAgent userAgent = UserAgent.parseUserAgentString(request.getHeader("user-agent")); //String clientType = userAgent.getOperatingSystem().getDeviceType().toString(); //操作系统类型 //String os = userAgent.getOperatingSystem().getName(); //请求ip //String ip = IpUtils.getIpAddr(request); //浏览器类型 //String browser = userAgent.getBrowser().toString(); public static String getIpAddr(HttpServletRequest request) { String ip = request.getHeader("x-forwarded-for"); if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) { ip = request.getHeader("X-Real-IP"); //LOGGER.error("X-Real-IP:"+ip); } if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) { ip = request.getHeader("http_client_ip"); } if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) { ip = request.getRemoteAddr(); } if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) { ip = request.getHeader("Proxy-Client-IP"); } if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) { ip = request.getHeader("WL-Proxy-Client-IP"); } if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) { ip = request.getHeader("HTTP_X_FORWARDED_FOR"); } // 如果是多级代理,那么取第一个ip为客户ip if (ip != null && ip.indexOf(",") != -1) { ip = ip.substring(ip.lastIndexOf(",") + 1, ip.length()).trim(); } return ip; }}
PassToken注解:
import java.lang.annotation.ElementType;import java.lang.annotation.Retention;import java.lang.annotation.RetentionPolicy;import java.lang.annotation.Target;@Target({ElementType.METHOD, ElementType.TYPE})@Retention(RetentionPolicy.RUNTIME)public @interface PassToken { boolean required() default true;}
RedisPreEnum枚举类:
/** * redis key前缀 */@AllArgsConstructor@Getterpublic enum RedisPreEnum { JWT_TOKEN_PRE("JWT_TOKEN_","token前缀",60*60*24); private String pre; private String desc; private Integer expired;}
User实体类:
@Data@EqualsAndHashCode(callSuper = false)@Accessors(chain = true)public class Users implements Serializable { private static final long serialVersionUID = 1L; @TableId(value = "id", type = IdType.AUTO) private Integer id; private String name; private String password;}
这里使用的是mybatis-plus操作数据库,实体类使用代码生成器生成,没有使用mybatis-plus的小伙伴 只需要把findByUsername替换成自己的的即可,就是单纯 根据用户名查询用户信息
最重要的一个拦截器类:AuthenticationInterceptor
public class AuthenticationInterceptor implements HandlerInterceptor { @Resource private RedisUtil redisUtil; @Override public boolean preHandle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object object) throws Exception { String token = httpServletRequest.getHeader("token");// 从 http 请求头中取出 token // 如果不是映射到方法直接通过 if (!(object instanceof HandlerMethod)) { return true; } HandlerMethod handlerMethod = (HandlerMethod) object; Method method = handlerMethod.getMethod(); //检查是否有passtoken注释,有则跳过认证 if (method.isAnnotationPresent(PassToken.class)) { PassToken passToken = method.getAnnotation(PassToken.class); if (passToken.required()) { return true; } } if (token == null) { throw new RuntimeException("无token,请重新登录"); } String userId; try { userId = JWT.decode(token).getAudience().get(0); } catch (JWTDecodeException j) { throw new Exception("token无效"); } Users user = null; String key = RedisPreEnum.JWT_TOKEN_PRE.getPre() + userId + IpUtils.getIpAddr(httpServletRequest); if (redisUtil.hasKey(key)) { Object o = redisUtil.get(key); user = JSONObject.toJavaObject((JSON) JSON.toJSON(o), Users.class); } if (user == null) { throw new RuntimeException("请重新登录"); } // 验证 token JWTVerifier jwtVerifier = JWT.require(Algorithm.HMAC256(user.getPassword())).build(); try { jwtVerifier.verify(token); } catch (Exception e) { throw new Exception("token无效"); } return true; } @Override public void postHandle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object o, ModelAndView modelAndView) throws Exception { } @Override public void afterCompletion(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object o, Exception e) throws Exception { }}
数据库mysql对应的表
效果截图:
使用postman,进行登录:
redis当中,可以看到有这个key
请求头当中携带登录接口返回的token
到这里,只是验证了单点登录。
接下来是验证同一个账号只能在一台设备上登录,由于本地测试,获取到的ip地址都是127.0.0.1。
这里使用postman在请求头添加x-forwarded-for字段,模拟ip地址
用一个账号,模拟用不同ip地址登录,看看在redis当中是什么样的
第一次登录:
redis当中的信息
第二次登录:
redis当中,可以看到redis当中key已经被覆盖了
当第一次登录的设备再次刷新页面就会,退出登录
总结:
本文只提供后端具体代码
同一个账号只能在一台设备上登录实现方式,
登录时,判断该账号是否在其它设备登录,如果有,就把key清除,然后存储用户信息和ip地址拼接为key,存储在redis当中
在拦截器当中(用户每次接口请求都会经过该拦截器),去获取这个key,如果key没有,直接返回重新登录。本文采取的并非建立长连接的方式。
所以同一个账号设备A登录,然后又在设备B登录时,设备A并不会直接强制退出,需要刷新页面才能强制退出,当然,如果你想达到强制退出的效果,可以模仿长连接的心跳检查,也就是前端定时向服务器发送接口请求,该接口什么事也不用做,只是接受浏览器的请求,然后经过拦截器即可。也能达到强制退出的效果。
好的,本文分享就到这里了,代码本人实测有效,有不好的地方或者错误的地方,欢迎各位多多评论指出。谢谢