From a44ff28d153ae41815cf5e44facd103f36204a95 Mon Sep 17 00:00:00 2001 From: Glenna Manns Date: Wed, 10 Oct 2018 09:58:35 -0700 Subject: [PATCH 1/6] create testing/prod aad, publish easy auth settings, run easy auth locally --- .../Actions/AuthActions/BaseAuthAction.cs | 11 + .../AuthActions/CreateAADApplication.cs | 36 ++ .../AzureActions/CreateAADApplication.cs | 43 +++ .../AzureActions/PublishFunctionAppAction.cs | 4 +- .../Actions/HostActions/StartHostAction.cs | 87 ++++- src/Azure.Functions.Cli/Arm/Models/Site.cs | 2 + src/Azure.Functions.Cli/Common/AuthManager.cs | 312 ++++++++++++++++++ .../Common/AuthSettingsFile.cs | 81 +++++ src/Azure.Functions.Cli/Common/Constants.cs | 7 + .../Common/SecretsManager.cs | 29 ++ src/Azure.Functions.Cli/Common/Utilities.cs | 7 + src/Azure.Functions.Cli/Context.cs | 5 +- .../Helpers/AzureHelper.cs | 29 ++ .../Helpers/SecurityHelpers.cs | 9 +- .../Interfaces/IAuthManager.cs | 8 + .../Interfaces/ISecretsManager.cs | 1 + src/Azure.Functions.Cli/Program.cs | 3 + 17 files changed, 656 insertions(+), 18 deletions(-) create mode 100644 src/Azure.Functions.Cli/Actions/AuthActions/BaseAuthAction.cs create mode 100644 src/Azure.Functions.Cli/Actions/AuthActions/CreateAADApplication.cs create mode 100644 src/Azure.Functions.Cli/Actions/AzureActions/CreateAADApplication.cs create mode 100644 src/Azure.Functions.Cli/Common/AuthManager.cs create mode 100644 src/Azure.Functions.Cli/Common/AuthSettingsFile.cs create mode 100644 src/Azure.Functions.Cli/Interfaces/IAuthManager.cs diff --git a/src/Azure.Functions.Cli/Actions/AuthActions/BaseAuthAction.cs b/src/Azure.Functions.Cli/Actions/AuthActions/BaseAuthAction.cs new file mode 100644 index 000000000..3620f8326 --- /dev/null +++ b/src/Azure.Functions.Cli/Actions/AuthActions/BaseAuthAction.cs @@ -0,0 +1,11 @@ +using System.Linq; +using Azure.Functions.Cli.Actions.AzureActions; +using Fclp; + +namespace Azure.Functions.Cli.Actions.AuthActions +{ + abstract class BaseAuthAction : BaseAzureAction + { + protected BaseAuthAction() { } + } +} \ No newline at end of file diff --git a/src/Azure.Functions.Cli/Actions/AuthActions/CreateAADApplication.cs b/src/Azure.Functions.Cli/Actions/AuthActions/CreateAADApplication.cs new file mode 100644 index 000000000..6de62c001 --- /dev/null +++ b/src/Azure.Functions.Cli/Actions/AuthActions/CreateAADApplication.cs @@ -0,0 +1,36 @@ +using System; +using System.Threading.Tasks; +using Azure.Functions.Cli.Common; +using Azure.Functions.Cli.Interfaces; +using Fclp; +namespace Azure.Functions.Cli.Actions.AuthActions +{ + // Invoke via `func auth create-aad --aad-name {displayNameOfAAD}` + [Action(Name = "create-aad", Context = Context.Auth, HelpText = "Creates an Azure Active Directory application with given application name for local development")] + class CreateAADApplication : BaseAuthAction + { + private readonly IAuthManager _authManager; + + public string AADName { get; set; } + + public CreateAADApplication(IAuthManager authManager) + { + _authManager = authManager; + } + + public override async Task RunAsync() + { + await _authManager.CreateAADApplication(AccessToken, AADName); + } + + public override ICommandLineParserResult ParseArgs(string[] args) + { + Parser + .Setup("aad-name") + .WithDescription("Name of AD application to create") + .Callback(f => AADName = f); + + return base.ParseArgs(args); + } + } +} \ No newline at end of file diff --git a/src/Azure.Functions.Cli/Actions/AzureActions/CreateAADApplication.cs b/src/Azure.Functions.Cli/Actions/AzureActions/CreateAADApplication.cs new file mode 100644 index 000000000..b9d53ceb9 --- /dev/null +++ b/src/Azure.Functions.Cli/Actions/AzureActions/CreateAADApplication.cs @@ -0,0 +1,43 @@ +using System; +using System.Threading.Tasks; +using Azure.Functions.Cli.Common; +using Azure.Functions.Cli.Interfaces; +using Fclp; +namespace Azure.Functions.Cli.Actions.AzureActions +{ + // Invoke via `func azure auth create-aad -aad-name {displayNameOfAAD} --app-name {displayNameOfApp}` + [Action(Name = "create-aad", Context = Context.Azure, SubContext = Context.Auth, HelpText = "Creates a production Azure Active Directory application with given name. Links it to specified Azure Application")] + class CreateAADApplication : BaseAzureAction + { + private readonly IAuthManager _authManager; + + public string AADName { get; set; } + + public string AppName { get; set; } + + public CreateAADApplication(IAuthManager authManager) + { + _authManager = authManager; + } + + public override async Task RunAsync() + { + await _authManager.CreateAADApplication(AccessToken, AADName, AppName); + } + + public override ICommandLineParserResult ParseArgs(string[] args) + { + Parser + .Setup("aad-name") + .WithDescription("Name of AD application to create") + .Callback(f => AADName = f); + + Parser + .Setup("app-name") + .WithDescription("Name of Azure Websites Application/Function to link AAD application to") + .Callback(f => AppName = f); + + return base.ParseArgs(args); + } + } +} \ No newline at end of file diff --git a/src/Azure.Functions.Cli/Actions/AzureActions/PublishFunctionAppAction.cs b/src/Azure.Functions.Cli/Actions/AzureActions/PublishFunctionAppAction.cs index ebb34e63b..b25a1a953 100644 --- a/src/Azure.Functions.Cli/Actions/AzureActions/PublishFunctionAppAction.cs +++ b/src/Azure.Functions.Cli/Actions/AzureActions/PublishFunctionAppAction.cs @@ -7,8 +7,6 @@ using System.Net.Http.Headers; using System.Threading; using System.Threading.Tasks; -using Azure.Functions.Cli.Actions.LocalActions; -using Azure.Functions.Cli.Arm; using Azure.Functions.Cli.Arm.Models; using Azure.Functions.Cli.Common; using Azure.Functions.Cli.Extensions; @@ -430,7 +428,7 @@ private IDictionary MergeAppSettings(IDictionary foreach (var pair in local) { if (result.ContainsKeyCaseInsensitive(pair.Key) && - !result.GetValueCaseInsensitive(pair.Key).Equals(pair.Value, StringComparison.OrdinalIgnoreCase)) + !string.Equals(result.GetValueCaseInsensitive(pair.Key), pair.Value, StringComparison.OrdinalIgnoreCase)) { ColoredConsole.WriteLine($"App setting {pair.Key} is different between azure and {SecretsManager.AppSettingsFileName}"); if (OverwriteSettings) diff --git a/src/Azure.Functions.Cli/Actions/HostActions/StartHostAction.cs b/src/Azure.Functions.Cli/Actions/HostActions/StartHostAction.cs index 60441b885..30c873387 100644 --- a/src/Azure.Functions.Cli/Actions/HostActions/StartHostAction.cs +++ b/src/Azure.Functions.Cli/Actions/HostActions/StartHostAction.cs @@ -1,11 +1,13 @@ using System; using System.Collections; using System.Collections.Generic; +using System.Diagnostics; using System.IO; using System.Linq; using System.Net; using System.Net.Http; using System.Security.Cryptography.X509Certificates; +using System.Threading; using System.Threading.Tasks; using Azure.Functions.Cli.Actions.HostActions.WebHost.Security; using Azure.Functions.Cli.Common; @@ -39,7 +41,7 @@ namespace Azure.Functions.Cli.Actions.HostActions [Action(Name = "start", HelpText = "Launches the functions runtime host")] internal class StartHostAction : BaseAction { - private const int DefaultPort = 7071; + public const int DefaultPort = 7071; private const int DefaultTimeout = 20; private readonly ISecretsManager _secretsManager; @@ -184,6 +186,42 @@ private async Task> GetConfigurationSettings(string return settings; } + /// + /// Start up authentication middleware process + /// + /// Where clients send requests + /// Where middleware authentication container sends its modified requests. Also where the Functions runtime will be listening to + /// Full (not relative) path to local certificate (used for HTTPS) + /// Password used when creating above certificate + /// Started task + private async Task StartAuthenticationProcessAsync(string listenUrl, string hostUrl, string certPath, string certPassword) + { + // Listen URL: Where client requests come in to + // Host Destination URL: Output location of middleware authentication container. This is what the Functions runtime will be listening to. + if (CommandChecker.CommandExists("dotnet")) + { + // TODO IMPORTANT - this is very much just for dev/testing. NOT shippable. + // The Middleware Linux Nuget is not yet available. + // Once it is, this code will be changed to invoke that, rather than a hardcoded DLL + string dllPath = @"C:\wagit\AAPT\Antares\EasyAuth\src\EasyAuth\MiddlewareLinux\bin\Debug\netcoreapp2.0\MiddlewareLinux.dll"; + + string query = $"--{Constants.MiddlewareListenUrlSetting} {listenUrl} --{Constants.MiddlewareHostUrlSetting} {hostUrl} " + + $"--{Constants.MiddlewareLocalSettingsSetting} {SecretsManager.MiddlewareAuthSettingsFileName}"; + if (certPath != null && certPassword != null) + { + query += $" --{Constants.MiddlewareCertPathSetting} {certPath} --{Constants.MiddlewareCertPasswordSetting} {certPassword}"; + } + var process = Process.Start("CMD.exe", $"/C dotnet {dllPath} {query}"); + var cancellationToken = new CancellationTokenSource(); + cancellationToken.Token.Register(() => process.Kill()); + await process.WaitForExitAsync(); + } + else + { + throw new FileNotFoundException("Must have dotnet installed in order to use local authentication."); + } + } + private void UpdateEnvironmentVariables(IDictionary secrets) { foreach (var secret in secrets) @@ -217,7 +255,33 @@ public override async Task RunAsync() var settings = SelfHostWebHostSettingsFactory.Create(Environment.CurrentDirectory); - (var listenUri, var baseUri, var certificate) = await Setup(); + // Determine if middleware (Easy Auth) is enabled + var middlewareAuthSettings = _secretsManager.GetMiddlewareAuthSettings(); + bool authenticationEnabled = middlewareAuthSettings.ContainsKeyCaseInsensitive(Constants.MiddlewareAuthEnabledSetting) && + middlewareAuthSettings[Constants.MiddlewareAuthEnabledSetting].ToLower().Equals("true"); + + (var listenUri, var baseUri, var certificate, string certPath, string certPassword) = await Setup(); + + int originalPort = Port; + if (authenticationEnabled) + { + // If it is enabled, then the Functions Host needs a different port + Port = Port + 2; + } + + // Regardless of whether or not auth is enabled, clients should send requests here + var originalListenUri = listenUri.SetPort(originalPort); + var originalBaseUri = baseUri.SetPort(originalPort); + var authTask = Task.CompletedTask; + if (authenticationEnabled) + { + // 1. Modify the Function's Uris to listen to the output of the middleware container, + // rather than the port client requests come in on + // 2. Start the middleware container to listen to the Function's + string originalUrl = originalListenUri.ToString(); // 0.0.0.0:port, where requests will be sent + string destinationHostUrl = baseUri.ToString(); // Output of middleware container + authTask = StartAuthenticationProcessAsync(originalUrl, destinationHostUrl, certPath, certPassword); + } IWebHost host = await BuildWebHost(settings, workerRuntime, listenUri, certificate); var runTask = host.RunAsync(); @@ -226,16 +290,16 @@ public override async Task RunAsync() await hostService.DelayUntilHostReady(); - ColoredConsole.WriteLine($"Listening on {listenUri}"); + ColoredConsole.WriteLine($"Listening on {originalListenUri}"); ColoredConsole.WriteLine("Hit CTRL-C to exit..."); var scriptHost = hostService.Services.GetRequiredService(); var httpOptions = hostService.Services.GetRequiredService>(); - DisplayHttpFunctionsInfo(scriptHost, httpOptions.Value, baseUri); + DisplayHttpFunctionsInfo(scriptHost, httpOptions.Value, originalBaseUri); DisplayDisabledFunctions(scriptHost); await SetupDebuggerAsync(baseUri); - await runTask; + await Task.WhenAll(runTask, authTask); } private async Task PreRunConditions(WorkerRuntime workerRuntime) @@ -395,13 +459,16 @@ internal static async Task CheckNonOptionalSettings(IEnumerable Setup() + private async Task<(Uri listenUri, Uri baseUri, X509Certificate2 cert, string path, string password)> Setup() { var protocol = UseHttps ? "https" : "http"; - X509Certificate2 cert = UseHttps - ? await SecurityHelpers.GetOrCreateCertificate(CertPath, CertPassword) - : null; - return (new Uri($"{protocol}://0.0.0.0:{Port}"), new Uri($"{protocol}://localhost:{Port}"), cert); + if (UseHttps) + { + (X509Certificate2 cert, string certPath, string certPassword) = await SecurityHelpers.GetOrCreateCertificate(CertPath, CertPassword); + return (new Uri($"{protocol}://0.0.0.0:{Port}"), new Uri($"{protocol}://localhost:{Port}"), cert, certPath, certPassword); + } + + return (new Uri($"{protocol}://0.0.0.0:{Port}"), new Uri($"{protocol}://localhost:{Port}"), null, null, null); } public class Startup : IStartup diff --git a/src/Azure.Functions.Cli/Arm/Models/Site.cs b/src/Azure.Functions.Cli/Arm/Models/Site.cs index 38600be82..5482b6465 100644 --- a/src/Azure.Functions.Cli/Arm/Models/Site.cs +++ b/src/Azure.Functions.Cli/Arm/Models/Site.cs @@ -28,6 +28,8 @@ public class Site public IDictionary AzureAppSettings { get; set; } + public IDictionary AzureAuthSettings { get; set; } + public IDictionary ConnectionStrings { get; set; } public bool IsLinux diff --git a/src/Azure.Functions.Cli/Common/AuthManager.cs b/src/Azure.Functions.Cli/Common/AuthManager.cs new file mode 100644 index 000000000..ecf830ba8 --- /dev/null +++ b/src/Azure.Functions.Cli/Common/AuthManager.cs @@ -0,0 +1,312 @@ +using System; +using System.Collections.Generic; +using System.IdentityModel.Tokens.Jwt; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text; +using System.Threading.Tasks; +using Azure.Functions.Cli.Actions.HostActions; +using Azure.Functions.Cli.Arm.Models; +using Azure.Functions.Cli.Helpers; +using Azure.Functions.Cli.Interfaces; +using Colors.Net; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using static Azure.Functions.Cli.Common.OutputTheme; +using static Colors.Net.StringStaticMethods; + +namespace Azure.Functions.Cli.Common +{ + internal class AuthManager : IAuthManager + { + public AuthManager() { } + + private AuthSettingsFile MiddlewareAuthSettings; + + public async Task CreateAADApplication(string accessToken, string AADName, string appName) + { + if (string.IsNullOrEmpty(AADName)) + { + throw new CliArgumentsException("Must specify name of new Azure Active Directory application with --aad-name parameter.", + new CliArgument { Name = "app-name", Description = "Name of new Azure Active Directory application" }); + } + + if (CommandChecker.CommandExists("az")) + { + List replyUrls; + string tempFile, clientSecret, query = CreateQuery(AADName, appName, out tempFile, out clientSecret, out replyUrls); + + ColoredConsole.WriteLine("Query successfully constructed. Creating new Azure AD Application now.."); + + var az = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? new Executable("cmd", $"/c az ad app create {query}") + : new Executable("az", $"ad app create {query}"); + var stdout = new StringBuilder(); + var stderr = new StringBuilder(); + + int exitCode = await az.RunAsync(o => stdout.AppendLine(o), e => stderr.AppendLine(e)); + + // Clean up file we created to pass data in proper format to az CLI + File.Delete($"{tempFile}"); + + if (exitCode != 0) + { + ColoredConsole.WriteLine(Red(stderr.ToString().Trim(' ', '\n', '\r', '"'))); + return; + } + + string response = stdout.ToString().Trim(' ', '\n', '\r', '"'); + ColoredConsole.WriteLine(Green(response)); + + JObject application = JObject.Parse(response); + var jwt = new JwtSecurityToken(accessToken); + string tenantId = jwt.Payload["tid"] as string; + string clientId = (string)application["appId"]; + string homepage = (string)application["homepage"]; + + if (appName == null) + { + // Update function application's (local) auth settings + CreateAndCommitAuthSettings(homepage, clientId, clientSecret, tenantId, replyUrls); + ColoredConsole.WriteLine(Yellow($"This application will only work for the Function Host default port of {StartHostAction.DefaultPort}")); + } + else + { + // Connect this AAD application to the Site whose name was supplied + // Sets the site's /config/authsettings + var connectedSite = await AzureHelper.GetFunctionApp(appName, accessToken); + var authSettingsToPublish = CreateAuthSettingsToPublish(homepage, clientId, clientSecret, tenantId, replyUrls); + await PublishAuthSettingAsync(connectedSite, accessToken, authSettingsToPublish); + } + } + else + { + throw new FileNotFoundException("Cannot find az cli. `auth create-aad` requires the Azure CLI."); + } + } + + /// + /// Create the query to send to az ad app create + /// + /// Name of the new AAD application + /// Name of an existing Azure Application to link to this AAD application + /// + public string CreateQuery(string AADName, string appName, out string tempFile, out string clientSecret, out List replyUrls) + { + clientSecret = GeneratePassword(128); + string authCallback = "/.auth/login/aad/callback"; + + // Assemble the required resources in the proper format + var resourceList = new List(); + var access = new requiredResourceAccess(); + access.resourceAppId = AADConstants.ServicePrincipals.AzureADGraph; + access.resourceAccess = new resourceAccess[] + { + new resourceAccess { type = AADConstants.ResourceAccessTypes.User, id = AADConstants.Permissions.EnableSSO.ToString() } + }; + + resourceList.Add(access); + + // It is easiest to pass them in the right format to the az CLI via a (temp) file + filename + tempFile = $"{Guid.NewGuid()}.txt"; + File.WriteAllText(tempFile, JsonConvert.SerializeObject(resourceList)); + + // Based on whether or not this AAD application is to be used in production or a local environment, + // these parameters are different (plus reply URLs): + string identifierUrl, homepage; + + // This AAD application is for local development - use localhost reply URLs, create local.middleware.json + if (appName == null) + { + // OAuth is port sensitive. There is no way of using a wildcard in the reply URLs to allow for variable ports + // Set the port in the reply URLs to the default used by the Functions Host + identifierUrl = "https://" + AADName + ".localhost"; + homepage = "http://localhost:" + StartHostAction.DefaultPort; + string localhostSSL = "https://localhost:" + StartHostAction.DefaultPort + authCallback; + string localhost = "http://localhost:" + StartHostAction.DefaultPort + authCallback; + + replyUrls = new List + { + localhostSSL, + localhost + }; + } + else + { + identifierUrl = "https://" + appName + ".azurewebsites.net"; + homepage = identifierUrl; + string replyUrl = homepage + authCallback; + + replyUrls = new List + { + replyUrl + }; + } + + replyUrls.Sort(); + string serializedReplyUrls = string.Join(" ", replyUrls.ToArray()); + + string query = $"--display-name {AADName} --homepage {homepage} --identifier-uris {identifierUrl} --password {clientSecret}" + + $" --reply-urls {serializedReplyUrls} --oauth2-allow-implicit-flow true --required-resource-access @{tempFile}"; + + return query; + } + + public void CreateAndCommitAuthSettings(string homepage, string clientId, string clientSecret, string tenant, List replyUrls) + { + // The WEBSITE_AUTH_ALLOWED_AUDIENCES setting is of the form "{replyURL1} {replyURL2}" + string serializedReplyUrls = string.Join(" ", replyUrls.ToArray()); + + // Create a local auth .json file that will be used by the middleware + var middlewareAuthSettingsFile = SecretsManager.MiddlewareAuthSettingsFileName; + MiddlewareAuthSettings = new AuthSettingsFile(middlewareAuthSettingsFile); + MiddlewareAuthSettings.SetAuthSetting("WEBSITE_AUTH_AUTO_AAD", "True"); + MiddlewareAuthSettings.SetAuthSetting("WEBSITE_AUTH_CLIENT_ID", clientId); + MiddlewareAuthSettings.SetAuthSetting("WEBSITE_AUTH_CLIENT_SECRET", clientSecret); + MiddlewareAuthSettings.SetAuthSetting("WEBSITE_AUTH_DEFAULT_PROVIDER", "AzureActiveDirectory"); + MiddlewareAuthSettings.SetAuthSetting("WEBSITE_AUTH_ENABLED", "True"); + MiddlewareAuthSettings.SetAuthSetting("WEBSITE_AUTH_OPENID_ISSUER", "https://sts.windows.net/" + tenant + "/"); + MiddlewareAuthSettings.SetAuthSetting("WEBSITE_AUTH_RUNTIME_VERSION", "1.0.0"); + MiddlewareAuthSettings.SetAuthSetting("WEBSITE_AUTH_TOKEN_STORE", "True"); + MiddlewareAuthSettings.SetAuthSetting("WEBSITE_AUTH_UNAUTHENTICATED_ACTION", "AllowAnonymous"); + + // Middleware requires signing and encryption keys for local testing + // These will be different than the encryption and signing keys used by the application in production + string encryptionKey = ComputeSha256Hash(clientSecret); + string signingKey = ComputeSha256Hash(clientId); + MiddlewareAuthSettings.SetAuthSetting("WEBSITE_AUTH_ENCRYPTION_KEY", encryptionKey); + MiddlewareAuthSettings.SetAuthSetting("WEBSITE_AUTH_SIGNING_KEY", signingKey); + MiddlewareAuthSettings.SetAuthSetting("WEBSITE_AUTH_ALLOWED_AUDIENCES", serializedReplyUrls); + MiddlewareAuthSettings.Commit(); + } + + public Dictionary CreateAuthSettingsToPublish(string homepage, string clientId, string clientSecret, string tenant, List replyUrls) + { + // The 'allowedAudiences' setting of /config/authsettings is of the form ["{replyURL1}", "{replyURL2}"] + string serializedArray = JsonConvert.SerializeObject(replyUrls, Formatting.Indented); + + var authSettingsToPublish = new Dictionary(); + authSettingsToPublish.Add("allowedAudiences", serializedArray); + authSettingsToPublish.Add("isAadAutoProvisioned", "True"); + authSettingsToPublish.Add("clientId", clientId); + authSettingsToPublish.Add("clientSecret", clientSecret); + authSettingsToPublish.Add("defaultProvider", "0"); // 0 corresponds to AzureActiveDirectory + authSettingsToPublish.Add("enabled", "True"); + authSettingsToPublish.Add("issuer", "https://sts.windows.net/" + tenant + "/"); + authSettingsToPublish.Add("runtimeVersion", "1.0.0"); + authSettingsToPublish.Add("tokenStoreEnabled", "True"); + authSettingsToPublish.Add("unauthenticatedClientAction", "1"); // Corresponds to AllowAnonymous + + return authSettingsToPublish; + } + + private static async Task PublishAuthSettingAsync(Site functionApp, string accessToken, Dictionary authSettings) + { + functionApp.AzureAuthSettings = authSettings; + var result = await AzureHelper.UpdateFunctionAppAuthSettings(functionApp, accessToken); + if (!result.IsSuccessful) + { + ColoredConsole + .Error + .WriteLine(ErrorColor("Error updating app settings:")) + .WriteLine(ErrorColor(result.ErrorResult)); + return false; + } + return true; + } + + static string ComputeSha256Hash(string rawData) + { + // Create a SHA256 + using (SHA256 sha256Hash = SHA256.Create()) + { + // ComputeHash - returns byte array + byte[] bytes = sha256Hash.ComputeHash(Encoding.UTF8.GetBytes(rawData)); + // Convert byte array to a string + StringBuilder builder = new StringBuilder(); + for (int i = 0; i < bytes.Length; i++) + { + builder.Append(bytes[i].ToString("x2")); + } + return builder.ToString(); + } + } + + public static string GeneratePassword(int length) + { + const string PasswordChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHJKLMNPQRSTWXYZ0123456789#$"; + string pwd = GetRandomString(PasswordChars, length); + + while (!MeetsConstraint(pwd)) + { + pwd = GetRandomString(PasswordChars, length); + } + + return pwd; + } + + private static string GetRandomString(string allowedChars, int length) + { + StringBuilder retVal = new StringBuilder(length); + byte[] randomBytes = new byte[length * 4]; + using (RNGCryptoServiceProvider rng = new RNGCryptoServiceProvider()) + { + rng.GetBytes(randomBytes); + + for (int i = 0; i < length; i++) + { + int seed = BitConverter.ToInt32(randomBytes, i * 4); + Random random = new Random(seed); + retVal.Append(allowedChars[random.Next(allowedChars.Length)]); + } + } + + return retVal.ToString(); + } + + private static bool MeetsConstraint(string password) + { + return !string.IsNullOrEmpty(password) && + password.Any(c => char.IsUpper(c)) && + password.Any(c => char.IsLower(c)) && + password.Any(c => char.IsDigit(c)) && + password.Any(c => !char.IsLetterOrDigit(c)); + } + } + + static class AADConstants + { + public static class ServicePrincipals + { + public const string AzureADGraph = "00000002-0000-0000-c000-000000000000"; + } + + public static class Permissions + { + public static readonly Guid AccessApplication = new Guid("92042086-4970-4f83-be1c-e9c8e2fab4c8"); + public static readonly Guid EnableSSO = new Guid("311a71cc-e848-46a1-bdf8-97ff7156d8e6"); + public static readonly Guid ReadDirectoryData = new Guid("5778995a-e1bf-45b8-affa-663a9f3f4d04"); + public static readonly Guid ReadAndWriteDirectoryData = new Guid("78c8a3c8-a07e-4b9e-af1b-b5ccab50a175"); + } + + public static class ResourceAccessTypes + { + public const string Application = "Role"; + public const string User = "Scope"; + } + } + + class resourceAccess + { + public string id { get; set; } + public string type { get; set; } + } + + class requiredResourceAccess + { + public string resourceAppId { get; set; } + public resourceAccess[] resourceAccess { get; set; } + } +} \ No newline at end of file diff --git a/src/Azure.Functions.Cli/Common/AuthSettingsFile.cs b/src/Azure.Functions.Cli/Common/AuthSettingsFile.cs new file mode 100644 index 000000000..5a8020bcd --- /dev/null +++ b/src/Azure.Functions.Cli/Common/AuthSettingsFile.cs @@ -0,0 +1,81 @@ +using Azure.Functions.Cli.Common; +using Colors.Net; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +namespace Azure.Functions.Cli.Common +{ + class AuthSettingsFile + { + public bool IsEncrypted { get; set; } + public Dictionary Values { get; set; } = new Dictionary(); + private readonly string _filePath; + private const string reason = "secrets.manager.auth"; + + public AuthSettingsFile(string filePath) + { + _filePath = filePath; + try + { + string path = Path.Combine(Environment.CurrentDirectory, _filePath); + var content = FileSystemHelpers.ReadAllTextFromFile(path); + var authSettings = JObject.Parse(content); + Values = authSettings.ToObject>(); + } + catch + { + Values = new Dictionary(); + IsEncrypted = false; + } + } + + public void SetAuthSetting(string name, string value) + { + if (IsEncrypted) + { + Values[name] = Convert.ToBase64String(ProtectedData.Protect(Encoding.Default.GetBytes(value), reason)); + } + else + { + Values[name] = value; + }; + } + + public void RemoveSetting(string name) + { + if (Values.ContainsKey(name)) + { + Values.Remove(name); + } + } + + public void Commit() + { + FileSystemHelpers.WriteAllTextToFile(_filePath, JsonConvert.SerializeObject(this.GetValues(), Formatting.Indented)); + } + + public IDictionary GetValues() + { + if (IsEncrypted) + { + try + { + return Values.ToDictionary(k => k.Key, v => string.IsNullOrEmpty((string)v.Value) ? string.Empty : + Encoding.Default.GetString(ProtectedData.Unprotect(Convert.FromBase64String((string)v.Value), reason))); + } + catch (Exception e) + { + throw new CliException("Failed to decrypt settings.", e); + } + } + else + { + return Values.ToDictionary(k => k.Key, v => (string)v.Value); + } + } + } +} \ No newline at end of file diff --git a/src/Azure.Functions.Cli/Common/Constants.cs b/src/Azure.Functions.Cli/Common/Constants.cs index 22eb3fd4f..02fdd2bd9 100644 --- a/src/Azure.Functions.Cli/Common/Constants.cs +++ b/src/Azure.Functions.Cli/Common/Constants.cs @@ -27,6 +27,13 @@ internal static class Constants public const string AzureWebJobsStorage = "AzureWebJobsStorage"; public const string PackageReferenceElementName = "PackageReference"; + public const string MiddlewareAuthEnabledSetting = "WEBSITE_AUTH_ENABLED"; + public const string MiddlewareLocalSettingsSetting = "Host.LocalSettingsPath"; + public const string MiddlewareCertPathSetting = "Host.HttpsCertPath"; + public const string MiddlewareCertPasswordSetting = "Host.HttpsCertPassword"; + public const string MiddlewareListenUrlSetting = "Host.ListenUrl"; + public const string MiddlewareHostUrlSetting = "Host.DestinationHostUrl"; + public static string CliVersion => typeof(Constants).GetTypeInfo().Assembly.GetName().Version.ToString(3); public static string CliDetailedVersion = typeof(Constants).Assembly.GetCustomAttribute()?.InformationalVersion ?? string.Empty; diff --git a/src/Azure.Functions.Cli/Common/SecretsManager.cs b/src/Azure.Functions.Cli/Common/SecretsManager.cs index ae584c338..565661457 100644 --- a/src/Azure.Functions.Cli/Common/SecretsManager.cs +++ b/src/Azure.Functions.Cli/Common/SecretsManager.cs @@ -28,6 +28,21 @@ public static string AppSettingsFilePath } } + public static string MiddlewareAuthSettingsFilePath + { + get + { + var authFile = "local.middleware.json"; + var rootPath = ScriptHostHelpers.GetFunctionAppRootDirectory(Environment.CurrentDirectory, new List + { + ScriptConstants.HostMetadataFileName, + authFile, + }); + var authFilePath = Path.Combine(rootPath, authFile); + return authFilePath; + } + } + public static string AppSettingsFileName { get @@ -36,12 +51,26 @@ public static string AppSettingsFileName } } + public static string MiddlewareAuthSettingsFileName + { + get + { + return Path.GetFileName(MiddlewareAuthSettingsFilePath); + } + } + public IDictionary GetSecrets() { var appSettingsFile = new AppSettingsFile(AppSettingsFilePath); return appSettingsFile.GetValues(); } + public IDictionary GetMiddlewareAuthSettings() + { + var authSettingsFile = new AuthSettingsFile(MiddlewareAuthSettingsFilePath); + return authSettingsFile.GetValues(); + } + public IEnumerable GetConnectionStrings() { var appSettingsFile = new AppSettingsFile(AppSettingsFilePath); diff --git a/src/Azure.Functions.Cli/Common/Utilities.cs b/src/Azure.Functions.Cli/Common/Utilities.cs index d71c921ec..a81213601 100644 --- a/src/Azure.Functions.Cli/Common/Utilities.cs +++ b/src/Azure.Functions.Cli/Common/Utilities.cs @@ -88,5 +88,12 @@ internal static bool EqualsIgnoreCaseAndSpace(string str, string another) { return str.Replace(" ", string.Empty).Equals(another.Replace(" ", string.Empty), StringComparison.OrdinalIgnoreCase); } + + public static Uri SetPort(this Uri uri, int newPort) + { + var builder = new UriBuilder(uri); + builder.Port = newPort; + return builder.Uri; + } } } diff --git a/src/Azure.Functions.Cli/Context.cs b/src/Azure.Functions.Cli/Context.cs index ea7d40820..7c96d4405 100644 --- a/src/Azure.Functions.Cli/Context.cs +++ b/src/Azure.Functions.Cli/Context.cs @@ -34,7 +34,10 @@ internal enum Context Templates, [Description("Commands for installing extensions")] - Extensions + Extensions, + + [Description("Commands for setting up and using authentication tools")] + Auth } internal static class ContextEnumExtensions diff --git a/src/Azure.Functions.Cli/Helpers/AzureHelper.cs b/src/Azure.Functions.Cli/Helpers/AzureHelper.cs index 09fb71838..2bcd118cc 100644 --- a/src/Azure.Functions.Cli/Helpers/AzureHelper.cs +++ b/src/Azure.Functions.Cli/Helpers/AzureHelper.cs @@ -95,6 +95,7 @@ await new[] LoadSitePublishingCredentialsAsync(site, accessToken), LoadSiteConfigAsync(site, accessToken), LoadAppSettings(site, accessToken), + LoadAuthSettings(site, accessToken), LoadConnectionStrings(site, accessToken) } //.IgnoreFailures() @@ -118,6 +119,14 @@ private async static Task LoadAppSettings(Site site, string accessToken) return site; } + private async static Task LoadAuthSettings(Site site, string accessToken) + { + var url = new Uri($"{ArmUriTemplates.ArmUrl}{site.SiteId}/config/AuthSettings/list?api-version={ArmUriTemplates.WebsitesApiVersion}"); + var armResponse = await ArmHttpAsync>>(HttpMethod.Post, url, accessToken); + site.AzureAuthSettings = armResponse.properties; + return site; + } + public static async Task LoadSitePublishingCredentialsAsync(Site site, string accessToken) { var url = new Uri($"{ArmUriTemplates.ArmUrl}{site.SiteId}/config/PublishingCredentials/list?api-version={ArmUriTemplates.WebsitesApiVersion}"); @@ -250,6 +259,26 @@ public static async Task, string>> UpdateF } } + public static async Task, string>> UpdateFunctionAppAuthSettings(Site site, string accessToken) + { + var url = new Uri($"{ArmUriTemplates.ArmUrl}{site.SiteId}/config/authsettings?api-version={_storageApiVersion}"); + var response = await ArmClient.HttpInvoke(HttpMethod.Put, url, accessToken, new { properties = site.AzureAuthSettings }); + if (response.IsSuccessStatusCode) + { + var result = await response.Content.ReadAsAsync>>(); + return new HttpResult, string>(result.properties); + } + else + { + var result = await response.Content.ReadAsStringAsync(); + var parsedResult = JsonConvert.DeserializeObject(result); + var errorMessage = parsedResult["Message"].ToString(); + return string.IsNullOrEmpty(errorMessage) + ? new HttpResult, string>(null, result) + : new HttpResult, string>(null, errorMessage); + } + } + public static async Task PrintFunctionsInfo(Site functionApp, string accessToken, bool showKeys) { var functions = await GetFunctions(functionApp, accessToken); diff --git a/src/Azure.Functions.Cli/Helpers/SecurityHelpers.cs b/src/Azure.Functions.Cli/Helpers/SecurityHelpers.cs index 144824a54..871417619 100644 --- a/src/Azure.Functions.Cli/Helpers/SecurityHelpers.cs +++ b/src/Azure.Functions.Cli/Helpers/SecurityHelpers.cs @@ -70,14 +70,14 @@ private static string InternalReadPassword() } } - internal static async Task GetOrCreateCertificate(string certPath, string certPassword) + internal static async Task<(X509Certificate2 cert, string path, string password)> GetOrCreateCertificate(string certPath, string certPassword) { if (!string.IsNullOrEmpty(certPath) && !string.IsNullOrEmpty(certPassword)) { certPassword = File.Exists(certPassword) ? File.ReadAllText(certPassword).Trim() : certPassword; - return new X509Certificate2(certPath, certPassword); + return (new X509Certificate2(certPath, certPassword), certPath, certPassword); } else if (CommandChecker.CommandExists("openssl")) { @@ -115,7 +115,7 @@ internal static async Task GetOrCreateCertificate(string certP throw new CliException("Auto cert generation is currently not working on the .NET Core build."); } - internal static async Task CreateCertificateOpenSSL() + internal static async Task<(X509Certificate2 cert, string path, string password)> CreateCertificateOpenSSL() { const string DEFAULT_PASSWORD = "localcert"; @@ -139,7 +139,8 @@ internal static async Task CreateCertificateOpenSSL() throw new CliException($"Could not create a Certificate using openssl."); } - return new X509Certificate2($"{certFileNames}certificate.pfx", DEFAULT_PASSWORD); + string fullName = $"{certFileNames}certificate.pfx"; + return (new X509Certificate2($"{fullName}", DEFAULT_PASSWORD), fullName, DEFAULT_PASSWORD); } } } diff --git a/src/Azure.Functions.Cli/Interfaces/IAuthManager.cs b/src/Azure.Functions.Cli/Interfaces/IAuthManager.cs new file mode 100644 index 000000000..58978ace2 --- /dev/null +++ b/src/Azure.Functions.Cli/Interfaces/IAuthManager.cs @@ -0,0 +1,8 @@ +using System.Threading.Tasks; +namespace Azure.Functions.Cli.Interfaces +{ + internal interface IAuthManager + { + Task CreateAADApplication(string accessToken, string AADName, string appName = null); + } +} \ No newline at end of file diff --git a/src/Azure.Functions.Cli/Interfaces/ISecretsManager.cs b/src/Azure.Functions.Cli/Interfaces/ISecretsManager.cs index 513c4ab65..29459c790 100644 --- a/src/Azure.Functions.Cli/Interfaces/ISecretsManager.cs +++ b/src/Azure.Functions.Cli/Interfaces/ISecretsManager.cs @@ -14,5 +14,6 @@ public interface ISecretsManager void DeleteSecret(string name); void DeleteConnectionString(string name); HostStartSettings GetHostStartSettings(); + IDictionary GetMiddlewareAuthSettings(); } } diff --git a/src/Azure.Functions.Cli/Program.cs b/src/Azure.Functions.Cli/Program.cs index bb5eb598d..c4fc7f53d 100644 --- a/src/Azure.Functions.Cli/Program.cs +++ b/src/Azure.Functions.Cli/Program.cs @@ -62,6 +62,9 @@ internal static IContainer InitializeAutofacContainer() builder.RegisterType() .As(); + builder.RegisterType() + .As(); + return builder.Build(); } } From c30f947ff7f9b3821f75da60dc0e3e4e69f63a8f Mon Sep 17 00:00:00 2001 From: Glenna Manns Date: Thu, 11 Oct 2018 15:30:42 -0700 Subject: [PATCH 2/6] Remove dotnet dependency by injecting Easy Auth middleware into request pipeline --- .../Actions/HostActions/StartHostAction.cs | 114 ++++++++---------- .../WebHost/Security/AuthMiddleware.cs | 33 +++++ .../Azure.Functions.Cli.csproj | 10 ++ 3 files changed, 91 insertions(+), 66 deletions(-) create mode 100644 src/Azure.Functions.Cli/Actions/HostActions/WebHost/Security/AuthMiddleware.cs diff --git a/src/Azure.Functions.Cli/Actions/HostActions/StartHostAction.cs b/src/Azure.Functions.Cli/Actions/HostActions/StartHostAction.cs index 30c873387..3edd1101e 100644 --- a/src/Azure.Functions.Cli/Actions/HostActions/StartHostAction.cs +++ b/src/Azure.Functions.Cli/Actions/HostActions/StartHostAction.cs @@ -19,6 +19,7 @@ using Fclp; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; +using Microsoft.Azure.AppService.Middleware; using Microsoft.Azure.WebJobs.Extensions.Http; using Microsoft.Azure.WebJobs.Script; using Microsoft.Azure.WebJobs.Script.WebHost; @@ -126,7 +127,7 @@ public override ICommandLineParserResult ParseArgs(string[] args) return Parser.Parse(args); } - private async Task BuildWebHost(ScriptApplicationHostOptions hostOptions, WorkerRuntime workerRuntime, Uri baseAddress, X509Certificate2 certificate) + private async Task BuildWebHost(ScriptApplicationHostOptions hostOptions, WorkerRuntime workerRuntime, Uri baseAddress, X509Certificate2 certificate, bool authenticationEnabled, Uri baseUri) { IDictionary settings = await GetConfigurationSettings(hostOptions.ScriptPath, baseAddress); settings.AddRange(LanguageWorkerHelper.GetWorkerConfiguration(workerRuntime, LanguageWorkerSetting)); @@ -139,13 +140,18 @@ private async Task BuildWebHost(ScriptApplicationHostOptions hostOptio defaultBuilder .UseKestrel(options => { - options.Listen(IPAddress.Loopback, baseAddress.Port, listenOptins => + options.Listen(IPAddress.Loopback, baseAddress.Port, listenOptions => { - listenOptins.UseHttps(certificate); + listenOptions.UseHttps(certificate); }); }); } + if (authenticationEnabled) + { + SetupMiddlewareConfig(baseUri); + } + return defaultBuilder .UseSetting(WebHostDefaults.ApplicationKey, typeof(Startup).Assembly.GetName().Name) .UseUrls(baseAddress.ToString()) @@ -159,7 +165,7 @@ private async Task BuildWebHost(ScriptApplicationHostOptions hostOptio loggingBuilder.AddDefaultWebJobsFilters(); loggingBuilder.AddProvider(new ColoredConsoleLoggerProvider((cat, level) => level >= LogLevel.Information)); }) - .ConfigureServices((context, services) => services.AddSingleton(new Startup(context, hostOptions, CorsOrigins))) + .ConfigureServices((context, services) => services.AddSingleton(new Startup(context, hostOptions, CorsOrigins, authenticationEnabled))) .Build(); } @@ -186,42 +192,6 @@ private async Task> GetConfigurationSettings(string return settings; } - /// - /// Start up authentication middleware process - /// - /// Where clients send requests - /// Where middleware authentication container sends its modified requests. Also where the Functions runtime will be listening to - /// Full (not relative) path to local certificate (used for HTTPS) - /// Password used when creating above certificate - /// Started task - private async Task StartAuthenticationProcessAsync(string listenUrl, string hostUrl, string certPath, string certPassword) - { - // Listen URL: Where client requests come in to - // Host Destination URL: Output location of middleware authentication container. This is what the Functions runtime will be listening to. - if (CommandChecker.CommandExists("dotnet")) - { - // TODO IMPORTANT - this is very much just for dev/testing. NOT shippable. - // The Middleware Linux Nuget is not yet available. - // Once it is, this code will be changed to invoke that, rather than a hardcoded DLL - string dllPath = @"C:\wagit\AAPT\Antares\EasyAuth\src\EasyAuth\MiddlewareLinux\bin\Debug\netcoreapp2.0\MiddlewareLinux.dll"; - - string query = $"--{Constants.MiddlewareListenUrlSetting} {listenUrl} --{Constants.MiddlewareHostUrlSetting} {hostUrl} " + - $"--{Constants.MiddlewareLocalSettingsSetting} {SecretsManager.MiddlewareAuthSettingsFileName}"; - if (certPath != null && certPassword != null) - { - query += $" --{Constants.MiddlewareCertPathSetting} {certPath} --{Constants.MiddlewareCertPasswordSetting} {certPassword}"; - } - var process = Process.Start("CMD.exe", $"/C dotnet {dllPath} {query}"); - var cancellationToken = new CancellationTokenSource(); - cancellationToken.Token.Register(() => process.Kill()); - await process.WaitForExitAsync(); - } - else - { - throw new FileNotFoundException("Must have dotnet installed in order to use local authentication."); - } - } - private void UpdateEnvironmentVariables(IDictionary secrets) { foreach (var secret in secrets) @@ -262,44 +232,23 @@ public override async Task RunAsync() (var listenUri, var baseUri, var certificate, string certPath, string certPassword) = await Setup(); - int originalPort = Port; - if (authenticationEnabled) - { - // If it is enabled, then the Functions Host needs a different port - Port = Port + 2; - } - - // Regardless of whether or not auth is enabled, clients should send requests here - var originalListenUri = listenUri.SetPort(originalPort); - var originalBaseUri = baseUri.SetPort(originalPort); - var authTask = Task.CompletedTask; - if (authenticationEnabled) - { - // 1. Modify the Function's Uris to listen to the output of the middleware container, - // rather than the port client requests come in on - // 2. Start the middleware container to listen to the Function's - string originalUrl = originalListenUri.ToString(); // 0.0.0.0:port, where requests will be sent - string destinationHostUrl = baseUri.ToString(); // Output of middleware container - authTask = StartAuthenticationProcessAsync(originalUrl, destinationHostUrl, certPath, certPassword); - } - - IWebHost host = await BuildWebHost(settings, workerRuntime, listenUri, certificate); + IWebHost host = await BuildWebHost(settings, workerRuntime, listenUri, certificate, authenticationEnabled, baseUri); var runTask = host.RunAsync(); var hostService = host.Services.GetRequiredService(); await hostService.DelayUntilHostReady(); - ColoredConsole.WriteLine($"Listening on {originalListenUri}"); + ColoredConsole.WriteLine($"Listening on {listenUri}"); ColoredConsole.WriteLine("Hit CTRL-C to exit..."); var scriptHost = hostService.Services.GetRequiredService(); var httpOptions = hostService.Services.GetRequiredService>(); - DisplayHttpFunctionsInfo(scriptHost, httpOptions.Value, originalBaseUri); + DisplayHttpFunctionsInfo(scriptHost, httpOptions.Value, baseUri); DisplayDisabledFunctions(scriptHost); await SetupDebuggerAsync(baseUri); - await Task.WhenAll(runTask, authTask); + await runTask; } private async Task PreRunConditions(WorkerRuntime workerRuntime) @@ -459,6 +408,29 @@ internal static async Task CheckNonOptionalSettings(IEnumerable + /// Add additional settings to be consumed by the EasyAuth Middleware + /// + /// + private static void SetupMiddlewareConfig(Uri baseUri) + { + // These are not necessary when Easy Auth is used in conjunction with the Functions CLI + // We add them here for compatibility with existing Easy Auth / Middleware codebase + var args = new string[] { + string.Format("{0}={1}", Constants.MiddlewareListenUrlSetting, baseUri.ToString()), + string.Format("{0}={1}", Constants.MiddlewareHostUrlSetting, baseUri.ToString()), + }; + + // Add above arguments, as well as the settings from local.middleware.json + IConfigurationRoot config = new ConfigurationBuilder() + .SetBasePath(Directory.GetCurrentDirectory()) + .AddJsonFile(SecretsManager.MiddlewareAuthSettingsFilePath) + .AddCommandLine(args) + .Build(); + + ModuleConfig.HostConfig = config; + } + private async Task<(Uri listenUri, Uri baseUri, X509Certificate2 cert, string path, string password)> Setup() { var protocol = UseHttps ? "https" : "http"; @@ -476,11 +448,13 @@ public class Startup : IStartup private readonly WebHostBuilderContext _builderContext; private readonly ScriptApplicationHostOptions _hostOptions; private readonly string[] _corsOrigins; + private readonly bool _authenticationEnabled; - public Startup(WebHostBuilderContext builderContext, ScriptApplicationHostOptions hostOptions, string corsOrigins) + public Startup(WebHostBuilderContext builderContext, ScriptApplicationHostOptions hostOptions, string corsOrigins, bool authEnabled) { _builderContext = builderContext; _hostOptions = hostOptions; + _authenticationEnabled = authEnabled; if (!string.IsNullOrEmpty(corsOrigins)) { @@ -536,6 +510,14 @@ public void Configure(IApplicationBuilder app) IApplicationLifetime applicationLifetime = app.ApplicationServices .GetRequiredService(); + if (_authenticationEnabled) + { + ILoggerProvider loggerProvider = app.ApplicationServices + .GetRequiredService(); + + app.UseAppServiceMiddleware(loggerProvider); + } + app.UseWebJobsScriptHost(applicationLifetime); } } diff --git a/src/Azure.Functions.Cli/Actions/HostActions/WebHost/Security/AuthMiddleware.cs b/src/Azure.Functions.Cli/Actions/HostActions/WebHost/Security/AuthMiddleware.cs new file mode 100644 index 000000000..669db1e94 --- /dev/null +++ b/src/Azure.Functions.Cli/Actions/HostActions/WebHost/Security/AuthMiddleware.cs @@ -0,0 +1,33 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; + +namespace Azure.Functions.Cli.Actions.HostActions.WebHost.Security +{ + static class AuthMiddlewareExtensions + { + public static IApplicationBuilder UseAppServiceMiddleware(this IApplicationBuilder builder, ILoggerProvider loggerProvider) + { + return builder.UseMiddleware(builder, loggerProvider); + } + } + + class AuthMiddleware : Microsoft.Azure.AppService.MiddlewareShim.Startup + { + // The middleware delegate to call after this one finishes processing + private readonly RequestDelegate _next; + + public AuthMiddleware(RequestDelegate next, IApplicationBuilder app, ILoggerProvider loggerProvider) + { + _next = next; + this.Configure(app: app, loggerFactory: null, loggerProvider: loggerProvider); + } + + public Task Invoke(HttpContext httpContext) + { + // OnRequest handles calling Invoke on _next and sending the response + return this.OnRequest(httpContext, _next); + } + } +} diff --git a/src/Azure.Functions.Cli/Azure.Functions.Cli.csproj b/src/Azure.Functions.Cli/Azure.Functions.Cli.csproj index eb416f3ed..441b3c5f9 100644 --- a/src/Azure.Functions.Cli/Azure.Functions.Cli.csproj +++ b/src/Azure.Functions.Cli/Azure.Functions.Cli.csproj @@ -72,6 +72,16 @@ + + + + + C:\wagit\AAPT\Antares\EasyAuth\src\EasyAuth\MiddlewareLinux\bin\Debug\netcoreapp2.0\Microsoft.Azure.AppService.Middleware.dll + + + C:\wagit\AAPT\Antares\EasyAuth\src\EasyAuth\MiddlewareLinux\bin\Debug\netcoreapp2.0\MiddlewareLinux.dll + + From c0b2b08adf185d6cb7cbfdce6821de8fdf13da3d Mon Sep 17 00:00:00 2001 From: Glenna Manns Date: Fri, 12 Oct 2018 10:25:04 -0700 Subject: [PATCH 3/6] Cleanup based on PR comments --- .../Actions/AuthActions/BaseAuthAction.cs | 11 -- .../AuthActions/CreateAADApplication.cs | 36 ----- .../AzureActions/CreateAADApplication.cs | 2 +- src/Azure.Functions.Cli/Common/AuthManager.cs | 143 ++++++++++-------- src/Azure.Functions.Cli/Common/Constants.cs | 23 +++ .../Extensions/StringExtensions.cs | 19 +++ 6 files changed, 121 insertions(+), 113 deletions(-) delete mode 100644 src/Azure.Functions.Cli/Actions/AuthActions/BaseAuthAction.cs delete mode 100644 src/Azure.Functions.Cli/Actions/AuthActions/CreateAADApplication.cs diff --git a/src/Azure.Functions.Cli/Actions/AuthActions/BaseAuthAction.cs b/src/Azure.Functions.Cli/Actions/AuthActions/BaseAuthAction.cs deleted file mode 100644 index 3620f8326..000000000 --- a/src/Azure.Functions.Cli/Actions/AuthActions/BaseAuthAction.cs +++ /dev/null @@ -1,11 +0,0 @@ -using System.Linq; -using Azure.Functions.Cli.Actions.AzureActions; -using Fclp; - -namespace Azure.Functions.Cli.Actions.AuthActions -{ - abstract class BaseAuthAction : BaseAzureAction - { - protected BaseAuthAction() { } - } -} \ No newline at end of file diff --git a/src/Azure.Functions.Cli/Actions/AuthActions/CreateAADApplication.cs b/src/Azure.Functions.Cli/Actions/AuthActions/CreateAADApplication.cs deleted file mode 100644 index 6de62c001..000000000 --- a/src/Azure.Functions.Cli/Actions/AuthActions/CreateAADApplication.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System; -using System.Threading.Tasks; -using Azure.Functions.Cli.Common; -using Azure.Functions.Cli.Interfaces; -using Fclp; -namespace Azure.Functions.Cli.Actions.AuthActions -{ - // Invoke via `func auth create-aad --aad-name {displayNameOfAAD}` - [Action(Name = "create-aad", Context = Context.Auth, HelpText = "Creates an Azure Active Directory application with given application name for local development")] - class CreateAADApplication : BaseAuthAction - { - private readonly IAuthManager _authManager; - - public string AADName { get; set; } - - public CreateAADApplication(IAuthManager authManager) - { - _authManager = authManager; - } - - public override async Task RunAsync() - { - await _authManager.CreateAADApplication(AccessToken, AADName); - } - - public override ICommandLineParserResult ParseArgs(string[] args) - { - Parser - .Setup("aad-name") - .WithDescription("Name of AD application to create") - .Callback(f => AADName = f); - - return base.ParseArgs(args); - } - } -} \ No newline at end of file diff --git a/src/Azure.Functions.Cli/Actions/AzureActions/CreateAADApplication.cs b/src/Azure.Functions.Cli/Actions/AzureActions/CreateAADApplication.cs index b9d53ceb9..93a93de5c 100644 --- a/src/Azure.Functions.Cli/Actions/AzureActions/CreateAADApplication.cs +++ b/src/Azure.Functions.Cli/Actions/AzureActions/CreateAADApplication.cs @@ -6,7 +6,7 @@ namespace Azure.Functions.Cli.Actions.AzureActions { // Invoke via `func azure auth create-aad -aad-name {displayNameOfAAD} --app-name {displayNameOfApp}` - [Action(Name = "create-aad", Context = Context.Azure, SubContext = Context.Auth, HelpText = "Creates a production Azure Active Directory application with given name. Links it to specified Azure Application")] + [Action(Name = "create-aad", Context = Context.Azure, SubContext = Context.Auth, HelpText = "Creates a production Azure Active Directory application with given name. Can be linked to specified Azure Application")] class CreateAADApplication : BaseAzureAction { private readonly IAuthManager _authManager; diff --git a/src/Azure.Functions.Cli/Common/AuthManager.cs b/src/Azure.Functions.Cli/Common/AuthManager.cs index ecf830ba8..1901e71ba 100644 --- a/src/Azure.Functions.Cli/Common/AuthManager.cs +++ b/src/Azure.Functions.Cli/Common/AuthManager.cs @@ -7,6 +7,7 @@ using System.Security.Cryptography; using System.Text; using System.Threading.Tasks; +using System.Xml.Linq; using Azure.Functions.Cli.Actions.HostActions; using Azure.Functions.Cli.Arm.Models; using Azure.Functions.Cli.Helpers; @@ -14,7 +15,8 @@ using Colors.Net; using Newtonsoft.Json; using Newtonsoft.Json.Linq; -using static Azure.Functions.Cli.Common.OutputTheme; +using static Azure.Functions.Cli.Common.Constants; +using static Azure.Functions.Cli.Extensions.StringExtensions; using static Colors.Net.StringStaticMethods; namespace Azure.Functions.Cli.Common @@ -48,13 +50,12 @@ public async Task CreateAADApplication(string accessToken, string AADName, strin int exitCode = await az.RunAsync(o => stdout.AppendLine(o), e => stderr.AppendLine(e)); - // Clean up file we created to pass data in proper format to az CLI + // Delete temporary file created to pass data in proper format to az CLI File.Delete($"{tempFile}"); if (exitCode != 0) { - ColoredConsole.WriteLine(Red(stderr.ToString().Trim(' ', '\n', '\r', '"'))); - return; + throw new CliException(stderr.ToString().Trim(' ', '\n', '\r', '"')); } string response = stdout.ToString().Trim(' ', '\n', '\r', '"'); @@ -119,7 +120,7 @@ public string CreateQuery(string AADName, string appName, out string tempFile, o // This AAD application is for local development - use localhost reply URLs, create local.middleware.json if (appName == null) - { + { // OAuth is port sensitive. There is no way of using a wildcard in the reply URLs to allow for variable ports // Set the port in the reply URLs to the default used by the Functions Host identifierUrl = "https://" + AADName + ".localhost"; @@ -162,6 +163,7 @@ public void CreateAndCommitAuthSettings(string homepage, string clientId, string // Create a local auth .json file that will be used by the middleware var middlewareAuthSettingsFile = SecretsManager.MiddlewareAuthSettingsFileName; MiddlewareAuthSettings = new AuthSettingsFile(middlewareAuthSettingsFile); + MiddlewareAuthSettings.SetAuthSetting("WEBSITE_AUTH_ALLOWED_AUDIENCES", serializedReplyUrls); MiddlewareAuthSettings.SetAuthSetting("WEBSITE_AUTH_AUTO_AAD", "True"); MiddlewareAuthSettings.SetAuthSetting("WEBSITE_AUTH_CLIENT_ID", clientId); MiddlewareAuthSettings.SetAuthSetting("WEBSITE_AUTH_CLIENT_SECRET", clientSecret); @@ -172,14 +174,66 @@ public void CreateAndCommitAuthSettings(string homepage, string clientId, string MiddlewareAuthSettings.SetAuthSetting("WEBSITE_AUTH_TOKEN_STORE", "True"); MiddlewareAuthSettings.SetAuthSetting("WEBSITE_AUTH_UNAUTHENTICATED_ACTION", "AllowAnonymous"); - // Middleware requires signing and encryption keys for local testing - // These will be different than the encryption and signing keys used by the application in production + // Create signing and encryption keys for local testing string encryptionKey = ComputeSha256Hash(clientSecret); string signingKey = ComputeSha256Hash(clientId); MiddlewareAuthSettings.SetAuthSetting("WEBSITE_AUTH_ENCRYPTION_KEY", encryptionKey); - MiddlewareAuthSettings.SetAuthSetting("WEBSITE_AUTH_SIGNING_KEY", signingKey); - MiddlewareAuthSettings.SetAuthSetting("WEBSITE_AUTH_ALLOWED_AUDIENCES", serializedReplyUrls); + MiddlewareAuthSettings.SetAuthSetting("WEBSITE_AUTH_SIGNING_KEY", signingKey); MiddlewareAuthSettings.Commit(); + + // We also need to add this file to the .csproj so that it copies to \bin\debug\netstandard2.x when the Function builds + var csProjFiles = FileSystemHelpers.GetFiles(Environment.CurrentDirectory, searchPattern: "*.csproj").ToList(); + + if (csProjFiles.Count == 1) + { + ModifyCSProj(csProjFiles.First()); + return; + } + else if (csProjFiles.Count == 0) + { + // The working directory might be \bin\debug\netstandard2.x + // Try going up three levels to the main Function directory + var functionDir = Path.GetFullPath(Path.Combine(Environment.CurrentDirectory, @"..\..\..\")); + var functionDirProjFiles = FileSystemHelpers.GetFiles(functionDir, searchPattern: "*.csproj").ToList(); + + if (functionDirProjFiles.Count == 1) + { + ModifyCSProj(functionDirProjFiles.First()); + return; + } + } + + throw new CliException($"Need to be in same folder as .csproj file. Expected 1 .csproj but found {csProjFiles.Count}"); + } + + /// + /// Modify the Function's .csproj so the middleware auth json file will copy to the output directory + /// + public static void ModifyCSProj(string csProj) + { + var xmlFile = XDocument.Load(csProj); + + var project = xmlFile.Element("Project"); + var itemGroups = project.Elements("ItemGroup"); + + var existing = itemGroups.Elements("None").FirstOrDefault(elm => elm.Attribute("Update").Value.Equals(SecretsManager.MiddlewareAuthSettingsFileName)); + if (existing != null) + { + // If we've previously added this file to the .csproj during a previous create-aad call, do not add again + return; + } + + // Assemble the attribute + var newItemGroup = new XElement("ItemGroup"); + var noneElement = new XElement("None", new XAttribute("Update", SecretsManager.MiddlewareAuthSettingsFileName)); + noneElement.Add(new XElement("CopyToOutputDirectory", "PreserveNewest")); + noneElement.Add(new XElement("CopyToPublishDirectory", "Never")); + newItemGroup.Add(noneElement); + + // append item group to project, rather than modifying existing item group + project.Add(newItemGroup); + xmlFile.Save(csProj); + ColoredConsole.WriteLine(Yellow($"Modified {csProj} to include {SecretsManager.MiddlewareAuthSettingsFileName} in output directory.")); } public Dictionary CreateAuthSettingsToPublish(string homepage, string clientId, string clientSecret, string tenant, List replyUrls) @@ -187,17 +241,19 @@ public Dictionary CreateAuthSettingsToPublish(string homepage, s // The 'allowedAudiences' setting of /config/authsettings is of the form ["{replyURL1}", "{replyURL2}"] string serializedArray = JsonConvert.SerializeObject(replyUrls, Formatting.Indented); - var authSettingsToPublish = new Dictionary(); - authSettingsToPublish.Add("allowedAudiences", serializedArray); - authSettingsToPublish.Add("isAadAutoProvisioned", "True"); - authSettingsToPublish.Add("clientId", clientId); - authSettingsToPublish.Add("clientSecret", clientSecret); - authSettingsToPublish.Add("defaultProvider", "0"); // 0 corresponds to AzureActiveDirectory - authSettingsToPublish.Add("enabled", "True"); - authSettingsToPublish.Add("issuer", "https://sts.windows.net/" + tenant + "/"); - authSettingsToPublish.Add("runtimeVersion", "1.0.0"); - authSettingsToPublish.Add("tokenStoreEnabled", "True"); - authSettingsToPublish.Add("unauthenticatedClientAction", "1"); // Corresponds to AllowAnonymous + var authSettingsToPublish = new Dictionary + { + { "allowedAudiences", serializedArray }, + { "isAadAutoProvisioned", "True" }, + { "clientId", clientId }, + { "clientSecret", clientSecret }, + { "defaultProvider", "0" }, // 0 corresponds to AzureActiveDirectory + { "enabled", "True" }, + { "issuer", "https://sts.windows.net/" + tenant + "/" }, + { "runtimeVersion", "1.0.0" }, + { "tokenStoreEnabled", "True" }, + { "unauthenticatedClientAction", "1" } // Corresponds to AllowAnonymous + }; return authSettingsToPublish; } @@ -208,31 +264,10 @@ private static async Task PublishAuthSettingAsync(Site functionApp, string var result = await AzureHelper.UpdateFunctionAppAuthSettings(functionApp, accessToken); if (!result.IsSuccessful) { - ColoredConsole - .Error - .WriteLine(ErrorColor("Error updating app settings:")) - .WriteLine(ErrorColor(result.ErrorResult)); - return false; + throw new CliException((result.ErrorResult)); } return true; - } - - static string ComputeSha256Hash(string rawData) - { - // Create a SHA256 - using (SHA256 sha256Hash = SHA256.Create()) - { - // ComputeHash - returns byte array - byte[] bytes = sha256Hash.ComputeHash(Encoding.UTF8.GetBytes(rawData)); - // Convert byte array to a string - StringBuilder builder = new StringBuilder(); - for (int i = 0; i < bytes.Length; i++) - { - builder.Append(bytes[i].ToString("x2")); - } - return builder.ToString(); - } - } + } public static string GeneratePassword(int length) { @@ -276,28 +311,6 @@ private static bool MeetsConstraint(string password) } } - static class AADConstants - { - public static class ServicePrincipals - { - public const string AzureADGraph = "00000002-0000-0000-c000-000000000000"; - } - - public static class Permissions - { - public static readonly Guid AccessApplication = new Guid("92042086-4970-4f83-be1c-e9c8e2fab4c8"); - public static readonly Guid EnableSSO = new Guid("311a71cc-e848-46a1-bdf8-97ff7156d8e6"); - public static readonly Guid ReadDirectoryData = new Guid("5778995a-e1bf-45b8-affa-663a9f3f4d04"); - public static readonly Guid ReadAndWriteDirectoryData = new Guid("78c8a3c8-a07e-4b9e-af1b-b5ccab50a175"); - } - - public static class ResourceAccessTypes - { - public const string Application = "Role"; - public const string User = "Scope"; - } - } - class resourceAccess { public string id { get; set; } diff --git a/src/Azure.Functions.Cli/Common/Constants.cs b/src/Azure.Functions.Cli/Common/Constants.cs index 02fdd2bd9..32106b9c5 100644 --- a/src/Azure.Functions.Cli/Common/Constants.cs +++ b/src/Azure.Functions.Cli/Common/Constants.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Reflection; @@ -45,6 +46,28 @@ public static class Errors public const string EitherPidOrAllMustBeSpecified = "Must specify either -a/--all or -p/--processId "; } + public static class AADConstants + { + public static class ServicePrincipals + { + public const string AzureADGraph = "00000002-0000-0000-c000-000000000000"; + } + + public static class Permissions + { + public static readonly Guid AccessApplication = new Guid("92042086-4970-4f83-be1c-e9c8e2fab4c8"); + public static readonly Guid EnableSSO = new Guid("311a71cc-e848-46a1-bdf8-97ff7156d8e6"); + public static readonly Guid ReadDirectoryData = new Guid("5778995a-e1bf-45b8-affa-663a9f3f4d04"); + public static readonly Guid ReadAndWriteDirectoryData = new Guid("78c8a3c8-a07e-4b9e-af1b-b5ccab50a175"); + } + + public static class ResourceAccessTypes + { + public const string Application = "Role"; + public const string User = "Scope"; + } + } + public static class ArmConstants { public const string AADAuthorityBase = "https://login.microsoftonline.com"; diff --git a/src/Azure.Functions.Cli/Extensions/StringExtensions.cs b/src/Azure.Functions.Cli/Extensions/StringExtensions.cs index 3acb89b14..8712dcff1 100644 --- a/src/Azure.Functions.Cli/Extensions/StringExtensions.cs +++ b/src/Azure.Functions.Cli/Extensions/StringExtensions.cs @@ -1,6 +1,8 @@ using Azure.Functions.Cli.Exceptions; using Newtonsoft.Json; using System; +using System.Security.Cryptography; +using System.Text; using System.Text.RegularExpressions; namespace Azure.Functions.Cli.Extensions @@ -41,5 +43,22 @@ public static string SanitizeImageName(this string imageName) return cleanImageName.ToLowerInvariant().Substring(0, Math.Min(cleanImageName.Length, 128)).Trim(); } + + public static string ComputeSha256Hash(string rawData) + { + // Create a SHA256 + using (SHA256 sha256Hash = SHA256.Create()) + { + // ComputeHash - returns byte array + byte[] bytes = sha256Hash.ComputeHash(Encoding.UTF8.GetBytes(rawData)); + // Convert byte array to a string + StringBuilder builder = new StringBuilder(); + for (int i = 0; i < bytes.Length; i++) + { + builder.Append(bytes[i].ToString("x2")); + } + return builder.ToString(); + } + } } } From 67e1bd4e6fe617cda6192d942ff7429a4a2df2f7 Mon Sep 17 00:00:00 2001 From: Glenna Manns Date: Tue, 16 Oct 2018 11:28:56 -0700 Subject: [PATCH 4/6] Enable local testing of Token binding get AAD permissions from bindings, cleanup Cleanup and bug fixes cont. --- .../AzureActions/CreateAADApplication.cs | 23 ++- .../Actions/HostActions/StartHostAction.cs | 30 +-- .../WebHost/Security/AuthMiddleware.cs | 2 +- src/Azure.Functions.Cli/Common/AuthManager.cs | 179 +++++++++++++----- .../Common/AuthSettingsFile.cs | 6 +- src/Azure.Functions.Cli/Common/Constants.cs | 64 ++++++- .../Helpers/AzureHelper.cs | 9 - .../Helpers/ExtensionsHelper.cs | 17 ++ .../Helpers/SecurityHelpers.cs | 9 +- .../Interfaces/IAuthManager.cs | 6 +- 10 files changed, 253 insertions(+), 92 deletions(-) diff --git a/src/Azure.Functions.Cli/Actions/AzureActions/CreateAADApplication.cs b/src/Azure.Functions.Cli/Actions/AzureActions/CreateAADApplication.cs index 93a93de5c..dd39199ed 100644 --- a/src/Azure.Functions.Cli/Actions/AzureActions/CreateAADApplication.cs +++ b/src/Azure.Functions.Cli/Actions/AzureActions/CreateAADApplication.cs @@ -1,40 +1,47 @@ using System; +using System.Linq; using System.Threading.Tasks; using Azure.Functions.Cli.Common; +using Azure.Functions.Cli.Helpers; using Azure.Functions.Cli.Interfaces; using Fclp; + namespace Azure.Functions.Cli.Actions.AzureActions { - // Invoke via `func azure auth create-aad -aad-name {displayNameOfAAD} --app-name {displayNameOfApp}` - [Action(Name = "create-aad", Context = Context.Azure, SubContext = Context.Auth, HelpText = "Creates a production Azure Active Directory application with given name. Can be linked to specified Azure Application")] + // Invoke via `func azure auth create-aad --appRegistrationName {yourAppRegistrationName} --appName {yourAppName}` + [Action(Name = "create-aad", Context = Context.Azure, SubContext = Context.Auth, HelpText = "Creates an Azure Active Directory registration. Can be linked to an Azure App Service or Function app.")] class CreateAADApplication : BaseAzureAction { private readonly IAuthManager _authManager; + private readonly ISecretsManager _secretsManager; public string AADName { get; set; } public string AppName { get; set; } - public CreateAADApplication(IAuthManager authManager) + public CreateAADApplication(IAuthManager authManager, ISecretsManager secretsManager) { _authManager = authManager; + _secretsManager = secretsManager; } public override async Task RunAsync() { - await _authManager.CreateAADApplication(AccessToken, AADName, AppName); + var workerRuntime = WorkerRuntimeLanguageHelper.GetCurrentWorkerRuntimeLanguage(_secretsManager); + + await _authManager.CreateAADApplication(AccessToken, AADName, workerRuntime, AppName); } public override ICommandLineParserResult ParseArgs(string[] args) { Parser - .Setup("aad-name") - .WithDescription("Name of AD application to create") + .Setup("appRegistrationName") + .WithDescription("Name of the Azure Active Directory app registration to create.") .Callback(f => AADName = f); Parser - .Setup("app-name") - .WithDescription("Name of Azure Websites Application/Function to link AAD application to") + .Setup("appName") + .WithDescription("Name of the Azure App Service or Azure Functions app which corresponds to the Azure AD app registration.") .Callback(f => AppName = f); return base.ParseArgs(args); diff --git a/src/Azure.Functions.Cli/Actions/HostActions/StartHostAction.cs b/src/Azure.Functions.Cli/Actions/HostActions/StartHostAction.cs index 3edd1101e..57a867fc8 100644 --- a/src/Azure.Functions.Cli/Actions/HostActions/StartHostAction.cs +++ b/src/Azure.Functions.Cli/Actions/HostActions/StartHostAction.cs @@ -131,6 +131,15 @@ private async Task BuildWebHost(ScriptApplicationHostOptions hostOptio { IDictionary settings = await GetConfigurationSettings(hostOptions.ScriptPath, baseAddress); settings.AddRange(LanguageWorkerHelper.GetWorkerConfiguration(workerRuntime, LanguageWorkerSetting)); + + if (authenticationEnabled) + { + var middlewareAuthSettings = new AuthSettingsFile(SecretsManager.MiddlewareAuthSettingsFilePath).GetValues(); + settings.AddRange((IReadOnlyDictionary)middlewareAuthSettings); + + SetupMiddlewareConfig(baseUri); + } + UpdateEnvironmentVariables(settings); var defaultBuilder = Microsoft.AspNetCore.WebHost.CreateDefaultBuilder(Array.Empty()); @@ -147,11 +156,6 @@ private async Task BuildWebHost(ScriptApplicationHostOptions hostOptio }); } - if (authenticationEnabled) - { - SetupMiddlewareConfig(baseUri); - } - return defaultBuilder .UseSetting(WebHostDefaults.ApplicationKey, typeof(Startup).Assembly.GetName().Name) .UseUrls(baseAddress.ToString()) @@ -227,10 +231,12 @@ public override async Task RunAsync() // Determine if middleware (Easy Auth) is enabled var middlewareAuthSettings = _secretsManager.GetMiddlewareAuthSettings(); - bool authenticationEnabled = middlewareAuthSettings.ContainsKeyCaseInsensitive(Constants.MiddlewareAuthEnabledSetting) && - middlewareAuthSettings[Constants.MiddlewareAuthEnabledSetting].ToLower().Equals("true"); + bool authenticationEnabled = + middlewareAuthSettings.TryGetValue(Constants.MiddlewareAuthEnabledSetting, out string enabledValue) && + bool.TryParse(enabledValue, out bool isEnabled) && + isEnabled; - (var listenUri, var baseUri, var certificate, string certPath, string certPassword) = await Setup(); + (var listenUri, var baseUri, var certificate) = await Setup(); IWebHost host = await BuildWebHost(settings, workerRuntime, listenUri, certificate, authenticationEnabled, baseUri); var runTask = host.RunAsync(); @@ -431,16 +437,16 @@ private static void SetupMiddlewareConfig(Uri baseUri) ModuleConfig.HostConfig = config; } - private async Task<(Uri listenUri, Uri baseUri, X509Certificate2 cert, string path, string password)> Setup() + private async Task<(Uri listenUri, Uri baseUri, X509Certificate2 cert)> Setup() { var protocol = UseHttps ? "https" : "http"; if (UseHttps) { - (X509Certificate2 cert, string certPath, string certPassword) = await SecurityHelpers.GetOrCreateCertificate(CertPath, CertPassword); - return (new Uri($"{protocol}://0.0.0.0:{Port}"), new Uri($"{protocol}://localhost:{Port}"), cert, certPath, certPassword); + X509Certificate2 cert = await SecurityHelpers.GetOrCreateCertificate(CertPath, CertPassword); + return (new Uri($"{protocol}://0.0.0.0:{Port}"), new Uri($"{protocol}://localhost:{Port}"), cert); } - return (new Uri($"{protocol}://0.0.0.0:{Port}"), new Uri($"{protocol}://localhost:{Port}"), null, null, null); + return (new Uri($"{protocol}://0.0.0.0:{Port}"), new Uri($"{protocol}://localhost:{Port}"), null); } public class Startup : IStartup diff --git a/src/Azure.Functions.Cli/Actions/HostActions/WebHost/Security/AuthMiddleware.cs b/src/Azure.Functions.Cli/Actions/HostActions/WebHost/Security/AuthMiddleware.cs index 669db1e94..77eb41d87 100644 --- a/src/Azure.Functions.Cli/Actions/HostActions/WebHost/Security/AuthMiddleware.cs +++ b/src/Azure.Functions.Cli/Actions/HostActions/WebHost/Security/AuthMiddleware.cs @@ -21,7 +21,7 @@ class AuthMiddleware : Microsoft.Azure.AppService.MiddlewareShim.Startup public AuthMiddleware(RequestDelegate next, IApplicationBuilder app, ILoggerProvider loggerProvider) { _next = next; - this.Configure(app: app, loggerFactory: null, loggerProvider: loggerProvider); + this.Configure(app, loggerProvider); } public Task Invoke(HttpContext httpContext) diff --git a/src/Azure.Functions.Cli/Common/AuthManager.cs b/src/Azure.Functions.Cli/Common/AuthManager.cs index 1901e71ba..59d97688d 100644 --- a/src/Azure.Functions.Cli/Common/AuthManager.cs +++ b/src/Azure.Functions.Cli/Common/AuthManager.cs @@ -15,6 +15,7 @@ using Colors.Net; using Newtonsoft.Json; using Newtonsoft.Json.Linq; + using static Azure.Functions.Cli.Common.Constants; using static Azure.Functions.Cli.Extensions.StringExtensions; using static Colors.Net.StringStaticMethods; @@ -27,18 +28,22 @@ public AuthManager() { } private AuthSettingsFile MiddlewareAuthSettings; - public async Task CreateAADApplication(string accessToken, string AADName, string appName) + private WorkerRuntime _workerRuntime; + + public async Task CreateAADApplication(string accessToken, string AADName, WorkerRuntime workerRuntime, string appName) { if (string.IsNullOrEmpty(AADName)) { - throw new CliArgumentsException("Must specify name of new Azure Active Directory application with --aad-name parameter.", - new CliArgument { Name = "app-name", Description = "Name of new Azure Active Directory application" }); + throw new CliArgumentsException("Must specify name of new Azure Active Directory registration with --appRegistrationName parameter.", + new CliArgument { Name = "appRegistrationName", Description = "Name of new Azure Active Directory registration" }); } if (CommandChecker.CommandExists("az")) { + _workerRuntime = workerRuntime; + List replyUrls; - string tempFile, clientSecret, query = CreateQuery(AADName, appName, out tempFile, out clientSecret, out replyUrls); + string tempFile, clientSecret, hostName, query = CreateQuery(AADName, appName, out tempFile, out clientSecret, out replyUrls, out hostName); ColoredConsole.WriteLine("Query successfully constructed. Creating new Azure AD Application now.."); @@ -59,27 +64,31 @@ public async Task CreateAADApplication(string accessToken, string AADName, strin } string response = stdout.ToString().Trim(' ', '\n', '\r', '"'); - ColoredConsole.WriteLine(Green(response)); + ColoredConsole.WriteLine(Green($"Successfully created new AAD registration {AADName}")); + ColoredConsole.WriteLine(White(response)); JObject application = JObject.Parse(response); var jwt = new JwtSecurityToken(accessToken); string tenantId = jwt.Payload["tid"] as string; string clientId = (string)application["appId"]; - string homepage = (string)application["homepage"]; if (appName == null) { // Update function application's (local) auth settings - CreateAndCommitAuthSettings(homepage, clientId, clientSecret, tenantId, replyUrls); - ColoredConsole.WriteLine(Yellow($"This application will only work for the Function Host default port of {StartHostAction.DefaultPort}")); + CreateAndCommitAuthSettings(hostName, clientId, clientSecret, tenantId, replyUrls); } else { - // Connect this AAD application to the Site whose name was supplied - // Sets the site's /config/authsettings + // Connect this AAD application to the Site whose name was supplied (set site's /config/authsettings) + // Tell customer what we're doing, since finding and updating the site can take a number of seconds + ColoredConsole.WriteLine($"\nUpdating auth settings of application {appName}.."); + var connectedSite = await AzureHelper.GetFunctionApp(appName, accessToken); - var authSettingsToPublish = CreateAuthSettingsToPublish(homepage, clientId, clientSecret, tenantId, replyUrls); + var authSettingsToPublish = CreateAuthSettingsToPublish(clientId, clientSecret, tenantId, replyUrls); + await PublishAuthSettingAsync(connectedSite, accessToken, authSettingsToPublish); + + ColoredConsole.WriteLine(Green($"Successfully updated {appName}'s auth settings to reference new AAD registration {AADName}")); } } else @@ -94,25 +103,15 @@ public async Task CreateAADApplication(string accessToken, string AADName, strin /// Name of the new AAD application /// Name of an existing Azure Application to link to this AAD application /// - public string CreateQuery(string AADName, string appName, out string tempFile, out string clientSecret, out List replyUrls) + private string CreateQuery(string AADName, string appName, out string tempFile, out string clientSecret, out List replyUrls, out string hostName) { clientSecret = GeneratePassword(128); string authCallback = "/.auth/login/aad/callback"; - // Assemble the required resources in the proper format - var resourceList = new List(); - var access = new requiredResourceAccess(); - access.resourceAppId = AADConstants.ServicePrincipals.AzureADGraph; - access.resourceAccess = new resourceAccess[] - { - new resourceAccess { type = AADConstants.ResourceAccessTypes.User, id = AADConstants.Permissions.EnableSSO.ToString() } - }; - - resourceList.Add(access); - + var requiredResourceAccess = GetRequiredResourceAccesses(); // It is easiest to pass them in the right format to the az CLI via a (temp) file + filename tempFile = $"{Guid.NewGuid()}.txt"; - File.WriteAllText(tempFile, JsonConvert.SerializeObject(resourceList)); + File.WriteAllText(tempFile, JsonConvert.SerializeObject(requiredResourceAccess)); // Based on whether or not this AAD application is to be used in production or a local environment, // these parameters are different (plus reply URLs): @@ -123,21 +122,23 @@ public string CreateQuery(string AADName, string appName, out string tempFile, o { // OAuth is port sensitive. There is no way of using a wildcard in the reply URLs to allow for variable ports // Set the port in the reply URLs to the default used by the Functions Host - identifierUrl = "https://" + AADName + ".localhost"; - homepage = "http://localhost:" + StartHostAction.DefaultPort; + identifierUrl = "http://" + AADName + ".localhost"; + homepage = "http://localhost:" + StartHostAction.DefaultPort; + hostName = "localhost:" + StartHostAction.DefaultPort; string localhostSSL = "https://localhost:" + StartHostAction.DefaultPort + authCallback; string localhost = "http://localhost:" + StartHostAction.DefaultPort + authCallback; replyUrls = new List - { - localhostSSL, - localhost + { + localhost, + localhostSSL }; } else { identifierUrl = "https://" + appName + ".azurewebsites.net"; homepage = identifierUrl; + hostName = appName + ".azurewebsites.net"; string replyUrl = homepage + authCallback; replyUrls = new List @@ -146,16 +147,70 @@ public string CreateQuery(string AADName, string appName, out string tempFile, o }; } - replyUrls.Sort(); string serializedReplyUrls = string.Join(" ", replyUrls.ToArray()); - string query = $"--display-name {AADName} --homepage {homepage} --identifier-uris {identifierUrl} --password {clientSecret}" + + return $"--display-name {AADName} --homepage {homepage} --identifier-uris {identifierUrl} --password {clientSecret}" + $" --reply-urls {serializedReplyUrls} --oauth2-allow-implicit-flow true --required-resource-access @{tempFile}"; - return query; } - public void CreateAndCommitAuthSettings(string homepage, string clientId, string clientSecret, string tenant, List replyUrls) + private List GetRequiredResourceAccesses() + { + var bindings = ExtensionsHelper.GetBindingsWithDirection(); + + // Required for basic Easy Auth / Middleware authentication + var resourceList = new List + { + new requiredResourceAccess + { + resourceAppId = AADConstants.ServicePrincipals.AzureADGraph, + resourceAccess = new resourceAccess[] + { + new resourceAccess + { + type = AADConstants.ResourceAccessTypes.User, + id = AADConstants.Permissions.EnableSSO.ToString() + } + } + }, + }; + + // Determine which Microsoft Graph permissions are necessary, + // Based on which I/O bindings are in the user's functions + HashSet requiredPermissions = new HashSet(); + + foreach (var binding in bindings) + { + // Determine the required permissions for this binding + if (AADConstants.PermissionMap.ContainsKey(binding)) + { + requiredPermissions.UnionWith(AADConstants.PermissionMap[binding]); + } + } + + var resourceAccessList = new List(); + foreach (var permission in requiredPermissions) + { + resourceAccessList.Add(new resourceAccess + { + type = AADConstants.ResourceAccessTypes.User, + id = permission.ToString() + }); + } + + if (resourceAccessList.Count > 0) + { + resourceList.Add(new requiredResourceAccess + { + resourceAppId = AADConstants.ServicePrincipals.MicrosoftGraph, + resourceAccess = resourceAccessList.ToArray() + }); + } + + return resourceList; + } + + private void CreateAndCommitAuthSettings(string hostName, string clientId, string clientSecret, string tenant, List replyUrls) { // The WEBSITE_AUTH_ALLOWED_AUDIENCES setting is of the form "{replyURL1} {replyURL2}" string serializedReplyUrls = string.Join(" ", replyUrls.ToArray()); @@ -173,43 +228,67 @@ public void CreateAndCommitAuthSettings(string homepage, string clientId, string MiddlewareAuthSettings.SetAuthSetting("WEBSITE_AUTH_RUNTIME_VERSION", "1.0.0"); MiddlewareAuthSettings.SetAuthSetting("WEBSITE_AUTH_TOKEN_STORE", "True"); MiddlewareAuthSettings.SetAuthSetting("WEBSITE_AUTH_UNAUTHENTICATED_ACTION", "AllowAnonymous"); + MiddlewareAuthSettings.SetAuthSetting("WEBSITE_HOSTNAME", hostName); // Create signing and encryption keys for local testing string encryptionKey = ComputeSha256Hash(clientSecret); string signingKey = ComputeSha256Hash(clientId); MiddlewareAuthSettings.SetAuthSetting("WEBSITE_AUTH_ENCRYPTION_KEY", encryptionKey); - MiddlewareAuthSettings.SetAuthSetting("WEBSITE_AUTH_SIGNING_KEY", signingKey); + MiddlewareAuthSettings.SetAuthSetting("WEBSITE_AUTH_SIGNING_KEY", signingKey); MiddlewareAuthSettings.Commit(); - // We also need to add this file to the .csproj so that it copies to \bin\debug\netstandard2.x when the Function builds - var csProjFiles = FileSystemHelpers.GetFiles(Environment.CurrentDirectory, searchPattern: "*.csproj").ToList(); + ColoredConsole.WriteLine(Yellow($"Created {SecretsManager.MiddlewareAuthSettingsFileName} with authentication settings necessary for local development.\n" + + $"Running this function locally will only work with the Function Host default port of {StartHostAction.DefaultPort}")); + if (_workerRuntime == WorkerRuntime.dotnet) + { + // If this is a dotnet function, we also need to add this file to the .csproj + // so that it copies to \bin\debug\netstandard2.x when the Function builds + string csProjFile = GetCSProjFilePath(); + + if (csProjFile == null) + { + throw new CliException($"Auth settings file {SecretsManager.MiddlewareAuthSettingsFileName} could not be added to a .csproj and will not be present in the the bin or output directories."); + } + + ModifyCSProj(csProjFile); + } + } + + public static string GetCSProjFilePath() + { + // If we're in the Function root, the .csproj file will be in this folder + var csProjFiles = FileSystemHelpers.GetFiles(Environment.CurrentDirectory, searchPattern: "*.csproj").ToList(); if (csProjFiles.Count == 1) { - ModifyCSProj(csProjFiles.First()); - return; + return csProjFiles.First(); } - else if (csProjFiles.Count == 0) + + // If we're in the function root\bin\debug\netstandard2.x, the .csproj file will be up three directories + try { - // The working directory might be \bin\debug\netstandard2.x - // Try going up three levels to the main Function directory var functionDir = Path.GetFullPath(Path.Combine(Environment.CurrentDirectory, @"..\..\..\")); var functionDirProjFiles = FileSystemHelpers.GetFiles(functionDir, searchPattern: "*.csproj").ToList(); if (functionDirProjFiles.Count == 1) { - ModifyCSProj(functionDirProjFiles.First()); - return; + return functionDirProjFiles.First(); + } + else + { + return null; } } - - throw new CliException($"Need to be in same folder as .csproj file. Expected 1 .csproj but found {csProjFiles.Count}"); + catch (DirectoryNotFoundException e) + { + return null; + } } /// /// Modify the Function's .csproj so the middleware auth json file will copy to the output directory /// - public static void ModifyCSProj(string csProj) + private static void ModifyCSProj(string csProj) { var xmlFile = XDocument.Load(csProj); @@ -219,7 +298,7 @@ public static void ModifyCSProj(string csProj) var existing = itemGroups.Elements("None").FirstOrDefault(elm => elm.Attribute("Update").Value.Equals(SecretsManager.MiddlewareAuthSettingsFileName)); if (existing != null) { - // If we've previously added this file to the .csproj during a previous create-aad call, do not add again + // If we've added this file to the .csproj during a previous create-aad call, do not add again return; } @@ -230,13 +309,14 @@ public static void ModifyCSProj(string csProj) noneElement.Add(new XElement("CopyToPublishDirectory", "Never")); newItemGroup.Add(noneElement); - // append item group to project, rather than modifying existing item group + // Append item group to project & save project.Add(newItemGroup); xmlFile.Save(csProj); + ColoredConsole.WriteLine(Yellow($"Modified {csProj} to include {SecretsManager.MiddlewareAuthSettingsFileName} in output directory.")); } - public Dictionary CreateAuthSettingsToPublish(string homepage, string clientId, string clientSecret, string tenant, List replyUrls) + private Dictionary CreateAuthSettingsToPublish(string clientId, string clientSecret, string tenant, List replyUrls) { // The 'allowedAudiences' setting of /config/authsettings is of the form ["{replyURL1}", "{replyURL2}"] string serializedArray = JsonConvert.SerializeObject(replyUrls, Formatting.Indented); @@ -266,6 +346,7 @@ private static async Task PublishAuthSettingAsync(Site functionApp, string { throw new CliException((result.ErrorResult)); } + return true; } diff --git a/src/Azure.Functions.Cli/Common/AuthSettingsFile.cs b/src/Azure.Functions.Cli/Common/AuthSettingsFile.cs index 5a8020bcd..1ef9f6383 100644 --- a/src/Azure.Functions.Cli/Common/AuthSettingsFile.cs +++ b/src/Azure.Functions.Cli/Common/AuthSettingsFile.cs @@ -12,7 +12,7 @@ namespace Azure.Functions.Cli.Common class AuthSettingsFile { public bool IsEncrypted { get; set; } - public Dictionary Values { get; set; } = new Dictionary(); + public Dictionary Values { get; set; } private readonly string _filePath; private const string reason = "secrets.manager.auth"; @@ -24,11 +24,11 @@ public AuthSettingsFile(string filePath) string path = Path.Combine(Environment.CurrentDirectory, _filePath); var content = FileSystemHelpers.ReadAllTextFromFile(path); var authSettings = JObject.Parse(content); - Values = authSettings.ToObject>(); + Values = new Dictionary(authSettings.ToObject>(), StringComparer.OrdinalIgnoreCase); } catch { - Values = new Dictionary(); + Values = new Dictionary(StringComparer.OrdinalIgnoreCase); IsEncrypted = false; } } diff --git a/src/Azure.Functions.Cli/Common/Constants.cs b/src/Azure.Functions.Cli/Common/Constants.cs index 32106b9c5..0ecc1639c 100644 --- a/src/Azure.Functions.Cli/Common/Constants.cs +++ b/src/Azure.Functions.Cli/Common/Constants.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Collections.ObjectModel; using System.Reflection; +using Microsoft.Azure.WebJobs.Script.Description; namespace Azure.Functions.Cli.Common { @@ -51,14 +52,29 @@ public static class AADConstants public static class ServicePrincipals { public const string AzureADGraph = "00000002-0000-0000-c000-000000000000"; + public const string MicrosoftGraph = "00000003-0000-0000-c000-000000000000"; } public static class Permissions { public static readonly Guid AccessApplication = new Guid("92042086-4970-4f83-be1c-e9c8e2fab4c8"); - public static readonly Guid EnableSSO = new Guid("311a71cc-e848-46a1-bdf8-97ff7156d8e6"); - public static readonly Guid ReadDirectoryData = new Guid("5778995a-e1bf-45b8-affa-663a9f3f4d04"); - public static readonly Guid ReadAndWriteDirectoryData = new Guid("78c8a3c8-a07e-4b9e-af1b-b5ccab50a175"); + public static readonly Guid EnableSSO = new Guid("311a71cc-e848-46a1-bdf8-97ff7156d8e6"); + } + + public static class MicrosoftGraphReadPermissions + { + public static readonly Guid UserRead = new Guid("e1fe6dd8-ba31-4d61-89e7-88639da4683d"); // sign in, read user profile + public static readonly Guid FilesReadAll = new Guid("df85f4d6-205c-4ac5-a5ea-6bf408dba283"); // read all files user can access + public static readonly Guid FilesRead = new Guid("10465720-29dd-4523-a11a-6a75c743c9d9"); // read user's files + public static readonly Guid MailRead = new Guid("570282fd-fa5c-430d-a7fd-fc8dc98a9dca"); // read user's mail + } + + public static class MicrosoftGraphReadWritePermissions + { + public static readonly Guid FilesWriteAll = new Guid("863451e7-0667-486c-a5d6-d135439485f0"); // full access to all files user can access + public static readonly Guid FilesWrite = new Guid("5c28f0bf-8a70-41f1-8ab2-9032436ddb65"); // full access to user's files + public static readonly Guid MailWrite = new Guid("024d486e-b451-40bb-833d-3e66d98c5c73"); // read, write user's mail + public static readonly Guid MailSend = new Guid("e383f46e-2787-4529-855e-0e479a3ffac0"); // send mail on behalf of user } public static class ResourceAccessTypes @@ -66,6 +82,48 @@ public static class ResourceAccessTypes public const string Application = "Role"; public const string User = "Scope"; } + + public static Dictionary<(string, BindingDirection), List> PermissionMap = new Dictionary<(string, BindingDirection), List> + { + { ("graphwebhooktrigger", BindingDirection.In), new List + { + MicrosoftGraphReadPermissions.FilesRead, + MicrosoftGraphReadPermissions.MailRead, + MicrosoftGraphReadPermissions.UserRead // TODO !! check if this is the case + } + }, + { ("outlook", BindingDirection.In), new List + { + MicrosoftGraphReadPermissions.MailRead + } + }, + { ("outlook", BindingDirection.Out), new List + { + MicrosoftGraphReadWritePermissions.MailWrite, + MicrosoftGraphReadWritePermissions.MailSend + } + }, + { ("excel", BindingDirection.In), new List + { + MicrosoftGraphReadPermissions.FilesRead, + } + }, + { ("excel", BindingDirection.Out), new List + { + MicrosoftGraphReadWritePermissions.FilesWrite, + } + }, + { ("onedrive", BindingDirection.In), new List + { + MicrosoftGraphReadPermissions.FilesRead, + } + }, + { ("onedrive", BindingDirection.Out), new List + { + MicrosoftGraphReadWritePermissions.FilesWrite, + } + } + }; } public static class ArmConstants diff --git a/src/Azure.Functions.Cli/Helpers/AzureHelper.cs b/src/Azure.Functions.Cli/Helpers/AzureHelper.cs index 2bcd118cc..0eab2f814 100644 --- a/src/Azure.Functions.Cli/Helpers/AzureHelper.cs +++ b/src/Azure.Functions.Cli/Helpers/AzureHelper.cs @@ -95,7 +95,6 @@ await new[] LoadSitePublishingCredentialsAsync(site, accessToken), LoadSiteConfigAsync(site, accessToken), LoadAppSettings(site, accessToken), - LoadAuthSettings(site, accessToken), LoadConnectionStrings(site, accessToken) } //.IgnoreFailures() @@ -119,14 +118,6 @@ private async static Task LoadAppSettings(Site site, string accessToken) return site; } - private async static Task LoadAuthSettings(Site site, string accessToken) - { - var url = new Uri($"{ArmUriTemplates.ArmUrl}{site.SiteId}/config/AuthSettings/list?api-version={ArmUriTemplates.WebsitesApiVersion}"); - var armResponse = await ArmHttpAsync>>(HttpMethod.Post, url, accessToken); - site.AzureAuthSettings = armResponse.properties; - return site; - } - public static async Task LoadSitePublishingCredentialsAsync(Site site, string accessToken) { var url = new Uri($"{ArmUriTemplates.ArmUrl}{site.SiteId}/config/PublishingCredentials/list?api-version={ArmUriTemplates.WebsitesApiVersion}"); diff --git a/src/Azure.Functions.Cli/Helpers/ExtensionsHelper.cs b/src/Azure.Functions.Cli/Helpers/ExtensionsHelper.cs index 8721cab31..1f9aa153a 100644 --- a/src/Azure.Functions.Cli/Helpers/ExtensionsHelper.cs +++ b/src/Azure.Functions.Cli/Helpers/ExtensionsHelper.cs @@ -52,6 +52,23 @@ private static IEnumerable GetBindings() return bindings; } + public static IEnumerable<(string, BindingDirection)> GetBindingsWithDirection() + { + var functionJsonfiles = FileSystemHelpers.GetFiles(Environment.CurrentDirectory, searchPattern: Constants.FunctionJsonFileName); + var bindings = new HashSet<(string, BindingDirection)>(); + foreach (var functionJson in functionJsonfiles) + { + string functionJsonContents = FileSystemHelpers.ReadAllTextFromFile(functionJson); + var functionMetadata = JsonConvert.DeserializeObject(functionJsonContents); + foreach (var binding in functionMetadata.Bindings) + { + bindings.Add((binding.Type.ToLower(), binding.Direction)); + } + } + return bindings; + } + + public static IEnumerable GetExtensionPackages() { Dictionary packages = new Dictionary(); diff --git a/src/Azure.Functions.Cli/Helpers/SecurityHelpers.cs b/src/Azure.Functions.Cli/Helpers/SecurityHelpers.cs index 871417619..144824a54 100644 --- a/src/Azure.Functions.Cli/Helpers/SecurityHelpers.cs +++ b/src/Azure.Functions.Cli/Helpers/SecurityHelpers.cs @@ -70,14 +70,14 @@ private static string InternalReadPassword() } } - internal static async Task<(X509Certificate2 cert, string path, string password)> GetOrCreateCertificate(string certPath, string certPassword) + internal static async Task GetOrCreateCertificate(string certPath, string certPassword) { if (!string.IsNullOrEmpty(certPath) && !string.IsNullOrEmpty(certPassword)) { certPassword = File.Exists(certPassword) ? File.ReadAllText(certPassword).Trim() : certPassword; - return (new X509Certificate2(certPath, certPassword), certPath, certPassword); + return new X509Certificate2(certPath, certPassword); } else if (CommandChecker.CommandExists("openssl")) { @@ -115,7 +115,7 @@ private static string InternalReadPassword() throw new CliException("Auto cert generation is currently not working on the .NET Core build."); } - internal static async Task<(X509Certificate2 cert, string path, string password)> CreateCertificateOpenSSL() + internal static async Task CreateCertificateOpenSSL() { const string DEFAULT_PASSWORD = "localcert"; @@ -139,8 +139,7 @@ private static string InternalReadPassword() throw new CliException($"Could not create a Certificate using openssl."); } - string fullName = $"{certFileNames}certificate.pfx"; - return (new X509Certificate2($"{fullName}", DEFAULT_PASSWORD), fullName, DEFAULT_PASSWORD); + return new X509Certificate2($"{certFileNames}certificate.pfx", DEFAULT_PASSWORD); } } } diff --git a/src/Azure.Functions.Cli/Interfaces/IAuthManager.cs b/src/Azure.Functions.Cli/Interfaces/IAuthManager.cs index 58978ace2..18677902a 100644 --- a/src/Azure.Functions.Cli/Interfaces/IAuthManager.cs +++ b/src/Azure.Functions.Cli/Interfaces/IAuthManager.cs @@ -1,8 +1,10 @@ -using System.Threading.Tasks; +using Azure.Functions.Cli.Helpers; +using System.Threading.Tasks; + namespace Azure.Functions.Cli.Interfaces { internal interface IAuthManager { - Task CreateAADApplication(string accessToken, string AADName, string appName = null); + Task CreateAADApplication(string accessToken, string AADName, WorkerRuntime workerRuntime, string appName = null); } } \ No newline at end of file From c3813e2e70c2462f7d3b866cd79a2d0094b2a147 Mon Sep 17 00:00:00 2001 From: Glenna Manns Date: Mon, 29 Oct 2018 09:22:35 -0700 Subject: [PATCH 5/6] Update based on Chris' CR feedback --- .../AzureActions/CreateAADApplication.cs | 12 ++-- src/Azure.Functions.Cli/Common/AuthManager.cs | 69 +++++++++---------- .../Common/AuthSettingsFile.cs | 50 +++++++++++--- src/Azure.Functions.Cli/Common/Constants.cs | 2 +- .../Common/SecretsManager.cs | 2 +- src/Azure.Functions.Cli/Common/Utilities.cs | 7 -- .../Helpers/AzureHelper.cs | 3 +- 7 files changed, 83 insertions(+), 62 deletions(-) diff --git a/src/Azure.Functions.Cli/Actions/AzureActions/CreateAADApplication.cs b/src/Azure.Functions.Cli/Actions/AzureActions/CreateAADApplication.cs index dd39199ed..a72805f68 100644 --- a/src/Azure.Functions.Cli/Actions/AzureActions/CreateAADApplication.cs +++ b/src/Azure.Functions.Cli/Actions/AzureActions/CreateAADApplication.cs @@ -8,14 +8,14 @@ namespace Azure.Functions.Cli.Actions.AzureActions { - // Invoke via `func azure auth create-aad --appRegistrationName {yourAppRegistrationName} --appName {yourAppName}` - [Action(Name = "create-aad", Context = Context.Azure, SubContext = Context.Auth, HelpText = "Creates an Azure Active Directory registration. Can be linked to an Azure App Service or Function app.")] + // Invoke via `func azure auth create-aad --AADAppRegistrationName {yourAppRegistrationName} --appName {yourAppName}` + [Action(Name = "create-aad", Context = Context.Azure, SubContext = Context.Auth, HelpText = "Creates an Azure Active Directory app registration. Can be linked to an Azure App Service or Function app.")] class CreateAADApplication : BaseAzureAction { private readonly IAuthManager _authManager; private readonly ISecretsManager _secretsManager; - public string AADName { get; set; } + public string AADAppRegistrationName { get; set; } public string AppName { get; set; } @@ -29,15 +29,15 @@ public override async Task RunAsync() { var workerRuntime = WorkerRuntimeLanguageHelper.GetCurrentWorkerRuntimeLanguage(_secretsManager); - await _authManager.CreateAADApplication(AccessToken, AADName, workerRuntime, AppName); + await _authManager.CreateAADApplication(AccessToken, AADAppRegistrationName, workerRuntime, AppName); } public override ICommandLineParserResult ParseArgs(string[] args) { Parser - .Setup("appRegistrationName") + .Setup("AADAppRegistrationName") .WithDescription("Name of the Azure Active Directory app registration to create.") - .Callback(f => AADName = f); + .Callback(f => AADAppRegistrationName = f); Parser .Setup("appName") diff --git a/src/Azure.Functions.Cli/Common/AuthManager.cs b/src/Azure.Functions.Cli/Common/AuthManager.cs index 59d97688d..97a9c3273 100644 --- a/src/Azure.Functions.Cli/Common/AuthManager.cs +++ b/src/Azure.Functions.Cli/Common/AuthManager.cs @@ -30,41 +30,45 @@ public AuthManager() { } private WorkerRuntime _workerRuntime; - public async Task CreateAADApplication(string accessToken, string AADName, WorkerRuntime workerRuntime, string appName) + private static string _requiredResourceFilename = "requiredResourceAccessList.txt"; + + public async Task CreateAADApplication(string accessToken, string AADAppRegistrationName, WorkerRuntime workerRuntime, string appName) { - if (string.IsNullOrEmpty(AADName)) + if (string.IsNullOrEmpty(AADAppRegistrationName)) { - throw new CliArgumentsException("Must specify name of new Azure Active Directory registration with --appRegistrationName parameter.", - new CliArgument { Name = "appRegistrationName", Description = "Name of new Azure Active Directory registration" }); + throw new CliArgumentsException("Must provide name for a new Azure Active Directory app registration with --AADAppRegistrationName parameter.", + new CliArgument { Name = "AADAppRegistrationName", Description = "Name of new Azure Active Directory app registration" }); } if (CommandChecker.CommandExists("az")) { _workerRuntime = workerRuntime; + _requiredResourceFilename = string.Format("{0}-{1}", AADAppRegistrationName, _requiredResourceFilename); List replyUrls; - string tempFile, clientSecret, hostName, query = CreateQuery(AADName, appName, out tempFile, out clientSecret, out replyUrls, out hostName); + string clientSecret, hostName, commandLineArgs = GetCommandLineArguments(AADAppRegistrationName, appName, out clientSecret, out replyUrls, out hostName); - ColoredConsole.WriteLine("Query successfully constructed. Creating new Azure AD Application now.."); + string command = $"ad app create {commandLineArgs}"; + ColoredConsole.WriteLine($"Creating new Azure AD Application via:\n" + + $"az {command}"); var az = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) - ? new Executable("cmd", $"/c az ad app create {query}") - : new Executable("az", $"ad app create {query}"); + ? new Executable("cmd", $"/c az {command}") + : new Executable("az", command); + + var stdout = new StringBuilder(); var stderr = new StringBuilder(); int exitCode = await az.RunAsync(o => stdout.AppendLine(o), e => stderr.AppendLine(e)); - // Delete temporary file created to pass data in proper format to az CLI - File.Delete($"{tempFile}"); - if (exitCode != 0) { throw new CliException(stderr.ToString().Trim(' ', '\n', '\r', '"')); } string response = stdout.ToString().Trim(' ', '\n', '\r', '"'); - ColoredConsole.WriteLine(Green($"Successfully created new AAD registration {AADName}")); + ColoredConsole.WriteLine(Green($"Successfully created new AAD registration {AADAppRegistrationName}")); ColoredConsole.WriteLine(White(response)); JObject application = JObject.Parse(response); @@ -75,7 +79,7 @@ public async Task CreateAADApplication(string accessToken, string AADName, Worke if (appName == null) { // Update function application's (local) auth settings - CreateAndCommitAuthSettings(hostName, clientId, clientSecret, tenantId, replyUrls); + CreateAndCommitAuthSettings(hostName, clientId, clientSecret, tenantId, replyUrls); } else { @@ -88,7 +92,7 @@ public async Task CreateAADApplication(string accessToken, string AADName, Worke await PublishAuthSettingAsync(connectedSite, accessToken, authSettingsToPublish); - ColoredConsole.WriteLine(Green($"Successfully updated {appName}'s auth settings to reference new AAD registration {AADName}")); + ColoredConsole.WriteLine(Green($"Successfully updated {appName}'s auth settings to reference new AAD app registration {AADAppRegistrationName}")); } } else @@ -98,20 +102,20 @@ public async Task CreateAADApplication(string accessToken, string AADName, Worke } /// - /// Create the query to send to az ad app create + /// Create the string of arguments to send to az ad app create /// - /// Name of the new AAD application + /// Name of the new AAD application /// Name of an existing Azure Application to link to this AAD application /// - private string CreateQuery(string AADName, string appName, out string tempFile, out string clientSecret, out List replyUrls, out string hostName) + private string GetCommandLineArguments(string AADAppRegistrationName, string appName, out string clientSecret, out List replyUrls, out string hostName) { clientSecret = GeneratePassword(128); string authCallback = "/.auth/login/aad/callback"; var requiredResourceAccess = GetRequiredResourceAccesses(); + // It is easiest to pass them in the right format to the az CLI via a (temp) file + filename - tempFile = $"{Guid.NewGuid()}.txt"; - File.WriteAllText(tempFile, JsonConvert.SerializeObject(requiredResourceAccess)); + File.WriteAllText(_requiredResourceFilename, JsonConvert.SerializeObject(requiredResourceAccess)); // Based on whether or not this AAD application is to be used in production or a local environment, // these parameters are different (plus reply URLs): @@ -122,7 +126,7 @@ private string CreateQuery(string AADName, string appName, out string tempFile, { // OAuth is port sensitive. There is no way of using a wildcard in the reply URLs to allow for variable ports // Set the port in the reply URLs to the default used by the Functions Host - identifierUrl = "http://" + AADName + ".localhost"; + identifierUrl = "http://" + AADAppRegistrationName + ".localhost"; homepage = "http://localhost:" + StartHostAction.DefaultPort; hostName = "localhost:" + StartHostAction.DefaultPort; string localhostSSL = "https://localhost:" + StartHostAction.DefaultPort + authCallback; @@ -149,8 +153,8 @@ private string CreateQuery(string AADName, string appName, out string tempFile, string serializedReplyUrls = string.Join(" ", replyUrls.ToArray()); - return $"--display-name {AADName} --homepage {homepage} --identifier-uris {identifierUrl} --password {clientSecret}" + - $" --reply-urls {serializedReplyUrls} --oauth2-allow-implicit-flow true --required-resource-access @{tempFile}"; + return $"--display-name {AADAppRegistrationName} --homepage {homepage} --identifier-uris {identifierUrl} --password {clientSecret}" + + $" --reply-urls {serializedReplyUrls} --oauth2-allow-implicit-flow true --required-resource-access @{_requiredResourceFilename}"; } @@ -213,7 +217,7 @@ private List GetRequiredResourceAccesses() private void CreateAndCommitAuthSettings(string hostName, string clientId, string clientSecret, string tenant, List replyUrls) { // The WEBSITE_AUTH_ALLOWED_AUDIENCES setting is of the form "{replyURL1} {replyURL2}" - string serializedReplyUrls = string.Join(" ", replyUrls.ToArray()); + string serializedReplyUrls = string.Join(" ", replyUrls); // Create a local auth .json file that will be used by the middleware var middlewareAuthSettingsFile = SecretsManager.MiddlewareAuthSettingsFileName; @@ -265,24 +269,19 @@ public static string GetCSProjFilePath() } // If we're in the function root\bin\debug\netstandard2.x, the .csproj file will be up three directories - try + var functionDir = Path.GetFullPath(Path.Combine(Environment.CurrentDirectory, @"..\..\..\")); + + if (Directory.Exists(functionDir)) { - var functionDir = Path.GetFullPath(Path.Combine(Environment.CurrentDirectory, @"..\..\..\")); var functionDirProjFiles = FileSystemHelpers.GetFiles(functionDir, searchPattern: "*.csproj").ToList(); if (functionDirProjFiles.Count == 1) { return functionDirProjFiles.First(); } - else - { - return null; - } - } - catch (DirectoryNotFoundException e) - { - return null; } + + return null; } /// @@ -319,11 +318,11 @@ private static void ModifyCSProj(string csProj) private Dictionary CreateAuthSettingsToPublish(string clientId, string clientSecret, string tenant, List replyUrls) { // The 'allowedAudiences' setting of /config/authsettings is of the form ["{replyURL1}", "{replyURL2}"] - string serializedArray = JsonConvert.SerializeObject(replyUrls, Formatting.Indented); + string serializedReplyUrls = JsonConvert.SerializeObject(replyUrls, Formatting.Indented); var authSettingsToPublish = new Dictionary { - { "allowedAudiences", serializedArray }, + { "allowedAudiences", serializedReplyUrls }, { "isAadAutoProvisioned", "True" }, { "clientId", clientId }, { "clientSecret", clientSecret }, diff --git a/src/Azure.Functions.Cli/Common/AuthSettingsFile.cs b/src/Azure.Functions.Cli/Common/AuthSettingsFile.cs index 1ef9f6383..18eb56c42 100644 --- a/src/Azure.Functions.Cli/Common/AuthSettingsFile.cs +++ b/src/Azure.Functions.Cli/Common/AuthSettingsFile.cs @@ -18,15 +18,32 @@ class AuthSettingsFile public AuthSettingsFile(string filePath) { - _filePath = filePath; - try + _filePath = filePath ?? throw new CliException("Received null value for auth settings filename."); + + string path = Path.Combine(Environment.CurrentDirectory, _filePath); + + if (File.Exists(path)) { - string path = Path.Combine(Environment.CurrentDirectory, _filePath); - var content = FileSystemHelpers.ReadAllTextFromFile(path); - var authSettings = JObject.Parse(content); - Values = new Dictionary(authSettings.ToObject>(), StringComparer.OrdinalIgnoreCase); + try + { + var content = FileSystemHelpers.ReadAllTextFromFile(path); + var authSettings = JObject.Parse(content); + Values = new Dictionary(authSettings.ToObject>(), StringComparer.OrdinalIgnoreCase); + } + catch (UnauthorizedAccessException unauthorizedAccess) + { + throw new CliException(unauthorizedAccess.Message); + } + catch (JsonReaderException jsonError) + { + throw new CliException(jsonError.Message); + } + catch (Exception generic) + { + throw new CliException(generic.ToString()); + } } - catch + else { Values = new Dictionary(StringComparer.OrdinalIgnoreCase); IsEncrypted = false; @@ -55,7 +72,18 @@ public void RemoveSetting(string name) public void Commit() { - FileSystemHelpers.WriteAllTextToFile(_filePath, JsonConvert.SerializeObject(this.GetValues(), Formatting.Indented)); + try + { + FileSystemHelpers.WriteAllTextToFile(_filePath, JsonConvert.SerializeObject(this.GetValues(), Formatting.Indented)); + } + catch (UnauthorizedAccessException unauthorizedAccess) + { + throw new CliException(unauthorizedAccess.Message); + } + catch (Exception generic) + { + throw new CliException(generic.ToString()); + } } public IDictionary GetValues() @@ -64,8 +92,8 @@ public IDictionary GetValues() { try { - return Values.ToDictionary(k => k.Key, v => string.IsNullOrEmpty((string)v.Value) ? string.Empty : - Encoding.Default.GetString(ProtectedData.Unprotect(Convert.FromBase64String((string)v.Value), reason))); + return Values.ToDictionary(k => k.Key, v => string.IsNullOrEmpty(v.Value) ? string.Empty : + Encoding.Default.GetString(ProtectedData.Unprotect(Convert.FromBase64String(v.Value), reason))); } catch (Exception e) { @@ -74,7 +102,7 @@ public IDictionary GetValues() } else { - return Values.ToDictionary(k => k.Key, v => (string)v.Value); + return Values.ToDictionary(k => k.Key, v => v.Value); } } } diff --git a/src/Azure.Functions.Cli/Common/Constants.cs b/src/Azure.Functions.Cli/Common/Constants.cs index 0ecc1639c..79d7d65ac 100644 --- a/src/Azure.Functions.Cli/Common/Constants.cs +++ b/src/Azure.Functions.Cli/Common/Constants.cs @@ -89,7 +89,7 @@ public static class ResourceAccessTypes { MicrosoftGraphReadPermissions.FilesRead, MicrosoftGraphReadPermissions.MailRead, - MicrosoftGraphReadPermissions.UserRead // TODO !! check if this is the case + MicrosoftGraphReadPermissions.UserRead } }, { ("outlook", BindingDirection.In), new List diff --git a/src/Azure.Functions.Cli/Common/SecretsManager.cs b/src/Azure.Functions.Cli/Common/SecretsManager.cs index 565661457..7bea8922c 100644 --- a/src/Azure.Functions.Cli/Common/SecretsManager.cs +++ b/src/Azure.Functions.Cli/Common/SecretsManager.cs @@ -33,7 +33,7 @@ public static string MiddlewareAuthSettingsFilePath get { var authFile = "local.middleware.json"; - var rootPath = ScriptHostHelpers.GetFunctionAppRootDirectory(Environment.CurrentDirectory, new List + var rootPath = ScriptHostHelpers.GetFunctionAppRootDirectory(Environment.CurrentDirectory, new [] { ScriptConstants.HostMetadataFileName, authFile, diff --git a/src/Azure.Functions.Cli/Common/Utilities.cs b/src/Azure.Functions.Cli/Common/Utilities.cs index a81213601..d71c921ec 100644 --- a/src/Azure.Functions.Cli/Common/Utilities.cs +++ b/src/Azure.Functions.Cli/Common/Utilities.cs @@ -88,12 +88,5 @@ internal static bool EqualsIgnoreCaseAndSpace(string str, string another) { return str.Replace(" ", string.Empty).Equals(another.Replace(" ", string.Empty), StringComparison.OrdinalIgnoreCase); } - - public static Uri SetPort(this Uri uri, int newPort) - { - var builder = new UriBuilder(uri); - builder.Port = newPort; - return builder.Uri; - } } } diff --git a/src/Azure.Functions.Cli/Helpers/AzureHelper.cs b/src/Azure.Functions.Cli/Helpers/AzureHelper.cs index 0eab2f814..ac6bd8ef9 100644 --- a/src/Azure.Functions.Cli/Helpers/AzureHelper.cs +++ b/src/Azure.Functions.Cli/Helpers/AzureHelper.cs @@ -17,6 +17,7 @@ namespace Azure.Functions.Cli.Helpers public static class AzureHelper { private static string _storageApiVersion = "2018-02-01"; + private static string _authSettingsApiVersion = "2018-02-01"; public static async Task GetFunctionApp(string name, string accessToken) { @@ -252,7 +253,7 @@ public static async Task, string>> UpdateF public static async Task, string>> UpdateFunctionAppAuthSettings(Site site, string accessToken) { - var url = new Uri($"{ArmUriTemplates.ArmUrl}{site.SiteId}/config/authsettings?api-version={_storageApiVersion}"); + var url = new Uri($"{ArmUriTemplates.ArmUrl}{site.SiteId}/config/authsettings?api-version={_authSettingsApiVersion}"); var response = await ArmClient.HttpInvoke(HttpMethod.Put, url, accessToken, new { properties = site.AzureAuthSettings }); if (response.IsSuccessStatusCode) { From c802d4b668289a7825ae32f0dab899f4306fa305 Mon Sep 17 00:00:00 2001 From: Glenna Manns Date: Tue, 30 Oct 2018 09:26:12 -0700 Subject: [PATCH 6/6] Allow for fsharp projects, remove allowed audiences --- .../AzureActions/CreateAADApplication.cs | 8 +- src/Azure.Functions.Cli/Common/AuthManager.cs | 88 +++++++++---------- .../Extensions/StringExtensions.cs | 2 +- 3 files changed, 46 insertions(+), 52 deletions(-) diff --git a/src/Azure.Functions.Cli/Actions/AzureActions/CreateAADApplication.cs b/src/Azure.Functions.Cli/Actions/AzureActions/CreateAADApplication.cs index a72805f68..b943c9ab1 100644 --- a/src/Azure.Functions.Cli/Actions/AzureActions/CreateAADApplication.cs +++ b/src/Azure.Functions.Cli/Actions/AzureActions/CreateAADApplication.cs @@ -1,15 +1,13 @@ using System; -using System.Linq; using System.Threading.Tasks; -using Azure.Functions.Cli.Common; using Azure.Functions.Cli.Helpers; using Azure.Functions.Cli.Interfaces; using Fclp; namespace Azure.Functions.Cli.Actions.AzureActions { - // Invoke via `func azure auth create-aad --AADAppRegistrationName {yourAppRegistrationName} --appName {yourAppName}` - [Action(Name = "create-aad", Context = Context.Azure, SubContext = Context.Auth, HelpText = "Creates an Azure Active Directory app registration. Can be linked to an Azure App Service or Function app.")] + // Invoke via `func azure auth create-aad-app --AADAppRegistrationName {yourAppRegistrationName} --appName {yourAppName}` + [Action(Name = "create-aad-app", Context = Context.Azure, SubContext = Context.Auth, HelpText = "Creates an Azure Active Directory app registration. Can be linked to an Azure App Service or Function app.")] class CreateAADApplication : BaseAzureAction { private readonly IAuthManager _authManager; @@ -41,7 +39,7 @@ public override ICommandLineParserResult ParseArgs(string[] args) Parser .Setup("appName") - .WithDescription("Name of the Azure App Service or Azure Functions app which corresponds to the Azure AD app registration.") + .WithDescription("Name of the Azure App Service or Azure Functions app which will be connected to the Azure AD app registration.") .Callback(f => AppName = f); return base.ParseArgs(args); diff --git a/src/Azure.Functions.Cli/Common/AuthManager.cs b/src/Azure.Functions.Cli/Common/AuthManager.cs index 97a9c3273..042e7f3de 100644 --- a/src/Azure.Functions.Cli/Common/AuthManager.cs +++ b/src/Azure.Functions.Cli/Common/AuthManager.cs @@ -45,11 +45,10 @@ public async Task CreateAADApplication(string accessToken, string AADAppRegistra _workerRuntime = workerRuntime; _requiredResourceFilename = string.Format("{0}-{1}", AADAppRegistrationName, _requiredResourceFilename); - List replyUrls; - string clientSecret, hostName, commandLineArgs = GetCommandLineArguments(AADAppRegistrationName, appName, out clientSecret, out replyUrls, out hostName); + string clientSecret, hostName, homepage, commandLineArgs = GetCommandLineArguments(AADAppRegistrationName, appName, out clientSecret, out hostName, out homepage); string command = $"ad app create {commandLineArgs}"; - ColoredConsole.WriteLine($"Creating new Azure AD Application via:\n" + + ColoredConsole.WriteLine($"Creating new Azure AD Application via:{Environment.NewLine}" + $"az {command}"); var az = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) @@ -79,16 +78,16 @@ public async Task CreateAADApplication(string accessToken, string AADAppRegistra if (appName == null) { // Update function application's (local) auth settings - CreateAndCommitAuthSettings(hostName, clientId, clientSecret, tenantId, replyUrls); + CreateAndCommitAuthSettings(homepage, hostName, clientId, clientSecret, tenantId); } else { // Connect this AAD application to the Site whose name was supplied (set site's /config/authsettings) // Tell customer what we're doing, since finding and updating the site can take a number of seconds - ColoredConsole.WriteLine($"\nUpdating auth settings of application {appName}.."); + ColoredConsole.WriteLine($"{Environment.NewLine}Updating auth settings of application {appName}.."); var connectedSite = await AzureHelper.GetFunctionApp(appName, accessToken); - var authSettingsToPublish = CreateAuthSettingsToPublish(clientId, clientSecret, tenantId, replyUrls); + var authSettingsToPublish = CreateAuthSettingsToPublish(homepage, clientId, clientSecret, tenantId); await PublishAuthSettingAsync(connectedSite, accessToken, authSettingsToPublish); @@ -97,7 +96,7 @@ public async Task CreateAADApplication(string accessToken, string AADAppRegistra } else { - throw new FileNotFoundException("Cannot find az cli. `auth create-aad` requires the Azure CLI."); + throw new FileNotFoundException("Cannot find az cli. `auth create-aad-app` requires the Azure CLI."); } } @@ -107,7 +106,7 @@ public async Task CreateAADApplication(string accessToken, string AADAppRegistra /// Name of the new AAD application /// Name of an existing Azure Application to link to this AAD application /// - private string GetCommandLineArguments(string AADAppRegistrationName, string appName, out string clientSecret, out List replyUrls, out string hostName) + private string GetCommandLineArguments(string AADAppRegistrationName, string appName, out string clientSecret, out string hostName, out string homepage) { clientSecret = GeneratePassword(128); string authCallback = "/.auth/login/aad/callback"; @@ -119,7 +118,9 @@ private string GetCommandLineArguments(string AADAppRegistrationName, string app // Based on whether or not this AAD application is to be used in production or a local environment, // these parameters are different (plus reply URLs): - string identifierUrl, homepage; + string identifierUrl; + + string serializedReplyUrls; // This AAD application is for local development - use localhost reply URLs, create local.middleware.json if (appName == null) @@ -132,11 +133,12 @@ private string GetCommandLineArguments(string AADAppRegistrationName, string app string localhostSSL = "https://localhost:" + StartHostAction.DefaultPort + authCallback; string localhost = "http://localhost:" + StartHostAction.DefaultPort + authCallback; - replyUrls = new List + var replyUrlsArray = new [] { localhost, localhostSSL - }; + }; + serializedReplyUrls = string.Join(" ", replyUrlsArray); } else { @@ -145,14 +147,13 @@ private string GetCommandLineArguments(string AADAppRegistrationName, string app hostName = appName + ".azurewebsites.net"; string replyUrl = homepage + authCallback; - replyUrls = new List + var replyUrlsArray = new [] { replyUrl }; + serializedReplyUrls = string.Join(" ", replyUrlsArray); } - string serializedReplyUrls = string.Join(" ", replyUrls.ToArray()); - return $"--display-name {AADAppRegistrationName} --homepage {homepage} --identifier-uris {identifierUrl} --password {clientSecret}" + $" --reply-urls {serializedReplyUrls} --oauth2-allow-implicit-flow true --required-resource-access @{_requiredResourceFilename}"; @@ -214,15 +215,12 @@ private List GetRequiredResourceAccesses() return resourceList; } - private void CreateAndCommitAuthSettings(string hostName, string clientId, string clientSecret, string tenant, List replyUrls) + private void CreateAndCommitAuthSettings(string homepage, string hostName, string clientId, string clientSecret, string tenant) { - // The WEBSITE_AUTH_ALLOWED_AUDIENCES setting is of the form "{replyURL1} {replyURL2}" - string serializedReplyUrls = string.Join(" ", replyUrls); - // Create a local auth .json file that will be used by the middleware var middlewareAuthSettingsFile = SecretsManager.MiddlewareAuthSettingsFileName; MiddlewareAuthSettings = new AuthSettingsFile(middlewareAuthSettingsFile); - MiddlewareAuthSettings.SetAuthSetting("WEBSITE_AUTH_ALLOWED_AUDIENCES", serializedReplyUrls); + MiddlewareAuthSettings.SetAuthSetting("WEBSITE_AUTH_ALLOWED_AUDIENCES", homepage); MiddlewareAuthSettings.SetAuthSetting("WEBSITE_AUTH_AUTO_AAD", "True"); MiddlewareAuthSettings.SetAuthSetting("WEBSITE_AUTH_CLIENT_ID", clientId); MiddlewareAuthSettings.SetAuthSetting("WEBSITE_AUTH_CLIENT_SECRET", clientSecret); @@ -235,61 +233,62 @@ private void CreateAndCommitAuthSettings(string hostName, string clientId, strin MiddlewareAuthSettings.SetAuthSetting("WEBSITE_HOSTNAME", hostName); // Create signing and encryption keys for local testing - string encryptionKey = ComputeSha256Hash(clientSecret); - string signingKey = ComputeSha256Hash(clientId); + string encryptionKey = clientSecret.ComputeSha256Hash(); + string signingKey = clientId.ComputeSha256Hash(); MiddlewareAuthSettings.SetAuthSetting("WEBSITE_AUTH_ENCRYPTION_KEY", encryptionKey); MiddlewareAuthSettings.SetAuthSetting("WEBSITE_AUTH_SIGNING_KEY", signingKey); MiddlewareAuthSettings.Commit(); - ColoredConsole.WriteLine(Yellow($"Created {SecretsManager.MiddlewareAuthSettingsFileName} with authentication settings necessary for local development.\n" + + ColoredConsole.WriteLine(Yellow($"Created {SecretsManager.MiddlewareAuthSettingsFileName} with authentication settings necessary for local development.{Environment.NewLine}" + $"Running this function locally will only work with the Function Host default port of {StartHostAction.DefaultPort}")); if (_workerRuntime == WorkerRuntime.dotnet) { - // If this is a dotnet function, we also need to add this file to the .csproj + // If this is a dotnet function, we also need to add this file to the .(fs/cs)proj // so that it copies to \bin\debug\netstandard2.x when the Function builds - string csProjFile = GetCSProjFilePath(); + string projFile = GetProjFilePath(); - if (csProjFile == null) + if (projFile == null) { - throw new CliException($"Auth settings file {SecretsManager.MiddlewareAuthSettingsFileName} could not be added to a .csproj and will not be present in the the bin or output directories."); + ColoredConsole.WriteLine(Red($"Auth settings file {SecretsManager.MiddlewareAuthSettingsFileName} could not be added to a .csproj or .fsproj file and will not be present in the the bin or output directories.")); + return; } - ModifyCSProj(csProjFile); + ModifyCSProj(projFile); } } - public static string GetCSProjFilePath() + public static string GetProjFilePath() { - // If we're in the Function root, the .csproj file will be in this folder - var csProjFiles = FileSystemHelpers.GetFiles(Environment.CurrentDirectory, searchPattern: "*.csproj").ToList(); - if (csProjFiles.Count == 1) + // If we're in the Function root, the fs/cs proj file will be in this folder + var projFiles = FileSystemHelpers.GetFiles(Environment.CurrentDirectory, searchPattern: "*.*proj").ToList(); + if (projFiles.Count == 1) { - return csProjFiles.First(); + return projFiles.First(); } - // If we're in the function root\bin\debug\netstandard2.x, the .csproj file will be up three directories + // If we're in the function root\bin\debug\netstandard2.x, the fs/cs proj file will be up three directories var functionDir = Path.GetFullPath(Path.Combine(Environment.CurrentDirectory, @"..\..\..\")); if (Directory.Exists(functionDir)) { - var functionDirProjFiles = FileSystemHelpers.GetFiles(functionDir, searchPattern: "*.csproj").ToList(); + var functionDirProjFiles = FileSystemHelpers.GetFiles(functionDir, searchPattern: "*.*proj").ToList(); if (functionDirProjFiles.Count == 1) { return functionDirProjFiles.First(); } - } + } return null; } /// - /// Modify the Function's .csproj so the middleware auth json file will copy to the output directory + /// Modify the Function's .csproj or .fsproj so the middleware auth json file will copy to the output directory /// - private static void ModifyCSProj(string csProj) + private static void ModifyCSProj(string projFile) { - var xmlFile = XDocument.Load(csProj); + var xmlFile = XDocument.Load(projFile); var project = xmlFile.Element("Project"); var itemGroups = project.Elements("ItemGroup"); @@ -297,7 +296,7 @@ private static void ModifyCSProj(string csProj) var existing = itemGroups.Elements("None").FirstOrDefault(elm => elm.Attribute("Update").Value.Equals(SecretsManager.MiddlewareAuthSettingsFileName)); if (existing != null) { - // If we've added this file to the .csproj during a previous create-aad call, do not add again + // If we've added this file to the proj file during a previous create-aad-app call, do not add again return; } @@ -310,19 +309,16 @@ private static void ModifyCSProj(string csProj) // Append item group to project & save project.Add(newItemGroup); - xmlFile.Save(csProj); + xmlFile.Save(projFile); - ColoredConsole.WriteLine(Yellow($"Modified {csProj} to include {SecretsManager.MiddlewareAuthSettingsFileName} in output directory.")); + ColoredConsole.WriteLine(Yellow($"Modified {projFile} to include {SecretsManager.MiddlewareAuthSettingsFileName} in the output directories.")); } - private Dictionary CreateAuthSettingsToPublish(string clientId, string clientSecret, string tenant, List replyUrls) + private Dictionary CreateAuthSettingsToPublish(string homepage, string clientId, string clientSecret, string tenant) { - // The 'allowedAudiences' setting of /config/authsettings is of the form ["{replyURL1}", "{replyURL2}"] - string serializedReplyUrls = JsonConvert.SerializeObject(replyUrls, Formatting.Indented); - var authSettingsToPublish = new Dictionary { - { "allowedAudiences", serializedReplyUrls }, + { "allowedAudiences", homepage }, { "isAadAutoProvisioned", "True" }, { "clientId", clientId }, { "clientSecret", clientSecret }, diff --git a/src/Azure.Functions.Cli/Extensions/StringExtensions.cs b/src/Azure.Functions.Cli/Extensions/StringExtensions.cs index 8712dcff1..cb93bece9 100644 --- a/src/Azure.Functions.Cli/Extensions/StringExtensions.cs +++ b/src/Azure.Functions.Cli/Extensions/StringExtensions.cs @@ -44,7 +44,7 @@ public static string SanitizeImageName(this string imageName) return cleanImageName.ToLowerInvariant().Substring(0, Math.Min(cleanImageName.Length, 128)).Trim(); } - public static string ComputeSha256Hash(string rawData) + public static string ComputeSha256Hash(this string rawData) { // Create a SHA256 using (SHA256 sha256Hash = SHA256.Create())