不务正业的前端之 SSO(单点登录)实践 - V2EX
V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
lyh2668
V2EX    Node.js

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

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

    引言

    首先为什么是不务正业呢...因为我们公司就我一个前端,不乖乖写页面写什么 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。
    GG668v26Fd55CP5W
        4
    GG668v26Fd55CP5W  
       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     Solana     955 人在线   最高记录 6679       Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 32ms UTC 19:43 PVG 03:43 LAX 11:43 JFK 14:43
    Do have faith in what you're doing.
    ubao msn snddm index pchome yahoo rakuten mypaper meadowduck bidyahoo youbao zxmzxm asda bnvcg cvbfg dfscv mmhjk xxddc yybgb zznbn ccubao uaitu acv GXCV ET GDG YH FG BCVB FJFH CBRE CBC GDG ET54 WRWR RWER WREW WRWER RWER SDG EW SF DSFSF fbbs ubao fhd dfg ewr dg df ewwr ewwr et ruyut utut dfg fgd gdfgt etg dfgt dfgd ert4 gd fgg wr 235 wer3 we vsdf sdf gdf ert xcv sdf rwer hfd dfg cvb rwf afb dfh jgh bmn lgh rty gfds cxv xcv xcs vdas fdf fgd cv sdf tert sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf shasha9178 shasha9178 shasha9178 shasha9178 shasha9178 liflif2 liflif2 liflif2 liflif2 liflif2 liblib3 liblib3 liblib3 liblib3 liblib3 zhazha444 zhazha444 zhazha444 zhazha444 zhazha444 dende5 dende denden denden2 denden21 fenfen9 fenf619 fen619 fenfe9 fe619 sdf sdf sdf sdf sdf zhazh90 zhazh0 zhaa50 zha90 zh590 zho zhoz zhozh zhozho zhozho2 lislis lls95 lili95 lils5 liss9 sdf0ty987 sdft876 sdft9876 sdf09876 sd0t9876 sdf0ty98 sdf0976 sdf0ty986 sdf0ty96 sdf0t76 sdf0876 df0ty98 sf0t876 sd0ty76 sdy76 sdf76 sdf0t76 sdf0ty9 sdf0ty98 sdf0ty987 sdf0ty98 sdf6676 sdf876 sd876 sd876 sdf6 sdf6 sdf9876 sdf0t sdf06 sdf0ty9776 sdf0ty9776 sdf0ty76 sdf8876 sdf0t sd6 sdf06 s688876 sd688 sdf86