第三章 Session共享&单点登陆笔记
一、传统Session机制及身份认证方案
1、Cookie与服务器的交互
如上图,http是无状态的协议,客户每次读取web页面时,服务器都打开新的会话,而且服务器也不会自动维护客户的上下文信息。比如我们现在要实现一个电商内的购物车功能,要怎么才能知道哪些购物车请求对应的是来自同一个客户的请求呢?session就是一种保存上下文信息的机制,它是针对每一个用户的,变量的值保存在服务器端,通过SessionID来区分不同的客户。session是以cookie或URL重写为基础的,默认使用cookie来实现,系统会创造一个名为JSESSIONID的值输出到cookie。
注意JSESSIONID是存储于浏览器内存中的,并不是写到硬盘上的,如果我们把浏览器的cookie禁止,则web服务器会采用URL重写的方式传递Sessionid,我们就可以在地址栏看到 sessionid=KWJHUG6JJM65之类的字符串。 通常JSESSIONID是不能跨窗口使用的,当你新开了一个浏览器窗口进入相同页面时,系统会赋予你一个新的sessionid,这样我们信息共享的目的就达不到了。
2、服务器端的session的机制
session机制是一种服务器端的机制,服务器使用一种类似于散列表的结构(也可能就是使用散列表)来保存信息。但程序需要为某个客户端的请求创建一个session的时候,服务器首先检查这个客户端的请求里是否包含了一个JSESSIONID标识的sessionid,如果已经包含一个session id则说明以前已经为此客户创建过session,服务器就根据sessionid把这个session检索出来使用(如果检索不到,可能会新建一个,这种情况可能出现在服务端已经删除了该用户对应的session对象,但用户人为地在请求的URL后面附加上一个JSESSION的参数)。
如果客户请求不包含 session id,则为此客户创建一个session并且生成一个与此session相关联的session id,这个session id将在本次响应中返回给客户端保存。对每次http请求,都经历以下步骤处理:服务端首先查找对应的cookie的值(sessionid)。 根据sessionid从服务器端session存储中获取对应id的 session数据,进行返回。 如果找不到sessionid,服务器端就创建session,生成sessionid对应的cookie,写入到响应头中。
3、基于session的身份认证
看下图:
因为http请求是无状态请求,所以在Web领域,几乎所有的身份认证过程,都是这种模式。
二、集群下Session困境及解决方案
如上图,随着分布式架构的流行,单个服务器已经不能满足系统的需要了,通常都会把系统部署在多台服务器上,通过负载均衡把请求分发到其中的一台服务器上,这样很可能同一个用户的请求被分发到不同的服务器上,因为session是保存在服务器上的,那么很有可能第一次请求访问的A服务器,创建了session,但是第二次访问到了B服务器,这时就会出现取不到session的情况。我们知道,Session一般是用来存会话全局的用户信息(不仅仅是登陆方面的问题),用来简化/加速后续的业务请求。要在集群环境下使用,最好的的解决办法就是使用session共享:
1、Session共享方案
传统的session由服务器端生成并存储,当应用进行分布式集群部署的时候,如何保证不同服务器上session信息能够共享呢?两种实现思路:session集中存储(redis,memcached,hbase等)。不同服务器上session数据进行复制,此方案延迟问题比较严重。 我们一般推荐第一种方案,基于session集中存储的实现方案,见下图:
具体过程如下:
新增Filter拦截请求,包装HttpServletRequest(使用HttpServletRequestWrapper) 改写getSession方法,从第三方存储中获取session数据(若没有则创建一个),返回自定义的HttpSession实例在http返回response时,提交session信息到第三方存储中。
2、需要考虑的问题
2.1、需要考虑以下问题:
session数据如何在Redis中存储?
session属性变更何时触发存储?
2.2、实现:
考虑到session中数据类似map的结构,采用redis中hash存储session数据比较合适,如果使用单个value存储session数据,不加锁的情况下,就会存在session覆盖的问题,因此使用hash存储session,每次只保存本次变更session属性的数据,避免了锁处理,性能更好。如果每改一个session的属性就触发存储,在变更较多session属性时会触发多次redis写操作, 对性能也会有影响,我们是在每次请求处理完后,做一次session的写入,并且写入变更过的属性。如果本次没有做session的更改,是不会做redis写入的,仅当没有变更的session超过一个时间阀值(不变更session刷新过期时间的阀值),就会触发session保存,以便session能够延长有效期。
3、代码实战
3.1、新建项目springboot-session-bussiness
主要实现正常的登录拦截功能,为后面项目改造提供参考。
代码参见:springboot-platform------>springboot-session-bussiness模块
代码git地址:https://gitee.com/hankin_chj/springboot-platform.git
3.2、新建springboot-session-bussiness模块
该模块为公共模块,主要用于为其他服务提供统一的session拦截、分装等功能,主要对系统的request和session对象进行重写,其他服务需要用到request的时候就使用封装好的MyRequestWrapper来获取session相关内容。
1)重写HttpSession
public class MySession implements Serializable,HttpSession {
private static final long serialVersionUID = -3923541488767125713L;
private String id;
private Map<String,Object> attrs;
.....
2)request封装代码实现如下:
public class MyRequestWrapper extends HttpServletRequestWrapper {
private volatile boolean committed = false;
private String uuid = UUID.randomUUID().toString();
private MySession session;
private RedisTemplate redisTemplate;
public MyRequestWrapper(HttpServletRequest request,RedisTemplate redisTemplate) {
super(request);
this.redisTemplate = redisTemplate;
}
// 提交session内值到redis
public void commitSession() {
if (committed) {
return;
}
committed = true;
MySession session = this.getSession();
if (session != null && null != session.getAttrs()) {
redisTemplate.opsForHash().putAll(session.getId(),session.getAttrs());
}
}
//创建新session
public MySession createSession() {
String sessionId = CookieBasedSession.getRequestedSessionId(this);//从页面传来的
Map<String,Object> attr ;
if (null != sessionId){
attr = redisTemplate.opsForHash().entries(sessionId);
} else {
System.out.println("create session by rId:"+uuid);
sessionId = UUID.randomUUID().toString();
attr = new HashMap<>();
}
//session成员变量持有
session = new MySession();
session.setId(sessionId);
session.setAttrs(attr);
return session;
}
//或取session
public MySession getSession() {
return this.getSession(true);
}
// 取session
public MySession getSession(boolean create) {
if (null != session){
return session;
}
return this.createSession();
}
//是否已登陆
public boolean isLogin(){
Object user = getSession().getAttribute(SessionFilter.USER_INFO);
return null != user;
}
}
3)包装request对象,提交session到redis,SessionFilter代码实现:
public class SessionFilter implements Filter {
public static final String USER_INFO = "user";
private RedisTemplate redisTemplate;
public void setRedisTemplate(RedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
@Override
public void init(FilterConfig filterConfig) throws ServletException {}
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse,
FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) servletRequest;
HttpServletResponse response = (HttpServletResponse)servletResponse;
//包装request对象
MyRequestWrapper myRequestWrapper = new MyRequestWrapper(request,redisTemplate);
//如果未登陆,则拒绝请求,转向登陆页面
String requestUrl = request.getServletPath();
if (!"/toLogin".equals(requestUrl)//不是登陆页面
&& !requestUrl.startsWith("/login")//不是去登陆
&& !myRequestWrapper.isLogin()) {//不是登陆状态
request.getRequestDispatcher("/toLogin").forward(myRequestWrapper,response);
return ;
}
try {
filterChain.doFilter(myRequestWrapper,servletResponse);
} finally {
//提交session到redis
myRequestWrapper.commitSession();
}
}
@Override
public void destroy() { }
}
4)工具类CookieBasedSession通过cookie获取sessionID:
public class CookieBasedSession{
public static final String COOKIE_NAME_SESSION = "psession";
public static String getRequestedSessionId(HttpServletRequest request) {
Cookie[] cookies = request.getCookies();
if (cookies == null) {
return null;
}
for (Cookie cookie : cookies) {
if (cookie == null) {
continue;
}
if (!COOKIE_NAME_SESSION.equalsIgnoreCase(cookie.getName())) {
continue;
}
return cookie.getValue();
}
return null;
}
public static void onNewSession(HttpServletRequest request,HttpServletResponse response) {
HttpSession session = request.getSession();
String sessionId = session.getId();
Cookie cookie = new Cookie(COOKIE_NAME_SESSION, sessionId);
cookie.setHttpOnly(true);
cookie.setPath(request.getContextPath() + "/");
// 设置一级域名
cookie.setDomain("dev.com");
cookie.setMaxAge(Integer.MAX_VALUE);
response.addCookie(cookie);
}
}
3.3、新建springboot-sessionA与springboot-sessionB项目
SessionA和sessionB项目代码基本一致,知识配置不一样,模拟两个业务服务登录场景
1)pom文件配置
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>com.chj</groupId>
<artifactId>springboot-session-support</artifactId>
<version>1.0.0</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<!--使用swagger2-->
<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-swagger2</artifactId>
<version>2.6.1</version>
</dependency>
<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-swagger-ui</artifactId>
<version>2.6.1</version>
</dependency>
</dependencies>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>2.1.3.RELEASE</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
2)application.yml配置
spring:
redis:
host: 127.0.0.1
port: 6379
devtools:
restart:
enabled: true #开启
additional-paths: src/main/java #监听目录
swagger:
host: local.dev.com
3)controller层代码实现,注意接口参数MyRequestWrapper代替了HttpServletRequest
@Controller
public class IndexController {
@GetMapping("/toLogin")
public String toLogin(Model model,MyRequestWrapper request) {//仅限本次会话
UserForm user = new UserForm();
user.setUsername("hankin");
user.setPassword("hankin");
user.setBackurl(request.getParameter("url"));
model.addAttribute("user", user);
return "login";
}
@PostMapping("/login")
public void login(@ModelAttribute UserForm user,MyRequestWrapper request,HttpServletResponse response) throws IOException, ServletException {
request.getSession().setAttribute(SessionFilter.USER_INFO,user);
//TODO 种cookie
CookieBasedSession.onNewSession(request,response);
//重定向
response.sendRedirect("/index");
}
@GetMapping("/index")
public ModelAndView index(MyRequestWrapper request) {
ModelAndView modelAndView = new ModelAndView();
modelAndView.setViewName("index");
modelAndView.addObject("user", request.getSession().getAttribute(SessionFilter.USER_INFO));
request.getSession().setAttribute("test","123");
return modelAndView;
}
}
4)session配置将SessionFilter注入到servlet容器
/**
* 把SessionFilter 注册掉servlet容器里面
*/
@Configuration
public class SessionConfig {
//配置filter生效
@Bean
public FilterRegistrationBean sessionFilterRegistration(SessionFilter sessionFilter) {
FilterRegistrationBean registration = new FilterRegistrationBean();
registration.setFilter(sessionFilter);
registration.addUrlPatterns("/*");
registration.addInitParameter("paramName", "paramValue");
registration.setName("sessionFilter");
registration.setOrder(1);
return registration;
}
//定义过滤器组件
@Bean
public SessionFilter sessionFilter(RedisTemplate redisTemplate){
SessionFilter sessionFilter = new SessionFilter();
sessionFilter.setRedisTemplate(redisTemplate);
return sessionFilter;
}
}
注意:
SessionA和sessionB两个服务的登录方法里面都会讲讲session信息放到一级域名cookie里面,所以一旦一个系统登录,另外一个系统也会从这个一级域名下面获取到登录信息。
@PostMapping("/login")
public void login(@ModelAttribute UserForm user, MyRequestWrapper request, HttpServletResponse response) throws IOException, ServletException {
request.getSession().setAttribute(SessionFilter.USER_INFO,user);
//种cookie
CookieBasedSession.onNewSession(request,response);
//重定向
response.sendRedirect("/index");
}
3.4、启动服务访问测试:
A、B服务刚开始访问都是未登录状态如下:
当a登录以后刷新b服务,发现已经是登录状态:
三、多服务下的登陆困境及SSO方案
1、SSO的产生背景
较大的企业内部,一般都有很多的业务支持系统为其提供相应的管理和IT服务。通常来说,每个单独的系统都会有自己的安全体系和身份认证系统。进入每个系统都需要进行登录,这样的局面不仅给管理上带来了很大的困难,对客户来说也极不友好。那么如何让客户只需登陆一次,就可以进入多个系统,而不需要重新登录呢?
“单点登录”就是专为解决此类问题的。其大致思想流程如下:通过一个ticket进行串接各系统间的用户信息
2、SSO的底层原理CAS
2.1、起点
1)对于完全不同域名的系统,cookie是无法跨域名共享的,因此sessionId在页面端也无法共享。
2)cas方案,直接启用一个专业的用来登陆的域名(比如:cas.com)来供所有的系统的sessionId。
3)当业务系统(如 b.com)被打开时,借助cast系统来登陆,具体流程参见2.2
4)cas.com的登陆页面被打开时,如果此时cas.com本来就是登陆状态的,则自动返回生成ticket给业务系统。整个单点登陆的关键部位,是利用cas.com的cookie保持cas.com是登陆状态,此后任何第三个系统跳入,都将自动完成登陆过程。
5)本示例中,使用了redis来做cas的服务接口,根据工作情况,自行替换为合适的服务接口(主要是根据sessionid来判断用户是否已登陆)。
6)为提高安全性,ticket应该使用过即作废(本例中,会用有效期机制)。
2.2、cas登陆的全过程:
1)b.com打开时,发现自己未登陆,于是跳转到cas.com去登陆。
2)cas.com登陆页面被打开,用户输入帐户/密码登陆成功。
3)cas.com登陆成功,种cookie到cas.com域名下,把sessionid放入后台redis《ticket,sessionId》然后页面跳回b.com。
4)b.com重新被打开,发现仍然是未登陆,但是有了一个ticket值。
5)b.com用ticket值,到redis里查到sessionid,并做session同步,种cookie到自己域名下面,页面原地重跳。
6)b.com打开自己页面,此时有了cookie,后台校验登陆状态,成功。
3、代码实战:
新建项目:springboot-cas-login,springboot-cas-website,springboot-cas-website
3.1、springboot-cas-login项目核心代码配置如下:
1)pom配置
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.1.3.RELEASE</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>springboot-cas-login</artifactId>
<name>springboot-cas-login</name>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<maven.compiler.source>1.8</maven.compiler.source>
<maven.compiler.target>1.8</maven.compiler.target>
<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>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
</dependencies>
2)application.yml配置,注意本地host配置域名cas.com
#启动: http://cas.com:8080/index
server:
port: 8080
compression:
enabled: true
connection-timeout: 3000
spring:
redis:
host: 127.0.0.1
port: 6379
3)登录拦截过滤处理类代码
public class LoginFilter implements Filter {
public static final String USER_INFO = "user";
@Override
public void init(FilterConfig filterConfig) throws ServletException { }
@Override
public void doFilter(ServletRequest servletRequest,ServletResponse servletResponse,
FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) servletRequest;
HttpServletResponse response = (HttpServletResponse)servletResponse;
Object userInfo = request.getSession().getAttribute(USER_INFO);;
//TODO 如果未登陆,则拒绝请求,转向登陆页面
String requestUrl = request.getServletPath();
if (!"/toLogin".equals(requestUrl)//不是登陆页面
&& !requestUrl.startsWith("/login")//不是去登陆
&& null == userInfo) {//不是登陆状态
request.getRequestDispatcher("/toLogin").forward(request,response);
return ;
}
filterChain.doFilter(request,servletResponse);
}
@Override
public void destroy() {
}
}
4)配置filter生效
@Configuration
public class LoginConfig {
//配置filter生效
@Bean
public FilterRegistrationBean sessionFilterRegistration() {
FilterRegistrationBean registration = new FilterRegistrationBean();
registration.setFilter(new LoginFilter());
registration.addUrlPatterns("/*");
registration.addInitParameter("paramName", "paramValue");
registration.setName("sessionFilter");
registration.setOrder(1);
return registration;
}
}
5)接口访问类代码
@Controller
public class IndexController {
@Autowired
private RedisTemplate redisTemplate;
@GetMapping("/toLogin")
public String toLogin(Model model,HttpServletRequest request) {
Object userInfo = request.getSession().getAttribute(LoginFilter.USER_INFO);
//不为空,则是已登陆状态
if (null != userInfo){
String ticket = UUID.randomUUID().toString();
redisTemplate.opsForValue().set(ticket,userInfo,2, TimeUnit.SECONDS);
return "redirect:"+request.getParameter("url")+"?ticket="+ticket;
}
UserForm user = new UserForm();
user.setUsername("hankin");
user.setPassword("hankin");
user.setBackurl(request.getParameter("url"));
model.addAttribute("user", user);
return "login";
}
@PostMapping("/login")
public void login(@ModelAttribute UserForm user,HttpServletRequest request,HttpServletResponse response) throws IOException, ServletException {
System.out.println("backurl:"+user.getBackurl());
request.getSession().setAttribute(LoginFilter.USER_INFO,user);
//TODO 登陆成功,创建用户信息票据
String ticket = UUID.randomUUID().toString();
redisTemplate.opsForValue().set(ticket,user,20, TimeUnit.SECONDS);
//TODO 重定向,回原url---a.com
if (null == user.getBackurl() || user.getBackurl().length()==0){
response.sendRedirect("/index");
} else {
response.sendRedirect(user.getBackurl()+"?ticket="+ticket);
}
}
@GetMapping("/index")
public ModelAndView index(HttpServletRequest request) {
ModelAndView modelAndView = new ModelAndView();
modelAndView.setViewName("index");
modelAndView.addObject("user", request.getSession().getAttribute(LoginFilter.USER_INFO));
request.getSession().setAttribute("test","123");
return modelAndView;
}
}
3.2、springboot-cas-website项目代码配置
其他配置基本同cas-login项目一样,唯一不同的就是拦截过滤代码部分:
1)如果未登陆,则拒绝请求,转向登陆页面;
2)有票据,则使用票据去尝试拿取用户信息;
3)无法得到用户信息,则去登陆页面;
4)将用户信息,加载进session中。
public class SSOFilter implements Filter {
private RedisTemplate redisTemplate;
public static final String USER_INFO = "user";
public SSOFilter(RedisTemplate redisTemplate){
this.redisTemplate = redisTemplate;
}
@Override
public void init(FilterConfig filterConfig) throws ServletException {}
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse,
FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) servletRequest;
HttpServletResponse response = (HttpServletResponse)servletResponse;
Object userInfo = request.getSession().getAttribute(USER_INFO);;
//TODO 如果未登陆,则拒绝请求,转向登陆页面
String requestUrl = request.getServletPath();
if (!"/toLogin".equals(requestUrl)//不是登陆页面
&& !requestUrl.startsWith("/login")//不是去登陆
&& null == userInfo) {//不是登陆状态
String ticket = request.getParameter("ticket");
//TODO 有票据,则使用票据去尝试拿取用户信息
if (null != ticket){
userInfo = redisTemplate.opsForValue().get(ticket);
}
//TODO 无法得到用户信息,则去登陆页面
if (null == userInfo){
response.sendRedirect("http://cas.com:8080/toLogin?url="+request.getRequestURL().toString());
return ;
}
// TODO 将用户信息,加载进session中
request.getSession().setAttribute(SSOFilter.USER_INFO,userInfo);
redisTemplate.delete(ticket);
}
filterChain.doFilter(request,servletResponse);
}
@Override
public void destroy() { }
}
3.3、springboot-cas-website2项目代码
springboot-cas-website2与springboot-cas-website代码一样,除了application.yml配置文件。
3.4、启动项目测试
访问地址:
http://cas.com:8080/toLogin?url=http://a.com:8090/index
http://a.com:8090/index?ticket=1ef7a91b-ea3b-4990-a46d-eaeeaaf37a3b
http://b.com:8091/index?ticket=38089aee-6dc9-498c-8284-ef4b95fb2368
开始没登录的时候三个地址访问进去都是跳转到登录页面,需要登录当有一个地方登录以后,再次访问其他两个地址,发现都已经登录成功,结果如下所示: