/* * Copyright (c) 2025 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.Collections.Generic; 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.Settings; using SafeExamBrowser.Settings.Security; using SafeExamBrowser.SystemComponents.Contracts; using SafeExamBrowser.UserInterface.Contracts; using SafeExamBrowser.UserInterface.Contracts.MessageBox; namespace SafeExamBrowser.Runtime.Operations.Session { internal class ConfigurationOperation : ConfigurationBaseOperation { private readonly string[] commandLineArgs; private readonly IFileSystem fileSystem; private readonly IHashAlgorithm hashAlgorithm; public override event StatusChangedEventHandler StatusChanged; public ConfigurationOperation( string[] commandLineArgs, Dependencies dependencies, IFileSystem fileSystem, IHashAlgorithm hashAlgorithm, IConfigurationRepository repository, IUserInterfaceFactory uiFactory) : base(dependencies, repository, uiFactory) { this.commandLineArgs = commandLineArgs; this.fileSystem = fileSystem; this.hashAlgorithm = hashAlgorithm; } 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; } private OperationResult LoadDefaultSettings() { Logger.Info("No valid configuration resource specified and no local client configuration found - loading default settings..."); Context.Next.Settings = repository.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.Browser.DeleteCacheOnShutdown = false; Context.Next.Settings.Browser.DeleteCookiesOnShutdown = false; Context.Next.Settings.Browser.StartUrl = uri.AbsoluteUri; Context.Next.Settings.Display.AllowedDisplays = 10; Context.Next.Settings.Display.IgnoreError = true; Context.Next.Settings.Display.InternalDisplayOnly = false; Context.Next.Settings.Security.AllowReconfiguration = true; Context.Next.Settings.Security.VirtualMachinePolicy = VirtualMachinePolicy.Allow; Context.Next.Settings.Service.IgnoreService = true; Context.Next.Settings.UserInterface.ActionCenter.EnableActionCenter = false; 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); var success = new bool?(true); Logger.Info("Starting client configuration..."); if (mustAuthenticate) { success = AuthenticateForClientConfiguration(currentPassword); } else { Logger.Info("Authentication is not required."); } if (success == true) { var status = repository.ConfigureClientWith(uri, passwordParams); success = status == SaveStatus.Success; if (success == true) { Logger.Info("Client configuration was successful."); } else { Logger.Error($"Client configuration failed with status '{status}'!"); ShowMessageBox(TextKey.MessageBox_ClientConfigurationError, TextKey.MessageBox_ClientConfigurationErrorTitle, icon: MessageBoxIcon.Error); } } 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 = default(bool?); for (var attempts = 0; attempts < 5 && authenticated != true; attempts++) { var success = TryGetPassword(PasswordRequestPurpose.LocalAdministrator, out var password); if (success) { authenticated = currentPassword.Equals(hashAlgorithm.GenerateHashFor(password), StringComparison.OrdinalIgnoreCase); } else { authenticated = default; break; } } if (authenticated == true) { Logger.Info("Authentication was successful."); } if (authenticated == false) { Logger.Info("Authentication has failed!"); ShowMessageBox(TextKey.MessageBox_InvalidPasswordError, TextKey.MessageBox_InvalidPasswordErrorTitle, icon: MessageBoxIcon.Error); } if (authenticated == default) { Logger.Info("Authentication was aborted."); } return authenticated; } private bool AbortAfterClientConfiguration() { var message = TextKey.MessageBox_ClientConfigurationQuestion; var title = TextKey.MessageBox_ClientConfigurationQuestionTitle; var result = ShowMessageBox(message, title, MessageBoxAction.YesNo, MessageBoxIcon.Question); var abort = result == MessageBoxResult.Yes; Logger.Info($"The user chose to {(abort ? "abort" : "continue")} startup after successful client configuration."); return abort; } private void ShowFailureMessage(LoadStatus status, Uri uri) { var error = MessageBoxIcon.Error; var message = default(TextKey); var placeholders = new Dictionary(); var title = default(TextKey); switch (status) { case LoadStatus.PasswordNeeded: message = TextKey.MessageBox_InvalidPasswordError; title = TextKey.MessageBox_InvalidPasswordErrorTitle; break; case LoadStatus.InvalidData: message = TextKey.MessageBox_InvalidConfigurationData; placeholders["%%URI%%"] = uri.ToString(); title = TextKey.MessageBox_InvalidConfigurationDataTitle; break; case LoadStatus.NotSupported: message = TextKey.MessageBox_NotSupportedConfigurationResource; placeholders["%%URI%%"] = uri.ToString(); title = TextKey.MessageBox_NotSupportedConfigurationResourceTitle; break; case LoadStatus.UnexpectedError: message = TextKey.MessageBox_UnexpectedConfigurationError; placeholders["%%URI%%"] = uri.ToString(); title = TextKey.MessageBox_UnexpectedConfigurationErrorTitle; break; } ShowMessageBox(message, title, icon: error, messagePlaceholders: placeholders); } 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; } } } }