diff --git a/AAIntegration.SimmonsBank.API/AAIntegration.SimmonsBank.API.csproj b/AAIntegration.SimmonsBank.API/AAIntegration.SimmonsBank.API.csproj index 9617359..c6baf26 100644 --- a/AAIntegration.SimmonsBank.API/AAIntegration.SimmonsBank.API.csproj +++ b/AAIntegration.SimmonsBank.API/AAIntegration.SimmonsBank.API.csproj @@ -30,6 +30,7 @@ + diff --git a/AAIntegration.SimmonsBank.API/Configs/PuppeteerConfig.cs b/AAIntegration.SimmonsBank.API/Configs/PuppeteerConfig.cs index 907eaea..edd269c 100644 --- a/AAIntegration.SimmonsBank.API/Configs/PuppeteerConfig.cs +++ b/AAIntegration.SimmonsBank.API/Configs/PuppeteerConfig.cs @@ -5,6 +5,8 @@ public class PuppeteerConfig public int KeepAliveIntervalMinutes { get; set; } public int CheckForNewDataIntervalMinutes { get; set; } public int TaskCheckIntervalMinutes { get; set; } + public string SimmonsBankBaseUrl { get; set; } + public int BrowserOperationTimeoutSeconds { get; set; } } public class PuppeteerConfigConstants @@ -12,4 +14,6 @@ public class PuppeteerConfigConstants public const string KeepAliveIntervalMinutes = "KeepAliveIntervalMinutes"; public const string CheckForNewDataIntervalMinutes = "CheckForNewDataIntervalMinutes"; public const string TaskCheckIntervalMinutes = "TaskCheckIntervalMinutes"; + public const string SimmonsBankBaseUrl = "SimmonsBankBaseUrl"; + public const string BrowserOperationTimeoutSeconds = "BrowserOperationTimeoutSeconds"; } \ No newline at end of file diff --git a/AAIntegration.SimmonsBank.API/Processes/IPuppeteerProcess.cs b/AAIntegration.SimmonsBank.API/Processes/IPuppeteerProcess.cs index 714f270..4313528 100644 --- a/AAIntegration.SimmonsBank.API/Processes/IPuppeteerProcess.cs +++ b/AAIntegration.SimmonsBank.API/Processes/IPuppeteerProcess.cs @@ -6,5 +6,6 @@ namespace AAIntegration.SimmonsBank.API.Processes; public interface IPuppeteerProcess { + void SetStoppingToken(CancellationToken stoppingToken); Task StayLoggedIn(User user); } diff --git a/AAIntegration.SimmonsBank.API/Processes/PuppeteerProcess.cs b/AAIntegration.SimmonsBank.API/Processes/PuppeteerProcess.cs index e00ed65..83ab597 100644 --- a/AAIntegration.SimmonsBank.API/Processes/PuppeteerProcess.cs +++ b/AAIntegration.SimmonsBank.API/Processes/PuppeteerProcess.cs @@ -8,31 +8,215 @@ using AAIntegration.SimmonsBank.API.Services; using Internal; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Options; +using PuppeteerSharp; +using OtpNet; +using System.Text; namespace AAIntegration.SimmonsBank.API.Processes; public class PuppeteerProcess : IPuppeteerProcess { - private readonly PuppeteerConfig _config; - private readonly ILogger _logger; - private readonly IMemoryCache _memoryCache; + private readonly PuppeteerConfig _config; + private readonly ILogger _logger; + private readonly IMemoryCache _memoryCache; + private const string BROWSER_CACHE_KEY = "BrowserCacheKey"; + private const string DASHBOARD_SELECTOR = "body > banno-web > bannoweb-layout > bannoweb-dashboard"; + private CancellationToken _stoppingToken; - public PuppeteerProcess( + public PuppeteerProcess( IOptions config, ILogger logger, IMemoryCache memoryCache) - { + { _config = config.Value; _logger = logger; _memoryCache = memoryCache; - } + _stoppingToken = new CancellationToken(); + } + + public void SetStoppingToken(CancellationToken stoppingToken) + { + _stoppingToken = stoppingToken; + } public async Task StayLoggedIn(User user) { _logger.LogInformation($"... doing work and processing for user {user.Id} ..."); + + IBrowser browser = await GetUserBrowserAsync(user); + + if (!await IsLoggedIn(user, browser)) + { + await Login(user, browser); + } } // Helper Functions - + private async Task Delay(int milliseconds) + { + await Task.Delay(TimeSpan.FromMilliseconds(milliseconds), _stoppingToken); + } + + private async Task GetUserBrowserAsync(User user) + { + if (_memoryCache.TryGetValue>(BROWSER_CACHE_KEY, out var internalKeys)) + { + List> list = internalKeys.Where(x => x.Key == user.Id).ToList(); + + if (list.Count > 0 && list.First().Value != null) + { + _logger.LogInformation($"Found the browser for user with id '{user.Id}'."); + return list.First().Value; + } + } + + _logger.LogInformation($"Could NOT find the browser for user with id '{user.Id}'. About to create one..."); + + using var browserFetcher = new BrowserFetcher(); + await browserFetcher.DownloadAsync(); + + var options = new LaunchOptions { + Headless = false, + IgnoreHTTPSErrors = true + }; + + IBrowser browser = await Puppeteer.LaunchAsync(options); + + internalKeys ??= new Dictionary(); + internalKeys.Add(user.Id, browser); + _memoryCache.Set(BROWSER_CACHE_KEY, internalKeys); + + return browser; + } + + private async Task Login(User user, IBrowser browser) + { + TimeSpan timeout = TimeSpan.FromSeconds(_config.BrowserOperationTimeoutSeconds); + + // Setup Page + await using IPage page = await browser.NewPageAsync(); + await page.SetViewportAsync(new ViewPortOptions { Width = 1200, Height = 720 }); + WaitUntilNavigation[] waitUntils = { WaitUntilNavigation.Networkidle0 }; + + // Navigate to login screen + await page.GoToAsync(_config.SimmonsBankBaseUrl + "/login");//, null, waitUntils); // wait until page load + + try + { + // Type username + string selector = "#username"; + await page.WaitForSelectorAsync(selector).WaitAsync(timeout, _stoppingToken); + await page.TypeAsync(selector, user.SimmonsBankUsername); + + // Press 1st Submit Button + selector = "jha-button"; + await page.WaitForSelectorAsync(selector).WaitAsync(timeout, _stoppingToken); + await page.ClickAsync(selector); + + // Type password + selector = "#password"; + await page.WaitForSelectorAsync(selector).WaitAsync(timeout, _stoppingToken); + await page.TypeAsync(selector, user.SimmonsBankPassword); + + // Click SignIn button - In Chrome -> JS Path worked well + selector = "#login-password-form > bannoweb-flex-wrapper:nth-child(5) > div > jha-button"; + await page.WaitForSelectorAsync(selector).WaitAsync(timeout, _stoppingToken); + IElementHandle signInButton = await page.QuerySelectorAsync(selector); + + if (signInButton != null) + { + await signInButton.ClickAsync(); + } + else + { + _logger.LogError("Failed to find Sign-In button"); + return false; + } + + await page.WaitForNetworkIdleAsync(); + + // Find TOTP input + selector = "body > banno-web > bannoweb-login > bannoweb-login-steps > bannoweb-two-factor-verify > jha-slider > jha-slider-content > jha-slider-pane:nth-child(4) > bannoweb-two-factor-enter-code > article > form > jha-form-floating-group > input[type=text]"; + await page.WaitForSelectorAsync(selector).WaitAsync(timeout, _stoppingToken); + //await Delay(150); + + // Generate TOTP code + Totp totpInstance = new Totp(Base32Encoding.ToBytes(user.MFAKey)); + string totpCode = totpInstance.ComputeTotp(); + + // Type TOTP code + IElementHandle totpInput = await page.QuerySelectorAsync(selector); + await totpInput.TypeAsync(totpCode); + + // Click Verify Button + selector = "body > banno-web > bannoweb-login > bannoweb-login-steps > bannoweb-two-factor-verify > jha-slider > jha-slider-content > jha-slider-pane:nth-child(4) > bannoweb-two-factor-enter-code > article > form > jha-button"; + await page.WaitForSelectorAsync(selector).WaitAsync(timeout, _stoppingToken); + IElementHandle verifyButton = await page.QuerySelectorAsync(selector); + if (verifyButton != null) + { + await verifyButton.ClickAsync(); + } + else + { + _logger.LogError("Failed to find Verify button"); + return false; + } + + + try + { + await page.WaitForSelectorAsync(DASHBOARD_SELECTOR).WaitAsync(timeout, _stoppingToken); + } + catch(TimeoutException) + { + _logger.LogWarning($"Dashboard isn't loading after login for user '{user.Id}'"); + return false; + } + + _logger.LogInformation($"Dashboard found for '{user.Id}'"); + + } + catch (TaskCanceledException) + { + _logger.LogError($"Login Task for user '{user.Id}' was canceled"); + } + catch (TimeoutException) + { + _logger.LogWarning($"Login Task timed out for user '{user.Id}' after {timeout} seconds"); + return false; + } + finally + { + await page.CloseAsync(); + } + + _logger.LogInformation($"Login completed for user {user.Id}"); + return true; + } + + private async Task IsLoggedIn(User user, IBrowser browser) + { + // Setup Page + await using IPage page = await browser.NewPageAsync(); + await page.SetViewportAsync(new ViewPortOptions { Width = 1200, Height = 720 }); + + // Navigate to home screen + await page.GoToAsync(_config.SimmonsBankBaseUrl); + + try + { + await page.WaitForSelectorAsync(DASHBOARD_SELECTOR).WaitAsync(TimeSpan.FromSeconds(_config.BrowserOperationTimeoutSeconds), _stoppingToken); + } + catch(TaskCanceledException) + { + _logger.LogWarning($"IsLoggedIn Task for user '{user.Id}' was canceled"); + } + catch(TimeoutException) + { + return false; + } + + return true; + } } diff --git a/AAIntegration.SimmonsBank.API/Workers/PuppeteerWorker.cs b/AAIntegration.SimmonsBank.API/Workers/PuppeteerWorker.cs index 9017a00..305ad20 100644 --- a/AAIntegration.SimmonsBank.API/Workers/PuppeteerWorker.cs +++ b/AAIntegration.SimmonsBank.API/Workers/PuppeteerWorker.cs @@ -41,6 +41,7 @@ public class PuppeteerWorker : BackgroundService using (var scope = _serviceScopeFactory.CreateScope()) { _userService = scope.ServiceProvider.GetService(); + _puppeteerProcess.SetStoppingToken(stoppingToken); // This is how we keep the app running (in the background) while (!stoppingToken.IsCancellationRequested) @@ -74,7 +75,7 @@ public class PuppeteerWorker : BackgroundService //_logger.LogInformation($"KeepAlive configured to {ka}. This user hasn't been kept alive in {uka} minute(s)."); - if (_config.KeepAliveIntervalMinutes < (int)DateTime.UtcNow.Subtract(GetUserLastExecution(PuppeteerConfigConstants.KeepAliveIntervalMinutes, user.Id)).TotalMinutes) + if (_config.KeepAliveIntervalMinutes <= (int)DateTime.UtcNow.Subtract(GetUserLastExecution(PuppeteerConfigConstants.KeepAliveIntervalMinutes, user.Id)).TotalMinutes) { await _puppeteerProcess.StayLoggedIn(user); SetUserLastExecution(PuppeteerConfigConstants.KeepAliveIntervalMinutes, user.Id, DateTime.UtcNow); diff --git a/AAIntegration.SimmonsBank.API/appsettings.json b/AAIntegration.SimmonsBank.API/appsettings.json index 1443388..f7948a2 100644 --- a/AAIntegration.SimmonsBank.API/appsettings.json +++ b/AAIntegration.SimmonsBank.API/appsettings.json @@ -26,8 +26,10 @@ "APIUrl": "https://localhost:7260" }, "Puppeteer": { + "BrowserOperationTimeoutSeconds": 5, "TaskCheckIntervalMinutes": 1, - "KeepAliveIntervalMinutes": 10, - "CheckForNewDataIntervalMinutes": 15 + "KeepAliveIntervalMinutes": 1, + "CheckForNewDataIntervalMinutes": 15, + "SimmonsBankBaseUrl": "https://login.simmonsbank.com" } } diff --git a/README.md b/README.md index 7044722..e4f0f5a 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,10 @@ This is an integration for ActiveAllocator. The type is Transaction Importer, specifically created for interfacing with SimmonsBank's online banking website. +## Dependencies + +[Otp.NET library](https://github.com/kspearrin/Otp.NET) + ## Dev Environment Setup On Archlinux install the following to use dotnet: ```sudo pacman -Sy dotnet-sdk dotnet-runtime aspnet-runtime```.