Nest入门记之「鉴权源码解读及定制鉴权的错误返回」

前言

Nest自带的鉴权模块功能强大,具体使用方法见传送门:在 Nest.js 应用里验证用户的身份(基于 JWT)

本文介绍笔者在使用默认身份验证模块过程,经过排查,实现定制返回的解决方案。

在使用过程中,发现:授权错误时返回的http状态码非200,并且返回的json结构体里是英文的文案。不禁引申出一个疑问:需要定制状态码和文案应该怎么做?

默认授权模块的缺点:
– 不支持定制为200的状态码(尽管400是未授权的http状态标志)

翻查文档发现,Auth模块可以配置返回的错误状态码,但偏偏不包括200(因为200属于正常请求的状态码,nest将未授权视为请求出错)。
– 无法区分授权错误原因(不存在/过期/错误)。(无论业务是否需要)

原因分析

尽管翻阅文档可以知道,如果有业务定制需求,应该使用Extends Guards方案。但是,为了更好地深化对鉴权的了解,还是翻阅一波源码分析一下,出现无法区分错误的原因:

  1. 请求调用后,guard会执行canActivate方法,然后执行passportFn来进行身份验证,然后执行handleRequest方法,但err为空,user=false,所以抛出的都将会是同样的未授权异常。
    node_modules/@nestjs/passport/dist/auth.guard.js
 canActivate(context) {
            return __awaiter(this, void 0, void 0, function* () {
                const options = Object.assign(Object.assign(Object.assign({}, options_1.defaultOptions), this.options), this.getAuthenticateOptions(context));
                const [request, response] = [
                    this.getRequest(context),
                    context.switchToHttp().getResponse()
                ];
                const passportFn = createPassportContext(request, response);
                const user = yield passportFn(type || this.options.defaultStrategy, options, (err, user, info, status) => this.handleRequest(err, user, info, context, status));
                request[options.property || options_1.defaultOptions.property] = user;
                return true;
            });
        }

 handleRequest(err, user, info, context, status) {
            if (err || !user) {
                throw err || new common_1.UnauthorizedException();
            }
            return user;
        }

  1. 执行authenticate方法,但此处在报错时,err为空,错误信息是在info中,即执行了callback(null,false,Error,undefined)

node_modules/@nestjs/passport/dist/auth.guard.js

const createPassportContext = (request, response) => (type, options, callback) => new Promise((resolve, reject) =>{
    return  passport.authenticate(type, options, (err, user, info, status) => {
        try {
            request.authInfo = info;
            return resolve(callback(err, user, info, status));
        }
        catch (err) {
            reject(err);
        }
    })(request, response, (err) => (err ? reject(err) : resolve()))
});
  1. 从passport-jwt库入手,可以查到,没有token时,直接在fail中抛出 “no auth token”的方法。fail方法在哪?
    node_modules/passport-jwt/lib/strategy.js
/**
 * Authenticate request based on JWT obtained from header or post body
 */
JwtStrategy.prototype.authenticate = function(req, options) {
    var self = this;
    var token = self._jwtFromRequest(req);
    if (!token) {
        return self.fail(new Error("No auth token"));
    }
    this._secretOrKeyProvider(req, token, function(secretOrKeyError, secretOrKey) {
        if (secretOrKeyError) {
            self.fail(secretOrKeyError)
        } else {
            // Verify the JWT
            JwtStrategy.JwtVerifier(token, secretOrKey, self._verifOpts, function(jwt_err, payload) {
                if (jwt_err) {
                    return self.fail(jwt_err);
                } else {
                    // Pass the parsed token to the user
                    var verified = function(err, user, info) {
                        if(err) {
                            return self.error(err);
                        } else if (!user) {
                            return self.fail(info);
                        } else {
                            return self.success(user, info);
                        }
                    };
                    try {
                        if (self._passReqToCallback) {
                            self._verify(req, payload, verified);
                        } else {
                            self._verify(payload, verified);
                        }
                    } catch(ex) {
                        self.error(ex);
                    }
                }
            });
        }
    });
};
  1. 经过调用堆栈分析,发现在passport库的middleware里,实现了fail方法。
    node_modules/passport/lib/middleware/authenticate.js
      /**
       * Fail authentication, with optional `challenge` and `status`, defaulting
       * to 401.
       *
       * Strategies should call this function to fail an authentication attempt.
       *
       * @param {String} challenge
       * @param {Number} status
       * @api public
       */
      strategy.fail = function(challenge, status) {
        if (typeof challenge == 'number') {
          status = challenge;
          challenge = undefined;
        }

        // push this failure into the accumulator and attempt authentication
        // using the next strategy
        failures.push({ challenge: challenge, status: status });
        attempt(i + 1);
      };
   ```

5. 对应的attemp和allFailed方法:

```javascript
function attempt(i) {
      var layer = name[i];
      // If no more strategies exist in the chain, authentication has failed.
      if (!layer) { return allFailed(); }
    ......

// allFailed
   function allFailed() {
      if (callback) {
        if (!multi) {
          return callback(null, false, failures[0].challenge, failures[0].status);
        } else {
          var challenges = failures.map(function(f) { return f.challenge; });
          var statuses = failures.map(function(f) { return f.status; });
          return callback(null, false, challenges, statuses);
        }
      }

可以看到,在执行fail时,是将error设置为null,将错误信息通过第三个info参数带回上层。

总结: 默认的AuthGuard在执行身份验证过程,遇到业务逻辑(token失效,不存在token等)的错误(即进入了fail),都会被视为正常回调;
当非业务错误(缺少验证回调、缺少jwtFromRequest等)时,才会进入error方法,抛出错误。以致AuthGuard在处理正常回调时,由于没有错误信息,统一抛出未授权的错误。

解决方案

Q:将回调抛出错误是否可行?

A:不可行。因为改为error,抛出的是一个Error,如果应用没做统一的错误处理,那么这里将会被视为一个500+服务器内部错误,比抛出400+未授权更加糟糕。

正确的姿势,在文档中也有提及,就是——实现Extends Guards。

实现Extends Guards 扩展守卫

经过上述的分析,得知只需要将handleRequest做适当的重写,将info里的错误提取出来判断,即可区分错误类型,并且可以定制返回。

import {
  ExecutionContext,
  Injectable
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { BusinessException, ErrorCode } from '../../support/code';


@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
  canActivate(context: ExecutionContext) {
    return super.canActivate(context);
  }

  handleRequest(err, user, info) {
    if(info && info.name ==='TokenExpiredError'){
      throw new BusinessException(ErrorCode.ERR_400('签名已过期'))
    }
    if (err || !user) {
      throw err || new BusinessException(ErrorCode.ERR_400());
    }
    return user;
  }
}

接口使用:

......
import { JwtAuthGuard } from './jwt-guard'
@Controller('auth')
export class AuthController {
  ......
  @UseGuards(JwtAuthGuard)
  @Get('xxx')
  async xxxxxx(@Request() request): Promise<ApiResult> {
    return xxxxxx
  }
}

这样就可以随心定制自己想要的错误返回啦~

总结

通过“默认的身份认证功能无法定制返回”的问题,解读源码,分析原因,并且通过扩展守卫的方案,实现nestjs认证失败返回的定制个性化。

Nest入门记之「鉴权源码解读及定制鉴权的错误返回」
滚动到顶部