From: Henryk Plötz Date: Thu, 23 Aug 2018 23:33:35 +0000 (+0200) Subject: Allow pausing and resuming of dialogs X-Git-Tag: v2.0.0~1^2~87 X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=8e752bb26baf7fe077b9c253c49b1d4b7acd7387;p=thirdparty%2Fpython-fints.git Allow pausing and resuming of dialogs --- diff --git a/fints/client.py b/fints/client.py index f0fb811..53b3a0a 100644 --- a/fints/client.py +++ b/fints/client.py @@ -1,9 +1,7 @@ import datetime import logging -import base64 -import zlib -import json from decimal import Decimal +from contextlib import contextmanager from collections import OrderedDict from fints.segments.debit import HKDME, HKDSE @@ -35,7 +33,7 @@ from .segments.saldo import HKSAL5, HKSAL6, HKSAL7 from .segments.statement import HKKAZ5, HKKAZ6, HKKAZ7 from .segments.transfer import HKCCM, HKCCS from .types import SegmentSequence -from .utils import MT535_Miniparser, Password, mt940_to_array +from .utils import MT535_Miniparser, Password, mt940_to_array, compress_datablob, decompress_datablob from .parser import FinTS3Serializer logger = logging.getLogger(__name__) @@ -106,25 +104,7 @@ class FinTS3Client: return self._new_dialog(lazy_init=lazy_init) - @staticmethod - def _compress_data_v1(data): - data = dict(data) - for k, v in data.items(): - if k.endswith("_bin"): - if v: - data[k] = base64.b64encode(v).decode("us-ascii") - serialized = json.dumps(data).encode('utf-8') - compressed = zlib.compress(serialized, 9) - return DATA_BLOB_MAGIC + b';1;' + compressed - - def _set_data_v1(self, blob): - decompressed = zlib.decompress(blob) - data = json.loads(decompressed.decode('utf-8')) - for k, v in data.items(): - if k.endswith("_bin"): - if v: - data[k] = base64.b64decode(v.encode('us-ascii')) - + def _set_data_v1(self, data): self.system_id = data.get('system_id', self.system_id) if all(x in data for x in ('bpd_bin', 'bpa_bin', 'bpd_version')): @@ -161,24 +141,11 @@ class FinTS3Client: "allowed_security_functions": self.allowed_security_functions, }) - return self._compress_data_v1(data) + return compress_datablob(DATA_BLOB_MAGIC, 1, data) def set_data(self, blob): # FIXME Test, document - if not blob.startswith(DATA_BLOB_MAGIC): - raise ValueError("Incorrect data blob") - s = blob.split(b';', 2) - if len(s) != 3: - raise ValueError("Incorrect data blob") - if not s[1].isdigit(): - raise ValueError("Incorrect data blob") - version = int(s[1].decode('us-ascii'), 10) - - setfunc = getattr(self, "_set_data_v{}".format(version), None) - if not setfunc: - raise ValueError("Unknown data blob version") - - setfunc(s[2]) + decompress_datablob(DATA_BLOB_MAGIC, self, blob) def process_institute_response(self, message): bpa = message.find_segment_first(HIBPA3) @@ -591,6 +558,57 @@ class FinTS3Client: for resp in response.response_segments(seg, 'HITAB'): return resp.tan_usage_option, list(resp.tan_media_list) + def pause_dialog(self): + """Pause a standing dialog and return the saved dialog state. + + Sometimes, for example in a web app, it's not possible to keep a context open + during user input. In some cases, though, it's required to send a response + within the same dialog that issued the original task (f.e. TAN with TANTimeDialogAssociation.NOT_ALLOWED). + This method freezes the current standing dialog (started with FinTS3Client.__enter__()) and + returns the frozen state. + + Commands MUST NOT be issued in the dialog after calling this method. + + MUST be used in conjunction with get_data()/set_data(). + + Caller SHOULD ensure that the dialog is resumed (and properly ended) within a reasonable amount of time. + + :Example: + client = FinTS3PinTanClient(..., set_data=None) + with client: + challenge = client.start_sepa_transfer(...) + + dialog_data = client.pause_dialog() + + # dialog is now frozen, no new commands may be issued + # exiting the context does not end the dialog + + client_data = client.get_data() + + # Store dialog_data and client_data out-of-band somewhere + # ... Some time passes ... + # Later, possibly in a different process, restore the state + + client = FinTS3PinTanClient(..., set_data=client_data) + with client.resume_dialog(dialog_data): + client.send_tan(...) + + # Exiting the context here ends the dialog, unless frozen with pause_dialog() again. + """ + if not self._standing_dialog: + raise Error("Cannot pause dialog, no standing dialog exists") + return self._standing_dialog.pause() + + @contextmanager + def resume_dialog(self, dialog_data): + # FIXME document, test, NOTE NO UNTRUSTED SOURCES + if self._standing_dialog: + raise Error("Cannot resume dialog, existing standing dialog") + self._standing_dialog = FinTSDialog.create_resume(self, dialog_data) + with self._standing_dialog: + yield self + self._standing_dialog = None + class FinTS3PinTanClient(FinTS3Client): diff --git a/fints/dialog.py b/fints/dialog.py index 80bc025..b528bb4 100644 --- a/fints/dialog.py +++ b/fints/dialog.py @@ -1,4 +1,6 @@ import logging +import pickle +import io from .formals import ( BankIdentifier, Language2, SynchronisationMode, SystemIDStatus, @@ -9,10 +11,12 @@ from .message import ( from .segments.auth import HKIDN, HKIDN2, HKSYN, HKVVB, HKVVB3 from .segments.dialog import HKEND, HKEND1 from .segments.message import HNHBK3, HNHBS1 +from .utils import compress_datablob, decompress_datablob logger = logging.getLogger(__name__) DIALOGUE_ID_UNASSIGNED = '0' +DATA_BLOB_MAGIC = b'python-fints_DIALOG_DATABLOB' class FinTSDialogError(Exception): pass @@ -29,6 +33,7 @@ class FinTSDialog: self.need_init = True self.lazy_init = lazy_init self.dialogue_id = DIALOGUE_ID_UNASSIGNED + self.paused = False self._context_count = 0 def __enter__(self): @@ -40,10 +45,14 @@ class FinTSDialog: def __exit__(self, exc_type, exc_value, traceback): self._context_count -= 1 - if self._context_count == 0: - self.end() + if not self.paused: + if self._context_count == 0: + self.end() def init(self, *extra_segments): + if self.paused: + raise Error("Cannot init() a paused dialog") + if self.need_init and not self.open: segments = [ HKIDN2( @@ -75,11 +84,17 @@ class FinTSDialog: self.lazy_init = False def end(self): + if self.paused: + raise Error("Cannot end() on a paused dialog") + if self.open: self.send(HKEND1(self.dialogue_id)) self.open = False def send(self, *segments): + if self.paused: + raise Error("Cannot send() on a paused dialog") + if not self.open: if self.lazy_init and self.need_init: self.init() @@ -120,6 +135,9 @@ class FinTSDialog: return response def new_customer_message(self): + if self.paused: + raise Error("Cannot call new_customer_message() on a paused dialog") + message = FinTSCustomerMessage(self) message += HNHBK3(0, 300, self.dialogue_id, self.next_message_number[message.DIRECTION]) @@ -129,6 +147,9 @@ class FinTSDialog: return message def finish_message(self, message): + if self.paused: + raise Error("Cannot call finish_message() on a paused dialog") + # Create signature(s) in reverse order: from inner to outer for auth_mech in reversed(self.auth_mechanisms): auth_mech.sign_commit(message) @@ -140,6 +161,64 @@ class FinTSDialog: message.segments[0].message_size = len(message.render_bytes()) + def pause(self): + # FIXME Document, test + if self.paused: + raise Error("Cannot pause a paused dialog") + + external_dialog = self + external_client = self.client + class SmartPickler(pickle.Pickler): + def persistent_id(self, obj): + if obj is external_dialog: + return "dialog" + if obj is external_client: + return "client" + return None + + pickle_out = io.BytesIO() + SmartPickler(pickle_out, protocol=4).dump({ + k: getattr(self, k) for k in [ + 'next_message_number', + 'messages', + 'auth_mechanisms', + 'enc_mechanism', + 'open', + 'need_init', + 'lazy_init', + 'dialogue_id', + ] + }) + + data_pickled = pickle_out.getvalue() + + self.paused = True + + return compress_datablob(DATA_BLOB_MAGIC, 1, {'data_bin': data_pickled}) + + @classmethod + def create_resume(cls, client, blob): + retval = cls(client=client) + decompress_datablob(DATA_BLOB_MAGIC, retval, blob) + return retval + + def _set_data_v1(self, data): + external_dialog = self + external_client = self.client + class SmartUnpickler(pickle.Unpickler): + def persistent_load(self, pid): + if pid == 'dialog': + return external_dialog + if pid == 'client': + return external_client + raise pickle.UnpicklingError("unsupported persistent object") + + pickle_in = io.BytesIO(data['data_bin']) + data_unpickled = SmartUnpickler(pickle_in).load() + + for k, v in data_unpickled.items(): + setattr(self, k, v) + class FinTSDialogOLD: def __init__(self, blz, username, pin, systemid, connection): diff --git a/fints/utils.py b/fints/utils.py index 20a370e..2a0fe39 100644 --- a/fints/utils.py +++ b/fints/utils.py @@ -1,5 +1,8 @@ import inspect import re +import base64 +import json +import zlib from contextlib import contextmanager from datetime import datetime from enum import Enum @@ -57,6 +60,45 @@ def fints_unescape(content): return content.replace('??', '?').replace("?'", "'").replace('?+', '+').replace('?:', ':').replace('?@', '@') +def compress_datablob(magic: bytes, version: int, data: dict): + data = dict(data) + for k, v in data.items(): + if k.endswith("_bin"): + if v: + data[k] = base64.b64encode(v).decode("us-ascii") + serialized = json.dumps(data).encode('utf-8') + compressed = zlib.compress(serialized, 9) + return b';'.join([magic, b'1', str(version).encode('us-ascii'), compressed]) + + +def decompress_datablob(magic: bytes, obj: object, blob: bytes): + if not blob.startswith(magic): + raise ValueError("Incorrect data blob") + s = blob.split(b';', 3) + if len(s) != 4: + raise ValueError("Incorrect data blob") + if not s[1].isdigit() or not s[2].isdigit(): + raise ValueError("Incorrect data blob") + encoding_version = int(s[1].decode('us-ascii'), 10) + blob_version = int(s[2].decode('us-ascii'), 10) + + if encoding_version != 1: + raise ValueError("Unsupported encoding version {}".format(encoding_version)) + + setfunc = getattr(obj, "_set_data_v{}".format(blob_version), None) + if not setfunc: + raise ValueError("Unknown data blob version") + + decompressed = zlib.decompress(s[3]) + data = json.loads(decompressed.decode('utf-8')) + for k, v in data.items(): + if k.endswith("_bin"): + if v: + data[k] = base64.b64decode(v.encode('us-ascii')) + + setfunc(data) + + class SubclassesMixin: @classmethod def _all_subclasses(cls):