分布式 - 应用会话管理

1,002 阅读14分钟

Session和Cookie

由于HTTP协议是无状态的协议,所以服务端需要记录用户的状态时,就需要用某种机制来识具体的用户,这个机制就是Session。 Session是保存在服务端的,有一个唯一标识。在大型的网站,一般会有专门的Session服务器集群,这个时候 Session 信息都是放在内存的。

思考一下服务端如何识别特定的客户?这个时候Cookie就登场了。每次HTTP请求的时候,客户端都会发送相应的Cookie信息到服务端。 实际上大多数的应用都是用 Cookie 来实现Session跟踪的,第一次创建Session的时候,服务端会在HTTP协议中告诉客户端, 需要在 Cookie 里面记录一个Session ID,以后每次请求把这个会话ID发送到服务器,我就知道你是谁了。 有人问,如果客户端的浏览器禁用了 Cookie 怎么办?一般这种情况下,会使用一种叫做URL重写的技术来进行会话跟踪, 即每次HTTP交互,URL后面都会被附加上一个诸如 sid=xxxxx 这样的参数,服务端据此来识别用户。

Cookie其实还可以用在一些方便用户的场景下,设想你某次登陆过一个网站,下次登录的时候不想再次输入账号了,怎么办? 这个信息可以写到Cookie里面,访问网站的时候,网站页面的脚本可以读取这个信息,就自动帮你把用户名给填了,能够方便一下用户。 这也是Cookie名称的由来,给用户的一点甜头。

所以,总结一下:

  1. session 在服务器端,cookie 在客户端(浏览器)
  2. session 默认被存在在服务器的一个文件里(不是内存)
  3. session 的运行依赖 session id,而 session_id是存在cookie中的,也就是说,如果浏览器禁用了cookie, 同时session也会失效(但是可以通过其它方式实现,比如在 url 中传递 session_id)
  4. session 可以放在 文件、数据库、或内存中都可以。
  5. 用户验证这种场合一般会用 session 因此,维持一个会话的核心就是客户端的唯一标识,即 session id

种常见的实现web应用会话管理的方式:

  • 基于server端session的管理方式
  • 基于cookie的管理方式
  • 基于token的管理方式

基于Server端session

在早期web应用中,通常使用服务端session来管理用户的会话。

1)服务端session是用户第一次访问应用时,服务器就会创建的对象,代表用户的一次会话过程,可以用来存放数据。 服务器为每一个session都分配一个唯一的sessionid,以保证每个用户都有一个不同的session对象。

2)服务器在创建完session后,会把sessionid通过cookie返回给用户所在的浏览器,这样当用户第二次及以后向服务器发送请求的时候, 就会通过cookie把sessionid传回给服务器,以便服务器能够根据sessionid找到与该用户对应的session对象。

3)session通常有失效时间的设定,比如2个小时。当失效时间到,服务器会销毁之前的session,并创建新的session返回给用户。 但是只要用户在失效时间内,有发送新的请求给服务器,通常服务器都会把他对应的session的失效时间根据当前的请求时间再延长2个小时。

4)session在一开始并不具备会话管理的作用。它只有在用户登录认证成功之后,并且往sesssion对象里面放入了用户登录成功的凭证, 才能用来管理会话。管理会话的逻辑也很简单,只要拿到用户的session对象,看它里面有没有登录成功的凭证,就能判断这个用户是否已经登录。 当用户主动退出的时候,会把它的session对象里的登录凭证清掉。 所以在用户登录前或退出后或者session对象失效时,肯定都是拿不到需要的登录凭证的。

可简单使用流程图描述如下:

image

主流的web开发平台都原生支持这种会话管理的方式,而且开发起来很简单。它还有一个比较大的优点就是安全性好, 因为在浏览器端与服务器端保持会话状态的媒介始终只是一个sessionid串,只要这个串够随机,攻击者就不能轻易冒充他人的sessionid进行操作; 除非通过CSRF或http劫持的方式,才有可能冒充别人进行操作;即使冒充成功,也必须被冒充的用户session里面包含有效的登录凭证才行。

但是这种方式也有几个问题需要解决:

1)这种方式将会话信息存储在web服务器里面,所以在用户同时在线量比较多时,这些会话信息会占据比较多的内存;

2)当应用采用集群部署的时候,会遇到多台web服务器之间如何做session共享的问题。因为session是由单个服务器创建的, 但是处理用户请求的服务器不一定是那个创建session的服务器,这样他就拿不到之前已经放入到session中的登录凭证之类的信息了;

3)多个应用要共享session时,除了以上问题,还会遇到跨域问题,因为不同的应用可能部署的主机不一样,需要在各个应用做好cookie跨域的处理。

针对问题1和问题2,我见过的解决方案是采用redis/memcached这种中间服务器来管理session的增删改查, 一来减轻web服务器的负担,二来解决不同web服务器共享session的问题。

针对问题3,由于服务端的session依赖cookie来传递sessionid,所以在实际项目中,只要解决各个项目里面如何实现sessionid的cookie跨域访问即可, 这个是可以实现的,就是比较麻烦,前后端有可能都要做处理。

如果在一些小型的web应用中使用,可以不用考虑上面三个问题,所以很适合这种方式。

基于cookie

由于前一种方式会增加服务器的负担和架构的复杂性,所以后来就有人想出直接把用户的登录凭证直接存到客户端的方案, 当用户登录成功之后,把登录凭证写到cookie里面,并给cookie设置有效期,后续请求直接验证存有登录凭证的cookie是否存在以及凭证是否有效, 即可判断用户的登录状态。使用它来实现会话管理的整体流程如下:

1)用户发起登录请求,服务端根据传入的用户密码之类的身份信息,验证用户是否满足登录条件,如果满足,就根据用户信息创建一个登录凭证, 这个登录凭证简单来说就是一个对象,最简单的形式可以只包含用户id,凭证创建时间和过期时间三个值。

2)服务端把上一步创建好的登录凭证,先对它做数字签名,然后再用对称加密算法做加密处理,将签名、加密后的字串,写入cookie。 cookie的名字必须固定(如ticket),因为后面再获取的时候,还得根据这个名字来获取cookie值。 这一步添加数字签名的目的是防止登录凭证里的信息被篡改,因为一旦信息被篡改,那么下一步做签名验证的时候肯定会失败。 做加密的目的,是防止cookie被别人截取的时候,无法轻易读到其中的用户信息。

3)用户登录后发起后续请求,服务端根据上一步存登录凭证的cookie名字,获取到相关的cookie值。然后先做解密处理,再做数字签名的认证, 如果这两步都失败,说明这个登录凭证非法;如果这两步成功,接着就可以拿到原始存入的登录凭证了。然后用这个凭证的过期时间和当前时间做对比, 判断凭证是否过期,如果过期,就需要用户再重新登录;如果未过期,则允许请求继续。

可简单使用流程图描述如下:

image

它的缺点也比较明显:

1)cookie有大小限制,存储不了太多数据

2)每次传送cookie,增加了请求的数量,对访问性能也有影响;

3)也有跨域问题,毕竟还是要用cookie。

相比起第一种方式,基于cookie方案明显还是要好一些,目前好多web开发平台或框架都默认使用这种方式来做会话管理。

跨域的问题可以用CORS(跨域资源共享)的方式来快速解决。

基于token

前面两种会话管理方式因为都用到cookie,不适合用在移动端native app里面,native app不好管理cookie,毕竟它不是浏览器。 这两种方案都不适合用来做纯api服务的登录认证,就要考虑第三种会话管理方式,也就是token认证。

这种方式从流程和实现上来说,跟cookie-based的方式没有太多区别,只不过cookie-based里面写到cookie里面的ticket在这种方式下称为token, 这个token在返回给客户端之后,后续请求都必须通过url参数或者是http header的形式,主动带上token, 这样服务端接收到请求之后就能直接从http header或者url里面取到token进行验证:

这种方式不通过cookie进行token的传递,而是每次请求的时候,主动把token加到http header里面或者url后面, 所以即使在native app里面也能使用它来调用我们通过web发布的api接口。app里面还要做两件事情:

1)有效存储token,得保证每次调接口的时候都能从同一个位置拿到同一个token;

2)每次调接口的的代码里都得把token加到header或者接口地址里面。

可简单使用流程图描述如下:

image

这种方式同样适用于网页应用,token可以存于localStorage或者sessionStorage里面,然后每发ajax请求的时候, 都把token拿出来放到ajax请求的header里即可。不过如果是非接口的请求,比如直接通过点击链接请求一个页面这种, 是无法自动带上token的。所以这种方式也仅限于走纯接口的web应用。

基于token的标准实现: JWT

现在SPA应用,前后端完全分离,基于API接口的应用越来越多,这时候基于token的认证就是最好的选择方式了。 好在这个方式的技术其实早就有很多实现了,而且还有现成的标准可用,这个标准就是JWT(json-web-token)。

JWT本身并没有做任何技术实现,它只是定义了token-based的管理方式该如何实现, 它规定了token的应该包含的标准内容以及token的生成过程和方法。目前实现了这个标准的技术已经有非常多:

官方网站:jwt.io/#libraries-… Git主页:github.com/auth0/java-…

SpringSession

生产环境我们的应用示例不可能是单节点部署, 通常都是多结点部署, 结点上层会进行域映射, 实例之间负载响应请求. 比如常见的Nginx + Tomcat负载均衡场景中。常用的均衡算法有IP_Hash、轮训、根据权重、随机等。不管对于哪一种负载均衡算法,由于Nginx对不同的请求分发到某一个Tomcat,Tomcat在运行的时候分别是不同的容器里,因此会出现session不同步或者丢失的问题。

解决方案

IP_HASH

nginx可以根据客户端IP进行负载均衡,在upstream里设置ip_hash,就可以针对同一个C类地址段中的客户端选择同一个后端服务器,除非那个后端服务器宕了才会换一个. 这样如果该类QPS高会导致该台服务器的负载升高,负载不均.

nginx基于ip_hash的session管理方案

通过容器插件

在容器层面扩展可共享存储的插件; 比如基于Tomcat的tomcat-redis-session-manager,基于Jetty的jetty-session-redis等等。好处是对项目来说是透明的,无需改动代码。该方案由于过于依赖容器,一旦容器升级或者更换意味着又得从新来过。并且代码不在项目中,对开发者来说维护也是个问题。

会话管理工具

自己写一套会话管理的工具类,包括Session管理和Cookie管理,在需要使用会话的时候都从自己的工具类中获取,而工具类后端存储可以放到Redis中。很显然这个方案灵活性最大,但开发需要一些额外的时间。并且系统中存在两套Session方案,很容易弄错而导致取不到数据。

开源解决方案

这里以开源框架Spring-Session为例,Spring-Session扩展了Servlet的会话管理(所有的request都会经过SessionRepositoryFilter,而 SessionRepositoryFilter是一个优先级最高的javax.servlet.Filter,它使用了一个SessionRepositoryRequestWrapper类接管了Http Session的创建和管理工作),既不依赖容器,又不需要改动代码. 可插拔, 轻量级. 支持多维度存储;诸如 Redis 、Pivotal GemFire、Jdbc、Mongo 、Hazelcast等

SpringSession应用

SpringMvc项目使用SpringSession

maven依赖

<dependency>
	 <groupId>org.springframework.session</groupId>
	 <artifactId>spring-session</artifactId>
	 <version>1.3.1.RELEASE</version>
</dependency>
<dependency>
	 <groupId>org.springframework.session</groupId>
	 <artifactId>spring-session-data-redis</artifactId>
	 <version>1.3.1.RELEASE</version>
</dependency>

配置web.xml

<filter>
   <filter-name>springSessionRepositoryFilter</filter-name>
   <filter-class>org.springframework.web.filter.DelegatingFilterProxy</filter-class>
</filter>
<filter-mapping>
   <filter-name>springSessionRepositoryFilter</filter-name>
   <url-pattern>/*</url-pattern>
</filter-mapping>

配置redis、以及redisHttpSession存储

<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xmlns:context="http://www.springframework.org/schema/context"
       xsi:schemaLocation="http://www.springframework.org/schema/beans
        http://www.springframework.org/schema/beans/spring-beans-4.2.xsd
        http://www.springframework.org/schema/context
        http://www.springframework.org/schema/context/spring-context.xsd">

    <context:annotation-config/>

    <!-- 将session放入redis -->
    <bean id="redisHttpSessionConfiguration" class="org.springframework.session.data.redis.config.annotation.web.http.RedisHttpSessionConfiguration">
        <property name="maxInactiveIntervalInSeconds" value="1800" />
    </bean>

    <bean id="jedisPoolConfig" class="redis.clients.jedis.JedisPoolConfig">
        <property name="maxTotal" value="100" />
        <property name="maxIdle" value="10" />
    </bean>

    <bean id="jedisConnectionFactory" class="org.springframework.data.redis.connection.jedis.JedisConnectionFactory" destroy-method="destroy">
        <property name="hostName" value="127.0.0.1"/>
        <property name="port" value="6379"/>
        <property name="password" value="" />
        <property name="timeout" value="3000"/>
        <property name="usePool" value="true"/>
        <property name="poolConfig" ref="jedisPoolConfig"/>
    </bean>

    <bean id="redisTemplate" class="org.springframework.data.redis.core.StringRedisTemplate">
        <property name="connectionFactory" ref="jedisConnectionFactory" />
    </bean>

</beans>

测试代码

@RestController
@RequestMapping("/api/cloud")
public class ApiSessionController extends BaseMultiController {

    private static final Logger LOG = LoggerFactory.getLogger(ApiSessionController.class);

    @Autowired
    protected HttpSession httpSession;

    @GetMapping("/session/put")
    public APIResult sessionPut(){
        httpSession.setAttribute("cloud", JSON.toJSONString(new User("Elonsu", "123456")));
        String userString = (String)httpSession.getAttribute("cloud");
        LOG.info("[session][set]:" + userString);
        return APIResult.success(true);
    }

    @GetMapping("/session/get")
    public APIResult sessionGet(){
        String userString = (String)httpSession.getAttribute("cloud");
        LOG.info("[session][get]:" + userString);
        User user = JSONObject.parseObject(userString, User.class);
        return APIResult.success(user);
    }

    @Data
    @Builder
    @NoArgsConstructor
    @AllArgsConstructor
    public static class User implements Serializable {
        private String username;
        private String password;
    }

}

测试输出

启两个容器实例,端口分别使用8080和8081进行访问

实例1访问结果

image

实例2访问结果

image

查看redis中存储的session

image

应用SpringSession

maven依赖

<dependencies>
   <dependency>
           <groupId>org.springframework.session</groupId>
           <artifactId>spring-session</artifactId>
           <version>1.3.1.RELEASE</version>
   </dependency>
   <dependency>
           <groupId>org.springframework.boot</groupId>
           <artifactId>spring-boot-starter-redis</artifactId>
   </dependency>
</dependencies>

启动主类增加注解

启动类上增加注解@EnableRedisHttpSession

@Configuration
@SpringBootApplication
@EnableAutoConfiguration
@EnableRedisHttpSession
public class Application extends WebMvcStrap {

    protected final static Logger LOG = LoggerFactory.getLogger(Application.class);

    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }

}

应用配置文件配置

应用配置文件application.properties 增加如下配置

# spring redis
spring.redis.host=127.0.0.1
spring.redis.port=6379
spring.redis.password=
# spring session
spring.session.store-type=redis
server.session.timeout=5

官方文档