From c296283d586f7100fb157ab0e7097d3614b8eca8 Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Thu, 28 Mar 2024 10:32:14 +0100 Subject: [PATCH] Implement decoupled TAN process --- docs/debits.rst | 7 +++++-- docs/transfers.rst | 7 +++++-- docs/trouble.rst | 20 +++++++++++-------- fints/client.py | 48 ++++++++++++++++++++++++++++++++++++++-------- 4 files changed, 62 insertions(+), 20 deletions(-) diff --git a/docs/debits.rst b/docs/debits.rst index 7829cea..f22f910 100644 --- a/docs/debits.rst +++ b/docs/debits.rst @@ -67,7 +67,7 @@ You can easily generate XML using the ``sepaxml`` python library: pain_descriptor='urn:iso:std:iso:20022:tech:xsd:pain.008.002.02' ) - if isinstance(res, NeedTANResponse): + while isinstance(res, NeedTANResponse): print("A TAN is required", res.challenge) if getattr(res, 'challenge_hhduc', None): @@ -76,7 +76,10 @@ You can easily generate XML using the ``sepaxml`` python library: except KeyboardInterrupt: pass - tan = input('Please enter TAN:') + if result.decoupled: + tan = input('Please press enter after confirming the transaction in your app:') + else: + tan = input('Please enter TAN:') res = client.send_tan(res, tan) print(res.status) diff --git a/docs/transfers.rst b/docs/transfers.rst index 28d6a0a..787a9ca 100644 --- a/docs/transfers.rst +++ b/docs/transfers.rst @@ -55,7 +55,7 @@ Full example endtoend_id='NOTPROVIDED', ) - if isinstance(res, NeedTANResponse): + while isinstance(res, NeedTANResponse): print("A TAN is required", res.challenge) if getattr(res, 'challenge_hhduc', None): @@ -64,7 +64,10 @@ Full example except KeyboardInterrupt: pass - tan = input('Please enter TAN:') + if result.decoupled: + tan = input('Please press enter after confirming the transaction in your app:') + else: + tan = input('Please enter TAN:') res = client.send_tan(res, tan) print(res.status) diff --git a/docs/trouble.rst b/docs/trouble.rst index 35a2247..f2ee010 100644 --- a/docs/trouble.rst +++ b/docs/trouble.rst @@ -44,8 +44,9 @@ the problem. getpass.getpass('PIN: '), 'REPLACEME' # ENDPOINT ) + product_id = 'REPLACEME' - f = FinTS3PinTanClient(*client_args) + f = FinTS3PinTanClient(*client_args, product_id=product_id) minimal_interactive_cli_bootstrap(f) @@ -57,19 +58,22 @@ the problem. terminal_flicker_unix(response.challenge_hhduc) except KeyboardInterrupt: pass - tan = input('Please enter TAN:') + if response.decoupled: + tan = input('Please press enter after confirming the transaction in your app:') + else: + tan = input('Please enter TAN:') return f.send_tan(response, tan) # Open the actual dialog with f: # Since PSD2, a TAN might be needed for dialog initialization. Let's check if there is one required - if f.init_tan_response: - ask_for_tan(f.init_tan_response) + while isinstance(f.init_tan_response, NeedTANResponse): + f.init_tan_response = ask_for_tan(f.init_tan_response) # Fetch accounts accounts = f.get_sepa_accounts() - if isinstance(accounts, NeedTANResponse): + while isinstance(accounts, NeedTANResponse): accounts = ask_for_tan(accounts) if len(accounts) == 1: account = accounts[0] @@ -85,7 +89,7 @@ the problem. client_data = f.deconstruct(including_private=True) - f = FinTS3PinTanClient(*client_args, from_data=client_data) + f = FinTS3PinTanClient(*client_args, product_id=product_id, from_data=client_data) with f.resume_dialog(dialog_data): while True: operations = [ @@ -167,7 +171,7 @@ the problem. endtoend_id='NOTPROVIDED', ) - if isinstance(res, NeedTANResponse): - ask_for_tan(res) + while isinstance(res, NeedTANResponse): + res = ask_for_tan(res) except FinTSUnsupportedOperation as e: print("This operation is not supported by this bank:", e) \ No newline at end of file diff --git a/fints/client.py b/fints/client.py index f262c72..b546682 100644 --- a/fints/client.py +++ b/fints/client.py @@ -1004,11 +1004,13 @@ class NeedTANResponse(NeedRetryResponse): challenge_html = None #: HTML-safe challenge text, possibly with formatting challenge_hhduc = None #: HHD_UC challenge to be transmitted to the TAN generator challenge_matrix = None #: Matrix code challenge: tuple(mime_type, data) + decoupled = None #: Use decoupled process - def __init__(self, command_seg, tan_request, resume_method=None, tan_request_structured=False): + def __init__(self, command_seg, tan_request, resume_method=None, tan_request_structured=False, decoupled=False): self.command_seg = command_seg self.tan_request = tan_request self.tan_request_structured = tan_request_structured + self.decoupled = decoupled if hasattr(resume_method, '__func__'): self.resume_method = resume_method.__func__.__name__ else: @@ -1206,10 +1208,10 @@ def _get_tan_segment(self, orig_seg, tan_process, tan_seg=None): if tan_process == '4' and tan_mechanism.VERSION >= 6: seg.segment_type = orig_seg.header.type - if tan_process in ('2', '3'): + if tan_process in ('2', '3', 'S'): seg.task_reference = tan_seg.task_reference - if tan_process in ('1', '2'): + if tan_process in ('1', '2', 'S'): seg.further_tan_follows = False return seg @@ -1236,8 +1238,14 @@ def _send_with_possible_retry(self, dialog, command_seg, resume_func): response = dialog.send(command_seg, tan_seg) for resp in response.responses(tan_seg): - if resp.code == '0030': - return NeedTANResponse(command_seg, response.find_segment_first('HITAN'), resume_func, self.is_challenge_structured()) + if resp.code in ('0030', '3955'): + return NeedTANResponse( + command_seg, + response.find_segment_first('HITAN'), + resume_func, + self.is_challenge_structured(), + resp.code == '3955', + ) if resp.code.startswith('9'): raise Exception("Error response: {!r}".format(response)) else: @@ -1255,17 +1263,41 @@ def send_tan(self, challenge: NeedTANResponse, tan: str): """ Sends a TAN to confirm a pending operation. + If ``NeedTANResponse.decoupled`` is ``True``, the ``tan`` parameter is ignored and can be kept empty. + If the operation was not yet confirmed using the decoupled app, this method will again return a + ``NeedTANResponse``. + :param challenge: NeedTANResponse to respond to :param tan: TAN value - :return: Currently no response + :return: New response after sending TAN """ with self._get_dialog() as dialog: - tan_seg = self._get_tan_segment(challenge.command_seg, '2', challenge.tan_request) - self._pending_tan = tan + if challenge.decoupled: + tan_seg = self._get_tan_segment(challenge.command_seg, 'S', challenge.tan_request) + else: + tan_seg = self._get_tan_segment(challenge.command_seg, '2', challenge.tan_request) + self._pending_tan = tan response = dialog.send(tan_seg) + if challenge.decoupled: + # TAN process = S + status_segment = response.find_segment_first('HITAN') + if not status_segment: + raise FinTSClientError( + "No TAN status received." + ) + for resp in response.responses(tan_seg): + if resp.code == '3956': + return NeedTANResponse( + challenge.command_seg, + challenge.tan_request, + challenge.resume_method, + challenge.tan_request_structured, + challenge.decoupled, + ) + resume_func = getattr(self, challenge.resume_method) return resume_func(challenge.command_seg, response)