首先为何是游手好闲呢...由于咱们公司就我一个前端,不乖乖写页面写什么SSO。我之因此会想到去写SSO单点登陆呢,一是发现公司的登陆这块特别的乱,每一个系统都是独立的登陆,而某些业务都是有所交集的,既然一个是a.xxx.com一个是b.xxx.com,那为何不把登陆统一一下呢...正巧遇上咱们后端大哥在攻坚一个技术难关,因而乎我在等接口的间隙就着手写了一下单点登陆。html
技术栈方面,后端采用 NodeJS 去实现,局部会话用 express-session 维护, session 的存储使用了 redis ,因为目前的项目都是先后端分离的,为了更加契合当前的业务逻辑,把常规的跳转至 passport 认证服务器登陆这部分改形成接口的方式,这样使得这个 SSO 比较适合用在 SPA 中。前端
下面将具体阐述实现以及总结一些须要注意的点,愿在下的拙见对你们能有所帮助。node
SSO即Single Sign On,是指在多系统应用群中登陆一个系统,即可在其余全部系统中获得受权而无需再次登陆。 SSO通常都须要一个独立的认证中心(passport),子系统的登陆均得经过passport,子系统自己将不参与登陆操做,当一个系统成功登陆之后,passport将会颁发一个令牌给各个子系统,子系统能够拿着令牌会获取各自的受保护资源,为了减小频繁认证,各个子系统在被passport受权之后,会创建一个局部会话,在必定时间内能够无需再次向passport发起认证。ios
如图所示,是一个比较常见的SSO实现,图片取自 nginx
首先须要作一些准备工做,为了方便测试SSO,须要至少三个域名,这边我直接在本地模拟。若是手头有服务器域名的,这一步天然就能够跳过了。git
// 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
复制代码
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;
}
}
复制代码
// 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程序员
登陆所有向paspport发起,这里采用了jwt来维护用户的登陆态(考虑到app端),登陆成功之后会把token存储到redis中,而且将token写入domain为xxx.com这个顶级域名中,这样的话不一样的子系统均可得到token,同时设置httpOnly能够预防一部分xss攻击。github
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))
}
})
复制代码
登陆成功之后,咱们能够尝试去获取受保护资源,因为passport对domain为xxx.com的域名设置了cookie,因此不管是a.xxx.com仍是b.xxx.com都可使用该cookie去向各自的服务器去发起资源的请求。前面有提到,请求资源以前须要进行认证,认证成功之后将会生成局部会话,以后的请求均可以在必定时间内无需认证。redis
// 发起一个认证请求
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来操心。数据库
认证成功之后获取具体的资源则由各个子系统各自执行。
认证这一环节主要是检验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))
}
})
复制代码
当用户主动退出某个子系统时,须要将该domain下的全部子系统都退出,因为以前将session相关的存入了redis中,因此在注销的时候须要将这些session所有清除,不然的话可能会致使子系统在必定时间内仍然能够获取资源的问题。这里我交给了clearClientStore(token)
和deleteToken(token)
这两个函数。
其实整个SSO流程走下来仍是比较清晰的,但在作以前感受至关棘手至关有难度(或许只是对我这个前端来讲有难度),这期间也碰到了不少奇怪的问题,一方面是本身思路常常走歪的问题,另外一方面则是本身不够熟练,摸石头过河。期间碰到问题之后也看了诸如express-session和connect-redis的部分源码实现才得以理解。
'sess:'
,因此从redis中获取sid的时候必须得get prefix + sid
深入认识到有些时候苦苦不能解决一个问题的时候,那必定是以前的思路有问题,这时候必须得静下心来从问题的根源找起,对于程序员来讲寻找问题的根源的最有效办法就是阅读源码了。
还在设计的过程当中考虑如何减小子系统的接入成本(仅须要进行认证一步操做),安全性方面的考虑(httpOnly,RealIP过滤,session有效期等),性能方面的考虑(局部会话和redis)
最后附上完整的示例代码 恳请各位大佬给个Star吧,小弟在此跪谢了
,代码里把config文件夹ignore了,里面只有一份数据库配置项和加盐参数而已。passport应该作一些调整便可直接使用。
还有诸多考虑不周的地方,但愿各位大佬能够给予些许指点。