diff --git a/bankid/__init__.py b/bankid/__init__.py index 388b10b..c80df4a 100644 --- a/bankid/__init__.py +++ b/bankid/__init__.py @@ -11,8 +11,8 @@ 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. @@ -22,10 +22,12 @@ from .__version__ import __version__, version from .certutils import create_bankid_test_server_cert_and_key from .jsonclient import AsyncBankIDJSONClient, BankIDJSONClient +from .jsonclient6 import BankIDJSONClient6 __all__ = [ "BankIDJSONClient", "AsyncBankIDJSONClient", + "BankIDJSONClient6", "exceptions", "create_bankid_test_server_cert_and_key", "__version__", diff --git a/bankid/__version__.py b/bankid/__version__.py index 27a327a..f90f7c4 100644 --- a/bankid/__version__.py +++ b/bankid/__version__.py @@ -3,5 +3,5 @@ Version info """ -__version__ = "0.14.1" +__version__ = "0.15.0" version = __version__ # backwards compatibility name diff --git a/bankid/jsonclient6.py b/bankid/jsonclient6.py new file mode 100644 index 0000000..c82190e --- /dev/null +++ b/bankid/jsonclient6.py @@ -0,0 +1,273 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +:mod:`bankid.jsonclient6` -- BankID JSON Client +============================================== + +Created on 2024-01-18 by mxamin + +""" +import base64 +from urllib import parse as urlparse + +import requests + +from bankid.certutils import resolve_cert_path +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 BankIDJSONClient6(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/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.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, + requirement=None, + user_visible_data=None, + user_non_visible_data=None, + **kwargs + ): + """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: IP address of the user requesting + the authentication. + :type end_user_ip: str + :param requirement: An optional dictionary stating how the signature + must be created and verified. See BankID Relying Party Integration Guide for v6.0 + for more details. + :type requirement: dict + :param user_visible_data: The information that the end user + is requested to sign. + :type user_visible_data: str + :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 requirement and isinstance(requirement, dict): + data["requirement"] = requirement + if user_visible_data: + data["userVisibleData"] = _encode_user_data(user_visible_data) + if user_non_visible_data: + data["userNonVisibleData"] = _encode_user_data(user_non_visible_data) + # 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, + 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. + + 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 requirement: An optional dictionary stating how the signature + must be created and verified. See BankID Relying Party Integration Guide for v6.0 + 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} + 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 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._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/tests/test_jsonclient6.py b/tests/test_jsonclient6.py new file mode 100644 index 0000000..a6ba8e1 --- /dev/null +++ b/tests/test_jsonclient6.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +:mod:`test_client` +================== + +.. module:: test_client + :platform: Unix, Windows + :synopsis: + +.. moduleauthor:: mxamin + +Created on 2024-01-18 + +""" + +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.BankIDJSONClient6(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.BankIDJSONClient6(certificates=cert_and_key, test_server=True) + out = c.sign( + ip_address, + "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_invalid_orderref_raises_error(cert_and_key): + c = bankid.BankIDJSONClient6(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.BankIDJSONClient6(certificates=cert_and_key, test_server=True) + pn = _get_random_personal_number() + out = c.authenticate(ip_address, requirement={"personalNumber": pn}) + with pytest.raises(bankid.exceptions.AlreadyInProgressError): + out2 = c.authenticate(ip_address, requirement={"personalNumber": pn}) + + +def test_already_in_progress_raises_error_2(cert_and_key, ip_address): + c = bankid.BankIDJSONClient6(certificates=cert_and_key, test_server=True) + pn = _get_random_personal_number() + out = c.sign(ip_address, "Text to sign", requirement={"personalNumber": pn}) + with pytest.raises(bankid.exceptions.AlreadyInProgressError): + out2 = c.sign(ip_address, "Text to sign", requirement={"personalNumber": pn}) + + +def test_authentication_and_cancel(cert_and_key, ip_address): + """Authenticate call and then cancel it""" + + c = bankid.BankIDJSONClient6(certificates=cert_and_key, test_server=True) + out = c.authenticate(ip_address, requirement={"personalNumber": _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.BankIDJSONClient6(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.BankIDJSONClient6(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