一、多租户SaaS里OAuth 2.0授权的核心痛点

做过SaaS产品的朋友都知道,多租户就像一栋共享的写字楼,不同公司(租户)租了不同楼层,大家用的是同一栋楼的基础设施,但各自的办公区域、资料、员工都是完全隔离的。要是有个访客管理系统(对应OAuth 2.0授权),不能做到每个租户的访客(对应用户)只能进自己租的楼层,那整个产品就乱套了。

普通的OAuth 2.0授权服务器,是给单租户设计的,就像一栋只租给一个公司的办公楼,访客管理系统不用管楼层隔离,只要是这个公司的访客就能进。但放到多租户SaaS里,就会出大问题:比如租户A的员工拿了个令牌,居然能访问租户B的数据库、看租户B的客户数据,这绝对是安全事故。

所以多租户SaaS里的OAuth 2.0授权,核心需求就是两个:第一,每个租户的令牌只能访问自己的资源;第二,租户之间的授权逻辑完全隔离,不能互相干扰。

二、租户隔离的令牌发放机制实现思路

令牌发放是OAuth 2.0的核心环节,要实现租户隔离,得从“怎么识别租户”“怎么给令牌加租户标记”“怎么限制令牌的访问范围”这三个步骤来做。

2.1 第一步:先识别当前请求的租户

要给租户发专属令牌,首先得知道这个请求是哪个租户的。识别租户的方式有好几种,最常用的有两种: 第一种是用租户专属的域名,比如租户A的域名是a.xxx.com,租户B的是b.xxx.com,授权服务器拿到请求后,先看请求的域名,就能对应到租户A或B; 第二种是在请求里加租户ID参数,比如用户登录的时候,请求地址里带tenant_id=tenant_a_123,授权服务器拿到这个参数,就能知道是哪个租户。

这里给大家举个具体的例子,用Node.js的Express框架来做请求拦截,识别租户: 技术栈:Node.js + Express

const express = require('express');
const app = express();

// 模拟租户配置的数据库:key是租户ID,value是租户的域名、密钥等配置
const tenantConfig = {
  'tenant_a_123': {
    domain: 'a.xxx.com',
    secret: 'tenant_a_secret_xxx',
    resourceList: ['a.xxx.com/api/*'] // 租户A能访问的资源范围
  },
  'tenant_b_456': {
    domain: 'b.xxx.com',
    secret: 'tenant_b_secret_xxx',
    resourceList: ['b.xxx.com/api/*'] // 租户B能访问的资源范围
  }
};

// 中间件:识别当前请求的租户
app.use((req, res, next) => {
  let tenantId;
  // 优先从域名识别租户
  const host = req.headers.host;
  for (const [id, config] of Object.entries(tenantConfig)) {
    if (host === config.domain) {
      tenantId = id;
      break;
    }
  }
  // 如果域名没匹配到,再从请求参数里找tenant_id
  if (!tenantId && req.query.tenant_id) {
    tenantId = req.query.tenant_id;
  }
  // 没识别到租户的话,直接返回错误
  if (!tenantId || !tenantConfig[tenantId]) {
    return res.status(400).json({ message: '未识别到合法租户' });
  }
  // 把租户信息挂到请求对象上,后面的接口可以直接用
  req.tenant = tenantConfig[tenantId];
  req.tenantId = tenantId;
  next();
});

这个中间件的逻辑很简单,先看域名,找不到再看参数,最后把识别到的租户信息存起来,给后面的步骤用。

2.2 第二步:给令牌加租户专属标记

识别到租户后,发令牌的时候,得把租户的信息加进去,这样后面验证令牌的时候,才能知道这个令牌属于哪个租户。

OAuth 2.0的令牌一般有两种:JWT令牌和不透明令牌(就是一串随机字符串,需要查数据库才能知道内容)。JWT令牌用得最多,因为它是自包含的,不用查数据库就能拿到里面的信息,性能更好。

给JWT加租户标记的方法很简单,就是在JWT的payload(有效负载)里加个tenant_id字段。这里继续用上面的Node.js例子,发JWT令牌: 技术栈:Node.js + Express + jsonwebtoken

const jwt = require('jsonwebtoken');

// 授权接口:给合法用户发令牌
app.post('/oauth/token', (req, res) => {
  const { username, password } = req.body;
  const tenant = req.tenant; // 从之前的中间件拿到租户信息

  // 这里先模拟验证用户:假设租户A的合法用户是user_a,租户B的是user_b
  const validUser = {
    'tenant_a_123': 'user_a',
    'tenant_b_456': 'user_b'
  };
  if (validUser[req.tenantId] !== username || password !== '123456') {
    return res.status(401).json({ message: '用户名或密码错误' });
  }

  // 生成JWT令牌,payload里加tenant_id
  const token = jwt.sign(
    {
      username: username,
      tenant_id: req.tenantId, // 租户专属标记
      resource: tenant.resourceList // 令牌能访问的资源范围
    },
    tenant.secret, // 用租户专属的密钥加密,每个租户的密钥不一样
    { expiresIn: '1h' } // 令牌有效期1小时
  );

  res.json({ access_token: token, token_type: 'Bearer' });
});

这里有个很关键的点:每个租户的加密密钥是不一样的。比如租户A的密钥是tenant_a_secret_xxx,租户B的是tenant_b_secret_xxx,这样就算两个租户的令牌内容结构一样,也不能互相用——因为加密密钥不同,别人拿租户A的令牌去访问租户B的资源,验证的时候会用租户B的密钥解密,根本解不开,自然就通不过。

2.3 第三步:限制令牌的访问范围

光加租户标记还不够,还得限制这个令牌能访问哪些资源。比如租户A的令牌,只能访问a.xxx.com下面的接口,不能访问b.xxx.com的。

这个限制可以在JWT的payload里加resource字段,就像上面例子里的tenant.resourceList,里面存的是这个令牌能访问的资源范围。后面验证令牌的时候,除了验证租户,还要验证请求的接口是不是在这个resource范围内。

三、租户隔离的令牌验证机制实现思路

令牌验证的逻辑,就是拿到用户带的令牌,先验证令牌是不是合法的,再验证这个令牌能不能访问当前请求的资源。

3.1 验证令牌的合法性

令牌合法性验证分两种情况: 如果是JWT令牌,就用对应的密钥解密,看能不能解开,有没有过期; 如果是不透明令牌,就查数据库,看这个令牌是不是存在,有没有过期,属于哪个租户。

这里继续用Node.js的例子,做令牌验证的中间件: 技术栈:Node.js + Express + jsonwebtoken

// 中间件:验证令牌合法性
app.use('/api/*', (req, res, next) => {
  // 从请求头里拿令牌,格式是Authorization: Bearer xxx
  const authHeader = req.headers.authorization;
  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    return res.status(401).json({ message: '未提供合法令牌' });
  }
  const token = authHeader.split(' ')[1];

  try {
    // 先从令牌里拿到tenant_id,这样才能找到对应的解密密钥
    // 这里先不验证签名,只是解码拿到payload(JWT的前两部分是明文的)
    const decodedPayload = jwt.decode(token);
    const tenantId = decodedPayload.tenant_id;
    const tenant = tenantConfig[tenantId];
    if (!tenant) {
      return res.status(401).json({ message: '令牌所属租户不合法' });
    }

    // 用租户专属的密钥验证令牌签名,同时验证有没有过期
    const verifiedPayload = jwt.verify(token, tenant.secret);
    // 把验证后的令牌信息挂到请求对象上
    req.tokenInfo = verifiedPayload;
    next();
  } catch (err) {
    // 验证失败的原因:签名错误、过期、格式不对等
    return res.status(401).json({ message: '令牌无效或已过期', error: err.message });
  }
});

这个中间件的逻辑很清晰:先拿令牌,再从令牌里找租户,用租户的密钥验证令牌,没问题就把令牌信息存起来。

3.2 验证令牌的访问权限

验证完令牌合法,还要验证这个令牌能不能访问当前的接口。比如当前请求的接口是b.xxx.com/api/user,令牌里的resource是['a.xxx.com/api/*'],那这个请求就通不过。

继续加个权限验证的中间件: 技术栈:Node.js + Express

// 中间件:验证令牌的访问权限
app.use('/api/*', (req, res, next) => {
  const tokenInfo = req.tokenInfo;
  const requestPath = req.path; // 当前请求的路径,比如/api/user
  const requestHost = req.headers.host; // 当前请求的域名,比如a.xxx.com

  // 把当前请求的完整路径拼出来,比如a.xxx.com/api/user
  const fullRequestPath = `${requestHost}${requestPath}`;

  // 遍历令牌允许的资源范围,看当前请求是不是在里面
  const hasPermission = tokenInfo.resource.some(resourcePattern => {
    // 简单的路径匹配:比如resourcePattern是a.xxx.com/api/*,fullRequestPath是a.xxx.com/api/user,就匹配
    return fullRequestPath.startsWith(resourcePattern.replace('*', ''));
  });

  if (!hasPermission) {
    return res.status(403).json({ message: '没有访问该资源的权限' });
  }

  next();
});

这样一来,租户A的令牌就只能访问自己的资源,租户B的也一样,完全隔离。

四、核心技术的优缺点分析

4.1 租户专属密钥的优缺点

优点:每个租户的密钥独立,就算一个租户的密钥泄露了,也不会影响其他租户,安全系数高;验证的时候,不同租户的令牌不会互相混淆,逻辑清晰。 缺点:每个租户都要存专属密钥,管理起来麻烦一点,比如租户要重置密钥,得单独处理;如果租户数量特别多,密钥的存储和维护会增加一定的工作量。

4.2 JWT令牌的优缺点

优点:自包含,不用查数据库就能拿到租户、资源等信息,性能好,适合高并发场景;不用在授权服务器和资源服务器之间做额外的交互,架构简单。 缺点:令牌一旦生成,不能主动作废,除非加黑名单,但加黑名单又会失去JWT不用查数据库的优势;令牌的payload不能太大,不然会增加请求的体积。

4.3 不透明令牌的优缺点

优点:可以主动作废,只要把数据库里的令牌删掉就行;令牌的内容可以很复杂,不用受体积限制。 缺点:每次验证都要查数据库,性能比JWT差;架构上需要授权服务器和资源服务器共享令牌数据库,增加了系统的复杂度。

五、实际应用场景与注意事项

5.1 应用场景

这个设计适合几乎所有的多租户SaaS产品,比如:

  • 企业级的办公SaaS:不同公司的员工只能访问自己公司的项目、文档;
  • 电商SaaS:不同商家只能访问自己的店铺数据、订单数据;
  • 教育SaaS:不同学校的老师、学生只能访问自己学校的课程、作业。

5.2 注意事项

第一,租户密钥的安全:租户的密钥要加密存储,不能明文放在数据库里,最好用专门的密钥管理服务(KMS)来存; 第二,令牌过期时间的设置:不能太长,不然令牌泄露的风险高;也不能太短,不然用户要频繁登录,体验不好; 第三,租户识别的可靠性:如果用域名识别租户,要防止域名劫持;如果用参数识别,要防止参数被篡改; 第四,日志的记录:所有令牌的发放、验证、访问请求都要记录日志,万一出了安全问题,能快速排查; 第五,权限的最小化:给令牌分配的资源范围要尽量小,比如一个员工只需要访问自己的任务接口,就不要给他分配整个公司的接口权限。

六、最佳实践总结

做这个设计的时候,有几个最佳实践可以参考: 第一,优先用JWT令牌,性能好,架构简单;如果需要主动作废令牌,可以加个轻量的黑名单,比如用Redis存作废的令牌ID,验证的时候先查黑名单; 第二,每个租户的配置(包括密钥、资源范围)要单独管理,最好有专门的租户管理后台,方便管理员新增、修改、删除租户; 第三,授权服务器和资源服务器要分离,授权服务器只负责发令牌、验证令牌,资源服务器只负责处理业务请求,这样架构更清晰,也更容易扩展; 第四,定期做安全审计,比如检查租户的密钥有没有泄露,令牌的访问范围有没有不合理的地方,日志有没有异常的访问请求; 第五,做好容灾备份,比如授权服务器挂了,要有备用的授权服务器;租户的配置要定期备份,防止数据丢失。

最后给大家提个醒,多租户SaaS的安全是底线,哪怕性能差点、架构复杂点,也不能在隔离上偷工减料。毕竟一旦出现数据泄露的问题,对产品的打击是致命的。