Skip to content

Commit

Permalink
Merge pull request #9 from gjsjohnmurray/fix-7
Browse files Browse the repository at this point in the history
Fix #7 - Use host keychain for password storage
  • Loading branch information
gjsjohnmurray authored Jul 24, 2020
2 parents 3c0f3c1 + 277304d commit eabe54a
Show file tree
Hide file tree
Showing 11 changed files with 893 additions and 8 deletions.
402 changes: 402 additions & 0 deletions package-lock.json

Large diffs are not rendered by default.

47 changes: 41 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
"@types/glob": "^7.1.1",
"@types/mocha": "^5.2.6",
"@types/node": "^8.10.60",
"@types/keytar": "^4.4.2",
"glob": "^7.1.6",
"mocha": "^7.1.2",
"ts-loader": "^6.2.2",
Expand All @@ -56,7 +57,13 @@
"vscode-test": "^1.3.0"
},
"main": "./out/extension",
"activationEvents": [],
"activationEvents": [
"onCommand:intersystems-community.servermanager.testPickServer",
"onCommand:intersystems-community.servermanager.testPickServerFlushingCachedCredentials",
"onCommand:intersystems-community.servermanager.testPickServerDetailed",
"onCommand:intersystems-community.servermanager.storePassword",
"onCommand:intersystems-community.servermanager.clearPassword"
],
"contributes": {
"configuration": {
"title": "InterSystems® Server Manager",
Expand All @@ -73,23 +80,23 @@
"host": "127.0.0.1",
"port": 52773
},
"description": "Connection to default local InterSystems IRIS™ installation. Delete if unwanted."
"description": "Connection to local InterSystems IRIS™ installed with default settings."
},
"cache": {
"webServer": {
"scheme": "http",
"host": "127.0.0.1",
"port": 57772
},
"description": "Connection to default local InterSystems Caché® installation. Delete if unwanted."
"description": "Connection to local InterSystems Caché® installed with default settings."
},
"ensemble": {
"webServer": {
"scheme": "http",
"host": "127.0.0.1",
"port": 57772
},
"description": "Connection to default local InterSystems Ensemble® installation. Delete if unwanted."
"description": "Connection to local InterSystems Ensemble® installed with default settings."
},
"/default": "iris"
},
Expand Down Expand Up @@ -151,7 +158,8 @@
},
"password": {
"type": "string",
"description": "Password of username. If not set here it must be provided when connecting."
"description": "Password of username.",
"deprecationMessage": "Storing password in plaintext is not recommended. Instead, use the Command Palette command to store it in your keychain."
},
"description": {
"type": "string",
Expand All @@ -173,6 +181,33 @@
"additionalProperties": false
}
}
}
},
"commands": [
{
"command": "intersystems-community.servermanager.storePassword",
"category": "InterSystems Server Manager",
"title": "Store Password in Keychain"
},
{
"command": "intersystems-community.servermanager.clearPassword",
"category": "InterSystems Server Manager",
"title": "Clear Password from Keychain"
},
{
"command": "intersystems-community.servermanager.testPickServer",
"category": "InterSystems Server Manager",
"title": "Test Server Selection"
},
{
"command": "intersystems-community.servermanager.testPickServerFlushingCachedCredentials",
"category": "InterSystems Server Manager",
"title": "Test Server Selection (flush cached credentials)"
},
{
"command": "intersystems-community.servermanager.testPickServerDetailed",
"category": "InterSystems Server Manager",
"title": "Test Server Selection with Details"
}
]
}
}
32 changes: 32 additions & 0 deletions src/api/getServerNames.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import * as vscode from 'vscode';
import { ServerName, ServerSpec } from '../extension';

export function getServerNames(scope?: vscode.ConfigurationScope): ServerName[] {
let names: ServerName[] = [];
const servers = vscode.workspace.getConfiguration('intersystems', scope).get('servers');

if (typeof servers === 'object' && servers) {
const defaultName: string = servers['/default'] || '';
if (defaultName.length > 0 && servers[defaultName]) {
names.push({
name: defaultName,
description: `${servers[defaultName].description || ''} (default)`,
detail: serverDetail(servers[defaultName])
});
}
for (const key in servers) {
if (!key.startsWith('/') && key !== defaultName) {
names.push({
name: key,
description: servers[key].description || '',
detail: serverDetail(servers[key])
});
}
}
}
return names;
}

function serverDetail(connSpec: ServerSpec): string {
return `${connSpec.webServer.scheme || 'http'}://${connSpec.webServer.host}:${connSpec.webServer.port}/${connSpec.webServer.pathPrefix || ''}`;
}
89 changes: 89 additions & 0 deletions src/api/getServerSpec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import * as vscode from 'vscode';
import { ServerSpec } from '../extension';
import { Keychain } from '../keychain';

interface CredentialSet {
username: string,
password: string
}

export let credentialCache = new Map<string, CredentialSet>();

export async function getServerSpec(name: string, scope?: vscode.ConfigurationScope, flushCredentialCache: boolean = false): Promise<ServerSpec | undefined> {
if (flushCredentialCache) {
credentialCache[name] = undefined;
}
let server: ServerSpec | undefined = vscode.workspace.getConfiguration('intersystems.servers', scope).get(name);

// Unknown server
if (!server) {
return undefined;
}

server.name = name;
server.description = server.description || '';
server.webServer.scheme = server.webServer.scheme || 'http';
server.webServer.pathPrefix = server.webServer.pathPrefix || '';

// Obtain a username (including blank to try connecting anonymously)
if (!server.username) {
await vscode.window
.showInputBox({
placeHolder: `Username to connect to InterSystems server '${name}' as`,
prompt: 'Leave empty to attempt unauthenticated access',
ignoreFocusOut: true,
})
.then((username) => {
if (username && server) {
server.username = username;
} else {
return undefined;
}
});
if (!server.username) {
server.username = '';
server.password = '';
}
}

// Obtain password from session cache or keychain unless trying to connect anonymously
if (server.username && !server.password) {
if (credentialCache[name] && credentialCache[name].username === server.username) {
server.password = credentialCache[name];
} else {
const keychain = new Keychain(name);
const password = await keychain.getPassword().then(result => {
if (typeof result === 'string') {
return result;
} else {
return undefined;
}
});
if (password) {
server.password = password;
credentialCache[name] = {username: server.username, password: password};
}
}

}
if (server.username && !server.password) {
await vscode.window
.showInputBox({
password: true,
placeHolder: `Password for user '${server.username}' on InterSystems server '${name}'`,
validateInput: (value => {
return value.length > 0 ? '' : 'Mandatory field';
}),
ignoreFocusOut: true,
})
.then((password) => {
if (password && server) {
server.password = password;
credentialCache[name] = {username: server.username, password: password};
} else {
server = undefined;
}
})
}
return server;
}
19 changes: 19 additions & 0 deletions src/api/pickServer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import * as vscode from 'vscode';
import { getServerNames } from './getServerNames';

export async function pickServer(scope?: vscode.ConfigurationScope, options: vscode.QuickPickOptions = {}): Promise<string | undefined> {
const names = getServerNames(scope);

let qpItems: vscode.QuickPickItem[] = [];

options.matchOnDescription = options?.matchOnDescription || true;
options.placeHolder = options?.placeHolder || 'Pick an InterSystems server';
options.canPickMany = false;

names.forEach(element => {
qpItems.push({label: element.name, description: element.description, detail: options?.matchOnDetail ? element.detail : undefined});
});
return await vscode.window.showQuickPick(qpItems, options).then(item => {
return item ? item.label : undefined;
});
}
59 changes: 59 additions & 0 deletions src/commands/managePasswords.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import * as vscode from 'vscode';
import { extensionId } from '../extension';
import { Keychain } from '../keychain';
import { credentialCache } from '../api/getServerSpec';

export async function storePassword() {
const name = await commonPickServer({matchOnDetail: true});
if (name) {
await vscode.window
.showInputBox({
password: true,
placeHolder: 'Password to store in keychain',
prompt: `For connection to InterSystems server '${name}'`,
validateInput: (value => {
return value.length > 0 ? '' : 'Mandatory field';
}),
ignoreFocusOut: true,
})
.then((password) => {
if (password) {
credentialCache[name] = undefined;
new Keychain(name).setPassword(password).then(() => {
vscode.window.showInformationMessage(`Password for '${name}' stored in keychain.`);
});
}
})

}
}

export async function clearPassword() {
const name = await commonPickServer({matchOnDetail: true});
if (name) {
credentialCache[name] = undefined;
const keychain = new Keychain(name);
if (!await keychain.getPassword()) {
vscode.window.showWarningMessage(`No password for '${name}' found in keychain.`);
} else if (await keychain.deletePassword()) {
vscode.window.showInformationMessage(`Password for '${name}' removed from keychain.`);
} else {
vscode.window.showWarningMessage(`Failed to remove password for '${name}' from keychain.`);
}
}
}

async function commonPickServer(options?: vscode.QuickPickOptions): Promise<string | undefined> {
// Deliberately uses its own API to illustrate how other extensions would
const serverManagerExtension = vscode.extensions.getExtension(extensionId);
if (!serverManagerExtension) {
vscode.window.showErrorMessage(`Extension '${extensionId}' is not installed, or has been disabled.`)
return;
}
if (!serverManagerExtension.isActive) {
serverManagerExtension.activate();
}
const myApi = serverManagerExtension.exports;

return await myApi.pickServer(undefined, options);
}
35 changes: 35 additions & 0 deletions src/commands/testPickServer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import * as vscode from 'vscode';
import { extensionId, ServerSpec } from '../extension';

export async function testPickServer() {
await commonTestPickServer();
}

export async function testPickServerWithoutCachedCredentials() {
await commonTestPickServer(undefined, true);
}

export async function testPickServerDetailed() {
await commonTestPickServer({matchOnDetail: true});
}

async function commonTestPickServer(options?: vscode.QuickPickOptions, flushCredentialCache: boolean = false) {
// Deliberately uses its own API in the same way as other extensions would
const serverManagerExtension = vscode.extensions.getExtension(extensionId);
if (!serverManagerExtension) {
vscode.window.showErrorMessage(`Extension '${extensionId}' is not installed, or has been disabled.`)
return
}
if (!serverManagerExtension.isActive) {
serverManagerExtension.activate();
}
const myApi = serverManagerExtension.exports;

const name: string = await myApi.pickServer(undefined, options);
if (name) {
const connSpec: ServerSpec = await myApi.getServerSpec(name, undefined, flushCredentialCache);
if (connSpec) {
vscode.window.showInformationMessage(`Picked server '${connSpec.name}' at ${connSpec.webServer.scheme}://${connSpec.webServer.host}:${connSpec.webServer.port}/${connSpec.webServer.pathPrefix} ${!connSpec.username ? 'with unauthenticated access' : 'as user ' + connSpec.username }.`, 'OK');
}
}
}
Loading

0 comments on commit eabe54a

Please sign in to comment.