namespace AAIntegration.SimmonsBank.API.Services; using AutoMapper; using BCrypt.Net; using AAIntegration.SimmonsBank.API.Entities; using AAIntegration.SimmonsBank.API.Config; using AAIntegration.SimmonsBank.API.Models.Users; using System; using System.Collections; using System.Collections.Generic; using Microsoft.EntityFrameworkCore.Internal; using Microsoft.IdentityModel.Tokens; using System.Text; using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using Microsoft.Extensions.Options; using System.Security.Cryptography; using PuppeteerSharp; using AAIntegration.SimmonsBank.API.Configs; using Microsoft.Extensions.Caching.Memory; using OtpNet; using Newtonsoft.Json.Linq; using NuGet.Protocol; using Microsoft.Extensions.Logging; using NuGet.Protocol.Core.Types; using AAIntegration.SimmonsBank.API.Models.Accounts; using AAIntegration.SimmonsBank.API.Models.Transactions; public interface IPuppeteerService { Task Login(User user, CancellationToken cancellationToken); Task IsLoggedIn(User user, CancellationToken cancellationToken); Task> GetAccounts(User user); Task> GetTransactions(User user, string accountGuid, uint offset = 0, uint limit = 500); } public class PuppeteerService : IPuppeteerService { private const string API_BASE_PATH = "/a/consumer/api/v0"; private const string DASHBOARD_SELECTOR = "body > banno-web > bannoweb-layout > bannoweb-dashboard"; private const string USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36"; private readonly PuppeteerConfig _config; private readonly ILogger _logger; private readonly IMemoryCache _memoryCache; private DataContext _context; private readonly IMapper _mapper; private readonly IOptions _appSettings; private readonly ICacheService _cacheService; public PuppeteerService( IOptions config, ILogger logger, IMemoryCache memoryCache, DataContext context, IMapper mapper, IOptions appSettings, ICacheService cacheService) { _config = config.Value; _logger = logger; _memoryCache = memoryCache; _context = context; _mapper = mapper; _appSettings = appSettings; _cacheService = cacheService; } public async Task Login(User user, CancellationToken cancellationToken) { string prefix = $"Task::Login - {user.Id} - "; TimeSpan timeout = TimeSpan.FromSeconds(_config.BrowserOperationTimeoutSeconds); // Setup Page IBrowser browser = await GetUserBrowserAsync(user, cancellationToken); await using IPage page = await browser.NewPageAsync(); await page.SetUserAgentAsync(USER_AGENT); await page.SetViewportAsync(new ViewPortOptions { Width = 1200, Height = 720 }); WaitUntilNavigation[] waitUntils = { WaitUntilNavigation.Networkidle0 }; // Navigate to login screen await page.GoToAsync(_config.SimmonsBankBaseUrl + "/login"); try { // Type username string selector = "#username"; await page.WaitForSelectorAsync(selector).WaitAsync(timeout, cancellationToken); await page.TypeAsync(selector, user.SimmonsBankUsername); // Press 1st Submit Button selector = "jha-button"; await page.WaitForSelectorAsync(selector).WaitAsync(timeout, cancellationToken); await page.ClickAsync(selector); // Type password selector = "#password"; await page.WaitForSelectorAsync(selector).WaitAsync(timeout, cancellationToken); 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, cancellationToken); IElementHandle signInButton = await page.QuerySelectorAsync(selector); if (signInButton != null) { await signInButton.ClickAsync(); } else { _logger.LogError(prefix + "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, cancellationToken); //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); // Setup response handling page.Response += LoginResponseHandler; async void LoginResponseHandler(object sender, ResponseCreatedEventArgs args) { //IPage page = sender as IPage; page.Response -= LoginResponseHandler; JToken json = await args.Response.JsonAsync(); string userId = json["id"].Value(); _cacheService.SetCachedUserValue(user, PuppeteerConstants.USER_SB_ID, userId); } // 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, cancellationToken); IElementHandle verifyButton = await page.QuerySelectorAsync(selector); if (verifyButton != null) { await verifyButton.ClickAsync(); } else { _logger.LogError(prefix + "Failed to find Verify button"); return false; } try { await page.WaitForSelectorAsync(DASHBOARD_SELECTOR).WaitAsync(timeout, cancellationToken); } catch(TimeoutException) { _logger.LogWarning(prefix + $"Dashboard isn't loading after login"); return false; } _logger.LogInformation(prefix + $"Login success"); return true; } catch (TaskCanceledException) { _logger.LogError(prefix + $"Login Task was canceled"); } catch (TimeoutException ex) { //_logger.LogWarning($"Login Task timed out for user '{user.Id}' after {timeout} seconds"); _logger.LogError(0, ex, prefix + $"Login Task timed out after {timeout} seconds"); return false; } finally { await page.CloseAsync(); } return false; } public async Task IsLoggedIn(User user, CancellationToken cancellationToken) { string prefix = $"Task::IsLoggedIn - {user.Id} - "; // Get User ID string userSbId = _cacheService.GetCachedUserValue(user, PuppeteerConstants.USER_SB_ID, ""); if (string.IsNullOrWhiteSpace(userSbId)) { //_logger.LogInformation(prefix + $"User SimmonsBank ID not found. User is not logged in."); return false; } // Setup Page IBrowser browser = await GetUserBrowserAsync(user, cancellationToken); await using IPage page = await browser.NewPageAsync(); await page.SetUserAgentAsync(USER_AGENT); await page.SetViewportAsync(new ViewPortOptions { Width = 1200, Height = 720 }); // Fetch accounts string url = _config.SimmonsBankBaseUrl + API_BASE_PATH + "/users/" + userSbId + "/accounts"; try { IResponse response = await page.GoToAsync(url).WaitAsync(TimeSpan.FromSeconds(_config.BrowserOperationTimeoutSeconds), cancellationToken); //_logger.LogInformation(prefix + $"Request response code '{response.Status}'. Url: '{url}'"); return response.Status == System.Net.HttpStatusCode.OK; } catch(TaskCanceledException) { _logger.LogError(prefix + $"Task was canceled"); } catch(TimeoutException) { _logger.LogError(prefix + $"Request to '{url}' timed out"); } return false; } public async Task> GetAccounts(User user) { string prefix = $"Task::GetAccounts - {user.Id} - "; // Get User ID string userSbId = _cacheService.GetCachedUserValue(user, PuppeteerConstants.USER_SB_ID, ""); if (string.IsNullOrWhiteSpace(userSbId)) { _logger.LogWarning(prefix + $"User SimmonsBank ID not found. User is not logged in."); return null; } // Setup Page IBrowser browser = await GetUserBrowserAsync(user, new CancellationToken()); await using IPage page = await browser.NewPageAsync(); await page.SetUserAgentAsync(USER_AGENT); await page.SetViewportAsync(new ViewPortOptions { Width = 1200, Height = 720 }); // Fetch accounts string url = _config.SimmonsBankBaseUrl + API_BASE_PATH + "/users/" + userSbId + "/accounts"; try { IResponse response = await page.GoToAsync(url).WaitAsync(TimeSpan.FromSeconds(_config.BrowserOperationTimeoutSeconds)); //_logger.LogInformation(prefix + $"Request response code '{response.Status}'. Url: '{url}'"); if (response.Status == System.Net.HttpStatusCode.OK) { JToken accounts = await response.JsonAsync(); return accounts.SelectToken("accounts").ToObject>(); } else _logger.LogError(prefix + $"Received unexpected status code '{response.Status}'"); } catch(TaskCanceledException) { _logger.LogError(prefix + $"Task was canceled"); } catch(TimeoutException) { _logger.LogError(prefix + $"Request to '{url}' timed out"); } return null; } public async Task> GetTransactions(User user, string accountGuid, uint offset = 0, uint limit = 500) { string prefix = $"Task::GetTransactions - {user.Id} - "; // Get User ID string userSbId = _cacheService.GetCachedUserValue(user, PuppeteerConstants.USER_SB_ID, ""); if (string.IsNullOrWhiteSpace(userSbId)) { _logger.LogWarning(prefix + $"User SimmonsBank ID not found. User is not logged in."); return null; } // Setup Page IBrowser browser = await GetUserBrowserAsync(user, new CancellationToken()); await using IPage page = await browser.NewPageAsync(); await page.SetUserAgentAsync(USER_AGENT); await page.SetViewportAsync(new ViewPortOptions { Width = 1200, Height = 720 }); // Fetch transactions string url = _config.SimmonsBankBaseUrl + API_BASE_PATH + $"/users/{userSbId}/accounts/{accountGuid}/transactions?offset={offset}&limit={limit}"; try { IResponse response = await page.GoToAsync(url).WaitAsync(TimeSpan.FromSeconds(_config.BrowserOperationTimeoutSeconds)); //_logger.LogInformation(prefix + $"Request response code '{response.Status}'. Url: '{url}'"); if (response.Status == System.Net.HttpStatusCode.OK) { JToken transactions = await response.JsonAsync(); return transactions.SelectToken("transactions").ToObject>(); } else _logger.LogError(prefix + $"Received unexpected status code '{response.Status}'"); } catch(TaskCanceledException) { _logger.LogError(prefix + $"Task was canceled"); } catch(TimeoutException) { _logger.LogError(prefix + $"Request to '{url}' timed out"); } return null; } // Helper / Private Functions private async Task GetUserBrowserAsync(User user, CancellationToken cancellationToken) { IBrowser cachedBrowser = _cacheService.GetCachedUserValue(user, PuppeteerConstants.BROWSER_CACHE_KEY, null); if (cachedBrowser != null) return cachedBrowser; //_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().WaitAsync(TimeSpan.FromSeconds(_config.BrowserOperationTimeoutSeconds * 20), cancellationToken); var options = new LaunchOptions { Headless = _config.Headless, IgnoreHTTPSErrors = true, }; IBrowser browser = await Puppeteer.LaunchAsync(options).WaitAsync(TimeSpan.FromSeconds(_config.BrowserOperationTimeoutSeconds), cancellationToken); _cacheService.SetCachedUserValue(user, PuppeteerConstants.BROWSER_CACHE_KEY, browser); return browser; } }