Skip to content

Commit 2942e61

Browse files
committed
porting ARM Auth functionality
1 parent 13441ef commit 2942e61

File tree

6 files changed

+274
-9
lines changed

6 files changed

+274
-9
lines changed

src/WebJobs.Script.WebHost/Filters/AuthorizationLevelAttribute.cs

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ namespace Microsoft.Azure.WebJobs.Script.WebHost.Filters
2020
public class AuthorizationLevelAttribute : AuthorizationFilterAttribute
2121
{
2222
public const string FunctionsKeyHeaderName = "x-functions-key";
23+
public const string ArmTokenHeaderName = "x-ms-site-restricted-token";
2324

2425
public AuthorizationLevelAttribute(AuthorizationLevel level)
2526
{
@@ -48,20 +49,33 @@ public async override Task OnAuthorizationAsync(HttpActionContext actionContext,
4849
throw new ArgumentNullException("actionContext");
4950
}
5051

52+
var request = actionContext.Request;
53+
5154
AuthorizationLevel requestAuthorizationLevel = actionContext.Request.GetAuthorizationLevel();
5255

5356
// If the request has not yet been authenticated, authenticate it
54-
var request = actionContext.Request;
5557
if (requestAuthorizationLevel == AuthorizationLevel.Anonymous)
5658
{
57-
// determine the authorization level for the function and set it
58-
// as a request property
59-
var secretManager = actionContext.ControllerContext.Configuration.DependencyResolver.GetService<ISecretManager>();
60-
61-
var result = await GetAuthorizationResultAsync(request, secretManager, EvaluateKeyMatch, KeyName);
62-
requestAuthorizationLevel = result.AuthorizationLevel;
63-
request.SetAuthorizationLevel(result.AuthorizationLevel);
64-
request.SetProperty(ScriptConstants.AzureFunctionsHttpRequestKeyNameKey, result.KeyName);
59+
string armToken = GetArmTokenHeader(actionContext);
60+
61+
if (armToken != null)
62+
{
63+
if (SimpleWebTokenHelper.TryValidateToken(armToken, DateTime.UtcNow))
64+
{
65+
request.SetAuthorizationLevel(AuthorizationLevel.Admin);
66+
}
67+
}
68+
else
69+
{
70+
// determine the authorization level for the function and set it
71+
// as a request property
72+
var secretManager = actionContext.ControllerContext.Configuration.DependencyResolver.GetService<ISecretManager>();
73+
74+
var result = await GetAuthorizationResultAsync(request, secretManager, EvaluateKeyMatch, KeyName);
75+
requestAuthorizationLevel = result.AuthorizationLevel;
76+
request.SetAuthorizationLevel(result.AuthorizationLevel);
77+
request.SetProperty(ScriptConstants.AzureFunctionsHttpRequestKeyNameKey, result.KeyName);
78+
}
6579
}
6680

6781
if (request.IsAuthDisabled() ||
@@ -78,6 +92,16 @@ public async override Task OnAuthorizationAsync(HttpActionContext actionContext,
7892
}
7993
}
8094

95+
internal static string GetArmTokenHeader(HttpActionContext actionContext)
96+
{
97+
if (actionContext.Request.Headers.TryGetValues(ArmTokenHeaderName, out IEnumerable<string> values))
98+
{
99+
return values.First();
100+
}
101+
102+
return null;
103+
}
104+
81105
protected virtual string EvaluateKeyMatch(IDictionary<string, string> secrets, string keyName, string keyValue) => GetKeyMatchOrNull(secrets, keyName, keyValue);
82106

83107
internal static Task<KeyAuthorizationResult> GetAuthorizationResultAsync(HttpRequestMessage request, ISecretManager secretManager, string keyName = null, string functionName = null)

src/WebJobs.Script.WebHost/GlobalSuppressions.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,4 +114,6 @@
114114
[assembly: System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Reliability", "CA2000:Dispose objects before losing scope", Scope = "member", Target = "Microsoft.Azure.WebJobs.Script.WebHost.WebHostResolver.#CreateDefaultLoggerFactory(Microsoft.Azure.WebJobs.Script.WebHost.WebHostSettings)")]
115115
[assembly: System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Performance", "CA1822:MarkMembersAsStatic", Scope = "member", Target = "Microsoft.Azure.WebJobs.Script.WebHost.Diagnostics.FunctionsSystemLogsEventSource.#SetActivityId(System.String)")]
116116
[assembly: System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Reliability", "CA2000:Dispose objects before losing scope", Scope = "member", Target = "Microsoft.Azure.WebJobs.Script.WebHost.Controllers.AdminController.#Ping()")]
117+
[assembly: System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Usage", "CA2202:Do not dispose objects multiple times", Scope = "member", Target = "Microsoft.Azure.WebJobs.Script.WebHost.Security.SimpleWebTokenHelper.#Decrypt(System.Byte[],System.String)")]
118+
[assembly: System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Reliability", "CA2000:Dispose objects before losing scope", Scope = "member", Target = "Microsoft.Azure.WebJobs.Script.WebHost.Security.SimpleWebTokenHelper.#Decrypt(System.Byte[],System.String)")]
117119

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the MIT License. See License.txt in the project root for license information.
3+
4+
using System;
5+
using System.IO;
6+
using System.Linq;
7+
using System.Security.Cryptography;
8+
using System.Text;
9+
10+
namespace Microsoft.Azure.WebJobs.Script.WebHost.Security
11+
{
12+
internal static class SimpleWebTokenHelper
13+
{
14+
public static string Decrypt(byte[] encryptionKey, string value)
15+
{
16+
var parts = value.Split(new[] { '.' }, StringSplitOptions.RemoveEmptyEntries);
17+
if (parts.Length != 2 && parts.Length != 3)
18+
{
19+
throw new InvalidOperationException("Malformed token.");
20+
}
21+
22+
var iv = Convert.FromBase64String(parts[0]);
23+
var data = Convert.FromBase64String(parts[1]);
24+
var base64KeyHash = parts.Length == 3 ? parts[2] : null;
25+
26+
if (!string.IsNullOrEmpty(base64KeyHash) && !string.Equals(GetSHA256Base64String(encryptionKey), base64KeyHash))
27+
{
28+
throw new InvalidOperationException(string.Format("Key with hash {0} does not exist.", base64KeyHash));
29+
}
30+
31+
using (var aes = new AesManaged { Key = encryptionKey })
32+
{
33+
using (var ms = new MemoryStream())
34+
{
35+
using (var cs = new CryptoStream(ms, aes.CreateDecryptor(aes.Key, iv), CryptoStreamMode.Write))
36+
using (var binaryWriter = new BinaryWriter(cs))
37+
{
38+
binaryWriter.Write(data, 0, data.Length);
39+
}
40+
41+
return Encoding.UTF8.GetString(ms.ToArray());
42+
}
43+
}
44+
}
45+
46+
public static bool TryValidateToken(string token, DateTime dateTime)
47+
{
48+
try
49+
{
50+
return ValidateToken(token, dateTime);
51+
}
52+
catch
53+
{
54+
return false;
55+
}
56+
}
57+
58+
public static bool ValidateToken(string token, DateTime dateTime)
59+
{
60+
// Use WebSiteAuthEncryptionKey if available.
61+
byte[] key = GetEncryptionKey(EnvironmentSettingNames.WebsiteAuthEncryptionKey);
62+
63+
var data = Decrypt(key, token);
64+
65+
var parsedToken = data
66+
// token = key1=value1;key2=value2
67+
.Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries)
68+
// ["key1=value1", "key2=value2"]
69+
.Select(v => v.Split(new[] { '=' }, StringSplitOptions.RemoveEmptyEntries))
70+
// [["key1", "value1"], ["key2", "value2"]]
71+
.ToDictionary(k => k[0], v => v[1]);
72+
73+
return parsedToken.ContainsKey("exp") && dateTime < new DateTime(long.Parse(parsedToken["exp"]));
74+
}
75+
76+
private static string GetSHA256Base64String(byte[] key)
77+
{
78+
using (var sha256 = new SHA256Managed())
79+
{
80+
return Convert.ToBase64String(sha256.ComputeHash(key));
81+
}
82+
}
83+
84+
internal static byte[] GetEncryptionKey(string keyName)
85+
{
86+
var hexOrBase64 = Environment.GetEnvironmentVariable(keyName);
87+
if (string.IsNullOrEmpty(hexOrBase64))
88+
{
89+
throw new InvalidOperationException($"No {keyName} defined in the environment");
90+
}
91+
92+
return hexOrBase64.ToKeyBytes();
93+
}
94+
95+
public static byte[] ToKeyBytes(this string hexOrBase64)
96+
{
97+
// only support 32 bytes (256 bits) key length
98+
if (hexOrBase64.Length == 64)
99+
{
100+
return Enumerable.Range(0, hexOrBase64.Length)
101+
.Where(x => x % 2 == 0)
102+
.Select(x => Convert.ToByte(hexOrBase64.Substring(x, 2), 16))
103+
.ToArray();
104+
}
105+
106+
return Convert.FromBase64String(hexOrBase64);
107+
}
108+
}
109+
}

src/WebJobs.Script.WebHost/WebJobs.Script.WebHost.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -509,6 +509,7 @@
509509
<Compile Include="Filters\EnableDebugModeAttribute.cs" />
510510
<Compile Include="Filters\RequiresRunningHostAttribute.cs" />
511511
<Compile Include="Security\SecretsUtility.cs" />
512+
<Compile Include="Security\SimpleWebTokenHelper.cs" />
512513
<Compile Include="StandbyManager.cs" />
513514
<Compile Include="Controllers\AdminController.cs" />
514515
<Compile Include="Controllers\FunctionsController.cs" />

src/WebJobs.Script/EnvironmentSettingNames.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ public static class EnvironmentSettingNames
2525
public const string AppInsightsInstrumentationKey = "APPINSIGHTS_INSTRUMENTATIONKEY";
2626
public const string ProxySiteExtensionEnabledKey = "ROUTING_EXTENSION_VERSION";
2727
public const string FunctionsExtensionVersion = "FUNCTIONS_EXTENSION_VERSION";
28+
public const string WebsiteAuthEncryptionKey = "WEBSITE_AUTH_ENCRYPTION_KEY";
2829

2930
/// <summary>
3031
/// Environment variable dynamically set by the platform when it is safe to

test/WebJobs.Script.Tests/Filters/AuthorizationLevelAttributeTests.cs

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,12 @@
33

44
using System;
55
using System.Collections.Generic;
6+
using System.IO;
67
using System.Net;
78
using System.Net.Http;
89
using System.Reflection;
10+
using System.Security.Cryptography;
11+
using System.Text;
912
using System.Threading;
1013
using System.Threading.Tasks;
1114
using System.Web.Http;
@@ -14,6 +17,7 @@
1417
using Microsoft.Azure.WebJobs.Extensions.Http;
1518
using Microsoft.Azure.WebJobs.Script.WebHost;
1619
using Microsoft.Azure.WebJobs.Script.WebHost.Filters;
20+
using Microsoft.Azure.WebJobs.Script.WebHost.Security;
1721
using Microsoft.WebJobs.Script.Tests;
1822
using Moq;
1923
using Xunit;
@@ -391,6 +395,76 @@ public async Task OnAuthorization_WithNamedKeyHeader_Succeeds()
391395
Assert.Null(actionContext.Response);
392396
}
393397

398+
[Fact]
399+
public async Task OnAuthorization_Arm_Success_SetsAdminAuthLevel()
400+
{
401+
byte[] key = GenerateKeyBytes();
402+
string keyString = GenerateKeyHexString(key);
403+
string token = CreateSimpleWebToken(DateTime.UtcNow.AddMinutes(5), key);
404+
405+
var attribute = new AuthorizationLevelAttribute(AuthorizationLevel.Admin);
406+
407+
HttpRequestMessage request = new HttpRequestMessage();
408+
request.Headers.Add(AuthorizationLevelAttribute.ArmTokenHeaderName, token);
409+
var actionContext = CreateActionContext(typeof(TestController).GetMethod(nameof(TestController.Get)), HttpConfig);
410+
actionContext.ControllerContext.Request = request;
411+
412+
using (new TestScopedEnvironmentVariable(EnvironmentSettingNames.WebsiteAuthEncryptionKey, keyString))
413+
{
414+
await attribute.OnAuthorizationAsync(actionContext, CancellationToken.None);
415+
}
416+
417+
Assert.Null(actionContext.Response);
418+
Assert.Equal(AuthorizationLevel.Admin, actionContext.Request.GetAuthorizationLevel());
419+
}
420+
421+
[Fact]
422+
public async Task OnAuthorization_Arm_Expired_ReturnsUnauthorized()
423+
{
424+
byte[] key = GenerateKeyBytes();
425+
string keyString = GenerateKeyHexString(key);
426+
string token = CreateSimpleWebToken(DateTime.UtcNow.AddMinutes(-5), key);
427+
428+
var attribute = new AuthorizationLevelAttribute(AuthorizationLevel.Admin);
429+
430+
HttpRequestMessage request = new HttpRequestMessage();
431+
request.Headers.Add(AuthorizationLevelAttribute.ArmTokenHeaderName, token);
432+
var actionContext = CreateActionContext(typeof(TestController).GetMethod(nameof(TestController.Get)), HttpConfig);
433+
actionContext.ControllerContext.Request = request;
434+
435+
using (new TestScopedEnvironmentVariable(EnvironmentSettingNames.WebsiteAuthEncryptionKey, keyString))
436+
{
437+
await attribute.OnAuthorizationAsync(actionContext, CancellationToken.None);
438+
}
439+
440+
Assert.Equal(HttpStatusCode.Unauthorized, actionContext.Response.StatusCode);
441+
Assert.Equal(AuthorizationLevel.Anonymous, actionContext.Request.GetAuthorizationLevel());
442+
}
443+
444+
[Fact]
445+
public async Task OnAuthorization_Arm_Invalid_ReturnsUnauthorized()
446+
{
447+
byte[] key = GenerateKeyBytes();
448+
string keyString = GenerateKeyHexString(key);
449+
string token = CreateSimpleWebToken(DateTime.UtcNow.AddMinutes(5), key);
450+
token = token.Substring(0, token.Length - 5);
451+
452+
var attribute = new AuthorizationLevelAttribute(AuthorizationLevel.Admin);
453+
454+
HttpRequestMessage request = new HttpRequestMessage();
455+
request.Headers.Add(AuthorizationLevelAttribute.ArmTokenHeaderName, token);
456+
var actionContext = CreateActionContext(typeof(TestController).GetMethod(nameof(TestController.Get)), HttpConfig);
457+
actionContext.ControllerContext.Request = request;
458+
459+
using (new TestScopedEnvironmentVariable(EnvironmentSettingNames.WebsiteAuthEncryptionKey, keyString))
460+
{
461+
await attribute.OnAuthorizationAsync(actionContext, CancellationToken.None);
462+
}
463+
464+
Assert.Equal(HttpStatusCode.Unauthorized, actionContext.Response.StatusCode);
465+
Assert.Equal(AuthorizationLevel.Anonymous, actionContext.Request.GetAuthorizationLevel());
466+
}
467+
394468
protected static HttpActionContext CreateActionContext(MethodInfo action, System.Web.Http.HttpConfiguration config = null)
395469
{
396470
config = config ?? new System.Web.Http.HttpConfiguration();
@@ -406,6 +480,60 @@ protected static HttpActionContext CreateActionContext(MethodInfo action, System
406480
return actionContext;
407481
}
408482

483+
public static byte[] GenerateKeyBytes()
484+
{
485+
using (var aes = new AesManaged())
486+
{
487+
aes.GenerateKey();
488+
return aes.Key;
489+
}
490+
}
491+
492+
public static string GenerateKeyHexString(byte[] key = null)
493+
{
494+
return BitConverter.ToString(key ?? GenerateKeyBytes()).Replace("-", string.Empty);
495+
}
496+
497+
private static string CreateSimpleWebToken(DateTime validUntil, byte[] key = null) => EncryptSimpleWebToken($"exp={validUntil.Ticks}", key);
498+
499+
private static string EncryptSimpleWebToken(string value, byte[] key = null)
500+
{
501+
if (key == null)
502+
{
503+
key = SimpleWebTokenHelper.GetEncryptionKey(EnvironmentSettingNames.WebsiteAuthEncryptionKey);
504+
}
505+
506+
using (var aes = new AesManaged { Key = key })
507+
{
508+
// IV is always generated for the key every time
509+
aes.GenerateIV();
510+
var input = Encoding.UTF8.GetBytes(value);
511+
var iv = Convert.ToBase64String(aes.IV);
512+
513+
using (var encrypter = aes.CreateEncryptor(aes.Key, aes.IV))
514+
using (var cipherStream = new MemoryStream())
515+
{
516+
using (var cryptoStream = new CryptoStream(cipherStream, encrypter, CryptoStreamMode.Write))
517+
using (var binaryWriter = new BinaryWriter(cryptoStream))
518+
{
519+
binaryWriter.Write(input);
520+
cryptoStream.FlushFinalBlock();
521+
}
522+
523+
// return {iv}.{swt}.{sha236(key)}
524+
return string.Format("{0}.{1}.{2}", iv, Convert.ToBase64String(cipherStream.ToArray()), GetSHA256Base64String(aes.Key));
525+
}
526+
}
527+
}
528+
529+
private static string GetSHA256Base64String(byte[] key)
530+
{
531+
using (var sha256 = new SHA256Managed())
532+
{
533+
return Convert.ToBase64String(sha256.ComputeHash(key));
534+
}
535+
}
536+
409537
public class TestController : ApiController
410538
{
411539
public void Get()

0 commit comments

Comments
 (0)