572 lines
16 KiB
JavaScript
572 lines
16 KiB
JavaScript
'use strict';
|
|
|
|
const Boom = require('@hapi/boom');
|
|
const Bounce = require('@hapi/bounce');
|
|
const Hoek = require('@hapi/hoek');
|
|
|
|
const Config = require('./config');
|
|
const Request = require('./request');
|
|
|
|
|
|
const internals = {
|
|
missing: Symbol('missing')
|
|
};
|
|
|
|
|
|
exports = module.exports = internals.Auth = class {
|
|
|
|
#core = null;
|
|
#schemes = {};
|
|
#strategies = {};
|
|
|
|
api = {}; // Do not reassign api or settings, as they are referenced in public()
|
|
settings = {
|
|
default: null // Strategy used as default if route has no auth settings
|
|
};
|
|
|
|
constructor(core) {
|
|
|
|
this.#core = core;
|
|
}
|
|
|
|
public(server) {
|
|
|
|
return {
|
|
api: this.api,
|
|
settings: this.settings,
|
|
scheme: this.scheme.bind(this),
|
|
strategy: this._strategy.bind(this, server),
|
|
default: this.default.bind(this),
|
|
test: this.test.bind(this),
|
|
verify: this.verify.bind(this),
|
|
lookup: this.lookup.bind(this)
|
|
};
|
|
}
|
|
|
|
scheme(name, scheme) {
|
|
|
|
Hoek.assert(name, 'Authentication scheme must have a name');
|
|
Hoek.assert(!this.#schemes[name], 'Authentication scheme name already exists:', name);
|
|
Hoek.assert(typeof scheme === 'function', 'scheme must be a function:', name);
|
|
|
|
this.#schemes[name] = scheme;
|
|
}
|
|
|
|
_strategy(server, name, scheme, options = {}) {
|
|
|
|
Hoek.assert(name, 'Authentication strategy must have a name');
|
|
Hoek.assert(typeof options === 'object', 'options must be an object');
|
|
Hoek.assert(!this.#strategies[name], 'Authentication strategy name already exists');
|
|
Hoek.assert(scheme, 'Authentication strategy', name, 'missing scheme');
|
|
Hoek.assert(this.#schemes[scheme], 'Authentication strategy', name, 'uses unknown scheme:', scheme);
|
|
|
|
server = server._clone();
|
|
const strategy = this.#schemes[scheme](server, options);
|
|
|
|
Hoek.assert(strategy.authenticate, 'Invalid scheme:', name, 'missing authenticate() method');
|
|
Hoek.assert(typeof strategy.authenticate === 'function', 'Invalid scheme:', name, 'invalid authenticate() method');
|
|
Hoek.assert(!strategy.payload || typeof strategy.payload === 'function', 'Invalid scheme:', name, 'invalid payload() method');
|
|
Hoek.assert(!strategy.response || typeof strategy.response === 'function', 'Invalid scheme:', name, 'invalid response() method');
|
|
strategy.options = strategy.options ?? {};
|
|
Hoek.assert(strategy.payload || !strategy.options.payload, 'Cannot require payload validation without a payload method');
|
|
|
|
this.#strategies[name] = {
|
|
methods: strategy,
|
|
realm: server.realm
|
|
};
|
|
|
|
if (strategy.api) {
|
|
this.api[name] = strategy.api;
|
|
}
|
|
}
|
|
|
|
default(options) {
|
|
|
|
Hoek.assert(!this.settings.default, 'Cannot set default strategy more than once');
|
|
options = Config.apply('auth', options, 'default strategy');
|
|
|
|
this.settings.default = this._setupRoute(Hoek.clone(options)); // Prevent changes to options
|
|
|
|
const routes = this.#core.router.table();
|
|
for (const route of routes) {
|
|
route.rebuild();
|
|
}
|
|
}
|
|
|
|
async test(name, request) {
|
|
|
|
Hoek.assert(name, 'Missing authentication strategy name');
|
|
const strategy = this.#strategies[name];
|
|
Hoek.assert(strategy, 'Unknown authentication strategy:', name);
|
|
|
|
const bind = strategy.methods;
|
|
const realm = strategy.realm;
|
|
const response = await request._core.toolkit.execute(strategy.methods.authenticate, request, { bind, realm, auth: true });
|
|
|
|
if (!response.isAuth) {
|
|
throw response;
|
|
}
|
|
|
|
if (response.error) {
|
|
throw response.error;
|
|
}
|
|
|
|
return response.data;
|
|
}
|
|
|
|
async verify(request) {
|
|
|
|
const auth = request.auth;
|
|
|
|
if (auth.error) {
|
|
throw auth.error;
|
|
}
|
|
|
|
if (!auth.isAuthenticated) {
|
|
return;
|
|
}
|
|
|
|
const strategy = this.#strategies[auth.strategy];
|
|
Hoek.assert(strategy, 'Unknown authentication strategy:', auth.strategy);
|
|
|
|
if (!strategy.methods.verify) {
|
|
return;
|
|
}
|
|
|
|
const bind = strategy.methods;
|
|
await strategy.methods.verify.call(bind, auth);
|
|
}
|
|
|
|
static testAccess(request, route) {
|
|
|
|
const auth = request._core.auth;
|
|
|
|
try {
|
|
return auth._access(request, route);
|
|
}
|
|
catch (err) {
|
|
Bounce.rethrow(err, 'system');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
_setupRoute(options, path) {
|
|
|
|
if (!options) {
|
|
return options; // Preserve the difference between undefined and false
|
|
}
|
|
|
|
if (typeof options === 'string') {
|
|
options = { strategies: [options] };
|
|
}
|
|
else if (options.strategy) {
|
|
options.strategies = [options.strategy];
|
|
delete options.strategy;
|
|
}
|
|
|
|
if (path &&
|
|
!options.strategies) {
|
|
|
|
Hoek.assert(this.settings.default, 'Route missing authentication strategy and no default defined:', path);
|
|
options = Hoek.applyToDefaults(this.settings.default, options);
|
|
}
|
|
|
|
path = path ?? 'default strategy';
|
|
Hoek.assert(options.strategies?.length, 'Missing authentication strategy:', path);
|
|
|
|
options.mode = options.mode ?? 'required';
|
|
|
|
if (options.entity !== undefined || // Backwards compatibility with <= 11.x.x
|
|
options.scope !== undefined) {
|
|
|
|
options.access = [{ entity: options.entity, scope: options.scope }];
|
|
delete options.entity;
|
|
delete options.scope;
|
|
}
|
|
|
|
if (options.access) {
|
|
for (const access of options.access) {
|
|
access.scope = internals.setupScope(access);
|
|
}
|
|
}
|
|
|
|
if (options.payload === true) {
|
|
options.payload = 'required';
|
|
}
|
|
|
|
let hasAuthenticatePayload = false;
|
|
for (const name of options.strategies) {
|
|
const strategy = this.#strategies[name];
|
|
Hoek.assert(strategy, 'Unknown authentication strategy', name, 'in', path);
|
|
|
|
Hoek.assert(strategy.methods.payload || options.payload !== 'required', 'Payload validation can only be required when all strategies support it in', path);
|
|
hasAuthenticatePayload = hasAuthenticatePayload || strategy.methods.payload;
|
|
Hoek.assert(!strategy.methods.options.payload || options.payload === undefined || options.payload === 'required', 'Cannot set authentication payload to', options.payload, 'when a strategy requires payload validation in', path);
|
|
}
|
|
|
|
Hoek.assert(!options.payload || hasAuthenticatePayload, 'Payload authentication requires at least one strategy with payload support in', path);
|
|
|
|
return options;
|
|
}
|
|
|
|
lookup(route) {
|
|
|
|
if (route.settings.auth === false) {
|
|
return false;
|
|
}
|
|
|
|
return route.settings.auth || this.settings.default;
|
|
}
|
|
|
|
_enabled(route, type) {
|
|
|
|
const config = this.lookup(route);
|
|
if (!config) {
|
|
return false;
|
|
}
|
|
|
|
if (type === 'authenticate') {
|
|
return true;
|
|
}
|
|
|
|
if (type === 'access') {
|
|
return !!config.access;
|
|
}
|
|
|
|
for (const name of config.strategies) {
|
|
const strategy = this.#strategies[name];
|
|
if (strategy.methods[type]) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
static authenticate(request) {
|
|
|
|
const auth = request._core.auth;
|
|
return auth._authenticate(request);
|
|
}
|
|
|
|
async _authenticate(request) {
|
|
|
|
const config = this.lookup(request.route);
|
|
|
|
const errors = [];
|
|
request.auth.mode = config.mode;
|
|
|
|
// Injection bypass
|
|
|
|
if (request.auth.credentials) {
|
|
internals.validate(null, { credentials: request.auth.credentials, artifacts: request.auth.artifacts }, request.auth.strategy, config, request, errors);
|
|
return;
|
|
}
|
|
|
|
// Try each strategy
|
|
|
|
for (const name of config.strategies) {
|
|
const strategy = this.#strategies[name];
|
|
|
|
const bind = strategy.methods;
|
|
const realm = strategy.realm;
|
|
const response = await request._core.toolkit.execute(strategy.methods.authenticate, request, { bind, realm, auth: true });
|
|
|
|
const message = (response.isAuth ? internals.validate(response.error, response.data, name, config, request, errors) : internals.validate(response, null, name, config, request, errors));
|
|
if (!message) {
|
|
return;
|
|
}
|
|
|
|
if (message !== internals.missing) {
|
|
return message;
|
|
}
|
|
}
|
|
|
|
// No more strategies
|
|
|
|
const err = Boom.unauthorized('Missing authentication', errors);
|
|
if (config.mode === 'required') {
|
|
throw err;
|
|
}
|
|
|
|
request.auth.isAuthenticated = false;
|
|
request.auth.credentials = null;
|
|
request.auth.error = err;
|
|
request._log(['auth', 'unauthenticated']);
|
|
}
|
|
|
|
static access(request) {
|
|
|
|
const auth = request._core.auth;
|
|
request.auth.isAuthorized = auth._access(request);
|
|
}
|
|
|
|
_access(request, route) {
|
|
|
|
const config = this.lookup(route || request.route);
|
|
if (!config?.access) {
|
|
return true;
|
|
}
|
|
|
|
const credentials = request.auth.credentials;
|
|
if (!credentials) {
|
|
if (config.mode !== 'required') {
|
|
return false;
|
|
}
|
|
|
|
throw Boom.forbidden('Request is unauthenticated');
|
|
}
|
|
|
|
const requestEntity = (credentials.user ? 'user' : 'app');
|
|
|
|
const scopeErrors = [];
|
|
for (const access of config.access) {
|
|
|
|
// Check entity
|
|
|
|
const entity = access.entity;
|
|
if (entity &&
|
|
entity !== 'any' &&
|
|
entity !== requestEntity) {
|
|
|
|
continue;
|
|
}
|
|
|
|
// Check scope
|
|
|
|
let scope = access.scope;
|
|
if (scope) {
|
|
if (!credentials.scope) {
|
|
scopeErrors.push(scope);
|
|
continue;
|
|
}
|
|
|
|
scope = internals.expandScope(request, scope);
|
|
if (!internals.validateScope(credentials, scope, 'required') ||
|
|
!internals.validateScope(credentials, scope, 'selection') ||
|
|
!internals.validateScope(credentials, scope, 'forbidden')) {
|
|
|
|
scopeErrors.push(scope);
|
|
continue;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
// Scope error
|
|
|
|
if (scopeErrors.length) {
|
|
request._log(['auth', 'scope', 'error']);
|
|
throw Boom.forbidden('Insufficient scope', { got: credentials.scope, need: scopeErrors });
|
|
}
|
|
|
|
// Entity error
|
|
|
|
if (requestEntity === 'app') {
|
|
request._log(['auth', 'entity', 'user', 'error']);
|
|
throw Boom.forbidden('Application credentials cannot be used on a user endpoint');
|
|
}
|
|
|
|
request._log(['auth', 'entity', 'app', 'error']);
|
|
throw Boom.forbidden('User credentials cannot be used on an application endpoint');
|
|
}
|
|
|
|
static async payload(request) {
|
|
|
|
if (!request.auth.isAuthenticated || !request.auth[Request.symbols.authPayload]) {
|
|
return;
|
|
}
|
|
|
|
const auth = request._core.auth;
|
|
const strategy = auth.#strategies[request.auth.strategy];
|
|
Hoek.assert(strategy, 'Unknown authentication strategy:', request.auth.strategy);
|
|
|
|
if (!strategy.methods.payload) {
|
|
return;
|
|
}
|
|
|
|
const config = auth.lookup(request.route);
|
|
const setting = config.payload ?? (strategy.methods.options.payload ? 'required' : false);
|
|
if (!setting) {
|
|
return;
|
|
}
|
|
|
|
const bind = strategy.methods;
|
|
const realm = strategy.realm;
|
|
const response = await request._core.toolkit.execute(strategy.methods.payload, request, { bind, realm });
|
|
|
|
if (response.isBoom &&
|
|
response.isMissing) {
|
|
|
|
return setting === 'optional' ? undefined : Boom.unauthorized('Missing payload authentication');
|
|
}
|
|
|
|
return response;
|
|
}
|
|
|
|
static async response(response) {
|
|
|
|
const request = response.request;
|
|
const auth = request._core.auth;
|
|
if (!request.auth.isAuthenticated) {
|
|
return;
|
|
}
|
|
|
|
const strategy = auth.#strategies[request.auth.strategy];
|
|
Hoek.assert(strategy, 'Unknown authentication strategy:', request.auth.strategy);
|
|
|
|
if (!strategy.methods.response) {
|
|
return;
|
|
}
|
|
|
|
const bind = strategy.methods;
|
|
const realm = strategy.realm;
|
|
const error = await request._core.toolkit.execute(strategy.methods.response, request, { bind, realm, continue: 'undefined' });
|
|
if (error) {
|
|
throw error;
|
|
}
|
|
}
|
|
};
|
|
|
|
|
|
internals.setupScope = function (access) {
|
|
|
|
// No scopes
|
|
|
|
if (!access.scope) {
|
|
return false;
|
|
}
|
|
|
|
// Already setup
|
|
|
|
if (!Array.isArray(access.scope)) {
|
|
return access.scope;
|
|
}
|
|
|
|
const scope = {};
|
|
for (const value of access.scope) {
|
|
const prefix = value[0];
|
|
const type = prefix === '+' ? 'required' : (prefix === '!' ? 'forbidden' : 'selection');
|
|
const clean = type === 'selection' ? value : value.slice(1);
|
|
scope[type] = scope[type] ?? [];
|
|
scope[type].push(clean);
|
|
|
|
if ((!scope._hasParameters?.[type]) &&
|
|
/{([^}]+)}/.test(clean)) {
|
|
|
|
scope._hasParameters = scope._hasParameters ?? {};
|
|
scope._hasParameters[type] = true;
|
|
}
|
|
}
|
|
|
|
return scope;
|
|
};
|
|
|
|
|
|
internals.validate = function (err, result, name, config, request, errors) { // err can be Boom, Error, or a valid response object
|
|
|
|
result = result ?? {};
|
|
request.auth.isAuthenticated = !err;
|
|
|
|
if (err) {
|
|
|
|
// Non-error response
|
|
|
|
if (err instanceof Error === false) {
|
|
request._log(['auth', 'unauthenticated', 'response', name], { statusCode: err.statusCode });
|
|
return err;
|
|
}
|
|
|
|
// Missing authenticated
|
|
|
|
if (err.isMissing) {
|
|
request._log(['auth', 'unauthenticated', 'missing', name], err);
|
|
errors.push(err.output.headers['WWW-Authenticate']);
|
|
return internals.missing;
|
|
}
|
|
}
|
|
|
|
request.auth.strategy = name;
|
|
request.auth.credentials = result.credentials;
|
|
request.auth.artifacts = result.artifacts;
|
|
|
|
// Authenticated
|
|
|
|
if (!err) {
|
|
return;
|
|
}
|
|
|
|
// Unauthenticated
|
|
|
|
request.auth.error = err;
|
|
|
|
if (config.mode === 'try') {
|
|
request._log(['auth', 'unauthenticated', 'try', name], err);
|
|
return;
|
|
}
|
|
|
|
request._log(['auth', 'unauthenticated', 'error', name], err);
|
|
throw err;
|
|
};
|
|
|
|
|
|
internals.expandScope = function (request, scope) {
|
|
|
|
if (!scope._hasParameters) {
|
|
return scope;
|
|
}
|
|
|
|
const expanded = {
|
|
required: internals.expandScopeType(request, scope, 'required'),
|
|
selection: internals.expandScopeType(request, scope, 'selection'),
|
|
forbidden: internals.expandScopeType(request, scope, 'forbidden')
|
|
};
|
|
|
|
return expanded;
|
|
};
|
|
|
|
|
|
internals.expandScopeType = function (request, scope, type) {
|
|
|
|
if (!scope._hasParameters[type]) {
|
|
return scope[type];
|
|
}
|
|
|
|
const expanded = [];
|
|
const context = {
|
|
params: request.params,
|
|
query: request.query,
|
|
payload: request.payload,
|
|
credentials: request.auth.credentials
|
|
};
|
|
|
|
for (const template of scope[type]) {
|
|
expanded.push(Hoek.reachTemplate(context, template));
|
|
}
|
|
|
|
return expanded;
|
|
};
|
|
|
|
|
|
internals.validateScope = function (credentials, scope, type) {
|
|
|
|
if (!scope[type]) {
|
|
return true;
|
|
}
|
|
|
|
const count = typeof credentials.scope === 'string' ?
|
|
scope[type].indexOf(credentials.scope) !== -1 ? 1 : 0 :
|
|
Hoek.intersect(scope[type], credentials.scope).length;
|
|
|
|
if (type === 'forbidden') {
|
|
return count === 0;
|
|
}
|
|
|
|
if (type === 'required') {
|
|
return count === scope.required.length;
|
|
}
|
|
|
|
return !!count;
|
|
};
|