1

1. 概述

之前因为对Spring Security OAuth2感兴趣,就做了demo项目,分享实现过程的文章。因为是demo项目,写的就很随意,包括我之前写的很多文章,都没有考虑多节点负载均衡的场景。但最近发现有项目组在用这套框架了,他们给我反馈了个问题。他们同时启动多个 oauth server 的节点,通过nginx做了一层负载均衡,客户端都访问负载均衡的地址后,但发现单点登录经常“不生效”了。

原因应该比较好分析,最初单节点的设计上,很多资源是存储在内存中的,不同服务器节点之间是不共享的,例如:client detail、authentication code,等等。最简单解决的方式,就是将其都改为基于数据库存储。项目上的问题,可能需要针对具体的问题再分析。但本文想要讨论的不是这些,而是有关session的原理,已经如何实现session的共享。

2. 原理分析

2.1. 单点登录

单点登录的定义是啥?

用户只需要登录一次就可以访问所有相互信任的应用系统的保护资源,若用户在某个应用系统中进行注销登录,所有的应用系统都不能再直接访问保护资源。

当通过OAuth2授权码模式访问各个应用系统时,是如何实现一次登录,后续访问其他的应用系统时都无需再登录,而且能识别用户身份的呢?

2.2. cookie、session作用

这个就离不开经典的cookie和session知识点了。

  • cookie:cookie是一小段文本信息(单个不超过4k),存放在客户浏览器上。客户端请求服务器,如果服务器需要记录该用户状态,就使用response向客户端浏览器颁发一个cookie。客户端会把cookie保存起来。当浏览器再请求该网站时,浏览器把请求的网址连同该cookie一同提交给服务器。服务器检查该cookie,以此来辨认用户状态。
  • session:session可以存储的文本很大,存放在服务器端。客户端浏览器访问服务器的时候,服务器把客户端信息以某种形式记录在服务器上,这就是session。在创建session的时候通常也会向客户端颁发cookie,cookie的值就是该session的id。客户端浏览器再次访问时携带cookie中session的id,从而可以在服务器中找到想要的session信息,同时能标识当前客户端用户的身份呢。

因此在单点登录中,cookie和session扮演的角色是:

  1. 用户登陆某个系统,在进行完身份认证后,该系统会在服务器上为这个用户创建一个session,并将认证后的用户信息保存到session里。
  2. 服务器在创建session的同时会创建一个key(可在spring配置文件中设置属性server.servlet.session.cookie.name),value为该session id的cookie响应给浏览器。
  3. 这个用户的后续请求就会带着该cookie,服务器就可以通过请求中的cookie判定这些请求是该用户的请求。

2.3. Spring OAuth过程

回到 Spring OAuth2 的单点登录过程:

  1. 首次访问某系统时,客户浏览器携带cookie访问服务端,没有对应的sessionId,或服务端未找到该sessionId对应的有效session,则跳转到登录页。
  2. 客户浏览器登录成功后,服务器端创建用户session,并在客户端的cookie中写入sessionId,客户浏览器后续访问该域名网站时都携带该cookie。
  3. 当客户浏览器再次访问该系统,或同一单点登录作用域下其他系统时,因为携带前面的cookie。服务器通过sessionId都能找到该用户的session,则不需要再次登录。这就实现了登录一次就可以访问所有相互信任的应用系统的保护资源。
  4. 注销时访问注销的url和登录页是同一域名,同样会携带该cookie,服务器做的处理是,找到该sessionId对应的session,将其清空失效,然后重定向回去。这就实现了注销一次所有的应用系统都不能再直接访问保护资源。

在Spring Security中,该过程都有相应的代码实现。请求进来时,检查session,如果有SecurityContext(已认证用户对象的一个封装类)拿出来放到线程里即SecurityContextHolder中,返回时校验SecurityContextHolder中是否有securityContext,有则放入session,从而实现认证信息在多个请求中共享。

这也就能解答大家常见的几个问题:

  • 无法跨浏览器实现单点登录:因为cookie是单点登录关键,用于标识客户端的身份。但cookie存放在浏览器上的,不同的浏览器之间不共享,因此无法跨浏览器实现单点登录。
  • 登录授权服务多节点扩展时,单点登录不生效:这就是开头谈的那个问题,因为session是在服务器上的。客户浏览器第一次请求被nginx转发到某一台服务器A,在该服务器A上创建了session,将sessionId设置在cookie上。当客户浏览器携带cookie再次请求时,可能被nginx转发到另外一台服务器B上,此时cookie中的sessionId在服务器B上无法找到session,就又重现登录,重新创建另一个session,给cookie设置另一个sessionId。循环往复,如果你nginx轮询策略是挨个转发的话,那就永远成功不了。但如果轮询策略是像 ip_hash 这类的话,就又能完美避过这个问题。

3. Session共享

为了这次实验,先做了以下的准备:

  • 分别启动3个 oauth server,对应的端口分别是 8071、8072、8073,模拟3台服务器。
  • 启动nginx,针对上述的3个server配置负载均衡,对外暴露的端口是88。

因此,原本 http://localhost:8071/oauth/authorize (8072/8073)的地址,就改成了 http://localhost:88/oauth/authorize,包括注销的url也是 http://localhost:88/logout

当然,和之前的分析一样,单点登录失效了,除非我们只保留一个 oauth server运行。

我们的解决思路,就是将原本存放在各自服务器上的session,存放在一个公共的资源库中,最容易想到的就是数据库。用户请求涉及到的并发还是比较高的,而redis作为内存数据库,是最适合存放session的,我们下面拿常见的redis和mysql都做了示例,当然其他数据库也可以,这里就不一一列举了。

3.1. redis实现

pom.xml
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.session</groupId>
            <artifactId>spring-session-data-redis</artifactId>
        </dependency>
application.yml
spring:
  redis:
    database: 0
    port: 6379
    host: localhost

spring session 还是很强大的,只需要对3个节点做以上配置,session都会存储在redis中,实现session共享。此时我们再访问 88 端口的地址,发现单点登录又生效了,说明我们的目的达到了。

3.2. mysql实现

pom.xml
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <version>8.0.21</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.session</groupId>
            <artifactId>spring-session-jdbc</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>
application.yml
spring:
  datasource:
    url: jdbc:mysql://localhost:3306/spring_session?useUnicode=true&characterEncoding=utf8&serverTimezone=GMT%2B8&useSSL=false
    username: username
    password: password
    driver-class-name: com.mysql.cj.jdbc.Driver
数据库表创建脚本.sql
drop database  if exists `SPRING_SESSION`;
CREATE DATABASE `SPRING_SESSION ` charset utf8;
use `SPRING_SESSION `;

DROP TABLE IF EXISTS SPRING_SESSION_ATTRIBUTES;
DROP TABLE IF EXISTS SPRING_SESSION;
CREATE TABLE SPRING_SESSION (
    PRIMARY_ID CHAR(36) NOT NULL,
    SESSION_ID CHAR(36) NOT NULL,
    CREATION_TIME BIGINT NOT NULL,
    LAST_ACCESS_TIME BIGINT NOT NULL,
    MAX_INACTIVE_INTERVAL INT NOT NULL,
    EXPIRY_TIME BIGINT NOT NULL,
    PRINCIPAL_NAME VARCHAR(100),
    CONSTRAINT SPRING_SESSION_PK PRIMARY KEY (PRIMARY_ID)
) ENGINE=INNODB ROW_FORMAT=DYNAMIC;

CREATE UNIQUE INDEX SPRING_SESSION_IX1 ON SPRING_SESSION (SESSION_ID);
CREATE INDEX SPRING_SESSION_IX2 ON SPRING_SESSION (EXPIRY_TIME);
CREATE INDEX SPRING_SESSION_IX3 ON SPRING_SESSION (PRINCIPAL_NAME);

CREATE TABLE SPRING_SESSION_ATTRIBUTES (
    SESSION_PRIMARY_ID CHAR(36) NOT NULL,
    ATTRIBUTE_NAME VARCHAR(200) NOT NULL,
    ATTRIBUTE_BYTES BLOB NOT NULL,
    CONSTRAINT SPRING_SESSION_ATTRIBUTES_PK PRIMARY KEY (SESSION_PRIMARY_ID, ATTRIBUTE_NAME),
    CONSTRAINT SPRING_SESSION_ATTRIBUTES_FK FOREIGN KEY (SESSION_PRIMARY_ID) REFERENCES SPRING_SESSION(PRIMARY_ID) ON DELETE CASCADE
) ENGINE=INNODB ROW_FORMAT=DYNAMIC;

mysql的方式比起redis,除了数据库配置不一样,还多了表结构的初始化,毕竟这是关系数据库的特点。同样也是,当3个节点配置完成后,后续的session信息都会被存储在mysql的这两张表中,实现session共享,同样也能达到我们的目的。

参考文献
(1)《spring-security》博客


KerryWu
641 声望159 粉丝

保持饥饿


引用和评论

0 条评论