From 3ce57aad3bcf7f3eabd4303a6e247ad0607ef89e Mon Sep 17 00:00:00 2001 From: Simon Olofsson <36161882+dotchetter@users.noreply.github.com> Date: Mon, 15 Nov 2021 22:11:23 +0100 Subject: [PATCH 01/26] Update README.rst Corrected typo in examples --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index b6bff14..57d247b 100644 --- a/README.rst +++ b/README.rst @@ -118,7 +118,7 @@ with the ``collect`` method using the received ``orderRef``: 'orderRef': 'a9b791c3-459f-492b-bf61-23027876140b', 'status': 'pending' } - >>> c.collect(order_ref="a9b791c3-459f-492b-bf61-23027876140b") + >>> client.collect(order_ref="a9b791c3-459f-492b-bf61-23027876140b") { 'completionData': { 'cert': { From 3fcb4471963fe1fcf84822bca64e4b5979aee758 Mon Sep 17 00:00:00 2001 From: Colin 't Hart Date: Thu, 4 May 2023 12:15:25 +0200 Subject: [PATCH 02/26] Update __init__.py Fix breakage with urllib 2.0.x --- bankid/__init__.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/bankid/__init__.py b/bankid/__init__.py index cc37d5b..d21cee7 100644 --- a/bankid/__init__.py +++ b/bankid/__init__.py @@ -19,10 +19,6 @@ """ -import warnings as _warnings - -from requests.packages.urllib3.exceptions import SubjectAltNameWarning as _sanw - from .jsonclient import BankIDJSONClient from .certutils import create_bankid_test_server_cert_and_key from .__version__ import __version__, version @@ -35,5 +31,3 @@ "__version__", "version", ] - -_warnings.simplefilter("ignore", _sanw) From 27ea5e1b885f5a50dd4060d9b49a2d34fe551388 Mon Sep 17 00:00:00 2001 From: Henrik Blidh Date: Fri, 5 May 2023 16:38:50 +0200 Subject: [PATCH 03/26] Github Action fixes --- .github/workflows/build_and_test.yml | 15 +++++++++++---- .github/workflows/pypi-publish.yml | 5 ++++- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/.github/workflows/build_and_test.yml b/.github/workflows/build_and_test.yml index 0f91e39..ed0e66e 100644 --- a/.github/workflows/build_and_test.yml +++ b/.github/workflows/build_and_test.yml @@ -18,30 +18,37 @@ jobs: strategy: matrix: os: [ubuntu-latest, windows-latest, macos-latest] - python-version: [2.7, 3.6, 3.7, 3.8, 3.9, '3.10'] + python-version: [2.7, 3.7, 3.8, 3.9, '3.10', '3.11'] steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 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 + - name: Lint with flake8 run: | # stop the build if there are Python syntax errors or undefined names flake8 ./bankid --count --select=E9,F63,F7,F82 --show-source --statistics # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide flake8 ./bankid --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + - name: Test with pytest run: | pytest tests --junitxml=junit/test-results-${{ matrix.os }}-${{ matrix.python-version }}.xml --cov=bankid --cov-report=xml --cov-report=html + - name: Upload pytest test results - uses: actions/upload-artifact@v1 + uses: actions/upload-artifact@v3 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 a3c276d..675bca6 100644 --- a/.github/workflows/pypi-publish.yml +++ b/.github/workflows/pypi-publish.yml @@ -14,15 +14,18 @@ jobs: steps: - uses: actions/checkout@v3 - name: Set up Python - uses: actions/setup-python@v3 + uses: actions/setup-python@v4 with: python-version: '3.x' + - name: Install dependencies run: | python -m pip install --upgrade pip pip install build + - name: Build package run: python -m build + - name: Publish package uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 with: From 5390edaf20bbfa1e6347f09d1e11ca019596cf05 Mon Sep 17 00:00:00 2001 From: Henrik Blidh Date: Fri, 5 May 2023 16:40:14 +0200 Subject: [PATCH 04/26] Version bump --- bankid/__version__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bankid/__version__.py b/bankid/__version__.py index 99c4dfb..cae8e5e 100644 --- a/bankid/__version__.py +++ b/bankid/__version__.py @@ -8,5 +8,5 @@ from __future__ import print_function from __future__ import absolute_import -__version__ = "0.13.1" +__version__ = "0.14.0" version = __version__ # backwards compatibility name From bd831f9381d09cceafb02eaa42a1553a4f496321 Mon Sep 17 00:00:00 2001 From: Henrik Blidh Date: Fri, 5 May 2023 16:41:31 +0200 Subject: [PATCH 05/26] Bump reqs for example --- examples/qrdemo/requirements.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/qrdemo/requirements.txt b/examples/qrdemo/requirements.txt index 1346f79..cd5843b 100644 --- a/examples/qrdemo/requirements.txt +++ b/examples/qrdemo/requirements.txt @@ -1,3 +1,3 @@ -flask==2.0.2 -pybankid==0.13.1 +flask==2.3.2 +pybankid==0.14.0 Flask-Caching==1.10.1 From 8e513f4a33b6aab302a342fea8230eaf5649e9e4 Mon Sep 17 00:00:00 2001 From: Henrik Blidh Date: Fri, 5 May 2023 19:12:51 +0200 Subject: [PATCH 06/26] Bundle the BankID Test certificate The BankID pages now returns a captcha instead of the actual certificate when fetching with requests. The actual cert is now bundled instead of fetched each time. --- bankid/certs/FPTestcert4_20220818.p12 | Bin 0 -> 2829 bytes bankid/certs/__init__.py | 6 ++++++ bankid/certutils.py | 27 +++++++------------------- setup.py | 2 +- 4 files changed, 14 insertions(+), 21 deletions(-) create mode 100644 bankid/certs/FPTestcert4_20220818.p12 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# Date: Sat, 6 May 2023 15:59:32 +0200 Subject: [PATCH 07/26] Failure detection in openssl test cert conversion --- bankid/certutils.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/bankid/certutils.py b/bankid/certutils.py index b6b0eeb..52aeda3 100644 --- a/bankid/certutils.py +++ b/bankid/certutils.py @@ -130,7 +130,13 @@ def split_certificate(certificate_path, destination_folder, password=None): p = subprocess.Popen( list(filter(None, pipeline_1)), stdout=subprocess.PIPE, stderr=subprocess.PIPE ) - p.communicate() + out, err = p.communicate() + + if p.returncode: + raise BankIDError( + f"Error converting certificate: {err.decode('utf-8')}" + ) + pipeline_2 = [ openssl_executable, "pkcs12", @@ -146,7 +152,12 @@ def split_certificate(certificate_path, destination_folder, password=None): p = subprocess.Popen( list(filter(None, pipeline_2)), stdout=subprocess.PIPE, stderr=subprocess.PIPE ) - p.communicate() + out, err = p.communicate() + + if p.returncode: + raise BankIDError( + f"Error converting certificate: {err.decode('utf-8')}" + ) # Return path tuples. return out_cert_path, out_key_path From 3b12c351971b4b92a81ae48d8ce14e975a8a975a Mon Sep 17 00:00:00 2001 From: Stefan Berg Date: Sat, 6 May 2023 16:04:20 +0200 Subject: [PATCH 08/26] Add possibility to provide p12 test cert through existing file --- bankid/certutils.py | 40 +++++++++++++++++++++++++--------------- 1 file changed, 25 insertions(+), 15 deletions(-) diff --git a/bankid/certutils.py b/bankid/certutils.py index 52aeda3..bfd1fb8 100644 --- a/bankid/certutils.py +++ b/bankid/certutils.py @@ -31,6 +31,9 @@ def create_bankid_test_server_cert_and_key(destination_path): a certificate part and a key part and save them as separate files, stored in PEM format. + If the environment variable TEST_CERT_FILE is set, use this file + instead of fetching the P12 certificate. + :param destination_path: The directory to save certificate and key files to. :type destination_path: str :returns: The path tuple ``(cert_path, key_path)``. @@ -38,22 +41,29 @@ def create_bankid_test_server_cert_and_key(destination_path): """ - # Fetch P12 certificate and store in temporary folder. - cert_tmp_path = os.path.join( - tempfile.gettempdir(), os.path.basename(_TEST_CERT_URL) - ) - r = requests.get(_TEST_CERT_URL) - with open(cert_tmp_path, "wb") as f: - f.write(r.content) + if os.getenv("TEST_CERT_FILE"): + certificate, key = split_certificate( + os.getenv("TEST_CERT_FILE"), destination_path, password=_TEST_CERT_PASSWORD + ) - certificate, key = split_certificate( - cert_tmp_path, destination_path, password=_TEST_CERT_PASSWORD - ) - # Try to remove temporary file. - try: # pragma: no cover - os.remove(cert_tmp_path) - except: # pragma: no cover - pass + else: + # Fetch P12 certificate and store in temporary folder. + cert_tmp_path = os.path.join( + tempfile.gettempdir(), os.path.basename(_TEST_CERT_URL) + ) + + r = requests.get(_TEST_CERT_URL) + with open(cert_tmp_path, "wb") as f: + f.write(r.content) + + certificate, key = split_certificate( + cert_tmp_path, destination_path, password=_TEST_CERT_PASSWORD + ) + # Try to remove temporary file. + try: # pragma: no cover + os.remove(cert_tmp_path) + except: # pragma: no cover + pass # Return path tuples. return certificate, key From dccb09688d99cb7725ce11c5e32382c637c33049 Mon Sep 17 00:00:00 2001 From: Henrik Blidh Date: Sun, 7 May 2023 15:00:52 +0200 Subject: [PATCH 09/26] Bundle the BankID Test certificate in pem format Bundle pem formats as well. --- bankid/certs/FPTestcert4_20220818_cert.pem | 34 ++++++++++++++++++++++ bankid/certs/FPTestcert4_20220818_key.pem | 31 ++++++++++++++++++++ bankid/certs/__init__.py | 7 +++++ tests/conftest.py | 4 +-- tests/test_jsonclient.py | 1 - 5 files changed, 74 insertions(+), 3 deletions(-) create mode 100644 bankid/certs/FPTestcert4_20220818_cert.pem create mode 100644 bankid/certs/FPTestcert4_20220818_key.pem diff --git a/bankid/certs/FPTestcert4_20220818_cert.pem b/bankid/certs/FPTestcert4_20220818_cert.pem new file mode 100644 index 0000000..fe69112 --- /dev/null +++ b/bankid/certs/FPTestcert4_20220818_cert.pem @@ -0,0 +1,34 @@ +Bag Attributes + localKeyID: A9 F3 0C D7 04 B6 7D 23 86 84 71 C3 E9 42 62 8B 1B D7 75 C3 +subject=C = SE, O = Testbank A AB (publ), serialNumber = 5566304928, name = Test av BankID, CN = FP Testcert 4 + +issuer=C = SE, O = Testbank A AB (publ), serialNumber = 111111111111, CN = Testbank A RP CA v1 for BankID Test + +-----BEGIN CERTIFICATE----- +MIIEyjCCArKgAwIBAgIIMLbIMaRHjMMwDQYJKoZIhvcNAQELBQAwcTELMAkGA1UE +BhMCU0UxHTAbBgNVBAoMFFRlc3RiYW5rIEEgQUIgKHB1YmwpMRUwEwYDVQQFEwwx +MTExMTExMTExMTExLDAqBgNVBAMMI1Rlc3RiYW5rIEEgUlAgQ0EgdjEgZm9yIEJh +bmtJRCBUZXN0MB4XDTIyMDgxNzIyMDAwMFoXDTI0MDgxODIxNTk1OVowcjELMAkG +A1UEBhMCU0UxHTAbBgNVBAoMFFRlc3RiYW5rIEEgQUIgKHB1YmwpMRMwEQYDVQQF +Ewo1NTY2MzA0OTI4MRcwFQYDVQQpDA5UZXN0IGF2IEJhbmtJRDEWMBQGA1UEAwwN +RlAgVGVzdGNlcnQgNDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAL4L +8ERHNSi7Jph9gj4ah7Ieok5lZHZbNyW1AiJJ1OfeD1lbAzxSidtTu6NfC83zxCjL +q091lHY5G7dpNDt1rN5Y+jQvrtcLc8nUpgqLfEUnbGKzZaHlO97jh6pqO8nj/mal +TrWI70Fr6SO3SxbsgxuwJXlRUAQxI0mPvD1gOd+uymA+EqdYS39ijC2eICHSf7bU +wvmscy8TAyEcT4GYmcjai1vbIjlhemmAv+NKJiSpD+zqvuHGIzBm71/Fd6cTAXqk +HkqTlJsxF2m6eojKCfcm5uAvSTXhVbGM155wmpzLskzkQ0dx6LbRNtA+BDe1MsAA +v8aE2FQ0j31ALgZePY0CAwEAAaNlMGMwEQYDVR0gBAowCDAGBgQqAwQFMA4GA1Ud +DwEB/wQEAwIHgDAfBgNVHSMEGDAWgBTiuVUIvGKgRjldgAxQSpIBy0zvizAdBgNV +HQ4EFgQUoiM2SwR2MdMVjaZz04J9LbOEau8wDQYJKoZIhvcNAQELBQADggIBAGBA +X1IC7mg1blaeqrTW+TtPkF7GvsbsWIh0RgG9DYRtXXofad3bn6kbDrfFXKZzv4JH +ERmJSyLXzMLoiwJB16V8Vz/kHT7AK94ZpLPjedPr2O4U2DGQXu1TwP5nkfgQxTeP +K/XnDVHNsMKqTnc+YNX6mj/UyLnbs8eq/a9uHOBJR30e0OPAdlc2fTbBT2Cui29E +ctcNH4LrcH4au9vO+RpEUm1hqZy3mHrx1p8Six6+qJSERNYIWTID8gklyp8MSyG5 +q7dk0WcyvytM1dmVf/q+KriljaZ8x2zLhQRz9vpgnfwJ6Qh3cLVoPItVdQ03WpKW +WAB1NCMMyNcszkLZ9OO3IRz8iyWV/KWGI07ngVuGa7dHuTje6ZjcObBCr2e4uuU+ +CLENcretUAv0BtCsOBhQLXZ0qzqrgsVebTRQzm2zTM0yfBpcTtPd3MOMFeMQTHJJ +8QH6twAKeJfY1lUCTXJYy1ZcrKnrNehksST8tk98Km9t5M2X59QZk7mJzzsUbnWr +t+izid7xF7FAgDYj9XJgQHz04a4RjRSw5/6dgexAgvGoeOkG7uUhYd5DEYQCyQyR +Zy69pJN32L0nM2dC2e3NFU5BOBwocoKza3hdtSqqvIkj2kzyeU38uaJUco/Vk3OU +s+sQNZbk5C1pxkLLwzu815tKg77Om4Nwbi+bgDvI +-----END CERTIFICATE----- diff --git a/bankid/certs/FPTestcert4_20220818_key.pem b/bankid/certs/FPTestcert4_20220818_key.pem new file mode 100644 index 0000000..18291aa --- /dev/null +++ b/bankid/certs/FPTestcert4_20220818_key.pem @@ -0,0 +1,31 @@ +Bag Attributes + localKeyID: A9 F3 0C D7 04 B6 7D 23 86 84 71 C3 E9 42 62 8B 1B D7 75 C3 +Key Attributes: +-----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/certs/__init__.py b/bankid/certs/__init__.py index 9d9c593..82b7198 100644 --- a/bankid/certs/__init__.py +++ b/bankid/certs/__init__.py @@ -6,3 +6,10 @@ def get_test_cert_p12(): return (Path(__file__).parent / "FPTestcert4_20220818.p12").resolve() + + +def get_test_cert_and_key(): + return ( + (Path(__file__).parent / "FPTestcert4_20220818_cert.pem").resolve(), + (Path(__file__).parent / "FPTestcert4_20220818_key.pem").resolve(), + ) diff --git a/tests/conftest.py b/tests/conftest.py index 4e20d21..bab4773 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,6 +5,7 @@ import requests import bankid +from bankid.certs import get_test_cert_and_key @pytest.fixture(scope="module") @@ -14,6 +15,5 @@ def ip_address(): @pytest.fixture(scope="session") def cert_and_key(tmpdir_factory): - testcert_dir = tmpdir_factory.mktemp("testcert") - cert, key = bankid.create_bankid_test_server_cert_and_key(str(testcert_dir)) + cert, key = get_test_cert_and_key() return cert, key diff --git a/tests/test_jsonclient.py b/tests/test_jsonclient.py index 1ba85ab..bdb1e4c 100644 --- a/tests/test_jsonclient.py +++ b/tests/test_jsonclient.py @@ -29,7 +29,6 @@ from unittest import mock except: import mock -import requests import bankid From 91f9b26eef187978cdba842b14e5c6f34d5c3d6c Mon Sep 17 00:00:00 2001 From: Henrik Blidh Date: Sun, 7 May 2023 15:07:36 +0200 Subject: [PATCH 10/26] Python 2.7 compat. fix --- bankid/certutils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bankid/certutils.py b/bankid/certutils.py index f712284..59de02d 100644 --- a/bankid/certutils.py +++ b/bankid/certutils.py @@ -131,7 +131,7 @@ def split_certificate(certificate_path, destination_folder, password=None): if p.returncode: raise BankIDError( - f"Error converting certificate: {err.decode('utf-8')}" + "Error converting certificate: {0}".format(err.decode("utf-8")) ) pipeline_2 = [ @@ -153,7 +153,7 @@ def split_certificate(certificate_path, destination_folder, password=None): if p.returncode: raise BankIDError( - f"Error converting certificate: {err.decode('utf-8')}" + "Error converting certificate: {0}".format(err.decode("utf-8")) ) # Return path tuples. From 873e4952e6e9bf27e38accbfe5656cad03d2c1e6 Mon Sep 17 00:00:00 2001 From: Henrik Blidh Date: Sun, 7 May 2023 15:09:05 +0200 Subject: [PATCH 11/26] Rmoving certutils test for the time being --- tests/test_certutils.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/tests/test_certutils.py b/tests/test_certutils.py index 33774b6..f163b9c 100644 --- a/tests/test_certutils.py +++ b/tests/test_certutils.py @@ -5,14 +5,14 @@ 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_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 From ba592d4650f607aa662932829deb2bec3fe91e4e Mon Sep 17 00:00:00 2001 From: Henrik Blidh Date: Sun, 7 May 2023 15:43:22 +0200 Subject: [PATCH 12/26] Remove Python 2.7 support --- .github/workflows/build_and_test.yml | 2 +- README.rst | 35 +------- bankid/__version__.py | 4 - bankid/certutils.py | 59 +++----------- bankid/exceptions.py | 5 -- bankid/experimental/helper.py | 41 +++++----- bankid/experimental/verify.py | 117 ++++++++++++++------------- bankid/jsonclient.py | 41 ++-------- docs/conf.py | 18 ++--- examples/qrdemo/qrdemo/app.py | 20 ++--- requirements.txt | 1 - setup.py | 5 +- 12 files changed, 114 insertions(+), 234 deletions(-) diff --git a/.github/workflows/build_and_test.yml b/.github/workflows/build_and_test.yml index ed0e66e..89ff12c 100644 --- a/.github/workflows/build_and_test.yml +++ b/.github/workflows/build_and_test.yml @@ -18,7 +18,7 @@ jobs: strategy: matrix: os: [ubuntu-latest, windows-latest, macos-latest] - python-version: [2.7, 3.7, 3.8, 3.9, '3.10', '3.11'] + python-version: [3.7, 3.8, 3.9, '3.10', '3.11'] steps: - uses: actions/checkout@v3 diff --git a/README.rst b/README.rst index fbe71c2..1d58d18 100644 --- a/README.rst +++ b/README.rst @@ -33,31 +33,11 @@ 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 `_. - Usage ----- ``BankIDJSONClient`` is the client to be used to -communicate with the BankID service. It uses the JSON API released in February 2018. +communicate with the BankID service. It uses the JSON 5.1 API released in April 2020. JSON client ~~~~~~~~~~~ @@ -212,19 +192,6 @@ be obtained through PyBankID: >>> client = bankid.BankIDJSONClient( certificates=cert_and_key, test_server=True) - -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 ``pybankid`` with the ``security`` extras as -described above, or to manually install ``pyopenssl`` according to -`this issue `_ and -`docstrings in requests `_. - -Optionally, the environment variable ``PYBANKID_DISABLE_WARNINGS`` can be set to disable these warnings. - Testing ------- diff --git a/bankid/__version__.py b/bankid/__version__.py index cae8e5e..86f674f 100644 --- a/bankid/__version__.py +++ b/bankid/__version__.py @@ -4,9 +4,5 @@ Version info """ -from __future__ import division -from __future__ import print_function -from __future__ import absolute_import - __version__ = "0.14.0" version = __version__ # backwards compatibility name diff --git a/bankid/certutils.py b/bankid/certutils.py index 59de02d..f1074ab 100644 --- a/bankid/certutils.py +++ b/bankid/certutils.py @@ -8,24 +8,14 @@ """ -from __future__ import division -from __future__ import print_function -from __future__ import unicode_literals -from __future__ import absolute_import - import os -import tempfile import subprocess -import requests -import pathlib from bankid.certs import get_test_cert_p12 from bankid.exceptions import BankIDError _TEST_CERT_PASSWORD = "qwerty123" -_TEST_CERT_URL = ( - "https://www.bankid.com/assets/bankid/rp/FPTestcert4_20220818.p12" -) +_TEST_CERT_URL = "https://www.bankid.com/assets/bankid/rp/FPTestcert4_20220818.p12" def create_bankid_test_server_cert_and_key(destination_path): @@ -48,9 +38,7 @@ def create_bankid_test_server_cert_and_key(destination_path): else: # Fetch testP12 certificate path - certificate, key = split_certificate( - str(get_test_cert_p12()), destination_path, password=_TEST_CERT_PASSWORD - ) + certificate, key = split_certificate(str(get_test_cert_p12()), destination_path, password=_TEST_CERT_PASSWORD) # Return path tuples. return certificate, key @@ -71,19 +59,11 @@ def split_certificate(certificate_path, destination_folder, password=None): """ try: # Attempt Linux and Darwin call first. - p = subprocess.Popen( - ["openssl", "version"], stdout=subprocess.PIPE, stderr=subprocess.PIPE - ) + p = subprocess.Popen(["openssl", "version"], stdout=subprocess.PIPE, stderr=subprocess.PIPE) sout, serr = p.communicate() openssl_executable_version = sout.decode().lower() - if not ( - openssl_executable_version.startswith("openssl") - or openssl_executable_version.startswith("libressl") - ): - raise BankIDError( - "OpenSSL executable could not be found. " - "Splitting cannot be performed." - ) + if not (openssl_executable_version.startswith("openssl") or openssl_executable_version.startswith("libressl")): + raise BankIDError("OpenSSL executable could not be found. " "Splitting cannot be performed.") openssl_executable = "openssl" except Exception: # Attempt to call on standard Git for Windows path. @@ -94,22 +74,15 @@ def split_certificate(certificate_path, destination_folder, password=None): ) sout, serr = p.communicate() if not sout.decode().lower().startswith("openssl"): - raise BankIDError( - "OpenSSL executable could not be found. " - "Splitting cannot be performed." - ) + raise BankIDError("OpenSSL executable could not be found. " "Splitting cannot be performed.") openssl_executable = "C:\\Program Files\\Git\\mingw64\\bin\\openssl.exe" if not os.path.exists(os.path.abspath(os.path.expanduser(destination_folder))): os.makedirs(os.path.abspath(os.path.expanduser(destination_folder))) # Paths to output files. - out_cert_path = os.path.join( - os.path.abspath(os.path.expanduser(destination_folder)), "certificate.pem" - ) - out_key_path = os.path.join( - os.path.abspath(os.path.expanduser(destination_folder)), "key.pem" - ) + out_cert_path = os.path.join(os.path.abspath(os.path.expanduser(destination_folder)), "certificate.pem") + out_key_path = os.path.join(os.path.abspath(os.path.expanduser(destination_folder)), "key.pem") # Use openssl for converting to pem format. pipeline_1 = [ @@ -124,15 +97,11 @@ def split_certificate(certificate_path, destination_folder, password=None): "-clcerts", "-nokeys", ] - p = subprocess.Popen( - list(filter(None, pipeline_1)), stdout=subprocess.PIPE, stderr=subprocess.PIPE - ) + p = subprocess.Popen(list(filter(None, pipeline_1)), stdout=subprocess.PIPE, stderr=subprocess.PIPE) out, err = p.communicate() if p.returncode: - raise BankIDError( - "Error converting certificate: {0}".format(err.decode("utf-8")) - ) + raise BankIDError("Error converting certificate: {0}".format(err.decode("utf-8"))) pipeline_2 = [ openssl_executable, @@ -146,15 +115,11 @@ def split_certificate(certificate_path, destination_folder, password=None): "-nocerts", "-nodes", ] - p = subprocess.Popen( - list(filter(None, pipeline_2)), stdout=subprocess.PIPE, stderr=subprocess.PIPE - ) + p = subprocess.Popen(list(filter(None, pipeline_2)), stdout=subprocess.PIPE, stderr=subprocess.PIPE) out, err = p.communicate() if p.returncode: - raise BankIDError( - "Error converting certificate: {0}".format(err.decode("utf-8")) - ) + raise BankIDError("Error converting certificate: {0}".format(err.decode("utf-8"))) # Return path tuples. return out_cert_path, out_key_path diff --git a/bankid/exceptions.py b/bankid/exceptions.py index 2372d56..54bb1b1 100644 --- a/bankid/exceptions.py +++ b/bankid/exceptions.py @@ -10,11 +10,6 @@ """ -from __future__ import division -from __future__ import print_function -from __future__ import unicode_literals -from __future__ import absolute_import - def get_json_error_class(response): data = response.json() diff --git a/bankid/experimental/helper.py b/bankid/experimental/helper.py index 08c305e..33ef57d 100644 --- a/bankid/experimental/helper.py +++ b/bankid/experimental/helper.py @@ -2,10 +2,10 @@ import xml.etree.ElementTree as ET from textwrap import wrap -make_cert = lambda e: '-----BEGIN CERTIFICATE-----\n' + "\n".join(wrap(e, 54)) + '\n-----END CERTIFICATE-----' +make_cert = lambda e: "-----BEGIN CERTIFICATE-----\n" + "\n".join(wrap(e, 54)) + "\n-----END CERTIFICATE-----" -class B64Value: +class B64Value: def __init__(self, value): self.value = value @@ -21,7 +21,6 @@ def __str__(self): class BankIdSignatureContainer: - def __init__(self, signature): self.root = ET.fromstring(signature.decode) self.raw = signature @@ -53,8 +52,8 @@ def certificates(self): @property def bid_signed_data_raw(self): raw_xml_string = self.raw.decode.decode() - start = raw_xml_string.find('') + len('') - stop = raw_xml_string.find('') + start = raw_xml_string.find("") + len("") + stop = raw_xml_string.find("") return raw_xml_string[start:stop] @property @@ -64,57 +63,55 @@ def user_non_visible_data(self): @property def signed_info(self): raw_xml_string = self.raw.decode.decode() - start = raw_xml_string.find('') - return raw_xml_string[start:stop] + '' + start = raw_xml_string.find("") + return raw_xml_string[start:stop] + "" @property def key_info_raw(self): raw_xml_string = self.raw.decode.decode() - start = raw_xml_string.find('') - return raw_xml_string[start:stop] + '' + start = raw_xml_string.find("") + return raw_xml_string[start:stop] + "" @property def server_info(self): return { - 'name': B64Value(self.root[3][0][2][0].text).decode, - 'displayName': B64Value(self.root[3][0][2][2].text).decode + "name": B64Value(self.root[3][0][2][0].text).decode, + "displayName": B64Value(self.root[3][0][2][2].text).decode, } class CompletionDataContainer: - def __init__(self, completion_data): self.completion_data = completion_data @property def order_ref(self): - return self.completion_data['orderRef'] + return self.completion_data["orderRef"] @property def ocsp_response(self): - return self.completion_data['ocspResponse'] + return self.completion_data["ocspResponse"] @property def device(self): - return self.completion_data['device'] + return self.completion_data["device"] @property def user(self): - return self.completion_data['user'] + return self.completion_data["user"] @property def signature(self): - return B64Value(self.completion_data['signature']) + return B64Value(self.completion_data["signature"]) @property def signature_container(self): return BankIdSignatureContainer(self.signature) -class NonceParse(): - +class NonceParse: def __init__(self, bytes): self.bytes = bytes @@ -128,4 +125,4 @@ def critical(self): @property def value(self): - return self.bytes[16:] \ No newline at end of file + return self.bytes[16:] diff --git a/bankid/experimental/verify.py b/bankid/experimental/verify.py index 8e83997..19cce80 100644 --- a/bankid/experimental/verify.py +++ b/bankid/experimental/verify.py @@ -15,16 +15,17 @@ _LOG = getLogger(__name__) + def verify_bankid_response(bank_id_response, ensure_certificates_still_valid=True, BANK_ID_ROOT_CERT=None): if not isinstance(bank_id_response, dict): raise TypeError("Response not a dictionary") - if 'completionData' not in bank_id_response: - raise AttributeError('Completion data missing in dictionary') + if "completionData" not in bank_id_response: + raise AttributeError("Completion data missing in dictionary") try: - cdc = CompletionDataContainer(bank_id_response['completionData']) + cdc = CompletionDataContainer(bank_id_response["completionData"]) # First step is to hash the data and verify the digest matches @@ -40,10 +41,10 @@ def verify_bankid_response(bank_id_response, ensure_certificates_still_valid=Tru key_info_hash_from_signature = base64.b64decode(cdc.signature_container.key_data_digest.text).hex() if bid_signed_data_hash != signed_data_hash_from_signature: - raise AssertionError('Signed Data hash does not match!') + raise AssertionError("Signed Data hash does not match!") if key_info_hash != key_info_hash_from_signature: - raise AssertionError('Key Info hash does not match!') + raise AssertionError("Key Info hash does not match!") _LOG.info("\n2. Signature verification\n") @@ -51,8 +52,9 @@ def verify_bankid_response(bank_id_response, ensure_certificates_still_valid=Tru user_certificate_string = make_cert(cdc.signature_container.certificates[0].text) # Making a certificate object out of it - user_certificate = crypto.load_certificate(crypto.FILETYPE_PEM, - BytesIO(user_certificate_string.encode()).read()) + user_certificate = crypto.load_certificate( + crypto.FILETYPE_PEM, BytesIO(user_certificate_string.encode()).read() + ) signature_bytes = base64.b64decode(cdc.signature_container.signature_value.text) signed_info = cdc.signature_container.signed_info.encode() @@ -62,77 +64,77 @@ def verify_bankid_response(bank_id_response, ensure_certificates_still_valid=Tru _LOG.debug("Signature Bytes:", signature_bytes) _LOG.debug("Signature Data Raw:", signed_info) - OpenSSL.crypto.verify(user_certificate, signature_bytes, signed_info, 'sha256') + OpenSSL.crypto.verify(user_certificate, signature_bytes, signed_info, "sha256") except OpenSSL.crypto.Error as e: - raise AssertionError('The BankID signature is not valid!') + raise AssertionError("The BankID signature is not valid!") _LOG.info("\n3. OCSP Response Verification\n") - ocsp = base64.b64decode(bank_id_response['completionData']['ocspResponse']) + ocsp = base64.b64decode(bank_id_response["completionData"]["ocspResponse"]) ocsp_response = asn1crypto.ocsp.OCSPResponse.load(ocsp) - basic_ocsp_response = ocsp_response['response_bytes']['response'].parsed + basic_ocsp_response = ocsp_response["response_bytes"]["response"].parsed # Some help by listing all the different parts of the OCSP response - _LOG.debug("TBS Response Data", basic_ocsp_response['tbs_response_data']) - _LOG.debug("SignatureAlgorithm", basic_ocsp_response['signature_algorithm'].signature_algo) - _LOG.debug("SignatureAlgorithm Hash Function", basic_ocsp_response['signature_algorithm'].hash_algo) - _LOG.debug("Signature", basic_ocsp_response['signature'].__bytes__()) - _LOG.debug("Cert", basic_ocsp_response['certs']) + _LOG.debug("TBS Response Data", basic_ocsp_response["tbs_response_data"]) + _LOG.debug("SignatureAlgorithm", basic_ocsp_response["signature_algorithm"].signature_algo) + _LOG.debug("SignatureAlgorithm Hash Function", basic_ocsp_response["signature_algorithm"].hash_algo) + _LOG.debug("Signature", basic_ocsp_response["signature"].__bytes__()) + _LOG.debug("Cert", basic_ocsp_response["certs"]) # Response content - _LOG.debug("version", basic_ocsp_response['tbs_response_data']['version']) - _LOG.debug("responderID", basic_ocsp_response['tbs_response_data']['responder_id']) # has native - _LOG.info("producedAt", basic_ocsp_response['tbs_response_data']['produced_at']) + _LOG.debug("version", basic_ocsp_response["tbs_response_data"]["version"]) + _LOG.debug("responderID", basic_ocsp_response["tbs_response_data"]["responder_id"]) # has native + _LOG.info("producedAt", basic_ocsp_response["tbs_response_data"]["produced_at"]) - cest = pytz.timezone('Europe/Stockholm') - ocsp_produced_at = basic_ocsp_response['tbs_response_data']['produced_at'].native + cest = pytz.timezone("Europe/Stockholm") + ocsp_produced_at = basic_ocsp_response["tbs_response_data"]["produced_at"].native if not isinstance(ocsp_produced_at, datetime.datetime): - raise AssertionError('OCSP produced at is not a datetime!') + raise AssertionError("OCSP produced at is not a datetime!") - ocsp_produced_at = ocsp_produced_at.astimezone(cest).strftime('%Y-%m-%d %H:%M:%S') + ocsp_produced_at = ocsp_produced_at.astimezone(cest).strftime("%Y-%m-%d %H:%M:%S") - _LOG.debug("responses", basic_ocsp_response['tbs_response_data']['responses']) - _LOG.debug("response Extentions", basic_ocsp_response['tbs_response_data']['response_extensions']) + _LOG.debug("responses", basic_ocsp_response["tbs_response_data"]["responses"]) + _LOG.debug("response Extentions", basic_ocsp_response["tbs_response_data"]["response_extensions"]) _LOG.debug("Extentions") - extention = basic_ocsp_response['tbs_response_data']['response_extensions'][0] + extention = basic_ocsp_response["tbs_response_data"]["response_extensions"][0] - _LOG.debug('extn_id', extention['extn_id']) - _LOG.debug('critical', extention['critical']) + _LOG.debug("extn_id", extention["extn_id"]) + _LOG.debug("critical", extention["critical"]) # Cannot _LOG.debug the value without an exception being raised - need to parse that ourself later # print ('extn_value', extention['extn_value']) - single_response = basic_ocsp_response['tbs_response_data']['responses'][0] + single_response = basic_ocsp_response["tbs_response_data"]["responses"][0] - _LOG.debug("CertID", single_response['cert_id']) - _LOG.debug("certStatus", single_response['cert_status']) - _LOG.debug("thisUpdate", single_response['this_update']) - _LOG.debug("nextUpdate", single_response['next_update']) - _LOG.debug("singleExtensions", single_response['single_extensions']) + _LOG.debug("CertID", single_response["cert_id"]) + _LOG.debug("certStatus", single_response["cert_status"]) + _LOG.debug("thisUpdate", single_response["this_update"]) + _LOG.debug("nextUpdate", single_response["next_update"]) + _LOG.debug("singleExtensions", single_response["single_extensions"]) _LOG.info("3.1. OCSP Response - Verify success ") - if ocsp_response['response_status'].native != 'successful': - raise AssertionError('OCSP response status was not successful') + if ocsp_response["response_status"].native != "successful": + raise AssertionError("OCSP response status was not successful") _LOG.info("3.2. OCSP Response - Verify signature ") # Transform the asn1 certificate to an openssl certificate - der_bytes = basic_ocsp_response['certs'][0].dump() - pem_bytes = pem.armor('CERTIFICATE', der_bytes) + der_bytes = basic_ocsp_response["certs"][0].dump() + pem_bytes = pem.armor("CERTIFICATE", der_bytes) ocsp_certificate = crypto.load_certificate(crypto.FILETYPE_PEM, pem_bytes) # Get the signature bytes - signature = basic_ocsp_response['signature'].__bytes__() + signature = basic_ocsp_response["signature"].__bytes__() # Dump the TBS response data as DER bytes - signature_data = basic_ocsp_response['tbs_response_data'].dump() + signature_data = basic_ocsp_response["tbs_response_data"].dump() # Define the hashing algorithm to be used - digest_method = basic_ocsp_response['signature_algorithm'].hash_algo + digest_method = basic_ocsp_response["signature_algorithm"].hash_algo _LOG.debug("Certificate", ocsp_certificate.get_subject()) _LOG.debug("Signature", signature) @@ -142,11 +144,11 @@ def verify_bankid_response(bank_id_response, ensure_certificates_still_valid=Tru try: OpenSSL.crypto.verify(ocsp_certificate, signature, signature_data, digest_method) except OpenSSL.crypto.Error as e: - raise AssertionError('The OCSP signature is not valid!') + raise AssertionError("The OCSP signature is not valid!") _LOG.info("3.2. OCSP Response - Compare nonce") - nonce_computed = hashlib.sha1(bank_id_response['completionData']['signature'].encode('utf-8')).digest().hex() + nonce_computed = hashlib.sha1(bank_id_response["completionData"]["signature"].encode("utf-8")).digest().hex() # A helper because the asn1 library seems to have a problem with the nonce parsing in some form or the other nonce_parser = NonceParse(extention.contents) @@ -158,18 +160,21 @@ def verify_bankid_response(bank_id_response, ensure_certificates_still_valid=Tru _LOG.debug("Nonce value presented", nonce_parser.value.hex()) if not nonce_parser.value.hex().startswith(nonce_computed): - raise AssertionError('Computed nonce not matching the OCSP nonce') + raise AssertionError("Computed nonce not matching the OCSP nonce") _LOG.info("\n4. Verify all the certificates by relying on the BankID root certificate as a trusted one \n") user_cert = crypto.load_certificate( - crypto.FILETYPE_PEM, make_cert(cdc.signature_container.certificates[0].text).encode()) + crypto.FILETYPE_PEM, make_cert(cdc.signature_container.certificates[0].text).encode() + ) bank_user_cert = crypto.load_certificate( - crypto.FILETYPE_PEM, make_cert(cdc.signature_container.certificates[1].text).encode()) + crypto.FILETYPE_PEM, make_cert(cdc.signature_container.certificates[1].text).encode() + ) bank_bank_id_cert = crypto.load_certificate( - crypto.FILETYPE_PEM, make_cert(cdc.signature_container.certificates[2].text).encode()) + crypto.FILETYPE_PEM, make_cert(cdc.signature_container.certificates[2].text).encode() + ) bank_id_root_cert = crypto.load_certificate(crypto.FILETYPE_PEM, BANK_ID_ROOT_CERT.encode()) @@ -179,12 +184,12 @@ def verify_bankid_response(bank_id_response, ensure_certificates_still_valid=Tru if not ensure_certificates_still_valid: tomorrow = datetime.datetime.now() + datetime.timedelta(days=1) - bank_user_cert.set_notAfter(tomorrow.strftime('%Y%m%d%H%M%SZ').encode()) - bank_bank_id_cert.set_notAfter(tomorrow.strftime('%Y%m%d%H%M%SZ').encode()) - bank_id_root_cert.set_notAfter(tomorrow.strftime('%Y%m%d%H%M%SZ').encode()) + bank_user_cert.set_notAfter(tomorrow.strftime("%Y%m%d%H%M%SZ").encode()) + bank_bank_id_cert.set_notAfter(tomorrow.strftime("%Y%m%d%H%M%SZ").encode()) + bank_id_root_cert.set_notAfter(tomorrow.strftime("%Y%m%d%H%M%SZ").encode()) - ocsp_certificate.set_notAfter(tomorrow.strftime('%Y%m%d%H%M%SZ').encode()) - user_cert.set_notAfter(tomorrow.strftime('%Y%m%d%H%M%SZ').encode()) + ocsp_certificate.set_notAfter(tomorrow.strftime("%Y%m%d%H%M%SZ").encode()) + user_cert.set_notAfter(tomorrow.strftime("%Y%m%d%H%M%SZ").encode()) store = crypto.X509Store() store.add_cert(bank_user_cert) @@ -195,17 +200,17 @@ def verify_bankid_response(bank_id_response, ensure_certificates_still_valid=Tru # Verify the user certificate up to the root certificate store_ctx = crypto.X509StoreContext(store, user_cert) store_ctx.verify_certificate() - _LOG.debug('User Certificate issued by the respective bank... OK') + _LOG.debug("User Certificate issued by the respective bank... OK") except X509StoreContextError: - raise AssertionError('BankID user certificate chain could not be verified.') + raise AssertionError("BankID user certificate chain could not be verified.") try: # Verify the ocsp certificate up to the root certificate store_ctx = crypto.X509StoreContext(store, ocsp_certificate) store_ctx.verify_certificate() - _LOG.debug('OCSP Certificate issued by the respective bank... OK') + _LOG.debug("OCSP Certificate issued by the respective bank... OK") except X509StoreContextError: - raise AssertionError('OCSP certificate chain could not be verified.') + raise AssertionError("OCSP certificate chain could not be verified.") except Exception as e: raise e diff --git a/bankid/jsonclient.py b/bankid/jsonclient.py index bf6c06f..45463c8 100644 --- a/bankid/jsonclient.py +++ b/bankid/jsonclient.py @@ -7,39 +7,16 @@ Created on 2018-02-19 by hbldh """ - -from __future__ import division -from __future__ import print_function -from __future__ import unicode_literals -from __future__ import absolute_import - import os import six import base64 +from urllib import parse as urlparse import requests from pkg_resources import resource_filename from bankid.exceptions import get_json_error_class -try: - # Python 3 - from urllib import parse as urlparse -except ImportError: - # Python 2 - import urlparse - - -# Handling Python 2.7 verification of certificates with urllib3. -# See README.rst for details. -try: - import requests.packages.urllib3.contrib.pyopenssl - - requests.packages.urllib3.contrib.pyopenssl.inject_into_urllib3() -except ImportError: - if bool(os.environ.get("PYBANKID_DISABLE_WARNINGS", False)): - requests.packages.urllib3.disable_warnings() - class BankIDJSONClient(object): """The client to use for communicating with BankID servers via the v.5 API. @@ -60,14 +37,10 @@ def __init__(self, certificates, test_server=False, request_timeout=None): 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" - ) + 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.verify_cert = resource_filename("bankid.certs", "appapi2.bankid.com.pem") self.client = requests.Session() self.client.verify = self.verify_cert @@ -83,9 +56,7 @@ 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 - ): + 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. @@ -268,9 +239,7 @@ def collect(self, order_ref): when error has been returned from server. """ - response = self._post( - self._collect_endpoint, json={"orderRef": order_ref} - ) + response = self._post(self._collect_endpoint, json={"orderRef": order_ref}) if response.status_code == 200: return response.json() diff --git a/docs/conf.py b/docs/conf.py index 542de44..7d33fd4 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -50,18 +50,18 @@ master_doc = "index" # General information about the project. -project = u"PyBankID" -copyright = u"2018, Henrik Blidh" -author = u"Henrik Blidh" +project = "PyBankID" +copyright = "2018, Henrik Blidh" +author = "Henrik Blidh" # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. # # The short X.Y version. -version = u"0.7.0" +version = "0.7.0" # The full version, including alpha/beta/rc tags. -release = u"0.7.0" +release = "0.7.0" # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. @@ -223,9 +223,7 @@ # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). -latex_documents = [ - (master_doc, "PyBankID.tex", u"PyBankID Documentation", u"Henrik Blidh", "manual") -] +latex_documents = [(master_doc, "PyBankID.tex", "PyBankID Documentation", "Henrik Blidh", "manual")] # The name of an image file (relative to this directory) to place at the top of # the title page. @@ -252,7 +250,7 @@ # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). -man_pages = [(master_doc, "pybankid", u"PyBankID Documentation", [author], 1)] +man_pages = [(master_doc, "pybankid", "PyBankID Documentation", [author], 1)] # If true, show URL addresses after external links. # man_show_urls = False @@ -267,7 +265,7 @@ ( master_doc, "PyBankID", - u"PyBankID Documentation", + "PyBankID Documentation", author, "PyBankID", "One line description of project.", diff --git a/examples/qrdemo/qrdemo/app.py b/examples/qrdemo/qrdemo/app.py index 7c3dfc3..54e2446 100644 --- a/examples/qrdemo/qrdemo/app.py +++ b/examples/qrdemo/qrdemo/app.py @@ -18,9 +18,7 @@ # The client should be initialized in a better way, e.g. with Flask_BankID so that it is stored in the # 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) - ) + cert_paths = create_bankid_test_server_cert_and_key(str(pathlib.Path(__file__).parent)) client = BankIDJSONClient(cert_paths, test_server=True) else: # Set your own cert paths for you production certificate and key here. @@ -52,9 +50,7 @@ def auth_complete(): collect_response = {} age = None - response = make_response( - render_template("auth_complete.html", auth=collect_response) - ) + response = make_response(render_template("auth_complete.html", auth=collect_response)) # Unsetting the cookie to make the app capable of being run again. response.set_cookie("QRDemo-Auth", "", expires=0) return response @@ -79,9 +75,7 @@ def initiate(): 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={"tokenStartRequired": True if pn else False}, # 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() @@ -89,9 +83,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 = generate_qr_code_content(resp["qrStartToken"], resp["start_t"], resp["qrStartSecret"]) return render_template( "qr.html", order_ref=resp["orderRef"], @@ -107,9 +99,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 = generate_qr_code_content(x["qrStartToken"], x["start_t"], x["qrStartSecret"]) response = make_response(qr_content, 200) response.mimetype = "text/plain" return response diff --git a/requirements.txt b/requirements.txt index 173f430..111f18b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1 @@ requests>=2.20.0 -six>=1.10.0 diff --git a/setup.py b/setup.py index e39199e..ba3cc79 100644 --- a/setup.py +++ b/setup.py @@ -88,20 +88,19 @@ def run(self): version=about["__version__"], description=DESCRIPTION, long_description=long_description, - long_description_content_type='text/x-rst', + long_description_content_type="text/x-rst", author=AUTHOR, author_email=EMAIL, url=URL, packages=find_packages(exclude=("tests",)), install_requires=REQUIRED, include_package_data=True, - package_data={"": ["*.pem", '*.p12']}, + package_data={"": ["*.pem", "*.p12"]}, license="MIT", classifiers=[ # Trove classifiers # Full list: https://pypi.python.org/pypi?%3Aaction=list_classifiers "Programming Language :: Python", - "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", "License :: OSI Approved :: MIT License", "Operating System :: POSIX :: Linux", From 52e33f748d61da76da29d543a015f0a5c677a514 Mon Sep 17 00:00:00 2001 From: Henrik Blidh Date: Sun, 7 May 2023 15:48:29 +0200 Subject: [PATCH 13/26] Remove six dependency --- bankid/jsonclient.py | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/bankid/jsonclient.py b/bankid/jsonclient.py index 45463c8..4edfea1 100644 --- a/bankid/jsonclient.py +++ b/bankid/jsonclient.py @@ -7,8 +7,6 @@ Created on 2018-02-19 by hbldh """ -import os -import six import base64 from urllib import parse as urlparse @@ -18,6 +16,13 @@ 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. @@ -114,7 +119,7 @@ def sign( user_non_visible_data=None, **kwargs ): - """Request an signing order. The :py:meth:`collect` method + """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 @@ -157,9 +162,9 @@ def sign( data = {"endUserIp": end_user_ip} if personal_number: data["personalNumber"] = personal_number - data["userVisibleData"] = self._encode_user_data(user_visible_data) + data["userVisibleData"] = _encode_user_data(user_visible_data) if user_non_visible_data: - data["userNonVisibleData"] = self._encode_user_data(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. @@ -266,9 +271,3 @@ def cancel(self, order_ref): return response.json() == {} else: raise get_json_error_class(response) - - def _encode_user_data(self, user_data): - if isinstance(user_data, six.text_type): - return base64.b64encode(user_data.encode("utf-8")).decode("ascii") - else: - return base64.b64encode(user_data).decode("ascii") From b74223681bb5bb0d79cb1acacdc64f699eccbf4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?William=20Tis=C3=A4ter?= Date: Fri, 15 Dec 2023 14:50:47 +0100 Subject: [PATCH 14/26] Async client using httpx (#55) * Test against Python 3.12 * Install setuptools after testing * Swap out pkg_resources for importlib * Downgrade importlib-resources to 5.12.0 * Always use compat package * Read required packages in setup.py from requirements.txt * Drop unused six and update docs * Async client * Tidy up async wrapper * Install requirements-dev.txt on CI * Add two more packages to requirements-dev.txt from CI * Update bankid/jsonclient.py Co-authored-by: David Svenson * Update bankid/jsonclient.py Co-authored-by: David Svenson * Drop unused TypeVar * Update bankid/jsonclient.py Co-authored-by: David Svenson --------- Co-authored-by: David Svenson --- .github/workflows/build_and_test.yml | 10 +- .gitignore | 2 + .vscode/extensions.json | 8 + .vscode/settings.json | 33 ++++ bankid/__init__.py | 8 +- bankid/__version__.py | 1 - bankid/certutils.py | 20 ++- bankid/exceptions.py | 11 -- bankid/jsonclient.py | 251 +++++++++++++++++++-------- docs/_static/.gitkeep | 0 docs/api_reference.rst | 17 ++ docs/certutils.rst | 22 ++- docs/conf.py | 9 +- docs/examples.rst | 4 +- docs/exceptions.rst | 13 -- docs/get_started.rst | 163 +++++++++++++---- docs/index.rst | 3 +- docs/jsonclient.rst | 111 ------------ requirements-dev.txt | 6 +- requirements.txt | 3 +- ruff.toml | 1 + setup.py | 10 +- tests/conftest.py | 59 +++++-- tests/test_certutils.py | 24 ++- tests/test_exceptions.py | 5 +- tests/test_jsonclient.py | 145 ---------------- tests/test_jsonclient_async.py | 86 +++++++++ tests/test_jsonclient_sync.py | 79 +++++++++ 28 files changed, 647 insertions(+), 457 deletions(-) create mode 100644 .vscode/extensions.json create mode 100644 .vscode/settings.json 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_jsonclient_async.py create mode 100644 tests/test_jsonclient_sync.py diff --git a/.github/workflows/build_and_test.yml b/.github/workflows/build_and_test.yml index 89ff12c..e0c8674 100644 --- a/.github/workflows/build_and_test.yml +++ b/.github/workflows/build_and_test.yml @@ -18,7 +18,7 @@ jobs: strategy: matrix: os: [ubuntu-latest, windows-latest, macos-latest] - python-version: [3.7, 3.8, 3.9, '3.10', '3.11'] + python-version: [3.7, 3.8, 3.9, '3.10', '3.11', '3.12'] steps: - uses: actions/checkout@v3 @@ -27,14 +27,11 @@ jobs: 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,6 +44,9 @@ 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 with: diff --git a/.gitignore b/.gitignore index 5eff4b0..92a55ff 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,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/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..0e4aecc --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,8 @@ +{ + "recommendations": [ + "ms-python.python", + "ms-python.vscode-pylance", + "ms-python.flake8", + "charliermarsh.ruff" + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..c042cac --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,33 @@ +{ + "editor.tabSize": 2, + "editor.insertSpaces": true, + "python.analysis.autoImportCompletions": true, + "python.analysis.diagnosticMode": "workspace", + "python.analysis.indexing": true, + "files.insertFinalNewline": true, + "files.exclude": { + ".pytest_cache": true, + "**/__pycache__": true, + ".venv": true, + "build": true, + "dist": true, + "*.egg-info": true, + }, + "editor.codeActionsOnSave": { + "source.fixAll": true, + "source.organizeImports": true + }, + "editor.defaultFormatter": "charliermarsh.ruff", + "editor.formatOnSave": true, + "python.testing.pytestArgs": [ + "tests" + ], + "python.testing.unittestEnabled": false, + "python.testing.pytestEnabled": true, + "flake8.importStrategy": "fromEnvironment", + "flake8.args": [ + "--max-complexity=10", + "--max-line-length=127", + "--select=E9,F63,F7,F82", + ], +} diff --git a/bankid/__init__.py b/bankid/__init__.py index d21cee7..388b10b 100644 --- a/bankid/__init__.py +++ b/bankid/__init__.py @@ -1,4 +1,3 @@ -#!/usr/bin/env python # -*- coding: utf-8 -*- """ :mod:`bankid` @@ -19,13 +18,14 @@ """ -from .jsonclient import BankIDJSONClient -from .certutils import create_bankid_test_server_cert_and_key +from . import exceptions from .__version__ import __version__, version -import bankid.exceptions +from .certutils import create_bankid_test_server_cert_and_key +from .jsonclient import AsyncBankIDJSONClient, BankIDJSONClient __all__ = [ "BankIDJSONClient", + "AsyncBankIDJSONClient", "exceptions", "create_bankid_test_server_cert_and_key", "__version__", diff --git a/bankid/__version__.py b/bankid/__version__.py index 15729d5..27a327a 100644 --- a/bankid/__version__.py +++ b/bankid/__version__.py @@ -1,4 +1,3 @@ -#!/usr/bin/env python # -*- coding: utf-8 -*- """ Version info diff --git a/bankid/certutils.py b/bankid/certutils.py index 0dbd1a3..0551b2d 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. @@ -31,9 +37,7 @@ def create_bankid_test_server_cert_and_key(destination_path): """ if os.getenv("TEST_CERT_FILE"): - certificate, key = split_certificate( - os.getenv("TEST_CERT_FILE"), destination_path, password=_TEST_CERT_PASSWORD - ) + certificate, key = split_certificate(os.getenv("TEST_CERT_FILE"), destination_path, password=_TEST_CERT_PASSWORD) else: # Fetch testP12 certificate path diff --git a/bankid/exceptions.py b/bankid/exceptions.py index 54bb1b1..28415e6 100644 --- a/bankid/exceptions.py +++ b/bankid/exceptions.py @@ -1,15 +1,4 @@ -#!/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() diff --git a/bankid/jsonclient.py b/bankid/jsonclient.py index 6523ba1..d6a3920 100644 --- a/bankid/jsonclient.py +++ b/bankid/jsonclient.py @@ -1,30 +1,26 @@ -#!/usr/bin/env python # -*- coding: utf-8 -*- -""" -:mod:`bankid.jsonclient` -- BankID JSON Client -============================================== -Created on 2018-02-19 by hbldh - -""" +import asyncio import base64 +from typing import Any, Dict, Optional, Tuple, Union from urllib import parse as urlparse -import requests -from pkg_resources import resource_filename +import httpx +from bankid.certutils import resolve_cert_path from bankid.exceptions import get_json_error_class -def _encode_user_data(user_data): +def _encode_user_data(user_data: Union[str, bytes]) -> str: 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. +class AsyncBankIDJSONClient: + """ + Asynchronous BankID client. :param certificates: Tuple of string paths to the certificate to use and the key to sign with. @@ -33,35 +29,80 @@ class BankIDJSONClient(object): :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): + def __init__( + self, + certificates: Tuple[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/v5.1/" - self.verify_cert = resource_filename("bankid.certs", "appapi2.test.bankid.com.pem") + self.verify_cert = resolve_cert_path("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.verify_cert = resolve_cert_path("appapi2.bankid.com.pem") 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) + self.client = httpx.AsyncClient( + cert=self.certs, + headers={"Content-Type": "application/json"}, + verify=self.verify_cert, + timeout=self._request_timeout, + ) + + def authenticate_payload( + self, + end_user_ip: str, + personal_number: Optional[str] = None, + requirement: Optional[str] = None, + **kwargs, + ) -> Dict[str, Any]: + 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) + return data - def authenticate(self, end_user_ip, personal_number=None, requirement=None, **kwargs): + def sign_payload( + self, + end_user_ip: str, + user_visible_data: str, + personal_number: Optional[str] = None, + requirement: Optional[Dict[str, Any]] = None, + user_non_visible_data: Optional[str] = None, + **kwargs, + ) -> Dict[str, Any]: + 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) + return data + + async def authenticate( + self, + end_user_ip: str, + personal_number: Optional[str] = None, + requirement: Optional[str] = None, + **kwargs, + ) -> Dict[str, Any]: """Request an authentication order. The :py:meth:`collect` method is used to query the status of the order. @@ -94,31 +135,30 @@ def authenticate(self, end_user_ip, personal_number=None, requirement=None, **kw :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) - + response = await self.client.post( + self._auth_endpoint, + json=self.authenticate_payload( + end_user_ip, + personal_number, + requirement, + **kwargs, + ), + ) if response.status_code == 200: return response.json() - else: - raise get_json_error_class(response) - def sign( + raise get_json_error_class(response) + + async def sign( self, - end_user_ip, - user_visible_data, - personal_number=None, - requirement=None, - user_non_visible_data=None, - **kwargs - ): + end_user_ip: str, + user_visible_data: str, + personal_number: Optional[str] = None, + requirement: Optional[Dict[str, Any]] = None, + user_non_visible_data: Optional[str] = None, + **kwargs, + ) -> Dict[str, Any]: """Request a signing order. The :py:meth:`collect` method is used to query the status of the order. @@ -157,26 +197,24 @@ def sign( :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) - + response = await self.client.post( + self._sign_endpoint, + json=self.sign_payload( + end_user_ip, + user_visible_data, + personal_number, + requirement, + user_non_visible_data, + **kwargs, + ), + ) if response.status_code == 200: return response.json() - else: - raise get_json_error_class(response) - def collect(self, order_ref): + raise get_json_error_class(response) + + async def collect(self, order_ref: str) -> Dict[str, Any]: """Collects the result of a sign or auth order using the ``orderRef`` as reference. @@ -242,16 +280,19 @@ def collect(self, order_ref): :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}) - + response = await self.client.post( + self._collect_endpoint, + json=dict( + orderRef=order_ref, + ), + ) if response.status_code == 200: return response.json() - else: - raise get_json_error_class(response) - def cancel(self, order_ref): + 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 @@ -263,11 +304,77 @@ def cancel(self, order_ref): :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}) - + response = await self.client.post( + self._cancel_endpoint, + json=dict( + orderRef=order_ref, + ), + ) if response.status_code == 200: return response.json() == {} - else: - raise get_json_error_class(response) + + raise get_json_error_class(response) + + +class BankIDJSONClient(AsyncBankIDJSONClient): + """Synchronous BankID client. + + :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], + test_server: bool = False, + request_timeout: Optional[int] = None, + ): + self.loop = asyncio.new_event_loop() + self.async_runner = self.loop.run_until_complete + self.async_client = super() + self.async_client.__init__(certificates, test_server, request_timeout) + + def __del__(self): + self.loop.close() + + def cancel( + self, + order_ref: str, + ) -> Dict[str, Any]: + return self.async_runner( + self.async_client.cancel(order_ref), + ) + + def collect( + self, + order_ref: str, + ) -> Dict[str, Any]: + return self.async_runner( + self.async_client.collect(order_ref), + ) + + def sign( + self, + ip_address: str, + user_visible_data: str, + personal_number: Optional[str] = None, + user_non_visible_data: Optional[str] = None, + ) -> Dict[str, Any]: + return self.async_runner( + self.async_client.sign(ip_address, user_visible_data, personal_number, user_non_visible_data), + ) + + def authenticate( + self, + ip_address: str, + personal_number: Optional[str] = None, + ) -> Dict[str, Any]: + return self.async_runner( + self.async_client.authenticate(ip_address, personal_number), + ) 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..47e0232 --- /dev/null +++ b/docs/api_reference.rst @@ -0,0 +1,17 @@ +.. _api_reference: + + +API Reference +============= + +:mod:`bankid.jsonclient` -- Clients +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. automodule:: bankid.jsonclient + :members: + +:mod:`bankid.exceptions` -- Exceptions +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +.. automodule:: bankid.exceptions + :members: + diff --git a/docs/certutils.rst b/docs/certutils.rst index 38f7c86..cdaa656 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 @@ -44,8 +47,8 @@ These can then be used for testing purposes, by sending in ``test_server=True`` keyword in the :py:class:`~BankIDClient` or :py:class:`~BankIDJSONClient`. -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..2365cd0 100644 --- a/docs/examples.rst +++ b/docs/examples.rst @@ -1,7 +1,7 @@ .. _examples: -PyBankID and QR code --------------------- +Generating QR codes +------------------- PyBankID cannot generate QR codes for you, but there is an example application in the `examples folder of the repo `_ where a 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..55e54a4 100644 --- a/docs/get_started.rst +++ b/docs/get_started.rst @@ -3,6 +3,8 @@ Getting Started =============== +PyBankID use BankID JSON API version 5.1 released in April 2020. + Installation ------------ @@ -12,41 +14,136 @@ 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==0.24.1 `_ +* `importlib-resources==5.12.0 `_ + +Using the client +---------------------- + +PyBankID provide 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. + +Get started by importing and initializing the client: + +.. code-block:: python + + >>> from bankid import AsyncBankIDJSONClient + >>> client = AsyncBankIDJSONClient(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', + ... 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 + + >>> await 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 we are using BankID ``v5.1`` 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 + + >>> await client.collect(order_ref="a9b791c3-459f-492b-bf61-23027876140b") + { + 'hintCode': 'outstandingTransaction', + 'orderRef': 'a9b791c3-459f-492b-bf61-23027876140b', + 'status': 'pending' + } + >>> await client.collect(order_ref="a9b791c3-459f-492b-bf61-23027876140b") + { + 'hintCode': 'userSign', + 'orderRef': 'a9b791c3-459f-492b-bf61-23027876140b', + 'status': 'pending' + } + >>> await client.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"*. + +Synchronous client +---------------------- + +The synchronous client is used in the same way as the asynchronous client, but the +methods are blocking. The synchronous client call the aynchronous client under the +hood. + +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 BankIDJSONClient + >>> client = BankIDJSONClient(certificates=( + ... 'path/to/certificate.pem', + ... 'path/to/key.pem', + ... )) + >>> 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' + } diff --git a/docs/index.rst b/docs/index.rst index 8b8807b..239f014 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -34,8 +34,7 @@ about how the BankID methods are defined and how to use them. :maxdepth: 2 get_started - jsonclient - exceptions + api_reference certutils examples 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/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..3244f28 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,2 @@ -requests>=2.20.0 +httpx==0.24.1 +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..ead8e00 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,19 +1,58 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- +import random +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_asyncio.fixture() +async def ip_address(): + client = httpx.AsyncClient() + response = await client.get("https://httpbin.org/ip") + return response.json()["origin"].split(",")[0] -@pytest.fixture(scope="session") -def cert_and_key(tmpdir_factory): +@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_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 index bdb1e4c..be2a43e 100644 --- a/tests/test_jsonclient.py +++ b/tests/test_jsonclient.py @@ -1,153 +1,8 @@ -#!/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")], diff --git a/tests/test_jsonclient_async.py b/tests/test_jsonclient_async.py new file mode 100644 index 0000000..0566835 --- /dev/null +++ b/tests/test_jsonclient_async.py @@ -0,0 +1,86 @@ +import uuid + +import pytest + +import bankid + + +@pytest.mark.asyncio +async def test_authentication_and_collect(cert_and_key, ip_address, random_personal_number): + """Authenticate call and then collect with the returned orderRef UUID.""" + c = bankid.AsyncBankIDJSONClient(certificates=cert_and_key, test_server=True) + assert "appapi2.test.bankid.com.pem" in c.verify_cert + out = await c.authenticate(ip_address, random_personal_number) + 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, random_personal_number): + """Sign call and then collect with the returned orderRef UUID.""" + + c = bankid.AsyncBankIDJSONClient(certificates=cert_and_key, test_server=True) + out = await c.sign( + ip_address, + "The data to be signed", + personal_number=random_personal_number, + 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_invalid_orderref_raises_error(cert_and_key): + c = bankid.AsyncBankIDJSONClient(certificates=cert_and_key, test_server=True) + with pytest.raises(bankid.exceptions.InvalidParametersError): + await c.collect("invalid-uuid") + + +@pytest.mark.asyncio +async def test_already_in_progress_raises_error(cert_and_key, ip_address, random_personal_number): + c = bankid.AsyncBankIDJSONClient(certificates=cert_and_key, test_server=True) + await c.authenticate(ip_address, random_personal_number) + with pytest.raises(bankid.exceptions.AlreadyInProgressError): + await c.authenticate(ip_address, random_personal_number) + + +@pytest.mark.asyncio +async def test_already_in_progress_raises_error_2(cert_and_key, ip_address, random_personal_number): + c = bankid.AsyncBankIDJSONClient(certificates=cert_and_key, test_server=True) + await c.sign(ip_address, "Text to sign", random_personal_number) + with pytest.raises(bankid.exceptions.AlreadyInProgressError): + await c.sign(ip_address, "Text to sign", random_personal_number) + + +@pytest.mark.asyncio +async def test_authentication_and_cancel(cert_and_key, ip_address, random_personal_number): + """Authenticate call and then cancel it""" + c = bankid.AsyncBankIDJSONClient(certificates=cert_and_key, test_server=True) + out = await 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 = 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(bankid.exceptions.InvalidParametersError): + collect_status = await c.collect(out.get("orderRef")) + + +@pytest.mark.asyncio +async def test_cancel_with_invalid_uuid(cert_and_key): + c = bankid.AsyncBankIDJSONClient(certificates=cert_and_key, test_server=True) + invalid_order_ref = uuid.uuid4() + with pytest.raises(bankid.exceptions.InvalidParametersError): + await c.cancel(str(invalid_order_ref)) diff --git a/tests/test_jsonclient_sync.py b/tests/test_jsonclient_sync.py new file mode 100644 index 0000000..3a80552 --- /dev/null +++ b/tests/test_jsonclient_sync.py @@ -0,0 +1,79 @@ +import uuid + +import pytest + +import bankid + + +def test_authentication_and_collect(cert_and_key, ip_address, random_personal_number): + """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, random_personal_number) + assert isinstance(out, dict) + # UUID.__init__ performs the UUID compliance assertion. + 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, random_personal_number): + """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=random_personal_number, + 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 = 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): + c.collect("invalid-uuid") + + +def test_already_in_progress_raises_error(cert_and_key, ip_address, random_personal_number): + c = bankid.BankIDJSONClient(certificates=cert_and_key, test_server=True) + c.authenticate(ip_address, random_personal_number) + with pytest.raises(bankid.exceptions.AlreadyInProgressError): + c.authenticate(ip_address, random_personal_number) + + +def test_already_in_progress_raises_error_2(cert_and_key, ip_address, random_personal_number): + c = bankid.BankIDJSONClient(certificates=cert_and_key, test_server=True) + c.sign(ip_address, "Text to sign", random_personal_number) + with pytest.raises(bankid.exceptions.AlreadyInProgressError): + c.sign(ip_address, "Text to sign", random_personal_number) + + +def test_authentication_and_cancel(cert_and_key, ip_address, random_personal_number): + """Authenticate call and then cancel it""" + c = bankid.BankIDJSONClient(certificates=cert_and_key, test_server=True) + 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") + 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): + c.cancel(str(invalid_order_ref)) From c5b5c6b95045f4a8ce89ccb0e9414ddabde592ba Mon Sep 17 00:00:00 2001 From: Amin Solhizadeh Date: Thu, 18 Jan 2024 14:19:45 +0100 Subject: [PATCH 15/26] Add support for RP v6.0 --- bankid/__init__.py | 6 +- bankid/__version__.py | 2 +- bankid/jsonclient6.py | 273 ++++++++++++++++++++++++++++++++++++++ tests/test_jsonclient6.py | 157 ++++++++++++++++++++++ 4 files changed, 435 insertions(+), 3 deletions(-) create mode 100644 bankid/jsonclient6.py create mode 100644 tests/test_jsonclient6.py 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 From cf9ec7a4783a1bc52b69ebf8761cf010dabd3cd5 Mon Sep 17 00:00:00 2001 From: Henrik Blidh Date: Thu, 21 Mar 2024 18:46:57 +0100 Subject: [PATCH 16/26] First draft of v6 clients Sync and Async clients Implementing parts of v6 API Removing all v5 and v5.1 API implementations Lacking documentation rewrite Builds on #53, #54, #56, #57, #58 --- bankid/__init__.py | 15 +- bankid/__version__.py | 2 +- bankid/asyncclient.py | 239 +++++++++++ bankid/base.py | 78 ++++ bankid/certutils.py | 4 +- bankid/exceptions.py | 1 + bankid/jsonclient.py | 380 ------------------ bankid/jsonclient6.py | 273 ------------- bankid/syncclient.py | 238 +++++++++++ examples/qrdemo/qrdemo/app.py | 29 +- requirements.txt | 4 +- tests/conftest.py | 16 +- ...sonclient_async.py => test_asyncclient.py} | 73 ++-- tests/test_jsonclient.py | 13 - tests/test_jsonclient6.py | 157 -------- tests/test_jsonclient_sync.py | 79 ---- tests/test_syncclient.py | 112 ++++++ 17 files changed, 745 insertions(+), 968 deletions(-) create mode 100644 bankid/asyncclient.py create mode 100644 bankid/base.py delete mode 100644 bankid/jsonclient.py delete mode 100644 bankid/jsonclient6.py create mode 100644 bankid/syncclient.py rename tests/{test_jsonclient_async.py => test_asyncclient.py} (53%) delete mode 100644 tests/test_jsonclient.py delete mode 100644 tests/test_jsonclient6.py delete mode 100644 tests/test_jsonclient_sync.py create mode 100644 tests/test_syncclient.py diff --git a/bankid/__init__.py b/bankid/__init__.py index c80df4a..04626e9 100644 --- a/bankid/__init__.py +++ b/bankid/__init__.py @@ -18,16 +18,15 @@ """ -from . import exceptions -from .__version__ import __version__, version -from .certutils import create_bankid_test_server_cert_and_key -from .jsonclient import AsyncBankIDJSONClient, BankIDJSONClient -from .jsonclient6 import BankIDJSONClient6 +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", - "AsyncBankIDJSONClient", - "BankIDJSONClient6", + "BankIdClient", + "BankIdAsyncClient", "exceptions", "create_bankid_test_server_cert_and_key", "__version__", diff --git a/bankid/__version__.py b/bankid/__version__.py index f90f7c4..e44bb76 100644 --- a/bankid/__version__.py +++ b/bankid/__version__.py @@ -3,5 +3,5 @@ Version info """ -__version__ = "0.15.0" +__version__ = "1.0.0a1" version = __version__ # backwards compatibility name diff --git a/bankid/asyncclient.py b/bankid/asyncclient.py new file mode 100644 index 0000000..b5ac6b4 --- /dev/null +++ b/bankid/asyncclient.py @@ -0,0 +1,239 @@ +from typing import Optional, Tuple, Dict, Any, Awaitable + +import httpx + +from bankid.base 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 sign( + self, + end_user_ip, + 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 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 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/base.py b/bankid/base.py new file mode 100644 index 0000000..349e133 --- /dev/null +++ b/bankid/base.py @@ -0,0 +1,78 @@ +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.""" + + 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._sign_endpoint = urljoin(self.api_url, "sign") + self._collect_endpoint = urljoin(self.api_url, "collect") + self._cancel_endpoint = urljoin(self.api_url, "cancel") + + self.client = None + + @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") + + @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}" + + def _create_payload( + 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, + ): + 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/certutils.py b/bankid/certutils.py index 0551b2d..d443d14 100644 --- a/bankid/certutils.py +++ b/bankid/certutils.py @@ -37,7 +37,9 @@ def create_bankid_test_server_cert_and_key(destination_path: str) -> Tuple[str]: """ if os.getenv("TEST_CERT_FILE"): - certificate, key = split_certificate(os.getenv("TEST_CERT_FILE"), destination_path, password=_TEST_CERT_PASSWORD) + certificate, key = split_certificate( + os.getenv("TEST_CERT_FILE"), destination_path, password=_TEST_CERT_PASSWORD + ) else: # Fetch testP12 certificate path diff --git a/bankid/exceptions.py b/bankid/exceptions.py index 28415e6..2ce9bd9 100644 --- a/bankid/exceptions.py +++ b/bankid/exceptions.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- + def get_json_error_class(response): data = response.json() error_class = _JSON_ERROR_CODE_TO_CLASS.get(data.get("errorCode"), BankIDError) diff --git a/bankid/jsonclient.py b/bankid/jsonclient.py deleted file mode 100644 index d6a3920..0000000 --- a/bankid/jsonclient.py +++ /dev/null @@ -1,380 +0,0 @@ -# -*- coding: utf-8 -*- - -import asyncio -import base64 -from typing import Any, Dict, Optional, Tuple, Union -from urllib import parse as urlparse - -import httpx - -from bankid.certutils import resolve_cert_path -from bankid.exceptions import get_json_error_class - - -def _encode_user_data(user_data: Union[str, bytes]) -> str: - if isinstance(user_data, str): - return base64.b64encode(user_data.encode("utf-8")).decode("ascii") - else: - return base64.b64encode(user_data).decode("ascii") - - -class AsyncBankIDJSONClient: - """ - Asynchronous BankID client. - - :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], - 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/v5.1/" - self.verify_cert = resolve_cert_path("appapi2.test.bankid.com.pem") - else: - self.api_url = "https://appapi2.bankid.com/rp/v5.1/" - self.verify_cert = resolve_cert_path("appapi2.bankid.com.pem") - - 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") - - self.client = httpx.AsyncClient( - cert=self.certs, - headers={"Content-Type": "application/json"}, - verify=self.verify_cert, - timeout=self._request_timeout, - ) - - def authenticate_payload( - self, - end_user_ip: str, - personal_number: Optional[str] = None, - requirement: Optional[str] = None, - **kwargs, - ) -> Dict[str, Any]: - 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) - return data - - def sign_payload( - self, - end_user_ip: str, - user_visible_data: str, - personal_number: Optional[str] = None, - requirement: Optional[Dict[str, Any]] = None, - user_non_visible_data: Optional[str] = None, - **kwargs, - ) -> Dict[str, Any]: - 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) - return data - - async def authenticate( - self, - end_user_ip: str, - personal_number: Optional[str] = None, - requirement: Optional[str] = None, - **kwargs, - ) -> Dict[str, Any]: - """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. - """ - response = await self.client.post( - self._auth_endpoint, - json=self.authenticate_payload( - end_user_ip, - personal_number, - requirement, - **kwargs, - ), - ) - if response.status_code == 200: - return response.json() - - raise get_json_error_class(response) - - async def sign( - self, - end_user_ip: str, - user_visible_data: str, - personal_number: Optional[str] = None, - requirement: Optional[Dict[str, Any]] = None, - user_non_visible_data: Optional[str] = None, - **kwargs, - ) -> Dict[str, Any]: - """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. - """ - response = await self.client.post( - self._sign_endpoint, - json=self.sign_payload( - end_user_ip, - user_visible_data, - personal_number, - requirement, - user_non_visible_data, - **kwargs, - ), - ) - if response.status_code == 200: - return response.json() - - raise get_json_error_class(response) - - async def collect(self, order_ref: str) -> Dict[str, Any]: - """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 = await self.client.post( - self._collect_endpoint, - json=dict( - orderRef=order_ref, - ), - ) - if response.status_code == 200: - return response.json() - - 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=dict( - orderRef=order_ref, - ), - ) - if response.status_code == 200: - return response.json() == {} - - raise get_json_error_class(response) - - -class BankIDJSONClient(AsyncBankIDJSONClient): - """Synchronous BankID client. - - :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], - test_server: bool = False, - request_timeout: Optional[int] = None, - ): - self.loop = asyncio.new_event_loop() - self.async_runner = self.loop.run_until_complete - self.async_client = super() - self.async_client.__init__(certificates, test_server, request_timeout) - - def __del__(self): - self.loop.close() - - def cancel( - self, - order_ref: str, - ) -> Dict[str, Any]: - return self.async_runner( - self.async_client.cancel(order_ref), - ) - - def collect( - self, - order_ref: str, - ) -> Dict[str, Any]: - return self.async_runner( - self.async_client.collect(order_ref), - ) - - def sign( - self, - ip_address: str, - user_visible_data: str, - personal_number: Optional[str] = None, - user_non_visible_data: Optional[str] = None, - ) -> Dict[str, Any]: - return self.async_runner( - self.async_client.sign(ip_address, user_visible_data, personal_number, user_non_visible_data), - ) - - def authenticate( - self, - ip_address: str, - personal_number: Optional[str] = None, - ) -> Dict[str, Any]: - return self.async_runner( - self.async_client.authenticate(ip_address, personal_number), - ) diff --git a/bankid/jsonclient6.py b/bankid/jsonclient6.py deleted file mode 100644 index c82190e..0000000 --- a/bankid/jsonclient6.py +++ /dev/null @@ -1,273 +0,0 @@ -#!/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/bankid/syncclient.py b/bankid/syncclient.py new file mode 100644 index 0000000..67d5fc9 --- /dev/null +++ b/bankid/syncclient.py @@ -0,0 +1,238 @@ +from typing import Optional, Tuple, Dict, Any + +import httpx + +from bankid.base 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], 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 sign( + 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 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 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/examples/qrdemo/qrdemo/app.py b/examples/qrdemo/qrdemo/app.py index 54e2446..68dad8a 100644 --- a/examples/qrdemo/qrdemo/app.py +++ b/examples/qrdemo/qrdemo/app.py @@ -7,7 +7,7 @@ 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.certutils import create_bankid_test_server_cert_and_key USE_TEST_SERVER = True @@ -19,13 +19,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 @@ -74,8 +74,7 @@ def initiate(): # 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}, # 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 +82,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 +98,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 @@ -123,19 +122,3 @@ def collect(order_ref: str): return response else: return jsonify(collect_response) - - -# Helper methods - - -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}" diff --git a/requirements.txt b/requirements.txt index 3244f28..5690bb5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,2 @@ -httpx==0.24.1 -importlib-resources==5.12.0 +httpx +importlib-resources>=5.12.0 diff --git a/tests/conftest.py b/tests/conftest.py index ead8e00..0cb3405 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,4 +1,5 @@ import random +from typing import Awaitable import httpx import pytest @@ -7,11 +8,18 @@ from bankid.certs import get_test_cert_and_key +@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_asyncio.fixture() -async def ip_address(): - client = httpx.AsyncClient() - response = await client.get("https://httpbin.org/ip") - return response.json()["origin"].split(",")[0] +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() diff --git a/tests/test_jsonclient_async.py b/tests/test_asyncclient.py similarity index 53% rename from tests/test_jsonclient_async.py rename to tests/test_asyncclient.py index 0566835..64cc2f8 100644 --- a/tests/test_jsonclient_async.py +++ b/tests/test_asyncclient.py @@ -1,16 +1,30 @@ +""" +:mod:`test_asyncclient` +======================= + +.. module:: test_asyncclient + :platform: Unix, Windows + :synopsis: + +.. moduleauthor:: tiwilliam + +Created on 2023-12-15 + +""" + import uuid import pytest -import bankid +from bankid import BankIdAsyncClient, exceptions @pytest.mark.asyncio -async def test_authentication_and_collect(cert_and_key, ip_address, random_personal_number): +async def test_authentication_and_collect(cert_and_key, ip_address_async): """Authenticate call and then collect with the returned orderRef UUID.""" - c = bankid.AsyncBankIDJSONClient(certificates=cert_and_key, test_server=True) + 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, random_personal_number) + 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) @@ -20,14 +34,13 @@ async def test_authentication_and_collect(cert_and_key, ip_address, random_perso @pytest.mark.asyncio -async def test_sign_and_collect(cert_and_key, ip_address, random_personal_number): +async def test_sign_and_collect(cert_and_key, ip_address_async): """Sign call and then collect with the returned orderRef UUID.""" - c = bankid.AsyncBankIDJSONClient(certificates=cert_and_key, test_server=True) + c = BankIdAsyncClient(certificates=cert_and_key, test_server=True) out = await c.sign( - ip_address, - "The data to be signed", - personal_number=random_personal_number, + ip_address_async, + user_visible_data="The data to be signed", user_non_visible_data="Non visible data", ) assert isinstance(out, dict) @@ -40,32 +53,38 @@ async def test_sign_and_collect(cert_and_key, ip_address, random_personal_number @pytest.mark.asyncio async def test_invalid_orderref_raises_error(cert_and_key): - c = bankid.AsyncBankIDJSONClient(certificates=cert_and_key, test_server=True) - with pytest.raises(bankid.exceptions.InvalidParametersError): + 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, random_personal_number): - c = bankid.AsyncBankIDJSONClient(certificates=cert_and_key, test_server=True) - await c.authenticate(ip_address, random_personal_number) - with pytest.raises(bankid.exceptions.AlreadyInProgressError): - await c.authenticate(ip_address, random_personal_number) +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, random_personal_number): - c = bankid.AsyncBankIDJSONClient(certificates=cert_and_key, test_server=True) - await c.sign(ip_address, "Text to sign", random_personal_number) - with pytest.raises(bankid.exceptions.AlreadyInProgressError): - await c.sign(ip_address, "Text to sign", random_personal_number) +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, random_personal_number): +async def test_authentication_and_cancel(cert_and_key, ip_address_async): """Authenticate call and then cancel it""" - c = bankid.AsyncBankIDJSONClient(certificates=cert_and_key, test_server=True) - out = await c.authenticate(ip_address, random_personal_number) + 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) @@ -74,13 +93,13 @@ async def test_authentication_and_cancel(cert_and_key, ip_address, random_person assert collect_status.get("hintCode") in ("outstandingTransaction", "noClient") success = await c.cancel(str(order_ref)) assert success - with pytest.raises(bankid.exceptions.InvalidParametersError): + 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 = bankid.AsyncBankIDJSONClient(certificates=cert_and_key, test_server=True) + c = BankIdAsyncClient(certificates=cert_and_key, test_server=True) invalid_order_ref = uuid.uuid4() - with pytest.raises(bankid.exceptions.InvalidParametersError): + with pytest.raises(exceptions.InvalidParametersError): await c.cancel(str(invalid_order_ref)) diff --git a/tests/test_jsonclient.py b/tests/test_jsonclient.py deleted file mode 100644 index be2a43e..0000000 --- a/tests/test_jsonclient.py +++ /dev/null @@ -1,13 +0,0 @@ -import pytest - -import bankid - - -@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_jsonclient6.py b/tests/test_jsonclient6.py deleted file mode 100644 index a6ba8e1..0000000 --- a/tests/test_jsonclient6.py +++ /dev/null @@ -1,157 +0,0 @@ -#!/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 diff --git a/tests/test_jsonclient_sync.py b/tests/test_jsonclient_sync.py deleted file mode 100644 index 3a80552..0000000 --- a/tests/test_jsonclient_sync.py +++ /dev/null @@ -1,79 +0,0 @@ -import uuid - -import pytest - -import bankid - - -def test_authentication_and_collect(cert_and_key, ip_address, random_personal_number): - """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, random_personal_number) - assert isinstance(out, dict) - # UUID.__init__ performs the UUID compliance assertion. - 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, random_personal_number): - """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=random_personal_number, - 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 = 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): - c.collect("invalid-uuid") - - -def test_already_in_progress_raises_error(cert_and_key, ip_address, random_personal_number): - c = bankid.BankIDJSONClient(certificates=cert_and_key, test_server=True) - c.authenticate(ip_address, random_personal_number) - with pytest.raises(bankid.exceptions.AlreadyInProgressError): - c.authenticate(ip_address, random_personal_number) - - -def test_already_in_progress_raises_error_2(cert_and_key, ip_address, random_personal_number): - c = bankid.BankIDJSONClient(certificates=cert_and_key, test_server=True) - c.sign(ip_address, "Text to sign", random_personal_number) - with pytest.raises(bankid.exceptions.AlreadyInProgressError): - c.sign(ip_address, "Text to sign", random_personal_number) - - -def test_authentication_and_cancel(cert_and_key, ip_address, random_personal_number): - """Authenticate call and then cancel it""" - c = bankid.BankIDJSONClient(certificates=cert_and_key, test_server=True) - 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") - 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): - c.cancel(str(invalid_order_ref)) diff --git a/tests/test_syncclient.py b/tests/test_syncclient.py new file mode 100644 index 0000000..13582fb --- /dev/null +++ b/tests/test_syncclient.py @@ -0,0 +1,112 @@ +#!/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_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_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 From 024b7cb9e33269ce046d1d8bd39a2d7672e9fa31 Mon Sep 17 00:00:00 2001 From: Henrik Blidh Date: Fri, 22 Mar 2024 11:36:55 +0100 Subject: [PATCH 17/26] Corrected the example app to work with v1.0.0 --- bankid/exceptions.py | 25 +-- examples/qrdemo/qrdemo/app.py | 44 +++-- examples/qrdemo/qrdemo/templates/index.html | 2 +- examples/qrdemo/qrdemo/templates/qr.html | 183 ++++++++++++-------- examples/qrdemo/requirements.txt | 2 +- 5 files changed, 164 insertions(+), 92 deletions(-) diff --git a/bankid/exceptions.py b/bankid/exceptions.py index 2ce9bd9..5ffcfac 100644 --- a/bankid/exceptions.py +++ b/bankid/exceptions.py @@ -4,15 +4,16 @@ 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): @@ -33,6 +34,8 @@ class InvalidParametersError(BankIDError): communicated to the user as a BankID error. """ + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) class AlreadyInProgressError(BankIDError): @@ -50,7 +53,7 @@ class AlreadyInProgressError(BankIDError): """ def __init__(self, *args, **kwargs): - super(AlreadyInProgressError, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) self.rfa = 4 @@ -68,7 +71,7 @@ class InternalError(BankIDError): """ def __init__(self, *args, **kwargs): - super(InternalError, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) self.rfa = 5 @@ -84,7 +87,7 @@ class MaintenanceError(BankIDError): """ def __init__(self, *args, **kwargs): - super(MaintenanceError, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) self.rfa = 5 @@ -98,12 +101,12 @@ class UnauthorizedError(BankIDError): communicated to the user as a BankID error. """ - - 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`` @@ -112,8 +115,8 @@ class NotFoundError(BankIDError): communicated to the user as a BankID error. """ - - pass + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) class RequestTimeoutError(BankIDError): @@ -128,7 +131,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/examples/qrdemo/qrdemo/app.py b/examples/qrdemo/qrdemo/app.py index 68dad8a..4ed322a 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 BankIdClient +from bankid.exceptions import BankIDError from bankid.certutils import create_bankid_test_server_cert_and_key USE_TEST_SERVER = True @@ -63,18 +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. - requirement={"personalNumber": pn}, # 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() @@ -107,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 @@ -122,3 +122,25 @@ def collect(order_ref: str): return response else: return jsonify(collect_response) + + +@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 + + +@app.errorhandler(500) +def internal_error(error): + return str(error) \ No newline at end of file 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 From beb4e0c40e6b839542ff744cfba9e288438baed4 Mon Sep 17 00:00:00 2001 From: Henrik Blidh Date: Fri, 22 Mar 2024 11:59:48 +0100 Subject: [PATCH 18/26] Documentation update --- bankid/asyncclient.py | 4 +- bankid/{base.py => baseclient.py} | 0 bankid/syncclient.py | 2 +- docs/api_reference.rst | 18 +++++-- docs/certutils.rst | 2 +- docs/examples.rst | 79 +++++++++++++++++++++++-------- docs/get_started.rst | 66 +++++++++++++++----------- docs/index.rst | 9 +++- examples/qrdemo/README.md | 5 +- 9 files changed, 127 insertions(+), 58 deletions(-) rename bankid/{base.py => baseclient.py} (100%) diff --git a/bankid/asyncclient.py b/bankid/asyncclient.py index b5ac6b4..2257c3a 100644 --- a/bankid/asyncclient.py +++ b/bankid/asyncclient.py @@ -1,8 +1,8 @@ -from typing import Optional, Tuple, Dict, Any, Awaitable +from typing import Optional, Tuple, Dict, Any import httpx -from bankid.base import BankIDClientBaseclass +from bankid.baseclient import BankIDClientBaseclass from bankid.exceptions import get_json_error_class diff --git a/bankid/base.py b/bankid/baseclient.py similarity index 100% rename from bankid/base.py rename to bankid/baseclient.py diff --git a/bankid/syncclient.py b/bankid/syncclient.py index 67d5fc9..8da625c 100644 --- a/bankid/syncclient.py +++ b/bankid/syncclient.py @@ -2,7 +2,7 @@ import httpx -from bankid.base import BankIDClientBaseclass +from bankid.baseclient import BankIDClientBaseclass from bankid.exceptions import get_json_error_class diff --git a/docs/api_reference.rst b/docs/api_reference.rst index 47e0232..cb7ec4c 100644 --- a/docs/api_reference.rst +++ b/docs/api_reference.rst @@ -4,10 +4,22 @@ API Reference ============= -:mod:`bankid.jsonclient` -- Clients -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +:mod:`bankid.syncclient` -- Base Client (parent) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. automodule:: bankid.baseclient + :members: + +:mod:`bankid.syncclient` -- Synchronous Client +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. automodule:: bankid.syncclient + :members: + +:mod:`bankid.asyncclient` -- Asynchronous Client +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -.. automodule:: bankid.jsonclient +.. automodule:: bankid.asyncclient :members: :mod:`bankid.exceptions` -- Exceptions diff --git a/docs/certutils.rst b/docs/certutils.rst index cdaa656..2f814fd 100644 --- a/docs/certutils.rst +++ b/docs/certutils.rst @@ -44,7 +44,7 @@ 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`. Splitting certificates diff --git a/docs/examples.rst b/docs/examples.rst index 2365cd0..3061f3f 100644 --- a/docs/examples.rst +++ b/docs/examples.rst @@ -1,30 +1,71 @@ .. _examples: +=================== 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 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 content for the QR code is generated by this method: +Below follows the app's README file: -.. 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 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: + + ```bash + $ export FLASK_APP=qrdemo.app:app + $ flask run -h 0.0.0.0 + ``` + 2. From Powershell: + + ```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 ``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](https://www.bankid.com/en/utvecklare/guider/teknisk-integrationsguide/) diff --git a/docs/get_started.rst b/docs/get_started.rst index 55e54a4..e1dcd9c 100644 --- a/docs/get_started.rst +++ b/docs/get_started.rst @@ -3,7 +3,7 @@ Getting Started =============== -PyBankID use BankID JSON API version 5.1 released in April 2020. +PyBankID use BankID JSON API version 6.0 released in May 2023. Installation ------------ @@ -19,11 +19,11 @@ Dependencies PyBankID makes use of the following external packages: -* `httpx==0.24.1 `_ -* `importlib-resources==5.12.0 `_ +* `httpx`_ +* `importlib-resources>=5.12.0 `_ Using the client ----------------------- +---------------- PyBankID provide both a synchronous and an asynchronous client for communication with BankID services. Example below will use the asynchronous @@ -34,7 +34,7 @@ Get started by importing and initializing the client: .. code-block:: python >>> from bankid import AsyncBankIDJSONClient - >>> client = AsyncBankIDJSONClient(certificates=( + >>> client = BankIdAsyncClient(certificates=( ... 'path/to/certificate.pem', ... 'path/to/key.pem', ... )) @@ -48,8 +48,7 @@ is initiated as such: .. code-block:: python - >>> await client.authenticate(end_user_ip='194.168.2.25', - ... personal_number="YYYYMMDDXXXX") + >>> await client.authenticate(end_user_ip='194.168.2.25') { 'orderRef': 'ee3421ea-2096-4000-8130-82648efe0927', 'autoStartToken': 'e8df5c3c-c67b-4a01-bfe5-fefeab760beb', @@ -61,9 +60,10 @@ 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.", - ... personal_number="YYYYMMDDXXXX") + >>> 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', @@ -71,28 +71,44 @@ and a sign order is initiated in a similar fashion: 'qrStartSecret': 'b4214886-3b5b-46ab-bc08-6862fddc0e06' } -Since we are using BankID ``v5.1`` 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 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(order_ref="a9b791c3-459f-492b-bf61-23027876140b") + >>> await client.collect("a9b791c3-459f-492b-bf61-23027876140b") { 'hintCode': 'outstandingTransaction', 'orderRef': 'a9b791c3-459f-492b-bf61-23027876140b', 'status': 'pending' } - >>> await client.collect(order_ref="a9b791c3-459f-492b-bf61-23027876140b") + >>> await client.collect("a9b791c3-459f-492b-bf61-23027876140b") { 'hintCode': 'userSign', 'orderRef': 'a9b791c3-459f-492b-bf61-23027876140b', 'status': 'pending' } - >>> await client.collect(order_ref="a9b791c3-459f-492b-bf61-23027876140b") + >>> await client.collect("a9b791c3-459f-492b-bf61-23027876140b") { 'completionData': { 'cert': { @@ -116,31 +132,27 @@ with the ``collect`` method using the received ``orderRef``: } Please note that the ``collect`` method should be used sparingly: in the -`BankID Relying Party Guidelines `_ +`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 synchronous client call the aynchronous client under the -hood. +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. +as well, by simply removing the ``await`` keyword. .. code-block:: python - >>> from bankid import BankIDJSONClient - >>> client = BankIDJSONClient(certificates=( + >>> from bankid import BankIdClient + >>> client = BankIdClient(certificates=( ... 'path/to/certificate.pem', ... 'path/to/key.pem', ... )) - >>> 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', diff --git a/docs/index.rst b/docs/index.rst index 239f014..07223a2 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. 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/) From 77626a9c99c092569f343b53c895333e0f06af7f Mon Sep 17 00:00:00 2001 From: Henrik Blidh Date: Fri, 22 Mar 2024 14:27:06 +0100 Subject: [PATCH 19/26] Cleanup before PR Documentation fixes Renaming and docstring fixes Demo app modifications Version bump --- bankid/__version__.py | 2 +- bankid/baseclient.py | 19 +++++++++++-------- bankid/exceptions.py | 3 +++ bankid/syncclient.py | 2 +- docs/api_reference.rst | 16 ++++++++-------- docs/examples.rst | 28 +++++++++++++++------------- docs/get_started.rst | 6 +++--- docs/index.rst | 2 +- examples/qrdemo/qrdemo/app.py | 2 +- 9 files changed, 44 insertions(+), 36 deletions(-) diff --git a/bankid/__version__.py b/bankid/__version__.py index e44bb76..9f148f9 100644 --- a/bankid/__version__.py +++ b/bankid/__version__.py @@ -3,5 +3,5 @@ Version info """ -__version__ = "1.0.0a1" +__version__ = "1.0.0" version = __version__ # backwards compatibility name diff --git a/bankid/baseclient.py b/bankid/baseclient.py index 349e133..186d7a6 100644 --- a/bankid/baseclient.py +++ b/bankid/baseclient.py @@ -11,7 +11,10 @@ class BankIDClientBaseclass: - """Baseclass for BankID clients.""" + """Baseclass for BankID clients. + + Both the synchronous and asynchronous clients inherit from this base class and has the methods implemented here. + """ def __init__( self, @@ -36,13 +39,6 @@ def __init__( self.client = None - @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") - @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 @@ -58,6 +54,13 @@ def generate_qr_code_content(qr_start_token: str, start_t: [float, datetime], qr ).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, diff --git a/bankid/exceptions.py b/bankid/exceptions.py index 5ffcfac..383d9e5 100644 --- a/bankid/exceptions.py +++ b/bankid/exceptions.py @@ -34,6 +34,7 @@ class InvalidParametersError(BankIDError): communicated to the user as a BankID error. """ + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -101,6 +102,7 @@ class UnauthorizedError(BankIDError): communicated to the user as a BankID error. """ + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -115,6 +117,7 @@ class NotFoundError(BankIDError): communicated to the user as a BankID error. """ + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) diff --git a/bankid/syncclient.py b/bankid/syncclient.py index 8da625c..65c9206 100644 --- a/bankid/syncclient.py +++ b/bankid/syncclient.py @@ -19,7 +19,7 @@ class BankIdClient(BankIDClientBaseclass): """ - def __init__(self, certificates: Tuple[str], test_server: bool = False, request_timeout: Optional[int] = None): + def __init__(self, certificates: Tuple[str, str], test_server: bool = False, request_timeout: Optional[int] = None): super().__init__(certificates, test_server, request_timeout) kwargs = { diff --git a/docs/api_reference.rst b/docs/api_reference.rst index cb7ec4c..ff134a6 100644 --- a/docs/api_reference.rst +++ b/docs/api_reference.rst @@ -4,26 +4,26 @@ API Reference ============= -:mod:`bankid.syncclient` -- Base Client (parent) -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Base Client +~~~~~~~~~~~~~~~~~~~~ .. automodule:: bankid.baseclient :members: -:mod:`bankid.syncclient` -- Synchronous Client -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Synchronous Client +~~~~~~~~~~~~~~~~~~ .. automodule:: bankid.syncclient :members: -:mod:`bankid.asyncclient` -- Asynchronous Client -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Asynchronous Client +~~~~~~~~~~~~~~~~~~~ .. automodule:: bankid.asyncclient :members: -:mod:`bankid.exceptions` -- Exceptions -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Exceptions +~~~~~~~~~~ .. automodule:: bankid.exceptions :members: diff --git a/docs/examples.rst b/docs/examples.rst index 3061f3f..7f5dcc4 100644 --- a/docs/examples.rst +++ b/docs/examples.rst @@ -4,11 +4,11 @@ Generating QR codes =================== -PyBankID can generate QR codes for you. 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. -Below follows the app's README file: +Below follows the app's README file, for your convenience. QR Authentication Example ------------------------- @@ -18,7 +18,7 @@ Making a simple authentication via QR code solution using Flask, Flask-Caching a Running the application ~~~~~~~~~~~~~~~~~~~~~~~ -1. Navigate your terminal to the same folder that this README resides in. +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`` @@ -26,16 +26,18 @@ Running the application 1. From Bash: - ```bash - $ export FLASK_APP=qrdemo.app:app - $ flask run -h 0.0.0.0 - ``` + .. code-block:: bash + + $ export FLASK_APP=qrdemo.app:app + $ flask run -h 0.0.0.0 + 2. From Powershell: - ```powershell - > $env:FLASK_APP = "qrdemo.app:app" - > flask run -h 0.0.0.0 - ``` + .. 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``. @@ -48,7 +50,7 @@ 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 ``generate_qr_code_content`` method. + 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. @@ -68,4 +70,4 @@ There are a few shortcuts taken here: References ~~~~~~~~~~ -[BankID Integration Guide](https://www.bankid.com/en/utvecklare/guider/teknisk-integrationsguide/) +- `BankID Integration Guide `_ diff --git a/docs/get_started.rst b/docs/get_started.rst index e1dcd9c..9cb15a2 100644 --- a/docs/get_started.rst +++ b/docs/get_started.rst @@ -19,8 +19,8 @@ Dependencies PyBankID makes use of the following external packages: -* `httpx`_ -* `importlib-resources>=5.12.0 `_ +* `httpx `_ +* `importlib-resources >= 5.12.0 `_ Using the client ---------------- @@ -33,7 +33,7 @@ Get started by importing and initializing the client: .. code-block:: python - >>> from bankid import AsyncBankIDJSONClient + >>> from bankid import BankIdAsyncClient >>> client = BankIdAsyncClient(certificates=( ... 'path/to/certificate.pem', ... 'path/to/key.pem', diff --git a/docs/index.rst b/docs/index.rst index 07223a2..0617229 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -39,9 +39,9 @@ about how the BankID methods are defined and how to use them. :maxdepth: 2 get_started - api_reference certutils examples + api_reference Indices and tables diff --git a/examples/qrdemo/qrdemo/app.py b/examples/qrdemo/qrdemo/app.py index 4ed322a..f4f979f 100644 --- a/examples/qrdemo/qrdemo/app.py +++ b/examples/qrdemo/qrdemo/app.py @@ -143,4 +143,4 @@ def cancel(order_ref: str): @app.errorhandler(500) def internal_error(error): - return str(error) \ No newline at end of file + return str(error) From 8e6c1a6fca1f93d07d2c32391432756f36f3d325 Mon Sep 17 00:00:00 2001 From: Henrik Blidh Date: Fri, 22 Mar 2024 14:37:51 +0100 Subject: [PATCH 20/26] Remove .vscode folder --- .gitignore | 1 + .vscode/extensions.json | 8 -------- .vscode/settings.json | 33 --------------------------------- 3 files changed, 1 insertion(+), 41 deletions(-) delete mode 100644 .vscode/extensions.json delete mode 100644 .vscode/settings.json diff --git a/.gitignore b/.gitignore index 92a55ff..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 diff --git a/.vscode/extensions.json b/.vscode/extensions.json deleted file mode 100644 index 0e4aecc..0000000 --- a/.vscode/extensions.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "recommendations": [ - "ms-python.python", - "ms-python.vscode-pylance", - "ms-python.flake8", - "charliermarsh.ruff" - ] -} diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index c042cac..0000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "editor.tabSize": 2, - "editor.insertSpaces": true, - "python.analysis.autoImportCompletions": true, - "python.analysis.diagnosticMode": "workspace", - "python.analysis.indexing": true, - "files.insertFinalNewline": true, - "files.exclude": { - ".pytest_cache": true, - "**/__pycache__": true, - ".venv": true, - "build": true, - "dist": true, - "*.egg-info": true, - }, - "editor.codeActionsOnSave": { - "source.fixAll": true, - "source.organizeImports": true - }, - "editor.defaultFormatter": "charliermarsh.ruff", - "editor.formatOnSave": true, - "python.testing.pytestArgs": [ - "tests" - ], - "python.testing.unittestEnabled": false, - "python.testing.pytestEnabled": true, - "flake8.importStrategy": "fromEnvironment", - "flake8.args": [ - "--max-complexity=10", - "--max-line-length=127", - "--select=E9,F63,F7,F82", - ], -} From e1d77b31ff8658845466e02f07092b107e6b1081 Mon Sep 17 00:00:00 2001 From: Henrik Blidh Date: Fri, 22 Mar 2024 14:39:30 +0100 Subject: [PATCH 21/26] Minor doc change --- docs/get_started.rst | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/get_started.rst b/docs/get_started.rst index 9cb15a2..05ba21e 100644 --- a/docs/get_started.rst +++ b/docs/get_started.rst @@ -3,7 +3,7 @@ Getting Started =============== -PyBankID use BankID JSON API version 6.0 released in May 2023. +PyBankID uses BankID JSON API version 6.0 released in May 2023. Installation ------------ @@ -25,9 +25,10 @@ PyBankID makes use of the following external packages: Using the client ---------------- -PyBankID provide both a synchronous and an asynchronous client for +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. +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: From 696d89e0aa46d7c387926a18fb580ecb50614eff Mon Sep 17 00:00:00 2001 From: Henrik Blidh Date: Fri, 22 Mar 2024 14:57:20 +0100 Subject: [PATCH 22/26] Updated README.rst --- README.rst | 111 +++++++++++++++++++++++++++++++++-------------------- 1 file changed, 69 insertions(+), 42 deletions(-) diff --git a/README.rst b/README.rst index 1d58d18..d8c1e05 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 From 3a5dda717551728461f3bde9d0f3cb4441b1974f Mon Sep 17 00:00:00 2001 From: Henrik Blidh Date: Fri, 22 Mar 2024 15:44:37 +0100 Subject: [PATCH 23/26] CI changes Removed testing in windows and macos Also removed 3.7 and 3.8 from test matrix. --- .github/workflows/build_and_test.yml | 6 +++--- .github/workflows/pypi-publish.yml | 2 +- README.rst | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/build_and_test.yml b/.github/workflows/build_and_test.yml index e0c8674..d79467b 100644 --- a/.github/workflows/build_and_test.yml +++ b/.github/workflows/build_and_test.yml @@ -17,10 +17,10 @@ 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', '3.12'] + 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 diff --git a/.github/workflows/pypi-publish.yml b/.github/workflows/pypi-publish.yml index 675bca6..809cee6 100644 --- a/.github/workflows/pypi-publish.yml +++ b/.github/workflows/pypi-publish.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v4 with: diff --git a/README.rst b/README.rst index d8c1e05..5d706e9 100644 --- a/README.rst +++ b/README.rst @@ -226,4 +226,4 @@ The PyBankID solution can be tested with `pytest `_: .. code-block:: bash - py.test + py.test tests/ From 65dd43510661d26415c6812cee07653f05ebdb60 Mon Sep 17 00:00:00 2001 From: Henrik Blidh Date: Fri, 22 Mar 2024 15:47:04 +0100 Subject: [PATCH 24/26] Upgrading CI action versions --- .github/workflows/build_and_test.yml | 4 ++-- .github/workflows/pypi-publish.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build_and_test.yml b/.github/workflows/build_and_test.yml index d79467b..300b4c9 100644 --- a/.github/workflows/build_and_test.yml +++ b/.github/workflows/build_and_test.yml @@ -23,7 +23,7 @@ jobs: - 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 }} @@ -48,7 +48,7 @@ jobs: 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 809cee6..36f4d5d 100644 --- a/.github/workflows/pypi-publish.yml +++ b/.github/workflows/pypi-publish.yml @@ -14,7 +14,7 @@ jobs: steps: - uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: '3.x' From cba58a6e05eeeb4fedbc64606d338c18eefa0e2c Mon Sep 17 00:00:00 2001 From: Henrik Blidh Date: Fri, 22 Mar 2024 21:16:09 +0100 Subject: [PATCH 25/26] Implemented phone/auth and phone/sign --- README.rst | 8 +-- bankid/__init__.py | 8 +-- bankid/asyncclient.py | 132 +++++++++++++++++++++++++++++++++- bankid/baseclient.py | 8 ++- bankid/syncclient.py | 132 +++++++++++++++++++++++++++++++++- docs/certutils.rst | 2 +- docs/get_started.rst | 8 +-- examples/qrdemo/qrdemo/app.py | 6 +- tests/test_asyncclient.py | 48 ++++++++++--- tests/test_syncclient.py | 48 ++++++++++--- 10 files changed, 361 insertions(+), 39 deletions(-) diff --git a/README.rst b/README.rst index 5d706e9..62d2b15 100644 --- a/README.rst +++ b/README.rst @@ -51,8 +51,8 @@ Synchronous client .. code-block:: python - >>> from bankid import BankIdClient - >>> client = BankIdClient(certificates=( + >>> from bankid import BankIDClient + >>> client = BankIDClient(certificates=( 'path/to/certificate.pem', 'path/to/key.pem', )) @@ -166,8 +166,8 @@ as well, by simply adding the ``await`` keyword: .. code-block:: python - >>> from bankid import BankIdAsyncClient - >>> client = BankIdAsyncClient(certificates=( + >>> from bankid import BankIDAsyncClient + >>> client = BankIDAsyncClient(certificates=( 'path/to/certificate.pem', 'path/to/key.pem', )) diff --git a/bankid/__init__.py b/bankid/__init__.py index 04626e9..7f628bc 100644 --- a/bankid/__init__.py +++ b/bankid/__init__.py @@ -21,12 +21,12 @@ 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 +from bankid.syncclient import BankIDClient +from bankid.asyncclient import BankIDAsyncClient __all__ = [ - "BankIdClient", - "BankIdAsyncClient", + "BankIDClient", + "BankIDAsyncClient", "exceptions", "create_bankid_test_server_cert_and_key", "__version__", diff --git a/bankid/asyncclient.py b/bankid/asyncclient.py index 2257c3a..0625546 100644 --- a/bankid/asyncclient.py +++ b/bankid/asyncclient.py @@ -6,7 +6,7 @@ from bankid.exceptions import get_json_error_class -class BankIdAsyncClient(BankIDClientBaseclass): +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 @@ -89,11 +89,76 @@ async def authenticate( 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_visible_data: str = None, user_non_visible_data: str = None, user_visible_data_format: str = None, ) -> Dict[str, str]: @@ -145,6 +210,69 @@ async def sign( 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. diff --git a/bankid/baseclient.py b/bankid/baseclient.py index 186d7a6..0d146f6 100644 --- a/bankid/baseclient.py +++ b/bankid/baseclient.py @@ -33,7 +33,9 @@ def __init__( 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") @@ -63,13 +65,15 @@ def _encode_user_data(user_data): def _create_payload( self, - end_user_ip: str, + 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 = {"endUserIp": end_user_ip} + data = {} + if end_user_ip: + data["endUserIp"] = end_user_ip if requirement and isinstance(requirement, dict): data["requirement"] = requirement if user_visible_data: diff --git a/bankid/syncclient.py b/bankid/syncclient.py index 65c9206..2e44a6f 100644 --- a/bankid/syncclient.py +++ b/bankid/syncclient.py @@ -6,7 +6,7 @@ from bankid.exceptions import get_json_error_class -class BankIdClient(BankIDClientBaseclass): +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 @@ -89,11 +89,76 @@ def authenticate( 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_visible_data: str = None, user_non_visible_data: str = None, user_visible_data_format: str = None, ) -> Dict[str, str]: @@ -144,6 +209,69 @@ def sign( 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. diff --git a/docs/certutils.rst b/docs/certutils.rst index 2f814fd..9a4caa3 100644 --- a/docs/certutils.rst +++ b/docs/certutils.rst @@ -44,7 +44,7 @@ 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:`~BankIdAsyncClient`. +keyword in the :py:class:`~BankIDClient` or :py:class:`~BankIDAsyncClient`. Splitting certificates diff --git a/docs/get_started.rst b/docs/get_started.rst index 05ba21e..43db15f 100644 --- a/docs/get_started.rst +++ b/docs/get_started.rst @@ -34,8 +34,8 @@ Get started by importing and initializing the client: .. code-block:: python - >>> from bankid import BankIdAsyncClient - >>> client = BankIdAsyncClient(certificates=( + >>> from bankid import BankIDAsyncClient + >>> client = BankIDAsyncClient(certificates=( ... 'path/to/certificate.pem', ... 'path/to/key.pem', ... )) @@ -148,8 +148,8 @@ as well, by simply removing the ``await`` keyword. .. code-block:: python - >>> from bankid import BankIdClient - >>> client = BankIdClient(certificates=( + >>> from bankid import BankIDClient + >>> client = BankIDClient(certificates=( ... 'path/to/certificate.pem', ... 'path/to/key.pem', ... )) diff --git a/examples/qrdemo/qrdemo/app.py b/examples/qrdemo/qrdemo/app.py index f4f979f..09ff379 100644 --- a/examples/qrdemo/qrdemo/app.py +++ b/examples/qrdemo/qrdemo/app.py @@ -4,7 +4,7 @@ from flask import Flask, make_response, render_template, request, jsonify from flask_caching import Cache -from bankid import BankIdClient +from bankid import BankIDClient from bankid.exceptions import BankIDError from bankid.certutils import create_bankid_test_server_cert_and_key @@ -17,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 = BankIdClient(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 = BankIdClient(cert_paths, test_server=False) + client = BankIDClient(cert_paths, test_server=False) # Frontend pages diff --git a/tests/test_asyncclient.py b/tests/test_asyncclient.py index 64cc2f8..81650fd 100644 --- a/tests/test_asyncclient.py +++ b/tests/test_asyncclient.py @@ -16,13 +16,13 @@ import pytest -from bankid import BankIdAsyncClient, exceptions +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) + 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) @@ -37,7 +37,7 @@ async def test_authentication_and_collect(cert_and_key, ip_address_async): 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) + c = BankIDAsyncClient(certificates=cert_and_key, test_server=True) out = await c.sign( ip_address_async, user_visible_data="The data to be signed", @@ -51,16 +51,30 @@ async def test_sign_and_collect(cert_and_key, ip_address_async): 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) + 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) + 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}) @@ -68,7 +82,7 @@ async def test_already_in_progress_raises_error(cert_and_key, ip_address_async, @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) + c = BankIDAsyncClient(certificates=cert_and_key, test_server=True) await c.sign( ip_address_async, requirement={"personalNumber": random_personal_number}, @@ -83,7 +97,7 @@ async def test_already_in_progress_raises_error_2(cert_and_key, ip_address_async @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) + 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. @@ -97,9 +111,27 @@ async def test_authentication_and_cancel(cert_and_key, ip_address_async): 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) + 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_syncclient.py b/tests/test_syncclient.py index 13582fb..f1fc3b5 100644 --- a/tests/test_syncclient.py +++ b/tests/test_syncclient.py @@ -22,13 +22,13 @@ except: import mock -from bankid import BankIdClient, exceptions +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) + 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) @@ -42,7 +42,7 @@ def test_authentication_and_collect(cert_and_key, ip_address, random_personal_nu 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) + c = BankIDClient(certificates=cert_and_key, test_server=True) out = c.sign( ip_address, user_visible_data="The data to be signed", @@ -56,21 +56,34 @@ def test_sign_and_collect(cert_and_key, ip_address): 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) + 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) + 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) + 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( @@ -81,7 +94,7 @@ def test_already_in_progress_raises_error_2(cert_and_key, ip_address, random_per 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) + 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. @@ -95,8 +108,25 @@ def test_authentication_and_cancel(cert_and_key, ip_address, random_personal_num 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) + 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)) @@ -107,6 +137,6 @@ def test_cancel_with_invalid_uuid(cert_and_key): [(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) + 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 From 4913557ef1f97ece277c2452069677d77cabedf8 Mon Sep 17 00:00:00 2001 From: David Svenson Date: Mon, 8 Apr 2024 16:29:09 +0200 Subject: [PATCH 26/26] Expose QR code helper explicitly. This simplifies making use of it without having access to a client instance. --- bankid/__init__.py | 2 ++ bankid/baseclient.py | 30 +++++++++++++++++------------- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/bankid/__init__.py b/bankid/__init__.py index 7f628bc..f640091 100644 --- a/bankid/__init__.py +++ b/bankid/__init__.py @@ -23,12 +23,14 @@ from bankid.certutils import create_bankid_test_server_cert_and_key from bankid.syncclient import BankIDClient from bankid.asyncclient import BankIDAsyncClient +from bankid.baseclient import generate_qr_code_content __all__ = [ "BankIDClient", "BankIDAsyncClient", "exceptions", "create_bankid_test_server_cert_and_key", + "generate_qr_code_content", "__version__", "version", ] diff --git a/bankid/baseclient.py b/bankid/baseclient.py index 0d146f6..f23f88c 100644 --- a/bankid/baseclient.py +++ b/bankid/baseclient.py @@ -42,19 +42,8 @@ def __init__( 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}" + def generate_qr_code_content(qr_start_token: str, start_t: [float, datetime], qr_start_secret: str) -> str: + return generate_qr_code_content(qr_start_token, start_t, qr_start_secret) @staticmethod def _encode_user_data(user_data): @@ -83,3 +72,18 @@ def _create_payload( if user_visible_data_format and user_visible_data_format == "simpleMarkdownV1": data["userVisibleDataFormat"] = "simpleMarkdownV1" return data + + +def generate_qr_code_content(qr_start_token: str, start_t: [float, datetime], qr_start_secret: str) -> 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}"