Background

前面一系列关于基建的文章主要侧重子模块的实现,但是技术选型还没有确定,此外虽然模块功能已经确定,但还缺少服务端必不可少的基础服务,比如日志、鉴权等。

Node 端比较流行的服务框架有 koa/express/Nest/Nuxt 等等,鉴于我们主要是实现简单的 http endpoint,使用 koa/express 就足以,本文以 koa 为例,数据库选择 MongoDB/Redis 更符合前端人员的开发思维。

登录

既然是内部工具,一方面是保证系统安全性不被外人访问到,一方面就是免登,最简单的方案就是接入内部使用的 IM,比如钉钉、飞书等。

飞书登录流程

这里我们以飞书为例,流程参考 飞书登录流程,其实很简单,授权登录是一个拿 codetoken 的流程。从前端开发的角度来看,需要调用2个 API

1、获取 code

async auth(ctx: any) {
  const { redirect_uri } = ctx.request.query || {}
  const query = `client_id=${client_id}&redirect_uri=${encodeURIComponent(redirect_uri)}`
  return `https://passport.feishu.cn/suite/passport/oauth/authorize?${query}`
}

服务端会返回如上的 url ,客户端拿到后手动跳转,用户授权之后,飞书会自动在 redirect_uri 中加入 code

2、前端从地址栏解析 code,再次调用后端接口获取 token 然后据此获取用户信息

class AccountController {
  async token(ctx: any) {
    try {
      let res = await axios.post('https://passport.feishu.cn/suite/passport/oauth/token', {
        grant_type: 'authorization_code',
        client_id,
        client_secret,
        code, // 地址栏解析的 code 
        redirect_uri,
      })

      // 拿到 token 获取用户信息
      const { access_token } = res.data
      res = await axios.get('https://passport.feishu.cn/suite/passport/oauth/userinfo', {
        headers: {
          Authorization: `Bearer ${access_token}`,
        },
      })
      const { user_id, name, avatar_url } = res.data || {}
    } catch (e) {
      logger.error(e)
    }
  }
}

到此,前后端都知道用户身份了,不过前后端保存用户身份的逻辑不同

1、前端 把 token 写入到 localStorage 中,后续的接口调用都要在 header 带上它再传给后端验证

2、后端 写入 mongodb,不过通常 token 具有有效期,写入 redis 可以快速查询是否过期,

let doc = await AccountModel.findOne({ userId: user_id })
if (doc) { // 同一个用户,更新 token 而已
  doc.token = access_token
  const res = await doc.save()
} 
else { // 说明是新建的
  await AccountModel.create({
    name,
    userId: user_id,
    avatar: avatar_url,
    token: access_token,
  })
}

永久登录

在我们这个内部系统,为了避免老是登录,就做了一个假的永久登录态,即使 token 失效也不管。换句话说,

1、只要飞书授权成功之后,就把 token 更新到数据库中(见上面的代码),不在考虑 refreshToken 的事情

2、鉴权只判断数据库中是否存在 token,存在即有效,不存在就失效

3、除非用户主动退出,才会清空数据库中的 token

这个方案简单粗暴,有个很明显的问题,如果用户在不同的端访问系统,token 会被反复覆盖。举个例子,

1、用户先用PC端浏览器访问,因为是第一次访问,会生成 tokenA 写入数据库,

2、再用 pad 端浏览器访问,因为是第一次访问,又会生成新的 tokenB 从而覆盖 tokenA, 因为此时数据库中已经有 user_id 了,这时候更新 token 即可,而不是创建一个新用户

3、过一段时间,再用PC访问,此时是二次访问,headers 中带有 tokenA,但是数据库已经找不到 tokenA 从而强制用户退登

解决方法也很简单,加个 redis 既支持真的 token 有效期,也支持多端登录。

koa 中间件

API 鉴权

大多数 API 都是要鉴权的,不过也有少部分不需要,比如前文提到的2个接口 auth() & token(),此时还未授权,哪来的 token。代码参考如下

const whitePathList = [
  '/account/auth',
  '/account/token',
]
export function tokenIntercept() {
  return async (ctx, next) => {
    if (whitePathList.includes(ctx.request.path)) {
      return next()
    }
    
    const tokenValue = ctx.request.headers['x-access-token']
    
    if (!tokenValue) {
      return 'token is null'
    }
    
    const user = await AccountModel.findOne({ token: tokenValue })
    if (!user) {
      return 'invalid token'
    }

    await next()
  }
}

跨域

即使通过白名单跳过 token 验证,某些 api 依旧访问不通,会出现跨域的问题,比如埋点系统的自动验证插件,会调用服务端的 /track/validatemock 平台/mockme/proxy

跨域也很好解决,最简单的就是服务端支持 cors,koa 有自己的三方中间件。这里从3个方面判断是否可以跨域

  • 域名是否在白名单内
  • 请求头 header
  • 请求方法
import cors from 'koa2-cors'
app.use(
    cors({
      origin: (ctx: any) => {
        const url = ctx.header.referer?.substr(0, ctx.header.referer.length - 1)
        if (url && whiteList.some((u) => url.includes(u))) {
          return url
        }
        if (ctx.header['x-test-mock'] == 'mock-me') {
          return url
        }
      },
      allowMethods: ['POST', 'GET'],
      allowHeaders: ['Content-Type', 'x-access-token', 'x-test-mock'],
    })
  )

log4js

最后就是日志服务,服务端不像前端,可以到直接在浏览器 debug,所以后端的日志服务是非常重要的,这里选择了比较流行的一个三方日志库 log4js-node,功能挺多的。

那我们要搜集哪些日志信息呢??我分为3大类

  • 所有日志:info、warning、error 所有信息,帮助分析用户行为
  • 错误日志:代码异常、数据库异常等
  • API日志:请求了哪些接口?接口的耗时分布?
log4js.configure({
  appenders: {
    console: {
      type: 'console',
    },

    // 所有的日志
    app: {
      type: 'file',
      filename: 'log/app.log',
      maxLogSize: 10485760,
    },

    // 访问接口日志
    access: {
      type: 'dateFile',
      filename: 'log/access.log',
      pattern: 'yyyy-MM-dd.log',
      alwaysIncludePattern: true,
    },

    // 错误日志
    error: {
      type: 'dateFile',
      filename: 'log/error.log',
      pattern: 'yyyy-MM-dd.log',
      alwaysIncludePattern: true,
    },
    'just-error': {
      type: 'logLevelFilter',
      appender: 'error',
      level: 'error',
    },
  }
})

这段代码的 error appender 使用了 logLevelFilter,简单来说就是根据 level 过滤事件。比如线上环境,我们希望 info 以上的事件记录到 app appender 中同时 error 以上的事件记录到 error.log

log4js.configure({
  // ...
  
  categories: {
    default: {
      appenders: ['just-error', 'app'],
      level: 'info',
    },
    access: {
      appenders: ['access'],
      level: 'info',
    },
  }
})

export let logger = log4js.getLogger()

API 日志

要想记录访问了哪些api、入参等信息,显然需要实现中间件,代码也很简单

const logAccess = () => {
  const accessLogger = log4js.getLogger('access')

  return async (ctx, next) => {
    accessLogger.info(
      JSON.stringify({
        url: ctx.url,
        query: ctx.query,
        ua: ctx.userAgent,
        timestamp: Date.now(),
      })
    )

    await next()
  }
}

待改进

目前的服务端是所有子项目、定时任务部署在一起

1、只要其中一个服务挂起,其他服务也会受到影响

2、只改某个项目的代码,也会部署整套服务

只因现在业务不复杂,这样做是可以的,如果后续继续完善各个项目,单独部署是有必要的,后续可以考虑 nestjs 以及微服务。