diff --git a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/Common/WebRequestPSCmdlet.Common.cs b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/Common/WebRequestPSCmdlet.Common.cs index e9eb8f77e561..a63d0e4c39bc 100644 --- a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/Common/WebRequestPSCmdlet.Common.cs +++ b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/Common/WebRequestPSCmdlet.Common.cs @@ -10,6 +10,7 @@ using System.Text; using System.Collections; using System.Globalization; +using System.Security; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; #if !CORECLR @@ -19,6 +20,32 @@ namespace Microsoft.PowerShell.Commands { + /// + /// The valid values for the -Authentication parameter for Invoke-RestMethod and Invoke-WebRequest + /// + public enum WebAuthenticationType + { + /// + /// No authentication. Default. + /// + None, + + /// + /// RFC-7617 Basic Authentication. Requires -Credential + /// + Basic, + + /// + /// RFC-6750 OAuth 2.0 Bearer Authentication. Requires -Token + /// + Bearer, + + /// + /// RFC-6750 OAuth 2.0 Bearer Authentication. Requires -Token + /// + OAuth, + } + /// /// Base class for Invoke-RestMethod and Invoke-WebRequest commands. /// @@ -61,6 +88,22 @@ public abstract partial class WebRequestPSCmdlet : PSCmdlet #region Authorization and Credentials + /// + /// Gets or sets the AllowUnencryptedAuthentication property + /// + [Parameter] + public virtual SwitchParameter AllowUnencryptedAuthentication { get; set; } + + /// + /// Gets or sets the Authentication property used to determin the Authentication method for the web session. + /// Authentication does not work with UseDefaultCredentials. + /// Authentication over unencrypted sessions requires AllowUnencryptedAuthentication. + /// Basic: Requires Credential + /// OAuth/Bearer: Requires Token + /// + [Parameter] + public virtual WebAuthenticationType Authentication { get; set; } = WebAuthenticationType.None; + /// /// gets or sets the Credential property /// @@ -94,6 +137,12 @@ public abstract partial class WebRequestPSCmdlet : PSCmdlet [Parameter] public virtual SwitchParameter SkipCertificateCheck { get; set; } + /// + /// Gets or sets the Token property. Token is required by Authentication OAuth and Bearer. + /// + [Parameter] + public virtual SecureString Token { get; set; } + #endregion #region Headers @@ -274,6 +323,38 @@ internal virtual void ValidateParameters() ThrowTerminatingError(error); } + // Authentication + if (UseDefaultCredentials && (Authentication != WebAuthenticationType.None)) + { + ErrorRecord error = GetValidationError(WebCmdletStrings.AuthenticationConflict, + "WebCmdletAuthenticationConflictException"); + ThrowTerminatingError(error); + } + if ((Authentication != WebAuthenticationType.None) && (null != Token) && (null != Credential)) + { + ErrorRecord error = GetValidationError(WebCmdletStrings.AuthenticationTokenConflict, + "WebCmdletAuthenticationTokenConflictException"); + ThrowTerminatingError(error); + } + if ((Authentication == WebAuthenticationType.Basic) && (null == Credential)) + { + ErrorRecord error = GetValidationError(WebCmdletStrings.AuthenticationCredentialNotSupplied, + "WebCmdletAuthenticationCredentialNotSuppliedException"); + ThrowTerminatingError(error); + } + if ((Authentication == WebAuthenticationType.OAuth || Authentication == WebAuthenticationType.Bearer) && (null == Token)) + { + ErrorRecord error = GetValidationError(WebCmdletStrings.AuthenticationTokenNotSupplied, + "WebCmdletAuthenticationTokenNotSuppliedException"); + ThrowTerminatingError(error); + } + if (!AllowUnencryptedAuthentication && (Authentication != WebAuthenticationType.None) && (Uri.Scheme != "https")) + { + ErrorRecord error = GetValidationError(WebCmdletStrings.AllowUnencryptedAuthenticationRequired, + "WebCmdletAllowUnencryptedAuthenticationRequiredException"); + ThrowTerminatingError(error); + } + // credentials if (UseDefaultCredentials && (null != Credential)) { @@ -389,7 +470,7 @@ internal virtual void PrepareSession() // // handle credentials // - if (null != Credential) + if (null != Credential && Authentication == WebAuthenticationType.None) { // get the relevant NetworkCredential NetworkCredential netCred = Credential.GetNetworkCredential(); @@ -398,6 +479,10 @@ internal virtual void PrepareSession() // supplying a credential overrides the UseDefaultCredentials setting WebSession.UseDefaultCredentials = false; } + else if ((null != Credential || null!= Token) && Authentication != WebAuthenticationType.None) + { + ProcessAuthentication(); + } else if (UseDefaultCredentials) { WebSession.UseDefaultCredentials = true; @@ -666,6 +751,34 @@ private bool IsCustomMethodSet() return (ParameterSetName == "CustomMethod"); } + private string GetBasicAuthorizationHeader() + { + string unencoded = String.Format("{0}:{1}", Credential.UserName, Credential.GetNetworkCredential().Password); + Byte[] bytes = Encoding.UTF8.GetBytes(unencoded); + return String.Format("Basic {0}", Convert.ToBase64String(bytes)); + } + + private string GetBearerAuthorizationHeader() + { + return String.Format("Bearer {0}", new NetworkCredential(String.Empty, Token).Password); + } + + private void ProcessAuthentication() + { + if(Authentication == WebAuthenticationType.Basic) + { + WebSession.Headers["Authorization"] = GetBasicAuthorizationHeader(); + } + else if (Authentication == WebAuthenticationType.Bearer || Authentication == WebAuthenticationType.OAuth) + { + WebSession.Headers["Authorization"] = GetBearerAuthorizationHeader(); + } + else + { + Diagnostics.Assert(false, String.Format("Unrecognized Authentication value: {0}", Authentication)); + } + } + #endregion Helper Methods } } diff --git a/src/Microsoft.PowerShell.Commands.Utility/resources/WebCmdletStrings.resx b/src/Microsoft.PowerShell.Commands.Utility/resources/WebCmdletStrings.resx index 17fccac5f44b..5ee6d6baf8ce 100644 --- a/src/Microsoft.PowerShell.Commands.Utility/resources/WebCmdletStrings.resx +++ b/src/Microsoft.PowerShell.Commands.Utility/resources/WebCmdletStrings.resx @@ -120,6 +120,21 @@ Access to the path '{0}' is denied. + + The cmdlet cannot protect plain text secrets sent over unencrypted connections. To supress this warning and send plain text secrets over unencrypted networks, reissue the command specifying the AllowUnencryptedAuthentication parameter. + + + The cmdlet cannot run because the following conflicting parameters are specified: Authentication and UseDefaultCredentials. Authentication does not support Default Credentials. Specify either Authentication or UseDefaultCredentials, then retry. + + + The cmdlet cannot run because the following parameter is not specified: Credential. The supplied Authentication type requires a Credential. Specify Credential, then retry. + + + The cmdlet cannot run because the following parameter is not specified: Token. The supplied Authentication type requires a Token. Specify Token, then retry. + + + The cmdlet cannot run because the following conflicting parameters are specified: Credential and Token. Specify either Credential or Token, then retry. + The cmdlet cannot run because the following conflicting parameters are specified: Body and InFile. Specify either Body or Infile, then retry. diff --git a/test/powershell/Modules/Microsoft.PowerShell.Utility/WebCmdlets.Tests.ps1 b/test/powershell/Modules/Microsoft.PowerShell.Utility/WebCmdlets.Tests.ps1 index a91875f8e082..927f2151c584 100644 --- a/test/powershell/Modules/Microsoft.PowerShell.Utility/WebCmdlets.Tests.ps1 +++ b/test/powershell/Modules/Microsoft.PowerShell.Utility/WebCmdlets.Tests.ps1 @@ -1286,6 +1286,115 @@ Describe "Invoke-WebRequest tests" -Tags "Feature" { } } + Context "Invoke-WebRequest -Authentication tests" { + BeforeAll { + #[SuppressMessage("Microsoft.Security", "CS002:SecretInNextLine", Justification="Demo/doc/test secret.")] + $token = "testpassword" | ConvertTo-SecureString -AsPlainText -Force + $credential = [pscredential]::new("testuser",$token) + $httpUri = Get-WebListenerUrl -Test 'Get' + $httpsUri = Get-WebListenerUrl -Test 'Get' -Https + $testCases = @( + @{Authentication = "bearer"} + @{Authentication = "OAuth"} + ) + } + + It "Verifies Invoke-WebRequest -Authentication Basic" { + $params = @{ + Uri = $httpsUri + Authentication = "Basic" + Credential = $credential + SkipCertificateCheck = $true + } + $Response = Invoke-WebRequest @params + $result = $response.Content | ConvertFrom-Json + + $result.Headers.Authorization | Should BeExactly "Basic dGVzdHVzZXI6dGVzdHBhc3N3b3Jk" + } + + It "Verifies Invoke-WebRequest -Authentication " -TestCases $testCases { + param($Authentication) + $params = @{ + Uri = $httpsUri + Authentication = $Authentication + Token = $token + SkipCertificateCheck = $true + } + $Response = Invoke-WebRequest @params + $result = $response.Content | ConvertFrom-Json + + $result.Headers.Authorization | Should BeExactly "Bearer testpassword" + } + + It "Verifies Invoke-WebRequest -Authentication does not support -UseDefaultCredentials" { + $params = @{ + Uri = $httpsUri + Token = $token + Authentication = "OAuth" + UseDefaultCredentials = $true + ErrorAction = 'Stop' + SkipCertificateCheck = $true + } + { Invoke-WebRequest @params } | ShouldBeErrorId "WebCmdletAuthenticationConflictException,Microsoft.PowerShell.Commands.InvokeWebRequestCommand" + } + + It "Verifies Invoke-WebRequest -Authentication does not support Both -Credential and -Token" { + $params = @{ + Uri = $httpsUri + Token = $token + Credential = $credential + Authentication = "OAuth" + ErrorAction = 'Stop' + SkipCertificateCheck = $true + } + { Invoke-WebRequest @params } | ShouldBeErrorId "WebCmdletAuthenticationTokenConflictException,Microsoft.PowerShell.Commands.InvokeWebRequestCommand" + } + + It "Verifies Invoke-WebRequest -Authentication requires -Token" -TestCases $testCases { + param($Authentication) + $params = @{ + Uri = $httpsUri + Authentication = $Authentication + ErrorAction = 'Stop' + SkipCertificateCheck = $true + } + { Invoke-WebRequest @params } | ShouldBeErrorId "WebCmdletAuthenticationTokenNotSuppliedException,Microsoft.PowerShell.Commands.InvokeWebRequestCommand" + } + + It "Verifies Invoke-WebRequest -Authentication Basic requires -Credential" { + $params = @{ + Uri = $httpsUri + Authentication = "Basic" + ErrorAction = 'Stop' + SkipCertificateCheck = $true + } + { Invoke-WebRequest @params } | ShouldBeErrorId "WebCmdletAuthenticationCredentialNotSuppliedException,Microsoft.PowerShell.Commands.InvokeWebRequestCommand" + } + + It "Verifies Invoke-WebRequest -Authentication Requires HTTPS" { + $params = @{ + Uri = $httpUri + Token = $token + Authentication = "OAuth" + ErrorAction = 'Stop' + } + { Invoke-WebRequest @params } | ShouldBeErrorId "WebCmdletAllowUnencryptedAuthenticationRequiredException,Microsoft.PowerShell.Commands.InvokeWebRequestCommand" + } + + It "Verifies Invoke-WebRequest -Authentication Can use HTTP with -AllowUnencryptedAuthentication" { + $params = @{ + Uri = $httpUri + Token = $token + Authentication = "OAuth" + AllowUnencryptedAuthentication = $true + } + $Response = Invoke-WebRequest @params + $result = $response.Content | ConvertFrom-Json + + $result.Headers.Authorization | Should BeExactly "Bearer testpassword" + } + } + BeforeEach { if ($env:http_proxy) { $savedHttpProxy = $env:http_proxy @@ -2097,6 +2206,112 @@ Describe "Invoke-RestMethod tests" -Tags "Feature" { } } + Context "Invoke-RestMethod -Authentication tests" { + BeforeAll { + #[SuppressMessage("Microsoft.Security", "CS002:SecretInNextLine", Justification="Demo/doc/test secret.")] + $token = "testpassword" | ConvertTo-SecureString -AsPlainText -Force + $credential = [pscredential]::new("testuser",$token) + $httpUri = Get-WebListenerUrl -Test 'Get' + $httpsUri = Get-WebListenerUrl -Test 'Get' -Https + $testCases = @( + @{Authentication = "bearer"} + @{Authentication = "OAuth"} + ) + } + + It "Verifies Invoke-RestMethod -Authentication Basic" { + $params = @{ + Uri = $httpsUri + Authentication = "Basic" + Credential = $credential + SkipCertificateCheck = $true + } + $result = Invoke-RestMethod @params + + $result.Headers.Authorization | Should BeExactly "Basic dGVzdHVzZXI6dGVzdHBhc3N3b3Jk" + } + + It "Verifies Invoke-RestMethod -Authentication " -TestCases $testCases { + param($Authentication) + $params = @{ + Uri = $httpsUri + Authentication = $Authentication + Token = $token + SkipCertificateCheck = $true + } + $result = Invoke-RestMethod @params + + $result.Headers.Authorization | Should BeExactly "Bearer testpassword" + } + + It "Verifies Invoke-RestMethod -Authentication does not support -UseDefaultCredentials" { + $params = @{ + Uri = $httpsUri + Token = $token + Authentication = "OAuth" + UseDefaultCredentials = $true + ErrorAction = 'Stop' + SkipCertificateCheck = $true + } + { Invoke-RestMethod @params } | ShouldBeErrorId "WebCmdletAuthenticationConflictException,Microsoft.PowerShell.Commands.InvokeRestMethodCommand" + } + + It "Verifies Invoke-RestMethod -Authentication does not support Both -Credential and -Token" { + $params = @{ + Uri = $httpsUri + Token = $token + Credential = $credential + Authentication = "OAuth" + ErrorAction = 'Stop' + SkipCertificateCheck = $true + } + { Invoke-RestMethod @params } | ShouldBeErrorId "WebCmdletAuthenticationTokenConflictException,Microsoft.PowerShell.Commands.InvokeRestMethodCommand" + } + + It "Verifies Invoke-RestMethod -Authentication requires -Token" -TestCases $testCases { + param($Authentication) + $params = @{ + Uri = $httpsUri + Authentication = $Authentication + ErrorAction = 'Stop' + SkipCertificateCheck = $true + } + { Invoke-RestMethod @params } | ShouldBeErrorId "WebCmdletAuthenticationTokenNotSuppliedException,Microsoft.PowerShell.Commands.InvokeRestMethodCommand" + } + + It "Verifies Invoke-RestMethod -Authentication Basic requires -Credential" { + $params = @{ + Uri = $httpsUri + Authentication = "Basic" + ErrorAction = 'Stop' + SkipCertificateCheck = $true + } + { Invoke-RestMethod @params } | ShouldBeErrorId "WebCmdletAuthenticationCredentialNotSuppliedException,Microsoft.PowerShell.Commands.InvokeRestMethodCommand" + } + + It "Verifies Invoke-RestMethod -Authentication Requires HTTPS" { + $params = @{ + Uri = $httpUri + Token = $token + Authentication = "OAuth" + ErrorAction = 'Stop' + } + { Invoke-RestMethod @params } | ShouldBeErrorId "WebCmdletAllowUnencryptedAuthenticationRequiredException,Microsoft.PowerShell.Commands.InvokeRestMethodCommand" + } + + It "Verifies Invoke-RestMethod -Authentication Can use HTTP with -AllowUnencryptedAuthentication" { + $params = @{ + Uri = $httpUri + Token = $token + Authentication = "OAuth" + AllowUnencryptedAuthentication = $true + } + $result = Invoke-RestMethod @params + + $result.Headers.Authorization | Should BeExactly "Bearer testpassword" + } + } + BeforeEach { if ($env:http_proxy) { $savedHttpProxy = $env:http_proxy