diff --git a/.env.template b/.env.template index 98bed0b7..ba0be9e1 100644 --- a/.env.template +++ b/.env.template @@ -352,6 +352,17 @@ ## Allow a burst of requests of up to this size, while maintaining the average indicated by `ADMIN_RATELIMIT_SECONDS`. # ADMIN_RATELIMIT_MAX_BURST=3 +## SSO settings (OpenID Connect) +## Controls whether users can login using an OpenID Connect identity provider +# SSO_ENABLED=true +## Prevent users from logging in directly without going through SSO +# SSO_ONLY=false +## Base URL of the OIDC server (auto-discovery is used) +# SSO_AUTHORITY=https://auth.example.com +## Set your Client ID and Client Key +# SSO_CLIENT_ID=11111 +# SSO_CLIENT_SECRET=AAAAAAAAAAAAAAAAAAAAAAAA + ## Set the lifetime of admin sessions to this value (in minutes). # ADMIN_SESSION_LIFETIME=20 diff --git a/Cargo.lock b/Cargo.lock index cf74b7f8..34f07812 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -268,6 +268,12 @@ dependencies = [ "rustc-demangle", ] +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base64" version = "0.13.1" @@ -400,7 +406,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b48814962d2fd604c50d2b9433c2a41a0ab567779ee2c02f7fba6eca1221f082" dependencies = [ "cached_proc_macro_types", - "darling", + "darling 0.14.4", "proc-macro2", "quote", "syn 1.0.109", @@ -471,6 +477,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-oid" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28c122c3980598d243d63d9a704629a2d748d101f278052ff068be5a4423ab6f" + [[package]] name = "cookie" version = "0.16.2" @@ -581,6 +593,18 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crypto-bigint" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf4c2f4e1afd912bc40bfd6fed5d9dc1f288e0ba01bfcc835cc5bc3eb13efe15" +dependencies = [ + "generic-array", + "rand_core", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.6" @@ -597,8 +621,18 @@ version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b750cb3417fd1b327431a470f388520309479ab0bf5e323505daf0290cd3850" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.14.4", + "darling_macro 0.14.4", +] + +[[package]] +name = "darling" +version = "0.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0209d94da627ab5605dcccf08bb18afa5009cfbef48d8a8b7d7bdbc79be25c5e" +dependencies = [ + "darling_core 0.20.3", + "darling_macro 0.20.3", ] [[package]] @@ -615,17 +649,42 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "darling_core" +version = "0.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "177e3443818124b357d8e76f53be906d60937f0d3a90773a664fa63fa253e621" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.28", +] + [[package]] name = "darling_macro" version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4aab4dbc9f7611d8b55048a3a16d2d010c2c8334e46304b40ac1cc14bf3b48e" dependencies = [ - "darling_core", + "darling_core 0.14.4", "quote", "syn 1.0.109", ] +[[package]] +name = "darling_macro" +version = "0.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "836a9bbc7ad63342d6d6e7b815ccab164bc77a2d95d84bc3117a8c0d5c98e2d5" +dependencies = [ + "darling_core 0.20.3", + "quote", + "syn 2.0.28", +] + [[package]] name = "dashmap" version = "5.5.0" @@ -651,11 +710,25 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41b319d1b62ffbd002e057f36bebd1f42b9f97927c9577461d855f3513c4289f" +[[package]] +name = "der" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fffa369a668c8af7dbf8b5e56c9f744fbd399949ed171606040001947de40b1c" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + [[package]] name = "deranged" version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7684a49fb1af197853ef7b2ee694bc1f5b4179556f1e5710e1760c5db6f5e929" +dependencies = [ + "serde", +] [[package]] name = "devise" @@ -759,6 +832,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", + "const-oid", "crypto-common", "subtle", ] @@ -769,12 +843,53 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "dyn-clone" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfc4744c1b8f2a09adc0e55242f60b1af195d88596bd8700be74418c056c555" + +[[package]] +name = "ecdsa" +version = "0.16.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4b1e0c257a9e9f25f90ff76d7a68360ed497ee519c8e428d1825ef0000799d4" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + [[package]] name = "either" version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a26ae43d7bcc3b814de94796a5e736d4029efb0ee900c12e2d54c993ad1a1e07" +[[package]] +name = "elliptic-curve" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "968405c8fdc9b3bf4df0a6638858cc0b52462836ab6b1c87377785dd09cf1c0b" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "hkdf", + "pem-rfc7468", + "pkcs8", + "rand_core", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "email-encoding" version = "0.2.0" @@ -882,6 +997,16 @@ dependencies = [ "syslog", ] +[[package]] +name = "ff" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ded41244b729663b1e574f1b4fb731469f69f79c17667b5d776b16cda0479449" +dependencies = [ + "rand_core", + "subtle", +] + [[package]] name = "figment" version = "0.10.10" @@ -1067,6 +1192,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -1076,8 +1202,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be4136b2a15dd319360be1c07d9933517ccf0be8f16bf62a3bee4f0d618df427" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -1122,6 +1250,17 @@ dependencies = [ "smallvec", ] +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core", + "subtle", +] + [[package]] name = "h2" version = "0.3.20" @@ -1192,6 +1331,21 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "443144c8cdadd93ebf52ddb4056d257f5b52c04d3c804e657d19eb73fc33668b" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791a029f6b9fc27657f6f188ec6e5e43f6911f6f878e0dc5501396e09809d437" +dependencies = [ + "hmac", +] + [[package]] name = "hmac" version = "0.12.1" @@ -1279,6 +1433,20 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d78e1e73ec14cf7375674f74d7dde185c8206fd9dea6fb6295e8a98098aaa97" +dependencies = [ + "futures-util", + "http", + "hyper", + "rustls", + "tokio", + "tokio-rustls", +] + [[package]] name = "hyper-tls" version = "0.5.0" @@ -1371,6 +1539,7 @@ checksum = "d5477fe2230a79769d8dc68e0eabf5437907c0457a5614a9e8dddb67f65eb65d" dependencies = [ "equivalent", "hashbrown 0.14.0", + "serde", ] [[package]] @@ -1428,6 +1597,15 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.9" @@ -1488,6 +1666,9 @@ name = "lazy_static" version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" +dependencies = [ + "spin 0.5.2", +] [[package]] name = "lettre" @@ -1524,6 +1705,12 @@ version = "0.2.147" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4668fb0ea861c1df094127ac5f1da3409a82116a4ba74fca2e58ef927159bb3" +[[package]] +name = "libm" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7012b1bbb0719e1097c47611d3898568c546d597c2e74d66f6087edd5233ff4" + [[package]] name = "libmimalloc-sys" version = "0.1.33" @@ -1795,6 +1982,23 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-bigint-dig" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc84195820f291c7697304f3cbdadd1cb7199c0efc917ff5eafd71225c136151" +dependencies = [ + "byteorder", + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand", + "smallvec", + "zeroize", +] + [[package]] name = "num-derive" version = "0.4.0" @@ -1816,6 +2020,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-iter" +version = "0.1.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d03e6c028c5dc5cac6e2dec0efda81fc887605bb3d884578bb6d6bf7514e252" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.16" @@ -1823,6 +2038,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f30b0abd723be7e2ffca1272140fac1a2f084c77ec3e123c192b66af1ee9e6c2" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -1844,6 +2060,26 @@ dependencies = [ "libc", ] +[[package]] +name = "oauth2" +version = "4.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09a6e2a2b13a56ebeabba9142f911745be6456163fd6c3d361274ebcd891a80c" +dependencies = [ + "base64 0.13.1", + "chrono", + "getrandom", + "http", + "rand", + "reqwest", + "serde", + "serde_json", + "serde_path_to_error", + "sha2", + "thiserror", + "url", +] + [[package]] name = "object" version = "0.31.1" @@ -1859,6 +2095,37 @@ version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d" +[[package]] +name = "openidconnect" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03335ade401352b354b017e7597ddb40040091da445b031bf659e597e032b1fc" +dependencies = [ + "base64 0.13.1", + "chrono", + "dyn-clone", + "hmac", + "http", + "itertools", + "log", + "oauth2", + "p256", + "p384", + "rand", + "rsa", + "serde", + "serde-value", + "serde_derive", + "serde_json", + "serde_path_to_error", + "serde_plain", + "serde_with", + "sha2", + "subtle", + "thiserror", + "url", +] + [[package]] name = "openssl" version = "0.10.56" @@ -1913,12 +2180,45 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "ordered-float" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7940cf2ca942593318d07fcf2596cdca60a85c9e7fab408a5e21a4f9dcd40d87" +dependencies = [ + "num-traits", +] + [[package]] name = "overload" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p384" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70786f51bcc69f6a4c0360e063a4cac5419ef7c5cd5b3c99ad70f3be5ba79209" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + [[package]] name = "parking" version = "2.1.0" @@ -2006,6 +2306,15 @@ dependencies = [ "base64 0.13.1", ] +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.0" @@ -2112,6 +2421,27 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.27" @@ -2149,6 +2479,15 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "primeorder" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c2fcef82c0ec6eefcc179b978446c399b3cdf73c392c35604e399eee6df1ee3" +dependencies = [ + "elliptic-curve", +] + [[package]] name = "proc-macro2" version = "1.0.66" @@ -2365,6 +2704,7 @@ dependencies = [ "http", "http-body", "hyper", + "hyper-rustls", "hyper-tls", "ipnet", "js-sys", @@ -2374,11 +2714,14 @@ dependencies = [ "once_cell", "percent-encoding", "pin-project-lite", + "rustls", + "rustls-pemfile", "serde", "serde_json", "serde_urlencoded", "tokio", "tokio-native-tls", + "tokio-rustls", "tokio-socks", "tokio-util", "tower-service", @@ -2388,6 +2731,7 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams", "web-sys", + "webpki-roots", "winreg 0.10.1", ] @@ -2401,6 +2745,16 @@ dependencies = [ "quick-error", ] +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + [[package]] name = "ring" version = "0.16.20" @@ -2539,6 +2893,28 @@ dependencies = [ "winapi", ] +[[package]] +name = "rsa" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ab43bb47d23c1a631b4b680199a45255dce26fa9ab2fa902581f624ff13e6a8" +dependencies = [ + "byteorder", + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-iter", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core", + "signature", + "spki", + "subtle", + "zeroize", +] + [[package]] name = "rtoolbox" version = "0.0.1" @@ -2674,6 +3050,20 @@ dependencies = [ "untrusted", ] +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + [[package]] name = "security-framework" version = "2.9.2" @@ -2712,6 +3102,16 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-value" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" +dependencies = [ + "ordered-float", + "serde", +] + [[package]] name = "serde_cbor" version = "0.11.2" @@ -2744,6 +3144,25 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4beec8bce849d58d06238cb50db2e1c417cfeafa4c63f692b15c82b7c80f8335" +dependencies = [ + "itoa", + "serde", +] + +[[package]] +name = "serde_plain" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce1fc6db65a611022b23a0dec6975d63fb80a302cb3388835ff02c097258d50" +dependencies = [ + "serde", +] + [[package]] name = "serde_spanned" version = "0.6.3" @@ -2765,6 +3184,35 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_with" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ca3b16a3d82c4088f343b7480a93550b3eabe1a358569c2dfe38bbcead07237" +dependencies = [ + "base64 0.21.2", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.0.0", + "serde", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e6be15c453eb305019bfa438b1593c731f36a289a7853f7707ee29e870b3b3c" +dependencies = [ + "darling 0.20.3", + "proc-macro2", + "quote", + "syn 2.0.28", +] + [[package]] name = "sha-1" version = "0.10.1" @@ -2826,6 +3274,16 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e1788eed21689f9cf370582dfc467ef36ed9c707f073528ddafa8d83e3b8500" +dependencies = [ + "digest", + "rand_core", +] + [[package]] name = "simple_asn1" version = "0.6.2" @@ -2891,6 +3349,16 @@ version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +[[package]] +name = "spki" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1e996ef02c474957d681f1b05213dfb0abab947b446a62d37770b23500184a" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "stable-pattern" version = "0.1.0" @@ -3473,6 +3941,7 @@ dependencies = [ "num-derive", "num-traits", "once_cell", + "openidconnect", "openssl", "paste", "percent-encoding", @@ -3652,6 +4121,25 @@ dependencies = [ "url", ] +[[package]] +name = "webpki" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f095d78192e208183081cc07bc5515ef55216397af48b873e5edcd72637fa1bd" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "webpki-roots" +version = "0.22.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c71e40d7d2c34a5106301fb632274ca37242cd0c9d3e64dbece371a40a2d87" +dependencies = [ + "webpki", +] + [[package]] name = "which" version = "4.4.0" @@ -3830,3 +4318,9 @@ dependencies = [ "sha1", "threadpool", ] + +[[package]] +name = "zeroize" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0956f1ba7c7909bfb66c2e9e4124ab6f6482560f6628b5aaeba39207c9aad9" diff --git a/Cargo.toml b/Cargo.toml index 6875db8a..5e6e20d3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -149,6 +149,9 @@ pico-args = "0.5.0" paste = "1.0.14" governor = "0.6.0" +# OIDC for SSO +openidconnect = "3.3.0" + # Check client versions for specific features. semver = "1.0.18" diff --git a/migrations/mysql/2023-02-01-133000_add_sso/down.sql b/migrations/mysql/2023-02-01-133000_add_sso/down.sql new file mode 100644 index 00000000..2c946dc5 --- /dev/null +++ b/migrations/mysql/2023-02-01-133000_add_sso/down.sql @@ -0,0 +1 @@ +DROP TABLE sso_nonce; diff --git a/migrations/mysql/2023-02-01-133000_add_sso/up.sql b/migrations/mysql/2023-02-01-133000_add_sso/up.sql new file mode 100644 index 00000000..c10ab5cf --- /dev/null +++ b/migrations/mysql/2023-02-01-133000_add_sso/up.sql @@ -0,0 +1,3 @@ +CREATE TABLE sso_nonce ( + nonce CHAR(36) NOT NULL PRIMARY KEY +); diff --git a/migrations/postgresql/2023-02-01-133000_add_sso/down.sql b/migrations/postgresql/2023-02-01-133000_add_sso/down.sql new file mode 100644 index 00000000..2c946dc5 --- /dev/null +++ b/migrations/postgresql/2023-02-01-133000_add_sso/down.sql @@ -0,0 +1 @@ +DROP TABLE sso_nonce; diff --git a/migrations/postgresql/2023-02-01-133000_add_sso/up.sql b/migrations/postgresql/2023-02-01-133000_add_sso/up.sql new file mode 100644 index 00000000..57f976c1 --- /dev/null +++ b/migrations/postgresql/2023-02-01-133000_add_sso/up.sql @@ -0,0 +1,3 @@ +CREATE TABLE sso_nonce ( + nonce CHAR(36) NOT NULL PRIMARY KEY +); \ No newline at end of file diff --git a/migrations/sqlite/2023-02-01-133000_add_sso/down.sql b/migrations/sqlite/2023-02-01-133000_add_sso/down.sql new file mode 100644 index 00000000..2c946dc5 --- /dev/null +++ b/migrations/sqlite/2023-02-01-133000_add_sso/down.sql @@ -0,0 +1 @@ +DROP TABLE sso_nonce; diff --git a/migrations/sqlite/2023-02-01-133000_add_sso/up.sql b/migrations/sqlite/2023-02-01-133000_add_sso/up.sql new file mode 100644 index 00000000..c10ab5cf --- /dev/null +++ b/migrations/sqlite/2023-02-01-133000_add_sso/up.sql @@ -0,0 +1,3 @@ +CREATE TABLE sso_nonce ( + nonce CHAR(36) NOT NULL PRIMARY KEY +); diff --git a/src/api/core/accounts.rs b/src/api/core/accounts.rs index 3feccd80..bff701b7 100644 --- a/src/api/core/accounts.rs +++ b/src/api/core/accounts.rs @@ -28,6 +28,7 @@ pub fn routes() -> Vec { get_public_keys, post_keys, post_password, + post_set_password, post_kdf, post_rotatekey, post_sstamp, @@ -78,6 +79,21 @@ pub struct RegisterData { OrganizationUserId: Option, } +#[derive(Deserialize, Debug)] +#[allow(non_snake_case)] +pub struct SetPasswordData { + Kdf: Option, + KdfIterations: Option, + KdfMemory: Option, + KdfParallelism: Option, + Key: String, + Keys: Option, + MasterPasswordHash: String, + MasterPasswordHint: Option, + #[allow(dead_code)] + orgIdentifier: Option, +} + #[derive(Deserialize, Debug)] #[allow(non_snake_case)] struct KeysData { @@ -85,6 +101,13 @@ struct KeysData { PublicKey: String, } +#[derive(Debug, Serialize, Deserialize)] +struct TokenPayload { + exp: i64, + email: String, + nonce: String, +} + /// Trims whitespace from password hints, and converts blank password hints to `None`. fn clean_password_hint(password_hint: &Option) -> Option { match password_hint { @@ -215,6 +238,50 @@ pub async fn _register(data: JsonUpcase, mut conn: DbConn) -> Json }))) } +#[post("/accounts/set-password", data = "")] +async fn post_set_password(data: JsonUpcase, headers: Headers, mut conn: DbConn) -> JsonResult { + let data: SetPasswordData = data.into_inner().data; + let mut user = headers.user; + + // Check against the password hint setting here so if it fails, the user + // can retry without losing their invitation below. + let password_hint = clean_password_hint(&data.MasterPasswordHint); + enforce_password_hint_setting(&password_hint)?; + + if let Some(client_kdf_iter) = data.KdfIterations { + user.client_kdf_iter = client_kdf_iter; + } + + if let Some(client_kdf_type) = data.Kdf { + user.client_kdf_type = client_kdf_type; + } + + // We need to allow revision-date to use the old security_timestamp + let routes = vec!["revision_date"]; + let routes: Option> = Some(routes.iter().map(ToString::to_string).collect()); + + user.client_kdf_memory = data.KdfMemory; + user.client_kdf_parallelism = data.KdfParallelism; + + user.set_password(&data.MasterPasswordHash, Some(data.Key), false, routes); + user.password_hint = password_hint; + + if let Some(keys) = data.Keys { + user.private_key = Some(keys.EncryptedPrivateKey); + user.public_key = Some(keys.PublicKey); + } + + if CONFIG.mail_enabled() { + mail::send_set_password(&user.email.to_lowercase(), &user.name).await?; + } + + user.save(&mut conn).await?; + Ok(Json(json!({ + "Object": "set-password", + "CaptchaBypassToken": "", + }))) +} + #[get("/accounts/profile")] async fn profile(headers: Headers, mut conn: DbConn) -> Json { Json(headers.user.to_json(&mut conn).await) @@ -835,7 +902,7 @@ struct SecretVerificationRequest { } #[post("/accounts/verify-password", data = "")] -fn verify_password(data: JsonUpcase, headers: Headers) -> EmptyResult { +fn verify_password(data: JsonUpcase, headers: Headers) -> JsonResult { let data: SecretVerificationRequest = data.into_inner().data; let user = headers.user; @@ -843,7 +910,9 @@ fn verify_password(data: JsonUpcase, headers: Headers err!("Invalid password") } - Ok(()) + Ok(Json(json!({ + "MasterPasswordPolicy": {}, // Required for SSO login with mobile apps + }))) } async fn _api_key( diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs index 0bcf262f..e949affa 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -40,6 +40,7 @@ pub fn routes() -> Vec { post_organization_collection_delete, bulk_delete_organization_collections, get_org_details, + get_org_domain_sso_details, get_org_users, send_invite, reinvite_user, @@ -56,6 +57,7 @@ pub fn routes() -> Vec { post_org_import, list_policies, list_policies_token, + list_policies_invited_user, get_policy, put_policy, get_organization_tax, @@ -95,6 +97,7 @@ pub fn routes() -> Vec { get_org_export, api_key, rotate_api_key, + get_auto_enroll_status, ] } @@ -304,6 +307,13 @@ async fn get_user_collections(headers: Headers, mut conn: DbConn) -> Json })) } +#[get("/organizations/<_identifier>/auto-enroll-status")] +fn get_auto_enroll_status(_identifier: String) -> JsonResult { + Ok(Json(json!({ + "ResetPasswordEnabled": false, // Not implemented + }))) +} + #[get("/organizations//collections")] async fn get_org_collections(org_id: &str, _headers: ManagerHeadersLoose, mut conn: DbConn) -> Json { Json(json!({ @@ -781,6 +791,14 @@ async fn _get_org_details(org_id: &str, host: &str, user_uuid: &str, conn: &mut json!(ciphers_json) } +#[post("/organizations/domain/sso/details")] +fn get_org_domain_sso_details() -> JsonResult { + Ok(Json(json!({ + "organizationIdentifier": "vaultwarden", + "ssoAvailable": CONFIG.sso_enabled() + }))) +} + #[derive(FromForm)] struct GetOrgUserData { #[field(name = "includeCollections")] @@ -1660,6 +1678,25 @@ async fn list_policies_token(org_id: &str, token: &str, mut conn: DbConn) -> Jso }))) } +#[allow(non_snake_case)] +#[get("/organizations//policies/invited-user?")] +async fn list_policies_invited_user(org_id: String, userId: String, mut conn: DbConn) -> JsonResult { + // We should confirm the user is part of the organization, but unique domain_hints must be supported first. + + if userId.is_empty() { + err!("userId must not be empty"); + } + + let policies = OrgPolicy::find_by_org(&org_id, &mut conn).await; + let policies_json: Vec = policies.iter().map(OrgPolicy::to_json).collect(); + + Ok(Json(json!({ + "Data": policies_json, + "Object": "list", + "ContinuationToken": null + }))) +} + #[get("/organizations//policies/")] async fn get_policy(org_id: &str, pol_type: i32, _headers: AdminHeaders, mut conn: DbConn) -> JsonResult { let pol_type_enum = match OrgPolicyType::from_i32(pol_type) { diff --git a/src/api/core/two_factor/authenticator.rs b/src/api/core/two_factor/authenticator.rs index 37221df1..cadcfce1 100644 --- a/src/api/core/two_factor/authenticator.rs +++ b/src/api/core/two_factor/authenticator.rs @@ -26,9 +26,7 @@ async fn generate_authenticator(data: JsonUpcase, headers: Headers let data: PasswordData = data.into_inner().data; let user = headers.user; - if !user.check_valid_password(&data.MasterPasswordHash) { - err!("Invalid password"); - } + crate::api::core::two_factor::authenticator_activation_check(&user, &data.MasterPasswordHash)?; let type_ = TwoFactorType::Authenticator as i32; let twofactor = TwoFactor::find_by_user_and_type(&user.uuid, type_, &mut conn).await; @@ -60,15 +58,12 @@ async fn activate_authenticator( mut conn: DbConn, ) -> JsonResult { let data: EnableAuthenticatorData = data.into_inner().data; - let password_hash = data.MasterPasswordHash; let key = data.Key; let token = data.Token.into_string(); let mut user = headers.user; - if !user.check_valid_password(&password_hash) { - err!("Invalid password"); - } + crate::api::core::two_factor::authenticator_activation_check(&user, &data.MasterPasswordHash)?; // Validate key as base32 and 20 bytes length let decoded_key: Vec = match BASE32.decode(key.as_bytes()) { diff --git a/src/api/core/two_factor/duo.rs b/src/api/core/two_factor/duo.rs index c4ca0ba8..ad171349 100644 --- a/src/api/core/two_factor/duo.rs +++ b/src/api/core/two_factor/duo.rs @@ -95,9 +95,7 @@ const DISABLED_MESSAGE_DEFAULT: &str = ", headers: Headers, mut conn: DbConn) -> JsonResult { let data: PasswordData = data.into_inner().data; - if !headers.user.check_valid_password(&data.MasterPasswordHash) { - err!("Invalid password"); - } + crate::api::core::two_factor::authenticator_activation_check(&headers.user, &data.MasterPasswordHash)?; let data = get_user_duo_data(&headers.user.uuid, &mut conn).await; @@ -159,9 +157,7 @@ async fn activate_duo(data: JsonUpcase, headers: Headers, mut con let data: EnableDuoData = data.into_inner().data; let mut user = headers.user; - if !user.check_valid_password(&data.MasterPasswordHash) { - err!("Invalid password"); - } + crate::api::core::two_factor::authenticator_activation_check(&user, &data.MasterPasswordHash)?; let (data, data_str) = if check_duo_fields_custom(&data) { let data_req: DuoData = data.into(); diff --git a/src/api/core/two_factor/email.rs b/src/api/core/two_factor/email.rs index 1ca5152b..9a7d3c8e 100644 --- a/src/api/core/two_factor/email.rs +++ b/src/api/core/two_factor/email.rs @@ -80,9 +80,7 @@ async fn get_email(data: JsonUpcase, headers: Headers, mut conn: D let data: PasswordData = data.into_inner().data; let user = headers.user; - if !user.check_valid_password(&data.MasterPasswordHash) { - err!("Invalid password"); - } + crate::api::core::two_factor::authenticator_activation_check(&user, &data.MasterPasswordHash)?; let (enabled, mfa_email) = match TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Email as i32, &mut conn).await { @@ -114,9 +112,7 @@ async fn send_email(data: JsonUpcase, headers: Headers, mut conn: let data: SendEmailData = data.into_inner().data; let user = headers.user; - if !user.check_valid_password(&data.MasterPasswordHash) { - err!("Invalid password"); - } + crate::api::core::two_factor::authenticator_activation_check(&user, &data.MasterPasswordHash)?; if !CONFIG._enable_email_2fa() { err!("Email 2FA is disabled") @@ -154,9 +150,7 @@ async fn email(data: JsonUpcase, headers: Headers, mut conn: DbConn) let data: EmailData = data.into_inner().data; let mut user = headers.user; - if !user.check_valid_password(&data.MasterPasswordHash) { - err!("Invalid password"); - } + crate::api::core::two_factor::authenticator_activation_check(&user, &data.MasterPasswordHash)?; let type_ = TwoFactorType::EmailVerificationChallenge as i32; let mut twofactor = diff --git a/src/api/core/two_factor/mod.rs b/src/api/core/two_factor/mod.rs index 35c1867f..b2226ad4 100644 --- a/src/api/core/two_factor/mod.rs +++ b/src/api/core/two_factor/mod.rs @@ -5,7 +5,7 @@ use rocket::Route; use serde_json::Value; use crate::{ - api::{core::log_user_event, JsonResult, JsonUpcase, NumberOrString, PasswordData}, + api::{core::log_user_event, EmptyResult, JsonResult, JsonUpcase, NumberOrString, PasswordData}, auth::{ClientHeaders, Headers}, crypto, db::{models::*, DbConn, DbPool}, @@ -54,9 +54,7 @@ fn get_recover(data: JsonUpcase, headers: Headers) -> JsonResult { let data: PasswordData = data.into_inner().data; let user = headers.user; - if !user.check_valid_password(&data.MasterPasswordHash) { - err!("Invalid password"); - } + crate::api::core::two_factor::authenticator_activation_check(&user, &data.MasterPasswordHash)?; Ok(Json(json!({ "Code": user.totp_recover, @@ -84,10 +82,7 @@ async fn recover(data: JsonUpcase, client_headers: ClientHeade None => err!("Username or password is incorrect. Try again."), }; - // Check password - if !user.check_valid_password(&data.MasterPasswordHash) { - err!("Username or password is incorrect. Try again.") - } + crate::api::core::two_factor::authenticator_activation_check(&user, &data.MasterPasswordHash)?; // Check if recovery code is correct if !user.check_valid_recovery_code(&data.RecoveryCode) { @@ -224,3 +219,16 @@ fn get_device_verification_settings(_headers: Headers, _conn: DbConn) -> Json EmptyResult { + if !user.check_valid_password(password_hash) { + err!("Invalid password"); + } + + if CONFIG.sso_enabled() && CONFIG.sso_only() { + err!("Cannot activate 2FA when SSO is the only login option"); + } + + Ok(()) +} diff --git a/src/api/core/two_factor/webauthn.rs b/src/api/core/two_factor/webauthn.rs index 3c62754a..2cb37701 100644 --- a/src/api/core/two_factor/webauthn.rs +++ b/src/api/core/two_factor/webauthn.rs @@ -108,9 +108,7 @@ async fn get_webauthn(data: JsonUpcase, headers: Headers, mut conn err!("`DOMAIN` environment variable is not set. Webauthn disabled") } - if !headers.user.check_valid_password(&data.data.MasterPasswordHash) { - err!("Invalid password"); - } + crate::api::core::two_factor::authenticator_activation_check(&headers.user, &data.data.MasterPasswordHash)?; let (enabled, registrations) = get_webauthn_registrations(&headers.user.uuid, &mut conn).await?; let registrations_json: Vec = registrations.iter().map(WebauthnRegistration::to_json).collect(); @@ -124,9 +122,7 @@ async fn get_webauthn(data: JsonUpcase, headers: Headers, mut conn #[post("/two-factor/get-webauthn-challenge", data = "")] async fn generate_webauthn_challenge(data: JsonUpcase, headers: Headers, mut conn: DbConn) -> JsonResult { - if !headers.user.check_valid_password(&data.data.MasterPasswordHash) { - err!("Invalid password"); - } + crate::api::core::two_factor::authenticator_activation_check(&headers.user, &data.data.MasterPasswordHash)?; let registrations = get_webauthn_registrations(&headers.user.uuid, &mut conn) .await? diff --git a/src/api/core/two_factor/yubikey.rs b/src/api/core/two_factor/yubikey.rs index 7681ab01..327f268a 100644 --- a/src/api/core/two_factor/yubikey.rs +++ b/src/api/core/two_factor/yubikey.rs @@ -90,9 +90,7 @@ async fn generate_yubikey(data: JsonUpcase, headers: Headers, mut let data: PasswordData = data.into_inner().data; let user = headers.user; - if !user.check_valid_password(&data.MasterPasswordHash) { - err!("Invalid password"); - } + crate::api::core::two_factor::authenticator_activation_check(&user, &data.MasterPasswordHash)?; let user_uuid = &user.uuid; let yubikey_type = TwoFactorType::YubiKey as i32; @@ -122,9 +120,7 @@ async fn activate_yubikey(data: JsonUpcase, headers: Headers, let data: EnableYubikeyData = data.into_inner().data; let mut user = headers.user; - if !user.check_valid_password(&data.MasterPasswordHash) { - err!("Invalid password"); - } + crate::api::core::two_factor::authenticator_activation_check(&user, &data.MasterPasswordHash)?; // Check if we already have some data let mut yubikey_data = diff --git a/src/api/identity.rs b/src/api/identity.rs index 8dbb78f6..ff8e133e 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -1,8 +1,10 @@ use chrono::Utc; +use jsonwebtoken::DecodingKey; use num_traits::FromPrimitive; use rocket::serde::json::Json; use rocket::{ form::{Form, FromForm}, + http::CookieJar, Route, }; use serde_json::Value; @@ -14,14 +16,16 @@ use crate::{ core::two_factor::{duo, email, email::EmailTokenData, yubikey}, ApiResult, EmptyResult, JsonResult, JsonUpcase, }, - auth::{generate_organization_api_key_login_claims, ClientHeaders, ClientIp}, + auth::{encode_jwt, generate_organization_api_key_login_claims, generate_ssotoken_claims, ClientHeaders, ClientIp}, db::{models::*, DbConn}, error::MapResult, - mail, util, CONFIG, + mail, util, + util::{CookieManager, CustomRedirect}, + CONFIG, }; pub fn routes() -> Vec { - routes![login, prelogin, identity_register] + routes![login, prelogin, identity_register, prevalidate, authorize, oidcsignin] } #[post("/connect/token", data = "")] @@ -58,6 +62,15 @@ async fn login(data: Form, client_header: ClientHeaders, mut conn: _api_key_login(data, &mut user_uuid, &mut conn, &client_header.ip).await } + "authorization_code" => { + _check_is_some(&data.client_id, "client_id cannot be blank")?; + _check_is_some(&data.code, "code cannot be blank")?; + + _check_is_some(&data.device_identifier, "device_identifier cannot be blank")?; + _check_is_some(&data.device_name, "device_name cannot be blank")?; + _check_is_some(&data.device_type, "device_type cannot be blank")?; + _authorization_login(data, &mut user_uuid, &mut conn, &client_header.ip).await + } t => err!("Invalid type", t), }; @@ -127,6 +140,142 @@ async fn _refresh_login(data: ConnectData, conn: &mut DbConn) -> JsonResult { Ok(Json(result)) } +#[derive(Debug, Serialize, Deserialize)] +struct TokenPayload { + exp: i64, + email: Option, + nonce: String, +} + +async fn _authorization_login( + data: ConnectData, + user_uuid: &mut Option, + conn: &mut DbConn, + ip: &ClientIp, +) -> JsonResult { + let scope = match data.scope.as_ref() { + None => err!("Got no scope in OIDC data"), + Some(scope) => scope, + }; + if scope != "api offline_access" { + err!("Scope not supported") + } + + let scope_vec = vec!["api".into(), "offline_access".into()]; + let code = match data.code.as_ref() { + None => err!("Got no code in OIDC data"), + Some(code) => code, + }; + + let (refresh_token, id_token, user_info) = match get_auth_code_access_token(code).await { + Ok((refresh_token, id_token, user_info)) => (refresh_token, id_token, user_info), + Err(_err) => err!("Could not retrieve access token"), + }; + + let mut validation = jsonwebtoken::Validation::default(); + validation.insecure_disable_signature_validation(); + + let token = + match jsonwebtoken::decode::(id_token.as_str(), &DecodingKey::from_secret(&[]), &validation) { + Err(_err) => err!("Could not decode id token"), + Ok(payload) => payload.claims, + }; + + // let expiry = token.exp; + let nonce = token.nonce; + let mut new_user = false; + + match SsoNonce::find(&nonce, conn).await { + Some(sso_nonce) => { + match sso_nonce.delete(conn).await { + Ok(_) => { + let user_email = match token.email { + Some(email) => email, + None => match user_info.email() { + None => err!("Neither id token nor userinfo contained an email"), + Some(email) => email.to_owned().to_string(), + }, + }; + let now = Utc::now().naive_utc(); + + let mut user = match User::find_by_mail(&user_email, conn).await { + Some(user) => user, + None => { + new_user = true; + User::new(user_email.clone()) + } + }; + + if new_user { + user.verified_at = Some(Utc::now().naive_utc()); + user.save(conn).await?; + } + + // Set the user_uuid here to be passed back used for event logging. + *user_uuid = Some(user.uuid.clone()); + + let (mut device, new_device) = get_device(&data, conn, &user).await; + + let twofactor_token = twofactor_auth(&user.uuid, &data, &mut device, ip, true, conn).await?; + + if CONFIG.mail_enabled() && new_device { + if let Err(e) = + mail::send_new_device_logged_in(&user.email, &ip.ip.to_string(), &now, &device.name).await + { + error!("Error sending new device email: {:#?}", e); + + if CONFIG.require_device_email() { + err!("Could not send login notification email. Please contact your administrator.") + } + } + } + + if CONFIG.sso_acceptall_invites() { + for user_org in UserOrganization::find_invited_by_user(&user.uuid, conn).await.iter_mut() { + user_org.status = UserOrgStatus::Accepted as i32; + user_org.save(conn).await?; + } + } + + device.refresh_token = refresh_token.clone(); + device.save(conn).await?; + + let orgs = UserOrganization::find_confirmed_by_user(&user.uuid, conn).await; + let (access_token, expires_in) = device.refresh_tokens(&user, orgs, scope_vec); + device.save(conn).await?; + + let mut result = json!({ + "access_token": access_token, + "token_type": "Bearer", + "refresh_token": device.refresh_token, + "expires_in": expires_in, + "Key": user.akey, + "PrivateKey": user.private_key, + "Kdf": user.client_kdf_type, + "KdfIterations": user.client_kdf_iter, + "KdfMemory": user.client_kdf_memory, + "KdfParallelism": user.client_kdf_parallelism, + "ResetMasterPassword": user.password_hash.is_empty(), + "scope": scope, + "unofficialServer": true, + }); + + if let Some(token) = twofactor_token { + result["TwoFactorToken"] = Value::String(token); + } + + info!("User {} logged in successfully. IP: {}", user.email, ip.ip); + Ok(Json(result)) + } + Err(_) => err!("Failed to delete nonce"), + } + } + None => { + err!("Invalid nonce") + } + } +} + async fn _password_login( data: ConnectData, user_uuid: &mut Option, @@ -143,6 +292,10 @@ async fn _password_login( // Ratelimit the login crate::ratelimit::check_limit_login(&ip.ip)?; + if CONFIG.sso_enabled() && CONFIG.sso_only() { + err!("SSO sign-in is required"); + } + // Get the user let username = data.username.as_ref().unwrap().trim(); let mut user = match User::find_by_mail(username, conn).await { @@ -242,7 +395,7 @@ async fn _password_login( let (mut device, new_device) = get_device(&data, conn, &user).await; - let twofactor_token = twofactor_auth(&user.uuid, &data, &mut device, ip, conn).await?; + let twofactor_token = twofactor_auth(&user.uuid, &data, &mut device, ip, false, conn).await?; if CONFIG.mail_enabled() && new_device { if let Err(e) = mail::send_new_device_logged_in(&user.email, &ip.ip.to_string(), &now, &device.name).await { @@ -453,6 +606,7 @@ async fn twofactor_auth( data: &ConnectData, device: &mut Device, ip: &ClientIp, + is_sso: bool, conn: &mut DbConn, ) -> ApiResult> { let twofactors = TwoFactor::find_by_user(user_uuid, conn).await; @@ -469,7 +623,17 @@ async fn twofactor_auth( let twofactor_code = match data.two_factor_token { Some(ref code) => code, - None => err_json!(_json_err_twofactor(&twofactor_ids, user_uuid, conn).await?, "2FA token not provided"), + None => { + if is_sso { + if CONFIG.sso_only() { + err!("2FA not supported with SSO login, contact your administrator"); + } else { + err!("2FA not supported with SSO login, log in directly using email and master password"); + } + } else { + err_json!(_json_err_twofactor(&twofactor_ids, user_uuid, conn).await?, "2FA token not provided"); + } + } }; let selected_twofactor = twofactors.into_iter().find(|tf| tf.atype == selected_id && tf.enabled); @@ -668,11 +832,187 @@ struct ConnectData { two_factor_remember: Option, #[field(name = uncased("authrequest"))] auth_request: Option, + // Needed for authorization code + #[form(field = uncased("code"))] + code: Option, } - fn _check_is_some(value: &Option, msg: &str) -> EmptyResult { if value.is_none() { err!(msg) } Ok(()) } + +#[get("/account/prevalidate")] +#[allow(non_snake_case)] +fn prevalidate() -> JsonResult { + let claims = generate_ssotoken_claims(); + let ssotoken = encode_jwt(&claims); + Ok(Json(json!({ + "token": ssotoken, + }))) +} + +use openidconnect::core::{CoreClient, CoreProviderMetadata, CoreResponseType, CoreUserInfoClaims}; +use openidconnect::reqwest::async_http_client; +use openidconnect::{ + AuthenticationFlow, AuthorizationCode, ClientId, ClientSecret, CsrfToken, IssuerUrl, Nonce, OAuth2TokenResponse, + RedirectUrl, Scope, +}; + +async fn get_client_from_sso_config() -> ApiResult { + let redirect = CONFIG.sso_callback_path(); + let client_id = ClientId::new(CONFIG.sso_client_id()); + let client_secret = ClientSecret::new(CONFIG.sso_client_secret()); + let issuer_url = match IssuerUrl::new(CONFIG.sso_authority()) { + Ok(issuer) => issuer, + Err(_err) => err!("invalid issuer URL"), + }; + + let provider_metadata = match CoreProviderMetadata::discover_async(issuer_url, async_http_client).await { + Ok(metadata) => metadata, + Err(_err) => { + err!("Failed to discover OpenID provider") + } + }; + + let redirect_uri = match RedirectUrl::new(redirect) { + Ok(uri) => uri, + Err(err) => err!("Invalid redirection url: {}", err.to_string()), + }; + let client = CoreClient::from_provider_metadata(provider_metadata, client_id, Some(client_secret)) + .set_redirect_uri(redirect_uri); + + Ok(client) +} + +#[get("/connect/oidc-signin?")] +fn oidcsignin(code: String, jar: &CookieJar<'_>, _conn: DbConn) -> ApiResult { + let cookiemanager = CookieManager::new(jar); + + let redirect_uri = match cookiemanager.get_cookie("redirect_uri".to_string()) { + None => err!("No redirect_uri in cookie"), + Some(uri) => uri, + }; + let orig_state = match cookiemanager.get_cookie("state".to_string()) { + None => err!("No state in cookie"), + Some(state) => state, + }; + + cookiemanager.delete_cookie("redirect_uri".to_string()); + cookiemanager.delete_cookie("state".to_string()); + + let redirect = CustomRedirect { + url: format!("{redirect_uri}?code={code}&state={orig_state}"), + headers: vec![], + }; + + Ok(redirect) +} + +#[derive(FromForm)] +#[allow(non_snake_case)] +struct AuthorizeData { + #[allow(unused)] + #[field(name = uncased("client_id"))] + #[field(name = uncased("clientid"))] + client_id: Option, + #[field(name = uncased("redirect_uri"))] + #[field(name = uncased("redirecturi"))] + redirect_uri: Option, + #[allow(unused)] + #[field(name = uncased("response_type"))] + #[field(name = uncased("responsetype"))] + response_type: Option, + #[allow(unused)] + #[field(name = uncased("scope"))] + scope: Option, + #[field(name = uncased("state"))] + state: Option, + #[allow(unused)] + #[field(name = uncased("code_challenge"))] + code_challenge: Option, + #[allow(unused)] + #[field(name = uncased("code_challenge_method"))] + code_challenge_method: Option, + #[allow(unused)] + #[field(name = uncased("response_mode"))] + response_mode: Option, + #[allow(unused)] + #[field(name = uncased("domain_hint"))] + domain_hint: Option, + #[allow(unused)] + #[field(name = uncased("ssoToken"))] + ssoToken: Option, +} + +#[get("/connect/authorize?")] +async fn authorize(data: AuthorizeData, jar: &CookieJar<'_>, mut conn: DbConn) -> ApiResult { + let cookiemanager = CookieManager::new(jar); + match get_client_from_sso_config().await { + Ok(client) => { + let (auth_url, _csrf_state, nonce) = client + .authorize_url( + AuthenticationFlow::::AuthorizationCode, + CsrfToken::new_random, + Nonce::new_random, + ) + .add_scope(Scope::new("email".to_string())) + .add_scope(Scope::new("profile".to_string())) + .url(); + + let sso_nonce = SsoNonce::new(nonce.secret().to_string()); + sso_nonce.save(&mut conn).await?; + + let redirect_uri = match data.redirect_uri { + None => err!("No redirect_uri in data"), + Some(uri) => uri, + }; + cookiemanager.set_cookie("redirect_uri".to_string(), redirect_uri); + let state = match data.state { + None => err!("No state in data"), + Some(state) => state, + }; + cookiemanager.set_cookie("state".to_string(), state); + + let redirect = CustomRedirect { + url: format!("{}", auth_url), + headers: vec![], + }; + + Ok(redirect) + } + Err(_err) => err!("Unable to find client from identifier"), + } +} + +async fn get_auth_code_access_token(code: &str) -> ApiResult<(String, String, CoreUserInfoClaims)> { + let oidc_code = AuthorizationCode::new(String::from(code)); + match get_client_from_sso_config().await { + Ok(client) => match client.exchange_code(oidc_code).request_async(async_http_client).await { + Ok(token_response) => { + let refresh_token = match token_response.refresh_token() { + Some(token) => token.secret().to_string(), + None => String::new(), + }; + let id_token = match token_response.extra_fields().id_token() { + None => err!("Token response did not contain an id_token"), + Some(token) => token.to_string(), + }; + + let user_info: CoreUserInfoClaims = + match client.user_info(token_response.access_token().to_owned(), None) { + Err(_err) => err!("Token response did not contain user_info"), + Ok(info) => match info.request_async(async_http_client).await { + Err(_err) => err!("Request to user_info endpoint failed"), + Ok(claim) => claim, + }, + }; + + Ok((refresh_token, id_token, user_info)) + } + Err(err) => err!("Failed to contact token endpoint: {}", err.to_string()), + }, + Err(_err) => err!("Unable to find client"), + } +} diff --git a/src/auth.rs b/src/auth.rs index 6879bb6e..072ad401 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -19,6 +19,7 @@ pub static JWT_LOGIN_ISSUER: Lazy = Lazy::new(|| format!("{}|login", CON static JWT_INVITE_ISSUER: Lazy = Lazy::new(|| format!("{}|invite", CONFIG.domain_origin())); static JWT_EMERGENCY_ACCESS_INVITE_ISSUER: Lazy = Lazy::new(|| format!("{}|emergencyaccessinvite", CONFIG.domain_origin())); +static JWT_SSOTOKEN_ISSUER: Lazy = Lazy::new(|| format!("{}|ssotoken", CONFIG.domain_origin())); static JWT_DELETE_ISSUER: Lazy = Lazy::new(|| format!("{}|delete", CONFIG.domain_origin())); static JWT_VERIFYEMAIL_ISSUER: Lazy = Lazy::new(|| format!("{}|verifyemail", CONFIG.domain_origin())); static JWT_ADMIN_ISSUER: Lazy = Lazy::new(|| format!("{}|admin", CONFIG.domain_origin())); @@ -287,6 +288,28 @@ pub fn generate_delete_claims(uuid: String) -> BasicJwtClaims { } } +#[derive(Debug, Serialize, Deserialize)] +pub struct SsoTokenJwtClaims { + // Not before + pub nbf: i64, + // Expiration time + pub exp: i64, + // Issuer + pub iss: String, + // Subject + pub sub: String, +} + +pub fn generate_ssotoken_claims() -> SsoTokenJwtClaims { + let time_now = Utc::now().naive_utc(); + SsoTokenJwtClaims { + nbf: time_now.timestamp(), + exp: (time_now + Duration::minutes(2)).timestamp(), + iss: JWT_SSOTOKEN_ISSUER.to_string(), + sub: "vaultwarden".to_string(), + } +} + pub fn generate_verify_email_claims(uuid: String) -> BasicJwtClaims { let time_now = Utc::now().naive_utc(); let expire_hours = i64::from(CONFIG.invitation_expiration_hours()); diff --git a/src/config.rs b/src/config.rs index d54b356b..13d249d8 100644 --- a/src/config.rs +++ b/src/config.rs @@ -602,6 +602,24 @@ make_config! { org_groups_enabled: bool, false, def, false; }, + /// OpenID Connect SSO settings + sso { + /// Enabled + sso_enabled: bool, true, def, false; + /// Force SSO login + sso_only: bool, true, def, false; + /// Client ID + sso_client_id: String, true, def, String::new(); + /// Client Key + sso_client_secret: Pass, true, def, String::new(); + /// Authority Server + sso_authority: String, true, def, String::new(); + /// CallBack Path + sso_callback_path: String, false, gen, |c| generate_sso_callback_path(&c.domain); + /// Allow workaround so SSO logins accept all invites + sso_acceptall_invites: bool, true, def, false; + }, + /// Yubikey settings yubico: _enable_yubico { /// Enabled @@ -756,6 +774,12 @@ fn validate_config(cfg: &ConfigItems) -> Result<(), Error> { err!("All Duo options need to be set for global Duo support") } + if cfg.sso_enabled + && (cfg.sso_client_id.is_empty() || cfg.sso_client_secret.is_empty() || cfg.sso_authority.is_empty()) + { + err!("`SSO_CLIENT_ID`, `SSO_CLIENT_SECRET` and `SSO_AUTHORITY` must be set for SSO support") + } + if cfg._enable_yubico { if cfg.yubico_client_id.is_some() != cfg.yubico_secret_key.is_some() { err!("Both `YUBICO_CLIENT_ID` and `YUBICO_SECRET_KEY` must be set for Yubikey OTP support") @@ -952,6 +976,10 @@ fn generate_smtp_img_src(embed_images: bool, domain: &str) -> String { } } +fn generate_sso_callback_path(domain: &str) -> String { + format!("{domain}/identity/connect/oidc-signin") +} + /// Generate the correct URL for the icon service. /// This will be used within icons.rs to call the external icon service. fn generate_icon_service_url(icon_service: &str) -> String { @@ -1247,6 +1275,7 @@ where reg!("email/send_single_org_removed_from_org", ".html"); reg!("email/send_org_invite", ".html"); reg!("email/send_emergency_access_invite", ".html"); + reg!("email/set_password", ".html"); reg!("email/twofactor_email", ".html"); reg!("email/verify_email", ".html"); reg!("email/welcome", ".html"); diff --git a/src/db/models/mod.rs b/src/db/models/mod.rs index 0379141a..9a4e7585 100644 --- a/src/db/models/mod.rs +++ b/src/db/models/mod.rs @@ -11,6 +11,7 @@ mod group; mod org_policy; mod organization; mod send; +mod sso_nonce; mod two_factor; mod two_factor_incomplete; mod user; @@ -28,6 +29,7 @@ pub use self::group::{CollectionGroup, Group, GroupUser}; pub use self::org_policy::{OrgPolicy, OrgPolicyErr, OrgPolicyType}; pub use self::organization::{Organization, OrganizationApiKey, UserOrgStatus, UserOrgType, UserOrganization}; pub use self::send::{Send, SendType}; +pub use self::sso_nonce::SsoNonce; pub use self::two_factor::{TwoFactor, TwoFactorType}; pub use self::two_factor_incomplete::TwoFactorIncomplete; pub use self::user::{Invitation, User, UserKdfType, UserStampException}; diff --git a/src/db/models/org_policy.rs b/src/db/models/org_policy.rs index 8b3f1271..a0589a89 100644 --- a/src/db/models/org_policy.rs +++ b/src/db/models/org_policy.rs @@ -28,7 +28,7 @@ pub enum OrgPolicyType { MasterPassword = 1, PasswordGenerator = 2, SingleOrg = 3, - // RequireSso = 4, // Not supported + RequireSso = 4, PersonalOwnership = 5, DisableSend = 6, SendOptions = 7, diff --git a/src/db/models/organization.rs b/src/db/models/organization.rs index 32ffc437..f368dbce 100644 --- a/src/db/models/organization.rs +++ b/src/db/models/organization.rs @@ -166,7 +166,7 @@ impl Organization { "UseTotp": true, "UsePolicies": true, // "UseScim": false, // Not supported (Not AGPLv3 Licensed) - "UseSso": false, // Not supported + "UseSso": CONFIG.sso_enabled(), // "UseKeyConnector": false, // Not supported "SelfHost": true, "UseApi": true, @@ -346,7 +346,7 @@ impl UserOrganization { "ResetPasswordEnrolled": self.reset_password_key.is_some(), "UseResetPassword": CONFIG.mail_enabled(), "SsoBound": false, // Not supported - "UseSso": false, // Not supported + "UseSso": CONFIG.sso_enabled(), "ProviderId": null, "ProviderName": null, // "KeyConnectorEnabled": false, diff --git a/src/db/models/sso_nonce.rs b/src/db/models/sso_nonce.rs new file mode 100644 index 00000000..0a9533e0 --- /dev/null +++ b/src/db/models/sso_nonce.rs @@ -0,0 +1,60 @@ +use crate::api::EmptyResult; +use crate::db::DbConn; +use crate::error::MapResult; + +db_object! { + #[derive(Identifiable, Queryable, Insertable)] + #[diesel(table_name = sso_nonce)] + #[diesel(primary_key(nonce))] + pub struct SsoNonce { + pub nonce: String, + } +} + +/// Local methods +impl SsoNonce { + pub fn new(nonce: String) -> Self { + Self { + nonce, + } + } +} + +/// Database methods +impl SsoNonce { + pub async fn save(&self, conn: &mut DbConn) -> EmptyResult { + db_run! { conn: + sqlite, mysql { + diesel::replace_into(sso_nonce::table) + .values(SsoNonceDb::to_db(self)) + .execute(conn) + .map_res("Error saving SSO device") + } + postgresql { + let value = SsoNonceDb::to_db(self); + diesel::insert_into(sso_nonce::table) + .values(&value) + .execute(conn) + .map_res("Error saving SSO nonce") + } + } + } + + pub async fn delete(self, conn: &mut DbConn) -> EmptyResult { + db_run! { conn: { + diesel::delete(sso_nonce::table.filter(sso_nonce::nonce.eq(self.nonce))) + .execute(conn) + .map_res("Error deleting SSO nonce") + }} + } + + pub async fn find(nonce: &str, conn: &mut DbConn) -> Option { + db_run! { conn: { + sso_nonce::table + .filter(sso_nonce::nonce.eq(nonce)) + .first::(conn) + .ok() + .from_db() + }} + } +} diff --git a/src/db/schemas/mysql/schema.rs b/src/db/schemas/mysql/schema.rs index c2b2c961..ca5e3e90 100644 --- a/src/db/schemas/mysql/schema.rs +++ b/src/db/schemas/mysql/schema.rs @@ -241,6 +241,12 @@ table! { } } +table! { + sso_nonce (nonce) { + nonce -> Text, + } +} + table! { emergency_access (uuid) { uuid -> Text, diff --git a/src/db/schemas/postgresql/schema.rs b/src/db/schemas/postgresql/schema.rs index 4ae9e821..33d82432 100644 --- a/src/db/schemas/postgresql/schema.rs +++ b/src/db/schemas/postgresql/schema.rs @@ -241,6 +241,12 @@ table! { } } +table! { + sso_nonce (nonce) { + nonce -> Text, + } +} + table! { emergency_access (uuid) { uuid -> Text, diff --git a/src/db/schemas/sqlite/schema.rs b/src/db/schemas/sqlite/schema.rs index 62c04e91..1d899990 100644 --- a/src/db/schemas/sqlite/schema.rs +++ b/src/db/schemas/sqlite/schema.rs @@ -241,6 +241,12 @@ table! { } } +table! { + sso_nonce (nonce) { + nonce -> Text, + } +} + table! { emergency_access (uuid) { uuid -> Text, diff --git a/src/mail.rs b/src/mail.rs index b5f1ea87..be182a45 100644 --- a/src/mail.rs +++ b/src/mail.rs @@ -492,6 +492,18 @@ pub async fn send_change_email(address: &str, token: &str) -> EmptyResult { send_email(address, &subject, body_html, body_text).await } +pub async fn send_set_password(address: &str, user_name: &str) -> EmptyResult { + let (subject, body_html, body_text) = get_text( + "email/set_password", + json!({ + "url": CONFIG.domain(), + "img_src": CONFIG._smtp_img_src(), + "user_name": user_name, + }), + )?; + send_email(address, &subject, body_html, body_text).await +} + pub async fn send_test(address: &str) -> EmptyResult { let (subject, body_html, body_text) = get_text( "email/smtp_test", diff --git a/src/static/templates/email/set_password.hbs b/src/static/templates/email/set_password.hbs new file mode 100644 index 00000000..923c80f2 --- /dev/null +++ b/src/static/templates/email/set_password.hbs @@ -0,0 +1,6 @@ +Master Password Has Been Changed + +The master password for {{user_name}} has been changed. If you did not initiate this request, please reach out to your administrator immediately. + +=== +{{> email/email_footer_text }} \ No newline at end of file diff --git a/src/static/templates/email/set_password.html.hbs b/src/static/templates/email/set_password.html.hbs new file mode 100644 index 00000000..ede5da0c --- /dev/null +++ b/src/static/templates/email/set_password.html.hbs @@ -0,0 +1,11 @@ +Master Password Has Been Changed + +{{> email/email_header }} + + + + +
+ The master password for {{user_name}} has been changed. If you did not initiate this request, please reach out to your administrator immediately. +
+{{> email/email_footer }} \ No newline at end of file diff --git a/src/util.rs b/src/util.rs index 0ebfefec..b3f1c4d4 100644 --- a/src/util.rs +++ b/src/util.rs @@ -8,7 +8,7 @@ use std::{ use rocket::{ fairing::{Fairing, Info, Kind}, - http::{ContentType, Header, HeaderMap, Method, Status}, + http::{ContentType, Cookie, CookieJar, Header, HeaderMap, Method, SameSite, Status}, request::FromParam, response::{self, Responder}, Data, Orbit, Request, Response, Rocket, @@ -115,8 +115,9 @@ impl Cors { fn get_allowed_origin(headers: &HeaderMap<'_>) -> Option { let origin = Cors::get_header(headers, "Origin"); let domain_origin = CONFIG.domain_origin(); + let sso_origin = CONFIG.sso_authority(); let safari_extension_origin = "file://"; - if origin == domain_origin || origin == safari_extension_origin { + if origin == domain_origin || origin == safari_extension_origin || origin == sso_origin { Some(origin) } else { None @@ -241,6 +242,33 @@ impl<'r> FromParam<'r> for SafeString { } } +pub struct CustomRedirect { + pub url: String, + pub headers: Vec<(String, String)>, +} + +impl<'r> rocket::response::Responder<'r, 'static> for CustomRedirect { + fn respond_to(self, _: &rocket::request::Request<'_>) -> rocket::response::Result<'static> { + let mut response = Response::build() + .status(rocket::http::Status { + code: 307, + }) + .raw_header("Location", self.url) + .header(ContentType::HTML) + .finalize(); + + // Normal headers + response.set_raw_header("Referrer-Policy", "same-origin"); + response.set_raw_header("X-XSS-Protection", "0"); + + for header in &self.headers { + response.set_raw_header(header.0.clone(), header.1.clone()); + } + + Ok(response) + } +} + // Log all the routes from the main paths list, and the attachments endpoint // Effectively ignores, any static file route, and the alive endpoint const LOGGED_ROUTES: [&str; 7] = ["/api", "/admin", "/identity", "/icons", "/attachments", "/events", "/notifications"]; @@ -738,3 +766,29 @@ pub fn convert_json_key_lcase_first(src_json: Value) -> Value { value => value, } } + +pub struct CookieManager<'a> { + jar: &'a CookieJar<'a>, +} + +impl<'a> CookieManager<'a> { + pub fn new(jar: &'a CookieJar<'a>) -> Self { + Self { + jar, + } + } + + pub fn set_cookie(&self, name: String, value: String) { + let cookie = Cookie::build(name, value).same_site(SameSite::Lax).finish(); + + self.jar.add(cookie) + } + + pub fn get_cookie(&self, name: String) -> Option { + self.jar.get(&name).map(|c| c.value().to_string()) + } + + pub fn delete_cookie(&self, name: String) { + self.jar.remove(Cookie::named(name)); + } +}