2024-04-10 13:15:46 -05:00

345 lines
14 KiB
C#

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<bool> Login(User user, CancellationToken cancellationToken);
Task<bool> IsLoggedIn(User user, CancellationToken cancellationToken);
Task<List<AccountDTO>> GetAccounts(User user);
Task<List<TransactionDTO>> 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<PuppeteerService> _logger;
private readonly IMemoryCache _memoryCache;
private DataContext _context;
private readonly IMapper _mapper;
private readonly IOptions<AppSettings> _appSettings;
private readonly ICacheService _cacheService;
public PuppeteerService(
IOptions<PuppeteerConfig> config,
ILogger<PuppeteerService> logger,
IMemoryCache memoryCache,
DataContext context,
IMapper mapper,
IOptions<AppSettings> appSettings,
ICacheService cacheService)
{
_config = config.Value;
_logger = logger;
_memoryCache = memoryCache;
_context = context;
_mapper = mapper;
_appSettings = appSettings;
_cacheService = cacheService;
}
public async Task<bool> 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<JToken>();
string userId = json["id"].Value<string>();
_cacheService.SetCachedUserValue<string>(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<bool> IsLoggedIn(User user, CancellationToken cancellationToken)
{
string prefix = $"Task::IsLoggedIn - {user.Id} - ";
// Get User ID
string userSbId = _cacheService.GetCachedUserValue<string>(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<List<AccountDTO>> GetAccounts(User user)
{
string prefix = $"Task::GetAccounts - {user.Id} - ";
// Get User ID
string userSbId = _cacheService.GetCachedUserValue<string>(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<JToken>();
return accounts.SelectToken("accounts").ToObject<List<AccountDTO>>();
}
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<List<TransactionDTO>> GetTransactions(User user, string accountGuid, uint offset = 0, uint limit = 500)
{
string prefix = $"Task::GetTransactions - {user.Id} - ";
// Get User ID
string userSbId = _cacheService.GetCachedUserValue<string>(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<JToken>();
return transactions.SelectToken("transactions").ToObject<List<TransactionDTO>>();
}
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<IBrowser> GetUserBrowserAsync(User user, CancellationToken cancellationToken)
{
IBrowser cachedBrowser = _cacheService.GetCachedUserValue<IBrowser>(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,
Args = new [] { "--no-sandbox" }
};
IBrowser browser = await Puppeteer.LaunchAsync(options).WaitAsync(TimeSpan.FromSeconds(_config.BrowserOperationTimeoutSeconds), cancellationToken);
_cacheService.SetCachedUserValue<IBrowser>(user, PuppeteerConstants.BROWSER_CACHE_KEY, browser);
return browser;
}
}