diff --git a/Wabbajack.Common/Utils.cs b/Wabbajack.Common/Utils.cs index 2494701a..895abfa3 100644 --- a/Wabbajack.Common/Utils.cs +++ b/Wabbajack.Common/Utils.cs @@ -11,6 +11,7 @@ using System.Reactive.Linq; using System.Reactive.Subjects; using System.Reflection; using System.Runtime.InteropServices; +using System.Security; using System.Security.Cryptography; using System.Text; using System.Threading; @@ -1385,5 +1386,19 @@ namespace Wabbajack.Common await using var d = File.Create(dest); await s.CopyToAsync(d); } + + public static string ToNormalString(this SecureString value) + { + var valuePtr = IntPtr.Zero; + try + { + valuePtr = Marshal.SecureStringToGlobalAllocUnicode(value); + return Marshal.PtrToStringUni(valuePtr); + } + finally + { + Marshal.ZeroFreeGlobalAllocUnicode(valuePtr); + } + } } } diff --git a/Wabbajack.Lib/Downloaders/INeedsLogin.cs b/Wabbajack.Lib/Downloaders/INeedsLogin.cs index fb01a4a8..6f331011 100644 --- a/Wabbajack.Lib/Downloaders/INeedsLogin.cs +++ b/Wabbajack.Lib/Downloaders/INeedsLogin.cs @@ -1,11 +1,6 @@ using System; -using System.Collections.Generic; -using System.ComponentModel; -using System.Linq; using System.Reactive; -using System.Text; -using System.Threading.Tasks; -using System.Windows.Input; +using System.Security; using ReactiveUI; namespace Wabbajack.Lib.Downloaders @@ -20,4 +15,21 @@ namespace Wabbajack.Lib.Downloaders Uri SiteURL { get; } Uri IconUri { get; } } + + public struct LoginReturnMessage + { + public string Message; + public bool Failure; + + public LoginReturnMessage(string message, bool failure) + { + Message = message; + Failure = failure; + } + } + + public interface INeedsLoginCredentials : INeedsLogin + { + LoginReturnMessage LoginWithCredentials(string username, SecureString password); + } } diff --git a/Wabbajack.Lib/Downloaders/MEGADownloader.cs b/Wabbajack.Lib/Downloaders/MEGADownloader.cs index 81757ee6..c7c398e9 100644 --- a/Wabbajack.Lib/Downloaders/MEGADownloader.cs +++ b/Wabbajack.Lib/Downloaders/MEGADownloader.cs @@ -1,12 +1,77 @@ using System; +using System.Reactive; +using System.Reactive.Linq; +using System.Security; using System.Threading.Tasks; using CG.Web.MegaApiClient; +using ReactiveUI; using Wabbajack.Common; namespace Wabbajack.Lib.Downloaders { - public class MegaDownloader : IDownloader, IUrlDownloader + public class MegaDownloader : IUrlDownloader, INeedsLoginCredentials { + public MegaApiClient MegaApiClient; + private const string DataName = "mega-cred"; + + public LoginReturnMessage LoginWithCredentials(string username, SecureString password) + { + MegaApiClient.AuthInfos authInfos; + + try + { + authInfos = MegaApiClient.GenerateAuthInfos(username, password.ToNormalString()); + username = null; + password = null; + } + catch (ApiException e) + { + return e.ApiResultCode switch + { + ApiResultCode.BadArguments => new LoginReturnMessage($"Email or password was wrong! {e.Message}", + true), + ApiResultCode.InternalError => new LoginReturnMessage( + $"Internal error occured! Please report this to the Wabbajack Team! {e.Message}", true), + _ => new LoginReturnMessage($"Error generating authentication information! {e.Message}", true) + }; + } + + try + { + MegaApiClient.Login(authInfos); + } + catch (ApiException e) + { + if ((int)e.ApiResultCode == -26) + { + return new LoginReturnMessage("Two-Factor Authentication needs to be disabled before login!", true); + } + return e.ApiResultCode switch + { + ApiResultCode.InternalError => new LoginReturnMessage( + $"Internal error occured! Please report this to the Wabbajack Team! {e.Message}", true), + _ => new LoginReturnMessage($"Error during login: {e.Message}", true) + }; + } + + if(MegaApiClient.IsLoggedIn) + authInfos.ToEcryptedJson(DataName); + + return new LoginReturnMessage("Logged in successfully, you can now close this window.", + !MegaApiClient.IsLoggedIn || !Utils.HaveEncryptedJson(DataName)); + } + + public MegaDownloader() + { + MegaApiClient = new MegaApiClient(); + + TriggerLogin = ReactiveCommand.Create(() => { }, + IsLoggedIn.Select(b => !b).ObserveOnGuiThread()); + + ClearLogin = ReactiveCommand.Create(() => Utils.CatchAndLog(() => Utils.DeleteEncryptedJson(DataName)), + IsLoggedIn.ObserveOnGuiThread()); + } + public async Task GetDownloaderState(dynamic archiveINI) { var url = archiveINI?.General?.directURL; @@ -16,7 +81,7 @@ namespace Wabbajack.Lib.Downloaders public AbstractDownloadState GetDownloaderState(string url) { if (url != null && url.StartsWith(Consts.MegaPrefix)) - return new State { Url = url }; + return new State { Url = url, MegaApiClient = MegaApiClient}; return null; } @@ -26,27 +91,45 @@ namespace Wabbajack.Lib.Downloaders public class State : HTTPDownloader.State { + public MegaApiClient MegaApiClient; + + private void MegaLogin() + { + if (MegaApiClient.IsLoggedIn) + return; + + if (!Utils.HaveEncryptedJson(DataName)) + { + Utils.Status("Logging into MEGA (as anonymous)"); + MegaApiClient.LoginAnonymous(); + } + else + { + Utils.Status("Logging into MEGA with saved credentials."); + var authInfo = Utils.FromEncryptedJson(DataName); + MegaApiClient.Login(authInfo); + } + } + public override async Task Download(Archive a, string destination) { - var client = new MegaApiClient(); - Utils.Status("Logging into MEGA (as anonymous)"); - client.LoginAnonymous(); + MegaLogin(); + var fileLink = new Uri(Url); - var node = client.GetNodeFromLink(fileLink); + var node = MegaApiClient.GetNodeFromLink(fileLink); Utils.Status($"Downloading MEGA file: {a.Name}"); - client.DownloadFile(fileLink, destination); + MegaApiClient.DownloadFile(fileLink, destination); return true; } public override async Task Verify(Archive a) { - var client = new MegaApiClient(); - Utils.Status("Logging into MEGA (as anonymous)"); - client.LoginAnonymous(); + MegaLogin(); + var fileLink = new Uri(Url); try { - var node = client.GetNodeFromLink(fileLink); + var node = MegaApiClient.GetNodeFromLink(fileLink); return true; } catch (Exception) @@ -55,5 +138,13 @@ namespace Wabbajack.Lib.Downloaders } } } + + public ReactiveCommand TriggerLogin { get; } + public ReactiveCommand ClearLogin { get; } + public IObservable IsLoggedIn => Utils.HaveEncryptedJsonObservable(DataName); + public string SiteName => "MEGA"; + public IObservable MetaInfo => Observable.Return(""); + public Uri SiteURL => new Uri("https://mega.nz/"); + public Uri IconUri => new Uri("https://mega.nz/favicon.ico"); } } diff --git a/Wabbajack/View Models/Settings/CredentialsLoginVM.cs b/Wabbajack/View Models/Settings/CredentialsLoginVM.cs new file mode 100644 index 00000000..08f860cc --- /dev/null +++ b/Wabbajack/View Models/Settings/CredentialsLoginVM.cs @@ -0,0 +1,40 @@ +using System.Reactive; +using System.Reactive.Linq; +using System.Security; +using ReactiveUI; +using ReactiveUI.Fody.Helpers; +using Wabbajack.Lib; +using Wabbajack.Lib.Downloaders; + +namespace Wabbajack +{ + public class CredentialsLoginVM : ViewModel + { + [Reactive] + public string Username { get; set; } + + [Reactive] + public LoginReturnMessage ReturnMessage { get; set; } + + private readonly ObservableAsPropertyHelper _loginEnabled; + public bool LoginEnabled => _loginEnabled.Value; + + private readonly INeedsLoginCredentials _downloader; + + public CredentialsLoginVM(INeedsLoginCredentials downloader) + { + _downloader = downloader; + + _loginEnabled = this.WhenAny(x => x.Username) + .Select(username => !string.IsNullOrWhiteSpace(username)) + .ToGuiProperty(this, + nameof(LoginEnabled)); + } + + public void Login(SecureString password) + { + ReturnMessage = _downloader.LoginWithCredentials(Username, password); + password.Clear(); + } + } +} diff --git a/Wabbajack/View Models/Settings/LoginManagerVM.cs b/Wabbajack/View Models/Settings/LoginManagerVM.cs index 1487adf7..e7ad9745 100644 --- a/Wabbajack/View Models/Settings/LoginManagerVM.cs +++ b/Wabbajack/View Models/Settings/LoginManagerVM.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Reactive; using System.Reactive.Linq; using System.Text; using System.Threading.Tasks; @@ -27,16 +28,39 @@ namespace Wabbajack public class LoginTargetVM : ViewModel { - private readonly ObservableAsPropertyHelper _MetaInfo; - public string MetaInfo => _MetaInfo.Value; + private readonly ObservableAsPropertyHelper _metaInfo; + public string MetaInfo => _metaInfo.Value; public INeedsLogin Login { get; } + public INeedsLoginCredentials LoginWithCredentials { get; } + public bool UsesCredentials { get; } + + public ReactiveCommand TriggerCredentialsLogin; public LoginTargetVM(INeedsLogin login) { Login = login; - _MetaInfo = (login.MetaInfo ?? Observable.Return("")) + + if (login is INeedsLoginCredentials loginWithCredentials) + { + UsesCredentials = true; + LoginWithCredentials = loginWithCredentials; + } + + _metaInfo = (login.MetaInfo ?? Observable.Return("")) .ToGuiProperty(this, nameof(MetaInfo)); + + if (!UsesCredentials) + return; + + TriggerCredentialsLogin = ReactiveCommand.Create(() => + { + if (!(login is INeedsLoginCredentials)) + return; + + var loginWindow = new LoginWindowView(LoginWithCredentials); + loginWindow.Show(); + }, LoginWithCredentials.IsLoggedIn.Select(b => !b).ObserveOnGuiThread()); } } } diff --git a/Wabbajack/Views/Settings/CredentialsLoginView.xaml b/Wabbajack/Views/Settings/CredentialsLoginView.xaml new file mode 100644 index 00000000..957337c1 --- /dev/null +++ b/Wabbajack/Views/Settings/CredentialsLoginView.xaml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/Wabbajack/Views/Settings/CredentialsLoginView.xaml.cs b/Wabbajack/Views/Settings/CredentialsLoginView.xaml.cs new file mode 100644 index 00000000..efc1951c --- /dev/null +++ b/Wabbajack/Views/Settings/CredentialsLoginView.xaml.cs @@ -0,0 +1,37 @@ +using System.Reactive.Disposables; +using System.Windows; +using ReactiveUI; +using Wabbajack.Lib.Downloaders; + +namespace Wabbajack +{ + public partial class CredentialsLoginView + { + public INeedsLoginCredentials Downloader { get; set; } + + public CredentialsLoginView(INeedsLoginCredentials downloader) + { + Downloader = downloader; + + if (ViewModel == null) + ViewModel = new CredentialsLoginVM(downloader); + + InitializeComponent(); + + this.WhenActivated(disposable => + { + this.Bind(ViewModel, x => x.Username, x => x.Username.Text) + .DisposeWith(disposable); + this.Bind(ViewModel, x => x.LoginEnabled, x => x.LoginButton.IsEnabled) + .DisposeWith(disposable); + this.OneWayBind(ViewModel, x => x.ReturnMessage.Message, x => x.Message.Text) + .DisposeWith(disposable); + }); + } + + private void LoginButton_OnClick(object sender, RoutedEventArgs e) + { + ViewModel.Login(Password.SecurePassword); + } + } +} diff --git a/Wabbajack/Views/Settings/LoginItemView.xaml.cs b/Wabbajack/Views/Settings/LoginItemView.xaml.cs index fdcc7aaa..49102a7f 100644 --- a/Wabbajack/Views/Settings/LoginItemView.xaml.cs +++ b/Wabbajack/Views/Settings/LoginItemView.xaml.cs @@ -1,40 +1,34 @@ using System; -using System.Collections.Generic; -using System.Linq; using System.Reactive.Disposables; using System.Reactive.Linq; -using System.Text; -using System.Threading.Tasks; -using System.Windows; -using System.Windows.Controls; -using System.Windows.Data; -using System.Windows.Documents; -using System.Windows.Input; -using System.Windows.Media; -using System.Windows.Media.Imaging; -using System.Windows.Navigation; -using System.Windows.Shapes; using ReactiveUI; namespace Wabbajack { - /// - /// Interaction logic for LoginItemView.xaml - /// - public partial class LoginItemView : ReactiveUserControl + public partial class LoginItemView { public LoginItemView() { InitializeComponent(); this.WhenActivated(disposable => { - this.OneWayBindStrict(this.ViewModel, x => x.Login.SiteName, x => x.SiteNameText.Text) + this.OneWayBindStrict(ViewModel, x => x.Login.SiteName, x => x.SiteNameText.Text) .DisposeWith(disposable); - this.OneWayBindStrict(this.ViewModel, x => x.Login.TriggerLogin, x => x.LoginButton.Command) + + if (!ViewModel.UsesCredentials) + { + this.OneWayBindStrict(ViewModel, x => x.Login.TriggerLogin, x => x.LoginButton.Command) + .DisposeWith(disposable); + } + else + { + this.OneWayBindStrict(ViewModel, x => x.TriggerCredentialsLogin, x => x.LoginButton.Command) .DisposeWith(disposable); - this.OneWayBindStrict(this.ViewModel, x => x.Login.ClearLogin, x => x.LogoutButton.Command) + } + + this.OneWayBindStrict(ViewModel, x => x.Login.ClearLogin, x => x.LogoutButton.Command) .DisposeWith(disposable); - this.OneWayBindStrict(this.ViewModel, x => x.MetaInfo, x => x.MetaText.Text) + this.OneWayBindStrict(ViewModel, x => x.MetaInfo, x => x.MetaText.Text) .DisposeWith(disposable); // Modify label state @@ -42,13 +36,14 @@ namespace Wabbajack .Switch() .Subscribe(x => { - this.LoginButton.Content = x ? "Login" : "Logged In"; + LoginButton.Content = x ? "Login" : "Logged In"; }); + this.WhenAny(x => x.ViewModel.Login.ClearLogin.CanExecute) .Switch() .Subscribe(x => { - this.LogoutButton.Content = x ? "Logout" : "Logged Out"; + LogoutButton.Content = x ? "Logout" : "Logged Out"; }); }); } diff --git a/Wabbajack/Views/Settings/LoginWindowView.xaml b/Wabbajack/Views/Settings/LoginWindowView.xaml new file mode 100644 index 00000000..857c7be4 --- /dev/null +++ b/Wabbajack/Views/Settings/LoginWindowView.xaml @@ -0,0 +1,16 @@ + + + + + diff --git a/Wabbajack/Views/Settings/LoginWindowView.xaml.cs b/Wabbajack/Views/Settings/LoginWindowView.xaml.cs new file mode 100644 index 00000000..04ad66f7 --- /dev/null +++ b/Wabbajack/Views/Settings/LoginWindowView.xaml.cs @@ -0,0 +1,20 @@ +using Wabbajack.Lib.Downloaders; + +namespace Wabbajack +{ + public partial class LoginWindowView + { + public INeedsLoginCredentials Downloader { get; set; } + + public LoginWindowView(INeedsLoginCredentials downloader) + { + Downloader = downloader; + + InitializeComponent(); + + var loginView = new CredentialsLoginView(downloader); + + Grid.Children.Add(loginView); + } + } +}