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
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__)
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')):
"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)
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):
import logging
+import pickle
+import io
from .formals import (
BankIdentifier, Language2, SynchronisationMode, SystemIDStatus,
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
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):
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(
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()
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])
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)
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):
import inspect
import re
+import base64
+import json
+import zlib
from contextlib import contextmanager
from datetime import datetime
from enum import Enum
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):