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,10 +93,22 @@ 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)
} }
internalAuth, err := handler.usesInternalAuthentication(portainer.UserID(userID))
if err != nil {
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) err = handler.CryptoService.CompareHashAndData(user.Password, payload.Password)
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.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)
if err != nil { if err != nil {
@ -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({ return object({
password: string().required('Password is required.'), password: string().required('Password is required.'),
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.'),
});
}
return object({
password: string().optional(),
description: string()
.max(128, 'Description must be at most 128 characters')
.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,6 +20,7 @@ export function CreateUserAccessTokenInnerForm() {
onSubmit={handleSubmit} onSubmit={handleSubmit}
autoComplete="off" autoComplete="off"
> >
{showAuthentication && (
<FormControl <FormControl
inputId="password" inputId="password"
label="Current password" label="Current password"
@ -31,6 +36,7 @@ export function CreateUserAccessTokenInnerForm() {
autoComplete="new-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)
*/ */