From 05f0a4d77e0d3ea4df35df393471153ba9cb46a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Ciarcin=CC=81ski?= Date: Fri, 14 Aug 2026 13:40:51 +0200 Subject: [PATCH 1/4] Prepare for a new MFA method --- ...bd332de9e8014d6cb1df0ba17fdf6d3a44ef4.json | 3 +- ...361e91fdb169a8f9c2ce7b222abed5970e69b.json | 3 +- ...3d526645f5d0770cabad082d4cb38ceb608b7.json | 75 ------- ...fd22316da6fdc4f7fc52effdac59d745a4c1.json} | 4 +- ...eff81ff2143d5713ce5b5424c388b79086fb3.json | 3 +- ...445bab4d9fb37e419ccabceaf5e39d4801500.json | 23 +++ ...62662fdf15fc1c809d039ea8adccad2c5c2a0.json | 3 +- ...c952892fc5a2d575fbe0b0ec0b01cc64fe37.json} | 4 +- ...68dfad796a246e90d3faac2e136e476314696.json | 23 --- ...174798bc280b6a3c2237d574dba90ecd3342c.json | 3 +- Cargo.lock | 183 +++++++++++++----- Cargo.toml | 10 +- crates/defguard_common/Cargo.toml | 4 +- .../src/db/models/biometric_auth.rs | 157 +++++++-------- .../src/db/models/settings/mod.rs | 24 +-- .../src/db/models/wireguard.rs | 5 +- .../src/db/models/wireguard/tests.rs | 3 +- crates/defguard_core/Cargo.toml | 1 - .../src/grpc/proxy/client_mfa.rs | 71 +++---- .../tests/integration/api/activity_log.rs | 2 +- .../tests/integration/api/mfa_flow.rs | 3 +- .../tests/integration/api/settings.rs | 2 +- .../tests/integration/api/user.rs | 6 +- .../tests/integration/api/wireguard.rs | 6 +- .../api/wireguard_network_allowed_groups.rs | 3 +- .../api/wireguard_network_devices.rs | 3 +- .../api/wireguard_network_import.rs | 3 +- .../tests/integration/grpc/common/mod.rs | 2 +- crates/defguard_event_logger/src/tests/mod.rs | 2 +- crates/defguard_proxy_manager/Cargo.toml | 1 + .../src/servers/enrollment.rs | 2 +- .../tests/proxy_manager/handler/enrollment.rs | 30 ++- .../tests/proxy_manager/handler/polling.rs | 6 +- .../tests/proxy_manager/handler/support.rs | 4 +- .../tests/integration/common.rs | 2 +- 35 files changed, 319 insertions(+), 360 deletions(-) delete mode 100644 .sqlx/query-75816bc9b2928c64f5565ec1f2e3d526645f5d0770cabad082d4cb38ceb608b7.json rename .sqlx/{query-e770f574ecb1c8fc700e610334914e72dbffca5448b0aef3e0b0e3f60af6b5f7.json => query-7beb83800d49617900ed112e7ddafd22316da6fdc4f7fc52effdac59d745a4c1.json} (79%) create mode 100644 .sqlx/query-a10e0a0b0621db79acd4728da49445bab4d9fb37e419ccabceaf5e39d4801500.json rename .sqlx/{query-41d50b33737847c6a7639125b3d04d1d499ee1defd1557b08ec0f48d9bbd1ac3.json => query-eaf01227119c12908f63db143840c952892fc5a2d575fbe0b0ec0b01cc64fe37.json} (84%) delete mode 100644 .sqlx/query-fc63c581cfb32138100410208cb68dfad796a246e90d3faac2e136e476314696.json 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-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-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-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-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-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-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-fc63c581cfb32138100410208cb68dfad796a246e90d3faac2e136e476314696.json b/.sqlx/query-fc63c581cfb32138100410208cb68dfad796a246e90d3faac2e136e476314696.json deleted file mode 100644 index 8024e17b71..0000000000 --- a/.sqlx/query-fc63c581cfb32138100410208cb68dfad796a246e90d3faac2e136e476314696.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "db_name": "PostgreSQL", - "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", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Int8" - } - ], - "parameters": { - "Left": [ - "Int8", - "Text" - ] - }, - "nullable": [ - false - ] - }, - "hash": "fc63c581cfb32138100410208cb68dfad796a246e90d3faac2e136e476314696" -} 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 f5b0e546e5..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" @@ -815,9 +821,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.4.3" +version = "1.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d" +checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" dependencies = [ "find-msvc-tools", "jobserver", @@ -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,11 +2196,27 @@ 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.17.0" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" dependencies = [ "serde", ] @@ -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" @@ -2723,9 +2779,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.16" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9f37a958b41b3b19ee2707c06439c0e9e547e847223eb791ecb0cb821c65e27" +checksum = "839c0e8a181239723652be9062bb56ca5bf5f64011f73b623f6f4fc59086a228" dependencies = [ "atomic-waker", "bytes", @@ -3153,9 +3209,9 @@ checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" [[package]] name = "icu_provider" -version = "2.3.0" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92a7ed671a6aad807a8651a2e1782a6598fda9ce5185dd8158549e95a91c6428" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ "displaydoc", "icu_locale_core", @@ -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", ] @@ -5390,18 +5440,18 @@ dependencies = [ [[package]] name = "ref-cast" -version = "1.0.26" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" dependencies = [ "ref-cast-impl", ] [[package]] name = "ref-cast-impl" -version = "1.0.26" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" dependencies = [ "proc-macro2", "quote", @@ -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" @@ -8205,9 +8286,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.7" +version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94b5c6b5976d66c1d703c4fd17d3f5e43c8cedaacf604961b171adc7130896d8" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" dependencies = [ "yoke", "zerofrom", @@ -8216,9 +8297,9 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.5" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f212a141d820099d57ffafb9569be9617a6f27d3dc881fbee8fb56642f917a9" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", 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..7b759e7cd8 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_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,40 @@ 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,27 +87,15 @@ impl BiometricAuth { .await } - 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", - user_id, - pub_key - ) - .fetch_optional(executor) - .await?; - Ok(q_result.is_some()) - } - pub async fn find_by_user_id<'e, E>(executor: E, user_id: Id) -> sqlx::Result> where E: PgExecutor<'e>, { 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 +104,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 +125,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 +147,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 +159,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 +175,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 bad_pub_key = STANDARD.encode([1, 2, 3]); - let result = verify(&signature_b64, &bad_pub_key, challenge); - - 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/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/grpc/proxy/client_mfa.rs b/crates/defguard_core/src/grpc/proxy/client_mfa.rs index d5251d0722..36ea3b9a62 100644 --- a/crates/defguard_core/src/grpc/proxy/client_mfa.rs +++ b/crates/defguard_core/src/grpc/proxy/client_mfa.rs @@ -359,7 +359,7 @@ impl ClientMfaServer { )); } - let mut selected_mobile_auth: Option> = None; + let mut selected_mobile_auth = None; // check if selected method is configured match selected_method { @@ -447,15 +447,23 @@ impl ClientMfaServer { } } - let biometric_challenge: Option = match selected_method { + // generate auth token + // let token = Self::generate_token(&request.pubkey)?; + + info!( + "Desktop client MFA login started for {} at location {}", + user.username, location.name + ); + + let biometric_challenge = match selected_method { MfaMethod::Biometric => match selected_mobile_auth { Some(mobile_auth) => { - let challenge = BiometricChallenge::new_with_owner(&mobile_auth.pub_key) + let challenge = BiometricChallenge::with_pubkey(mobile_auth.pub_key()) .map_err(|e| { error!( "Start biometric MFA failed. Challenge creation failed. Reason: {e}" ); - Status::invalid_argument("Invalid public key") + Status::invalid_argument("invalid public key") })?; Some(challenge) } @@ -712,7 +720,7 @@ impl ClientMfaServer { BidiRequestContext::new(user.id, user.username.clone(), ip, format!("{device}")); // name of the device used to approve a mobile approve login; populated below - let mut mobile_auth_device_name: Option = None; + let mut mobile_auth_device_name = None; // validate code match method { @@ -729,30 +737,15 @@ impl ClientMfaServer { error!("Authorization device key missing in request"); Status::invalid_argument("Authorization device key missing in request") })?; - if !BiometricAuth::verify_owner(&self.pool, user.id, &auth_device_pub_key) - .await - .map_err(|err| { - error!( - "Failed to verify mobile approve owner for user {}: {err}", - user.id - ); - Status::internal("unexpected error") - })? - { - return Err(Status::invalid_argument("Arguments invalid")); - } - // record the approving device's name for the success activity log event + + // Record the approving device's name for the success activity log event. mobile_auth_device_name = - BiometricAuth::find_device(&self.pool, user.id, &auth_device_pub_key) + BiometricAuth::find_device_name(&self.pool, user.id, &auth_device_pub_key) .await - .map_err(|err| { - error!( - "Failed to find mobile approve device for user {}: {err}", - user.id - ); - Status::internal("unexpected error") - })? - .map(|auth_device| auth_device.name); + .map_err(|_| Status::internal("unexpected error"))?; + if mobile_auth_device_name.is_none() { + return Err(Status::invalid_argument("Arguments invalid")); + } match challenge.verify(signature.as_str(), Some(auth_device_pub_key)) { Ok(()) => { debug!("Signature verified successfully."); @@ -779,19 +772,17 @@ impl ClientMfaServer { } } MfaMethod::Biometric => { - let challenge = biometric_challenge.as_ref().ok_or_else(|| { + let Some(challenge) = biometric_challenge else { error!("Challenge not found in MFA session !"); - Status::internal("Challenge not found in MFA session") - })?; - let signed_challenge = request.code.ok_or_else(|| { + return Err(Status::internal("Challenge not found in MFA session")); + }; + let Some(signed_challenge) = request.code else { error!("Signed challenge not found in request"); - Status::invalid_argument("Challenge not found in request") - })?; + return Err(Status::invalid_argument("Challenge not found in request")); + }; match challenge.verify(signed_challenge.as_str(), None) { // verification passed - Ok(()) => { - debug!("Signature verified successfully."); - } + Ok(()) => debug!("Signature verified successfully."), // challenge rejected Err(e) => { error!( @@ -815,9 +806,7 @@ impl ClientMfaServer { } } MfaMethod::Totp => { - let code = if let Some(code) = request.code { - code.clone() - } else { + let Some(code) = request.code else { error!("TOTP code not provided in request"); self.emit_event(BidiStreamEvent { context, @@ -850,9 +839,7 @@ impl ClientMfaServer { } } MfaMethod::Email => { - let code = if let Some(code) = request.code { - code.clone() - } else { + let Some(code) = request.code else { error!("Email MFA code not provided in request"); self.emit_event(BidiStreamEvent { context, 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..f56bd00036 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}; 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_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 b6ebb922be..f40ad76c5f 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 0877c3a32b..02a672a949 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}; @@ -717,7 +718,8 @@ pub(crate) async fn send_mfa_start_with_challenge( /// 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"); From dd71e47edf0e1ee053889985f14a3108f431fc36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Ciarcin=CC=81ski?= Date: Mon, 17 Aug 2026 11:36:32 +0200 Subject: [PATCH 2/4] More FIDO2 --- ...9ea500b1eb42a8f525d44238f61e696078cc3.json | 22 ++ crates/defguard_common/src/db/models/user.rs | 2 + .../src/db/models/vpn_client_session.rs | 37 ++- .../defguard_common/src/db/models/webauthn.rs | 14 + .../src/grpc/proxy/client_mfa.rs | 20 ++ crates/defguard_core/src/handlers/mfa_flow.rs | 9 +- .../tests/integration/api/mfa_flow.rs | 5 +- crates/defguard_proto/src/lib.rs | 4 + .../20260821083715_[2.2.0]_fido2.down.sql | 13 + .../20260821083715_[2.2.0]_fido2.up.sql | 1 + .../LocationMfaConfiguration.tsx | 239 ++++++++++++++++++ .../LocationMfaConfiguration/types.ts | 50 ++++ 12 files changed, 407 insertions(+), 9 deletions(-) create mode 100644 .sqlx/query-a185fa490bd1255a6b45cdb25ac9ea500b1eb42a8f525d44238f61e696078cc3.json create mode 100644 migrations/20260821083715_[2.2.0]_fido2.down.sql create mode 100644 migrations/20260821083715_[2.2.0]_fido2.up.sql create mode 100644 web/src/shared/components/LocationMfaConfiguration/LocationMfaConfiguration.tsx create mode 100644 web/src/shared/components/LocationMfaConfiguration/types.ts 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/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 de210b1a1f..9b3ab63f29 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 { @@ -41,8 +42,9 @@ impl VpnClientMfaMethod { /// including license tier and provider presence), so this predicate stays model-level. /// /// Per-user setup reads `User::totp_enabled` (TOTP), `User::email_mfa_enabled` (email), - /// `User::openid_sub` (OIDC identity), and the `biometric_auth` table - keyed on the device - /// for biometric and on any of the user's devices for mobile-approve. + /// `User::openid_sub` (OIDC identity), the `webauthn` table (FIDO2 security keys), and the + /// `biometric_auth` table - keyed on the device for biometric and on any of the user's devices + /// for mobile-approve. pub async fn is_configured<'e, E: PgExecutor<'e>>( self, executor: E, @@ -61,6 +63,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) } @@ -225,8 +228,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, }; @@ -295,6 +298,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; @@ -364,5 +373,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_core/src/grpc/proxy/client_mfa.rs b/crates/defguard_core/src/grpc/proxy/client_mfa.rs index 36ea3b9a62..f57d4383fc 100644 --- a/crates/defguard_core/src/grpc/proxy/client_mfa.rs +++ b/crates/defguard_core/src/grpc/proxy/client_mfa.rs @@ -363,6 +363,20 @@ impl ClientMfaServer { // check if selected method is configured match selected_method { + // FIDO2 has no single-step counterpart: the assertion exchange needs the multi-step + // MFA flow, which this legacy fused path does not implement. A location whose flow + // contains FIDO2 is not legacy-representable, so `derive_legacy_mode` above already + // refuses it - this arm only catches a client that names the method outright. + MfaMethod::Fido2 => { + error!( + "FIDO2 MFA is not supported on the legacy single-step client login path \ + (user {})", + user.username + ); + return Err(Status::invalid_argument( + "selected MFA method is not available", + )); + } MfaMethod::Biometric => { if let Some(found) = BiometricAuth::find_by_device_id(&self.pool, device.id) .await @@ -724,6 +738,12 @@ impl ClientMfaServer { // validate code match method { + // Unreachable in practice: `handle_client_mfa_start` refuses FIDO2 before a session is + // stored, so no session can carry this method. Fail closed rather than fall through. + MfaMethod::Fido2 => { + error!("FIDO2 MFA is not supported on the legacy single-step client login path"); + return Err(Status::unauthenticated("unauthorized")); + } MfaMethod::MobileApprove => { let challenge = biometric_challenge.as_ref().ok_or_else(|| { error!("Challenge not found in MFA session."); 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/tests/integration/api/mfa_flow.rs b/crates/defguard_core/tests/integration/api/mfa_flow.rs index f56bd00036..42310a1b34 100644 --- a/crates/defguard_core/tests/integration/api/mfa_flow.rs +++ b/crates/defguard_core/tests/integration/api/mfa_flow.rs @@ -704,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; @@ -719,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 @@ -741,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_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/migrations/20260821083715_[2.2.0]_fido2.down.sql b/migrations/20260821083715_[2.2.0]_fido2.down.sql new file mode 100644 index 0000000000..e66886b5f7 --- /dev/null +++ b/migrations/20260821083715_[2.2.0]_fido2.down.sql @@ -0,0 +1,13 @@ +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..2c3024ed01 --- /dev/null +++ b/migrations/20260821083715_[2.2.0]_fido2.up.sql @@ -0,0 +1 @@ +ALTER TYPE mfa_method ADD VALUE 'fido2'; diff --git a/web/src/shared/components/LocationMfaConfiguration/LocationMfaConfiguration.tsx b/web/src/shared/components/LocationMfaConfiguration/LocationMfaConfiguration.tsx new file mode 100644 index 0000000000..c9de5fb4c8 --- /dev/null +++ b/web/src/shared/components/LocationMfaConfiguration/LocationMfaConfiguration.tsx @@ -0,0 +1,239 @@ +import './style.scss'; +import { useQuery } from '@tanstack/react-query'; +import { Reorder } from 'motion/react'; +import { sort } from 'radashi'; +import { useCallback, useMemo, useState } from 'react'; +import { m } from '../../../paraglide/messages'; +import { FieldError } from '../../defguard-ui/components/FieldError/FieldError'; +import { useApp } from '../../hooks/useApp'; +import { getLicenseInfoQueryOptions } from '../../query'; +import { canUseEnterpriseFeature } from '../../utils/license'; +import { LocationMfaConfigurationStep } from './components/LocationMfaConfigurationStep'; +import { LocationMfaMethodsMenu } from './components/LocationMfaMethodsMenu'; +import type { + LocationMfaConfigurationProps, + LocationMfaConfigurationStepData, + LocationMfaMethodValue, +} from './types'; +import { LocationMfaMethod, locationMfaMethodLabels } from './types'; + +type InternalStepsMap = Map; + +const mapToSortedArray = (map: InternalStepsMap): LocationMfaConfigurationStepData[] => + sort(Array.from(map.values()), (s) => s.order); + +const deleteAndReorder = ( + map: InternalStepsMap, + deleted: LocationMfaConfigurationStepData, +): void => { + map.delete(deleted.id); + for (const [key, s] of map) { + if (s.order > deleted.order) map.set(key, { ...s, order: s.order - 1 }); + } +}; + +export const LocationMfaConfiguration = ({ + onChange, + steps, + error, +}: LocationMfaConfigurationProps) => { + const smtpAvailable = useApp((s) => s.appInfo.smtp_enabled); + + const { data: licenseInfo } = useQuery(getLicenseInfoQueryOptions); + + const isEnterprise = useMemo( + () => canUseEnterpriseFeature(licenseInfo ?? null), + [licenseInfo], + ); + + const [internalSteps, setInternalSteps] = useState( + () => new Map(steps.map((step) => [step.id, step])), + ); + + const onReorder = useCallback( + (newSteps: LocationMfaConfigurationStepData[]) => { + const reordered = newSteps.map((s, i) => ({ ...s, order: i + 1 })); + const next: InternalStepsMap = new Map(reordered.map((s) => [s.id, s])); + setInternalSteps(next); + onChange(reordered); + }, + [onChange], + ); + + const onDeleteStep = useCallback( + (id: string) => { + const step = internalSteps.get(id); + if (!step) return; + + const next: InternalStepsMap = new Map(internalSteps); + deleteAndReorder(next, step); + + setInternalSteps(next); + onChange(mapToSortedArray(next)); + }, + [internalSteps, onChange], + ); + + const onAddStep = useCallback( + (initialFactor: LocationMfaMethodValue) => { + const id = crypto.randomUUID(); + const next: InternalStepsMap = new Map(internalSteps); + next.set(id, { id, order: next.size + 1, factors: [initialFactor] }); + + setInternalSteps(next); + onChange(mapToSortedArray(next)); + }, + [internalSteps, onChange], + ); + + const onAddFactor = useCallback( + (stepId: string, factor: LocationMfaMethodValue) => { + const step = internalSteps.get(stepId); + if (!step) return; + + const next: InternalStepsMap = new Map(internalSteps); + next.set(stepId, { ...step, factors: [...step.factors, factor] }); + + setInternalSteps(next); + onChange(mapToSortedArray(next)); + }, + [internalSteps, onChange], + ); + + const onDeleteFactor = useCallback( + (stepId: string, factor: LocationMfaMethodValue) => { + const step = internalSteps.get(stepId); + if (!step) return; + + const next: InternalStepsMap = new Map(internalSteps); + const remaining = step.factors.filter((f) => f !== factor); + if (remaining.length === 0) { + deleteAndReorder(next, step); + } else { + next.set(stepId, { ...step, factors: remaining }); + } + + setInternalSteps(next); + onChange(mapToSortedArray(next)); + }, + [internalSteps, onChange], + ); + + const sortedSteps = useMemo(() => mapToSortedArray(internalSteps), [internalSteps]); + + const usedFactors = useMemo( + () => new Set(sortedSteps.flatMap((s) => s.factors)), + [sortedSteps], + ); + + const availableMethods = useMemo( + () => + [ + LocationMfaMethod.Email, + LocationMfaMethod.MobileConfirm, + LocationMfaMethod.Totp, + LocationMfaMethod.Biometry, + LocationMfaMethod.Fido2, + LocationMfaMethod.Tpm, + LocationMfaMethod.OpenId, + ].filter((method) => !usedFactors.has(method)), + [usedFactors], + ); + + const buildOption = useCallback( + (method: LocationMfaMethodValue, onClick: () => void) => { + const isEmailWithoutSmtp = method === LocationMfaMethod.Email && !smtpAvailable; + let disabledHelper: string | undefined; + let disabled = false; + + if (isEmailWithoutSmtp) { + disabledHelper = m.cmp_location_mfa_smtp_disabled(); + disabled = true; + } + + if (!isEnterprise) { + if (method === LocationMfaMethod.Tpm || method === LocationMfaMethod.OpenId) { + disabled = true; + disabledHelper = m.cmp_location_mfa_enterprise_required(); + } + } + return { + text: locationMfaMethodLabels[method], + disabled, + disabledHelper, + onClick, + }; + }, + [isEnterprise, smtpAvailable], + ); + + const methodGroups = useMemo(() => { + if (isEnterprise) { + return [{ header: undefined, items: availableMethods }]; + } + + const planMethods = [ + LocationMfaMethod.Email, + LocationMfaMethod.MobileConfirm, + LocationMfaMethod.Totp, + LocationMfaMethod.Biometry, + LocationMfaMethod.Fido2, + ].filter((m) => availableMethods.includes(m)); + + const higherPlanMethods = [LocationMfaMethod.Tpm, LocationMfaMethod.OpenId].filter( + (m) => availableMethods.includes(m), + ); + + return [ + { header: { text: 'Available in your plan' }, items: planMethods }, + { header: { text: 'Available in higher plans' }, items: higherPlanMethods }, + ]; + }, [availableMethods, isEnterprise]); + + const addStepMenuOptions = useMemo( + () => + methodGroups.map((group) => ({ + ...group, + items: group.items.map((method) => buildOption(method, () => onAddStep(method))), + })), + [methodGroups, buildOption, onAddStep], + ); + + return ( +
+
+ + {sortedSteps.map((step) => ( + + ))} + +
+ {availableMethods.length > 0 && ( +
+ +
+ )} + +
+ ); +}; diff --git a/web/src/shared/components/LocationMfaConfiguration/types.ts b/web/src/shared/components/LocationMfaConfiguration/types.ts new file mode 100644 index 0000000000..66082892cf --- /dev/null +++ b/web/src/shared/components/LocationMfaConfiguration/types.ts @@ -0,0 +1,50 @@ +import type { MenuItemProps } from '../../defguard-ui/components/Menu/types'; + +export const LocationMfaMethod = { + Totp: 'totp', + Tpm: 'tpm', + Email: 'email', + Biometry: 'biometry', + MobileConfirm: 'confirm_mobile', + Fido2: 'fido2', + OpenId: 'openid', +} as const; + +export const locationMfaMethodLabels: Record = { + totp: 'Authenticator App', + tpm: 'Hardware key (TPM 2.0)', + email: 'Email Verification Code', + biometry: 'Biometry', + confirm_mobile: 'Defguard Mobile Client', + fido2: 'FIDO2 Security Key', + openid: 'External ID Provider', +}; + +export type LocationMfaMethodValue = + (typeof LocationMfaMethod)[keyof typeof LocationMfaMethod]; + +export interface LocationMfaConfigurationStepData { + id: string; + order: number; + factors: LocationMfaMethodValue[]; +} + +export type LocationMfaMethodGroup = { + header?: { text: string }; + items: LocationMfaMethodValue[]; +}; + +export type LocationMfaConfigurationStepProps = { + step: LocationMfaConfigurationStepData; + methodGroups: LocationMfaMethodGroup[]; + onDeleteStep: (id: string) => void; + onAddFactor: (stepId: string, factor: LocationMfaMethodValue) => void; + onDeleteFactor: (stepId: string, factor: LocationMfaMethodValue) => void; + buildOption: (method: LocationMfaMethodValue, onClick: () => void) => MenuItemProps; +}; + +export type LocationMfaConfigurationProps = { + steps: LocationMfaConfigurationStepData[]; + onChange: (steps: LocationMfaConfigurationStepData[]) => void; + error?: string; +}; From 840a93ce336bba8c0341821e5f5ab14864a60a18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Ciarcin=CC=81ski?= Date: Fri, 21 Aug 2026 11:00:50 +0200 Subject: [PATCH 3/4] Change proto branch --- proto | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/proto b/proto index 0b44d8c673..326694e734 160000 --- a/proto +++ b/proto @@ -1 +1 @@ -Subproject commit 0b44d8c6730e0129ac79f078041894813631a8e3 +Subproject commit 326694e734cb5ca1ceaca342f3a72fb9c7a8b8a0 From a619cdfff7f9efe0732e24e4771e6c475dc9581d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Ciarcin=CC=81ski?= Date: Fri, 21 Aug 2026 12:01:12 +0200 Subject: [PATCH 4/4] Fix web --- .../src/grpc/proxy/client_mfa.rs | 3 + .../src/tests/proxy_manager/handler/mfa.rs | 1 + .../tests/proxy_manager/handler/support.rs | 3 + .../20260821083715_[2.2.0]_fido2.down.sql | 37 +++ .../20260821083715_[2.2.0]_fido2.up.sql | 6 + web/messages/en/mfa_flow.json | 1 + web/src/shared/api/types.ts | 1 + .../LocationMfaConfiguration.tsx | 239 ------------------ .../LocationMfaConfiguration/types.ts | 50 ---- .../MfaConfiguration/MfaConfiguration.tsx | 1 + 10 files changed, 53 insertions(+), 289 deletions(-) delete mode 100644 web/src/shared/components/LocationMfaConfiguration/LocationMfaConfiguration.tsx delete mode 100644 web/src/shared/components/LocationMfaConfiguration/types.ts diff --git a/crates/defguard_core/src/grpc/proxy/client_mfa.rs b/crates/defguard_core/src/grpc/proxy/client_mfa.rs index f57d4383fc..a5396df421 100644 --- a/crates/defguard_core/src/grpc/proxy/client_mfa.rs +++ b/crates/defguard_core/src/grpc/proxy/client_mfa.rs @@ -2934,6 +2934,7 @@ mod tests { token: token.clone(), code: Some(code), auth_pub_key: None, + step_attempt_id: None, }, device_info(), ) @@ -3038,6 +3039,7 @@ mod tests { token: token.clone(), code: Some("000000".to_owned()), auth_pub_key: None, + step_attempt_id: None, }, device_info(), ) @@ -3336,6 +3338,7 @@ mod tests { token: token.clone(), code: Some(code), auth_pub_key: None, + step_attempt_id: None, }, device_info(), ) diff --git a/crates/defguard_proxy_manager/src/tests/proxy_manager/handler/mfa.rs b/crates/defguard_proxy_manager/src/tests/proxy_manager/handler/mfa.rs index 3691253f99..4db7e591e4 100644 --- a/crates/defguard_proxy_manager/src/tests/proxy_manager/handler/mfa.rs +++ b/crates/defguard_proxy_manager/src/tests/proxy_manager/handler/mfa.rs @@ -298,6 +298,7 @@ async fn test_mfa_finish_fails_with_wrong_totp_code(_: PgPoolOptions, options: P token: token.clone(), code: Some("000000".to_owned()), auth_pub_key: None, + step_attempt_id: None, }, )), }); 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 02a672a949..b61ed3ab58 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 @@ -770,6 +770,7 @@ pub(crate) async fn send_mfa_finish_signed( token: token.to_owned(), code: code.map(str::to_owned), auth_pub_key: auth_pub_key.map(str::to_owned), + step_attempt_id: None, }, )), }); @@ -810,6 +811,7 @@ pub(crate) async fn send_mfa_finish_no_recv( token: token.to_owned(), code: code.map(str::to_owned), auth_pub_key: None, + step_attempt_id: None, }, )), }); @@ -835,6 +837,7 @@ pub(crate) async fn send_mfa_finish_raw( token: token.to_owned(), code: code.map(str::to_owned), auth_pub_key: None, + step_attempt_id: None, }, )), }); diff --git a/migrations/20260821083715_[2.2.0]_fido2.down.sql b/migrations/20260821083715_[2.2.0]_fido2.down.sql index e66886b5f7..5ea7314bdb 100644 --- a/migrations/20260821083715_[2.2.0]_fido2.down.sql +++ b/migrations/20260821083715_[2.2.0]_fido2.down.sql @@ -1,3 +1,40 @@ +-- 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', diff --git a/migrations/20260821083715_[2.2.0]_fido2.up.sql b/migrations/20260821083715_[2.2.0]_fido2.up.sql index 2c3024ed01..bcdd2d7f32 100644 --- a/migrations/20260821083715_[2.2.0]_fido2.up.sql +++ b/migrations/20260821083715_[2.2.0]_fido2.up.sql @@ -1 +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/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/LocationMfaConfiguration/LocationMfaConfiguration.tsx b/web/src/shared/components/LocationMfaConfiguration/LocationMfaConfiguration.tsx deleted file mode 100644 index c9de5fb4c8..0000000000 --- a/web/src/shared/components/LocationMfaConfiguration/LocationMfaConfiguration.tsx +++ /dev/null @@ -1,239 +0,0 @@ -import './style.scss'; -import { useQuery } from '@tanstack/react-query'; -import { Reorder } from 'motion/react'; -import { sort } from 'radashi'; -import { useCallback, useMemo, useState } from 'react'; -import { m } from '../../../paraglide/messages'; -import { FieldError } from '../../defguard-ui/components/FieldError/FieldError'; -import { useApp } from '../../hooks/useApp'; -import { getLicenseInfoQueryOptions } from '../../query'; -import { canUseEnterpriseFeature } from '../../utils/license'; -import { LocationMfaConfigurationStep } from './components/LocationMfaConfigurationStep'; -import { LocationMfaMethodsMenu } from './components/LocationMfaMethodsMenu'; -import type { - LocationMfaConfigurationProps, - LocationMfaConfigurationStepData, - LocationMfaMethodValue, -} from './types'; -import { LocationMfaMethod, locationMfaMethodLabels } from './types'; - -type InternalStepsMap = Map; - -const mapToSortedArray = (map: InternalStepsMap): LocationMfaConfigurationStepData[] => - sort(Array.from(map.values()), (s) => s.order); - -const deleteAndReorder = ( - map: InternalStepsMap, - deleted: LocationMfaConfigurationStepData, -): void => { - map.delete(deleted.id); - for (const [key, s] of map) { - if (s.order > deleted.order) map.set(key, { ...s, order: s.order - 1 }); - } -}; - -export const LocationMfaConfiguration = ({ - onChange, - steps, - error, -}: LocationMfaConfigurationProps) => { - const smtpAvailable = useApp((s) => s.appInfo.smtp_enabled); - - const { data: licenseInfo } = useQuery(getLicenseInfoQueryOptions); - - const isEnterprise = useMemo( - () => canUseEnterpriseFeature(licenseInfo ?? null), - [licenseInfo], - ); - - const [internalSteps, setInternalSteps] = useState( - () => new Map(steps.map((step) => [step.id, step])), - ); - - const onReorder = useCallback( - (newSteps: LocationMfaConfigurationStepData[]) => { - const reordered = newSteps.map((s, i) => ({ ...s, order: i + 1 })); - const next: InternalStepsMap = new Map(reordered.map((s) => [s.id, s])); - setInternalSteps(next); - onChange(reordered); - }, - [onChange], - ); - - const onDeleteStep = useCallback( - (id: string) => { - const step = internalSteps.get(id); - if (!step) return; - - const next: InternalStepsMap = new Map(internalSteps); - deleteAndReorder(next, step); - - setInternalSteps(next); - onChange(mapToSortedArray(next)); - }, - [internalSteps, onChange], - ); - - const onAddStep = useCallback( - (initialFactor: LocationMfaMethodValue) => { - const id = crypto.randomUUID(); - const next: InternalStepsMap = new Map(internalSteps); - next.set(id, { id, order: next.size + 1, factors: [initialFactor] }); - - setInternalSteps(next); - onChange(mapToSortedArray(next)); - }, - [internalSteps, onChange], - ); - - const onAddFactor = useCallback( - (stepId: string, factor: LocationMfaMethodValue) => { - const step = internalSteps.get(stepId); - if (!step) return; - - const next: InternalStepsMap = new Map(internalSteps); - next.set(stepId, { ...step, factors: [...step.factors, factor] }); - - setInternalSteps(next); - onChange(mapToSortedArray(next)); - }, - [internalSteps, onChange], - ); - - const onDeleteFactor = useCallback( - (stepId: string, factor: LocationMfaMethodValue) => { - const step = internalSteps.get(stepId); - if (!step) return; - - const next: InternalStepsMap = new Map(internalSteps); - const remaining = step.factors.filter((f) => f !== factor); - if (remaining.length === 0) { - deleteAndReorder(next, step); - } else { - next.set(stepId, { ...step, factors: remaining }); - } - - setInternalSteps(next); - onChange(mapToSortedArray(next)); - }, - [internalSteps, onChange], - ); - - const sortedSteps = useMemo(() => mapToSortedArray(internalSteps), [internalSteps]); - - const usedFactors = useMemo( - () => new Set(sortedSteps.flatMap((s) => s.factors)), - [sortedSteps], - ); - - const availableMethods = useMemo( - () => - [ - LocationMfaMethod.Email, - LocationMfaMethod.MobileConfirm, - LocationMfaMethod.Totp, - LocationMfaMethod.Biometry, - LocationMfaMethod.Fido2, - LocationMfaMethod.Tpm, - LocationMfaMethod.OpenId, - ].filter((method) => !usedFactors.has(method)), - [usedFactors], - ); - - const buildOption = useCallback( - (method: LocationMfaMethodValue, onClick: () => void) => { - const isEmailWithoutSmtp = method === LocationMfaMethod.Email && !smtpAvailable; - let disabledHelper: string | undefined; - let disabled = false; - - if (isEmailWithoutSmtp) { - disabledHelper = m.cmp_location_mfa_smtp_disabled(); - disabled = true; - } - - if (!isEnterprise) { - if (method === LocationMfaMethod.Tpm || method === LocationMfaMethod.OpenId) { - disabled = true; - disabledHelper = m.cmp_location_mfa_enterprise_required(); - } - } - return { - text: locationMfaMethodLabels[method], - disabled, - disabledHelper, - onClick, - }; - }, - [isEnterprise, smtpAvailable], - ); - - const methodGroups = useMemo(() => { - if (isEnterprise) { - return [{ header: undefined, items: availableMethods }]; - } - - const planMethods = [ - LocationMfaMethod.Email, - LocationMfaMethod.MobileConfirm, - LocationMfaMethod.Totp, - LocationMfaMethod.Biometry, - LocationMfaMethod.Fido2, - ].filter((m) => availableMethods.includes(m)); - - const higherPlanMethods = [LocationMfaMethod.Tpm, LocationMfaMethod.OpenId].filter( - (m) => availableMethods.includes(m), - ); - - return [ - { header: { text: 'Available in your plan' }, items: planMethods }, - { header: { text: 'Available in higher plans' }, items: higherPlanMethods }, - ]; - }, [availableMethods, isEnterprise]); - - const addStepMenuOptions = useMemo( - () => - methodGroups.map((group) => ({ - ...group, - items: group.items.map((method) => buildOption(method, () => onAddStep(method))), - })), - [methodGroups, buildOption, onAddStep], - ); - - return ( -
-
- - {sortedSteps.map((step) => ( - - ))} - -
- {availableMethods.length > 0 && ( -
- -
- )} - -
- ); -}; diff --git a/web/src/shared/components/LocationMfaConfiguration/types.ts b/web/src/shared/components/LocationMfaConfiguration/types.ts deleted file mode 100644 index 66082892cf..0000000000 --- a/web/src/shared/components/LocationMfaConfiguration/types.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { MenuItemProps } from '../../defguard-ui/components/Menu/types'; - -export const LocationMfaMethod = { - Totp: 'totp', - Tpm: 'tpm', - Email: 'email', - Biometry: 'biometry', - MobileConfirm: 'confirm_mobile', - Fido2: 'fido2', - OpenId: 'openid', -} as const; - -export const locationMfaMethodLabels: Record = { - totp: 'Authenticator App', - tpm: 'Hardware key (TPM 2.0)', - email: 'Email Verification Code', - biometry: 'Biometry', - confirm_mobile: 'Defguard Mobile Client', - fido2: 'FIDO2 Security Key', - openid: 'External ID Provider', -}; - -export type LocationMfaMethodValue = - (typeof LocationMfaMethod)[keyof typeof LocationMfaMethod]; - -export interface LocationMfaConfigurationStepData { - id: string; - order: number; - factors: LocationMfaMethodValue[]; -} - -export type LocationMfaMethodGroup = { - header?: { text: string }; - items: LocationMfaMethodValue[]; -}; - -export type LocationMfaConfigurationStepProps = { - step: LocationMfaConfigurationStepData; - methodGroups: LocationMfaMethodGroup[]; - onDeleteStep: (id: string) => void; - onAddFactor: (stepId: string, factor: LocationMfaMethodValue) => void; - onDeleteFactor: (stepId: string, factor: LocationMfaMethodValue) => void; - buildOption: (method: LocationMfaMethodValue, onClick: () => void) => MenuItemProps; -}; - -export type LocationMfaConfigurationProps = { - steps: LocationMfaConfigurationStepData[]; - onChange: (steps: LocationMfaConfigurationStepData[]) => void; - error?: string; -}; 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) => {