UI: Allow userpass user to update their own password (#23797)

This commit is contained in:
Chelsea Shaw
2023-10-24 15:01:30 -05:00
committed by GitHub
parent a10685c521
commit b7708875e1
13 changed files with 313 additions and 30 deletions

3
changelog/23797.txt Normal file
View File

@@ -0,0 +1,3 @@
```release-note:improvement
ui: Allow users in userpass auth mount to update their own password
```

View File

@@ -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 } });
},
});

View 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>

View 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');
}
}
}

View File

@@ -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

View File

@@ -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;

View File

@@ -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: '/' });

View 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,
};
}
}

View File

@@ -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) {

View File

@@ -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>

View File

@@ -0,0 +1 @@
<Page::UserpassResetPassword @backend={{this.model.backend}} @username={{this.model.username}} />

View 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');
});
});

View File

@@ -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');
});
});