不务正业的前端之 Nodejs SSO(单点登录)实践

不务正业的前端之 Nodejs SSO(单点登录)实践

引言

首先为什么是不务正业呢...因为我们公司就我一个前端,不乖乖写页面写什么 SSO。我之所以会想到去写 SSO 单点登录呢,一是发现公司的登录这块特别的乱,每个系统都是独立的登录,而某些业务都是有所交集的,既然一个是 a.xxx.com 一个是 b.xxx.com ,那为什么不把登录统一一下呢...正巧赶上我们后端大哥在攻坚一个技术难关,于是乎我在等接口的间隙就着手写了一下单点登录。

技术栈方面,后端采用 NodeJS 去实现,局部会话用 express-session 维护, session 的存储使用了 redis ,由于目前的项目都是前后端分离的,为了更加契合当前的业务逻辑,把常规的跳转至 passport 认证服务器登录这部分改造成接口的方式,这样使得这个 SSO 比较适合用在 SPA 中。

下面将具体阐述实现以及总结一些需要注意的点,愿在下的拙见对大家能有所帮助。

实现原理

SSO 即 Single Sign On,是指在多系统应用群中登录一个系统,便可在其他所有系统中得到授权而无需再次登录。 SSO 一般都需要一个独立的认证中心( passport ),子系统的登录均得通过 passport,子系统本身将不参与登录操作,当一个系统成功登录以后,passport 将会颁发一个令牌给各个子系统,子系统可以拿着令牌会获取各自的受保护资源,为了减少频繁认证,各个子系统在被 passport 授权以后,会建立一个局部会话,在一定时间内可以无需再次向 passport 发起认证。

如图所示,是一个比较常见的 SSO 实现,图片取自 上面这张图很详细地描述了一个 SSO 的请求资源的流程。但是这里有一点地方不适合我当前的业务场景,那就是我并不希望在登录的时候跳转到认证中心,所以我把这个部分转化成了接口的方式去实现,其他的实现基本如图一致。

具体实现

准备环境

首先需要做一些准备工作,为了方便测试 SSO,需要至少三个域名,这边我直接在本地模拟。如果手头有服务器域名的,这一步自然就可以跳过了。

构造本地域名( Mac )

1. 配置 hosts 文件

// MacOS
sudo vim /etc/hosts
// 添加以下三行
127.0.0.1   testssoa.xxx.com
127.0.0.1   testssob.xxx.com
127.0.0.1   passport.xxx.com

2. 添加 nginx 反向代理配置

  1. 先安装 nginx
  2. 添加对应站点的配置
vim /usr/local/etc/nginx/nginx.conf

// 添加以下 3 个代理 server { listen 1280; server_name passport.xxx.com;

location / { proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_pass http://127.0.0.1:11000; } }

server { listen 1280; server_name testssoa.xxx.com;

location / { proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_pass http://127.0.0.1:11001; } }

server { listen 1280; server_name testssob.xxx.com;

location / { proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_pass http://127.0.0.1:11002; } }

  1. nginx -t 检测配置是否有效
  2. nginx -s reload 重启 nginx

准备一份简单的登录页面

页面大概就长这个样子,这里分别要准备 testssoa 和 testssob 两个域名,为了公用一个页面这里我采用的方案是直接通过 node 将该页面 render 回来的方式,并且需要根据上面 nginx 配置的端口号启动端口指定为 11001 和 11002 的服务。

// package.json
"scripts": {
  "start": "babel-node passport.js",
  "starta": "cross-env NODE_ENV=ssoa babel-node index.js",
  "startb": "cross-env NODE_ENV=ssob babel-node index.js"
}

// index.js import express from ‘express’ // import 需要 babel 支持 const app = express() const mapPort = { ‘ssoa’: 11001, ‘ssob’: 11002 } const port = mapPort[process.env.NODE_ENV] if (port) { console.log('listen port: ', port) app.listen(port) }

简单的配置一下,这样可以直接通过 npm run starta 和 npm run startb 来起来两个 server

具体步骤

1. 用户登录

登录全部向 paspport 发起,这里采用了 jwt 来维护用户的登录态(考虑到 app 端),登录成功以后会把 token 存储到 redis 中,并且将 token 写入 domain 为 xxx.com 这个顶级域名中,这样的话不同的子系统都可获得 token,同时设置 httpOnly 可以预防一部分 xss 攻击。

app.post('/login', async (req, res, next) => {
  // 登录成功则给当前 domain 下的 cookie 设置 token
  const { username, password } = req.body

// 通过 username 跟 password 取出数据库中的用户 try { const user = await authUser(username, password) const lastToken = user.token // 此处生成 token,此处使用 jwt const newToken = jwt.sign( { username, id: user.id }, tokenConfig.secret, { expiresIn: tokenConfig.expiresIn } ) // 保存 token 到 redis 中 await storeToken(newToken)

// 生成新的 token 以后需要清除子系统的 session
if (lastToken) {
  await clearClientStore(lastToken)
  await deleteToken(lastToken)
}

res.setHeader(
  'Set-Cookie',
  `token=${newToken};domain=xxx.com;max-age=${tokenConfig.expiresIn};httpOnly`)

return res.json({
  code: 0,
  msg: 'success'
})

} catch (err) { next(new Error(err)) } })

2. 用户访问受保护资源(认证过程)

登录成功以后,我们可以尝试去获取受保护资源,由于 passport 对 domain 为 xxx.com 的域名设置了 cookie,所以无论是 a.xxx.com 还是 b.xxx.com 均可使用该 cookie 去向各自的服务器去发起资源的请求。前面有提到,请求资源之前需要进行认证,认证成功以后将会生成局部会话,之后的请求都可以在一定时间内无需认证。

// 发起一个认证请求
const authenticate = async (req) => {
  const cookies = splitCookies(req.headers.cookie)
  // 判断是否含有 token,如没有 token,则返回失败分支
  const token = cookies['token']
  if (!token) {
    throw new Error('token is required.')
  }

const sid = cookies[‘sid’]

// 如果获取到 user,则说明该用户已经登录 if (req.session.user) { return req.session.user }

// 向 passport 服务器发起一个认证请求 try { // 这里的 sid 应该是存在 redis 里的 key let response = await axiosInstance.post(’/authenticate’, { token, sid: defaultPrefix + req.sessionID, name: ‘xxxx’ // 可以用来区分具体的子系统 }) if (response.data.code !== 0) { throw new Error(response.data.msg) } // 认证成功则建立局部会话,并将用户标识保存起来,比如这里可以是一个 uid,或者也可以是 token req.session.user = response.data.data req.session.save()

return response.data

} catch (err) { throw err } }

对于需要接入 SSO 的子系统来说,真正需要做的事情就只有发起认证这一件事情,所以对于子系统本身来说,接入成本是很低的。即便不同语言的子系统实现的方式会有所差别,但是也没什么关系,这里最核心的一件事情就是向 passport 发起认证,只需要按照约定把认证所需要的参数传递过去即可,剩下的事情都应该交给 passport 来操心。

认证成功以后获取具体的资源则由各个子系统各自执行。

3. 认证环节( passport )

认证这一环节主要是检验 token 的有效性,一是检验该 token 是否存在于 redis 之中,二是校验该 token 是否还有效,是否过期,并且解析出其中的用户信息,校验成功以后需要将子系统注册一下(存入 redis,以 token 为 key ),方便后续注销。这里还加了一个小判断,就是判断 x-real-ip 的,可以防范一定程度的伪造。

app.post('/authenticate', async (req, res, next) => {
  const { token, sid, name } = req.body
  try {
    // 检查请求的真实 IP 是否为授权系统
    // nginx 会将真实 IP 传过来,伪造 x-forward-for 是无效的
    if (!checkSecurityIP(req.headers['x-real-ip'])) {
      throw new Error('ip is invalid')
    }
    // 判断 token 是否还存在于 redis 中并验证 token 是否有效, 取得用户名和用户 id
    const tokenExists = await redisClient.existsAsync(token)
    if (!tokenExists) {
      throw new Error('token is invalid')
    }
    const { username, id } = await jwt.verify(token, tokenConfig.secret)
    // 校验成功注册子系统
    register(token, sid, name)
    return res.json({
      code: 0,
      msg: 'success',
      data: { username, id }
    })
  } catch (err) {
    // 对于 token 过期也应该执行一次 clear 操作
    next(new Error(err))
  }
})

4. 注销环节

当用户主动退出某个子系统时,需要将该 domain 下的所有子系统都退出,由于之前将 session 相关的存入了 redis 中,所以在注销的时候需要将这些 session 全部清除,否则的话可能会导致子系统在一定时间内仍然可以获取资源的问题。这里我交给了clearClientStore(token)deleteToken(token)这两个函数。

问题思考与总结

其实整个 SSO 流程走下来还是比较清晰的,但在做之前感觉相当棘手相当有难度(或许只是对我这个前端来说有难度),这期间也碰到了很多奇怪的问题,一方面是自己思路经常走歪的问题,另一方面则是自己不够熟练,摸石头过河。期间碰到问题以后也看了诸如 express-session 和 connect-redis 的部分源码实现才得以理解。

遇到的问题及解决

  1. 使用 express-session 的时候一直在用 regenerate 去重新生成 session,一直纳闷自己的 session 玩什么没有生成,后来在某个大佬的指点下静下心来看了源码发现,有些事情中间件已经帮忙做好了,对于 session 的操作我只需要做最简单的 set 和 get 即可。
  2. redis 一直读取不到 session 的 key 值问题,这个问题在看了 connect-redis 的源码发现,它会默认给 sid 加一个一个 prefix 前缀,默认为'sess:',所以从 redis 中获取 sid 的时候必须得get prefix + sid

深刻认识到有些时候苦苦不能解决一个问题的时候,那一定是之前的思路有问题,这时候必须得静下心来从问题的根源找起,对于程序员来说寻找问题的根源的最有效办法就是阅读源码了。

还在设计的过程中考虑如何减少子系统的接入成本(仅需要进行认证一步操作),安全性方面的考虑( httpOnly,RealIP 过滤,session 有效期等),性能方面的考虑(局部会话和 redis )

最后附上完整的示例代码 恳请各位大佬给个 Star 吧,小弟在此跪谢了,代码里把 config 文件夹 ignore 了,里面只有一份数据库配置项和加盐参数而已。passport 应该做一些调整即可直接使用。

还有诸多考虑不周的地方,希望各位大佬可以给予些许指点。


14 回复

写的很不错,赞一个!


ucenter 了解一下,康盛家的,简直是全能级别
phpsso 了解一下,是盛大的

这种前端水平能拿多少 K,我刚做完单点登录呢,需求有点不同,我们公司要求用客户公司的账户直接登录我们系统,可能涉及多个 idp。

写得不错,收藏了

mark

写的不错,收藏了。顺便问下如何服务器失效 jwt 比较好,是否有这种需求。

jwt 本身就有一个过期时间的,存储到 redis 中还可以配合 redis 本身的失效时间处理

只是想自己实践实践哈

不清楚…反正我收入很低就是了…安慰自己去一线可以拿 20 把:)

登录一次更新 token,只要别处登录了,你这边就没法登录了,不用这么麻烦把?

这个跟业务需求有关,那只需要在重新生成 token 的时候不让之前的 token 失效就可以了,这样自然就可以多设备登录了。

推荐用 IDaaS 产品:10 分钟实现单点登录( SSO ):-)
https://docs.authing.cn/authing/quickstart/implement-sso-with-authing

这一类实现单点登录的云服务已经很多了,为什么还要自行开发呢?
比如楼上说的国内的 Authing,还有美国的 Auth0 和 AWS Cognito 都行的(国内由于政策原因用不了)。
用了以后就回不去了,再也无需开发、运维用户系统。

关于你提到的“不务正业的前端之 Node.js SSO(单点登录)实践”,这实际上是一个非常有趣且实用的技术探索。SSO 旨在允许用户在一个系统登录后,无需再次登录即可访问相关系统中的其他应用,极大地提升了用户体验和系统安全性。

在 Node.js 中实现 SSO,通常会用到一些中间件和库来简化流程。以下是一个简单的示例,展示如何使用 express-sessionpassport.js 来实现基本的 SSO 功能:

const express = require('express');
const session = require('express-session');
const passport = require('passport');
const SamlStrategy = require('passport-saml').Strategy;

const app = express();

app.use(session({ secret: 'your_secret_key', resave: false, saveUninitialized: true }));
app.use(passport.initialize());
app.use(passport.session());

passport.use(new SamlStrategy({
  entryPoint: 'https://idp.example.com/saml/login',
  issuer: 'https://yourapp.com/saml/metadata',
  callbackUrl: 'https://yourapp.com/saml/callback',
  decryptionPvk: 'your_decryption_key.pem'
},
function(profile, done) {
  // 处理用户信息并存储
  User.findOrCreate({ username: profile.username }, function (err, user) {
    return done(err, user);
  });
}));

app.get('/saml/login', passport.authenticate('saml', { failureRedirect: '/' }));

app.post('/saml/callback',
  passport.authenticate('saml', { failureRedirect: '/' }),
  function(req, res) {
    res.redirect('/'); // 登录成功后重定向
  });

app.listen(3000, function() {
  console.log('Server is running on port 3000');
});

这个示例展示了如何使用 passport-saml 实现 SAML 协议的 SSO。实际应用中,你可能需要根据具体需求进行更多配置和优化。

回到顶部