egg中使用egg-jwt会自动校验token是否过期

254 阅读4分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第2天

起因

在写项目的时候发现使用了JWT,设置了过期时间,但是并没有做过期校验。却可以触发到过期校验。 就很好奇是怎么回事?扒拉了一些源码和文档,找到了原因。

一些前提知识

EGG中的中间件和插件

中间件

Egg 是基于 Koa 实现的,所以 Egg 的中间件形式和 Koa 的中间件形式是一样的,都是基于洋葱圈模型。每次我们编写一个中间件,就相当于在洋葱外面包了一层。

中间件最主要的作用就是在请求前后做一些工作,比如鉴权,错误处理等。当然中间件也可以配置全局的和特定路由的。官网有具体配置方法。

插件

为啥要使用插件,egg官方回答

我们在使用 Koa 中间件过程中发现了下面一些问题:

  1. 中间件加载其实是有先后顺序的,但是中间件自身却无法管理这种顺序,只能交给使用者。这样其实非常不友好,一旦顺序不对,结果可能有天壤之别。

  2. 中间件的定位是拦截用户请求,并在它前后做一些事情,例如:鉴权、安全检查、访问日志等等。但实际情况是,有些功能是和请求无关的,例如:定时任务、消息订阅、后台逻辑等等。

  3. 有些功能包含非常复杂的初始化逻辑,需要在应用启动的时候完成。这显然也不适合放到中间件中去实现。

综上所述,我们需要一套更加强大的机制,来管理、编排那些相对独立的业务逻辑。

中间件和插件的关系

一个插件其实就是一个『迷你的应用』,和应用(app)几乎一样:

他们的关系是:

  • 应用可以直接引入 Koa 的中间件。
  • 当遇到上一节提到的场景时,则应用需引入插件。
  • 插件本身可以包含中间件。
  • 多个插件可以包装为一个上层框架

jwt中的中间件会自动校验token是否过期

从jwt过期的报错的位置定位到是这里:

截屏2022-10-14 下午5.44.55.png

这个就是一个中间件文件。

const jwt = require("jsonwebtoken");
const UnauthorizedError = require("./errors/UnauthorizedError");
const unless = require("koa-unless");
const async = require("async");
const set = require("lodash.set");
const isFunction = require("lodash.isfunction");

const wrapIsRevokedInAsync = isRevoked => {
  return async function(_, __) {
    return isRevoked || false;
  };
};

function wrapStaticSecretInAsync(secret) {
  return async function(_, __) {
    return secret;
  };
}

module.exports = function(options) {
  if (!options || !options.secret) throw new Error("secret should be set");

  let secretAsync = options.secret;

  if (!isFunction(secretAsync)) {
    secretAsync = wrapStaticSecretInAsync(secretAsync);
  }

  let isRevokedAsync = options.isRevoked;
  if (!isFunction(isRevokedAsync)) {
    isRevokedAsync = wrapIsRevokedInAsync(isRevokedAsync);
  }

  const property = options.property || options.userProperty || "user";
  const credentialsRequired =
    typeof options.credentialsRequired === "undefined"
      ? true
      : options.credentialsRequired;

  const middleware = async function(ctx, next) {
    let token;

    if (ctx.method === "OPTIONS" && ctx.get("access-control-request-headers")) {
      const hasAuthInAccessControl = !!~ctx
        .get("access-control-request-headers")
        .split(",")
        .map(function(header) {
          return header.trim();
        })
        .indexOf("authorization");

      if (hasAuthInAccessControl) {
        return await next();
      }
    }

    if (options.getToken && typeof options.getToken === "function") {
      try {
        token = options.getToken(ctx);
      } catch (e) {
        throw e;
      }
    } else if (ctx.get("authorization")) {
      const parts = ctx.get("authorization").split(" ");
      if (parts.length == 2) {
        const scheme = parts[0];
        const credentials = parts[1];

        if (/^Bearer$/i.test(scheme)) {
          token = credentials;
        } else {
          if (credentialsRequired) {
            throw new UnauthorizedError("credentials_bad_scheme", {
              message: "Format is Authorization: Bearer [token]"
            });
          } else {
            return await next();
          }
        }
      } else {
        throw new UnauthorizedError("credentials_bad_format", {
          message: "Format is Authorization: Bearer [token]"
        });
      }
    }

    if (!token) {
      if (credentialsRequired) {
        throw new UnauthorizedError("credentials_required", {
          message: "No authorization token was found"
        });
      } else {
        return await next();
      }
    }

    let dtoken;

    try {
      dtoken = jwt.decode(token, { complete: true }) || {};
    } catch (err) {
      throw new UnauthorizedError("invalid_token", err);
    }

    const arity = secretAsync.length;
    let secret;
    if (arity == 4) {
      secret = await secretAsync(ctx, dtoken.header, dtoken.payload);
    } else {
      // arity == 3
      secret = await secretAsync(ctx, dtoken.payload);
    }

    const decoded = await new Promise((resolve, reject) => {
      jwt.verify(token, secret, options, function(err, decoded) {
        if (err) {
          reject(new UnauthorizedError("invalid_token", err));
        } else {
          resolve(decoded);
        }
      });
    });

    const result = await new Promise(async (resolve, reject) => {
      let revoked;
      try {
        revoked = await isRevokedAsync(ctx, dtoken.payload);
      } catch (err) {
        reject(err);
      }

      if (revoked) {
        reject(
          new UnauthorizedError("revoked_token", {
            message: "The token has been revoked."
          })
        );
      } else {
        resolve(decoded);
      }
    });

    set(ctx.state, property, result);

    await next();
  };

  middleware.unless = unless;
  middleware.UnauthorizedError = UnauthorizedError;

  return middleware;
};

module.exports.UnauthorizedError = UnauthorizedError;

里面会进行decode解密token,然后校验,如果token失效抛出。(以下为源码)

new UnauthorizedError("invalid_token", err)

  const decoded = await new Promise((resolve, reject) => {
      jwt.verify(token, secret, options, function(err, decoded) {
        if (err) {
          reject(new UnauthorizedError("invalid_token", err));
        } else {
          resolve(decoded);
        }
      });
    });

function UnauthorizedError(code, error) {
  this.name = "UnauthorizedError";
  this.message = error.message;
  Error.call(this, error.message);
  Error.captureStackTrace(this, this.constructor);
  this.code = code;
  this.status = 401;
  this.inner = error;
}

UnauthorizedError.prototype = Object.create(Error.prototype);
UnauthorizedError.prototype.constructor = UnauthorizedError;

module.exports = UnauthorizedError;

最后接口响应码为401,前端就可以判断然后重新引导用户登录了。 所以说在egg-jwt插件中已经做好了校验。

中间件执行顺序

我们可以自定义应用的中间件,而插件中也会有中间件。那中间件是什么顺序排列的呢。

打印了一下当前项目的中间件,做了分析

截屏2022-10-14 下午5.25.14.png

最后一个errorHandler是我自定义的中间件,可以发下插件的中间件jwtmiddlewarewrapper是在我们自己定义的中间件之前。

再往前看的中间件都是框架自带的一些中间件。如图:源码中的框架中间件

截屏2022-10-14 下午6.02.01.png

截屏2022-10-14 下午6.04.08.png

所以得出中间件的执行顺序:

  1. 框架中间件
  2. 插件中间件
  3. 自定义中间件

📢:框架默认中间件不能被应用层中间件覆盖,如果应用层有自定义同名中间件,在启动时会报错。