详解Spring Session

1.背景

1.1 session

说起session还要从http协议说起,http协议是无状态协议。所谓无状态协议,就是http请求之间是相互独立的。比如,你用百度搜索“2020年NBA总冠军是谁”,百度服务器会给你一个响应,这一次的http请求就算完事儿了。当你再去搜索“谁会赢得2020年美国总统大选”,这个时候百度服务器并不会感知到你上一次搜索了啥,你的两次搜索是完全独立的。可以用下面的图简略表示下。

对于百度搜索这样的场景,这样的无状态协议问题不大。但是对于大多数场景,这种无状态协议是远远不够的。比如,你要查看自己淘宝的购物车,淘宝会返回一个登陆页面,这个时候你输入了自己的用户名和密码,发起了一次登陆的http请求,假设用户名和密码都正确,这个时候系统会自动跳转到登陆成功的页面;但是当你点击付款的时候,发起了一次付款的http请求,这个时候系统会校验当前的用户信息,以及用户是否登陆,由于http是无状态协议,服务器并不知道你已经通过上次请求了登陆,会再次返回登陆页面。这样每操作一次都需要登陆一次,用户体验极差。

为了解决类似的问题,session应运而生。以淘宝登陆为例,它的主要工作流程如下:

1. 用户在登陆页面输入用户名和密码,点击登陆,发送登录请求至淘宝服务器。

2. 服务器接收到请求后,校验请求头部中未携带sessionId,于是创建session对象,并将sessionId放入response的cookie对象中。

3. 服务器校验用户和密码成功后,将用户信息存放至session中。请求返回,浏览器将response中cookie缓存至本地。

4. 用户点击付款,发送付款请求,由于请求的是相同的域名,浏览器会自动在请求头部中添加sessionId。

5. 服务器接收到请求后,识别到请求头部中sessionId,直接根据sessionId去查找上一次创建的session对象,并将该对象放入request对象中。

6. 服务器从request的session对象中获取到用户信息,校验通过,付款成功。

      

        

1.2 分布式应用存在的问题

1.2.1问题复现

session的出现很大程度上解决了http请求无状态的问题。但是,随着业务量增加和对服务高可用的诉求,分布式应用和微服务得到了越来越广泛的应用,这样使用传统的session就会存在问题。接下来我们就来复现一下这个问题。

1.使用springboot实现一个简单的登陆功能

访问http://localhost:8080/login,如果用户已经登陆,则直接返回主页面;如果用户未登陆,跳转至登陆页面。在登陆页面输入用户名和密码,点击登陆,跳转至登陆成功页面(此处不对用户名和密码进行校验)。

package com.example.mysession.collector;

import org.springframework.stereotype.Controller;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;

@Controller
public class LoginContoller {

    /**
     * 返回登陆页面
     */
    @GetMapping("/login")
    public String login() {
        HttpServletRequest request = getRequest();
        HttpSession session = request.getSession();
        // session中包含用户信息,直接返回主页
        String usernameInSession = (String) session.getAttribute("username");
        if (!StringUtils.isEmpty(usernameInSession)) {
            return "main";
        }

        // 重定向至登陆页面
        return "loginpage";
    }


    /**
     * 处理登陆请求
     */
    @PostMapping("/doLogin")
    public String doLogin(@RequestParam String username, @RequestParam String password) {
        HttpServletRequest request = getRequest();
        HttpSession session = request.getSession();
        // 将用户名放入session,并通知用户登陆成功(这里不对用户名和密码进行校验)
        session.setAttribute("username", username);
        return "main";
    }


    private HttpServletRequest getRequest() {
        ServletRequestAttributes servletRequestAttributes =
            (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        return servletRequestAttributes.getRequest();
    }
}

2.我们来分析一下这个简单的登陆流程

(1)springboot启动后,访问http://localhost:8084/login。回想1.1中的流程,由于我们是首次访问,请求头中没有带sessionId,因此tomcat会为我们创建一个session对象,并将sessionId返回,浏览器将sessionId缓存至本地。

请求前:

请求后:

(2)输入用户名和密码,点击登陆。这时请求会带上SessionId,tomcat会根据SessionId找到之前创建session,并将其放入request对象中。doLogin()方法在处理时会将用户信息存入session。

(3)再访问http://localhost:8084/login。此次请求浏览器依然会将SessionId放入请求头部,login()方法根据SessionId找到session,进而在该session中找到用户信息,判断当前用户已经登陆,直接返回登陆成功页面。

3.使用nginx反向代理,实现简单的集群

(1)上述是单机环境,session完美解决了http协议无状态的问题,使得我们不需要频繁登陆。接下来,我们启动两个相同的服务(1中简单登陆服务),他们分别监听8084和8085端口。

(2)测试两个服务是否都正常可用

8084:

8085:

(3)使用ngnix进行反向代理

nginx.conf:


#user  nobody;
worker_processes  1;

events {
    # 并发连接数量
    worker_connections  1024;
}


http {
    # tomcat集群
    upstream tomcat_servers{
        server 127.0.0.1:8084;
        server 127.0.0.1:8085;
    }

    server {
        # 监听80端口
        listen       80;
        server_name  localhost;

        # 将请求交给tomcat集群处理
        location / {
		    proxy_set_header Host $host:$server_port;
            proxy_pass http://tomcat_servers;
        }

    }
}

4.出现问题

(1)访问http://localhost/login

(2)输入用户名和密码,点击登陆。

(3) 再次访问http://localhost/login。按照之前单节点的处理逻辑,应该返回登陆成功的页面才对,但是现在好像有点不太对?

1.2.2 原因分析

上述的现象比较明显,就是服务器好像并没有记住我们已经登陆过了。要解释这个问题我们首先要看下session的本质,session本质是tomcat为我们创建的一个对象,它使用ConcurrentHashMap保存属性值。tomcat本质也是一个Java程序,一个tomcat容器(8084)创建的session另外一个tomcat(8085)自然是获取不到的。

好了,有了上面的知识,我们再来看看这个问题是怎么产生的。我们启动了两个tomcat服务器(分别监听8084和8085端口),使用ngnix进行反向代理,为了达到负载均衡的效果,我们在ngnix上置的策略是轮流访问8084和8085,也就是请求第一次访问8084节点,第二次访问8085节点,再是8084...以此类推。

当我们第一次访问http://localhost/login时,ngnix会为我们路由到8084节点,由于是第一次访问8084会为我们创建一个session,sessionId为32AC7EEFE4CD0486F88B23B84594A768,并返回至浏览器。

当我们输入用户名和密码,点击登陆,第二次访问时,ngnix会为我们路由到8085节点。这一次我们请求头部带了SessionId:32AC7EEFE4CD0486F88B23B84594A768,但是这个session是8084节点创建的,8085节点获取不到这个session,所以8085又会创建一个新的session,709F94B639FFD8DF34110E1F1760D84D,返回至浏览器。

至此我们已经登陆成功了。我们再次访问http://localhost/login,ngnix会为我们路由到8084节点,请求头部带上上一次请求返回的sessionId:709F94B639FFD8DF34110E1F1760D84D,而这个是8085节点创建的,8084节点获取不到这个session,所以认为我们没有登陆过。

...

如此循环,我们会发现每一次请求,tomcat都会为我们创建一个session对象,而一个tomcat容器创建的session对象,另一个tomcat容器获取不到,这就是问题的根源。可能有的人会说,这不简单,在ngnix上配置策略,让每次来自同一个IP的用户访问同一个tomcat容器,这样不就好了?但是这样主要有两个问题,第一,如果一个节点出问题挂掉了,那么之前一直访问这个节点的用户登陆信息就丢失了,需要重新登陆,这显然不合理第二,移动端应用越来越多,而移动端可能换一个基站IP就变了。

2.spring session

2.1 解决问题的原理

知道了上述问题的原因,我们来看看怎么解决这个问题。首先来回顾下tomcat处理请求的流程

从上面的分析以及工作流程我们可以知道,解决该问题的关键就是如何让多个tomcat共享同一个session。我们自然而然就会想到,把session存储在一个公共的地方,这样每个tomcat就都会获取到了,这个公共的地方就是数据库(本文以Redis为例)。spring session的实现原理,就是在tomcat中加入了一个优先级很高的filter,来一个偷天换日,将request中的session置换为spring session,而这个spring session就存储在数据库中(Redis),这样一来,不同的tomca就可以共享同一个session啦。具体的细节我们在源码解析中再详细介绍,先看看spring session如何使用。

2.2 spring session的使用

可以参考spring官网https://docs.spring.io/spring-session/docs/1.3.0.RELEASE/reference/html5/guides/httpsession.html

1.添加依赖

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.3.5.RELEASE</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>com.example</groupId>
    <artifactId>my-session</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>my-session</name>
    <description>Demo project for Spring Boot</description>

    <properties>
        <java.version>1.8</java.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-thymeleaf</artifactId>
        </dependency>

        <!-- spring session -->
        <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>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>

</project>

 

2.添加一个配置文件

package com.example.mysession.config;

import org.springframework.context.annotation.Bean;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.session.data.redis.config.annotation.web.http.EnableRedisHttpSession;

@EnableRedisHttpSession
public class SpringSessionConfig {

    @Bean
    public LettuceConnectionFactory connectionFactory() {
        return new LettuceConnectionFactory();
    }
    
}

3.向spring中注入一个Initializer

package com.example.mysession.initializer;

import jdk.nashorn.internal.runtime.regexp.joni.Config;
import org.springframework.session.web.context.AbstractHttpSessionApplicationInitializer;
import org.springframework.stereotype.Component;

@Component
public class Initializer extends AbstractHttpSessionApplicationInitializer {

    public Initializer() {
        super(Config.class);
    }
}

4.测试

(1)访问http://localhost/login

(2)输入用户名和密码,点击登陆。

(3)再次请求http://localhost/login。直接跳转至登陆成功页面。

这里我们可以看到,第二次请求和第三次请求携带的都是第一次请求返回的sessionId。

2.3 spring session源码解析

spring的官网写的非常清晰,我们使用@EnableRedisHttpSession注解创建了一个springSessionRepositoryFilter。而这个filter就是用来将tomcat创建的session对象替换为spring session。

实际处理的Filter时SessionRepositoryFilter,而SessionRepositoryFilter又继承OncePerRequestFilter,所以dofilter()方法在OncePerRequestFilter类中实现。

OncePerRequestFilter中的dofilter()方法

	/**
	 * This {@code doFilter} implementation stores a request attribute for "already
	 * filtered", proceeding without filtering again if the attribute is already there.
	 * @param request the request
	 * @param response the response
	 * @param filterChain the filter chain
	 * @throws ServletException if request is not HTTP request
	 * @throws IOException in case of I/O operation exception
	 */
	@Override
	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;
		String alreadyFilteredAttributeName = this.alreadyFilteredAttributeName;
		boolean hasAlreadyFilteredAttribute = request.getAttribute(alreadyFilteredAttributeName) != null;

        // 已经过滤过了
		if (hasAlreadyFilteredAttribute) {
			if (DispatcherType.ERROR.equals(request.getDispatcherType())) {
				doFilterNestedErrorDispatch(httpRequest, httpResponse, filterChain);
				return;
			}
			// Proceed without invoking this filter...
			filterChain.doFilter(request, response);
		}
        // 还未过滤
		else {
			// Do invoke this filter...
			request.setAttribute(alreadyFilteredAttributeName, Boolean.TRUE);
			try {
				doFilterInternal(httpRequest, httpResponse, filterChain);
			}
			finally {
				// Remove the "already filtered" request attribute for this request.
				request.removeAttribute(alreadyFilteredAttributeName);
			}
		}
	}

我们来看这个doFilterInternal方法。(SessionRepositoryFilter类中的方法)这里采用了装饰器模式,将HttpServletRequest和HttpServletResponse封装成SessionRepositoryRequestWrapper和SessionRepositoryResponseWrapper。在这两个Wrapper中对tomcat的session进行了替换。

	@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);
		SessionRepositoryResponseWrapper wrappedResponse = new SessionRepositoryResponseWrapper(wrappedRequest,
				response);

		try {
			filterChain.doFilter(wrappedRequest, wrappedResponse);
		}
		finally {
			wrappedRequest.commitSession();
		}
	}

 

 

 

 

 

 

 

  • 15
    点赞
  • 61
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
Spring Security是一个功能强大且灵活的身份验证和访问控制框架,用于保护基于Java的应用程序。它提供了一套全面的安全解决方案,可用于保护Web应用程序、RESTful API、方法级别的安全等。 Spring Security的核心原则是基于拦截器链(Filter Chain)的安全性,它通过一系列的过滤器(Filters)来处理不同的安全问题。这些过滤器可以在认证(Authentication)和授权(Authorization)过程中执行各种任务。 在Spring Security中,认证是指验证用户的身份,通常通过用户名和密码进行验证。授权是指根据用户的身份和角色来确定其是否有权访问特定资源或执行特定操作。 Spring Security提供了许多功能和扩展点,可以轻松地自定义和扩展以满足特定的需求。以下是一些Spring Security的主要功能: 1. 身份验证(Authentication):Spring Security支持多种身份验证方式,如基于数据库、LDAP、OAuth等。它还提供了记住我(Remember Me)和匿名访问等功能。 2. 授权(Authorization):Spring Security支持基于角色和权限的授权机制。可以配置细粒度的访问控制规则,以确保只有具有合适权限的用户可以访问受保护的资源。 3. 安全性注解(Security Annotations):Spring Security提供了一套注解,可以在方法级别上标记安全性要求。这些注解可以用于限制对特定方法的访问,并进行细粒度的授权控制。 4. CSRF保护(CSRF Protection):Spring Security提供了一种防止跨站请求伪造(CSRF)攻击的机制。它通过生成和验证CSRF令牌来确保只有合法的请求才能被处理。 5. Session管理(Session Management):Spring Security提供了对会话管理的支持,包括会话过期、并发控制和无效会话处理等功能。 6. 安全事件与日志(Security Events and Logging):Spring Security可以生成安全相关的事件,并提供了灵活的日志配置选项,以便记录和监视应用程序的安全状态。 以上只是Spring Security的一些主要功能,它还有很多其他特性和扩展点可用于满足各种安全需求。要详细了解Spring Security的使用和配置,可以参考官方文档或其他相关资源。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值