diff --git a/.github/workflows/windows-build.yml b/.github/workflows/windows-build.yml new file mode 100644 index 0000000..dfa1dca --- /dev/null +++ b/.github/workflows/windows-build.yml @@ -0,0 +1,91 @@ +name: Windows build + +on: + push: + pull_request: + workflow_dispatch: + +env: + BUILD_CONFIGURATION: Release + +jobs: + credential-provider: + name: Credential Provider (${{ matrix.artifactName }}) + runs-on: windows-2022 + timeout-minutes: 60 + + strategy: + fail-fast: false + matrix: + include: + - platform: x64 + artifactName: x64 + - platform: Win32 + artifactName: x86 + + steps: + - name: Check out sources + uses: actions/checkout@v4 + + - name: Locate MSBuild + uses: microsoft/setup-msbuild@v2 + + - name: Build solution + shell: pwsh + run: > + msbuild multiOTPCredentialProvider.sln + /m + /nologo + /p:Configuration=${{ env.BUILD_CONFIGURATION }} + /p:Platform=${{ matrix.platform }} + /p:PlatformToolset=v143 + /bl:${{ github.workspace }}\msbuild-${{ matrix.artifactName }}.binlog + + - name: Upload ${{ matrix.artifactName }} artifacts + if: ${{ always() }} + uses: actions/upload-artifact@v4 + with: + name: multiotp-credential-provider-${{ matrix.artifactName }} + if-no-files-found: error + path: | + CredentialProvider/${{ matrix.platform }}/${{ env.BUILD_CONFIGURATION }}/*.dll + CredentialProviderFilter/${{ matrix.platform }}/${{ env.BUILD_CONFIGURATION }}/*.dll + CppClientCore/CppClientCore/${{ matrix.platform }}/${{ env.BUILD_CONFIGURATION }}/*.lib + WixInstall/multiOTPCredentialProviderInstaller/bin/${{ matrix.artifactName }}/${{ env.BUILD_CONFIGURATION }}/*.msi + msbuild-${{ matrix.artifactName }}.binlog + + wpf-manager: + name: WPF manager + runs-on: windows-2022 + timeout-minutes: 30 + + steps: + - name: Check out sources + uses: actions/checkout@v4 + + - name: Locate MSBuild + uses: microsoft/setup-msbuild@v2 + + - name: Install .NET Framework 4.5.2 Developer Pack + shell: pwsh + run: choco install netfx-4.5.2-devpack --no-progress -y + + - name: Build manager + shell: pwsh + run: > + msbuild MultiOtpManager/MultiOtpManager.csproj + /m + /nologo + /p:Configuration=${{ env.BUILD_CONFIGURATION }} + /p:Platform=AnyCPU + /bl:${{ github.workspace }}\msbuild-manager.binlog + + - name: Upload manager artifacts + if: ${{ always() }} + uses: actions/upload-artifact@v4 + with: + name: multiotp-manager + if-no-files-found: error + path: | + MultiOtpManager/bin/${{ env.BUILD_CONFIGURATION }}/* + msbuild-manager.binlog diff --git a/MultiOtpManager/App.config b/MultiOtpManager/App.config new file mode 100644 index 0000000..c845ae4 --- /dev/null +++ b/MultiOtpManager/App.config @@ -0,0 +1,6 @@ + + + + + + diff --git a/MultiOtpManager/App.xaml b/MultiOtpManager/App.xaml new file mode 100644 index 0000000..8e52a96 --- /dev/null +++ b/MultiOtpManager/App.xaml @@ -0,0 +1,5 @@ + + diff --git a/MultiOtpManager/App.xaml.cs b/MultiOtpManager/App.xaml.cs new file mode 100644 index 0000000..c8ee035 --- /dev/null +++ b/MultiOtpManager/App.xaml.cs @@ -0,0 +1,66 @@ +using System.Globalization; +using System.IO; +using System.Threading; +using System.Windows; +using MultiOtpManager.Core; + +namespace MultiOtpManager +{ + public partial class App : Application + { + protected override void OnStartup(StartupEventArgs e) + { + // Pick the UI culture before any window is created so that + // MainWindow's {x:Static p:Resources.Key} bindings resolve against + // the chosen language. + AppSettings settings = AppSettings.Load(); + + // First-run auto-detection: if there is no settings file on disk, + // guess the best matching built-in language from the system UI + // culture and persist the choice so the picker reflects it later. + if (!File.Exists(AppSettings.SettingsFilePath)) + { + settings.Language = GuessInitialLanguage(); + settings.Save(); + } + + ApplyLanguage(settings.Language); + + base.OnStartup(e); + } + + private static string GuessInitialLanguage() + { + CultureInfo current = CultureInfo.CurrentUICulture; + string name = current != null ? current.Name : string.Empty; + + // Anything in the zh family maps to Simplified Chinese; otherwise + // fall through to English, which is the Resources.resx fallback. + if (name.StartsWith("zh", System.StringComparison.OrdinalIgnoreCase)) + { + return "zh-Hans"; + } + return "en"; + } + + private static void ApplyLanguage(string cultureName) + { + if (string.IsNullOrEmpty(cultureName)) + { + return; + } + + try + { + CultureInfo culture = CultureInfo.GetCultureInfo(cultureName); + CultureInfo.DefaultThreadCurrentUICulture = culture; + Thread.CurrentThread.CurrentUICulture = culture; + } + catch (CultureNotFoundException) + { + // Unknown culture name in settings: ignore and fall back to the + // process default, which will resolve to Resources.resx. + } + } + } +} diff --git a/MultiOtpManager/Core/AppSettings.cs b/MultiOtpManager/Core/AppSettings.cs new file mode 100644 index 0000000..3f14f0a --- /dev/null +++ b/MultiOtpManager/Core/AppSettings.cs @@ -0,0 +1,133 @@ +using System; +using System.IO; + +namespace MultiOtpManager.Core +{ + /// + /// Per-user preferences persisted as JSON in %LocalAppData%. The file is + /// best-effort: corrupted or unreadable settings silently fall back to + /// defaults so a malformed disk file cannot stop the app from starting. + /// Serialization is hand-written to keep the project free of any + /// external JSON dependency (System.Text.Json is not available on + /// .NET Framework 4.5.2 without an extra reference assembly). + /// + public sealed class AppSettings + { + /// + /// UI culture name (for example "zh-Hans") or empty for system default. + /// Looked up via CultureInfo.GetCultureInfo during startup. + /// + public string Language { get; set; } = string.Empty; + + private static readonly string SettingsDirectory = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "MultiOtpManager"); + + private static readonly string SettingsPath = Path.Combine( + SettingsDirectory, "settings.json"); + + public static string SettingsFilePath + { + get { return SettingsPath; } + } + + public static AppSettings Load() + { + try + { + if (File.Exists(SettingsPath)) + { + string text = File.ReadAllText(SettingsPath); + return Parse(text) ?? new AppSettings(); + } + } + catch (Exception) + { + // Corrupted or unreadable; fall through to defaults. + } + return new AppSettings(); + } + + public void Save() + { + try + { + Directory.CreateDirectory(SettingsDirectory); + File.WriteAllText(SettingsPath, Serialize()); + } + catch (Exception) + { + // Best-effort persistence; ignore write errors so a read-only + // profile does not break the rest of the app. + } + } + + private string Serialize() + { + // Hand-rolled JSON keeps the file readable while avoiding any + // external JSON dependency. Language is the only field, so a + // constant template is sufficient. + return "{\n \"Language\": \"" + EscapeJsonString(Language ?? string.Empty) + "\"\n}\n"; + } + + private static AppSettings Parse(string text) + { + if (string.IsNullOrWhiteSpace(text)) + { + return null; + } + + AppSettings settings = new AppSettings(); + int searchFrom = 0; + while (searchFrom < text.Length) + { + int keyStart = text.IndexOf('"', searchFrom); + if (keyStart < 0) + { + break; + } + int keyEnd = text.IndexOf('"', keyStart + 1); + if (keyEnd < 0) + { + break; + } + string key = text.Substring(keyStart + 1, keyEnd - keyStart - 1); + + int colon = text.IndexOf(':', keyEnd); + if (colon < 0) + { + break; + } + int valueStart = text.IndexOf('"', colon); + if (valueStart < 0) + { + break; + } + int valueEnd = text.IndexOf('"', valueStart + 1); + if (valueEnd < 0) + { + break; + } + string value = UnescapeJsonString( + text.Substring(valueStart + 1, valueEnd - valueStart - 1)); + + if (key == "Language") + { + settings.Language = value; + } + searchFrom = valueEnd + 1; + } + return settings; + } + + private static string EscapeJsonString(string value) + { + return value.Replace("\\", "\\\\").Replace("\"", "\\\""); + } + + private static string UnescapeJsonString(string value) + { + return value.Replace("\\\"", "\"").Replace("\\\\", "\\"); + } + } +} diff --git a/MultiOtpManager/Core/CredentialProviderRegistryService.cs b/MultiOtpManager/Core/CredentialProviderRegistryService.cs new file mode 100644 index 0000000..da2062f --- /dev/null +++ b/MultiOtpManager/Core/CredentialProviderRegistryService.cs @@ -0,0 +1,127 @@ +using Microsoft.Win32; +using System; + +namespace MultiOtpManager.Core +{ + public sealed class CredentialProviderSettings + { + public string LogonMode { get; set; } + public string UnlockMode { get; set; } + public bool TwoStepHideOtp { get; set; } + } + + public sealed class CredentialProviderRegistryService + { + private const string RegistryPath = "CLSID\\{FCEFDFAB-B0A1-4C4D-8B2B-4FF4E0A3D978}"; + + public CredentialProviderSettings Load() + { + using (RegistryKey classesRoot = OpenClassesRoot()) + using (RegistryKey key = classesRoot.OpenSubKey(RegistryPath)) + { + if (key == null) + { + return new CredentialProviderSettings + { + LogonMode = "3d", + UnlockMode = "3d", + TwoStepHideOtp = false + }; + } + + return new CredentialProviderSettings + { + LogonMode = ReadString(key, "cpus_logon", "3d"), + UnlockMode = ReadString(key, "cpus_unlock", "3d"), + TwoStepHideOtp = ReadInteger(key, "two_step_hide_otp", 0) != 0 + }; + } + } + + public void Save(CredentialProviderSettings settings) + { + if (settings == null) + { + throw new ArgumentNullException("settings"); + } + + string logonMode = NormalizeScenario(settings.LogonMode, "3d"); + string unlockMode = NormalizeScenario(settings.UnlockMode, "3d"); + + using (RegistryKey classesRoot = OpenClassesRoot()) + using (RegistryKey key = classesRoot.CreateSubKey(RegistryPath)) + { + key.SetValue("cpus_logon", logonMode, RegistryValueKind.String); + key.SetValue("cpus_unlock", unlockMode, RegistryValueKind.String); + key.SetValue("two_step_hide_otp", settings.TwoStepHideOtp ? "1" : "0", RegistryValueKind.String); + } + } + + private static RegistryKey OpenClassesRoot() + { + RegistryView view = Environment.Is64BitOperatingSystem ? RegistryView.Registry64 : RegistryView.Default; + return RegistryKey.OpenBaseKey(RegistryHive.ClassesRoot, view); + } + + private static string ReadString(RegistryKey key, string name, string fallback) + { + object value = key.GetValue(name); + if (value == null) + { + return fallback; + } + + string text = Convert.ToString(value); + return string.IsNullOrWhiteSpace(text) ? fallback : text; + } + + private static int ReadInteger(RegistryKey key, string name, int fallback) + { + object value = key.GetValue(name); + if (value == null) + { + return fallback; + } + + try + { + return Convert.ToInt32(value); + } + catch (FormatException) + { + return fallback; + } + catch (InvalidCastException) + { + return fallback; + } + catch (OverflowException) + { + return fallback; + } + } + + private static string NormalizeScenario(string value, string fallback) + { + string text = (value ?? string.Empty).Trim(); + if (text.Length == 0) + { + text = fallback; + } + + char scope = text[0]; + if (scope != '0' && scope != '1' && scope != '2' && scope != '3') + { + scope = fallback[0]; + } + + char availability = text.Length > 1 ? char.ToLowerInvariant(text[text.Length - 1]) : 'd'; + if (availability != 'e' && availability != 'd') + { + availability = 'd'; + } + + return string.Concat(scope.ToString(), availability.ToString()); + } + } +} diff --git a/MultiOtpManager/Core/MultiOtpCliClient.cs b/MultiOtpManager/Core/MultiOtpCliClient.cs new file mode 100644 index 0000000..0a62b6a --- /dev/null +++ b/MultiOtpManager/Core/MultiOtpCliClient.cs @@ -0,0 +1,265 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace MultiOtpManager.Core +{ + public sealed class MultiOtpCliClient + { + private readonly MultiOtpProcessExecutor executor; + + public MultiOtpCliClient() + { + string baseDirectory = AppDomain.CurrentDomain.BaseDirectory; + string executablePath = Path.Combine(baseDirectory, "multiotp.exe"); + executor = new MultiOtpProcessExecutor(executablePath); + } + + public string ExecutablePath + { + get { return executor.ExecutablePath; } + } + + public bool UseVerifySwitch { get; set; } + + // --- Authentication --- + + public Task VerifyAsync(string username, string otp, TimeSpan timeout, CancellationToken ct) + { + ValidateRequiredText(username, "username"); + ValidateRequiredText(otp, "OTP"); + List args = new List(); + if (UseVerifySwitch) args.Add("-verify"); + args.Add(username); + args.Add(otp); + return executor.ExecuteAsync(args, timeout, ct); + } + + // --- User Management --- + + public Task GetUsersAsync(TimeSpan timeout, CancellationToken ct) + { + return executor.ExecuteAsync(new List { "-userslist" }, timeout, ct); + } + + public Task GetUserAsync(string username, TimeSpan timeout, CancellationToken ct) + { + ValidateRequiredText(username, "username"); + return executor.ExecuteAsync(new List { "-user-info", username }, timeout, ct); + } + + public Task CreateUserAsync(string username, string algorithm, string seed, string pin, string digits, string interval, TimeSpan timeout, CancellationToken ct) + { + ValidateRequiredText(username, "username"); + List args = new List { "-create", username }; + if (!string.IsNullOrWhiteSpace(algorithm)) args.Add(algorithm); + if (!string.IsNullOrWhiteSpace(seed)) args.Add(seed); + if (!string.IsNullOrWhiteSpace(pin)) args.Add(pin); + if (!string.IsNullOrWhiteSpace(digits)) args.Add(digits); + if (!string.IsNullOrWhiteSpace(interval)) args.Add(interval); + return executor.ExecuteAsync(args, timeout, ct); + } + + public Task FastCreateUserAsync(string username, string pin, TimeSpan timeout, CancellationToken ct) + { + ValidateRequiredText(username, "username"); + List args = new List { "-fastcreate", username }; + if (!string.IsNullOrWhiteSpace(pin)) args.Add(pin); + return executor.ExecuteAsync(args, timeout, ct); + } + + public Task FastCreateNoPinUserAsync(string username, TimeSpan timeout, CancellationToken ct) + { + ValidateRequiredText(username, "username"); + return executor.ExecuteAsync(new List { "-fastcreatenopin", username }, timeout, ct); + } + + public Task CreateGoogleAuthUserAsync(string username, string base32Seed, string pin, TimeSpan timeout, CancellationToken ct) + { + ValidateRequiredText(username, "username"); + ValidateRequiredText(base32Seed, "base32 seed"); + List args = new List { "-createga", username, base32Seed }; + if (!string.IsNullOrWhiteSpace(pin)) args.Add(pin); + return executor.ExecuteAsync(args, timeout, ct); + } + + public Task DeleteUserAsync(string username, TimeSpan timeout, CancellationToken ct) + { + ValidateRequiredText(username, "username"); + return executor.ExecuteAsync(new List { "-delete", username }, timeout, ct); + } + + public Task ActivateUserAsync(string username, TimeSpan timeout, CancellationToken ct) + { + ValidateRequiredText(username, "username"); + return executor.ExecuteAsync(new List { "-activate", username }, timeout, ct); + } + + public Task DeactivateUserAsync(string username, TimeSpan timeout, CancellationToken ct) + { + ValidateRequiredText(username, "username"); + return executor.ExecuteAsync(new List { "-deactivate", username }, timeout, ct); + } + + public Task LockUserAsync(string username, TimeSpan timeout, CancellationToken ct) + { + ValidateRequiredText(username, "username"); + return executor.ExecuteAsync(new List { "-lock", username }, timeout, ct); + } + + public Task UnlockUserAsync(string username, TimeSpan timeout, CancellationToken ct) + { + ValidateRequiredText(username, "username"); + return executor.ExecuteAsync(new List { "-unlock", username }, timeout, ct); + } + + public Task ResyncTokenAsync(string username, string token1, string token2, TimeSpan timeout, CancellationToken ct) + { + ValidateRequiredText(username, "username"); + ValidateRequiredText(token1, "token1"); + ValidateRequiredText(token2, "token2"); + return executor.ExecuteAsync(new List { "-resync", username, token1, token2 }, timeout, ct); + } + + public Task UpdatePinAsync(string username, string pin, TimeSpan timeout, CancellationToken ct) + { + ValidateRequiredText(username, "username"); + ValidateRequiredText(pin, "pin"); + return executor.ExecuteAsync(new List { "-update-pin", username, pin }, timeout, ct); + } + + public Task SetUserAttributeAsync(string username, string attribute, string value, TimeSpan timeout, CancellationToken ct) + { + ValidateRequiredText(username, "username"); + ValidateRequiredText(attribute, "attribute"); + return executor.ExecuteAsync(new List { "-set", username, attribute + "=" + value }, timeout, ct); + } + + public Task GetLockedUsersAsync(TimeSpan timeout, CancellationToken ct) + { + return executor.ExecuteAsync(new List { "-lockeduserslist" }, timeout, ct); + } + + // --- Provisioning --- + + public Task GetUrlLinkAsync(string username, TimeSpan timeout, CancellationToken ct) + { + ValidateRequiredText(username, "username"); + return executor.ExecuteAsync(new List { "-urllink", username }, timeout, ct); + } + + public Task CreateQrCodeAsync(string username, string pngFilePath, TimeSpan timeout, CancellationToken ct) + { + ValidateRequiredText(username, "username"); + ValidateRequiredText(pngFilePath, "PNG file path"); + return executor.ExecuteAsync(new List { "-qrcode", username, pngFilePath }, timeout, ct); + } + + // --- Token Management --- + + public Task GetTokensAsync(TimeSpan timeout, CancellationToken ct) + { + return executor.ExecuteAsync(new List { "-tokenslist" }, timeout, ct); + } + + public Task AssignTokenAsync(string username, string tokenId, TimeSpan timeout, CancellationToken ct) + { + ValidateRequiredText(username, "username"); + ValidateRequiredText(tokenId, "token ID"); + return executor.ExecuteAsync(new List { "-assign-token", username, tokenId }, timeout, ct); + } + + public Task RemoveTokenAsync(string username, TimeSpan timeout, CancellationToken ct) + { + ValidateRequiredText(username, "username"); + return executor.ExecuteAsync(new List { "-remove-token", username }, timeout, ct); + } + + public Task DeleteTokenAsync(string tokenId, TimeSpan timeout, CancellationToken ct) + { + ValidateRequiredText(tokenId, "token ID"); + return executor.ExecuteAsync(new List { "-delete-token", tokenId }, timeout, ct); + } + + // --- Logs & Diagnostics --- + + public Task ShowLogAsync(TimeSpan timeout, CancellationToken ct) + { + return executor.ExecuteAsync(new List { "-showlog" }, timeout, ct); + } + + public Task ClearLogAsync(TimeSpan timeout, CancellationToken ct) + { + return executor.ExecuteAsync(new List { "-clearlog" }, timeout, ct); + } + + public Task GetErrorCodesAsync(TimeSpan timeout, CancellationToken ct) + { + return executor.ExecuteAsync(new List { "-error-codes" }, timeout, ct); + } + + public Task GetVersionAsync(TimeSpan timeout, CancellationToken ct) + { + return executor.ExecuteAsync(new List { "-version" }, timeout, ct); + } + + // --- AD/LDAP --- + + public Task LdapCheckAsync(TimeSpan timeout, CancellationToken ct) + { + return executor.ExecuteAsync(new List { "-ldap-check" }, timeout, ct); + } + + public Task LdapUsersListAsync(TimeSpan timeout, CancellationToken ct) + { + return executor.ExecuteAsync(new List { "-ldap-users-list" }, timeout, ct); + } + + public Task LdapUsersSyncAsync(TimeSpan timeout, CancellationToken ct) + { + return executor.ExecuteAsync(new List { "-ldap-users-sync" }, timeout, ct); + } + + public Task LdapUserInfoAsync(string username, TimeSpan timeout, CancellationToken ct) + { + ValidateRequiredText(username, "username"); + return executor.ExecuteAsync(new List { "-ldap-user-info", username }, timeout, ct); + } + + // --- Backup & Maintenance --- + + public Task BackupConfigAsync(string password, TimeSpan timeout, CancellationToken ct) + { + ValidateRequiredText(password, "password"); + return executor.ExecuteAsync(new List { "-backup-config", password }, timeout, ct); + } + + public Task RestoreConfigAsync(string password, TimeSpan timeout, CancellationToken ct) + { + ValidateRequiredText(password, "password"); + return executor.ExecuteAsync(new List { "-restore-config", password }, timeout, ct); + } + + public Task PurgeLockFolderAsync(TimeSpan timeout, CancellationToken ct) + { + return executor.ExecuteAsync(new List { "-purge-lock-folder" }, timeout, ct); + } + + public Task PurgeLdapCacheAsync(TimeSpan timeout, CancellationToken ct) + { + return executor.ExecuteAsync(new List { "-purge-ldap-cache-folder" }, timeout, ct); + } + + // --- Helpers --- + + private static void ValidateRequiredText(string value, string fieldName) + { + if (string.IsNullOrWhiteSpace(value)) + { + throw new ArgumentException(fieldName + " is required.", fieldName); + } + } + } +} diff --git a/MultiOtpManager/Core/MultiOtpProcessExecutor.cs b/MultiOtpManager/Core/MultiOtpProcessExecutor.cs new file mode 100644 index 0000000..0e913a4 --- /dev/null +++ b/MultiOtpManager/Core/MultiOtpProcessExecutor.cs @@ -0,0 +1,262 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace MultiOtpManager.Core +{ + public sealed class ProcessRunResult + { + public int ExitCode { get; set; } + public string StandardOutput { get; set; } + public string StandardError { get; set; } + } + + public sealed class MultiOtpProcessExecutor + { + private const int FlushTimeoutMilliseconds = 2000; + + public MultiOtpProcessExecutor(string executablePath) + { + if (string.IsNullOrWhiteSpace(executablePath)) + { + throw new ArgumentException("The executable path is required.", "executablePath"); + } + + ExecutablePath = Path.GetFullPath(executablePath); + } + + public string ExecutablePath { get; private set; } + + public static string BuildArguments(IEnumerable arguments) + { + if (arguments == null) + { + throw new ArgumentNullException("arguments"); + } + + string[] escapedArguments = arguments + .Select(EscapeArgument) + .ToArray(); + + return string.Join(" ", escapedArguments); + } + + public static string EscapeArgument(string argument) + { + if (argument == null) + { + throw new ArgumentNullException("argument"); + } + + if (argument.Length == 0) + { + return "\"\""; + } + + StringBuilder escaped = new StringBuilder(argument.Length + 8); + int trailingBackslashes = 0; + + foreach (char character in argument) + { + if (character == '\\') + { + trailingBackslashes++; + continue; + } + + if (character == '"') + { + escaped.Append('\\', (trailingBackslashes * 2) + 1); + escaped.Append('"'); + } + else + { + escaped.Append('\\', trailingBackslashes); + escaped.Append(character); + } + + trailingBackslashes = 0; + } + + if (trailingBackslashes > 0) + { + escaped.Append('\\', trailingBackslashes * 2); + } + + escaped.Insert(0, '"'); + escaped.Append('"'); + return escaped.ToString(); + } + + public async Task ExecuteAsync( + IList arguments, + TimeSpan timeout, + CancellationToken cancellationToken) + { + if (arguments == null) + { + throw new ArgumentNullException("arguments"); + } + + if (!File.Exists(ExecutablePath)) + { + throw new FileNotFoundException("multiotp.exe was not found beside MultiOtpManager.exe.", ExecutablePath); + } + + cancellationToken.ThrowIfCancellationRequested(); + + ProcessStartInfo startInfo = new ProcessStartInfo(); + startInfo.FileName = ExecutablePath; + startInfo.Arguments = BuildArguments(arguments); + startInfo.WorkingDirectory = Path.GetDirectoryName(ExecutablePath); + startInfo.UseShellExecute = false; + startInfo.CreateNoWindow = true; + startInfo.RedirectStandardOutput = true; + startInfo.RedirectStandardError = true; + startInfo.StandardOutputEncoding = Encoding.UTF8; + startInfo.StandardErrorEncoding = Encoding.UTF8; + + using (Process process = new Process()) + using (CancellationTokenSource timeoutSource = new CancellationTokenSource()) + using (CancellationTokenSource linkedSource = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, + timeoutSource.Token)) + { + process.StartInfo = startInfo; + process.EnableRaisingEvents = true; + + StringBuilder standardOutput = new StringBuilder(); + StringBuilder standardError = new StringBuilder(); + object outputLock = new object(); + TaskCompletionSource exitWaiter = new TaskCompletionSource(); + bool timedOut = false; + + EventHandler exitedHandler = delegate + { + exitWaiter.TrySetResult(null); + }; + + DataReceivedEventHandler outputHandler = delegate(object sender, DataReceivedEventArgs eventArgs) + { + if (eventArgs.Data != null) + { + lock (outputLock) + { + standardOutput.AppendLine(eventArgs.Data); + } + } + }; + + DataReceivedEventHandler errorHandler = delegate(object sender, DataReceivedEventArgs eventArgs) + { + if (eventArgs.Data != null) + { + lock (outputLock) + { + standardError.AppendLine(eventArgs.Data); + } + } + }; + + process.Exited += exitedHandler; + process.OutputDataReceived += outputHandler; + process.ErrorDataReceived += errorHandler; + + try + { + process.Start(); + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + + if (timeout > TimeSpan.Zero) + { + timeoutSource.CancelAfter(timeout); + } + + using (CancellationTokenRegistration registration = linkedSource.Token.Register(delegate + { + timedOut = !cancellationToken.IsCancellationRequested; + TryStopProcess(process); + exitWaiter.TrySetCanceled(); + })) + { + await exitWaiter.Task.ConfigureAwait(false); + } + + process.WaitForExit(FlushTimeoutMilliseconds); + + string output; + string error; + + lock (outputLock) + { + output = standardOutput.ToString(); + error = standardError.ToString(); + } + + return new ProcessRunResult + { + ExitCode = process.ExitCode, + StandardOutput = output, + StandardError = error + }; + } + catch (OperationCanceledException) + { + if (timedOut) + { + throw new TimeoutException("The multiOTP command timed out."); + } + + throw; + } + finally + { + process.Exited -= exitedHandler; + process.OutputDataReceived -= outputHandler; + process.ErrorDataReceived -= errorHandler; + TryWaitAfterCancellation(process); + } + } + } + + private static void TryStopProcess(Process process) + { + try + { + if (!process.HasExited) + { + process.Kill(); + } + } + catch (InvalidOperationException) + { + } + catch (System.ComponentModel.Win32Exception) + { + } + } + + private static void TryWaitAfterCancellation(Process process) + { + try + { + if (!process.HasExited) + { + process.WaitForExit(FlushTimeoutMilliseconds); + } + } + catch (InvalidOperationException) + { + } + catch (System.ComponentModel.Win32Exception) + { + } + } + } +} diff --git a/MultiOtpManager/Core/SystemUserProbe.cs b/MultiOtpManager/Core/SystemUserProbe.cs new file mode 100644 index 0000000..7c0c4c8 --- /dev/null +++ b/MultiOtpManager/Core/SystemUserProbe.cs @@ -0,0 +1,107 @@ +using System; +using System.DirectoryServices; +using System.DirectoryServices.AccountManagement; +using System.Threading; +using System.Threading.Tasks; + +namespace MultiOtpManager.Core +{ + public sealed class SystemUserProbe + { + public async Task ExistsAnywhereAsync(string username, TimeSpan timeout, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(username)) + { + return false; + } + + try + { + using (CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct)) + { + if (timeout > TimeSpan.Zero) + { + linked.CancelAfter(timeout); + } + return await Task.Run(delegate { return ProbeInternal(username); }, linked.Token).ConfigureAwait(false); + } + } + catch (OperationCanceledException) + { + return false; + } + catch (Exception) + { + // Treat any unexpected exception as "unknown" so the caller can still warn instead of crashing. + return false; + } + } + + private static bool ProbeInternal(string username) + { + if (ExistsInLocalMachine(username)) + { + return true; + } + + if (IsDomainJoined() && ExistsInDomain(username)) + { + return true; + } + + return false; + } + + private static bool ExistsInLocalMachine(string username) + { + try + { + using (PrincipalContext context = new PrincipalContext(ContextType.Machine)) + { + using (UserPrincipal found = UserPrincipal.FindByIdentity(context, IdentityType.SamAccountName, username)) + { + return found != null; + } + } + } + catch (Exception) + { + return false; + } + } + + private static bool IsDomainJoined() + { + try + { + using (DirectoryEntry rootDse = new DirectoryEntry("LDAP://rootDSE")) + { + object defaultContext = rootDse.Properties["defaultNamingContext"].Value; + return defaultContext != null; + } + } + catch (Exception) + { + return false; + } + } + + private static bool ExistsInDomain(string username) + { + try + { + using (PrincipalContext context = new PrincipalContext(ContextType.Domain)) + { + using (UserPrincipal found = UserPrincipal.FindByIdentity(context, IdentityType.SamAccountName, username)) + { + return found != null; + } + } + } + catch (Exception) + { + return false; + } + } + } +} diff --git a/MultiOtpManager/Core/UserModels.cs b/MultiOtpManager/Core/UserModels.cs new file mode 100644 index 0000000..ec466a1 --- /dev/null +++ b/MultiOtpManager/Core/UserModels.cs @@ -0,0 +1,138 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace MultiOtpManager.Core +{ + public sealed class UserSummary + { + public string Name { get; set; } + public string TokenType { get; set; } + public string Status { get; set; } + } + + public sealed class UserDetail + { + private readonly Dictionary values = new Dictionary(StringComparer.OrdinalIgnoreCase); + + public static UserDetail Parse(string text) + { + UserDetail detail = new UserDetail(); + if (string.IsNullOrEmpty(text)) + { + return detail; + } + + string[] lines = text.Split(new[] { "\r\n", "\n" }, StringSplitOptions.RemoveEmptyEntries); + foreach (string line in lines) + { + int separatorIndex = line.IndexOf(':'); + if (separatorIndex <= 0) + { + continue; + } + + string key = line.Substring(0, separatorIndex).Trim(); + string value = line.Substring(separatorIndex + 1).Trim(); + if (key.Length > 0) + { + detail.values[key] = value; + } + } + + return detail; + } + + public string Username + { + get { return GetValue("Information for user", "(unknown)"); } + } + + public string TokenType + { + get { return GetValue("Algorithm", "Not provided"); } + } + + public string Created + { + get { return "Not provided by this CLI version"; } + } + + public string Status + { + get + { + string activated = GetValue("Activated", "unknown"); + string locked = GetValue("Locked", "no"); + string delayed = GetValue("Delayed", "no"); + + List states = new List(); + states.Add(StringComparer.OrdinalIgnoreCase.Equals(activated, "yes") ? "Activated" : "Disabled"); + + if (StringComparer.OrdinalIgnoreCase.Equals(locked, "yes")) + { + states.Add("Locked"); + } + + if (StringComparer.OrdinalIgnoreCase.Equals(delayed, "yes")) + { + states.Add("Delayed"); + } + + return string.Join(", ", states.ToArray()); + } + } + + public string OtpDigits + { + get { return GetValue("OTP digits", "Not provided"); } + } + + public string Description + { + get { return GetValue("Description", string.Empty); } + } + + public string Email + { + get { return GetValue("Email", string.Empty); } + } + + public string MobilePhone + { + get { return GetValue("Mobile phone", string.Empty); } + } + + public string ToMaskedDisplayText() + { + IEnumerable lines = values + .OrderBy(delegate(KeyValuePair item) { return item.Key; }) + .Select(delegate(KeyValuePair item) + { + if (IsSensitiveKey(item.Key)) + { + return item.Key + ": ********"; + } + + return item.Key + ": " + item.Value; + }); + + return string.Join(Environment.NewLine, lines.ToArray()); + } + + private string GetValue(string key, string fallback) + { + string value; + return values.TryGetValue(key, out value) && !string.IsNullOrWhiteSpace(value) ? value : fallback; + } + + private static bool IsSensitiveKey(string key) + { + string lowerCaseKey = key.ToLowerInvariant(); + return lowerCaseKey.Contains("seed") || + lowerCaseKey.Contains("secret") || + lowerCaseKey.Contains("password") || + lowerCaseKey.Contains("pin"); + } + } +} diff --git a/MultiOtpManager/MainWindow.xaml b/MultiOtpManager/MainWindow.xaml new file mode 100644 index 0000000..39162da --- /dev/null +++ b/MultiOtpManager/MainWindow.xaml @@ -0,0 +1,449 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +