/* * Copyright (c) 2024 ETH Zürich, IT Services * * This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ using System; using System.IO; using SafeExamBrowser.Communication.Contracts.Data; using SafeExamBrowser.Configuration.Contracts; using SafeExamBrowser.Configuration.Contracts.Cryptography; using SafeExamBrowser.Core.Contracts.OperationModel; using SafeExamBrowser.Core.Contracts.OperationModel.Events; using SafeExamBrowser.I18n.Contracts; using SafeExamBrowser.Logging.Contracts; using SafeExamBrowser.Runtime.Operations.Events; using SafeExamBrowser.Settings; using SafeExamBrowser.Settings.Security; using SafeExamBrowser.SystemComponents.Contracts; namespace SafeExamBrowser.Runtime.Operations { internal class ConfigurationOperation : ConfigurationBaseOperation { private readonly IFileSystem fileSystem; private readonly IHashAlgorithm hashAlgorithm; private readonly ILogger logger; public override event ActionRequiredEventHandler ActionRequired; public override event StatusChangedEventHandler StatusChanged; public ConfigurationOperation( string[] commandLineArgs, IConfigurationRepository configuration, IFileSystem fileSystem, IHashAlgorithm hashAlgorithm, ILogger logger, SessionContext sessionContext) : base(commandLineArgs, configuration, sessionContext) { this.fileSystem = fileSystem; this.hashAlgorithm = hashAlgorithm; this.logger = logger; } public override OperationResult Perform() { logger.Info("Initializing application configuration..."); StatusChanged?.Invoke(TextKey.OperationStatus_InitializeConfiguration); var result = OperationResult.Failed; var isValidUri = TryInitializeSettingsUri(out var uri, out var source); if (isValidUri) { result = LoadSettingsForStartup(uri, source); } else { result = LoadDefaultSettings(); } LogOperationResult(result); return result; } public override OperationResult Repeat() { logger.Info("Initializing new application configuration..."); StatusChanged?.Invoke(TextKey.OperationStatus_InitializeConfiguration); var result = OperationResult.Failed; var isValidUri = TryValidateSettingsUri(Context.ReconfigurationFilePath, out var uri); if (isValidUri) { result = LoadSettingsForReconfiguration(uri); } else { logger.Warn($"The resource specified for reconfiguration does not exist or is not valid!"); } LogOperationResult(result); return result; } public override OperationResult Revert() { return OperationResult.Success; } protected override void InvokeActionRequired(ActionRequiredEventArgs args) { ActionRequired?.Invoke(args); } private OperationResult LoadDefaultSettings() { logger.Info("No valid configuration resource specified and no local client configuration found - loading default settings..."); Context.Next.Settings = configuration.LoadDefaultSettings(); return OperationResult.Success; } private OperationResult LoadSettingsForStartup(Uri uri, UriSource source) { var currentPassword = default(string); var passwordParams = default(PasswordParameters); var settings = default(AppSettings); var status = default(LoadStatus?); if (source == UriSource.CommandLine) { var hasAppDataFile = File.Exists(AppDataFilePath); var hasProgramDataFile = File.Exists(ProgramDataFilePath); if (hasProgramDataFile) { status = TryLoadSettings(new Uri(ProgramDataFilePath, UriKind.Absolute), UriSource.ProgramData, out _, out settings); } else if (hasAppDataFile) { status = TryLoadSettings(new Uri(AppDataFilePath, UriKind.Absolute), UriSource.AppData, out _, out settings); } if ((!hasProgramDataFile && !hasAppDataFile) || status == LoadStatus.Success) { currentPassword = settings?.Security.AdminPasswordHash; status = TryLoadSettings(uri, source, out passwordParams, out settings, currentPassword); } } else { status = TryLoadSettings(uri, source, out passwordParams, out settings); } if (status.HasValue) { return DetermineLoadResult(uri, source, settings, status.Value, passwordParams, currentPassword); } else { return OperationResult.Aborted; } } private OperationResult LoadSettingsForReconfiguration(Uri uri) { var currentPassword = Context.Current.Settings.Security.AdminPasswordHash; var source = UriSource.Reconfiguration; var status = TryLoadSettings(uri, source, out var passwordParams, out var settings, currentPassword); var result = OperationResult.Failed; if (status.HasValue) { result = DetermineLoadResult(uri, source, settings, status.Value, passwordParams, currentPassword); } else { result = OperationResult.Aborted; } if (result == OperationResult.Success && Context.Current.IsBrowserResource) { HandleReconfigurationByBrowserResource(); } fileSystem.Delete(uri.LocalPath); logger.Info($"Deleted temporary configuration file '{uri}'."); return result; } private OperationResult DetermineLoadResult(Uri uri, UriSource source, AppSettings settings, LoadStatus status, PasswordParameters passwordParams, string currentPassword = default) { var result = OperationResult.Failed; if (status == LoadStatus.LoadWithBrowser || status == LoadStatus.Success) { var isNewConfiguration = source == UriSource.CommandLine || source == UriSource.Reconfiguration; Context.Next.Settings = settings; if (status == LoadStatus.LoadWithBrowser) { result = HandleBrowserResource(uri); } else if (isNewConfiguration && settings.ConfigurationMode == ConfigurationMode.ConfigureClient) { result = HandleClientConfiguration(uri, passwordParams, currentPassword); } else { result = OperationResult.Success; } HandleStartUrlQuery(uri, source); } else { ShowFailureMessage(status, uri); } return result; } private OperationResult HandleBrowserResource(Uri uri) { Context.Next.IsBrowserResource = true; Context.Next.Settings.Applications.Blacklist.Clear(); Context.Next.Settings.Applications.Whitelist.Clear(); Context.Next.Settings.Display.AllowedDisplays = 10; Context.Next.Settings.Display.IgnoreError = true; Context.Next.Settings.Display.InternalDisplayOnly = false; Context.Next.Settings.Browser.DeleteCacheOnShutdown = false; Context.Next.Settings.Browser.DeleteCookiesOnShutdown = false; Context.Next.Settings.Browser.StartUrl = uri.AbsoluteUri; Context.Next.Settings.Security.AllowReconfiguration = true; Context.Next.Settings.Security.VirtualMachinePolicy = VirtualMachinePolicy.Allow; Context.Next.Settings.Service.IgnoreService = true; logger.Info($"The configuration resource needs authentication or is a webpage, using '{uri}' as start URL for the browser."); return OperationResult.Success; } private OperationResult HandleClientConfiguration(Uri uri, PasswordParameters passwordParams, string currentPassword = default) { var isFirstSession = Context.Current == null; var success = TryConfigureClient(uri, passwordParams, currentPassword); var result = OperationResult.Failed; if (!success.HasValue || (success == true && isFirstSession && AbortAfterClientConfiguration())) { result = OperationResult.Aborted; } else if (success == true) { result = OperationResult.Success; } return result; } private void HandleReconfigurationByBrowserResource() { Context.Next.Settings.Browser.DeleteCookiesOnStartup = false; logger.Info("Some browser settings were overridden in order to retain a potential LMS / web application session."); } private void HandleStartUrlQuery(Uri uri, UriSource source) { if (source == UriSource.Reconfiguration && Uri.TryCreate(Context.ReconfigurationUrl, UriKind.Absolute, out var reconfigurationUri)) { uri = reconfigurationUri; } if (uri != default && uri.Query.LastIndexOf('?') > 0) { Context.Next.Settings.Browser.StartUrlQuery = uri.Query.Substring(uri.Query.LastIndexOf('?')); } } private bool? TryConfigureClient(Uri uri, PasswordParameters passwordParams, string currentPassword = default) { var mustAuthenticate = IsRequiredToAuthenticateForClientConfiguration(passwordParams, currentPassword); logger.Info("Starting client configuration..."); if (mustAuthenticate) { var authenticated = AuthenticateForClientConfiguration(currentPassword); if (authenticated == true) { logger.Info("Authentication was successful."); } if (authenticated == false) { logger.Info("Authentication has failed!"); ActionRequired?.Invoke(new InvalidPasswordMessageArgs()); return false; } if (!authenticated.HasValue) { logger.Info("Authentication was aborted."); return null; } } else { logger.Info("Authentication is not required."); } var status = configuration.ConfigureClientWith(uri, passwordParams); var success = status == SaveStatus.Success; if (success) { logger.Info("Client configuration was successful."); } else { logger.Error($"Client configuration failed with status '{status}'!"); ActionRequired?.Invoke(new ClientConfigurationErrorMessageArgs()); } return success; } private bool IsRequiredToAuthenticateForClientConfiguration(PasswordParameters passwordParams, string currentPassword = default) { var mustAuthenticate = currentPassword != default; if (mustAuthenticate) { var nextPassword = Context.Next.Settings.Security.AdminPasswordHash; var hasSettingsPassword = passwordParams.Password != null; var sameAdminPassword = currentPassword.Equals(nextPassword, StringComparison.OrdinalIgnoreCase); if (sameAdminPassword) { mustAuthenticate = false; } else if (hasSettingsPassword) { var settingsPassword = passwordParams.IsHash ? passwordParams.Password : hashAlgorithm.GenerateHashFor(passwordParams.Password); var knowsAdminPassword = currentPassword.Equals(settingsPassword, StringComparison.OrdinalIgnoreCase); mustAuthenticate = !knowsAdminPassword; } } return mustAuthenticate; } private bool? AuthenticateForClientConfiguration(string currentPassword) { var authenticated = false; for (var attempts = 0; attempts < 5 && !authenticated; attempts++) { var success = TryGetPassword(PasswordRequestPurpose.LocalAdministrator, out var password); if (success) { authenticated = currentPassword.Equals(hashAlgorithm.GenerateHashFor(password), StringComparison.OrdinalIgnoreCase); } else { return null; } } return authenticated; } private bool AbortAfterClientConfiguration() { var args = new ConfigurationCompletedEventArgs(); ActionRequired?.Invoke(args); logger.Info($"The user chose to {(args.AbortStartup ? "abort" : "continue")} startup after successful client configuration."); return args.AbortStartup; } private void ShowFailureMessage(LoadStatus status, Uri uri) { switch (status) { case LoadStatus.PasswordNeeded: ActionRequired?.Invoke(new InvalidPasswordMessageArgs()); break; case LoadStatus.InvalidData: ActionRequired?.Invoke(new InvalidDataMessageArgs(uri.ToString())); break; case LoadStatus.NotSupported: ActionRequired?.Invoke(new NotSupportedMessageArgs(uri.ToString())); break; case LoadStatus.UnexpectedError: ActionRequired?.Invoke(new UnexpectedErrorMessageArgs(uri.ToString())); break; } } private bool TryInitializeSettingsUri(out Uri uri, out UriSource source) { var isValidUri = false; uri = default; source = default; if (commandLineArgs?.Length > 1) { isValidUri = Uri.TryCreate(commandLineArgs[1], UriKind.Absolute, out uri); source = UriSource.CommandLine; logger.Info($"Found command-line argument for configuration resource: '{uri}', the URI is {(isValidUri ? "valid" : "invalid")}."); } if (!isValidUri && File.Exists(ProgramDataFilePath)) { isValidUri = Uri.TryCreate(ProgramDataFilePath, UriKind.Absolute, out uri); source = UriSource.ProgramData; logger.Info($"Found configuration file in program data directory: '{uri}'."); } if (!isValidUri && File.Exists(AppDataFilePath)) { isValidUri = Uri.TryCreate(AppDataFilePath, UriKind.Absolute, out uri); source = UriSource.AppData; logger.Info($"Found configuration file in app data directory: '{uri}'."); } return isValidUri; } private bool TryValidateSettingsUri(string path, out Uri uri) { var isValidUri = Uri.TryCreate(path, UriKind.Absolute, out uri); isValidUri &= uri != null && uri.IsFile; isValidUri &= File.Exists(path); return isValidUri; } private void LogOperationResult(OperationResult result) { switch (result) { case OperationResult.Aborted: logger.Info("The configuration was aborted by the user."); break; case OperationResult.Failed: logger.Warn("The configuration has failed!"); break; case OperationResult.Success: logger.Info("The configuration was successful."); break; } } } }