From 0b9bde191714e83cc004e6f6c53ae373bb170287 Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Fri, 24 May 2019 14:46:45 +0100 Subject: [PATCH] Add ISystemPrompts component and impl for Windows Introduce the `ISystemPrompts` component which provides native/system UI prompts, starting with basic credential prompts. Include a basic implementation for Windows. --- .../BasicAuthenticationTests.cs | 103 ++++++++-- .../Windows/WindowsSystemPromptsTests.cs | 31 +++ .../Authentication/BasicAuthentication.cs | 29 ++- .../Authentication/MicrosoftAuthentication.cs | 2 +- .../CommandContext.cs | 17 ++ .../ISystemPrompts.cs | 10 + .../Interop/MacOS/MacOSSystemPrompts.cs | 13 ++ .../Interop/Windows/Native/Advapi32.cs | 1 - .../Interop/Windows/Native/CredUi.cs | 176 ++++++++++++++++++ .../Interop/Windows/Native/Win32Error.cs | 16 ++ .../Interop/Windows/WindowsSystemPrompts.cs | 150 +++++++++++++++ .../Objects/TestCommandContext.cs | 10 + .../Objects/TestSystemPrompts.cs | 17 ++ 13 files changed, 559 insertions(+), 16 deletions(-) create mode 100644 src/shared/Microsoft.Git.CredentialManager.Tests/Interop/Windows/WindowsSystemPromptsTests.cs create mode 100644 src/shared/Microsoft.Git.CredentialManager/ISystemPrompts.cs create mode 100644 src/shared/Microsoft.Git.CredentialManager/Interop/MacOS/MacOSSystemPrompts.cs create mode 100644 src/shared/Microsoft.Git.CredentialManager/Interop/Windows/Native/CredUi.cs create mode 100644 src/shared/Microsoft.Git.CredentialManager/Interop/Windows/WindowsSystemPrompts.cs create mode 100644 src/shared/TestInfrastructure/Objects/TestSystemPrompts.cs diff --git a/src/shared/Microsoft.Git.CredentialManager.Tests/Authentication/BasicAuthenticationTests.cs b/src/shared/Microsoft.Git.CredentialManager.Tests/Authentication/BasicAuthenticationTests.cs index 81c752d3fc..9e88522720 100644 --- a/src/shared/Microsoft.Git.CredentialManager.Tests/Authentication/BasicAuthenticationTests.cs +++ b/src/shared/Microsoft.Git.CredentialManager.Tests/Authentication/BasicAuthenticationTests.cs @@ -3,6 +3,7 @@ using System; using Microsoft.Git.CredentialManager.Authentication; using Microsoft.Git.CredentialManager.Tests.Objects; +using Moq; using Xunit; namespace Microsoft.Git.CredentialManager.Tests.Authentication @@ -19,49 +20,50 @@ public void BasicAuthentication_GetCredentials_NullResource_ThrowsException() } [Fact] - public void BasicAuthentication_GetCredentials_ResourceAndUserName_PasswordPromptReturnsCredentials() + public void BasicAuthentication_GetCredentials_NonDesktopSession_ResourceAndUserName_PasswordPromptReturnsCredentials() { const string testResource = "https://example.com"; const string testUserName = "john.doe"; const string testPassword = "letmein123"; - var context = new TestCommandContext(); + var context = new TestCommandContext {IsDesktopSession = false}; context.Terminal.SecretPrompts["Password"] = testPassword; var basicAuth = new BasicAuthentication(context); - GitCredential credential = basicAuth.GetCredentials(testResource, testUserName); + ICredential credential = basicAuth.GetCredentials(testResource, testUserName); Assert.Equal(testUserName, credential.UserName); Assert.Equal(testPassword, credential.Password); } [Fact] - public void BasicAuthentication_GetCredentials_Resource_UserPassPromptReturnsCredentials() + public void BasicAuthentication_GetCredentials_NonDesktopSession_Resource_UserPassPromptReturnsCredentials() { const string testResource = "https://example.com"; const string testUserName = "john.doe"; const string testPassword = "letmein123"; - var context = new TestCommandContext(); + var context = new TestCommandContext {IsDesktopSession = false}; context.Terminal.Prompts["Username"] = testUserName; context.Terminal.SecretPrompts["Password"] = testPassword; var basicAuth = new BasicAuthentication(context); - GitCredential credential = basicAuth.GetCredentials(testResource); + ICredential credential = basicAuth.GetCredentials(testResource); Assert.Equal(testUserName, credential.UserName); Assert.Equal(testPassword, credential.Password); } [Fact] - public void BasicAuthentication_GetCredentials_NoInteraction_ThrowsException() + public void BasicAuthentication_GetCredentials_NonDesktopSession_NoTerminalPrompts_ThrowsException() { const string testResource = "https://example.com"; var context = new TestCommandContext { + IsDesktopSession = false, Settings = {IsInteractionAllowed = false}, }; @@ -70,19 +72,98 @@ public void BasicAuthentication_GetCredentials_NoInteraction_ThrowsException() Assert.Throws(() => basicAuth.GetCredentials(testResource)); } - [Fact] - public void BasicAuthentication_GetCredentials_NoTerminalPrompts_ThrowsException() + [PlatformFact(Platform.Windows)] + public void BasicAuthentication_GetCredentials_DesktopSession_Resource_UserPassPromptReturnsCredentials() { const string testResource = "https://example.com"; + const string testUserName = "john.doe"; + const string testPassword = "letmein123"; var context = new TestCommandContext { - Settings = {IsTerminalPromptsEnabled = false}, + IsDesktopSession = true, + SystemPrompts = + { + CredentialPrompt = (resource, userName) => + { + Assert.Equal(testResource, resource); + Assert.Null(userName); + + return new GitCredential(testUserName, testPassword); + } + } }; var basicAuth = new BasicAuthentication(context); - Assert.Throws(() => basicAuth.GetCredentials(testResource)); + ICredential credential = basicAuth.GetCredentials(testResource); + + Assert.NotNull(credential); + Assert.Equal(testUserName, credential.UserName); + Assert.Equal(testPassword, credential.Password); + } + + [PlatformFact(Platform.Windows)] + public void BasicAuthentication_GetCredentials_DesktopSession_ResourceAndUser_PassPromptReturnsCredentials() + { + const string testResource = "https://example.com"; + const string testUserName = "john.doe"; + const string testPassword = "letmein123"; + + var context = new TestCommandContext + { + IsDesktopSession = true, + SystemPrompts = + { + CredentialPrompt = (resource, userName) => + { + Assert.Equal(testResource, resource); + Assert.Equal(testUserName, userName); + + return new GitCredential(testUserName, testPassword); + } + } + }; + + var basicAuth = new BasicAuthentication(context); + + ICredential credential = basicAuth.GetCredentials(testResource, testUserName); + + Assert.NotNull(credential); + Assert.Equal(testUserName, credential.UserName); + Assert.Equal(testPassword, credential.Password); + } + + [PlatformFact(Platform.Windows)] + public void BasicAuthentication_GetCredentials_DesktopSession_ResourceAndUser_PassPromptDiffUserReturnsCredentials() + { + const string testResource = "https://example.com"; + const string testUserName = "john.doe"; + const string newUserName = "jane.doe"; + const string testPassword = "letmein123"; + + var context = new TestCommandContext + { + IsDesktopSession = true, + SystemPrompts = + { + CredentialPrompt = (resource, userName) => + { + Assert.Equal(testResource, resource); + Assert.Equal(testUserName, userName); + + return new GitCredential(newUserName, testPassword); + } + } + }; + + var basicAuth = new BasicAuthentication(context); + + ICredential credential = basicAuth.GetCredentials(testResource, testUserName); + + Assert.NotNull(credential); + Assert.Equal(newUserName, credential.UserName); + Assert.Equal(testPassword, credential.Password); } } } diff --git a/src/shared/Microsoft.Git.CredentialManager.Tests/Interop/Windows/WindowsSystemPromptsTests.cs b/src/shared/Microsoft.Git.CredentialManager.Tests/Interop/Windows/WindowsSystemPromptsTests.cs new file mode 100644 index 0000000000..b90b473630 --- /dev/null +++ b/src/shared/Microsoft.Git.CredentialManager.Tests/Interop/Windows/WindowsSystemPromptsTests.cs @@ -0,0 +1,31 @@ +using System; +using Microsoft.Git.CredentialManager.Interop.Windows; +using Microsoft.Git.CredentialManager.Tests.Objects; +using Xunit; + +namespace Microsoft.Git.CredentialManager.Tests.Interop.Windows +{ + public class WindowsSystemPromptsTests + { + [Fact] + public void WindowsSystemPrompts_ShowCredentialPrompt_NullResource_ThrowsException() + { + var sysPrompts = new WindowsSystemPrompts(); + Assert.Throws(() => sysPrompts.ShowCredentialPrompt(null, null, out _)); + } + + [Fact] + public void WindowsSystemPrompts_ShowCredentialPrompt_EmptyResource_ThrowsException() + { + var sysPrompts = new WindowsSystemPrompts(); + Assert.Throws(() => sysPrompts.ShowCredentialPrompt(string.Empty, null, out _)); + } + + [Fact] + public void WindowsSystemPrompts_ShowCredentialPrompt_WhiteSpaceResource_ThrowsException() + { + var sysPrompts = new WindowsSystemPrompts(); + Assert.Throws(() => sysPrompts.ShowCredentialPrompt(" ", null, out _)); + } + } +} diff --git a/src/shared/Microsoft.Git.CredentialManager/Authentication/BasicAuthentication.cs b/src/shared/Microsoft.Git.CredentialManager/Authentication/BasicAuthentication.cs index 165924c9fa..ec1fa540ab 100644 --- a/src/shared/Microsoft.Git.CredentialManager/Authentication/BasicAuthentication.cs +++ b/src/shared/Microsoft.Git.CredentialManager/Authentication/BasicAuthentication.cs @@ -1,16 +1,17 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. +using System; namespace Microsoft.Git.CredentialManager.Authentication { public interface IBasicAuthentication { - GitCredential GetCredentials(string resource, string userName); + ICredential GetCredentials(string resource, string userName); } public static class BasicAuthenticationExtensions { - public static GitCredential GetCredentials(this IBasicAuthentication basicAuth, string resource) + public static ICredential GetCredentials(this IBasicAuthentication basicAuth, string resource) { return basicAuth.GetCredentials(resource, null); } @@ -26,13 +27,25 @@ public class BasicAuthentication : AuthenticationBase, IBasicAuthentication public BasicAuthentication(ICommandContext context) : base (context) { } - public GitCredential GetCredentials(string resource, string userName) + public ICredential GetCredentials(string resource, string userName) { EnsureArgument.NotNullOrWhiteSpace(resource, nameof(resource)); ThrowIfUserInteractionDisabled(); + + // TODO: we only support system GUI prompts on Windows currently + if (Context.IsDesktopSession && PlatformUtils.IsWindows()) + { + return GetCredentialsByUi(resource, userName); + } + ThrowIfTerminalPromptsDisabled(); + return GetCredentialsByTty(resource, userName); + } + + private ICredential GetCredentialsByTty(string resource, string userName) + { Context.Terminal.WriteLine("Enter basic credentials for '{0}':", resource); if (!string.IsNullOrWhiteSpace(userName)) @@ -51,5 +64,15 @@ public GitCredential GetCredentials(string resource, string userName) return new GitCredential(userName, password); } + + private ICredential GetCredentialsByUi(string resource, string userName) + { + if (!Context.SystemPrompts.ShowCredentialPrompt(resource, userName, out ICredential credential)) + { + throw new Exception("User cancelled the authentication prompt."); + } + + return credential; + } } } diff --git a/src/shared/Microsoft.Git.CredentialManager/Authentication/MicrosoftAuthentication.cs b/src/shared/Microsoft.Git.CredentialManager/Authentication/MicrosoftAuthentication.cs index 3bb4f5ec03..20be241812 100644 --- a/src/shared/Microsoft.Git.CredentialManager/Authentication/MicrosoftAuthentication.cs +++ b/src/shared/Microsoft.Git.CredentialManager/Authentication/MicrosoftAuthentication.cs @@ -129,7 +129,7 @@ private async Task GetAccessTokenInProcAsync(string authority, str } #elif NETSTANDARD // MSAL requires the application redirect URI is a loopback address to use the System WebView - if (PlatformUtils.IsDesktopSession() && app.IsSystemWebViewAvailable && redirectUri.IsLoopback) + if (Context.IsDesktopSession && app.IsSystemWebViewAvailable && redirectUri.IsLoopback) { result = await app.AcquireTokenInteractive(scopes) .WithPrompt(Prompt.SelectAccount) diff --git a/src/shared/Microsoft.Git.CredentialManager/CommandContext.cs b/src/shared/Microsoft.Git.CredentialManager/CommandContext.cs index 34d3cb272b..fea97e1bff 100644 --- a/src/shared/Microsoft.Git.CredentialManager/CommandContext.cs +++ b/src/shared/Microsoft.Git.CredentialManager/CommandContext.cs @@ -28,6 +28,11 @@ public interface ICommandContext : IDisposable /// ITerminal Terminal { get; } + /// + /// Returns true if in a GUI session/desktop is available, false otherwise. + /// + bool IsDesktopSession { get; } + /// /// Application tracing system. /// @@ -57,6 +62,11 @@ public interface ICommandContext : IDisposable /// The current process environment. /// IEnvironment Environment { get; } + + /// + /// Native UI prompts. + /// + ISystemPrompts SystemPrompts { get; } } /// @@ -76,6 +86,7 @@ public CommandContext() Environment = new WindowsEnvironment(FileSystem); Terminal = new WindowsTerminal(Trace); CredentialStore = WindowsCredentialManager.Open(); + SystemPrompts = new WindowsSystemPrompts(); } else if (PlatformUtils.IsPosix()) { @@ -83,6 +94,7 @@ public CommandContext() { FileSystem = new MacOSFileSystem(); CredentialStore = MacOSKeychain.Open(); + SystemPrompts = new MacOSSystemPrompts(); } else if (PlatformUtils.IsLinux()) { @@ -96,6 +108,7 @@ public CommandContext() string repoPath = Git.GetRepositoryPath(FileSystem.GetCurrentDirectory()); Settings = new Settings(Environment, Git, repoPath); HttpClientFactory = new HttpClientFactory(Trace, Settings, Streams); + IsDesktopSession = PlatformUtils.IsDesktopSession(); } #region ICommandContext @@ -106,6 +119,8 @@ public CommandContext() public ITerminal Terminal { get; } + public bool IsDesktopSession { get; } + public ITrace Trace { get; } public IFileSystem FileSystem { get; } @@ -118,6 +133,8 @@ public CommandContext() public IEnvironment Environment { get; } + public ISystemPrompts SystemPrompts { get; } + #endregion #region IDisposable diff --git a/src/shared/Microsoft.Git.CredentialManager/ISystemPrompts.cs b/src/shared/Microsoft.Git.CredentialManager/ISystemPrompts.cs new file mode 100644 index 0000000000..0d2f459612 --- /dev/null +++ b/src/shared/Microsoft.Git.CredentialManager/ISystemPrompts.cs @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +namespace Microsoft.Git.CredentialManager +{ + public interface ISystemPrompts + { + bool ShowCredentialPrompt(string resource, string userName, out ICredential credential); + } +} diff --git a/src/shared/Microsoft.Git.CredentialManager/Interop/MacOS/MacOSSystemPrompts.cs b/src/shared/Microsoft.Git.CredentialManager/Interop/MacOS/MacOSSystemPrompts.cs new file mode 100644 index 0000000000..7a4b893351 --- /dev/null +++ b/src/shared/Microsoft.Git.CredentialManager/Interop/MacOS/MacOSSystemPrompts.cs @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +namespace Microsoft.Git.CredentialManager.Interop.MacOS +{ + public class MacOSSystemPrompts : ISystemPrompts + { + public bool ShowCredentialPrompt(string resource, string userName, out ICredential credential) + { + throw new System.NotImplementedException(); + } + } +} diff --git a/src/shared/Microsoft.Git.CredentialManager/Interop/Windows/Native/Advapi32.cs b/src/shared/Microsoft.Git.CredentialManager/Interop/Windows/Native/Advapi32.cs index a6de2f85c5..13b79b8bfa 100644 --- a/src/shared/Microsoft.Git.CredentialManager/Interop/Windows/Native/Advapi32.cs +++ b/src/shared/Microsoft.Git.CredentialManager/Interop/Windows/Native/Advapi32.cs @@ -1,6 +1,5 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. - using System; using System.Runtime.InteropServices; using System.Runtime.InteropServices.ComTypes; diff --git a/src/shared/Microsoft.Git.CredentialManager/Interop/Windows/Native/CredUi.cs b/src/shared/Microsoft.Git.CredentialManager/Interop/Windows/Native/CredUi.cs new file mode 100644 index 0000000000..e62031a5f3 --- /dev/null +++ b/src/shared/Microsoft.Git.CredentialManager/Interop/Windows/Native/CredUi.cs @@ -0,0 +1,176 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. +using System; +using System.Runtime.InteropServices; +using System.Text; + +namespace Microsoft.Git.CredentialManager.Interop.Windows.Native +{ + // https://docs.microsoft.com/en-us/windows/desktop/api/wincred/ + public static class CredUi + { + private const string LibraryName = "credui.dll"; + + [Flags] + public enum CredentialPackFlags : uint + { + None = 0, + ProtectedCredentials = 0x1, + WowBuffer = 0x2, + GenericCredentials = 0x4, + } + + [Flags] + public enum CredentialUiFlags : uint + { + None = 0, + IncorrectPassword = 0x1, + DoNoPersist = 0x2, + RequestAdministrator = 0x4, + ExcludeCertificates = 0x8, + RequireCertificates = 0x10, + ShowSaveCheckbox = 0x40, + AlwaysShowUi = 0x80, + RequireSmartCard = 0x100, + PasswordOnlyOk = 0x200, + ValidateUsername = 0x400, + CompleteUsername = 0x800, + Persist = 0x1000, + ServerCredential = 0x4000, + ExpectConfirmation = 0x20000, + GenericCredentials = 0x40000, + UsernameTargetCredentials = 0x80000, + KeepUsername = 0x100000, + } + + public enum CredentialUiResult : uint + { + Success = 0, + Cancelled = 1223, + NoSuchLogonSession = 1312, + NotFound = 1168, + InvalidAccountName = 1315, + InsufficientBuffer = 122, + InvalidParameter = 87, + InvalidFlags = 1004, + } + + [Flags] + public enum CredentialUiWindowsFlags : uint + { + None = 0, + + /// + /// The caller is requesting that the credential provider return the user name and password in plain text. + /// + Generic = 0x0001, + + /// + /// The Save check box is displayed in the dialog box. + /// + Checkbox = 0x0002, + + /// + /// Only credential providers that support the authentication package specified by the `authPackage` parameter + /// should be enumerated. + /// + AuthPackageOnly = 0x0010, + + /// + /// Only the credentials specified by the `inAuthBuffer` parameter for the authentication package specified + /// by the `authPackage` parameter should be enumerated. + /// + /// If this flag is set, and the `inAuthBuffer` parameter is `null`, the function fails. + /// + InCredOnly = 0x0020, + + /// + /// Credential providers should enumerate only administrators. + /// + /// This value is intended for User Account Control (UAC) purposes only. + /// + /// We recommend that external callers not set this flag. + /// + EnumerateAdmins = 0x0100, + + /// + /// Only the incoming credentials for the authentication package specified by the `authPackage` parameter + /// should be enumerated. + /// + EnumerateCurrentUser = 0x0200, + + /// + /// The credential dialog box should be displayed on the secure desktop. + /// + /// This value cannot be combined with . + /// + SecurePrompt = 0x1000, + + /// + /// The credential dialog box is invoked by the SspiPromptForCredentials function, and the client is prompted + /// before a prior handshake. + /// + /// If SSPIPFC_NO_CHECKBOX is passed in the `inAuthBuffer` parameter, then the credential provider should + /// not display the check box. + /// + Preprompting = 0x2000, + + /// + /// The credential provider should align the credential BLOB pointed to by the `outAuthBuffer` parameter to + /// a 32-bit boundary, even if the provider is running on a 64-bit system. + /// + Pack32Wow = 0x10000000, + } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + public struct CredentialUiInfo + { + [MarshalAs(UnmanagedType.U4)] + public int Size; + + [MarshalAs(UnmanagedType.SysInt)] + public IntPtr Parent; + + [MarshalAs(UnmanagedType.LPWStr)] + public string MessageText; + + [MarshalAs(UnmanagedType.LPWStr)] + public string CaptionText; + + [MarshalAs(UnmanagedType.SysInt)] + public IntPtr BannerArt; + } + + [DllImport(LibraryName, EntryPoint = "CredUIPromptForWindowsCredentialsW", CallingConvention = CallingConvention.StdCall, CharSet = CharSet.Unicode, SetLastError = true)] + public static extern int CredUIPromptForWindowsCredentials( + ref CredentialUiInfo credInfo, + uint authError, + ref uint authPackage, + IntPtr inAuthBuffer, + uint inAuthBufferSize, + out IntPtr outAuthBuffer, + out uint outAuthBufferSize, + ref bool saveCredentials, + CredentialUiWindowsFlags flags); + + [DllImport(LibraryName, EntryPoint = "CredPackAuthenticationBufferW", CallingConvention = CallingConvention.StdCall, CharSet = CharSet.Unicode, SetLastError = true)] + public static extern bool CredPackAuthenticationBuffer( + CredentialPackFlags flags, + string username, + string password, + IntPtr packedCredentials, + ref int packedCredentialsSize); + + [DllImport(LibraryName, EntryPoint = "CredUnPackAuthenticationBufferW", CallingConvention = CallingConvention.StdCall, CharSet = CharSet.Unicode, SetLastError = true)] + public static extern bool CredUnPackAuthenticationBuffer( + CredentialPackFlags flags, + IntPtr authBuffer, + uint authBufferSize, + StringBuilder username, + ref int maxUsernameLen, + StringBuilder domainName, + ref int maxDomainNameLen, + StringBuilder password, + ref int maxPasswordLen); + } +} diff --git a/src/shared/Microsoft.Git.CredentialManager/Interop/Windows/Native/Win32Error.cs b/src/shared/Microsoft.Git.CredentialManager/Interop/Windows/Native/Win32Error.cs index 98ecfbb1b4..0f647e274e 100644 --- a/src/shared/Microsoft.Git.CredentialManager/Interop/Windows/Native/Win32Error.cs +++ b/src/shared/Microsoft.Git.CredentialManager/Interop/Windows/Native/Win32Error.cs @@ -79,6 +79,11 @@ internal static class Win32Error /// public const int NotFound = 1168; + /// + /// The operation was canceled by the user. + /// + public const int Cancelled = 1223; + /// /// A specified logon session does not exist. It may already have been terminated. /// @@ -94,6 +99,17 @@ public static int GetLastError(bool success) return Marshal.GetLastWin32Error(); } + /// + /// Throw an if is not true. + /// + /// Windows API return code. + /// Default error message. + /// Throw if is not true. + public static void ThrowIfError(bool succeeded, string defaultErrorMessage = "Unknown error.") + { + ThrowIfError(GetLastError(succeeded), defaultErrorMessage); + } + /// /// Throw an if is not . /// diff --git a/src/shared/Microsoft.Git.CredentialManager/Interop/Windows/WindowsSystemPrompts.cs b/src/shared/Microsoft.Git.CredentialManager/Interop/Windows/WindowsSystemPrompts.cs new file mode 100644 index 0000000000..03a936df33 --- /dev/null +++ b/src/shared/Microsoft.Git.CredentialManager/Interop/Windows/WindowsSystemPrompts.cs @@ -0,0 +1,150 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. +using System; +using System.Runtime.InteropServices; +using System.Text; +using Microsoft.Git.CredentialManager.Interop.Windows.Native; + +namespace Microsoft.Git.CredentialManager.Interop.Windows +{ + public class WindowsSystemPrompts : ISystemPrompts + { + public bool ShowCredentialPrompt(string resource, string userName, out ICredential credential) + { + EnsureArgument.NotNullOrWhiteSpace(resource, nameof(resource)); + + string message = $"Enter credentials for '{resource}'"; + + var credUiInfo = new CredUi.CredentialUiInfo + { + BannerArt = IntPtr.Zero, + CaptionText = "Git Credential Manager", // TODO: make this a parameter? + Parent = IntPtr.Zero, // TODO: get the parent window handle + MessageText = message, + Size = Marshal.SizeOf(typeof(CredUi.CredentialUiInfo)) + }; + + var packFlags = CredUi.CredentialPackFlags.None; + var uiFlags = CredUi.CredentialUiWindowsFlags.Generic; + if (!string.IsNullOrEmpty(userName)) + { + // If we are given a username, pre-populate the dialog with the given value + uiFlags |= CredUi.CredentialUiWindowsFlags.InCredOnly; + } + + IntPtr inBufferPtr = IntPtr.Zero; + uint inBufferSize; + + try + { + CreateCredentialInfoBuffer(userName, packFlags, out inBufferSize, out inBufferPtr); + + return DisplayCredentialPrompt(ref credUiInfo, ref packFlags, inBufferPtr, inBufferSize, false, uiFlags, out credential); + } + finally + { + if (inBufferPtr != IntPtr.Zero) + { + Marshal.FreeHGlobal(inBufferPtr); + } + } + } + + private static void CreateCredentialInfoBuffer(string userName, CredUi.CredentialPackFlags flags, out uint inBufferSize, out IntPtr inBufferPtr) + { + // Windows Credential API calls require at least an empty string; not null + userName = userName ?? string.Empty; + + int desiredBufSize = 0; + + // Execute with a null packed credentials pointer to determine the required buffer size. + // This method always returns false when determining the buffer size so we only fail if the size is not strictly positive. + CredUi.CredPackAuthenticationBuffer(flags, userName, string.Empty, IntPtr.Zero, ref desiredBufSize); + Win32Error.ThrowIfError(desiredBufSize > 0, "Unable to determine credential buffer size."); + + // Create a buffer of the desired size and pass the pointer and size back to the caller + inBufferSize = (uint) desiredBufSize; + inBufferPtr = Marshal.AllocHGlobal(desiredBufSize); + + Win32Error.ThrowIfError( + CredUi.CredPackAuthenticationBuffer(flags, userName, string.Empty, inBufferPtr, ref desiredBufSize), + "Unable to write to credential buffer." + ); + } + + private static bool DisplayCredentialPrompt( + ref CredUi.CredentialUiInfo credUiInfo, + ref CredUi.CredentialPackFlags packFlags, + IntPtr inBufferPtr, + uint inBufferSize, + bool saveCredentials, + CredUi.CredentialUiWindowsFlags uiFlags, + out ICredential credential) + { + uint authPackage = 0; + IntPtr outBufferPtr = IntPtr.Zero; + uint outBufferSize; + + try + { + // Open a standard Windows authentication dialog to acquire username and password credentials + int error = CredUi.CredUIPromptForWindowsCredentials( + ref credUiInfo, + 0, + ref authPackage, + inBufferPtr, + inBufferSize, + out outBufferPtr, + out outBufferSize, + ref saveCredentials, + uiFlags); + + switch (error) + { + case Win32Error.Cancelled: + credential = null; + return false; + default: + Win32Error.ThrowIfError(error, "Failed to show credential prompt."); + break; + } + + int maxUserLength = 512; + int maxPassLength = 512; + int maxDomainLength = 256; + var usernameBuffer = new StringBuilder(maxUserLength); + var domainBuffer = new StringBuilder(maxDomainLength); + var passwordBuffer = new StringBuilder(maxPassLength); + + // Unpack the result + Win32Error.ThrowIfError( + CredUi.CredUnPackAuthenticationBuffer( + packFlags, + outBufferPtr, + outBufferSize, + usernameBuffer, + ref maxUserLength, + domainBuffer, + ref maxDomainLength, + passwordBuffer, + ref maxPassLength), + "Failed to unpack credential buffer." + ); + + // Return the plaintext credential strings to the caller + string userName = usernameBuffer.ToString(); + string password = passwordBuffer.ToString(); + + credential = new GitCredential(userName, password); + return true; + } + finally + { + if (outBufferPtr != IntPtr.Zero) + { + Marshal.FreeHGlobal(outBufferPtr); + } + } + } + } +} diff --git a/src/shared/TestInfrastructure/Objects/TestCommandContext.cs b/src/shared/TestInfrastructure/Objects/TestCommandContext.cs index 57fefeae75..1cefb2503b 100644 --- a/src/shared/TestInfrastructure/Objects/TestCommandContext.cs +++ b/src/shared/TestInfrastructure/Objects/TestCommandContext.cs @@ -1,6 +1,10 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.IO; +using System.Text; namespace Microsoft.Git.CredentialManager.Tests.Objects { @@ -9,12 +13,14 @@ public class TestCommandContext : ICommandContext public TestSettings Settings { get; set; } = new TestSettings(); public TestStandardStreams Streams { get; set; } = new TestStandardStreams(); public TestTerminal Terminal { get; set; } = new TestTerminal(); + public bool IsDesktopSession { get; set; } = true; public ITrace Trace { get; set; } = new NullTrace(); public TestFileSystem FileSystem { get; set; } = new TestFileSystem(); public TestCredentialStore CredentialStore { get; set; } = new TestCredentialStore(); public TestHttpClientFactory HttpClientFactory { get; set; } = new TestHttpClientFactory(); public TestGit Git { get; set; } = new TestGit(); public TestEnvironment Environment { get; set; } = new TestEnvironment(); + public TestSystemPrompts SystemPrompts { get; set; } = new TestSystemPrompts(); #region ICommandContext @@ -24,6 +30,8 @@ public class TestCommandContext : ICommandContext ITerminal ICommandContext.Terminal => Terminal; + bool ICommandContext.IsDesktopSession => IsDesktopSession; + ITrace ICommandContext.Trace => Trace; IFileSystem ICommandContext.FileSystem => FileSystem; @@ -36,6 +44,8 @@ public class TestCommandContext : ICommandContext IEnvironment ICommandContext.Environment => Environment; + ISystemPrompts ICommandContext.SystemPrompts => SystemPrompts; + #endregion #region IDisposable diff --git a/src/shared/TestInfrastructure/Objects/TestSystemPrompts.cs b/src/shared/TestInfrastructure/Objects/TestSystemPrompts.cs new file mode 100644 index 0000000000..5cff935a9d --- /dev/null +++ b/src/shared/TestInfrastructure/Objects/TestSystemPrompts.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. +using System; + +namespace Microsoft.Git.CredentialManager.Tests.Objects +{ + public class TestSystemPrompts : ISystemPrompts + { + public Func CredentialPrompt { get; set; } = (resource, user) => null; + + public bool ShowCredentialPrompt(string resource, string userName, out ICredential credential) + { + credential = CredentialPrompt(resource, userName); + return credential != null; + } + } +}