前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >单点登录简单实现

单点登录简单实现

作者头像
腾讯IVWEB团队
发布2020-06-28 10:43:01
1.9K0
发布2020-06-28 10:43:01
举报

简述

单点登录系统是用来实现用户能够在多个系统中同时处于登录状态或者未登录状态。

下面展示同一级域名和不同一级域名这两种情况下的单点登录的实现方案,这两种情况下实现的方法差不多,前面一种情况相对后面来说略微简单一点,所以我们将先展示如何在同一级域名下实现单点登录,最后再展示不同一级域名下的情况。

其实造成这两种情况不能使用一套代码完成的原因在与同源策略,浏览器同源策略使得在不同域下的页面之间不能共享 cookie。所以这两种情况的解决方法不同的地方在于如何实现各个系统间 cookie 同步(统一设置和删除)。

下面展示的前端代码使用的是 jQuery 工具库,后端代码使用的是 egg.js 框架,egg.js 框架是在 koa 框架的基础上封装的,处理业务依旧使用的洋葱模型,下面展示的代码将尽量对代码用意进行注释。

同一级域实现单点登录

实现逻辑

用户第一次访问某一个系统页面,业务前端页面请求后端服务器,业务后端服务器检测用户是否登录(这里暂且让业务后端请求登录系统后端提供的检测用户登录状态的接口,来判断用户是否登录),如果用户未登录返回给前端未登录的状态码,前端页面收到未登录的状态码后,跳转到登录系统的前端页面,用户在登录系统前端输入账号和密码后点击登录,前端页面带着用户输入的信息请求登录系统后台提供的登录接口,登录后端验证用户输入是否正确,确认输入正确以后生成 token(我这里的 token 使用的是一个随机字符串),以 token 为键用户信息为为值存入 redis 数据库中(只要能暂时存放用户信息的数据库都行),将 token 写入浏览器的 cookie 中并使用 domain 属性来指定所属域名为一级域名(如: .example.com,根据自己系统所在的一级域名来定,如果对设置 cookie 的 domain 属性不熟悉的可以访问 同源策略 来进行了解),登录后端返回给登录系统前端登录成功的状态码,登录前端收到用户登录成功的状态码后就跳转到用户刚刚访问的业务前端页面。

下面是用户第一次访问某一个系统页面的思维导图(下面思维导图中检测用户是否登录的步骤相较于前面提供的思想要多一个步骤,后面再解释,先就按照前面的思想将检测看成请求登录后端的检测接口):

单点登录同一级域.jpg

登录功能

要实现单点登录不免需要先实现一个普通的登录系统,如下登录接口的实现的核心代码:

代码语言:javascript
复制
// controller 层
...
async login() {
  const { ctx } = this;
  const { request } = ctx;

  try {
  	// 用来验证用户输入的格式是否服务要求 loginParamRule 变量没有展示
    ctx.validate(loginParamRule);
    // 调用 server 层来处理用户登录,request.body 中的内容是用户输入的账号和密码,返回的 result 数据中包含返回的状态码和token
    const result = await ctx.service.user.login(request.body);
    // 将 token 写入浏览器的 cookie 中,并设置所属域名
    result.token && ctx.cookies.set('userInfoToken', result.token, {
      domain: '.example.com',
      maxAge: 24 * 60 * 60 * 1000,
    });
    // 响应请求
    this.response(result);
  } catch (error) {
  	// 如果 catch 到错误就打印日志并返回服务器错误,这个与登录流程无关
    ctx.logger.error(`magic-sso: login ======${error}`);
    this.response({ code: 300004 });
  }
}
...
代码语言:javascript
复制
// server 层
...
// es6 解构赋值
async login({ email, password }) {
  const { ctx, app } = this;
  const { req, logger } = ctx;

  try {
  	// 根据 email 来获取数据库用户表中的用户数据
    const user = await app.database.get('manager', { email });
    // 判断用户是否存在,不存在就返回
    if (!user) {
      return { code: 200003 };
    }
    // 生成随机字符串
    const token = uuidv1();
    // 验证用户输入的密码是否正确,encrypt 是一个加密方法
    password = encrypt(password, user.uuid, user.email);
    if (user.password !== password) {
      return { code: 200002 };
    }
    // 删除 user 对象的 password 属性
    Reflect.deleteProperty(user, 'password');
    // 使用 redis 来存放用户的登录信息,token 为键,用户信息为值,这里有一个交 logined 的属性与不同一级域名单点登录有关与主要的登录流程没多大关系,先不管。
    await app.redis.set(token, JSON.stringify({ ...user, logined: [] }), 'PX', sessionMaxAge);
    // 打印登录成功的日志
    logger.info(`magic sso: ${email} 登录成功`);
    // 返回操作状态和 token
    return { code: 100001, token };
  } catch (error) {
  	// 作用同 controller 层
    logger.error(`magic sso: ${email} login ====== ${error}`);
    return { code: 300004 };
  }
}
...

检测登录系统是否存有用户登录信息

注意:代码中的 logined 暂时不考虑,后面会讲解它的用法

代码语言:javascript
复制
// controller 层
...
async checkUser() {
  const { ctx, app } = this;
  const { logger, request, header: { origin } } = ctx;
  // 获取传入的 token 
  const { token } = request.query;
  
  try {
 		// 根据请求体中的 token 值来获取 redis 中存放的用户登录信息
    let user = await app.redis.get(token);
    // 如果没有获取到与传入的 token 相对应的用户登录信息,就返回用户未登录
    if (!user) {
      this.response({ code: 200001 });
      return;
    }
    user = JSON.parse(user);
    // logined 属性是用来存放哪些系统后端拥有用户登录信息,就是前面登录功能的 server 层中的 logined 一样,先不管
    const logined = new Set(user.logined);
    logined.add(origin);
    user.logined = [...logined];
    // 更新 redis,这里用来刷新用户登录信息的存活时间并且更新该信息中的 logined 属性
    await app.redis.set(token, JSON.stringify(user), 'PX', sessionMaxAge);
    // 删除 user 对象中 logined 这个无用属性,先不管
    Reflect.deleteProperty(user, 'logined');
    // 返回成功状态码,并返回用户登录信息
    this.response({ data: user, code: 100001 });
  } catch (error) {
  	// 同上
    logger.error(`checkUser-${error}`);
    this.response({ code: 300004 });
  }
}
...

用户第一次访问系统页面

用户访问业务系统的页面时,前端页面请求后端接口,后端接口在真正处理业务逻辑前,需要对用户登录状态就行判别,这里因为只要前端请求需要用户登录的数据时都需要经过鉴别用户登录状态这一过程,所以我们将这一判别写成中间件,代码如下:

代码语言:javascript
复制
const unLoginBody = {
  code: '200001',
  msg: '用户未登录',
};
const userInfoKey = 'magicUserInfo';

module.exports = (options, app) => {
  return async function magicSSO(ctx, next) {
    const { req, logger } = ctx;

    try {
    	// 获取前端请求中带的 cookie
      const token = ctx.cookies.get(userInfoKey, {
        signed: false,
      });
			// 请求登录系统后端提供的检测用户是否登录的接口
      const res = await axios({
        method: 'get',
        url: normalizeUrl(`${options.domain}/api/checkUser?token=${token}`),
        headers: {
          origin: ctx.header.host
        },
      });
      if (parseInt(res.data.code) === 100001) {
      	// 用户已经登录就将业务处理权限交给下一层(交给业务逻辑处理或者其他的中间件)
        await next();
        logger.info(`varbee-sso: 鉴权成功 ====== ${token}`);
      } else {
      	// 否则响应未登录的状态码
        ctx.body = unLoginBody;
      }
    } catch (error) {
      logger.error(`varbee-sso: 操作错误 ${error}`);
      this.response({ code: 300004 });
    }
  };
};

这里发起请求的业务前端页面收到未登录的状态码以后就跳转到登录系统前端页面,然后用户进行登录,前面已经展示了登录逻辑代码了,就不重复展示,后面将会重点讲解登录功能板块中 controller 层中设置 cookie 的代码:

代码语言:javascript
复制
result.token && ctx.cookies.set('userInfoToken', result.token, {
  domain: '.example.com',
  maxAge: 24 * 60 * 60 * 1000,
});

这里将 token 存放在 cookie 中,并且设置这个 cookie 所属域名为 .example.com 这个一级域名下,这样设置以后只要是同一级域名下的页面向后端请求的时候都会带上这个存放 token 的 cookie,这样后端就可以根据这个 token 来判断用户是否处于登录状态。

用户登录成功以后,登录前端页面跳转到用户开始进入的业务前端页面(获取跳转前的页面 document.referer。由于刷新页面以后,document.referer 为空字符串,所以我们这边在业务页面条登录页面的时候在 url 上面显式的存放原地址)。

用户第二次以及以后访问

这里所说的以后多次指的是 redis 中存放的用户登录信息有效的时间段中。

用户再次访问业务前端页面,前端页面发起请求并带上 userInfoToken 这个 cookie,业务后端中间件获取 token 值,并请求登录系统后端提供的检测用户登录信息的接口,此时登录系统后端能够获取到有效的用户登录信息,登录系统后端返回以登录的状态码,业务后端获取到登录系统后端响应后,知道用户已登录,然后将处理权限交给后面的业务逻辑。至此主要的单点登录逻辑完成了。

完善

大家是否注意到前面业务系统后端每次检测用户登录状态时都需要去请求登录系统后端提供的检测用户登录接口,这样响应前端请求所历经的时间就多了一次请求的时间,所以后面我们的目标是让业务系统后端并不是每次都去请求登录系统后端提供的接口。

业务系统后端自己实现用户登录检测的前提条件就是在自己本系统下存放用户登录信息。不知道大家是否还记得,登录系统后端提供的检测用户登录状态接口不只是响应一个状态码,还会返回用户登录信息。所以我们先在业务系统后台中使用 token 获取用户登录信息,如果有就将处理权限交给后面的业务逻辑,如果没有就去请求登录后端提供的接口,获取到登录后端的响应后,以 token 为键用户登录信息为值存入 redis 中。这样就完成了本业务系统自己存放一份用户登录信息的需求。实现代码如下:

代码语言:javascript
复制
// 前面注释过的代码不再注释
...
module.exports = (options, app) => {
  return async function magicSSO(ctx, next) {
    const { req, logger } = ctx;

    try {
      const token = ctx.cookies.get(userInfoKey, {
        signed: false,
      });
      
      // 检测本系统中是否存放用户登录信息?
      const user = await app.redis.get(token);
      if (user) {
      	// 如果有就更新本系统登录信息的有效时间,并将处理权限交给后面的逻辑(其实在交付权限前这里还应该去刷新登录系统后端中用户登录信息的有效时间,在我做的系统中没有将刷新用户登录信息的有效时间和检测分离所以需要请求登录后端提供的检测用户登录的接口,只是这里不需要并行,所以还是节约了一个网络请求所消耗的时间)
        await app.redis.set(token, user, 'PX', sessionMaxAge);
        //axios({
        //  method: 'get',
        //  url: normalizeUrl(`${options.domain}/api/checkUser?token=${token}`),
        //  headers: {
        //    origin: ctx.header.host
        //  },
        //});
        await next();
      } else {
      	// 如果没有就检测登录系统后端是否存放用户登录信息
        const res = await axios({
          method: 'get',
          url: normalizeUrl(`${options.domain}/api/checkUser?token=${token}`),
          headers: {
            origin: ctx.header.host
          },
        });
        // 判断登录后端用户登录信息是否有效
        if (parseInt(res.data.code) === 100001) {
        	// 有效,就将用户登录信息存入本业务系统后端,并将处理权限交给后面的逻辑
          await app.redis.set(token, JSON.stringify(res.data.data), 'PX', sessionMaxAge);
          await next();
          logger.info(`varbee-sso: 鉴权成功 ====== ${token}`);
        } else {
        	// 无效,返回未登录(登录后端是用户信息中心,如果他表示用户未登录,那就是未登录)
          ctx.body = unLoginBody;
        }
      }
    } catch (error) {
      logger.error(`varbee-sso: 操作错误 ${error}`);
      this.response({ code: 300004 });
    }
  };
};

统一登出

前面已经将单点登录统一登录完成了,现在我们将完成统一登出的功能。

其实对于同一级域名的多系统统一退出只需要将 userInfoToken 这个 cookie 删除就行了,代码如下:

代码语言:javascript
复制
...
const userInfoKey = 'userInfoToken';
module.exports = (options, app) => {
  return async function magicSSO(ctx, next) {
    const { req, logger } = ctx;

    try {
      const token = ctx.cookies.get(userInfoKey, {
        signed: false,
      });

      // 客户端请求登出接口,options.logoutPath = '/api/logout',将名为 userInfoToken 的 cookie 的值设置成 null 
      if (ctx.path === options.logoutPath) {
        ctx.cookie.set(userInfokey, null);
      }
			
			...
    } catch (error) {
      logger.error(`varbee-sso: 操作错误 ${error}`);
      this.response({ code: 300004 });
    }
  };
};

不同一级域名实现单点登录

不同一级域名实现单点登录和同一级域名实现的方式大体相同,不同的点在于 setCookie 和 统一登出上面的操作有所不同,后面将只会展示不同点的实现。

如下是不同一级域名下实现单点登录的思维导图:

单点登录不同一级域.jpg

setCookie 功能实现

大家都知道由于浏览器的同源策略不同域名的页面不能够相互设置或者读取 cookie,所以我们需要这些页面在自己的域下面设置 cookie,这样我们就需要不同域下页面的通信。html5 提供了一个叫 postMessage 的 api 来实现不同域下的页面通信,因此下面我们就使用 iframe 和 postMessage 来实现 setCookie 业务需求。

先展示登录页面中的与 setCookie 有关的代码:

代码语言:javascript
复制
// html,我们得业务中使用同构来做的登录系统,这里的语法是 ejs,感觉和 handlebars 差不多(差不多忘完 handlebars 的语法了),这里的 domains 是我们提供的不同域下面的 setCookie 页面的地址
<div id="iframe-wraper">
  <% domains.forEach(domain => { %>
    <iframe style="display: none;" src=<%= domain %>></iframe>
  <% }) %>
</div>
代码语言:javascript
复制
// js,当用户登录成功以后就调用 setCookie 函数,
function setCookie() {
  const wrapper = document.getElementById('iframe-wraper');
  // 使用 postMessage api 来向所有用来 setCookie 的 iframe 页面发送信息,信息的内容就是用户登录成功以后写入登录页面的 userInfoToken 这个 cookie 的值
  wrapper.childNodes.forEach(node => node.nodeName.toLowerCase === 'iframe' && node.contentWindow.postMessage(getCookie('userInfoToken'), '*'));
}

// 工具函数,获取某个 cookie 的值
function getCookie(name) {
  const cookieStr = document.cookie.split(';');
  cookies = cookieStr.map(cookie => cookie.split('='))
  let result;
  cookies.forEach(item => {
    if (item[0].trim() === name) {
      result = item[1];
    }
  });
  return result;
}

下面将展示其他域下面的 setCookie 页面代码(实现代码都是一样的):

代码语言:javascript
复制
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <script type="text/javascript">
  	// 监听 window 对象的 message 事件(其他域的页面向本页面发起的 postMessage 会触发 message 事件),e.data 是发送的信息
  	window.addEventListener('message', function(e) {
  		// 前端页面的 cookie 存放的时间
  		var maxAge = 24 * 60 * 60 * 1000;
  		var ip_reg = /^(\d+)\.(\d+)\.(\d+)\.(\d+)$/;
  		var hostname = window.location.hostname;
  		// domain 是用来存放 cookie 的 domain 值的
  		var domain;
  		// 检测访问本页面是否使用的 ip 访问的
  		if (ip_reg.test(hostname)) {
  			// 如果是,就设置 domain 为这个 ip
  			domain = hostname
  		} else {
  			// 如果不是,就检测域名是否符合一般域名的格式
  			var hostArr = hostname.split('.');
  			var length = hostArr.length;
  			if (length > 2) {
  				// 符合,将将 domain 设置成 '.example.com' 形式
  				domain = '.' + hostArr[length - 2] + '.' + hostArr[length - 1];
  			} else {
  				// 不符合,如 localhost,就将 domain 设置成 hostname
  				domain = hostname;
  			}
  		}
  		// 判断 message 事件的来源,如果 message 事件来源是登录页面发起的,就将值存入 userInfoToken 这个 cookie 中
	    if (e.origin === 'http://www.login.com') {
	      document.cookie='userInfoToken=' + e.data + ';domain=' + domain + ';max-age=' + maxAge;
	    }
    },false);
  </script>
</head>
<body></body>
</html>

实际开发中不应该像上面这样将 userInfoToken 这个 cookie 所属域设置成一级域,应该在各个业务系统前端中监听 window message 事件(写成一个工具包,每个业务系统前端 import 就行了),这样将 cookie 的 domain 属性设置成本页面的域名。如果打包工具没有做好按需加载那就可以使用 nginx,来做代理请求(监听与业务系统前端地址一样的域名,如果访问路径为 /setCookie/ 就代理获取统一的 setCookie 页面数据)

统一登出

由于我们所有的业务系统域名下面单独存放了 userInfoToken 这个 cookie,所以我们不能够像同一级域名那样直接删除 userInfoToken 这个 cookie 来实现统一登出。我们这里是将所有后台存放的用户登录信息删除的方式来实现统一登出需求。

业务处理逻辑:发起登出的业务系统后端请求登录中心后端提供的统一登出接口,登录中心后端接收请求后,将本系统后端中存放的该用户登录信息删除,然后请求各个系统后端的删除用户登录信息的接口,其他系统后端接收到删除用户登录信息以后就执行删除操作,删除成功后返回操作成功的状态码。

我们先来看一下改写以后的业务后端有关用户登录状态有关的中间件代码:

代码语言:javascript
复制
...
const userInfoKey = 'userInfoToken';
module.exports = (options, app) => {
  return async function magicSSO(ctx, next) {
    const { req, logger } = ctx;

    try {
      const token = ctx.cookies.get(userInfoKey, {
        signed: false,
      });
      
      // 登录中心后台要求删除用户信息
      if (ctx.path === '/sso/delete/session') {
      	// 获取登录后台发送过来的用户信息的 token
        const userToken = ctx.query.token;
        // 删除本系统后端中存放的用户信息
        await app.redis.del(userToken);
        // 返回数据
        ctx.body = { code: '100001', msg: '操作成功' };
        return;
      }

      // 业务系统主动登出
      if (ctx.path === options.logoutPath) {
        // 请求登录后端提供的统一登出的接口,需要带上 token
        const res = await axios({
          method: 'get',
          url: normalizeUrl(`${options.domain}/api/logout?token=${token}`)
        });
        // 如果接口操作是否成功
        if (parseInt(res.data.code) === 100001) {
          // 如果接口操作成功就返回用户未登录状态码
          ctx.body = unLoginBody;
          logger.info(`varbee-sso: 退出登录成功 ====== ${token}`);
        } else {
          // 登出操作错误就返回错误信息
          logger.info(`varbee-sso: 退出登录失败 ====== ${token}`);
          ctx.body = res.data;
        }
        return;
      }
			
			...
    } catch (error) {
      logger.error(`varbee-sso: 操作错误 ${error}`);
      this.response({ code: 300004 });
    }
  };
};

登录中心后端提供的统一登出接口代码,如下:

代码语言:javascript
复制
// controller 层
async logout() {
  const { ctx, ctx: { query, logger } } = this;

  try {
  	// query 是业务系统后端请求这个接口带的 token 值,将业务处理权限给 server 层
    const result = await ctx.service.user.logout(query.query);
    // 返回操作状态
    this.response(result);
  } catch (error) {
    logger.error(`magic-sso: logout ====== ${error}`);
    this.response({ code: 300004 });
  }
}
代码语言:javascript
复制
// server 层
async logout({ token }) {
  const { ctx: { logger }, app } = this;
	
  try {
  	// 获取用户登录信息,判断是否有此用户的登录信息
    const user = await app.redis.get(token);
    // 如果没有就直接返回操作成功。如果有,就删除本地存放的信息后其他业务系统后端中存放的用户信息,然后返回操作成功
    if (user) {
    	// 不知道大家是否还记得 logined 这个变量,它是用来存放哪些系统后端存放了本用户的登录状态信息,如果没有这个参数,我们就需要向所有使用本登录中心系统的业务后端发送删除用户登录信息的操作
      const { logined } = JSON.parse(user);
      // 等待其他业务系统后端将用户信息删除
      await Promise.all(logined.map(url => {
        axios({
          method: 'get',
          url: normalizeUrl(`${url}/sso/delete/session?token=${token}`),
        });
      }));
      // 删除登录系统后端中存放的用户登录信息
      await app.redis.del(token);
      logger.info(`magic sso: ${token} logout`);
    }
    // 返回操作成功
    return { code: 100001 };
  } catch (error) {
    logger.error(`magic sso: logout ====== ${error}`);
    return { code: 300004 };
  }
}
本文参与 腾讯云自媒体分享计划,分享自作者个人站点/博客。
如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 简述
  • 同一级域实现单点登录
    • 实现逻辑
      • 登录功能
        • 检测登录系统是否存有用户登录信息
          • 用户第一次访问系统页面
            • 用户第二次以及以后访问
              • 完善
                • 统一登出
                • 不同一级域名实现单点登录
                  • setCookie 功能实现
                    • 统一登出
                    相关产品与服务
                    访问管理
                    访问管理(Cloud Access Management,CAM)可以帮助您安全、便捷地管理对腾讯云服务和资源的访问。您可以使用CAM创建子用户、用户组和角色,并通过策略控制其访问范围。CAM支持用户和角色SSO能力,您可以根据具体管理场景针对性设置企业内用户和腾讯云的互通能力。
                    领券
                    问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档