fix(apikey): don't authenticate api key for external auth [EE-6932] (#11462)

pull/11519/head
Matt Hook 2024-04-08 11:03:13 +12:00 committed by GitHub
parent 5d7d68bc44
commit 8616f9b742
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 90 additions and 32 deletions

View File

@ -2,6 +2,7 @@ package users
import ( import (
"errors" "errors"
"fmt"
"net/http" "net/http"
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
@ -20,9 +21,6 @@ type userAccessTokenCreatePayload struct {
} }
func (payload *userAccessTokenCreatePayload) Validate(r *http.Request) error { 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) { if govalidator.IsNull(payload.Description) {
return errors.New("invalid description: cannot be empty") return errors.New("invalid description: cannot be empty")
} }
@ -44,6 +42,7 @@ type accessTokenResponse struct {
// @summary Generate an API key for a user // @summary Generate an API key for a user
// @description Generates an API key for a user. // @description Generates an API key for a user.
// @description Only the calling user can generate a token for themselves. // @description Only the calling user can generate a token for themselves.
// @description Password is required only for internal authentication.
// @description **Access policy**: restricted // @description **Access policy**: restricted
// @tags users // @tags users
// @security jwt // @security jwt
@ -94,9 +93,21 @@ func (handler *Handler) userCreateAccessToken(w http.ResponseWriter, r *http.Req
return httperror.InternalServerError("Unable to find a user with the specified identifier inside the database", 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) internalAuth, err := handler.usesInternalAuthentication(portainer.UserID(userID))
if err != nil { if err != nil {
return httperror.Forbidden("Current password doesn't match", errors.New("Current password does not match the password provided. Please try again")) return httperror.InternalServerError("Unable to determine the authentication method", err)
}
if internalAuth {
// Internal auth requires the password field and must not be empty
if govalidator.IsNull(payload.Password) {
return httperror.BadRequest("Invalid request payload", errors.New("invalid password: cannot be empty"))
}
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) rawAPIKey, apiKey, err := handler.apiKeyService.GenerateApiKey(*user, payload.Description)
@ -107,3 +118,18 @@ func (handler *Handler) userCreateAccessToken(w http.ResponseWriter, r *http.Req
w.WriteHeader(http.StatusCreated) w.WriteHeader(http.StatusCreated)
return response.JSON(w, accessTokenResponse{rawAPIKey, *apiKey}) return response.JSON(w, accessTokenResponse{rawAPIKey, *apiKey})
} }
func (handler *Handler) usesInternalAuthentication(userid portainer.UserID) (bool, error) {
// userid 1 is the admin user and always uses internal auth
if userid == 1 {
return true, nil
}
// otherwise determine the auth method from the settings
settings, err := handler.DataStore.Settings().Settings()
if err != nil {
return false, fmt.Errorf("unable to retrieve the settings from the database: %w", err)
}
return settings.AuthenticationMethod == portainer.AuthenticationInternal, nil
}

View File

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

View File

@ -33,7 +33,10 @@ test('the button is disabled when all fields are blank and enabled when all fiel
}); });
function renderComponent() { function renderComponent() {
const user = new UserViewModel({ Username: 'user' }); const user = new UserViewModel({
Username: 'admin',
Id: 1,
});
const Wrapped = withTestQueryProvider( const Wrapped = withTestQueryProvider(
withUserProvider(withTestRouter(CreateUserAccessToken), user) withUserProvider(withTestRouter(CreateUserAccessToken), user)

View File

@ -3,10 +3,13 @@ import { useState } from 'react';
import { useCurrentUser } from '@/react/hooks/useUser'; import { useCurrentUser } from '@/react/hooks/useUser';
import { useAnalytics } from '@/react/hooks/useAnalytics'; import { useAnalytics } from '@/react/hooks/useAnalytics';
import { AuthenticationMethod } from '@/react/portainer/settings/types';
import { Widget } from '@@/Widget'; import { Widget } from '@@/Widget';
import { PageHeader } from '@@/PageHeader'; import { PageHeader } from '@@/PageHeader';
import { usePublicSettings } from '../../settings/queries/usePublicSettings';
import { ApiKeyFormValues } from './types'; import { ApiKeyFormValues } from './types';
import { getAPITokenValidationSchema } from './CreateUserAcccessToken.validation'; import { getAPITokenValidationSchema } from './CreateUserAcccessToken.validation';
import { useCreateUserAccessTokenMutation } from './useCreateUserAccessTokenMutation'; import { useCreateUserAccessTokenMutation } from './useCreateUserAccessTokenMutation';
@ -23,6 +26,11 @@ export function CreateUserAccessToken() {
const { user } = useCurrentUser(); const { user } = useCurrentUser();
const [newAPIToken, setNewAPIToken] = useState(''); const [newAPIToken, setNewAPIToken] = useState('');
const { trackEvent } = useAnalytics(); const { trackEvent } = useAnalytics();
const settings = usePublicSettings();
const requirePassword =
settings.data?.AuthenticationMethod === AuthenticationMethod.Internal ||
user.Id === 1;
return ( return (
<> <>
@ -43,12 +51,16 @@ export function CreateUserAccessToken() {
<Formik <Formik
initialValues={initialValues} initialValues={initialValues}
onSubmit={onSubmit} onSubmit={onSubmit}
validationSchema={getAPITokenValidationSchema} validationSchema={getAPITokenValidationSchema(
requirePassword
)}
> >
<CreateUserAccessTokenInnerForm /> <CreateUserAccessTokenInnerForm
showAuthentication={requirePassword}
/>
</Formik> </Formik>
) : ( ) : (
DisplayUserAccessToken(newAPIToken) <DisplayUserAccessToken apikey={newAPIToken} />
)} )}
</Widget.Body> </Widget.Body>
</Widget> </Widget>

View File

@ -6,7 +6,11 @@ import { LoadingButton } from '@@/buttons';
import { ApiKeyFormValues } from './types'; import { ApiKeyFormValues } from './types';
export function CreateUserAccessTokenInnerForm() { interface Props {
showAuthentication: boolean;
}
export function CreateUserAccessTokenInnerForm({ showAuthentication }: Props) {
const { errors, values, handleSubmit, isValid, dirty } = const { errors, values, handleSubmit, isValid, dirty } =
useFormikContext<ApiKeyFormValues>(); useFormikContext<ApiKeyFormValues>();
@ -16,21 +20,23 @@ export function CreateUserAccessTokenInnerForm() {
onSubmit={handleSubmit} onSubmit={handleSubmit}
autoComplete="off" autoComplete="off"
> >
<FormControl {showAuthentication && (
inputId="password" <FormControl
label="Current password" inputId="password"
required label="Current password"
errors={errors.password} required
> errors={errors.password}
<Field >
as={Input} <Field
type="password" as={Input}
id="password" type="password"
name="password" id="password"
value={values.password} name="password"
autoComplete="new-password" value={values.password}
/> autoComplete="new-password"
</FormControl> />
</FormControl>
)}
<FormControl <FormControl
inputId="description" inputId="description"
label="Description" label="Description"

View File

@ -4,7 +4,7 @@ import { Button, CopyButton } from '@@/buttons';
import { FormSectionTitle } from '@@/form-components/FormSectionTitle'; import { FormSectionTitle } from '@@/form-components/FormSectionTitle';
import { TextTip } from '@@/Tip/TextTip'; import { TextTip } from '@@/Tip/TextTip';
export function DisplayUserAccessToken(apikey: string) { export function DisplayUserAccessToken({ apikey }: { apikey: string }) {
const router = useRouter(); const router = useRouter();
return ( return (
<> <>

View File

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

View File

@ -72,11 +72,11 @@ export interface OAuthSettings {
KubeSecretKey: string; KubeSecretKey: string;
} }
enum AuthenticationMethod { export enum AuthenticationMethod {
/** /**
* Internal represents the internal authentication method (authentication against Portainer API) * Internal represents the internal authentication method (authentication against Portainer API)
*/ */
Internal, Internal = 1,
/** /**
* LDAP represents the LDAP authentication method (authentication against a LDAP server) * LDAP represents the LDAP authentication method (authentication against a LDAP server)
*/ */