# session 共享入门

# 大纲

  1. 分享背景 1.1 cookie/session 机制

    1.2 tomcat 对 session 的管理

  2. 为什么要 session 共享

  3. 常用的方案

    3.1 session sticky

    3.2 组播方式 3.3 spring-session 3.4 jwt 3.5 Servlet 容器提供的插件

  4. 方案的选择

  5. 代码示例

# 1. 分享背景

Cookie 和 Session 是为了在无状态的 HTTP 协议之上维护会话状态, 因为 HTTP 协议是无状态的,即每次用户请求到达服务器时,HTTP 服务器并不知道这个用户是谁、是否登录过等。现在的服务器之所以知道我们是否已经登录,是因为服务器在登录时设置了浏览器的 Cookie!Session 则是借由 Cookie 而实现的更高层的服务器与浏览器之间的会话。

Cookie 是由客户端保存的小型文本文件,其内容为一系列的键值对。 Cookie 是由 HTTP 服务器设置的,保存在浏览器中, 由于 cookie 保存在了客户端,就存在了一些安全隐患,比如被篡改等问题。早期通过服务器为每个 Cookie 项生成签名,由于用户篡改 Cookie 后无法生成对应的签名, 服务器便可以得知用户对 Cookie 进行了篡改 ,又因为 cookie 是明文传输,只要服务器设置过一次签名, 以后就可以用这个签名来欺骗服务器了 ,进而出现了 session 机制。Session 是存储在服务器端的,避免了在客户端 Cookie 中存储敏感数据。 Session 可以存储在 HTTP 服务器的内存中,也可以存在内存数据库(如 redis)中, 对于重量级的应用甚至可以存储在数据库中。

# tomcat 对 session 的管理

Session 管理是 JavaEE 容器比较重要的一部分 ,在 Tomcat 中主要由每个 context 容器内的一个 Manager 对象来管理 session。对于这个 manager 对象的实现,可以根据 tomcat 提供的接口或基类来自己定制,同时,tomcat 也提供了标准实现。在每个 context 对象,即 web app 都具有一个独立的 manager 对象。通过 server.xml 可以配置定制化的 manager,也可以不配置。不管怎样,在生成 context 对象时,都会生成一个 manager 对象。缺省的是 StandardManager 类,在 Tomcat 中,Session 是保存到一个 ConcurrentHashMap 中的 。

/**
* Minimal implementation of the <b>Manager</b> interface that supports
* no session persistence or distributable capabilities. This class may
* be subclassed to create more sophisticated Manager implementations.
*
* @author Craig R. McClanahan
*/
public abstract class ManagerBase extends LifecycleMBeanBase implements Manager {
    /**
    * The set of currently active Sessions for this Manager, keyed by
    * session identifier.
    */
    protected Map<String, Session> sessions = new ConcurrentHashMap<>();
    ...
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

若是实现 Session 自定义化,可以实现标准 servlet 的 session 接口:

javax.servlet.http.HttpSession
1

Tomcat 也提供了标准的 session 实现:

org.apache.catalina.session.StandardSession
1
/**
* Standard implementation of the <b>Session</b> interface. This object is
* serializable, so that it can be stored in persistent storage or transferred
* to a different JVM for distributable session support.
* <p>
* <b>IMPLEMENTATION NOTE</b>: An instance of this class represents both the
* internal (Session) and application level (HttpSession) view of the session.
* However, because the class itself is not declared public, Java logic outside
* of the <code>org.apache.catalina.session</code> package cannot cast an
* HttpSession view of this instance back to a Session view.
* <p>
* <b>IMPLEMENTATION NOTE</b>: If you add fields to this class, you must
* make sure that you carry them over in the read/writeObject methods so
* that this class is properly serialized.
*
* @author Craig R. McClanahan
* @author Sean Legassick
* @author <a href="mailto:jon@latchkey.com">Jon S. Stevens</a>
*/
public class StandardSession implements HttpSession, Session, Serializable {
    /**
    * Construct a new Session associated with the specified Manager.
    *
    * @param manager The manager with which this Session is associated
    */
    public StandardSession(Manager manager) {
        super();
        this.manager = manager;
        // Initialize access count
        if (ACTIVITY_CHECK) {
            accessCount = new AtomicInteger();
        }
    }
    ...
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35

# 2. 为什么要 session 共享

为了使 web 能适应大规模的访问,需要实现应用的集群部署。集群最有效的方案就是负载均衡,而实现负载均衡用户每一个请求都有可能被分配到不固定的服务器上,就出现了 session 一致性问题。比如 Nginx 使用轮询方式,用户第一次请求,请求被分配到到了 A web 服务器,用户在 A web 服务器上进行登录,并保存登录信息(Session 信息),并将 Session 信息响应给浏览器。当用户第二次在请求的时候 通过轮询会定位到 B web 服务器,此时 B web 服务器上没有 用户的登录信息(Session 信息),则会提示用户进行登录。

# 3. 常用 session 共享思路

# session sticky

当服务端的一个特定路径会被同一个用户连续访问时,如果负载均衡策略还是轮询的话,那该用户的多次访问会被打到各台服务器上,这显然并不高效。甚至考虑一种极端情况,用户需要分片上传文件到服务器下,然后再由服务器将分片合并,这时如果用户的请求到达了不同的服务器,那么分片将存储于不同的服务器目录中,导致无法将分片合并。所以,此类场景可以考虑采用 nginx 提供的 ip_hash 策略。既能满足每个用户请求到同一台服务器,又能满足不同用户之间负载均衡。 一般来讲,要用到 url_hash,是要配合缓存命中来使用。如有一个服务器集群 A,需要对外提供文件下载,由于文件上传量巨大,没法存储到服务器磁盘中,所以用到了第三方云存储来做文件存储。服务器集群 A 收到客户端请求之后,需要从云存储中下载文件然后返回,为了省去不必要的网络带宽和下载耗时,在服务器集群 A 上做了一层临时缓存(缓存一个月)。由于是服务器集群,所以同一个资源多次请求,可能会到达不同的服务器上,导致不必要的多次下载,缓存命中率不高,以及一些资源时间的浪费。在此类场景下,为了使得缓存命中率提高,很适合使用 url_hash 策略,同一个 url(也就是同一个资源请求)会到达同一台机器,一旦缓存住了资源,再此收到请求,就可以从缓存中读取,既减少了带宽,也减少的下载时间 。

# 组播方式

传统的 IP 通信有两种方式:一种是在源主机与目的主机之间点对点的通信,即单播;另一种是在源主机与同一网段中所有其它主机之间点对多点的通信,即广播。如果要将信息发送给多个主机而非所有主机,若采用广播方式实现,不仅会将信息发送给不需要的主机而浪费带宽,也不能实现跨网段发送;若采用单播方式实现,重复的 IP 包不仅会占用大量带宽,也会增加源主机的负载。所以,传统的单播和广播通信方式不能有效地解决单点发送、多点接收的问题。

组播是指在 IP 网络中将数据包以尽力传送的形式发送到某个确定的节点集合(即组播组),其基本思想是:源主机(即组播源)只发送一份数据,其目的地址为组播组地址;组播组中的所有接收者都可收到同样的数据拷贝,并且只有组播组内的主机可以接收该数据,而其它主机则不能收到。

# spring-session

此处不深入剖析 Spring Session 的结构,主要是一起来看看 Spring 是如何接管 Session 的 。

# Spring Session 基于 XML 的配置
## spring 配置文件
<bean id="redisHttpSessionConfiguration" class="org.springframework.session.data.redis.config.annotation.web.http.RedisHttpSessionConfiguration">
    <property name="maxInactiveIntervalInSeconds" value="1800" />
</bean>

## 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>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 核心类解析

DelegatingFilterProxy 解析

DelegatingFilterProxy 是一个 Filter 的代理类,DelegatingFilterProxy 继承自 GenericFilterBean。GenericFilterBean 是一个抽象类,分别实现了 Filter, BeanNameAware, EnvironmentAware, ServletContextAware, InitializingBean, DisposableBean 接口,继承关系如下图:

对于 Filter 来说,最重要肯定就是初始化(init)和 doFilter 方法了,初始化方法在父类 GenericFilterBean 中:

/**
* Standard way of initializing this filter.
* Map config parameters onto bean properties of this filter, and
* invoke subclass initialization.
* @param filterConfig the configuration for this filter
* @throws ServletException if bean properties are invalid (or required
* properties are missing), or if subclass initialization fails.
* @see #initFilterBean
*/
@Override
public final void init(FilterConfig filterConfig) throws ServletException {
    ... //省略部分代码
    // Let subclasses do whatever initialization they like.
    initFilterBean();  //注意这里,initFilterBean在子类中实现
    if (logger.isDebugEnabled()) {
        logger.debug("Filter '" + filterConfig.getFilterName() + "' configured successfully");
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

initFilterBean 在子类中实现,也就是说当 DelegatingFilterProxy 在执行 Filter 的 init 方法时,会调用 initFilterBean 方法,如下:

@Override
protected void initFilterBean() throws ServletException {
    synchronized (this.delegateMonitor) {
        // delegate 为实际的Filter
        if (this.delegate == null) {
            // If no target bean name specified, use filter name.
            if (this.targetBeanName == null) {
                //这里的targetBeanName 便是我们web.xml 中配置的springSessionRepositoryFilter
                this.targetBeanName = getFilterName();
            }
            // Fetch Spring root application context and initialize the delegate early,
            // if possible. If the root application context will be started after this
            // filter proxy, we'll have to resort to lazy initialization.
            WebApplicationContext wac = findWebApplicationContext();
            if (wac != null) {
                //初始化Filter
                this.delegate = initDelegate(wac);
            }
        }
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

delegate 为真正的 Filter,通过 web.xml 中配置的 Filter 名字(即:springSessionRepositoryFilter)来获取这个 Filter,我们继续往下看 initDelegate 这个方法:

protected Filter initDelegate(WebApplicationContext wac) throws ServletException {
    Filter delegate = wac.getBean(getTargetBeanName(), Filter.class);
    if (isTargetFilterLifecycle()) {
        delegate.init(getFilterConfig());
    }
    return delegate;
}
1
2
3
4
5
6
7

通过上下文,获取到这个 Filter Bean,然后初始化这个 Filter。但是我们似乎没有手动配置这个 Bean,那么这个切入点就是我们开始在 spring 配置文件中声明的 RedisHttpSessionConfiguration 这个 bean 了 。

SessionRepositoryFilter 解析

Spring Session 对 HTTP 的支持所依靠的是一个简单老式的 Servlet Filter,借助 servlet 规范中标准的特性来实现 Spring Session 的功能。Spring Session 在 RedisHttpSessionConfiguration 以及它的父类 SpringHttpSessionConfiguration 中 自动生成了许多 Bean,这里列举 springSessionRepositoryFilter 这个 Bean,而这个 Bean 是 SessionRepositoryFilter 类型的。SessionRepositoryFilter 继承自 OncePerRequestFilter,也是一个标准的 Servlet Filter。真正的核心在于它对请求的 HttpServletRequest ,HttpServletResponse 对进行包装了之后,然后调用 filterChain.doFilter(strategyRequest, strategyResponse); 往后传递,后面调用者通过 HttpServletRequest.getSession();获得 session 的话,得到的将会是 Spring Session 提供的 HttpServletSession 实例。

@Bean
public <S extends Session> SessionRepositoryFilter<? extends Session> springSessionRepositoryFilter(SessionRepository<S> sessionRepository) {
    //传入 sessionRepository
    SessionRepositoryFilter<S> sessionRepositoryFilter = new SessionRepositoryFilter(sessionRepository);
    sessionRepositoryFilter.setServletContext(this.servletContext);
    sessionRepositoryFilter.setHttpSessionIdResolver(this.httpSessionIdResolver);
    return sessionRepositoryFilter;
}
1
2
3
4
5
6
7
8

RedisOperationsSessionRepository 解析

现在我们知道,DelegatingFilterProxy 中真正的 Filter 是 SessionRepositoryFilter,在生成 SessionRepositoryFilter 是传入了 sessionRepository,通过名字我们大概知道,这个应该是具体操作 session 的类,而这个类又具体是什么呢?,看下面的代码:

@Bean
public RedisOperationsSessionRepository sessionRepository() {
    // 操作redis的redisTemplate
    RedisTemplate<Object, Object> redisTemplate = this.createRedisTemplate();
    RedisOperationsSessionRepository sessionRepository = new RedisOperationsSessionRepository(redisTemplate);
    sessionRepository.setApplicationEventPublisher(this.applicationEventPublisher);
    if(this.defaultRedisSerializer != null) {
        sessionRepository.setDefaultSerializer(this.defaultRedisSerializer);
    }

   // maxInactiveIntervalInSeconds设置过期时间
  sessionRepository.setDefaultMaxInactiveInterval(this.maxInactiveIntervalInSeconds.intValue());
    if(StringUtils.hasText(this.redisNamespace)) {
        sessionRepository.setRedisKeyNamespace(this.redisNamespace);
    }

    sessionRepository.setRedisFlushMode(this.redisFlushMode);
    return sessionRepository;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

到这里我们知道,这个 sessionRepository 具体就是:RedisOperationsSessionRepository ,这个类就是实际通过 redis 操作 session 的类 。

好了,现在回到 DelegatingFilterProxy -> initFilterBean 方法,具体的 Filter 已经找到了,这个 Filter 就是 SessionRepositoryFilter,那么这个 Filter 又是在什么时候生效的呢?,接下来我们看 DelegatingFilterProxy 的 doFilter 方法。

public void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain) throws ServletException, IOException {
    Filter delegateToUse = this.delegate;
    if(delegateToUse == null) {
        Object var5 = this.delegateMonitor;
        synchronized(this.delegateMonitor) {
            delegateToUse = this.delegate;
            if(delegateToUse == null) {
                WebApplicationContext wac = this.findWebApplicationContext();
                if(wac == null) {
                    throw new IllegalStateException("No WebApplicationContext found: no ContextLoaderListener or DispatcherServlet registered?");
                }

                delegateToUse = this.initDelegate(wac);
            }

            this.delegate = delegateToUse;
        }
    }

    this.invokeDelegate(delegateToUse, request, response, filterChain);		//注意
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

注意 invokeDelegate 方法,进去看看:

protected void invokeDelegate(Filter delegate, ServletRequest request, ServletResponse response, FilterChain filterChain) throws ServletException, IOException {
    //调用 delegate的 doFilter 方法
    delegate.doFilter(request, response, filterChain);
}
1
2
3
4

这里我们大概知道了,DelegatingFilterProxy 实际上是委托给 delegate 的,而这里的 delegate 是 SessionRepositoryFilter,现在我们又回到 SessionRepositoryFilter 看看:

SessionRepositoryFilter 没有重写 doFilter 方法,因此查看父类:OncePerRequestFilter,通过名字我们知道,这个 Filter 只执行一次.

OncePerRequestFilter -> doFilter:
/**
* This {@code doFilter} implementation stores a request attribute for
* "already filtered", proceeding without filtering again if the attribute is already * there.
*/
public final void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain) throws ServletException, IOException {
    if (!(request instanceof HttpServletRequest) || !(response instanceof HttpServletResponse)){
        throw new ServletException( "OncePerRequestFilter just supports HTTP requests");
    }
    HttpServletRequest httpRequest = (HttpServletRequest) request;
    HttpServletResponse httpResponse = (HttpServletResponse) response; //查看是否已经过滤了
    boolean hasAlreadyFilteredAttribute = request
        .getAttribute(this.alreadyFilteredAttributeName) != null;
    if (hasAlreadyFilteredAttribute) {
        // Proceed without invoking this filter...
        filterChain.doFilter(request, response);
    } else {
        // Do invoke this filter...
        //加入已经过滤标志
        request.setAttribute(this.alreadyFilteredAttributeName, Boolean.TRUE);
        try {
            //调用 doFilterInternal
            doFilterInternal(httpRequest, httpResponse, filterChain);
        } finally {
            // Remove the "already filtered" request attribute for this request.
            request.removeAttribute(this.alreadyFilteredAttributeName);
        }
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29

在该方法中,会检查该请求是否已经被过滤了,如果过滤过了那么执行下一个 Filter,否则放入已过滤标识,然后执行 Filter 功能,doFilterInternal 在 SessionRepositoryFilter 被重写 。

SessionRepositoryFilter -> doFilterInternal:
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
    request.setAttribute(SESSION_REPOSITORY_ATTR, this.sessionRepository);
    SessionRepositoryRequestWrapper wrappedRequest = new SessionRepositoryRequestWrapper( request, response, this.servletContext);
    SessionRepositoryResponseWrapper wrappedResponse = new SessionRepositoryResponseWrapper( wrappedRequest, response);
    //包装 request,response
    HttpServletRequest strategyRequest = this.httpSessionStrategy .wrapRequest(wrappedRequest, wrappedResponse);
    HttpServletResponse strategyResponse = this.httpSessionStrategy .wrapResponse(wrappedRequest, wrappedResponse);
    try {
        filterChain.doFilter(strategyRequest, strategyResponse);
    } finally {
        //更新session
        wrappedRequest.commitSession();
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

Spring 接管 Tomcat 的 session 主要在这里可以看出来,Spring 会包装 HttpServletRequest ,HttpServletResponse ,当执行了这个 Filter 后,将包装了 request,response 专递到后面,这样后面所使用的都是 spring 包装过的,这样在获取 session 的接口,就被 spring 所控制了,当由服务器返回给用户是,调用 commitSession,更新 session。其中,SessionRepositoryRequestWrapper 和 SessionRepositoryResponseWrapper 是 SessionRepositoryFilter 中的 内部类。

可以在 Controller 中进行 debug 看看我们拿到的 HttpServletRequest 和 HttpSession 到底是什么?

@RestController
public class EchoController {

    @RequestMapping(value = "/query", method = RequestMethod.GET)
    public User query(String name, HttpServletRequest request, HttpSession session){
        System.out.println(session);
        User user = new User();
        user.setId(15L);
        user.setName(name);
        user.setPassword("root");
        user.setAge(28);
        session.setAttribute("user", user);
        return user;
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

具接下来我们看一下 SessionRepositoryRequestWrapper 中关于 getSession()实现 :

private S getSession(String sessionId) {
    //通过sessionRepository 获取session,在redis 中 这里的 sessionRepository 是RedisOperationsSessionRepository
    S session = SessionRepositoryFilter.this.sessionRepository .getSession(sessionId);
    if (session == null) {
        return null;
    }
    session.setLastAccessedTime(System.currentTimeMillis());
    return session;
}
1
2
3
4
5
6
7
8
9
// 重写 父类 getSession 方法
@Override
public HttpSessionWrapper getSession(boolean create) {
    HttpSessionWrapper currentSession = getCurrentSession();
    if (currentSession != null) {
        return currentSession;
    }
    //从当前请求获取sessionId
    String requestedSessionId = getRequestedSessionId();
    if (requestedSessionId != null && getAttribute(INVALID_SESSION_ID_ATTR) == null) {
        S session = getSession(requestedSessionId);
        if (session != null) {
            this.requestedSessionIdValid = true;
            //对Spring session 进行包装(包装成HttpSession)
            currentSession = new HttpSessionWrapper(session, getServletContext());
            currentSession.setNew(false);
            setCurrentSession(currentSession);
            return currentSession;
        } else {
            // This is an invalid session id. No need to ask again if
            // request.getSession is invoked for the duration of this request
            setAttribute(INVALID_SESSION_ID_ATTR, "true");
        }
    }
    if (!create) {
        return null;
    }
    S session = SessionRepositoryFilter.this.sessionRepository.createSession();
    session.setLastAccessedTime(System.currentTimeMillis());
    //对Spring session 进行包装(包装成HttpSession)
    currentSession = new HttpSessionWrapper(session, getServletContext());
    setCurrentSession(currentSession);
    return currentSession;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34

到这里应该就很清楚了,spring 对 HttpServletRequest 进行包装,然后重写对 Session 操作的接口,内部调用 SessionRepository 的实现类来对 session 进行操作,到此大致讲述了 Spring Session 是如何控制 session 的了

# 总结

当我们配置 DelegatingFilterProxy 时,会配置 filter-name:springSessionRepositoryFilter,当我们配置 RedisHttpSessionConfiguration 这个 bean 时,这个 Filter 则由 Spring 生成,而这个 Filter 实际是 :SessionRepositoryFilter, 当有请求到达时,DelegatingFilterProxy 委托给 SessionRepositoryFilter,而它又将 HttpServletRequest,HttpServletResponse 进行一定的包装,重写对 session 操作的接口,然后将包装好的 request,response 传递到后续的 Filter 中,完成了对 Session 的拦截操作,后续应用操作的 Session 都是 Spring Session 包装后的 Session。

注意:1.spring bean 的这个配置文件一定要写在 web.xml 的 context-param 部分。

2.filter 的名字必须是 springSessionRepositoryFilter

# jwt(JSON Web Tokens )

一般情况下,web 项目都是通过 session 进行认证,每次请求数据时,都会把 jsessionid 放在 cookie 中,以便与服务端保持会话。

前后端分离项目中,也可以通过 token 进行认证(登录时,生成唯一的 token 凭证),每次请求数据时,都会把 token 放在 header 中,服务端解析 token,并确定用户身份及用户权限,数据通过 json 交互。但是 token 一般都是 UUID 生成的一个随机码,作为一个 key 使用,从类似 redis 等缓存中获取具体的用户信息。所以一般需要一个存储介质来保存 token 和用户信息。在一些场景中,如单点登录时候有点麻烦。

有没一种更方便的方式呢?答案是有的,就是我们今天要讲的 jwt。

Json web token (JWT), 是为了在网络应用环境间传递声明而执行的一种基于 JSON 的开放标准。JWT 的声明一般被用来在身份提供者和服务提供者间传递被认证的用户身份信息,以便于从资源服务器获取资源,也可以增加一些额外的其它业务逻辑所必须的声明信息。jwt 也算是一个特殊的 token,不过 jwt 中自带了用户的相关信息,所以不需要存储介质,只需要验证签名保证安全的前提下就可以直接获取到用户的相关信息。jwt 被设计为紧凑且安全的,特别适用于分布式站点的单点登录(SSO)场景。

JWT 的原理是,服务器认证以后,生成一个 JSON 对象,发回给用户 ,以后,用户与服务端通信的时候,都要发回这个 JSON 对象。服务器完全只靠这个对象认定用户身份。为了防止用户篡改数据,服务器在生成这个对象的时候,会加上签名。服务器就不保存任何 session 数据了,也就是说,服务器变成无状态了,从而比较容易实现扩展。

JWT 的三个部分依次如下。

Header(头部)
Payload(负载)
Signature(签名)
1
2
3

下面依次介绍这三个部分

头部 (Header) Header 部分是一个 JSON 对象,描述 JWT 的元数据,通常是下面的样子

{
  "alg": "HS256",
  "typ": "JWT"
}
1
2
3
4

alg 属性表示签名的算法(algorithm),默认是 HMAC SHA256(写成 HS256);typ 属性表示这个令牌(tokenPayload 部分也是一个 JSON 对象,用来存放实际需要传递的数据。JWT 规定了 7 个官方字段,供选用 )的类型(type),JWT 令牌统一写为 JWT。最后,将上面的 JSON 对象使用 Base64URL 算法转成字符串。

载荷 (Payload)

这里是承载消息具体内容的地方 。内容又可以分为 3 中标准。

  • 标准中注册的声明

  • 公共的声明

  • 私有的声明

payload-标准中注册的声明 (建议但不强制使用) :

  • iss: jwt 签发者

  • sub: jwt 所面向的用户

  • aud: 接收 jwt 的一方

  • exp: jwt 的过期时间,这个过期时间必须要大于签发时间

  • nbf: 定义在什么时间之前,该 jwt 都是不可用的.

  • iat: jwt 的签发时间

  • jti: jwt 的唯一身份标识,主要用来作为一次性 token,从而回避重放攻击。

payload-公共的声明 :

公共的声明可以添加任何的信息。一般这里我们会存放一下用户的基本信息(非敏感信息)。

payload-私有的声明 :

私有声明是提供者和消费者所共同定义的声明。

需要注意的是,不要存放敏感信息,不要存放敏感信息,不要存放敏感信息

签证 (Signature)

Signature 部分是对前两部分的签名,防止数据篡改

首先,需要指定一个密钥(secret)。这个密钥只有服务器才知道,不能泄露给用户。然后,使用 Header 里面指定的签名算法(默认是 HMAC SHA256),按照下面的公式产生签名。

HMACSHA256(
	base64UrlEncode(header) + "." +
	base64UrlEncode(payload),
	secret)
1
2
3
4

算出签名以后,把 Header、Payload、Signature 三个部分拼成一个字符串,每个部分之间用"点"(.)分隔,就可以返回给用户

JWT 的使用方式

客户端收到服务器返回的 JWT,可以储存在 Cookie 里面,也可以储存在 localStorage。

此后,客户端每次与服务器通信,都要带上这个 JWT。你可以把它放在 Cookie 里面自动发送,但是这样不能跨域,所以更好的做法是放在 HTTP 请求的头信息Authorization字段里面

Authorization: Bearer <token>
1

另一种做法是,跨域的时候,JWT 就放在 POST 请求的数据体里面

jwt 的特点

(1)简洁,jwt 可以通过 URL,POST 参数或者在 HTTP header 发送,因为数据量小,传输速度也很快

(2)自包含,jwt 负载中包含了所有用户所需要的信息,避免了多次查询数据库或缓存

(3) JWT 默认是不加密,但也是可以加密的。生成原始 Token 以后,可以用密钥再加密一次。

(4)JWT 不仅可以用于认证,也可以用于交换信息。有效使用 JWT,可以降低服务器查询数据库的次数。

(5)JWT 的最大缺点是,由于服务器不保存 session 状态,因此无法在使用过程中废止某个 token,或者更改 token 的权限。也就是说,一旦 JWT 签发了,在到期之前就会始终有效,除非服务器部署额外的逻辑。

(6)JWT 本身包含了认证信息,一旦泄露,任何人都可以获得该令牌的所有权限。为了减少盗用,JWT 的有效期应该设置得比较短。对于一些比较重要的权限,使用时应该再次对用户进行认证。

(7)为了减少盗用,JWT 不应该使用 HTTP 协议明码传输,要使用 HTTPS 协议传输。

# Servlet 容器提供的插件

实现 Session 共享的方案很多,其中一种常用的就是使用 Tomcat、Jetty 等容器提供的 Session 共享功能,将 Session 的内容统一存储在一个数据库(如 MySQL)或缓存(如 Redis)中 。它的主要思想是利用 Servlet 容器提供的插件功能,自定义 HttpSession 的创建和管理策略,并通过配置的方式替换掉默认的策略。

其中 tomcat-redis-session-manager 插件重写了 Tomcat 的 org.apache.catalina.session.ManagerBase 里边的具体写的操作, 将 tomcat 的 session 存储位置指向了 Redis:

RedisSessionManager 继承了 org.apache.catalina.session.ManagerBase 并重写了 add、findSession、createEmptySession、remove 等方法,并将对 session 的增删改查操作指向了对 Redis 数据存储的操作。

# 4. 方案的选择

session sticky 实际等同于单点部署,优点是可以不依赖 LVS 或者 nginx 等中间件,也不会出现 session 同步过程中的网络消耗,但是如果宕机,则会导致 session 丢失,而且对于扩展和删除集群中的节点,也会导致部分会话丢失。

通过组播同步 session,可以不依赖 redis 这样的第三方组件,但是会造成网络流量瓶颈。

Spring Session 不依赖于 Servlet 容器,而是 Web 应用代码层面的实现,直接在已有项目基础上加入 spring Session 框架来实现 Session 统一存储在 Redis 中。如果 Web 应用是基于 Spring 框架开发的,只需要对现有项目进行少量配置,即可将一个单机版的 Web 应用改为一个分布式应用,由于不基于 Servlet 容器,所以可以随意将项目移植到其他容器。但是 Spring Session 实现分布式 Session 共享有个缺陷, 无法实现跨域名共享 session , 只能在单台服务器上共享 session,这是因为是依赖 cookie 机制,而 cookie 无法跨域 。所以 spring Session 一般是用于多台服务器负载均衡时共享 Session 的,都是同一个域名,不会跨域。

在 Web 应用中,绝大多数情况下,传统的 cookie-session 机制工作得更好。JWT 适合一次性的命令认证,颁发一个有效期极短的 JWT,即使暴露了危险也很小,由于每次操作都会生成新的 JWT,因此也没必要保存 JWT,真正实现无状态。

使用 tomcat-redis-session-manager 对外透明 ,可以在不修改代码的前提下实现。但是配置相对还是有一点繁琐的,需要人为的去修改 Tomcat 的配置, 需要耦合 Tomcat 等 Servlet 容器的代码,必须在同一种中间件之间完成(如:tomcat-tomcat 之间),session 复制带来的性能损失会快速增加.特别是当 session 中保存了较大的对象,而且对象变化较快时, 性能下降更加显著,会消耗系统性能,这种特性使得 web 应用的水平扩展受到了限制 。根据官方文档显示,目前对 tomcat6/7 支持,对 tomcat8 支持不友好。

# 5. 代码示例

# spring session

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>
1
2
3
4
5
6
7
8
9
spring-redis.xml配置
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:cache="http://www.springframework.org/schema/cache"
       xmlns:util="http://www.springframework.org/schema/util"
       xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd http://www.springframework.org/schema/data/mongo http://www.springframework.org/schema/data/mongo/spring-mongo.xsd http://www.alibaba.com/schema/stat http://www.alibaba.com/schema/stat.xsd http://www.springframework.org/schema/cache http://www.springframework.org/schema/cache/spring-cache.xsd http://www.springframework.org/schema/util http://www.springframework.org/schema/util/spring-util.xsd">

    <!--spring-session+redis-->
    <bean id="redisHttpSessionConfiguration"
          class="org.springframework.session.data.redis.config.annotation.web.http.RedisHttpSessionConfiguration">
        <!--session过期时间,单位秒-->
        <property name="maxInactiveIntervalInSeconds" value="${redis.session.timeout}"/>
    </bean>

    <bean id="jedisPoolConfig" class="redis.clients.jedis.JedisPoolConfig">
        <property name="maxTotal" value="${redis.maxTotal}"></property>
        <property name="maxIdle" value="${redis.maxIdle}"></property>
        <property name="maxWaitMillis" value="${redis.maxWait}"></property>
        <property name="testOnBorrow" value="${redis.testOnBorrow}"></property>
    </bean>

    <bean id="jedisConnectionFactory"
          class="org.springframework.data.redis.connection.jedis.JedisConnectionFactory" destroy-method="destroy">
        <property name="hostName" value="${redis.hostname}"/>
        <property name="port" value="${redis.port}"/>
        <property name="timeout" value="${redis.timeout}"/>
        <property name="usePool" value="${redis.usePool}"/>
        <property name="poolConfig" ref="jedisPoolConfig"/>
    </bean>

    <bean id="redisTemplate" class="org.springframework.data.redis.core.RedisTemplate">
        <property name="connectionFactory" ref="jedisConnectionFactory"></property>
        <property name="defaultSerializer">
            <bean class="org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer"></bean>
        </property>
        <property name="keySerializer">
            <bean class="org.springframework.data.redis.serializer.StringRedisSerializer"></bean>
        </property>
        <property name="hashKeySerializer">
            <bean class="org.springframework.data.redis.serializer.StringRedisSerializer" />
        </property>
    </bean>


    <bean id="redisUtil" class="com.dist.util.RedisUtil">
        <constructor-arg ref="redisTemplate"/>
    </bean>

    <!-- 让Spring Session不再执行config命令 -->
    <util:constant
            static-field="org.springframework.session.data.redis.config.ConfigureRedisAction.NO_OP"/>
</beans>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
pom.xml配置
<dependency>
    <groupId>redis.clients</groupId>
    <artifactId>jedis</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.session</groupId>
    <artifactId>spring-session-data-redis</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.data</groupId>
    <artifactId>spring-data-redis</artifactId>
</dependency>
1
2
3
4
5
6
7
8
9
10
11
12
13

# 组播方式

tomcat 之间通过配置 cluster + web.xml 配置实现 session 复制功能

<Cluster className="org.apache.catalina.ha.tcp.SimpleTcpCluster"
                 channelSendOptions="8">

          <Manager className="org.apache.catalina.ha.session.DeltaManager"
                   expireSessionsOnShutdown="false"
                   notifyListenersOnReplication="true"/>

          <Channel className="org.apache.catalina.tribes.group.GroupChannel">
            <Membership className="org.apache.catalina.tribes.membership.McastService"
                        address="228.0.0.4"
                        port="45564"
                        frequency="500"
                        dropTime="3000"/>
            <Receiver className="org.apache.catalina.tribes.transport.nio.NioReceiver"
                      address="10.0.125.15"
                      port="4001"
                      autoBind="100"
                      selectorTimeout="5000"
                      maxThreads="6"/>

            <Sender className="org.apache.catalina.tribes.transport.ReplicationTransmitter">
              <Transport className="org.apache.catalina.tribes.transport.nio.PooledParallelSender"/>
            </Sender>
            <Interceptor className="org.apache.catalina.tribes.group.interceptors.TcpFailureDetector"/>
            <Interceptor className="org.apache.catalina.tribes.group.interceptors.MessageDispatch15Interceptor"/>
          </Channel>

          <Valve className="org.apache.catalina.ha.tcp.ReplicationValve"
                 filter=""/>
          <Valve className="org.apache.catalina.ha.session.JvmRouteBinderValve"/>

          <ClusterListener className="org.apache.catalina.ha.session.JvmRouteSessionIDBinderListener"/>
          <ClusterListener className="org.apache.catalina.ha.session.ClusterSessionListener"/>
        </Cluster>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34

Manager 用来在节点间拷贝 Session,默认使用 DeltaManager,DeltaManager 采用的一种 all-to-all 的工作方式,即集群中的节点会把 Session 数据向所有其他节点拷贝,而不管其他节点是否部署了当前应用。当集群中的节点数量很多并且部署着不同应用时,可以使用 BackupManager,BackManager 仅向部署了当前应用的节点拷贝 Session。但是到目前为止 BackupManager 并未经过大规模测试,可靠性不及 DeltaManager。

Membership 用于发现集群中的其他节点,这里的 address 用的是组播地址使用同一个组播地址和端口的多个节点同属一个子集群,因此通过自定义组播地址和端口就可将一个大的 tomcat 集群分成多个子集群。

receiver 用于各个节点接收其他节点发送的数据,在默认配置下 tomcat 会从 4000-4100 间依次选取一个可用的端口进行接收,自定义配置时,如果多个 tomcat 节点在一台物理服务器上注意要使用不同的端口。

Sender 用于向其他节点发送数据,具体实现通过 Transport 配置。

Channel 是一个抽象的端口,和 socket 类似,集群 member 通过它收发信息。

Valve 用于在节点向客户端响应前进行检测或进行某些操作,ReplicationValve 就是用于检测当前的响应是否涉及 Session 数据的更新,如果是则启动 Session 拷贝操作,filter 用于过滤请求,如客户端对图片,css,js 的请求就不会涉及 Session,因此不需检测,默认状态下不进行过滤,监测所有的响应。

修改 web.xml ,即只在之间添加

<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns="http://java.sun.com/xml/ns/javaee"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://java.sun.com/xml/ns/javaee
                      http://java.sun.com/xml/ns/javaee/web-app_3_0.xsd"
         version="3.0"
         metadata-complete="true">

    <display-name>gxtz-server-web</display-name>
    <welcome-file-list>
        <welcome-file>index.html</welcome-file>
    </welcome-file-list>

    <distributable/>

    ...
</web-app>

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

# tomcat-redis-session-manager 使用

开源项目地址:https://github.com/jcoleman/tomcat-redis-session-manager

下载代码之后需要进行重新编译,生成所需要的 jar 。然后将编译生产tomcat-redis-session-1.0-SNAPSHOT.jar以及jedis-2.7.2.jar、commons-pool2-2.0.jar 三个 jar 包放在各个 tomcat 实例下的 lib 目录下。接着修改 tomcat 实例下 conf/contex.xml 文件

<?xml version='1.0' encoding='utf-8'?>
<Context>
    <WatchedResource>WEB-INF/web.xml</WatchedResource>
    <!-- tomcat-redis-session共享配置 -->
    <Valve className="com.orangefunction.tomcat.redissessions.RedisSessionHandlerValve" /> 			<Manager className="com.orangefunction.tomcat.redissessions.RedisSessionManager"
         host="192.168.1.149"
         port="6379"
         database="0"
         maxInactiveInterval="60" />
</Context>
1
2
3
4
5
6
7
8
9
10

如果 Redis 配置了访问权限,请添加密码:

<?xml version='1.0' encoding='utf-8'?>
<Context>
    <WatchedResource>WEB-INF/web.xml</WatchedResource>
    <!-- tomcat-redis-session共享配置 -->
    <Valve className="com.orangefunction.tomcat.redissessions.RedisSessionHandlerValve" /> 		<Manager className="com.orangefunction.tomcat.redissessions.RedisSessionManager"
         host="192.168.1.149"
         port="6379"
         database="0"
         password="redispassword"
         maxInactiveInterval="60" />
</Context>
1
2
3
4
5
6
7
8
9
10
11

# JWT 使用

引入依赖

<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt</artifactId>
    <version>0.9.1</version>
</dependency>
1
2
3
4
5

封装工具类

@ConfigurationProperties(prefix = "renren.jwt")

@Component

public class JwtUtils {

    private Logger logger = LoggerFactory.getLogger(getClass());

    private String secret;

    private long expire;

    private String header;

    /**
    * 生成jwt token
    */
    public String generateToken(long userId) {
        Date nowDate = new Date();        //过期时间
        Date expireDate = new Date(nowDate.getTime() + expire * 1000);

        return Jwts.builder()
            .setHeaderParam("typ", "JWT")
            .setSubject(userId+"")
            .setIssuedAt(nowDate)
            .setExpiration(expireDate)
            .signWith(SignatureAlgorithm.HS512, secret)
            .compact();
    }

    /**
    *获取载荷信息
    */
    public Claims getClaimByToken(String token) {

        try {

            return Jwts.parser()
                .setSigningKey(secret)
                .parseClaimsJws(token)
                .getBody();
        }catch (Exception e){
            logger.debug("validate is token error ", e);

            return null;
        }
    }

    /**
    * token是否过期
    * @return  true:过期
    */
    public boolean isTokenExpired(Date expiration) {

        return expiration.before(new Date());
    }

    //getter、setter

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60

生成 token

String token = jwtUtils.generateToken(userId)
1

校验 token

Claims claims = jwtUtils.getClaimByToken(token)if(claims == null || jwtUtils.isTokenExpired(claims.getExpiration())){
	log.info("token失效");
	return;
}
long userId =   Long.parseLong(claims.getSubject())
1
2
3
4
5
6