Skip to content

Commit d6e31d4

Browse files
authored
feat(persistence): implement connection and credential storage (#319)
1 parent e68b236 commit d6e31d4

File tree

7 files changed

+346
-3
lines changed

7 files changed

+346
-3
lines changed

src/CodingWithCalvin.CouchbaseExplorer/CodingWithCalvin.CouchbaseExplorer.csproj

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,9 @@
7575
<DesignTime>True</DesignTime>
7676
<DependentUpon>VSCommandTable.vsct</DependentUpon>
7777
</Compile>
78+
<Compile Include="Services\ConnectionInfo.cs" />
79+
<Compile Include="Services\ConnectionSettingsService.cs" />
80+
<Compile Include="Services\CredentialManagerService.cs" />
7881
<Compile Include="ViewModels\BucketNode.cs" />
7982
<Compile Include="ViewModels\BucketsFolderNode.cs" />
8083
<Compile Include="ViewModels\CollectionNode.cs" />
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
using System;
2+
3+
namespace CodingWithCalvin.CouchbaseExplorer.Services
4+
{
5+
/// <summary>
6+
/// Serializable connection information for VS settings storage.
7+
/// Password is NOT stored here - it goes in Windows Credential Manager.
8+
/// </summary>
9+
[Serializable]
10+
public class ConnectionInfo
11+
{
12+
public string Id { get; set; }
13+
public string Name { get; set; }
14+
public string ConnectionString { get; set; }
15+
public string Username { get; set; }
16+
public bool UseSsl { get; set; }
17+
18+
public ConnectionInfo()
19+
{
20+
Id = Guid.NewGuid().ToString();
21+
}
22+
23+
public ConnectionInfo(string name, string connectionString, string username, bool useSsl)
24+
: this()
25+
{
26+
Name = name;
27+
ConnectionString = connectionString;
28+
Username = username;
29+
UseSsl = useSsl;
30+
}
31+
}
32+
}
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Text.Json;
4+
using Microsoft.VisualStudio.Settings;
5+
using Microsoft.VisualStudio.Shell;
6+
using Microsoft.VisualStudio.Shell.Settings;
7+
8+
namespace CodingWithCalvin.CouchbaseExplorer.Services
9+
{
10+
/// <summary>
11+
/// Service for persisting connection information in Visual Studio settings.
12+
/// Passwords are NOT stored here - they go in Windows Credential Manager.
13+
/// </summary>
14+
public class ConnectionSettingsService
15+
{
16+
private const string CollectionPath = "CouchbaseExplorer";
17+
private const string ConnectionsPropertyName = "Connections";
18+
19+
private readonly WritableSettingsStore _settingsStore;
20+
21+
public ConnectionSettingsService()
22+
{
23+
ThreadHelper.ThrowIfNotOnUIThread();
24+
25+
var settingsManager = new ShellSettingsManager(ServiceProvider.GlobalProvider);
26+
_settingsStore = settingsManager.GetWritableSettingsStore(SettingsScope.UserSettings);
27+
28+
EnsureCollectionExists();
29+
}
30+
31+
private void EnsureCollectionExists()
32+
{
33+
if (!_settingsStore.CollectionExists(CollectionPath))
34+
{
35+
_settingsStore.CreateCollection(CollectionPath);
36+
}
37+
}
38+
39+
public List<ConnectionInfo> LoadConnections()
40+
{
41+
try
42+
{
43+
if (!_settingsStore.PropertyExists(CollectionPath, ConnectionsPropertyName))
44+
{
45+
return new List<ConnectionInfo>();
46+
}
47+
48+
var json = _settingsStore.GetString(CollectionPath, ConnectionsPropertyName);
49+
50+
if (string.IsNullOrEmpty(json))
51+
{
52+
return new List<ConnectionInfo>();
53+
}
54+
55+
return JsonSerializer.Deserialize<List<ConnectionInfo>>(json) ?? new List<ConnectionInfo>();
56+
}
57+
catch (Exception)
58+
{
59+
return new List<ConnectionInfo>();
60+
}
61+
}
62+
63+
public void SaveConnections(List<ConnectionInfo> connections)
64+
{
65+
var json = JsonSerializer.Serialize(connections ?? new List<ConnectionInfo>());
66+
_settingsStore.SetString(CollectionPath, ConnectionsPropertyName, json);
67+
}
68+
69+
public void AddConnection(ConnectionInfo connection)
70+
{
71+
if (connection == null)
72+
throw new ArgumentNullException(nameof(connection));
73+
74+
var connections = LoadConnections();
75+
connections.Add(connection);
76+
SaveConnections(connections);
77+
}
78+
79+
public void UpdateConnection(ConnectionInfo connection)
80+
{
81+
if (connection == null)
82+
throw new ArgumentNullException(nameof(connection));
83+
84+
var connections = LoadConnections();
85+
var index = connections.FindIndex(c => c.Id == connection.Id);
86+
87+
if (index >= 0)
88+
{
89+
connections[index] = connection;
90+
SaveConnections(connections);
91+
}
92+
}
93+
94+
public void DeleteConnection(string connectionId)
95+
{
96+
if (string.IsNullOrEmpty(connectionId))
97+
return;
98+
99+
var connections = LoadConnections();
100+
connections.RemoveAll(c => c.Id == connectionId);
101+
SaveConnections(connections);
102+
103+
CredentialManagerService.DeletePassword(connectionId);
104+
}
105+
106+
public ConnectionInfo GetConnection(string connectionId)
107+
{
108+
if (string.IsNullOrEmpty(connectionId))
109+
return null;
110+
111+
var connections = LoadConnections();
112+
return connections.Find(c => c.Id == connectionId);
113+
}
114+
}
115+
}
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
using System;
2+
using System.Runtime.InteropServices;
3+
using System.Text;
4+
5+
namespace CodingWithCalvin.CouchbaseExplorer.Services
6+
{
7+
/// <summary>
8+
/// Service for securely storing and retrieving passwords using Windows Credential Manager.
9+
/// </summary>
10+
public static class CredentialManagerService
11+
{
12+
private const string CredentialPrefix = "CouchbaseExplorer:";
13+
14+
public static void SavePassword(string connectionId, string password)
15+
{
16+
if (string.IsNullOrEmpty(connectionId))
17+
throw new ArgumentNullException(nameof(connectionId));
18+
19+
var targetName = GetTargetName(connectionId);
20+
21+
if (string.IsNullOrEmpty(password))
22+
{
23+
DeletePassword(connectionId);
24+
return;
25+
}
26+
27+
var passwordBytes = Encoding.Unicode.GetBytes(password);
28+
29+
var credential = new NativeMethods.CREDENTIAL
30+
{
31+
Type = NativeMethods.CRED_TYPE_GENERIC,
32+
TargetName = targetName,
33+
CredentialBlobSize = (uint)passwordBytes.Length,
34+
CredentialBlob = Marshal.AllocHGlobal(passwordBytes.Length),
35+
Persist = NativeMethods.CRED_PERSIST_LOCAL_MACHINE,
36+
UserName = connectionId
37+
};
38+
39+
try
40+
{
41+
Marshal.Copy(passwordBytes, 0, credential.CredentialBlob, passwordBytes.Length);
42+
43+
if (!NativeMethods.CredWrite(ref credential, 0))
44+
{
45+
throw new InvalidOperationException($"Failed to save credential. Error: {Marshal.GetLastWin32Error()}");
46+
}
47+
}
48+
finally
49+
{
50+
Marshal.FreeHGlobal(credential.CredentialBlob);
51+
}
52+
}
53+
54+
public static string GetPassword(string connectionId)
55+
{
56+
if (string.IsNullOrEmpty(connectionId))
57+
return null;
58+
59+
var targetName = GetTargetName(connectionId);
60+
61+
if (!NativeMethods.CredRead(targetName, NativeMethods.CRED_TYPE_GENERIC, 0, out var credentialPtr))
62+
{
63+
return null;
64+
}
65+
66+
try
67+
{
68+
var credential = Marshal.PtrToStructure<NativeMethods.CREDENTIAL>(credentialPtr);
69+
70+
if (credential.CredentialBlobSize > 0 && credential.CredentialBlob != IntPtr.Zero)
71+
{
72+
var passwordBytes = new byte[credential.CredentialBlobSize];
73+
Marshal.Copy(credential.CredentialBlob, passwordBytes, 0, (int)credential.CredentialBlobSize);
74+
return Encoding.Unicode.GetString(passwordBytes);
75+
}
76+
77+
return null;
78+
}
79+
finally
80+
{
81+
NativeMethods.CredFree(credentialPtr);
82+
}
83+
}
84+
85+
public static void DeletePassword(string connectionId)
86+
{
87+
if (string.IsNullOrEmpty(connectionId))
88+
return;
89+
90+
var targetName = GetTargetName(connectionId);
91+
NativeMethods.CredDelete(targetName, NativeMethods.CRED_TYPE_GENERIC, 0);
92+
}
93+
94+
private static string GetTargetName(string connectionId)
95+
{
96+
return CredentialPrefix + connectionId;
97+
}
98+
99+
private static class NativeMethods
100+
{
101+
public const int CRED_TYPE_GENERIC = 1;
102+
public const int CRED_PERSIST_LOCAL_MACHINE = 2;
103+
104+
[DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
105+
public static extern bool CredWrite([In] ref CREDENTIAL credential, [In] uint flags);
106+
107+
[DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
108+
public static extern bool CredRead(string targetName, int type, int flags, out IntPtr credential);
109+
110+
[DllImport("advapi32.dll", SetLastError = true)]
111+
public static extern bool CredDelete(string targetName, int type, int flags);
112+
113+
[DllImport("advapi32.dll", SetLastError = true)]
114+
public static extern void CredFree(IntPtr credential);
115+
116+
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
117+
public struct CREDENTIAL
118+
{
119+
public uint Flags;
120+
public int Type;
121+
public string TargetName;
122+
public string Comment;
123+
public System.Runtime.InteropServices.ComTypes.FILETIME LastWritten;
124+
public uint CredentialBlobSize;
125+
public IntPtr CredentialBlob;
126+
public int Persist;
127+
public uint AttributeCount;
128+
public IntPtr Attributes;
129+
public string TargetAlias;
130+
public string UserName;
131+
}
132+
}
133+
}
134+
}

src/CodingWithCalvin.CouchbaseExplorer/ViewModels/ConnectionDialogViewModel.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using System.Threading.Tasks;
66
using System.Windows.Input;
77
using System.Windows.Media;
8+
using CodingWithCalvin.CouchbaseExplorer.Services;
89

910
namespace CodingWithCalvin.CouchbaseExplorer.ViewModels
1011
{
@@ -238,6 +239,8 @@ public ConnectionDialogViewModel(HashSet<string> existingConnectionNames = null,
238239
_username = existingConnection.Username;
239240
_useSsl = existingConnection.UseSsl;
240241

242+
_password = CredentialManagerService.GetPassword(existingConnection.Id);
243+
241244
// Detect if it's Capella based on URL
242245
if (!string.IsNullOrEmpty(_host) && _host.Contains(".cloud.couchbase.com"))
243246
{

src/CodingWithCalvin.CouchbaseExplorer/ViewModels/ConnectionNode.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
using System;
2+
13
namespace CodingWithCalvin.CouchbaseExplorer.ViewModels
24
{
35
public class ConnectionNode : TreeNodeBase
@@ -7,6 +9,8 @@ public class ConnectionNode : TreeNodeBase
79

810
public override string NodeType => "Connection";
911

12+
public string Id { get; set; } = Guid.NewGuid().ToString();
13+
1014
public bool IsConnected
1115
{
1216
get => _isConnected;

0 commit comments

Comments
 (0)