前言
Nest自带的鉴权模块功能强大,具体使用方法见传送门:在 Nest.js 应用里验证用户的身份(基于 JWT)
本文介绍笔者在使用默认身份验证模块过程,经过排查,实现定制返回的解决方案。
在使用过程中,发现:授权错误时返回的http状态码非200,并且返回的json结构体里是英文的文案。不禁引申出一个疑问:需要定制状态码和文案应该怎么做?
默认授权模块的缺点:
– 不支持定制为200的状态码(尽管400是未授权的http状态标志)
翻查文档发现,Auth模块可以配置返回的错误状态码,但偏偏不包括200(因为200属于正常请求的状态码,nest将未授权视为请求出错)。
– 无法区分授权错误原因(不存在/过期/错误)。(无论业务是否需要)
原因分析
尽管翻阅文档可以知道,如果有业务定制需求,应该使用Extends Guards方案。但是,为了更好地深化对鉴权的了解,还是翻阅一波源码分析一下,出现无法区分错误的原因:
- 请求调用后,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;
}
- 执行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()))
});
- 从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);
}
}
});
}
});
};
- 经过调用堆栈分析,发现在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认证失败返回的定制个性化。