1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-08-07 14:55:27 +02:00

fix(api-key): add password requirement to generate api key [EE-6140] (#10617)
Some checks are pending
ci / build_images (map[arch:amd64 platform:linux]) (push) Waiting to run
ci / build_images (map[arch:amd64 platform:windows version:1809]) (push) Waiting to run
ci / build_images (map[arch:amd64 platform:windows version:ltsc2022]) (push) Waiting to run
ci / build_images (map[arch:arm64 platform:linux]) (push) Waiting to run
ci / build_manifests (push) Blocked by required conditions
/ triage (push) Waiting to run
Lint / Run linters (push) Waiting to run
Test / test-client (push) Waiting to run
Test / test-server (map[arch:amd64 platform:linux]) (push) Waiting to run
Test / test-server (map[arch:amd64 platform:windows version:1809]) (push) Waiting to run
Test / test-server (map[arch:amd64 platform:windows version:ltsc2022]) (push) Waiting to run
Test / test-server (map[arch:arm64 platform:linux]) (push) Waiting to run

This commit is contained in:
Matt Hook 2024-01-09 11:14:24 +13:00 committed by GitHub
parent 236e669332
commit dbd2e609d7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 305 additions and 251 deletions

View file

@ -5,9 +5,8 @@ import { withCurrentUser } from '@/react-tools/withCurrentUser';
import { r2a } from '@/react-tools/react2angular';
import { withReactQuery } from '@/react-tools/withReactQuery';
import { withUIRouter } from '@/react-tools/withUIRouter';
import { CreateAccessToken } from '@/react/portainer/account/CreateAccessTokenView';
import { CreateUserAccessToken } from '@/react/portainer/account/CreateAccessTokenView';
import { EdgeComputeSettingsView } from '@/react/portainer/settings/EdgeComputeView/EdgeComputeSettingsView';
import { withI18nSuspense } from '@/react-tools/withI18nSuspense';
import { EdgeAutoCreateScriptView } from '@/react/portainer/environments/EdgeAutoCreateScriptView';
import { ListView as EnvironmentsListView } from '@/react/portainer/environments/ListView';
import { BackupSettingsPanel } from '@/react/portainer/settings/SettingsView/BackupSettingsView/BackupSettingsPanel';
@ -36,11 +35,11 @@ export const viewsModule = angular
)
)
.component(
'createAccessToken',
r2a(withI18nSuspense(withUIRouter(CreateAccessToken)), [
'onSubmit',
'onError',
])
'createUserAccessToken',
r2a(
withReactQuery(withCurrentUser(withUIRouter(CreateUserAccessToken))),
[]
)
)
.component(
'settingsEdgeCompute',

View file

@ -17,7 +17,6 @@ angular.module('portainer.app').factory('Users', [
queryMemberships: { method: 'GET', isArray: true, params: { id: '@id', entity: 'memberships' } },
checkAdminUser: { method: 'GET', params: { id: 'admin', entity: 'check' }, isArray: true, ignoreLoadingBar: true },
initAdminUser: { method: 'POST', params: { id: 'admin', entity: 'init' }, ignoreLoadingBar: true },
createAccessToken: { url: `${API_ENDPOINT_USERS}/:id/tokens`, method: 'POST', params: { id: '@id' }, ignoreLoadingBar: true },
getAccessTokens: { method: 'GET', params: { id: '@id', entity: 'tokens' }, isArray: true },
deleteAccessToken: { url: `${API_ENDPOINT_USERS}/:id/tokens/:tokenId`, method: 'DELETE', params: { id: '@id', entityId: '@tokenId' } },
}

View file

@ -112,19 +112,6 @@ export function UserService($q, Users, TeamService, TeamMembershipService) {
return deferred.promise;
};
service.createAccessToken = function (id, description) {
const deferred = $q.defer();
const payload = { description };
Users.createAccessToken({ id }, payload)
.$promise.then((data) => {
deferred.resolve(data);
})
.catch(function error(err) {
deferred.reject({ msg: 'Unable to create user', err: err });
});
return deferred.promise;
};
service.getAccessTokens = function (id) {
var deferred = $q.defer();

View file

@ -1,33 +0,0 @@
export default class CreateUserAccessTokenController {
/* @ngInject */
constructor($async, $analytics, Authentication, UserService, Notifications) {
this.$async = $async;
this.$analytics = $analytics;
this.Authentication = Authentication;
this.UserService = UserService;
this.Notifications = Notifications;
this.onSubmit = this.onSubmit.bind(this);
this.onError = this.onError.bind(this);
}
async onSubmit(description) {
const accessToken = await this.UserService.createAccessToken(this.state.userId, description);
// Dispatch analytics event upon success accessToken generation
this.$analytics.eventTrack('portainer-account-access-token-create', { category: 'portainer' });
return accessToken;
}
onError(heading, error, message) {
this.Notifications.error(heading, error, message);
}
$onInit() {
return this.$async(async () => {
const userId = this.Authentication.getUserDetails().ID;
this.state = {
userId,
};
});
}
}

View file

@ -1,8 +0,0 @@
<page-header title="'Create access token'" breadcrumbs="[{label:'User settings', link:'portainer.account'}, 'Add access token']" reload="true"> </page-header>
<div class="row">
<div class="col-sm-12">
<!-- mount react feature/view -->
<create-access-token user-id="$ctrl.state.userId" on-submit="($ctrl.onSubmit)" on-success="($ctrl.onSuccess)" on-error="($ctrl.onError)"></create-access-token>
</div>
</div>

View file

@ -1,7 +0,0 @@
import angular from 'angular';
import controller from './create-user-access-token.controller';
angular.module('portainer.app').component('createUserAccessToken', {
templateUrl: './create-user-access-token.html',
controller,
});

View file

@ -1,54 +0,0 @@
import userEvent from '@testing-library/user-event';
import { render } from '@/react-tools/test-utils';
import { CreateAccessToken } from './CreateAccessToken';
test('the button is disabled when description is missing and enabled when description is filled', async () => {
const queries = renderComponent();
const button = queries.getByRole('button', { name: 'Add access token' });
expect(button).toBeDisabled();
const descriptionField = queries.getByLabelText('Description');
userEvent.type(descriptionField, 'description');
expect(button).toBeEnabled();
userEvent.clear(descriptionField);
expect(button).toBeDisabled();
});
test('once the button is clicked, the access token is generated and displayed', async () => {
const token = 'a very long access token that should be displayed';
const onSubmit = jest.fn(() => Promise.resolve({ rawAPIKey: token }));
const queries = renderComponent(onSubmit);
const descriptionField = queries.getByLabelText('Description');
userEvent.type(descriptionField, 'description');
const button = queries.getByRole('button', { name: 'Add access token' });
userEvent.click(button);
expect(onSubmit).toHaveBeenCalledWith('description');
expect(onSubmit).toHaveBeenCalledTimes(1);
await expect(queries.findByText('New access token')).resolves.toBeVisible();
expect(queries.getByText(token)).toHaveTextContent(token);
});
function renderComponent(onSubmit = jest.fn()) {
const queries = render(
<CreateAccessToken onSubmit={onSubmit} onError={jest.fn()} />
);
expect(queries.getByLabelText('Description')).toBeVisible();
return queries;
}

View file

@ -1,114 +0,0 @@
import { PropsWithChildren, useEffect, useState } from 'react';
import { useRouter } from '@uirouter/react';
import { Trans, useTranslation } from 'react-i18next';
import { Widget, WidgetBody } from '@@/Widget';
import { FormControl } from '@@/form-components/FormControl';
import { Button } from '@@/buttons';
import { FormSectionTitle } from '@@/form-components/FormSectionTitle';
import { TextTip } from '@@/Tip/TextTip';
import { Code } from '@@/Code';
import { CopyButton } from '@@/buttons/CopyButton';
import { Input } from '@@/form-components/Input';
interface AccessTokenResponse {
rawAPIKey: string;
}
export interface Props {
// onSubmit dispatches a successful matomo analytics event
onSubmit: (description: string) => Promise<AccessTokenResponse>;
// onError is called when an error occurs; this is a callback to Notifications.error
onError: (heading: string, err: unknown, message: string) => void;
}
export function CreateAccessToken({
onSubmit,
onError,
}: PropsWithChildren<Props>) {
const translationNS = 'account.accessTokens.create';
const { t } = useTranslation(translationNS);
const router = useRouter();
const [description, setDescription] = useState('');
const [errorText, setErrorText] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [accessToken, setAccessToken] = useState('');
useEffect(() => {
if (description.length === 0) {
setErrorText(t('this field is required'));
} else setErrorText('');
}, [description, t]);
async function generateAccessToken() {
if (isLoading) {
return;
}
setIsLoading(true);
try {
const response = await onSubmit(description);
setAccessToken(response.rawAPIKey);
} catch (err) {
onError('Failure', err, 'Failed to generate access token');
} finally {
setIsLoading(false);
}
}
return (
<Widget>
<WidgetBody>
<div className="form-horizontal">
<FormControl
inputId="input"
label={t('Description')}
errors={errorText}
>
<Input
id="input"
onChange={(e) => setDescription(e.target.value)}
value={description}
/>
</FormControl>
<div className="row mt-5">
<Button
disabled={!!errorText || !!accessToken}
onClick={() => generateAccessToken()}
>
{t('Add access token')}
</Button>
</div>
</div>
{accessToken && (
<div className="mt-5">
<FormSectionTitle>
<Trans ns={translationNS}>New access token</Trans>
</FormSectionTitle>
<TextTip>
<Trans ns={translationNS}>
Please copy the new access token. You won&#39;t be able to view
the token again.
</Trans>
</TextTip>
<Code>{accessToken}</Code>
<div className="mt-2">
<CopyButton copyText={accessToken}>
<Trans ns={translationNS}>Copy access token</Trans>
</CopyButton>
</div>
<hr />
<Button
type="button"
onClick={() => router.stateService.go('portainer.account')}
>
<Trans ns={translationNS}>Done</Trans>
</Button>
</div>
)}
</WidgetBody>
</Widget>
);
}

View file

@ -0,0 +1,12 @@
import { SchemaOf, object, string } from 'yup';
import { ApiKeyFormValues } from './types';
export function getAPITokenValidationSchema(): SchemaOf<ApiKeyFormValues> {
return object({
password: string().required('Password is required.'),
description: string()
.max(128, 'Description must be at most 128 characters')
.required('Description is required'),
});
}

View file

@ -0,0 +1,41 @@
import userEvent from '@testing-library/user-event';
import { renderWithQueryClient, waitFor } from '@/react-tools/test-utils';
import { UserViewModel } from '@/portainer/models/user';
import { UserContext } from '@/react/hooks/useUser';
import { CreateUserAccessToken } from './CreateUserAccessToken';
test('the button is disabled when all fields are blank and enabled when all fields are filled', async () => {
const { getByRole, getByLabelText } = renderComponent();
const button = getByRole('button', { name: 'Add access token' });
await waitFor(() => {
expect(button).toBeDisabled();
});
const descriptionField = getByLabelText(/Description/);
const passwordField = getByLabelText(/Current password/);
userEvent.type(passwordField, 'password');
userEvent.type(descriptionField, 'description');
await waitFor(() => {
expect(button).toBeEnabled();
});
userEvent.clear(descriptionField);
await waitFor(() => {
expect(button).toBeDisabled();
});
});
function renderComponent() {
const user = new UserViewModel({ Username: 'user' });
return renderWithQueryClient(
<UserContext.Provider value={{ user }}>
<CreateUserAccessToken />
</UserContext.Provider>
);
}

View file

@ -0,0 +1,74 @@
import { Formik } from 'formik';
import { useState } from 'react';
import { useCurrentUser } from '@/react/hooks/useUser';
import { useAnalytics } from '@/react/hooks/useAnalytics';
import { Widget } from '@@/Widget';
import { PageHeader } from '@@/PageHeader';
import { ApiKeyFormValues } from './types';
import { getAPITokenValidationSchema } from './CreateUserAcccessToken.validation';
import { useCreateUserAccessTokenMutation } from './useCreateUserAccessTokenMutation';
import { CreateUserAccessTokenInnerForm } from './CreateUserAccessTokenInnerForm';
import { DisplayUserAccessToken } from './DisplayUserAccessToken';
const initialValues: ApiKeyFormValues = {
password: '',
description: '',
};
export function CreateUserAccessToken() {
const mutation = useCreateUserAccessTokenMutation();
const { user } = useCurrentUser();
const [newAPIToken, setNewAPIToken] = useState('');
const { trackEvent } = useAnalytics();
return (
<>
<PageHeader
title="Create access token"
breadcrumbs={[
{ label: 'My account', link: 'portainer.account' },
'Add access token',
]}
reload
/>
<div className="row">
<div className="col-sm-12">
<Widget>
<Widget.Body>
{newAPIToken === '' ? (
<Formik
initialValues={initialValues}
onSubmit={onSubmit}
validationSchema={getAPITokenValidationSchema}
>
<CreateUserAccessTokenInnerForm />
</Formik>
) : (
DisplayUserAccessToken(newAPIToken)
)}
</Widget.Body>
</Widget>
</div>
</div>
</>
);
async function onSubmit(values: ApiKeyFormValues) {
mutation.mutate(
{ values, userid: user.Id },
{
onSuccess(response) {
setNewAPIToken(response);
},
}
);
trackEvent('portainer-account-access-token-create', {
category: 'portainer',
});
}
}

View file

@ -0,0 +1,56 @@
import { Field, Form, useFormikContext } from 'formik';
import { FormControl } from '@@/form-components/FormControl';
import { Input } from '@@/form-components/Input';
import { LoadingButton } from '@@/buttons';
import { ApiKeyFormValues } from './types';
export function CreateUserAccessTokenInnerForm() {
const { errors, values, handleSubmit, isValid, dirty } =
useFormikContext<ApiKeyFormValues>();
return (
<Form
className="form-horizontal"
onSubmit={handleSubmit}
autoComplete="off"
>
<FormControl
inputId="password"
label="Current password"
required
errors={errors.password}
>
<Field
as={Input}
type="password"
id="password"
name="password"
value={values.password}
autoComplete="new-password"
/>
</FormControl>
<FormControl
inputId="description"
label="Description"
required
errors={errors.description}
>
<Field
as={Input}
id="description"
name="description"
value={values.description}
/>
</FormControl>
<LoadingButton
disabled={!isValid || !dirty}
isLoading={false}
loadingText="Adding access token..."
>
Add access token
</LoadingButton>
</Form>
);
}

View file

@ -0,0 +1,33 @@
import { useRouter } from '@uirouter/react';
import { Button, CopyButton } from '@@/buttons';
import { FormSectionTitle } from '@@/form-components/FormSectionTitle';
import { TextTip } from '@@/Tip/TextTip';
export function DisplayUserAccessToken(apikey: string) {
const router = useRouter();
return (
<>
<FormSectionTitle>New access token</FormSectionTitle>
<TextTip>
Please copy the new access token. You won&#39;t be able to view the
token again.
</TextTip>
<div className="pt-5">
<div className="inline-flex">
<div className="">{apikey}</div>
<div>
<CopyButton copyText={apikey} color="link" />
</div>
</div>
<hr />
</div>
<Button
type="button"
onClick={() => router.stateService.go('portainer.account')}
>
Done
</Button>
</>
);
}

View file

@ -1 +1 @@
export { CreateAccessToken } from './CreateAccessToken';
export { CreateUserAccessToken } from './CreateUserAccessToken';

View file

@ -0,0 +1,4 @@
export interface ApiKeyFormValues {
password: string;
description: string;
}

View file

@ -0,0 +1,39 @@
import { useMutation } from 'react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { withError } from '@/react-tools/react-query';
import { notifySuccess } from '@/portainer/services/notifications';
import { ApiKeyFormValues } from './types';
export interface ApiKeyResponse {
rawAPIKey: string;
}
export function useCreateUserAccessTokenMutation() {
return useMutation(createUserAccessToken, {
...withError('Unable to create access token'),
onSuccess: () => {
notifySuccess('Success', 'Access token successfully created');
// TODO: invalidate query when user page migrated to react.
},
});
}
async function createUserAccessToken({
values,
userid,
}: {
values: ApiKeyFormValues;
userid: number;
}) {
try {
const response = await axios.post<ApiKeyResponse>(
`/users/${userid}/tokens`,
values
);
return response.data.rawAPIKey;
} catch (e) {
throw parseAxiosError(e);
}
}