mirror of
https://github.com/optim-enterprises-bv/vault.git
synced 2025-10-29 01:32:33 +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`;
|
||||
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>
|
||||
</li>
|
||||
{{/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">
|
||||
{{! container is required in navbar collapsed state }}
|
||||
<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
|
||||
return !!this.auth.authData?.entity_id;
|
||||
}
|
||||
get isUserpass() {
|
||||
return this.auth.authData?.backend?.type === 'userpass';
|
||||
}
|
||||
|
||||
get isRenewing() {
|
||||
return this.fakeRenew || this.auth.isRenewing;
|
||||
|
||||
@@ -54,6 +54,7 @@ Router.map(function () {
|
||||
this.mount('open-api-explorer', { path: '/api-explorer' });
|
||||
});
|
||||
this.route('access', function () {
|
||||
this.route('reset-password');
|
||||
this.route('methods', { path: '/' });
|
||||
this.route('method', { path: '/:path' }, function () {
|
||||
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 { 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';
|
||||
@@ -118,9 +117,12 @@ export default Service.extend({
|
||||
}
|
||||
const backend = this.backendFromTokenName(token);
|
||||
const stored = this.getTokenData(token);
|
||||
|
||||
return assign(stored, {
|
||||
backend: BACKENDS.findBy('type', backend),
|
||||
return Object.assign(stored, {
|
||||
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) {
|
||||
defaults.headers['X-Vault-Namespace'] = namespace;
|
||||
}
|
||||
const opts = assign(defaults, options);
|
||||
const opts = Object.assign(defaults, options);
|
||||
|
||||
return fetch(url, {
|
||||
method: opts.method || 'GET',
|
||||
@@ -223,30 +225,7 @@ export default Service.extend({
|
||||
};
|
||||
},
|
||||
|
||||
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;
|
||||
calculateRootNamespace(currentNamespace, namespace_path, backend) {
|
||||
// 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
|
||||
@@ -266,6 +245,39 @@ export default Service.extend({
|
||||
if (typeof userRootNamespace === 'undefined') {
|
||||
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 = {
|
||||
userRootNamespace,
|
||||
displayName,
|
||||
@@ -285,7 +297,7 @@ export default Service.extend({
|
||||
);
|
||||
|
||||
if (resp.renewable) {
|
||||
assign(data, this.calculateExpiration(resp));
|
||||
Object.assign(data, this.calculateExpiration(resp));
|
||||
}
|
||||
|
||||
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