From 18fa4960a7935ce69579e9befc8f61cbd91af7b4 Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Thu, 28 Mar 2024 12:43:38 +0100 Subject: [PATCH] Fix #155 -- Implement HKTAN7 and decoupled TAN process (#162) * Implement HKTAN7 * Implement decoupled TAN process --- docs/debits.rst | 7 ++++-- docs/transfers.rst | 7 ++++-- docs/trouble.rst | 20 ++++++++++------- fints/client.py | 51 ++++++++++++++++++++++++++++++++++-------- fints/formals.py | 30 +++++++++++++++++++++++++ fints/segments/auth.py | 37 +++++++++++++++++++++++++++++- 6 files changed, 130 insertions(+), 22 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 fde0a16..0931e54 100644 --- a/fints/client.py +++ b/fints/client.py @@ -26,7 +26,7 @@ PinTanTwoStepAuthenticationMechanism, ) from .segments.accounts import HISPA1, HKSPA1 -from .segments.auth import HIPINS1, HKTAB4, HKTAB5, HKTAN2, HKTAN3, HKTAN5, HKTAN6 +from .segments.auth import HIPINS1, HKTAB4, HKTAB5, HKTAN2, HKTAN3, HKTAN5, HKTAN6, HKTAN7 from .segments.bank import HIBPA3, HIUPA4, HKKOM4 from .segments.debit import ( HKDBS1, HKDBS2, HKDMB1, HKDMC1, HKDME1, HKDME2, @@ -1005,11 +1005,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: @@ -1111,6 +1113,7 @@ def _parse_tan_challenge(self): 3: HKTAN3, 5: HKTAN5, 6: HKTAN6, + 7: HKTAN7, } @@ -1220,10 +1223,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 @@ -1250,8 +1253,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: @@ -1269,17 +1278,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) diff --git a/fints/formals.py b/fints/formals.py index 315329c..5dab04f 100644 --- a/fints/formals.py +++ b/fints/formals.py @@ -397,6 +397,32 @@ class TwoStepParameters6(TwoStepParametersCommon): supported_media_number = DataElementField(type='num', length=1, required=False, _d="Anzahl unterstützter aktiver TAN-Medien") +class TwoStepParameters7(TwoStepParametersCommon): + zka_id = DataElementField(type='an', max_length=32, _d="DK TAN-Verfahren") + zka_version = DataElementField(type='an', max_length=10, _d="Version DK TAN-Verfahren") + name = DataElementField(type='an', max_length=30, _d="Name des Zwei-Schritt-Verfahrens") + max_length_input = DataElementField(type='num', max_length=2, _d="Maximale Länge des Eingabewertes im Zwei-Schritt-Verfahren") + allowed_format = CodeField(enum=AllowedFormat, length=1, _d="Erlaubtes Format im Zwei-Schritt-Verfahren") + text_return_value = DataElementField(type='an', max_length=30, _d="Text zur Belegung des Rückgabewertes im Zwei-Schritt-Verfahren") + max_length_return_value = DataElementField(type='num', max_length=4, _d="Maximale Länge des Rückgabewertes im Zwei-Schritt-Verfahren") + multiple_tans_allowed = DataElementField(type='jn', _d="Mehrfach-TAN erlaubt") + tan_time_dialog_association = CodeField(enum=TANTimeDialogAssociation, length=1, _d="TAN Zeit- und Dialogbezug") + cancel_allowed = DataElementField(type='jn', _d="Auftragsstorno erlaubt") + sms_charge_account_required = CodeField(enum=SMSChargeAccountRequired, length=1, _d="SMS-Abbuchungskonto erforderlich") + principal_account_required = CodeField(enum=PrincipalAccountRequired, length=1, _d="Auftraggeberkonto erforderlich") + challenge_class_required = DataElementField(type='jn', _d="Challenge-Klasse erforderlich") + challenge_structured = DataElementField(type='jn', _d="Challenge strukturiert") + initialization_mode = CodeField(enum=InitializationMode, _d="Initialisierungsmodus") + description_required = CodeField(enum=DescriptionRequired, length=1, _d="Bezeichnung des TAN-Medium erforderlich") + response_hhd_uc_required = DataElementField(type='jn', _d="Antwort HHD_UC erforderlich") + supported_media_number = DataElementField(type='num', length=1, required=False, _d="Anzahl unterstützter aktiver TAN-Medien") + decoupled_max_poll_number = DataElementField(type='num', max_length=3, required=False, _d="Maximale Anzahl Statusabfragen Decoupled") + wait_before_first_poll = DataElementField(type='num', max_length=3, required=False, _d="Wartezeit vor erster Statusabfrage") + wait_before_next_poll = DataElementField(type='num', max_length=3, required=False, _d="Wartezeit vor nächster Statusabfrage") + manual_confirmation_allowed = DataElementField(type='jn', required=False, _d="Manuelle Bestätigung möglich") + automated_polling_allowed = DataElementField(type='jn', required=False, _d="Automatische Statusabfragen erlaubt") + + class ParameterTwostepCommon(DataElementGroup): onestep_method_allowed = DataElementField(type='jn') multiple_tasks_allowed = DataElementField(type='jn') @@ -428,6 +454,10 @@ class ParameterTwostepTAN6(ParameterTwostepCommon): twostep_parameters = DataElementGroupField(type=TwoStepParameters6, min_count=1, max_count=98) +class ParameterTwostepTAN7(ParameterTwostepCommon): + twostep_parameters = DataElementGroupField(type=TwoStepParameters7, min_count=1, max_count=98) + + class TransactionTanRequired(DataElementGroup): transaction = DataElementField(type='an', max_length=6) tan_required = DataElementField(type='jn') diff --git a/fints/segments/auth.py b/fints/segments/auth.py index e23d9cd..57f665f 100644 --- a/fints/segments/auth.py +++ b/fints/segments/auth.py @@ -5,7 +5,7 @@ ParameterTwostepTAN2, ParameterTwostepTAN3, ParameterTwostepTAN4, ParameterTwostepTAN5, ParameterTwostepTAN6, ResponseHHDUC, SystemIDStatus, TANMedia4, TANMedia5, TANMediaClass3, - TANMediaClass4, TANMediaType2, TANUsageOption, + TANMediaClass4, TANMediaType2, TANUsageOption, ParameterTwostepTAN7, ) from .base import FinTS3Segment, ParameterSegment @@ -97,6 +97,24 @@ class HKTAN6(FinTS3Segment): response_hhd_uc = DataElementGroupField(type=ResponseHHDUC, required=False, _d="Antwort HHD_UC") +class HKTAN7(FinTS3Segment): + """Zwei-Schritt-TAN-Einreichung, version 7 + + Source: FinTS Financial Transaction Services, Schnittstellenspezifikation, Sicherheitsverfahren PIN/TAN""" + tan_process = DataElementField(type='code', length=1, _d="TAN-Prozess") + segment_type = DataElementField(type='an', max_length=6, required=False, _d="Segmentkennung") + account = DataElementGroupField(type=KTI1, required=False, _d="Kontoverbindung international Auftraggeber") + task_hash_value = DataElementField(type='bin', max_length=256, required=False, _d="Auftrags-Hashwert") + task_reference = DataElementField(type='an', max_length=35, required=False, _d="Auftragsreferenz") + further_tan_follows = DataElementField(type='jn', length=1, required=False, _d="Weitere TAN folgt") + cancel_task = DataElementField(type='jn', length=1, required=False, _d="Auftrag stornieren") + sms_charge_account = DataElementGroupField(type=KTI1, required=False, _d="SMS-Abbuchungskonto") + challenge_class = DataElementField(type='num', max_length=2, required=False, _d="Challenge-Klasse") + parameter_challenge_class = DataElementGroupField(type=ParameterChallengeClass, required=False, _d="Parameter Challenge-Klasse") + tan_medium_name = DataElementField(type='an', max_length=32, required=False, _d="Bezeichnung des TAN-Mediums") + response_hhd_uc = DataElementGroupField(type=ResponseHHDUC, required=False, _d="Antwort HHD_UC") + + class HITAN2(FinTS3Segment): """Zwei-Schritt-TAN-Einreichung Rückmeldung, version 2 @@ -152,6 +170,19 @@ class HITAN6(FinTS3Segment): tan_medium_name = DataElementField(type='an', max_length=32, required=False, _d="Bezeichnung des TAN-Mediums") +class HITAN7(FinTS3Segment): + """Zwei-Schritt-TAN-Einreichung Rückmeldung, version 7 + + Source: FinTS Financial Transaction Services, Schnittstellenspezifikation, Sicherheitsverfahren PIN/TAN""" + tan_process = DataElementField(type='code', length=1, _d="TAN-Prozess") + task_hash_value = DataElementField(type='bin', max_length=256, required=False, _d="Auftrags-Hashwert") + task_reference = DataElementField(type='an', max_length=35, required=False, _d="Auftragsreferenz") + challenge = DataElementField(type='an', max_length=2048, required=False, _d="Challenge") + challenge_hhduc = DataElementField(type='bin', required=False, _d="Challenge HHD_UC") + challenge_valid_until = DataElementGroupField(type=ChallengeValidUntil, required=False, _d="Gültigkeitsdatum und -uhrzeit für Challenge") + tan_medium_name = DataElementField(type='an', max_length=32, required=False, _d="Bezeichnung des TAN-Mediums") + + class HKTAB4(FinTS3Segment): """TAN-Generator/Liste anzeigen Bestand, version 4 @@ -216,6 +247,10 @@ class HITANS6(HITANSBase): parameter = DataElementGroupField(type=ParameterTwostepTAN6) +class HITANS7(HITANSBase): + parameter = DataElementGroupField(type=ParameterTwostepTAN7) + + class HIPINS1(ParameterSegment): """PIN/TAN-spezifische Informationen, version 1