From 6214dbe6163a0ad709074856f73d469e4ba6e5c4 Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Wed, 1 Apr 2020 16:40:30 -0400 Subject: [PATCH] [7.x] Add username/password validation to login form (#60681) (#62220) --- .../__snapshots__/login_form.test.tsx.snap | 16 ++- .../components/login_form/login_form.tsx | 47 +++++---- .../login_form/validate_login.test.ts | 63 ++++++++++++ .../components/login_form/validate_login.ts | 97 +++++++++++++++++++ 4 files changed, 201 insertions(+), 22 deletions(-) create mode 100644 x-pack/plugins/security/public/authentication/login/components/login_form/validate_login.test.ts create mode 100644 x-pack/plugins/security/public/authentication/login/components/login_form/validate_login.ts diff --git a/x-pack/plugins/security/public/authentication/login/components/login_form/__snapshots__/login_form.test.tsx.snap b/x-pack/plugins/security/public/authentication/login/components/login_form/__snapshots__/login_form.test.tsx.snap index a25498a637c2f..7b8283b7bec0e 100644 --- a/x-pack/plugins/security/public/authentication/login/components/login_form/__snapshots__/login_form.test.tsx.snap +++ b/x-pack/plugins/security/public/authentication/login/components/login_form/__snapshots__/login_form.test.tsx.snap @@ -51,6 +51,7 @@ exports[`LoginForm login selector renders as expected with login form 1`] = ` fullWidth={false} hasChildLabel={true} hasEmptyLabelSpace={false} + isInvalid={false} label={ - @@ -170,6 +174,7 @@ exports[`LoginForm renders as expected 1`] = ` fullWidth={false} hasChildLabel={true} hasEmptyLabelSpace={false} + isInvalid={false} label={ - diff --git a/x-pack/plugins/security/public/authentication/login/components/login_form/login_form.tsx b/x-pack/plugins/security/public/authentication/login/components/login_form/login_form.tsx index a028eb1ba4b70..01f5c40a69aeb 100644 --- a/x-pack/plugins/security/public/authentication/login/components/login_form/login_form.tsx +++ b/x-pack/plugins/security/public/authentication/login/components/login_form/login_form.tsx @@ -9,6 +9,7 @@ import ReactMarkdown from 'react-markdown'; import { EuiButton, EuiCallOut, + EuiFieldPassword, EuiFieldText, EuiFormRow, EuiPanel, @@ -18,6 +19,7 @@ import { import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { HttpStart, IHttpFetchError, NotificationsStart } from 'src/core/public'; +import { LoginValidator, LoginValidationResult } from './validate_login'; import { parseNext } from '../../../../../common/parse_next'; import { LoginSelector } from '../../../../../common/login_state'; @@ -40,6 +42,7 @@ interface State { message: | { type: MessageType.None } | { type: MessageType.Danger | MessageType.Info; content: string }; + formError: LoginValidationResult | null; } enum LoadingStateType { @@ -55,14 +58,21 @@ enum MessageType { } export class LoginForm extends Component { - public state: State = { - loadingState: { type: LoadingStateType.None }, - username: '', - password: '', - message: this.props.infoMessage - ? { type: MessageType.Info, content: this.props.infoMessage } - : { type: MessageType.None }, - }; + private readonly validator: LoginValidator; + + constructor(props: Props) { + super(props); + this.validator = new LoginValidator({ shouldValidate: false }); + this.state = { + loadingState: { type: LoadingStateType.None }, + username: '', + password: '', + message: this.props.infoMessage + ? { type: MessageType.Info, content: this.props.infoMessage } + : { type: MessageType.None }, + formError: null, + }; + } public render() { return ( @@ -90,6 +100,7 @@ export class LoginForm extends Component { defaultMessage="Username" /> } + {...this.validator.validateUsername(this.state.username)} > { defaultMessage="Password" /> } + {...this.validator.validatePassword(this.state.password)} > - { } } - private isFormValid = () => { - const { username, password } = this.state; - - return username && password; - }; - private onUsernameChange = (e: ChangeEvent) => { this.setState({ username: e.target.value, @@ -271,8 +276,15 @@ export class LoginForm extends Component { ) => { e.preventDefault(); - if (!this.isFormValid()) { + this.validator.enableValidation(); + + const { username, password } = this.state; + const result = this.validator.validateForLogin(username, password); + if (result.isInvalid) { + this.setState({ formError: result }); return; + } else { + this.setState({ formError: null }); } this.setState({ @@ -281,7 +293,6 @@ export class LoginForm extends Component { }); const { http } = this.props; - const { username, password } = this.state; try { await http.post('/internal/security/login', { body: JSON.stringify({ username, password }) }); diff --git a/x-pack/plugins/security/public/authentication/login/components/login_form/validate_login.test.ts b/x-pack/plugins/security/public/authentication/login/components/login_form/validate_login.test.ts new file mode 100644 index 0000000000000..6cd582bbcb4c0 --- /dev/null +++ b/x-pack/plugins/security/public/authentication/login/components/login_form/validate_login.test.ts @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { LoginValidator, LoginValidationResult } from './validate_login'; + +function expectValid(result: LoginValidationResult) { + expect(result.isInvalid).toBe(false); +} + +function expectInvalid(result: LoginValidationResult) { + expect(result.isInvalid).toBe(true); +} + +describe('LoginValidator', () => { + describe('#validateUsername', () => { + it(`returns 'valid' if validation is disabled`, () => { + expectValid(new LoginValidator().validateUsername('')); + }); + + it(`returns 'invalid' if username is missing`, () => { + expectInvalid(new LoginValidator({ shouldValidate: true }).validateUsername('')); + }); + + it(`returns 'valid' for correct usernames`, () => { + expectValid(new LoginValidator({ shouldValidate: true }).validateUsername('u')); + }); + }); + + describe('#validatePassword', () => { + it(`returns 'valid' if validation is disabled`, () => { + expectValid(new LoginValidator().validatePassword('')); + }); + + it(`returns 'invalid' if password is missing`, () => { + expectInvalid(new LoginValidator({ shouldValidate: true }).validatePassword('')); + }); + + it(`returns 'valid' for correct passwords`, () => { + expectValid(new LoginValidator({ shouldValidate: true }).validatePassword('p')); + }); + }); + + describe('#validateForLogin', () => { + it(`returns 'valid' if validation is disabled`, () => { + expectValid(new LoginValidator().validateForLogin('', '')); + }); + + it(`returns 'invalid' if username is invalid`, () => { + expectInvalid(new LoginValidator({ shouldValidate: true }).validateForLogin('', 'p')); + }); + + it(`returns 'invalid' if password is invalid`, () => { + expectInvalid(new LoginValidator({ shouldValidate: true }).validateForLogin('u', '')); + }); + + it(`returns 'valid' if username and password are valid`, () => { + expectValid(new LoginValidator({ shouldValidate: true }).validateForLogin('u', 'p')); + }); + }); +}); diff --git a/x-pack/plugins/security/public/authentication/login/components/login_form/validate_login.ts b/x-pack/plugins/security/public/authentication/login/components/login_form/validate_login.ts new file mode 100644 index 0000000000000..0873098a0ff1d --- /dev/null +++ b/x-pack/plugins/security/public/authentication/login/components/login_form/validate_login.ts @@ -0,0 +1,97 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +interface LoginValidatorOptions { + shouldValidate?: boolean; +} + +export interface LoginValidationResult { + isInvalid: boolean; + error?: string; +} + +export class LoginValidator { + private shouldValidate?: boolean; + + constructor(options: LoginValidatorOptions = {}) { + this.shouldValidate = options.shouldValidate; + } + + public enableValidation() { + this.shouldValidate = true; + } + + public disableValidation() { + this.shouldValidate = false; + } + + public validateUsername(username: string): LoginValidationResult { + if (!this.shouldValidate) { + return valid(); + } + + if (!username) { + // Elasticsearch has more stringent requirements for usernames in the Native realm. However, the login page is used for other realms, + // such as LDAP and Active Directory. Because of that, the best validation we can do here is to ensure the username is not empty. + return invalid( + i18n.translate( + 'xpack.security.authentication.login.validateLogin.requiredUsernameErrorMessage', + { + defaultMessage: 'Username is required', + } + ) + ); + } + + return valid(); + } + + public validatePassword(password: string): LoginValidationResult { + if (!this.shouldValidate) { + return valid(); + } + + if (!password) { + // Elasticsearch has more stringent requirements for passwords in the Native realm. However, the login page is used for other realms, + // such as LDAP and Active Directory. Because of that, the best validation we can do here is to ensure the password is not empty. + return invalid( + i18n.translate( + 'xpack.security.authentication.login.validateLogin.requiredPasswordErrorMessage', + { + defaultMessage: 'Password is required', + } + ) + ); + } + return valid(); + } + + public validateForLogin(username: string, password: string): LoginValidationResult { + const { isInvalid: isUsernameInvalid } = this.validateUsername(username); + const { isInvalid: isPasswordInvalid } = this.validatePassword(password); + + if (isUsernameInvalid || isPasswordInvalid) { + return invalid(); + } + + return valid(); + } +} + +function invalid(error?: string): LoginValidationResult { + return { + isInvalid: true, + error, + }; +} + +function valid(): LoginValidationResult { + return { + isInvalid: false, + }; +}