diff --git a/.sqlx/query-093a7426c1d5df52f5e282a296fa2e1f4559c6ef703fc2a2343cbab1058963c9.json b/.sqlx/query-093a7426c1d5df52f5e282a296fa2e1f4559c6ef703fc2a2343cbab1058963c9.json index e81a8ae962..b56b07cf77 100644 --- a/.sqlx/query-093a7426c1d5df52f5e282a296fa2e1f4559c6ef703fc2a2343cbab1058963c9.json +++ b/.sqlx/query-093a7426c1d5df52f5e282a296fa2e1f4559c6ef703fc2a2343cbab1058963c9.json @@ -74,7 +74,8 @@ "none", "one_time_password", "webauthn", - "email" + "email", + "fido2" ] } } diff --git a/.sqlx/query-13189e37219a66f99df58fc726aa108cc45e1e2b7e4adc80f2e6bee4e5c258d2.json b/.sqlx/query-13189e37219a66f99df58fc726aa108cc45e1e2b7e4adc80f2e6bee4e5c258d2.json index 2fc71a6c78..23fae4ae02 100644 --- a/.sqlx/query-13189e37219a66f99df58fc726aa108cc45e1e2b7e4adc80f2e6bee4e5c258d2.json +++ b/.sqlx/query-13189e37219a66f99df58fc726aa108cc45e1e2b7e4adc80f2e6bee4e5c258d2.json @@ -34,7 +34,8 @@ "none", "one_time_password", "webauthn", - "email" + "email", + "fido2" ] } } diff --git a/.sqlx/query-212a211fca991b0a58e4cde25571edc03e2e3b9970079b7903ac3def6efff79a.json b/.sqlx/query-212a211fca991b0a58e4cde25571edc03e2e3b9970079b7903ac3def6efff79a.json index 8481fcd594..ab79d0d0af 100644 --- a/.sqlx/query-212a211fca991b0a58e4cde25571edc03e2e3b9970079b7903ac3def6efff79a.json +++ b/.sqlx/query-212a211fca991b0a58e4cde25571edc03e2e3b9970079b7903ac3def6efff79a.json @@ -109,7 +109,8 @@ "none", "one_time_password", "webauthn", - "email" + "email", + "fido2" ] } } diff --git a/.sqlx/query-21d29424c3a8df807ea526da51f5d2753ad992eedaadd215e9058b10b245581e.json b/.sqlx/query-21d29424c3a8df807ea526da51f5d2753ad992eedaadd215e9058b10b245581e.json index 98185b0c2f..b7970bef89 100644 --- a/.sqlx/query-21d29424c3a8df807ea526da51f5d2753ad992eedaadd215e9058b10b245581e.json +++ b/.sqlx/query-21d29424c3a8df807ea526da51f5d2753ad992eedaadd215e9058b10b245581e.json @@ -74,7 +74,8 @@ "none", "one_time_password", "webauthn", - "email" + "email", + "fido2" ] } } diff --git a/.sqlx/query-2540640625dbc36da2632daf756fa43cccaec4da1b6317696f892f878aadd96d.json b/.sqlx/query-2540640625dbc36da2632daf756fa43cccaec4da1b6317696f892f878aadd96d.json index 29a6b68de1..771e2d0028 100644 --- a/.sqlx/query-2540640625dbc36da2632daf756fa43cccaec4da1b6317696f892f878aadd96d.json +++ b/.sqlx/query-2540640625dbc36da2632daf756fa43cccaec4da1b6317696f892f878aadd96d.json @@ -37,7 +37,8 @@ "none", "one_time_password", "webauthn", - "email" + "email", + "fido2" ] } } diff --git a/.sqlx/query-25c223aecb861460fae25048225bc6005e322fbd1a8208a29a4ad662e58045c0.json b/.sqlx/query-25c223aecb861460fae25048225bc6005e322fbd1a8208a29a4ad662e58045c0.json index e007ea16df..200dd060be 100644 --- a/.sqlx/query-25c223aecb861460fae25048225bc6005e322fbd1a8208a29a4ad662e58045c0.json +++ b/.sqlx/query-25c223aecb861460fae25048225bc6005e322fbd1a8208a29a4ad662e58045c0.json @@ -74,7 +74,8 @@ "none", "one_time_password", "webauthn", - "email" + "email", + "fido2" ] } } diff --git a/.sqlx/query-31b3fcaa8109121f985a2c740475f1d77fe8dcbcab76e64164be140ad361ab14.json b/.sqlx/query-31b3fcaa8109121f985a2c740475f1d77fe8dcbcab76e64164be140ad361ab14.json index ce29e5b69a..04b52c76cf 100644 --- a/.sqlx/query-31b3fcaa8109121f985a2c740475f1d77fe8dcbcab76e64164be140ad361ab14.json +++ b/.sqlx/query-31b3fcaa8109121f985a2c740475f1d77fe8dcbcab76e64164be140ad361ab14.json @@ -14,7 +14,8 @@ "none", "one_time_password", "webauthn", - "email" + "email", + "fido2" ] } } diff --git a/.sqlx/query-33bbbc2b7a47acce0d333a71f0edf12111864c122a9f2e1d539a80551350013c.json b/.sqlx/query-33bbbc2b7a47acce0d333a71f0edf12111864c122a9f2e1d539a80551350013c.json index 32e811cebf..3f2173a089 100644 --- a/.sqlx/query-33bbbc2b7a47acce0d333a71f0edf12111864c122a9f2e1d539a80551350013c.json +++ b/.sqlx/query-33bbbc2b7a47acce0d333a71f0edf12111864c122a9f2e1d539a80551350013c.json @@ -74,7 +74,8 @@ "none", "one_time_password", "webauthn", - "email" + "email", + "fido2" ] } } diff --git a/.sqlx/query-3a6870df1845033d891e88530acb40d355f476044a7ce5a20dd55d4abbb6321a.json b/.sqlx/query-3a6870df1845033d891e88530acb40d355f476044a7ce5a20dd55d4abbb6321a.json index 156ced9da0..46c0a623f7 100644 --- a/.sqlx/query-3a6870df1845033d891e88530acb40d355f476044a7ce5a20dd55d4abbb6321a.json +++ b/.sqlx/query-3a6870df1845033d891e88530acb40d355f476044a7ce5a20dd55d4abbb6321a.json @@ -74,7 +74,8 @@ "none", "one_time_password", "webauthn", - "email" + "email", + "fido2" ] } } diff --git a/.sqlx/query-3e465851dfa15717c0546a2e2fbbd332de9e8014d6cb1df0ba17fdf6d3a44ef4.json b/.sqlx/query-3e465851dfa15717c0546a2e2fbbd332de9e8014d6cb1df0ba17fdf6d3a44ef4.json index 6e400d69b3..fd70b99159 100644 --- a/.sqlx/query-3e465851dfa15717c0546a2e2fbbd332de9e8014d6cb1df0ba17fdf6d3a44ef4.json +++ b/.sqlx/query-3e465851dfa15717c0546a2e2fbbd332de9e8014d6cb1df0ba17fdf6d3a44ef4.json @@ -22,7 +22,8 @@ "Enum": [ "none", "starttls", - "implicittls" + "implicittls", + "xoauth2" ] } } diff --git a/.sqlx/query-5aaac5fbf3656a825ab33f07dd9bf8698c283d3158c7b6806a24d777819c7e64.json b/.sqlx/query-5aaac5fbf3656a825ab33f07dd9bf8698c283d3158c7b6806a24d777819c7e64.json index 7f0454377f..189fd21df6 100644 --- a/.sqlx/query-5aaac5fbf3656a825ab33f07dd9bf8698c283d3158c7b6806a24d777819c7e64.json +++ b/.sqlx/query-5aaac5fbf3656a825ab33f07dd9bf8698c283d3158c7b6806a24d777819c7e64.json @@ -14,7 +14,8 @@ "none", "one_time_password", "webauthn", - "email" + "email", + "fido2" ] } } diff --git a/.sqlx/query-5da675e723a05e1367bdd91c174361e91fdb169a8f9c2ce7b222abed5970e69b.json b/.sqlx/query-5da675e723a05e1367bdd91c174361e91fdb169a8f9c2ce7b222abed5970e69b.json index 46819b2dd4..8cb15c5ef7 100644 --- a/.sqlx/query-5da675e723a05e1367bdd91c174361e91fdb169a8f9c2ce7b222abed5970e69b.json +++ b/.sqlx/query-5da675e723a05e1367bdd91c174361e91fdb169a8f9c2ce7b222abed5970e69b.json @@ -34,7 +34,8 @@ "email", "oidc", "biometric", - "mobileapprove" + "mobileapprove", + "fido2" ] } } diff --git a/.sqlx/query-6788faaac53f42dbf2bf68025e1338be91f5e0190b75e9ab8838df76e5168fb3.json b/.sqlx/query-6788faaac53f42dbf2bf68025e1338be91f5e0190b75e9ab8838df76e5168fb3.json index 075749e9ac..baa17a7266 100644 --- a/.sqlx/query-6788faaac53f42dbf2bf68025e1338be91f5e0190b75e9ab8838df76e5168fb3.json +++ b/.sqlx/query-6788faaac53f42dbf2bf68025e1338be91f5e0190b75e9ab8838df76e5168fb3.json @@ -109,7 +109,8 @@ "none", "one_time_password", "webauthn", - "email" + "email", + "fido2" ] } } diff --git a/.sqlx/query-75816bc9b2928c64f5565ec1f2e3d526645f5d0770cabad082d4cb38ceb608b7.json b/.sqlx/query-75816bc9b2928c64f5565ec1f2e3d526645f5d0770cabad082d4cb38ceb608b7.json deleted file mode 100644 index f70feb3c55..0000000000 --- a/.sqlx/query-75816bc9b2928c64f5565ec1f2e3d526645f5d0770cabad082d4cb38ceb608b7.json +++ /dev/null @@ -1,75 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT d.id, d.name, d.wireguard_pubkey, d.user_id, d.created, d.description, d.device_type \"device_type: DeviceType\", d.configured FROM biometric_auth as b JOIN device d ON b.device_id = d.id WHERE d.user_id = $1 AND b.pub_key = $2", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Int8" - }, - { - "ordinal": 1, - "name": "name", - "type_info": "Text" - }, - { - "ordinal": 2, - "name": "wireguard_pubkey", - "type_info": "Text" - }, - { - "ordinal": 3, - "name": "user_id", - "type_info": "Int8" - }, - { - "ordinal": 4, - "name": "created", - "type_info": "Timestamp" - }, - { - "ordinal": 5, - "name": "description", - "type_info": "Text" - }, - { - "ordinal": 6, - "name": "device_type: DeviceType", - "type_info": { - "Custom": { - "name": "device_type", - "kind": { - "Enum": [ - "user", - "network" - ] - } - } - } - }, - { - "ordinal": 7, - "name": "configured", - "type_info": "Bool" - } - ], - "parameters": { - "Left": [ - "Int8", - "Text" - ] - }, - "nullable": [ - false, - false, - false, - false, - false, - true, - false, - false - ] - }, - "hash": "75816bc9b2928c64f5565ec1f2e3d526645f5d0770cabad082d4cb38ceb608b7" -} diff --git a/.sqlx/query-e770f574ecb1c8fc700e610334914e72dbffca5448b0aef3e0b0e3f60af6b5f7.json b/.sqlx/query-7beb83800d49617900ed112e7ddafd22316da6fdc4f7fc52effdac59d745a4c1.json similarity index 79% rename from .sqlx/query-e770f574ecb1c8fc700e610334914e72dbffca5448b0aef3e0b0e3f60af6b5f7.json rename to .sqlx/query-7beb83800d49617900ed112e7ddafd22316da6fdc4f7fc52effdac59d745a4c1.json index e08b8d9d28..ec7246f42d 100644 --- a/.sqlx/query-e770f574ecb1c8fc700e610334914e72dbffca5448b0aef3e0b0e3f60af6b5f7.json +++ b/.sqlx/query-7beb83800d49617900ed112e7ddafd22316da6fdc4f7fc52effdac59d745a4c1.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT b.id, b.pub_key, b.device_id FROM biometric_auth as b JOIN device d ON b.device_id = d.id WHERE d.user_id = $1", + "query": "SELECT b.id, b.pub_key, b.device_id FROM biometric_auth b JOIN device d ON b.device_id = d.id WHERE d.user_id = $1", "describe": { "columns": [ { @@ -30,5 +30,5 @@ false ] }, - "hash": "e770f574ecb1c8fc700e610334914e72dbffca5448b0aef3e0b0e3f60af6b5f7" + "hash": "7beb83800d49617900ed112e7ddafd22316da6fdc4f7fc52effdac59d745a4c1" } diff --git a/.sqlx/query-7f7f2da3cabcf74dcf9aa1f4711ee99229d9197e9a1cf2cdefe154ba275dad8c.json b/.sqlx/query-7f7f2da3cabcf74dcf9aa1f4711ee99229d9197e9a1cf2cdefe154ba275dad8c.json index 90f0a3b214..ee34c7b061 100644 --- a/.sqlx/query-7f7f2da3cabcf74dcf9aa1f4711ee99229d9197e9a1cf2cdefe154ba275dad8c.json +++ b/.sqlx/query-7f7f2da3cabcf74dcf9aa1f4711ee99229d9197e9a1cf2cdefe154ba275dad8c.json @@ -109,7 +109,8 @@ "none", "one_time_password", "webauthn", - "email" + "email", + "fido2" ] } } diff --git a/.sqlx/query-842b460f91cd2f54be03ead78e5eff81ff2143d5713ce5b5424c388b79086fb3.json b/.sqlx/query-842b460f91cd2f54be03ead78e5eff81ff2143d5713ce5b5424c388b79086fb3.json index bcb58797cc..000b8f200e 100644 --- a/.sqlx/query-842b460f91cd2f54be03ead78e5eff81ff2143d5713ce5b5424c388b79086fb3.json +++ b/.sqlx/query-842b460f91cd2f54be03ead78e5eff81ff2143d5713ce5b5424c388b79086fb3.json @@ -19,7 +19,8 @@ "email", "oidc", "biometric", - "mobileapprove" + "mobileapprove", + "fido2" ] } } diff --git a/.sqlx/query-898fd61e48f35b9befabec12c7b71d0c45b63dad212faea2a7b15b05161e1b12.json b/.sqlx/query-898fd61e48f35b9befabec12c7b71d0c45b63dad212faea2a7b15b05161e1b12.json index 24d5834c4f..6587ec90af 100644 --- a/.sqlx/query-898fd61e48f35b9befabec12c7b71d0c45b63dad212faea2a7b15b05161e1b12.json +++ b/.sqlx/query-898fd61e48f35b9befabec12c7b71d0c45b63dad212faea2a7b15b05161e1b12.json @@ -74,7 +74,8 @@ "none", "one_time_password", "webauthn", - "email" + "email", + "fido2" ] } } diff --git a/.sqlx/query-91d9845411ad48a9847aa65c675ef1f6ff2537571d41b17e12c3379e261faafe.json b/.sqlx/query-91d9845411ad48a9847aa65c675ef1f6ff2537571d41b17e12c3379e261faafe.json index 61d57f3cad..ecdd01b3b1 100644 --- a/.sqlx/query-91d9845411ad48a9847aa65c675ef1f6ff2537571d41b17e12c3379e261faafe.json +++ b/.sqlx/query-91d9845411ad48a9847aa65c675ef1f6ff2537571d41b17e12c3379e261faafe.json @@ -74,7 +74,8 @@ "none", "one_time_password", "webauthn", - "email" + "email", + "fido2" ] } } diff --git a/.sqlx/query-94d375808a70533f9e2c09eff7ef7a02214cb9a1cb0b90e5b87304a6cdd866d8.json b/.sqlx/query-94d375808a70533f9e2c09eff7ef7a02214cb9a1cb0b90e5b87304a6cdd866d8.json index 137d8f49d2..47960fcc94 100644 --- a/.sqlx/query-94d375808a70533f9e2c09eff7ef7a02214cb9a1cb0b90e5b87304a6cdd866d8.json +++ b/.sqlx/query-94d375808a70533f9e2c09eff7ef7a02214cb9a1cb0b90e5b87304a6cdd866d8.json @@ -74,7 +74,8 @@ "none", "one_time_password", "webauthn", - "email" + "email", + "fido2" ] } } diff --git a/.sqlx/query-985c077436165c3f123f39c59e7629cef8fcc2029cc6fe4255dd11258ad9dbe2.json b/.sqlx/query-985c077436165c3f123f39c59e7629cef8fcc2029cc6fe4255dd11258ad9dbe2.json index ec4520f2ea..d7f7091f1b 100644 --- a/.sqlx/query-985c077436165c3f123f39c59e7629cef8fcc2029cc6fe4255dd11258ad9dbe2.json +++ b/.sqlx/query-985c077436165c3f123f39c59e7629cef8fcc2029cc6fe4255dd11258ad9dbe2.json @@ -74,7 +74,8 @@ "none", "one_time_password", "webauthn", - "email" + "email", + "fido2" ] } } diff --git a/.sqlx/query-a10e0a0b0621db79acd4728da49445bab4d9fb37e419ccabceaf5e39d4801500.json b/.sqlx/query-a10e0a0b0621db79acd4728da49445bab4d9fb37e419ccabceaf5e39d4801500.json new file mode 100644 index 0000000000..530032a0e6 --- /dev/null +++ b/.sqlx/query-a10e0a0b0621db79acd4728da49445bab4d9fb37e419ccabceaf5e39d4801500.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT d.name FROM biometric_auth b JOIN device d ON b.device_id = d.id WHERE d.user_id = $1 AND b.pub_key = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "name", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Int8", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "a10e0a0b0621db79acd4728da49445bab4d9fb37e419ccabceaf5e39d4801500" +} diff --git a/.sqlx/query-a185fa490bd1255a6b45cdb25ac9ea500b1eb42a8f525d44238f61e696078cc3.json b/.sqlx/query-a185fa490bd1255a6b45cdb25ac9ea500b1eb42a8f525d44238f61e696078cc3.json new file mode 100644 index 0000000000..367ffbdef6 --- /dev/null +++ b/.sqlx/query-a185fa490bd1255a6b45cdb25ac9ea500b1eb42a8f525d44238f61e696078cc3.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM webauthn WHERE user_id = $1)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + null + ] + }, + "hash": "a185fa490bd1255a6b45cdb25ac9ea500b1eb42a8f525d44238f61e696078cc3" +} diff --git a/.sqlx/query-a5efa87acd3b331ae142a5f0b4f3daf814b72b1c2c7b22d96a2ebf870cf388dd.json b/.sqlx/query-a5efa87acd3b331ae142a5f0b4f3daf814b72b1c2c7b22d96a2ebf870cf388dd.json index 1f6ac09cef..cc56463321 100644 --- a/.sqlx/query-a5efa87acd3b331ae142a5f0b4f3daf814b72b1c2c7b22d96a2ebf870cf388dd.json +++ b/.sqlx/query-a5efa87acd3b331ae142a5f0b4f3daf814b72b1c2c7b22d96a2ebf870cf388dd.json @@ -32,7 +32,8 @@ "none", "one_time_password", "webauthn", - "email" + "email", + "fido2" ] } } diff --git a/.sqlx/query-b8ca90c8d5135c7020ba287bfdab3e2dfeb6e14bfae355fe5216feeb21796883.json b/.sqlx/query-b8ca90c8d5135c7020ba287bfdab3e2dfeb6e14bfae355fe5216feeb21796883.json index 309f6b8166..514dacec9a 100644 --- a/.sqlx/query-b8ca90c8d5135c7020ba287bfdab3e2dfeb6e14bfae355fe5216feeb21796883.json +++ b/.sqlx/query-b8ca90c8d5135c7020ba287bfdab3e2dfeb6e14bfae355fe5216feeb21796883.json @@ -74,7 +74,8 @@ "none", "one_time_password", "webauthn", - "email" + "email", + "fido2" ] } } diff --git a/.sqlx/query-bd8af5f0863b7f96407000b87253a8b5bdaf9555cdd212dc2975828387262ff2.json b/.sqlx/query-bd8af5f0863b7f96407000b87253a8b5bdaf9555cdd212dc2975828387262ff2.json index 323f67f9e6..ae6b1011a8 100644 --- a/.sqlx/query-bd8af5f0863b7f96407000b87253a8b5bdaf9555cdd212dc2975828387262ff2.json +++ b/.sqlx/query-bd8af5f0863b7f96407000b87253a8b5bdaf9555cdd212dc2975828387262ff2.json @@ -74,7 +74,8 @@ "none", "one_time_password", "webauthn", - "email" + "email", + "fido2" ] } } diff --git a/.sqlx/query-be5959bb82c34ffd3293b1168af62662fdf15fc1c809d039ea8adccad2c5c2a0.json b/.sqlx/query-be5959bb82c34ffd3293b1168af62662fdf15fc1c809d039ea8adccad2c5c2a0.json index 7777110f2e..541a1760e7 100644 --- a/.sqlx/query-be5959bb82c34ffd3293b1168af62662fdf15fc1c809d039ea8adccad2c5c2a0.json +++ b/.sqlx/query-be5959bb82c34ffd3293b1168af62662fdf15fc1c809d039ea8adccad2c5c2a0.json @@ -24,7 +24,8 @@ "email", "oidc", "biometric", - "mobileapprove" + "mobileapprove", + "fido2" ] } } diff --git a/.sqlx/query-cb7d346e5385fe780f0a2e0fb333779aa50b25176fe9e7799db2d701b47ea7f0.json b/.sqlx/query-cb7d346e5385fe780f0a2e0fb333779aa50b25176fe9e7799db2d701b47ea7f0.json index c81ce9a035..93d4004a82 100644 --- a/.sqlx/query-cb7d346e5385fe780f0a2e0fb333779aa50b25176fe9e7799db2d701b47ea7f0.json +++ b/.sqlx/query-cb7d346e5385fe780f0a2e0fb333779aa50b25176fe9e7799db2d701b47ea7f0.json @@ -74,7 +74,8 @@ "none", "one_time_password", "webauthn", - "email" + "email", + "fido2" ] } } diff --git a/.sqlx/query-ce08d19023028c4797c2ca33ff76824b882f17f5891bb512deff13a2c26069bc.json b/.sqlx/query-ce08d19023028c4797c2ca33ff76824b882f17f5891bb512deff13a2c26069bc.json index 3b569125bd..ffcf4ade0b 100644 --- a/.sqlx/query-ce08d19023028c4797c2ca33ff76824b882f17f5891bb512deff13a2c26069bc.json +++ b/.sqlx/query-ce08d19023028c4797c2ca33ff76824b882f17f5891bb512deff13a2c26069bc.json @@ -74,7 +74,8 @@ "none", "one_time_password", "webauthn", - "email" + "email", + "fido2" ] } } diff --git a/.sqlx/query-dc8062f91f1ae908ec3e81cf0195a76f38faf1a26df3ff4c045a8a9bbd7f73de.json b/.sqlx/query-dc8062f91f1ae908ec3e81cf0195a76f38faf1a26df3ff4c045a8a9bbd7f73de.json index fc82447453..1591caf293 100644 --- a/.sqlx/query-dc8062f91f1ae908ec3e81cf0195a76f38faf1a26df3ff4c045a8a9bbd7f73de.json +++ b/.sqlx/query-dc8062f91f1ae908ec3e81cf0195a76f38faf1a26df3ff4c045a8a9bbd7f73de.json @@ -74,7 +74,8 @@ "none", "one_time_password", "webauthn", - "email" + "email", + "fido2" ] } } diff --git a/.sqlx/query-e20bb4d7529adc50b620a5a96bdccea9f2f51f0f45e1833555263cb776daf0d0.json b/.sqlx/query-e20bb4d7529adc50b620a5a96bdccea9f2f51f0f45e1833555263cb776daf0d0.json index e29f0eff37..c55450edd7 100644 --- a/.sqlx/query-e20bb4d7529adc50b620a5a96bdccea9f2f51f0f45e1833555263cb776daf0d0.json +++ b/.sqlx/query-e20bb4d7529adc50b620a5a96bdccea9f2f51f0f45e1833555263cb776daf0d0.json @@ -74,7 +74,8 @@ "none", "one_time_password", "webauthn", - "email" + "email", + "fido2" ] } } diff --git a/.sqlx/query-41d50b33737847c6a7639125b3d04d1d499ee1defd1557b08ec0f48d9bbd1ac3.json b/.sqlx/query-eaf01227119c12908f63db143840c952892fc5a2d575fbe0b0ec0b01cc64fe37.json similarity index 84% rename from .sqlx/query-41d50b33737847c6a7639125b3d04d1d499ee1defd1557b08ec0f48d9bbd1ac3.json rename to .sqlx/query-eaf01227119c12908f63db143840c952892fc5a2d575fbe0b0ec0b01cc64fe37.json index 51ac03b6f6..83feb57b2e 100644 --- a/.sqlx/query-41d50b33737847c6a7639125b3d04d1d499ee1defd1557b08ec0f48d9bbd1ac3.json +++ b/.sqlx/query-eaf01227119c12908f63db143840c952892fc5a2d575fbe0b0ec0b01cc64fe37.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT id, pub_key, device_id FROM biometric_auth WHERE device_id=$1", + "query": "SELECT id, pub_key, device_id FROM biometric_auth WHERE device_id = $1", "describe": { "columns": [ { @@ -30,5 +30,5 @@ false ] }, - "hash": "41d50b33737847c6a7639125b3d04d1d499ee1defd1557b08ec0f48d9bbd1ac3" + "hash": "eaf01227119c12908f63db143840c952892fc5a2d575fbe0b0ec0b01cc64fe37" } diff --git a/.sqlx/query-fd7f6f507a106fdd886d5bb174b174798bc280b6a3c2237d574dba90ecd3342c.json b/.sqlx/query-fd7f6f507a106fdd886d5bb174b174798bc280b6a3c2237d574dba90ecd3342c.json index ee7293c366..bfb1b43f33 100644 --- a/.sqlx/query-fd7f6f507a106fdd886d5bb174b174798bc280b6a3c2237d574dba90ecd3342c.json +++ b/.sqlx/query-fd7f6f507a106fdd886d5bb174b174798bc280b6a3c2237d574dba90ecd3342c.json @@ -26,7 +26,8 @@ "email", "oidc", "biometric", - "mobileapprove" + "mobileapprove", + "fido2" ] } } diff --git a/Cargo.lock b/Cargo.lock index dca9e58445..8a69956ab6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -534,6 +534,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" +[[package]] +name = "base16ct" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" + [[package]] name = "base32" version = "0.5.1" @@ -1217,6 +1223,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ "hybrid-array", + "rand_core 0.10.1", ] [[package]] @@ -1280,7 +1287,24 @@ dependencies = [ "cpufeatures 0.2.17", "curve25519-dalek-derive", "digest 0.10.7", - "fiat-crypto", + "fiat-crypto 0.2.9", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "curve25519-dalek-derive", + "digest 0.11.3", + "fiat-crypto 0.3.0", + "rand_core 0.10.1", "rustc_version", "subtle", "zeroize", @@ -1309,7 +1333,7 @@ dependencies = [ "rand_core 0.6.4", "serdect 0.3.0", "sha3", - "signature", + "signature 2.2.0", "subtle", "zeroize", ] @@ -1478,11 +1502,11 @@ dependencies = [ "claims", "clap", "defguard_certs", - "ed25519-dalek", + "ed25519-dalek 3.0.0", + "getrandom 0.4.3", "humantime", "ipnetwork", "jsonwebtoken", - "matches", "model_derive", "openidconnect", "rand 0.8.7", @@ -1505,7 +1529,7 @@ dependencies = [ "uuid", "vergen-git2", "webauthn-rs", - "x25519-dalek", + "x25519-dalek 3.0.0", ] [[package]] @@ -1541,7 +1565,6 @@ dependencies = [ "jsonwebtoken", "ldap3", "lettre", - "matches", "md4", "model_derive", "mrml", @@ -1595,7 +1618,7 @@ dependencies = [ "webauthn-rs", "webauthn-rs-proto", "wiremock", - "x25519-dalek", + "x25519-dalek 3.0.0", ] [[package]] @@ -1706,7 +1729,8 @@ dependencies = [ "defguard_grpc_tls", "defguard_proto", "defguard_version", - "ed25519-dalek", + "ed25519-dalek 3.0.0", + "getrandom 0.4.3", "hyper-rustls", "hyper-util", "ipnetwork", @@ -2079,7 +2103,7 @@ dependencies = [ "pkcs8", "rfc6979", "sha2 0.10.9", - "signature", + "signature 2.2.0", "zeroize", ] @@ -2133,7 +2157,7 @@ dependencies = [ "digest 0.10.7", "elliptic-curve", "rfc6979", - "signature", + "signature 2.2.0", "spki", ] @@ -2144,7 +2168,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" dependencies = [ "pkcs8", - "signature", + "signature 2.2.0", +] + +[[package]] +name = "ed25519" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" +dependencies = [ + "serdect 0.4.3", + "signature 3.0.0", ] [[package]] @@ -2153,8 +2187,8 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" dependencies = [ - "curve25519-dalek", - "ed25519", + "curve25519-dalek 4.1.3", + "ed25519 2.2.3", "rand_core 0.6.4", "serde", "sha2 0.10.9", @@ -2162,6 +2196,22 @@ dependencies = [ "zeroize", ] +[[package]] +name = "ed25519-dalek" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de" +dependencies = [ + "curve25519-dalek 5.0.0", + "ed25519 3.0.0", + "rand_core 0.10.1", + "serde", + "sha2 0.11.0", + "signature 3.0.0", + "subtle", + "zeroize", +] + [[package]] name = "either" version = "1.18.0" @@ -2177,7 +2227,7 @@ version = "0.13.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" dependencies = [ - "base16ct", + "base16ct 0.2.0", "base64ct", "crypto-bigint", "digest 0.10.7", @@ -2357,6 +2407,12 @@ version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" +[[package]] +name = "fiat-crypto" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" + [[package]] name = "find-msvc-tools" version = "0.1.11" @@ -3443,7 +3499,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc" dependencies = [ "base64 0.22.1", - "ed25519-dalek", + "ed25519-dalek 2.2.0", "getrandom 0.2.17", "hmac", "js-sys", @@ -3455,7 +3511,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.10.9", - "signature", + "signature 2.2.0", "simple_asn1", "zeroize", ] @@ -3471,7 +3527,7 @@ dependencies = [ "elliptic-curve", "once_cell", "sha2 0.10.9", - "signature", + "signature 2.2.0", ] [[package]] @@ -3707,12 +3763,6 @@ dependencies = [ "regex-automata", ] -[[package]] -name = "matches" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" - [[package]] name = "matchit" version = "0.8.4" @@ -4300,7 +4350,7 @@ dependencies = [ "base64 0.21.7", "chrono", "dyn-clone", - "ed25519-dalek", + "ed25519-dalek 2.2.0", "hmac", "http", "itertools 0.10.5", @@ -4430,7 +4480,7 @@ version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2" dependencies = [ - "base16ct", + "base16ct 0.2.0", "ecdsa", "elliptic-curve", "primeorder", @@ -4612,7 +4662,7 @@ dependencies = [ "cipher", "const-oid 0.9.6", "crc24", - "curve25519-dalek", + "curve25519-dalek 4.1.3", "cx448", "derive_builder", "derive_more 2.1.1", @@ -4621,7 +4671,7 @@ dependencies = [ "dsa", "eax", "ecdsa", - "ed25519-dalek", + "ed25519-dalek 2.2.0", "elliptic-curve", "flate2", "generic-array", @@ -4648,11 +4698,11 @@ dependencies = [ "sha1-checked", "sha2 0.10.9", "sha3", - "signature", + "signature 2.2.0", "smallvec", "snafu", "twofish", - "x25519-dalek", + "x25519-dalek 2.0.1", "zeroize", ] @@ -5548,7 +5598,7 @@ dependencies = [ "pkcs8", "rand_core 0.6.4", "sha2 0.10.9", - "signature", + "signature 2.2.0", "spki", "subtle", "zeroize", @@ -5753,7 +5803,7 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" dependencies = [ - "base16ct", + "base16ct 0.2.0", "der", "generic-array", "pkcs8", @@ -6006,7 +6056,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a84f14a19e9a014bb9f4512488d9829a68e04ecabffb0f9904cd1ace94598177" dependencies = [ - "base16ct", + "base16ct 0.2.0", "serde", ] @@ -6016,7 +6066,17 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f42f67da2385b51a5f9652db9c93d78aeaf7610bf5ec366080b6de810604af53" dependencies = [ - "base16ct", + "base16ct 0.2.0", + "serde", +] + +[[package]] +name = "serdect" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e" +dependencies = [ + "base16ct 1.0.0", "serde", ] @@ -6142,6 +6202,15 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "signature" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "simd-adler32" version = "0.3.10" @@ -6492,7 +6561,7 @@ dependencies = [ "rsa", "sec1", "sha2 0.10.9", - "signature", + "signature 2.2.0", "ssh-cipher", "ssh-encoding", "subtle", @@ -8042,12 +8111,24 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" dependencies = [ - "curve25519-dalek", + "curve25519-dalek 4.1.3", "rand_core 0.6.4", "serde", "zeroize", ] +[[package]] +name = "x25519-dalek" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e8131a03190127fb2263afc72b322ecadae46b6ff8c6f399ff5d02f5559af6" +dependencies = [ + "curve25519-dalek 5.0.0", + "getrandom 0.4.3", + "rand_core 0.10.1", + "zeroize", +] + [[package]] name = "x509-parser" version = "0.16.0" diff --git a/Cargo.toml b/Cargo.toml index bb291aa42a..0689da50b2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ edition = "2024" license-file = "LICENSE.md" homepage = "https://defguard.net/" repository = "https://github.com/DefGuard/defguard" -rust-version = "1.91.0" +rust-version = "1.96" [workspace] members = ["crates/*", "tools/*"] @@ -55,8 +55,9 @@ chrono = { version = "0.4", default-features = false, features = [ "serde", ] } claims = "0.8" -clap = { version = "4.5", features = ["derive", "env"] } -ed25519-dalek = { version = "2.2", features = ["rand_core"] } +clap = { version = "4.6", features = ["derive", "env"] } +ed25519-dalek = { version = "3.0", features = ["rand_core", "serde"] } +getrandom = { version = "0.4", features = ["sys_rng"] } futures = "0.3" http = "1.5" hyper-rustls = { version = "0.27", features = ["http2"] } @@ -72,7 +73,6 @@ lettre = { version = "0.11", default-features = false, features = [ "smtp-transport", "tokio1-rustls-tls", ] } -matches = "0.1" md4 = "0.10" openidconnect = { version = "4.0", default-features = false, features = [ "reqwest", @@ -151,7 +151,7 @@ webauthn-rs = { version = "0.5", features = [ "danger-allow-state-serialisation", ] } webauthn-rs-proto = "0.5" -x25519-dalek = { version = "2.0", features = ["static_secrets"] } +x25519-dalek = { version = "3.0", features = ["getrandom", "static_secrets"] } x509-parser = "0.18" [profile.release] diff --git a/crates/defguard_common/Cargo.toml b/crates/defguard_common/Cargo.toml index 382ad46fc4..94fa8b5ff2 100644 --- a/crates/defguard_common/Cargo.toml +++ b/crates/defguard_common/Cargo.toml @@ -18,7 +18,8 @@ base64.workspace = true chrono.workspace = true claims.workspace = true clap.workspace = true -ed25519-dalek = { version = "2.2", features = ["rand_core"] } +ed25519-dalek.workspace = true +getrandom.workspace = true humantime.workspace = true ipnetwork.workspace = true jsonwebtoken.workspace = true @@ -51,7 +52,6 @@ url = "2.5" test-support = ["tokio/net", "tokio/io-util"] [dev-dependencies] -matches.workspace = true [build-dependencies] vergen-git2 = "10.0" diff --git a/crates/defguard_common/src/db/models/biometric_auth.rs b/crates/defguard_common/src/db/models/biometric_auth.rs index 3fb8642a4c..c685de336c 100644 --- a/crates/defguard_common/src/db/models/biometric_auth.rs +++ b/crates/defguard_common/src/db/models/biometric_auth.rs @@ -1,15 +1,12 @@ -use base64::{Engine, engine::general_purpose, prelude::BASE64_STANDARD}; -use ed25519_dalek::{Signature, Verifier, VerifyingKey}; +use base64::{Engine, engine::general_purpose::STANDARD}; +use ed25519_dalek::{PUBLIC_KEY_LENGTH, SIGNATURE_LENGTH, Signature, Verifier, VerifyingKey}; use model_derive::Model; use serde::{Deserialize, Serialize}; -use sqlx::{PgExecutor, query, query_as}; +use sqlx::{PgExecutor, query, query_as, query_scalar}; use thiserror::Error; use crate::{ - db::{ - Id, NoId, - models::device::{Device, DeviceType}, - }, + db::{Id, NoId}, random::gen_alphanumeric, }; @@ -27,17 +24,15 @@ pub enum BiometricAuthError { ChallengeNotOwned, } -impl From for tonic::Status { - fn from(value: BiometricAuthError) -> Self { - Self::invalid_argument(value.to_string()) - } -} +type PubKeyBytes = [u8; PUBLIC_KEY_LENGTH]; +type SignatureBytes = [u8; SIGNATURE_LENGTH]; -#[derive(Model, Clone)] +#[derive(Model)] #[table(biometric_auth)] pub struct BiometricAuth { - pub id: I, - pub pub_key: String, + id: I, + /// `ed25519_dalek::VerifyingKey` encoded in base64. + pub_key: String, pub device_id: Id, } @@ -50,45 +45,39 @@ impl BiometricAuth { pub_key, } } - - pub fn validate_pubkey(pub_key: &str) -> Result<(), BiometricAuthError> { - let decoded = BASE64_STANDARD.decode(pub_key)?; - if decoded.len() != ed25519_dalek::PUBLIC_KEY_LENGTH { - return Err(BiometricAuthError::InvalidPublicKey); - } - Ok(()) - } } impl BiometricAuth { + #[must_use] + pub fn pub_key(&self) -> &str { + self.pub_key.as_str() + } + pub async fn find_by_device_id<'e, E>(executor: E, device_id: Id) -> sqlx::Result> where E: PgExecutor<'e>, { query_as!( Self, - "SELECT id, pub_key, device_id FROM biometric_auth WHERE device_id=$1", + "SELECT id, pub_key, device_id FROM biometric_auth WHERE device_id = $1", &device_id ) .fetch_optional(executor) .await } - /// Returns the device owning the given biometric auth public key, scoped to + /// Returns the name of device owning the given biometric auth public key, scoped to /// the provided user. `None` if no such device exists. - pub async fn find_device<'e, E>( + pub async fn find_device_name<'e, E>( executor: E, user_id: Id, pub_key: &str, - ) -> sqlx::Result>> + ) -> sqlx::Result> where E: PgExecutor<'e>, { - query_as!( - Device, - "SELECT d.id, d.name, d.wireguard_pubkey, d.user_id, d.created, d.description, \ - d.device_type \"device_type: DeviceType\", d.configured \ - FROM biometric_auth as b JOIN device d ON b.device_id = d.id \ + query_scalar!( + "SELECT d.name FROM biometric_auth b JOIN device d ON b.device_id = d.id \ WHERE d.user_id = $1 AND b.pub_key = $2", user_id, pub_key @@ -97,12 +86,14 @@ impl BiometricAuth { .await } + // FIXME: this method is probably superfluous: already covered by `find_device_name()`. pub async fn verify_owner<'e, E>(executor: E, user_id: Id, pub_key: &str) -> sqlx::Result where E: PgExecutor<'e>, { let q_result = query!( - "SELECT b.id FROM biometric_auth as b JOIN device d ON b.device_id = d.id WHERE d.user_id = $1 AND b.pub_key = $2", + "SELECT b.id FROM biometric_auth as b JOIN device d ON b.device_id = d.id \ + WHERE d.user_id = $1 AND b.pub_key = $2", user_id, pub_key ) @@ -117,7 +108,9 @@ impl BiometricAuth { { query_as!( Self, - "SELECT b.id, b.pub_key, b.device_id FROM biometric_auth as b JOIN device d ON b.device_id = d.id WHERE d.user_id = $1", &user_id + "SELECT b.id, b.pub_key, b.device_id FROM biometric_auth b \ + JOIN device d ON b.device_id = d.id WHERE d.user_id = $1", + &user_id ) .fetch_all(executor) .await @@ -126,19 +119,18 @@ impl BiometricAuth { #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] pub struct BiometricChallenge { - pub auth_pub_key: Option, + pub auth_pub_key: Option, pub challenge: String, } fn decode_pub_key(public_key: &str) -> Result { - let pub_bytes: [u8; ed25519_dalek::PUBLIC_KEY_LENGTH] = general_purpose::STANDARD + let pub_bytes: PubKeyBytes = STANDARD .decode(public_key) .map_err(|_| BiometricAuthError::InvalidPublicKey)? .try_into() .map_err(|_| BiometricAuthError::InvalidPublicKey)?; - let verifying_key = - VerifyingKey::from_bytes(&pub_bytes).map_err(|_| BiometricAuthError::InvalidPublicKey)?; - Ok(verifying_key) + + VerifyingKey::from_bytes(&pub_bytes).map_err(|_| BiometricAuthError::InvalidPublicKey) } impl Default for BiometricChallenge { @@ -148,10 +140,10 @@ impl Default for BiometricChallenge { } impl BiometricChallenge { - pub fn new_with_owner(pub_key: &str) -> Result { - let _ = decode_pub_key(pub_key)?; + pub fn with_pubkey(pub_key: &str) -> Result { + let verifying_key = decode_pub_key(pub_key)?; let mut res = Self::new(); - res.auth_pub_key = Some(pub_key.to_owned()); + res.auth_pub_key = Some(verifying_key); Ok(res) } @@ -170,10 +162,11 @@ impl BiometricChallenge { owner: Option, ) -> Result<(), BiometricAuthError> { if let Some(auth_pub_key) = owner { - return verify(signed_challenge, auth_pub_key.as_str(), &self.challenge); + let verifying_key = decode_pub_key(auth_pub_key.as_str())?; + return verify(signed_challenge, &verifying_key, &self.challenge); } - if let Some(auth_pub_key) = &self.auth_pub_key { - return verify(signed_challenge, auth_pub_key.as_str(), &self.challenge); + if let Some(verifying_key) = &self.auth_pub_key { + return verify(signed_challenge, verifying_key, &self.challenge); } Err(BiometricAuthError::ChallengeNotOwned) } @@ -181,11 +174,10 @@ impl BiometricChallenge { fn verify( signature: &str, - public_key: &str, + verifying_key: &VerifyingKey, original_challenge: &str, ) -> Result<(), BiometricAuthError> { - let verifying_key = decode_pub_key(public_key)?; - let sig_bytes: [u8; ed25519_dalek::SIGNATURE_LENGTH] = general_purpose::STANDARD + let sig_bytes: SignatureBytes = STANDARD .decode(signature) .map_err(|_| BiometricAuthError::InvalidSignature)? .try_into() @@ -198,53 +190,49 @@ fn verify( #[cfg(test)] mod test { - use base64::engine::general_purpose; - use ed25519_dalek::Signer; - use matches::assert_matches; + use std::assert_matches; + + use ed25519_dalek::{Signer, SigningKey}; + use getrandom::{SysRng, rand_core::UnwrapErr}; use super::*; + const TEST_CHALLENGE: &str = "test-challenge"; + #[test] fn test_verify_valid_sig() { - let mut csprng = rand::rngs::OsRng; - let signing_key = ed25519_dalek::SigningKey::generate(&mut csprng); - let challenge = "test-challenge"; - let signed = signing_key.sign(challenge.as_bytes()); - let serialized_signature = BASE64_STANDARD.encode(signed.to_bytes()); - let serialized_pub_key = BASE64_STANDARD.encode(signing_key.verifying_key().as_bytes()); - - assert_matches!( - verify(&serialized_signature, &serialized_pub_key, challenge), - Ok(()) + let mut csprng = UnwrapErr(SysRng); + let signing_key = SigningKey::generate(&mut csprng); + let signed = signing_key.sign(TEST_CHALLENGE.as_bytes()); + let serialized_signature = STANDARD.encode(signed.to_bytes()); + assert!( + verify( + &serialized_signature, + &signing_key.verifying_key(), + TEST_CHALLENGE + ) + .is_ok() ); } #[test] fn test_verify_invalid_signature() { - let mut csprng = rand::rngs::OsRng; - let signing_key = ed25519_dalek::SigningKey::generate(&mut csprng); - let challenge = "test-challenge"; - - let bad_signature = [0u8; ed25519_dalek::SIGNATURE_LENGTH]; - let signature_b64 = general_purpose::STANDARD.encode(bad_signature); - let public_key_b64 = - general_purpose::STANDARD.encode(signing_key.verifying_key().as_bytes()); - - let result = verify(&signature_b64, &public_key_b64, challenge); + let mut csprng = UnwrapErr(SysRng); + let signing_key = SigningKey::generate(&mut csprng); + let bad_signature = [0u8; SIGNATURE_LENGTH]; + let signature_b64 = STANDARD.encode(bad_signature); + let result = verify(&signature_b64, &signing_key.verifying_key(), TEST_CHALLENGE); assert_matches!(result, Err(BiometricAuthError::InvalidSignature)); } #[test] fn test_verify_invalid_public_key() { - let challenge = "test-challenge"; - let signature = [0u8; ed25519_dalek::SIGNATURE_LENGTH]; - let signature_b64 = general_purpose::STANDARD.encode(signature); - - let bad_pub_key = general_purpose::STANDARD.encode([1, 2, 3]); - - let result = verify(&signature_b64, &bad_pub_key, challenge); + let bad_pub_key = STANDARD.encode([1, 2, 3]); - assert_matches!(result, Err(BiometricAuthError::InvalidPublicKey)); + assert_matches!( + decode_pub_key(bad_pub_key.as_str()), + Err(BiometricAuthError::InvalidPublicKey) + ); } } diff --git a/crates/defguard_common/src/db/models/settings/mod.rs b/crates/defguard_common/src/db/models/settings/mod.rs index 4269b8d449..a92070079b 100644 --- a/crates/defguard_common/src/db/models/settings/mod.rs +++ b/crates/defguard_common/src/db/models/settings/mod.rs @@ -1171,23 +1171,17 @@ mod test { config.secret_key = Some(SecretString::from("a".repeat(64))); config.enrollment_url = Some(Url::parse("https://proxy.example.com").unwrap()); config.mfa_code_timeout = Some(Duration::from(std::time::Duration::from_secs(75))); - config.session_timeout = Some(Duration::from(std::time::Duration::from_secs( - 10 * 24 * 3600, - ))); + config.session_timeout = Some(Duration::from(std::time::Duration::from_hours(240))); config.disable_stats_purge = Some(true); - config.stats_purge_frequency = - Some(Duration::from(std::time::Duration::from_secs(5 * 3600))); - config.stats_purge_threshold = Some(Duration::from(std::time::Duration::from_secs( - 12 * 24 * 3600, - ))); - config.enrollment_token_timeout = - Some(Duration::from(std::time::Duration::from_secs(7 * 3600))); + config.stats_purge_frequency = Some(Duration::from(std::time::Duration::from_hours(5))); + config.stats_purge_threshold = Some(Duration::from(std::time::Duration::from_hours(288))); + config.enrollment_token_timeout = Some(Duration::from(std::time::Duration::from_hours(7))); config.password_reset_token_timeout = - Some(Duration::from(std::time::Duration::from_secs(9 * 3600))); + Some(Duration::from(std::time::Duration::from_hours(9))); config.enrollment_session_timeout = - Some(Duration::from(std::time::Duration::from_secs(15 * 60))); + Some(Duration::from(std::time::Duration::from_mins(15))); config.password_reset_session_timeout = - Some(Duration::from(std::time::Duration::from_secs(20 * 60))); + Some(Duration::from(std::time::Duration::from_mins(20))); settings.apply_from_config(&config); @@ -1538,9 +1532,7 @@ mod test { let mut config = DefGuardConfig::new_test_config(); config.mfa_code_timeout = Some(Duration::from(std::time::Duration::from_secs(90))); - config.session_timeout = Some(Duration::from(std::time::Duration::from_secs( - 2 * 24 * 3600, - ))); + config.session_timeout = Some(Duration::from(std::time::Duration::from_hours(48))); config.disable_stats_purge = Some(true); settings.update_from_config(&pool, &config).await.unwrap(); diff --git a/crates/defguard_common/src/db/models/user.rs b/crates/defguard_common/src/db/models/user.rs index 5cefb00459..fc1735ad4c 100644 --- a/crates/defguard_common/src/db/models/user.rs +++ b/crates/defguard_common/src/db/models/user.rs @@ -58,6 +58,7 @@ pub enum MFAMethod { OneTimePassword, Webauthn, Email, + Fido2, } // Web MFA methods @@ -68,6 +69,7 @@ impl fmt::Display for MFAMethod { Self::OneTimePassword => "TOTP", Self::Webauthn => "WebAuthn", Self::Email => "Email", + Self::Fido2 => "FIDO2", }) } } diff --git a/crates/defguard_common/src/db/models/vpn_client_session.rs b/crates/defguard_common/src/db/models/vpn_client_session.rs index 4c5968b52a..d431a53dba 100644 --- a/crates/defguard_common/src/db/models/vpn_client_session.rs +++ b/crates/defguard_common/src/db/models/vpn_client_session.rs @@ -8,7 +8,7 @@ use crate::db::{ Id, NoId, models::{ WireguardNetwork, biometric_auth::BiometricAuth, user::User, - vpn_session_stats::VpnSessionStats, + vpn_session_stats::VpnSessionStats, webauthn::WebAuthn, }, }; @@ -30,6 +30,7 @@ pub enum VpnClientMfaMethod { Oidc, Biometric, MobileApprove, + Fido2, } impl VpnClientMfaMethod { @@ -65,6 +66,7 @@ impl VpnClientMfaMethod { Self::MobileApprove => !BiometricAuth::find_by_user_id(executor, user.id) .await? .is_empty(), + Self::Fido2 => WebAuthn::exists_for_user(executor, user.id).await?, }; Ok(configured) } @@ -229,8 +231,8 @@ mod tests { use super::VpnClientMfaMethod; use crate::db::{ - Id, - models::{Device, DeviceType, User, biometric_auth::BiometricAuth}, + Id, NoId, + models::{Device, DeviceType, User, biometric_auth::BiometricAuth, webauthn::WebAuthn}, setup_pool, }; @@ -299,6 +301,12 @@ mod tests { .await .unwrap() ); + assert!( + !VpnClientMfaMethod::Fido2 + .is_configured(&pool, &user, device.id, false, false) + .await + .unwrap() + ); // TOTP: set up -> configured (no deployment gate). user.totp_enabled = true; @@ -368,5 +376,23 @@ mod tests { .await .unwrap() ); + + // FIDO2: the user has a registered security key -> configured. The passkey blob is never + // deserialized by the predicate, which only asks whether a key row exists. + WebAuthn { + id: NoId, + user_id: user.id, + name: "security-key".to_owned(), + passkey: Vec::new(), + } + .save(&pool) + .await + .expect("failed to save security key"); + assert!( + VpnClientMfaMethod::Fido2 + .is_configured(&pool, &user, device.id, false, false) + .await + .unwrap() + ); } } diff --git a/crates/defguard_common/src/db/models/webauthn.rs b/crates/defguard_common/src/db/models/webauthn.rs index ca17beb767..910bf1aa49 100644 --- a/crates/defguard_common/src/db/models/webauthn.rs +++ b/crates/defguard_common/src/db/models/webauthn.rs @@ -49,6 +49,20 @@ impl WebAuthn { }) } + /// Check whether a user has at least one security key registered. + pub async fn exists_for_user<'e, E>(executor: E, user_id: Id) -> sqlx::Result + where + E: PgExecutor<'e>, + { + query_scalar!( + "SELECT EXISTS(SELECT 1 FROM webauthn WHERE user_id = $1)", + user_id + ) + .fetch_one(executor) + .await + .map(Option::unwrap_or_default) + } + /// Fetch all for a given user. pub async fn all_for_user(pool: &PgPool, user_id: Id) -> sqlx::Result> { query_as!( diff --git a/crates/defguard_common/src/db/models/wireguard.rs b/crates/defguard_common/src/db/models/wireguard.rs index 904cba1d37..f352d8c58f 100644 --- a/crates/defguard_common/src/db/models/wireguard.rs +++ b/crates/defguard_common/src/db/models/wireguard.rs @@ -9,7 +9,6 @@ use base64::prelude::{BASE64_STANDARD, Engine}; use chrono::{NaiveDateTime, TimeDelta, Utc}; use ipnetwork::{IpNetwork, IpNetworkError, NetworkSize}; use model_derive::Model; -use rand::rngs::OsRng; use serde::{Deserialize, Serialize}; use sqlx::{FromRow, PgConnection, PgExecutor, PgPool, Type, query, query_as, query_scalar}; use thiserror::Error; @@ -230,7 +229,7 @@ impl WireguardNetwork { where V: Into>, { - let prvkey = StaticSecret::random_from_rng(OsRng); + let prvkey = StaticSecret::random(); let pubkey = PublicKey::from(&prvkey); Self { id: NoId, @@ -443,7 +442,7 @@ impl WireguardNetwork { /// Utility method to create WireGuard keypair #[must_use] pub fn genkey() -> WireguardKey { - let private = StaticSecret::random_from_rng(OsRng); + let private = StaticSecret::random(); let public = PublicKey::from(&private); WireguardKey { private: BASE64_STANDARD.encode(private.to_bytes()), diff --git a/crates/defguard_common/src/db/models/wireguard/tests.rs b/crates/defguard_common/src/db/models/wireguard/tests.rs index 3244efd0d5..25771b0017 100644 --- a/crates/defguard_common/src/db/models/wireguard/tests.rs +++ b/crates/defguard_common/src/db/models/wireguard/tests.rs @@ -1,6 +1,5 @@ -use std::{net::Ipv6Addr, str::FromStr}; +use std::{assert_matches, net::Ipv6Addr, str::FromStr}; -use matches::assert_matches; use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; use super::*; diff --git a/crates/defguard_core/Cargo.toml b/crates/defguard_core/Cargo.toml index e04340545f..0d0e6ac48e 100644 --- a/crates/defguard_core/Cargo.toml +++ b/crates/defguard_core/Cargo.toml @@ -100,7 +100,6 @@ async-stream = "0.3" defguard_common = { workspace = true, features = ["test-support"] } claims.workspace = true hyper-util = "0.1" -matches.workspace = true reqwest = { version = "0.12", features = [ "cookies", "json", diff --git a/crates/defguard_core/src/enterprise/handlers/openid_login.rs b/crates/defguard_core/src/enterprise/handlers/openid_login.rs index ca243a0c85..576fa13f7e 100644 --- a/crates/defguard_core/src/enterprise/handlers/openid_login.rs +++ b/crates/defguard_core/src/enterprise/handlers/openid_login.rs @@ -349,211 +349,205 @@ pub async fn user_from_claims( let sub = token_claims.subject().to_string(); // Handle logging in or creating user. - let user = match User::find_by_sub(pool, &sub) + let user = if let Some(user) = User::find_by_sub(pool, &sub) .await .map_err(|err| WebError::Authorization(err.to_string()))? { - Some(user) => { + debug!( + "User {} is trying to log in using an OpenID provider.", + user.username + ); + // Make sure the user is not disabled + if !user.is_active { + debug!("User {} tried to log in, but is disabled", user.username); + return Err(WebError::Authorization("User is disabled".into())); + } + user + } else { + if resolution == ClaimsUserResolution::LookupOnly { debug!( - "User {} is trying to log in using an OpenID provider.", - user.username + "No user is linked to this provider identity, and this flow does not link \ + accounts" ); - // Make sure the user is not disabled + return Err(WebError::Authorization( + "No account is linked to this OpenID identity".into(), + )); + } + if let Some(mut user) = User::find_by_email(pool, email).await? { if !user.is_active { debug!("User {} tried to log in, but is disabled", user.username); return Err(WebError::Authorization("User is disabled".into())); } + // User with the same email already exists, merge the accounts. + info!( + "User with email address {} is logging in through OpenID Connect for the \ + first time and we've found an existing account with the same email \ + address. Merging accounts.", + user.email + ); + user.openid_sub = Some(sub); + user.save(pool).await?; user - } - None => { - if resolution == ClaimsUserResolution::LookupOnly { - debug!( - "No user is linked to this provider identity, and this flow does not link \ - accounts" + } else { + let settings = Settings::get_current_settings(); + // Check if the user should be created, if doesn't exist (default: true). + if !settings.openid_create_account { + warn!( + "User with email address {} is trying to log in through OpenID Connect \ + for the first time, but the account creation is disabled. An enrollment \ + should be performed.", + email.as_str() ); return Err(WebError::Authorization( - "No account is linked to this OpenID identity".into(), + "User not found and the automatic account creation is disabled. \ + Create the user or make sure they belong to an allowed LDAP synchronization group." + .into(), )); } - if let Some(mut user) = User::find_by_email(pool, email).await? { - if !user.is_active { - debug!("User {} tried to log in, but is disabled", user.username); - return Err(WebError::Authorization("User is disabled".into())); - } - // User with the same email already exists, merge the accounts. - info!( - "User with email address {} is logging in through OpenID Connect for the \ - first time and we've found an existing account with the same email \ - address. Merging accounts.", - user.email - ); - user.openid_sub = Some(sub); - user.save(pool).await?; - user - } else { - let settings = Settings::get_current_settings(); - // Check if the user should be created, if doesn't exist (default: true). - if !settings.openid_create_account { - warn!( - "User with email address {} is trying to log in through OpenID Connect \ - for the first time, but the account creation is disabled. An enrollment \ - should be performed.", - email.as_str() - ); - return Err(WebError::Authorization( - "User not found and the automatic account creation is disabled. \ - Create the user or make sure they belong to an allowed LDAP synchronization group." - .into(), - )); - } - // If user synchronization is enabled and limited to specific directory groups, only allow - // creating accounts for users who are members of one of those groups. - if provider.directory_sync_enabled - && let Some(user_groups_filter) = provider - .directory_sync_user_groups - .as_ref() - .filter(|groups| !groups.is_empty()) - { - let in_groups = user_in_directory_groups(pool, email, user_groups_filter) - .await - .map_err(|err| { - error!( - "Failed to check directory group membership of user with email address {} during OpenID account creation: {err}", - email.as_str() - ); - WebError::Authorization( - "Failed to verify user's directory group membership".into(), - ) - })?; - if !in_groups { - warn!( - "User with email address {} is trying to log in for the first time but is not a member of any - directory groups configured for user synchronization. Blocking account creation.", + // If user synchronization is enabled and limited to specific directory groups, only allow + // creating accounts for users who are members of one of those groups. + if provider.directory_sync_enabled + && let Some(user_groups_filter) = provider + .directory_sync_user_groups + .as_ref() + .filter(|groups| !groups.is_empty()) + { + let in_groups = user_in_directory_groups(pool, email, user_groups_filter) + .await + .map_err(|err| { + error!( + "Failed to check directory group membership of user with email address {} during OpenID account creation: {err}", email.as_str() ); - return Err(WebError::UserGroupsNotSynced( - "User is not a member of any of the directory groups allowed for account creation".into(), - )); - } + WebError::Authorization( + "Failed to verify user's directory group membership".into(), + ) + })?; + if !in_groups { + warn!( + "User with email address {} is trying to log in for the first time but is not a member of any + directory groups configured for user synchronization. Blocking account creation.", + email.as_str() + ); + return Err(WebError::UserGroupsNotSynced( + "User is not a member of any of the directory groups allowed for account creation".into(), + )); } + } - // Try to get the username from `preferred_username` claim. - // If it's not there, extract it from email. - let username = if let Some(username) = token_claims.preferred_username() { - let username = username.as_str(); - debug!( - "Preferred username {username} found in the claims. Using the username." - ); - username - } else { - debug!( - "Preferred username not found in the claims, extracting from email address." - ); - // Extract the username from the email address - let username = email.split('@').next().ok_or(WebError::BadRequest( - "Failed to extract username from email address".into(), - ))?; - debug!("Username extracted from email ({email:?}): {username})"); - username - }; - - let username = prune_username(username, settings.openid_username_handling); - // Check if the username is valid just in case, not everything can be handled by the - // pruning. - check_username(&username)?; + // Try to get the username from `preferred_username` claim. + // If it's not there, extract it from email. + let username = if let Some(username) = token_claims.preferred_username() { + let username = username.as_str(); + debug!("Preferred username {username} found in the claims. Using the username."); + username + } else { + debug!( + "Preferred username not found in the claims, extracting from email address." + ); + // Extract the username from the email address + let username = email.split('@').next().ok_or(WebError::BadRequest( + "Failed to extract username from email address".into(), + ))?; + debug!("Username extracted from email ({email:?}): {username})"); + username + }; + + let username = prune_username(username, settings.openid_username_handling); + // Check if the username is valid just in case, not everything can be handled by the + // pruning. + check_username(&username)?; + + info!( + "User {username} is logging in through OpenID Connect for the first time and \ + there is no account with the same email address ({}). Creating a new account.", + email.as_str() + ); + // Check if user with the same username already exists (usernames are unique). + if User::find_by_username(pool, &username).await?.is_some() { + return Err(WebError::Authorization(format!( + "User with username {username} already exists" + ))); + } - info!( - "User {username} is logging in through OpenID Connect for the first time and \ - there is no account with the same email address ({}). Creating a new account.", + if let Some((user_count, limit)) = reached_user_license_limit() { + // Details (username/email/counts) are recorded in the activity + // log and admin notification email, but deliberately not + // returned to the client, which only learns that it should + // contact an administrator. + error!( + "Skipping OpenID account creation for user {username} (email: {}) because \ + license user limit has been reached ({user_count}/{limit})", email.as_str() ); - // Check if user with the same username already exists (usernames are unique). - if User::find_by_username(pool, &username).await?.is_some() { - return Err(WebError::Authorization(format!( - "User with username {username} already exists" - ))); + if let Err(err) = send_user_import_blocked_email(pool).await { + error!( + "Failed to send user import blocked emails for OpenID login attempt: \ + {err}" + ); } - - if let Some((user_count, limit)) = reached_user_license_limit() { - // Details (username/email/counts) are recorded in the activity - // log and admin notification email, but deliberately not - // returned to the client, which only learns that it should - // contact an administrator. + if let Some(event_tx) = event_tx + && let Err(err) = event_tx.send(ApiEvent { + context: ApiRequestContext::new( + None::, + username.clone(), + ip_addr, + user_agent.unwrap_or_default().to_string(), + ), + event: Box::new(ApiEventType::UserImportBlocked { + username: username.clone(), + email: email.as_str().to_string(), + user_count, + limit, + }), + }) + { error!( - "Skipping OpenID account creation for user {username} (email: {}) because \ - license user limit has been reached ({user_count}/{limit})", - email.as_str() + "Failed to emit activity log event for blocked OpenID account \ + creation: {err}" ); - if let Err(err) = send_user_import_blocked_email(pool).await { - error!( - "Failed to send user import blocked emails for OpenID login attempt: \ - {err}" - ); - } - if let Some(event_tx) = event_tx - && let Err(err) = event_tx.send(ApiEvent { - context: ApiRequestContext::new( - None::, - username.clone(), - ip_addr, - user_agent.unwrap_or_default().to_string(), - ), - event: Box::new(ApiEventType::UserImportBlocked { - username: username.clone(), - email: email.as_str().to_string(), - user_count, - limit, - }), - }) - { - error!( - "Failed to emit activity log event for blocked OpenID account \ - creation: {err}" - ); - } - return Err(WebError::LicenseLimitReached( - "Could not log in. Please contact your administrator.".to_string(), - )); } + return Err(WebError::LicenseLimitReached( + "Could not log in. Please contact your administrator.".to_string(), + )); + } - // Extract all necessary information from the token or call the userinfo endpoint. - let given_name = token_claims - .given_name() - // `None` gets the default value from a localized claim. - // Otherwise, it is required to pass a locale. - .and_then(|claim| claim.get(None)); - let family_name = token_claims.family_name().and_then(|claim| claim.get(None)); - let phone = token_claims.phone_number(); - - let userinfo_response: CoreUserInfoClaims; - let (given_name, family_name, phone) = if let ( - Some(given_name), - Some(family_name), - phone, - ) = (given_name, family_name, phone) + // Extract all necessary information from the token or call the userinfo endpoint. + let given_name = token_claims + .given_name() + // `None` gets the default value from a localized claim. + // Otherwise, it is required to pass a locale. + .and_then(|claim| claim.get(None)); + let family_name = token_claims.family_name().and_then(|claim| claim.get(None)); + let phone = token_claims.phone_number(); + + let userinfo_response: CoreUserInfoClaims; + let (given_name, family_name, phone) = + if let (Some(given_name), Some(family_name), phone) = + (given_name, family_name, phone) { debug!("Given name and family name found in the claims for user {username}."); (given_name, family_name, phone) } else { debug!( "Given name or family name not found in the claims for user {username}, \ - trying to get them from the user info endpoint. Current values: \ - given_name: {given_name:?}, family_name: {family_name:?}, phone: {phone:?}" + trying to get them from the user info endpoint. Current values: \ + given_name: {given_name:?}, family_name: {family_name:?}, phone: {phone:?}" ); let async_http_client = get_async_http_client()?; let retrieval_error = "Failed to retrieve given name and family name from \ - provider's userinfo endpoint. Make sure you have configured your provider \ - correctly and that you have granted the necessary permissions to retrieve \ - such information from the token or the userinfo endpoint."; + provider's userinfo endpoint. Make sure you have configured your provider \ + correctly and that you have granted the necessary permissions to retrieve \ + such information from the token or the userinfo endpoint."; userinfo_response = core_client .user_info(access_token.clone(), Some(token_claims.subject().clone())) .map_err(|err| { error!( "Failed to get family name and given name from provider's \ - userinfo endpoint, they may not support this. Error details: {err}" + userinfo endpoint, they may not support this. Error details: {err}" ); WebError::BadRequest(retrieval_error.into()) })? @@ -562,7 +556,7 @@ pub async fn user_from_claims( .map_err(|err| { error!( "Failed to get family name and given name from provider's userinfo \ - endpoint. Error details: {err}", + endpoint. Error details: {err}", ); WebError::BadRequest(retrieval_error.into()) })?; @@ -570,9 +564,9 @@ pub async fn user_from_claims( let claim_error = |claim_name: &str| { format!( "Failed to retrieve {claim_name} from provider's userinfo endpoint and \ - the ID token. Make sure you have configured your provider correctly \ - and that you have granted the necessary permissions to retrieve such \ - information from the token or the userinfo endpoint.", + the ID token. Make sure you have configured your provider correctly \ + and that you have granted the necessary permissions to retrieve such \ + information from the token or the userinfo endpoint.", ) }; let given_name = userinfo_response @@ -587,23 +581,22 @@ pub async fn user_from_claims( debug!( "Given name and family name successfully retrieved from the user info \ - endpoint for user {username}." + endpoint for user {username}." ); (given_name, family_name, phone) }; - let mut user = User::new( - username.clone(), - None, - family_name.to_string(), - given_name.to_string(), - email.to_string(), - phone.map(|v| v.to_string()), - ); - user.openid_sub = Some(sub); - user.save(pool).await? - } + let mut user = User::new( + username.clone(), + None, + family_name.to_string(), + given_name.to_string(), + email.to_string(), + phone.map(|v| v.to_string()), + ); + user.openid_sub = Some(sub); + user.save(pool).await? } }; diff --git a/crates/defguard_core/src/grpc/client_version.rs b/crates/defguard_core/src/grpc/client_version.rs index c0fe89000f..17c1d9e2c0 100644 --- a/crates/defguard_core/src/grpc/client_version.rs +++ b/crates/defguard_core/src/grpc/client_version.rs @@ -172,6 +172,7 @@ impl ClientFeature { /// Returns `true` when a location should be omitted from a device's config because the location's /// MFA configuration has no legacy equivalent and either the device's client version does not /// support multi-step MFA or multi-step MFA is unavailable without an active business license. +#[must_use] pub fn should_omit_location_for_device( location_mfa_mode: Option, device_info: Option<&DeviceInfo>, diff --git a/crates/defguard_core/src/handlers/mfa_flow.rs b/crates/defguard_core/src/handlers/mfa_flow.rs index 31f0faecce..2c6c71bc97 100644 --- a/crates/defguard_core/src/handlers/mfa_flow.rs +++ b/crates/defguard_core/src/handlers/mfa_flow.rs @@ -871,8 +871,8 @@ pub enum MethodAvailabilityReason { /// Compute per-method availability for the MFA flow editor. /// /// Checks license tier, SMTP configuration, and OIDC provider presence to -/// determine which methods are currently usable. All five methods in -/// [`VpnClientMfaMethod`] are always enumerated; unavailable methods carry +/// determine which methods are currently usable. Every method in +/// [`VpnClientMfaMethod`] is always enumerated; unavailable methods carry /// a `reason` that the UI maps to an appropriate CTA. fn compute_method_availability( smtp_configured: bool, @@ -916,6 +916,11 @@ fn compute_method_availability( true, MethodAvailabilityReason::Available, ), + ( + VpnClientMfaMethod::Fido2, + true, + MethodAvailabilityReason::Available, + ), ]; methods diff --git a/crates/defguard_core/src/mfa_engine/authorize.rs b/crates/defguard_core/src/mfa_engine/authorize.rs index c75d4d6306..d01f93754a 100644 --- a/crates/defguard_core/src/mfa_engine/authorize.rs +++ b/crates/defguard_core/src/mfa_engine/authorize.rs @@ -68,6 +68,7 @@ impl EventChannels { } } +#[must_use] pub fn build_authorized_gateway_network_info( network_device: WireguardNetworkDevice, preshared_key: String, diff --git a/crates/defguard_core/src/mfa_engine/method.rs b/crates/defguard_core/src/mfa_engine/method.rs index f113934beb..4f47607f58 100644 --- a/crates/defguard_core/src/mfa_engine/method.rs +++ b/crates/defguard_core/src/mfa_engine/method.rs @@ -80,11 +80,11 @@ pub async fn initiate( .await?; Ok(None) } - VpnClientMfaMethod::Biometric => { + VpnClientMfaMethod::Biometric | VpnClientMfaMethod::Fido2 => { let Some(auth) = BiometricAuth::find_by_device_id(pool, ctx.device.id).await? else { return Err(InitiateError::BiometricNotConfigured); }; - Ok(Some(BiometricChallenge::new_with_owner(&auth.pub_key)?)) + Ok(Some(BiometricChallenge::with_pubkey(auth.pub_key())?)) } VpnClientMfaMethod::MobileApprove => Ok(Some(BiometricChallenge::new())), } @@ -127,7 +127,7 @@ pub async fn verify( }) } } - VpnClientMfaMethod::Biometric => { + VpnClientMfaMethod::Biometric | VpnClientMfaMethod::Fido2 => { let challenge = ephemeral .biometric_challenge .as_ref() @@ -167,6 +167,7 @@ pub async fn verify( message: "Authorization device key missing in request", event: None, })?; + // FIXME: probably not needed if !BiometricAuth::verify_owner(pool, ctx.user.id, auth_device_pub_key).await? { // A signing device not owned by the user is indistinguishable from a wrong // signature, so the "does this pubkey belong to user X" oracle cannot be probed diff --git a/crates/defguard_core/src/mfa_engine/mod.rs b/crates/defguard_core/src/mfa_engine/mod.rs index 24e451450b..6958f642ef 100644 --- a/crates/defguard_core/src/mfa_engine/mod.rs +++ b/crates/defguard_core/src/mfa_engine/mod.rs @@ -450,7 +450,7 @@ impl MfaEngine { FinishError::Internal })?; mobile_auth_device_name = - BiometricAuth::find_device(&self.pool, ctx.user.id, auth_pub_key) + BiometricAuth::find_device_name(&self.pool, ctx.user.id, auth_pub_key) .await .map_err(|err| { error!( @@ -458,8 +458,7 @@ impl MfaEngine { ctx.user.id ); FinishError::Internal - })? - .map(|auth_device| auth_device.name); + })?; } } Ok(Verdict::NotYet) => { @@ -722,11 +721,11 @@ fn log_initiate_error(err: &InitiateError, username: &str) { InitiateError::EmailCode(e) => error!("Failed to generate email MFA code: {e}"), InitiateError::Database(e) => error!("Database error: {e}"), InitiateError::Mail(e) => { - error!("Failed to send email MFA code for user {username}: {e}") + error!("Failed to send email MFA code for user {username}: {e}"); } InitiateError::BiometricNotConfigured => {} InitiateError::InvalidPublicKey(e) => { - error!("Start biometric MFA failed. Challenge creation failed. Reason: {e}") + error!("Start biometric MFA failed. Challenge creation failed. Reason: {e}"); } } } diff --git a/crates/defguard_core/tests/integration/api/activity_log.rs b/crates/defguard_core/tests/integration/api/activity_log.rs index be693cedd8..ae332aaf13 100644 --- a/crates/defguard_core/tests/integration/api/activity_log.rs +++ b/crates/defguard_core/tests/integration/api/activity_log.rs @@ -400,7 +400,7 @@ async fn test_activity_log_module_sort_is_alphabetical( event: EventType::UserLogout, module, device: "integration-test".to_owned(), - description: Some(marker.to_string()), + description: Some(marker.clone()), metadata: None, } .save(&db) diff --git a/crates/defguard_core/tests/integration/api/mfa_flow.rs b/crates/defguard_core/tests/integration/api/mfa_flow.rs index 7084f8baa4..42310a1b34 100644 --- a/crates/defguard_core/tests/integration/api/mfa_flow.rs +++ b/crates/defguard_core/tests/integration/api/mfa_flow.rs @@ -1,3 +1,5 @@ +use std::assert_matches; + use defguard_common::db::{ models::{ Settings, User, mfa_flow::MfaFlow, settings::update_current_settings, @@ -9,7 +11,6 @@ use defguard_core::{ enterprise::license::{get_cached_license, set_cached_license}, events::ApiEventType, }; -use matches::assert_matches; use reqwest::StatusCode; use serde_json::json; use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; @@ -703,7 +704,7 @@ async fn test_location_mfa_flows_clear_disabled_location( ); } -/// Method availability returns all five methods with correct availability. +/// Method availability returns every method with correct availability. #[sqlx::test] async fn test_method_availability_basic(_: PgPoolOptions, options: PgConnectOptions) { let pool = setup_pool(options).await; @@ -718,7 +719,7 @@ async fn test_method_availability_basic(_: PgPoolOptions, options: PgConnectOpti assert_eq!(response.status(), StatusCode::OK); let items = response.json::().await; let items = items.as_array().unwrap(); - assert_eq!(items.len(), 5); + assert_eq!(items.len(), 6); let find = |method: &str| -> &serde_json::Value { items @@ -740,6 +741,7 @@ async fn test_method_availability_basic(_: PgPoolOptions, options: PgConnectOpti ); assert_eq!(find("biometric")["available"].as_bool(), Some(true)); assert_eq!(find("mobileapprove")["available"].as_bool(), Some(true)); + assert_eq!(find("fido2")["available"].as_bool(), Some(true)); set_cached_license(None); let response = client diff --git a/crates/defguard_core/tests/integration/api/settings.rs b/crates/defguard_core/tests/integration/api/settings.rs index cec5cf5fe1..52d7ba18a7 100644 --- a/crates/defguard_core/tests/integration/api/settings.rs +++ b/crates/defguard_core/tests/integration/api/settings.rs @@ -299,7 +299,7 @@ async fn test_ldap_remote_enrollment_validation(_: PgPoolOptions, options: PgCon // configure LDAP fields (without SMTP) let patch: SettingsPatch = serde_json::from_str(&format!( - r#"{{ {VALID_LDAP_FIELDS_NO_URL}, {VALID_LDAP_URL} }}"# + r"{{ {VALID_LDAP_FIELDS_NO_URL}, {VALID_LDAP_URL} }}" )) .unwrap(); let response = client.patch("/api/v1/settings").json(&patch).send().await; diff --git a/crates/defguard_core/tests/integration/api/user.rs b/crates/defguard_core/tests/integration/api/user.rs index 8b74bdab2a..128d7c8ef5 100644 --- a/crates/defguard_core/tests/integration/api/user.rs +++ b/crates/defguard_core/tests/integration/api/user.rs @@ -328,7 +328,7 @@ async fn test_list_users_group_filter(_: PgPoolOptions, options: PgConnectOption .iter() .map(|u| u["username"].as_str().unwrap()) .collect(); - usernames.sort(); + usernames.sort_unstable(); assert_eq!(usernames, vec!["admin", "hpotter"]); assert_eq!(body["pagination"]["total_items"].as_u64().unwrap(), 2); @@ -396,7 +396,7 @@ async fn test_list_users_no_group_filter(_: PgPoolOptions, options: PgConnectOpt .iter() .map(|u| u["username"].as_str().unwrap()) .collect(); - usernames.sort(); + usernames.sort_unstable(); assert_eq!(usernames, vec!["admin", "hpotter"]); assert_eq!(body["pagination"]["total_items"].as_u64().unwrap(), 2); @@ -451,7 +451,7 @@ async fn test_list_users_no_group_multi_group_filter(_: PgPoolOptions, options: .iter() .map(|u| u["username"].as_str().unwrap()) .collect(); - usernames.sort(); + usernames.sort_unstable(); assert_eq!(usernames, vec!["admin", "hpotter", "rweasley"]); assert_eq!(body["pagination"]["total_items"].as_u64().unwrap(), 3); diff --git a/crates/defguard_core/tests/integration/api/wireguard.rs b/crates/defguard_core/tests/integration/api/wireguard.rs index 61d1ae59aa..464b729dff 100644 --- a/crates/defguard_core/tests/integration/api/wireguard.rs +++ b/crates/defguard_core/tests/integration/api/wireguard.rs @@ -1,4 +1,7 @@ -use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; +use std::{ + assert_matches, + net::{IpAddr, Ipv4Addr, Ipv6Addr}, +}; use defguard_common::db::{ Id, @@ -23,7 +26,6 @@ use defguard_core::{ handlers::{Auth, GroupInfo, wireguard::WireguardNetworkData}, }; use ipnetwork::IpNetwork; -use matches::assert_matches; use reqwest::StatusCode; use serde_json::json; use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; diff --git a/crates/defguard_core/tests/integration/api/wireguard_network_allowed_groups.rs b/crates/defguard_core/tests/integration/api/wireguard_network_allowed_groups.rs index 34e4fe976a..1bcce27f89 100644 --- a/crates/defguard_core/tests/integration/api/wireguard_network_allowed_groups.rs +++ b/crates/defguard_core/tests/integration/api/wireguard_network_allowed_groups.rs @@ -1,4 +1,4 @@ -use std::net::IpAddr; +use std::{assert_matches, net::IpAddr}; use claims::assert_err; use defguard_common::{ @@ -23,7 +23,6 @@ use defguard_core::{ }, location_management::allowed_peers::get_location_allowed_peers, }; -use matches::assert_matches; use reqwest::StatusCode; use serde_json::json; use sqlx::{ diff --git a/crates/defguard_core/tests/integration/api/wireguard_network_devices.rs b/crates/defguard_core/tests/integration/api/wireguard_network_devices.rs index 9125b0a48c..23324d2394 100644 --- a/crates/defguard_core/tests/integration/api/wireguard_network_devices.rs +++ b/crates/defguard_core/tests/integration/api/wireguard_network_devices.rs @@ -1,4 +1,4 @@ -use std::{net::IpAddr, str::FromStr}; +use std::{assert_matches, net::IpAddr, str::FromStr}; use defguard_common::db::{ Id, @@ -9,7 +9,6 @@ use defguard_core::{ handlers::{Auth, network_devices::AddNetworkDevice}, }; use ipnetwork::IpNetwork; -use matches::assert_matches; use reqwest::StatusCode; use serde::Deserialize; use serde_json::{Value, json}; diff --git a/crates/defguard_core/tests/integration/api/wireguard_network_import.rs b/crates/defguard_core/tests/integration/api/wireguard_network_import.rs index 7d04a32e53..9f6221f03e 100644 --- a/crates/defguard_core/tests/integration/api/wireguard_network_import.rs +++ b/crates/defguard_core/tests/integration/api/wireguard_network_import.rs @@ -1,4 +1,4 @@ -use std::net::IpAddr; +use std::{assert_matches, net::IpAddr}; use defguard_common::db::models::{ Device, DeviceType, User, WireguardNetwork, device::UserDevice, wireguard::ServiceLocationMode, @@ -8,7 +8,6 @@ use defguard_core::{ grpc::GatewayCommand, handlers::{Auth, wireguard::ImportedNetworkData}, }; -use matches::assert_matches; use reqwest::StatusCode; use serde_json::json; use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; diff --git a/crates/defguard_core/tests/integration/grpc/common/mod.rs b/crates/defguard_core/tests/integration/grpc/common/mod.rs index 39df3f175c..ff815e675e 100644 --- a/crates/defguard_core/tests/integration/grpc/common/mod.rs +++ b/crates/defguard_core/tests/integration/grpc/common/mod.rs @@ -56,7 +56,7 @@ impl TestGrpcServer { .serve_with_incoming(tokio_stream::once(Ok::<_, std::io::Error>(server_stream))) .await .map_err(|err| eprintln!("Unexpected test gRPC server error: {err}")) - .unwrap() + .unwrap(); }); Self { diff --git a/crates/defguard_event_logger/src/tests/mod.rs b/crates/defguard_event_logger/src/tests/mod.rs index 01d33895e6..e9302fbac8 100644 --- a/crates/defguard_event_logger/src/tests/mod.rs +++ b/crates/defguard_event_logger/src/tests/mod.rs @@ -364,7 +364,7 @@ fn api_event_cases() -> Vec { let webhook = WebHook { id: 1, url: "http://x".into(), - description: "".into(), + description: String::new(), token: "t".into(), enabled: true, on_user_created: false, diff --git a/crates/defguard_proto/src/lib.rs b/crates/defguard_proto/src/lib.rs index a3410a125a..fc346aba08 100644 --- a/crates/defguard_proto/src/lib.rs +++ b/crates/defguard_proto/src/lib.rs @@ -103,6 +103,7 @@ impl fmt::Display for MfaMethod { Self::Oidc => "OIDC", Self::Biometric => "Biometric", Self::MobileApprove => "MobileApprove", + Self::Fido2 => "FIDO2", }) } } @@ -120,6 +121,7 @@ impl Serialize for MfaMethod { Self::MobileApprove => { serializer.serialize_unit_variant("MfaMethod", 4, "MobileApprove") } + Self::Fido2 => serializer.serialize_unit_variant("MfaMethod", 5, "Fido2"), } } } @@ -132,6 +134,7 @@ impl From for VpnClientMfaMethod { MfaMethod::Oidc => Self::Oidc, MfaMethod::Biometric => Self::Biometric, MfaMethod::MobileApprove => Self::MobileApprove, + MfaMethod::Fido2 => Self::Fido2, } } } @@ -144,6 +147,7 @@ impl From for MfaMethod { VpnClientMfaMethod::Oidc => Self::Oidc, VpnClientMfaMethod::Biometric => Self::Biometric, VpnClientMfaMethod::MobileApprove => Self::MobileApprove, + VpnClientMfaMethod::Fido2 => Self::Fido2, } } } diff --git a/crates/defguard_proxy_manager/Cargo.toml b/crates/defguard_proxy_manager/Cargo.toml index 48014275f7..9bf0477d05 100644 --- a/crates/defguard_proxy_manager/Cargo.toml +++ b/crates/defguard_proxy_manager/Cargo.toml @@ -35,6 +35,7 @@ tracing.workspace = true defguard_common = { workspace = true, features = ["test-support"] } base32.workspace = true base64.workspace = true +getrandom.workspace = true ed25519-dalek.workspace = true hyper-util.workspace = true ipnetwork.workspace = true diff --git a/crates/defguard_proxy_manager/src/servers/enrollment.rs b/crates/defguard_proxy_manager/src/servers/enrollment.rs index 29588d015e..1165217d1e 100644 --- a/crates/defguard_proxy_manager/src/servers/enrollment.rs +++ b/crates/defguard_proxy_manager/src/servers/enrollment.rs @@ -347,7 +347,7 @@ impl EnrollmentServer { "Device with given public key doesn't exist", )); }; - BiometricAuth::validate_pubkey(&request.device_pub_key)?; + let mobile_auth = BiometricAuth::new(device.id, request.auth_pub_key); let _ = mobile_auth.save(&self.pool).await.map_err(|err| { error!("Failed to save mobile auth into db: {err}"); diff --git a/crates/defguard_proxy_manager/src/tests/proxy_manager/handler/enrollment.rs b/crates/defguard_proxy_manager/src/tests/proxy_manager/handler/enrollment.rs index 7e9a009ace..f5e32713e9 100644 --- a/crates/defguard_proxy_manager/src/tests/proxy_manager/handler/enrollment.rs +++ b/crates/defguard_proxy_manager/src/tests/proxy_manager/handler/enrollment.rs @@ -722,23 +722,20 @@ async fn test_code_mfa_setup_finish_totp_returns_recovery_codes( let finish_resp = send_code_mfa_setup_finish(&mut context, &token.id, MfaMethod::Totp, &code).await; - match &finish_resp.payload { - Some(core_response::Payload::CodeMfaSetupFinishResponse(r)) => { - assert!( - !r.recovery_codes.is_empty(), - "finish must return at least one recovery code" + if let Some(core_response::Payload::CodeMfaSetupFinishResponse(r)) = &finish_resp.payload { + assert!( + !r.recovery_codes.is_empty(), + "finish must return at least one recovery code" + ); + } else { + // Show the error code if it came back as CoreError. + if let Some(core_response::Payload::CoreError(e)) = &finish_resp.payload { + panic!( + "expected CodeMfaSetupFinishResponse, got CoreError: {:?}", + e.message ); } - _ => { - // Show the error code if it came back as CoreError. - if let Some(core_response::Payload::CoreError(e)) = &finish_resp.payload { - panic!( - "expected CodeMfaSetupFinishResponse, got CoreError: {:?}", - e.message - ); - } - panic!("expected CodeMfaSetupFinishResponse"); - } + panic!("expected CodeMfaSetupFinishResponse"); } // DB: user must now have totp_enabled = true and mfa_enabled = true. @@ -931,7 +928,8 @@ async fn test_register_mobile_auth_happy_path(_: PgPoolOptions, options: PgConne .expect("DB query for BiometricAuth failed") .expect("expected a BiometricAuth row after RegisterMobileAuth"); assert_eq!( - bio_auth.pub_key, VALID_ED25519_PUBKEY_B64, + bio_auth.pub_key(), + VALID_ED25519_PUBKEY_B64, "BiometricAuth.pub_key must equal the submitted auth_pub_key" ); diff --git a/crates/defguard_proxy_manager/src/tests/proxy_manager/handler/polling.rs b/crates/defguard_proxy_manager/src/tests/proxy_manager/handler/polling.rs index 8de15dd370..1d34fdf218 100644 --- a/crates/defguard_proxy_manager/src/tests/proxy_manager/handler/polling.rs +++ b/crates/defguard_proxy_manager/src/tests/proxy_manager/handler/polling.rs @@ -330,13 +330,11 @@ async fn test_polling_reflects_network_changes(_: PgPoolOptions, options: PgConn let first_cfg_count = first_info .device_config .as_ref() - .map(|c| c.configs.len()) - .unwrap_or(0); + .map_or(0, |c| c.configs.len()); let second_cfg_count = second_info .device_config .as_ref() - .map(|c| c.configs.len()) - .unwrap_or(0); + .map_or(0, |c| c.configs.len()); assert!( second_cfg_count > first_cfg_count, diff --git a/crates/defguard_proxy_manager/src/tests/proxy_manager/handler/support.rs b/crates/defguard_proxy_manager/src/tests/proxy_manager/handler/support.rs index 104f44f0fa..0483ace9be 100644 --- a/crates/defguard_proxy_manager/src/tests/proxy_manager/handler/support.rs +++ b/crates/defguard_proxy_manager/src/tests/proxy_manager/handler/support.rs @@ -50,6 +50,7 @@ use defguard_proto::{ }, }; use ed25519_dalek::{Signer, SigningKey}; +use getrandom::{SysRng, rand_core::UnwrapErr}; use ipnetwork::IpNetwork; use sqlx::PgPool; use tokio::{sync::mpsc::UnboundedReceiver, time::timeout}; @@ -797,7 +798,8 @@ pub(crate) async fn send_mfa_step_start( /// Both legacy signature flows verify a challenge against a key the device enrolled up front, so /// a test has to plant one before it can produce a signature the handler will accept. pub(crate) async fn register_biometric_key(pool: &PgPool, device_id: Id) -> SigningKey { - let signing_key = SigningKey::generate(&mut rand::rngs::OsRng); + let mut csprng = UnwrapErr(SysRng); + let signing_key = SigningKey::generate(&mut csprng); let pub_key = BASE64_STANDARD.encode(signing_key.verifying_key().as_bytes()); BiometricAuth::new(device_id, pub_key) .save(pool) diff --git a/crates/defguard_setup/tests/integration/common.rs b/crates/defguard_setup/tests/integration/common.rs index 00553cc8d5..28c816db7c 100644 --- a/crates/defguard_setup/tests/integration/common.rs +++ b/crates/defguard_setup/tests/integration/common.rs @@ -134,7 +134,7 @@ pub async fn init_settings_with_secret_key(pool: &PgPool) { .expect("Failed to initialize settings"); let mut settings = Settings::get_current_settings(); settings.secret_key = Some(TEST_SECRET_KEY.to_owned()); - settings.defguard_url = "http://localhost:8000".to_owned(); + "http://localhost:8000".clone_into(&mut settings.defguard_url); update_current_settings(pool, settings) .await .expect("Failed to update settings"); diff --git a/migrations/20260821083715_[2.2.0]_fido2.down.sql b/migrations/20260821083715_[2.2.0]_fido2.down.sql new file mode 100644 index 0000000000..5ea7314bdb --- /dev/null +++ b/migrations/20260821083715_[2.2.0]_fido2.down.sql @@ -0,0 +1,50 @@ +-- Postgres cannot drop an enum value, so both types are rebuilt and their dependent columns are +-- re-typed through text. + +-- Client MFA method. `mfa_flow_step.methods` is the only column of this type: the authorized +-- session no longer stores a method (see the mfa session store migration), and in-progress +-- sessions keep theirs in `vpn_client_mfa_session.steps_snapshot` as JSON, not as this enum. +-- Those snapshots are dropped rather than rewritten - a downgraded server cannot deserialize a +-- FIDO2 method, and the rows are short-lived by construction (`expires_at`). +DELETE FROM vpn_client_mfa_session +WHERE steps_snapshot::text LIKE '%fido2%'; + +-- A step made up solely of FIDO2 has nothing left once the value is stripped, and an empty +-- `methods` array violates mfa_flow_step_methods_nonempty. Drop those steps before the rewrite. +-- Positions are left as-is: reads are ordered by `position`, so a gap is harmless. +DELETE FROM mfa_flow_step +WHERE methods <@ ARRAY['fido2']::vpn_client_mfa_method[]; + +UPDATE mfa_flow_step +SET methods = array_remove(methods, 'fido2'::vpn_client_mfa_method) +WHERE 'fido2' = ANY (methods); + +CREATE TYPE vpn_client_mfa_method_new AS ENUM ( + 'totp', + 'email', + 'oidc', + 'biometric', + 'mobileapprove' +); + +ALTER TABLE mfa_flow_step + ALTER COLUMN methods TYPE vpn_client_mfa_method_new[] + USING methods::text[]::vpn_client_mfa_method_new[]; + +DROP TYPE vpn_client_mfa_method; +ALTER TYPE vpn_client_mfa_method_new RENAME TO vpn_client_mfa_method; + +-- Web login MFA method. +CREATE TYPE mfa_method_new AS ENUM ( + 'none', + 'one_time_password', + 'webauthn', + 'email' +); +UPDATE "user" SET mfa_method = 'none' WHERE mfa_method = 'fido2'; +ALTER TABLE "user" + ALTER COLUMN mfa_method DROP DEFAULT, + ALTER COLUMN mfa_method TYPE mfa_method_new USING mfa_method::TEXT::mfa_method_new, + ALTER COLUMN mfa_method SET DEFAULT 'none'::mfa_method_new; +DROP TYPE mfa_method; +ALTER TYPE mfa_method_new RENAME TO mfa_method; diff --git a/migrations/20260821083715_[2.2.0]_fido2.up.sql b/migrations/20260821083715_[2.2.0]_fido2.up.sql new file mode 100644 index 0000000000..bcdd2d7f32 --- /dev/null +++ b/migrations/20260821083715_[2.2.0]_fido2.up.sql @@ -0,0 +1,7 @@ +-- Web login MFA method (`MFAMethod`). +ALTER TYPE mfa_method ADD VALUE 'fido2'; + +-- Desktop/mobile client MFA method (`VpnClientMfaMethod`). Backs both +-- `vpn_client_session.mfa_method` and `mfa_flow_step.methods`, so the MFA flow editor cannot +-- store a FIDO2 step without it. +ALTER TYPE vpn_client_mfa_method ADD VALUE 'fido2'; diff --git a/proto b/proto index 735d72260a..326694e734 160000 --- a/proto +++ b/proto @@ -1 +1 @@ -Subproject commit 735d72260ae9d73d5be8a7fe477da8ec7356a681 +Subproject commit 326694e734cb5ca1ceaca342f3a72fb9c7a8b8a0 diff --git a/web/messages/en/mfa_flow.json b/web/messages/en/mfa_flow.json index 71a35a9ff9..a9f2785f06 100644 --- a/web/messages/en/mfa_flow.json +++ b/web/messages/en/mfa_flow.json @@ -39,6 +39,7 @@ "mfa_flow_methods_available_in_plan": "Available in your plan", "mfa_flow_methods_available_in_higher_plans": "Available in higher plans", "mfa_flow_method_biometric": "Biometrics", + "mfa_flow_method_fido2": "FIDO2 Security Key", "mfa_flow_method_mobile_only": "Mobile only", "mfa_flow_form_action_create": "Create MFA flow", "mfa_flow_created": "MFA flow created successfully.", diff --git a/web/src/shared/api/types.ts b/web/src/shared/api/types.ts index 8fe7966567..152c079356 100644 --- a/web/src/shared/api/types.ts +++ b/web/src/shared/api/types.ts @@ -1421,6 +1421,7 @@ export const MfaFlowMethod = { OpenId: 'oidc', Biometric: 'biometric', MobileApprove: 'mobileapprove', + Fido2: 'fido2', } as const; export type MfaFlowMethodValue = (typeof MfaFlowMethod)[keyof typeof MfaFlowMethod]; diff --git a/web/src/shared/components/MfaConfiguration/MfaConfiguration.tsx b/web/src/shared/components/MfaConfiguration/MfaConfiguration.tsx index ce651cfa24..87d3e765a5 100644 --- a/web/src/shared/components/MfaConfiguration/MfaConfiguration.tsx +++ b/web/src/shared/components/MfaConfiguration/MfaConfiguration.tsx @@ -44,6 +44,7 @@ const methodLabels: Record = { [MfaFlowMethod.OpenId]: m.mfa_flow_method_external_provider(), [MfaFlowMethod.Email]: m.mfa_flow_method_email_code(), [MfaFlowMethod.Biometric]: m.mfa_flow_method_biometric(), + [MfaFlowMethod.Fido2]: m.mfa_flow_method_fido2(), }; export const MfaConfiguration = ({ onChange, steps, error }: MfaConfigurationProps) => {