vianvio / FE-Companions

山虽高,我心已决要攀登, 路再难,绊不住我的脚跟; 因为我看到生命之路就在这里。 -- 《天路历程》
447 stars 34 forks source link

20200415 - Simple #62

Open vianvio opened 4 years ago

vianvio commented 4 years ago

问题列表: 假设我们有一个请求GET /cup/1

  1. 如何校验用户是否可以访问这个数据?校验逻辑写在哪里?
  2. 杯子需要补充制作人信息,假设入口请求qps 50000,cup服务的机器数量足够,而制作人信息的机器不够,怎么处理?
  3. 杯子又需要一些额外信息了,而且评估出来可能以后还会有各种不同信息,这时候cup服务需要做哪些改造?
  4. 底层各个服务也需要用户鉴权,怎么综合处理?
SimpleCodeCX commented 4 years ago

1. 假设我们有一个请求GET /cup/1,如何校验用户是否可以访问这个数据?校验逻辑写在哪里?

首先,我们先定一个 http header token 的约定,用户在本系统登录成功后,会获得一个登录成功的凭证 token, 随后用户每次请求接口的时候,需要把登录凭证 token 通过 Http Header 的形式携带过来,后端接口收到用户的请求后,会从 Http Header 中获取 token,并根据 token 获取到该用户在本系统的信息及权限。

如上通过用户的 token 获取到用户的权限后,就可以判断用户是否有权限访问该接口了,如果没有权限的话,就直接返回,响应相关的接口 error 信息。

考虑到系统中的其他接口也需要做权限校验,并且每个接口的权限校验逻辑基本是一样的,只是需要验证的权限的 key 不一样而已,所以可以把这个校验逻辑做成一个通用的中间件。我把这个中间件的名字定义为 Permission,Permission 的代码(ts 版)大致如下:

import { RouterContext } from 'koa-router';
export class Permission {
  static test(permissions: Array<string> | string = []) {
    if (!Array.isArray(permissions)) {
      permissions = [permissions];
    }
    return async function (ctx: RouterContext, next) {
      const token = ctx.request.headers['token'];
      if (!token) {
        ctx.body = '检测到您尚未登录,请先登录'
        return;
      }

      const userPermissions = [];  // 根据 token 从数据库获取用户的权限信息
      ctx.hasPermission = (permissionNames: Array<string> = []): boolean => {
        return permissionNames.every(p => userPermissions.includes(p))
      };

      // 检查是否有权限 
      if (permissions.length > 0 && !ctx.hasPermission(permissions)) {
        ctx.body = '您没有访问此接口的权限';
        return;
      }
      await next();
    };
  }
}

接口层的代码(ts版)大致如下:

cup router

import { Permission } from './Permission';
/**
 * @api {GET} /cup/:id 获取某个 cup 的信息
 * @apiDescription 获取某个 cup 的信息
 * @apiVersion 1.0.0
 * @apiName getCup
 * @apiGroup cups
 *
 * @apiHeader {String} Token token 用户的登录凭证
 *
 * @apiParam (path) {string} id cup的编号
 */
router.get('/cup/:id', Permission.test('cup-get'), cupController.getCup);

car router

import { Permission } from './Permission';
/**
 * @api {GET} /car/:id 获取某个 car 的信息
 * @apiDescription 获取某个 car 的信息
 * @apiVersion 1.0.0
 * @apiName getCar
 * @apiGroup cars
 *
 * @apiHeader {String} Token token 用户的登录凭证
 *
 * @apiParam (path) {string} id car的编号
 */
router.get('/car/:id', Permission.test('car-get'), carController.getCar);

2. 杯子需要补充制作人信息,假设入口请求qps 50000,cup服务的机器数量足够,而制作人信息的机器不够,怎么处理?

假设我们的项目是基于 docker 部署的:有一个 docker 私服,每次项目发布,都会把镜像推送到 docker 仓库中,然后各个机器在从 docker 仓库中 docker pull 下来,再 docker run 起来。

那么此时就可以对制作人信息的机器进行机器扩容。

3. 杯子又需要一些额外信息了,而且评估出来可能以后还会有各种不同信息,这时候cup服务需要做哪些改造?

在写代码的过程中,我们需要遵循开放封闭原则,即对扩展开发,对修改封闭,因为修改很有可能就会意味着影响原有的功能。把这个思想用在微服务上也是合适的。

此时的场景是杯子又需要一些额外的信息,如果在原有服务上增加功能的话,可能会修改到原来的接口代码或者甚至是数据库表结构,这样可能会影响原有的接口功能。而且随着接口变多,服务器的访问也会变大,服务器的负载也会变大。所以这里可以考虑另一种做法:做一个新的服务,来提供杯子的额外信息,然后在 cup 服务上调用新的服务,获取到数据再进行数据汇总,这样带来的好处是,

1)可以尽量不影响 cup 原有接口的功能

2)可以减轻 cup 服务的负载。

而此时,随着服务越来越多,就会存在一个服务鉴权的问题,比如用户调用服务接口需要检查该用户是否有访问权限,服务1调用服务2的接口,服务2也需要检查服务1是否有访问权限。这个问题在问题4中回答。

4. 底层各个服务也需要用户鉴权,怎么综合处理?

一般来说,当一个系统的功能越来越多时,我们会对系统的功能进行拆分,拆分成多个微服务,而对用户来说,用户不希望每个微服务都要单独登录才能访问,用户希望在系统登录成功后,获取到的 token 可以访问该系统下的所有的微服务。

而对每个微服务来说,当一个请求进来时,首先从请求中读取请求者的 token,然后验证该 token 是否有效,最后再验证该 token 是否有访问权限。

每个微服务除了被用户调用,还有可能被其他的微服务调用,所以这里的鉴权,不仅要对用户鉴权,还要对微服务鉴权。

为了解决以上问题,可以采用单点登录的方案(SSO),思路如下:

实现一个新的服务,我把它命名为 SSO Service,SSO Service 提供的功能如下:

1) 提供用户的登录功能 2) 提供服务的登录功能 3) 统一的权限管理功能以及配套的权限管理系统, 由于这里涉及到用户调用服务的权限和服务调用其他服务的权限,所以对用户和服务使用同一套权限管理规则,可以降低复杂度

SSO Service 提供的接口如下:

1) POST auth/user/login

此接口用于用户登录,登录成功后,返回登录成功凭证 token

2) POST auth/service/login

此接口用于服务登录,登录成功后,返回登录成功凭证 token

3) GET auth/validation/:userToken?servieToken=xxxx

此接口用于获取 userToken 在 serviceToken 下的权限信息

最后封装一个通用的包给各个服务使用,基于问题1中的Permission 改造如下:

import { RouterContext } from 'koa-router';
import axios from 'axios';

// 从 SSO Service 获取 userToken 在 serviceToken 权限信息
async function getUserPermissionFromSSO(userToken, serviceToken) {
  const _url = `https://www.sso.com/auth/validation/${userToken}?serviceToken=${serviceToken}`;
  const _res = await axios.get(_url);
  const userPermission = _res.data;
  return userPermission;
}

export class Permission {
  static test(permissions: Array<string> | string = []) {
    if (!Array.isArray(permissions)) {
      permissions = [permissions];
    }
    return async function (ctx: RouterContext, next) {
      const token = ctx.request.headers['token'];
      if (!token) {
        ctx.body = '检测到您尚未登录,请先登录'
        return;
      }

      const serviceToken = 'xxxxxxxx'; // 这里的 serviceToken 需要该服务登录 POST auth/service/login 获得

      // 从 SSO Service 获取 userToken 在 serviceToken 权限信息
      const userPermissions = await getUserPermissionFromSSO(token, serviceToken);
      ctx.hasPermission = (permissionNames: Array<string> = []): boolean => {
        return permissionNames.every(p => userPermissions.includes(p))
      };

      // 检查是否有权限 
      if (permissions.length > 0 && !ctx.hasPermission(permissions)) {
        ctx.body = '您没有访问此接口的权限';
        return;
      }
      await next();
    };
  }
}

接口层的代码(ts版)如下:

cup router

import { Permission } from './Permission';
/**
 * @api {GET} /cup/:id 获取某个 cup 的信息
 * @apiDescription 获取某个 cup 的信息
 * @apiVersion 1.0.0
 * @apiName getCup
 * @apiGroup cups
 *
 * @apiHeader {String} Token token 用户的登录凭证
 *
 * @apiParam (path) {string} id cup的编号
 */
router.get('/cup/:id', Permission.test('cup-get'), cupController.getCup);

car router

import { Permission } from './Permission';
/**
 * @api {GET} /car/:id 获取某个 car 的信息
 * @apiDescription 获取某个 car 的信息
 * @apiVersion 1.0.0
 * @apiName getCar
 * @apiGroup cars
 *
 * @apiHeader {String} Token token 用户的登录凭证
 *
 * @apiParam (path) {string} id car的编号
 */
router.get('/car/:id', Permission.test('car-get'), carController.getCar);