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:
Thom Heymann 2021-09-14 21:58:25 +01:00 committed by GitHub
parent 34581ff1ed
commit db5cf95724
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 346 additions and 9 deletions

View 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');

View 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);

View 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');

View 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');

View 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" "$@"

View 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

View file

@ -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>
);

View file

@ -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);
});
});

View file

@ -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>

View file

@ -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();
}
}

View file

@ -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');
});
});
});

View 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)}.`);
}
}
}
}