Skip to content

Commit

Permalink
Authentication model for Extension WebHooks
Browse files Browse the repository at this point in the history
  • Loading branch information
mathewc authored and fabiocav committed Jul 29, 2017
1 parent cf174ca commit 8309e2b
Show file tree
Hide file tree
Showing 18 changed files with 243 additions and 152 deletions.
11 changes: 8 additions & 3 deletions src/WebJobs.Script.WebHost/Controllers/AdminController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -218,13 +218,18 @@ public override Task<HttpResponseMessage> ExecuteAsync(HttpControllerContext con
[AllowAnonymous]
public async Task<HttpResponseMessage> ExtensionWebHookHandler(string name, CancellationToken token)
{
var provider = this._scriptHostManager.BindingWebHookProvider;
var provider = _scriptHostManager.BindingWebHookProvider;

var handler = provider.GetHandlerOrNull(name);
if (handler != null)
{
var response = await handler.ConvertAsync(this.Request, token);
return response;
string keyName = WebJobsSdkExtensionHookProvider.GetKeyName(name);
if (!this.Request.HasAuthorizationLevel(AuthorizationLevel.System, keyName))
{
return new HttpResponseMessage(HttpStatusCode.Unauthorized);
}

return await handler.ConvertAsync(this.Request, token);
}

return new HttpResponseMessage(HttpStatusCode.NotFound);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,10 @@ public override async Task<HttpResponseMessage> ExecuteAsync(HttpControllerConte
public static async Task<AuthorizationLevel> DetermineAuthorizationLevelAsync(HttpRequestMessage request, FunctionDescriptor function, IDependencyResolver resolver)
{
var secretManager = resolver.GetService<ISecretManager>();
var authorizationLevel = await AuthorizationLevelAttribute.GetAuthorizationLevelAsync(request, secretManager, functionName: function.Name);
var authorizationResult = await AuthorizationLevelAttribute.GetAuthorizationResultAsync(request, secretManager, functionName: function.Name);
var authorizationLevel = authorizationResult.AuthorizationLevel;
request.SetAuthorizationLevel(authorizationLevel);
request.SetProperty(ScriptConstants.AzureFunctionsHttpRequestKeyNameKey, authorizationResult.KeyName);

return authorizationLevel;
}
Expand Down
4 changes: 2 additions & 2 deletions src/WebJobs.Script.WebHost/Controllers/SwaggerController.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
// 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.Net;
using System.Threading.Tasks;
using System.Web.Http;
using Microsoft.Azure.WebJobs.Extensions.Http;
using Microsoft.Azure.WebJobs.Host;
using Microsoft.Azure.WebJobs.Script.WebHost.Filters;
using Microsoft.Azure.WebJobs.Script.WebHost.Properties;
Expand All @@ -13,7 +13,7 @@

namespace Microsoft.Azure.WebJobs.Script.WebHost.Controllers
{
[SystemAuthorizationLevel(ScriptConstants.SwaggerDocumentationKey)]
[AuthorizationLevel(AuthorizationLevel.System, ScriptConstants.SwaggerDocumentationKey)]
public class SwaggerController : ApiController
{
private readonly ISwaggerDocumentManager _swaggerDocumentManager;
Expand Down
70 changes: 52 additions & 18 deletions src/WebJobs.Script.WebHost/Filters/AuthorizationLevelAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
using System.Web.Http.Controllers;
using System.Web.Http.Filters;
using Microsoft.Azure.WebJobs.Extensions.Http;
using Microsoft.Azure.WebJobs.Script.WebHost.Security;

namespace Microsoft.Azure.WebJobs.Script.WebHost.Filters
{
Expand All @@ -25,8 +26,21 @@ public AuthorizationLevelAttribute(AuthorizationLevel level)
Level = level;
}

public AuthorizationLevelAttribute(AuthorizationLevel level, string keyName)
{
if (string.IsNullOrEmpty(keyName))
{
throw new ArgumentNullException(nameof(keyName));
}

Level = level;
KeyName = keyName;
}

public AuthorizationLevel Level { get; }

public string KeyName { get; }

public async override Task OnAuthorizationAsync(HttpActionContext actionContext, CancellationToken cancellationToken)
{
if (actionContext == null)
Expand All @@ -44,32 +58,35 @@ public async override Task OnAuthorizationAsync(HttpActionContext actionContext,
// as a request property
var secretManager = actionContext.ControllerContext.Configuration.DependencyResolver.GetService<ISecretManager>();

requestAuthorizationLevel = await GetAuthorizationLevelAsync(request, secretManager, EvaluateKeyMatch);
request.SetAuthorizationLevel(requestAuthorizationLevel);
var result = await GetAuthorizationResultAsync(request, secretManager, EvaluateKeyMatch, KeyName);
requestAuthorizationLevel = result.AuthorizationLevel;
request.SetAuthorizationLevel(result.AuthorizationLevel);
request.SetProperty(ScriptConstants.AzureFunctionsHttpRequestKeyNameKey, result.KeyName);
}

if (request.IsAuthDisabled() ||
SkipAuthorization(actionContext) ||
Level == AuthorizationLevel.Anonymous)
{
// no authorization required
return;
}

if (requestAuthorizationLevel < Level)
if (!request.HasAuthorizationLevel(Level))
{
actionContext.Response = new HttpResponseMessage(HttpStatusCode.Unauthorized);
}
}

protected virtual bool EvaluateKeyMatch(IDictionary<string, string> secrets, string keyValue) => HasMatchingKey(secrets, keyValue);
protected virtual string EvaluateKeyMatch(IDictionary<string, string> secrets, string keyName, string keyValue) => GetKeyMatchOrNull(secrets, keyName, keyValue);

internal static Task<AuthorizationLevel> GetAuthorizationLevelAsync(HttpRequestMessage request, ISecretManager secretManager, string functionName = null)
internal static Task<KeyAuthorizationResult> GetAuthorizationResultAsync(HttpRequestMessage request, ISecretManager secretManager, string keyName = null, string functionName = null)
{
return GetAuthorizationLevelAsync(request, secretManager, HasMatchingKey, functionName);
return GetAuthorizationResultAsync(request, secretManager, GetKeyMatchOrNull, keyName, functionName);
}

internal static async Task<AuthorizationLevel> GetAuthorizationLevelAsync(HttpRequestMessage request, ISecretManager secretManager,
Func<IDictionary<string, string>, string, bool> matchEvaluator, string functionName = null)
internal static async Task<KeyAuthorizationResult> GetAuthorizationResultAsync(HttpRequestMessage request, ISecretManager secretManager,
Func<IDictionary<string, string>, string, string, string> matchEvaluator, string keyName = null, string functionName = null)
{
// first see if a key value is specified via headers or query string (header takes precedence)
IEnumerable<string> values;
Expand All @@ -91,35 +108,52 @@ internal static async Task<AuthorizationLevel> GetAuthorizationLevelAsync(HttpRe
if (!string.IsNullOrEmpty(hostSecrets.MasterKey) &&
Key.SecretValueEquals(keyValue, hostSecrets.MasterKey))
{
return AuthorizationLevel.Admin;
return new KeyAuthorizationResult(ScriptConstants.DefaultMasterKeyName, AuthorizationLevel.Admin);
}

if (matchEvaluator(hostSecrets.SystemKeys, keyValue))
string matchedKeyName = matchEvaluator(hostSecrets.SystemKeys, keyName, keyValue);
if (matchedKeyName != null)
{
return AuthorizationLevel.System;
return new KeyAuthorizationResult(matchedKeyName, AuthorizationLevel.System);
}

// see if the key specified matches the host function key
if (matchEvaluator(hostSecrets.FunctionKeys, keyValue))
matchedKeyName = matchEvaluator(hostSecrets.FunctionKeys, keyName, keyValue);
if (matchedKeyName != null)
{
return AuthorizationLevel.Function;
return new KeyAuthorizationResult(matchedKeyName, AuthorizationLevel.Function);
}

// if there is a function specific key specified try to match against that
if (functionName != null)
{
IDictionary<string, string> functionSecrets = await secretManager.GetFunctionSecretsAsync(functionName);
if (matchEvaluator(functionSecrets, keyValue))
var functionSecrets = await secretManager.GetFunctionSecretsAsync(functionName);
matchedKeyName = matchEvaluator(functionSecrets, keyName, keyValue);
if (matchedKeyName != null)
{
return AuthorizationLevel.Function;
return new KeyAuthorizationResult(matchedKeyName, AuthorizationLevel.Function);
}
}
}

return AuthorizationLevel.Anonymous;
return new KeyAuthorizationResult(null, AuthorizationLevel.Anonymous);
}

private static bool HasMatchingKey(IDictionary<string, string> secrets, string keyValue) => secrets != null && secrets.Values.Any(s => Key.SecretValueEquals(s, keyValue));
internal static string GetKeyMatchOrNull(IDictionary<string, string> secrets, string keyName, string keyValue)
{
if (secrets != null)
{
foreach (var pair in secrets)
{
if (Key.SecretValueEquals(pair.Value, keyValue) &&
(keyName == null || string.Equals(pair.Key, keyName, StringComparison.OrdinalIgnoreCase)))
{
return pair.Key;
}
}
}
return null;
}

internal static bool SkipAuthorization(HttpActionContext actionContext)
{
Expand Down

This file was deleted.

20 changes: 20 additions & 0 deletions src/WebJobs.Script.WebHost/Security/KeyAuthorizationResult.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT License. See License.txt in the project root for license information.

using Microsoft.Azure.WebJobs.Extensions.Http;

namespace Microsoft.Azure.WebJobs.Script.WebHost.Security
{
public class KeyAuthorizationResult
{
public KeyAuthorizationResult(string keyId, AuthorizationLevel level)
{
KeyName = keyId;
AuthorizationLevel = level;
}

public string KeyName { get; }

public AuthorizationLevel AuthorizationLevel { get; }
}
}
2 changes: 1 addition & 1 deletion src/WebJobs.Script.WebHost/Security/SecretManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -402,7 +402,7 @@ private Key CreateKey(string name, string secret)
return _keyValueConverterFactory.WriteKey(key);
}

private static string GenerateSecret()
internal static string GenerateSecret()
{
using (var rng = RandomNumberGenerator.Create())
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.Azure.WebJobs.Host.Config;
using Microsoft.Azure.WebJobs.Script.Config;
using HttpHandler = Microsoft.Azure.WebJobs.IAsyncConverter<System.Net.Http.HttpRequestMessage, System.Net.Http.HttpResponseMessage>;
Expand All @@ -13,18 +14,26 @@ namespace Microsoft.Azure.WebJobs.Script.WebHost
// This is registered with the JobHostConfiguration and extensions will call on it to register for a handler.
internal class WebJobsSdkExtensionHookProvider : IWebHookProvider
{
private readonly ISecretManager _secretManager;

// Map from an extension name to a http handler.
private IDictionary<string, HttpHandler> _customHttpHandlers = new Dictionary<string, HttpHandler>(StringComparer.OrdinalIgnoreCase);

public WebJobsSdkExtensionHookProvider(ISecretManager secretManager)
{
_secretManager = secretManager;
}

// Get a registered handler, or null
public HttpHandler GetHandlerOrNull(string name)
{
HttpHandler handler;
_customHttpHandlers.TryGetValue(name, out handler);

return handler;
}

// Exposed to extensions to get get the URL for their http handler.
// Exposed to extensions to get the URL for their http handler.
public Uri GetUrl(IExtensionConfigProvider extension)
{
var extensionType = extension.GetType();
Expand All @@ -41,7 +50,7 @@ public Uri GetUrl(IExtensionConfigProvider extension)
}

// Provides the URL for accessing the admin extensions WebHook route.
internal static Uri GetExtensionWebHookRoute(string name)
private Uri GetExtensionWebHookRoute(string extensionName)
{
var settings = ScriptSettingsManager.Instance;
var hostName = settings.GetSetting(EnvironmentSettingNames.AzureWebsiteHostName);
Expand All @@ -52,8 +61,30 @@ internal static Uri GetExtensionWebHookRoute(string name)

bool isLocalhost = hostName.StartsWith("localhost:", StringComparison.OrdinalIgnoreCase);
var scheme = isLocalhost ? "http" : "https";
string keyValue = GetOrCreateExtensionKey(extensionName).GetAwaiter().GetResult();

return new Uri($"{scheme}://{hostName}/admin/extensions/{extensionName}?code={keyValue}");
}

return new Uri($"{scheme}://{hostName}/admin/extensions/{name}");
private async Task<string> GetOrCreateExtensionKey(string extensionName)
{
var hostSecrets = _secretManager.GetHostSecretsAsync().GetAwaiter().GetResult();
string keyName = GetKeyName(extensionName);
string keyValue = null;
if (!hostSecrets.SystemKeys.TryGetValue(keyName, out keyValue))
{
// if the requested secret doesn't exist, create it on demand
keyValue = SecretManager.GenerateSecret();
await _secretManager.AddOrUpdateFunctionSecretAsync(keyName, keyValue, HostKeyScopes.SystemKeys, ScriptSecretsType.Host);
}

return keyValue;
}

internal static string GetKeyName(string extensionName)
{
// key names for extension webhooks are named by convention
return $"{extensionName}_extension".ToLowerInvariant();
}
}
}
2 changes: 1 addition & 1 deletion src/WebJobs.Script.WebHost/WebJobs.Script.WebHost.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -493,7 +493,6 @@
<Compile Include="Diagnostics\WebHostMetricsLogger.cs" />
<Compile Include="Extensions\DependencyResolverExtensions.cs" />
<Compile Include="Filters\AuthorizationLevelAttribute.cs" />
<Compile Include="Filters\SystemAuthorizationLevelAttribute.cs" />
<Compile Include="Formatting\PlaintextMediaTypeFormatter.cs" />
<Compile Include="Global.asax.cs">
<DependentUpon>Global.asax</DependentUpon>
Expand Down Expand Up @@ -529,6 +528,7 @@
<Compile Include="Security\ISecretManagerFactory.cs" />
<Compile Include="Security\ISecretsRepository.cs" />
<Compile Include="Security\ISecretsRepositoryFactory.cs" />
<Compile Include="Security\KeyAuthorizationResult.cs" />
<Compile Include="Security\KeyOperationResult.cs" />
<Compile Include="Security\ScriptSecretsType.cs" />
<Compile Include="Security\SecretsChangedEventArgs.cs" />
Expand Down
5 changes: 3 additions & 2 deletions src/WebJobs.Script.WebHost/WebScriptHostManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,7 @@ public class WebScriptHostManager : ScriptHostManager
private readonly object _syncLock = new object();
private readonly int _hostTimeoutSeconds;
private readonly int _hostRunningPollIntervalMilliseconds;

private readonly WebJobsSdkExtensionHookProvider _bindingWebHookProvider = new WebJobsSdkExtensionHookProvider();
private readonly WebJobsSdkExtensionHookProvider _bindingWebHookProvider;

private bool _warmupComplete = false;
private bool _hostStarted = false;
Expand Down Expand Up @@ -90,6 +89,8 @@ public WebScriptHostManager(ScriptHostConfiguration config,

var secretsRepository = secretsRepositoryFactory.Create(settingsManager, webHostSettings, config);
_secretManager = secretManagerFactory.Create(settingsManager, config.TraceWriter, config.HostConfig.LoggerFactory, secretsRepository);

_bindingWebHookProvider = new WebJobsSdkExtensionHookProvider(_secretManager);
}

public WebScriptHostManager(ScriptHostConfiguration config,
Expand Down
Loading

0 comments on commit 8309e2b

Please sign in to comment.