V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
lyh2668
V2EX  ›  Node.js

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

  •  4
     
  •   lyh2668 · 2018-07-23 14:29:13 +08:00 · 6518 次点击
    这是一个创建于 2322 天前的主题,其中的信息可能已经有所发展或是发生改变。

    引言

    首先为什么是不务正业呢...因为我们公司就我一个前端,不乖乖写页面写什么 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 应该做一些调整即可直接使用。

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

    13 条回复    2020-06-24 16:54:05 +08:00
    MES
        1
    MES  
       2018-07-23 17:11:56 +08:00
    写的很不错,赞一个!
    nciyuan
        2
    nciyuan  
       2018-07-23 17:21:33 +08:00 via Android
    ucenter 了解一下,康盛家的,简直是全能级别
    phpsso 了解一下,是盛大的
    luofan004
        3
    luofan004  
       2018-07-23 17:23:25 +08:00
    这种前端水平能拿多少 K,我刚做完单点登录呢,需求有点不同,我们公司要求用客户公司的账户直接登录我们系统,可能涉及多个 idp。
    falcon05
        4
    falcon05  
       2018-07-23 17:29:03 +08:00 via iPhone
    写得不错,收藏了
    allgy
        5
    allgy  
       2018-07-23 18:36:31 +08:00
    mark
    imdoge
        6
    imdoge  
       2018-07-24 02:23:53 +08:00 via Android
    写的不错,收藏了。顺便问下如何服务器失效 jwt 比较好,是否有这种需求。
    lyh2668
        7
    lyh2668  
    OP
       2018-07-24 10:00:41 +08:00
    @imdoge jwt 本身就有一个过期时间的,存储到 redis 中还可以配合 redis 本身的失效时间处理
    lyh2668
        8
    lyh2668  
    OP
       2018-07-24 10:01:18 +08:00
    @nciyuan 只是想自己实践实践哈
    lyh2668
        9
    lyh2668  
    OP
       2018-07-24 10:02:44 +08:00
    @luofan004 不清楚...反正我收入很低就是了...安慰自己去一线可以拿 20 把:)
    xusongfu5050
        10
    xusongfu5050  
       2018-07-26 08:56:27 +08:00
    登录一次更新 token,只要别处登录了,你这边就没法登录了,不用这么麻烦把?
    lyh2668
        11
    lyh2668  
    OP
       2018-07-27 08:51:05 +08:00
    @xusongfu5050 这个跟业务需求有关,那只需要在重新生成 token 的时候不让之前的 token 失效就可以了,这样自然就可以多设备登录了。
    chexie
        12
    chexie  
       2019-08-20 08:00:29 +08:00
    推荐用 IDaaS 产品:10 分钟实现单点登录( SSO ):-)
    https://docs.authing.cn/authing/quickstart/implement-sso-with-authing
    Shikyou
        13
    Shikyou  
       2020-06-24 16:54:05 +08:00
    这一类实现单点登录的云服务已经很多了,为什么还要自行开发呢?
    比如楼上说的国内的 Authing,还有美国的 Auth0 和 AWS Cognito 都行的(国内由于政策原因用不了)。
    用了以后就回不去了,再也无需开发、运维用户系统。
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   985 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 25ms · UTC 20:01 · PVG 04:01 · LAX 12:01 · JFK 15:01
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.