282 lines
8.3 KiB
JavaScript
282 lines
8.3 KiB
JavaScript
// Copyright IBM Corp. 2014,2019. All Rights Reserved.
|
|
// Node module: loopback
|
|
// This file is licensed under the MIT License.
|
|
// License text available at https://opensource.org/licenses/MIT
|
|
|
|
/*!
|
|
* Module Dependencies.
|
|
*/
|
|
|
|
'use strict';
|
|
const g = require('../../lib/globalize');
|
|
const loopback = require('../../lib/loopback');
|
|
const assert = require('assert');
|
|
const uid = require('uid2');
|
|
const DEFAULT_TOKEN_LEN = 64;
|
|
|
|
/**
|
|
* Token based authentication and access control.
|
|
*
|
|
* **Default ACLs**
|
|
*
|
|
* - DENY EVERYONE `*`
|
|
* - ALLOW EVERYONE create
|
|
*
|
|
* @property {String} id Generated token ID.
|
|
* @property {Number} ttl Time to live in seconds, 2 weeks by default.
|
|
* @property {Date} created When the token was created.
|
|
* @property {Object} settings Extends the `Model.settings` object.
|
|
* @property {Number} settings.accessTokenIdLength Length of the base64-encoded string access token. Default value is 64.
|
|
* Increase the length for a more secure access token.
|
|
*
|
|
* @class AccessToken
|
|
* @inherits {PersistedModel}
|
|
*/
|
|
|
|
module.exports = function(AccessToken) {
|
|
/**
|
|
* Anonymous Token
|
|
*
|
|
* ```js
|
|
* assert(AccessToken.ANONYMOUS.id === '$anonymous');
|
|
* ```
|
|
*/
|
|
|
|
AccessToken.ANONYMOUS = new AccessToken({id: '$anonymous'});
|
|
|
|
/**
|
|
* Create a cryptographically random access token id.
|
|
*
|
|
* @callback {Function} callback
|
|
* @param {Error} err
|
|
* @param {String} token
|
|
*/
|
|
|
|
AccessToken.createAccessTokenId = function(fn) {
|
|
uid(this.settings.accessTokenIdLength || DEFAULT_TOKEN_LEN, function(err, guid) {
|
|
if (err) {
|
|
fn(err);
|
|
} else {
|
|
fn(null, guid);
|
|
}
|
|
});
|
|
};
|
|
|
|
/*!
|
|
* Hook to create accessToken id.
|
|
*/
|
|
AccessToken.observe('before save', function(ctx, next) {
|
|
if (!ctx.instance || ctx.instance.id) {
|
|
// We are running a partial update or the instance already has an id
|
|
return next();
|
|
}
|
|
|
|
AccessToken.createAccessTokenId(function(err, id) {
|
|
if (err) return next(err);
|
|
ctx.instance.id = id;
|
|
next();
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Extract the access token id from the HTTP request
|
|
* @param {Request} req HTTP request object
|
|
* @options {Object} [options] Each option array is used to add additional keys to find an `accessToken` for a `request`.
|
|
* @property {Array} [cookies] Array of cookie names.
|
|
* @property {Array} [headers] Array of header names.
|
|
* @property {Array} [params] Array of param names.
|
|
* @property {Boolean} [searchDefaultTokenKeys] Use the default search locations for Token in request
|
|
* @property {Boolean} [bearerTokenBase64Encoded] Defaults to `true`. For `Bearer` token based `Authorization` headers,
|
|
* decode the value from `Base64`. If set to `false`, the decoding will be skipped and the token id will be the raw value
|
|
* parsed from the header.
|
|
* @return {String} The access token
|
|
*/
|
|
AccessToken.getIdForRequest = function(req, options) {
|
|
options = options || {};
|
|
let params = options.params || [];
|
|
let headers = options.headers || [];
|
|
let cookies = options.cookies || [];
|
|
let i = 0;
|
|
let length, id;
|
|
|
|
// https://github.com/strongloop/loopback/issues/1326
|
|
if (options.searchDefaultTokenKeys !== false) {
|
|
params = params.concat(['access_token']);
|
|
headers = headers.concat(['X-Access-Token', 'authorization']);
|
|
cookies = cookies.concat(['access_token', 'authorization']);
|
|
}
|
|
|
|
for (length = params.length; i < length; i++) {
|
|
const param = params[i];
|
|
// replacement for deprecated req.param()
|
|
id = req.params && req.params[param] !== undefined ? req.params[param] :
|
|
req.body && req.body[param] !== undefined ? req.body[param] :
|
|
req.query && req.query[param] !== undefined ? req.query[param] :
|
|
undefined;
|
|
|
|
if (typeof id === 'string') {
|
|
return id;
|
|
}
|
|
}
|
|
|
|
for (i = 0, length = headers.length; i < length; i++) {
|
|
id = req.header(headers[i]);
|
|
|
|
if (typeof id === 'string') {
|
|
// Add support for oAuth 2.0 bearer token
|
|
// http://tools.ietf.org/html/rfc6750
|
|
|
|
// To prevent Error: Model::findById requires the id argument
|
|
// with loopback-datasource-juggler 2.56.0+
|
|
if (id === '') continue;
|
|
|
|
if (id.indexOf('Bearer ') === 0) {
|
|
id = id.substring(7);
|
|
if (options.bearerTokenBase64Encoded) {
|
|
// Decode from base64
|
|
const buf = new Buffer(id, 'base64');
|
|
id = buf.toString('utf8');
|
|
}
|
|
} else if (/^Basic /i.test(id)) {
|
|
id = id.substring(6);
|
|
id = (new Buffer(id, 'base64')).toString('utf8');
|
|
// The spec says the string is user:pass, so if we see both parts
|
|
// we will assume the longer of the two is the token, so we will
|
|
// extract "a2b2c3" from:
|
|
// "a2b2c3"
|
|
// "a2b2c3:" (curl http://a2b2c3@localhost:3000/)
|
|
// "token:a2b2c3" (curl http://token:a2b2c3@localhost:3000/)
|
|
// ":a2b2c3"
|
|
const parts = /^([^:]*):(.*)$/.exec(id);
|
|
if (parts) {
|
|
id = parts[2].length > parts[1].length ? parts[2] : parts[1];
|
|
}
|
|
}
|
|
return id;
|
|
}
|
|
}
|
|
|
|
if (req.signedCookies) {
|
|
for (i = 0, length = cookies.length; i < length; i++) {
|
|
id = req.signedCookies[cookies[i]];
|
|
|
|
if (typeof id === 'string') {
|
|
return id;
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
};
|
|
|
|
/**
|
|
* Resolve and validate the access token by id
|
|
* @param {String} id Access token
|
|
* @callback {Function} cb Callback function
|
|
* @param {Error} err Error information
|
|
* @param {Object} Resolved access token object
|
|
*/
|
|
AccessToken.resolve = function(id, cb) {
|
|
this.findById(id, function(err, token) {
|
|
if (err) {
|
|
cb(err);
|
|
} else if (token) {
|
|
token.validate(function(err, isValid) {
|
|
if (err) {
|
|
cb(err);
|
|
} else if (isValid) {
|
|
cb(null, token);
|
|
} else {
|
|
const e = new Error(g.f('Invalid Access Token'));
|
|
e.status = e.statusCode = 401;
|
|
e.code = 'INVALID_TOKEN';
|
|
cb(e);
|
|
}
|
|
});
|
|
} else {
|
|
cb();
|
|
}
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Find a token for the given `ServerRequest`.
|
|
*
|
|
* @param {ServerRequest} req
|
|
* @param {Object} [options] Options for finding the token
|
|
* @callback {Function} callback
|
|
* @param {Error} err
|
|
* @param {AccessToken} token
|
|
*/
|
|
AccessToken.findForRequest = function(req, options, cb) {
|
|
if (cb === undefined && typeof options === 'function') {
|
|
cb = options;
|
|
options = {};
|
|
}
|
|
|
|
const id = this.getIdForRequest(req, options);
|
|
|
|
if (id) {
|
|
this.resolve(id, cb);
|
|
} else {
|
|
process.nextTick(cb);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Validate the token.
|
|
*
|
|
* @callback {Function} callback
|
|
* @param {Error} err
|
|
* @param {Boolean} isValid
|
|
*/
|
|
AccessToken.prototype.validate = function(cb) {
|
|
try {
|
|
assert(
|
|
this.created && typeof this.created.getTime === 'function',
|
|
'token.created must be a valid Date',
|
|
);
|
|
assert(this.ttl !== 0, 'token.ttl must be not be 0');
|
|
assert(this.ttl, 'token.ttl must exist');
|
|
assert(this.ttl >= -1, 'token.ttl must be >= -1');
|
|
|
|
const AccessToken = this.constructor;
|
|
const userRelation = AccessToken.relations.user; // may not be set up
|
|
let User = userRelation && userRelation.modelTo;
|
|
|
|
// redefine user model if accessToken's principalType is available
|
|
if (this.principalType) {
|
|
User = AccessToken.registry.findModel(this.principalType);
|
|
if (!User) {
|
|
process.nextTick(function() {
|
|
return cb(null, false);
|
|
});
|
|
}
|
|
}
|
|
|
|
const now = Date.now();
|
|
const created = this.created.getTime();
|
|
const elapsedSeconds = (now - created) / 1000;
|
|
const secondsToLive = this.ttl;
|
|
const eternalTokensAllowed = !!(User && User.settings.allowEternalTokens);
|
|
const isEternalToken = secondsToLive === -1;
|
|
const isValid = isEternalToken ?
|
|
eternalTokensAllowed :
|
|
elapsedSeconds < secondsToLive;
|
|
|
|
if (isValid) {
|
|
process.nextTick(function() {
|
|
cb(null, isValid);
|
|
});
|
|
} else {
|
|
this.destroy(function(err) {
|
|
cb(err, isValid);
|
|
});
|
|
}
|
|
} catch (e) {
|
|
process.nextTick(function() {
|
|
cb(e);
|
|
});
|
|
}
|
|
};
|
|
};
|