mirror of https://github.com/portainer/portainer
fix(apikey): don't authenticate api key for external auth [EE-6932] (#11462)
parent
5d7d68bc44
commit
8616f9b742
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
@ -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.'),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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 (
|
||||||
<>
|
<>
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
export interface ApiKeyFormValues {
|
export interface ApiKeyFormValues {
|
||||||
password: string;
|
password?: string;
|
||||||
description: string;
|
description: string;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
*/
|
*/
|
||||||
|
|
Loading…
Reference in New Issue