From 32dfd7d7b11b906dd71c2a0d6ad70c4580bbc761 Mon Sep 17 00:00:00 2001 From: projectmoon Date: Tue, 21 Mar 2023 16:58:19 +0100 Subject: [PATCH] More work on refactoring and making statements page function --- src/background/auth-state-handler.ts | 18 +++++- src/background/message-handler.ts | 33 +++++++++- src/config.ts | 3 + src/content-scripts/ensure-state.ts | 91 ++++++++++++++++++++++++++++ src/landsbankinn/graphql/api.ts | 2 +- src/landsbankinn/netbanki/api.ts | 40 +++++++++++- src/landsbankinn/netbanki/models.ts | 14 ++--- src/manifest.json | 21 +++++-- src/state/empty.ts | 0 src/state/index.ts | 25 ++++++++ src/{state.ts => state/level.ts} | 18 +----- 11 files changed, 228 insertions(+), 37 deletions(-) create mode 100644 src/config.ts create mode 100644 src/content-scripts/ensure-state.ts create mode 100644 src/state/empty.ts create mode 100644 src/state/index.ts rename src/{state.ts => state/level.ts} (82%) diff --git a/src/background/auth-state-handler.ts b/src/background/auth-state-handler.ts index 8128350..5fb40cc 100644 --- a/src/background/auth-state-handler.ts +++ b/src/background/auth-state-handler.ts @@ -1,7 +1,8 @@ const matchGraphQL = { urls: ["https://graphql.landsbankinn.is/v2"] }; -import { fetchAccounts } from '~/src/landsbankinn'; -import { PartialStateUpdate, State } from '~/src/state'; +import { fetchAccounts } from '../landsbankinn'; +import { PartialStateUpdate } from '../state'; +import { State } from '../state/level'; type HttpHeaders = browser.webRequest.HttpHeaders; @@ -50,3 +51,16 @@ browser.webRequest.onBeforeSendHeaders.addListener(e => { }, matchGraphQL, ["requestHeaders"]); export { } + +// Here we intercept and record the necessary auth info. But what if +// we cannot do that, if user logs directly into a page where there +// are no graphql calls? + +// We can get device fingerprint via content_script, and call session. + +// Have a message called ENSURE_STATE. +// Sent to background page w/ device_fingerprint. +// If state not ready, call session endpoint, and parse output. +// Record state. +// Content script for ensure state runs everywhere. +// There is a value on the page that has everything we need! diff --git a/src/background/message-handler.ts b/src/background/message-handler.ts index 3cbd7e6..940edb4 100644 --- a/src/background/message-handler.ts +++ b/src/background/message-handler.ts @@ -1,6 +1,10 @@ -import { State } from "~/src/state"; +import { PartialStateUpdate } from "../state"; +import { State } from '../state/level'; +import * as landsbankinn from '../landsbankinn'; +import { fetchAccounts } from "../landsbankinn"; -enum Actions { +export enum Actions { + EnsureState = 'ENSURE_STATE', GetAccounts = 'GET_ACCOUNTS', DownloadTransactions = 'DOWNLOAD_TRANSACTIONS' } @@ -16,8 +20,33 @@ async function getAccounts(sender: MessageSender): Promise { browser.tabs.sendMessage(sender.tab?.id!, message); } +async function ensureState(message: any, sender: MessageSender) { + const clientState: PartialStateUpdate = message.clientState; + const state = await State.current; + + if (!state.ready) { + await State.update({ ready: true }); + const session = await landsbankinn.authSession(); + const newState = await State.update({ + authToken: session.accessToken, ...clientState + }) + + if (!newState.accounts) { + const accounts = await fetchAccounts(newState); + await State.update({ accounts }); + console.info('Acquired account data'); + } + + console.info('Updated state from client and auth session'); + console.log(await State.current); + } +} + browser.runtime.onMessage.addListener((message, sender) => { + console.log('received message', message); switch (message.action) { + case Actions.EnsureState: + ensureState(message, sender); case Actions.GetAccounts: getAccounts(sender); } diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..cd0fdc9 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,3 @@ +export default { + StateTTL: 5 * 60 * 1000 +} diff --git a/src/content-scripts/ensure-state.ts b/src/content-scripts/ensure-state.ts new file mode 100644 index 0000000..f447bda --- /dev/null +++ b/src/content-scripts/ensure-state.ts @@ -0,0 +1,91 @@ +import { PartialStateUpdate } from "../state"; // TODO do not run state on content scripts +import { Actions } from '../background/message-handler'; + +// The actual information we care about +interface ApiInformation { + graphQLUrl: string; + apikey: string; + locale: string; +} + +interface RegularWindowProperties { + add_deviceprint(): string + LIPageData: ApiInformation; +} + +interface MegaLIData { + megaHeader: ApiInformation; +} + +interface MegaWindowProperties { + LIPageData: MegaLIData; + add_deviceprint(): string; +} + +interface LIWindowRegular extends Window { + wrappedJSObject: RegularWindowProperties; +} + +interface LIWindowMega extends Window { + wrappedJSObject: MegaWindowProperties; +} + +type LIWindow = LIWindowRegular | LIWindowMega; +type AnyWindow = Window | LIWindowRegular | LIWindowMega + +function isApiInformation(liData: ApiInformation) { + return liData.graphQLUrl !== undefined && + liData.apikey !== undefined && + liData.locale !== undefined; +} +function isRegularWindow(win: AnyWindow): win is LIWindowRegular { + const liWindow = win as LIWindowRegular; + return typeof liWindow.wrappedJSObject.add_deviceprint === 'function' && + liWindow.wrappedJSObject.LIPageData !== undefined && + isApiInformation(liWindow.wrappedJSObject.LIPageData); +} + +function isMegaHeaderWindow(win: AnyWindow): win is LIWindowRegular { + const liWindow = win as LIWindowMega; + return typeof liWindow.wrappedJSObject.add_deviceprint === 'function' && + liWindow.wrappedJSObject.LIPageData !== undefined && + isApiInformation(liWindow.wrappedJSObject.LIPageData.megaHeader); +} + +// Type guard that coerces the global window object to the specialized +// Landsbankinn window object with useful info attached to it. There +// are at least two different ways this data is presented. +function isLandsbankinnWindow(win: AnyWindow): win is LIWindow { + return isRegularWindow(win) || isMegaHeaderWindow(win); +} + +function extractStateUpdate(win: LIWindow): PartialStateUpdate | undefined { + if (isRegularWindow(win)) { + return { + apiKey: win.wrappedJSObject.LIPageData.apikey, + fingerprintValue: win.wrappedJSObject.add_deviceprint() + }; + } else if (isMegaHeaderWindow(win)) { + return { + apiKey: win.wrappedJSObject.LIPageData.megaHeader.apikey, + fingerprintValue: win.wrappedJSObject.add_deviceprint() + }; + } else { + return undefined; + } +} + +// Communicates data about the GraphQL URL and API key to the +// extension. +document.addEventListener('DOMContentLoaded', async () => { + console.log(window); + if (isLandsbankinnWindow(window)) { + console.info('Ensuring extension state'); + const clientState = extractStateUpdate(window); + await browser.runtime.sendMessage({ + action: Actions.EnsureState, clientState + }); + } +}); + +export { } diff --git a/src/landsbankinn/graphql/api.ts b/src/landsbankinn/graphql/api.ts index a3a7f29..2ef1074 100644 --- a/src/landsbankinn/graphql/api.ts +++ b/src/landsbankinn/graphql/api.ts @@ -34,7 +34,7 @@ export async function fetchAccounts(ctx: Context): Promise> { referrerPolicy: "strict-origin-when-cross-origin", headers: { apikey: ctx.apiKey, - authorization: ctx.authToken, + authorization: `Bearer ${ctx.authToken}`, "rsa-fingerprint": ctx.fingerprintValue, "onlinebank-type": "personal" //Todo can it be also business? }, diff --git a/src/landsbankinn/netbanki/api.ts b/src/landsbankinn/netbanki/api.ts index f8b8480..90c9e19 100644 --- a/src/landsbankinn/netbanki/api.ts +++ b/src/landsbankinn/netbanki/api.ts @@ -1,9 +1,19 @@ import { setDefaultOptions, format } from 'date-fns'; import { enUS } from 'date-fns/locale'; -import { RawTransaction, DownloadTransactionsRequest } from './models'; +import { RawTransaction, AuthSession } from './models'; setDefaultOptions({ locale: enUS }); +export enum TransactionFormat { JSON }; +export enum OutputFormat { CSV }; + +export interface DownloadTransactionsRequest { + accountNumber: string; + dateFrom: Date; + dateTo: Date; + outputFormat: OutputFormat; +} + const formatDate = (date: Date) => format(date, 'ccc LLL dd yyyy'); function getUrl(accountNumber: string, dateFrom: Date, dateTo: Date) { @@ -40,3 +50,31 @@ export async function downloadTransactions(req: DownloadTransactionsRequest): Pr return new Array(); } } + +export async function authSession(): Promise { + const url = 'https://netbanki.landsbankinn.is/api/auth/session'; + const options: RequestInit = { + method: "GET", + credentials: "include", + cache: "no-cache", + referrerPolicy: "strict-origin-when-cross-origin", + headers: { + "Accept": "*/*", + "Content-Type": "application/json", + }, + }; + + const result = await fetch(url, options); + if (result.ok) { + return await result.json(); + } else { + //attempt to read error json. + const error = await result.json(); + if (error.hasOwnProperty('message')) { + console.error(error['message']); + return Promise.reject(error); + } else { + return Promise.reject(result); + } + } +} diff --git a/src/landsbankinn/netbanki/models.ts b/src/landsbankinn/netbanki/models.ts index e891391..36ae2ad 100644 --- a/src/landsbankinn/netbanki/models.ts +++ b/src/landsbankinn/netbanki/models.ts @@ -1,11 +1,9 @@ -export enum TransactionFormat { JSON }; -export enum OutputFormat { CSV }; - -export interface DownloadTransactionsRequest { - accountNumber: string; - dateFrom: Date; - dateTo: Date; - outputFormat: OutputFormat; +export interface AuthSession { + accessToken: string; + name: string; + type: string; + username: string; + ttlInSeconds: number; } export interface RawTransaction { diff --git a/src/manifest.json b/src/manifest.json index c37a51a..7b4bb37 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -9,12 +9,21 @@ "webRequestBlocking" ], "background": { - "scripts": [ "background/auth-state-handler.ts", "background/message-handler.ts" ], + "scripts": [ + "background/message-handler.ts" + ], "persistent": false }, - "content_scripts": [{ - "matches": ["https://netbanki.landsbankinn.is/Ebli/Statements/ClientSummary.aspx"], - "js": ["content-scripts/statement-page.tsx"], - "run_at": "document_start" - }] + "content_scripts": [ + { + "matches": ["https://netbanki.landsbankinn.is/*"], + "js": ["content-scripts/ensure-state.ts"], + "run_at": "document_start" + }, + { + "matches": ["https://netbanki.landsbankinn.is/Ebli/Statements/ClientSummary.aspx"], + "js": ["content-scripts/statement-page.tsx"], + "run_at": "document_start" + } + ] } diff --git a/src/state/empty.ts b/src/state/empty.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/state/index.ts b/src/state/index.ts new file mode 100644 index 0000000..c78c891 --- /dev/null +++ b/src/state/index.ts @@ -0,0 +1,25 @@ +import { Account } from '../landsbankinn/models'; + +export interface ExporterState { + apiKey: string; + authToken: string; + fingerprintValue: string; + accounts: Array; + ready: boolean; +} + +export interface PartialStateUpdate { + apiKey?: string; + authToken?: string; + fingerprintValue?: string; + accounts?: Array; + ready?: boolean; +} + +// TODO export it and replace direct references to level with it +interface StateStore { + readonly current: Promise; + update(u: ExporterState | PartialStateUpdate): Promise; + clear(): Promise; + +} diff --git a/src/state.ts b/src/state/level.ts similarity index 82% rename from src/state.ts rename to src/state/level.ts index 434d715..6ac9d1d 100644 --- a/src/state.ts +++ b/src/state/level.ts @@ -1,21 +1,5 @@ import { Level } from 'level'; -import { Account } from '~landsbankinn/models'; - -export interface ExporterState { - apiKey: string; - authToken: string; - fingerprintValue: string; - accounts: Array; - ready: boolean; -} - -export interface PartialStateUpdate { - apiKey?: string; - authToken?: string; - fingerprintValue?: string; - accounts?: Array; - ready?: boolean; -} +import { ExporterState, PartialStateUpdate } from './index'; const FIVE_MINUTES = 0.25 * 60 * 1000;