diff --git a/src/WebJobs.Script.WebHost/App_Data/secrets/HttpTrigger.json b/src/WebJobs.Script.WebHost/App_Data/secrets/HttpTrigger.json index 53d0cb34ad..c196563b97 100644 --- a/src/WebJobs.Script.WebHost/App_Data/secrets/HttpTrigger.json +++ b/src/WebJobs.Script.WebHost/App_Data/secrets/HttpTrigger.json @@ -1,3 +1,6 @@ { - "key": "hyexydhln844f2mb7hgsup2yf8dowlb0885mbiq1" + "keys": { + "a": "hyexydhln844f2mb7hgsup2yf8dowlb0885mbiq1", + "b": "m3vg59azmxzxb8ofwwjeg738f654qjve0bwmyhte" + } } \ No newline at end of file diff --git a/src/WebJobs.Script.WebHost/App_Data/secrets/WebHook-Generic.json b/src/WebJobs.Script.WebHost/App_Data/secrets/WebHook-Generic.json index 58e0303edb..a6075654bf 100644 --- a/src/WebJobs.Script.WebHost/App_Data/secrets/WebHook-Generic.json +++ b/src/WebJobs.Script.WebHost/App_Data/secrets/WebHook-Generic.json @@ -1,3 +1,6 @@ { - "key": "1388a6b0d05eca2237f10e4a4641260b0a08f3a5" + "keys": { + "a": "1388a6b0d05eca2237f10e4a4641260b0a08f3a5", + "b": "5cv9ac6o0c482yocidjxdjyhlvys516p4k8vtgm1" + } } \ No newline at end of file diff --git a/src/WebJobs.Script.WebHost/App_Data/secrets/host.json b/src/WebJobs.Script.WebHost/App_Data/secrets/host.json index 858d29e96e..e1d0567d4c 100644 --- a/src/WebJobs.Script.WebHost/App_Data/secrets/host.json +++ b/src/WebJobs.Script.WebHost/App_Data/secrets/host.json @@ -1,4 +1,4 @@ { - "masterKey": "t8laajal0a1ajkgzoqlfv5gxr4ebhqozebw4qzdy", - "functionKey": "zlnu496ve212kk1p84ncrtdvmtpembduqp25ajjc" + "masterKey": "t8laajal0a1ajkgzoqlfv5gxr4ebhqozebw4qzdy", + "functionKey": "zlnu496ve212kk1p84ncrtdvmtpembduqp25ajjc" } diff --git a/src/WebJobs.Script.WebHost/App_Data/secrets/httptrigger-disabled.json b/src/WebJobs.Script.WebHost/App_Data/secrets/httptrigger-disabled.json new file mode 100644 index 0000000000..0403df1b5c --- /dev/null +++ b/src/WebJobs.Script.WebHost/App_Data/secrets/httptrigger-disabled.json @@ -0,0 +1,4 @@ +{ + "key": "wEmJalWXnxeG4RA4KzAXvM1rzpaCm7EWfDUiw/pf32bA9bd/a6O2dg==", + "keys": null +} \ No newline at end of file diff --git a/src/WebJobs.Script.WebHost/App_Data/secrets/webhook-azure-csharp.json b/src/WebJobs.Script.WebHost/App_Data/secrets/webhook-azure-csharp.json index 15174e1f36..5caf9c06a4 100644 --- a/src/WebJobs.Script.WebHost/App_Data/secrets/webhook-azure-csharp.json +++ b/src/WebJobs.Script.WebHost/App_Data/secrets/webhook-azure-csharp.json @@ -1,3 +1,3 @@ { - "key": "yKjiimZjC1FQoGlaIj8TUfGltnPE/f2LhgZNq6Fw9/XfAOGHmSgUlQ==" + "key": "lr5jlquf0ynr3p7jsu012kif4auv21qfivjftg8e" } \ No newline at end of file diff --git a/src/WebJobs.Script.WebHost/Filters/AuthorizationLevelAttribute.cs b/src/WebJobs.Script.WebHost/Filters/AuthorizationLevelAttribute.cs index cc73bc2599..22f99c185b 100644 --- a/src/WebJobs.Script.WebHost/Filters/AuthorizationLevelAttribute.cs +++ b/src/WebJobs.Script.WebHost/Filters/AuthorizationLevelAttribute.cs @@ -57,40 +57,45 @@ internal static AuthorizationLevel GetAuthorizationLevel(HttpRequestMessage requ // first see if a key value is specified via headers or query string (header takes precidence) IEnumerable values; string keyValue = null; + string keyId = null; if (request.Headers.TryGetValues(FunctionsKeyHeaderName, out values)) { + // TODO: also allow keyId to be specified via header + keyValue = values.FirstOrDefault(); } else { var queryParameters = request.GetQueryNameValuePairs().ToDictionary(p => p.Key, p => p.Value, StringComparer.OrdinalIgnoreCase); queryParameters.TryGetValue("code", out keyValue); + queryParameters.TryGetValue("id", out keyId); } + // if a key has been specified on the request, validate it if (!string.IsNullOrEmpty(keyValue)) { - // see if the key specified is the master key HostSecrets hostSecrets = secretManager.GetHostSecrets(); - if (!string.IsNullOrEmpty(hostSecrets.MasterKey) && - SecretEqual(keyValue, hostSecrets.MasterKey)) + if (hostSecrets != null) { - return AuthorizationLevel.Admin; - } + // see if the key specified matches the master key + if (SecretEqual(keyValue, hostSecrets.MasterKey)) + { + return AuthorizationLevel.Admin; + } - // see if the key specified matches the host function key - if (!string.IsNullOrEmpty(hostSecrets.FunctionKey) && - SecretEqual(keyValue, hostSecrets.FunctionKey)) - { - return AuthorizationLevel.Function; + // see if the key specified matches the host function key + if (SecretEqual(keyValue, hostSecrets.FunctionKey)) + { + return AuthorizationLevel.Function; + } } - // if there is a function specific key specified try to match against that + // see if the specified key matches the function specific key if (functionName != null) { FunctionSecrets functionSecrets = secretManager.GetFunctionSecrets(functionName); if (functionSecrets != null && - !string.IsNullOrEmpty(functionSecrets.Key) && - SecretEqual(keyValue, functionSecrets.Key)) + SecretEqual(keyValue, functionSecrets.GetKeyValue(keyId))) { return AuthorizationLevel.Function; } @@ -116,7 +121,8 @@ private static bool SecretEqual(string inputA, string inputB) return true; } - if (inputA == null || inputB == null || inputA.Length != inputB.Length) + if (string.IsNullOrEmpty(inputA) || string.IsNullOrEmpty(inputB) || + inputA.Length != inputB.Length) { return false; } diff --git a/src/WebJobs.Script.WebHost/FunctionSecrets.cs b/src/WebJobs.Script.WebHost/FunctionSecrets.cs index 7d6d10f705..8e2e0b4214 100644 --- a/src/WebJobs.Script.WebHost/FunctionSecrets.cs +++ b/src/WebJobs.Script.WebHost/FunctionSecrets.cs @@ -1,13 +1,36 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the MIT License. See License.txt in the project root for license information. +using System.Collections.Generic; using Newtonsoft.Json; namespace WebJobs.Script.WebHost { public class FunctionSecrets { + /// + /// Gets or sets the function specific key value. These keys only allow invocation of + /// the single function they apply to. + /// Can contain either a single key value, or multiple comma separated values. + /// [JsonProperty(PropertyName = "key")] public string Key { get; set; } + + [JsonProperty(PropertyName = "keys")] + public Dictionary Keys { get; set; } + + public string GetKeyValue(string keyId) + { + string key = null; + if (keyId != null && Keys != null && + Keys.TryGetValue(keyId, out key)) + { + return key; + } + else + { + return Key; + } + } } } \ No newline at end of file diff --git a/src/WebJobs.Script.WebHost/GlobalSuppressions.cs b/src/WebJobs.Script.WebHost/GlobalSuppressions.cs index 7a7a8cc6ae..720633ab8d 100644 --- a/src/WebJobs.Script.WebHost/GlobalSuppressions.cs +++ b/src/WebJobs.Script.WebHost/GlobalSuppressions.cs @@ -21,3 +21,4 @@ [assembly: System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Reliability", "CA2000:Dispose objects before losing scope", Scope = "member", Target = "WebJobs.Script.WebHost.App_Start.AutofacBootstrap.#Initialize(Autofac.ContainerBuilder,WebJobs.Script.WebHost.WebHostSettings)")] [assembly: System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Usage", "CA2227:CollectionPropertiesShouldBeReadOnly", Scope = "member", Target = "WebJobs.Script.WebHost.Models.HostStatus.#Errors")] [assembly: System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Performance", "CA1810:InitializeReferenceTypeStaticFieldsInline", Scope = "member", Target = "WebJobs.Script.WebHost.Diagnostics.MetricsEventManager.#.cctor()")] +[assembly: System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Usage", "CA2227:CollectionPropertiesShouldBeReadOnly", Scope = "member", Target = "WebJobs.Script.WebHost.FunctionSecrets.#Keys")] \ No newline at end of file diff --git a/src/WebJobs.Script.WebHost/HostSecrets.cs b/src/WebJobs.Script.WebHost/HostSecrets.cs index 6a6e8e2736..7dbc706250 100644 --- a/src/WebJobs.Script.WebHost/HostSecrets.cs +++ b/src/WebJobs.Script.WebHost/HostSecrets.cs @@ -7,8 +7,17 @@ namespace WebJobs.Script.WebHost { public class HostSecrets { + /// + /// Gets or sets the host master (admin) key value. This key allows invocation of + /// any function, and also permit access to additional admin operations. + /// [JsonProperty(PropertyName = "masterKey")] public string MasterKey { get; set; } + + /// + /// Gets or sets the host level function key value. This key allows invocation of + /// any function. + /// [JsonProperty(PropertyName = "functionKey")] public string FunctionKey { get; set; } } diff --git a/src/WebJobs.Script.WebHost/KeyValuesJsonConverter.cs b/src/WebJobs.Script.WebHost/KeyValuesJsonConverter.cs new file mode 100644 index 0000000000..85962b8b99 --- /dev/null +++ b/src/WebJobs.Script.WebHost/KeyValuesJsonConverter.cs @@ -0,0 +1,75 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Globalization; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace WebJobs.Script.WebHost +{ + public class KeyValuesJsonConverter : JsonConverter + { + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + { + throw new NotSupportedException(); + //base.WriteJson(writer, value, serializer); + //WriteValue(writer, value); + } + + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + { + return ReadValue(reader); + } + + private object ReadValue(JsonReader reader) + { + while (reader.TokenType == JsonToken.Comment) + { + if (!reader.Read()) + { + throw new JsonSerializationException("Unexpected Token when converting IDictionary"); + } + } + + switch (reader.TokenType) + { + case JsonToken.StartArray: + return ReadArray(reader); + case JsonToken.String: + return reader.Value; + default: + string msg = string.Format(CultureInfo.InvariantCulture, "Unexpected token when converting IDictionary: {0}", reader.TokenType); + throw new JsonSerializationException(msg); + } + } + + private object ReadArray(JsonReader reader) + { + IList list = new List(); + + while (reader.Read()) + { + switch (reader.TokenType) + { + case JsonToken.Comment: + break; + default: + var value = ReadValue(reader); + list.Add(value); + break; + case JsonToken.EndArray: + return list; + } + } + + throw new JsonSerializationException("Unexpected end when reading IDictionary"); + } + + public override bool CanConvert(Type objectType) + { + return typeof(IList).IsAssignableFrom(objectType); + } + } +} diff --git a/src/WebJobs.Script.WebHost/SecretManager.cs b/src/WebJobs.Script.WebHost/SecretManager.cs index f31429722f..463ac40251 100644 --- a/src/WebJobs.Script.WebHost/SecretManager.cs +++ b/src/WebJobs.Script.WebHost/SecretManager.cs @@ -89,6 +89,7 @@ public virtual FunctionSecrets GetFunctionSecrets(string functionName) else { // initialize with new secrets and save it + // TODO: generate in the new file format? secrets = new FunctionSecrets { Key = GenerateSecretString() diff --git a/src/WebJobs.Script.WebHost/WebHooks/DynamicWebHookReceiverConfig.cs b/src/WebJobs.Script.WebHost/WebHooks/DynamicWebHookReceiverConfig.cs index 36944a0c07..0395ad6d88 100644 --- a/src/WebJobs.Script.WebHost/WebHooks/DynamicWebHookReceiverConfig.cs +++ b/src/WebJobs.Script.WebHost/WebHooks/DynamicWebHookReceiverConfig.cs @@ -17,13 +17,23 @@ public DynamicWebHookReceiverConfig(SecretManager secretManager) public Task GetReceiverConfigAsync(string name, string id) { + string functionName = id; + string keyId = null; + int idx = id.IndexOf(':'); + if (idx > 0) + { + functionName = id.Substring(0, idx); + keyId = id.Substring(idx + 1); + } + // "id" will be the function name // we ignore the "name" parameter since we only allow a function // to be mapped to a single receiver - FunctionSecrets secrets = _secretManager.GetFunctionSecrets(id); + FunctionSecrets secrets = _secretManager.GetFunctionSecrets(functionName); if (secrets != null) { - return Task.FromResult(secrets.Key); + string key = secrets.GetKeyValue(keyId); + return Task.FromResult(key); } return null; diff --git a/src/WebJobs.Script.WebHost/WebHooks/WebHookReceiverManager.cs b/src/WebJobs.Script.WebHost/WebHooks/WebHookReceiverManager.cs index 772a7d6a47..ef9ee7eb84 100644 --- a/src/WebJobs.Script.WebHost/WebHooks/WebHookReceiverManager.cs +++ b/src/WebJobs.Script.WebHost/WebHooks/WebHookReceiverManager.cs @@ -79,6 +79,14 @@ public async Task HandleRequestAsync(FunctionDescriptor fun await request.Content.LoadIntoBufferAsync(); string receiverId = function.Name.ToLowerInvariant(); + + Dictionary queryParams = request.GetQueryNameValuePairs().ToDictionary(p => p.Key, p => p.Value, StringComparer.OrdinalIgnoreCase); + string keyId = null; + if (queryParams.TryGetValue("id", out keyId)) + { + receiverId += ":" + keyId; + } + return await receiver.ReceiveAsync(receiverId, context, request); } diff --git a/src/WebJobs.Script.WebHost/WebJobs.Script.WebHost.csproj b/src/WebJobs.Script.WebHost/WebJobs.Script.WebHost.csproj index 4cfdced839..2025c84c8f 100644 --- a/src/WebJobs.Script.WebHost/WebJobs.Script.WebHost.csproj +++ b/src/WebJobs.Script.WebHost/WebJobs.Script.WebHost.csproj @@ -321,7 +321,7 @@ - + diff --git a/src/WebJobs.Script/Description/Node/NodeFunctionInvoker.cs b/src/WebJobs.Script/Description/Node/NodeFunctionInvoker.cs index 51130c19ee..cf400b8009 100644 --- a/src/WebJobs.Script/Description/Node/NodeFunctionInvoker.cs +++ b/src/WebJobs.Script/Description/Node/NodeFunctionInvoker.cs @@ -308,8 +308,9 @@ private Dictionary CreateScriptExecutionContext(object input, IB HttpRequestMessage request = (HttpRequestMessage)input; string rawBody = null; var requestObject = CreateRequestObject(request, out rawBody); + requestObject["rawBody"] = rawBody; input = requestObject; - + if (rawBody != null) { bindDataInput = rawBody; diff --git a/test/WebJobs.Script.Tests/NodeEndToEndTests.cs b/test/WebJobs.Script.Tests/NodeEndToEndTests.cs index 074a337880..c73ccf8d24 100644 --- a/test/WebJobs.Script.Tests/NodeEndToEndTests.cs +++ b/test/WebJobs.Script.Tests/NodeEndToEndTests.cs @@ -161,8 +161,10 @@ public async Task HttpTrigger_Get() string body = await response.Content.ReadAsStringAsync(); JObject resultObject = JObject.Parse(body); - Assert.Equal((string)resultObject["reqBodyType"], "undefined"); + Assert.Equal("undefined", (string)resultObject["reqBodyType"]); Assert.Null((string)resultObject["reqBody"]); + Assert.Equal("undefined", (string)resultObject["reqRawBodyType"]); + Assert.Null((string)resultObject["reqRawBody"]); // validate input headers JObject reqHeaders = (JObject)resultObject["reqHeaders"]; @@ -191,8 +193,9 @@ public async Task HttpTrigger_Post_PlainText() string body = await response.Content.ReadAsStringAsync(); JObject resultObject = JObject.Parse(body); - Assert.Equal((string)resultObject["reqBodyType"], "string"); - Assert.Equal((string)resultObject["reqBody"], testData); + Assert.Equal("string", (string)resultObject["reqBodyType"]); + Assert.Equal(testData, (string)resultObject["reqBody"]); + Assert.Equal(testData, (string)resultObject["reqRawBody"]); } [Fact] @@ -203,11 +206,12 @@ public async Task HttpTrigger_Post_Json() { { "testData", testData } }; + string rawBody = testObject.ToString(); HttpRequestMessage request = new HttpRequestMessage { RequestUri = new Uri(string.Format("http://localhost/api/httptrigger")), Method = HttpMethod.Post, - Content = new StringContent(testObject.ToString()) + Content = new StringContent(rawBody) }; request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json"); @@ -222,9 +226,11 @@ public async Task HttpTrigger_Post_Json() string body = await response.Content.ReadAsStringAsync(); JObject resultObject = JObject.Parse(body); - Assert.Equal((string)resultObject["reqBodyType"], "object"); - Assert.Equal((string)resultObject["reqBody"]["testData"], testData); - Assert.Equal((string)resultObject["bindingData"]["testData"], testData); + Assert.Equal("string", (string)resultObject["reqRawBodyType"]); + Assert.Equal(rawBody, (string)resultObject["reqRawBody"]); + Assert.Equal("object", (string)resultObject["reqBodyType"]); + Assert.Equal(testData, (string)resultObject["reqBody"]["testData"]); + Assert.Equal(testData, (string)resultObject["bindingData"]["testData"]); } [Fact] diff --git a/test/WebJobs.Script.Tests/SamplesEndToEndTests.cs b/test/WebJobs.Script.Tests/SamplesEndToEndTests.cs index 2b052597d5..e5cace9051 100644 --- a/test/WebJobs.Script.Tests/SamplesEndToEndTests.cs +++ b/test/WebJobs.Script.Tests/SamplesEndToEndTests.cs @@ -67,7 +67,7 @@ public async Task Home_Get_Succeeds() [Fact] public async Task HttpTrigger_Get_Succeeds() { - string uri = "api/httptrigger?code=hyexydhln844f2mb7hgsup2yf8dowlb0885mbiq1&name=Mathew"; + string uri = "api/httptrigger?code=hyexydhln844f2mb7hgsup2yf8dowlb0885mbiq1&id=a&name=Mathew"; HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Get, uri); HttpResponseMessage response = await this._fixture.HttpClient.SendAsync(request); @@ -75,6 +75,12 @@ public async Task HttpTrigger_Get_Succeeds() string body = await response.Content.ReadAsStringAsync(); Assert.Equal("text/plain", response.Content.Headers.ContentType.MediaType); Assert.Equal("Hello Mathew", body); + + // verify that the secondary key also works + uri = "api/httptrigger?code=m3vg59azmxzxb8ofwwjeg738f654qjve0bwmyhte&id=b&name=Mathew"; + request = new HttpRequestMessage(HttpMethod.Get, uri); + response = await this._fixture.HttpClient.SendAsync(request); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); } [Fact] @@ -114,7 +120,7 @@ public async Task GenericWebHook_CSharp_Post_Succeeds() [Fact] public async Task AzureWebHook_CSharp_Post_Succeeds() { - string uri = "api/webhook-azure-csharp?code=yKjiimZjC1FQoGlaIj8TUfGltnPE/f2LhgZNq6Fw9/XfAOGHmSgUlQ=="; + string uri = "api/webhook-azure-csharp?code=lr5jlquf0ynr3p7jsu012kif4auv21qfivjftg8e"; HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Post, uri); request.Content = new StringContent(Resources.AzureWebHookEventRequest); request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json"); @@ -146,7 +152,7 @@ public async Task HttpTriggerWithObject_CSharp_Post_Succeeds() [Fact] public async Task GenericWebHook_Post_Succeeds() { - string uri = "api/webhook-generic?code=1388a6b0d05eca2237f10e4a4641260b0a08f3a5"; + string uri = "api/webhook-generic?code=1388a6b0d05eca2237f10e4a4641260b0a08f3a5&id=a"; HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Post, uri); request.Content = new StringContent("{ 'value': 'Foobar' }"); request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json"); diff --git a/test/WebJobs.Script.Tests/TestScripts/Node/HttpTrigger/index.js b/test/WebJobs.Script.Tests/TestScripts/Node/HttpTrigger/index.js index 042f0cb950..9fba43b2d5 100644 --- a/test/WebJobs.Script.Tests/TestScripts/Node/HttpTrigger/index.js +++ b/test/WebJobs.Script.Tests/TestScripts/Node/HttpTrigger/index.js @@ -4,8 +4,10 @@ context.res = { status: 200, body: { - reqBodyType: typeof req.body, reqBody: req.body, + reqBodyType: typeof req.body, + reqRawBody: req.rawBody, + reqRawBodyType: typeof req.rawBody, reqHeaders: req.headers, bindingData: context.bindingData },