分布式集群环境下,如何实现每个服务的登陆认证?

626 阅读6分钟

听说微信搜索《Java鱼仔》会变更强哦!

本文收录于JavaStarter ,里面有我完整的Java系列文章,学习或面试都可以看看哦

(一)前言

在单体项目中,通过cookies和session就可以实现人员的认证。但是随着现在项目朝着分布式的方向发展,单体项目中的session认证方式似乎变得不可用了。以集群项目为例,我们会启动多个服务,而session是存在于执行当前服务的JVM中,所以访问第一个节点的认证信息是无法在第二个节点中使用的。

因此这篇文章就带你来聊聊分布式Session的处理方式。

(二)单体Session认证项目

我还是会通过一个项目来介绍今天的内容,为了节省篇幅,那些简单的代码我就用文字代替了,核心代码都会放上来,如果你在运行过程中遇到问题需要所有代码,请评论区回复。

首先我先简单搭建一个单体环境下的人员登陆认证系统。

2.1 新建一个SpringBoot项目

新建一个SpringBoot项目,把相关的依赖引入:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <optional>true</optional>
</dependency>
<!--mybatis数据库相关依赖-->
<dependency>
    <groupId>org.mybatis.spring.boot</groupId>
    <artifactId>mybatis-spring-boot-starter</artifactId>
    <version>2.1.3</version>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    <scope>runtime</scope>
</dependency>

在application.yml中配置服务端口,数据库连接方式以及mybatis的一些路径配置

server:
  port: 8189
spring:
  datasource:
    url: jdbc:mysql://localhost:3306/mycoding?serverTimezone=UTC&useUnicode=true&characterEncoding=utf-8
    username: root
    password: 123456
    driver-class-name: com.mysql.cj.jdbc.Driver
mybatis:
  type-aliases-package:
  mapper-locations: classpath:mapper/*.xml

2.2 创建实体类

这里创建一个需要用到的用户实体类User:

@Data
@AllArgsConstructor
@NoArgsConstructor
@ToString
public class User implements Serializable {
    private String username;
    private String password;
    private String levelId;
    private String nickname;
    private String phone;
    private int status;
    private Timestamp createTime;
}

顺便附带上用户表的数据库生成语句:

CREATE TABLE `user` (
  `id` int(40) NOT NULL AUTO_INCREMENT,
  `level_id` int(4) NOT NULL,
  `username` varchar(100) NOT NULL,
  `password` varchar(100) NOT NULL,
  `nickname` varchar(100) NOT NULL,
  `phone` varchar(100) NOT NULL,
  `status` int(4) NOT NULL DEFAULT '1',
  `create_time` datetime DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8;

2.3 编写登陆逻辑

首先来理一下这层逻辑,前端通过post请求传给后端用户名和密码,首先判断该用户的用户名和密码是否和数据库中的匹配,如果匹配的话,将当前用户信息塞入到session中,返回登陆成功的结果,否则就返回登陆失败。

首先我们写一个BaseController获取基本的request和response信息

public class BaseController {

    public HttpServletRequest getRequest(){
        return ((ServletRequestAttributes)RequestContextHolder.getRequestAttributes()).getRequest();
    }

    public HttpServletResponse getResponse(){
        return ((ServletRequestAttributes)RequestContextHolder.getRequestAttributes()).getResponse();
    }

    public HttpSession getHttpSession(){
        return getRequest().getSession();
    }

}

编写UserController继承BaseController,在这里实现登陆逻辑。

@RestController
@RequestMapping("/sso")
public class UserController extends BaseController {

    @Autowired
    private UserService userService;

    @PostMapping("/login")
    public CommonResult login(@RequestParam String username,@RequestParam String password){
        //在数据库中判断用户是否存在
        User user = userService.login(username, password);
        //如果存在
        if (user!=null){
            getHttpSession().setAttribute("user",user);
            return new CommonResult(ResponseCode.SUCCESS.getCode(),ResponseCode.SUCCESS.getMsg(),username+"登陆成功");
        }
        return new CommonResult(ResponseCode.USER_NOT_EXISTS.getCode(),ResponseCode.USER_NOT_EXISTS.getMsg(),"");
    }
}

2.4 拦截器拦截未登录状态

如何判断用户有没有登陆呢?通过拦截器就可以,我们需要拦截除了/sso下面的所有请求,新建一个配置类IntercepterConfiguration,拦截除了/sso下的所有请求。

@Configuration
public class IntercepterConfiguration implements WebMvcConfigurer {

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        List list=new ArrayList();
        list.add("/sso/**");
        registry.addInterceptor(authInterceptorHandler())
                .addPathPatterns("/**")
                .excludePathPatterns(list);
    }

    @Bean
    public AuthInterceptorHandler authInterceptorHandler(){
        return new AuthInterceptorHandler();
    }
}

有了拦截器后我们还需要对拦截的请求进行处理,处理方式就是验证是否可以找到当前session,如果存在session就放行,说明已经登陆了,否则就给一个未登录的信息。

@Slf4j
public class AuthInterceptorHandler implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        log.info("进入拦截器");
        if (!ObjectUtils.isEmpty(request.getSession().getAttribute("user"))){
            return true;
        }
        response.setHeader("Content-Type","application/json");
        response.setCharacterEncoding("UTF-8");
        String result = new ObjectMapper().writeValueAsString(new CommonResult(ResponseCode.NEED_LOGIN.getCode(), ResponseCode.NEED_LOGIN.getMsg(), ""));
        response.getWriter().println(result);
        return false;
    }
}

2.5 验证

为了测试新建一个IndexController,从session中获取用户名并返回。

@RestController
public class IndexController extends BaseController{

    @RequestMapping(value = "/index",method = RequestMethod.GET)
    public CommonResult index(){
        User user = (User) getHttpSession().getAttribute("user");
        return new CommonResult(ResponseCode.SUCCESS.getCode(),ResponseCode.SUCCESS.getMsg(),user.getUsername());
    }
}

启动项目,在postman中直接访问localhost:端口/index:

因为未登录,被拦截了,接下来先登陆:

在这里插入图片描述

登陆成功后,再访问localhost:端口/index:

在这里插入图片描述

至此,一个单体的登陆认证服务其实已经完成了。

(三)如果项目变成集群部署呢?

当用户使用量慢慢变多,发现服务器快撑不住了,于是领导一声令下,做集群!于是模拟了一个集群(两个节点测试一下),Idea配置中勾选允许多程序运行:

在这里插入图片描述

然后启动项目,修改server.port端口后再启动项目,此时就启动了两个项目了

在这里插入图片描述

我启动了两个项目,分别运行在8188和8189端口上,这个时候需要配nginx负载均衡,让每次请求轮询访问到两个节点。我这里就省略了,手动访问模拟nginx。

这个时候就出现了一个问题,我在8188端口上登陆后,认证成功了,但是一旦请求发给了8189端口,又需要再认证一次。两台服务器还没什么,如果有几十台服务器,那用户就需要登陆几十次。太不合理了。

(四)分布式session

解决上面这个问题的方法有许多:

1、比如nginx上请求策略改成ip匹配的策略,一个ip只会访问一个节点(一般不会采用)

2、又比如搭建一个统一认证服务,所有的请求先走统一认证(CAS等等)。我目前所做的项目用的是这种CAS的统一认证方式,不过比较繁琐,这里先不介绍。

3、今天介绍一种方便又实用的方式实现分布式Session--SpringSession。

SpringSession的原理很简单,不把session存放在JVM中了,而是存放在一个公共的地方。比如mysql、redis中。显然放在redis中是最高效的。

4.1 引入依赖

<dependency>
    <groupId>org.springframework.session</groupId>
    <artifactId>spring-session-data-redis</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

因为用到了redis,因此需要把redis的依赖也引入。

4.2 配置redis

还是通用的redis配置类,保证传输的序列化,这段通用的,直接复制过去就好了。

@Configuration
public class RedisConfiguration {
    //自定义的redistemplate
    @Bean(name = "redisTemplate")
    public RedisTemplate<String,Object> redisTemplate(RedisConnectionFactory factory){
        //创建一个RedisTemplate对象,为了方便返回key为string,value为Object
        RedisTemplate<String,Object> template = new RedisTemplate<>();
        template.setConnectionFactory(factory);
        //设置json序列化配置
        Jackson2JsonRedisSerializer jackson2JsonRedisSerializer=new
                Jackson2JsonRedisSerializer(Object.class);
        ObjectMapper objectMapper=new ObjectMapper();
        objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        objectMapper.activateDefaultTyping(LaissezFaireSubTypeValidator.instance);
        //string的序列化
        StringRedisSerializer stringRedisSerializer=new StringRedisSerializer();
        //key采用string的序列化方式
        template.setKeySerializer(stringRedisSerializer);
        //value采用jackson的序列化方式
        template.setValueSerializer(jackson2JsonRedisSerializer);
        //hashkey采用string的序列化方式
        template.setHashKeySerializer(stringRedisSerializer);
        //hashvalue采用jackson的序列化方式
        template.setHashValueSerializer(jackson2JsonRedisSerializer);
        template.afterPropertiesSet();
        return template;
    }
}

4.3 在配置文件中配置springsession

在application.yml中增加session存储方式以及redis的ip端口等:

spring:
  redis:
    host: 192.168.78.128
    port: 6379
  session:
    store-type: redis

4.4 开启springsession

新建一个配置类RedisHttpSessionConfiguration,并设置一个最大存活的时间,这里设置为1小时

@EnableRedisHttpSession(maxInactiveIntervalInSeconds = 3600)
public class RedisHttpSessionConfiguration {
}

4.5 验证

现在只要登陆一次,就可以在两台集群上直接访问对应的index接口了。并且在redis中已经可以看到我们塞入的session了。

在这里插入图片描述

(五)总结

以目前的技术来说,有许多方式来实现分布式Session。基本上的思路都是将session数据存放到一个统一的地方。我们下期再见!