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() - }) -})