Skip to content

Commit

Permalink
Enable import from bank account if TAN is required after login
Browse files Browse the repository at this point in the history
  • Loading branch information
FestplattenSchnitzel committed Sep 29, 2024
1 parent c9af60e commit 751f2ae
Show file tree
Hide file tree
Showing 6 changed files with 192 additions and 6 deletions.
134 changes: 130 additions & 4 deletions web/blueprints/finance/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,8 @@
BankAccountActivityReadForm,
BankAccountActivitiesImportManualForm,
ConfirmPaymentReminderMail,
FinTSClientForm,
FinTSTANForm,
)
from web.blueprints.finance.tables import (
FinanceTable,
Expand Down Expand Up @@ -175,7 +177,9 @@ def actions(bank_account: BankAccount) -> list[BtnColResponse]:
icon="fa-eye",
),
BtnColResponse(
href=url_for(".bank_accounts_import", bank_account_id=bank_account.id),
href=url_for(
".bank_accounts_login", bank_account_id=bank_account.id, action="import"
),
title="",
btn_class="btn-primary btn-sm",
icon="fa-file-import",
Expand Down Expand Up @@ -233,6 +237,69 @@ def actions(activity_id: int) -> list[BtnColResponse]:
).model_dump()


from fints.client import FinTS3PinTanClient, NeedTANResponse, NeedRetryResponse
from base64 import b64encode, b64decode
from itsdangerous import Signer
from flask import current_app


# Move to lib?
def b64_sign(data: bytes) -> str:
s = Signer(current_app.secret_key)
return s.sign(b64encode(data)).decode("utf-8")


@bp.route("/bank-accounts/<int:bank_account_id>/login/<action>", methods=["GET", "POST"])
def bank_accounts_login(bank_account_id: int, action: str) -> ResponseReturnValue:
form = FinTSTANForm()

if not form.is_submitted():
# TODO: Display bank name
del form.tan
return render_template("finance/fints_login.html", form=form)

bank_account = session.get(BankAccount, bank_account_id)

client = FinTS3PinTanClient(
bank_account.routing_number,
form.user.data,
form.secret_pin.data,
bank_account.fints_endpoint,
product_id=config.fints_product_id,
)
with client:
mechanisms = client.get_tan_mechanisms()

if "913" in mechanisms:
client.set_tan_mechanism("913") # QR-Code
elif "910" in mechanisms:
client.set_tan_mechanism("910") # Manual
else:
raise ValueError("We don't like any of the mechanisms.")

with client:
if client.init_tan_response:
challenge: NeedTANResponse = client.init_tan_response
qrcode = "data:image/png;base64," + b64encode(challenge.challenge_matrix[1]).decode(
"ascii"
)
dialog_data = client.pause_dialog()

client_data = client.deconstruct(including_private=True)

form.fints_challenge.data = b64_sign(challenge.get_data())
form.fints_dialog.data = b64_sign(dialog_data)
form.fints_client.data = b64_sign(client_data)

return render_template(
"finance/fints_tan.html",
form=form,
action=action,
bank_account_id=bank_account.id,
qrcode=qrcode,
)


@bp.route('/bank-accounts/import/errors/json')
def bank_accounts_errors_json() -> ResponseReturnValue:
return TableResponse[ImportErrorRow](
Expand All @@ -255,6 +322,59 @@ def bank_accounts_errors_json() -> ResponseReturnValue:
).model_dump()


def b64_unsign(data: str) -> bytes:
s = Signer(current_app.secret_key)
return b64decode(s.unsign(data))


def get_set_up_fints_client(form: FinTSTANForm, bank_account: BankAccount) -> FinTS3PinTanClient:
client_data = b64_unsign(form.fints_client.data)
dialog_data = b64_unsign(form.fints_dialog.data)
challenge = b64_unsign(form.fints_challenge.data)

client = get_fints_client(
product_id=config.fints_product_id,
user_id=form.user.data,
secret_pin=form.secret_pin.data,
bank_account=bank_account,
from_data=client_data,
)

with client.resume_dialog(dialog_data):
client.send_tan(NeedRetryResponse.from_data(challenge), form.tan.data)

return client


@bp.route("/bank-accounts/<int:bank_account_id>/import", methods=["POST"])
@access.require("finance_change")
def bank_accounts_import(bank_account_id: int) -> ResponseReturnValue:
fints_form = FinTSTANForm()
bank_account = session.get(BankAccount, bank_account_id)

# Send TAN
client = get_set_up_fints_client(fints_form, bank_account)

form = BankAccountActivitiesImportForm()
form.user.data = fints_form.user.data
form.secret_pin.data = fints_form.secret_pin.data
form.fints_client.data = b64_sign(client.deconstruct(including_private=True))

form.start_date.data = (
datetime.date(i) if (i := bank_account.last_imported_at) is not None else date(2018, 1, 1)
)
form.end_date.data = date.today() - timedelta(days=1)

return render_template(
"finance/bank_accounts_import.html",
form=form,
transactions=[],
old_transactions=[],
doubtful_transactions=[],
bank_account_id=bank_account.id,
)


from contextlib import contextmanager

@contextmanager
Expand All @@ -276,11 +396,12 @@ def flash_fints_errors() -> t.Iterator[None]:
raise PycroftException from e


@bp.route("/bank-accounts/<int:bank_account_id>/import", methods=["GET", "POST"])
@bp.route("/bank-accounts/<int:bank_account_id>/import/run", methods=["POST"])
@access.require("finance_change")
def bank_accounts_import(bank_account_id: int) -> ResponseReturnValue:
def bank_accounts_import_run(bank_account_id: int) -> ResponseReturnValue:
form = BankAccountActivitiesImportForm()
imported = ImportedTransactions([], [], [])
bank_account = session.get(BankAccount, bank_account_id)

def display_form_response(
imported: ImportedTransactions,
Expand All @@ -290,9 +411,9 @@ def display_form_response(
transactions=imported.new,
old_transactions=imported.old,
doubtful_transactions=imported.doubtful,
bank_account_id=bank_account.id,
)

bank_account = session.get(BankAccount, bank_account_id)

if not form.is_submitted():
form.start_date.data = (
Expand All @@ -307,11 +428,14 @@ def display_form_response(
if not form.validate():
return display_form_response(imported)

fints_client_data = b64_unsign(form.fints_client.data)

fints_client = get_fints_client(
product_id=config.fints_product_id,
user_id=form.user.data,
secret_pin=form.secret_pin.data,
bank_account=bank_account,
from_data=fints_client_data,
)

try:
Expand Down Expand Up @@ -340,6 +464,8 @@ def display_form_response(
f"/ {len(imported.doubtful)} zu neu (Buchung >= {date.today()}T00:00Z)."
)
if not form.do_import.data:
form.fints_client.data = b64_sign(fints_client.deconstruct(including_private=True))

return display_form_response(imported)

# persist transactions and errors
Expand Down
12 changes: 11 additions & 1 deletion web/blueprints/finance/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,9 +110,19 @@ class BankAccountActivityEditForm(BankAccountActivityReadForm):
description = StringField("Beschreibung")


class BankAccountActivitiesImportForm(Form):
class FinTSClientForm(Form):
user = StringField("Loginname", validators=[DataRequired()])
secret_pin = PasswordField("PIN", validators=[DataRequired()])
fints_client = HiddenField("FinTS client data", validators=[DataRequired()])


class FinTSTANForm(FinTSClientForm):
tan = StringField("TAN", validators=[DataRequired()])
fints_challenge = HiddenField("FinTS Challenge", validators=[DataRequired()])
fints_dialog = HiddenField("FinTS dialog data", validators=[DataRequired()])


class BankAccountActivitiesImportForm(FinTSClientForm):
start_date = DateField("Startdatum")
end_date = DateField("Enddatum")
do_import = BooleanField("Import durchführen", default=False)
Expand Down
2 changes: 1 addition & 1 deletion web/templates/finance/bank_accounts_import.html
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
{% block content %}
<div class="container">
<div class="row">
{{ forms.simple_form(form, '', url_for('.bank_accounts_list'), autocomplete="on") }}
{{ forms.simple_form(form, url_for('.bank_accounts_import_run', bank_account_id=bank_account_id), url_for('.bank_accounts_list'), autocomplete="on") }}
</div>

<div class="row mt-3">
Expand Down
14 changes: 14 additions & 0 deletions web/templates/finance/fints_login.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{#
Copyright (c) 2015 The Pycroft Authors. See the AUTHORS file.
This file is part of the Pycroft project and licensed under the terms of
the Apache License, Version 2.0. See the LICENSE file for details.
#}
{% extends "layout.html" %}

{% set page_title = "FinTS-Zugang erlangen" %}

{% import "macros/forms.html" as forms %}

{% block single_row_content %}
{{ forms.simple_form(form, '', url_for('.bank_accounts_list') ) }}
{% endblock %}
22 changes: 22 additions & 0 deletions web/templates/finance/fints_tan.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{#
Copyright (c) 2015 The Pycroft Authors. See the AUTHORS file.
This file is part of the Pycroft project and licensed under the terms of
the Apache License, Version 2.0. See the LICENSE file for details.
#}
{% extends "layout.html" %}

{% set page_title = "FinTS-Zugang erlangen" %}

{% import "macros/forms.html" as forms %}

{% block content %}
<div class="container">
<div class="row">
{{ forms.simple_form(form, url_for('.bank_accounts_' ~ action, bank_account_id=bank_account_id), url_for('.bank_accounts_list'), autocomplete="on") }}
</div>

<div class="row mt-3 w-25">
<img src="{{ qrcode }}"></img>
</div>
</div>
{% endblock %}
14 changes: 14 additions & 0 deletions web/templates/finance/login.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{#
Copyright (c) 2015 The Pycroft Authors. See the AUTHORS file.
This file is part of the Pycroft project and licensed under the terms of
the Apache License, Version 2.0. See the LICENSE file for details.
#}
{% extends "layout.html" %}

{% set page_title = "FinTS-Zugang erlangen" %}

{% import "macros/forms.html" as forms %}

{% block single_row_content %}
{{ forms.simple_form(form, '', url_for('finance.login') ) }}
{% endblock %}

0 comments on commit 751f2ae

Please sign in to comment.