diff --git a/Makefile b/Makefile index 3dc4030fa..3bd0b715e 100644 --- a/Makefile +++ b/Makefile @@ -52,6 +52,16 @@ py-lint: ## Run python linting scanners pipenv run flake8 . pipenv run isort --check-only ./app ./tests +.PHONY: avg-complexity +avg-complexity: + echo "*** Shows average complexity in radon of all code ***" + pipenv run radon cc ./app -a -na + +.PHONY: too-complex +too-complex: + echo "*** Shows code that got a rating of C, D or F in radon ***" + pipenv run radon cc ./app -a -nc + .PHONY: py-test py-test: export NEW_RELIC_ENVIRONMENT=test py-test: ## Run python unit tests diff --git a/Pipfile b/Pipfile index 19d52f7d8..47946e22d 100644 --- a/Pipfile +++ b/Pipfile @@ -6,8 +6,7 @@ name = "pypi" [packages] ago = "~=0.0.95" blinker = "~=1.4" -exceptiongroup = "==1.1.2" -fido2 = "~=0.9" +exceptiongroup = "==1.1.3" flask = "~=2.3" flask-basicauth = "~=0.2" flask-login = "~=0.6" @@ -16,7 +15,7 @@ gds-metrics = {version = "==0.2.4", ref = "6f1840a57b6fb1ee40b7e84f2f18ec229de8a govuk-bank-holidays = "==0.13" govuk-frontend-jinja = {version = "==0.5.8-alpha", git = "https://github.com/alphagov/govuk-frontend-jinja.git"} gunicorn = {version = "==20.1.0", extras = ["eventlet"], ref = "1299ea9e967a61ae2edebe191082fd169b864c64", git = "https://github.com/benoitc/gunicorn.git"} -humanize = "~=4.1" +humanize = "~=4.8" itsdangerous = "~=2.1" jinja2 = "~=3.1" notifications-python-client = "==8.0.1" @@ -39,12 +38,14 @@ notifications-utils = {editable = true, ref = "main", git = "https://github.com/ coverage = "*" pytest-playwright = "*" vulture = "==2.7" +radon = "==6.0.1" [dev-packages] isort = "==5.12.0" pytest = "==7.4.0" pytest-env = "==0.8.2" pytest-mock = "==3.11.1" +pytest-playwright = "==0.4.2" pytest-xdist = "==3.3.1" beautifulsoup4 = "==4.12.2" freezegun = "==1.2.2" diff --git a/Pipfile.lock b/Pipfile.lock index 8811c29ff..f5bb946b1 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "0d69ab20cc57f9e6a67aaf4a1f5e7fd1c9562ba75b3386cbb14368fe986fdf62" + "sha256": "1ba88d2c8e70243162c4fd03b8ddf1ec9c5542d0d2ccee7b4e8f4fd543912179" }, "pipfile-spec": 6, "requires": { @@ -26,18 +26,18 @@ }, "async-timeout": { "hashes": [ - "sha256:2163e1640ddb52b7a8c80d0a67a08587e5d245cc9c553a74a847056bc2976b15", - "sha256:8ca1e4fcf50d07413d66d1a5e416e42cfdf5851c981d679a09851a6853383b3c" + "sha256:4640d96be84d82d02ed59ea2b7105a0f7b33abe8703703cd0ab0bf87c427522f", + "sha256:7405140ff1230c310e51dc27b3145b9092d659ce68ff733fb0cefe3ee42be028" ], "markers": "python_full_version <= '3.11.2'", - "version": "==4.0.2" + "version": "==4.0.3" }, "bleach": { "hashes": [ "sha256:1a1a85c1595e07d8db14c5f09f09e6433502c51c595970edc090551f0db99414", "sha256:33c16e3353dbd13028ab4799a0f89a83f113405c766e9c122df8a06f5b85b3f4" ], - "markers": "python_version >= '3.7'", + "markers": "python_full_version >= '3.7.0'", "version": "==6.0.0" }, "blinker": { @@ -50,26 +50,26 @@ }, "boto3": { "hashes": [ - "sha256:87ecac82d2a68430c0292b7946512c8b1f01ea6971b43dc5832582fcb176c0dd", - "sha256:f2ec3e6f173fe8d141d512ea7d90138db5a58af130773e26ce8e72bdbfd2cddc" + "sha256:2761f3249fe25c3ec1a8cd6b95fca2317747503e6f1d127daf6a3d2cdeb25680", + "sha256:dc6d72470f6d8926b8cdc10ee7708d7ccdd36d6313c7aa298bc1cf6bedb8921e" ], - "markers": "python_version >= '3.7'", - "version": "==1.28.18" + "markers": "python_full_version >= '3.7.0'", + "version": "==1.28.31" }, "botocore": { "hashes": [ - "sha256:909db57f5d6ca765fc9dc9dcae962a87566d0123da1d2bd5be32432493d5785e", - "sha256:c4c01fae2ba32c242ce62175cad719aa49415618560d6e215ed76dab91991dc5" + "sha256:1eef14ae98e8662e43f7cf6d993c732793def02644e2d489c5171d3b9269e900", + "sha256:950a49c5286fe1f6d72cfbe2910b9ddbdfbb907975ddc41cf38ac9709b4d1291" ], - "markers": "python_version >= '3.7'", - "version": "==1.31.18" + "markers": "python_full_version >= '3.7.0'", + "version": "==1.31.31" }, "cachetools": { "hashes": [ "sha256:95ef631eeaea14ba2e36f06437f36463aac3a096799e876ee55e5cdccb102590", "sha256:dce83f2d9b4e1f732a8cd44af8e8fab2dbe46201467fc98b3ef8f269092bf62b" ], - "markers": "python_version >= '3.7'", + "markers": "python_full_version >= '3.7.0'", "version": "==5.3.1" }, "certifi": { @@ -154,7 +154,7 @@ "sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7", "sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970" ], - "markers": "python_version >= '3.7'", + "markers": "python_full_version >= '3.7.0'", "version": "==5.2.0" }, "charset-normalizer": { @@ -235,82 +235,82 @@ "sha256:f779d3ad205f108d14e99bb3859aa7dd8e9c68874617c72354d7ecaec2a054ac", "sha256:f87f746ee241d30d6ed93969de31e5ffd09a2961a051e60ae6bddde9ec3583aa" ], - "markers": "python_version >= '3.7'", + "markers": "python_full_version >= '3.7.0'", "version": "==3.2.0" }, "click": { "hashes": [ - "sha256:48ee849951919527a045bfe3bf7baa8a959c423134e1a5b98c05c20ba75a1cbd", - "sha256:fa244bb30b3b5ee2cae3da8f55c9e5e0c0e86093306301fb418eb9dc40fbded5" + "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28", + "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de" ], - "markers": "python_version >= '3.7'", - "version": "==8.1.6" + "markers": "python_full_version >= '3.7.0'", + "version": "==8.1.7" + }, + "colorama": { + "hashes": [ + "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", + "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6" + ], + "markers": "python_version >= '3.5'", + "version": "==0.4.6" }, "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": [ @@ -338,16 +338,16 @@ "sha256:ce785cf81a7bdade534297ef9e490ddff800d956625020ab2ec2780a556c313e", "sha256:d0d651aa754ef58d75cec6edfbd21259d93810b73f6ec246436a21b7841908de" ], - "markers": "python_version >= '3.7'", + "markers": "python_full_version >= '3.7.0'", "version": "==41.0.3" }, "dnspython": { "hashes": [ - "sha256:5b7488477388b8c0b70a8ce93b227c5603bc7b77f1565afe8e729c36c51447d7", - "sha256:c33971c79af5be968bb897e95c2448e11a645ee84d93b265ce0b7aabe5dfdca8" + "sha256:57c6fbaaeaaf39c891292012060beb141791735dbb4004798328fc2c467402d8", + "sha256:8dcfae8c7460a2f84b4072e26f1c9f4101ca20c071649cb7c34e8b6a93d58984" ], "markers": "python_version >= '3.8' and python_version < '4.0'", - "version": "==2.4.1" + "version": "==2.4.2" }, "docopt": { "hashes": [ @@ -372,26 +372,19 @@ }, "exceptiongroup": { "hashes": [ - "sha256:12c3e887d6485d16943a309616de20ae5582633e0a2eda17f4e10fd61c1e8af5", - "sha256:e346e69d186172ca7cf029c8c1d16235aa0e04035e5750b4b95039e65204328f" + "sha256:097acd85d473d75af5bb98e41b61ff7fe35efe6675e4f9370ec6ec5126d160e9", + "sha256:343280667a4585d195ca1cf9cef84a4e178c4b6cf2274caef9859782b567d5e3" ], "index": "pypi", - "version": "==1.1.2" - }, - "fido2": { - "hashes": [ - "sha256:b45e89a6109cfcb7f1bb513776aa2d6408e95c4822f83a253918b944083466ec" - ], - "index": "pypi", - "version": "==0.9.3" + "version": "==1.1.3" }, "flask": { "hashes": [ - "sha256:77fd4e1249d8c9923de34907236b747ced06e5467ecac1a7bb7115ae0e9670b0", - "sha256:8c2f9abd47a9e8df7f0c3f091ce9497d011dc3b31effcf4c85a6e2b50f4114ef" + "sha256:09c347a92aa7ff4a8e7f3206795f30d826654baf38b873d0744cd571ca609efc", + "sha256:f69fcd559dc907ed196ab9df0e48471709175e696d6e698dd4dbe940f96ce66b" ], "index": "pypi", - "version": "==2.3.2" + "version": "==2.3.3" }, "flask-basicauth": { "hashes": [ @@ -442,7 +435,7 @@ "sha256:e49df982b204ed481e4c1236c57f587adf71537301cf8faf7120ab27d73c7568", "sha256:ff3d75acab60b1e66504a11f7ea12c104bad32ff3c410a807788663b966dee4a" ], - "markers": "python_version < '3.12' and python_version >= '3.7'", + "markers": "python_version < '3.12' and python_full_version >= '3.7.0'", "version": "==3.0.1" }, "govuk-bank-holidays": { @@ -534,11 +527,11 @@ }, "humanize": { "hashes": [ - "sha256:7ca0e43e870981fa684acb5b062deb307218193bca1a01f2b2676479df849b3a", - "sha256:df7c429c2d27372b249d3f26eb53b07b166b661326e0325793e0a988082e3889" + "sha256:8bc9e2bb9315e61ec06bf690151ae35aeb65651ab091266941edf97c90836404", + "sha256:9783373bf1eec713a770ecaa7c2d7a7902c98398009dfa3d8a2df91eec9311e8" ], "index": "pypi", - "version": "==4.7.0" + "version": "==4.8.0" }, "idna": { "hashes": [ @@ -561,7 +554,7 @@ "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374" ], - "markers": "python_version >= '3.7'", + "markers": "python_full_version >= '3.7.0'", "version": "==2.0.0" }, "itsdangerous": { @@ -585,7 +578,7 @@ "sha256:02e2e4cc71b5bcab88332eebf907519190dd9e6e82107fa7f83b1003a6252980", "sha256:90261b206d6defd58fdd5e85f478bf633a2901798906be2ad389150c5c60edbe" ], - "markers": "python_version >= '3.7'", + "markers": "python_full_version >= '3.7.0'", "version": "==1.0.1" }, "lml": { @@ -693,6 +686,13 @@ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==4.9.3" }, + "mando": { + "hashes": [ + "sha256:18baa999b4b613faefb00eac4efadcf14f510b59b924b66e08289aa1de8c3500", + "sha256:26ef1d70928b6057ee3ca12583d73c63e05c49de8972d620c278a7b206581a8a" + ], + "version": "==0.7.1" + }, "markupsafe": { "hashes": [ "sha256:05fb21170423db021895e1ea1e1f3ab3adb85d1c2333cbc2310f2a26bc77272e", @@ -746,7 +746,7 @@ "sha256:e4dd52d80b8c83fdce44e12478ad2e85c64ea965e75d66dbeafb0a3e77308fcc", "sha256:fec21693218efe39aa7f8599346e90c705afa52c5b31ae019b2e57e8f6542bb2" ], - "markers": "python_version >= '3.7'", + "markers": "python_full_version >= '3.7.0'", "version": "==2.1.3" }, "mistune": { @@ -758,24 +758,24 @@ }, "newrelic": { "hashes": [ - "sha256:13662a5eec5b64a5ec5e220fa1a7ac99c5e7ff6eb868bb303273689e7c90d762", - "sha256:23450caafa8da15a8a6632642b64a6b1921335a4edd58caeb9f244d740e190b9", - "sha256:260d9536d4a2c910387aa13c91621e88f96567bb4acf7278608cd1905c25808b", - "sha256:4f86037cd179a21efaef52784567c3fc951b400f72fe08567b19d47ab31a6a9d", - "sha256:54c607fb502582484f3bb0e134b19a3634d9b07e79474082d1cde9ff31616844", - "sha256:57159c99ee6a37f1e487ac5ab68fd67afd1407da1689705b3560f7a903867453", - "sha256:732dd981bcda6030c9c377eb8765c60ba5f510ba2052b689cb92a964b16290c0", - "sha256:755d210314f208735a4e53a8ba124ada00fda1fd3ccbdc6d8f01a0d521711c1f", - "sha256:7eb535500073363d25e14ff4a30379957972339530c600673a7bccb26f009aef", - "sha256:a8e8cdfd5ac5f8e58aa11909921e65b4da8afb721de996cd4ecd5c372e339c3c", - "sha256:d9b1ad14417e424b8db24c2da9f4f095b4ea3fac27cf6d5efdeebd140ad07086", - "sha256:ec36f973d6835eb6a0f27871c0dc8127e8bbc4118c3456dbf3f8ef4aae7d707a", - "sha256:f2200b2d4e70b0544a5b99b5103c96eecdf204cc2bbbf499106ad93ebd8f6469", - "sha256:f249f06dbc8b1a160532ece3d6b1989be1ed63c2fe3f569a8913d7c2f0ee9a7e", - "sha256:fa53d9610ba18a577a3bb62e3fc9e22c13e8b99a88ef8f8f6ebc5538f0390ff0" + "sha256:2567ba9e29fd7b9f4c23cf16a5a149097eb0e5da587734c5a40732d75aaec189", + "sha256:365d3b1a10d1021217beeb28a93c1356a9feb94bd24f02972691dc71227e40dc", + "sha256:4ed36fb91f152128825459eae9a52da364352ea95bcd78b405b0a5b8057b2ed7", + "sha256:55a64d2abadf69bbc7bb01178332c4f25247689a97b01a62125d162ea7ec8974", + "sha256:722072d57e2d416de68b650235878583a2a8809ea39c7dd5c8c11a19089b7665", + "sha256:8a2271b76ea684a63936302579d6085d46a2b54042cb91dc9b0d71a0cd4dd38b", + "sha256:9601d886669fe1e0c23bbf91fb68ab23086011816ba96c6dd714c60dc0a74088", + "sha256:b6cddd869ac8f7f32f6de8212ae878a21c9e63f2183601d239a76d38c5d5a366", + "sha256:cf3b67327e64d2b50aec855821199b2bc46bc0c2d142df269d420748dd49b31b", + "sha256:d9af0130e1f1ca032c606d15a6d5558d27273a063b7c53702218b3beccd50b23", + "sha256:dbda843100c99ac3291701c0a70fedb705c0b0707800c60b93657d3985aae357", + "sha256:ecd0666557419dbe11b04e3b38480b3113b3c4670d42619420d60352a1956dd8", + "sha256:f2fd24b32dbf510e4e3fe40b71ad395dd73a4bb9f5eaf59eb5ff22ed76ba2d41", + "sha256:f9c9f7842234a51e4a2fdafe42c42ebe0b6b1966279f2f91ec8a9c16480c2236", + "sha256:fc975c29548e25805ead794d9de7ab3cb8ba4a6a106098646e1ab03112d1432e" ], "index": "pypi", - "version": "==8.9.0" + "version": "==8.10.0" }, "notifications-python-client": { "hashes": [ @@ -787,7 +787,7 @@ "notifications-utils": { "editable": true, "git": "https://github.com/GSA/notifications-utils.git", - "ref": "86ebeec8985cc94ac9e402e1305fbd28c02b02d6" + "ref": "9197f35d9bf07983cd632a8b50f098d5eba762f0" }, "numpy": { "hashes": [ @@ -839,35 +839,35 @@ "sha256:994793af429502c4ea2ebf6bf664629d07c1a9fe974af92966e4b8d2df7edc61", "sha256:a392980d2b6cffa644431898be54b0045151319d1e7ec34f0cfed48767dd334f" ], - "markers": "python_version >= '3.7'", + "markers": "python_full_version >= '3.7.0'", "version": "==23.1" }, "phonenumbers": { "hashes": [ - "sha256:89671217c706cbaa3ced101deefafa779836feac3e059434d886ac31f09f32c0", - "sha256:e8ffd86b2e0b844fd6189fdb0927dbe8707cb03b59102cba5532b3ea305cc1bd" + "sha256:3d802739a22592e4127139349937753dee9b6a20bdd5d56847cd885bdc766b1f", + "sha256:b360c756252805d44b447b5bca6d250cf6bd6c69b6f0f4258f3bfe5ab81bef69" ], - "version": "==8.13.17" + "version": "==8.13.18" }, "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": [ "sha256:c2fd55a7d7a3863cba1a013e4e2414658b1d07b6bc57b3919e0c63c9abb99849", "sha256:d12f0c4b579b15f5e054301bb226ee85eeeba08ffec228092f8defbaa3a4c4b3" ], - "markers": "python_version >= '3.7'", + "markers": "python_full_version >= '3.7.0'", "version": "==1.2.0" }, "prometheus-client": { @@ -944,17 +944,9 @@ "sha256:57e28d156e3d5c10088e0c68abb90bfac3df82b40a71bd0daa20c65ccd5c23de", "sha256:59127c392cc44c2da5bb3192169a91f429924e17aff6534d70fdc02ab3e04320" ], - "markers": "python_version >= '3.7'", + "markers": "python_full_version >= '3.7.0'", "version": "==2.8.0" }, - "pypdf2": { - "hashes": [ - "sha256:a74408f69ba6271f71b9352ef4ed03dc53a31aa404d29b5d31f53bfecfee1440", - "sha256:d16e4205cfee272fbdc0568b68d82be796540b1537508cef59388f839c191928" - ], - "markers": "python_version >= '3.6'", - "version": "==3.0.1" - }, "pyproj": { "hashes": [ "sha256:00fab048596c17572fa8980014ef117dbb2a445e6f7ba3b9ddfcc683efc598e7", @@ -991,7 +983,7 @@ "sha256:78bf16451a2eb8c7a2ea98e32dc119fd2aa758f1d5d66dbf0a59d69a3969df32", "sha256:b4bf8c45bd59934ed84001ad51e11b4ee40d40a1229d2c79f9c592b0a3f6bd8a" ], - "markers": "python_version >= '3.7'", + "markers": "python_full_version >= '3.7.0'", "version": "==7.4.0" }, "pytest-base-url": { @@ -999,16 +991,16 @@ "sha256:e1e88a4fd221941572ccdcf3bf6c051392d2f8b6cef3e0bc7da95abec4b5346e", "sha256:ed36fd632c32af9f1c08f2c2835dcf42ca8fcd097d6ed44a09f253d365ad8297" ], - "markers": "python_version >= '3.7' and python_version < '4.0'", + "markers": "python_version < '4.0' and python_full_version >= '3.7.0'", "version": "==2.0.0" }, "pytest-playwright": { "hashes": [ - "sha256:83a896b1b28bfaa081ca9ea27229a06a114e106e2e62fb3d5f06544748fbc1fe", - "sha256:9bf79c633c97dd1405308b8d3600e6c8c2a200a733e2f36c5a150ba4701936f8" + "sha256:68dd0069e2dbf8e6024c6237d51d0277626687d94ba0dd387cea2a94ae4005b9", + "sha256:9e9622e5507f8d27a3c7fd07aadcf43122f0086b69d1b3e6728995c8dbc0e44f" ], "index": "pypi", - "version": "==0.3.3" + "version": "==0.4.2" }, "python-dateutil": { "hashes": [ @@ -1039,7 +1031,7 @@ "sha256:70ca6ea68fe63ecc8fa4fcf00ae651fc8a5d02d93dcd12ae6d4fc7ca46c4d395", "sha256:ce0d46ddb668b3be82f4ed5e503dbc33dd815d83e2eb6824211310d3fb172a27" ], - "markers": "python_version >= '3.7'", + "markers": "python_full_version >= '3.7.0'", "version": "==8.0.1" }, "pytz": { @@ -1096,20 +1088,28 @@ "markers": "python_version >= '3.6'", "version": "==6.0.1" }, + "radon": { + "hashes": [ + "sha256:632cc032364a6f8bb1010a2f6a12d0f14bc7e5ede76585ef29dc0cecf4cd8859", + "sha256:d1ac0053943a893878940fedc8b19ace70386fc9c9bf0a09229a44125ebf45b5" + ], + "index": "pypi", + "version": "==6.0.1" + }, "redis": { "hashes": [ - "sha256:585dc516b9eb042a619ef0a39c3d7d55fe81bdb4df09a52c9cdde0d07bf1aa7d", - "sha256:e2b03db868160ee4591de3cb90d40ebb50a90dd302138775937f6a42b7ed183c" + "sha256:06570d0b2d84d46c21defc550afbaada381af82f5b83e5b3777600e05d8e2ed0", + "sha256:5cea6c0d335c9a7332a460ed8729ceabb4d0c489c7285b0a86dbbf8a017bd120" ], - "markers": "python_version >= '3.7'", - "version": "==4.6.0" + "markers": "python_full_version >= '3.7.0'", + "version": "==5.0.0" }, "requests": { "hashes": [ "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f", "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1" ], - "markers": "python_version >= '3.7'", + "markers": "python_full_version >= '3.7.0'", "version": "==2.31.0" }, "rtreelib": { @@ -1122,19 +1122,19 @@ }, "s3transfer": { "hashes": [ - "sha256:3c0da2d074bf35d6870ef157158641178a4204a6e689e82546083e31e0311346", - "sha256:640bb492711f4c0c0905e1f62b6aaeb771881935ad27884852411f8e9cacbca9" + "sha256:b014be3a8a2aab98cfe1abc7229cc5a9a0cf05eb9c1f2b86b230fd8df3f78084", + "sha256:cab66d3380cca3e70939ef2255d01cd8aece6a4907a9528740f668c4b0611861" ], - "markers": "python_version >= '3.7'", - "version": "==0.6.1" + "markers": "python_full_version >= '3.7.0'", + "version": "==0.6.2" }, "setuptools": { "hashes": [ - "sha256:11e52c67415a381d10d6b462ced9cfb97066179f0e871399e006c4ab101fc85f", - "sha256:baf1fdb41c6da4cd2eae722e135500da913332ab3f2f5c7d33af9b492acb5235" + "sha256:3d4dfa6d95f1b101d695a6160a7626e15583af71a5f52176efa5d39a054d475d", + "sha256:3d8083eed2d13afc9426f227b24fd1659489ec107c0e86cec2ffdde5c92e790b" ], - "markers": "python_version >= '3.7'", - "version": "==68.0.0" + "markers": "python_version >= '3.8'", + "version": "==68.1.2" }, "shapely": { "hashes": [ @@ -1177,7 +1177,7 @@ "sha256:f32a748703e7bf6e92dfa3d2936b2fbfe76f8ce5f756e24f49ef72d17d26ad02", "sha256:f470a130d6ddb05b810fc1776d918659407f8d025b7f56d2742a596b6dffa6c7" ], - "markers": "python_version >= '3.7'", + "markers": "python_full_version >= '3.7.0'", "version": "==2.0.1" }, "six": { @@ -1221,7 +1221,7 @@ "sha256:440d5dd3af93b060174bf433bccd69b0babc3b15b1a8dca43789fd7f61514b36", "sha256:b75ddc264f0ba5615db7ba217daeb99701ad295353c45f9e95963337ceeeffb2" ], - "markers": "python_version < '3.10'", + "markers": "python_full_version >= '3.7.0'", "version": "==4.7.1" }, "urllib3": { @@ -1241,11 +1241,11 @@ }, "werkzeug": { "hashes": [ - "sha256:935539fa1413afbb9195b24880778422ed620c0fc09670945185cce4d91a8890", - "sha256:98c774df2f91b05550078891dee5f0eb0cb797a522c757a2452b9cee5b202330" + "sha256:2b8c0e447b4b9dbcc85dd97b6eeb4dcbaf6c8b6c3be0bd654e25553e0a2157d8", + "sha256:effc12dba7f3bd72e605ce49807bbe692bd729c3bb122a3b91747a6ae77df528" ], "index": "pypi", - "version": "==2.3.6" + "version": "==2.3.7" }, "wtforms": { "hashes": [ @@ -1306,19 +1306,19 @@ }, "boto3": { "hashes": [ - "sha256:87ecac82d2a68430c0292b7946512c8b1f01ea6971b43dc5832582fcb176c0dd", - "sha256:f2ec3e6f173fe8d141d512ea7d90138db5a58af130773e26ce8e72bdbfd2cddc" + "sha256:2761f3249fe25c3ec1a8cd6b95fca2317747503e6f1d127daf6a3d2cdeb25680", + "sha256:dc6d72470f6d8926b8cdc10ee7708d7ccdd36d6313c7aa298bc1cf6bedb8921e" ], - "markers": "python_version >= '3.7'", - "version": "==1.28.18" + "markers": "python_full_version >= '3.7.0'", + "version": "==1.28.31" }, "botocore": { "hashes": [ - "sha256:909db57f5d6ca765fc9dc9dcae962a87566d0123da1d2bd5be32432493d5785e", - "sha256:c4c01fae2ba32c242ce62175cad719aa49415618560d6e215ed76dab91991dc5" + "sha256:1eef14ae98e8662e43f7cf6d993c732793def02644e2d489c5171d3b9269e900", + "sha256:950a49c5286fe1f6d72cfbe2910b9ddbdfbb907975ddc41cf38ac9709b4d1291" ], - "markers": "python_version >= '3.7'", - "version": "==1.31.18" + "markers": "python_full_version >= '3.7.0'", + "version": "==1.31.31" }, "cachecontrol": { "extras": [ @@ -1486,7 +1486,7 @@ "sha256:f779d3ad205f108d14e99bb3859aa7dd8e9c68874617c72354d7ecaec2a054ac", "sha256:f87f746ee241d30d6ed93969de31e5ffd09a2961a051e60ae6bddde9ec3583aa" ], - "markers": "python_version >= '3.7'", + "markers": "python_full_version >= '3.7.0'", "version": "==3.2.0" }, "cryptography": { @@ -1515,7 +1515,7 @@ "sha256:ce785cf81a7bdade534297ef9e490ddff800d956625020ab2ec2780a556c313e", "sha256:d0d651aa754ef58d75cec6edfbd21259d93810b73f6ec246436a21b7841908de" ], - "markers": "python_version >= '3.7'", + "markers": "python_full_version >= '3.7.0'", "version": "==41.0.3" }, "cyclonedx-python-lib": { @@ -1536,11 +1536,11 @@ }, "exceptiongroup": { "hashes": [ - "sha256:12c3e887d6485d16943a309616de20ae5582633e0a2eda17f4e10fd61c1e8af5", - "sha256:e346e69d186172ca7cf029c8c1d16235aa0e04035e5750b4b95039e65204328f" + "sha256:097acd85d473d75af5bb98e41b61ff7fe35efe6675e4f9370ec6ec5126d160e9", + "sha256:343280667a4585d195ca1cf9cef84a4e178c4b6cf2274caef9859782b567d5e3" ], "index": "pypi", - "version": "==1.1.2" + "version": "==1.1.3" }, "execnet": { "hashes": [ @@ -1605,6 +1605,72 @@ "markers": "python_version >= '3.7'", "version": "==3.1.32" }, + "greenlet": { + "hashes": [ + "sha256:03a8f4f3430c3b3ff8d10a2a86028c660355ab637cee9333d63d66b56f09d52a", + "sha256:0bf60faf0bc2468089bdc5edd10555bab6e85152191df713e2ab1fcc86382b5a", + "sha256:18a7f18b82b52ee85322d7a7874e676f34ab319b9f8cce5de06067384aa8ff43", + "sha256:18e98fb3de7dba1c0a852731c3070cf022d14f0d68b4c87a19cc1016f3bb8b33", + "sha256:1a819eef4b0e0b96bb0d98d797bef17dc1b4a10e8d7446be32d1da33e095dbb8", + "sha256:26fbfce90728d82bc9e6c38ea4d038cba20b7faf8a0ca53a9c07b67318d46088", + "sha256:2780572ec463d44c1d3ae850239508dbeb9fed38e294c68d19a24d925d9223ca", + "sha256:283737e0da3f08bd637b5ad058507e578dd462db259f7f6e4c5c365ba4ee9343", + "sha256:2d4686f195e32d36b4d7cf2d166857dbd0ee9f3d20ae349b6bf8afc8485b3645", + "sha256:2dd11f291565a81d71dab10b7033395b7a3a5456e637cf997a6f33ebdf06f8db", + "sha256:30bcf80dda7f15ac77ba5af2b961bdd9dbc77fd4ac6105cee85b0d0a5fcf74df", + "sha256:32e5b64b148966d9cccc2c8d35a671409e45f195864560829f395a54226408d3", + "sha256:36abbf031e1c0f79dd5d596bfaf8e921c41df2bdf54ee1eed921ce1f52999a86", + "sha256:3a06ad5312349fec0ab944664b01d26f8d1f05009566339ac6f63f56589bc1a2", + "sha256:3a51c9751078733d88e013587b108f1b7a1fb106d402fb390740f002b6f6551a", + "sha256:3c9b12575734155d0c09d6c3e10dbd81665d5c18e1a7c6597df72fd05990c8cf", + "sha256:3f6ea9bd35eb450837a3d80e77b517ea5bc56b4647f5502cd28de13675ee12f7", + "sha256:4b58adb399c4d61d912c4c331984d60eb66565175cdf4a34792cd9600f21b394", + "sha256:4d2e11331fc0c02b6e84b0d28ece3a36e0548ee1a1ce9ddde03752d9b79bba40", + "sha256:5454276c07d27a740c5892f4907c86327b632127dd9abec42ee62e12427ff7e3", + "sha256:561091a7be172ab497a3527602d467e2b3fbe75f9e783d8b8ce403fa414f71a6", + "sha256:6c3acb79b0bfd4fe733dff8bc62695283b57949ebcca05ae5c129eb606ff2d74", + "sha256:703f18f3fda276b9a916f0934d2fb6d989bf0b4fb5a64825260eb9bfd52d78f0", + "sha256:7492e2b7bd7c9b9916388d9df23fa49d9b88ac0640db0a5b4ecc2b653bf451e3", + "sha256:76ae285c8104046b3a7f06b42f29c7b73f77683df18c49ab5af7983994c2dd91", + "sha256:7cafd1208fdbe93b67c7086876f061f660cfddc44f404279c1585bbf3cdc64c5", + "sha256:7efde645ca1cc441d6dc4b48c0f7101e8d86b54c8530141b09fd31cef5149ec9", + "sha256:88d9ab96491d38a5ab7c56dd7a3cc37d83336ecc564e4e8816dbed12e5aaefc8", + "sha256:8eab883b3b2a38cc1e050819ef06a7e6344d4a990d24d45bc6f2cf959045a45b", + "sha256:910841381caba4f744a44bf81bfd573c94e10b3045ee00de0cbf436fe50673a6", + "sha256:9190f09060ea4debddd24665d6804b995a9c122ef5917ab26e1566dcc712ceeb", + "sha256:937e9020b514ceedb9c830c55d5c9872abc90f4b5862f89c0887033ae33c6f73", + "sha256:94c817e84245513926588caf1152e3b559ff794d505555211ca041f032abbb6b", + "sha256:971ce5e14dc5e73715755d0ca2975ac88cfdaefcaab078a284fea6cfabf866df", + "sha256:9d14b83fab60d5e8abe587d51c75b252bcc21683f24699ada8fb275d7712f5a9", + "sha256:9f35ec95538f50292f6d8f2c9c9f8a3c6540bbfec21c9e5b4b751e0a7c20864f", + "sha256:a1846f1b999e78e13837c93c778dcfc3365902cfb8d1bdb7dd73ead37059f0d0", + "sha256:acd2162a36d3de67ee896c43effcd5ee3de247eb00354db411feb025aa319857", + "sha256:b0ef99cdbe2b682b9ccbb964743a6aca37905fda5e0452e5ee239b1654d37f2a", + "sha256:b80f600eddddce72320dbbc8e3784d16bd3fb7b517e82476d8da921f27d4b249", + "sha256:b864ba53912b6c3ab6bcb2beb19f19edd01a6bfcbdfe1f37ddd1778abfe75a30", + "sha256:b9ec052b06a0524f0e35bd8790686a1da006bd911dd1ef7d50b77bfbad74e292", + "sha256:ba2956617f1c42598a308a84c6cf021a90ff3862eddafd20c3333d50f0edb45b", + "sha256:bdfea8c661e80d3c1c99ad7c3ff74e6e87184895bbaca6ee8cc61209f8b9b85d", + "sha256:be4ed120b52ae4d974aa40215fcdfde9194d63541c7ded40ee12eb4dda57b76b", + "sha256:c4302695ad8027363e96311df24ee28978162cdcdd2006476c43970b384a244c", + "sha256:c48f54ef8e05f04d6eff74b8233f6063cb1ed960243eacc474ee73a2ea8573ca", + "sha256:c9c59a2120b55788e800d82dfa99b9e156ff8f2227f07c5e3012a45a399620b7", + "sha256:cd021c754b162c0fb55ad5d6b9d960db667faad0fa2ff25bb6e1301b0b6e6a75", + "sha256:d27ec7509b9c18b6d73f2f5ede2622441de812e7b1a80bbd446cb0633bd3d5ae", + "sha256:d5508f0b173e6aa47273bdc0a0b5ba055b59662ba7c7ee5119528f466585526b", + "sha256:d75209eed723105f9596807495d58d10b3470fa6732dd6756595e89925ce2470", + "sha256:db1a39669102a1d8d12b57de2bb7e2ec9066a6f2b3da35ae511ff93b01b5d564", + "sha256:dbfcfc0218093a19c252ca8eb9aee3d29cfdcb586df21049b9d777fd32c14fd9", + "sha256:e0f72c9ddb8cd28532185f54cc1453f2c16fb417a08b53a855c4e6a418edd099", + "sha256:e7c8dc13af7db097bed64a051d2dd49e9f0af495c26995c00a9ee842690d34c0", + "sha256:ea9872c80c132f4663822dd2a08d404073a5a9b5ba6155bea72fb2a79d1093b5", + "sha256:eff4eb9b7eb3e4d0cae3d28c283dc16d9bed6b193c2e1ace3ed86ce48ea8df19", + "sha256:f82d4d717d8ef19188687aa32b8363e96062911e63ba22a0cff7802a8e58e5f1", + "sha256:fc3a569657468b6f3fb60587e48356fe512c1754ca05a564f11366ac9e306526" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", + "version": "==2.0.2" + }, "html5lib": { "hashes": [ "sha256:0d78f8fde1c230e99fe37986a60526d7049ed4bf8a9fadbad5f00e22e58e041d", @@ -1626,7 +1692,7 @@ "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374" ], - "markers": "python_version >= '3.7'", + "markers": "python_full_version >= '3.7.0'", "version": "==2.0.0" }, "isort": { @@ -1661,7 +1727,7 @@ "sha256:02e2e4cc71b5bcab88332eebf907519190dd9e6e82107fa7f83b1003a6252980", "sha256:90261b206d6defd58fdd5e85f478bf633a2901798906be2ad389150c5c60edbe" ], - "markers": "python_version >= '3.7'", + "markers": "python_full_version >= '3.7.0'", "version": "==1.0.1" }, "markdown-it-py": { @@ -1669,7 +1735,7 @@ "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb" ], - "markers": "python_full_version >= '3.8.0'", + "markers": "python_version >= '3.8'", "version": "==3.0.0" }, "markupsafe": { @@ -1725,7 +1791,7 @@ "sha256:e4dd52d80b8c83fdce44e12478ad2e85c64ea965e75d66dbeafb0a3e77308fcc", "sha256:fec21693218efe39aa7f8599346e90c705afa52c5b31ae019b2e57e8f6542bb2" ], - "markers": "python_version >= '3.7'", + "markers": "python_full_version >= '3.7.0'", "version": "==2.1.3" }, "mccabe": { @@ -1733,7 +1799,7 @@ "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e" ], - "markers": "python_full_version >= '3.6.0'", + "markers": "python_version >= '3.6'", "version": "==0.7.0" }, "mdurl": { @@ -1833,7 +1899,7 @@ "sha256:994793af429502c4ea2ebf6bf664629d07c1a9fe974af92966e4b8d2df7edc61", "sha256:a392980d2b6cffa644431898be54b0045151319d1e7ec34f0cfed48767dd334f" ], - "markers": "python_version >= '3.7'", + "markers": "python_full_version >= '3.7.0'", "version": "==23.1" }, "pbr": { @@ -1873,15 +1939,28 @@ "sha256:4659bc2a667783e7a15d190f6fccf8b2486685b6dba4c19c3876314769c57526", "sha256:b4fa3a7a0be38243123cf9d1f3518da10c51bdb165a2b2985566247f9155a7d3" ], - "markers": "python_full_version >= '3.6.0'", + "markers": "python_version >= '3.6'", "version": "==32.0.1" }, + "playwright": { + "hashes": [ + "sha256:41f0280472af94c426e941f6a969ff6a7ea156dc15fd01d09ac4b8f092e2346e", + "sha256:428fdf9bfff586b73f96df53692d50d422afb93ca4650624f61e8181f548fed2", + "sha256:678b9926be2df06321d11a525d4bf08d9f4a5b151354a3b82fe2ac14476322d5", + "sha256:68d56efe5ce916bab349177e90726837a6f0cae77ebd6a5200f5333b787b25fb", + "sha256:8b5d96aae54289129ab19d3d0e2e431171ae3e5d88d49a10900dcbe569a27d43", + "sha256:b476f63251876f1625f490af8d58ec0db90b555c623b7f54105f91d33878c06d", + "sha256:b574889ef97b7f44a633aa10d72b8966a850a4354d915fd0bc7e8658e825dd63" + ], + "markers": "python_version >= '3.8'", + "version": "==1.37.0" + }, "pluggy": { "hashes": [ "sha256:c2fd55a7d7a3863cba1a013e4e2414658b1d07b6bc57b3919e0c63c9abb99849", "sha256:d12f0c4b579b15f5e054301bb226ee85eeeba08ffec228092f8defbaa3a4c4b3" ], - "markers": "python_version >= '3.7'", + "markers": "python_full_version >= '3.7.0'", "version": "==1.2.0" }, "py-serializable": { @@ -1897,7 +1976,7 @@ "sha256:259bcc17857d8a8b3b4a2327324b79e5f020a13c16074670f9c8c8f872ea76d0", "sha256:5d1013ba8dc7895b548be5afb05740ca82454fd899971563d2ef625d090326f8" ], - "markers": "python_full_version >= '3.8.0'", + "markers": "python_version >= '3.8'", "version": "==2.11.0" }, "pycparser": { @@ -1907,21 +1986,28 @@ ], "version": "==2.21" }, + "pyee": { + "hashes": [ + "sha256:2770c4928abc721f46b705e6a72b0c59480c4a69c9a83ca0b00bb994f1ea4b32", + "sha256:9f066570130c554e9cc12de5a9d86f57c7ee47fece163bbdaa3e9c933cfbdfa5" + ], + "version": "==9.0.4" + }, "pyflakes": { "hashes": [ "sha256:4132f6d49cb4dae6819e5379898f2b8cce3c5f23994194c24b77d5da2e36f774", "sha256:a0aae034c444db0071aa077972ba4768d40c830d9539fd45bf4cd3f8f6992efc" ], - "markers": "python_full_version >= '3.8.0'", + "markers": "python_version >= '3.8'", "version": "==3.1.0" }, "pygments": { "hashes": [ - "sha256:8ace4d3c1dd481894b2005f560ead0f9f19ee64fe983366be1a21e171d12775c", - "sha256:db2db3deb4b4179f399a09054b023b6a586b76499d36965813c71aa8ed7b5fd1" + "sha256:13fc09fa63bc8d8671a6d247e1eb303c4b343eaee81d861f3404db2935653692", + "sha256:1daff0494820c69bc8941e407aa20f577374ee88364ee10a98fdbe0aece96e29" ], "markers": "python_version >= '3.7'", - "version": "==2.15.1" + "version": "==2.16.1" }, "pyparsing": { "hashes": [ @@ -1936,9 +2022,17 @@ "sha256:78bf16451a2eb8c7a2ea98e32dc119fd2aa758f1d5d66dbf0a59d69a3969df32", "sha256:b4bf8c45bd59934ed84001ad51e11b4ee40d40a1229d2c79f9c592b0a3f6bd8a" ], - "markers": "python_version >= '3.7'", + "markers": "python_full_version >= '3.7.0'", "version": "==7.4.0" }, + "pytest-base-url": { + "hashes": [ + "sha256:e1e88a4fd221941572ccdcf3bf6c051392d2f8b6cef3e0bc7da95abec4b5346e", + "sha256:ed36fd632c32af9f1c08f2c2835dcf42ca8fcd097d6ed44a09f253d365ad8297" + ], + "markers": "python_version < '4.0' and python_full_version >= '3.7.0'", + "version": "==2.0.0" + }, "pytest-env": { "hashes": [ "sha256:5e533273f4d9e6a41c3a3120e0c7944aae5674fa773b329f00a5eb1f23c53a38", @@ -1955,6 +2049,14 @@ "index": "pypi", "version": "==3.11.1" }, + "pytest-playwright": { + "hashes": [ + "sha256:68dd0069e2dbf8e6024c6237d51d0277626687d94ba0dd387cea2a94ae4005b9", + "sha256:9e9622e5507f8d27a3c7fd07aadcf43122f0086b69d1b3e6728995c8dbc0e44f" + ], + "index": "pypi", + "version": "==0.4.2" + }, "pytest-xdist": { "hashes": [ "sha256:d5ee0520eb1b7bcca50a60a518ab7a7707992812c578198f8b44fdfac78e8c93", @@ -1971,6 +2073,14 @@ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.8.2" }, + "python-slugify": { + "hashes": [ + "sha256:70ca6ea68fe63ecc8fa4fcf00ae651fc8a5d02d93dcd12ae6d4fc7ca46c4d395", + "sha256:ce0d46ddb668b3be82f4ed5e503dbc33dd815d83e2eb6824211310d3fb172a27" + ], + "markers": "python_full_version >= '3.7.0'", + "version": "==8.0.1" + }, "pyyaml": { "hashes": [ "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc", @@ -2022,7 +2132,7 @@ "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f", "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1" ], - "markers": "python_version >= '3.7'", + "markers": "python_full_version >= '3.7.0'", "version": "==2.31.0" }, "requests-mock": { @@ -2051,11 +2161,11 @@ }, "s3transfer": { "hashes": [ - "sha256:3c0da2d074bf35d6870ef157158641178a4204a6e689e82546083e31e0311346", - "sha256:640bb492711f4c0c0905e1f62b6aaeb771881935ad27884852411f8e9cacbca9" + "sha256:b014be3a8a2aab98cfe1abc7229cc5a9a0cf05eb9c1f2b86b230fd8df3f78084", + "sha256:cab66d3380cca3e70939ef2255d01cd8aece6a4907a9528740f668c4b0611861" ], - "markers": "python_version >= '3.7'", - "version": "==0.6.1" + "markers": "python_full_version >= '3.7.0'", + "version": "==0.6.2" }, "six": { "hashes": [ @@ -2070,7 +2180,7 @@ "sha256:2aba19d6a040e78d8b09de5c57e96207b09ed71d8e55ce0959eeee6c8e190d94", "sha256:c840e62059cd3be204b0c9c9f74be2c09d5648eddd4580d9314c3ecde0b30936" ], - "markers": "python_full_version >= '3.6.0'", + "markers": "python_version >= '3.6'", "version": "==5.0.0" }, "sortedcontainers": { @@ -2093,9 +2203,16 @@ "sha256:8cc040628f3cea5d7128f2e76cf486b2251a4e543c7b938f58d9a377f6694a2d", "sha256:a54534acf9b89bc7ed264807013b505bf07f74dbe4bcfa37d32bd063870b087c" ], - "markers": "python_full_version >= '3.8.0'", + "markers": "python_version >= '3.8'", "version": "==5.1.0" }, + "text-unidecode": { + "hashes": [ + "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8", + "sha256:bad6603bb14d279193107714b288be206cac565dfa49aa5b105294dd5c4aab93" + ], + "version": "==1.3" + }, "toml": { "hashes": [ "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", @@ -2119,6 +2236,14 @@ ], "version": "==6.0.12.11" }, + "typing-extensions": { + "hashes": [ + "sha256:440d5dd3af93b060174bf433bccd69b0babc3b15b1a8dca43789fd7f61514b36", + "sha256:b75ddc264f0ba5615db7ba217daeb99701ad295353c45f9e95963337ceeeffb2" + ], + "markers": "python_full_version >= '3.7.0'", + "version": "==4.7.1" + }, "urllib3": { "hashes": [ "sha256:8d36afa7616d8ab714608411b4a3b13e58f463aee519024578e062e141dce20f", @@ -2136,11 +2261,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/s3_client/__init__.py b/app/s3_client/__init__.py index 4b64f0f5c..540366200 100644 --- a/app/s3_client/__init__.py +++ b/app/s3_client/__init__.py @@ -1,7 +1,18 @@ import botocore from boto3 import Session +from botocore.config import Config from flask import current_app +AWS_CLIENT_CONFIG = Config( + # This config is required to enable S3 to connect to FIPS-enabled + # endpoints. See https://aws.amazon.com/compliance/fips/ for more + # information. + s3={ + 'addressing_style': 'virtual', + }, + use_fips_endpoint=True +) + def get_s3_object( bucket_name, @@ -11,8 +22,12 @@ def get_s3_object( region, ): # To inspect contents: obj.get()['Body'].read().decode('utf-8') - session = Session(aws_access_key_id=access_key, aws_secret_access_key=secret_key, region_name=region) - s3 = session.resource('s3') + session = Session( + aws_access_key_id=access_key, + aws_secret_access_key=secret_key, + region_name=region + ) + s3 = session.resource('s3', config=AWS_CLIENT_CONFIG) obj = s3.Object(bucket_name, filename) return obj 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/check/ok.html b/app/templates/views/check/ok.html index e62e9298e..3b8ca5fcd 100644 --- a/app/templates/views/check/ok.html +++ b/app/templates/views/check/ok.html @@ -93,8 +93,4 @@ Only showing the first {{ count_of_displayed_recipients }} rows

{% endif %} -
-

Messages remaining today / Messages left if list is sent

-

{{ remaining_messages }} / {{ remaining_messages - count_of_recipients }}

-
{% endblock %} 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/send.html b/app/templates/views/send.html index 1671c199c..3c1664da0 100644 --- a/app/templates/views/send.html +++ b/app/templates/views/send.html @@ -56,9 +56,4 @@

Your file will populate this template ({{ template.name }})

{{ template|string }} -
-

Messages Remaining Today / Daily Message Limit

-

{{ daily_remaining_messages }} / {{ current_service.message_limit }}

-
- {% endblock %} 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/docs/sprint-goals.md b/docs/sprint-goals.md index 50d6139e4..f774da9c5 100644 --- a/docs/sprint-goals.md +++ b/docs/sprint-goals.md @@ -1,6 +1,16 @@ # Notify Sprint Goals Log -## Sprint: P (8/3/23) +## Sprint: Q (8/17/23) + +| | Goals | Impact | +|-------------|-----------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------| +| Engineering | Continue app stabilization efforts, including: [Completing UK stylesheet removal](https://github.com/GSA/notifications-admin/issues/686), [Removing GDS libraries](https://github.com/GSA/notifications-admin/issues/674), and working to improve documentation | Increase app reliability and simplicity prior to first partner use | +| UX | Hone a visual brand strategy for proposal to leadership, ensure that USWDS elements are rendering properly | Consistent look and feel for Notify.gov, smooth, non-glitchy experience for users | +| Security | Begin scoping and planning for addressing LATO findings | Complete resolving POA&Ms in their designated timeline| +| Content | Use research and user test information to begin crafting more concise content | Have content ready for later addition to the site + + +## Sprint: Puffin (8/3/23) | | Goals | Impact | |-------------|-----------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------| 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() - }) -})