mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
Verification code CLI (#111707)
* Add verification code CLI * Added suggestion from code review * Fixed types * Added extra test * Added CLI dist scripts * Fixed typo * Write code to data instead of config directory
This commit is contained in:
parent
34581ff1ed
commit
db5cf95724
12 changed files with 346 additions and 9 deletions
9
scripts/kibana_verification_code.js
Normal file
9
scripts/kibana_verification_code.js
Normal file
|
@ -0,0 +1,9 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
require('../src/cli_verification_code/dev');
|
39
src/cli_verification_code/cli_verification_code.js
Normal file
39
src/cli_verification_code/cli_verification_code.js
Normal file
|
@ -0,0 +1,39 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { kibanaPackageJson, getDataPath } from '@kbn/utils';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import Command from '../cli/command';
|
||||
|
||||
const program = new Command('bin/kibana-verification-code');
|
||||
|
||||
program
|
||||
.version(kibanaPackageJson.version)
|
||||
.description('Tool to get Kibana verification code')
|
||||
.action(() => {
|
||||
const fpath = path.join(getDataPath(), 'verification_code');
|
||||
try {
|
||||
const code = fs.readFileSync(fpath).toString();
|
||||
console.log(
|
||||
`Your verification code is: ${chalk.black.bgCyanBright(
|
||||
` ${code.substr(0, 3)} ${code.substr(3)} `
|
||||
)}`
|
||||
);
|
||||
} catch (error) {
|
||||
console.log(`Couldn't find verification code.
|
||||
|
||||
If Kibana hasn't been configured yet, restart Kibana to generate a new code.
|
||||
|
||||
Otherwise, you can safely ignore this message and start using Kibana.`);
|
||||
}
|
||||
});
|
||||
|
||||
program.parse(process.argv);
|
10
src/cli_verification_code/dev.js
Normal file
10
src/cli_verification_code/dev.js
Normal file
|
@ -0,0 +1,10 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
require('../setup_node_env');
|
||||
require('./cli_verification_code');
|
10
src/cli_verification_code/dist.js
Normal file
10
src/cli_verification_code/dist.js
Normal file
|
@ -0,0 +1,10 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
require('../setup_node_env/dist');
|
||||
require('./cli_verification_code');
|
29
src/dev/build/tasks/bin/scripts/kibana-verification-code
Executable file
29
src/dev/build/tasks/bin/scripts/kibana-verification-code
Executable file
|
@ -0,0 +1,29 @@
|
|||
#!/bin/sh
|
||||
SCRIPT=$0
|
||||
|
||||
# SCRIPT may be an arbitrarily deep series of symlinks. Loop until we have the concrete path.
|
||||
while [ -h "$SCRIPT" ] ; do
|
||||
ls=$(ls -ld "$SCRIPT")
|
||||
# Drop everything prior to ->
|
||||
link=$(expr "$ls" : '.*-> \(.*\)$')
|
||||
if expr "$link" : '/.*' > /dev/null; then
|
||||
SCRIPT="$link"
|
||||
else
|
||||
SCRIPT=$(dirname "$SCRIPT")/"$link"
|
||||
fi
|
||||
done
|
||||
|
||||
DIR="$(dirname "${SCRIPT}")/.."
|
||||
CONFIG_DIR=${KBN_PATH_CONF:-"$DIR/config"}
|
||||
NODE="${DIR}/node/bin/node"
|
||||
test -x "$NODE"
|
||||
if [ ! -x "$NODE" ]; then
|
||||
echo "unable to find usable node.js executable."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -f "${CONFIG_DIR}/node.options" ]; then
|
||||
KBN_NODE_OPTS="$(grep -v ^# < ${CONFIG_DIR}/node.options | xargs)"
|
||||
fi
|
||||
|
||||
NODE_OPTIONS="$KBN_NODE_OPTS $NODE_OPTIONS" "${NODE}" "${DIR}/src/cli_verification_code/dist" "$@"
|
35
src/dev/build/tasks/bin/scripts/kibana-verification-code.bat
Executable file
35
src/dev/build/tasks/bin/scripts/kibana-verification-code.bat
Executable file
|
@ -0,0 +1,35 @@
|
|||
@echo off
|
||||
|
||||
SETLOCAL ENABLEDELAYEDEXPANSION
|
||||
|
||||
set SCRIPT_DIR=%~dp0
|
||||
for %%I in ("%SCRIPT_DIR%..") do set DIR=%%~dpfI
|
||||
|
||||
set NODE=%DIR%\node\node.exe
|
||||
|
||||
If Not Exist "%NODE%" (
|
||||
Echo unable to find usable node.js executable.
|
||||
Exit /B 1
|
||||
)
|
||||
|
||||
set CONFIG_DIR=%KBN_PATH_CONF%
|
||||
If ["%KBN_PATH_CONF%"] == [] (
|
||||
set "CONFIG_DIR=%DIR%\config"
|
||||
)
|
||||
|
||||
IF EXIST "%CONFIG_DIR%\node.options" (
|
||||
for /F "usebackq eol=# tokens=*" %%i in ("%CONFIG_DIR%\node.options") do (
|
||||
If [!NODE_OPTIONS!] == [] (
|
||||
set "NODE_OPTIONS=%%i"
|
||||
) Else (
|
||||
set "NODE_OPTIONS=!NODE_OPTIONS! %%i"
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
TITLE Kibana Verification Code
|
||||
"%NODE%" "%DIR%\src\cli_verification_code\dist" %*
|
||||
|
||||
:finally
|
||||
|
||||
ENDLOCAL
|
|
@ -12,6 +12,7 @@ import React, { useEffect, useRef } from 'react';
|
|||
import useList from 'react-use/lib/useList';
|
||||
import useUpdateEffect from 'react-use/lib/useUpdateEffect';
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { euiThemeVars } from '@kbn/ui-shared-deps/theme';
|
||||
|
||||
export interface SingleCharsFieldProps {
|
||||
|
@ -124,6 +125,10 @@ export const SingleCharsField: FunctionComponent<SingleCharsFieldProps> = ({
|
|||
maxLength={1}
|
||||
isInvalid={isInvalid}
|
||||
style={{ textAlign: 'center' }}
|
||||
aria-label={i18n.translate('interactiveSetup.singleCharsField.digitLabel', {
|
||||
defaultMessage: 'Digit {index}',
|
||||
values: { index: i + 1 },
|
||||
})}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
);
|
||||
|
|
|
@ -0,0 +1,83 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { fireEvent, render, waitFor } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
|
||||
import { coreMock } from 'src/core/public/mocks';
|
||||
|
||||
import { Providers } from './plugin';
|
||||
import { VerificationCodeForm } from './verification_code_form';
|
||||
|
||||
jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => ({
|
||||
htmlIdGenerator: () => () => `id-${Math.random()}`,
|
||||
}));
|
||||
|
||||
describe('VerificationCodeForm', () => {
|
||||
jest.setTimeout(20_000);
|
||||
|
||||
it('calls enrollment API when submitting form', async () => {
|
||||
const coreStart = coreMock.createStart();
|
||||
coreStart.http.post.mockResolvedValue({});
|
||||
|
||||
const onSuccess = jest.fn();
|
||||
|
||||
const { findByRole, findByLabelText } = render(
|
||||
<Providers http={coreStart.http}>
|
||||
<VerificationCodeForm onSuccess={onSuccess} />
|
||||
</Providers>
|
||||
);
|
||||
fireEvent.input(await findByLabelText('Digit 1'), {
|
||||
target: { value: '1' },
|
||||
});
|
||||
fireEvent.input(await findByLabelText('Digit 2'), {
|
||||
target: { value: '2' },
|
||||
});
|
||||
fireEvent.input(await findByLabelText('Digit 3'), {
|
||||
target: { value: '3' },
|
||||
});
|
||||
fireEvent.input(await findByLabelText('Digit 4'), {
|
||||
target: { value: '4' },
|
||||
});
|
||||
fireEvent.input(await findByLabelText('Digit 5'), {
|
||||
target: { value: '5' },
|
||||
});
|
||||
fireEvent.input(await findByLabelText('Digit 6'), {
|
||||
target: { value: '6' },
|
||||
});
|
||||
fireEvent.click(await findByRole('button', { name: 'Verify', hidden: true }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(coreStart.http.post).toHaveBeenLastCalledWith('/internal/interactive_setup/verify', {
|
||||
body: JSON.stringify({ code: '123456' }),
|
||||
});
|
||||
expect(onSuccess).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('validates form', async () => {
|
||||
const coreStart = coreMock.createStart();
|
||||
const onSuccess = jest.fn();
|
||||
|
||||
const { findAllByText, findByRole, findByLabelText } = render(
|
||||
<Providers http={coreStart.http}>
|
||||
<VerificationCodeForm onSuccess={onSuccess} />
|
||||
</Providers>
|
||||
);
|
||||
|
||||
fireEvent.click(await findByRole('button', { name: 'Verify', hidden: true }));
|
||||
|
||||
await findAllByText(/Enter a verification code/i);
|
||||
|
||||
fireEvent.input(await findByLabelText('Digit 1'), {
|
||||
target: { value: '1' },
|
||||
});
|
||||
|
||||
await findAllByText(/Enter all six digits/i);
|
||||
});
|
||||
});
|
|
@ -9,6 +9,7 @@
|
|||
import {
|
||||
EuiButton,
|
||||
EuiCallOut,
|
||||
EuiCode,
|
||||
EuiEmptyPrompt,
|
||||
EuiForm,
|
||||
EuiFormRow,
|
||||
|
@ -69,8 +70,8 @@ export const VerificationCodeForm: FunctionComponent<VerificationCodeFormProps>
|
|||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
if (error.response?.status === 403) {
|
||||
form.setError('code', error.body?.message);
|
||||
if ((error as IHttpFetchError).response?.status === 403) {
|
||||
form.setError('code', (error as IHttpFetchError).body?.message);
|
||||
return;
|
||||
} else {
|
||||
throw error;
|
||||
|
@ -111,7 +112,10 @@ export const VerificationCodeForm: FunctionComponent<VerificationCodeFormProps>
|
|||
<p>
|
||||
<FormattedMessage
|
||||
id="interactiveSetup.verificationCodeForm.codeDescription"
|
||||
defaultMessage="Copy the verification code from Kibana server."
|
||||
defaultMessage="Copy the code from the Kibana server or run {command} to retrieve it."
|
||||
values={{
|
||||
command: <EuiCode lang="bash">./bin/kibana-verification-code</EuiCode>,
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
</EuiText>
|
||||
|
|
|
@ -17,7 +17,7 @@ import type { ConfigSchema, ConfigType } from './config';
|
|||
import { ElasticsearchService } from './elasticsearch_service';
|
||||
import { KibanaConfigWriter } from './kibana_config_writer';
|
||||
import { defineRoutes } from './routes';
|
||||
import { VerificationCode } from './verification_code';
|
||||
import { VerificationService } from './verification_service';
|
||||
|
||||
// List of the Elasticsearch hosts Kibana uses by default.
|
||||
const DEFAULT_ELASTICSEARCH_HOSTS = [
|
||||
|
@ -29,7 +29,7 @@ const DEFAULT_ELASTICSEARCH_HOSTS = [
|
|||
export class InteractiveSetupPlugin implements PrebootPlugin {
|
||||
readonly #logger: Logger;
|
||||
readonly #elasticsearch: ElasticsearchService;
|
||||
readonly #verificationCode: VerificationCode;
|
||||
readonly #verification: VerificationService;
|
||||
|
||||
#elasticsearchConnectionStatusSubscription?: Subscription;
|
||||
|
||||
|
@ -47,7 +47,7 @@ export class InteractiveSetupPlugin implements PrebootPlugin {
|
|||
this.#elasticsearch = new ElasticsearchService(
|
||||
this.initializerContext.logger.get('elasticsearch')
|
||||
);
|
||||
this.#verificationCode = new VerificationCode(
|
||||
this.#verification = new VerificationService(
|
||||
this.initializerContext.logger.get('verification')
|
||||
);
|
||||
}
|
||||
|
@ -73,6 +73,14 @@ export class InteractiveSetupPlugin implements PrebootPlugin {
|
|||
return;
|
||||
}
|
||||
|
||||
const verificationCode = this.#verification.setup();
|
||||
if (!verificationCode) {
|
||||
this.#logger.error(
|
||||
'Interactive setup mode could not be activated. Ensure Kibana has permission to write to its config folder.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let completeSetup: (result: { shouldReloadConfig: boolean }) => void;
|
||||
core.preboot.holdSetupUntilResolved(
|
||||
'Validating Elasticsearch connection configuration…',
|
||||
|
@ -93,6 +101,7 @@ export class InteractiveSetupPlugin implements PrebootPlugin {
|
|||
elasticsearch: core.elasticsearch,
|
||||
connectionCheckInterval: this.#getConfig().connectionCheck.interval,
|
||||
});
|
||||
|
||||
this.#elasticsearchConnectionStatusSubscription = elasticsearch.connectionStatus$.subscribe(
|
||||
(status) => {
|
||||
if (status === ElasticsearchConnectionStatus.Configured) {
|
||||
|
@ -104,10 +113,9 @@ export class InteractiveSetupPlugin implements PrebootPlugin {
|
|||
this.#logger.debug(
|
||||
'Starting interactive setup mode since Kibana cannot to connect to Elasticsearch at http://localhost:9200.'
|
||||
);
|
||||
const { code } = this.#verificationCode;
|
||||
const pathname = core.http.basePath.prepend('/');
|
||||
const { protocol, hostname, port } = core.http.getServerInfo();
|
||||
const url = `${protocol}://${hostname}:${port}${pathname}?code=${code}`;
|
||||
const url = `${protocol}://${hostname}:${port}${pathname}?code=${verificationCode.code}`;
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`
|
||||
|
@ -135,7 +143,7 @@ Go to ${chalk.cyanBright.underline(url)} to get started.
|
|||
preboot: { ...core.preboot, completeSetup },
|
||||
kibanaConfigWriter: new KibanaConfigWriter(configPath, this.#logger.get('kibana-config')),
|
||||
elasticsearch,
|
||||
verificationCode: this.#verificationCode,
|
||||
verificationCode,
|
||||
getConfig: this.#getConfig.bind(this),
|
||||
});
|
||||
});
|
||||
|
@ -155,5 +163,6 @@ Go to ${chalk.cyanBright.underline(url)} to get started.
|
|||
}
|
||||
|
||||
this.#elasticsearch.stop();
|
||||
this.#verification.stop();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,55 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
|
||||
import { loggingSystemMock } from 'src/core/server/mocks';
|
||||
|
||||
import { VerificationCode } from './verification_code';
|
||||
import { VerificationService } from './verification_service';
|
||||
|
||||
jest.mock('fs');
|
||||
jest.mock('@kbn/utils', () => ({
|
||||
getDataPath: jest.fn().mockReturnValue('/data/'),
|
||||
}));
|
||||
|
||||
const loggerMock = loggingSystemMock.createLogger();
|
||||
|
||||
describe('VerificationService', () => {
|
||||
describe('setup()', () => {
|
||||
it('should generate verification code', () => {
|
||||
const service = new VerificationService(loggerMock);
|
||||
const setup = service.setup();
|
||||
expect(setup).toBeInstanceOf(VerificationCode);
|
||||
});
|
||||
|
||||
it('should write verification code to disk', () => {
|
||||
const service = new VerificationService(loggerMock);
|
||||
const setup = service.setup()!;
|
||||
expect(fs.writeFileSync).toHaveBeenCalledWith('/data/verification_code', setup.code);
|
||||
});
|
||||
|
||||
it('should not return verification code if cannot write to disk', () => {
|
||||
const service = new VerificationService(loggerMock);
|
||||
(fs.writeFileSync as jest.Mock).mockImplementationOnce(() => {
|
||||
throw new Error('Write error');
|
||||
});
|
||||
const setup = service.setup();
|
||||
expect(fs.writeFileSync).toHaveBeenCalledWith('/data/verification_code', expect.anything());
|
||||
expect(setup).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('stop()', () => {
|
||||
it('should remove verification code from disk', () => {
|
||||
const service = new VerificationService(loggerMock);
|
||||
service.stop();
|
||||
expect(fs.unlinkSync).toHaveBeenCalledWith('/data/verification_code');
|
||||
});
|
||||
});
|
||||
});
|
49
src/plugins/interactive_setup/server/verification_service.ts
Normal file
49
src/plugins/interactive_setup/server/verification_service.ts
Normal file
|
@ -0,0 +1,49 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { getDataPath } from '@kbn/utils';
|
||||
import type { Logger } from 'src/core/server';
|
||||
|
||||
import { getDetailedErrorMessage } from './errors';
|
||||
import { VerificationCode } from './verification_code';
|
||||
|
||||
export class VerificationService {
|
||||
private fileName: string;
|
||||
|
||||
constructor(private readonly logger: Logger) {
|
||||
this.fileName = path.join(getDataPath(), 'verification_code');
|
||||
}
|
||||
|
||||
public setup() {
|
||||
const verificationCode = new VerificationCode(this.logger);
|
||||
|
||||
try {
|
||||
fs.writeFileSync(this.fileName, verificationCode.code);
|
||||
this.logger.debug(`Successfully wrote verification code to ${this.fileName}`);
|
||||
return verificationCode;
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to write verification code to ${this.fileName}: ${getDetailedErrorMessage(error)}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public stop() {
|
||||
try {
|
||||
fs.unlinkSync(this.fileName);
|
||||
this.logger.debug(`Successfully removed ${this.fileName}`);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
this.logger.error(`Failed to remove ${this.fileName}: ${getDetailedErrorMessage(error)}.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue