Skip to content

Commit ee53847

Browse files
authored
Merge pull request #47 from MoritzR/support-tan
TAN authentication
2 parents f249ef9 + ae0dc8c commit ee53847

5 files changed

Lines changed: 435 additions & 70 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ The configuration file is located at `~/.config/fints2ledger/config.yml` (XDG co
108108
| `--files-path PATH` | Directory where fints2ledger stores its config files | — |
109109
| `-f`, `--journal-file FILE` | Path to the ledger journal file | `ledger.journalFile` |
110110
| `--date DATE` | Start date for fetching transactions (e.g. `25.01.2023`, `90 days ago`, `last monday`). Default: 90 days ago | — |
111-
| `--python-command CMD` | Python executable to use. Default: `python3` | — |
111+
| `--python-command PATH` | Python executable to use. Default: `python3` | — |
112112
| `--demo` | Run with sample transactions, without calling a FinTS endpoint | — |
113113
| `--config` | Open the config editor UI | — |
114114
| `--from-csv-file FILE` | Read transactions from a CSV file instead of a FinTS endpoint | — |

data/pyfints.py

Lines changed: 211 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,74 +1,238 @@
1-
from fints.client import FinTS3PinTanClient
1+
from fints.client import FinTS3PinTanClient, NeedTANResponse
22
from mt940.models import Date
3-
import os
3+
import base64
4+
import hashlib
45
import json
6+
import os
7+
import sys
8+
import tempfile
59

6-
def retrieve_transactions(
7-
account, blz, password, endpoint, selected_account, start, end
8-
):
9-
client = FinTS3PinTanClient(blz, account, password, endpoint, product_id = "EC449295201FA9BE5040B9154")
10-
return TRetriever(client, selected_account).get_hbci_transactions(start, end)
10+
SIMULATED_TAN_METHODS = [
11+
{"id": "900", "name": "Simulated pushTAN"},
12+
{"id": "901", "name": "Simulated photoTAN"},
13+
]
14+
SIMULATED_PHOTOTAN_PNG = base64.b64decode(
15+
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
16+
)
1117

1218

13-
class TRetriever:
14-
def __init__(self, client, accountnumber):
15-
self.client = client
16-
self.accountnumber = accountnumber
19+
def send(message_type, **values):
20+
print(json.dumps({"type": message_type, **values}), flush=True)
1721

18-
def get_hbci_transactions(self, start_date, end_date):
19-
accounts = self.client.get_sepa_accounts()
2022

21-
account = self.__find_matching_account(accounts, self.accountnumber)
23+
def receive(expected_type):
24+
line = sys.stdin.readline()
25+
if not line:
26+
raise RuntimeError("The fints2ledger process closed the protocol input")
27+
message = json.loads(line)
28+
if message.get("type") != expected_type:
29+
raise RuntimeError(
30+
f"Expected protocol message '{expected_type}', got {message.get('type')!r}"
31+
)
32+
return message
33+
34+
35+
def state_path(args):
36+
identity = "\0".join((args["endpoint"], args["blz"], args["account"]))
37+
digest = hashlib.sha256(identity.encode()).hexdigest()[:24]
38+
return os.path.join(args["stateDirectory"], f"fints-state-{digest}.bin")
39+
40+
41+
def load_state(path):
42+
try:
43+
with open(path, "rb") as reader:
44+
return reader.read()
45+
except FileNotFoundError:
46+
return None
47+
48+
49+
def store_state(path, data):
50+
os.makedirs(os.path.dirname(path), mode=0o700, exist_ok=True)
51+
fd, temporary_path = tempfile.mkstemp(dir=os.path.dirname(path))
52+
try:
53+
os.fchmod(fd, 0o600)
54+
with os.fdopen(fd, "wb") as writer:
55+
writer.write(data)
56+
os.replace(temporary_path, path)
57+
os.chmod(path, 0o600)
58+
except BaseException:
59+
try:
60+
os.unlink(temporary_path)
61+
except FileNotFoundError:
62+
pass
63+
raise
64+
65+
66+
def choose_tan_settings(client):
67+
if os.environ.get("FINTS2LEDGER_SIMULATE_TAN") == "1":
68+
send("tan_methods", methods=SIMULATED_TAN_METHODS)
69+
selected = receive("tan_method")["id"]
70+
if selected not in {method["id"] for method in SIMULATED_TAN_METHODS}:
71+
raise RuntimeError(f"Unknown simulated TAN method selected: {selected!r}")
72+
if selected == "901":
73+
send(
74+
"tan_challenge",
75+
challenge="Simulated photoTAN challenge",
76+
decoupled=False,
77+
hhduc=None,
78+
matrix={
79+
"mimeType": "image/png",
80+
"data": base64.b16encode(SIMULATED_PHOTOTAN_PNG).decode("ascii"),
81+
"base64Data": base64.b64encode(SIMULATED_PHOTOTAN_PNG).decode(
82+
"ascii"
83+
),
84+
},
85+
)
86+
receive("tan")
87+
# The simulated method is deliberately not passed to pyfints: the bank
88+
# did not advertise it and would reject it. Continue as a TAN-less bank.
89+
return
90+
91+
if not client.get_current_tan_mechanism():
92+
client.fetch_tan_mechanisms()
93+
mechanisms = list(client.get_tan_mechanisms().items())
94+
if not mechanisms:
95+
# TAN-less banks do not advertise any mechanisms. There is no
96+
# mechanism or medium to select, so continue with the dialog.
97+
return
98+
if len(mechanisms) == 1:
99+
client.set_tan_mechanism(mechanisms[0][0])
100+
else:
101+
send(
102+
"tan_methods",
103+
methods=[
104+
{"id": identifier, "name": mechanism.name}
105+
for identifier, mechanism in mechanisms
106+
],
107+
)
108+
selected = receive("tan_method")["id"]
109+
if selected not in dict(mechanisms):
110+
raise RuntimeError(f"Unknown TAN method selected: {selected!r}")
111+
client.set_tan_mechanism(selected)
112+
113+
if client.selected_tan_medium is None and client.is_tan_media_required():
114+
media = client.get_tan_media()[1]
115+
if len(media) == 1:
116+
client.set_tan_medium(media[0])
117+
elif len(media) == 0:
118+
# Some banks demand a medium field but return no selectable media.
119+
client.selected_tan_medium = ""
120+
else:
121+
send(
122+
"tan_media",
123+
media=[
124+
{
125+
"index": index,
126+
"name": medium.tan_medium_name,
127+
"mobile": medium.mobile_number_masked,
128+
}
129+
for index, medium in enumerate(media)
130+
],
131+
)
132+
selected = receive("tan_medium")["index"]
133+
if not isinstance(selected, int) or not 0 <= selected < len(media):
134+
raise RuntimeError(f"Unknown TAN medium selected: {selected!r}")
135+
client.set_tan_medium(media[selected])
136+
137+
138+
def send_tan(client, response):
139+
while isinstance(response, NeedTANResponse):
140+
matrix = None
141+
if response.challenge_matrix:
142+
mime_type, image_data = response.challenge_matrix
143+
matrix = {
144+
"mimeType": mime_type,
145+
"data": base64.b16encode(image_data).decode("ascii"),
146+
"base64Data": base64.b64encode(image_data).decode("ascii"),
147+
}
148+
149+
send(
150+
"tan_challenge",
151+
challenge=response.challenge or "A TAN is required",
152+
decoupled=bool(response.decoupled),
153+
hhduc=response.challenge_hhduc,
154+
matrix=matrix,
155+
)
156+
tan = receive("tan")["value"]
157+
if not isinstance(tan, str):
158+
raise RuntimeError("The TAN must be a string")
159+
response = client.send_tan(response, tan)
160+
return response
22161

162+
163+
class TransactionRetriever:
164+
def __init__(self, client, account_number):
165+
self.client = client
166+
self.account_number = account_number
167+
168+
def get_transactions(self, start_date, end_date):
169+
accounts = self.client.get_sepa_accounts()
170+
account = self.find_matching_account(accounts)
23171
return self.client.get_transactions(account, start_date, end_date)
24172

25-
def __find_matching_account(self, accounts, accountnumber):
173+
def find_matching_account(self, accounts):
26174
for account in accounts:
27-
if account.accountnumber == accountnumber:
175+
if account.accountnumber == self.account_number:
28176
return account
29-
raise Exception(
30-
f"Could not find a matching account for account number '{accountnumber}'. Possible accounts: {accounts}"
177+
raise RuntimeError(
178+
f"Could not find account '{self.account_number}'. Possible accounts: {accounts}"
31179
)
32180

33-
date_format="%Y/%m/%d"
181+
182+
DATE_FORMAT = "%Y/%m/%d"
183+
184+
34185
def transaction_to_object(transaction):
35186
hbci_data = transaction.data
36-
37-
date = hbci_data["date"].strftime(date_format)
38-
amount = str(hbci_data["amount"].amount)
39-
currency = hbci_data["amount"].currency
40-
# GLS bank provides no "posting_text", "AdditionalEntryInformation" seems to be equivalent
41-
posting_text = hbci_data.get("posting_text", hbci_data.get("AdditionalEntryInformation", None))
42-
applicant_name = hbci_data["applicant_name"]
43-
purpose = hbci_data["purpose"]
187+
posting_text = hbci_data.get(
188+
"posting_text", hbci_data.get("AdditionalEntryInformation")
189+
)
44190
return {
45-
"date": date,
46-
"amount": amount,
47-
"currency": currency,
191+
"date": hbci_data["date"].strftime(DATE_FORMAT),
192+
"amount": str(hbci_data["amount"].amount),
193+
"currency": hbci_data["amount"].currency,
48194
"posting": (posting_text or "").strip(),
49-
"payee": (applicant_name or "").strip(),
50-
"purpose": (purpose or "").strip(),
195+
"payee": (hbci_data["applicant_name"] or "").strip(),
196+
"purpose": (hbci_data["purpose"] or "").strip(),
51197
}
52198

53199

54-
def main():
55-
args = json.loads(os.environ["FINTS2LEDGER_ARGS"])
56-
transactions = retrieve_transactions(
57-
account=args["account"],
58-
blz=args["blz"],
59-
password=args["password"],
60-
endpoint=args["endpoint"],
61-
selected_account=args["selectedAccount"],
62-
start=date_string_to_mt940_date(args["start"]),
63-
end=date_string_to_mt940_date(args["end"]),
200+
def date_string_to_mt940_date(date_string):
201+
year, month, day = date_string.split("/")
202+
return Date(year=year, month=month, day=day)
203+
204+
205+
def run(args):
206+
path = state_path(args)
207+
client = FinTS3PinTanClient(
208+
args["blz"],
209+
args["account"],
210+
args["password"],
211+
args["endpoint"],
212+
product_id="EC449295201FA9BE5040B9154",
213+
from_data=load_state(path),
64214
)
65-
converted = json.dumps(list(map(transaction_to_object, transactions)))
66-
print(converted)
215+
choose_tan_settings(client)
67216

217+
with client:
218+
client.init_tan_response = send_tan(client, client.init_tan_response)
219+
result = TransactionRetriever(client, args["selectedAccount"]).get_transactions(
220+
date_string_to_mt940_date(args["start"]),
221+
date_string_to_mt940_date(args["end"]),
222+
)
223+
result = send_tan(client, result)
224+
transactions = [transaction_to_object(transaction) for transaction in result]
68225

69-
def date_string_to_mt940_date(date_string):
70-
parts = date_string.split("/")
71-
return Date(year=parts[0], month=parts[1], day=parts[2])
226+
store_state(path, client.deconstruct(including_private=True))
227+
send("transactions", transactions=transactions)
228+
229+
230+
def main():
231+
try:
232+
run(receive("start")["arguments"])
233+
except Exception as exception:
234+
send("error", message=str(exception))
235+
raise
72236

73237

74238
if __name__ == "__main__":

0 commit comments

Comments
 (0)