X-Git-Url: http://dolda2000.com/gitweb/?p=fulbank.git;a=blobdiff_plain;f=fulbank%2Ffsb.py;h=7a4a4a89dfe2126a536a3a2d553930e9799476f8;hp=ef413ae91232bd29d69f82b200b370ceb98140ab;hb=HEAD;hpb=61fd054fe24ac51425b1cdf03f90ed2cbacc63bd diff --git a/fulbank/fsb.py b/fulbank/fsb.py index ef413ae..7a4a4a8 100644 --- a/fulbank/fsb.py +++ b/fulbank/fsb.py @@ -1,6 +1,8 @@ -import json, http.cookiejar, binascii, time, pickle +import json, http.cookiejar, binascii, time, datetime, pickle, urllib.error, io +from PIL import Image from urllib import request, parse from bs4 import BeautifulSoup as soup +from . import currency, auth, data soupify = lambda cont: soup(cont, "html.parser") apibase = "https://online.swedbank.se/TDE_DAP_Portal_REST_WEB/api/" @@ -10,23 +12,41 @@ serviceid = "B7dZHQcY78VRVz9l" class fmterror(Exception): pass -class autherror(Exception): +class autherror(auth.autherror): pass +class jsonerror(Exception): + def __init__(self, code, data, headers): + self.code = code + self.data = data + self.headers = headers + + @classmethod + def fromerr(cls, err): + cs = err.headers.get_content_charset() + if cs is None: + cs = "utf-8" + data = json.loads(err.read().decode(cs)) + return cls(err.code, data, err.headers) + def resolve(d, keys, default=fmterror): - def err(): + def err(key): if default is fmterror: - raise fmterror() + raise fmterror(key) return default def rec(d, keys): if len(keys) == 0: return d if isinstance(d, dict): if keys[0] not in d: - return err() + return err(keys[0]) + return rec(d[keys[0]], keys[1:]) + elif isinstance(d, list): + if not 0 <= keys[0] < len(d): + return err(keys[0]) return rec(d[keys[0]], keys[1:]) else: - return err() + return err(keys[0]) return rec(d, keys) def linkurl(ln): @@ -47,20 +67,23 @@ def getdsid(): def base64(data): return binascii.b2a_base64(data).decode("ascii").strip().rstrip("=") -class transaction(object): +class transaction(data.transaction): def __init__(self, account, data): self.account = account self._data = data + _datefmt = "%Y-%m-%d" + @property - def amount(self): return float(resolve(self._data, ("amount",))) + def value(self): return currency.currency.get(resolve(self._data, ("currency",))).parse(resolve(self._data, ("amount",))) @property - def message(self): return resolve(self._data, ("details", "message")) - - def __repr__(self): - return "#" % (self.amount, self.message) + def message(self): return resolve(self._data, ("description",)) + @property + def date(self): + p = time.strptime(resolve(self._data, ("accountingDate",)), self._datefmt) + return datetime.date(p.tm_year, p.tm_mon, p.tm_mday) -class account(object): +class txnaccount(data.txnaccount): def __init__(self, sess, id, idata): self.sess = sess self.id = id @@ -80,18 +103,75 @@ class account(object): @property def fullnumber(self): return resolve(self.data, ("fullyFormattedNumber",)) @property + def balance(self): return currency.currency.get(resolve(self.data, ("balance", "currencyCode"))).parse(resolve(self.data, ("balance", "amount"))) + @property def name(self): return resolve(self._idata, ("name",)) def transactions(self): pagesz = 50 - data = self.sess._jreq("v5/engagement/transactions/" + self.id, transactionsPerPage=pagesz, page=1) - for tx in resolve(data, ("transactions",)): - yield transaction(self, tx) + page = 1 + while True: + data = self.sess._jreq("v5/engagement/transactions/" + self.id, transactionsPerPage=pagesz, page=page) + txlist = resolve(data, ("transactions",)) + if len(txlist) < 1: + break + for tx in txlist: + yield transaction(self, tx) + page += 1 - def __repr__(self): - return "#" % (self.fullnumber, self.name) +class cardtransaction(data.transaction): + def __init__(self, account, data): + self.account = account + self._data = data + + _datefmt = "%Y-%m-%d" + + @property + def value(self): + am = resolve(self._data, ("localAmount",)) + return currency.currency.get(resolve(am, ("currencyCode",))).parse(resolve(am, ("amount",))) + @property + def message(self): return resolve(self._data, ("description",)) + @property + def date(self): + p = time.strptime(resolve(self._data, ("date",)), self._datefmt) + return datetime.date(p.tm_year, p.tm_mon, p.tm_mday) + +class cardaccount(data.cardaccount): + def __init__(self, sess, id, idata): + self.sess = sess + self.id = id + self._data = None + self._idata = idata + + @property + def data(self): + if self._data is None: + self._data = self.sess._jreq("v5/engagement/cardaccount/" + self.id) + return self._data + + @property + def number(self): return resolve(self.data, ("cardAccount", "cardNumber")) + @property + def balance(self): + cc = resolve(self.data, ("transactions", 0, "localAmount", "currencyCode")) + return currency.currency.get(cc).parse(resolve(self.data, ("cardAccount", "currentBalance"))) + @property + def name(self): return resolve(self._idata, ("name",)) + + def transactions(self): + pagesz = 50 + page = 1 + while True: + data = self.sess._jreq("v5/engagement/cardaccount/" + self.id, transactionsPerPage=pagesz, page=page) + txlist = resolve(data, ("transactions",)) + if len(txlist) < 1: + break + for tx in txlist: + yield cardtransaction(self, tx) + page += 1 -class session(object): +class session(data.session): def __init__(self, dsid): self.dsid = dsid self.auth = base64((serviceid + ":" + str(int(time.time() * 1000))).encode("ascii")) @@ -129,7 +209,11 @@ class session(object): def _jreq(self, *args, **kwargs): headers = kwargs.pop("headers", {}) headers["Accept"] = "application/json" - ret = self._req(*args, headers=headers, **kwargs) + try: + ret = self._req(*args, headers=headers, **kwargs) + except urllib.error.HTTPError as e: + if e.headers.get_content_type() == "application/json": + raise jsonerror.fromerr(e) return json.loads(ret.decode("utf-8")) def _postlogin(self): @@ -144,29 +228,82 @@ class session(object): rolesw = linkurl(resolve(prof["banks"][0], ("privateProfile", "links", "next", "uri"))) self._jreq(rolesw, method="POST") - def auth_bankid(self, user): - data = self._jreq("v5/identification/bankid/mobile", data = { - "userId": user, - "useEasyLogin": False, - "generateEasyLoginId": False}) - if data.get("status") != "USER_SIGN": - raise fmterror("unexpected bankid status: " + str(data.get("status"))) + def auth_token(self, user, conv=None): + if conv is None: + conv = auth.default() + try: + data = self._jreq("v5/identification/securitytoken/challenge", data = { + "userId": user, + "useEasyLogin": "false", + "generateEasyLoginId": "false"}) + except jsonerror as e: + if e.code == 400: + flds = resolve(e.data, ("errorMessages", "fields"), False) + if isinstance(flds, list): + for fld in flds: + if resolve(fld, ("field",), None) == "userId": + raise autherror(fld["message"]) + raise + if data.get("useOneTimePassword"): + raise fmterror("unexpectedly found useOneTimePassword") + if data.get("challenge") != "": + raise fmterror("unexpected challenge: " + str(data.get("challenge"))) + if not isinstance(data.get("imageChallenge"), dict) or resolve(data, ("imageChallenge", "method")) != "GET": + raise fmterror("invalid image challenge: " + str(data.get("imageChallenge"))) + iurl = linkurl(resolve(data, ("imageChallenge", "uri"))) vfy = linkurl(resolve(data, ("links", "next", "uri"))) + img = Image.open(io.BytesIO(self._req(iurl))) + conv.image(img) + response = conv.prompt("Token response: ", True) + try: + data = self._jreq(vfy, data={"response": response}) + except jsonerror as e: + msgs = resolve(e.data, ("errorMessages", "general"), False) + if isinstance(msgs, list): + for msg in msgs: + if msg.get("message"): + raise autherror(msg.get("message")) + raise + if not data.get("authenticationRole", ""): + raise fmterror("authentication appears to have succeded, but there is no authenticationRole: " + str(data)) + self._postlogin() + + def auth_bankid(self, user, conv=None): + if conv is None: + conv = auth.default() + try: + data = self._jreq("v5/identification/bankid/mobile", data = { + "userId": user, + "useEasyLogin": False, + "generateEasyLoginId": False}) + except jsonerror as e: + if e.code == 400: + flds = resolve(e.data, ("errorMessages", "fields"), False) + if isinstance(flds, list): + for fld in flds: + if resolve(fld, ("field",), None) == "userId": + raise autherror(fld["message"]) + raise + st = data.get("status") + vfy = linkurl(resolve(data, ("links", "next", "uri"))) + fst = None while True: - time.sleep(3) - vdat = self._jreq(vfy) - st = vdat.get("status") - if st == "USER_SIGN": - continue - elif st == "CLIENT_NOT_STARTED": - continue + if st in {"USER_SIGN", "CLIENT_NOT_STARTED"}: + if st != fst: + conv.message("Status: %s" % (st,), auth.conv.msg_info) + fst = st elif st == "COMPLETE": self._postlogin() return elif st == "CANCELLED": raise autherror("authentication cancelled") + elif st == "OUTSTANDING_TRANSACTION": + raise autherror("another bankid transaction already in progress") else: raise fmterror("unexpected bankid status: " + str(st)) + time.sleep(3) + vdat = self._jreq(vfy) + st = vdat.get("status") def keepalive(self): data = self._jreq("v5/framework/clientsession") @@ -175,10 +312,13 @@ class session(object): @property def accounts(self): if self._accounts is None: - data = self._jreq("v5/engagement/overview") + txndata = self._jreq("v5/engagement/overview") + crddata = self._jreq("v5/card/creditcard") accounts = [] - for acct in resolve(data, ("transactionAccounts",)): - accounts.append(account(self, resolve(acct, ("id",)), acct)) + for acct in resolve(txndata, ("transactionAccounts",)): + accounts.append(txnaccount(self, resolve(acct, ("id",)), acct)) + for acct in resolve(crddata, ("cardAccounts",)): + accounts.append(cardaccount(self, resolve(acct, ("id",)), acct)) self._accounts = accounts return self._accounts @@ -191,6 +331,18 @@ class session(object): self.logout() self._req("v5/framework/clientsession", method="DELETE") + def __getstate__(self): + state = dict(self.__dict__) + state["jar"] = list(state["jar"].cookiejar) + return state + + def __setstate__(self, state): + jar = request.HTTPCookieProcessor() + for cookie in state["jar"]: + jar.cookiejar.set_cookie(cookie) + state["jar"] = jar + self.__dict__.update(state) + def __enter__(self): return self @@ -206,12 +358,3 @@ class session(object): @classmethod def create(cls): return cls(getdsid()) - - def save(self, filename): - with open(filename, "wb") as fp: - pickle.dump(self, fp) - - @classmethod - def load(cls, filename): - with open(filename, "rb") as fp: - return picke.load(fp)