功能简述: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并不会直接强制退出,需要刷新页面才能强制退出,当然,如果你想达到强制退出的效果,可以模仿长连接的心跳检查,也就是前端定时向服务器发送接口请求,该接口什么事也不用做,只是接受浏览器的请求,然后经过拦截器即可。也能达到强制退出的效果。

好的,本文分享就到这里了,代码本人实测有效,有不好的地方或者错误的地方,欢迎各位多多评论指出。谢谢