随着企业产品线的不断丰富,SSO 的必要性就逐渐体现出来,不然的话每个产品都需要注册登录,而且要打通用户也不那么容易。
使用 SSO 的优点很明显:
- 提升用户体验:用户不再需要每个产品输入一次账号密码(体验上 QQ 那种一键登录的方式也很方便),也不再需要去记住不同的密码;
- 方便开发:开发人员不再需要每个产品中都实现注册登录功能,涉及到功能升级也不需要每个产品都去改动。
看了好多文章,发现有一些文章对与 SSO 和 CAS 的概念有些混乱。
- SSO:(Single Sign On)单点登录,在多个应用系统中,只需要登录一次,就可以访问全部应用,属于一种架构;
- CAS:(Centeral Authentication Service)统一认证服务,是单点登录的一种解决方案。
二者的关系类似于抽象与实现。
同域、同父域
这两种情况下实现 SSO 相对简单,通过 cookie 控制即可。
同域(xyz.com/app1 和 xyz.com/app2)的情况最为简单,一般是通过 Nginx 代理实现的,这种情况直接通过 cookie 传递登录凭证,然后后台服务通过凭证向统一认证服务验证即可。
同父域(app1.xyz.com 和 app2.xyz.com)的情况基本相同,cookie 的是可以在相同 domain 下携带的,在写入 cookie 的时候指定 domain 为 xyz.com 即可在不同的三级域名下携带,同样的后台服务根据 cookie 去统一认证服务验证。
不同域名
在不同的域名(主域名)下不再像同域名下那样用 cookie 就可以简单实现 SSO,主流的方式是基于 CAS 的方式来实现 SSO。
CAS 中有以下概念:
- TGT:Ticket Grangting Ticket,是 CAS 为用户签发的登录票据,也就是 session。
- TGC:Ticket Granting Cookie,是 Session 的唯一标识(SessionId),以 Cookie 形式放到浏览器端。
- ST:Service Ticket,唯一标识,用于验证已经登录 CAS。
CAS 的整体流程如下:
- 用户首次打开 abc.com ,访问了受保护资源;
- abc.com 的服务器未识别到用户身份,重定向到 cas.com?rediectUrl=abc.com;
- cas.com 没有识别到用户身份,输入账号密码进行登录;
- 登陆成功之后,保存 TGT (session),将 TGC (cookie)写入浏览器,用于下次验证时保存状;
- 重定向到 abc.com?token=ST-12345;
- abc.com 未识别到用户身份,发现有 ST,向 CAS 验证 ST 合法性;
- ST 通过验证,返回用户身份信息;
- abc.com 服务保存用户信息到 session,返回浏览器请求的资源;
然后 def.com 的过程和 abc.com 一致,区别在于请求 ST 的时候不需要手动输入密码了, 因为上一次已经保存了 session 在 CAS 中,直接验证身份生成 ST。
我们基于 Koa 来用代码简单的实现一个 CAS。
先来分析一下我们核心需要做什么事情:
- 提供身份验证能力,也就是登录功能,登陆成功之后要携带 ST 重定向到原应用;
- 提供 ST 验真能力,客户端在携带 ST 访问某应用时,应用使用 ST 来验证用户身份;
基于这两个核心我们可以做一个简易的 CAS,首先我们先来安装以下所需的依赖
$ npm install koa @koa/router koa-bodyparser koa-views koa-session short-uuid ejs
具体的作用根据命名就可以看出来,实际功能在后面的代码中就能体会到,ejs 没有使用过的可能不清楚,这是一个模板渲染的方案,具体的使用方法不在这里细说,感兴趣的可以自行了解,这里只需要知道是用来渲染页面的就行了。
首先使用 koa 启动一个服务
import Koa from 'koa'
cosnt app = new Koa()
app.listen(4000, () => {
console.log('启动成功, 监听端口: 4000');
})
通过一个经典的三段式结构,我们就启动了一个服务监听 4000 端口,但是此时并不能通过 http 请求进行访问,因为请求需要通过中间件来进行处理,所以我们接下来来添加路由中间件。
可以使用最原始的 use 来添加中间件,如下
app.use(ctx => {
if(ctx.url === '/') {
ctx.body = 'hello'
}
if(ctx.url === '/test') {
ctx.body = 'test'
}
})
但是这样过于原始,如果再加上 HTTP method的判断,那代码看起来会很难看,所以我们可以使用 koa-router来简化路由
import Koa from 'koa'
cosnt app = new Koa()
const router = new Router()
// router……
app.use(router.routes())
app.listen(4000, () => {
console.log('启动成功, 监听端口: 4000')
})
现在路由已经实现了,我们来编写首页相关的代码。
我们使用 ST_MAP 来保存 ST 和 session 之间的映射,用于应用验证用户身份,实际应用中可能更为复杂,我们都是最简化的操作,SERVICES 保存了一些 ID,模拟 CAS 授权的应用集合,当有应用来验证用户身份是我们需要保证该应用是我们信任的,不能谁来请求都返回。
const ST_MAP = new Map() // 记录 session 和 ticket 之间的映射(实际场景中可以使用 redis 来保存)
const SERVICES = new Set(['123456', '654321']) // 记录已经授权的 service
router.get('/', async (ctx, next) => {
const redirectUrl = ctx.request.query.redirectUrl
if (ctx.session.userId && redirectUrl) {
const token = short.generate()
ST_MAP.set(token, ctx.session)
ctx.redirect(redirectUrl + '?token=' + token)
} else {
await ctx.render('index', {
redirectUrl
})
}
})
通过 session 检查用户身份,如果已经登录的话直接生成 ST 返回,这里就直接使用 short-uuid 来生成标识;如果未检测到用户身份时,通过 ejs 渲染登录页面。
上面提到的 session 和 渲染 ejs 我们需要配置中间件,首先是 session,我们需要在所有的中间件前面添加,否则后续的中间件无法获取 session。
const CONFIG = {
key: 'koa:sso', // cookie 键
maxAge: 1000 * 60 * 60 * 24 * 7, // 过期时间 7 天
httpOnly: true
}
app.keys = ['sso']
app.use(session(CONFIG, app))
app.keys 用于加密 cookie 内容,就是 JWT 的 secret,更多的 session 配置可以去查看 koa-session 的 github,通过这样一段配置,我们就可以在中间件中通过 ctx.session 来获取 session 信息了。
然后是模板渲染,koa-views 支持多种模板渲染,我们这里选择使用 ejs,我们需要添加以下代码
import { fileURLToPath } from 'node:url'
import { dirname } from 'node:path'
import views from 'koa-views'
const __dirname = dirname(fileURLToPath(import.meta.url))
app.use(views(__dirname + '/views', {
extension: 'ejs'
}))
因为我使用了 ESM, 所以__driname 不能再使用了,需要通过代码计算获得。
ejs 模板中我们添加以下内容
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SSO</title>
</head>
<body>
<form action="/login?redirectUrl=<%=redirectUrl%>" method="post">
<label for="username">用户名</label>
<input name="username" type="text" />
<br />
<label for="passowrd">密码</label>
<input name="password" type="password" />
<br />
<input type="submit" value="login" />
</form>
</body>
</html>
这里使用了一个变量redirectUrl,当表单提交时会携带重定向地址一起提交,判定成功之后会使用这个地址进行重定向。
然后我们来实现这个 login 接口
// 登录请求
router.post('/login', async (ctx, next) => {
const {username, password} = ctx.request.body;
// 用户名密码校验, 为了降低复杂度这里使用静态数据
if (username === 'admin' && password === '123456') {
const session = { username: 'admin', userId: 1 };
const token = short.generate()
ST_MAP.set(token, session)
ctx.session = session
const { redirectUrl } = ctx.request.query;
if (redirectUrl) {
ctx.redirect(redirectUrl + '?token=' + token)
} else {
ctx.body = '登录成功'
}
} else {
ctx.body = '账号密码错误'
}
})
这里就没有什么神秘的东西了,就是静态的账号密码确认,确认成功之后将用户信息保存到 session,用于下一次验证时身份确认;如果没有重定向地址时只显示登陆成功的反馈即可。
客户端一侧的功能已经完成了,接下来是应用侧的功能——ST 的验证
router.get('/validate', (ctx, next) => {
const { token, serviceId } = ctx.request.query
if (serviceId && SERVICES.has(serviceId)) {
const session = ST_MAP.get(token)
ST_MAP.delete(token) // 验证后清除
if (session) {
ctx.status = 200
ctx.body = session
} else {
ctx.status = 401
}
}
})
这里对 ST 的处理是有效性只有一次,验真之后销毁该 ST,防止 ST 被盗用;判断了当前请求的应用是否在信任应用集合中,通过验证之后将保存的用户 session 信息通过 JSON 的形式返回。
到这里 CAS 的功能已经基本实现接下来我们再来做一个应用端。大体的架构和 CAS 一致,就不详细说明了。
import Koa from 'koa';
import Router from '@koa/router'
import views from 'koa-views'
import { fileURLToPath } from 'node:url'
import { dirname } from 'node:path'
import session from 'koa-session'
import request from 'koa2-request'
const __dirname = dirname(fileURLToPath(import.meta.url))
const app = new Koa();
const router = new Router();
// 模板引擎渲染中间件
app.use(views(__dirname + '/views', {
extension: 'ejs'
}))
const CONFIG = {
key: 'koa:app1', // cookie 键
maxAge: 1000 * 60 * 60 * 24 * 7, // 过期时间 7 天
httpOnly: true
}
app.keys = ['app1']
app.use(session(CONFIG, app))
const SERVICE_ID = '123456'
router.get('/', async (ctx) => {
if (ctx.session.username) {
await ctx.render('index', ctx.session)
return
}
const token = ctx.request.query.token
if (token) {
const result = await request(`http://localhost:4000/validate?token=${token}&serviceId=${SERVICE_ID}`)
if (isJSON(result.body)) {
ctx.session = JSON.parse(result.body);
await ctx.render('index', ctx.session)
return
}
}
ctx.redirect('http://localhost:4000?redirectUrl=http://' + ctx.request.header.host)
})
app.use(router.routes())
app.listen(4001, () => {
console.log('APP1 listen on :4001')
})
function isJSON(str) {
if (typeof str !== 'string') {
return false
}
try {
JSON.parse(str);
return true;
} catch (e) {
return false;
}
}
app2 同 app1 只需要改一下 sessionKey、SERVICE_ID、port 等信息即可。
最终实现的效果如下