Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/tested.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
11 changes: 10 additions & 1 deletion docs/transfers.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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:')
Expand Down
36 changes: 27 additions & 9 deletions fints/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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)

Expand Down
6 changes: 6 additions & 0 deletions fints/formals.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)


Expand Down
9 changes: 8 additions & 1 deletion fints/security.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
167 changes: 167 additions & 0 deletions sample_consorsbank.py
Original file line number Diff line number Diff line change
@@ -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=<your user id>
FINTS_PIN=<your PIN>
FINTS_SERVER=https://brokerage-hbci.consorsbank.de/hbci
FINTS_PRODUCT_ID=<your registered product id>
MY_IBAN=<IBAN of the account to use>
"""

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()