From 16039a322426d9246fd18ed4b7ba25715ebd876e Mon Sep 17 00:00:00 2001 From: Henrik Blidh Date: Thu, 28 Mar 2024 21:14:55 +0000 Subject: [PATCH] Async and Sync clients, implementing v6 API (#59) Version 1.0.0 Breaking changes: Deprecating v5 of BankID API clients in favour of only v6 clients. Sync and Async clients Implementing the v6 API Removing all v5 and v5.1 API implementations Updated documentation Corrected the example app to work with v1.0.0 of PyBankID Contains and fixes #53, #54, #56, #57, #58 Big thanks to @tiwilliam and @mxamin for implementing the async client and v6 clients respectively. --- .github/workflows/build_and_test.yml | 18 +- .github/workflows/pypi-publish.yml | 4 +- .gitignore | 3 + README.rst | 113 +++--- bankid/__init__.py | 17 +- bankid/__version__.py | 3 +- bankid/asyncclient.py | 367 ++++++++++++++++++++ bankid/baseclient.py | 85 +++++ bankid/certs/FPTestcert4_20220818.p12 | Bin 0 -> 2829 bytes bankid/certs/FPTestcert4_20220818_cert.pem | 34 ++ bankid/certs/FPTestcert4_20220818_key.pem | 31 ++ bankid/certutils.py | 16 +- bankid/exceptions.py | 34 +- bankid/jsonclient.py | 273 --------------- bankid/syncclient.py | 366 +++++++++++++++++++ docs/_static/.gitkeep | 0 docs/api_reference.rst | 29 ++ docs/certutils.rst | 24 +- docs/conf.py | 9 +- docs/examples.rst | 83 +++-- docs/exceptions.rst | 13 - docs/get_started.rst | 176 ++++++++-- docs/index.rst | 12 +- docs/jsonclient.rst | 111 ------ examples/qrdemo/README.md | 5 +- examples/qrdemo/qrdemo/app.py | 63 ++-- examples/qrdemo/qrdemo/templates/index.html | 2 +- examples/qrdemo/qrdemo/templates/qr.html | 183 ++++++---- examples/qrdemo/requirements.txt | 2 +- requirements-dev.txt | 6 +- requirements.txt | 3 +- ruff.toml | 1 + setup.py | 10 +- tests/conftest.py | 67 +++- tests/test_asyncclient.py | 137 ++++++++ tests/test_certutils.py | 24 +- tests/test_exceptions.py | 5 +- tests/test_jsonclient.py | 158 --------- tests/test_syncclient.py | 142 ++++++++ 39 files changed, 1776 insertions(+), 853 deletions(-) create mode 100644 bankid/asyncclient.py create mode 100644 bankid/baseclient.py create mode 100644 bankid/certs/FPTestcert4_20220818.p12 create mode 100644 bankid/certs/FPTestcert4_20220818_cert.pem create mode 100644 bankid/certs/FPTestcert4_20220818_key.pem delete mode 100644 bankid/jsonclient.py create mode 100644 bankid/syncclient.py create mode 100644 docs/_static/.gitkeep create mode 100644 docs/api_reference.rst delete mode 100644 docs/exceptions.rst delete mode 100644 docs/jsonclient.rst create mode 100644 ruff.toml create mode 100644 tests/test_asyncclient.py delete mode 100644 tests/test_jsonclient.py create mode 100644 tests/test_syncclient.py diff --git a/.github/workflows/build_and_test.yml b/.github/workflows/build_and_test.yml index 89ff12c..300b4c9 100644 --- a/.github/workflows/build_and_test.yml +++ b/.github/workflows/build_and_test.yml @@ -17,24 +17,21 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - os: [ubuntu-latest, windows-latest, macos-latest] - python-version: [3.7, 3.8, 3.9, '3.10', '3.11'] + os: [ubuntu-latest] + python-version: [3.9, '3.10', '3.11', '3.12'] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - - name: Upgrade pip. setuptools and wheel - run: python -m pip install --upgrade pip setuptools wheel - - name: Install dependencies run: pip install -r requirements.txt - name: Install development dependencies - run: pip install pytest pytest-cov mock flake8 + run: pip install -r requirements-dev.txt - name: Lint with flake8 run: | @@ -47,8 +44,11 @@ jobs: run: | pytest tests --junitxml=junit/test-results-${{ matrix.os }}-${{ matrix.python-version }}.xml --cov=bankid --cov-report=xml --cov-report=html + - name: Upgrade pip. setuptools and wheel + run: python -m pip install --upgrade pip setuptools wheel + - name: Upload pytest test results - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: pytest-results-${{ matrix.os }}-${{ matrix.python-version }} path: junit/test-results-${{ matrix.os }}-${{ matrix.python-version }}.xml diff --git a/.github/workflows/pypi-publish.yml b/.github/workflows/pypi-publish.yml index 675bca6..36f4d5d 100644 --- a/.github/workflows/pypi-publish.yml +++ b/.github/workflows/pypi-publish.yml @@ -12,9 +12,9 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: '3.x' diff --git a/.gitignore b/.gitignore index 5eff4b0..cc484ff 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ # Created by .ignore support plugin (hsz.mobi) +.vscode/ ### Python template # Byte-compiled / optimized / DLL files @@ -26,6 +27,8 @@ var/ *.egg-info/ .installed.cfg *.egg +.venv +.python-version # PyInstaller # Usually these files are written by a python script from a template diff --git a/README.rst b/README.rst index 1d58d18..62d2b15 100644 --- a/README.rst +++ b/README.rst @@ -18,9 +18,14 @@ providing authentication and signing functionality to end users. This package provides a simplifying interface for initiating authentication and signing orders and then collecting the results from the BankID servers. +The only supported BankID API version supported by PyBankID from version 1.0.0 +is v6.0, which means that the Secure Start solution is the only supported way +of providing BankID services. PyBankID versions prior to 1.0.0 will not +work after 1st of May 2024. + If you intend to use PyBankID in your project, you are advised to read -the `BankID Relying Party Guidelines -`_ before +the `BankID Integration Guide +`_ before doing anything else. There, one can find information about how the BankID methods are defined and how to use them. @@ -36,17 +41,21 @@ PyBankID can be installed though pip: Usage ----- -``BankIDJSONClient`` is the client to be used to -communicate with the BankID service. It uses the JSON 5.1 API released in April 2020. +PyBankID provides both a synchronous and an asynchronous client for +communication with BankID services. Example below will use the asynchronous +client, but the synchronous client is used in the same way by merely omitting +the ``await`` keyword. -JSON client -~~~~~~~~~~~ +Synchronous client +~~~~~~~~~~~~~~~~~~ .. code-block:: python - >>> from bankid import BankIDJSONClient - >>> client = BankIDJSONClient(certificates=('path/to/certificate.pem', - 'path/to/key.pem')) + >>> from bankid import BankIDClient + >>> client = BankIDClient(certificates=( + 'path/to/certificate.pem', + 'path/to/key.pem', + )) Connection to production server is the default in the client. If test server is desired, send in the ``test_server=True`` keyword in the init @@ -58,8 +67,7 @@ is initiated as such: .. code-block:: python - >>> client.authenticate(end_user_ip='194.168.2.25', - personal_number="YYYYMMDDXXXX") + >>> client.authenticate(end_user_ip='194.168.2.25') { 'orderRef': 'ee3421ea-2096-4000-8130-82648efe0927', 'autoStartToken': 'e8df5c3c-c67b-4a01-bfe5-fefeab760beb', @@ -71,9 +79,27 @@ and a sign order is initiated in a similar fashion: .. code-block:: python - >>> client.sign(end_user_ip='194.168.2.25', - user_visible_data="The information to sign.", - personal_number="YYYYMMDDXXXX") + >>> client.sign( + end_user_ip='194.168.2.25', + user_visible_data="The information to sign." + ) + { + 'orderRef': 'ee3421ea-2096-4000-8130-82648efe0927', + 'autoStartToken': 'e8df5c3c-c67b-4a01-bfe5-fefeab760beb', + 'qrStartToken': '01f94e28-857f-4d8a-bf8e-6c5a24466658', + 'qrStartSecret': 'b4214886-3b5b-46ab-bc08-6862fddc0e06' + } + +If you want to ascertain that only one individual can authenticate or sign, you can +specify this using the ``requirement`` keyword: + +.. code-block:: python + + >>> client.sign( + end_user_ip='194.168.2.25', + user_visible_data="The information to sign." + requirement={"personalNumber": "YYYYMMDDXXXX"} + ) { 'orderRef': 'ee3421ea-2096-4000-8130-82648efe0927', 'autoStartToken': 'e8df5c3c-c67b-4a01-bfe5-fefeab760beb', @@ -81,9 +107,8 @@ and a sign order is initiated in a similar fashion: 'qrStartSecret': 'b4214886-3b5b-46ab-bc08-6862fddc0e06' } -Since the ``BankIDJSONClient`` is using the BankID ``v5`` JSON API, the ``personal_number`` can now be omitted when calling -``authenticate`` and ``sign``. See BankID Relying Party Guidelines -for more information about this. +If someone else than the one you specified tries to authenticate or sign, the +BankID app will state that the request is not intended for the user. The status of an order can then be studied by polling with the ``collect`` method using the received ``orderRef``: @@ -126,38 +151,40 @@ with the ``collect`` method using the received ``orderRef``: } Please note that the ``collect`` method should be used sparingly: in the -BankID Relying Party Guidelines -it states that *"collect should be called every two seconds and must not be +`BankID Integration Guide `_ +it is specified that *"collect should be called every two seconds and must not be called more frequent than once per second"*. -PyBankID and QR code --------------------- +Asynchronous client +~~~~~~~~~~~~~~~~~~~ -PyBankID cannot generate QR codes for you, but there is an example application in the -`examples folder of the repo `_ where a -Flask application called ``qrdemo`` shows one way to do authentication with animated QR codes. +The asynchronous client is used in the same way as the asynchronous client, but the +methods are blocking. -The content for the QR code is generated by this method: +The synchronous guide above can be used as a reference for the asynchronous client +as well, by simply adding the ``await`` keyword: .. code-block:: python - import hashlib - import hmac - from math import floor - import time + >>> from bankid import BankIDAsyncClient + >>> client = BankIDAsyncClient(certificates=( + 'path/to/certificate.pem', + 'path/to/key.pem', + )) + >>> await client.authenticate(end_user_ip='194.168.2.25') + { + 'orderRef': 'ee3421ea-2096-4000-8130-82648efe0927', + 'autoStartToken': 'e8df5c3c-c67b-4a01-bfe5-fefeab760beb', + 'qrStartToken': '01f94e28-857f-4d8a-bf8e-6c5a24466658', + 'qrStartSecret': 'b4214886-3b5b-46ab-bc08-6862fddc0e06' + } - def generate_qr_code_content(qr_start_token: str, start_t: float, qr_start_secret: str): - """Given QR start token, time.time() when initiated authentication call was made and the - QR start secret, calculate the current QR code content to display. - """ - elapsed_seconds_since_call = int(floor(time.time() - start_t)) - qr_auth_code = hmac.new( - qr_start_secret.encode(), - msg=str(elapsed_seconds_since_call).encode(), - digestmod=hashlib.sha256, - ).hexdigest() - return f"bankid.{qr_start_token}.{elapsed_seconds_since_call}.{qr_auth_code}" +PyBankID and QR codes +~~~~~~~~~~~~~~~~~~~~~ +PyBankID can generate QR codes for you, and there is an example application in the +`examples folder of the repo `_ where a +Flask application called ``qrdemo`` shows one way to do authentication with animated QR codes. Certificates ------------ @@ -167,7 +194,7 @@ Production certificates If you want to use BankID in a production environment, then you will have to purchase this service from one of the -`selling banks `_. +`selling banks `_. They will then provide you with a certificate that can be used to authenticate your company/application with the BankID servers. @@ -189,7 +216,7 @@ be obtained through PyBankID: dir_to_save_cert_and_key_in) >>> print(cert_and_key) ['/home/hbldh/certificate.pem', '/home/hbldh/key.pem'] - >>> client = bankid.BankIDJSONClient( + >>> client = bankid.BankIDClient( certificates=cert_and_key, test_server=True) Testing @@ -199,4 +226,4 @@ The PyBankID solution can be tested with `pytest `_: .. code-block:: bash - py.test + py.test tests/ diff --git a/bankid/__init__.py b/bankid/__init__.py index d21cee7..7f628bc 100644 --- a/bankid/__init__.py +++ b/bankid/__init__.py @@ -1,4 +1,3 @@ -#!/usr/bin/env python # -*- coding: utf-8 -*- """ :mod:`bankid` @@ -12,20 +11,22 @@ and signing orders and then collecting the results from the BankID servers. If you intend to use PyBankID in your project, you are advised to read -the `BankID Relying Party Guidelines -`_ before +the `BankID Relying Party Integration Guide +`_ before doing anything else. There, one can find information about how the BankID methods are defined and how to use them. """ -from .jsonclient import BankIDJSONClient -from .certutils import create_bankid_test_server_cert_and_key -from .__version__ import __version__, version -import bankid.exceptions +from bankid import exceptions +from bankid.__version__ import __version__, version +from bankid.certutils import create_bankid_test_server_cert_and_key +from bankid.syncclient import BankIDClient +from bankid.asyncclient import BankIDAsyncClient __all__ = [ - "BankIDJSONClient", + "BankIDClient", + "BankIDAsyncClient", "exceptions", "create_bankid_test_server_cert_and_key", "__version__", diff --git a/bankid/__version__.py b/bankid/__version__.py index 15729d5..9f148f9 100644 --- a/bankid/__version__.py +++ b/bankid/__version__.py @@ -1,8 +1,7 @@ -#!/usr/bin/env python # -*- coding: utf-8 -*- """ Version info """ -__version__ = "0.14.1" +__version__ = "1.0.0" version = __version__ # backwards compatibility name diff --git a/bankid/asyncclient.py b/bankid/asyncclient.py new file mode 100644 index 0000000..0625546 --- /dev/null +++ b/bankid/asyncclient.py @@ -0,0 +1,367 @@ +from typing import Optional, Tuple, Dict, Any + +import httpx + +from bankid.baseclient import BankIDClientBaseclass +from bankid.exceptions import get_json_error_class + + +class BankIDAsyncClient(BankIDClientBaseclass): + """The asynchronous client to use for communicating with BankID servers via the v6 API. + + :param certificates: Tuple of string paths to the certificate to use and + the key to sign with. + :type certificates: tuple + :param test_server: Use the test server for authenticating and signing. + :type test_server: bool + :param request_timeout: Timeout for BankID requests. + :type request_timeout: int + + """ + + def __init__(self, certificates: Tuple[str, str], test_server: bool = False, request_timeout: Optional[int] = None): + super().__init__(certificates, test_server, request_timeout) + + kwargs = { + "cert": self.certs, + "headers": {"Content-Type": "application/json"}, + "verify": self.verify_cert, + } + if request_timeout: + kwargs["timeout"] = request_timeout + self.client = httpx.AsyncClient(**kwargs) + + async def authenticate( + self, + end_user_ip: str, + requirement: Dict[str, Any] = None, + user_visible_data: str = None, + user_non_visible_data: str = None, + user_visible_data_format: str = None, + ) -> Dict[str, str]: + """Request an authentication order. The :py:meth:`collect` method + is used to query the status of the order. + + Example data returned: + + .. code-block:: json + + { + "orderRef": "ee3421ea-2096-4000-8130-82648efe0927", + "autoStartToken": "e8df5c3c-c67b-4a01-bfe5-fefeab760beb", + "qrStartToken": "01f94e28-857f-4d8a-bf8e-6c5a24466658", + "qrStartSecret": "b4214886-3b5b-46ab-bc08-6862fddc0e06" + } + + :param end_user_ip: The user IP address as seen by RP. String. IPv4 and IPv6 is allowed. + :type end_user_ip: str + :param requirement: Requirements on how the auth order must be performed. + See the section `Requirements `_ for more details. + :type requirement: dict + :param user_visible_data: Text displayed to the user during authentication with BankID, + with the purpose of providing context for the authentication and to enable users + to detect identification errors and averting fraud attempts. + :type user_visible_data: str + :param user_non_visible_data: Data is not displayed to the user. + :type user_non_visible_data: str + :param user_visible_data_format: If present, and set to “simpleMarkdownV1”, + this parameter indicates that userVisibleData holds formatting characters which + potentially make for a more pleasant user experience. + :type user_visible_data_format: str + :return: The order response. + :rtype: dict + :raises BankIDError: raises a subclass of this error + when error has been returned from server. + + """ + data = self._create_payload( + end_user_ip, + requirement=requirement, + user_visible_data=user_visible_data, + user_non_visible_data=user_non_visible_data, + user_visible_data_format=user_visible_data_format, + ) + + response = await self.client.post(self._auth_endpoint, json=data) + + if response.status_code == 200: + return response.json() + else: + raise get_json_error_class(response) + + async def phone_authenticate( + self, + personal_number: str, + call_initiator: str, + requirement: Dict[str, Any] = None, + user_visible_data: str = None, + user_non_visible_data: str = None, + user_visible_data_format: str = None, + ) -> Dict[str, str]: + """Initiates an authentication order when the user is talking + to the RP over the phone. The :py:meth:`collect` method + is used to query the status of the order. + + Example data returned: + + .. code-block:: json + + { + "orderRef": "ee3421ea-2096-4000-8130-82648efe0927" + } + + :param personal_number: The personal number of the user. 12 digits. + :type personal_number: str + :param call_initiator: Indicate if the user or the RP initiated the phone call. + "user": user called the RP + "RP": RP called the user + :type call_initiator: str + :param requirement: Requirements on how the auth order must be performed. + See the section `Requirements `_ for more details. + :type requirement: dict + :param user_visible_data: Text displayed to the user during authentication with BankID, + with the purpose of providing context for the authentication and to enable users + to detect identification errors and averting fraud attempts. + :type user_visible_data: str + :param user_non_visible_data: Data is not displayed to the user. + :type user_non_visible_data: str + :param user_visible_data_format: If present, and set to “simpleMarkdownV1”, + this parameter indicates that userVisibleData holds formatting characters which + potentially make for a more pleasant user experience. + :type user_visible_data_format: str + :return: The order response. + :rtype: dict + :raises BankIDError: raises a subclass of this error + when error has been returned from server. + + """ + if call_initiator not in ["user", "RP"]: + raise ValueError("call_initiator must be either 'user' or 'RP'") + + data = self._create_payload( + requirement=requirement, + user_visible_data=user_visible_data, + user_non_visible_data=user_non_visible_data, + user_visible_data_format=user_visible_data_format, + ) + data["personalNumber"] = personal_number + data["callInitiator"] = call_initiator + + response = await self.client.post(self._phone_auth_endpoint, json=data) + + if response.status_code == 200: + return response.json() + else: + raise get_json_error_class(response) + + async def sign( + self, + end_user_ip, + user_visible_data: str, + requirement: Dict[str, Any] = None, + user_non_visible_data: str = None, + user_visible_data_format: str = None, + ) -> Dict[str, str]: + """Request a signing order. The :py:meth:`collect` method + is used to query the status of the order. + + Example data returned: + + .. code-block:: json + + { + "orderRef": "ee3421ea-2096-4000-8130-82648efe0927", + "autoStartToken": "e8df5c3c-c67b-4a01-bfe5-fefeab760beb", + "qrStartToken": "01f94e28-857f-4d8a-bf8e-6c5a24466658", + "qrStartSecret": "b4214886-3b5b-46ab-bc08-6862fddc0e06" + } + + :param end_user_ip: The user IP address as seen by RP. String. IPv4 and IPv6 is allowed. + :type end_user_ip: str + :param requirement: Requirements on how the sign order must be performed. + See the section `Requirements `_ for more details. + :type requirement: dict + :param user_visible_data: Text to be displayed to the user. + :type user_visible_data: str + :param user_non_visible_data: Data is not displayed to the user. + :type user_non_visible_data: str + :param user_visible_data_format: If present, and set to “simpleMarkdownV1”, + this parameter indicates that userVisibleData holds formatting characters which + potentially make for a more pleasant user experience. + :type user_visible_data_format: str + :return: The order response. + :rtype: dict + :raises BankIDError: raises a subclass of this error + when error has been returned from server. + + """ + data = self._create_payload( + end_user_ip, + requirement=requirement, + user_visible_data=user_visible_data, + user_non_visible_data=user_non_visible_data, + user_visible_data_format=user_visible_data_format, + ) + + response = await self.client.post(self._sign_endpoint, json=data) + + if response.status_code == 200: + return response.json() + else: + raise get_json_error_class(response) + + async def phone_sign( + self, + personal_number: str, + call_initiator: str, + user_visible_data: str, + requirement: Dict[str, Any] = None, + user_non_visible_data: str = None, + user_visible_data_format: str = None, + ) -> Dict[str, str]: + """Initiates an authentication order when the user is talking to + the RP over the phone. The :py:meth:`collect` method + is used to query the status of the order. + + Example data returned: + + .. code-block:: json + + { + "orderRef": "ee3421ea-2096-4000-8130-82648efe0927" + } + + :param personal_number: The personal number of the user. 12 digits. + :type personal_number: str + :param call_initiator: Indicate if the user or the RP initiated the phone call. + "user": user called the RP + "RP": RP called the user + :type call_initiator: str + :param requirement: Requirements on how the sign order must be performed. + See the section `Requirements `_ for more details. + :type requirement: dict + :param user_visible_data: Text to be displayed to the user. + :type user_visible_data: str + :param user_non_visible_data: Data is not displayed to the user. + :type user_non_visible_data: str + :param user_visible_data_format: If present, and set to “simpleMarkdownV1”, + this parameter indicates that userVisibleData holds formatting characters which + potentially make for a more pleasant user experience. + :type user_visible_data_format: str + :return: The order response. + :rtype: dict + :raises BankIDError: raises a subclass of this error + when error has been returned from server. + + """ + if call_initiator not in ["user", "RP"]: + raise ValueError("call_initiator must be either 'user' or 'RP'") + + data = self._create_payload( + requirement=requirement, + user_visible_data=user_visible_data, + user_non_visible_data=user_non_visible_data, + user_visible_data_format=user_visible_data_format, + ) + data["personalNumber"] = personal_number + data["callInitiator"] = call_initiator + + response = await self.client.post(self._phone_sign_endpoint, json=data) + + if response.status_code == 200: + return response.json() + else: + raise get_json_error_class(response) + + async def collect(self, order_ref: str) -> dict: + """Collects the result of a sign or auth order using the + ``orderRef`` as reference. + + RP should keep on calling collect every two seconds if status is pending. + RP must abort if status indicates failed. The user identity is returned + when complete. + + Example collect results returned while authentication or signing is + still pending: + + .. code-block:: json + + { + "orderRef":"131daac9-16c6-4618-beb0-365768f37288", + "status":"pending", + "hintCode":"userSign" + } + + Example collect result when authentication or signing has failed: + + .. code-block:: json + + { + "orderRef":"131daac9-16c6-4618-beb0-365768f37288", + "status":"failed", + "hintCode":"userCancel" + } + + Example collect result when authentication or signing is successful + and completed: + + .. code-block:: json + + { + "orderRef": "131daac9-16c6-4618-beb0-365768f37288", + "status": "complete", + "completionData": { + "user": { + "personalNumber": "190000000000", + "name": "Karl Karlsson", + "givenName": "Karl", + "surname": "Karlsson" + }, + "device": { + "ipAddress": "192.168.0.1" + }, + "bankIdIssueDate": "2020-02-01", + "signature": "", + "ocspResponse": "" + } + } + + See `BankID Integration Guide `_ + for more details about how to inform end user of the current status, + whether it is pending, failed or completed. + + :param order_ref: The ``orderRef`` UUID returned from auth or sign. + :type order_ref: str + :return: The CollectResponse parsed to a dictionary. + :rtype: dict + :raises BankIDError: raises a subclass of this error + when error has been returned from server. + + """ + response = await self.client.post(self._collect_endpoint, json={"orderRef": order_ref}) + + if response.status_code == 200: + return response.json() + else: + raise get_json_error_class(response) + + async def cancel(self, order_ref: str) -> bool: + """Cancels an ongoing sign or auth order. + + This is typically used if the user cancels the order + in your service or app. + + :param order_ref: The UUID string specifying which order to cancel. + :type order_ref: str + :return: Boolean regarding success of cancellation. + :rtype: bool + :raises BankIDError: raises a subclass of this error + when error has been returned from server. + + """ + response = await self.client.post(self._cancel_endpoint, json={"orderRef": order_ref}) + + if response.status_code == 200: + return response.json() == {} + else: + raise get_json_error_class(response) diff --git a/bankid/baseclient.py b/bankid/baseclient.py new file mode 100644 index 0000000..0d146f6 --- /dev/null +++ b/bankid/baseclient.py @@ -0,0 +1,85 @@ +import base64 +from datetime import datetime +import hashlib +import hmac +from math import floor +import time +from typing import Tuple, Optional, Dict, Any +from urllib.parse import urljoin + +from bankid.certutils import resolve_cert_path + + +class BankIDClientBaseclass: + """Baseclass for BankID clients. + + Both the synchronous and asynchronous clients inherit from this base class and has the methods implemented here. + """ + + def __init__( + self, + certificates: Tuple[str, str], + test_server: bool = False, + request_timeout: Optional[int] = None, + ): + self.certs = certificates + self._request_timeout = request_timeout + + if test_server: + self.api_url = "https://appapi2.test.bankid.com/rp/v6.0/" + self.verify_cert = resolve_cert_path("appapi2.test.bankid.com.pem") + else: + self.api_url = "https://appapi2.bankid.com/rp/v6.0/" + self.verify_cert = resolve_cert_path("appapi2.bankid.com.pem") + + self._auth_endpoint = urljoin(self.api_url, "auth") + self._phone_auth_endpoint = urljoin(self.api_url, "phone/auth") + self._sign_endpoint = urljoin(self.api_url, "sign") + self._phone_sign_endpoint = urljoin(self.api_url, "phone/sign") + self._collect_endpoint = urljoin(self.api_url, "collect") + self._cancel_endpoint = urljoin(self.api_url, "cancel") + + self.client = None + + @staticmethod + def generate_qr_code_content(qr_start_token: str, start_t: [float, datetime], qr_start_secret: str): + """Given QR start token, time.time() or UTC datetime when initiated authentication call was made and the + QR start secret, calculate the current QR code content to display. + """ + if isinstance(start_t, datetime): + start_t = start_t.timestamp() + elapsed_seconds_since_call = int(floor(time.time() - start_t)) + qr_auth_code = hmac.new( + qr_start_secret.encode(), + msg=str(elapsed_seconds_since_call).encode(), + digestmod=hashlib.sha256, + ).hexdigest() + return f"bankid.{qr_start_token}.{elapsed_seconds_since_call}.{qr_auth_code}" + + @staticmethod + def _encode_user_data(user_data): + if isinstance(user_data, str): + return base64.b64encode(user_data.encode("utf-8")).decode("ascii") + else: + return base64.b64encode(user_data).decode("ascii") + + def _create_payload( + self, + end_user_ip: str = None, + requirement: Dict[str, Any] = None, + user_visible_data: str = None, + user_non_visible_data: str = None, + user_visible_data_format: str = None, + ): + data = {} + if end_user_ip: + data["endUserIp"] = end_user_ip + if requirement and isinstance(requirement, dict): + data["requirement"] = requirement + if user_visible_data: + data["userVisibleData"] = self._encode_user_data(user_visible_data) + if user_non_visible_data: + data["userNonVisibleData"] = self._encode_user_data(user_non_visible_data) + if user_visible_data_format and user_visible_data_format == "simpleMarkdownV1": + data["userVisibleDataFormat"] = "simpleMarkdownV1" + return data diff --git a/bankid/certs/FPTestcert4_20220818.p12 b/bankid/certs/FPTestcert4_20220818.p12 new file mode 100644 index 0000000000000000000000000000000000000000..f678de8e6fb70a998f9f2fae4901f412a6a3970e GIT binary patch literal 2829 zcmY+_X*3j!8V7JQ#xOIM?2I+r$XLb_+1HV+q(RoQM93OL7}D4x8JWhuo2e`zWX+N# zvX#^jg|ZXlwT9?=&$;)$_kMWJbI$*N&hzp2pztsV5I~Q@L*IiTG6^OL`z!!PKoK6= z3Bp5PpT*J(v3|nl1oT^F*%c?OSa1Gs2!3hyGC1MY(3-Jo(JoIFI8a5gy_~fWE3T z8c{xFAJTv*jlkdgOpokSBJKEkb~Y2;*7|hZzN&tRuu(8|BF(&jfxH%%`7#nT#D8CXH4r%O z_&B!YHPnAwvWTr;`OPOX*)tn!Fe-?U=)6R`aAQ7uG%>q`-h936%_oi>h9$U?yA$mg znSs2cb^fS0U)CE&5Z}0Yeu#L3jZtRG7~y~DhbuTF-xYzi$8jik=PIp_n+n$}S$_g( zOMnTxZDRKsHBTPoUZG{@^0zVFT2w2#mlg&Z4=LY6_RP{VNYp;F2U3r-?=K2~b8B?=yO8zM?sq(~%{)Mx#y5Y_u{sZm*S*{{|xyW5B=uBg(Bs)io+-4?c?7|7LQui?dkAPok{y)qg8it&o!^R#!az zx#e|QqvPPg`Am%d%SV6Yr;iQTImcn{Cr{Zs(O-dDz=(1s*HYk_QdUp#_Wp3DNTc!n zxuWnLiOQUw)rw~F$sgnAMiSgYRBYhp_ULcMrNnyCgcUEpqm{f*H1Kd0-Pp<(ih^_h z9#TU?2?-84+gOBgNl34@>jqH~(aDE9Sge}Wnj$73e!Lj(D69P7o6cwLns1KtWnLWm z?4Bfr&2725k*}sdjTY-^5ZPKMM+S*$FUi&SVqt_DlhVu*ChE=IhU`13y6PD_N%0L> zYh2CG*x)17k7l7X2h5TrZveie$q^!i*glUoOR-y2OL_21VTO?t`XzYtc4WrMJ)5>m ze7d@+{%%`m$QQx%fE-FJ=4x4XV9H5wC2T}`2p;|YNa(`|N+Vj(WZNMsj5Q2nuEJ_x zu!OG&958>4^ZiSmQ<#+P}<)>eZs`3b(Np=#Hu_wv6N z(fK?p#qTg*u@^84t@i*{4-e?@78{CqoKzIiE(^%$oq`S#};13JaoYEN!{Q(dPdeH6}&I9J@UTysV=6ufPz zdT~LIbm^`5LVKamBlLj-4p`qVwy=Zm=W3TE&{OOEWX!4^uD9*Pa9&<{4a3dF4!PYI zI%M-TisW4CPYXy5S^cXgvXIC(VD)@YVFDhN%8CDz)H~Gsq8HbJOd8HRR1mCnw@H03 z(O>Nu-c5+6DdW=jx**1_V4D@)F_JOF%!}c|?7dkKxL(#9)`|ix0@dxpR#qROYma?SLoej_h-A~JOXdXyz@4CHYdj=Hn!y#FKorI zAM*#(t$D%A$$AG4`th^QfD|dKD!q2@g3RHr*7jQoUISL2Y!-8s)`9-=BW@Yzc0kl5 z#7|hOa}wN!wd#-6(p9!VrX`HK*xe?!Wg^#(%YU}w*%dBaI042!N!q!#cSpLW{7ap! zripFhQZePg5IW>s@fGnBa#T?->>>K{q|O3(@=YqslYqXlRYhu;L57XfrXWubsZyNF zx`J!75kMAaA-*MgsVe@Q;meyFG}g(KD1vf9!OZ6!ai3=45837dOomrjT4rXpLjO{` zm@zc1n?2aKfOa|%iI@!npb|RMAAF%lz`4S&$yTvnXBVq}SblE9lJstA@Vy6VYF;3o zEF~HfYkD$-H1s7NFmGpwmXiS?jWOO^t=L_&?S%Iy({uWV;W4}(GUVI|SnNxD61RB@ zvm=Rw>W|zS;hO-HU{^zX0Gt=POj104Q)euZ#m$Z4fHUX0cySlqjc##d93OXv{|2*@ zxB1YU{I+*Z5wz)>sOJt{1p&>8c%bOVyQ^HgVD z66sQ9VA;3f@)FbVM{f8t1MUf4D`;JN3}Znp+NCnutSF2LK!fOfK1FDZ*E+G;Qw%P1 z!Uo1VxnJeCKJNJz4ZYG3e=EwHW=R#nIei@u>zFEU z*I`K_mYLk5z3SKJyNF;OX_nqvw1Lp!4@ZmU=7?2ANtF11 zb%by$@_@L?wwcF44N>9=aY5ZH25t`p# +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC+C/BERzUouyaY +fYI+GoeyHqJOZWR2WzcltQIiSdTn3g9ZWwM8UonbU7ujXwvN88Qoy6tPdZR2ORu3 +aTQ7dazeWPo0L67XC3PJ1KYKi3xFJ2xis2Wh5Tve44eqajvJ4/5mpU61iO9Ba+kj +t0sW7IMbsCV5UVAEMSNJj7w9YDnfrspgPhKnWEt/YowtniAh0n+21ML5rHMvEwMh +HE+BmJnI2otb2yI5YXppgL/jSiYkqQ/s6r7hxiMwZu9fxXenEwF6pB5Kk5SbMRdp +unqIygn3JubgL0k14VWxjNeecJqcy7JM5ENHcei20TbQPgQ3tTLAAL/GhNhUNI99 +QC4GXj2NAgMBAAECggEBAKiJH9b9Kxhm9/BNhZ4bmvEMF7XcVv5bIAnRfwX3YdcK +Z6Q/gRwSumyF0iYsmORY5EGldNOvmyxIstqxcn+0eMxqLeDv1Gaioll/uowpbNhL +AOR64Yt0Jecg8mPfeAwvo6FVwfpdaIgk8YkZ+H5o2lBIosL2qDY/eWK4FCB94HUL +Hq7za/7J7t5WOYjiOLmb48Fpe7cA1C6ezU/MEwVmDBwZARccCyeQFp96tdzUxb7N +ifSaDpUFyxHbb/GNy+hF2ApqFrJ69OBUsHqtYdd36lD/tPF0Lexsvtj/l21D/Nh6 +80mEnpegpJBzO9z7wJkhz/5etO3bnaVSUyGGgJl8KkUCgYEA5SnGKyWg3dDtNeEi +5qilYsTOERvulUJ49zzzva0ioD8sJHNlG1q7Dp9sb9rZW6VOL1W8FUZH63/2sgte +NE9njByK2fz9PXXUODu6yREAfDxcv9qkGTLWwZ0LFEQg68G+J1hIz6PQEuhAJqk8 +rYHXnTQ0qUw7R6gez2KoXp8wnFMCgYEA1E13E5NKs/VKctUQqXcKpy7VL017yBH8 +J2RTjDLVGh6BFcR9wGm5ipE659TpNKdqPN17bGPGj5MOdZL1+sGVTRkg4vSZeZuE +kpw192KgwNoDznjeVH5qY7VM8Zy2DI91mg2NQTQiMF0mRLaenMOfzFBjHwQZ2J/J +ecT3Vwepgp8CgYAsocIyzRVTnklU4RBHFDmBzwrDUklZUKT2oixmmL3Rr/wM7VyX +w0gDRRF9h4Ylz0A2/9+t1Q5U04tcidJDJePo6fYxFpDL05MNkLSETIdnqun1g8PK +FJi3BLsPq2UuBYHfb9Zeem0gAZPc88EZmdxAhdZr0qkI/7lgcrqQEzkIeQKBgGri +kVfOqSaPEStdL+VR5JAlGPmWtgIVY/DlJtcH5Jgg0XaHFZSg5ePomFKNs9dpjigU +jgYU+avhKr9w/NyBR8yoIRGCeh5qeMVjVhw1kJ9nY9E4sx6xApkudw2Ri2opc9ja +h8pTF/9ndlPT6WkdaD9yHWVJKEYStFnVG326gtIbAoGAetLNOSZBSW03SJlI7dhY +4hycNElfSd0t89Bf4YcYbWrpySeKCG0oTO7Y56ZS9RmgNEyz4HNXZcQ56inMNY6Z +M+o1wGEKJKLBtCJHZp7Sh8zy/RMI3naF4vc4r4BpK9k5ZAEL8gHVm9M5C2ZG8whc +r+Uu/g0P3m8w7INgsjxQy/U= +-----END PRIVATE KEY----- diff --git a/bankid/certutils.py b/bankid/certutils.py index 0dbd1a3..d443d14 100644 --- a/bankid/certutils.py +++ b/bankid/certutils.py @@ -1,15 +1,15 @@ -#!/usr/bin/env python # -*- coding: utf-8 -*- """ :mod:`bankid.certutils` -- Certificate Utilities ================================================ - -Created 2016-04-10 by hbldh - +.. moduleauthor:: hbldh """ import os import subprocess +from typing import Tuple + +import importlib_resources from bankid.certs import get_test_cert_p12 from bankid.exceptions import BankIDError @@ -17,7 +17,13 @@ _TEST_CERT_PASSWORD = "qwerty123" -def create_bankid_test_server_cert_and_key(destination_path): +def resolve_cert_path(file: str) -> str: + ref = importlib_resources.files("bankid.certs") / file + with importlib_resources.as_file(ref) as path: + return str(path) + + +def create_bankid_test_server_cert_and_key(destination_path: str) -> Tuple[str]: """Split the bundled test certificate into certificate and key parts and save them as separate files, stored in PEM format. diff --git a/bankid/exceptions.py b/bankid/exceptions.py index 54bb1b1..383d9e5 100644 --- a/bankid/exceptions.py +++ b/bankid/exceptions.py @@ -1,28 +1,19 @@ -#!/usr/bin/env python # -*- coding: utf-8 -*- -""" -:mod:`bankid.exceptions` -- PyBankID Exceptions -=============================================== - -.. moduleauthor:: hbldh - -Created on 2014-09-10, 08:29 - -""" def get_json_error_class(response): data = response.json() error_class = _JSON_ERROR_CODE_TO_CLASS.get(data.get("errorCode"), BankIDError) - return error_class("{0}: {1}".format(data.get("errorCode"), data.get("details"))) + return error_class("{0}: {1}".format(data.get("errorCode"), data.get("details")), raw_data=data) class BankIDError(Exception): """Parent exception class for all PyBankID errors.""" def __init__(self, *args, **kwargs): - super(BankIDError, self).__init__(*args, **kwargs) + super(BankIDError, self).__init__(*args) self.rfa = None + self.json = kwargs.get("raw_data", {}) class BankIDWarning(Warning): @@ -44,6 +35,9 @@ class InvalidParametersError(BankIDError): """ + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + class AlreadyInProgressError(BankIDError): """Failure to create new order due to one already in progress. @@ -60,7 +54,7 @@ class AlreadyInProgressError(BankIDError): """ def __init__(self, *args, **kwargs): - super(AlreadyInProgressError, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) self.rfa = 4 @@ -78,7 +72,7 @@ class InternalError(BankIDError): """ def __init__(self, *args, **kwargs): - super(InternalError, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) self.rfa = 5 @@ -94,7 +88,7 @@ class MaintenanceError(BankIDError): """ def __init__(self, *args, **kwargs): - super(MaintenanceError, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) self.rfa = 5 @@ -109,11 +103,12 @@ class UnauthorizedError(BankIDError): """ - pass + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) class NotFoundError(BankIDError): - """An erroneously URL path was used. + """An erroneous URL path was used. **Code:** ``notFound`` @@ -123,7 +118,8 @@ class NotFoundError(BankIDError): """ - pass + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) class RequestTimeoutError(BankIDError): @@ -138,7 +134,7 @@ class RequestTimeoutError(BankIDError): """ def __init__(self, *args, **kwargs): - super(RequestTimeoutError, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) self.rfa = 5 diff --git a/bankid/jsonclient.py b/bankid/jsonclient.py deleted file mode 100644 index 6523ba1..0000000 --- a/bankid/jsonclient.py +++ /dev/null @@ -1,273 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -""" -:mod:`bankid.jsonclient` -- BankID JSON Client -============================================== - -Created on 2018-02-19 by hbldh - -""" -import base64 -from urllib import parse as urlparse - -import requests -from pkg_resources import resource_filename - -from bankid.exceptions import get_json_error_class - - -def _encode_user_data(user_data): - if isinstance(user_data, str): - return base64.b64encode(user_data.encode("utf-8")).decode("ascii") - else: - return base64.b64encode(user_data).decode("ascii") - - -class BankIDJSONClient(object): - """The client to use for communicating with BankID servers via the v.5 API. - - :param certificates: Tuple of string paths to the certificate to use and - the key to sign with. - :type certificates: tuple - :param test_server: Use the test server for authenticating and signing. - :type test_server: bool - :param request_timeout: Timeout for BankID requests. - :type request_timeout: int - - """ - - def __init__(self, certificates, test_server=False, request_timeout=None): - self.certs = certificates - self._request_timeout = request_timeout - - if test_server: - self.api_url = "https://appapi2.test.bankid.com/rp/v5.1/" - self.verify_cert = resource_filename("bankid.certs", "appapi2.test.bankid.com.pem") - else: - self.api_url = "https://appapi2.bankid.com/rp/v5.1/" - self.verify_cert = resource_filename("bankid.certs", "appapi2.bankid.com.pem") - - self.client = requests.Session() - self.client.verify = self.verify_cert - self.client.cert = self.certs - self.client.headers = {"Content-Type": "application/json"} - - self._auth_endpoint = urlparse.urljoin(self.api_url, "auth") - self._sign_endpoint = urlparse.urljoin(self.api_url, "sign") - self._collect_endpoint = urlparse.urljoin(self.api_url, "collect") - self._cancel_endpoint = urlparse.urljoin(self.api_url, "cancel") - - def _post(self, endpoint, *args, **kwargs): - """Internal helper method for adding timeout to requests.""" - return self.client.post(endpoint, *args, timeout=self._request_timeout, **kwargs) - - def authenticate(self, end_user_ip, personal_number=None, requirement=None, **kwargs): - """Request an authentication order. The :py:meth:`collect` method - is used to query the status of the order. - - Note that personal number is not needed when authentication is to - be done on the same device, provided that the returned - ``autoStartToken`` is used to open the BankID Client. - - Example data returned: - - .. code-block:: json - - { - "orderRef": "ee3421ea-2096-4000-8130-82648efe0927", - "autoStartToken": "e8df5c3c-c67b-4a01-bfe5-fefeab760beb", - "qrStartToken": "01f94e28-857f-4d8a-bf8e-6c5a24466658", - "qrStartSecret": "b4214886-3b5b-46ab-bc08-6862fddc0e06" - } - - :param end_user_ip: IP address of the user requesting - the authentication. - :type end_user_ip: str - :param personal_number: The Swedish personal number in - format YYYYMMDDXXXX. - :type personal_number: str - :param requirement: An optional dictionary stating how the signature - must be created and verified. See BankID Relying Party Guidelines, - section 13.5 for more details. - :type requirement: dict - :return: The order response. - :rtype: dict - :raises BankIDError: raises a subclass of this error - when error has been returned from server. - - """ - data = {"endUserIp": end_user_ip} - if personal_number: - data["personalNumber"] = personal_number - if requirement and isinstance(requirement, dict): - data["requirement"] = requirement - # Handling potentially changed optional in-parameters. - data.update(kwargs) - response = self._post(self._auth_endpoint, json=data) - - if response.status_code == 200: - return response.json() - else: - raise get_json_error_class(response) - - def sign( - self, - end_user_ip, - user_visible_data, - personal_number=None, - requirement=None, - user_non_visible_data=None, - **kwargs - ): - """Request a signing order. The :py:meth:`collect` method - is used to query the status of the order. - - Note that personal number is not needed when signing is to be done - on the same device, provided that the returned ``autoStartToken`` - is used to open the BankID Client. - - Example data returned: - - .. code-block:: json - - { - "orderRef": "ee3421ea-2096-4000-8130-82648efe0927", - "autoStartToken": "e8df5c3c-c67b-4a01-bfe5-fefeab760beb", - "qrStartToken": "01f94e28-857f-4d8a-bf8e-6c5a24466658", - "qrStartSecret": "b4214886-3b5b-46ab-bc08-6862fddc0e06" - } - - :param end_user_ip: IP address of the user requesting - the authentication. - :type end_user_ip: str - :param user_visible_data: The information that the end user - is requested to sign. - :type user_visible_data: str - :param personal_number: The Swedish personal number in - format YYYYMMDDXXXX. - :type personal_number: str - :param requirement: An optional dictionary stating how the signature - must be created and verified. See BankID Relying Party Guidelines, - section 13.5 for more details. - :type requirement: dict - :param user_non_visible_data: Optional information sent with request - that the user never sees. - :type user_non_visible_data: str - :return: The order response. - :rtype: dict - :raises BankIDError: raises a subclass of this error - when error has been returned from server. - - """ - data = {"endUserIp": end_user_ip} - if personal_number: - data["personalNumber"] = personal_number - data["userVisibleData"] = _encode_user_data(user_visible_data) - if user_non_visible_data: - data["userNonVisibleData"] = _encode_user_data(user_non_visible_data) - if requirement and isinstance(requirement, dict): - data["requirement"] = requirement - # Handling potentially changed optional in-parameters. - data.update(kwargs) - response = self._post(self._sign_endpoint, json=data) - - if response.status_code == 200: - return response.json() - else: - raise get_json_error_class(response) - - def collect(self, order_ref): - """Collects the result of a sign or auth order using the - ``orderRef`` as reference. - - RP should keep on calling collect every two seconds as long as status - indicates pending. RP must abort if status indicates failed. The user - identity is returned when complete. - - Example collect results returned while authentication or signing is - still pending: - - .. code-block:: json - - { - "orderRef":"131daac9-16c6-4618-beb0-365768f37288", - "status":"pending", - "hintCode":"userSign" - } - - Example collect result when authentication or signing has failed: - - .. code-block:: json - - { - "orderRef":"131daac9-16c6-4618-beb0-365768f37288", - "status":"failed", - "hintCode":"userCancel" - } - - Example collect result when authentication or signing is successful - and completed: - - .. code-block:: json - - { - "orderRef":"131daac9-16c6-4618-beb0-365768f37288", - "status":"complete", - "completionData": { - "user": { - "personalNumber":"190000000000", - "name":"Karl Karlsson", - "givenName":"Karl", - "surname":"Karlsson" - }, - "device": { - "ipAddress":"192.168.0.1" - }, - "cert": { - "notBefore":"1502983274000", - "notAfter":"1563549674000" - }, - "signature":"", - "ocspResponse":"" - } - } - - See `BankID Relying Party Guidelines Version: 3.5 `_ - for more details about how to inform end user of the current status, - whether it is pending, failed or completed. - - :param order_ref: The ``orderRef`` UUID returned from auth or sign. - :type order_ref: str - :return: The CollectResponse parsed to a dictionary. - :rtype: dict - :raises BankIDError: raises a subclass of this error - when error has been returned from server. - - """ - response = self._post(self._collect_endpoint, json={"orderRef": order_ref}) - - if response.status_code == 200: - return response.json() - else: - raise get_json_error_class(response) - - def cancel(self, order_ref): - """Cancels an ongoing sign or auth order. - - This is typically used if the user cancels the order - in your service or app. - - :param order_ref: The UUID string specifying which order to cancel. - :type order_ref: str - :return: Boolean regarding success of cancellation. - :rtype: bool - :raises BankIDError: raises a subclass of this error - when error has been returned from server. - - """ - response = self._post(self._cancel_endpoint, json={"orderRef": order_ref}) - - if response.status_code == 200: - return response.json() == {} - else: - raise get_json_error_class(response) diff --git a/bankid/syncclient.py b/bankid/syncclient.py new file mode 100644 index 0000000..2e44a6f --- /dev/null +++ b/bankid/syncclient.py @@ -0,0 +1,366 @@ +from typing import Optional, Tuple, Dict, Any + +import httpx + +from bankid.baseclient import BankIDClientBaseclass +from bankid.exceptions import get_json_error_class + + +class BankIDClient(BankIDClientBaseclass): + """The synchronous client to use for communicating with BankID servers via the v6 API. + + :param certificates: Tuple of string paths to the certificate to use and + the key to sign with. + :type certificates: tuple + :param test_server: Use the test server for authenticating and signing. + :type test_server: bool + :param request_timeout: Timeout for BankID requests. + :type request_timeout: int + + """ + + def __init__(self, certificates: Tuple[str, str], test_server: bool = False, request_timeout: Optional[int] = None): + super().__init__(certificates, test_server, request_timeout) + + kwargs = { + "cert": self.certs, + "headers": {"Content-Type": "application/json"}, + "verify": self.verify_cert, + } + if request_timeout: + kwargs["timeout"] = request_timeout + self.client = httpx.Client(**kwargs) + + def authenticate( + self, + end_user_ip: str, + requirement: Dict[str, Any] = None, + user_visible_data: str = None, + user_non_visible_data: str = None, + user_visible_data_format: str = None, + ) -> Dict[str, str]: + """Request an authentication order. The :py:meth:`collect` method + is used to query the status of the order. + + Example data returned: + + .. code-block:: json + + { + "orderRef": "ee3421ea-2096-4000-8130-82648efe0927", + "autoStartToken": "e8df5c3c-c67b-4a01-bfe5-fefeab760beb", + "qrStartToken": "01f94e28-857f-4d8a-bf8e-6c5a24466658", + "qrStartSecret": "b4214886-3b5b-46ab-bc08-6862fddc0e06" + } + + :param end_user_ip: The user IP address as seen by RP. String. IPv4 and IPv6 is allowed. + :type end_user_ip: str + :param requirement: Requirements on how the auth order must be performed. + See the section `Requirements `_ for more details. + :type requirement: dict + :param user_visible_data: Text displayed to the user during authentication with BankID, + with the purpose of providing context for the authentication and to enable users + to detect identification errors and averting fraud attempts. + :type user_visible_data: str + :param user_non_visible_data: Data is not displayed to the user. + :type user_non_visible_data: str + :param user_visible_data_format: If present, and set to “simpleMarkdownV1”, + this parameter indicates that userVisibleData holds formatting characters which + potentially make for a more pleasant user experience. + :type user_visible_data_format: str + :return: The order response. + :rtype: dict + :raises BankIDError: raises a subclass of this error + when error has been returned from server. + + """ + data = self._create_payload( + end_user_ip, + requirement=requirement, + user_visible_data=user_visible_data, + user_non_visible_data=user_non_visible_data, + user_visible_data_format=user_visible_data_format, + ) + + response = self.client.post(self._auth_endpoint, json=data) + + if response.status_code == 200: + return response.json() + else: + raise get_json_error_class(response) + + def phone_authenticate( + self, + personal_number: str, + call_initiator: str, + requirement: Dict[str, Any] = None, + user_visible_data: str = None, + user_non_visible_data: str = None, + user_visible_data_format: str = None, + ) -> Dict[str, str]: + """Initiates an authentication order when the user is talking + to the RP over the phone. The :py:meth:`collect` method + is used to query the status of the order. + + Example data returned: + + .. code-block:: json + + { + "orderRef": "ee3421ea-2096-4000-8130-82648efe0927" + } + + :param personal_number: The personal number of the user. 12 digits. + :type personal_number: str + :param call_initiator: Indicate if the user or the RP initiated the phone call. + "user": user called the RP + "RP": RP called the user + :type call_initiator: str + :param requirement: Requirements on how the auth order must be performed. + See the section `Requirements `_ for more details. + :type requirement: dict + :param user_visible_data: Text displayed to the user during authentication with BankID, + with the purpose of providing context for the authentication and to enable users + to detect identification errors and averting fraud attempts. + :type user_visible_data: str + :param user_non_visible_data: Data is not displayed to the user. + :type user_non_visible_data: str + :param user_visible_data_format: If present, and set to “simpleMarkdownV1”, + this parameter indicates that userVisibleData holds formatting characters which + potentially make for a more pleasant user experience. + :type user_visible_data_format: str + :return: The order response. + :rtype: dict + :raises BankIDError: raises a subclass of this error + when error has been returned from server. + + """ + if call_initiator not in ["user", "RP"]: + raise ValueError("call_initiator must be either 'user' or 'RP'") + + data = self._create_payload( + requirement=requirement, + user_visible_data=user_visible_data, + user_non_visible_data=user_non_visible_data, + user_visible_data_format=user_visible_data_format, + ) + data["personalNumber"] = personal_number + data["callInitiator"] = call_initiator + + response = self.client.post(self._phone_auth_endpoint, json=data) + + if response.status_code == 200: + return response.json() + else: + raise get_json_error_class(response) + + def sign( + self, + end_user_ip: str, + user_visible_data: str, + requirement: Dict[str, Any] = None, + user_non_visible_data: str = None, + user_visible_data_format: str = None, + ) -> Dict[str, str]: + """Request a signing order. The :py:meth:`collect` method + is used to query the status of the order. + + Example data returned: + + .. code-block:: json + + { + "orderRef": "ee3421ea-2096-4000-8130-82648efe0927", + "autoStartToken": "e8df5c3c-c67b-4a01-bfe5-fefeab760beb", + "qrStartToken": "01f94e28-857f-4d8a-bf8e-6c5a24466658", + "qrStartSecret": "b4214886-3b5b-46ab-bc08-6862fddc0e06" + } + + :param end_user_ip: The user IP address as seen by RP. String. IPv4 and IPv6 is allowed. + :type end_user_ip: str + :param requirement: Requirements on how the sign order must be performed. + See the section `Requirements `_ for more details. + :type requirement: dict + :param user_visible_data: Text to be displayed to the user. + :type user_visible_data: str + :param user_non_visible_data: Data is not displayed to the user. + :type user_non_visible_data: str + :param user_visible_data_format: If present, and set to “simpleMarkdownV1”, + this parameter indicates that userVisibleData holds formatting characters which + potentially make for a more pleasant user experience. + :type user_visible_data_format: str + :return: The order response. + :rtype: dict + :raises BankIDError: raises a subclass of this error + when error has been returned from server. + + """ + data = self._create_payload( + end_user_ip, + requirement=requirement, + user_visible_data=user_visible_data, + user_non_visible_data=user_non_visible_data, + user_visible_data_format=user_visible_data_format, + ) + response = self.client.post(self._sign_endpoint, json=data) + + if response.status_code == 200: + return response.json() + else: + raise get_json_error_class(response) + + def phone_sign( + self, + personal_number: str, + call_initiator: str, + user_visible_data: str, + requirement: Dict[str, Any] = None, + user_non_visible_data: str = None, + user_visible_data_format: str = None, + ) -> Dict[str, str]: + """Initiates an authentication order when the user is talking to + the RP over the phone. The :py:meth:`collect` method + is used to query the status of the order. + + Example data returned: + + .. code-block:: json + + { + "orderRef": "ee3421ea-2096-4000-8130-82648efe0927" + } + + :param personal_number: The personal number of the user. 12 digits. + :type personal_number: str + :param call_initiator: Indicate if the user or the RP initiated the phone call. + "user": user called the RP + "RP": RP called the user + :type call_initiator: str + :param requirement: Requirements on how the sign order must be performed. + See the section `Requirements `_ for more details. + :type requirement: dict + :param user_visible_data: Text to be displayed to the user. + :type user_visible_data: str + :param user_non_visible_data: Data is not displayed to the user. + :type user_non_visible_data: str + :param user_visible_data_format: If present, and set to “simpleMarkdownV1”, + this parameter indicates that userVisibleData holds formatting characters which + potentially make for a more pleasant user experience. + :type user_visible_data_format: str + :return: The order response. + :rtype: dict + :raises BankIDError: raises a subclass of this error + when error has been returned from server. + + """ + if call_initiator not in ["user", "RP"]: + raise ValueError("call_initiator must be either 'user' or 'RP'") + + data = self._create_payload( + requirement=requirement, + user_visible_data=user_visible_data, + user_non_visible_data=user_non_visible_data, + user_visible_data_format=user_visible_data_format, + ) + data["personalNumber"] = personal_number + data["callInitiator"] = call_initiator + + response = self.client.post(self._phone_sign_endpoint, json=data) + + if response.status_code == 200: + return response.json() + else: + raise get_json_error_class(response) + + def collect(self, order_ref: str) -> dict: + """Collects the result of a sign or auth order using the + ``orderRef`` as reference. + + RP should keep on calling collect every two seconds if status is pending. + RP must abort if status indicates failed. The user identity is returned + when complete. + + Example collect results returned while authentication or signing is + still pending: + + .. code-block:: json + + { + "orderRef":"131daac9-16c6-4618-beb0-365768f37288", + "status":"pending", + "hintCode":"userSign" + } + + Example collect result when authentication or signing has failed: + + .. code-block:: json + + { + "orderRef":"131daac9-16c6-4618-beb0-365768f37288", + "status":"failed", + "hintCode":"userCancel" + } + + Example collect result when authentication or signing is successful + and completed: + + .. code-block:: json + + { + "orderRef": "131daac9-16c6-4618-beb0-365768f37288", + "status": "complete", + "completionData": { + "user": { + "personalNumber": "190000000000", + "name": "Karl Karlsson", + "givenName": "Karl", + "surname": "Karlsson" + }, + "device": { + "ipAddress": "192.168.0.1" + }, + "bankIdIssueDate": "2020-02-01", + "signature": "", + "ocspResponse": "" + } + } + + See `BankID Integration Guide `_ + for more details about how to inform end user of the current status, + whether it is pending, failed or completed. + + :param order_ref: The ``orderRef`` UUID returned from auth or sign. + :type order_ref: str + :return: The CollectResponse parsed to a dictionary. + :rtype: dict + :raises BankIDError: raises a subclass of this error + when error has been returned from server. + + """ + response = self.client.post(self._collect_endpoint, json={"orderRef": order_ref}) + + if response.status_code == 200: + return response.json() + else: + raise get_json_error_class(response) + + def cancel(self, order_ref: str) -> bool: + """Cancels an ongoing sign or auth order. + + This is typically used if the user cancels the order + in your service or app. + + :param order_ref: The UUID string specifying which order to cancel. + :type order_ref: str + :return: Boolean regarding success of cancellation. + :rtype: bool + :raises BankIDError: raises a subclass of this error + when error has been returned from server. + + """ + response = self.client.post(self._cancel_endpoint, json={"orderRef": order_ref}) + + if response.status_code == 200: + return response.json() == {} + else: + raise get_json_error_class(response) diff --git a/docs/_static/.gitkeep b/docs/_static/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docs/api_reference.rst b/docs/api_reference.rst new file mode 100644 index 0000000..ff134a6 --- /dev/null +++ b/docs/api_reference.rst @@ -0,0 +1,29 @@ +.. _api_reference: + + +API Reference +============= + +Base Client +~~~~~~~~~~~~~~~~~~~~ + +.. automodule:: bankid.baseclient + :members: + +Synchronous Client +~~~~~~~~~~~~~~~~~~ + +.. automodule:: bankid.syncclient + :members: + +Asynchronous Client +~~~~~~~~~~~~~~~~~~~ + +.. automodule:: bankid.asyncclient + :members: + +Exceptions +~~~~~~~~~~ +.. automodule:: bankid.exceptions + :members: + diff --git a/docs/certutils.rst b/docs/certutils.rst index 38f7c86..9a4caa3 100644 --- a/docs/certutils.rst +++ b/docs/certutils.rst @@ -27,11 +27,14 @@ be obtained through PyBankID: >>> import bankid >>> dir_to_save_cert_and_key_in = os.path.expanduser('~') >>> cert_and_key = bankid.create_bankid_test_server_cert_and_key( - dir_to_save_cert_and_key_in) + ... dir_to_save_cert_and_key_in + ... ) >>> print(cert_and_key) ['/home/hbldh/certificate.pem', '/home/hbldh/key.pem'] >>> client = bankid.BankIDJSONClient( - certificates=cert_and_key, test_server=True) + ... certificates=cert_and_key, + ... test_server=True + ... ) The test certificate is available on `BankID Technical Information webpage `_. The @@ -41,11 +44,11 @@ into one certificate and one key part and converts it from `.p12 or .pfx `_ format to `pem `_. These can then be used for testing purposes, by sending in ``test_server=True`` -keyword in the :py:class:`~BankIDClient` or :py:class:`~BankIDJSONClient`. +keyword in the :py:class:`~BankIDClient` or :py:class:`~BankIDAsyncClient`. -Converting/splitting certificates ---------------------------------- +Splitting certificates +~~~~~~~~~~~~~~~~~~~~~~ To convert your production certificate from PKCS_12 format to two ``pem``, ready to be used by PyBankID, one can do the following: @@ -53,11 +56,12 @@ ready to be used by PyBankID, one can do the following: .. code-block:: python >>> from bankid.certutils import split_certificate - >>> split_certificate('/path/to/certificate.p12', - '/destination/folder/', - 'password_for_certificate_p12') - ('/destination/folder/certificate.pem', - '/destination/folder/key.pem') + >>> split_certificate( + ... '/path/to/certificate.p12', + ... '/destination/folder/', + ... 'password_for_certificate_p12', + ... ) + ('/destination/folder/certificate.pem', '/destination/folder/key.pem') It can also be done via regular OpenSSL terminal calls: diff --git a/docs/conf.py b/docs/conf.py index aff8650..91af308 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -12,9 +12,6 @@ # All configuration values have a default; values that are commented out # serve to show the default. -import sys -import os -import re import pathlib _version = {} @@ -65,16 +62,16 @@ # built documents. # # The short X.Y version. -version = _version['__version__'] +version = _version["__version__"] # The full version, including alpha/beta/rc tags. -release = _version['__version__'] +release = _version["__version__"] # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. # # This is also used if you do content translation via gettext catalogs. # Usually you set "language" from the command line for these cases. -language = None +language = "en" # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: diff --git a/docs/examples.rst b/docs/examples.rst index fbd233e..7f5dcc4 100644 --- a/docs/examples.rst +++ b/docs/examples.rst @@ -1,30 +1,73 @@ .. _examples: -PyBankID and QR code --------------------- +=================== +Generating QR codes +=================== -PyBankID cannot generate QR codes for you, but there is an example application in the +PyBankID can generate QR codes for you. There is an demo application in the `examples folder of the repo `_ where a Flask application called ``qrdemo`` shows one way to do authentication with animated QR codes. -The content for the QR code is generated by this method: +Below follows the app's README file, for your convenience. -.. code-block:: python +QR Authentication Example +------------------------- - import hashlib - import hmac - from math import floor - import time +Making a simple authentication via QR code solution using Flask, Flask-Caching and PyBankID. - def generate_qr_code_content(qr_start_token: str, start_t: float, qr_start_secret: str): - """Given QR start token, time.time() when initiated authentication call was made and the - QR start secret, calculate the current QR code content to display. - """ - elapsed_seconds_since_call = int(floor(time.time() - start_t)) - qr_auth_code = hmac.new( - qr_start_secret.encode(), - msg=str(elapsed_seconds_since_call).encode(), - digestmod=hashlib.sha256, - ).hexdigest() - return f"bankid.{qr_start_token}.{elapsed_seconds_since_call}.{qr_auth_code}" +Running the application +~~~~~~~~~~~~~~~~~~~~~~~ +1. Navigate your terminal to the same folder that this ``README.md`` resides in. +2. Create a virtualenv: ``python -m venv .venv`` +3. Activate it. +4. Install requirements: ``pip install -r requirements.txt`` +5. Run Flask app: + + 1. From Bash: + + .. code-block:: bash + + $ export FLASK_APP=qrdemo.app:app + $ flask run -h 0.0.0.0 + + 2. From Powershell: + + .. code-block:: powershell + + > $env:FLASK_APP = "qrdemo.app:app" + > flask run -h 0.0.0.0 + + +The app can now be accessed from the running computer on ``http://127.0.0.1:5000``, ``http://localhost:5000`` or from an +external device on the same network on ``http://:5000``. + + +Basic workflow +~~~~~~~~~~~~~~ + +These are the steps that the application takes: + +1. Ask the user for Swedish Personal Identity Number (PN) or initiate an authentication without. +2. Upon POSTing that PN to the backend, initiate a BankID ``authenticate`` session. This generates tokens that + one can create QR codes from using the ``client.generate_qr_code_content`` method. +3. Continuously update the QR code according to the description in the BankID Relying Party Guidelines + Version: 3.6 (see below, Chapter 4). The new QR code content to display MUST be fetched from the backend since + the ``qrStartSecret`` must never be shown to the user for the authentication to be trustworthy. +4. Also make ``collect`` calls to the BankID servers continuously and monitor if signing is complete or failed. +5. Redirect when complete or failed. + + +Missing components +~~~~~~~~~~~~~~~~~~ + +There are a few shortcuts taken here: + +- There is no error handling of ``status: failed`` results when collecting the authentication response. +- There is no ``Recommended User Messages (RFA)`` handling. It merely displays the ``status`` and ``hintCode`` from the collect response. +- The Cache is a memory cache on this single instance web app. + +References +~~~~~~~~~~ + +- `BankID Integration Guide `_ diff --git a/docs/exceptions.rst b/docs/exceptions.rst deleted file mode 100644 index 2cc9fca..0000000 --- a/docs/exceptions.rst +++ /dev/null @@ -1,13 +0,0 @@ -.. _exceptions: - -PyBankID Exceptions -=================== - -These are all the exceptions and warnings that PyBankID can raise. - -API ---- - -.. automodule:: bankid.exceptions - :members: - diff --git a/docs/get_started.rst b/docs/get_started.rst index 2ccbf3d..43db15f 100644 --- a/docs/get_started.rst +++ b/docs/get_started.rst @@ -3,6 +3,8 @@ Getting Started =============== +PyBankID uses BankID JSON API version 6.0 released in May 2023. + Installation ------------ @@ -12,41 +14,149 @@ PyBankID can be installed though pip: pip install pybankid -To remedy the ``InsecurePlatformWarning`` problem detailed below -(`Python 2, urllib3 and certificate verification`_), you can install -``pybankid`` with the ``security`` extras: - -.. code-block:: bash - - pip install pybankid[security] - -This installs the ``pyopenssl``, ``ndg-httpsclient`` and ``pyasn1`` packages -as well. - -In Linux, this does however require the installation of some additional -system packages: - -.. code-block:: bash - - sudo apt-get install build-essential libssl-dev libffi-dev python-dev - -See the `cryptography package's documentation for details `_. - Dependencies ------------ PyBankID makes use of the following external packages: -* `requests>=2.20.0 `_ -* `six>=1.10.0 `_ - - -Python 2, urllib3 and certificate verification -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -An ``InsecurePlatformWarning`` is issued when using the client in Python 2 (See -`urllib3 documentation `_). -This can be remedied by installing ``pyopenssl`` according to -`this issue `_ and -`docstrings in requests `_. -Optionally, the environment variable ``PYBANKID_DISABLE_WARNINGS`` can be set to disable these warnings. +* `httpx `_ +* `importlib-resources >= 5.12.0 `_ + +Using the client +---------------- + +PyBankID provides both a synchronous and an asynchronous client for +communication with BankID services. Example below will use the asynchronous +client, but the synchronous client is used in the same way by merely omitting +the ``await`` keyword. + +Get started by importing and initializing the client: + +.. code-block:: python + + >>> from bankid import BankIDAsyncClient + >>> client = BankIDAsyncClient(certificates=( + ... 'path/to/certificate.pem', + ... 'path/to/key.pem', + ... )) + +The client will by default connect to production servers. If test +server is desired, pass the ``test_server=True`` keyword to the client. + +When using the JSON client, all authentication and signing calls requires +the end user's ip address to be included the requests. An authentication order +is initiated as such: + +.. code-block:: python + + >>> await client.authenticate(end_user_ip='194.168.2.25') + { + 'orderRef': 'ee3421ea-2096-4000-8130-82648efe0927', + 'autoStartToken': 'e8df5c3c-c67b-4a01-bfe5-fefeab760beb', + 'qrStartToken': '01f94e28-857f-4d8a-bf8e-6c5a24466658', + 'qrStartSecret': 'b4214886-3b5b-46ab-bc08-6862fddc0e06' + } + +and a sign order is initiated in a similar fashion: + +.. code-block:: python + + >>> await client.sign( + ... end_user_ip='194.168.2.25', + ... user_visible_data="The information to sign." + ...) + { + 'orderRef': 'ee3421ea-2096-4000-8130-82648efe0927', + 'autoStartToken': 'e8df5c3c-c67b-4a01-bfe5-fefeab760beb', + 'qrStartToken': '01f94e28-857f-4d8a-bf8e-6c5a24466658', + 'qrStartSecret': 'b4214886-3b5b-46ab-bc08-6862fddc0e06' + } + +If you want to ascertain that only one individual can authenticate or sign, you can +specify this using the ``requirement`` keyword: + +.. code-block:: python + + >>> await client.sign( + ... end_user_ip='194.168.2.25', + ... user_visible_data="The information to sign." + ... requirement={"personalNumber": "YYYYMMDDXXXX"} + ...) + { + 'orderRef': 'ee3421ea-2096-4000-8130-82648efe0927', + 'autoStartToken': 'e8df5c3c-c67b-4a01-bfe5-fefeab760beb', + 'qrStartToken': '01f94e28-857f-4d8a-bf8e-6c5a24466658', + 'qrStartSecret': 'b4214886-3b5b-46ab-bc08-6862fddc0e06' + } + +If someone else than the one you specified tries to authenticate or sign, the +BankID app will state that the request is not intended for the user. + +The status of an order can then be studied by polling +with the ``collect`` method using the received ``orderRef``: + +.. code-block:: python + + >>> await client.collect("a9b791c3-459f-492b-bf61-23027876140b") + { + 'hintCode': 'outstandingTransaction', + 'orderRef': 'a9b791c3-459f-492b-bf61-23027876140b', + 'status': 'pending' + } + >>> await client.collect("a9b791c3-459f-492b-bf61-23027876140b") + { + 'hintCode': 'userSign', + 'orderRef': 'a9b791c3-459f-492b-bf61-23027876140b', + 'status': 'pending' + } + >>> await client.collect("a9b791c3-459f-492b-bf61-23027876140b") + { + 'completionData': { + 'cert': { + 'notAfter': '1581289199000', + 'notBefore': '1518130800000' + }, + 'device': { + 'ipAddress': '0.0.0.0' + }, + 'ocspResponse': 'MIIHegoBAKCCB[...]', + 'signature': 'PD94bWwgdmVyc2lv[...]', + 'user': { + 'givenName': 'Namn', + 'name': 'Namn Namnsson', + 'personalNumber': 'YYYYMMDDXXXX', + 'surname': 'Namnsson' + } + }, + 'orderRef': 'a9b791c3-459f-492b-bf61-23027876140b', + 'status': 'complete' + } + +Please note that the ``collect`` method should be used sparingly: in the +`BankID Integration Guide `_ +it is specified that *"collect should be called every two seconds and must not be +called more frequent than once per second"*. + +Synchronous client +------------------ + +The synchronous client is used in the same way as the asynchronous client, but the +methods are blocking. + +The asynchronous guide above can be used as a reference for the synchronous client +as well, by simply removing the ``await`` keyword. + +.. code-block:: python + + >>> from bankid import BankIDClient + >>> client = BankIDClient(certificates=( + ... 'path/to/certificate.pem', + ... 'path/to/key.pem', + ... )) + >>> client.authenticate(end_user_ip='194.168.2.25') + { + 'orderRef': 'ee3421ea-2096-4000-8130-82648efe0927', + 'autoStartToken': 'e8df5c3c-c67b-4a01-bfe5-fefeab760beb', + 'qrStartToken': '01f94e28-857f-4d8a-bf8e-6c5a24466658', + 'qrStartSecret': 'b4214886-3b5b-46ab-bc08-6862fddc0e06' + } diff --git a/docs/index.rst b/docs/index.rst index 8b8807b..0617229 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -23,9 +23,14 @@ providing authentication and signing functionality to end users. This package provides a simplifying interface for initiating authentication and signing orders and then collecting the results from the BankID servers. +The only supported BankID API version supported by PyBankID from version 1.0.0 +is v6.0, which means that the Secure Start solution is the only supported way +of providing BankID services. PyBankID versions prior to 1.0.0 will not +work after 1st of May 2024. + If you intend to use PyBankID in your project, you are advised to read -the `BankID Relying Party Guidelines -`_ before +the `BankID Integration Guide +`_ before doing anything else. There, one can find information about how the BankID methods are defined and how to use them. @@ -34,10 +39,9 @@ about how the BankID methods are defined and how to use them. :maxdepth: 2 get_started - jsonclient - exceptions certutils examples + api_reference Indices and tables diff --git a/docs/jsonclient.rst b/docs/jsonclient.rst deleted file mode 100644 index 5326431..0000000 --- a/docs/jsonclient.rst +++ /dev/null @@ -1,111 +0,0 @@ -.. _jsonclient: - -BankID JSON Client -================== - -:py:class:`bankid.jsonclient.BankIDJSONClient` is the client to be used to -communicate with the BankID service. It uses the JSON API version 5.1 released in April 2020. - -Usage ------ - -Create a client: - -.. code-block:: python - - >>> from bankid import BankIDJSONClient - >>> client = BankIDJSONClient(certificates=('path/to/certificate.pem', - ... 'path/to/key.pem')) - -Connection to production server is the default in the client. If test -server is desired, send in the ``test_server=True`` keyword in the init -of the client. - -When using the JSON client, all authentication and signing calls requires -the end user's ip address to be included the requests. An authentication order -is initiated as such: - -.. code-block:: python - - >>> client.authenticate(end_user_ip='194.168.2.25', - ... personal_number="YYYYMMDDXXXX") - { - 'orderRef': 'ee3421ea-2096-4000-8130-82648efe0927', - 'autoStartToken': 'e8df5c3c-c67b-4a01-bfe5-fefeab760beb', - 'qrStartToken': '01f94e28-857f-4d8a-bf8e-6c5a24466658', - 'qrStartSecret': 'b4214886-3b5b-46ab-bc08-6862fddc0e06' - } - -and a sign order is initiated in a similar fashion: - -.. code-block:: python - - >>> client.sign(end_user_ip='194.168.2.25', - ... user_visible_data="The information to sign.", - ... personal_number="YYYYMMDDXXXX") - { - 'orderRef': 'ee3421ea-2096-4000-8130-82648efe0927', - 'autoStartToken': 'e8df5c3c-c67b-4a01-bfe5-fefeab760beb', - 'qrStartToken': '01f94e28-857f-4d8a-bf8e-6c5a24466658', - 'qrStartSecret': 'b4214886-3b5b-46ab-bc08-6862fddc0e06' - } - -Since the `BankIDJSONClient` is using the BankID ``v5`` JSON API, the `personal_number` can now be omitted when calling -`authenticate` and `sign`. See `BankID Relying Party Guidelines `_ -for more information about this. - -The status of an order can then be studied by polling -with the ``collect`` method using the received ``orderRef``: - -.. code-block:: python - - >>> client.collect(order_ref="a9b791c3-459f-492b-bf61-23027876140b") - { - 'hintCode': 'outstandingTransaction', - 'orderRef': 'a9b791c3-459f-492b-bf61-23027876140b', - 'status': 'pending' - } - >>> client.collect(order_ref="a9b791c3-459f-492b-bf61-23027876140b") - { - 'hintCode': 'userSign', - 'orderRef': 'a9b791c3-459f-492b-bf61-23027876140b', - 'status': 'pending' - } - >>> c.collect(order_ref="a9b791c3-459f-492b-bf61-23027876140b") - { - 'completionData': { - 'cert': { - 'notAfter': '1581289199000', - 'notBefore': '1518130800000' - }, - 'device': { - 'ipAddress': '0.0.0.0' - }, - 'ocspResponse': 'MIIHegoBAKCCB[...]', - 'signature': 'PD94bWwgdmVyc2lv[...]', - 'user': { - 'givenName': 'Namn', - 'name': 'Namn Namnsson', - 'personalNumber': 'YYYYMMDDXXXX', - 'surname': 'Namnsson' - } - }, - 'orderRef': 'a9b791c3-459f-492b-bf61-23027876140b', - 'status': 'complete' - } - -Please note that the ``collect`` method should be used sparingly: in the -`BankID Relying Party Guidelines `_ -it is specified that *"collect should be called every two seconds and must not be -called more frequent than once per second"*. - -QR Codes --------- - -See the examples section for more details: :ref:`examples`. - -API ---- - -.. automodule:: bankid.jsonclient - :members: diff --git a/examples/qrdemo/README.md b/examples/qrdemo/README.md index b6d27b1..a2a0582 100644 --- a/examples/qrdemo/README.md +++ b/examples/qrdemo/README.md @@ -28,7 +28,7 @@ external device on the same network on `http://:500 These are the steps that the application takes: -1. Ask the user for Swedish Personal Identity Number (PN). +1. Ask the user for Swedish Personal Identity Number (PN) or initiate an authentication without. 2. Upon POSTing that PN to the backend, initiate a BankID `authenticate` session. This generates tokens that one can create QR codes from using the `generate_qr_code_content` method. 3. Continuously update the QR code according to the description in the BankID Relying Party Guidelines @@ -48,5 +48,4 @@ There are a few shortcuts taken here: ## References -[BankID Relying Party Guidelines -Version: 3.6](https://www.bankid.com/assets/bankid/rp/bankid-relying-party-guidelines-v3.6.pdf) +[BankID Integration Guide](https://www.bankid.com/en/utvecklare/guider/teknisk-integrationsguide/) diff --git a/examples/qrdemo/qrdemo/app.py b/examples/qrdemo/qrdemo/app.py index 54e2446..09ff379 100644 --- a/examples/qrdemo/qrdemo/app.py +++ b/examples/qrdemo/qrdemo/app.py @@ -1,13 +1,11 @@ import pathlib import time -from math import floor -import hmac -import hashlib import uuid from flask import Flask, make_response, render_template, request, jsonify from flask_caching import Cache -from bankid import BankIDJSONClient +from bankid import BankIDClient +from bankid.exceptions import BankIDError from bankid.certutils import create_bankid_test_server_cert_and_key USE_TEST_SERVER = True @@ -19,13 +17,13 @@ # Flask app. For this demo it is sufficient to let it reside globally in this file. if USE_TEST_SERVER: cert_paths = create_bankid_test_server_cert_and_key(str(pathlib.Path(__file__).parent)) - client = BankIDJSONClient(cert_paths, test_server=True) + client = BankIDClient(cert_paths, test_server=True) else: # Set your own cert paths for you production certificate and key here. # Note that my recommendation is to get it to work with # test server certs first! cert_paths = ("certificate.pem", "key.pem") - client = BankIDJSONClient(cert_paths, test_server=False) + client = BankIDClient(cert_paths, test_server=False) # Frontend pages @@ -63,19 +61,16 @@ def auth_complete(): def initiate(): """Initiate a BankID Authentication session and cache details needed for QR code generation""" # Note that empty personal number is allowed here! That means that the - pn = request.form.get("personnumer") + pn = request.form.get("personnummer") - # From (https://www.bankid.com/assets/bankid/rp/bankid-relying-party-guidelines-v3.6.pdf): - # Note: If personal number is included in the call to the service, RP must - # consider setting the requirement tokenStartRequired to true. By this, the - # system enforces that no other device than the one started using the QR code - # or autoStartToken is used. + # By knowing the personal number of the one you want to autenticate, send in the + # personal number as a requirement to ensure that only autentication with that personal + # number is allowed. # Make Auth call to BankID. resp = client.authenticate( end_user_ip=request.remote_addr, # Get the IP of the device making the request. - personal_number=pn, - requirement={"tokenStartRequired": True if pn else False}, # Set to True if PN is provided. Recommended. + requirement={"personalNumber": pn} if pn else None, # Set to True if PN is provided. Recommended. ) # Record when this response was received. This is needed for generating sequential, animated QR codes. resp["start_t"] = time.time() @@ -83,7 +78,7 @@ def initiate(): # multi-instance apps. Using orderRef as key since it is unique and can be sent in a GET URL without problem. cache.set(resp.get("orderRef"), resp, timeout=5 * 60) # Generate the first QR code to display to user. - qr_content_0 = generate_qr_code_content(resp["qrStartToken"], resp["start_t"], resp["qrStartSecret"]) + qr_content_0 = client.generate_qr_code_content(resp["qrStartToken"], resp["start_t"], resp["qrStartSecret"]) return render_template( "qr.html", order_ref=resp["orderRef"], @@ -99,7 +94,7 @@ def get_qr_code(order_ref: str): if x is None: qr_content = "" else: - qr_content = generate_qr_code_content(x["qrStartToken"], x["start_t"], x["qrStartSecret"]) + qr_content = client.generate_qr_code_content(x["qrStartToken"], x["start_t"], x["qrStartSecret"]) response = make_response(qr_content, 200) response.mimetype = "text/plain" return response @@ -108,7 +103,11 @@ def get_qr_code(order_ref: str): @app.route("/collect/") def collect(order_ref: str): """Make collect calls to the BankID servers""" - collect_response = client.collect(order_ref) + try: + collect_response = client.collect(order_ref) + except BankIDError as e: + return jsonify(e.json), 400 + if collect_response.get("status") == "complete": # Create or Login the newly authenticated user, give it a session token or something similar. # Here I will use an ugly hack and just stick a generated UUID in a cookie so the frontend can tell @@ -125,17 +124,23 @@ def collect(order_ref: str): return jsonify(collect_response) -# Helper methods +@app.route("/cancel/") +def cancel(order_ref: str): + """Make a cancel call to the BankID servers""" + cancel_response = client.cancel(order_ref) + if cancel_response: + cache.delete(order_ref) + response = make_response(str(cancel_response), 200) + response.mimetype = "text/plain" + response.delete_cookie("QRDemo-Auth") + return response + else: + cache.delete(order_ref) + response = make_response(str(cancel_response), 500) + response.mimetype = "text/plain" + return response -def generate_qr_code_content(qr_start_token: str, start_t: float, qr_start_secret: str): - """Given QR start token, time.time() when initiated authentication call was made and the - QR start secret, calculate the current QR code content to display. - """ - elapsed_seconds_since_call = int(floor(time.time() - start_t)) - qr_auth_code = hmac.new( - qr_start_secret.encode(), - msg=str(elapsed_seconds_since_call).encode(), - digestmod=hashlib.sha256, - ).hexdigest() - return f"bankid.{qr_start_token}.{elapsed_seconds_since_call}.{qr_auth_code}" +@app.errorhandler(500) +def internal_error(error): + return str(error) diff --git a/examples/qrdemo/qrdemo/templates/index.html b/examples/qrdemo/qrdemo/templates/index.html index 0735b74..a1464c2 100644 --- a/examples/qrdemo/qrdemo/templates/index.html +++ b/examples/qrdemo/qrdemo/templates/index.html @@ -16,7 +16,7 @@

Initiate QR signing

- + Skicka in ett tomt fält för att starta autentisering utan personnummer.
diff --git a/examples/qrdemo/qrdemo/templates/qr.html b/examples/qrdemo/qrdemo/templates/qr.html index c68627b..869f85d 100644 --- a/examples/qrdemo/qrdemo/templates/qr.html +++ b/examples/qrdemo/qrdemo/templates/qr.html @@ -1,26 +1,32 @@ - - PyBankID QR Demo - + + PyBankID QR Demo + @@ -38,59 +44,100 @@

Perform QR signing

crossorigin="anonymous"> \ No newline at end of file diff --git a/examples/qrdemo/requirements.txt b/examples/qrdemo/requirements.txt index cd5843b..9da1046 100644 --- a/examples/qrdemo/requirements.txt +++ b/examples/qrdemo/requirements.txt @@ -1,3 +1,3 @@ flask==2.3.2 -pybankid==0.14.0 +pybankid Flask-Caching==1.10.1 diff --git a/requirements-dev.txt b/requirements-dev.txt index 8a33915..7aae967 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,3 +1,7 @@ +flake8 +mock pytest +pytest-asyncio +pytest-cov sphinx -sphinx-rtd-theme \ No newline at end of file +sphinx-rtd-theme diff --git a/requirements.txt b/requirements.txt index 111f18b..5690bb5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,2 @@ -requests>=2.20.0 +httpx +importlib-resources>=5.12.0 diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000..46121f3 --- /dev/null +++ b/ruff.toml @@ -0,0 +1 @@ +line-length = 127 diff --git a/setup.py b/setup.py index ba3cc79..70b1eee 100644 --- a/setup.py +++ b/setup.py @@ -14,8 +14,7 @@ The latest development version is available at the project's `GitHub site `_. -Created by hbldh -Created on 2013-09-14, 19:31 +.. moduleauthor:: hbldh """ @@ -24,7 +23,7 @@ import sys from shutil import rmtree -from setuptools import find_packages, setup, Command +from setuptools import Command, find_packages, setup # Package meta-data. NAME = "pybankid" @@ -34,7 +33,7 @@ AUTHOR = "Henrik Blidh" # What packages are required for this module to be executed? -REQUIRED = ["requests", "six"] +REQUIRED = open("requirements.txt").read().splitlines() here = os.path.abspath(os.path.dirname(__file__)) @@ -114,7 +113,6 @@ def run(self): # $ setup.py publish support. cmdclass={"upload": UploadCommand}, extras_require={ - "security": ["pyOpenSSL>=0.13", "ndg-httpsclient", "pyasn1"], - "signature-verification": {"pyOpenSSL", "asn1crypto", "freezegun", "pytz"}, + "signature-verification": {"pyOpenSSL", "asn1crypto", "pytz"}, }, ) diff --git a/tests/conftest.py b/tests/conftest.py index bab4773..0cb3405 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,19 +1,66 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- +import random +from typing import Awaitable +import httpx import pytest -import requests +import pytest_asyncio -import bankid from bankid.certs import get_test_cert_and_key -@pytest.fixture(scope="module") -def ip_address(): - return requests.get("https://httpbin.org/ip").json()["origin"].split(",")[0] +@pytest.fixture() +def ip_address() -> str: + with httpx.Client() as client: + response = client.get("https://httpbin.org/ip") + return response.json()["origin"].split(",")[0] -@pytest.fixture(scope="session") -def cert_and_key(tmpdir_factory): +@pytest_asyncio.fixture() +async def ip_address_async() -> str: + async with httpx.AsyncClient() as client: + response = await client.get("https://httpbin.org/ip") + return response.json()["origin"].split(",")[0] + + +@pytest.fixture() +def cert_and_key(): cert, key = get_test_cert_and_key() - return cert, key + return str(cert), str(key) + + +@pytest.fixture() +def random_personal_number(): + """Simple random Swedish personal number generator.""" + + def _luhn_digit(id_): + """Calculate Luhn control digit for personal number. + + Code adapted from `Faker + `_. + + :param id_: The partial number to calculate checksum of. + :type id_: str + :return: Integer digit in [0, 9]. + :rtype: int + + """ + + def digits_of(n): + return [int(i) for i in str(n)] + + id_ = int(id_) * 10 + digits = digits_of(id_) + checksum = sum(digits[-1::-2]) + for k in digits[-2::-2]: + checksum += sum(digits_of(k * 2)) + checksum %= 10 + + return checksum if checksum == 0 else 10 - checksum + + year = random.randint(1900, 2014) + month = random.randint(1, 12) + day = random.randint(1, 28) + suffix = random.randint(0, 999) + pn = "{0:04d}{1:02d}{2:02d}{3:03d}".format(year, month, day, suffix) + return pn + str(_luhn_digit(pn[2:])) diff --git a/tests/test_asyncclient.py b/tests/test_asyncclient.py new file mode 100644 index 0000000..81650fd --- /dev/null +++ b/tests/test_asyncclient.py @@ -0,0 +1,137 @@ +""" +:mod:`test_asyncclient` +======================= + +.. module:: test_asyncclient + :platform: Unix, Windows + :synopsis: + +.. moduleauthor:: tiwilliam + +Created on 2023-12-15 + +""" + +import uuid + +import pytest + +from bankid import BankIDAsyncClient, exceptions + + +@pytest.mark.asyncio +async def test_authentication_and_collect(cert_and_key, ip_address_async): + """Authenticate call and then collect with the returned orderRef UUID.""" + c = BankIDAsyncClient(certificates=cert_and_key, test_server=True) + assert "appapi2.test.bankid.com.pem" in c.verify_cert + out = await c.authenticate(ip_address_async) + assert isinstance(out, dict) + # UUID.__init__ performs the UUID compliance assertion. + uuid.UUID(out.get("orderRef"), version=4) + collect_status = await c.collect(out.get("orderRef")) + assert collect_status.get("status") == "pending" + assert collect_status.get("hintCode") in ("outstandingTransaction", "noClient") + + +@pytest.mark.asyncio +async def test_sign_and_collect(cert_and_key, ip_address_async): + """Sign call and then collect with the returned orderRef UUID.""" + + c = BankIDAsyncClient(certificates=cert_and_key, test_server=True) + out = await c.sign( + ip_address_async, + user_visible_data="The data to be signed", + user_non_visible_data="Non visible data", + ) + assert isinstance(out, dict) + # UUID.__init__ performs the UUID compliance assertion. + uuid.UUID(out.get("orderRef"), version=4) + collect_status = await c.collect(out.get("orderRef")) + assert collect_status.get("status") == "pending" + assert collect_status.get("hintCode") in ("outstandingTransaction", "noClient") + + +@pytest.mark.asyncio +async def test_phone_sign_and_collect(cert_and_key, random_personal_number): + """Phone sign call and then collect with the returned orderRef UUID.""" + + c = BankIDAsyncClient(certificates=cert_and_key, test_server=True) + out = await c.phone_sign(random_personal_number, "RP", user_visible_data="The data to be signed") + assert isinstance(out, dict) + # UUID.__init__ performs the UUID compliance assertion. + order_ref = uuid.UUID(out.get("orderRef"), version=4) + collect_status = await c.collect(out.get("orderRef")) + assert collect_status.get("status") == "pending" + assert collect_status.get("hintCode") in ("outstandingTransaction", "noClient") + + +@pytest.mark.asyncio +async def test_invalid_orderref_raises_error(cert_and_key): + c = BankIDAsyncClient(certificates=cert_and_key, test_server=True) + with pytest.raises(exceptions.InvalidParametersError): + await c.collect("invalid-uuid") + + +@pytest.mark.asyncio +async def test_already_in_progress_raises_error(cert_and_key, ip_address_async, random_personal_number): + c = BankIDAsyncClient(certificates=cert_and_key, test_server=True) + await c.authenticate(ip_address_async, requirement={"personalNumber": random_personal_number}) + with pytest.raises(exceptions.AlreadyInProgressError): + await c.authenticate(ip_address_async, requirement={"personalNumber": random_personal_number}) + + +@pytest.mark.asyncio +async def test_already_in_progress_raises_error_2(cert_and_key, ip_address_async, random_personal_number): + c = BankIDAsyncClient(certificates=cert_and_key, test_server=True) + await c.sign( + ip_address_async, + requirement={"personalNumber": random_personal_number}, + user_visible_data="Text to sign", + ) + with pytest.raises(exceptions.AlreadyInProgressError): + await c.sign( + ip_address_async, requirement={"personalNumber": random_personal_number}, user_visible_data="Text to sign" + ) + + +@pytest.mark.asyncio +async def test_authentication_and_cancel(cert_and_key, ip_address_async): + """Authenticate call and then cancel it""" + c = BankIDAsyncClient(certificates=cert_and_key, test_server=True) + out = await c.authenticate(ip_address_async) + assert isinstance(out, dict) + # UUID.__init__ performs the UUID compliance assertion. + order_ref = uuid.UUID(out.get("orderRef"), version=4) + collect_status = await c.collect(out.get("orderRef")) + assert collect_status.get("status") == "pending" + assert collect_status.get("hintCode") in ("outstandingTransaction", "noClient") + success = await c.cancel(str(order_ref)) + assert success + with pytest.raises(exceptions.InvalidParametersError): + collect_status = await c.collect(out.get("orderRef")) + + +@pytest.mark.asyncio +async def test_phone_authentication_and_cancel(cert_and_key, random_personal_number): + """Phone authenticate call and then cancel it""" + + c = BankIDAsyncClient(certificates=cert_and_key, test_server=True) + out = await c.phone_authenticate(random_personal_number, "user") + assert isinstance(out, dict) + # UUID.__init__ performs the UUID compliance assertion. + order_ref = uuid.UUID(out.get("orderRef"), version=4) + collect_status = await c.collect(out.get("orderRef")) + assert collect_status.get("status") == "pending" + assert collect_status.get("hintCode") in ("outstandingTransaction", "noClient") + success = await c.cancel(str(order_ref)) + assert success + with pytest.raises(exceptions.InvalidParametersError): + collect_status = await c.collect(out.get("orderRef")) + + +@pytest.mark.asyncio +async def test_cancel_with_invalid_uuid(cert_and_key): + c = BankIDAsyncClient(certificates=cert_and_key, test_server=True) + invalid_order_ref = uuid.uuid4() + with pytest.raises(exceptions.InvalidParametersError): + await c.cancel(str(invalid_order_ref)) diff --git a/tests/test_certutils.py b/tests/test_certutils.py index f163b9c..53be7d0 100644 --- a/tests/test_certutils.py +++ b/tests/test_certutils.py @@ -1,18 +1,16 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- import os +from pytest import TempdirFactory + import bankid -# def test_certutils_main(): -# bankid.certutils.main(verbose=False) -# -# assert os.path.exists(os.path.expanduser("~/certificate.pem")) -# assert os.path.exists(os.path.expanduser("~/key.pem")) -# -# try: -# os.remove(os.path.expanduser("~/certificate.pem")) -# os.remove(os.path.expanduser("~/key.pem")) -# except: -# pass +def test_create_bankid_test_server_cert_and_key(tmpdir_factory: TempdirFactory): + paths = bankid.certutils.create_bankid_test_server_cert_and_key(tmpdir_factory.mktemp("certs")) + assert os.path.exists(paths[0]) + assert os.path.exists(paths[1]) + try: + os.remove(paths[0]) + os.remove(paths[1]) + except Exception: + pass diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index 04295d5..95fb4af 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -1,5 +1,4 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- +from collections import namedtuple import pytest @@ -39,8 +38,6 @@ def test_exceptions(exception_class, rfa): ], ) def test_error_class_factory(exception_class, error_code): - from collections import namedtuple - MockResponse = namedtuple("MockResponse", ["json"]) response = MockResponse(json=lambda: {"errorCode": error_code}) e_class = bankid.exceptions.get_json_error_class(response) diff --git a/tests/test_jsonclient.py b/tests/test_jsonclient.py deleted file mode 100644 index bdb1e4c..0000000 --- a/tests/test_jsonclient.py +++ /dev/null @@ -1,158 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -""" -:mod:`test_client` -================== - -.. module:: test_client - :platform: Unix, Windows - :synopsis: - -.. moduleauthor:: hbldh - -Created on 2015-08-07, 12:00 - -""" - -from __future__ import division -from __future__ import print_function -from __future__ import unicode_literals -from __future__ import absolute_import - -import random -import tempfile -import uuid - -import pytest - -try: - from unittest import mock -except: - import mock - -import bankid - - -def _get_random_personal_number(): - """Simple random Swedish personal number generator.""" - - def _luhn_digit(id_): - """Calculate Luhn control digit for personal number. - - Code adapted from `Faker - `_. - - :param id_: The partial number to calculate checksum of. - :type id_: str - :return: Integer digit in [0, 9]. - :rtype: int - - """ - - def digits_of(n): - return [int(i) for i in str(n)] - - id_ = int(id_) * 10 - digits = digits_of(id_) - checksum = sum(digits[-1::-2]) - for k in digits[-2::-2]: - checksum += sum(digits_of(k * 2)) - checksum %= 10 - - return checksum if checksum == 0 else 10 - checksum - - year = random.randint(1900, 2014) - month = random.randint(1, 12) - day = random.randint(1, 28) - suffix = random.randint(0, 999) - pn = "{0:04d}{1:02d}{2:02d}{3:03d}".format(year, month, day, suffix) - return pn + str(_luhn_digit(pn[2:])) - - -def test_authentication_and_collect(cert_and_key, ip_address): - """Authenticate call and then collect with the returned orderRef UUID.""" - - c = bankid.BankIDJSONClient(certificates=cert_and_key, test_server=True) - assert "appapi2.test.bankid.com.pem" in c.verify_cert - out = c.authenticate(ip_address, _get_random_personal_number()) - assert isinstance(out, dict) - # UUID.__init__ performs the UUID compliance assertion. - order_ref = uuid.UUID(out.get("orderRef"), version=4) - collect_status = c.collect(out.get("orderRef")) - assert collect_status.get("status") == "pending" - assert collect_status.get("hintCode") in ("outstandingTransaction", "noClient") - - -def test_sign_and_collect(cert_and_key, ip_address): - """Sign call and then collect with the returned orderRef UUID.""" - - c = bankid.BankIDJSONClient(certificates=cert_and_key, test_server=True) - out = c.sign( - ip_address, - "The data to be signed", - personal_number=_get_random_personal_number(), - user_non_visible_data="Non visible data", - ) - assert isinstance(out, dict) - # UUID.__init__ performs the UUID compliance assertion. - order_ref = uuid.UUID(out.get("orderRef"), version=4) - collect_status = c.collect(out.get("orderRef")) - assert collect_status.get("status") == "pending" - assert collect_status.get("hintCode") in ("outstandingTransaction", "noClient") - - -def test_invalid_orderref_raises_error(cert_and_key): - c = bankid.BankIDJSONClient(certificates=cert_and_key, test_server=True) - with pytest.raises(bankid.exceptions.InvalidParametersError): - collect_status = c.collect("invalid-uuid") - - -def test_already_in_progress_raises_error(cert_and_key, ip_address): - c = bankid.BankIDJSONClient(certificates=cert_and_key, test_server=True) - pn = _get_random_personal_number() - out = c.authenticate(ip_address, pn) - with pytest.raises(bankid.exceptions.AlreadyInProgressError): - out2 = c.authenticate(ip_address, pn) - - -def test_already_in_progress_raises_error_2(cert_and_key, ip_address): - c = bankid.BankIDJSONClient(certificates=cert_and_key, test_server=True) - pn = _get_random_personal_number() - out = c.sign(ip_address, "Text to sign", pn) - with pytest.raises(bankid.exceptions.AlreadyInProgressError): - out2 = c.sign(ip_address, "Text to sign", pn) - - -def test_authentication_and_cancel(cert_and_key, ip_address): - """Authenticate call and then cancel it""" - - c = bankid.BankIDJSONClient(certificates=cert_and_key, test_server=True) - out = c.authenticate(ip_address, _get_random_personal_number()) - assert isinstance(out, dict) - # UUID.__init__ performs the UUID compliance assertion. - order_ref = uuid.UUID(out.get("orderRef"), version=4) - collect_status = c.collect(out.get("orderRef")) - assert collect_status.get("status") == "pending" - assert collect_status.get("hintCode") in ("outstandingTransaction", "noClient") - success = c.cancel(str(order_ref)) - assert success - with pytest.raises(bankid.exceptions.InvalidParametersError): - collect_status = c.collect(out.get("orderRef")) - - -def test_cancel_with_invalid_uuid(cert_and_key): - c = bankid.BankIDJSONClient(certificates=cert_and_key, test_server=True) - invalid_order_ref = uuid.uuid4() - with pytest.raises(bankid.exceptions.InvalidParametersError): - cancel_status = c.cancel(str(invalid_order_ref)) - - -@pytest.mark.parametrize( - "test_server, endpoint", - [(False, "appapi2.bankid.com"), (True, "appapi2.test.bankid.com")], -) -def test_correct_prod_server_urls(cert_and_key, test_server, endpoint): - c = bankid.BankIDJSONClient(certificates=cert_and_key, test_server=test_server) - assert c.api_url == "https://{0}/rp/v5.1/".format(endpoint) - assert "{0}.pem".format(endpoint) in c.verify_cert diff --git a/tests/test_syncclient.py b/tests/test_syncclient.py new file mode 100644 index 0000000..f1fc3b5 --- /dev/null +++ b/tests/test_syncclient.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +:mod:`test_syncclient` +====================== + +.. module:: test_syncclient + :platform: Unix, Windows + :synopsis: + +.. moduleauthor:: mxamin + +Created on 2024-01-18 + +""" +import uuid + +import pytest + +try: + from unittest import mock +except: + import mock + +from bankid import BankIDClient, exceptions + + +def test_authentication_and_collect(cert_and_key, ip_address, random_personal_number): + """Authenticate call and then collect with the returned orderRef UUID.""" + + c = BankIDClient(certificates=cert_and_key, test_server=True) + assert "appapi2.test.bankid.com.pem" in c.verify_cert + out = c.authenticate(ip_address, random_personal_number) + assert isinstance(out, dict) + # UUID.__init__ performs the UUID compliance assertion. + order_ref = uuid.UUID(out.get("orderRef"), version=4) + collect_status = c.collect(out.get("orderRef")) + assert collect_status.get("status") == "pending" + assert collect_status.get("hintCode") in ("outstandingTransaction", "noClient") + + +def test_sign_and_collect(cert_and_key, ip_address): + """Sign call and then collect with the returned orderRef UUID.""" + + c = BankIDClient(certificates=cert_and_key, test_server=True) + out = c.sign( + ip_address, + user_visible_data="The data to be signed", + user_non_visible_data="Non visible data", + ) + assert isinstance(out, dict) + # UUID.__init__ performs the UUID compliance assertion. + order_ref = uuid.UUID(out.get("orderRef"), version=4) + collect_status = c.collect(out.get("orderRef")) + assert collect_status.get("status") == "pending" + assert collect_status.get("hintCode") in ("outstandingTransaction", "noClient") + + +def test_phone_sign_and_collect(cert_and_key, random_personal_number): + """Phone sign call and then collect with the returned orderRef UUID.""" + + c = BankIDClient(certificates=cert_and_key, test_server=True) + out = c.phone_sign(random_personal_number, "user", user_visible_data="The data to be signed") + assert isinstance(out, dict) + # UUID.__init__ performs the UUID compliance assertion. + order_ref = uuid.UUID(out.get("orderRef"), version=4) + collect_status = c.collect(out.get("orderRef")) + assert collect_status.get("status") == "pending" + assert collect_status.get("hintCode") in ("outstandingTransaction", "noClient") + + +def test_invalid_orderref_raises_error(cert_and_key): + c = BankIDClient(certificates=cert_and_key, test_server=True) + with pytest.raises(exceptions.InvalidParametersError): + collect_status = c.collect("invalid-uuid") + + +def test_already_in_progress_raises_error(cert_and_key, ip_address, random_personal_number): + c = BankIDClient(certificates=cert_and_key, test_server=True) + out = c.authenticate(ip_address, requirement={"personalNumber": random_personal_number}) + with pytest.raises(exceptions.AlreadyInProgressError): + out2 = c.authenticate(ip_address, requirement={"personalNumber": random_personal_number}) + + +def test_already_in_progress_raises_error_2(cert_and_key, ip_address, random_personal_number): + c = BankIDClient(certificates=cert_and_key, test_server=True) + out = c.sign(ip_address, requirement={"personalNumber": random_personal_number}, user_visible_data="Text to sign") + with pytest.raises(exceptions.AlreadyInProgressError): + out2 = c.sign( + ip_address, requirement={"personalNumber": random_personal_number}, user_visible_data="Text to sign" + ) + + +def test_authentication_and_cancel(cert_and_key, ip_address, random_personal_number): + """Authenticate call and then cancel it""" + + c = BankIDClient(certificates=cert_and_key, test_server=True) + out = c.authenticate(ip_address, requirement={"personalNumber": random_personal_number}) + assert isinstance(out, dict) + # UUID.__init__ performs the UUID compliance assertion. + order_ref = uuid.UUID(out.get("orderRef"), version=4) + collect_status = c.collect(out.get("orderRef")) + assert collect_status.get("status") == "pending" + assert collect_status.get("hintCode") in ("outstandingTransaction", "noClient") + success = c.cancel(str(order_ref)) + assert success + with pytest.raises(exceptions.InvalidParametersError): + collect_status = c.collect(out.get("orderRef")) + + +def test_phone_authentication_and_cancel(cert_and_key, random_personal_number): + """Phone authenticate call and then cancel it""" + + c = BankIDClient(certificates=cert_and_key, test_server=True) + out = c.phone_authenticate(random_personal_number, "user") + assert isinstance(out, dict) + # UUID.__init__ performs the UUID compliance assertion. + order_ref = uuid.UUID(out.get("orderRef"), version=4) + collect_status = c.collect(out.get("orderRef")) + assert collect_status.get("status") == "pending" + assert collect_status.get("hintCode") in ("outstandingTransaction", "noClient") + success = c.cancel(str(order_ref)) + assert success + with pytest.raises(exceptions.InvalidParametersError): + collect_status = c.collect(out.get("orderRef")) + + +def test_cancel_with_invalid_uuid(cert_and_key): + c = BankIDClient(certificates=cert_and_key, test_server=True) + invalid_order_ref = uuid.uuid4() + with pytest.raises(exceptions.InvalidParametersError): + cancel_status = c.cancel(str(invalid_order_ref)) + + +@pytest.mark.parametrize( + "test_server, endpoint", + [(False, "appapi2.bankid.com"), (True, "appapi2.test.bankid.com")], +) +def test_correct_prod_server_urls(cert_and_key, test_server, endpoint): + c = BankIDClient(certificates=cert_and_key, test_server=test_server) + assert c.api_url == "https://{0}/rp/v6.0/".format(endpoint) + assert "{0}.pem".format(endpoint) in c.verify_cert