diff --git a/docs/tested.rst b/docs/tested.rst index 6ca09ce..d34f27d 100644 --- a/docs/tested.rst +++ b/docs/tested.rst @@ -11,11 +11,13 @@ Postbank Yes BBBank eG Yes Yes Sparkasse Heidelberg Yes comdirect Yes Yes +Consorsbank Yes Yes ======================================== ============ ======== ======== ====== Tested security functions ------------------------- +* ``900`` "photoTAN" / "Secure Plus" (QR code) * ``902`` "photoTAN" * ``921`` "pushTAN" * ``930`` "mobile TAN" diff --git a/docs/transfers.rst b/docs/transfers.rst index c2e20c2..8f9fec2 100644 --- a/docs/transfers.rst +++ b/docs/transfers.rst @@ -67,13 +67,22 @@ Full example if isinstance(res, NeedTANResponse): print("A TAN is required", res.challenge) + # photoTAN / QR code: save and display the image + if getattr(res, 'challenge_matrix', None): + mime_type, image_data = res.challenge_matrix + with open('tan_challenge.png', 'wb') as f: + f.write(image_data) + print(f"QR code saved to tan_challenge.png ({len(image_data)} bytes)") + # Optionally open the image automatically: + # import subprocess; subprocess.Popen(['open', 'tan_challenge.png']) + if getattr(res, 'challenge_hhduc', None): try: terminal_flicker_unix(res.challenge_hhduc) except KeyboardInterrupt: pass - if result.decoupled: + if res.decoupled: tan = input('Please press enter after confirming the transaction in your app:') else: tan = input('Please enter TAN:') diff --git a/fints/client.py b/fints/client.py index 7e0898a..bd8cf6e 100644 --- a/fints/client.py +++ b/fints/client.py @@ -1253,13 +1253,15 @@ def _parse_tan_challenge(self): class FinTS3PinTanClient(FinTS3Client): - def __init__(self, bank_identifier, user_id, pin, server, customer_id=None, tan_medium=None, *args, **kwargs): + def __init__(self, bank_identifier, user_id, pin, server, customer_id=None, tan_medium=None, + force_twostep_tan=None, *args, **kwargs): self.pin = Password(pin) if pin is not None else pin self._pending_tan = None self.connection = FinTSHTTPSConnection(server) self.allowed_security_functions = [] self.selected_security_function = None self.selected_tan_medium = tan_medium + self.force_twostep_tan = set(force_twostep_tan) if force_twostep_tan else set() self._bootstrap_mode = True super().__init__(bank_identifier=bank_identifier, user_id=user_id, customer_id=customer_id, *args, **kwargs) @@ -1394,14 +1396,16 @@ def _find_vop_format_for_segment(self, seg): def _need_twostep_tan_for_segment(self, seg): if not self.selected_security_function or self.selected_security_function == '999': return False - else: - hipins = self.bpd.find_segment_first(HIPINS1) - if not hipins: - return False - else: - for requirement in hipins.parameter.transaction_tans_required: - if seg.header.type == requirement.transaction: - return requirement.tan_required + + if seg.header.type in self.force_twostep_tan: + return True + + hipins = self.bpd.find_segment_first(HIPINS1) + if not hipins: + return False + for requirement in hipins.parameter.transaction_tans_required: + if seg.header.type == requirement.transaction: + return requirement.tan_required return False @@ -1483,6 +1487,20 @@ def _send_pay_with_possible_retry(self, dialog, command_seg, resume_func): ) if resp.code.startswith('9'): raise Exception("Error response: {!r}".format(response)) + + # Some banks (e.g. Consorsbank) attach the 0030 TAN-required + # response to the command segment (HKCCS) rather than the + # HKTAN segment. Check command_seg responses as fallback. + for resp in response.responses(command_seg): + if resp.code in ('0030', '3955'): + return NeedTANResponse( + command_seg, + response.find_segment_first('HITAN'), + resume_func, + self.is_challenge_structured(), + resp.code == '3955', + hivpp, + ) else: response = dialog.send(command_seg) diff --git a/fints/formals.py b/fints/formals.py index 2d9d907..3afe8ca 100644 --- a/fints/formals.py +++ b/fints/formals.py @@ -543,6 +543,12 @@ def from_sepa_account(cls, acc): return cls( iban=acc.iban, bic=acc.bic, + account_number=acc.accountnumber, + subaccount_number=acc.subaccount, + bank_identifier=BankIdentifier( + country_identifier=BankIdentifier.COUNTRY_ALPHA_TO_NUMERIC[acc.bic[4:6]], + bank_code=acc.blz + ) if acc.blz else None, ) diff --git a/fints/security.py b/fints/security.py index e9f14e1..f65b5a8 100644 --- a/fints/security.py +++ b/fints/security.py @@ -104,8 +104,15 @@ def sign_prepare(self, message: FinTSMessage): _now = datetime.datetime.now() rand = random.SystemRandom() + # Per ZKA FinTS spec, two-step TAN methods (security_function != '999') + # require security_method_version=2 in the SecurityProfile. + if self.security_function and self.security_function != '999': + security_method_version = 2 + else: + security_method_version = 1 + self.pending_signature = HNSHK4( - security_profile=SecurityProfile(SecurityMethod.PIN, 1), + security_profile=SecurityProfile(SecurityMethod.PIN, security_method_version), security_function=self.security_function, security_reference=rand.randint(1000000, 9999999), security_application_area=SecurityApplicationArea.SHM, diff --git a/sample_consorsbank.py b/sample_consorsbank.py new file mode 100644 index 0000000..7a779f5 --- /dev/null +++ b/sample_consorsbank.py @@ -0,0 +1,167 @@ +#!/usr/bin/env python3 +""" +Sample: Consorsbank (BLZ 76030080) with python-fints. + +Demonstrates fetching transactions and making SEPA transfers with +photoTAN (QR code) authentication. + +Consorsbank requires three compatibility fixes (see PR #209): + 1. security_method_version=2 for two-step TAN + 2. Full account details in KTI1.from_sepa_account + 3. force_twostep_tan for segments the bank requires TAN on + despite HIPINS reporting otherwise + +Additionally, Consorsbank attaches the TAN-required response (0030) +to the command segment (HKCCS) rather than the HKTAN segment, which +is handled by Fix 4 in this branch. + +Usage: + pip install python-fints python-dotenv + python sample_consorsbank.py + +Environment variables (or .env file): + FINTS_BLZ=76030080 + FINTS_USER= + FINTS_PIN= + FINTS_SERVER=https://brokerage-hbci.consorsbank.de/hbci + FINTS_PRODUCT_ID= + MY_IBAN= +""" + +import os +import sys +import logging +import subprocess +from datetime import date, timedelta +from decimal import Decimal + +from fints.client import FinTS3PinTanClient, NeedTANResponse + +logging.basicConfig(level=logging.WARNING) + +try: + from dotenv import load_dotenv + load_dotenv() +except ImportError: + pass + + +def handle_tan(response, client): + """Handle TAN challenges including photoTAN with QR code image.""" + while isinstance(response, NeedTANResponse): + print(f"\nTAN required: {response.challenge}") + + # photoTAN / QR code image + if response.challenge_matrix: + mime_type, image_data = response.challenge_matrix + ext = ".png" if "png" in mime_type else ".jpg" + img_path = f"tan_challenge{ext}" + with open(img_path, "wb") as f: + f.write(image_data) + print(f" QR code saved to {img_path} ({len(image_data)} bytes)") + # On macOS: subprocess.Popen(["open", img_path]) + # On Linux: subprocess.Popen(["xdg-open", img_path]) + tan = input("Scan the QR code and enter TAN: ") + + # Flicker / HHD UC challenge + elif response.challenge_hhduc: + print(f" HHD UC data available") + tan = input("Enter TAN: ") + + # Decoupled (app confirmation) + elif response.decoupled: + input("Confirm in your banking app, then press ENTER: ") + tan = "" + + # Manual TAN entry + else: + tan = input("Enter TAN: ") + + response = client.send_tan(response, tan) + return response + + +def main(): + blz = os.environ.get("FINTS_BLZ", "76030080") + user = os.environ["FINTS_USER"] + pin = os.environ["FINTS_PIN"] + server = os.environ.get("FINTS_SERVER", "https://brokerage-hbci.consorsbank.de/hbci") + product_id = os.environ.get("FINTS_PRODUCT_ID") + my_iban = os.environ.get("MY_IBAN") + + client = FinTS3PinTanClient( + bank_identifier=blz, + user_id=user, + pin=pin, + server=server, + product_id=product_id, + # Consorsbank reports HKKAZ:N and HKSAL:N in HIPINS but actually + # requires TAN for these operations. HKCCS always requires TAN. + force_twostep_tan={"HKKAZ", "HKSAL"}, + ) + + # Select photoTAN mechanism (Consorsbank uses 900) + if not client.get_current_tan_mechanism(): + client.fetch_tan_mechanisms() + client.set_tan_mechanism("900") + + with client: + if client.init_tan_response: + handle_tan(client.init_tan_response, client) + + # --- Fetch accounts --- + accounts = client.get_sepa_accounts() + if isinstance(accounts, NeedTANResponse): + accounts = handle_tan(accounts, client) + + print("Accounts:") + for a in accounts: + print(f" {a.iban} (BIC: {a.bic})") + + # Select account + if my_iban: + account = next((a for a in accounts if a.iban == my_iban), None) + if not account: + print(f"Account {my_iban} not found") + return + else: + account = accounts[0] + + print(f"\nUsing account: {account.iban}") + + # --- Fetch transactions --- + print("\nFetching transactions (last 30 days)...") + start_date = date.today() - timedelta(days=30) + res = client.get_transactions(account, start_date=start_date) + if isinstance(res, NeedTANResponse): + res = handle_tan(res, client) + + if res: + print(f"Found {len(res)} transactions:") + for t in res[-5:]: # show last 5 + d = t.data + amt = d.get("amount") + amount_str = f"{amt.amount:>10.2f} {amt.currency}" if amt else "" + print(f" {d.get('date')} {amount_str} {d.get('applicant_name', '')}") + else: + print("No transactions found.") + + # --- SEPA Transfer (uncomment to use) --- + # res = client.simple_sepa_transfer( + # account=account, + # iban="DE89370400440532013000", + # bic="COBADEFFXXX", + # recipient_name="Max Mustermann", + # amount=Decimal("1.00"), + # account_name="Your Name", + # reason="Test transfer", + # ) + # if isinstance(res, NeedTANResponse): + # res = handle_tan(res, client) + # print(f"Transfer result: {res.status} {res.responses}") + + print("\nDone!") + + +if __name__ == "__main__": + main()