Skip to content

Commit eabe54a

Browse files
Merge pull request #9 from gjsjohnmurray/fix-7
Fix #7 - Use host keychain for password storage
2 parents 3c0f3c1 + 277304d commit eabe54a

File tree

11 files changed

+893
-8
lines changed

11 files changed

+893
-8
lines changed

package-lock.json

Lines changed: 402 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
"@types/glob": "^7.1.1",
4848
"@types/mocha": "^5.2.6",
4949
"@types/node": "^8.10.60",
50+
"@types/keytar": "^4.4.2",
5051
"glob": "^7.1.6",
5152
"mocha": "^7.1.2",
5253
"ts-loader": "^6.2.2",
@@ -56,7 +57,13 @@
5657
"vscode-test": "^1.3.0"
5758
},
5859
"main": "./out/extension",
59-
"activationEvents": [],
60+
"activationEvents": [
61+
"onCommand:intersystems-community.servermanager.testPickServer",
62+
"onCommand:intersystems-community.servermanager.testPickServerFlushingCachedCredentials",
63+
"onCommand:intersystems-community.servermanager.testPickServerDetailed",
64+
"onCommand:intersystems-community.servermanager.storePassword",
65+
"onCommand:intersystems-community.servermanager.clearPassword"
66+
],
6067
"contributes": {
6168
"configuration": {
6269
"title": "InterSystems® Server Manager",
@@ -73,23 +80,23 @@
7380
"host": "127.0.0.1",
7481
"port": 52773
7582
},
76-
"description": "Connection to default local InterSystems IRIS™ installation. Delete if unwanted."
83+
"description": "Connection to local InterSystems IRIS™ installed with default settings."
7784
},
7885
"cache": {
7986
"webServer": {
8087
"scheme": "http",
8188
"host": "127.0.0.1",
8289
"port": 57772
8390
},
84-
"description": "Connection to default local InterSystems Caché® installation. Delete if unwanted."
91+
"description": "Connection to local InterSystems Caché® installed with default settings."
8592
},
8693
"ensemble": {
8794
"webServer": {
8895
"scheme": "http",
8996
"host": "127.0.0.1",
9097
"port": 57772
9198
},
92-
"description": "Connection to default local InterSystems Ensemble® installation. Delete if unwanted."
99+
"description": "Connection to local InterSystems Ensemble® installed with default settings."
93100
},
94101
"/default": "iris"
95102
},
@@ -151,7 +158,8 @@
151158
},
152159
"password": {
153160
"type": "string",
154-
"description": "Password of username. If not set here it must be provided when connecting."
161+
"description": "Password of username.",
162+
"deprecationMessage": "Storing password in plaintext is not recommended. Instead, use the Command Palette command to store it in your keychain."
155163
},
156164
"description": {
157165
"type": "string",
@@ -173,6 +181,33 @@
173181
"additionalProperties": false
174182
}
175183
}
176-
}
184+
},
185+
"commands": [
186+
{
187+
"command": "intersystems-community.servermanager.storePassword",
188+
"category": "InterSystems Server Manager",
189+
"title": "Store Password in Keychain"
190+
},
191+
{
192+
"command": "intersystems-community.servermanager.clearPassword",
193+
"category": "InterSystems Server Manager",
194+
"title": "Clear Password from Keychain"
195+
},
196+
{
197+
"command": "intersystems-community.servermanager.testPickServer",
198+
"category": "InterSystems Server Manager",
199+
"title": "Test Server Selection"
200+
},
201+
{
202+
"command": "intersystems-community.servermanager.testPickServerFlushingCachedCredentials",
203+
"category": "InterSystems Server Manager",
204+
"title": "Test Server Selection (flush cached credentials)"
205+
},
206+
{
207+
"command": "intersystems-community.servermanager.testPickServerDetailed",
208+
"category": "InterSystems Server Manager",
209+
"title": "Test Server Selection with Details"
210+
}
211+
]
177212
}
178213
}

src/api/getServerNames.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import * as vscode from 'vscode';
2+
import { ServerName, ServerSpec } from '../extension';
3+
4+
export function getServerNames(scope?: vscode.ConfigurationScope): ServerName[] {
5+
let names: ServerName[] = [];
6+
const servers = vscode.workspace.getConfiguration('intersystems', scope).get('servers');
7+
8+
if (typeof servers === 'object' && servers) {
9+
const defaultName: string = servers['/default'] || '';
10+
if (defaultName.length > 0 && servers[defaultName]) {
11+
names.push({
12+
name: defaultName,
13+
description: `${servers[defaultName].description || ''} (default)`,
14+
detail: serverDetail(servers[defaultName])
15+
});
16+
}
17+
for (const key in servers) {
18+
if (!key.startsWith('/') && key !== defaultName) {
19+
names.push({
20+
name: key,
21+
description: servers[key].description || '',
22+
detail: serverDetail(servers[key])
23+
});
24+
}
25+
}
26+
}
27+
return names;
28+
}
29+
30+
function serverDetail(connSpec: ServerSpec): string {
31+
return `${connSpec.webServer.scheme || 'http'}://${connSpec.webServer.host}:${connSpec.webServer.port}/${connSpec.webServer.pathPrefix || ''}`;
32+
}

src/api/getServerSpec.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import * as vscode from 'vscode';
2+
import { ServerSpec } from '../extension';
3+
import { Keychain } from '../keychain';
4+
5+
interface CredentialSet {
6+
username: string,
7+
password: string
8+
}
9+
10+
export let credentialCache = new Map<string, CredentialSet>();
11+
12+
export async function getServerSpec(name: string, scope?: vscode.ConfigurationScope, flushCredentialCache: boolean = false): Promise<ServerSpec | undefined> {
13+
if (flushCredentialCache) {
14+
credentialCache[name] = undefined;
15+
}
16+
let server: ServerSpec | undefined = vscode.workspace.getConfiguration('intersystems.servers', scope).get(name);
17+
18+
// Unknown server
19+
if (!server) {
20+
return undefined;
21+
}
22+
23+
server.name = name;
24+
server.description = server.description || '';
25+
server.webServer.scheme = server.webServer.scheme || 'http';
26+
server.webServer.pathPrefix = server.webServer.pathPrefix || '';
27+
28+
// Obtain a username (including blank to try connecting anonymously)
29+
if (!server.username) {
30+
await vscode.window
31+
.showInputBox({
32+
placeHolder: `Username to connect to InterSystems server '${name}' as`,
33+
prompt: 'Leave empty to attempt unauthenticated access',
34+
ignoreFocusOut: true,
35+
})
36+
.then((username) => {
37+
if (username && server) {
38+
server.username = username;
39+
} else {
40+
return undefined;
41+
}
42+
});
43+
if (!server.username) {
44+
server.username = '';
45+
server.password = '';
46+
}
47+
}
48+
49+
// Obtain password from session cache or keychain unless trying to connect anonymously
50+
if (server.username && !server.password) {
51+
if (credentialCache[name] && credentialCache[name].username === server.username) {
52+
server.password = credentialCache[name];
53+
} else {
54+
const keychain = new Keychain(name);
55+
const password = await keychain.getPassword().then(result => {
56+
if (typeof result === 'string') {
57+
return result;
58+
} else {
59+
return undefined;
60+
}
61+
});
62+
if (password) {
63+
server.password = password;
64+
credentialCache[name] = {username: server.username, password: password};
65+
}
66+
}
67+
68+
}
69+
if (server.username && !server.password) {
70+
await vscode.window
71+
.showInputBox({
72+
password: true,
73+
placeHolder: `Password for user '${server.username}' on InterSystems server '${name}'`,
74+
validateInput: (value => {
75+
return value.length > 0 ? '' : 'Mandatory field';
76+
}),
77+
ignoreFocusOut: true,
78+
})
79+
.then((password) => {
80+
if (password && server) {
81+
server.password = password;
82+
credentialCache[name] = {username: server.username, password: password};
83+
} else {
84+
server = undefined;
85+
}
86+
})
87+
}
88+
return server;
89+
}

src/api/pickServer.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import * as vscode from 'vscode';
2+
import { getServerNames } from './getServerNames';
3+
4+
export async function pickServer(scope?: vscode.ConfigurationScope, options: vscode.QuickPickOptions = {}): Promise<string | undefined> {
5+
const names = getServerNames(scope);
6+
7+
let qpItems: vscode.QuickPickItem[] = [];
8+
9+
options.matchOnDescription = options?.matchOnDescription || true;
10+
options.placeHolder = options?.placeHolder || 'Pick an InterSystems server';
11+
options.canPickMany = false;
12+
13+
names.forEach(element => {
14+
qpItems.push({label: element.name, description: element.description, detail: options?.matchOnDetail ? element.detail : undefined});
15+
});
16+
return await vscode.window.showQuickPick(qpItems, options).then(item => {
17+
return item ? item.label : undefined;
18+
});
19+
}

src/commands/managePasswords.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import * as vscode from 'vscode';
2+
import { extensionId } from '../extension';
3+
import { Keychain } from '../keychain';
4+
import { credentialCache } from '../api/getServerSpec';
5+
6+
export async function storePassword() {
7+
const name = await commonPickServer({matchOnDetail: true});
8+
if (name) {
9+
await vscode.window
10+
.showInputBox({
11+
password: true,
12+
placeHolder: 'Password to store in keychain',
13+
prompt: `For connection to InterSystems server '${name}'`,
14+
validateInput: (value => {
15+
return value.length > 0 ? '' : 'Mandatory field';
16+
}),
17+
ignoreFocusOut: true,
18+
})
19+
.then((password) => {
20+
if (password) {
21+
credentialCache[name] = undefined;
22+
new Keychain(name).setPassword(password).then(() => {
23+
vscode.window.showInformationMessage(`Password for '${name}' stored in keychain.`);
24+
});
25+
}
26+
})
27+
28+
}
29+
}
30+
31+
export async function clearPassword() {
32+
const name = await commonPickServer({matchOnDetail: true});
33+
if (name) {
34+
credentialCache[name] = undefined;
35+
const keychain = new Keychain(name);
36+
if (!await keychain.getPassword()) {
37+
vscode.window.showWarningMessage(`No password for '${name}' found in keychain.`);
38+
} else if (await keychain.deletePassword()) {
39+
vscode.window.showInformationMessage(`Password for '${name}' removed from keychain.`);
40+
} else {
41+
vscode.window.showWarningMessage(`Failed to remove password for '${name}' from keychain.`);
42+
}
43+
}
44+
}
45+
46+
async function commonPickServer(options?: vscode.QuickPickOptions): Promise<string | undefined> {
47+
// Deliberately uses its own API to illustrate how other extensions would
48+
const serverManagerExtension = vscode.extensions.getExtension(extensionId);
49+
if (!serverManagerExtension) {
50+
vscode.window.showErrorMessage(`Extension '${extensionId}' is not installed, or has been disabled.`)
51+
return;
52+
}
53+
if (!serverManagerExtension.isActive) {
54+
serverManagerExtension.activate();
55+
}
56+
const myApi = serverManagerExtension.exports;
57+
58+
return await myApi.pickServer(undefined, options);
59+
}

src/commands/testPickServer.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import * as vscode from 'vscode';
2+
import { extensionId, ServerSpec } from '../extension';
3+
4+
export async function testPickServer() {
5+
await commonTestPickServer();
6+
}
7+
8+
export async function testPickServerWithoutCachedCredentials() {
9+
await commonTestPickServer(undefined, true);
10+
}
11+
12+
export async function testPickServerDetailed() {
13+
await commonTestPickServer({matchOnDetail: true});
14+
}
15+
16+
async function commonTestPickServer(options?: vscode.QuickPickOptions, flushCredentialCache: boolean = false) {
17+
// Deliberately uses its own API in the same way as other extensions would
18+
const serverManagerExtension = vscode.extensions.getExtension(extensionId);
19+
if (!serverManagerExtension) {
20+
vscode.window.showErrorMessage(`Extension '${extensionId}' is not installed, or has been disabled.`)
21+
return
22+
}
23+
if (!serverManagerExtension.isActive) {
24+
serverManagerExtension.activate();
25+
}
26+
const myApi = serverManagerExtension.exports;
27+
28+
const name: string = await myApi.pickServer(undefined, options);
29+
if (name) {
30+
const connSpec: ServerSpec = await myApi.getServerSpec(name, undefined, flushCredentialCache);
31+
if (connSpec) {
32+
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');
33+
}
34+
}
35+
}

0 commit comments

Comments
 (0)