1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-07-25 00:09:40 +02:00

fix(apikey): don't authenticate api key for external auth [EE-6932] (#11460)
Some checks are pending
ci / build_images (map[arch:arm platform:linux version:]) (push) Waiting to run
ci / build_images (map[arch:arm64 platform:linux version:]) (push) Waiting to run
ci / build_images (map[arch:ppc64le platform:linux version:]) (push) Waiting to run
ci / build_images (map[arch:s390x platform:linux version:]) (push) Waiting to run
ci / build_images (map[arch:amd64 platform:linux version:]) (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_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-04-08 11:03:52 +12:00 committed by GitHub
parent a3c7eb0ce0
commit dc9d7ae3f1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 88 additions and 30 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;
} }