随着企业产品线的不断丰富,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 去统一认证服务验证。

image-20230103203659575

不同域名

在不同的域名(主域名)下不再像同域名下那样用 cookie 就可以简单实现 SSO,主流的方式是基于 CAS 的方式来实现 SSO。

CAS 中有以下概念:

  • TGT:Ticket Grangting Ticket,是 CAS 为用户签发的登录票据,也就是 session。
  • TGC:Ticket Granting Cookie,是 Session 的唯一标识(SessionId),以 Cookie 形式放到浏览器端。
  • ST:Service Ticket,唯一标识,用于验证已经登录 CAS。

CAS 的整体流程如下:

image-20230104213659883

  1. 用户首次打开 abc.com ,访问了受保护资源;
  2. abc.com 的服务器未识别到用户身份,重定向到 cas.com?rediectUrl=abc.com;
  3. cas.com 没有识别到用户身份,输入账号密码进行登录;
  4. 登陆成功之后,保存 TGT (session),将 TGC (cookie)写入浏览器,用于下次验证时保存状;
  5. 重定向到 abc.com?token=ST-12345;
  6. abc.com 未识别到用户身份,发现有 ST,向 CAS 验证 ST 合法性;
  7. ST 通过验证,返回用户身份信息;
  8. abc.com 服务保存用户信息到 session,返回浏览器请求的资源;

然后 def.com 的过程和 abc.com 一致,区别在于请求 ST 的时候不需要手动输入密码了, 因为上一次已经保存了 session 在 CAS 中,直接验证身份生成 ST。

我们基于 Koa 来用代码简单的实现一个 CAS。

先来分析一下我们核心需要做什么事情:

  1. 提供身份验证能力,也就是登录功能,登陆成功之后要携带 ST 重定向到原应用;
  2. 提供 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 等信息即可。

最终实现的效果如下

QQ20230105-205651-HD


前端小白