From 4e8c06f36daabcf8421f3b8f073ab561cf6bf680 Mon Sep 17 00:00:00 2001 From: Alan Ren Date: Wed, 14 Aug 2019 16:33:22 -0700 Subject: [PATCH] add sql password validation (#6715) * add sql password validation * refactor the code * correct the user name for bdc, it is also sa * comments --- extensions/resource-deployment/package.json | 15 +- .../resource-deployment/src/interfaces.ts | 2 + .../src/ui/deploymentDialog.ts | 166 +++++++++++++----- 3 files changed, 136 insertions(+), 47 deletions(-) diff --git a/extensions/resource-deployment/package.json b/extensions/resource-deployment/package.json index b7fb4d5878..681d236afc 100644 --- a/extensions/resource-deployment/package.json +++ b/extensions/resource-deployment/package.json @@ -113,7 +113,8 @@ { "label": "%docker-sql-password-field%", "variableName": "AZDATA_NB_VAR_DOCKER_PASSWORD", - "type": "password", + "type": "sql_password", + "userName": "sa", "confirmationRequired": true, "confirmationLabel": "%docker-confirm-sql-password-field%", "defaultValue": "", @@ -163,7 +164,8 @@ { "label": "%docker-sql-password-field%", "variableName": "AZDATA_NB_VAR_DOCKER_PASSWORD", - "type": "password", + "type": "sql_password", + "userName": "sa", "confirmationRequired": true, "confirmationLabel": "%docker-confirm-sql-password-field%", "defaultValue": "", @@ -266,7 +268,8 @@ { "label": "%bdc-password-field%", "variableName": "AZDATA_NB_VAR_BDC_CONTROLLER_PASSWORD", - "type": "password", + "type": "sql_password", + "userName": "sa", "confirmationRequired": true, "confirmationLabel": "%bdc-confirm-password-field%", "defaultValue": "", @@ -370,7 +373,8 @@ { "label": "%bdc-password-field%", "variableName": "AZDATA_NB_VAR_BDC_CONTROLLER_PASSWORD", - "type": "password", + "type": "sql_password", + "userName": "sa", "confirmationRequired": true, "confirmationLabel": "%bdc-confirm-password-field%", "defaultValue": "", @@ -421,7 +425,8 @@ { "label": "%bdc-password-field%", "variableName": "AZDATA_NB_VAR_BDC_CONTROLLER_PASSWORD", - "type": "password", + "type": "sql_password", + "userName": "sa", "confirmationRequired": true, "confirmationLabel": "%bdc-confirm-password-field%", "defaultValue": "", diff --git a/extensions/resource-deployment/src/interfaces.ts b/extensions/resource-deployment/src/interfaces.ts index a8e24ef08b..5a37c40b64 100644 --- a/extensions/resource-deployment/src/interfaces.ts +++ b/extensions/resource-deployment/src/interfaces.ts @@ -62,12 +62,14 @@ export interface DialogFieldInfo { required: boolean; options: string[]; placeHolder: string; + userName?: string; //needed for sql server's password complexity requirement check, password can not include the login name. } export enum FieldType { Text = 'text', Number = 'number', DateTimeText = 'datetime_text', + SQLPassword = 'sql_password', Password = 'password', Options = 'options' } diff --git a/extensions/resource-deployment/src/ui/deploymentDialog.ts b/extensions/resource-deployment/src/ui/deploymentDialog.ts index 32f0d83167..34b3ef5876 100644 --- a/extensions/resource-deployment/src/ui/deploymentDialog.ts +++ b/extensions/resource-deployment/src/ui/deploymentDialog.ts @@ -16,6 +16,7 @@ const localize = nls.loadMessageBundle(); export class DeploymentDialog extends DialogBase { private variables: { [s: string]: string | undefined; } = {}; + private validators: (() => { valid: boolean, message: string })[] = []; constructor(context: vscode.ExtensionContext, private notebookService: INotebookService, @@ -46,6 +47,22 @@ export class DeploymentDialog extends DialogBase { ); const form = formBuilder.withLayout({ width: '100%' }).component(); + const self = this; + this._dialogObject.registerCloseValidator(() => { + const messages: string[] = []; + self.validators.forEach(validator => { + const result = validator(); + if (!result.valid) { + messages.push(result.message); + } + }); + if (messages.length > 0) { + self._dialogObject.message = { level: azdata.window.MessageLevel.Error, text: messages.join('\n') }; + } else { + self._dialogObject.message = { text: '' }; + } + return messages.length === 0; + }); return view.initializeModel(form); }); @@ -55,74 +72,119 @@ export class DeploymentDialog extends DialogBase { } private addField(view: azdata.ModelView, fields: azdata.FormComponent[], fieldInfo: DialogFieldInfo): void { + this.variables[fieldInfo.variableName] = fieldInfo.defaultValue; + let component: { component: azdata.Component, title: string }[] | azdata.Component | undefined = undefined; switch (fieldInfo.type) { case FieldType.Options: - this.addOptionsTypeField(view, fields, fieldInfo); + component = this.createOptionsTypeField(view, fieldInfo); break; case FieldType.DateTimeText: + component = this.createDateTimeTextField(view, fieldInfo); + break; case FieldType.Number: + component = this.createNumberField(view, fieldInfo); + break; + case FieldType.SQLPassword: case FieldType.Password: + component = this.createPasswordField(view, fieldInfo); + break; case FieldType.Text: - this.addInputTypeField(view, fields, fieldInfo); + component = this.createTextField(view, fieldInfo); break; + default: + throw new Error(localize('deploymentDialog.UnknownFieldTypeError', "Unknown field type: \"{0}\"", fieldInfo.type)); + } + + if (component) { + if (Array.isArray(component)) { + fields.push(...component); + } else { + fields.push({ title: fieldInfo.label, component: component }); + } + } else { + throw new Error(localize('deploymentDialog.addFieldError', "Failed to add field: \"{0}\"", fieldInfo.label)); } } - private addOptionsTypeField(view: azdata.ModelView, fields: azdata.FormComponent[], fieldInfo: DialogFieldInfo): void { - const component = view.modelBuilder.dropDown().withProperties({ values: fieldInfo.options, value: fieldInfo.defaultValue }).component(); - this.variables[fieldInfo.variableName] = fieldInfo.defaultValue; - this._toDispose.push(component.onValueChanged(() => { this.variables[fieldInfo.variableName] = component.value; })); - fields.push({ title: fieldInfo.label, component: component }); + private createOptionsTypeField(view: azdata.ModelView, fieldInfo: DialogFieldInfo): azdata.DropDownComponent { + const dropdown = view.modelBuilder.dropDown().withProperties({ values: fieldInfo.options, value: fieldInfo.defaultValue }).component(); + this._toDispose.push(dropdown.onValueChanged(() => { this.variables[fieldInfo.variableName] = dropdown.value; })); + return dropdown; } - private addInputTypeField(view: azdata.ModelView, fields: azdata.FormComponent[], fieldInfo: DialogFieldInfo): void { - let inputType: azdata.InputBoxInputType = 'text'; - let defaultValue: string | undefined = fieldInfo.defaultValue; - - switch (fieldInfo.type) { - case FieldType.Number: - inputType = 'number'; - break; - case FieldType.Password: - inputType = 'password'; - break; - case FieldType.DateTimeText: - defaultValue = fieldInfo.defaultValue + new Date().toISOString().slice(0, 19).replace(/[^0-9]/g, ''); - break; - } - const component = view.modelBuilder.inputBox().withProperties({ - value: defaultValue, ariaLabel: fieldInfo.label, inputType: inputType, min: fieldInfo.min, max: fieldInfo.max, required: fieldInfo.required, placeHolder: fieldInfo.placeHolder + private createDateTimeTextField(view: azdata.ModelView, fieldInfo: DialogFieldInfo): azdata.InputBoxComponent { + const defaultValue = fieldInfo.defaultValue + new Date().toISOString().slice(0, 19).replace(/[^0-9]/g, ''); + const input = view.modelBuilder.inputBox().withProperties({ + value: defaultValue, ariaLabel: fieldInfo.label, inputType: 'text', required: fieldInfo.required, placeHolder: fieldInfo.placeHolder }).component(); this.variables[fieldInfo.variableName] = defaultValue; - this._toDispose.push(component.onTextChanged(() => { this.variables[fieldInfo.variableName] = component.value; })); - fields.push({ title: fieldInfo.label, component: component }); + this._toDispose.push(input.onTextChanged(() => { this.variables[fieldInfo.variableName] = input.value; })); + return input; - if (fieldInfo.type === FieldType.Password && fieldInfo.confirmationRequired) { - const confirmPasswordComponent = view.modelBuilder.inputBox().withProperties({ ariaLabel: fieldInfo.confirmationLabel, inputType: inputType, required: true }).component(); - fields.push({ title: fieldInfo.confirmationLabel, component: confirmPasswordComponent }); + } - this._dialogObject.registerCloseValidator((): boolean => { - const passwordMatches = component.value === confirmPasswordComponent.value; - if (!passwordMatches) { - this._dialogObject.message = { level: azdata.window.MessageLevel.Error, text: localize('passwordNotMatch', "{0} doesn't match the confirmation password", fieldInfo.label) }; + private createNumberField(view: azdata.ModelView, fieldInfo: DialogFieldInfo): azdata.InputBoxComponent { + const input = view.modelBuilder.inputBox().withProperties({ + value: fieldInfo.defaultValue, ariaLabel: fieldInfo.label, inputType: 'number', min: fieldInfo.min, max: fieldInfo.max, required: fieldInfo.required + }).component(); + this._toDispose.push(input.onTextChanged(() => { this.variables[fieldInfo.variableName] = input.value; })); + return input; + } + + private createTextField(view: azdata.ModelView, fieldInfo: DialogFieldInfo): azdata.InputBoxComponent { + const input = view.modelBuilder.inputBox().withProperties({ + value: fieldInfo.defaultValue, ariaLabel: fieldInfo.label, inputType: 'text', min: fieldInfo.min, max: fieldInfo.max, required: fieldInfo.required, placeHolder: fieldInfo.placeHolder + }).component(); + this._toDispose.push(input.onTextChanged(() => { this.variables[fieldInfo.variableName] = input.value; })); + return input; + } + + private createPasswordField(view: azdata.ModelView, fieldInfo: DialogFieldInfo): { title: string, component: azdata.Component }[] { + const components: { title: string, component: azdata.Component }[] = []; + const passwordInput = view.modelBuilder.inputBox().withProperties({ + ariaLabel: fieldInfo.label, inputType: 'password', required: fieldInfo.required, placeHolder: fieldInfo.placeHolder + }).component(); + this._toDispose.push(passwordInput.onTextChanged(() => { this.variables[fieldInfo.variableName] = passwordInput.value; })); + components.push({ title: fieldInfo.label, component: passwordInput }); + + if (fieldInfo.type === FieldType.SQLPassword) { + const invalidPasswordMessage = localize('invalidSQLPassword', "{0} doesn't meet the password complexity requirement. For more information: https://docs.microsoft.com/sql/relational-databases/security/password-policy", fieldInfo.label); + this._toDispose.push(passwordInput.onTextChanged(() => { + if (fieldInfo.type === FieldType.SQLPassword && this.isValidSQLPassword(fieldInfo, passwordInput)) { + this.removeValidationMessage(invalidPasswordMessage); } - return passwordMatches; + })); + + this.validators.push((): { valid: boolean, message: string } => { + return { valid: this.isValidSQLPassword(fieldInfo, passwordInput), message: invalidPasswordMessage }; + }); + } + + if (fieldInfo.confirmationRequired) { + const passwordNotMatchMessage = localize('passwordNotMatch', "{0} doesn't match the confirmation password", fieldInfo.label); + + const confirmPasswordInput = view.modelBuilder.inputBox().withProperties({ ariaLabel: fieldInfo.confirmationLabel, inputType: 'password', required: true }).component(); + components.push({ title: fieldInfo.confirmationLabel, component: confirmPasswordInput }); + + this.validators.push((): { valid: boolean, message: string } => { + const passwordMatches = passwordInput.value === confirmPasswordInput.value; + return { valid: passwordMatches, message: passwordNotMatchMessage }; }); - const checkPassword = (): void => { - const passwordMatches = component.value === confirmPasswordComponent.value; - if (passwordMatches) { - this._dialogObject.message = { text: '' }; + const updatePasswordMismatchMessage = () => { + if (passwordInput.value === confirmPasswordInput.value) { + this.removeValidationMessage(passwordNotMatchMessage); } }; - this._toDispose.push(component.onTextChanged(() => { - checkPassword(); + this._toDispose.push(passwordInput.onTextChanged(() => { + updatePasswordMismatchMessage(); })); - this._toDispose.push(confirmPasswordComponent.onTextChanged(() => { - checkPassword(); + this._toDispose.push(confirmPasswordInput.onTextChanged(() => { + updatePasswordMismatchMessage(); })); } + return components; } private onComplete(): void { @@ -132,4 +194,24 @@ export class DeploymentDialog extends DialogBase { this.notebookService.launchNotebook(this.deploymentProvider.notebook); this.dispose(); } + + private isValidSQLPassword(field: DialogFieldInfo, component: azdata.InputBoxComponent): boolean { + const password = component.value!; + // Validate SQL Server password + const containsUserName = password && field.userName && password.toUpperCase().includes(field.userName.toUpperCase()); + // Instead of using one RegEx, I am seperating it to make it more readable. + const hasUpperCase = /[A-Z]/.test(password) ? 1 : 0; + const hasLowerCase = /[a-z]/.test(password) ? 1 : 0; + const hasNumbers = /\d/.test(password) ? 1 : 0; + const hasNonalphas = /\W/.test(password) ? 1 : 0; + return !containsUserName && password.length >= 8 && password.length <= 128 && (hasUpperCase + hasLowerCase + hasNumbers + hasNonalphas >= 3); + } + + private removeValidationMessage(message: string): void { + if (this._dialogObject.message && this._dialogObject.message.text.includes(message)) { + const messageWithLineBreak = message + '\n'; + const searchText = this._dialogObject.message.text.includes(messageWithLineBreak) ? messageWithLineBreak : message; + this._dialogObject.message = { text: this._dialogObject.message.text.replace(searchText, '') }; + } + } }