fix(api-key): add password requirement to generate api key [EE-6140] (#10617)

pull/10925/head
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

@ -15,18 +15,22 @@ import (
)
type userAccessTokenCreatePayload struct {
Password string `validate:"required" example:"password" json:"password"`
Description string `validate:"required" example:"github-api-key" json:"description"`
}
func (payload *userAccessTokenCreatePayload) Validate(r *http.Request) error {
if govalidator.IsNull(payload.Password) {
return errors.New("invalid password: cannot be empty")
}
if govalidator.IsNull(payload.Description) {
return errors.New("invalid description. cannot be empty")
return errors.New("invalid description: cannot be empty")
}
if govalidator.HasWhitespaceOnly(payload.Description) {
return errors.New("invalid description. cannot contain only whitespaces")
return errors.New("invalid description: cannot contain only whitespaces")
}
if govalidator.MinStringLength(payload.Description, "128") {
return errors.New("invalid description. cannot be longer than 128 characters")
return errors.New("invalid description: cannot be longer than 128 characters")
}
return nil
}
@ -82,7 +86,12 @@ func (handler *Handler) userCreateAccessToken(w http.ResponseWriter, r *http.Req
user, err := handler.DataStore.User().Read(portainer.UserID(userID))
if err != nil {
return httperror.BadRequest("Unable to find a user", err)
return httperror.InternalServerError("Unable to find a user with the specified identifier inside the database", err)
}
err = handler.CryptoService.CompareHashAndData(user.Password, payload.Password)
if err != nil {
return httperror.Forbidden("Current password doesn't match", errors.New("Current password does not match the password provided. Please try again"))
}
rawAPIKey, apiKey, err := handler.apiKeyService.GenerateApiKey(*user, payload.Description)

View File

@ -25,7 +25,7 @@ func Test_userCreateAccessToken(t *testing.T) {
_, store := datastore.MustNewTestStore(t, true, true)
// create admin and standard user(s)
adminUser := &portainer.User{ID: 1, Username: "admin", Role: portainer.AdministratorRole}
adminUser := &portainer.User{ID: 1, Password: "password", Username: "admin", Role: portainer.AdministratorRole}
err := store.User().Create(adminUser)
is.NoError(err, "error creating admin user")
@ -43,13 +43,14 @@ func Test_userCreateAccessToken(t *testing.T) {
h := NewHandler(requestBouncer, rateLimiter, apiKeyService, nil, passwordChecker)
h.DataStore = store
h.CryptoService = testhelpers.NewCryptoService()
// generate standard and admin user tokens
adminJWT, _, _ := jwtService.GenerateToken(&portainer.TokenData{ID: adminUser.ID, Username: adminUser.Username, Role: adminUser.Role})
jwt, _, _ := jwtService.GenerateToken(&portainer.TokenData{ID: user.ID, Username: user.Username, Role: user.Role})
t.Run("standard user successfully generates API key", func(t *testing.T) {
data := userAccessTokenCreatePayload{Description: "test-token"}
data := userAccessTokenCreatePayload{Password: "password", Description: "test-token"}
payload, err := json.Marshal(data)
is.NoError(err)
@ -72,7 +73,7 @@ func Test_userCreateAccessToken(t *testing.T) {
})
t.Run("admin cannot generate API key for standard user", func(t *testing.T) {
data := userAccessTokenCreatePayload{Description: "test-token-admin"}
data := userAccessTokenCreatePayload{Password: "password", Description: "test-token-admin"}
payload, err := json.Marshal(data)
is.NoError(err)
@ -92,7 +93,7 @@ func Test_userCreateAccessToken(t *testing.T) {
rawAPIKey, _, err := apiKeyService.GenerateApiKey(*user, "test-api-key")
is.NoError(err)
data := userAccessTokenCreatePayload{Description: "test-token-fails"}
data := userAccessTokenCreatePayload{Password: "password", Description: "test-token-fails"}
payload, err := json.Marshal(data)
is.NoError(err)
@ -118,23 +119,23 @@ func Test_userAccessTokenCreatePayload(t *testing.T) {
shouldFail bool
}{
{
payload: userAccessTokenCreatePayload{Description: "test-token"},
payload: userAccessTokenCreatePayload{Password: "password", Description: "test-token"},
shouldFail: false,
},
{
payload: userAccessTokenCreatePayload{Description: ""},
payload: userAccessTokenCreatePayload{Password: "password", Description: ""},
shouldFail: true,
},
{
payload: userAccessTokenCreatePayload{Description: "test token"},
payload: userAccessTokenCreatePayload{Password: "password", Description: "test token"},
shouldFail: false,
},
{
payload: userAccessTokenCreatePayload{Description: "test-token "},
payload: userAccessTokenCreatePayload{Password: "password", Description: "test-token "},
shouldFail: false,
},
{
payload: userAccessTokenCreatePayload{Description: `
payload: userAccessTokenCreatePayload{Password: "password", Description: `
this string is longer than 128 characters and hence this will fail.
this string is longer than 128 characters and hence this will fail.
this string is longer than 128 characters and hence this will fail.

View File

@ -0,0 +1,16 @@
package testhelpers
// Service represents a service for encrypting/hashing data.
type cryptoService struct{}
func NewCryptoService() *cryptoService {
return &cryptoService{}
}
func (*cryptoService) Hash(data string) (string, error) {
return "", nil
}
func (*cryptoService) CompareHashAndData(hash string, data string) error {
return nil
}

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