mirror of
https://github.com/optim-enterprises-bv/vault.git
synced 2025-10-29 17:52:32 +00:00
UI: Allow userpass user to update their own password (#23797)
This commit is contained in:
3
changelog/23797.txt
Normal file
3
changelog/23797.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
```release-note:improvement
|
||||||
|
ui: Allow users in userpass auth mount to update their own password
|
||||||
|
```
|
||||||
@@ -78,4 +78,10 @@ export default ApplicationAdapter.extend({
|
|||||||
const url = `${this.buildURL()}/${this.pathForType()}/${encodePath(path)}tune`;
|
const url = `${this.buildURL()}/${this.pathForType()}/${encodePath(path)}tune`;
|
||||||
return this.ajax(url, 'POST', { data });
|
return this.ajax(url, 'POST', { data });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
resetPassword(backend, username, password) {
|
||||||
|
// For userpass auth types only
|
||||||
|
const url = `/v1/auth/${encodePath(backend)}/users/${encodePath(username)}/password`;
|
||||||
|
return this.ajax(url, 'POST', { data: { password } });
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
45
ui/app/components/page/userpass-reset-password.hbs
Normal file
45
ui/app/components/page/userpass-reset-password.hbs
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<PageHeader as |p|>
|
||||||
|
<p.levelLeft>
|
||||||
|
<h1 data-test-title class="title is-3">
|
||||||
|
Reset password
|
||||||
|
</h1>
|
||||||
|
</p.levelLeft>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<form {{on "submit" (perform this.updatePassword) on="submit"}}>
|
||||||
|
<div class="box is-sideless is-fullwidth is-marginless">
|
||||||
|
<Hds::Alert @type="inline" class="has-bottom-margin-m" as |A|>
|
||||||
|
<A.Title>Current user</A.Title>
|
||||||
|
<A.Description data-test-current-user-banner>You are updating the password for
|
||||||
|
<strong>{{@username}}</strong>
|
||||||
|
on the
|
||||||
|
<strong>{{@backend}}</strong>
|
||||||
|
auth mount.</A.Description>
|
||||||
|
</Hds::Alert>
|
||||||
|
<FormFieldLabel for="reset-password" @label="New password" />
|
||||||
|
<MaskedInput
|
||||||
|
id="reset-password"
|
||||||
|
@name="reset-password"
|
||||||
|
@value={{this.newPassword}}
|
||||||
|
@onChange={{fn (mut this.newPassword)}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Hds::ButtonSet class="has-top-margin-m">
|
||||||
|
<Hds::Button
|
||||||
|
@text="Save"
|
||||||
|
@icon={{if this.updatePassword.isRunning "loading"}}
|
||||||
|
disabled={{this.updatePassword.isRunning}}
|
||||||
|
type="submit"
|
||||||
|
data-test-reset-password-save
|
||||||
|
/>
|
||||||
|
{{#if this.error}}
|
||||||
|
<Hds::Alert @type="compact" @color="critical" class="has-left-margin-s" data-test-reset-password-error as |A|>
|
||||||
|
<A.Title>Error</A.Title>
|
||||||
|
<A.Description>
|
||||||
|
{{this.error}}
|
||||||
|
</A.Description>
|
||||||
|
</Hds::Alert>
|
||||||
|
{{/if}}
|
||||||
|
</Hds::ButtonSet>
|
||||||
|
</form>
|
||||||
38
ui/app/components/page/userpass-reset-password.js
Normal file
38
ui/app/components/page/userpass-reset-password.js
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { service } from '@ember/service';
|
||||||
|
import Component from '@glimmer/component';
|
||||||
|
import { tracked } from '@glimmer/tracking';
|
||||||
|
import { task } from 'ember-concurrency';
|
||||||
|
import errorMessage from 'vault/utils/error-message';
|
||||||
|
|
||||||
|
export default class PageUserpassResetPasswordComponent extends Component {
|
||||||
|
@service store;
|
||||||
|
@service flashMessages;
|
||||||
|
|
||||||
|
@tracked newPassword = '';
|
||||||
|
@tracked error = '';
|
||||||
|
|
||||||
|
onSuccess() {
|
||||||
|
this.error = '';
|
||||||
|
this.newPassword = '';
|
||||||
|
this.flashMessages.success('Successfully reset password');
|
||||||
|
}
|
||||||
|
|
||||||
|
@task
|
||||||
|
*updatePassword(evt) {
|
||||||
|
evt.preventDefault();
|
||||||
|
this.error = '';
|
||||||
|
const adapter = this.store.adapterFor('auth-method');
|
||||||
|
const { backend, username } = this.args;
|
||||||
|
if (!backend || !username) return;
|
||||||
|
if (!this.newPassword) {
|
||||||
|
this.error = 'Please provide a new password.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
yield adapter.resetPassword(backend, username, this.newPassword);
|
||||||
|
this.onSuccess();
|
||||||
|
} catch (e) {
|
||||||
|
this.error = errorMessage(e, 'Check Vault logs for details');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,6 +39,13 @@
|
|||||||
</LinkTo>
|
</LinkTo>
|
||||||
</li>
|
</li>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
{{#if this.isUserpass}}
|
||||||
|
<li class="action">
|
||||||
|
<LinkTo @route="vault.cluster.access.reset-password" data-test-user-menu-item="reset-password">
|
||||||
|
Reset password
|
||||||
|
</LinkTo>
|
||||||
|
</li>
|
||||||
|
{{/if}}
|
||||||
<li class="action" id="container">
|
<li class="action" id="container">
|
||||||
{{! container is required in navbar collapsed state }}
|
{{! container is required in navbar collapsed state }}
|
||||||
<Hds::Copy::Button
|
<Hds::Copy::Button
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ export default class SidebarUserMenuComponent extends Component {
|
|||||||
// in order to use the MFA end user setup they need an entity_id
|
// in order to use the MFA end user setup they need an entity_id
|
||||||
return !!this.auth.authData?.entity_id;
|
return !!this.auth.authData?.entity_id;
|
||||||
}
|
}
|
||||||
|
get isUserpass() {
|
||||||
|
return this.auth.authData?.backend?.type === 'userpass';
|
||||||
|
}
|
||||||
|
|
||||||
get isRenewing() {
|
get isRenewing() {
|
||||||
return this.fakeRenew || this.auth.isRenewing;
|
return this.fakeRenew || this.auth.isRenewing;
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ Router.map(function () {
|
|||||||
this.mount('open-api-explorer', { path: '/api-explorer' });
|
this.mount('open-api-explorer', { path: '/api-explorer' });
|
||||||
});
|
});
|
||||||
this.route('access', function () {
|
this.route('access', function () {
|
||||||
|
this.route('reset-password');
|
||||||
this.route('methods', { path: '/' });
|
this.route('methods', { path: '/' });
|
||||||
this.route('method', { path: '/:path' }, function () {
|
this.route('method', { path: '/:path' }, function () {
|
||||||
this.route('index', { path: '/' });
|
this.route('index', { path: '/' });
|
||||||
|
|||||||
38
ui/app/routes/vault/cluster/access/reset-password.js
Normal file
38
ui/app/routes/vault/cluster/access/reset-password.js
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import Route from '@ember/routing/route';
|
||||||
|
import { service } from '@ember/service';
|
||||||
|
import { encodePath } from 'vault/utils/path-encoding-helpers';
|
||||||
|
|
||||||
|
const ERROR_UNAVAILABLE = 'Password reset is not available for your current auth mount.';
|
||||||
|
const ERROR_NO_ACCESS =
|
||||||
|
'You do not have permissions to update your password. If you think this is a mistake ask your administrator to update your policy.';
|
||||||
|
export default class VaultClusterAccessResetPasswordRoute extends Route {
|
||||||
|
@service auth;
|
||||||
|
@service store;
|
||||||
|
|
||||||
|
async model() {
|
||||||
|
// Password reset is only available on userpass type auth mounts
|
||||||
|
if (this.auth.authData?.backend?.type !== 'userpass') {
|
||||||
|
throw new Error(ERROR_UNAVAILABLE);
|
||||||
|
}
|
||||||
|
const { backend, displayName } = this.auth.authData;
|
||||||
|
if (!backend.mountPath || !displayName) {
|
||||||
|
throw new Error(ERROR_UNAVAILABLE);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const capabilities = await this.store.findRecord(
|
||||||
|
'capabilities',
|
||||||
|
`auth/${encodePath(backend.mountPath)}/users/${encodePath(displayName)}/password`
|
||||||
|
);
|
||||||
|
// Check that the user has ability to update password
|
||||||
|
if (!capabilities.canUpdate) {
|
||||||
|
throw new Error(ERROR_NO_ACCESS);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// If capabilities can't be queried, default to letting the API decide
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
backend: backend.mountPath,
|
||||||
|
username: displayName,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,6 @@ import { getOwner } from '@ember/application';
|
|||||||
import { isArray } from '@ember/array';
|
import { isArray } from '@ember/array';
|
||||||
import { computed, get } from '@ember/object';
|
import { computed, get } from '@ember/object';
|
||||||
import { alias } from '@ember/object/computed';
|
import { alias } from '@ember/object/computed';
|
||||||
import { assign } from '@ember/polyfills';
|
|
||||||
import Service, { inject as service } from '@ember/service';
|
import Service, { inject as service } from '@ember/service';
|
||||||
import { capitalize } from '@ember/string';
|
import { capitalize } from '@ember/string';
|
||||||
import fetch from 'fetch';
|
import fetch from 'fetch';
|
||||||
@@ -118,9 +117,12 @@ export default Service.extend({
|
|||||||
}
|
}
|
||||||
const backend = this.backendFromTokenName(token);
|
const backend = this.backendFromTokenName(token);
|
||||||
const stored = this.getTokenData(token);
|
const stored = this.getTokenData(token);
|
||||||
|
return Object.assign(stored, {
|
||||||
return assign(stored, {
|
backend: {
|
||||||
backend: BACKENDS.findBy('type', backend),
|
// add mount path for password reset
|
||||||
|
mountPath: stored.backend.mountPath,
|
||||||
|
...BACKENDS.findBy('type', backend),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -184,7 +186,7 @@ export default Service.extend({
|
|||||||
if (namespace) {
|
if (namespace) {
|
||||||
defaults.headers['X-Vault-Namespace'] = namespace;
|
defaults.headers['X-Vault-Namespace'] = namespace;
|
||||||
}
|
}
|
||||||
const opts = assign(defaults, options);
|
const opts = Object.assign(defaults, options);
|
||||||
|
|
||||||
return fetch(url, {
|
return fetch(url, {
|
||||||
method: opts.method || 'GET',
|
method: opts.method || 'GET',
|
||||||
@@ -223,30 +225,7 @@ export default Service.extend({
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
persistAuthData() {
|
calculateRootNamespace(currentNamespace, namespace_path, backend) {
|
||||||
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,
|
// here we prefer namespace_path if its defined,
|
||||||
// else we look and see if there's already a namespace saved
|
// 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
|
// and then finally we'll use the current query param if the others
|
||||||
@@ -266,6 +245,39 @@ export default Service.extend({
|
|||||||
if (typeof userRootNamespace === 'undefined') {
|
if (typeof userRootNamespace === 'undefined') {
|
||||||
userRootNamespace = currentNamespace;
|
userRootNamespace = currentNamespace;
|
||||||
}
|
}
|
||||||
|
return userRootNamespace;
|
||||||
|
},
|
||||||
|
|
||||||
|
persistAuthData() {
|
||||||
|
const [firstArg, resp] = arguments;
|
||||||
|
const tokens = this.tokens;
|
||||||
|
const currentNamespace = this.namespaceService.path || '';
|
||||||
|
// Tab vs dropdown format
|
||||||
|
const mountPath = firstArg?.selectedAuth || firstArg?.data?.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 = {
|
||||||
|
mountPath,
|
||||||
|
...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;
|
||||||
|
const userRootNamespace = this.calculateRootNamespace(currentNamespace, namespace_path, backend);
|
||||||
const data = {
|
const data = {
|
||||||
userRootNamespace,
|
userRootNamespace,
|
||||||
displayName,
|
displayName,
|
||||||
@@ -285,7 +297,7 @@ export default Service.extend({
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (resp.renewable) {
|
if (resp.renewable) {
|
||||||
assign(data, this.calculateExpiration(resp));
|
Object.assign(data, this.calculateExpiration(resp));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data.displayName) {
|
if (!data.displayName) {
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<PageHeader as |p|>
|
||||||
|
<p.levelLeft>
|
||||||
|
<h1 data-test-title class="title is-3">
|
||||||
|
Reset password
|
||||||
|
</h1>
|
||||||
|
</p.levelLeft>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<EmptyState @title="No password reset access" @message={{this.model.message}}>
|
||||||
|
<p>
|
||||||
|
Learn more
|
||||||
|
<DocLink @path="vault/api-docs/auth/userpass#update-password-on-user">about updating passwords</DocLink>
|
||||||
|
here.</p>
|
||||||
|
</EmptyState>
|
||||||
1
ui/app/templates/vault/cluster/access/reset-password.hbs
Normal file
1
ui/app/templates/vault/cluster/access/reset-password.hbs
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<Page::UserpassResetPassword @backend={{this.model.backend}} @username={{this.model.username}} />
|
||||||
47
ui/tests/acceptance/reset-password-test.js
Normal file
47
ui/tests/acceptance/reset-password-test.js
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { module, test } from 'qunit';
|
||||||
|
import sinon from 'sinon';
|
||||||
|
import { currentURL, click, fillIn, settled } from '@ember/test-helpers';
|
||||||
|
import { setupApplicationTest } from 'vault/tests/helpers';
|
||||||
|
import authPage from 'vault/tests/pages/auth';
|
||||||
|
import { createPolicyCmd, mountAuthCmd, runCmd } from '../helpers/commands';
|
||||||
|
|
||||||
|
const resetPolicy = `
|
||||||
|
path "auth/userpass/users/reset-me/password" {
|
||||||
|
capabilities = ["update", "create"]
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
module('Acceptance | reset password', function (hooks) {
|
||||||
|
setupApplicationTest(hooks);
|
||||||
|
|
||||||
|
test('does not allow password reset for non-userpass users', async function (assert) {
|
||||||
|
await authPage.login();
|
||||||
|
await settled();
|
||||||
|
|
||||||
|
await click('[data-test-user-menu-trigger]');
|
||||||
|
assert.dom('[data-test-user-menu-item="reset-password"]').doesNotExist();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('allows password reset for userpass users', async function (assert) {
|
||||||
|
const flashMessages = this.owner.lookup('service:flashMessages');
|
||||||
|
const flashSpy = sinon.spy(flashMessages, 'success');
|
||||||
|
await authPage.login();
|
||||||
|
await runCmd([
|
||||||
|
mountAuthCmd('userpass'),
|
||||||
|
createPolicyCmd('userpass', resetPolicy),
|
||||||
|
'write auth/userpass/users/reset-me password=password token_policies=userpass',
|
||||||
|
]);
|
||||||
|
await authPage.loginUsername('reset-me', 'password');
|
||||||
|
|
||||||
|
await click('[data-test-user-menu-trigger]');
|
||||||
|
await click('[data-test-user-menu-item="reset-password"]');
|
||||||
|
|
||||||
|
assert.strictEqual(currentURL(), '/vault/access/reset-password', 'links to password reset');
|
||||||
|
|
||||||
|
assert.dom('[data-test-title]').hasText('Reset password', 'page title');
|
||||||
|
await fillIn('[data-test-textarea]', 'newpassword');
|
||||||
|
await click('[data-test-reset-password-save]');
|
||||||
|
|
||||||
|
assert.true(flashSpy.calledOnceWith('Successfully reset password'), 'Shows success message');
|
||||||
|
assert.dom('[data-test-textarea]').hasValue('', 'Resets input after save');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { module, test } from 'qunit';
|
||||||
|
import sinon from 'sinon';
|
||||||
|
import { setupRenderingTest } from 'vault/tests/helpers';
|
||||||
|
import { setupMirage } from 'ember-cli-mirage/test-support';
|
||||||
|
import { Response } from 'miragejs';
|
||||||
|
import { click, fillIn, render } from '@ember/test-helpers';
|
||||||
|
import { hbs } from 'ember-cli-htmlbars';
|
||||||
|
|
||||||
|
const S = {
|
||||||
|
infoBanner: '[data-test-current-user-banner]',
|
||||||
|
save: '[data-test-reset-password-save]',
|
||||||
|
error: '[data-test-reset-password-error]',
|
||||||
|
input: '[data-test-textarea]',
|
||||||
|
};
|
||||||
|
module('Integration | Component | page/userpass-reset-password', function (hooks) {
|
||||||
|
setupRenderingTest(hooks);
|
||||||
|
setupMirage(hooks);
|
||||||
|
|
||||||
|
hooks.beforeEach(function () {
|
||||||
|
this.backend = 'userpass3';
|
||||||
|
this.username = 'alice';
|
||||||
|
});
|
||||||
|
|
||||||
|
test('form works -- happy path', async function (assert) {
|
||||||
|
assert.expect(5);
|
||||||
|
const flashMessages = this.owner.lookup('service:flashMessages');
|
||||||
|
const flashSpy = sinon.spy(flashMessages, 'success');
|
||||||
|
this.server.post(`/auth/${this.backend}/users/${this.username}/password`, (schema, req) => {
|
||||||
|
const body = JSON.parse(req.requestBody);
|
||||||
|
assert.ok(true, 'correct endpoint called for update (once)');
|
||||||
|
assert.deepEqual(body, { password: 'new' }, 'request body is correct');
|
||||||
|
return {};
|
||||||
|
});
|
||||||
|
await render(hbs`<Page::UserpassResetPassword @backend={{this.backend}} @username={{this.username}} />`);
|
||||||
|
|
||||||
|
assert
|
||||||
|
.dom(S.infoBanner)
|
||||||
|
.hasText(
|
||||||
|
`You are updating the password for ${this.username} on the ${this.backend} auth mount.`,
|
||||||
|
'info text correct'
|
||||||
|
);
|
||||||
|
|
||||||
|
await fillIn(S.input, 'new');
|
||||||
|
await click(S.save);
|
||||||
|
|
||||||
|
assert.true(flashSpy.calledOnceWith('Successfully reset password'), 'Shows success message');
|
||||||
|
assert.dom(S.input).hasValue('', 'Reset shows input again with empty value');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('form works -- handles error', async function (assert) {
|
||||||
|
this.server.post(`/auth/${this.backend}/users/${this.username}/password`, () => {
|
||||||
|
return new Response(403, {}, { errors: ['some error occurred'] });
|
||||||
|
});
|
||||||
|
await render(hbs`<Page::UserpassResetPassword @backend={{this.backend}} @username={{this.username}} />`);
|
||||||
|
|
||||||
|
assert
|
||||||
|
.dom(S.infoBanner)
|
||||||
|
.hasText(`You are updating the password for ${this.username} on the ${this.backend} auth mount.`);
|
||||||
|
|
||||||
|
await click(S.save);
|
||||||
|
assert.dom(S.error).hasText('Error Please provide a new password.');
|
||||||
|
|
||||||
|
await fillIn(S.input, 'invalid-pw');
|
||||||
|
await click(S.save);
|
||||||
|
|
||||||
|
assert.dom(S.error).hasText('Error some error occurred', 'Shows error from API');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user