From 9e609efa1c1b58dc167c8c9abe49031cd764241e Mon Sep 17 00:00:00 2001 From: Carlo Costino Date: Mon, 14 Aug 2023 16:59:38 -0400 Subject: [PATCH] Remove webauthn hooks This changeset removes webauthn from the Notify.gov admin app. We are not using webauthn at all in our implementation and will be looking at an entirely different authentication system in the near future. Signed-off-by: Carlo Costino --- Pipfile | 1 - Pipfile.lock | 205 ++++---- app/__init__.py | 3 +- .../javascripts/authenticateSecurityKey.js | 74 --- app/assets/javascripts/registerSecurityKey.js | 58 --- app/assets/stylesheets/main.scss | 1 - app/assets/stylesheets/views/webauthn.scss | 40 -- app/formatters.py | 1 - app/main/__init__.py | 1 - app/main/forms.py | 4 - app/main/views/find_users.py | 4 +- app/main/views/manage_users.py | 5 +- app/main/views/new_password.py | 2 - app/main/views/sign_in.py | 2 - app/main/views/two_factor.py | 13 - app/main/views/user_profile.py | 76 --- app/main/views/webauthn_credentials.py | 161 ------- app/models/user.py | 18 - app/models/webauthn_credential.py | 76 --- app/navigation.py | 1 - app/notify_client/user_api_client.py | 34 -- .../components/webauthn-api-check.html | 7 - .../views/find-users/user-information.html | 8 +- .../views/manage-users/permissions.html | 6 +- app/templates/views/two-factor-webauthn.html | 71 --- app/templates/views/user-profile.html | 16 - .../user-profile/manage-security-key.html | 34 -- .../views/user-profile/security-keys.html | 107 ----- app/webauthn_server.py | 32 -- gulpfile.js | 6 +- tests/app/main/views/test_find_users.py | 22 - tests/app/main/views/test_manage_users.py | 56 --- tests/app/main/views/test_new_password.py | 28 -- tests/app/main/views/test_sign_in.py | 28 -- tests/app/main/views/test_two_factor.py | 48 -- tests/app/main/views/test_user_profile.py | 321 +------------ .../main/views/test_webauthn_credentials.py | 449 ------------------ tests/app/models/test_webauthn_credential.py | 71 --- tests/app/notify_client/test_user_client.py | 70 +-- tests/app/test_navigation.py | 9 - tests/app/test_webauthn_server.py | 38 -- tests/conftest.py | 45 +- .../authenticateSecurityKey.test.js | 279 ----------- tests/javascripts/registerSecurityKey.test.js | 165 ------- 44 files changed, 102 insertions(+), 2594 deletions(-) delete mode 100644 app/assets/javascripts/authenticateSecurityKey.js delete mode 100644 app/assets/javascripts/registerSecurityKey.js delete mode 100644 app/assets/stylesheets/views/webauthn.scss delete mode 100644 app/main/views/webauthn_credentials.py delete mode 100644 app/models/webauthn_credential.py delete mode 100644 app/templates/components/webauthn-api-check.html delete mode 100644 app/templates/views/two-factor-webauthn.html delete mode 100644 app/templates/views/user-profile/manage-security-key.html delete mode 100644 app/templates/views/user-profile/security-keys.html delete mode 100644 app/webauthn_server.py delete mode 100644 tests/app/main/views/test_webauthn_credentials.py delete mode 100644 tests/app/models/test_webauthn_credential.py delete mode 100644 tests/app/test_webauthn_server.py delete mode 100644 tests/javascripts/authenticateSecurityKey.test.js delete mode 100644 tests/javascripts/registerSecurityKey.test.js diff --git a/Pipfile b/Pipfile index 6e5312bac..b0027a0d3 100644 --- a/Pipfile +++ b/Pipfile @@ -7,7 +7,6 @@ name = "pypi" ago = "~=0.0.95" blinker = "~=1.4" exceptiongroup = "==1.1.2" -fido2 = "~=0.9" flask = "~=2.3" flask-basicauth = "~=0.2" flask-login = "~=0.6" diff --git a/Pipfile.lock b/Pipfile.lock index ba3bcf5c7..55f217c95 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "6f2fbb986d35c7f1cfdd94fab470e4982ee6398d23415b334da5c1e5c6803aef" + "sha256": "a57e91b82d0c2e4e411b1aacad278817a810c76cf2a64ca9f741ef2d889f1ee2" }, "pipfile-spec": 6, "requires": { @@ -50,19 +50,19 @@ }, "boto3": { "hashes": [ - "sha256:0300ca6ec8bc136eb316b32cc1e30c66b85bc497f5a5fe42e095ae4280569708", - "sha256:9d1b4713c888e53a218648ad71522bee9bec9d83f2999fff2494675af810b632" + "sha256:63619ffa44bc7f799b525c86d73bdb7f7a70994942bbff78253585bf64084e6e", + "sha256:a15841c7d04f87c63c9f2587b2b48198bec04d307d7b9950cbe4a021f845a5ba" ], "markers": "python_version >= '3.7'", - "version": "==1.28.24" + "version": "==1.28.26" }, "botocore": { "hashes": [ - "sha256:2d8f412c67f9285219f52d5dbbb6ef0dfa9f606da29cbdd41b6d6474bcc4bbd4", - "sha256:8c7ba9b09e9104e2d473214e1ffcf84b77e04cf6f5f2344942c1eed9e299f947" + "sha256:74d1c26144915312004a9f0232cdbe08946dfec9fc7dcd854456d2b73be9bfd9", + "sha256:e68a50ba76425ede8693fdf1f95b8411e283bc7619c03d7eb666db9f1de48153" ], "markers": "python_version >= '3.7'", - "version": "==1.31.24" + "version": "==1.31.26" }, "cachetools": { "hashes": [ @@ -235,7 +235,7 @@ "sha256:f779d3ad205f108d14e99bb3859aa7dd8e9c68874617c72354d7ecaec2a054ac", "sha256:f87f746ee241d30d6ed93969de31e5ffd09a2961a051e60ae6bddde9ec3583aa" ], - "markers": "python_version >= '3.7'", + "markers": "python_full_version >= '3.7.0'", "version": "==3.2.0" }, "click": { @@ -248,69 +248,61 @@ }, "coverage": { "hashes": [ - "sha256:06a9a2be0b5b576c3f18f1a241f0473575c4a26021b52b2a85263a00f034d51f", - "sha256:06fb182e69f33f6cd1d39a6c597294cff3143554b64b9825d1dc69d18cc2fff2", - "sha256:0a5f9e1dbd7fbe30196578ca36f3fba75376fb99888c395c5880b355e2875f8a", - "sha256:0e1f928eaf5469c11e886fe0885ad2bf1ec606434e79842a879277895a50942a", - "sha256:171717c7cb6b453aebac9a2ef603699da237f341b38eebfee9be75d27dc38e01", - "sha256:1e9d683426464e4a252bf70c3498756055016f99ddaec3774bf368e76bbe02b6", - "sha256:201e7389591af40950a6480bd9edfa8ed04346ff80002cec1a66cac4549c1ad7", - "sha256:245167dd26180ab4c91d5e1496a30be4cd721a5cf2abf52974f965f10f11419f", - "sha256:2aee274c46590717f38ae5e4650988d1af340fe06167546cc32fe2f58ed05b02", - "sha256:2e07b54284e381531c87f785f613b833569c14ecacdcb85d56b25c4622c16c3c", - "sha256:31563e97dae5598556600466ad9beea39fb04e0229e61c12eaa206e0aa202063", - "sha256:33d6d3ea29d5b3a1a632b3c4e4f4ecae24ef170b0b9ee493883f2df10039959a", - "sha256:3d376df58cc111dc8e21e3b6e24606b5bb5dee6024f46a5abca99124b2229ef5", - "sha256:419bfd2caae268623dd469eff96d510a920c90928b60f2073d79f8fe2bbc5959", - "sha256:48c19d2159d433ccc99e729ceae7d5293fbffa0bdb94952d3579983d1c8c9d97", - "sha256:49969a9f7ffa086d973d91cec8d2e31080436ef0fb4a359cae927e742abfaaa6", - "sha256:52edc1a60c0d34afa421c9c37078817b2e67a392cab17d97283b64c5833f427f", - "sha256:537891ae8ce59ef63d0123f7ac9e2ae0fc8b72c7ccbe5296fec45fd68967b6c9", - "sha256:54b896376ab563bd38453cecb813c295cf347cf5906e8b41d340b0321a5433e5", - "sha256:58c2ccc2f00ecb51253cbe5d8d7122a34590fac9646a960d1430d5b15321d95f", - "sha256:5b7540161790b2f28143191f5f8ec02fb132660ff175b7747b95dcb77ac26562", - "sha256:5baa06420f837184130752b7c5ea0808762083bf3487b5038d68b012e5937dbe", - "sha256:5e330fc79bd7207e46c7d7fd2bb4af2963f5f635703925543a70b99574b0fea9", - "sha256:61b9a528fb348373c433e8966535074b802c7a5d7f23c4f421e6c6e2f1697a6f", - "sha256:63426706118b7f5cf6bb6c895dc215d8a418d5952544042c8a2d9fe87fcf09cb", - "sha256:6d040ef7c9859bb11dfeb056ff5b3872436e3b5e401817d87a31e1750b9ae2fb", - "sha256:6f48351d66575f535669306aa7d6d6f71bc43372473b54a832222803eb956fd1", - "sha256:7ee7d9d4822c8acc74a5e26c50604dff824710bc8de424904c0982e25c39c6cb", - "sha256:81c13a1fc7468c40f13420732805a4c38a105d89848b7c10af65a90beff25250", - "sha256:8d13c64ee2d33eccf7437961b6ea7ad8673e2be040b4f7fd4fd4d4d28d9ccb1e", - "sha256:8de8bb0e5ad103888d65abef8bca41ab93721647590a3f740100cd65c3b00511", - "sha256:8fa03bce9bfbeeef9f3b160a8bed39a221d82308b4152b27d82d8daa7041fee5", - "sha256:924d94291ca674905fe9481f12294eb11f2d3d3fd1adb20314ba89e94f44ed59", - "sha256:975d70ab7e3c80a3fe86001d8751f6778905ec723f5b110aed1e450da9d4b7f2", - "sha256:976b9c42fb2a43ebf304fa7d4a310e5f16cc99992f33eced91ef6f908bd8f33d", - "sha256:9e31cb64d7de6b6f09702bb27c02d1904b3aebfca610c12772452c4e6c21a0d3", - "sha256:a342242fe22407f3c17f4b499276a02b01e80f861f1682ad1d95b04018e0c0d4", - "sha256:a3d33a6b3eae87ceaefa91ffdc130b5e8536182cd6dfdbfc1aa56b46ff8c86de", - "sha256:a895fcc7b15c3fc72beb43cdcbdf0ddb7d2ebc959edac9cef390b0d14f39f8a9", - "sha256:afb17f84d56068a7c29f5fa37bfd38d5aba69e3304af08ee94da8ed5b0865833", - "sha256:b1c546aca0ca4d028901d825015dc8e4d56aac4b541877690eb76490f1dc8ed0", - "sha256:b29019c76039dc3c0fd815c41392a044ce555d9bcdd38b0fb60fb4cd8e475ba9", - "sha256:b46517c02ccd08092f4fa99f24c3b83d8f92f739b4657b0f146246a0ca6a831d", - "sha256:b7aa5f8a41217360e600da646004f878250a0d6738bcdc11a0a39928d7dc2050", - "sha256:b7b4c971f05e6ae490fef852c218b0e79d4e52f79ef0c8475566584a8fb3e01d", - "sha256:ba90a9563ba44a72fda2e85302c3abc71c5589cea608ca16c22b9804262aaeb6", - "sha256:cb017fd1b2603ef59e374ba2063f593abe0fc45f2ad9abdde5b4d83bd922a353", - "sha256:d22656368f0e6189e24722214ed8d66b8022db19d182927b9a248a2a8a2f67eb", - "sha256:d2c2db7fd82e9b72937969bceac4d6ca89660db0a0967614ce2481e81a0b771e", - "sha256:d39b5b4f2a66ccae8b7263ac3c8170994b65266797fb96cbbfd3fb5b23921db8", - "sha256:d62a5c7dad11015c66fbb9d881bc4caa5b12f16292f857842d9d1871595f4495", - "sha256:e7d9405291c6928619403db1d10bd07888888ec1abcbd9748fdaa971d7d661b2", - "sha256:e84606b74eb7de6ff581a7915e2dab7a28a0517fbe1c9239eb227e1354064dcd", - "sha256:eb393e5ebc85245347950143969b241d08b52b88a3dc39479822e073a1a8eb27", - "sha256:ebba1cd308ef115925421d3e6a586e655ca5a77b5bf41e02eb0e4562a111f2d1", - "sha256:ee57190f24fba796e36bb6d3aa8a8783c643d8fa9760c89f7a98ab5455fbf818", - "sha256:f2f67fe12b22cd130d34d0ef79206061bfb5eda52feb6ce0dba0644e20a03cf4", - "sha256:f6951407391b639504e3b3be51b7ba5f3528adbf1a8ac3302b687ecababf929e", - "sha256:f75f7168ab25dd93110c8a8117a22450c19976afbc44234cbf71481094c1b850", - "sha256:fdec9e8cbf13a5bf63290fc6013d216a4c7232efb51548594ca3631a7f13c3a3" + "sha256:07ea61bcb179f8f05ffd804d2732b09d23a1238642bf7e51dad62082b5019b34", + "sha256:1084393c6bda8875c05e04fce5cfe1301a425f758eb012f010eab586f1f3905e", + "sha256:13c6cbbd5f31211d8fdb477f0f7b03438591bdd077054076eec362cf2207b4a7", + "sha256:211a4576e984f96d9fce61766ffaed0115d5dab1419e4f63d6992b480c2bd60b", + "sha256:2d22172f938455c156e9af2612650f26cceea47dc86ca048fa4e0b2d21646ad3", + "sha256:34f9f0763d5fa3035a315b69b428fe9c34d4fc2f615262d6be3d3bf3882fb985", + "sha256:3558e5b574d62f9c46b76120a5c7c16c4612dc2644c3d48a9f4064a705eaee95", + "sha256:36ce5d43a072a036f287029a55b5c6a0e9bd73db58961a273b6dc11a2c6eb9c2", + "sha256:37d5576d35fcb765fca05654f66aa71e2808d4237d026e64ac8b397ffa66a56a", + "sha256:3c9834d5e3df9d2aba0275c9f67989c590e05732439b3318fa37a725dff51e74", + "sha256:438856d3f8f1e27f8e79b5410ae56650732a0dcfa94e756df88c7e2d24851fcd", + "sha256:477c9430ad5d1b80b07f3c12f7120eef40bfbf849e9e7859e53b9c93b922d2af", + "sha256:49ab200acf891e3dde19e5aa4b0f35d12d8b4bd805dc0be8792270c71bd56c54", + "sha256:49dbb19cdcafc130f597d9e04a29d0a032ceedf729e41b181f51cd170e6ee865", + "sha256:4c8e31cf29b60859876474034a83f59a14381af50cbe8a9dbaadbf70adc4b214", + "sha256:4eddd3153d02204f22aef0825409091a91bf2a20bce06fe0f638f5c19a85de54", + "sha256:5247bab12f84a1d608213b96b8af0cbb30d090d705b6663ad794c2f2a5e5b9fe", + "sha256:5492a6ce3bdb15c6ad66cb68a0244854d9917478877a25671d70378bdc8562d0", + "sha256:56afbf41fa4a7b27f6635bc4289050ac3ab7951b8a821bca46f5b024500e6321", + "sha256:59777652e245bb1e300e620ce2bef0d341945842e4eb888c23a7f1d9e143c446", + "sha256:60f64e2007c9144375dd0f480a54d6070f00bb1a28f65c408370544091c9bc9e", + "sha256:63c5b8ecbc3b3d5eb3a9d873dec60afc0cd5ff9d9f1c75981d8c31cfe4df8527", + "sha256:68d8a0426b49c053013e631c0cdc09b952d857efa8f68121746b339912d27a12", + "sha256:74c160285f2dfe0acf0f72d425f3e970b21b6de04157fc65adc9fd07ee44177f", + "sha256:7a9baf8e230f9621f8e1d00c580394a0aa328fdac0df2b3f8384387c44083c0f", + "sha256:7df91fb24c2edaabec4e0eee512ff3bc6ec20eb8dccac2e77001c1fe516c0c84", + "sha256:7f297e0c1ae55300ff688568b04ff26b01c13dfbf4c9d2b7d0cb688ac60df479", + "sha256:80501d1b2270d7e8daf1b64b895745c3e234289e00d5f0e30923e706f110334e", + "sha256:85b7335c22455ec12444cec0d600533a238d6439d8d709d545158c1208483873", + "sha256:887665f00ea4e488501ba755a0e3c2cfd6278e846ada3185f42d391ef95e7e70", + "sha256:8f39c49faf5344af36042b293ce05c0d9004270d811c7080610b3e713251c9b0", + "sha256:90b6e2f0f66750c5a1178ffa9370dec6c508a8ca5265c42fbad3ccac210a7977", + "sha256:96d7d761aea65b291a98c84e1250cd57b5b51726821a6f2f8df65db89363be51", + "sha256:97af9554a799bd7c58c0179cc8dbf14aa7ab50e1fd5fa73f90b9b7215874ba28", + "sha256:97c44f4ee13bce914272589b6b41165bbb650e48fdb7bd5493a38bde8de730a1", + "sha256:a67e6bbe756ed458646e1ef2b0778591ed4d1fcd4b146fc3ba2feb1a7afd4254", + "sha256:ac0dec90e7de0087d3d95fa0533e1d2d722dcc008bc7b60e1143402a04c117c1", + "sha256:ad0f87826c4ebd3ef484502e79b39614e9c03a5d1510cfb623f4a4a051edc6fd", + "sha256:b3eb0c93e2ea6445b2173da48cb548364f8f65bf68f3d090404080d338e3a689", + "sha256:b543302a3707245d454fc49b8ecd2c2d5982b50eb63f3535244fd79a4be0c99d", + "sha256:b859128a093f135b556b4765658d5d2e758e1fae3e7cc2f8c10f26fe7005e543", + "sha256:bac329371d4c0d456e8d5f38a9b0816b446581b5f278474e416ea0c68c47dcd9", + "sha256:c02cfa6c36144ab334d556989406837336c1d05215a9bdf44c0bc1d1ac1cb637", + "sha256:c9737bc49a9255d78da085fa04f628a310c2332b187cd49b958b0e494c125071", + "sha256:ccc51713b5581e12f93ccb9c5e39e8b5d4b16776d584c0f5e9e4e63381356482", + "sha256:ce2ee86ca75f9f96072295c5ebb4ef2a43cecf2870b0ca5e7a1cbdd929cf67e1", + "sha256:d000a739f9feed900381605a12a61f7aaced6beae832719ae0d15058a1e81c1b", + "sha256:db76a1bcb51f02b2007adacbed4c88b6dee75342c37b05d1822815eed19edee5", + "sha256:e2ac9a1de294773b9fa77447ab7e529cf4fe3910f6a0832816e5f3d538cfea9a", + "sha256:e61260ec93f99f2c2d93d264b564ba912bec502f679793c56f678ba5251f0393", + "sha256:fac440c43e9b479d1241fe9d768645e7ccec3fb65dc3a5f6e90675e75c3f3e3a", + "sha256:fc0ed8d310afe013db1eedd37176d0839dc66c96bcfcce8f6607a73ffea2d6ba" ], "index": "pypi", - "version": "==7.2.7" + "version": "==7.3.0" }, "cryptography": { "hashes": [ @@ -378,13 +370,6 @@ "index": "pypi", "version": "==1.1.2" }, - "fido2": { - "hashes": [ - "sha256:b45e89a6109cfcb7f1bb513776aa2d6408e95c4822f83a253918b944083466ec" - ], - "index": "pypi", - "version": "==0.9.3" - }, "flask": { "hashes": [ "sha256:77fd4e1249d8c9923de34907236b747ced06e5467ecac1a7bb7115ae0e9670b0", @@ -779,7 +764,7 @@ "notifications-utils": { "editable": true, "git": "https://github.com/GSA/notifications-utils.git", - "ref": "86ebeec8985cc94ac9e402e1305fbd28c02b02d6" + "ref": "69ca3ffc963d32b0253b6537977ee235f5b31935" }, "numpy": { "hashes": [ @@ -903,14 +888,6 @@ "markers": "python_version >= '3.7'", "version": "==2.8.0" }, - "pypdf2": { - "hashes": [ - "sha256:a74408f69ba6271f71b9352ef4ed03dc53a31aa404d29b5d31f53bfecfee1440", - "sha256:d16e4205cfee272fbdc0568b68d82be796540b1537508cef59388f839c191928" - ], - "markers": "python_version >= '3.6'", - "version": "==3.0.1" - }, "pyproj": { "hashes": [ "sha256:00fab048596c17572fa8980014ef117dbb2a445e6f7ba3b9ddfcc683efc598e7", @@ -1125,14 +1102,6 @@ ], "version": "==1.6.7" }, - "typing-extensions": { - "hashes": [ - "sha256:440d5dd3af93b060174bf433bccd69b0babc3b15b1a8dca43789fd7f61514b36", - "sha256:b75ddc264f0ba5615db7ba217daeb99701ad295353c45f9e95963337ceeeffb2" - ], - "markers": "python_version < '3.10'", - "version": "==4.7.1" - }, "urllib3": { "hashes": [ "sha256:8d36afa7616d8ab714608411b4a3b13e58f463aee519024578e062e141dce20f", @@ -1150,11 +1119,11 @@ }, "werkzeug": { "hashes": [ - "sha256:935539fa1413afbb9195b24880778422ed620c0fc09670945185cce4d91a8890", - "sha256:98c774df2f91b05550078891dee5f0eb0cb797a522c757a2452b9cee5b202330" + "sha256:2b8c0e447b4b9dbcc85dd97b6eeb4dcbaf6c8b6c3be0bd654e25553e0a2157d8", + "sha256:effc12dba7f3bd72e605ce49807bbe692bd729c3bb122a3b91747a6ae77df528" ], "index": "pypi", - "version": "==2.3.6" + "version": "==2.3.7" }, "wtforms": { "hashes": [ @@ -1215,19 +1184,19 @@ }, "boto3": { "hashes": [ - "sha256:0300ca6ec8bc136eb316b32cc1e30c66b85bc497f5a5fe42e095ae4280569708", - "sha256:9d1b4713c888e53a218648ad71522bee9bec9d83f2999fff2494675af810b632" + "sha256:63619ffa44bc7f799b525c86d73bdb7f7a70994942bbff78253585bf64084e6e", + "sha256:a15841c7d04f87c63c9f2587b2b48198bec04d307d7b9950cbe4a021f845a5ba" ], "markers": "python_version >= '3.7'", - "version": "==1.28.24" + "version": "==1.28.26" }, "botocore": { "hashes": [ - "sha256:2d8f412c67f9285219f52d5dbbb6ef0dfa9f606da29cbdd41b6d6474bcc4bbd4", - "sha256:8c7ba9b09e9104e2d473214e1ffcf84b77e04cf6f5f2344942c1eed9e299f947" + "sha256:74d1c26144915312004a9f0232cdbe08946dfec9fc7dcd854456d2b73be9bfd9", + "sha256:e68a50ba76425ede8693fdf1f95b8411e283bc7619c03d7eb666db9f1de48153" ], "markers": "python_version >= '3.7'", - "version": "==1.31.24" + "version": "==1.31.26" }, "cachecontrol": { "extras": [ @@ -1395,7 +1364,7 @@ "sha256:f779d3ad205f108d14e99bb3859aa7dd8e9c68874617c72354d7ecaec2a054ac", "sha256:f87f746ee241d30d6ed93969de31e5ffd09a2961a051e60ae6bddde9ec3583aa" ], - "markers": "python_version >= '3.7'", + "markers": "python_full_version >= '3.7.0'", "version": "==3.2.0" }, "cryptography": { @@ -1708,7 +1677,7 @@ "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e" ], - "markers": "python_full_version >= '3.6.0'", + "markers": "python_version >= '3.6'", "version": "==0.7.0" }, "mdurl": { @@ -1853,16 +1822,16 @@ }, "playwright": { "hashes": [ - "sha256:428a719a6c7e40781c19860ed813840ac2d63678f7587abe12e800ea030d4b7e", - "sha256:4e396853034742b76654cdab27422155d238f46e4dc6369ea75854fafb935586", - "sha256:72e80076e595f5fcd8ebd89bf6635ad78e4bafa633119faed8b2568d17dbd398", - "sha256:84213339f179fd2a70f77ea7faea0616d74871349d556c53a1ecb7dd5097973c", - "sha256:89ca2261bb00b67d3dff97691cf18f4347ee0529a11e431e47df67b703d4d8fa", - "sha256:b7c6ddfca2b141b0385387cc56c125b14ea867902c39e3fc650ddd6c429b17da", - "sha256:ffbb927679b62fad5071439d5fe0840af46ad1844bc44bf80e1a0ad706140c98" + "sha256:41f0280472af94c426e941f6a969ff6a7ea156dc15fd01d09ac4b8f092e2346e", + "sha256:428fdf9bfff586b73f96df53692d50d422afb93ca4650624f61e8181f548fed2", + "sha256:678b9926be2df06321d11a525d4bf08d9f4a5b151354a3b82fe2ac14476322d5", + "sha256:68d56efe5ce916bab349177e90726837a6f0cae77ebd6a5200f5333b787b25fb", + "sha256:8b5d96aae54289129ab19d3d0e2e431171ae3e5d88d49a10900dcbe569a27d43", + "sha256:b476f63251876f1625f490af8d58ec0db90b555c623b7f54105f91d33878c06d", + "sha256:b574889ef97b7f44a633aa10d72b8966a850a4354d915fd0bc7e8658e825dd63" ], "markers": "python_version >= '3.8'", - "version": "==1.36.0" + "version": "==1.37.0" }, "pluggy": { "hashes": [ @@ -2065,7 +2034,7 @@ "sha256:146a90b3b6b47cac4a73c12866a499e9817426423f57c5a66949c086191a8808", "sha256:fb9d6c0a0f643c99eed3875b5377a184132ba9be4d61516a55273d3554d75a39" ], - "markers": "python_version >= '3.7'", + "markers": "python_full_version >= '3.7.0'", "version": "==13.5.2" }, "s3transfer": { @@ -2089,7 +2058,7 @@ "sha256:2aba19d6a040e78d8b09de5c57e96207b09ed71d8e55ce0959eeee6c8e190d94", "sha256:c840e62059cd3be204b0c9c9f74be2c09d5648eddd4580d9314c3ecde0b30936" ], - "markers": "python_full_version >= '3.6.0'", + "markers": "python_version >= '3.6'", "version": "==5.0.0" }, "sortedcontainers": { @@ -2150,7 +2119,7 @@ "sha256:440d5dd3af93b060174bf433bccd69b0babc3b15b1a8dca43789fd7f61514b36", "sha256:b75ddc264f0ba5615db7ba217daeb99701ad295353c45f9e95963337ceeeffb2" ], - "markers": "python_version < '3.10'", + "markers": "python_version >= '3.7'", "version": "==4.7.1" }, "urllib3": { @@ -2170,11 +2139,11 @@ }, "werkzeug": { "hashes": [ - "sha256:935539fa1413afbb9195b24880778422ed620c0fc09670945185cce4d91a8890", - "sha256:98c774df2f91b05550078891dee5f0eb0cb797a522c757a2452b9cee5b202330" + "sha256:2b8c0e447b4b9dbcc85dd97b6eeb4dcbaf6c8b6c3be0bd654e25553e0a2157d8", + "sha256:effc12dba7f3bd72e605ce49807bbe692bd729c3bb122a3b91747a6ae77df528" ], "index": "pypi", - "version": "==2.3.6" + "version": "==2.3.7" }, "xmltodict": { "hashes": [ diff --git a/app/__init__.py b/app/__init__.py index 035f585d4..5f244f630 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -34,7 +34,7 @@ from werkzeug.exceptions import HTTPException as WerkzeugHTTPException from werkzeug.exceptions import abort from werkzeug.local import LocalProxy -from app import proxy_fix, webauthn_server +from app import proxy_fix from app.asset_fingerprinter import asset_fingerprinter from app.config import configs from app.custom_auth import CustomBasicAuth @@ -257,7 +257,6 @@ def create_app(application): force_https=(application.config['HTTP_PROTOCOL'] == 'https') ) logging.init_app(application) - webauthn_server.init_app(application) login_manager.login_view = 'main.sign_in' login_manager.login_message_category = 'default' diff --git a/app/assets/javascripts/authenticateSecurityKey.js b/app/assets/javascripts/authenticateSecurityKey.js deleted file mode 100644 index 2d68555b1..000000000 --- a/app/assets/javascripts/authenticateSecurityKey.js +++ /dev/null @@ -1,74 +0,0 @@ -(function (window) { - "use strict"; - - window.GOVUK.Modules.AuthenticateSecurityKey = function () { - this.start = function (component) { - $(component) - .on('click', function (event) { - event.preventDefault(); - - // hide any existing error prompt - window.GOVUK.ErrorBanner.hideBanner(); - - fetch('/webauthn/authenticate') - .then(response => { - if (!response.ok) { - throw Error(response.statusText); - } - - return response.arrayBuffer(); - }) - .then(data => { - var options = window.CBOR.decode(data); - // triggers browser dialogue to login with authenticator - return window.navigator.credentials.get(options); - }) - .then(credential => { - const currentURL = new URL(window.location.href); - - // create authenticateURL from admin hostname plus /webauthn/authenticate path - const authenticateURL = new URL('/webauthn/authenticate', window.location.href); - - const nextUrl = currentURL.searchParams.get('next'); - if (nextUrl) { - // takes nextUrl from the query string on the current browser URL - // (which should be /two-factor-webauthn) and pass it through to - // the POST. put it in a query string so it's consistent with how - // the other login flows manage it - authenticateURL.searchParams.set('next', nextUrl); - } - - return fetch(authenticateURL, { - method: 'POST', - headers: { 'X-CSRFToken': component.data('csrfToken') }, - body: window.CBOR.encode({ - credentialId: new Uint8Array(credential.rawId), - authenticatorData: new Uint8Array(credential.response.authenticatorData), - signature: new Uint8Array(credential.response.signature), - clientDataJSON: new Uint8Array(credential.response.clientDataJSON), - }) - }); - }) - .then(response => { - if (!response.ok) { - throw Error(response.statusText); - } - - return response.arrayBuffer(); - }) - .then(cbor => { - return Promise.resolve(window.CBOR.decode(cbor)); - }) - .then(data => { - window.location.assign(data.redirect_url); - }) - .catch(error => { - console.error(error); - // some browsers will show an error dialogue for some - // errors; to be safe we always display an error message on the page. - window.GOVUK.ErrorBanner.showBanner(); - }); - }); - }; - }; -}) (window); diff --git a/app/assets/javascripts/registerSecurityKey.js b/app/assets/javascripts/registerSecurityKey.js deleted file mode 100644 index b7c93bef0..000000000 --- a/app/assets/javascripts/registerSecurityKey.js +++ /dev/null @@ -1,58 +0,0 @@ -(function(window) { - "use strict"; - - window.GOVUK.Modules.RegisterSecurityKey = function() { - this.start = function(component) { - $(component) - .on('click', function(event) { - event.preventDefault(); - - // hide any existing error prompt - window.GOVUK.ErrorBanner.hideBanner(); - - fetch('/webauthn/register') - .then((response) => { - if (!response.ok) { - throw Error(response.statusText); - } - - return response.arrayBuffer(); - }) - .then((data) => { - var options = window.CBOR.decode(data); - // triggers browser dialogue to select authenticator - return window.navigator.credentials.create(options); - }) - .then((credential) => { - return postWebAuthnCreateResponse( - credential.response, component.data('csrfToken') - ); - }) - .then((response) => { - if (!response.ok) { - throw Error(response.statusText); - } - - window.location.reload(); - }) - .catch((error) => { - console.error(error); - // some browsers will show an error dialogue for some - // errors; to be safe we always display an error message on the page. - window.GOVUK.ErrorBanner.showBanner(); - }); - }); - }; - }; - - function postWebAuthnCreateResponse(response, csrf_token) { - return fetch('/webauthn/register', { - method: 'POST', - headers: { 'X-CSRFToken': csrf_token }, - body: window.CBOR.encode({ - attestationObject: new Uint8Array(response.attestationObject), - clientDataJSON: new Uint8Array(response.clientDataJSON), - }) - }); - } -})(window); diff --git a/app/assets/stylesheets/main.scss b/app/assets/stylesheets/main.scss index 1d7c104db..0b97e96b1 100644 --- a/app/assets/stylesheets/main.scss +++ b/app/assets/stylesheets/main.scss @@ -80,7 +80,6 @@ $path: '/static/images/'; @import 'views/send'; @import 'views/get_started'; @import 'views/history'; -@import 'views/webauthn'; // TODO: break this up @import 'app'; diff --git a/app/assets/stylesheets/views/webauthn.scss b/app/assets/stylesheets/views/webauthn.scss deleted file mode 100644 index 33d1ee24e..000000000 --- a/app/assets/stylesheets/views/webauthn.scss +++ /dev/null @@ -1,40 +0,0 @@ -.webauthn__no-js { - .js-enabled & { - display: none; - } -} - -.webauthn__api-missing { - display: none; - - .js-enabled & { - display: block; - } - - .js-enabled.webauthn-api-enabled & { - display: none; - } -} - -.webauthn__api-required { - display: none; - - .webauthn-api-enabled & { - display: block; - } -} - -.webauthn-illustration { - - box-sizing: border-box; - width: 100%; - height: 100%; - margin: govuk-spacing(6) auto 0 auto; - padding: 0 govuk-spacing(9) 0 govuk-spacing(9); - - @include govuk-media-query($from: tablet) { - margin: govuk-spacing(9) auto 0 auto; - padding: 0; - } - -} diff --git a/app/formatters.py b/app/formatters.py index 6525ead4b..c1eadb657 100644 --- a/app/formatters.py +++ b/app/formatters.py @@ -487,7 +487,6 @@ def format_auth_type(auth_type, with_indefinite_article=False): indefinite_article, auth_type = { 'email_auth': ('an', 'Email link'), 'sms_auth': ('a', 'Text message code'), - 'webauthn_auth': ('a', 'Security key'), }[auth_type] if with_indefinite_article: diff --git a/app/main/__init__.py b/app/main/__init__.py index 9d2e0730b..9ab666c27 100644 --- a/app/main/__init__.py +++ b/app/main/__init__.py @@ -39,5 +39,4 @@ from app.main.views import ( # noqa isort:skip uploads, user_profile, verify, - webauthn_credentials, ) diff --git a/app/main/forms.py b/app/main/forms.py index f396e2b51..94fd57433 100644 --- a/app/main/forms.py +++ b/app/main/forms.py @@ -1058,10 +1058,6 @@ class BasePermissionsForm(StripWhitespaceForm): login_authentication=user.auth_type ) - # If a user logs in with a security key, we generally don't want a service admin to be able to change this. - # As well as enforcing this in the backend, we need to delete the auth radios to prevent validation errors. - if user.webauthn_auth: - del form.login_authentication return form diff --git a/app/main/views/find_users.py b/app/main/views/find_users.py index d01d2c6be..2ca737234 100644 --- a/app/main/views/find_users.py +++ b/app/main/views/find_users.py @@ -1,4 +1,4 @@ -from flask import abort, flash, redirect, render_template, request, url_for +from flask import flash, redirect, render_template, request, url_for from flask_login import current_user from notifications_python_client.errors import HTTPError @@ -56,8 +56,6 @@ def archive_user(user_id): @user_is_platform_admin def change_user_auth(user_id): user = User.from_id(user_id) - if user.webauthn_auth: - abort(403) form = AuthTypeForm(auth_type=user.auth_type) diff --git a/app/main/views/manage_users.py b/app/main/views/manage_users.py index b85e8980d..cfd6b5488 100644 --- a/app/main/views/manage_users.py +++ b/app/main/views/manage_users.py @@ -141,9 +141,8 @@ def edit_user_permissions(service_id, user_id): folder_permissions=form.folder_permissions.data, set_by_id=current_user.id, ) - # Only change the auth type if this is supported for a service. If a user logs in with a - # security key, we generally don't want them to be able to use something less secure. - if service_has_email_auth and not user.auth_type == 'webauthn_auth': + # Only change the auth type if this is supported for a service. + if service_has_email_auth: user.update(auth_type=form.login_authentication.data) return redirect(url_for('.manage_users', service_id=service_id)) diff --git a/app/main/views/new_password.py b/app/main/views/new_password.py index b44d1e8b1..55895e13d 100644 --- a/app/main/views/new_password.py +++ b/app/main/views/new_password.py @@ -47,8 +47,6 @@ def new_password(token): if user.email_auth: # they've just clicked an email link, so have done an email auth journey anyway. Just log them in. return log_in_user(user.id) - elif user.webauthn_auth: - return redirect(url_for('main.two_factor_webauthn', next=request.args.get('next'))) else: # send user a 2fa sms code user.send_verify_code() diff --git a/app/main/views/sign_in.py b/app/main/views/sign_in.py index edf3d4950..05ce1fe6a 100644 --- a/app/main/views/sign_in.py +++ b/app/main/views/sign_in.py @@ -59,8 +59,6 @@ def sign_in(): return redirect(url_for('.two_factor_sms', next=redirect_url)) if user.email_auth: return redirect(url_for('.two_factor_email_sent', next=redirect_url)) - if user.webauthn_auth: - return redirect(url_for('.two_factor_webauthn', next=redirect_url)) # Vague error message for login in case of user not known, locked, inactive or password not verified flash(Markup( diff --git a/app/main/views/two_factor.py b/app/main/views/two_factor.py index b2d9ae004..1f9e52ebb 100644 --- a/app/main/views/two_factor.py +++ b/app/main/views/two_factor.py @@ -1,7 +1,6 @@ import json from flask import ( - abort, current_app, redirect, render_template, @@ -88,18 +87,6 @@ def two_factor_sms(): return render_template('views/two-factor-sms.html', form=form, redirect_url=redirect_url) -@main.route('/two-factor-webauthn', methods=['GET']) -@redirect_to_sign_in -def two_factor_webauthn(): - user_id = session['user_details']['id'] - user = User.from_id(user_id) - - if not user.webauthn_auth: - abort(403) - - return render_template('views/two-factor-webauthn.html') - - @main.route('/re-validate-email', methods=['GET']) def revalidate_email_sent(): title = 'Email resent' if request.args.get('email_resent') else 'Check your email' diff --git a/app/main/views/user_profile.py b/app/main/views/user_profile.py index d461b72bc..34558b238 100644 --- a/app/main/views/user_profile.py +++ b/app/main/views/user_profile.py @@ -11,7 +11,6 @@ from flask import ( url_for, ) from flask_login import current_user -from notifications_python_client.errors import HTTPError from notifications_utils.url_safe_token import check_token from app import user_api_client @@ -25,7 +24,6 @@ from app.main.forms import ( ChangeMobileNumberForm, ChangeNameForm, ChangePasswordForm, - ChangeSecurityKeyNameForm, ConfirmPasswordForm, ServiceOnOffSettingForm, TwoFactorForm, @@ -266,77 +264,3 @@ def user_profile_disable_platform_admin_view(): 'views/user-profile/disable-platform-admin-view.html', form=form ) - - -@main.route("/user-profile/security-keys", methods=['GET']) -@user_is_logged_in -def user_profile_security_keys(): - if not current_user.can_use_webauthn: - abort(403) - - return render_template( - 'views/user-profile/security-keys.html', - ) - - -@main.route( - "/user-profile/security-keys//manage", - methods=['GET', 'POST'], - endpoint="user_profile_manage_security_key" -) -@main.route( - "/user-profile/security-keys//delete", - methods=['GET'], - endpoint="user_profile_confirm_delete_security_key" -) -@user_is_logged_in -def user_profile_manage_security_key(key_id): - if not current_user.can_use_webauthn: - abort(403) - - security_key = current_user.webauthn_credentials.by_id(key_id) - - if not security_key: - abort(404) - - form = ChangeSecurityKeyNameForm(security_key_name=security_key.name) - - if form.validate_on_submit(): - if form.security_key_name.data != security_key.name: - user_api_client.update_webauthn_credential_name_for_user( - user_id=current_user.id, - credential_id=key_id, - new_name_for_credential=form.security_key_name.data - ) - return redirect(url_for('.user_profile_security_keys')) - - if (request.endpoint == "main.user_profile_confirm_delete_security_key"): - flash("Are you sure you want to delete this security key?", 'delete') - - return render_template( - 'views/user-profile/manage-security-key.html', - security_key=security_key, - form=form - ) - - -@main.route("/user-profile/security-keys//delete", methods=['POST']) -@user_is_logged_in -def user_profile_delete_security_key(key_id): - if not current_user.can_use_webauthn: - abort(403) - - try: - user_api_client.delete_webauthn_credential_for_user( - user_id=current_user.id, - credential_id=key_id - ) - except HTTPError as e: - message = "Cannot delete last remaining webauthn credential for user" - if e.message == message: - flash("You cannot delete your last security key.") - return redirect(url_for('.user_profile_manage_security_key', key_id=key_id)) - else: - raise e - - return redirect(url_for('.user_profile_security_keys')) diff --git a/app/main/views/webauthn_credentials.py b/app/main/views/webauthn_credentials.py deleted file mode 100644 index 38895ac08..000000000 --- a/app/main/views/webauthn_credentials.py +++ /dev/null @@ -1,161 +0,0 @@ -from fido2 import cbor -from fido2.client import ClientData -from fido2.ctap2 import AuthenticatorData -from flask import abort, current_app, flash, redirect, request, session, url_for -from flask_login import current_user - -from app.main import main -from app.models.user import User -from app.models.webauthn_credential import RegistrationError, WebAuthnCredential -from app.notify_client.user_api_client import user_api_client -from app.utils.login import ( - email_needs_revalidating, - log_in_user, - redirect_to_sign_in, -) -from app.utils.user import user_is_logged_in - - -@main.route('/webauthn/register') -@user_is_logged_in -def webauthn_begin_register(): - if not current_user.can_use_webauthn: - abort(403) - - server = current_app.webauthn_server - - registration_data, state = server.register_begin( - { - "id": bytes(current_user.id, 'utf-8'), - "name": current_user.email_address, - "displayName": current_user.name, - }, - credentials=current_user.webauthn_credentials.as_cbor, - user_verification="discouraged", # don't ask for PIN - authenticator_attachment="cross-platform", - ) - - session["webauthn_registration_state"] = state - return cbor.encode(registration_data) - - -@main.route('/webauthn/register', methods=['POST']) -@user_is_logged_in -def webauthn_complete_register(): - if 'webauthn_registration_state' not in session: - return cbor.encode("No registration in progress"), 400 - - try: - credential = WebAuthnCredential.from_registration( - session.pop("webauthn_registration_state"), - cbor.decode(request.get_data()), - ) - except RegistrationError as e: - current_app.logger.info(f'User {current_user.id} could not register a new webauthn token - {e}') - abort(400) - - current_user.create_webauthn_credential(credential) - current_user.update(auth_type='webauthn_auth') - - flash(( - 'Registration complete. Next time you sign in to Notify ' - 'you’ll be asked to use your security key.' - ), 'default_with_tick') - - return cbor.encode('') - - -@main.route('/webauthn/authenticate', methods=['GET']) -@redirect_to_sign_in -def webauthn_begin_authentication(): - """ - Initiate the authentication flow. This is called after the user clicks the "Check security key" button. - - 1. Get the user's credentials out of the database to present to the browser. The browser will only let you use a - credential in that list. - 2. Call webauthn_server.authenticate_begin. This returns the authentication data, which includes the challenge and - the origin domain to authenticate with. This also returns the state, which we store in the cookie so we can ensure - the challenge is correct in webauthn_complete_authentication - """ - # get user from session - user_to_login = User.from_id(session['user_details']['id']) - - if not user_to_login.webauthn_auth: - abort(403) - - authentication_data, state = current_app.webauthn_server.authenticate_begin( - credentials=user_to_login.webauthn_credentials.as_cbor, - user_verification="discouraged", # don't ask for PIN - ) - session["webauthn_authentication_state"] = state - return cbor.encode(authentication_data) - - -@main.route('/webauthn/authenticate', methods=['POST']) -@redirect_to_sign_in -def webauthn_complete_authentication(): - """ - Complete the authentication flow. This is called after the user taps on their security key. - - 1. Try verifying the signed challenge returned from the browser with each public key we have in the database for - that user. - 2. If succesful, log the user in, setting up the session etc. Then return the URL they should be redirected to. - """ - user_id = session['user_details']['id'] - user_to_login = User.from_id(user_id) - - _verify_webauthn_authentication(user_to_login) - redirect = _complete_webauthn_login_attempt(user_to_login) - - return cbor.encode({'redirect_url': redirect.location}), 200 - - -def _verify_webauthn_authentication(user): - """ - Check that the presented security key is valid, has signed the right challenge, and belongs to the user - we're trying to log in. - """ - state = session.pop("webauthn_authentication_state") - request_data = cbor.decode(request.get_data()) - - try: - current_app.webauthn_server.authenticate_complete( - state=state, - credentials=user.webauthn_credentials.as_cbor, - credential_id=request_data['credentialId'], - client_data=ClientData(request_data['clientDataJSON']), - auth_data=AuthenticatorData(request_data['authenticatorData']), - signature=request_data['signature'] - ) - except ValueError as exc: - # We don't expect to reach this case in normal situations - normally errors (such as using the wrong - # security key) will be caught in the browser inside `window.navigator.credentials.get`, and the js will - # error first meaning it doesn't send the POST request to this method. If this method is called but the key - # couldn't be authenticated, something went wrong along the way, probably: - # * The browser didn't implement the webauthn standard correctly, and let something through it shouldn't have - # * The key itself is in some way corrupted, or of lower security standard - current_app.logger.info(f'User {user.id} could not sign in using their webauthn token - {exc}') - user.complete_webauthn_login_attempt(is_successful=False) - abort(403) - - -def _complete_webauthn_login_attempt(user): - """ - * check the user hasn't gone over their max logins - * check that the user's email is validated - * if succesful, update current_session_id, log in date, and then redirect - """ - redirect_url = request.args.get('next') - - # normally API handles this when verifying an sms or email code but since the webauthn logic happens in the - # admin we need a separate call that just finalises the login in the database - logged_in, _ = user.complete_webauthn_login_attempt() - if not logged_in: - # user account is locked as too many failed logins - abort(403) - - if email_needs_revalidating(user): - user_api_client.send_verify_code(user.id, 'email', None, redirect_url) - return redirect(url_for('.revalidate_email_sent', next=redirect_url)) - - return log_in_user(user.id) diff --git a/app/models/user.py b/app/models/user.py index e62006348..b4a30002c 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -11,7 +11,6 @@ from app.event_handlers import ( ) from app.models import JSONModel, ModelList from app.models.organization import Organization, Organizations -from app.models.webauthn_credential import WebAuthnCredentials from app.notify_client import InviteTokenError from app.notify_client.invite_api_client import invite_api_client from app.notify_client.org_invite_api_client import org_invite_api_client @@ -37,7 +36,6 @@ class User(JSONModel, UserMixin): MAX_FAILED_LOGIN_COUNT = 10 ALLOWED_PROPERTIES = { - 'can_use_webauthn', 'id', 'name', 'email_address', @@ -180,10 +178,6 @@ class User(JSONModel, UserMixin): def email_auth(self): return self.auth_type == 'email_auth' - @property - def webauthn_auth(self): - return self.auth_type == 'webauthn_auth' - def reset_failed_login_count(self): user_api_client.reset_failed_login_count(self.id) @@ -371,15 +365,6 @@ class User(JSONModel, UserMixin): '@nhs.uk', '.nhs.uk', '@nhs.net', '.nhs.net', )) - @property - def webauthn_credentials(self): - return WebAuthnCredentials(self.id) - - def create_webauthn_credential(self, credential): - user_api_client.create_webauthn_credential_for_user( - self.id, credential - ) - def serialize(self): dct = { "id": self.id, @@ -456,9 +441,6 @@ class User(JSONModel, UserMixin): self.id, ) - def complete_webauthn_login_attempt(self, is_successful=True): - return user_api_client.complete_webauthn_login_attempt(self.id, is_successful) - def is_editable_by(self, other_user): if other_user == self: return False diff --git a/app/models/webauthn_credential.py b/app/models/webauthn_credential.py deleted file mode 100644 index c5bee7a71..000000000 --- a/app/models/webauthn_credential.py +++ /dev/null @@ -1,76 +0,0 @@ -import base64 - -from fido2 import cbor -from fido2.client import ClientData -from fido2.cose import UnsupportedKey -from fido2.ctap2 import AttestationObject, AttestedCredentialData -from flask import current_app - -from app.models import JSONModel, ModelList -from app.notify_client.user_api_client import user_api_client - - -class RegistrationError(Exception): - pass - - -class WebAuthnCredential(JSONModel): - ALLOWED_PROPERTIES = { - 'id', - 'name', - 'credential_data', # contains public key and credential ID for auth - 'registration_response', # sent to API for later auditing (not used) - 'created_at', - 'updated_at' - } - - @classmethod - def from_registration(cls, state, response): - server = current_app.webauthn_server - - try: - auth_data = server.register_complete( - state, - ClientData(response["clientDataJSON"]), - AttestationObject(response["attestationObject"]), - ) - except ValueError as e: - raise RegistrationError(e) - - if isinstance(auth_data.credential_data.public_key, UnsupportedKey): - raise RegistrationError("Encryption algorithm not supported") - - return cls({ - 'name': 'Unnamed key', - 'credential_data': base64.b64encode( - cbor.encode(auth_data.credential_data), - ).decode('utf-8'), - 'registration_response': base64.b64encode( - cbor.encode(response), - ).decode('utf-8') - }) - - def to_credential_data(self): - return AttestedCredentialData( - cbor.decode(base64.b64decode(self.credential_data.encode())) - ) - - def serialize(self): - return { - 'name': self.name, - 'credential_data': self.credential_data, - 'registration_response': self.registration_response, - } - - -class WebAuthnCredentials(ModelList): - - model = WebAuthnCredential - client_method = user_api_client.get_webauthn_credentials_for_user - - @property - def as_cbor(self): - return [credential.to_credential_data() for credential in self] - - def by_id(self, key_id): - return next((key for key in self if key.id == key_id), None) diff --git a/app/navigation.py b/app/navigation.py index d2af07e68..1fe7ba3ad 100644 --- a/app/navigation.py +++ b/app/navigation.py @@ -118,7 +118,6 @@ class HeaderNavigation(Navigation): 'two_factor_email', 'two_factor_email_sent', 'two_factor_email_interstitial', - 'two_factor_webauthn', 'verify', 'verify_email', }, diff --git a/app/notify_client/user_api_client.py b/app/notify_client/user_api_client.py index 55071175f..326882f1c 100644 --- a/app/notify_client/user_api_client.py +++ b/app/notify_client/user_api_client.py @@ -132,20 +132,6 @@ class UserApiClient(NotifyAdminAPIClient): return False, e.message raise - @cache.delete('user-{user_id}') - def complete_webauthn_login_attempt(self, user_id, is_successful): - data = {'successful': is_successful} - endpoint = f'/user/{user_id}/complete/webauthn-login' - try: - current_app.logger.warn(f"Sending webauthn-login for {user_id}") - self.post(endpoint, data=data) - return True, '' - except HTTPError as e: - if e.status_code == 403: - current_app.logger.error(f"Webauthn-login attempt for {user_id} was invalid") - return False, e.message - raise - def get_users_for_service(self, service_id): endpoint = '/service/{}/users'.format(service_id) return self.get(endpoint)['data'] @@ -219,25 +205,5 @@ class UserApiClient(NotifyAdminAPIClient): endpoint = '/user/{}/organizations-and-services'.format(user_id) return self.get(endpoint) - def get_webauthn_credentials_for_user(self, user_id): - endpoint = f'/user/{user_id}/webauthn' - - return self.get(endpoint)['data'] - - def create_webauthn_credential_for_user(self, user_id, credential): - endpoint = f'/user/{user_id}/webauthn' - - return self.post(endpoint, data=credential.serialize()) - - def update_webauthn_credential_name_for_user(self, *, user_id, credential_id, new_name_for_credential): - endpoint = f'/user/{user_id}/webauthn/{credential_id}' - - return self.post(endpoint, data={"name": new_name_for_credential}) - - def delete_webauthn_credential_for_user(self, *, user_id, credential_id): - endpoint = f'/user/{user_id}/webauthn/{credential_id}' - - return self.delete(endpoint) - user_api_client = UserApiClient() diff --git a/app/templates/components/webauthn-api-check.html b/app/templates/components/webauthn-api-check.html deleted file mode 100644 index 19bc56bc5..000000000 --- a/app/templates/components/webauthn-api-check.html +++ /dev/null @@ -1,7 +0,0 @@ -{% macro webauthn_api_check() %} - -{% endmacro %} diff --git a/app/templates/views/find-users/user-information.html b/app/templates/views/find-users/user-information.html index d4ba0bf15..52c7efbb0 100644 --- a/app/templates/views/find-users/user-information.html +++ b/app/templates/views/find-users/user-information.html @@ -49,11 +49,9 @@

Authentication

{{ user.auth_type | format_auth_type }}

- {% if user.auth_type != 'webauthn_auth' %} - - Change authentication for this user - - {% endif %} + + Change authentication for this user +

Last login

{% if not user.logged_in_at %} diff --git a/app/templates/views/manage-users/permissions.html b/app/templates/views/manage-users/permissions.html index 4a7365233..1075adb71 100644 --- a/app/templates/views/manage-users/permissions.html +++ b/app/templates/views/manage-users/permissions.html @@ -11,11 +11,7 @@ {% endif %} {% if service_has_email_auth %} - {% if user.webauthn_auth %} -

- This user will login with a security key. -

- {% elif not mobile_number %} + {% if not mobile_number %} {{ radios( form.login_authentication, disable=['sms_auth'], diff --git a/app/templates/views/two-factor-webauthn.html b/app/templates/views/two-factor-webauthn.html deleted file mode 100644 index 7b155f556..000000000 --- a/app/templates/views/two-factor-webauthn.html +++ /dev/null @@ -1,71 +0,0 @@ -{% extends "withoutnav_template.html" %} -{% from "components/page-header.html" import page_header %} -{% from "components/uk_components/button/macro.njk" import govukButton %} -{% from "components/uk_components/back-link/macro.njk" import govukBackLink %} -{% from "components/webauthn-api-check.html" import webauthn_api_check %} -{% from "components/uk_components/error-message/macro.njk" import govukErrorMessage %} - -{% set page_title = 'Get your security key' %} - -{% block extra_javascripts_before_body %} - {{ webauthn_api_check() }} -{% endblock %} - -{% block per_page_title %} - {{ page_title }} -{% endblock %} - -{% block backLink %} - {{ govukBackLink({ "href": url_for('.user_profile') }) }} -{% endblock %} - -{% block maincolumn_content %} - - {{ govukErrorMessage({ - "classes": "banner-dangerous govuk-!-display-none", - "html": ( - 'There’s a problem with your security key' + - '

Check you have the right key and try again. ' + - 'If this does not work, ' + - 'contact us." + - '

' - ), - "attributes": { - "aria-live": "polite", - "tabindex": '-1' - } - }) }} - -
-
- {{ page_header(page_title) }} - -

- You need to have your security key to sign in. -

- - {{ govukButton({ - "element": "button", - "text": "Check security key", - "classes": "govuk-button--secondary webauthn__api-required", - "attributes": { - "data-module": "authenticate-security-key", - "data-csrf-token": csrf_token(), - } - }) }} - - {{ govukErrorMessage({ - "classes": "webauthn__api-missing", - "text": "Your browser does not support security keys. Try signing in to Notify using a different browser." - }) }} - - {{ govukErrorMessage({ - "classes": "webauthn__no-js", - "text": "JavaScript is not available for this page. Security keys need JavaScript to work." - }) }} -
-
- -
-
-{% endblock %} diff --git a/app/templates/views/user-profile.html b/app/templates/views/user-profile.html index b3cf15385..b603b7828 100644 --- a/app/templates/views/user-profile.html +++ b/app/templates/views/user-profile.html @@ -65,22 +65,6 @@ }} {% endcall %} - {% if current_user.can_use_webauthn %} - {% call row(id='security-keys') %} - {{ text_field('Security keys') }} - {{ optional_text_field( - ('{} registered'.format(current_user.webauthn_credentials|length)) if current_user.webauthn_credentials else None, - default='None registered' - ) }} - {{ edit_field( - 'Change', - url_for('.user_profile_security_keys'), - suffix='security keys' - ) - }} - {% endcall %} - {% endif %} - {% if current_user.platform_admin or session.get('disable_platform_admin_view') %} {% call row(id='disable-platform-admin') %} {{ text_field('Use platform admin view') }} diff --git a/app/templates/views/user-profile/manage-security-key.html b/app/templates/views/user-profile/manage-security-key.html deleted file mode 100644 index 67f8d2506..000000000 --- a/app/templates/views/user-profile/manage-security-key.html +++ /dev/null @@ -1,34 +0,0 @@ -{% extends "withoutnav_template.html" %} -{% from "components/page-header.html" import page_header %} -{% from "components/page-footer.html" import page_footer %} -{% from "components/form.html" import form_wrapper %} -{% from "components/uk_components/back-link/macro.njk" import govukBackLink %} - -{% set page_title = 'Manage ' + '‘' + security_key.name + '’' %} - -{% block per_page_title %} - {{ page_title }} -{% endblock %} - -{% block backLink %} - {{ govukBackLink({ "href": url_for('.user_profile_security_keys') }) }} -{% endblock %} - -{% block maincolumn_content %} - {{ page_header(page_title) }} - -
-
- {% call form_wrapper(autocomplete=True) %} - {{ form.security_key_name }} - {{ page_footer( - 'Save', - delete_link=url_for( - '.user_profile_confirm_delete_security_key', key_id=security_key.id), - delete_link_text='Delete' - ) }} - {% endcall %} -
-
- -{% endblock %} diff --git a/app/templates/views/user-profile/security-keys.html b/app/templates/views/user-profile/security-keys.html deleted file mode 100644 index 82603c69c..000000000 --- a/app/templates/views/user-profile/security-keys.html +++ /dev/null @@ -1,107 +0,0 @@ -{% extends "withoutnav_template.html" %} -{% from "components/page-header.html" import page_header %} -{% from "components/uk_components/button/macro.njk" import govukButton %} -{% from "components/uk_components/back-link/macro.njk" import govukBackLink %} -{% from "components/table.html" import edit_field, mapping_table, row, field, row_heading %} -{% from "components/webauthn-api-check.html" import webauthn_api_check %} -{% from "components/uk_components/error-message/macro.njk" import govukErrorMessage %} - -{% set page_title = 'Security keys' %} -{% set credentials = current_user.webauthn_credentials %} - -{% block extra_javascripts_before_body %} - {{ webauthn_api_check() }} -{% endblock %} - -{% block per_page_title %} - {{ page_title }} -{% endblock %} - -{% block backLink %} - {{ govukBackLink({ "href": url_for('.user_profile') }) }} -{% endblock %} - -{% block maincolumn_content %} - -{% set webauthn_button %} - {{ govukButton({ - "element": "button", - "text": "Register a key", - "classes": "govuk-button--secondary webauthn__api-required", - "attributes": { - "data-module": "register-security-key", - "data-csrf-token": csrf_token(), - } - }) }} - - {{ govukErrorMessage({ - "classes": "webauthn__api-missing", - "text": "Your browser does not support security keys. Try signing in to Notify using a different browser." - }) }} - - {{ govukErrorMessage({ - "classes": "webauthn__no-js", - "text": "JavaScript is not available for this page. Security keys need JavaScript to work." - }) }} -{% endset %} - -
- - {{ govukErrorMessage({ - "classes": "banner-dangerous govuk-!-display-none", - "html": ( - 'There’s a problem with your security key' + - '

Check you have the right key and try again. ' + - 'If this does not work, ' + - 'contact us." + - '

' - ), - "attributes": { - "aria-live": "polite", - "tabindex": '-1' - } - }) }} - - {% if credentials %} -
- {{ page_header(page_title) }} -
- {% call mapping_table( - caption=page_title, - field_headings=['Security key details', 'Action'], - field_headings_visible=False, - caption_visible=False, - ) %} - {% for credential in credentials %} - {% call row() %} - {% call field() %} -
{{ credential.name }}
-
Registered {{ credential.created_at|format_delta }}
- {% endcall %} - {{ edit_field('Manage', url_for('.user_profile_manage_security_key', key_id=credential.id)) }} - {% endcall %} - {% endfor %} - {% endcall %} -
- {{ webauthn_button }} -
- {% else %} -
- {{ page_header(page_title) }} -

- Security keys are an alternative way of signing in to Notify, - instead of getting a code in a text message -

-

- You can buy any key that’s compatible with the WebAuthn - standard. -

- {{ webauthn_button }} -
-
- -
- {% endif %} -
- -{% endblock %} diff --git a/app/webauthn_server.py b/app/webauthn_server.py deleted file mode 100644 index b9c4780f7..000000000 --- a/app/webauthn_server.py +++ /dev/null @@ -1,32 +0,0 @@ -from urllib.parse import urlparse - -from fido2.server import Fido2Server -from fido2.webauthn import PublicKeyCredentialRpEntity - - -def init_app(app): - base_url = urlparse(app.config["ADMIN_BASE_URL"]) - verify_origin_callback = None - - # stub verification in dev (to avoid need for HTTPS) - if app.config["NOTIFY_ENVIRONMENT"] == "development": - verify_origin_callback = stub_origin_checker - - relying_party = PublicKeyCredentialRpEntity( - id=base_url.hostname, - name="U.S. Notify", - ) - - app.webauthn_server = Fido2Server( - relying_party, - attestation="direct", - verify_origin=verify_origin_callback, - ) - - # some browsers don't seem to have a default timeout - # 30 seconds seems like a generous amount of time - app.webauthn_server.timeout = 30_000 - - -def stub_origin_checker(*args): - return True diff --git a/gulpfile.js b/gulpfile.js index 4d9689be0..70e569291 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -124,8 +124,6 @@ const javascripts = () => { paths.src + 'javascripts/templateFolderForm.js', paths.src + 'javascripts/collapsibleCheckboxes.js', paths.src + 'javascripts/radioSlider.js', - paths.src + 'javascripts/registerSecurityKey.js', - paths.src + 'javascripts/authenticateSecurityKey.js', paths.src + 'javascripts/updateStatus.js', paths.src + 'javascripts/errorBanner.js', paths.src + 'javascripts/homepage.js', @@ -243,7 +241,7 @@ const defaultTask = parallel( series( javascripts ), - sass, + sass, uswds.compile, uswds.copyAssets, copy.gtm @@ -295,4 +293,4 @@ exports.init = uswds.init; exports.compile = uswds.compile; exports.copyAll = uswds.copyAll; exports.watch = uswds.watch; -exports.copyAssets = uswds.copyAssets; \ No newline at end of file +exports.copyAssets = uswds.copyAssets; diff --git a/tests/app/main/views/test_find_users.py b/tests/app/main/views/test_find_users.py index 2b36ccb9c..566e01644 100644 --- a/tests/app/main/views/test_find_users.py +++ b/tests/app/main/views/test_find_users.py @@ -163,28 +163,6 @@ def test_user_information_page_shows_change_auth_type_link( assert normalize_spaces(link.text) == 'Change authentication for this user' -def test_user_information_page_doesnt_show_change_auth_type_link_if_user_on_webauthn( - client_request, - platform_admin_user, - api_user_active, - mock_get_organizations_and_services_for_user, - mocker -): - client_request.login(platform_admin_user) - mocker.patch('app.user_api_client.get_user', side_effect=[ - platform_admin_user, - user_json(id_=api_user_active['id'], name="Apple Bloom", auth_type='webauthn_auth') - ], autospec=True) - - page = client_request.get( - 'main.user_information', user_id=api_user_active['id'] - ) - change_auth_url = url_for('main.change_user_auth', user_id=api_user_active['id']) - - link = page.find_all('a', {'href': change_auth_url}) - assert len(link) == 0 - - @pytest.mark.parametrize('current_auth_type', ['email_auth', 'sms_auth']) def test_change_user_auth_preselects_current_auth_type( client_request, diff --git a/tests/app/main/views/test_manage_users.py b/tests/app/main/views/test_manage_users.py index e672c1a6b..efc154438 100644 --- a/tests/app/main/views/test_manage_users.py +++ b/tests/app/main/views/test_manage_users.py @@ -770,62 +770,6 @@ def test_edit_user_permissions_shows_authentication_for_email_auth_service( assert not any(button.has_attr("disabled") for button in radio_buttons) -def test_edit_user_permissions_hides_authentication_for_webauthn_user( - client_request, - service_one, - mock_get_users_by_service, - mock_get_template_folders, - active_user_with_permissions, -): - active_user_with_permissions['auth_type'] = 'webauthn_auth' - service_one['permissions'].append('email_auth') - - page = client_request.get( - 'main.edit_user_permissions', - service_id=SERVICE_ONE_ID, - user_id=active_user_with_permissions['id'], - ) - - assert 'This user will login with a security key' in str(page) - assert page.select_one('#login_authentication') is None - - -@pytest.mark.parametrize('new_auth_type', ['sms_auth', 'email_auth']) -def test_edit_user_permissions_preserves_auth_type_for_webauthn_user( - client_request, - service_one, - active_user_with_permissions, - mock_get_users_by_service, - mock_get_invites_for_service, - mock_set_user_permissions, - mock_update_user_attribute, - mock_get_template_folders, - new_auth_type, -): - active_user_with_permissions['auth_type'] = 'webauthn_auth' - service_one['permissions'].append('email_auth') - - client_request.post( - 'main.edit_user_permissions', - service_id=SERVICE_ONE_ID, - user_id=active_user_with_permissions['id'], - _data={ - 'email_address': active_user_with_permissions['email_address'], - 'permissions_field': [], - 'login_authentication': new_auth_type, - }, - _expected_status=302, - ) - - mock_set_user_permissions.assert_called_with( - str(active_user_with_permissions['id']), - SERVICE_ONE_ID, - permissions=set(), - folder_permissions=[], - ) - mock_update_user_attribute.assert_not_called() - - def test_should_show_page_for_inviting_user( client_request, mock_get_template_folders, diff --git a/tests/app/main/views/test_new_password.py b/tests/app/main/views/test_new_password.py index 9fcd608ba..0e4fca2e7 100644 --- a/tests/app/main/views/test_new_password.py +++ b/tests/app/main/views/test_new_password.py @@ -77,34 +77,6 @@ def test_should_redirect_to_two_factor_when_password_reset_is_successful( mock_get_user_by_email_request_password_reset.assert_called_once_with(user['email_address']) -@pytest.mark.parametrize('redirect_url', [ - None, - f'/services/{SERVICE_ONE_ID}/templates', -]) -def test_should_redirect_to_two_factor_webauthn_when_password_reset_is_successful( - notify_admin, - client_request, - mock_get_user_by_email_request_password_reset, - mock_send_verify_code, - mock_reset_failed_login_count, - redirect_url -): - client_request.logout() - user = mock_get_user_by_email_request_password_reset.return_value - user['auth_type'] = 'webauthn_auth' - data = json.dumps({'email': user['email_address'], 'created_at': str(datetime.utcnow())}) - token = generate_token(data, notify_admin.config['SECRET_KEY'], notify_admin.config['DANGEROUS_SALT']) - client_request.post_url( - url_for_endpoint_with_token('.new_password', token=token, next=redirect_url), - _data={'new_password': 'a-new_password'}, - _expected_redirect=url_for('.two_factor_webauthn', next=redirect_url), - ) - mock_get_user_by_email_request_password_reset.assert_called_once_with(user['email_address']) - - assert not mock_send_verify_code.called - assert mock_reset_failed_login_count.called - - def test_should_redirect_index_if_user_has_already_changed_password( notify_admin, client_request, diff --git a/tests/app/main/views/test_sign_in.py b/tests/app/main/views/test_sign_in.py index 4fcbd6784..b2929573b 100644 --- a/tests/app/main/views/test_sign_in.py +++ b/tests/app/main/views/test_sign_in.py @@ -192,34 +192,6 @@ def test_process_email_auth_sign_in_return_2fa_template( mock_verify_password.assert_called_with(api_user_active_email_auth['id'], 'val1dPassw0rd!') -@pytest.mark.parametrize('redirect_url', [ - None, - f'/services/{SERVICE_ONE_ID}/templates', -]) -def test_process_webauthn_auth_sign_in_redirects_to_webauthn_with_next_redirect( - client_request, - api_user_active, - mocker, - mock_verify_password, - redirect_url -): - client_request.logout() - api_user_active['auth_type'] = 'webauthn_auth' - mock_get_user_by_email = mocker.patch('app.user_api_client.get_user_by_email', return_value=api_user_active) - - client_request.post( - 'main.sign_in', - next=redirect_url, - _data={ - 'email_address': 'valid@example.gsa.gov', - 'password': 'val1dPassw0rd!', - }, - _expected_redirect=url_for('.two_factor_webauthn', next=redirect_url) - ) - - mock_get_user_by_email.assert_called_once_with('valid@example.gsa.gov') - - def test_should_return_locked_out_true_when_user_is_locked( client_request, mock_get_user_by_email_locked, diff --git a/tests/app/main/views/test_two_factor.py b/tests/app/main/views/test_two_factor.py index d4705006c..8144faa19 100644 --- a/tests/app/main/views/test_two_factor.py +++ b/tests/app/main/views/test_two_factor.py @@ -295,54 +295,6 @@ def test_two_factor_sms_post_should_redirect_to_sign_in_if_user_not_in_session( ) -@pytest.mark.parametrize('endpoint', ['main.two_factor_webauthn', 'main.two_factor_sms']) -def test_two_factor_endpoints_get_should_redirect_to_sign_in_if_user_not_in_session( - client_request, - endpoint, -): - client_request.get( - endpoint, - _expected_redirect=url_for('main.sign_in') - ) - - -def test_two_factor_webauthn_should_have_auth_signin_button( - client_request, - platform_admin_user, - mocker, -): - client_request.logout() - mock_get_user = mocker.patch('app.user_api_client.get_user', return_value=platform_admin_user) - with client_request.session_transaction() as session: - session['user_details'] = {'id': platform_admin_user['id'], 'email': platform_admin_user['email_address']} - - page = client_request.get('main.two_factor_webauthn') - - button = page.select_one("button[data-module=authenticate-security-key]") - - assert button.text.strip() == 'Check security key' - - assert button.name == 'button' - mock_get_user.assert_called_once_with(platform_admin_user['id']) - - -def test_two_factor_webauthn_should_reject_non_webauthn_auth_users( - client_request, - platform_admin_user, - mocker, -): - client_request.logout() - platform_admin_user['auth_type'] = 'sms_auth' - mocker.patch('app.user_api_client.get_user', return_value=platform_admin_user) - with client_request.session_transaction() as session: - session['user_details'] = {'id': platform_admin_user['id'], 'email': platform_admin_user['email_address']} - - client_request.get( - 'main.two_factor_webauthn', - _expected_status=403, - ) - - def test_two_factor_sms_should_activate_pending_user( client_request, mocker, diff --git a/tests/app/main/views/test_user_profile.py b/tests/app/main/views/test_user_profile.py index ead6cd4c6..ca2f4cb2f 100644 --- a/tests/app/main/views/test_user_profile.py +++ b/tests/app/main/views/test_user_profile.py @@ -3,13 +3,8 @@ import uuid import pytest from flask import url_for -from notifications_python_client.errors import HTTPError from notifications_utils.url_safe_token import generate_token -from app.models.webauthn_credential import ( - WebAuthnCredential, - WebAuthnCredentials, -) from tests.conftest import ( create_api_user_active, create_user, @@ -29,10 +24,8 @@ def test_should_show_overview_page( def test_overview_page_shows_disable_for_platform_admin( client_request, - platform_admin_user, - mocker + platform_admin_user ): - mocker.patch('app.models.webauthn_credential.WebAuthnCredentials.client_method') client_request.login(platform_admin_user) page = client_request.get('main.user_profile') assert page.select_one('h1').text.strip() == 'Your profile' @@ -41,30 +34,6 @@ def test_overview_page_shows_disable_for_platform_admin( 'Use platform admin view Yes Change whether to use platform admin view' -@pytest.mark.parametrize('key_count, expected_row_text', [ - (0, 'Security keys None registered Change security keys'), - (1, 'Security keys 1 registered Change security keys'), - (2, 'Security keys 2 registered Change security keys'), -]) -def test_overview_page_shows_security_keys_if_user_they_can_use_webauthn( - mocker, - client_request, - platform_admin_user, - webauthn_credential, - key_count, - expected_row_text, -): - client_request.login(platform_admin_user) - credentials = [webauthn_credential for _ in range(key_count)] - mocker.patch( - 'app.models.webauthn_credential.WebAuthnCredentials.client_method', - return_value=credentials, - ) - page = client_request.get('main.user_profile') - security_keys_row = page.select_one('#security-keys') - assert ' '.join(security_keys_row.text.split()) == expected_row_text - - def test_should_show_name_page( client_request ): @@ -448,291 +417,3 @@ def test_can_reenable_platform_admin(client_request, platform_admin_user): with client_request.session_transaction() as session: assert session['disable_platform_admin_view'] is False - - -def test_user_doesnt_see_security_keys_unless_they_can_use_webauthn( - client_request, - platform_admin_user -): - platform_admin_user['can_use_webauthn'] = False - client_request.login(platform_admin_user) - - client_request.get( - '.user_profile_security_keys', - _expected_status=403, - ) - - -def test_should_show_security_keys_page( - mocker, - client_request, - platform_admin_user, - webauthn_credential, -): - client_request.login(platform_admin_user) - - mocker.patch( - 'app.models.webauthn_credential.WebAuthnCredentials.client_method', - return_value=[webauthn_credential], - ) - - page = client_request.get('.user_profile_security_keys') - assert page.select_one('h1').text.strip() == 'Security keys' - - credential_row = page.select('tr')[-1] - assert 'Test credential' in credential_row.text - assert "Manage" in credential_row.find('a').text - assert credential_row.find('a')["href"] == url_for( - '.user_profile_manage_security_key', - key_id=webauthn_credential['id'] - ) - - register_button = page.select_one("[data-module='register-security-key']") - assert register_button.text.strip() == 'Register a key' - - -def test_get_key_from_list_of_keys( - mocker, - webauthn_credential, - webauthn_credential_2, - fake_uuid, -): - mocker.patch( - 'app.models.webauthn_credential.WebAuthnCredentials.client_method', - return_value=[webauthn_credential, webauthn_credential_2], - ) - assert WebAuthnCredentials(fake_uuid).by_id(webauthn_credential["id"]) == WebAuthnCredential(webauthn_credential) - - -def test_should_show_manage_security_key_page( - mocker, - client_request, - platform_admin_user, - webauthn_credential, -): - client_request.login(platform_admin_user) - - mocker.patch( - 'app.models.webauthn_credential.WebAuthnCredentials.client_method', - return_value=[webauthn_credential], - ) - - page = client_request.get('.user_profile_manage_security_key', key_id=webauthn_credential['id']) - assert page.select_one('h1').text.strip() == f'Manage ‘{webauthn_credential["name"]}’' - - assert page.select_one('.usa-back-link').text.strip() == 'Back' - assert page.select_one('.usa-back-link')['href'] == url_for('.user_profile_security_keys') - - assert page.select_one('#security_key_name')["value"] == webauthn_credential["name"] - - -def test_manage_security_key_page_404s_when_key_not_found( - mocker, - client_request, - platform_admin_user, - webauthn_credential, - webauthn_credential_2 -): - client_request.login(platform_admin_user) - - mocker.patch( - 'app.models.webauthn_credential.WebAuthnCredentials.client_method', - return_value=[webauthn_credential_2], - ) - client_request.get( - '.user_profile_manage_security_key', - key_id=webauthn_credential['id'], - _expected_status=404, - ) - - -@pytest.mark.parametrize('endpoint,method', [ - (".user_profile_manage_security_key", "get"), - (".user_profile_manage_security_key", "post"), - (".user_profile_confirm_delete_security_key", "get"), - (".user_profile_confirm_delete_security_key", "post"), - (".user_profile_delete_security_key", "post"), -]) -def test_cant_manage_security_keys_unless_can_use_webauthn( - client_request, - platform_admin_user, - webauthn_credential, - endpoint, - method -): - platform_admin_user['can_use_webauthn'] = False - client_request.login(platform_admin_user) - - if method == "get": - client_request.get( - endpoint, - key_id=webauthn_credential['id'], - _expected_status=403, - ) - - else: - client_request.post( - endpoint, - key_id=webauthn_credential['id'], - _expected_status=403, - ) - - -def test_should_redirect_after_change_of_security_key_name( - client_request, - platform_admin_user, - webauthn_credential, - mocker -): - client_request.login(platform_admin_user) - - mocker.patch( - 'app.models.webauthn_credential.WebAuthnCredentials.client_method', - return_value=[webauthn_credential], - ) - - mock_update = mocker.patch('app.user_api_client.update_webauthn_credential_name_for_user') - - client_request.post( - 'main.user_profile_manage_security_key', - key_id=webauthn_credential['id'], - _data={'security_key_name': "new name"}, - _expected_status=302, - _expected_redirect=url_for( - 'main.user_profile_security_keys', - ) - ) - - mock_update.assert_called_once_with( - credential_id=webauthn_credential['id'], - new_name_for_credential="new name", - user_id=platform_admin_user["id"] - ) - - -def test_user_profile_manage_security_key_should_not_call_api_if_key_name_stays_the_same( - client_request, - platform_admin_user, - webauthn_credential, - mocker -): - client_request.login(platform_admin_user) - - mocker.patch( - 'app.models.webauthn_credential.WebAuthnCredentials.client_method', - return_value=[webauthn_credential], - ) - - mock_update = mocker.patch('app.user_api_client.update_webauthn_credential_name_for_user') - - client_request.post( - 'main.user_profile_manage_security_key', - key_id=webauthn_credential['id'], - _data={'security_key_name': webauthn_credential['name']}, - _expected_status=302, - _expected_redirect=url_for( - 'main.user_profile_security_keys', - ) - ) - - assert not mock_update.called - - -def test_shows_delete_link_for_security_key( - mocker, - client_request, - platform_admin_user, - webauthn_credential, -): - client_request.login(platform_admin_user) - - mocker.patch( - 'app.models.webauthn_credential.WebAuthnCredentials.client_method', - return_value=[webauthn_credential], - ) - - page = client_request.get('.user_profile_manage_security_key', key_id=webauthn_credential['id']) - assert page.select_one('h1').text.strip() == f'Manage ‘{webauthn_credential["name"]}’' - - link = page.select_one('.page-footer a') - assert normalize_spaces(link.text) == 'Delete' - assert link['href'] == url_for('.user_profile_confirm_delete_security_key', key_id=webauthn_credential['id']) - - -def test_confirm_delete_security_key( - client_request, - platform_admin_user, - webauthn_credential, - mocker -): - client_request.login(platform_admin_user) - - mocker.patch( - 'app.models.webauthn_credential.WebAuthnCredentials.client_method', - return_value=[webauthn_credential], - ) - - page = client_request.get( - '.user_profile_confirm_delete_security_key', - key_id=webauthn_credential['id'], - _test_page_title=False, - ) - - assert normalize_spaces(page.select_one('.banner-dangerous').text) == ( - 'Are you sure you want to delete this security key? ' - 'Yes, delete' - ) - assert 'action' not in page.select_one('.banner-dangerous form') - assert page.select_one('.banner-dangerous form')['method'] == 'post' - - -def test_delete_security_key( - client_request, - platform_admin_user, - webauthn_credential, - mocker -): - client_request.login(platform_admin_user) - mock_delete = mocker.patch('app.user_api_client.delete_webauthn_credential_for_user') - - client_request.post( - '.user_profile_delete_security_key', - key_id=webauthn_credential['id'], - _expected_redirect=url_for( - '.user_profile_security_keys', - ) - ) - mock_delete.assert_called_once_with( - credential_id=webauthn_credential['id'], - user_id=platform_admin_user["id"] - ) - - -def test_delete_security_key_handles_last_credential_error( - client_request, - platform_admin_user, - webauthn_credential, - mocker, -): - client_request.login(platform_admin_user) - mocker.patch( - 'app.models.webauthn_credential.WebAuthnCredentials.client_method', - return_value=[webauthn_credential], - ) - - mocker.patch( - 'app.user_api_client.delete_webauthn_credential_for_user', - side_effect=HTTPError( - response={}, - message='Cannot delete last remaining webauthn credential for user' - ) - ) - - page = client_request.post( - '.user_profile_delete_security_key', - key_id=webauthn_credential['id'], - _follow_redirects=True - ) - assert 'Manage ‘Test credential’' in page.find('h1').text - expected_message = "You cannot delete your last security key." - assert expected_message in page.find('div', class_="banner-dangerous").text diff --git a/tests/app/main/views/test_webauthn_credentials.py b/tests/app/main/views/test_webauthn_credentials.py deleted file mode 100644 index 637bf5d1c..000000000 --- a/tests/app/main/views/test_webauthn_credentials.py +++ /dev/null @@ -1,449 +0,0 @@ -import base64 -from unittest.mock import ANY, Mock - -import pytest -from fido2 import cbor -from flask import url_for - -from app.models.webauthn_credential import RegistrationError, WebAuthnCredential - - -@pytest.fixture -def webauthn_authentication_post_data(fake_uuid, webauthn_credential, client_request): - - _set_up_webauthn_session(fake_uuid, client_request) - - credential_id = WebAuthnCredential(webauthn_credential).to_credential_data().credential_id - - return cbor.encode({ - 'credentialId': credential_id, - 'authenticatorData': base64.b64decode(b'dKbqkhPJnC90siSSsyDPQCYqlMGpUKA5fyklC2CEHvABAAACfQ=='), - 'clientDataJSON': b'{"challenge":"e-g-nXaRxMagEiqTJSyD82RsEc5if_6jyfJDy8bNKlw","origin":"https://webauthn.io","type":"webauthn.get"}', # noqa - 'signature': bytes.fromhex('304502204a76f05cd52a778cdd4df1565e0004e5cc1ead360419d0f5c3a0143bf37e7f15022100932b5c308a560cfe4f244214843075b904b3eda64e85d64662a81198c386cdde'), # noqa - }) - - -def _set_up_webauthn_session(user_id, client): - """ - Sets up session, challenge, etc as if a user with uuid `fake_uuid` has logged in and touched the webauthn token - as found in the `webauthn_credential` fixture. Sets up the session as if `begin_authentication` had been called - so that the challenge matches and the credential will validate (provided that the key belongs to the user referenced - in the session). - """ - with client.session_transaction() as session: - session['user_details'] = {'id': user_id} - session['webauthn_authentication_state'] = { - "challenge": "e-g-nXaRxMagEiqTJSyD82RsEc5if_6jyfJDy8bNKlw", - "user_verification": None - } - - -def test_begin_register_forbidden_unless_can_use_webauthn( - client_request, - platform_admin_user, - mocker, -): - platform_admin_user['can_use_webauthn'] = False - mocker.patch('app.user_api_client.get_user', return_value=platform_admin_user) - client_request.get('main.webauthn_begin_register', _expected_status=403) - - -def test_begin_register_returns_encoded_options( - mocker, - platform_admin_user, - client_request, - webauthn_dev_server, -): - mocker.patch('app.models.webauthn_credential.WebAuthnCredentials.client_method', return_value=[]) - - client_request.login(platform_admin_user) - response = client_request.get_response( - 'main.webauthn_begin_register', - ) - - webauthn_options = cbor.decode(response.data)['publicKey'] - assert webauthn_options['attestation'] == 'direct' - assert webauthn_options['timeout'] == 30_000 - - auth_selection = webauthn_options['authenticatorSelection'] - assert auth_selection['authenticatorAttachment'] == 'cross-platform' - assert auth_selection['userVerification'] == 'discouraged' - - user_options = webauthn_options['user'] - assert user_options['name'] == platform_admin_user['email_address'] - assert user_options['id'] == bytes(platform_admin_user['id'], 'utf-8') - - relying_party_options = webauthn_options['rp'] - assert relying_party_options['name'] == 'U.S. Notify' - assert relying_party_options['id'] == 'webauthn.io' - - -def test_begin_register_includes_existing_credentials( - client_request, - platform_admin_user, - webauthn_credential, - mocker, -): - mocker.patch( - 'app.models.webauthn_credential.WebAuthnCredentials.client_method', - return_value=[webauthn_credential, webauthn_credential] - ) - - client_request.login(platform_admin_user) - response = client_request.get_response( - 'main.webauthn_begin_register', - ) - - webauthn_options = cbor.decode(response.data)['publicKey'] - assert len(webauthn_options['excludeCredentials']) == 2 - - -def test_begin_register_stores_state_in_session( - client_request, - platform_admin_user, - mocker, -): - mocker.patch( - 'app.models.webauthn_credential.WebAuthnCredentials.client_method', - return_value=[]) - - client_request.login(platform_admin_user) - client_request.get_response( - 'main.webauthn_begin_register', - ) - - with client_request.session_transaction() as session: - assert session['webauthn_registration_state'] is not None - - -def test_complete_register_creates_credential( - platform_admin_user, - client_request, - mock_update_user_attribute, - mocker, -): - with client_request.session_transaction() as session: - session['webauthn_registration_state'] = 'state' - - user_api_mock = mocker.patch( - 'app.user_api_client.create_webauthn_credential_for_user' - ) - - credential_mock = mocker.patch( - 'app.models.webauthn_credential.WebAuthnCredential.from_registration', - return_value='cred' - ) - - client_request.login(platform_admin_user) - client_request.post_response( - 'main.webauthn_begin_register', - _data=cbor.encode('public_key_credential'), - _expected_status=200, - ) - - credential_mock.assert_called_once_with('state', 'public_key_credential') - user_api_mock.assert_called_once_with(platform_admin_user['id'], 'cred') - mock_update_user_attribute.assert_called_once_with( - platform_admin_user['id'], - auth_type='webauthn_auth', - ) - - -def test_complete_register_clears_session( - client_request, - platform_admin_user, - mocker, -): - with client_request.session_transaction() as session: - session['webauthn_registration_state'] = 'state' - - mocker.patch('app.user_api_client.create_webauthn_credential_for_user') - mocker.patch('app.models.webauthn_credential.WebAuthnCredential.from_registration') - - client_request.login(platform_admin_user) - client_request.post( - 'main.webauthn_complete_register', - _data=cbor.encode('public_key_credential'), - _expected_status=200, - ) - - with client_request.session_transaction() as session: - assert 'webauthn_registration_state' not in session - assert session['_flashes'] == [('default_with_tick', ( - 'Registration complete. Next time you sign in to Notify ' - 'you’ll be asked to use your security key.' - ))] - - -def test_complete_register_handles_library_errors( - client_request, - platform_admin_user, - mocker, -): - with client_request.session_transaction() as session: - session['webauthn_registration_state'] = 'state' - - mocker.patch( - 'app.models.webauthn_credential.WebAuthnCredential.from_registration', - side_effect=RegistrationError('error') - ) - - client_request.login(platform_admin_user) - client_request.post_response( - 'main.webauthn_complete_register', - _data=cbor.encode('public_key_credential'), - _expected_status=400, - ) - - -def test_complete_register_handles_missing_state( - client_request, - platform_admin_user, - mocker, -): - client_request.login(platform_admin_user) - response = client_request.post_response( - 'main.webauthn_complete_register', - _data=cbor.encode('public_key_credential'), - _expected_status=400, - ) - - assert cbor.decode(response.data) == 'No registration in progress' - - -def test_begin_authentication_forbidden_for_users_without_webauthn(client_request, mocker, platform_admin_user): - platform_admin_user['auth_type'] = 'sms_auth' - client_request.logout() - mocker.patch('app.user_api_client.get_user', return_value=platform_admin_user) - - with client_request.session_transaction() as session: - session['user_details'] = {'id': '1'} - - client_request.get( - 'main.webauthn_begin_authentication', - _expected_status=403, - ) - - -def test_begin_authentication_returns_encoded_options( - client_request, - mocker, - webauthn_credential, - platform_admin_user, -): - client_request.login(platform_admin_user) - - with client_request.session_transaction() as session: - session['user_details'] = {'id': platform_admin_user['id']} - - get_creds_mock = mocker.patch( - 'app.models.webauthn_credential.WebAuthnCredentials.client_method', - return_value=[webauthn_credential] - ) - response = client_request.get_response('main.webauthn_begin_authentication') - - decoded_data = cbor.decode(response.data) - allowed_credentials = decoded_data['publicKey']['allowCredentials'] - - assert len(allowed_credentials) == 1 - assert decoded_data['publicKey']['timeout'] == 30000 - get_creds_mock.assert_called_once_with(platform_admin_user['id']) - - -def test_begin_authentication_stores_state_in_session( - client_request, - mocker, - webauthn_credential, - platform_admin_user, -): - client_request.login(platform_admin_user) - - with client_request.session_transaction() as session: - session['user_details'] = {'id': platform_admin_user['id']} - - mocker.patch( - 'app.models.webauthn_credential.WebAuthnCredentials.client_method', - return_value=[webauthn_credential] - ) - client_request.get_response('main.webauthn_begin_authentication') - - with client_request.session_transaction() as session: - assert 'challenge' in session['webauthn_authentication_state'] - - -def test_complete_authentication_checks_credentials( - client_request, - mocker, - webauthn_credential, - webauthn_dev_server, - mock_create_event, - webauthn_authentication_post_data, - platform_admin_user -): - client_request.logout() - _set_up_webauthn_session(platform_admin_user['id'], client_request) - - mocker.patch('app.user_api_client.get_user', return_value=platform_admin_user) - mocker.patch('app.models.webauthn_credential.WebAuthnCredentials.client_method', return_value=[webauthn_credential]) - mocker.patch( - 'app.main.views.webauthn_credentials._complete_webauthn_login_attempt', - return_value=Mock(location='/foo') - ) - - response = client_request.post_response( - 'main.webauthn_complete_authentication', - _data=webauthn_authentication_post_data, - _expected_status=200, - ) - - assert cbor.decode(response.data) == {'redirect_url': '/foo'} - - -def test_complete_authentication_403s_if_key_isnt_in_users_credentials( - client_request, - mocker, - webauthn_credential, - webauthn_dev_server, - webauthn_authentication_post_data, - platform_admin_user -): - client_request.logout() - _set_up_webauthn_session(platform_admin_user['id'], client_request) - - mocker.patch('app.user_api_client.get_user', return_value=platform_admin_user) - # user has no keys in the database - mocker.patch('app.models.webauthn_credential.WebAuthnCredentials.client_method', return_value=[]) - mock_verify_webauthn_login = mocker.patch('app.main.views.webauthn_credentials._complete_webauthn_login_attempt') - mock_unsuccesful_login_api_call = mocker.patch('app.user_api_client.complete_webauthn_login_attempt') - - client_request.post_response( - 'main.webauthn_complete_authentication', - _data=webauthn_authentication_post_data, - _expected_status=403, - ) - - with client_request.session_transaction() as session: - assert session['user_details']['id'] == platform_admin_user['id'] - # user not logged in - assert 'user_id' not in session - # webauthn state reset so can't replay - assert 'webauthn_authentication_state' not in session - - assert mock_verify_webauthn_login.called is False - # make sure we incremented the failed login count - mock_unsuccesful_login_api_call.assert_called_once_with(platform_admin_user['id'], False) - - -def test_complete_authentication_clears_session( - client_request, - mocker, - webauthn_credential, - webauthn_dev_server, - webauthn_authentication_post_data, - mock_create_event, - platform_admin_user -): - client_request.logout() - mocker.patch('app.user_api_client.get_user', return_value=platform_admin_user) - mocker.patch('app.user_api_client.get_webauthn_credentials_for_user', return_value=[webauthn_credential]) - mocker.patch( - 'app.main.views.webauthn_credentials._complete_webauthn_login_attempt', - return_value=Mock(location='/foo') - ) - - client_request.post('main.webauthn_complete_authentication', _data=webauthn_authentication_post_data) - - with client_request.session_transaction() as session: - # it's important that we clear the session to ensure that we don't re-use old login artifacts in future - assert 'webauthn_authentication_state' not in session - - -@pytest.mark.parametrize('url_kwargs, expected_redirect', [ - ({}, '/accounts-or-dashboard'), - ({'next': '/bar'}, '/bar'), -]) -def test_verify_webauthn_login_signs_user_in( - client_request, - mocker, - mock_create_event, - platform_admin_user, - url_kwargs, - expected_redirect, -): - client_request.logout() - with client_request.session_transaction() as session: - session['user_details'] = { - 'id': platform_admin_user['id'], - 'email': platform_admin_user['email_address'] - } - client_request.login(platform_admin_user) - mocker.patch('app.main.views.webauthn_credentials._verify_webauthn_authentication') - mocker.patch('app.user_api_client.complete_webauthn_login_attempt', return_value=(True, None)) - mocker.patch('app.main.views.webauthn_credentials.email_needs_revalidating', return_value=False) - - resp = client_request.post_response( - 'main.webauthn_complete_authentication', - _expected_status=200, - **url_kwargs - ) - - assert cbor.decode(resp.data)['redirect_url'] == expected_redirect - # removes stuff from session - with client_request.session_transaction() as session: - assert 'user_details' not in session - - mock_create_event.assert_called_once_with('sucessful_login', ANY) - - -def test_verify_webauthn_login_signs_user_in_doesnt_sign_user_in_if_api_rejects( - client_request, - mocker, - platform_admin_user, -): - - with client_request.session_transaction() as session: - session['user_details'] = { - 'id': platform_admin_user['id'], - 'email': platform_admin_user['email_address'] - } - client_request.login(platform_admin_user) - mocker.patch('app.main.views.webauthn_credentials._verify_webauthn_authentication') - mocker.patch('app.user_api_client.complete_webauthn_login_attempt', return_value=(False, None)) - - client_request.post( - 'main.webauthn_complete_authentication', - _expected_status=403, - ) - - -def test_verify_webauthn_login_signs_user_in_sends_revalidation_email_if_needed( - client_request, - mocker, - mock_send_verify_code, - platform_admin_user, -): - user_details = { - 'id': platform_admin_user['id'], - 'email': platform_admin_user['email_address'] - } - - with client_request.session_transaction() as session: - session['user_details'] = user_details - - mocker.patch('app.user_api_client.get_user', return_value=platform_admin_user) - mocker.patch('app.main.views.webauthn_credentials._verify_webauthn_authentication') - mocker.patch('app.user_api_client.complete_webauthn_login_attempt', return_value=(True, None)) - mocker.patch('app.main.views.webauthn_credentials.email_needs_revalidating', return_value=True) - - resp = client_request.post_response( - 'main.webauthn_complete_authentication', - _expected_status=200, - ) - - assert cbor.decode(resp.data)['redirect_url'] == url_for('main.revalidate_email_sent') - - with client_request.session_transaction() as session: - # stuff stays in session so we can log them in later when they validate their email - assert session['user_details'] == user_details - - mock_send_verify_code.assert_called_once_with(platform_admin_user['id'], 'email', ANY, ANY) diff --git a/tests/app/models/test_webauthn_credential.py b/tests/app/models/test_webauthn_credential.py deleted file mode 100644 index 8096c72c3..000000000 --- a/tests/app/models/test_webauthn_credential.py +++ /dev/null @@ -1,71 +0,0 @@ -import base64 - -import pytest -from fido2 import cbor -from fido2.cose import ES256 - -from app.models.webauthn_credential import RegistrationError, WebAuthnCredential - -# noqa adapted from https://github.com/duo-labs/py_webauthn/blob/90e3d97e0182899a35a70fc510280b4082cce19b/tests/test_webauthn.py#L14-L24 -SESSION_STATE = {'challenge': 'bPzpX3hHQtsp9evyKYkaZtVc9UN07PUdJ22vZUdDp94', 'user_verification': 'discouraged'} -CLIENT_DATA_JSON = b'{"type": "webauthn.create", "clientExtensions": {}, "challenge": "bPzpX3hHQtsp9evyKYkaZtVc9UN07PUdJ22vZUdDp94", "origin": "https://webauthn.io"}' # noqa - -# had to use the cbor2 library to re-encode the attestationObject due to implementation differences -ATTESTATION_OBJECT = base64.b64decode(b'o2NmbXRoZmlkby11MmZnYXR0U3RtdKJjc2lnWEgwRgIhAI1qbvWibQos/t3zsTU05IXw1Ek3SDApATok09uc4UBwAiEAv0fB/lgb5Ot3zJ691Vje6iQLAtLhJDiA8zDxaGjcE3hjeDVjgVkCUzCCAk8wggE3oAMCAQICBDxoKU0wDQYJKoZIhvcNAQELBQAwLjEsMCoGA1UEAxMjWXViaWNvIFUyRiBSb290IENBIFNlcmlhbCA0NTcyMDA2MzEwIBcNMTQwODAxMDAwMDAwWhgPMjA1MDA5MDQwMDAwMDBaMDExLzAtBgNVBAMMJll1YmljbyBVMkYgRUUgU2VyaWFsIDIzOTI1NzM0ODExMTE3OTAxMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEvd9nk9t3lMNQMXHtLE1FStlzZnUaSLql2fm1ajoggXlrTt8rzXuSehSTEPvEaEdv/FeSqX22L6Aoa8ajIAIOY6M7MDkwIgYJKwYBBAGCxAoCBBUxLjMuNi4xLjQuMS40MTQ4Mi4xLjUwEwYLKwYBBAGC5RwCAQEEBAMCBSAwDQYJKoZIhvcNAQELBQADggEBAKrADVEJfuwVpIazebzEg0D4Z9OXLs5qZ/ukcONgxkRZ8K04QtP/CB5x6olTlxsj+SXArQDCRzEYUgbws6kZKfuRt2a1P+EzUiqDWLjRILSr+3/o7yR7ZP/GpiFKwdm+czb94POoGD+TS1IYdfXj94mAr5cKWx4EKjh210uovu/pLdLjc8xkQciUrXzZpPR9rT2k/q9HkZhHU+NaCJzky+PTyDbq0KKnzqVhWtfkSBCGw3ezZkTS+5lrvOKbIa24lfeTgu7FST5OwTPCFn8HcfWZMXMSD/KNU+iBqJdAwTLPPDRoLLvPTl29weCAIh+HUpmBQd0UltcPOrA/LFvAf61oYXV0aERhdGFYwnSm6pITyZwvdLIkkrMgz0AmKpTBqVCgOX8pJQtghB7wQQAAAAAAAAAAAAAAAAAAAAAAAAAAAECKU1ppjl9gmhHWyDkgHsUvZmhr6oF3/lD3llzLE2SaOSgOGIsIuAQqgp8JQSUu3r/oOaP8RS44dlQjrH+ALfYtpAECAyYhWCAxnqAfESXOYjKUc2WACuXZ3ch0JHxV0VFrrTyjyjIHXCJYIFnx8H87L4bApR4M+hPcV+fHehEOeW+KCyd0H+WGY8s6') # noqa - -# manually adapted by working out which character in the encoded CBOR corresponds to the public key algorithm ID -UNSUPPORTED_ATTESTATION_OBJECT = base64.b64decode(b'o2NmbXRoZmlkby11MmZnYXR0U3RtdKJjc2lnWEgwRgIhAI1qbvWibQos/t3zsTU05IXw1Ek3SDApATok09uc4UBwAiEAv0fB/lgb5Ot3zJ691Vje6iQLAtLhJDiA8zDxaGjcE3hjeDVjgVkCUzCCAk8wggE3oAMCAQICBDxoKU0wDQYJKoZIhvcNAQELBQAwLjEsMCoGA1UEAxMjWXViaWNvIFUyRiBSb290IENBIFNlcmlhbCA0NTcyMDA2MzEwIBcNMTQwODAxMDAwMDAwWhgPMjA1MDA5MDQwMDAwMDBaMDExLzAtBgNVBAMMJll1YmljbyBVMkYgRUUgU2VyaWFsIDIzOTI1NzM0ODExMTE3OTAxMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEvd9nk9t3lMNQMXHtLE1FStlzZnUaSLql2fm1ajoggXlrTt8rzXuSehSTEPvEaEdv/FeSqX22L6Aoa8ajIAIOY6M7MDkwIgYJKwYBBAGCxAoCBBUxLjMuNi4xLjQuMS40MTQ4Mi4xLjUwEwYLKwYBBAGC5RwCAQEEBAMCBSAwDQYJKoZIhvcNAQELBQADggEBAKrADVEJfuwVpIazebzEg0D4Z9OXLs5qZ/ukcONgxkRZ8K04QtP/CB5x6olTlxsj+SXArQDCRzEYUgbws6kZKfuRt2a1P+EzUiqDWLjRILSr+3/o7yR7ZP/GpiFKwdm+czb94POoGD+TS1IYdfXj94mAr5cKWx4EKjh210uovu/pLdLjc8xkQciUrXzZpPR9rT2k/q9HkZhHU+NaCJzky+PTyDbq0KKnzqVhWtfkSBCGw3ezZkTS+5lrvOKbIa24lfeTgu7FST5OwTPCFn8HcfWZMXMSD/KNU+iBqJdAwTLPPDRoLLvPTl29weCAIh+HUpmBQd0UltcPOrA/LFvAf61oYXV0aERhdGFYwnSm6pITyZwvdLIkkrMgz0AmKpTBqVCgOX8pJQtghB7wQQAAAAAAAAAAAAAAAAAAAAAAAAAAAECKU1ppjl9gmhHWyDkgHsUvZmhr6oF3/lD3llzLE2SaOSgOGIsIuAQqgp8JQSUu3r/oOaP8RS44dlQjrH+ALfYtpAECAyUhWCAxnqAfESXOYjKUc2WACuXZ3ch0JHxV0VFrrTyjyjIHXCJYIFnx8H87L4bApR4M+hPcV+fHehEOeW+KCyd0H+WGY8s6') # noqa - - -def test_from_registration_verifies_response(webauthn_dev_server): - registration_response = { - 'clientDataJSON': CLIENT_DATA_JSON, - 'attestationObject': ATTESTATION_OBJECT, - } - - credential = WebAuthnCredential.from_registration(SESSION_STATE, registration_response) - assert credential.name == 'Unnamed key' - assert credential.registration_response == base64.b64encode(cbor.encode(registration_response)).decode('utf-8') - - credential_data = credential.to_credential_data() - assert type(credential_data.credential_id) is bytes - assert type(credential_data.aaguid) is bytes - assert credential_data.public_key[3] == ES256.ALGORITHM - - -def test_from_registration_encodes_as_unicode(webauthn_dev_server): - registration_response = { - 'clientDataJSON': CLIENT_DATA_JSON, - 'attestationObject': ATTESTATION_OBJECT, - } - - credential = WebAuthnCredential.from_registration(SESSION_STATE, registration_response) - - serialized_credential = credential.serialize() - - assert type(serialized_credential['credential_data']) == str # noqa E721 - assert type(serialized_credential['registration_response']) == str # noqa E721 - - -def test_from_registration_handles_library_errors(): - registration_response = { - 'clientDataJSON': CLIENT_DATA_JSON, - 'attestationObject': ATTESTATION_OBJECT, - } - - with pytest.raises(RegistrationError) as exc_info: - WebAuthnCredential.from_registration(SESSION_STATE, registration_response) - - assert 'Invalid origin' in str(exc_info.value) - - -def test_from_registration_handles_unsupported_keys(webauthn_dev_server): - registration_response = { - 'clientDataJSON': CLIENT_DATA_JSON, - 'attestationObject': UNSUPPORTED_ATTESTATION_OBJECT, - } - - with pytest.raises(RegistrationError) as exc_info: - WebAuthnCredential.from_registration(SESSION_STATE, registration_response) - - assert 'Encryption algorithm not supported' in str(exc_info.value) diff --git a/tests/app/notify_client/test_user_client.py b/tests/app/notify_client/test_user_client.py index e7aff5610..519dd862d 100644 --- a/tests/app/notify_client/test_user_client.py +++ b/tests/app/notify_client/test_user_client.py @@ -1,11 +1,9 @@ import uuid -from unittest.mock import Mock, call +from unittest.mock import call import pytest -from notifications_python_client.errors import HTTPError from app import invite_api_client, service_api_client, user_api_client -from app.models.webauthn_credential import WebAuthnCredential from tests import sample_uuid from tests.conftest import SERVICE_ONE_ID @@ -190,7 +188,6 @@ def test_returns_value_from_cache( (user_api_client, 'update_password', [user_id, 'hunter2'], {}), (user_api_client, 'verify_password', [user_id, 'hunter2'], {}), (user_api_client, 'check_verify_code', [user_id, '', ''], {}), - (user_api_client, 'complete_webauthn_login_attempt', [user_id], {'is_successful': True}), (user_api_client, 'add_user_to_service', [SERVICE_ONE_ID, user_id, [], []], {}), (user_api_client, 'add_user_to_organization', [sample_uuid(), user_id], {}), (user_api_client, 'set_user_permissions', [user_id, SERVICE_ONE_ID, []], {}), @@ -241,71 +238,6 @@ def test_add_user_to_service_calls_correct_endpoint_and_deletes_keys_from_cache( ] -def test_get_webauthn_credentials_for_user(mocker, webauthn_credential, fake_uuid): - - mock_get = mocker.patch( - 'app.notify_client.user_api_client.UserApiClient.get', - return_value={'data': [webauthn_credential]} - ) - - credentials = user_api_client.get_webauthn_credentials_for_user(fake_uuid) - - mock_get.assert_called_once_with(f'/user/{fake_uuid}/webauthn') - assert len(credentials) == 1 - assert credentials[0]['name'] == 'Test credential' - - -def test_create_webauthn_credential_for_user(mocker, webauthn_credential, fake_uuid): - credential = WebAuthnCredential(webauthn_credential) - - mock_post = mocker.patch('app.notify_client.user_api_client.UserApiClient.post') - expected_url = f'/user/{fake_uuid}/webauthn' - - user_api_client.create_webauthn_credential_for_user(fake_uuid, credential) - mock_post.assert_called_once_with(expected_url, data=credential.serialize()) - - -def test_complete_webauthn_login_attempt_returns_true_and_no_message_normally(fake_uuid, mocker): - mock_post = mocker.patch('app.notify_client.user_api_client.UserApiClient.post') - - resp = user_api_client.complete_webauthn_login_attempt(fake_uuid, is_successful=True) - - expected_data = {'successful': True} - mock_post.assert_called_once_with(f'/user/{fake_uuid}/complete/webauthn-login', data=expected_data) - assert resp == (True, '') - - -def test_complete_webauthn_login_attempt_returns_false_and_message_on_403(fake_uuid, mocker): - mock_post = mocker.patch( - 'app.notify_client.user_api_client.UserApiClient.post', - side_effect=HTTPError( - response=Mock( - status_code=403, - json=Mock( - return_value={'message': 'forbidden'} - ) - ) - ) - ) - - resp = user_api_client.complete_webauthn_login_attempt(fake_uuid, is_successful=True) - - expected_data = {'successful': True} - mock_post.assert_called_once_with(f'/user/{fake_uuid}/complete/webauthn-login', data=expected_data) - - assert resp == (False, 'forbidden') - - -def test_complete_webauthn_login_attempt_raises_on_api_error(fake_uuid, mocker): - mocker.patch( - 'app.notify_client.user_api_client.UserApiClient.post', - side_effect=HTTPError(response=Mock(status_code=503, message='error')) - ) - - with pytest.raises(HTTPError): - user_api_client.complete_webauthn_login_attempt(fake_uuid, is_successful=True) - - def test_reset_password( mocker, fake_uuid, diff --git a/tests/app/test_navigation.py b/tests/app/test_navigation.py index 7afce65f3..b82ff64db 100644 --- a/tests/app/test_navigation.py +++ b/tests/app/test_navigation.py @@ -234,27 +234,22 @@ EXCLUDED_ENDPOINTS = tuple(map(Navigation.get_endpoint_with_blueprint, { 'two_factor_email', 'two_factor_email_interstitial', 'two_factor_email_sent', - 'two_factor_webauthn', 'update_email_branding', 'uploads', 'usage', 'user_information', 'user_profile', 'user_profile_confirm_delete_mobile_number', - 'user_profile_confirm_delete_security_key', - 'user_profile_delete_security_key', 'user_profile_disable_platform_admin_view', 'user_profile_email', 'user_profile_email_authenticate', 'user_profile_email_confirm', - 'user_profile_manage_security_key', 'user_profile_mobile_number', 'user_profile_mobile_number_authenticate', 'user_profile_mobile_number_confirm', 'user_profile_mobile_number_delete', 'user_profile_name', 'user_profile_password', - 'user_profile_security_keys', 'using_notify', 'verify', 'verify_email', @@ -271,10 +266,6 @@ EXCLUDED_ENDPOINTS = tuple(map(Navigation.get_endpoint_with_blueprint, { 'view_template', 'view_template_version', 'view_template_versions', - 'webauthn_begin_register', - 'webauthn_complete_register', - 'webauthn_begin_authentication', - 'webauthn_complete_authentication', 'who_its_for', })) diff --git a/tests/app/test_webauthn_server.py b/tests/app/test_webauthn_server.py deleted file mode 100644 index c2bc70381..000000000 --- a/tests/app/test_webauthn_server.py +++ /dev/null @@ -1,38 +0,0 @@ -import pytest - -from app import webauthn_server - - -@pytest.fixture -def app_with_mock_config(mocker): - app = mocker.Mock() - - app.config = { - 'ADMIN_BASE_URL': 'https://www.notify.works', - 'NOTIFY_ENVIRONMENT': 'development' - } - - return app - - -@pytest.mark.parametrize(('environment, allowed'), [ - ('development', True), - ('production', False) -]) -def test_server_origin_verification( - app_with_mock_config, - environment, - allowed -): - - app_with_mock_config.config['NOTIFY_ENVIRONMENT'] = environment - webauthn_server.init_app(app_with_mock_config) - assert app_with_mock_config.webauthn_server._verify('fake-domain') == allowed - - -def test_server_relying_party_id( - app_with_mock_config, - mocker, -): - webauthn_server.init_app(app_with_mock_config) - assert app_with_mock_config.webauthn_server.rp.id == 'www.notify.works' diff --git a/tests/conftest.py b/tests/conftest.py index 69ab8864e..946cf4984 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -12,7 +12,7 @@ from flask import Flask, url_for from notifications_python_client.errors import HTTPError from notifications_utils.url_safe_token import generate_token -from app import create_app, webauthn_server +from app import create_app from . import ( TestClient, @@ -2483,20 +2483,6 @@ def set_config_values(app, dict): app.config[key] = old_values[key] -@pytest.fixture -def webauthn_dev_server(notify_admin, mocker): - overrides = { - 'NOTIFY_ENVIRONMENT': 'development', - 'ADMIN_BASE_URL': 'https://webauthn.io', - } - - with set_config_values(notify_admin, overrides): - webauthn_server.init_app(notify_admin) - yield - - webauthn_server.init_app(notify_admin) - - @pytest.fixture(scope='function') def valid_token(notify_admin, fake_uuid): return generate_token( @@ -3078,15 +3064,13 @@ def create_active_user_manage_template_permissions(with_unique_id=False): ) -def create_platform_admin_user(with_unique_id=False, auth_type='webauthn_auth', permissions=None): +def create_platform_admin_user(with_unique_id=False, permissions=None): return create_user( id=str(uuid4()) if with_unique_id else sample_uuid(), name='Platform admin user', email_address='platform@admin.gsa.gov', permissions=permissions or {}, - platform_admin=True, - auth_type=auth_type, - can_use_webauthn=True, + platform_admin=True ) @@ -3132,7 +3116,6 @@ def create_user(**overrides): 'current_session_id': None, 'logged_in_at': None, 'email_access_validated_at': None, - 'can_use_webauthn': False, } user_data.update(overrides) return user_data @@ -3364,28 +3347,6 @@ def mock_get_invited_org_user_by_id(mocker, sample_org_invite): ) -@pytest.fixture -def webauthn_credential(): - return { - 'id': str(uuid4()), - 'name': 'Test credential', - 'credential_data': 'WJ8AAAAAAAAAAAAAAAAAAAAAAECKU1ppjl9gmhHWyDkgHsUvZmhr6oF3/lD3llzLE2SaOSgOGIsIuAQqgp8JQSUu3r/oOaP8RS44dlQjrH+ALfYtpQECAyYgASFYIDGeoB8RJc5iMpRzZYAK5dndyHQkfFXRUWutPKPKMgdcIlggWfHwfzsvhsClHgz6E9xX58d6EQ55b4oLJ3Qf5YZjyzo=', # noqa - 'registration_response': 'anything', - 'created_at': '2017-10-18T16:57:14.154185Z', - } - - -@pytest.fixture -def webauthn_credential_2(): - return { - 'id': str(uuid4()), - 'name': 'Another test credential', - 'credential_data': 'WJ0AAAAAAAAAAAAAAAAAAAAAAECKU1jppl9mhgHWyDkgHsUvZmhr6oF3/lD3llzLE2SaOSgOGIsIuAQqgp8JQSUu3r/oOaP8RS44dlQjrH+ALfYtpAECAyYhWCAxnqAfESXOYjKUc2WACuXZ3ch0JHxV0VFrrTyjyjIHXCJYIFnx8L4H87bApR4M+hPcV+fHehEOeW+KCyd0H+WGY8s6', # noqa - 'registration_response': 'stuff', - 'created_at': '2021-05-14T16:57:14.154185Z', - } - - @pytest.fixture(scope='session') def end_to_end_auth_context(browser): # Create a context with HTTP Authentication credentials for Playwright E2E diff --git a/tests/javascripts/authenticateSecurityKey.test.js b/tests/javascripts/authenticateSecurityKey.test.js deleted file mode 100644 index 9dabd1d63..000000000 --- a/tests/javascripts/authenticateSecurityKey.test.js +++ /dev/null @@ -1,279 +0,0 @@ -beforeAll(() => { - window.CBOR = require('../../node_modules/cbor-js/cbor.js') - require('../../app/assets/javascripts/authenticateSecurityKey.js') - - // populate missing values to allow consistent jest.spyOn() - window.fetch = () => { } - window.navigator.credentials = { get: () => { } } - window.GOVUK.ErrorBanner = { - showBanner: () => {}, - hideBanner: () => {} - }; -}) - -afterAll(() => { - require('./support/teardown.js') - - // restore window attributes to their original undefined state - delete window.fetch - delete window.navigator.credentials -}) - -describe('Authenticate with security key', () => { - let button - - beforeEach(() => { - // disable console.error() so we don't see it in test output - // you might need to comment this out to debug some failures - jest.spyOn(console, 'error').mockImplementation(() => {}) - - document.body.innerHTML = ` - ` - - button = document.querySelector('[data-module="authenticate-security-key"]') - window.GOVUK.modules.start() - }) - - afterEach(() => { - jest.restoreAllMocks() - }) - - test('authenticates a credential and redirects based on the admin app response', (done) => { - - jest.spyOn(window, 'fetch') - .mockImplementationOnce((_url) => { - // initial fetch of options from the server - // fetch defaults to GET - // options from the server are CBOR-encoded - const webauthnOptions = window.CBOR.encode('someArbitraryOptions') - - return Promise.resolve({ - ok: true, arrayBuffer: () => webauthnOptions - }) - }) - - jest.spyOn(window.navigator.credentials, 'get').mockImplementation((options) => { - expect(options).toEqual('someArbitraryOptions') - - // fake PublicKeyCredential response from WebAuthn API - // all of the array properties represent Array(Buffer) objects - const credentialsGetResponse = { - response: { - authenticatorData: [2, 2, 2], - signature: [3, 3, 3], - clientDataJSON: [4, 4, 4] - }, - rawId: [1, 1, 1], - type: "public-key", - } - return Promise.resolve(credentialsGetResponse) - }) - - jest.spyOn(window, 'fetch') - .mockImplementationOnce((_url, options = {}) => { - // subsequent POST of credential data to server - const decodedData = window.CBOR.decode(options.body) - expect(decodedData.credentialId).toEqual(new Uint8Array([1, 1, 1])) - expect(decodedData.authenticatorData).toEqual(new Uint8Array([2, 2, 2])) - expect(decodedData.signature).toEqual(new Uint8Array([3, 3, 3])) - expect(decodedData.clientDataJSON).toEqual(new Uint8Array([4, 4, 4])) - expect(options.headers['X-CSRFToken']).toBe('abc123') - const loginResponse = window.CBOR.encode({ redirect_url: '/foo' }) - - return Promise.resolve({ - ok: true, arrayBuffer: () => Promise.resolve(loginResponse) - }) - }) - - jest.spyOn(window.location, 'assign').mockImplementation((href) => { - expect(href).toEqual("/foo") - done() - }) - - // this will make the test fail if the error banner is displayed - jest.spyOn(window.GOVUK.ErrorBanner, 'showBanner').mockImplementation(() => { - done('didnt expect the banner to be shown') - }) - - button.click() - }) - - test('authenticates and passes a redirect url through to the authenticate admin endpoint', (done) => { - window.location.href = `${window.location.href}?next=%2Ffoo%3Fbar%3Dbaz` - jest.spyOn(window, 'fetch') - .mockImplementationOnce((_url) => { - // initial fetch of options from the server - // fetch defaults to GET - // options from the server are CBOR-encoded - let webauthnOptions = window.CBOR.encode('someArbitraryOptions') - - return Promise.resolve({ - ok: true, arrayBuffer: () => webauthnOptions - }) - }) - - jest.spyOn(window.navigator.credentials, 'get').mockImplementation((options) => { - let credentialsGetResponse = { - response: { - authenticatorData: [], - signature: [], - clientDataJSON: [] - }, - rawId: [], - type: "public-key", - } - return Promise.resolve(credentialsGetResponse) - }) - - jest.spyOn(window, 'fetch') - .mockImplementationOnce((url, options = {}) => { - // subsequent POST of credential data to server - expect(url.toString()).toEqual( - 'https://www.notifications.service.gov.uk/webauthn/authenticate?next=%2Ffoo%3Fbar%3Dbaz' - ) - return Promise.resolve({ - ok: true, arrayBuffer: () => Promise.resolve(window.CBOR.encode({ redirect_url: '/foo' })) - }) - }) - - jest.spyOn(window.location, 'assign').mockImplementation((href) => { - // ensure that the fetch mock implementation above was called - expect.assertions(1) - done() - }) - - button.click() - }) - - test.each([ - ['network'], - ['server'], - ])('errors if fetching WebAuthn fails (%s error)', (errorType, done) => { - jest.spyOn(window, 'fetch').mockImplementation((_url) => { - if (errorType == 'network') { - return Promise.reject('error') - } else { - return Promise.resolve({ ok: false, statusText: 'error' }) - } - }) - - jest.spyOn(window.GOVUK.ErrorBanner, 'showBanner').mockImplementation(() => { - done() - }) - - button.click() - }) - - test('errors if comms with the authenticator fails', (done) => { - jest.spyOn(window, 'fetch') - .mockImplementationOnce((_url) => { - const webauthnOptions = window.CBOR.encode('someArbitraryOptions') - - return Promise.resolve({ - ok: true, arrayBuffer: () => webauthnOptions - }) - }) - - jest.spyOn(window.navigator.credentials, 'get').mockImplementation(() => { - return Promise.reject(new DOMException('error')) - }) - - jest.spyOn(window.GOVUK.ErrorBanner, 'showBanner').mockImplementation(() => { - done() - }) - - button.click() - }) - - test.each([ - ['network'], - ['server'], - ])('errors if POSTing WebAuthn credentials fails (%s)', (errorType, done) => { - jest.spyOn(window, 'fetch') - .mockImplementationOnce((_url) => { - const webauthnOptions = window.CBOR.encode('someArbitraryOptions') - - return Promise.resolve({ - ok: true, arrayBuffer: () => webauthnOptions - }) - }) - - jest.spyOn(window.navigator.credentials, 'get').mockImplementation((options) => { - expect(options).toEqual('someArbitraryOptions') - const credentialsGetResponse = { - response: { - authenticatorData: [2, 2, 2], - signature: [3, 3, 3], - clientDataJSON: [4, 4, 4] - }, - rawId: [1, 1, 1], - type: "public-key", - } - return Promise.resolve(credentialsGetResponse) - }) - - jest.spyOn(window, 'fetch').mockImplementationOnce((_url) => { - // subsequent POST of credential data to server - switch (errorType) { - case 'network': - return Promise.reject('error') - case 'server': - return Promise.resolve({ ok: false, statusText: 'FORBIDDEN' }) - } - }) - - - jest.spyOn(window.GOVUK.ErrorBanner, 'showBanner').mockImplementation(() => { - done() - }) - - button.click() - }) - - - test('reloads page if POSTing WebAuthn credentials returns 403', (done) => { - jest.spyOn(window, 'fetch').mockImplementationOnce((_url) => { - const webauthnOptions = window.CBOR.encode('someArbitraryOptions') - - return Promise.resolve({ - ok: true, arrayBuffer: () => webauthnOptions - }) - }) - - jest.spyOn(window.navigator.credentials, 'get').mockImplementation((options) => { - expect(options).toEqual('someArbitraryOptions') - const credentialsGetResponse = { - response: { - authenticatorData: [2, 2, 2], - signature: [3, 3, 3], - clientDataJSON: [4, 4, 4] - }, - rawId: [1, 1, 1], - type: "public-key", - } - return Promise.resolve(credentialsGetResponse) - }) - - jest.spyOn(window, 'fetch').mockImplementationOnce((_url) => { - return Promise.resolve( - { - ok: false, - status: 403, - }) - }) - - // assert that reload is called and the page is refreshed - jest.spyOn(window.location, 'reload').mockImplementation(() => { - done() - }) - - // this will make the test fail if the error banner is displayed - jest.spyOn(window.GOVUK.ErrorBanner, 'showBanner').mockImplementation((msg) => { - done(msg) - }) - - button.click() - }) - - -}) diff --git a/tests/javascripts/registerSecurityKey.test.js b/tests/javascripts/registerSecurityKey.test.js deleted file mode 100644 index a69d7d3c1..000000000 --- a/tests/javascripts/registerSecurityKey.test.js +++ /dev/null @@ -1,165 +0,0 @@ -beforeAll(() => { - window.CBOR = require('../../node_modules/cbor-js/cbor.js') - require('../../app/assets/javascripts/registerSecurityKey.js') - - // populate missing values to allow consistent jest.spyOn() - window.fetch = () => {} - window.navigator.credentials = { create: () => { } } - window.GOVUK.ErrorBanner = { - showBanner: () => { }, - hideBanner: () => { } - }; -}) - -afterAll(() => { - require('./support/teardown.js') - - // restore window attributes to their original undefined state - delete window.fetch - delete window.navigator.credentials -}) - -describe('Register security key', () => { - let button - - beforeEach(() => { - // disable console.error() so we don't see it in test output - // you might need to comment this out to debug some failures - jest.spyOn(console, 'error').mockImplementation(() => {}) - - document.body.innerHTML = ` - - Register a key - ` - - button = document.querySelector('[data-module="register-security-key"]') - window.GOVUK.modules.start() - }) - - afterEach(() => { - jest.restoreAllMocks() - }) - - test('creates a new credential and reloads', (done) => { - - jest.spyOn(window, 'fetch').mockImplementationOnce((_url) => { - // initial fetch of options from the server - // options from the server are CBOR-encoded - const webauthnOptions = window.CBOR.encode('options') - - return Promise.resolve({ - ok: true, arrayBuffer: () => webauthnOptions - }) - }) - - jest.spyOn(window.navigator.credentials, 'create').mockImplementation((options) => { - expect(options).toEqual('options') - - // fake PublicKeyCredential response from WebAuthn API - // both of the nested properties are Array(Buffer) objects - return Promise.resolve({ - response: { - attestationObject: [1, 2, 3], - clientDataJSON: [4, 5, 6], - } - }) - }) - - jest.spyOn(window, 'fetch').mockImplementationOnce((_url, options) => { - // subsequent POST of credential data to server - const decodedData = window.CBOR.decode(options.body) - expect(decodedData.clientDataJSON).toEqual(new Uint8Array([4,5,6])) - expect(decodedData.attestationObject).toEqual(new Uint8Array([1,2,3])) - expect(options.headers['X-CSRFToken']).toBe() - return Promise.resolve({ ok: true }) - }) - - jest.spyOn(window.location, 'reload').mockImplementation(() => { - // signal that the async promise chain was called - done() - }) - - // this will make the test fail if the error banner is displayed - jest.spyOn(window.GOVUK.ErrorBanner, 'showBanner').mockImplementation(() => { - done('didnt expect the banner to be shown') - }) - - button.click() - }) - - test.each([ - ['network'], - ['server'], - ])('errors if fetching WebAuthn options fails (%s error)', (errorType, done) => { - jest.spyOn(window, 'fetch').mockImplementation((_url) => { - if (errorType == 'network') { - return Promise.reject('error') - } else { - return Promise.resolve({ ok: false, statusText: 'error' }) - } - }) - - jest.spyOn(window.GOVUK.ErrorBanner, 'showBanner').mockImplementation(() => { - done() - }) - - button.click() - }) - - test.each([ - ['network'], - ['server'], - ])('errors if sending WebAuthn credentials fails (%s)', (errorType, done) => { - - jest.spyOn(window, 'fetch').mockImplementationOnce((_url) => { - // initial fetch of options from the server - const webauthnOptions = window.CBOR.encode('options') - - return Promise.resolve({ - ok: true, arrayBuffer: () => webauthnOptions - }) - }) - - jest.spyOn(window.navigator.credentials, 'create').mockImplementation(() => { - // fake PublicKeyCredential response from WebAuthn API - return Promise.resolve({ response: {} }) - }) - - jest.spyOn(window, 'fetch').mockImplementationOnce((_url) => { - // subsequent POST of credential data to server - switch (errorType) { - case 'network': - return Promise.reject('error') - case 'server': - return Promise.resolve({ ok: false, statusText: 'FORBIDDEN' }) - } - }) - - jest.spyOn(window.GOVUK.ErrorBanner, 'showBanner').mockImplementation(() => { - done() - }) - - button.click() - }) - - test('errors if comms with the authenticator fails', (done) => { - jest.spyOn(window.navigator.credentials, 'create').mockImplementation(() => { - return Promise.reject(new DOMException('error')) - }) - - jest.spyOn(window, 'fetch').mockImplementation((_url) => { - // initial fetch of options from the server - const webauthnOptions = window.CBOR.encode('options') - - return Promise.resolve({ - ok: true, arrayBuffer: () => webauthnOptions - }) - }) - - jest.spyOn(window.GOVUK.ErrorBanner, 'showBanner').mockImplementation(() => { - done() - }) - - button.click() - }) -})