From db4731c69885dab2d880a60b538e6909c5c40641 Mon Sep 17 00:00:00 2001 From: Fredrik Tolf Date: Fri, 13 Nov 2020 22:19:27 +0100 Subject: [PATCH 1/9] fsb: Work around cookie processors no longer being pickleable. --- fulbank/fsb.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/fulbank/fsb.py b/fulbank/fsb.py index 145de7b..8f6280c 100644 --- a/fulbank/fsb.py +++ b/fulbank/fsb.py @@ -289,6 +289,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 -- 2.11.0 From 216561679af237371dc62bd7a1a07aed9dad3d83 Mon Sep 17 00:00:00 2001 From: Fredrik Tolf Date: Wed, 2 Jun 2021 02:45:40 +0200 Subject: [PATCH 2/9] auth: Add "support" for displaying images. It just calls sxiv right now, but what's the alternative? SIXEL? --- fulbank/auth.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/fulbank/auth.py b/fulbank/auth.py index 013b873..deb065c 100644 --- a/fulbank/auth.py +++ b/fulbank/auth.py @@ -1,4 +1,4 @@ -import sys, os, io, termios +import sys, os, io, termios, tempfile, subprocess class autherror(Exception): pass @@ -14,6 +14,8 @@ class conv(object): pass def prompt(self, prompt, echo, default=None): return default + def image(self, image): + pass class termconv(conv): def __init__(self, ifp, ofp): @@ -49,6 +51,14 @@ class termconv(conv): return ret[:-1] finally: termios.tcsetattr(self.ifp.fileno(), termios.TCSANOW, bka) + def image(self, image): + fd, fn = tempfile.mkstemp() + try: + with os.fdopen(fd, "wb") as fp: + image.save(fp, "PNG") + subprocess.call(["sxiv", fn]) + finally: + os.unlink(fn) class ctermconv(termconv): def __init__(self, fp): -- 2.11.0 From 66c360163e0fa23c7cd5613114b070b1766187f6 Mon Sep 17 00:00:00 2001 From: Fredrik Tolf Date: Wed, 2 Jun 2021 02:46:02 +0200 Subject: [PATCH 3/9] fsb: Add hardware token authentication support. --- fulbank/fsb.py | 43 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/fulbank/fsb.py b/fulbank/fsb.py index 8f6280c..9e059c1 100644 --- a/fulbank/fsb.py +++ b/fulbank/fsb.py @@ -1,4 +1,5 @@ -import json, http.cookiejar, binascii, time, datetime, pickle, urllib.error +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 @@ -227,6 +228,46 @@ class session(object): rolesw = linkurl(resolve(prof["banks"][0], ("privateProfile", "links", "next", "uri"))) self._jreq(rolesw, method="POST") + 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() -- 2.11.0 From a094b3b5d20430beee75635ada3be6b04ae5fb74 Mon Sep 17 00:00:00 2001 From: Fredrik Tolf Date: Wed, 2 Jun 2021 02:46:22 +0200 Subject: [PATCH 4/9] netbank: Support hardware token authentication. --- netbank | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/netbank b/netbank index a1d3566..5e7c474 100755 --- a/netbank +++ b/netbank @@ -59,18 +59,21 @@ def cmd_login(cmd, args): sys.exit(1) sess = sesstype.create() if args[0] == "bankid": - if len(args) < 2: - sys.stderr.write("usage: login bankid USER-ID\n") - sys.exit(1) - with auth.ttyconv() as conv: - try: - sess.auth_bankid(args[1], conv) - except auth.autherror as err: - sys.stderr.write("netbank: authentication failed: %s\n" % err) - sys.exit(1) + authfun = sess.auth_bankid + elif args[0] == "token": + authfun = sess.auth_token else: sys.stderr.write("netbank: %s: unknown authentication type\n" % (args[0])) sys.exit(1) + if len(args) < 2: + sys.stderr.write("usage: login bankid USER-ID\n") + sys.exit(1) + with auth.ttyconv() as conv: + try: + authfun(args[1], conv) + except auth.autherror as err: + sys.stderr.write("netbank: authentication failed: %s\n" % err) + sys.exit(1) commands["login"] = cmd_login @requiresess -- 2.11.0 From 04ce2557a4ab1447a3e17b541afb15cb83ce8854 Mon Sep 17 00:00:00 2001 From: Fredrik Tolf Date: Sat, 5 Jun 2021 15:33:47 +0200 Subject: [PATCH 5/9] fsb: Fix changes made to credit-card-account API. --- fulbank/fsb.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/fulbank/fsb.py b/fulbank/fsb.py index 9e059c1..166b5f5 100644 --- a/fulbank/fsb.py +++ b/fulbank/fsb.py @@ -312,11 +312,12 @@ 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",)): + for acct in resolve(txndata, ("transactionAccounts",)): accounts.append(txnaccount(self, resolve(acct, ("id",)), acct)) - for acct in resolve(data, ("cardAccounts",)): + for acct in resolve(crddata, ("cardAccounts",)): accounts.append(cardaccount(self, resolve(acct, ("id",)), acct)) self._accounts = accounts return self._accounts -- 2.11.0 From be69f65b9fdbb7944b3724cf0ba1e55b9ba66a3e Mon Sep 17 00:00:00 2001 From: Fredrik Tolf Date: Sat, 5 Jun 2021 16:35:04 +0200 Subject: [PATCH 6/9] Make session saving and lookup more reusable. --- fulbank/data.py | 62 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++- fulbank/fsb.py | 11 +--------- netbank | 48 ++++++++++++++++++-------------------------- 3 files changed, 81 insertions(+), 40 deletions(-) diff --git a/fulbank/data.py b/fulbank/data.py index 36efe20..ea73b8d 100644 --- a/fulbank/data.py +++ b/fulbank/data.py @@ -1,4 +1,4 @@ -import hashlib +import os, pwd, hashlib, pickle def _localname(type): mod = type.__module__ @@ -46,3 +46,63 @@ class transaction(object): def __repr__(self): return "#<%s %s: %r>" % (_localname(type(self)), self.value, self.message) + +class session(object): + def save(self, filename): + with open(filename, "wb") as fp: + pickle.dump(self, fp) + + @staticmethod + def load(filename): + with open(filename, "rb") as fp: + return pickle.load(fp) + +def getsessnam(name): + if name == "fsb": + from . import fsb + return fsb.session + raise ValueError("no such known session type: " + name) + +def _sesspath(name): + return os.path.join(pwd.getpwuid(os.getuid()).pw_dir, ".cache/fulbank", name) + +def defaultsess(): + ret = os.getenv("NETBANKSESS") + if ret: + return ret + return "master" + +def loadsess(name=None, default=FileNotFoundError): + if name is None: name = defaultsess() + path = _sesspath(name) + if not os.path.exists(path): + if default is FileNotFoundError: + raise FileNotFoundError(name) + return default + return session.load(path) + +def savesess(sess, name=None): + if name is None: name = defaultsess() + path = _sesspath(name) + if sess is not None: + sessdir = os.path.dirname(path) + if not os.path.isdir(sessdir): + os.makedirs(sessdir) + return sess.save(_sesspath(name)) + else: + if os.path.exists(path): + os.unlink(path) + +class savedsess(object): + def __init__(self, name=None): + if name is None: name = defaultsess() + self.name = name + self.sess = None + + def __enter__(self): + self.sess = loadsess(self.name) + return self.sess + + def __exit__(self): + savesess(self.sess, name) + self.sess = None diff --git a/fulbank/fsb.py b/fulbank/fsb.py index 166b5f5..7a4a4a8 100644 --- a/fulbank/fsb.py +++ b/fulbank/fsb.py @@ -171,7 +171,7 @@ class cardaccount(data.cardaccount): 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")) @@ -358,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 pickle.load(fp) diff --git a/netbank b/netbank index 5e7c474..a1520ca 100755 --- a/netbank +++ b/netbank @@ -1,9 +1,9 @@ #!/usr/bin/python3 import sys, os, getopt, pwd, operator -from fulbank import auth +from fulbank import auth, data -sesstype = None +sessname = data.defaultsess() sess = None def pfxmatch(pfx, item): @@ -40,7 +40,7 @@ def find(seq, *, item=None, test=None, match=None, key=None, default=LookupError return default def usage(out): - out.write("usage: netbank [-h] BANK-ID COMMAND [ARGS...]\n") + out.write("usage: netbank [-h] [-s SESSION-ID] COMMAND [ARGS...]\n") def requiresess(fn): def wrap(cmd, args): @@ -54,10 +54,15 @@ commands = {} def cmd_login(cmd, args): global sess + opts, args = getopt.getopt(args, "t:") + typename = sessname + for o, a in opts: + if o == "-t": + typename = a if len(args) < 1: - sys.stderr.write("usage: login TYPE\n") + sys.stderr.write("usage: login [-t BANK-ID] TYPE\n") sys.exit(1) - sess = sesstype.create() + sess = data.getsessnam(typename).create() if args[0] == "bankid": authfun = sess.auth_bankid elif args[0] == "token": @@ -118,44 +123,29 @@ def cmd_lstxn(cmd, args): commands["lstxn"] = cmd_lstxn def main(): - global sess, sesstype + global sess, sessname - opts, args = getopt.getopt(sys.argv[1:], "h") + opts, args = getopt.getopt(sys.argv[1:], "hs:") for o, a in opts: if o == "-h": usage(sys.stdout) sys.exit(0) - if len(args) < 2: + if o == "-s": + sessname = a + if len(args) < 1: usage(sys.stderr) sys.exit(1) - if args[0] == "fsb": - import fulbank.fsb - sesstype = fulbank.fsb.session - else: - sys.stderr.write("netbank: %s: unknown bank id\n" % (args[0])) - sys.exit(1) - sesspath = os.path.join(pwd.getpwuid(os.getuid()).pw_dir, ".cache/fulbank", args[0]) - cmd = args[1] - args = args[2:] + cmd = args[0] + args = args[1:] - if os.path.exists(sesspath): - sess = sesstype.load(sesspath) - else: - sess = None + sess = data.loadsess(sessname, None) if cmd in commands: commands[cmd](cmd, args) else: sys.stderr.write("netbank: %s: unknown command\n" % (cmd)) sys.exit(1) - if sess is not None: - sessdir = os.path.dirname(sesspath) - if not os.path.isdir(sessdir): - os.makedirs(sessdir) - sess.save(sesspath) - else: - if os.path.exists(sesspath): - os.unlink(sesspath) + data.savesess(sess, sessname) try: if __name__ == "__main__": -- 2.11.0 From 88d3c3d9e2b4c5ab6112bc76dbcd8c04244cb177 Mon Sep 17 00:00:00 2001 From: Fredrik Tolf Date: Sun, 6 Jun 2021 02:09:26 +0200 Subject: [PATCH 7/9] Move find() and friends to reusable module. --- fulbank/util.py | 37 +++++++++++++++++++++++++++++++++++++ netbank | 41 ++++------------------------------------- 2 files changed, 41 insertions(+), 37 deletions(-) create mode 100644 fulbank/util.py diff --git a/fulbank/util.py b/fulbank/util.py new file mode 100644 index 0000000..eaf1249 --- /dev/null +++ b/fulbank/util.py @@ -0,0 +1,37 @@ +import operator + +def pfxmatch(pfx, item): + return str(item)[:len(pfx)] == pfx + +def ipfxmatch(pfx, item): + return str(item).upper()[:len(pfx)] == pfx.upper() + +class ambiguous(LookupError): + def __init__(self, a, b): + super().__init__("ambigous match: %s and %s" % (a, b)) + self.a = a + self.b = b + +def find(seq, *, item=None, test=None, match=None, key=None, default=LookupError): + if key is None: + key = lambda o: o + if match is None and item is not None: + match = lambda o: test(item, o) + if test is None: + test = operator.eq + found = None + for thing in seq: + if match(key(thing)): + if found is None: + found = thing + else: + if default is LookupError: + raise ambiguous(key(found), key(thing)) + else: + return default + if found is not None: + return found + if default is LookupError: + raise LookupError() + else: + return default diff --git a/netbank b/netbank index a1520ca..988b105 100755 --- a/netbank +++ b/netbank @@ -1,44 +1,11 @@ #!/usr/bin/python3 -import sys, os, getopt, pwd, operator -from fulbank import auth, data +import sys, os, getopt, pwd +from fulbank import auth, data, util sessname = data.defaultsess() sess = None -def pfxmatch(pfx, item): - return str(item)[:len(pfx)] == pfx - -class ambiguous(LookupError): - def __init__(self, a, b): - super().__init__("ambigous match: %s and %s" % (a, b)) - self.a = a - self.b = b - -def find(seq, *, item=None, test=None, match=None, key=None, default=LookupError): - if key is None: - key = lambda o: o - if match is None and item is not None: - match = lambda o: test(item, o) - if test is None: - test = operator.eq - found = None - for thing in seq: - if match(key(thing)): - if found is None: - found = thing - else: - if default is LookupError: - raise ambiguous(key(found), key(thing)) - else: - return default - if found is not None: - return found - if default is LookupError: - raise LookupError() - else: - return default - def usage(out): out.write("usage: netbank [-h] [-s SESSION-ID] COMMAND [ARGS...]\n") @@ -111,8 +78,8 @@ def cmd_lstxn(cmd, args): sys.stderr.write("usage: lstxn [-n NUM] ACCOUNT\n") sys.exit(1) try: - acct = find(sess.accounts, item=args[0], key=lambda acct: acct.number, test=pfxmatch) - except ambiguous as exc: + acct = util.find(sess.accounts, item=args[0], key=lambda acct: acct.number, test=util.pfxmatch) + except util.ambiguous as exc: sys.stderr.write("netbank: %s: ambiguous match between %s and %s\n" % (args[0], exc.a, exc.b)) sys.exit(1) except LookupError: -- 2.11.0 From 619e377ba4263dc5ccdb710ae2e13388e7ed21e9 Mon Sep 17 00:00:00 2001 From: Fredrik Tolf Date: Sun, 6 Jun 2021 02:09:40 +0200 Subject: [PATCH 8/9] Make currency values hashable. --- fulbank/currency.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/fulbank/currency.py b/fulbank/currency.py index 547acb9..0c5660c 100644 --- a/fulbank/currency.py +++ b/fulbank/currency.py @@ -143,3 +143,6 @@ class value(object): if self.currency != other.currency: raise ValueError("cannot compare %s with %s" % (self.currency.symbol, other.currency.symbol)) return self.amount >= other.amount + + def __hash__(self): + return hash(self.amount) + hash(self.currency) -- 2.11.0 From 1cc2ef54ee7bea6b983c18a08b60f69f859913dc Mon Sep 17 00:00:00 2001 From: Fredrik Tolf Date: Sun, 6 Jun 2021 02:10:15 +0200 Subject: [PATCH 9/9] netbank: Changed login syntax. --- netbank | 21 ++++++++------------- 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/netbank b/netbank index 988b105..7f52628 100755 --- a/netbank +++ b/netbank @@ -21,28 +21,23 @@ commands = {} def cmd_login(cmd, args): global sess - opts, args = getopt.getopt(args, "t:") - typename = sessname - for o, a in opts: - if o == "-t": - typename = a - if len(args) < 1: - sys.stderr.write("usage: login [-t BANK-ID] TYPE\n") + if len(args) < 2: + sys.stderr.write("usage: login BANK-ID TYPE [ARGS...]\n") sys.exit(1) - sess = data.getsessnam(typename).create() - if args[0] == "bankid": + sess = data.getsessnam(args[0]).create() + if args[1] == "bankid": authfun = sess.auth_bankid - elif args[0] == "token": + elif args[1] == "token": authfun = sess.auth_token else: - sys.stderr.write("netbank: %s: unknown authentication type\n" % (args[0])) + sys.stderr.write("netbank: %s: unknown authentication type\n" % (args[1])) sys.exit(1) - if len(args) < 2: + if len(args) < 3: sys.stderr.write("usage: login bankid USER-ID\n") sys.exit(1) with auth.ttyconv() as conv: try: - authfun(args[1], conv) + authfun(args[2], conv) except auth.autherror as err: sys.stderr.write("netbank: authentication failed: %s\n" % err) sys.exit(1) -- 2.11.0