mirror of
https://github.com/optim-enterprises-bv/vault.git
synced 2025-11-02 11:38:02 +00:00
* Adding explicit MPL license for sub-package. This directory and its subdirectories (packages) contain files licensed with the MPLv2 `LICENSE` file in this directory and are intentionally licensed separately from the BSL `LICENSE` file at the root of this repository. * Adding explicit MPL license for sub-package. This directory and its subdirectories (packages) contain files licensed with the MPLv2 `LICENSE` file in this directory and are intentionally licensed separately from the BSL `LICENSE` file at the root of this repository. * Updating the license from MPL to Business Source License. Going forward, this project will be licensed under the Business Source License v1.1. Please see our blog post for more details at https://hashi.co/bsl-blog, FAQ at www.hashicorp.com/licensing-faq, and details of the license at www.hashicorp.com/bsl. * add missing license headers * Update copyright file headers to BUS-1.1 * Fix test that expected exact offset on hcl file --------- Co-authored-by: hashicorp-copywrite[bot] <110428419+hashicorp-copywrite[bot]@users.noreply.github.com> Co-authored-by: Sarah Thompson <sthompson@hashicorp.com> Co-authored-by: Brian Kassouf <bkassouf@hashicorp.com>
497 lines
14 KiB
JavaScript
497 lines
14 KiB
JavaScript
/**
|
|
* Copyright (c) HashiCorp, Inc.
|
|
* SPDX-License-Identifier: BUSL-1.1
|
|
*/
|
|
|
|
import Ember from 'ember';
|
|
import { task, timeout } from 'ember-concurrency';
|
|
import { getOwner } from '@ember/application';
|
|
import { isArray } from '@ember/array';
|
|
import { computed, get } from '@ember/object';
|
|
import { alias } from '@ember/object/computed';
|
|
import { assign } from '@ember/polyfills';
|
|
import Service, { inject as service } from '@ember/service';
|
|
import { capitalize } from '@ember/string';
|
|
import fetch from 'fetch';
|
|
import { resolve, reject } from 'rsvp';
|
|
|
|
import getStorage from 'vault/lib/token-storage';
|
|
import ENV from 'vault/config/environment';
|
|
import { supportedAuthBackends } from 'vault/helpers/supported-auth-backends';
|
|
|
|
const TOKEN_SEPARATOR = '☃';
|
|
const TOKEN_PREFIX = 'vault-';
|
|
const ROOT_PREFIX = '_root_';
|
|
const BACKENDS = supportedAuthBackends();
|
|
|
|
export { TOKEN_SEPARATOR, TOKEN_PREFIX, ROOT_PREFIX };
|
|
|
|
export default Service.extend({
|
|
permissions: service(),
|
|
currentCluster: service(),
|
|
router: service(),
|
|
namespaceService: service('namespace'),
|
|
|
|
IDLE_TIMEOUT: 3 * 60e3,
|
|
expirationCalcTS: null,
|
|
isRenewing: false,
|
|
mfaErrors: null,
|
|
|
|
get tokenExpired() {
|
|
const expiration = this.tokenExpirationDate;
|
|
return expiration ? this.now() >= expiration : null;
|
|
},
|
|
|
|
activeCluster: alias('currentCluster.cluster'),
|
|
|
|
// eslint-disable-next-line
|
|
tokens: computed({
|
|
get() {
|
|
return this._tokens || this.getTokensFromStorage() || [];
|
|
},
|
|
set(key, value) {
|
|
return (this._tokens = value);
|
|
},
|
|
}),
|
|
|
|
isActiveSession: computed(
|
|
'router.currentRouteName',
|
|
'currentToken',
|
|
'activeCluster.{dr.isSecondary,needsInit,sealed,name}',
|
|
function () {
|
|
if (this.activeCluster) {
|
|
if (this.activeCluster.dr?.isSecondary || this.activeCluster.needsInit || this.activeCluster.sealed) {
|
|
return false;
|
|
}
|
|
if (
|
|
this.activeCluster.name &&
|
|
this.currentToken &&
|
|
this.router.currentRouteName !== 'vault.cluster.auth'
|
|
) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
),
|
|
|
|
tokenExpirationDate: computed('currentTokenName', 'expirationCalcTS', function () {
|
|
const tokenName = this.currentTokenName;
|
|
if (!tokenName) {
|
|
return;
|
|
}
|
|
const { tokenExpirationEpoch } = this.getTokenData(tokenName);
|
|
const expirationDate = new Date(0);
|
|
return tokenExpirationEpoch ? expirationDate.setUTCMilliseconds(tokenExpirationEpoch) : null;
|
|
}),
|
|
|
|
renewAfterEpoch: computed('currentTokenName', 'expirationCalcTS', function () {
|
|
const tokenName = this.currentTokenName;
|
|
const { expirationCalcTS } = this;
|
|
const data = this.getTokenData(tokenName);
|
|
if (!tokenName || !data || !expirationCalcTS) {
|
|
return null;
|
|
}
|
|
const { ttl, renewable } = data;
|
|
// renew after last expirationCalc time + half of the ttl (in ms)
|
|
return renewable ? Math.floor((ttl * 1e3) / 2) + expirationCalcTS : null;
|
|
}),
|
|
|
|
// returns the key for the token to use
|
|
currentTokenName: computed('activeClusterId', 'tokens', 'tokens.[]', function () {
|
|
const regex = new RegExp(this.activeClusterId);
|
|
return this.tokens.find((key) => regex.test(key));
|
|
}),
|
|
|
|
currentToken: computed('currentTokenName', function () {
|
|
const name = this.currentTokenName;
|
|
const data = name && this.getTokenData(name);
|
|
// data.token is undefined so that's why it returns current token undefined
|
|
return name && data ? data.token : null;
|
|
}),
|
|
|
|
authData: computed('currentTokenName', function () {
|
|
const token = this.currentTokenName;
|
|
if (!token) {
|
|
return;
|
|
}
|
|
const backend = this.backendFromTokenName(token);
|
|
const stored = this.getTokenData(token);
|
|
|
|
return assign(stored, {
|
|
backend: BACKENDS.findBy('type', backend),
|
|
});
|
|
}),
|
|
|
|
init() {
|
|
this._super(...arguments);
|
|
this.checkForRootToken();
|
|
},
|
|
|
|
clusterAdapter() {
|
|
return getOwner(this).lookup('adapter:cluster');
|
|
},
|
|
|
|
generateTokenName({ backend, clusterId }, policies) {
|
|
return (policies || []).includes('root')
|
|
? `${TOKEN_PREFIX}${ROOT_PREFIX}${TOKEN_SEPARATOR}${clusterId}`
|
|
: `${TOKEN_PREFIX}${backend}${TOKEN_SEPARATOR}${clusterId}`;
|
|
},
|
|
|
|
backendFromTokenName(tokenName) {
|
|
return tokenName.includes(`${TOKEN_PREFIX}${ROOT_PREFIX}`)
|
|
? 'token'
|
|
: tokenName.slice(TOKEN_PREFIX.length).split(TOKEN_SEPARATOR)[0];
|
|
},
|
|
|
|
storage(tokenName) {
|
|
if (
|
|
tokenName &&
|
|
tokenName.indexOf(`${TOKEN_PREFIX}${ROOT_PREFIX}`) === 0 &&
|
|
this.environment() !== 'development'
|
|
) {
|
|
return getStorage('memory');
|
|
} else {
|
|
return getStorage();
|
|
}
|
|
},
|
|
|
|
environment() {
|
|
return ENV.environment;
|
|
},
|
|
|
|
now() {
|
|
return Date.now();
|
|
},
|
|
|
|
setCluster(clusterId) {
|
|
this.set('activeClusterId', clusterId);
|
|
},
|
|
|
|
ajax(url, method, options) {
|
|
const defaults = {
|
|
url,
|
|
method,
|
|
dataType: 'json',
|
|
headers: {
|
|
'X-Vault-Token': this.currentToken,
|
|
},
|
|
};
|
|
|
|
const namespace =
|
|
typeof options.namespace === 'undefined' ? this.namespaceService.path : options.namespace;
|
|
if (namespace) {
|
|
defaults.headers['X-Vault-Namespace'] = namespace;
|
|
}
|
|
const opts = assign(defaults, options);
|
|
|
|
return fetch(url, {
|
|
method: opts.method || 'GET',
|
|
headers: opts.headers || {},
|
|
}).then((response) => {
|
|
if (response.status === 204) {
|
|
return resolve();
|
|
} else if (response.status >= 200 && response.status < 300) {
|
|
return resolve(response.json());
|
|
} else {
|
|
return reject(response);
|
|
}
|
|
});
|
|
},
|
|
|
|
renewCurrentToken() {
|
|
const namespace = this.authData.userRootNamespace;
|
|
const url = '/v1/auth/token/renew-self';
|
|
return this.ajax(url, 'POST', { namespace });
|
|
},
|
|
|
|
revokeCurrentToken() {
|
|
const namespace = this.authData.userRootNamespace;
|
|
const url = '/v1/auth/token/revoke-self';
|
|
return this.ajax(url, 'POST', { namespace });
|
|
},
|
|
|
|
calculateExpiration(resp) {
|
|
const now = this.now();
|
|
const ttl = resp.ttl || resp.lease_duration;
|
|
const tokenExpirationEpoch = now + ttl * 1e3;
|
|
this.set('expirationCalcTS', now);
|
|
return {
|
|
ttl,
|
|
tokenExpirationEpoch,
|
|
};
|
|
},
|
|
|
|
persistAuthData() {
|
|
const [firstArg, resp] = arguments;
|
|
const tokens = this.tokens;
|
|
const currentNamespace = this.namespaceService.path || '';
|
|
let tokenName;
|
|
let options;
|
|
let backend;
|
|
if (typeof firstArg === 'string') {
|
|
tokenName = firstArg;
|
|
backend = this.backendFromTokenName(tokenName);
|
|
} else {
|
|
options = firstArg;
|
|
backend = options.backend;
|
|
}
|
|
|
|
const currentBackend = BACKENDS.findBy('type', backend);
|
|
let displayName;
|
|
if (isArray(currentBackend.displayNamePath)) {
|
|
displayName = currentBackend.displayNamePath.map((name) => get(resp, name)).join('/');
|
|
} else {
|
|
displayName = get(resp, currentBackend.displayNamePath);
|
|
}
|
|
|
|
const { entity_id, policies, renewable, namespace_path } = resp;
|
|
// here we prefer namespace_path if its defined,
|
|
// else we look and see if there's already a namespace saved
|
|
// and then finally we'll use the current query param if the others
|
|
// haven't set a value yet
|
|
// all of the typeof checks are necessary because the root namespace is ''
|
|
let userRootNamespace = namespace_path && namespace_path.replace(/\/$/, '');
|
|
// if we're logging in with token and there's no namespace_path, we can assume
|
|
// that the token belongs to the root namespace
|
|
if (backend === 'token' && !userRootNamespace) {
|
|
userRootNamespace = '';
|
|
}
|
|
if (typeof userRootNamespace === 'undefined') {
|
|
if (this.authData) {
|
|
userRootNamespace = this.authData.userRootNamespace;
|
|
}
|
|
}
|
|
if (typeof userRootNamespace === 'undefined') {
|
|
userRootNamespace = currentNamespace;
|
|
}
|
|
const data = {
|
|
userRootNamespace,
|
|
displayName,
|
|
backend: currentBackend,
|
|
token: resp.client_token || get(resp, currentBackend.tokenPath),
|
|
policies,
|
|
renewable,
|
|
entity_id,
|
|
};
|
|
|
|
tokenName = this.generateTokenName(
|
|
{
|
|
backend,
|
|
clusterId: (options && options.clusterId) || this.activeClusterId,
|
|
},
|
|
resp.policies
|
|
);
|
|
|
|
if (resp.renewable) {
|
|
assign(data, this.calculateExpiration(resp));
|
|
}
|
|
|
|
if (!data.displayName) {
|
|
data.displayName = (this.getTokenData(tokenName) || {}).displayName;
|
|
}
|
|
tokens.addObject(tokenName);
|
|
this.set('tokens', tokens);
|
|
this.set('allowExpiration', false);
|
|
this.setTokenData(tokenName, data);
|
|
return resolve({
|
|
namespace: currentNamespace || data.userRootNamespace,
|
|
token: tokenName,
|
|
isRoot: policies.includes('root'),
|
|
});
|
|
},
|
|
|
|
setTokenData(token, data) {
|
|
this.storage(token).setItem(token, data);
|
|
},
|
|
|
|
getTokenData(token) {
|
|
return this.storage(token).getItem(token);
|
|
},
|
|
|
|
removeTokenData(token) {
|
|
return this.storage(token).removeItem(token);
|
|
},
|
|
|
|
renew() {
|
|
const tokenName = this.currentTokenName;
|
|
const currentlyRenewing = this.isRenewing;
|
|
if (currentlyRenewing) {
|
|
return;
|
|
}
|
|
this.isRenewing = true;
|
|
return this.renewCurrentToken().then(
|
|
(resp) => {
|
|
this.isRenewing = false;
|
|
return this.persistAuthData(tokenName, resp.data || resp.auth);
|
|
},
|
|
(e) => {
|
|
this.isRenewing = false;
|
|
throw e;
|
|
}
|
|
);
|
|
},
|
|
|
|
checkShouldRenew: task(function* () {
|
|
while (true) {
|
|
if (Ember.testing) {
|
|
return;
|
|
}
|
|
yield timeout(5000);
|
|
if (this.shouldRenew()) {
|
|
yield this.renew();
|
|
}
|
|
}
|
|
}).on('init'),
|
|
shouldRenew() {
|
|
const now = this.now();
|
|
const lastFetch = this.lastFetch;
|
|
const renewTime = this.renewAfterEpoch;
|
|
if (!this.currentTokenName || this.tokenExpired || this.allowExpiration || !renewTime) {
|
|
return false;
|
|
}
|
|
if (lastFetch && now - lastFetch >= this.IDLE_TIMEOUT) {
|
|
this.set('allowExpiration', true);
|
|
return false;
|
|
}
|
|
if (now >= renewTime) {
|
|
return true;
|
|
}
|
|
return false;
|
|
},
|
|
|
|
setLastFetch(timestamp) {
|
|
const now = this.now();
|
|
this.set('lastFetch', timestamp);
|
|
// if expiration was allowed and we're over half the ttl we want to go ahead and renew here
|
|
if (this.allowExpiration && now >= this.renewAfterEpoch) {
|
|
this.renew();
|
|
}
|
|
this.set('allowExpiration', false);
|
|
},
|
|
|
|
getTokensFromStorage(filterFn) {
|
|
return this.storage()
|
|
.keys()
|
|
.reject((key) => {
|
|
return key.indexOf(TOKEN_PREFIX) !== 0 || (filterFn && filterFn(key));
|
|
});
|
|
},
|
|
|
|
checkForRootToken() {
|
|
if (this.environment() === 'development') {
|
|
return;
|
|
}
|
|
|
|
this.getTokensFromStorage().forEach((key) => {
|
|
const data = this.getTokenData(key);
|
|
if (data && data.policies && data.policies.includes('root')) {
|
|
this.removeTokenData(key);
|
|
}
|
|
});
|
|
},
|
|
|
|
_parseMfaResponse(mfa_requirement) {
|
|
// mfa_requirement response comes back in a shape that is not easy to work with
|
|
// convert to array of objects and add necessary properties to satisfy the view
|
|
if (mfa_requirement) {
|
|
const { mfa_request_id, mfa_constraints } = mfa_requirement;
|
|
const constraints = [];
|
|
for (const key in mfa_constraints) {
|
|
const methods = mfa_constraints[key].any;
|
|
const isMulti = methods.length > 1;
|
|
|
|
// friendly label for display in MfaForm
|
|
methods.forEach((m) => {
|
|
const typeFormatted = m.type === 'totp' ? m.type.toUpperCase() : capitalize(m.type);
|
|
m.label = `${typeFormatted} ${m.uses_passcode ? 'passcode' : 'push notification'}`;
|
|
});
|
|
constraints.push({
|
|
name: key,
|
|
methods,
|
|
selectedMethod: isMulti ? null : methods[0],
|
|
});
|
|
}
|
|
|
|
return {
|
|
mfa_requirement: { mfa_request_id, mfa_constraints: constraints },
|
|
};
|
|
}
|
|
return {};
|
|
},
|
|
|
|
async authenticate(/*{clusterId, backend, data, selectedAuth}*/) {
|
|
const [options] = arguments;
|
|
const adapter = this.clusterAdapter();
|
|
const resp = await adapter.authenticate(options);
|
|
|
|
if (resp.auth?.mfa_requirement) {
|
|
return this._parseMfaResponse(resp.auth?.mfa_requirement);
|
|
}
|
|
|
|
return this.authSuccess(options, resp.auth || resp.data);
|
|
},
|
|
|
|
async totpValidate({ mfa_requirement, ...options }) {
|
|
const resp = await this.clusterAdapter().mfaValidate(mfa_requirement);
|
|
return this.authSuccess(options, resp.auth || resp.data);
|
|
},
|
|
|
|
async authSuccess(options, response) {
|
|
// persist selectedAuth to localStorage to rehydrate auth form on logout
|
|
localStorage.setItem('selectedAuth', options.selectedAuth);
|
|
const authData = await this.persistAuthData(options, response, this.namespaceService.path);
|
|
await this.permissions.getPaths.perform();
|
|
return authData;
|
|
},
|
|
|
|
handleError(e) {
|
|
if (e.errors) {
|
|
return e.errors.map((error) => {
|
|
if (error.detail) {
|
|
return error.detail;
|
|
}
|
|
return error;
|
|
});
|
|
}
|
|
return [e];
|
|
},
|
|
|
|
getAuthType() {
|
|
// check localStorage first
|
|
const selectedAuth = localStorage.getItem('selectedAuth');
|
|
if (selectedAuth) return selectedAuth;
|
|
// fallback to authData which discerns backend type from token
|
|
return this.authData ? this.authData.backend.type : null;
|
|
},
|
|
|
|
deleteCurrentToken() {
|
|
const tokenName = this.currentTokenName;
|
|
this.deleteToken(tokenName);
|
|
this.removeTokenData(tokenName);
|
|
},
|
|
|
|
deleteToken(tokenName) {
|
|
const tokenNames = this.tokens.without(tokenName);
|
|
this.removeTokenData(tokenName);
|
|
this.set('tokens', tokenNames);
|
|
},
|
|
|
|
getOktaNumberChallengeAnswer(nonce, mount) {
|
|
const url = `/v1/auth/${mount}/verify/${nonce}`;
|
|
return this.ajax(url, 'GET', {}).then(
|
|
(resp) => {
|
|
return resp.data.correct_answer;
|
|
},
|
|
(e) => {
|
|
// if error status is 404, return and keep polling for a response
|
|
if (e.status === 404) {
|
|
return null;
|
|
} else {
|
|
throw e;
|
|
}
|
|
}
|
|
);
|
|
},
|
|
});
|