diff --git a/src/sql/azdata.proposed.d.ts b/src/sql/azdata.proposed.d.ts index 4a9853565f..f0edaeefed 100644 --- a/src/sql/azdata.proposed.d.ts +++ b/src/sql/azdata.proposed.d.ts @@ -403,6 +403,25 @@ declare module 'azdata' { title: string; } + export interface ConnectionProvider extends DataProvider { + /** + * Changes a user's password for the scenario of password expiration during SQL Authentication. (for Azure Data Studio use only) + */ + changePassword?(connectionUri: string, connectionInfo: ConnectionInfo, newPassword: string): Thenable; + } + + // Password Change Request ---------------------------------------------------------------------- + export interface PasswordChangeResult { + /** + * Whether the password change was successful + */ + result: boolean; + /** + * Error message if the password change was unsuccessful + */ + errorMessage?: string; + } + export interface IConnectionProfile extends ConnectionInfo { /** * The type of authentication to use when connecting diff --git a/src/sql/platform/connection/common/connectionManagement.ts b/src/sql/platform/connection/common/connectionManagement.ts index d34cbdd4d7..3711bd80eb 100644 --- a/src/sql/platform/connection/common/connectionManagement.ts +++ b/src/sql/platform/connection/common/connectionManagement.ts @@ -119,6 +119,11 @@ export interface IConnectionManagementService { */ connectAndSaveProfile(connection: IConnectionProfile, uri: string, options?: IConnectionCompletionOptions, callbacks?: IConnectionCallbacks): Promise; + /** + * Changes password of the connection profile's user. + */ + changePassword(connection: IConnectionProfile, uri: string, newPassword: string): Promise; + /** * Replaces a connectioninfo's associated uri with a new uri. */ diff --git a/src/sql/platform/connection/common/constants.ts b/src/sql/platform/connection/common/constants.ts index 9251bfffb2..8319360866 100644 --- a/src/sql/platform/connection/common/constants.ts +++ b/src/sql/platform/connection/common/constants.ts @@ -65,3 +65,6 @@ export const UNSAVED_GROUP_ID = 'unsaved'; /* Server Type Constants */ export const sqlDataWarehouse = 'Azure SQL Data Warehouse'; export const gen3Version = 12; + +/* SQL Server Password Reset Error Code */ +export const sqlPasswordErrorCode = 18488; diff --git a/src/sql/platform/connection/test/common/testConnectionManagementService.ts b/src/sql/platform/connection/test/common/testConnectionManagementService.ts index 694716c073..bf22ed8cca 100644 --- a/src/sql/platform/connection/test/common/testConnectionManagementService.ts +++ b/src/sql/platform/connection/test/common/testConnectionManagementService.ts @@ -180,6 +180,10 @@ export class TestConnectionManagementService implements IConnectionManagementSer return new Promise(() => true); } + changePassword(connection: IConnectionProfile, uri: string, newPassword: string): Promise { + return Promise.resolve(undefined!); + } + disconnectEditor(owner: IConnectableInput): Promise { return new Promise(() => true); } diff --git a/src/sql/platform/connection/test/common/testConnectionProvider.ts b/src/sql/platform/connection/test/common/testConnectionProvider.ts index 782f735852..ecf1e3a258 100644 --- a/src/sql/platform/connection/test/common/testConnectionProvider.ts +++ b/src/sql/platform/connection/test/common/testConnectionProvider.ts @@ -17,6 +17,10 @@ export class TestConnectionProvider implements azdata.ConnectionProvider { return Promise.resolve(true); } + changePassword(connectionUri: string, connectionInfo: azdata.ConnectionInfo, newPassword: string): Thenable { + return Promise.resolve({ result: false }); + } + cancelConnect(connectionUri: string): Thenable { return Promise.resolve(true); } diff --git a/src/sql/workbench/api/browser/mainThreadDataProtocol.ts b/src/sql/workbench/api/browser/mainThreadDataProtocol.ts index 32fc9ea853..355a2ca944 100644 --- a/src/sql/workbench/api/browser/mainThreadDataProtocol.ts +++ b/src/sql/workbench/api/browser/mainThreadDataProtocol.ts @@ -83,6 +83,9 @@ export class MainThreadDataProtocol extends Disposable implements MainThreadData disconnect(connectionUri: string): Thenable { return self._proxy.$disconnect(handle, connectionUri); }, + changePassword(connectionUri, connectionInfo, newPassword): Thenable { + return self._proxy.$changePassword(handle, connectionUri, connectionInfo, newPassword); + }, changeDatabase(connectionUri: string, newDatabase: string): Thenable { return self._proxy.$changeDatabase(handle, connectionUri, newDatabase); }, diff --git a/src/sql/workbench/api/common/extHostDataProtocol.ts b/src/sql/workbench/api/common/extHostDataProtocol.ts index a9f5d3e16f..729af93dd2 100644 --- a/src/sql/workbench/api/common/extHostDataProtocol.ts +++ b/src/sql/workbench/api/common/extHostDataProtocol.ts @@ -225,6 +225,13 @@ export class ExtHostDataProtocol extends ExtHostDataProtocolShape { return this._resolveProvider(handle).disconnect(connectionUri); } + override $changePassword(handle: number, connectionUri: string, connection: azdata.ConnectionInfo, newPassword: string): Thenable { + if (this.uriTransformer) { + connectionUri = this._getTransformedUri(connectionUri, this.uriTransformer.transformIncoming); + } + return this._resolveProvider(handle).changePassword(connectionUri, connection, newPassword); + } + override $cancelConnect(handle: number, connectionUri: string): Thenable { return this._resolveProvider(handle).cancelConnect(connectionUri); } diff --git a/src/sql/workbench/api/common/sqlExtHost.protocol.ts b/src/sql/workbench/api/common/sqlExtHost.protocol.ts index c60192dbe3..4604ed6131 100644 --- a/src/sql/workbench/api/common/sqlExtHost.protocol.ts +++ b/src/sql/workbench/api/common/sqlExtHost.protocol.ts @@ -67,6 +67,11 @@ export abstract class ExtHostDataProtocolShape { */ $disconnect(handle: number, connectionUri: string): Thenable { throw ni(); } + /** + * Changes password of the connection profile's user. + */ + $changePassword(handle: number, connectionUri: string, connection: azdata.ConnectionInfo, newPassword: string): Thenable { throw ni(); } + /** * Cancel a connection to a data source using the provided connectionUri string. */ diff --git a/src/sql/workbench/services/connection/browser/connectionDialogService.ts b/src/sql/workbench/services/connection/browser/connectionDialogService.ts index 18d5c0e6b1..c887600851 100644 --- a/src/sql/workbench/services/connection/browser/connectionDialogService.ts +++ b/src/sql/workbench/services/connection/browser/connectionDialogService.ts @@ -30,6 +30,7 @@ import { IConfigurationService } from 'vs/platform/configuration/common/configur import { IAdsTelemetryService } from 'sql/platform/telemetry/common/telemetry'; import * as TelemetryKeys from 'sql/platform/telemetry/common/telemetryKeys'; import { CmsConnectionController } from 'sql/workbench/services/connection/browser/cmsConnectionController'; +import { PasswordChangeDialog } from 'sql/workbench/services/connection/browser/passwordChangeDialog'; import { entries } from 'sql/base/common/collections'; import { onUnexpectedError } from 'vs/base/common/errors'; import { ILogService } from 'vs/platform/log/common/log'; @@ -241,6 +242,14 @@ export class ConnectionDialogService implements IConnectionDialogService { } } + /** + * Calls the default connect function (used by password reset dialog) + */ + public async callDefaultOnConnect(connection: IConnectionProfile, params: INewConnectionParams): Promise { + // Needed for password reset dialog to connect after changing password. + return this.handleDefaultOnConnect(params, connection); + } + private async handleDefaultOnConnect(params: INewConnectionParams, connection: IConnectionProfile): Promise { if (this.ignoreNextConnect) { this._connectionDialog.resetConnection(); @@ -275,6 +284,9 @@ export class ConnectionDialogService implements IConnectionDialogService { } else if (connectionResult && connectionResult.errorHandled) { this._connectionDialog.resetConnection(); this._logService.debug(`ConnectionDialogService: Error handled and connection reset - Error: ${connectionResult.errorMessage}`); + } else if (connection.providerName === Constants.mssqlProviderName && connectionResult.errorCode === Constants.sqlPasswordErrorCode) { + this._connectionDialog.resetConnection(); + this.launchChangePasswordDialog(connection, params); } else { this._connectionDialog.resetConnection(); this.showErrorDialog(Severity.Error, this._connectionErrorTitle, connectionResult.errorMessage, connectionResult.callStack, connectionResult.errorCode); @@ -495,6 +507,12 @@ export class ConnectionDialogService implements IConnectionDialogService { recentConnections.forEach(conn => conn.dispose()); } + public launchChangePasswordDialog(profile: IConnectionProfile, params: INewConnectionParams): void { + let dialog = this._instantiationService.createInstance(PasswordChangeDialog); + dialog.open(profile, params); + } + + private showErrorDialog(severity: Severity, headerTitle: string, message: string, messageDetails?: string, errorCode?: number): void { // Kerberos errors are currently very hard to understand, so adding handling of these to solve the common scenario // note that ideally we would have an extensible service to handle errors by error code and provider, but for now diff --git a/src/sql/workbench/services/connection/browser/connectionManagementService.ts b/src/sql/workbench/services/connection/browser/connectionManagementService.ts index da3465059e..ebd5540781 100644 --- a/src/sql/workbench/services/connection/browser/connectionManagementService.ts +++ b/src/sql/workbench/services/connection/browser/connectionManagementService.ts @@ -425,6 +425,14 @@ export class ConnectionManagementService extends Disposable implements IConnecti } } + /** + * Changes password of the connection profile's user. + */ + public changePassword(connection: interfaces.IConnectionProfile, uri: string, newPassword: string): + Promise { + return this.sendChangePasswordRequest(connection, uri, newPassword); + } + /** * Opens a new connection and saves the profile in the settings. * This method doesn't load the password because it only gets called from the @@ -1037,6 +1045,18 @@ export class ConnectionManagementService extends Disposable implements IConnecti }); } + private async sendChangePasswordRequest(connection: interfaces.IConnectionProfile, uri: string, newPassword: string): Promise { + let connectionInfo = Object.assign({}, { + options: connection.options + }); + + return this._providers.get(connection.providerName).onReady.then((provider) => { + return provider.changePassword(uri, connectionInfo, newPassword).then(result => { + return result; + }) + }); + } + private sendCancelRequest(uri: string): Promise { let providerId: string = this.getProviderIdFromUri(uri); if (!providerId) { diff --git a/src/sql/workbench/services/connection/browser/media/passwordDialog.css b/src/sql/workbench/services/connection/browser/media/passwordDialog.css new file mode 100644 index 0000000000..86574a2f56 --- /dev/null +++ b/src/sql/workbench/services/connection/browser/media/passwordDialog.css @@ -0,0 +1,31 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +.change-password-dialog { + width: 100%; + height: 100%; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.change-password-dialog .properties-content { + flex: 1 1 auto; + overflow-y: auto; +} + +.change-password-dialog .component-label { + vertical-align: middle; +} + +.change-password-dialog .components-grid { + display: grid; + /* grid-template-columns: column 1 is for label, column 2 is for component.*/ + grid-template-columns: max-content 1fr; + grid-template-rows: max-content; + grid-gap: 10px; + padding: 5px; + align-content: start; + box-sizing: border-box; +} diff --git a/src/sql/workbench/services/connection/browser/passwordChangeDialog.ts b/src/sql/workbench/services/connection/browser/passwordChangeDialog.ts new file mode 100644 index 0000000000..d314d79084 --- /dev/null +++ b/src/sql/workbench/services/connection/browser/passwordChangeDialog.ts @@ -0,0 +1,147 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import 'vs/css!./media/passwordDialog'; +import { Button } from 'sql/base/browser/ui/button/button'; +import { Modal } from 'sql/workbench/browser/modal/modal'; +import { IConnectionProfile } from 'sql/platform/connection/common/interfaces'; +import { attachInputBoxStyler } from 'sql/platform/theme/common/styler'; +import { INewConnectionParams } from 'sql/platform/connection/common/connectionManagement'; +import { IThemeService } from 'vs/platform/theme/common/themeService'; +import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; +import { IClipboardService } from 'vs/platform/clipboard/common/clipboardService'; +import { localize } from 'vs/nls'; +import { InputBox } from 'sql/base/browser/ui/inputBox/inputBox'; +import { attachButtonStyler } from 'vs/platform/theme/common/styler'; +import { IContextViewService } from 'vs/platform/contextview/browser/contextView'; +import * as DOM from 'vs/base/browser/dom'; +import { ILogService } from 'vs/platform/log/common/log'; +import { IAdsTelemetryService } from 'sql/platform/telemetry/common/telemetry'; +import { IConnectionDialogService } from 'sql/workbench/services/connection/common/connectionDialogService'; +import { IConnectionManagementService } from 'sql/platform/connection/common/connectionManagement'; +import Severity from 'vs/base/common/severity'; +import { IErrorMessageService } from 'sql/platform/errorMessage/common/errorMessageService'; +import { attachModalDialogStyler } from 'sql/workbench/common/styler'; +import { ILayoutService } from 'vs/platform/layout/browser/layoutService'; +import { ITextResourcePropertiesService } from 'vs/editor/common/services/textResourceConfiguration'; + +const dialogWidth: string = '300px'; // Width is set manually here as there is no default width for normal dialogs. +const okText: string = localize('passwordChangeDialog.ok', "OK"); +const cancelText: string = localize('passwordChangeDialog.cancel', "Cancel"); +const dialogTitle: string = localize('passwordChangeDialog.title', "Change Password"); +const newPasswordText: string = localize('passwordChangeDialog.newPassword', 'New password:'); +const confirmPasswordText: string = localize('passwordChangeDialog.confirmPassword', 'Confirm password:'); +const passwordChangeLoadText: string = localize('passwordChangeDialog.connecting', "Connecting"); +const errorHeader: string = localize('passwordChangeDialog.errorHeader', "Failure when attempting to change password"); +const errorPasswordMismatchMessage = localize('passwordChangeDialog.errorPasswordMismatchMessage', "Passwords entered do not match\n\nPress OK and enter the exact same password in both boxes."); + +export class PasswordChangeDialog extends Modal { + + private _okButton?: Button; + private _cancelButton?: Button; + private _profile: IConnectionProfile; + private _params: INewConnectionParams; + private _uri: string; + private _passwordValueText: InputBox; + private _confirmValueText: InputBox; + + constructor( + @IThemeService themeService: IThemeService, + @IClipboardService clipboardService: IClipboardService, + @IConnectionManagementService private readonly connectionManagementService: IConnectionManagementService, + @IErrorMessageService private readonly errorMessageService: IErrorMessageService, + @IConnectionDialogService private readonly connectionDialogService: IConnectionDialogService, + @ILayoutService layoutService: ILayoutService, + @IAdsTelemetryService telemetryService: IAdsTelemetryService, + @IContextKeyService contextKeyService: IContextKeyService, + @ILogService logService: ILogService, + @IContextViewService private readonly contextViewService: IContextViewService, + @ITextResourcePropertiesService textResourcePropertiesService: ITextResourcePropertiesService, + ) { + super('', '', telemetryService, layoutService, clipboardService, themeService, logService, textResourcePropertiesService, contextKeyService, { hasSpinner: true, spinnerTitle: passwordChangeLoadText, dialogStyle: 'normal', width: dialogWidth, dialogPosition: 'left' }); + } + + public open(profile: IConnectionProfile, params: INewConnectionParams) { + this._profile = profile; + this._params = params; + this._uri = this.connectionManagementService.getConnectionUri(profile); + this.render(); + this.show(); + this._okButton!.focus(); + } + + public override dispose(): void { + + } + + public override render() { + super.render(); + this.title = dialogTitle; + this._register(attachModalDialogStyler(this, this._themeService)); + this._okButton = this.addFooterButton(okText, () => this.handleOkButtonClick()); + this._cancelButton = this.addFooterButton(cancelText, () => this.hide('cancel'), 'right', true); + this._register(attachButtonStyler(this._okButton, this._themeService)); + this._register(attachButtonStyler(this._cancelButton, this._themeService)); + } + + protected renderBody(container: HTMLElement) { + const body = container.appendChild(DOM.$('.change-password-dialog')); + const contentElement = body.appendChild(DOM.$('.properties-content.components-grid')); + contentElement.appendChild(DOM.$('')).appendChild(DOM.$('span.component-label')).innerText = newPasswordText; + const passwordInputContainer = contentElement.appendChild(DOM.$('')); + this._passwordValueText = new InputBox(passwordInputContainer, this.contextViewService, { type: 'password' }); + this._register(attachInputBoxStyler(this._passwordValueText, this._themeService)); + + contentElement.appendChild(DOM.$('')).appendChild(DOM.$('span.component-label')).innerText = confirmPasswordText; + const confirmInputContainer = contentElement.appendChild(DOM.$('')); + this._confirmValueText = new InputBox(confirmInputContainer, this.contextViewService, { type: 'password' }); + this._register(attachInputBoxStyler(this._confirmValueText, this._themeService)); + } + + protected layout(height?: number): void { + // Nothing to re-layout + } + + /* espace key */ + protected override onClose() { + this.hide('close'); + } + + /* enter key */ + protected override onAccept() { + this.handleOkButtonClick(); + } + + private handleOkButtonClick(): void { + this._okButton.enabled = false; + this._cancelButton.enabled = false; + this.spinner = true; + this.changePasswordFunction(this._profile, this._params, this._uri, this._passwordValueText.value, this._confirmValueText.value).then( + () => { + this.hide('ok'); /* password changed successfully */ + }, + () => { + this._okButton.enabled = true; /* ignore, user must try again */ + this._cancelButton.enabled = true; + this.spinner = false; + } + ); + } + + private async changePasswordFunction(connection: IConnectionProfile, params: INewConnectionParams, uri: string, oldPassword: string, newPassword: string): Promise { + // Verify passwords match before changing the password. + if (oldPassword !== newPassword) { + this.errorMessageService.showDialog(Severity.Error, errorHeader, errorPasswordMismatchMessage); + return Promise.reject(new Error(errorPasswordMismatchMessage)); + } + let passwordChangeResult = await this.connectionManagementService.changePassword(connection, uri, newPassword); + if (!passwordChangeResult.result) { + this.errorMessageService.showDialog(Severity.Error, errorHeader, passwordChangeResult.errorMessage); + return Promise.reject(new Error(passwordChangeResult.errorMessage)); + } + connection.options['password'] = newPassword; + await this.connectionDialogService.callDefaultOnConnect(connection, params); + } +} diff --git a/src/sql/workbench/services/connection/common/connectionDialogService.ts b/src/sql/workbench/services/connection/common/connectionDialogService.ts index 373002ef75..b831d1f9a5 100644 --- a/src/sql/workbench/services/connection/common/connectionDialogService.ts +++ b/src/sql/workbench/services/connection/common/connectionDialogService.ts @@ -20,4 +20,9 @@ export interface IConnectionDialogService { * or dialog is closed */ openDialogAndWait(connectionManagementService: IConnectionManagementService, params?: INewConnectionParams, model?: IConnectionProfile, connectionResult?: IConnectionResult, doConnect?: boolean): Promise; + + /** + * Calls the default connect function (used by password reset dialog) + */ + callDefaultOnConnect(connection: IConnectionProfile, params: INewConnectionParams): Promise; } diff --git a/src/sql/workbench/services/connection/test/common/testConnectionDialogService.ts b/src/sql/workbench/services/connection/test/common/testConnectionDialogService.ts index b81ee95d46..c817acc073 100644 --- a/src/sql/workbench/services/connection/test/common/testConnectionDialogService.ts +++ b/src/sql/workbench/services/connection/test/common/testConnectionDialogService.ts @@ -25,4 +25,8 @@ export class TestConnectionDialogService implements IConnectionDialogService { params?: INewConnectionParams, model?: IConnectionProfile, connectionResult?: IConnectionResult): Promise { return Promise.resolve(undefined); } + + public callDefaultOnConnect(connection: IConnectionProfile, params: INewConnectionParams): Promise { + return Promise.resolve(undefined); + } }