diff --git a/Cargo.lock b/Cargo.lock index f2c5765d4..683b93c8a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -42,9 +42,9 @@ dependencies = [ [[package]] name = "aho-corasick" -version = "1.0.4" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6748e8def348ed4d14996fa801f4122cd763fff530258cdc03f64b25f89d3a5a" +checksum = "ea5d730647d4fadd988536d06fecce94b7b4f2a7efdae548f1cf4b63205518ab" dependencies = [ "memchr", ] @@ -92,15 +92,15 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.2" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15c4c2c83f81532e5845a733998b6971faca23490340a418e9b72a3ec9de12ea" +checksum = "7079075b41f533b8c61d2a4d073c4676e1f8b249ff94a393b0595db304e0dd87" [[package]] name = "anstyle-parse" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "938874ff5980b03a87c5524b3ae5b59cf99b1d6bc836848df7bc5ada9643c333" +checksum = "317b9a89c1868f5ea6ff1d9539a69f45dffc21ce321ac1fd1160dfa48c8e2140" dependencies = [ "utf8parse", ] @@ -189,9 +189,9 @@ dependencies = [ [[package]] name = "async-compression" -version = "0.4.1" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62b74f44609f0f91493e3082d3734d98497e094777144380ea4db9f9905dd5b6" +checksum = "bb42b2197bf15ccb092b62c74515dbd8b86d0effd934795f6687c93b6e679a2c" dependencies = [ "flate2", "futures-core", @@ -270,7 +270,7 @@ dependencies = [ "serde_bytes", "serde_cbor", "serde_json", - "sha2 0.10.7", + "sha2 0.10.8", "winapi", ] @@ -300,6 +300,7 @@ dependencies = [ "matchit", "memchr", "mime", + "multer", "percent-encoding", "pin-project-lite", "rustversion", @@ -464,7 +465,7 @@ dependencies = [ "lazycell", "log", "peeking_take_while", - "prettyplease 0.2.12", + "prettyplease 0.2.15", "proc-macro2", "quote", "regex", @@ -574,9 +575,9 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.13.0" +version = "3.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3e2c3daef883ecc1b5d58c15adae93470a91d425f3532ba1695849656af3fc1" +checksum = "7f30e7476521f6f8af1a1c4c0b8cc94f0bee37d91763d0ca2665f299b6cd8aec" [[package]] name = "byte-tools" @@ -586,9 +587,9 @@ checksum = "e3b5ca7a04898ad4bcd41c90c5285445ff5b791899bb1b0abdd2a2aa791211d7" [[package]] name = "bytemuck" -version = "1.13.1" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17febce684fd15d89027105661fec94afb475cb995fbc59d2865198446ba2eea" +checksum = "374d28ec25809ee0e23827c2ab573d729e293f281dfe393500e7ad618baa61c6" [[package]] name = "byteorder" @@ -1029,9 +1030,9 @@ dependencies = [ [[package]] name = "csv" -version = "1.2.2" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "626ae34994d3d8d668f4269922248239db4ae42d538b14c398b74a52208e8086" +checksum = "ac574ff4d437a7b5ad237ef331c17ccca63c46479e5b5453eb8e10bb99a759fe" dependencies = [ "csv-core", "itoa", @@ -1041,9 +1042,9 @@ dependencies = [ [[package]] name = "csv-core" -version = "0.1.10" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b2466559f260f48ad25fe6317b3c8dac77b5bdb5763ac7d9d6103530663bc90" +checksum = "5efa2b3d7902f4b634a20cae3c9c4e6209dc4779feb6863329607560143efa70" dependencies = [ "memchr", ] @@ -1370,18 +1371,18 @@ dependencies = [ [[package]] name = "enum-map" -version = "2.6.1" +version = "2.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9705d8de4776df900a4a0b2384f8b0ab42f775e93b083b42f8ce71bdc32a47e3" +checksum = "c188012f8542dee7b3996e44dd89461d64aa471b0a7c71a1ae2f595d259e96e5" dependencies = [ "enum-map-derive", ] [[package]] name = "enum-map-derive" -version = "0.13.0" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccb14d927583dd5c2eac0f2cf264fc4762aefe1ae14c47a8a20fc1939d3a5fc0" +checksum = "04d0b288e3bb1d861c4403c1774a6f7a798781dfc519b3647df2a3dd4ae95f25" dependencies = [ "proc-macro2", "quote", @@ -1390,18 +1391,18 @@ dependencies = [ [[package]] name = "enumflags2" -version = "0.7.7" +version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c041f5090df68b32bcd905365fd51769c8b9d553fe87fde0b683534f10c01bd2" +checksum = "5998b4f30320c9d93aed72f63af821bfdac50465b75428fce77b48ec482c3939" dependencies = [ "enumflags2_derive", ] [[package]] name = "enumflags2_derive" -version = "0.7.7" +version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e9a1f9f7d83e59740248a6e14ecf93929ade55027844dfcea78beafccc15745" +checksum = "f95e2801cd355d4a1a3e3953ce6ee5ae9603a5c833455343a8bfe3f44d418246" dependencies = [ "proc-macro2", "quote", @@ -1437,9 +1438,9 @@ checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" [[package]] name = "errno" -version = "0.3.2" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b30f669a7961ef1631673d2766cc92f52d64f7ef354d4fe0ddfd30ed52f0f4f" +checksum = "add4f07d43996f76ef320709726a556a9d4f965d9410d8d0271132d2f8293480" dependencies = [ "errno-dragonfly", "libc", @@ -1474,6 +1475,15 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" +[[package]] +name = "fallible_collections" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a88c69768c0a15262df21899142bc6df9b9b823546d4b4b9a7bc2d6c448ec6fd" +dependencies = [ + "hashbrown 0.13.2", +] + [[package]] name = "fancy-regex" version = "0.11.0" @@ -1517,9 +1527,9 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.0.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6999dc1837253364c2ebb0704ba97994bd874e8f195d665c50b7548f6ea92764" +checksum = "25cbce373ec4653f1a01a31e8a5e5ec0c622dc27ff9c4e6606eefef5cbbed4a5" [[package]] name = "fernet" @@ -1746,6 +1756,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "gif" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80792593675e051cf94a4b111980da2ba60d4a83e43e0048c5693baab3977045" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "gimli" version = "0.28.0" @@ -2431,6 +2451,15 @@ dependencies = [ "ahash 0.7.6", ] +[[package]] +name = "hashbrown" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43a3c133739dddd0d2990f9a4bdf8eb4b21ef50e4851ca85ab661199821d510e" +dependencies = [ + "ahash 0.8.3", +] + [[package]] name = "hashbrown" version = "0.14.1" @@ -2444,21 +2473,20 @@ dependencies = [ [[package]] name = "hashlink" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "312f66718a2d7789ffef4f4b7b213138ed9f1eb3aa1d0d82fc99f88fb3ffd26f" +checksum = "e8094feaf31ff591f651a2664fb9cfd92bba7a60ce3197265e9482ebe753c8f7" dependencies = [ "hashbrown 0.14.1", ] [[package]] name = "headers" -version = "0.3.8" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3e372db8e5c0d213e0cd0b9be18be2aca3d44cf2fe30a9d46a65581cd454584" +checksum = "06683b93020a07e3dbcf5f8c0f6d40080d725bea7936fc01ad345c01b97dc270" dependencies = [ - "base64 0.13.1", - "bitflags 1.3.2", + "base64 0.21.4", "bytes", "headers-core", "http", @@ -2484,9 +2512,9 @@ checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" [[package]] name = "hermit-abi" -version = "0.3.2" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "443144c8cdadd93ebf52ddb4056d257f5b52c04d3c804e657d19eb73fc33668b" +checksum = "d77f7ec81a6d05a3abb01ab6eb7590f6083d08449fe5a1c8b1e620283546ccb7" [[package]] name = "hex" @@ -2671,6 +2699,21 @@ dependencies = [ "num-traits", ] +[[package]] +name = "image" +version = "0.24.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f3dfdbdd72063086ff443e297b61695500514b1e41095b6fb9a5ab48a70a711" +dependencies = [ + "bytemuck", + "byteorder", + "color_quant", + "gif", + "jpeg-decoder", + "num-rational 0.4.1", + "num-traits", +] + [[package]] name = "implicit-clone" version = "0.3.6" @@ -2693,9 +2736,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.0.0" +version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5477fe2230a79769d8dc68e0eabf5437907c0457a5614a9e8dddb67f65eb65d" +checksum = "8adf3ddd720272c6ea8bf59463c04e0f93d0bbf7c5439b691bca2987e0270897" dependencies = [ "equivalent", "hashbrown 0.14.1", @@ -2772,6 +2815,12 @@ dependencies = [ "libc", ] +[[package]] +name = "jpeg-decoder" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc0000e42512c92e31c2252315bda326620a4e034105e900c98ec492fa077b3e" + [[package]] name = "js-sys" version = "0.3.64" @@ -3044,7 +3093,9 @@ dependencies = [ "filetime", "futures", "hashbrown 0.14.1", + "hex", "idlset", + "image 0.24.7", "kanidm_build_profiles", "kanidm_lib_crypto", "kanidm_proto", @@ -3054,6 +3105,7 @@ dependencies = [ "ldap3_proto", "libc", "libsqlite3-sys", + "lodepng", "nonempty", "num_enum", "openssl", @@ -3069,6 +3121,7 @@ dependencies = [ "smartstring", "smolset", "sshkeys", + "svg", "time", "tokio", "tokio-util", @@ -3289,9 +3342,9 @@ dependencies = [ [[package]] name = "linux-raw-sys" -version = "0.4.5" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57bcfdad1b858c2db7c38303a6d2ad4dfaf5eb53dfeb0910128b2c26d6158503" +checksum = "3852614a3bd9ca9804678ba6be5e3b8ce76dfc902cae004e3e0c44051b6e88db" [[package]] name = "lock_api" @@ -3303,6 +3356,19 @@ dependencies = [ "scopeguard", ] +[[package]] +name = "lodepng" +version = "3.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3cdccd0cf57a5d456f0656ebcff72c2e19503287e1afbf3b84382812adc0606" +dependencies = [ + "crc32fast", + "fallible_collections", + "flate2", + "libc", + "rgb", +] + [[package]] name = "log" version = "0.4.20" @@ -3344,9 +3410,9 @@ checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" [[package]] name = "matchit" -version = "0.7.2" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed1202b2a6f884ae56f04cff409ab315c5ce26b5e58d7412e484f01fd52f52ef" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" [[package]] name = "mathru" @@ -3370,9 +3436,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.6.3" +version = "2.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f232d6ef707e1956a43342693d2a31e72989554d58299d7a88738cc95b0d35c" +checksum = "f665ee40bc4a3c5590afb1e9677db74a508659dfd71e126420da8274909a0167" [[package]] name = "memmap2" @@ -3444,6 +3510,24 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "multer" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01acbdc23469fd8fe07ab135923371d5f5a422fbf9c522158677c8eb15bc51c2" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http", + "httparse", + "log", + "memchr", + "mime", + "spin", + "version_check", +] + [[package]] name = "native-tls" version = "0.2.11" @@ -3672,9 +3756,9 @@ dependencies = [ [[package]] name = "oauth2" -version = "4.4.1" +version = "4.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09a6e2a2b13a56ebeabba9142f911745be6456163fd6c3d361274ebcd891a80c" +checksum = "c38841cdd844847e3e7c8d29cef9dcfed8877f8f56f9071f77843ecf3baf937f" dependencies = [ "base64 0.13.1", "chrono", @@ -3684,16 +3768,16 @@ dependencies = [ "serde", "serde_json", "serde_path_to_error", - "sha2 0.10.7", + "sha2 0.10.8", "thiserror", "url", ] [[package]] name = "object" -version = "0.32.0" +version = "0.32.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77ac5bbd07aea88c60a577a1ce218075ffd59208b2d7ca97adf9bfc5aeb21ebe" +checksum = "9cf5f9dd3933bd50a9e1f149ec995f39ae2c496d31fd772c1fd45ebc27e902b0" dependencies = [ "memchr", ] @@ -3958,10 +4042,11 @@ checksum = "9b2a4787296e9989611394c33f193f676704af1686e70b8f8033ab5ba9a35a94" [[package]] name = "pest" -version = "2.7.2" +version = "2.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1acb4a4365a13f749a93f1a094a7805e5cfa0955373a9de860d962eaa3a5fe5a" +checksum = "c022f1e7b65d6a24c0dbbd5fb344c66881bc01f3e5ae74a1c8100f2f985d98a4" dependencies = [ + "memchr", "thiserror", "ucd-trie", ] @@ -4096,9 +4181,9 @@ dependencies = [ [[package]] name = "prettyplease" -version = "0.2.12" +version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c64d9ba0963cdcea2e1b2230fbae2bab30eb25a174be395c41e764bfb65dd62" +checksum = "ae005bd773ab59b4725093fd7df83fd7892f7d8eafb48dbd7de6e024e4215f9d" dependencies = [ "proc-macro2", "syn 2.0.37", @@ -4155,9 +4240,9 @@ dependencies = [ [[package]] name = "prodash" -version = "26.2.1" +version = "26.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50bcc40e3e88402f12b15f94d43a2c7673365e9601cc52795e119b95a266100c" +checksum = "794b5bf8e2d19b53dcdcec3e4bba628e20f5b6062503ba89281fa7037dd7bbcf" [[package]] name = "prokio" @@ -4199,7 +4284,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "16d2f1455f3630c6e5107b4f2b94e74d76dea80736de0981fd27644216cff57f" dependencies = [ "checked_int_cast", - "image", + "image 0.23.14", ] [[package]] @@ -4249,9 +4334,9 @@ dependencies = [ [[package]] name = "rayon" -version = "1.7.0" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d2df5196e37bcc87abebc0053e20787d73847bb33134a69841207dd0a47f03b" +checksum = "9c27db03db7734835b3f53954b534c91069375ce6ccaa2e065441e07d9b6cdb1" dependencies = [ "either", "rayon-core", @@ -4259,14 +4344,12 @@ dependencies = [ [[package]] name = "rayon-core" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b8f95bd6966f5c87776639160a66bd8ab9895d9d4ab01ddba9fc60661aebe8d" +checksum = "5ce3fb6ad83f861aac485e76e1985cd109d9a3713802152be56c3b1f0e0658ed" dependencies = [ - "crossbeam-channel", "crossbeam-deque", "crossbeam-utils", - "num_cpus", ] [[package]] @@ -4350,9 +4433,9 @@ checksum = "dbb5fb1acd8a1a18b3dd5be62d25485eb770e05afb408a9627d14d451bae12da" [[package]] name = "reqwest" -version = "0.11.20" +version = "0.11.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e9ad3fe7488d7e34558a2033d45a0c90b72d97b4f80705666fea71472e2e6a1" +checksum = "046cd98826c46c2ac8ddecae268eb5c2e58628688a5fc7a2643704a73faba95b" dependencies = [ "async-compression", "base64 0.21.4", @@ -4371,6 +4454,7 @@ dependencies = [ "js-sys", "log", "mime", + "mime_guess", "native-tls", "once_cell", "percent-encoding", @@ -4378,6 +4462,7 @@ dependencies = [ "serde", "serde_json", "serde_urlencoded", + "system-configuration", "tokio", "tokio-native-tls", "tokio-util", @@ -4389,6 +4474,15 @@ dependencies = [ "winreg", ] +[[package]] +name = "rgb" +version = "0.8.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20ec2d3e3fc7a92ced357df9cebd5a10b6fb2aa1ee797bf7e9ce2f17dffc8f59" +dependencies = [ + "bytemuck", +] + [[package]] name = "route-recognizer" version = "0.3.1" @@ -4478,9 +4572,9 @@ dependencies = [ [[package]] name = "rustix" -version = "0.38.9" +version = "0.38.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9bfe0f2582b4931a45d1fa608f8a8722e8b3c7ac54dd6d5f3b3212791fedef49" +checksum = "d2f9da0cbd88f9f09e7814e388301c8414c51c62aa6ce1e4b5c551d49d96e531" dependencies = [ "bitflags 2.4.0", "errno", @@ -4735,7 +4829,7 @@ dependencies = [ "chrono", "hex", "indexmap 1.9.3", - "indexmap 2.0.0", + "indexmap 2.0.2", "serde", "serde_json", "serde_with_macros", @@ -4756,9 +4850,9 @@ dependencies = [ [[package]] name = "sha1" -version = "0.10.5" +version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f04293dc80c3993519f2d7f6f511707ee7094fe0c6d3406feb330cdb3540eba3" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", "cpufeatures", @@ -4785,9 +4879,9 @@ dependencies = [ [[package]] name = "sha2" -version = "0.10.7" +version = "0.10.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479fb9d862239e610720565ca91403019f2f00410f1864c5aa7479b950a76ed8" +checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" dependencies = [ "cfg-if", "cpufeatures", @@ -4796,9 +4890,9 @@ dependencies = [ [[package]] name = "sharded-slab" -version = "0.1.4" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "900fba806f70c630b0a382d0d825e17a0f19fcd059a2ade1ff237bcddf446b31" +checksum = "c1b21f559e07218024e7e9f90f96f601825397de0e25420135f7f952453fed0b" dependencies = [ "lazy_static", ] @@ -4820,9 +4914,9 @@ dependencies = [ [[package]] name = "shlex" -version = "1.1.0" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43b2853a4d09f215c24cc5489c992ce46052d359b5109343cbafbf26bc62f8a3" +checksum = "a7cee0529a6d40f580e7a5e6c495c8fbfe21b7b52795ed4bb5e62cdf92bc6380" [[package]] name = "signal-hook" @@ -4875,9 +4969,9 @@ dependencies = [ [[package]] name = "smallvec" -version = "1.11.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62bb4feee49fdd9f707ef802e22365a35de4b7b299de4763d44bfea899442ff9" +checksum = "942b4a808e05215192e39f4ab80813e599068285906cc91aa64f923db842bd5a" dependencies = [ "serde", ] @@ -4915,14 +5009,20 @@ dependencies = [ [[package]] name = "socket2" -version = "0.5.3" +version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2538b18701741680e0322a2302176d3253a35388e2e62f172f64f4f16605f877" +checksum = "4031e820eb552adee9295814c0ced9e5cf38ddf1e8b7d566d6de8e2538ea989e" dependencies = [ "libc", "windows-sys 0.48.0", ] +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + [[package]] name = "sptr" version = "0.3.2" @@ -4964,6 +5064,12 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81cdd64d312baedb58e21336b31bc043b77e01cc99033ce76ef539f78e965ebc" +[[package]] +name = "svg" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d815ad337e8449d2374d4248448645edfe74e699343dd5719139d93fa87112" + [[package]] name = "syn" version = "1.0.109" @@ -5004,6 +5110,27 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "system-configuration" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "target-lexicon" version = "0.12.11" @@ -5034,18 +5161,18 @@ dependencies = [ [[package]] name = "thiserror" -version = "1.0.47" +version = "1.0.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97a802ec30afc17eee47b2855fc72e0c4cd62be9b4efe6591edde0ec5bd68d8f" +checksum = "1177e8c6d7ede7afde3585fd2513e611227efd6481bd78d2e82ba1ce16557ed4" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.47" +version = "1.0.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6bb623b56e39ab7dcd4b1b98bb6c8f8d907ed255b18de254088016b27a8ee19b" +checksum = "10712f02019e9288794769fba95cd6847df9874d49d871d062172f9dd41bc4cc" dependencies = [ "proc-macro2", "quote", @@ -5084,9 +5211,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.28" +version = "0.3.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17f6bb557fd245c28e6411aa56b6403c689ad95061f50e4be16c274e70a17e48" +checksum = "426f806f4089c493dcac0d24c29c01e2c38baf8e30f1b716ee37e83d200b18fe" dependencies = [ "deranged", "itoa", @@ -5099,15 +5226,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.1" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7300fbefb4dadc1af235a9cef3737cea692a9d97e1b9cbcd4ebdae6f8868e6fb" +checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" [[package]] name = "time-macros" -version = "0.2.14" +version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a942f44339478ef67935ab2bbaec2fb0322496cf3cbe84b261e06ac3814c572" +checksum = "4ad70d68dba9e1f8aceda7aa6711965dfec1cac869f311a51bd08b3a2ccbce20" dependencies = [ "time-core", ] @@ -5150,7 +5277,7 @@ dependencies = [ "num_cpus", "pin-project-lite", "signal-hook-registry", - "socket2 0.5.3", + "socket2 0.5.4", "tokio-macros", "windows-sys 0.48.0", ] @@ -5231,11 +5358,11 @@ checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" [[package]] name = "toml_edit" -version = "0.19.14" +version = "0.19.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8123f27e969974a3dfba720fdb560be359f57b44302d280ba72e76a74480e8a" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" dependencies = [ - "indexmap 2.0.0", + "indexmap 2.0.2", "toml_datetime", "winnow", ] @@ -5436,9 +5563,9 @@ dependencies = [ [[package]] name = "typenum" -version = "1.16.0" +version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "497961ef93d974e23eb6f433eb5fe1b7930b659f06d12dec6fc44a8f554c0bba" +checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" [[package]] name = "ucd-trie" @@ -5469,9 +5596,9 @@ checksum = "98e90c70c9f0d4d1ee6d0a7d04aa06cb9bbd53d8cfbdd62a0269a7c2eb640552" [[package]] name = "unicode-ident" -version = "1.0.11" +version = "1.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "301abaae475aa91687eb82514b328ab47a211a533026cb25fc3e519b86adfc3c" +checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" [[package]] name = "unicode-normalization" @@ -5490,9 +5617,9 @@ checksum = "1dd624098567895118886609431a7c3b8f516e41d30e0643f03d94592a147e36" [[package]] name = "unicode-width" -version = "0.1.10" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0edd1e5b14653f783770bce4a4dabb4a5108a5370a5f5d8cfe8710c361f6c8b" +checksum = "e51733f11c9c4f72aa0c160008246859e340b00807569a0da0e7a1079b27ba85" [[package]] name = "unicode-xid" @@ -5807,14 +5934,21 @@ dependencies = [ ] [[package]] -name = "which" -version = "4.4.0" +name = "weezl" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2441c784c52b289a054b7201fc93253e288f094e2f4be9058343127c4226a269" +checksum = "9193164d4de03a926d909d3bc7c30543cecb35400c02114792c2cae20d5e2dbb" + +[[package]] +name = "which" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" dependencies = [ "either", - "libc", + "home", "once_cell", + "rustix", ] [[package]] @@ -5845,9 +5979,9 @@ checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" [[package]] name = "winapi-util" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178" +checksum = "f29e6f9198ba0d26b4c9f07dbe6f9ed633e1f3d5b8b414090084349e46a52596" dependencies = [ "winapi", ] diff --git a/Cargo.toml b/Cargo.toml index 9b6e9c7ba..0b5705f35 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -88,15 +88,16 @@ argon2 = { version = "0.5.2", features = ["alloc"] } async-recursion = "1.0.5" async-trait = "^0.1.73" axum = { version = "0.6.20", features = [ - "json", - "http2", - "macros", - "tracing", + "form", "headers", + "http2", + "http2", + "json", + "macros", + "multipart", "original-uri", "query", - "form", - "http2", + "tracing", ] } axum-csp = { version = "0.0.5" } base32 = "^0.4.0" @@ -128,6 +129,11 @@ hex = "^0.4.3" hyper = { version = "0.14.27", features = ["full"] } hyper-tls = "0.5.0" idlset = "^0.2.4" +image = { version = "0.24.7", default-features = false, features = [ + "gif", + "jpeg", + "webp", +] } enum-iterator = "1.4.0" js-sys = "^0.3.63" # REMOVE this @@ -138,6 +144,7 @@ ldap3_proto = { version = "^0.3.5", features = ["serde"] } libc = "^0.2.148" libnss = "^0.4.0" libsqlite3-sys = "^0.25.0" +lodepng = "3.7.2" lru = "^0.8.0" mathru = "^0.13.0" notify-debouncer-full = { version = "0.1" } @@ -174,6 +181,7 @@ sketching = { path = "./libs/sketching" } smartstring = "^1.0.1" smolset = "^1.3.1" sshkeys = "^0.3.1" +svg = "0.13.1" syn = { version = "2.0.32", features = ["full"] } tempfile = "3.8.0" testkit-macros = { path = "./server/testkit-macros" } diff --git a/artwork/README.md b/artwork/README.md index 669f7de67..e833d4b1e 100644 --- a/artwork/README.md +++ b/artwork/README.md @@ -1,8 +1,8 @@ -## About these artworks +# About these artworks The original artworks were commissioned and produced by Jesse Irwin (tw: @wizardfortress). -The christmas logo was donated and produced by @ateneatla ( https://github.com/ateneatla/ ) +The christmas logo was donated and produced by [@ateneatla](https://github.com/ateneatla/). The recursive logo was donated and produced by Pi-Cla diff --git a/book/src/developers/designs/auth.rst b/book/src/developers/designs/auth.rst index 41a20c44a..b9a9fcdaa 100644 --- a/book/src/developers/designs/auth.rst +++ b/book/src/developers/designs/auth.rst @@ -55,7 +55,7 @@ disconnected from the network. Sudo on workstation =================== -These are re-use of the above two scenarios. +These are reuse of the above two scenarios. Access to VPN or Wifi ===================== diff --git a/book/src/developers/designs/device-authentication.rst b/book/src/developers/designs/device-authentication.rst index 1c3285242..489f70e41 100644 --- a/book/src/developers/designs/device-authentication.rst +++ b/book/src/developers/designs/device-authentication.rst @@ -73,7 +73,7 @@ authenticator for the laptops webauthn: * (phone) Login to website with password + roaming authenticator * (phone) Enroll webauthn for phone SE to account -While this process does not invole as much fiddling with TOTP, it still has weaknesses. +While this process does not involve as much fiddling with TOTP, it still has weaknesses. * The user is expected to own a roaming authenticator capable of working on their phone * The user is expected to understand different classes of MFA and how they are device bound or not diff --git a/book/src/developers/designs/kanidm-trust.rst b/book/src/developers/designs/kanidm-trust.rst index 58190132e..770080454 100644 --- a/book/src/developers/designs/kanidm-trust.rst +++ b/book/src/developers/designs/kanidm-trust.rst @@ -189,7 +189,7 @@ or to provide the required information to the remote domain. We would do a normal auth process, but on determining this is a trust account, we have to return a response to the core.rs layer. This should then trigger an async request to domain B which contains the request. When this is returned, we then complete the request to the client. This does -increase the liklihood of issues or delays in processing in the domain A IO layers if many requests +increase the likelihood of issues or delays in processing in the domain A IO layers if many requests exist at the same time. if multiple urls exist in the trustanchor, we should choose randomly which to contact for diff --git a/book/src/developers/designs/oauth2_refresh_tokens.md b/book/src/developers/designs/oauth2_refresh_tokens.md index e7f7ef598..c04f74023 100644 --- a/book/src/developers/designs/oauth2_refresh_tokens.md +++ b/book/src/developers/designs/oauth2_refresh_tokens.md @@ -76,7 +76,7 @@ already with our client authorisation checks. This is discussed in In this design we associate a "not issued before" (NIB) timestamp to our sessions. For a refresh token to be valid for issuance, the refresh tokens IAT must be greater than or equal to the NIB. -In this example were the refresh token with IAT 1 re-used after the second token was issued, then +In this example were the refresh token with IAT 1 reused after the second token was issued, then this condition would fail as the NIB has advanced to 2. Since IAT 1 is not greater or equal to NIB 2 then the refresh token _must_ have previously been used for access token exchange. @@ -132,7 +132,7 @@ hinders the ability to attack this for very little gain. ## Attack Detection [draft oauth security topics section 4.14.2](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics#section-4.14.2) -specifically calls out that when refresh token re-use is detected then all tokens of the session +specifically calls out that when refresh token reuse is detected then all tokens of the session should be canceled to cause a new authorisation code flow to be initiated. ## Inactive Refresh Tokens @@ -149,7 +149,7 @@ consistency plugin that already exists. Since the act of refreshing a token is implied activity then we do not require other signaling mechanisms. -# Questions +## Questions Currently with authorisation code grants and sessions we issue these where the sessions are recorded in an async manner. For consistency I believe the same should be true here but is there a concern diff --git a/book/src/developers/designs/recycle_bin.rst b/book/src/developers/designs/recycle_bin.rst index 5995f9843..b4a6c1d59 100644 --- a/book/src/developers/designs/recycle_bin.rst +++ b/book/src/developers/designs/recycle_bin.rst @@ -26,7 +26,7 @@ An option is to scan the filter for and Eq(class, deleted) terms, and if present and operation. A possibly better option is that filter constructors should have two constructors. One that -adds the wrapping AndNot term, and one that does not. This way the plugin implementor only +adds the wrapping AndNot term, and one that does not. This way the plugin implementer only needs to construct from the correct call, and they would exclude / include recycled items. This also would allow externally supplied filters to be correctly wrapped. The main consideration here is that it would require another api endpoint allowing recycle-bin searches. This is probably not diff --git a/book/src/developers/faq.md b/book/src/developers/faq.md index e36eed102..483f94495 100644 --- a/book/src/developers/faq.md +++ b/book/src/developers/faq.md @@ -1,4 +1,4 @@ -## Frequently Asked Questions +# Frequently Asked Questions This is a list of common questions that are generally raised by developers or technical users. @@ -28,7 +28,7 @@ parts. This creates production fragility and issues such as: This last point is key. It is a critical part of kanidm that the following must work on all machines, and run every single test in the suite. -``` +```shell git clone https://github.com/kanidm/kanidm.git cd kanidm cargo test @@ -46,7 +46,7 @@ where it would not be possible to effectively test for all developers. ## Why don't you use Raft/Etcd/MongoDB/Other to solve replication? There are a number of reasons why these are generally not compatible. Generally these databases or -technolgies do solve problems, but they are not the problems in Kanidm. +technologies do solve problems, but they are not the problems in Kanidm. ## CAP theorem @@ -105,7 +105,7 @@ Name Service Switch (NSS) is used for connecting the computers with different da resolve name-service information. By adding the nsswitch libraries to /etc/nsswitch.conf, we are telling NSS to lookup password info and group identities in Kanidm: -``` +```text passwd: compat kanidm group: compat kanidm ``` diff --git a/book/src/integrations/ldap.md b/book/src/integrations/ldap.md index 23ba34845..c1317fce5 100644 --- a/book/src/integrations/ldap.md +++ b/book/src/integrations/ldap.md @@ -88,7 +88,7 @@ To configure Kanidm to provide LDAP, add the argument to the `server.toml` confi ldapbindaddress = "127.0.0.1:3636" ``` -You should configure TLS certificates and keys as usual - LDAP will re-use the Web server TLS +You should configure TLS certificates and keys as usual - LDAP will reuse the Web server TLS material. ## Showing LDAP Entries and Attribute Maps diff --git a/book/src/monitoring.md b/book/src/monitoring.md index c05d422db..b782c05a7 100644 --- a/book/src/monitoring.md +++ b/book/src/monitoring.md @@ -1,7 +1,7 @@ # Monitoring the platform The monitoring design of Kanidm is still very much in its infancy - -[take part in the dicussion at github.com/kanidm/kanidm/issues/216](https://github.com/kanidm/kanidm/issues/216). +[take part in the discussion at github.com/kanidm/kanidm/issues/216](https://github.com/kanidm/kanidm/issues/216). ## kanidmd diff --git a/libs/client/Cargo.toml b/libs/client/Cargo.toml index d869c0f1e..b8b400141 100644 --- a/libs/client/Cargo.toml +++ b/libs/client/Cargo.toml @@ -13,7 +13,9 @@ repository = { workspace = true } [dependencies] tracing = { workspace = true } -reqwest = { workspace = true, default-features = false } +reqwest = { workspace = true, default-features = false, features = [ + "multipart", +] } kanidm_proto = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } diff --git a/libs/client/src/lib.rs b/libs/client/src/lib.rs index 117a91f22..d7a52306e 100644 --- a/libs/client/src/lib.rs +++ b/libs/client/src/lib.rs @@ -27,6 +27,7 @@ use std::time::Duration; use kanidm_proto::constants::{APPLICATION_JSON, ATTR_NAME}; use kanidm_proto::v1::*; use reqwest::header::CONTENT_TYPE; +use reqwest::Response; pub use reqwest::StatusCode; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; @@ -561,7 +562,7 @@ impl KanidmClient { } /// You've got the response from a reqwest and you want to turn it into a `ClientError` - fn handle_response_error(&self, error: reqwest::Error) -> ClientError { + pub fn handle_response_error(&self, error: reqwest::Error) -> ClientError { if error.is_connect() { if find_reqwest_error_source::(&error).is_some() { // TODO: one day handle IO errors better @@ -582,6 +583,18 @@ impl KanidmClient { ClientError::Transport(error) } + fn get_kopid_from_response(&self, response: &Response) -> String { + let opid = response + .headers() + .get(KOPID) + .and_then(|hv| hv.to_str().ok()) + .unwrap_or("missing_kopid") + .to_string(); + + debug!("opid -> {:?}", opid); + opid + } + async fn perform_simple_post_request( &self, dest: &str, @@ -602,13 +615,7 @@ impl KanidmClient { self.expect_version(&response).await; - let opid = response - .headers() - .get(KOPID) - .and_then(|hv| hv.to_str().ok()) - .unwrap_or("missing_kopid") - .to_string(); - debug!("opid -> {:?}", opid); + let opid = self.get_kopid_from_response(&response); match response.status() { reqwest::StatusCode::OK => {} @@ -679,12 +686,7 @@ impl KanidmClient { .and_then(|hv| hv.to_str().ok().map(str::to_string)); } - let opid = headers - .get(KOPID) - .and_then(|hv| hv.to_str().ok()) - .unwrap_or("missing_kopid") - .to_string(); - debug!("opid -> {:?}", opid); + let opid = self.get_kopid_from_response(&response); match response.status() { reqwest::StatusCode::OK => {} @@ -731,13 +733,7 @@ impl KanidmClient { self.expect_version(&response).await; - let opid = response - .headers() - .get(KOPID) - .and_then(|hv| hv.to_str().ok()) - .unwrap_or("missing_kopid") - .to_string(); - debug!("opid -> {:?}", opid); + let opid = self.get_kopid_from_response(&response); match response.status() { reqwest::StatusCode::OK => {} @@ -784,14 +780,7 @@ impl KanidmClient { self.expect_version(&response).await; - let opid = response - .headers() - .get(KOPID) - .and_then(|hv| hv.to_str().ok()) - .unwrap_or("missing_kopid") - .to_string(); - - debug!("opid -> {:?}", opid); + let opid = self.get_kopid_from_response(&response); match response.status() { reqwest::StatusCode::OK => {} @@ -838,13 +827,7 @@ impl KanidmClient { self.expect_version(&response).await; - let opid = response - .headers() - .get(KOPID) - .and_then(|hv| hv.to_str().ok()) - .unwrap_or("missing_kopid") - .to_string(); - debug!("opid -> {:?}", opid); + let opid = self.get_kopid_from_response(&response); match response.status() { reqwest::StatusCode::OK => {} @@ -885,14 +868,7 @@ impl KanidmClient { self.expect_version(&response).await; - let opid = response - .headers() - .get(KOPID) - .and_then(|hv| hv.to_str().ok()) - .unwrap_or("missing_kopid") - .to_string(); - - debug!("opid -> {:?}", opid); + let opid = self.get_kopid_from_response(&response); match response.status() { reqwest::StatusCode::OK => {} @@ -933,13 +909,7 @@ impl KanidmClient { self.expect_version(&response).await; - let opid = response - .headers() - .get(KOPID) - .and_then(|hv| hv.to_str().ok()) - .unwrap_or("missing_kopid") - .to_string(); - debug!("opid -> {:?}", opid); + let opid = self.get_kopid_from_response(&response); match response.status() { reqwest::StatusCode::OK => {} @@ -986,13 +956,7 @@ impl KanidmClient { self.expect_version(&response).await; - let opid = response - .headers() - .get(KOPID) - .and_then(|hv| hv.to_str().ok()) - .unwrap_or("missing_kopid") - .to_string(); - debug!("opid -> {:?}", opid); + let opid = self.get_kopid_from_response(&response); match response.status() { reqwest::StatusCode::OK => {} @@ -1459,14 +1423,7 @@ impl KanidmClient { self.expect_version(&response).await; - let opid = response - .headers() - .get(KOPID) - .and_then(|hv| hv.to_str().ok()) - .unwrap_or("missing_kopid") - .to_string(); - debug!("opid -> {:?}", opid); - + let opid = self.get_kopid_from_response(&response); match response.status() { // Continue to process. reqwest::StatusCode::OK => {} diff --git a/libs/client/src/oauth.rs b/libs/client/src/oauth.rs index 2754c1ddf..6d686def5 100644 --- a/libs/client/src/oauth.rs +++ b/libs/client/src/oauth.rs @@ -3,7 +3,9 @@ use kanidm_proto::constants::{ ATTR_DISPLAYNAME, ATTR_OAUTH2_ALLOW_INSECURE_CLIENT_DISABLE_PKCE, ATTR_OAUTH2_JWT_LEGACY_CRYPTO_ENABLE, ATTR_OAUTH2_RS_NAME, ATTR_OAUTH2_RS_ORIGIN, }; +use kanidm_proto::internal::ImageValue; use kanidm_proto::v1::Entry; +use reqwest::multipart; use std::collections::BTreeMap; impl KanidmClient { @@ -179,6 +181,74 @@ impl KanidmClient { .await } + /// Want to delete the image associated with a resource server? Here's your thing! + pub async fn idm_oauth2_rs_delete_image(&self, id: &str) -> Result<(), ClientError> { + self.perform_delete_request(format!("/v1/oauth2/{}/_image", id).as_str()) + .await + } + + /// Want to add/update the image associated with a resource server? Here's your thing! + pub async fn idm_oauth2_rs_update_image( + &self, + id: &str, + image: ImageValue, + ) -> Result<(), ClientError> { + let file_content_type = image.filetype.as_content_type_str(); + + let file_data = match multipart::Part::bytes(image.contents.clone()) + .file_name(image.filename) + .mime_str(file_content_type) + { + Ok(part) => part, + Err(err) => { + error!( + "Failed to generate multipart body from image data: {:}", + err + ); + return Err(ClientError::SystemError); + } + }; + + let form = multipart::Form::new().part("image", file_data); + + // send it + let response = self + .client + .post(self.make_url(&format!("/v1/oauth2/{}/_image", id))) + .multipart(form); + + let response = { + let tguard = self.bearer_token.read().await; + if let Some(token) = &(*tguard) { + response.bearer_auth(token) + } else { + response + } + }; + let response = response + .send() + .await + .map_err(|err| self.handle_response_error(err))?; + self.expect_version(&response).await; + + let opid = self.get_kopid_from_response(&response); + + match response.status() { + reqwest::StatusCode::OK => {} + unexpect => { + return Err(ClientError::Http( + unexpect, + response.json().await.ok(), + opid, + )) + } + } + response + .json() + .await + .map_err(|e| ClientError::JsonDecode(e, opid)) + } + pub async fn idm_oauth2_rs_enable_pkce(&self, id: &str) -> Result<(), ClientError> { let mut update_oauth2_rs = Entry { attrs: BTreeMap::new(), diff --git a/libs/file_permissions/src/lib.rs b/libs/file_permissions/src/lib.rs index 7274bddd5..9eb0a185b 100644 --- a/libs/file_permissions/src/lib.rs +++ b/libs/file_permissions/src/lib.rs @@ -38,12 +38,6 @@ pub fn readonly(meta: &Metadata) -> bool { #[cfg(target_family = "unix")] #[test] fn test_readonly() { - // check if the file Cargo.toml exists - use std::path::Path; - if Path::new("Cargo.toml").exists() == false { - panic!("Can't find Cargo.toml"); - } - let meta = std::fs::metadata("Cargo.toml").expect("Can't find Cargo.toml"); println!("meta={:?} -> readonly={:?}", meta, readonly(&meta)); assert!(readonly(&meta) == false); diff --git a/proto/src/constants.rs b/proto/src/constants.rs index 622ada270..a8544c8ec 100644 --- a/proto/src/constants.rs +++ b/proto/src/constants.rs @@ -1,4 +1,19 @@ -/// Because consistency is great! +//! Because consistency is great! + +pub const CONTENT_TYPE_JPG: &str = "image/jpeg"; +pub const CONTENT_TYPE_PNG: &str = "image/png"; +pub const CONTENT_TYPE_GIF: &str = "image/gif"; +pub const CONTENT_TYPE_SVG: &str = "image/svg+xml"; +pub const CONTENT_TYPE_WEBP: &str = "image/webp"; + +// for when the user uploads things to the various image endpoints +pub const VALID_IMAGE_UPLOAD_CONTENT_TYPES: [&str; 5] = [ + CONTENT_TYPE_JPG, + CONTENT_TYPE_PNG, + CONTENT_TYPE_GIF, + CONTENT_TYPE_SVG, + CONTENT_TYPE_WEBP, +]; pub const APPLICATION_JSON: &str = "application/json"; @@ -68,6 +83,7 @@ pub const ATTR_GIDNUMBER: &str = "gidnumber"; pub const ATTR_GRANT_UI_HINT: &str = "grant_ui_hint"; pub const ATTR_GROUP: &str = "group"; pub const ATTR_ID_VERIFICATION_ECKEY: &str = "id_verification_eckey"; +pub const ATTR_IMAGE: &str = "image"; pub const ATTR_INDEX: &str = "index"; pub const ATTR_IPANTHASH: &str = "ipanthash"; pub const ATTR_IPASSHPUBKEY: &str = "ipasshpubkey"; diff --git a/proto/src/internal.rs b/proto/src/internal.rs index 965e81ea1..1f529f1cc 100644 --- a/proto/src/internal.rs +++ b/proto/src/internal.rs @@ -1,3 +1,6 @@ +use crate::constants::{ + CONTENT_TYPE_GIF, CONTENT_TYPE_JPG, CONTENT_TYPE_PNG, CONTENT_TYPE_SVG, CONTENT_TYPE_WEBP, +}; use crate::v1::ApiTokenPurpose; use serde::{Deserialize, Serialize}; use url::Url; @@ -45,3 +48,83 @@ pub enum IdentifyUserResponse { CodeFailure, InvalidUserId, } + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Hash, Ord, PartialOrd)] +#[serde(rename_all = "lowercase")] +pub enum ImageType { + Png, + Jpg, + Gif, + Svg, + Webp, +} + +impl TryFrom<&str> for ImageType { + type Error = &'static str; + /// ``` + /// use kanidm_proto::internal::ImageType; + /// assert_eq!(ImageType::try_from("png").unwrap(), ImageType::Png); + /// assert!(ImageType::try_from("krabs").is_err()); + /// ``` + fn try_from(value: &str) -> Result { + #[allow(clippy::panic)] + match value { + "png" => Ok(Self::Png), + "jpg" => Ok(Self::Jpg), + "jpeg" => Ok(Self::Jpg), // ugh I hate this + "gif" => Ok(Self::Gif), + "svg" => Ok(Self::Svg), + "webp" => Ok(Self::Webp), + _ => Err("Invalid image type!"), + } + } +} + +impl ImageType { + pub fn try_from_content_type(content_type: &str) -> Result { + let content_type = content_type.to_lowercase(); + match content_type.as_str() { + CONTENT_TYPE_JPG => Ok(ImageType::Jpg), + CONTENT_TYPE_PNG => Ok(ImageType::Png), + CONTENT_TYPE_GIF => Ok(ImageType::Gif), + CONTENT_TYPE_WEBP => Ok(ImageType::Webp), + CONTENT_TYPE_SVG => Ok(ImageType::Svg), + _ => Err(format!("Invalid content type: {}", content_type)), + } + } + + pub fn as_content_type_str(&self) -> &'static str { + match &self { + ImageType::Jpg => CONTENT_TYPE_JPG, + ImageType::Png => CONTENT_TYPE_PNG, + ImageType::Gif => CONTENT_TYPE_GIF, + ImageType::Webp => CONTENT_TYPE_WEBP, + ImageType::Svg => CONTENT_TYPE_SVG, + } + } +} + +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize, Debug, PartialOrd, Ord, Hash)] +pub struct ImageValue { + pub filename: String, + pub filetype: ImageType, + pub contents: Vec, +} + +impl TryFrom<&str> for ImageValue { + type Error = String; + fn try_from(s: &str) -> Result { + serde_json::from_str(s) + .map_err(|e| format!("Failed to decode ImageValue from {} - {:?}", s, e)) + } +} + +impl ImageValue { + pub fn new(filename: String, filetype: ImageType, contents: Vec) -> Self { + Self { + filename, + filetype, + contents, + } + } +} diff --git a/server/core/src/actors/v1_read.rs b/server/core/src/actors/v1_read.rs index 663cd98c2..4d37ca358 100644 --- a/server/core/src/actors/v1_read.rs +++ b/server/core/src/actors/v1_read.rs @@ -4,7 +4,7 @@ use std::net::IpAddr; use std::path::{Path, PathBuf}; use std::sync::Arc; -use kanidm_proto::internal::{AppLink, IdentifyUserRequest, IdentifyUserResponse}; +use kanidm_proto::internal::{AppLink, IdentifyUserRequest, IdentifyUserResponse, ImageValue}; use kanidm_proto::v1::{ ApiToken, AuthIssueSession, AuthRequest, BackupCodesView, CURequest, CUSessionToken, CUStatus, CredentialStatus, Entry as ProtoEntry, OperationError, RadiusAuthToken, SearchRequest, @@ -402,6 +402,45 @@ impl QueryServerReadV1 { }) } + #[instrument(level = "debug", skip_all)] + /// pull an image so we can present it to the user + pub async fn handle_oauth2_rs_image_get_image( + &self, + uat: Option, + rs: Filter, + ) -> Result { + let mut idms_prox_read = self.idms.proxy_read().await; + let ct = duration_from_epoch_now(); + + let ident = idms_prox_read + .validate_and_parse_token_to_ident(uat.as_deref(), ct) + .map_err(|e| { + admin_error!(err = ?e, "Invalid identity in handle_oauth2_rs_image_get_image {:?}", uat); + e + })?; + let attrs = vec![Attribute::Image.to_string()]; + + let search = SearchEvent::from_internal_message( + ident, + &rs, + Some(attrs.as_slice()), + &mut idms_prox_read.qs_read, + )?; + + let entries = idms_prox_read.qs_read.search(&search)?; + if entries.is_empty() { + return Err(OperationError::NoMatchingEntries); + } + let entry = match entries.first() { + Some(entry) => entry, + None => return Err(OperationError::NoMatchingEntries), + }; + match entry.get_ava_single_image(Attribute::Image) { + Some(image) => Ok(image), + None => Err(OperationError::NoMatchingEntries), + } + } + #[instrument( level = "info", skip_all, diff --git a/server/core/src/actors/v1_write.rs b/server/core/src/actors/v1_write.rs index db5808482..ac560e8c7 100644 --- a/server/core/src/actors/v1_write.rs +++ b/server/core/src/actors/v1_write.rs @@ -1,6 +1,6 @@ -use std::time::Duration; use std::{iter, sync::Arc}; +use kanidm_proto::internal::ImageValue; use kanidm_proto::v1::{ AccountUnixExtend, CUIntentToken, CUSessionToken, CUStatus, CreateRequest, DeleteRequest, Entry as ProtoEntry, GroupUnixExtend, Modify as ProtoModify, ModifyList as ProtoModifyList, @@ -88,7 +88,7 @@ impl QueryServerWriteV1 { ) { Ok(m) => m, Err(e) => { - admin_error!(err=?e, "Failed to begin modify"); + admin_error!(err=?e, "Failed to begin modify during modify_from_parts"); return Err(e); } }; @@ -139,7 +139,7 @@ impl QueryServerWriteV1 { ) { Ok(m) => m, Err(e) => { - admin_error!(err = ?e, "Failed to begin modify"); + admin_error!(err = ?e, "Failed to begin modify during modify_from_internal_parts"); return Err(e); } }; @@ -212,7 +212,7 @@ impl QueryServerWriteV1 { let mdf = match ModifyEvent::from_message(ident, &req, &mut idms_prox_write.qs_write) { Ok(m) => m, Err(e) => { - admin_error!(err = ?e, "Failed to begin modify"); + admin_error!(err = ?e, "Failed to begin modify during handle_modify"); return Err(e); } }; @@ -292,7 +292,7 @@ impl QueryServerWriteV1 { let mdf = ModifyEvent::from_internal_parts(ident, &modlist, &filter, &idms_prox_write.qs_write) .map_err(|e| { - admin_error!(err = ?e, "Failed to begin modify"); + admin_error!(err = ?e, "Failed to begin modify during handle_internalpatch"); e })?; @@ -892,7 +892,7 @@ impl QueryServerWriteV1 { ) { Ok(m) => m, Err(e) => { - admin_error!(err = ?e, "Failed to begin modify"); + admin_error!(err = ?e, "Failed to begin modify during purge attribute"); return Err(e); } }; @@ -1181,6 +1181,72 @@ impl QueryServerWriteV1 { .map(|_| ()) } + #[instrument(level = "debug", skip_all)] + pub async fn handle_oauth2_rs_image_delete( + &self, + uat: Option, + rs: Filter, + ) -> Result<(), OperationError> { + let mut idms_prox_write = self.idms.proxy_write(duration_from_epoch_now()).await; + let ct = duration_from_epoch_now(); + + let ident = idms_prox_write + .validate_and_parse_token_to_ident(uat.as_deref(), ct) + .map_err(|e| { + admin_error!(err = ?e, "Invalid identity in handle_oauth2_rs_image_delete {:?}", uat); + e + })?; + let ml = ModifyList::new_purge(Attribute::Image); + let mdf = match ModifyEvent::from_internal_parts(ident, &ml, &rs, &idms_prox_write.qs_write) + { + Ok(m) => m, + Err(e) => { + admin_error!(err = ?e, "Failed to begin modify during handle_oauth2_rs_image_delete"); + return Err(e); + } + }; + idms_prox_write + .qs_write + .modify(&mdf) + .and_then(|_| idms_prox_write.commit().map(|_| ())) + } + + #[instrument(level = "debug", skip_all)] + pub async fn handle_oauth2_rs_image_update( + &self, + uat: Option, + rs: Filter, + image: ImageValue, + ) -> Result<(), OperationError> { + let mut idms_prox_write = self.idms.proxy_write(duration_from_epoch_now()).await; + let ct = duration_from_epoch_now(); + + let ident = idms_prox_write + .validate_and_parse_token_to_ident(uat.as_deref(), ct) + .map_err(|e| { + admin_error!(err = ?e, "Invalid identity in handle_oauth2_rs_image_update {:?}", uat); + e + })?; + + let ml = ModifyList::new_purge_and_set(Attribute::Image, Value::Image(image)); + + let mdf = match ModifyEvent::from_internal_parts(ident, &ml, &rs, &idms_prox_write.qs_write) + { + Ok(m) => m, + Err(e) => { + admin_error!(err = ?e, "Failed to begin modify during handle_oauth2_rs_image_update"); + return Err(e); + } + }; + + trace!(?mdf, "Begin modify event"); + + idms_prox_write + .qs_write + .modify(&mdf) + .and_then(|_| idms_prox_write.commit().map(|_| ())) + } + #[instrument( level = "info", skip_all, diff --git a/server/core/src/https/mod.rs b/server/core/src/https/mod.rs index 2f4930fd5..4a2987777 100644 --- a/server/core/src/https/mod.rs +++ b/server/core/src/https/mod.rs @@ -208,7 +208,8 @@ pub async fn create_https_server( .route("/", get(|| async { Redirect::temporary("/ui") })) .route("/manifest.webmanifest", get(manifest::manifest)) .nest("/ui", spa_router) - .layer(middleware::compression::new()) // TODO: this needs to be configured properly + .layer(middleware::compression::new()) + .route("/ui/images/oauth2/:rs_name", get(oauth2::oauth2_image_get)) } ServerRole::WriteReplicaNoUI => Router::new(), }; diff --git a/server/core/src/https/oauth2.rs b/server/core/src/https/oauth2.rs index 3cead9177..abe72a1a0 100644 --- a/server/core/src/https/oauth2.rs +++ b/server/core/src/https/oauth2.rs @@ -14,6 +14,7 @@ use http::header::{ use http::{HeaderMap, HeaderValue, StatusCode}; use hyper::Body; use kanidm_proto::constants::APPLICATION_JSON; +use kanidm_proto::internal::{ImageType, ImageValue}; use kanidm_proto::oauth2::{AuthorisationResponse, OidcDiscoveryResponse}; use kanidm_proto::v1::Entry as ProtoEntry; use kanidmd_lib::idm::oauth2::{ @@ -23,6 +24,7 @@ use kanidmd_lib::idm::oauth2::{ use kanidmd_lib::prelude::f_eq; use kanidmd_lib::prelude::*; use kanidmd_lib::value::PartialValue; +use kanidmd_lib::valueset::image::ImageValueThings; use serde::{Deserialize, Serialize}; pub struct HTTPOauth2Error(Oauth2Error); @@ -104,6 +106,7 @@ pub async fn oauth2_public_post( json_rest_event_post(state, classes, obj, kopid).await } +/// Get a filter matching a given OAuth2 Resource Server fn oauth2_id(rs_name: &str) -> Filter { filter_all!(f_and!([ f_eq(Attribute::Class, EntryClass::OAuth2ResourceServer.into()), @@ -222,6 +225,130 @@ pub async fn oauth2_id_delete( to_axum_response(res) } +/// this returns the image for the user if the user has permissions +pub async fn oauth2_image_get( + State(state): State, + Extension(kopid): Extension, + Path(rs_name): Path, +) -> Response { + let rs_filter = oauth2_id(&rs_name); + let res = state + .qe_r_ref + .handle_oauth2_rs_image_get_image(kopid.uat, rs_filter) + .await; + + let image = match res { + Ok(image) => image, + Err(_err) => { + admin_error!( + "Unable to get image for oauth2 resource server: {}", + rs_name + ); + #[allow(clippy::unwrap_used)] + return Response::builder() + .status(StatusCode::NOT_FOUND) + .body(Body::empty()) + .unwrap(); + } + }; + + #[allow(clippy::expect_used)] + Response::builder() + .header(CONTENT_TYPE, image.filetype.as_content_type_str()) + .body(Body::from(image.contents)) + .expect("Somehow failed to turn an image into a response!") +} + +pub async fn oauth2_id_image_delete( + State(state): State, + Extension(kopid): Extension, + Path(rs_name): Path, +) -> Response { + let rs_filter = oauth2_id(&rs_name); + let res = state + .qe_w_ref + .handle_oauth2_rs_image_delete(kopid.uat, rs_filter) + .await; + + to_axum_response(res) +} + +pub async fn oauth2_id_image_post( + State(state): State, + Extension(kopid): Extension, + Path(rs_name): Path, + mut multipart: axum::extract::Multipart, +) -> Response { + // because we might not get an image + let mut image: Option = None; + + while let Some(field) = multipart.next_field().await.unwrap_or(None) { + let filename = field.file_name().map(|f| f.to_string()).clone(); + if let Some(filename) = filename { + let content_type = field.content_type().map(|f| f.to_string()).clone(); + + let content_type = match content_type { + Some(val) => { + if VALID_IMAGE_UPLOAD_CONTENT_TYPES.contains(&val.as_str()) { + val + } else { + debug!("Invalid content type: {}", val); + let res = + to_axum_response::(Err(OperationError::InvalidRequestState)); + return res; + } + } + None => { + debug!("No content type header provided"); + let res = to_axum_response::(Err(OperationError::InvalidRequestState)); + return res; + } + }; + let data = match field.bytes().await { + Ok(val) => val, + Err(_e) => { + let res = to_axum_response::(Err(OperationError::InvalidRequestState)); + return res; + } + }; + + let filetype = match ImageType::try_from_content_type(&content_type) { + Ok(val) => val, + Err(_err) => { + let res = to_axum_response::(Err(OperationError::InvalidRequestState)); + return res; + } + }; + + image = Some(ImageValue { + filetype, + filename: filename.to_string(), + contents: data.to_vec(), + }); + }; + } + + let res = match image { + Some(image) => { + let image_validation_result = image.validate_image(); + if let Err(err) = image_validation_result { + admin_error!("Invalid image uploaded: {:?}", err); + return to_axum_response::(Err(OperationError::InvalidRequestState)); + } + + let rs_name = oauth2_id(&rs_name); + state + .qe_w_ref + .handle_oauth2_rs_image_update(kopid.uat, rs_name, image) + .await + } + None => Err(OperationError::InvalidAttribute( + "No image included, did you mean to use the DELETE method?".to_string(), + )), + }; + to_axum_response(res) +} + // == OAUTH2 PROTOCOL FLOW HANDLERS == // // oauth2 (partial) diff --git a/server/core/src/https/v1.rs b/server/core/src/https/v1.rs index 60ab310fc..12247aba8 100644 --- a/server/core/src/https/v1.rs +++ b/server/core/src/https/v1.rs @@ -1447,6 +1447,10 @@ pub fn router(state: ServerState) -> Router { .patch(super::oauth2::oauth2_id_patch) .delete(super::oauth2::oauth2_id_delete), ) + .route( + "/v1/oauth2/:rs_name/_image", + post(super::oauth2::oauth2_id_image_post).delete(super::oauth2::oauth2_id_image_delete), + ) .route( "/v1/oauth2/:rs_name/_basic_secret", get(super::oauth2::oauth2_id_get_basic_secret), diff --git a/server/core/src/lib.rs b/server/core/src/lib.rs index 93c46091d..40cab8f60 100644 --- a/server/core/src/lib.rs +++ b/server/core/src/lib.rs @@ -767,7 +767,7 @@ pub async fn create_server_core( Ok(_) => {} Err(e) => { error!( - "Unable to configure INTERGATION TEST admin account -> {:?}", + "Unable to configure INTEGRATION TEST admin account -> {:?}", e ); return Err(()); @@ -776,7 +776,7 @@ pub async fn create_server_core( match idms_prox_write.commit() { Ok(_) => {} Err(e) => { - error!("Unable to commit INTERGATION TEST setup -> {:?}", e); + error!("Unable to commit INTEGRATION TEST setup -> {:?}", e); return Err(()); } } diff --git a/server/lib/Cargo.toml b/server/lib/Cargo.toml index 44501721c..0848708a2 100644 --- a/server/lib/Cargo.toml +++ b/server/lib/Cargo.toml @@ -19,6 +19,10 @@ path = "src/lib.rs" name = "scaling_10k" harness = false +[[bench]] +name = "image_benches" +harness = false + [dependencies] base64 = { workspace = true } base64urlsafedata = { workspace = true } @@ -79,6 +83,14 @@ webauthn-rs = { workspace = true, features = [ webauthn-rs-core = { workspace = true } zxcvbn = { workspace = true } serde_with = { workspace = true } +hex.workspace = true +lodepng = { workspace = true } +image = { workspace = true, default-features = false, features = [ + "gif", + "jpeg", + "webp", +] } +svg = { workspace = true } # because windows really can't build without the bundled one [target.'cfg(target_family = "windows")'.dependencies] diff --git a/server/lib/benches/image_benches.rs b/server/lib/benches/image_benches.rs new file mode 100644 index 000000000..c2963d9aa --- /dev/null +++ b/server/lib/benches/image_benches.rs @@ -0,0 +1,138 @@ +/// This file contains benchmarks for the image module so we can work out the best order to run things in +use std::time::Duration; + +use criterion::{black_box, criterion_group, criterion_main, Criterion}; +use kanidmd_lib::valueset::image::jpg; +use kanidmd_lib::valueset::image::png; + +pub fn bench_png_lodepng_validate(c: &mut Criterion) { + let mut group = c.benchmark_group("png_lodepng_validate"); + group.bench_function("png_lodepng_validate_oversize", |b| { + let filename = black_box(format!( + "{}/src/valueset/image/test_images/oversize_dimensions.png", + env!("CARGO_MANIFEST_DIR") + )); + let contents = black_box(std::fs::read(filename).unwrap()); + b.iter(|| { + png::png_lodepng_validate(&contents, black_box(&"oversize_dimensions.png".to_string())) + }) + }); + group.bench_function("png_lodepng_validate_ok", |b| { + let filename = black_box(format!( + "{}/src/valueset/image/test_images/oversize_dimensions.png", + env!("CARGO_MANIFEST_DIR") + )); + let contents = black_box(std::fs::read(filename).unwrap()); + b.iter(|| { + png::png_lodepng_validate(&contents, black_box(&"oversize_dimensions.png".to_string())) + }) + }); + group.finish(); +} + +pub fn bench_png_has_trailer(c: &mut Criterion) { + let mut group = c.benchmark_group("png_has_trailer"); + group.bench_function("png_has_trailer_oversize", |b| { + let filename = black_box(format!( + "{}/src/valueset/image/test_images/oversize_dimensions.png", + env!("CARGO_MANIFEST_DIR") + )); + let contents = black_box(std::fs::read(filename).unwrap()); + b.iter(|| png::png_has_trailer(&contents)); + }); + group.bench_function("png_has_trailer_ok", |b| { + let filename = black_box(format!( + "{}/src/valueset/image/test_images/ok.png", + env!("CARGO_MANIFEST_DIR") + )); + let contents = black_box(std::fs::read(filename).unwrap()); + b.iter(|| png::png_has_trailer(&contents)); + }); + group.finish(); +} + +pub fn bench_jpg(c: &mut Criterion) { + let mut group = c.benchmark_group("jpg"); + group.bench_function("check_jpg_header", |b| { + let filename = black_box(format!( + "{}/src/valueset/image/test_images/oversize_dimensions.jpg", + env!("CARGO_MANIFEST_DIR") + )); + let contents = black_box(std::fs::read(filename).unwrap()); + b.iter(|| jpg::check_jpg_header(&contents)); + }); + group.bench_function("has_trailer", |b| { + let filename = black_box(format!( + "{}/src/valueset/image/test_images/oversize_dimensions.jpg", + env!("CARGO_MANIFEST_DIR") + )); + let contents = black_box(std::fs::read(filename).unwrap()); + b.iter(|| jpg::has_trailer(&contents)); + }); + group.bench_function("use_decoder", |b| { + let filename = black_box(format!( + "{}/src/valueset/image/test_images/oversize_dimensions.jpg", + env!("CARGO_MANIFEST_DIR") + )); + let contents = black_box(std::fs::read(&filename).unwrap()); + b.iter(|| jpg::validate_decoding(&filename, &contents, image::io::Limits::default())); + }); + group.finish(); +} + +pub fn compare_jpg(c: &mut Criterion) { + let mut group = c.benchmark_group("compare_jpg"); + group.bench_function("header, trailer, decoder", |b| { + let filename = black_box(format!( + "{}/src/valueset/image/test_images/oversize_dimensions.jpg", + env!("CARGO_MANIFEST_DIR") + )); + let contents = black_box(std::fs::read(&filename).unwrap()); + b.iter(|| { + assert!(jpg::check_jpg_header(&contents).is_ok()); + assert!(jpg::has_trailer(&contents).is_ok()); + assert!( + jpg::validate_decoding(&filename, &contents, image::io::Limits::default()).is_ok() + ); + }); + }); + group.bench_function("trailer, header, decoder", |b| { + let filename = black_box(format!( + "{}/src/valueset/image/test_images/oversize_dimensions.jpg", + env!("CARGO_MANIFEST_DIR") + )); + let contents = black_box(std::fs::read(&filename).unwrap()); + b.iter(|| { + assert!(jpg::has_trailer(&contents).is_ok()); + assert!(jpg::check_jpg_header(&contents).is_ok()); + assert!( + jpg::validate_decoding(&filename, &contents, image::io::Limits::default()).is_ok() + ); + }); + }); + group.bench_function("decoder, trailer, header", |b| { + let filename = black_box(format!( + "{}/src/valueset/image/test_images/oversize_dimensions.jpg", + env!("CARGO_MANIFEST_DIR") + )); + let contents = black_box(std::fs::read(&filename).unwrap()); + b.iter(|| { + assert!( + jpg::validate_decoding(&filename, &contents, image::io::Limits::default()).is_ok() + ); + assert!(jpg::has_trailer(&contents).is_ok()); + assert!(jpg::check_jpg_header(&contents).is_ok()); + }); + }); + + group.finish(); +} + +criterion_group!( + name = png_tests; + config = Criterion::default() + .measurement_time(Duration::from_secs(15)) + .with_plots(); + targets = bench_png_lodepng_validate, bench_png_has_trailer, bench_jpg, compare_jpg +); +criterion_main!(png_tests); diff --git a/server/lib/src/be/dbvalue.rs b/server/lib/src/be/dbvalue.rs index 0c8873743..bf6efc4c6 100644 --- a/server/lib/src/be/dbvalue.rs +++ b/server/lib/src/be/dbvalue.rs @@ -2,6 +2,7 @@ use std::fmt; use std::time::Duration; use hashbrown::HashSet; +use kanidm_proto::internal::ImageType; use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; use url::Url; @@ -506,6 +507,16 @@ pub enum DbValueOauth2Session { }, } +// Internal representation of an image +#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone)] +pub enum DbValueImage { + V1 { + filename: String, + filetype: ImageType, + contents: Vec, + }, +} + #[derive(Serialize, Deserialize, Debug)] pub enum DbValueV1 { #[serde(rename = "U8")] @@ -651,6 +662,8 @@ pub enum DbValueSetV2 { AuditLogString(Vec<(Cid, String)>), #[serde(rename = "EK")] EcKeyPrivate(Vec), + #[serde(rename = "IM")] + Image(Vec), } impl DbValueSetV2 { @@ -694,6 +707,7 @@ impl DbValueSetV2 { DbValueSetV2::UiHint(set) => set.len(), DbValueSetV2::TotpSecret(set) => set.len(), DbValueSetV2::AuditLogString(set) => set.len(), + DbValueSetV2::Image(set) => set.len(), DbValueSetV2::EcKeyPrivate(_key) => 1, // here we have to hard code it because the Vec // represents the bytes of SINGLE(!) key } diff --git a/server/lib/src/constants/acp.rs b/server/lib/src/constants/acp.rs index fa292e4cc..5d0614a60 100644 --- a/server/lib/src/constants/acp.rs +++ b/server/lib/src/constants/acp.rs @@ -1668,6 +1668,7 @@ lazy_static! { Attribute::Rs256PrivateKeyDer, Attribute::OAuth2JwtLegacyCryptoEnable, Attribute::OAuth2PreferShortUsername, + Attribute::Image, ], modify_removed_attrs: vec![ Attribute::Description, @@ -1684,6 +1685,7 @@ lazy_static! { Attribute::Rs256PrivateKeyDer, Attribute::OAuth2JwtLegacyCryptoEnable, Attribute::OAuth2PreferShortUsername, + Attribute::Image, ], modify_present_attrs: vec![ Attribute::Description, @@ -1696,6 +1698,7 @@ lazy_static! { Attribute::OAuth2AllowInsecureClientDisablePkce, Attribute::OAuth2JwtLegacyCryptoEnable, Attribute::OAuth2PreferShortUsername, + Attribute::Image, ], create_attrs: vec![ Attribute::Class, @@ -1709,6 +1712,7 @@ lazy_static! { Attribute::OAuth2AllowInsecureClientDisablePkce, Attribute::OAuth2JwtLegacyCryptoEnable, Attribute::OAuth2PreferShortUsername, + Attribute::Image, ], create_classes: vec![ EntryClass::Object, diff --git a/server/lib/src/constants/entries.rs b/server/lib/src/constants/entries.rs index a5d8b3016..3174b7880 100644 --- a/server/lib/src/constants/entries.rs +++ b/server/lib/src/constants/entries.rs @@ -88,6 +88,7 @@ pub enum Attribute { GrantUiHint, Group, IdVerificationEcKey, + Image, Index, IpaNtHash, IpaSshPubKey, @@ -262,6 +263,7 @@ impl TryFrom for Attribute { ATTR_GRANT_UI_HINT => Attribute::GrantUiHint, ATTR_GROUP => Attribute::Group, ATTR_ID_VERIFICATION_ECKEY => Attribute::IdVerificationEcKey, + ATTR_IMAGE => Attribute::Image, ATTR_INDEX => Attribute::Index, ATTR_IPANTHASH => Attribute::IpaNtHash, ATTR_IPASSHPUBKEY => Attribute::IpaSshPubKey, @@ -412,6 +414,7 @@ impl From for &'static str { Attribute::GrantUiHint => ATTR_GRANT_UI_HINT, Attribute::Group => ATTR_GROUP, Attribute::IdVerificationEcKey => ATTR_ID_VERIFICATION_ECKEY, + Attribute::Image => ATTR_IMAGE, Attribute::Index => ATTR_INDEX, Attribute::IpaNtHash => ATTR_IPANTHASH, Attribute::IpaSshPubKey => ATTR_IPASSHPUBKEY, diff --git a/server/lib/src/constants/schema.rs b/server/lib/src/constants/schema.rs index 49b6c7f5a..ab48de659 100644 --- a/server/lib/src/constants/schema.rs +++ b/server/lib/src/constants/schema.rs @@ -751,6 +751,7 @@ pub static ref SCHEMA_CLASS_OAUTH2_RS: SchemaClass = SchemaClass { Attribute::OAuth2JwtLegacyCryptoEnable.into(), Attribute::OAuth2PreferShortUsername.into(), Attribute::OAuth2RsOriginLanding.into(), + Attribute::Image.into(), ], systemmust: vec![ Attribute::OAuth2RsName.into(), diff --git a/server/lib/src/constants/uuids.rs b/server/lib/src/constants/uuids.rs index 1daa14106..130a747cb 100644 --- a/server/lib/src/constants/uuids.rs +++ b/server/lib/src/constants/uuids.rs @@ -238,6 +238,8 @@ pub const UUID_SCHEMA_ATTR_AUTH_SESSION_EXPIRY: Uuid = pub const UUID_SCHEMA_ATTR_AUTH_PRIVILEGE_EXPIRY: Uuid = uuid!("00000000-0000-0000-0000-ffff00000142"); +pub const UUID_SCHEMA_ATTR_IMAGE: Uuid = uuid!("00000000-0000-0000-0000-ffff00000143"); + // System and domain infos // I'd like to strongly criticise william of the past for making poor choices about these allocations. pub const UUID_SYSTEM_INFO: Uuid = uuid!("00000000-0000-0000-0000-ffffff000001"); diff --git a/server/lib/src/entry.rs b/server/lib/src/entry.rs index 5f46faa05..9d3e63217 100644 --- a/server/lib/src/entry.rs +++ b/server/lib/src/entry.rs @@ -30,7 +30,8 @@ use std::collections::{BTreeMap as Map, BTreeMap, BTreeSet}; use std::sync::Arc; use compact_jwt::JwsSigner; -use hashbrown::HashMap; +use hashbrown::{HashMap, HashSet}; +use kanidm_proto::internal::ImageValue; use kanidm_proto::v1::{ ConsistencyError, Entry as ProtoEntry, Filter as ProtoFilter, OperationError, SchemaError, UiHint, @@ -2519,6 +2520,22 @@ impl Entry { .and_then(|vs| vs.as_iutf8_set()) } + #[inline(always)] + pub fn get_ava_as_image(&self, attr: Attribute) -> Option<&HashSet> { + self.attrs + .get(attr.as_ref()) + .and_then(|vs| vs.as_imageset()) + } + + #[inline(always)] + pub fn get_ava_single_image(&self, attr: Attribute) -> Option { + let images = self + .attrs + .get(attr.as_ref()) + .and_then(|vs| vs.as_imageset())?; + images.iter().next().cloned() + } + #[inline(always)] pub fn get_ava_as_oauthscopes(&self, attr: Attribute) -> Option> { self.attrs diff --git a/server/lib/src/filter.rs b/server/lib/src/filter.rs index d1e458239..1aa60fdf9 100644 --- a/server/lib/src/filter.rs +++ b/server/lib/src/filter.rs @@ -1135,7 +1135,7 @@ impl FilterResolved { fn resolve_no_idx(fc: FilterComp, ev: &Identity) -> Option { // ⚠️ ⚠️ ⚠️ ⚠️ // Remember, this function means we have NO INDEX METADATA so we can only - // asssign slopes to values we can GUARANTEE will EXIST. + // assign slopes to values we can GUARANTEE will EXIST. match fc { FilterComp::Eq(a, v) => { // Since we have no index data, we manually configure a reasonable diff --git a/server/lib/src/idm/credupdatesession.rs b/server/lib/src/idm/credupdatesession.rs index c42b36eea..e3e4b0cb4 100644 --- a/server/lib/src/idm/credupdatesession.rs +++ b/server/lib/src/idm/credupdatesession.rs @@ -761,7 +761,7 @@ impl<'a> IdmServerProxyWriteTransaction<'a> { // Store the intent id in the session (if needed) so that we can check the state at the // end of the update. - // We need to pin the id from the intent token into the credential to ensure it's not re-used + // We need to pin the id from the intent token into the credential to ensure it's not reused // Need to change this to the expiry time, so we can purge up to. let session_id = uuid_from_duration(current_time + MAXIMUM_CRED_UPDATE_TTL, self.sid); diff --git a/server/lib/src/idm/oauth2.rs b/server/lib/src/idm/oauth2.rs index 70b5cec81..fd09f2f96 100644 --- a/server/lib/src/idm/oauth2.rs +++ b/server/lib/src/idm/oauth2.rs @@ -19,6 +19,7 @@ use concread::cowcell::*; use fernet::Fernet; use hashbrown::HashMap; use kanidm_proto::constants::*; + pub use kanidm_proto::oauth2::{ AccessTokenIntrospectRequest, AccessTokenIntrospectResponse, AccessTokenRequest, AccessTokenResponse, AuthorisationRequest, CodeChallengeMethod, ErrorResponse, GrantTypeReq, @@ -238,6 +239,8 @@ pub struct Oauth2RS { scopes_supported: BTreeSet, prefer_short_username: bool, type_: OauthRSType, + /// Does the RS have a custom image set? If not, we use the default. + has_custom_image: bool, } impl std::fmt::Debug for Oauth2RS { @@ -250,6 +253,7 @@ impl std::fmt::Debug for Oauth2RS { .field("origin", &self.origin) .field("scope_maps", &self.scope_maps) .field("sup_scope_maps", &self.sup_scope_maps) + .field("has_custom_image", &self.has_custom_image) .finish() } } @@ -416,6 +420,8 @@ impl<'a> Oauth2ResourceServersWriteTransaction<'a> { .get_ava_single_bool(Attribute::OAuth2PreferShortUsername) .unwrap_or(false); + let has_custom_image = ent.get_ava_single_image(Attribute::Image).is_some(); + let mut authorization_endpoint = self.inner.origin.clone(); authorization_endpoint.set_path("/ui/oauth2"); @@ -464,6 +470,7 @@ impl<'a> Oauth2ResourceServersWriteTransaction<'a> { scopes_supported, prefer_short_username, type_, + has_custom_image, }; Ok((client_id, rscfg)) @@ -4741,7 +4748,7 @@ mod tests { assert!(idms_prox_write.commit().is_ok()); } - // Test that re-use of a refresh token is denied + terminates the session. + // Test that reuse of a refresh token is denied + terminates the session. // // https://www.ietf.org/archive/id/draft-ietf-oauth-security-topics-18.html#refresh_token_protection #[idm_test] diff --git a/server/lib/src/idm/scim.rs b/server/lib/src/idm/scim.rs index 1c3d65beb..6ef5c03a2 100644 --- a/server/lib/src/idm/scim.rs +++ b/server/lib/src/idm/scim.rs @@ -642,7 +642,7 @@ impl<'a> IdmServerProxyWriteTransaction<'a> { // Refuse to proceed if any entries are in the recycled or tombstone state, since subsequent // operations WOULD fail. // - // I'm still a bit not sure what to do here though, because if we have uuid re-use from the + // I'm still a bit not sure what to do here though, because if we have uuid reuse from the // external system, that would be a pain, but I think we have to do this. This would be an // exceedingly rare situation though since 389-ds doesn't allow external uuid to be set, nor // does openldap. It would break both of their replication models for it to occur. diff --git a/server/lib/src/repl/proto.rs b/server/lib/src/repl/proto.rs index 6170746c1..b5dd96ee6 100644 --- a/server/lib/src/repl/proto.rs +++ b/server/lib/src/repl/proto.rs @@ -1,6 +1,7 @@ use super::cid::Cid; use super::entry::EntryChangeState; use super::entry::State; +use crate::be::dbvalue::DbValueImage; use crate::entry::Eattrs; use crate::prelude::*; use crate::schema::{SchemaReadTransaction, SchemaTransaction}; @@ -400,6 +401,9 @@ pub enum ReplAttrV1 { EcKeyPrivate { key: Vec, }, + Image { + set: Vec, + }, } #[derive(Serialize, Deserialize, Debug, PartialEq, Eq)] diff --git a/server/lib/src/schema.rs b/server/lib/src/schema.rs index 0dfff0894..24c442abf 100644 --- a/server/lib/src/schema.rs +++ b/server/lib/src/schema.rs @@ -229,6 +229,7 @@ impl SchemaAttribute { // Comparing on the label. SyntaxType::TotpSecret => matches!(v, PartialValue::Utf8(_)), SyntaxType::AuditLogString => matches!(v, PartialValue::Utf8(_)), + SyntaxType::Image => matches!(v, PartialValue::Utf8(_)), }; if r { Ok(()) @@ -280,6 +281,7 @@ impl SchemaAttribute { SyntaxType::TotpSecret => matches!(v, Value::TotpSecret(_, _)), SyntaxType::AuditLogString => matches!(v, Value::Utf8(_)), SyntaxType::EcKeyPrivate => matches!(v, Value::EcKeyPrivate(_)), + SyntaxType::Image => matches!(v, Value::Image(_)), }; if r { Ok(()) @@ -374,7 +376,7 @@ impl From for EntryInitNew { /// takes precedence. It is not possible to combine classes in an incompatible way due to these /// rules. /// -/// That in mind, and entry that has one of every possible class would probably be nonsensical, +/// That in mind, an entry that has one of every possible class would probably be nonsensical, /// but the addition rules make it easy to construct and understand with concepts like [`access`] /// controls or accounts and posix extensions. /// @@ -1753,7 +1755,7 @@ impl<'a> SchemaWriteTransaction<'a> { SchemaAttribute { name: Attribute::UidNumber.into(), uuid: UUID_SCHEMA_ATTR_UIDNUMBER, - description: String::from("An LDAP Compatible uidNumber"), + description: String::from("An LDAP Compatible uidNumber."), multivalue: false, unique: false, phantom: true, @@ -1763,6 +1765,21 @@ impl<'a> SchemaWriteTransaction<'a> { syntax: SyntaxType::Uint32, }, ); + self.attributes.insert( + Attribute::Image.into(), + SchemaAttribute { + name: Attribute::Image.into(), + uuid: UUID_SCHEMA_ATTR_IMAGE, + description: String::from("An image for display to end users."), + multivalue: false, + unique: false, + phantom: false, + sync_allowed: true, + replicated: true, + index: vec![], + syntax: SyntaxType::Image, + }, + ); // end LDAP masking phantoms self.classes.insert( diff --git a/server/lib/src/server/access/search.rs b/server/lib/src/server/access/search.rs index a2773672f..07fec0728 100644 --- a/server/lib/src/server/access/search.rs +++ b/server/lib/src/server/access/search.rs @@ -18,7 +18,7 @@ pub(super) fn apply_search_access<'a>( entry: &'a Arc, ) -> SearchResult<'a> { // This could be considered "slow" due to allocs each iter with the entry. We - // could move these out of the loop and re-use, but there are likely risks to + // could move these out of the loop and reuse, but there are likely risks to // that. let mut denied = false; let mut grant = false; @@ -144,12 +144,13 @@ fn search_oauth2_filter_entry<'a>( security_access!(entry = ?entry.get_uuid(), ident = ?iuser.entry.get_uuid2rdn(), "ident is a memberof a group granted an oauth2 scope by this entry"); return AccessResult::Allow(btreeset!( - ATTR_CLASS, - ATTR_DISPLAYNAME, - ATTR_UUID, - ATTR_OAUTH2_RS_NAME, - ATTR_OAUTH2_RS_ORIGIN, - ATTR_OAUTH2_RS_ORIGIN_LANDING + Attribute::Class.as_ref(), + Attribute::DisplayName.as_ref(), + Attribute::Uuid.as_ref(), + Attribute::OAuth2RsName.as_ref(), + Attribute::OAuth2RsOrigin.as_ref(), + Attribute::OAuth2RsOriginLanding.as_ref(), + Attribute::Image.as_ref() )); } AccessResult::Ignore diff --git a/server/lib/src/server/mod.rs b/server/lib/src/server/mod.rs index c1dbadacd..be8e303a7 100644 --- a/server/lib/src/server/mod.rs +++ b/server/lib/src/server/mod.rs @@ -540,6 +540,8 @@ pub trait QueryServerTransaction<'a> { } SyntaxType::JsonFilter => Value::new_json_filter_s(value) .ok_or_else(|| OperationError::InvalidAttribute("Invalid Filter syntax".to_string())), + SyntaxType::Image => Value::new_image(value), + SyntaxType::Credential => Err(OperationError::InvalidAttribute("Credentials can not be supplied through modification - please use the IDM api".to_string())), SyntaxType::SecretUtf8String => Err(OperationError::InvalidAttribute("Radius secrets can not be supplied through modification - please use the IDM api".to_string())), SyntaxType::SshKey => Err(OperationError::InvalidAttribute("SSH public keys can not be supplied through modification - please use the IDM api".to_string())), @@ -681,6 +683,7 @@ pub trait QueryServerTransaction<'a> { }), SyntaxType::AuditLogString => Ok(PartialValue::new_utf8s(value)), SyntaxType::EcKeyPrivate => Ok(PartialValue::SecretValue), + SyntaxType::Image => Ok(PartialValue::new_utf8s(value)), } } None => { diff --git a/server/lib/src/server/modify.rs b/server/lib/src/server/modify.rs index dd37f3018..6e71a5ec0 100644 --- a/server/lib/src/server/modify.rs +++ b/server/lib/src/server/modify.rs @@ -63,7 +63,7 @@ impl<'a> QueryServerWriteTransaction<'a> { if pre_candidates.is_empty() { if me.ident.is_internal() { trace!( - "modify: no candidates match filter ... continuing {:?}", + "modify_pre_apply: no candidates match filter ... continuing {:?}", me.filter ); return Ok(None); @@ -76,8 +76,8 @@ impl<'a> QueryServerWriteTransaction<'a> { } }; - trace!("modify: pre_candidates -> {:?}", pre_candidates); - trace!("modify: modlist -> {:?}", me.modlist); + trace!("modify_pre_apply: pre_candidates -> {:?}", pre_candidates); + trace!("modify_pre_apply: modlist -> {:?}", me.modlist); // Are we allowed to make the changes we want to? // modify_allow_operation diff --git a/server/lib/src/value.rs b/server/lib/src/value.rs index 8af1840f0..991520d06 100644 --- a/server/lib/src/value.rs +++ b/server/lib/src/value.rs @@ -17,6 +17,7 @@ use std::time::Duration; use base64::{engine::general_purpose, Engine as _}; use compact_jwt::JwsSigner; use hashbrown::HashSet; +use kanidm_proto::internal::ImageValue; use num_enum::TryFromPrimitive; use openssl::ec::EcKey; use openssl::pkey::Private; @@ -33,6 +34,7 @@ use crate::credential::{totp::Totp, Credential}; use crate::prelude::*; use crate::repl::cid::Cid; use crate::server::identity::IdentityId; +use crate::valueset::image::ImageValueThings; use crate::valueset::uuid_to_proto_string; use kanidm_proto::v1::ApiTokenPurpose; use kanidm_proto::v1::Filter as ProtoFilter; @@ -254,6 +256,7 @@ pub enum SyntaxType { ApiToken = 31, AuditLogString = 32, EcKeyPrivate = 33, + Image = 34, } impl TryFrom<&str> for SyntaxType { @@ -339,6 +342,7 @@ impl fmt::Display for SyntaxType { SyntaxType::ApiToken => "APITOKEN", SyntaxType::AuditLogString => "AUDIT_LOG_STRING", SyntaxType::EcKeyPrivate => "EC_KEY_PRIVATE", + SyntaxType::Image => "IMAGE", }) } } @@ -347,7 +351,7 @@ impl fmt::Display for SyntaxType { /// against a complete Value within a set in an Entry. /// /// A partialValue is typically used when you need to match against a value, but without -/// requiring all of it's data or expression. This is common in Filters or other direct +/// requiring all of its data or expression. This is common in Filters or other direct /// lookups and requests. #[derive(Hash, Debug, Clone, Eq, Ord, PartialOrd, PartialEq, Deserialize, Serialize)] pub enum PartialValue { @@ -387,6 +391,8 @@ pub enum PartialValue { UiHint(UiHint), Passkey(Uuid), DeviceKey(Uuid), + /// We compare on the value hash + Image(String), } impl From for PartialValue { @@ -702,6 +708,10 @@ impl PartialValue { Uuid::parse_str(us).map(PartialValue::DeviceKey).ok() } + pub fn new_image(input: &str) -> Self { + PartialValue::Image(input.to_string()) + } + pub fn to_str(&self) -> Option<&str> { match self { PartialValue::Utf8(s) => Some(s.as_str()), @@ -759,6 +769,7 @@ impl PartialValue { PartialValue::PhoneNumber(a) => a.to_string(), PartialValue::IntentToken(u) => u.clone(), PartialValue::UiHint(u) => (*u as u16).to_string(), + PartialValue::Image(imagehash) => imagehash.to_owned(), } } @@ -910,9 +921,9 @@ pub struct Oauth2Session { #[derive(Clone, Debug)] pub enum Value { Utf8(String), - // Case insensitive string + /// Case insensitive string Iutf8(String), - /// Case insensitive Name for a thing? + /// Case insensitive Name for a thing Iname(String), Uuid(Uuid), Bool(bool), @@ -936,8 +947,6 @@ pub enum Value { OauthScopeMap(Uuid, BTreeSet), PrivateBinary(Vec), PublicBinary(String, Vec), - // Enumeration(String), - // Float64(f64), RestrictedString(String), IntentToken(String, IntentTokenState), Passkey(Uuid, String, PasskeyV4), @@ -954,6 +963,8 @@ pub enum Value { TotpSecret(String, Totp), AuditLogString(Cid, String), EcKeyPrivate(EcKey), + + Image(ImageValue), } impl PartialEq for Value { @@ -992,6 +1003,9 @@ impl PartialEq for Value { // OauthScopeMap (Value::OauthScopeMap(a, c), Value::OauthScopeMap(b, d)) => a.eq(b) && c.eq(d), + (Value::Image(image1), Value::Image(image2)) => { + image1.hash_imagevalue().eq(&image2.hash_imagevalue()) + } (Value::Address(_), Value::Address(_)) | (Value::PrivateBinary(_), Value::PrivateBinary(_)) | (Value::SecretValue(_), Value::SecretValue(_)) => false, @@ -1233,6 +1247,13 @@ impl Value { } } + /// Want a `Value::Image`? use this! + pub fn new_image(input: &str) -> Result { + serde_json::from_str::(input) + .map(Value::Image) + .map_err(|_e| OperationError::InvalidValueState) + } + pub fn new_secret_str(cleartext: &str) -> Self { Value::SecretValue(cleartext.to_string()) } @@ -1710,7 +1731,7 @@ impl Value { && Value::validate_singleline(a) && Value::validate_singleline(b) } - + Value::Image(image) => image.validate_image().is_ok(), Value::Iname(s) => { Value::validate_str_escapes(s) && Value::validate_iname(s) diff --git a/server/lib/src/valueset/address.rs b/server/lib/src/valueset/address.rs index 75207f377..9189462b4 100644 --- a/server/lib/src/valueset/address.rs +++ b/server/lib/src/valueset/address.rs @@ -331,7 +331,7 @@ impl ValueSetT for ValueSetEmailAddress { let r = self.set.remove(a); if &self.primary == a { // if we can, inject another former address into primary. - if let Some(n) = self.set.iter().next().cloned() { + if let Some(n) = self.set.iter().take(1).next().cloned() { self.primary = n } } diff --git a/server/lib/src/valueset/image/jpg.rs b/server/lib/src/valueset/image/jpg.rs new file mode 100644 index 000000000..d58563734 --- /dev/null +++ b/server/lib/src/valueset/image/jpg.rs @@ -0,0 +1,126 @@ +use image::codecs::jpeg::JpegDecoder; +use image::ImageDecoder; +use sketching::*; + +use super::ImageValidationError; + +const JPEG_MAGIC: [u8; 2] = [0xff, 0xd8]; +const EOI_MAGIC: [u8; 2] = [0xff, 0xd9]; +const SOS_MARKER: [u8; 2] = [0xff, 0xda]; + +/// Checks to see if it has a valid JPEG magic bytes header +pub fn check_jpg_header(contents: &[u8]) -> Result<(), ImageValidationError> { + if !contents.starts_with(&JPEG_MAGIC) { + return Err(ImageValidationError::InvalidImage( + "Failed to parse JPEG file, invalid magic bytes".to_string(), + )); + } + Ok(()) +} + +// It's public so we can use it in benchmarking +/// Check to see if JPG is affected by acropalypse issues, returns `Ok(true)` if it is +/// based on +pub fn has_trailer(contents: &Vec) -> Result { + let buf = contents.as_slice(); + + let mut pos = JPEG_MAGIC.len(); + + while pos < buf.len() { + let marker = &buf[pos..pos + 2]; + pos += 2; + + let segment_size_bytes: &[u8] = &buf[pos..pos + 2]; + let segment_size = u16::from_be_bytes(segment_size_bytes.try_into().map_err(|_| { + ImageValidationError::InvalidImage("JPEG segment size bytes were invalid!".to_string()) + })?); + // we do not add 2 because the size prefix includes the size of the size prefix + pos += segment_size as usize; + + if marker == SOS_MARKER { + break; + } + } + + // setting this to a big value so we can see if we don't find the EOI marker + let mut eoi_index = buf.len() * 2; + trace!("buffer length: {}", buf.len()); + + // iterate through the file looking for the EOI_MAGIC bytes + for i in pos..=(buf.len() - EOI_MAGIC.len()) { + if buf[i..(i + EOI_MAGIC.len())] == EOI_MAGIC { + eoi_index = i; + break; + } + } + + if eoi_index > buf.len() { + Err(ImageValidationError::InvalidImage( + "End of image magic bytes not found in JPEG".to_string(), + )) + } else if (eoi_index + 2) < buf.len() { + // there's still bytes in the buffer after the EOI magic bytes + #[cfg(any(test, debug_assertions))] + println!( + "we're at pos: {} and buf len is {}, is not OK", + eoi_index, + buf.len() + ); + Ok(true) + } else { + #[cfg(any(test, debug_assertions))] + println!( + "we're at pos: {} and buf len is {}, is OK", + eoi_index, + buf.len() + ); + Ok(false) + } +} + +pub fn validate_decoding( + filename: &str, + contents: &[u8], + limits: image::io::Limits, +) -> Result<(), ImageValidationError> { + let mut decoder = match JpegDecoder::new(contents) { + Ok(val) => val, + Err(err) => { + return Err(ImageValidationError::InvalidImage(format!( + "Failed to parse {} as JPG: {:?}", + filename, err + ))) + } + }; + + match decoder.set_limits(limits) { + Err(err) => { + sketching::admin_warn!( + "Image validation failed while validating {}: {:?}", + filename, + err + ); + Err(ImageValidationError::ExceedsMaxDimensions) + } + Ok(_) => Ok(()), + } +} + +#[test] +fn test_jpg_has_trailer() { + let file_contents = std::fs::read(format!( + "{}/src/valueset/image/test_images/oversize_dimensions.jpg", + env!("CARGO_MANIFEST_DIR") + )) + .unwrap(); + assert!(!has_trailer(&file_contents).unwrap()); + + // checking a known bad imagee + let file_contents = std::fs::read(format!( + "{}/src/valueset/image/test_images/windows11_3_cropped.jpg", + env!("CARGO_MANIFEST_DIR") + )) + .unwrap(); + // let test_bytes = vec![0xff, 0xd8, 0xff, 0xda, 0xff, 0xd9]; + assert!(has_trailer(&file_contents).unwrap()); +} diff --git a/server/lib/src/valueset/image/mod.rs b/server/lib/src/valueset/image/mod.rs new file mode 100644 index 000000000..1d5e6f6d7 --- /dev/null +++ b/server/lib/src/valueset/image/mod.rs @@ -0,0 +1,534 @@ +#![allow(dead_code)] +use std::fmt::Display; + +use hashbrown::HashSet; +use image::codecs::gif::GifDecoder; +use image::codecs::webp::WebPDecoder; +use image::ImageDecoder; +use kanidm_proto::internal::{ImageType, ImageValue}; + +use crate::be::dbvalue::DbValueImage; +use crate::prelude::*; +use crate::repl::proto::ReplAttrV1; +use crate::schema::SchemaAttribute; +use crate::valueset::{DbValueSetV2, ValueSet}; + +#[derive(Debug, Clone)] +pub struct ValueSetImage { + set: HashSet, +} + +pub(crate) const MAX_IMAGE_HEIGHT: u32 = 1024; +pub(crate) const MAX_IMAGE_WIDTH: u32 = 1024; +/// 128kb should be enough for anyone... right? :D +pub(crate) const MAX_FILE_SIZE: u32 = 1024 * 128; + +const WEBP_MAGIC: &[u8; 4] = b"RIFF"; + +pub mod jpg; +pub mod png; + +pub trait ImageValueThings { + fn validate_image(&self) -> Result<(), ImageValidationError>; + fn validate_is_png(&self) -> Result<(), ImageValidationError>; + fn validate_is_gif(&self) -> Result<(), ImageValidationError>; + fn validate_is_jpg(&self) -> Result<(), ImageValidationError>; + fn validate_is_webp(&self) -> Result<(), ImageValidationError>; + fn validate_is_svg(&self) -> Result<(), ImageValidationError>; + + /// A sha256 of the filename/type/contents + fn hash_imagevalue(&self) -> String; + + fn get_limits(&self) -> image::io::Limits { + let mut limits = image::io::Limits::default(); + limits.max_image_height = Some(MAX_IMAGE_HEIGHT); + limits.max_image_width = Some(MAX_IMAGE_WIDTH); + limits + } +} + +#[derive(Debug)] +pub enum ImageValidationError { + Acropalypse(String), + ExceedsMaxWidth, + ExceedsMaxHeight, + ExceedsMaxDimensions, + ExceedsMaxFileSize, + InvalidImage(String), + InvalidPngPrelude, +} + +impl Display for ImageValidationError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ImageValidationError::ExceedsMaxWidth => f.write_fmt(format_args!( + "Exceeds the maximum width: {}", + MAX_IMAGE_WIDTH + )), + ImageValidationError::ExceedsMaxHeight => f.write_fmt(format_args!( + "Exceeds the maximum height: {}", + MAX_IMAGE_HEIGHT + )), + ImageValidationError::ExceedsMaxFileSize => f.write_fmt(format_args!( + "Exceeds maximum file size of {}", + MAX_FILE_SIZE + )), + ImageValidationError::InvalidImage(message) => { + if !message.is_empty() { + f.write_fmt(format_args!("Invalid Image: {}", message)) + } else { + f.write_str("Invalid Image") + } + } + ImageValidationError::ExceedsMaxDimensions => f.write_fmt(format_args!( + "Image exceeds max dimensions of {}x{}", + MAX_IMAGE_WIDTH, MAX_IMAGE_HEIGHT + )), + ImageValidationError::Acropalypse(message) => { + if !message.is_empty() { + f.write_fmt(format_args!( + "Image has extra data, is vulnerable to Acropalypse: {}", + message + )) + } else { + f.write_str("Image has extra data, is vulnerable to Acropalypse") + } + } + ImageValidationError::InvalidPngPrelude => { + f.write_str("Image has an invalid PNG prelude and is likely corrupt.") + } + } + } +} + +impl ImageValueThings for ImageValue { + fn validate_image(&self) -> Result<(), ImageValidationError> { + if self.contents.len() > MAX_FILE_SIZE as usize { + return Err(ImageValidationError::ExceedsMaxFileSize); + } + + match self.filetype { + ImageType::Gif => self.validate_is_gif(), + ImageType::Png => self.validate_is_png(), + ImageType::Svg => self.validate_is_svg(), + ImageType::Jpg => self.validate_is_jpg(), + ImageType::Webp => self.validate_is_webp(), + } + } + + /// Validate the PNG file contents, and that it's actually a PNG + fn validate_is_png(&self) -> Result<(), ImageValidationError> { + // based on code here: https://blog.cloudflare.com/how-cloudflare-images-addressed-the-acropalypse-vulnerability/ + + // this takes µs to run, where lodepng takes ms, so it comes first + if png::png_has_trailer(&self.contents)? { + return Err(ImageValidationError::Acropalypse( + "PNG file has a trailer which likely indicates the acropalypse vulnerability!" + .to_string(), + )); + } + + png::png_lodepng_validate(&self.contents, &self.filename) + } + + /// validate the JPG file contents, and that it's actually a JPG + fn validate_is_jpg(&self) -> Result<(), ImageValidationError> { + // check it starts with a valid header + jpg::check_jpg_header(&self.contents)?; + + jpg::validate_decoding(&self.filename, &self.contents, self.get_limits())?; + + if jpg::has_trailer(&self.contents)? { + Err(ImageValidationError::Acropalypse( + "File has a trailer which likely indicates the acropalypse vulnerability!" + .to_string(), + )) + } else { + Ok(()) + } + } + + /// validate the GIF file contents, and that it's actually a GIF + fn validate_is_gif(&self) -> Result<(), ImageValidationError> { + let Ok(mut decoder) = GifDecoder::new(&self.contents[..]) else { + return Err(ImageValidationError::InvalidImage( + "Failed to parse GIF".to_string(), + )); + }; + let limit_result = decoder.set_limits(self.get_limits()); + if limit_result.is_err() { + Err(ImageValidationError::ExceedsMaxDimensions) + } else { + Ok(()) + } + } + + /// validate the SVG file contents, and that it's actually a SVG (ish) + fn validate_is_svg(&self) -> Result<(), ImageValidationError> { + // svg is a string so let's do this + let svg_string = std::str::from_utf8(&self.contents).map_err(|e| { + ImageValidationError::InvalidImage(format!( + "Failed to parse SVG {} as a unicode string: {:?}", + self.hash_imagevalue(), + e + )) + })?; + svg::read(svg_string).map_err(|e| { + ImageValidationError::InvalidImage(format!( + "Failed to parse {} as SVG: {:?}", + self.hash_imagevalue(), + e + )) + })?; + Ok(()) + } + + /// validate the WebP file contents, and that it's actually a WebP file (as far as we can tell) + fn validate_is_webp(&self) -> Result<(), ImageValidationError> { + if !self.contents.starts_with(WEBP_MAGIC) { + return Err(ImageValidationError::InvalidImage( + "Failed to parse WebP file, invalid magic bytes".to_string(), + )); + } + + let Ok(mut decoder) = WebPDecoder::new(&self.contents[..]) else { + return Err(ImageValidationError::InvalidImage( + "Failed to parse WebP file".to_string(), + )); + }; + match decoder.set_limits(self.get_limits()) { + Err(err) => { + sketching::admin_warn!( + "Image validation failed while validating {}: {:?}", + self.filename, + err + ); + Err(ImageValidationError::ExceedsMaxDimensions) + } + Ok(_) => Ok(()), + } + } + + /// A sha256 of the filename/type/contents, uses openssl so has to live here + /// because proto don't need that jazz + fn hash_imagevalue(&self) -> String { + let filetype_repr = [self.filetype.clone() as u8]; + let mut hasher = openssl::sha::Sha256::new(); + hasher.update(self.filename.as_bytes()); + hasher.update(&filetype_repr); + hasher.update(&self.contents); + hex::encode(hasher.finish()) + } +} + +impl ValueSetImage { + pub fn new(image: ImageValue) -> Box { + let mut set = HashSet::new(); + match image.validate_image() { + Ok(_) => { + set.insert(image); + } + Err(err) => { + admin_error!( + "Image {} didn't pass validation, not adding to value! Error: {:?}", + image.filename, + err + ); + } + }; + Box::new(ValueSetImage { set }) + } + + // add the image, return a bool if there was a change + pub fn push(&mut self, image: ImageValue) -> bool { + match image.validate_image() { + Ok(_) => self.set.insert(image), + Err(err) => { + admin_error!( + "Image didn't pass validation, not adding to value! Error: {}", + err + ); + false + } + } + } + + pub fn from_dbvs2(data: &[DbValueImage]) -> Result { + Ok(Box::new(ValueSetImage { + set: data + .iter() + .cloned() + .map(|e| match e { + DbValueImage::V1 { + filename, + filetype, + contents, + } => ImageValue::new(filename, filetype, contents), + }) + .collect(), + })) + } + + pub fn from_repl_v1(data: &[DbValueImage]) -> Result { + let mut set: HashSet = HashSet::new(); + for image in data { + let image = match image.clone() { + DbValueImage::V1 { + filename, + filetype, + contents, + } => ImageValue::new(filename, filetype, contents), + }; + match image.validate_image() { + Ok(_) => { + set.insert(image.clone()); + } + Err(err) => { + admin_error!( + "Image didn't pass validation, not adding to value! Error: {:?}", + err + ); + return Err(OperationError::InvalidValueState); + } + } + } + + Ok(Box::new(ValueSetImage { set })) + } + + // We need to allow this, because rust doesn't allow us to impl FromIterator on foreign + // types, and `ImageValue` is foreign. + #[allow(clippy::should_implement_trait)] + pub fn from_iter(iter: T) -> Option> + where + T: IntoIterator, + { + let mut set: HashSet = HashSet::new(); + for image in iter { + match image.validate_image() { + Ok(_) => set.insert(image), + Err(err) => { + admin_error!( + "Image didn't pass validation, not adding to value! Error: {}", + err + ); + return None; + } + }; + } + Some(Box::new(ValueSetImage { set })) + } +} + +impl ValueSetT for ValueSetImage { + fn insert_checked(&mut self, value: Value) -> Result { + match value { + Value::Image(image) => match self.set.contains(&image) { + true => Ok(false), // image exists, no change, return false + false => Ok(self.push(image)), // this masks the operationerror + }, + _ => { + debug_assert!(false); + Err(OperationError::InvalidValueState) + } + } + } + + fn clear(&mut self) { + self.set.clear(); + } + + fn remove(&mut self, pv: &PartialValue, _cid: &Cid) -> bool { + match pv { + PartialValue::Image(pv) => { + let imgset = self.set.clone(); + + let res: Vec = imgset + .iter() + .filter_map(|image| { + if &image.hash_imagevalue() == pv { + Some(image) + } else { + None + } + }) + .map(|image| self.set.remove(image)) + .collect(); + res.into_iter().any(|e| e) + } + _ => { + debug_assert!(false); + false + } + } + } + + fn contains(&self, pv: &PartialValue) -> bool { + match pv { + PartialValue::Image(pvhash) => { + if let Some(image) = self.set.iter().take(1).next() { + &image.hash_imagevalue() == pvhash + } else { + false + } + } + _ => false, + } + } + + fn substring(&self, _pv: &PartialValue) -> bool { + false + } + + fn lessthan(&self, _pv: &PartialValue) -> bool { + false + } + + fn len(&self) -> usize { + self.set.len() + } + + fn generate_idx_eq_keys(&self) -> Vec { + self.set + .iter() + .map(|image| image.hash_imagevalue()) + .collect() + } + + fn syntax(&self) -> SyntaxType { + SyntaxType::Image + } + + fn validate(&self, schema_attr: &SchemaAttribute) -> bool { + if !schema_attr.multivalue && self.set.len() > 1 { + return false; + } + self.set.iter().all(|image| { + image + .validate_image() + .map_err(|err| error!("Image {} failed validation: {}", image.filename, err)) + .is_ok() + }) + } + + fn to_proto_string_clone_iter(&self) -> Box + '_> { + Box::new(self.set.iter().map(|image| image.hash_imagevalue())) + } + + fn to_db_valueset_v2(&self) -> DbValueSetV2 { + DbValueSetV2::Image( + self.set + .iter() + .cloned() + .map(|e| crate::be::dbvalue::DbValueImage::V1 { + filename: e.filename, + filetype: e.filetype, + contents: e.contents, + }) + .collect(), + ) + } + + fn to_repl_v1(&self) -> ReplAttrV1 { + ReplAttrV1::Image { + set: self + .set + .iter() + .cloned() + .map(|e| DbValueImage::V1 { + filename: e.filename, + filetype: e.filetype, + contents: e.contents, + }) + .collect(), + } + } + + fn to_partialvalue_iter(&self) -> Box + '_> { + Box::new( + self.set + .iter() + .cloned() + .map(|image| PartialValue::Image(image.hash_imagevalue())), + ) + } + + fn to_value_iter(&self) -> Box + '_> { + Box::new(self.set.iter().cloned().map(Value::Image)) + } + + fn equal(&self, other: &ValueSet) -> bool { + if let Some(other) = other.as_imageset() { + &self.set == other + } else { + debug_assert!(false); + false + } + } + + fn merge(&mut self, other: &ValueSet) -> Result<(), OperationError> { + if let Some(b) = other.as_imageset() { + mergesets!(self.set, b) + } else { + debug_assert!(false); + Err(OperationError::InvalidValueState) + } + } + + // this seems dumb + fn as_imageset(&self) -> Option<&HashSet> { + Some(&self.set) + } +} + +#[test] +/// tests that we can load a bunch of test images and it'll throw errors in a way we expect +fn test_imagevalue_things() { + ["gif", "png", "jpg", "webp"] + .into_iter() + .for_each(|extension| { + // test should-be-bad images + let filename = format!( + "{}/src/valueset/image/test_images/oversize_dimensions.{extension}", + env!("CARGO_MANIFEST_DIR") + ); + trace!("testing {}", &filename); + let image = ImageValue { + filename: format!("oversize_dimensions.{extension}"), + filetype: ImageType::try_from(extension).unwrap(), + contents: std::fs::read(filename).unwrap(), + }; + let res = image.validate_image(); + trace!("{:?}", &res); + assert!(res.is_err()); + + // test should-be-good images + let filename = format!( + "{}/src/valueset/image/test_images/ok.{extension}", + env!("CARGO_MANIFEST_DIR") + ); + trace!("testing {}", &filename); + let image = ImageValue { + filename: filename.clone(), + filetype: ImageType::try_from(extension).unwrap(), + contents: std::fs::read(filename).unwrap(), + }; + let res = image.validate_image(); + trace!("validation result of {}: {:?}", image.filename, &res); + assert!(res.is_ok()); + + let filename = format!( + "{}/src/valueset/image/test_images/ok.svg", + env!("CARGO_MANIFEST_DIR") + ); + let image = ImageValue { + filename: filename.clone(), + filetype: ImageType::Svg, + contents: std::fs::read(&filename).unwrap(), + }; + let res = image.validate_image(); + trace!("SVG Validation result of {}: {:?}", filename, &res); + assert!(res.is_ok()); + assert_eq!(image.hash_imagevalue().is_empty(), false); + }) +} diff --git a/server/lib/src/valueset/image/png.rs b/server/lib/src/valueset/image/png.rs new file mode 100644 index 000000000..00e4cdd3e --- /dev/null +++ b/server/lib/src/valueset/image/png.rs @@ -0,0 +1,158 @@ +use super::{ImageValidationError, MAX_IMAGE_HEIGHT, MAX_IMAGE_WIDTH}; +use crate::prelude::*; +static PNG_PRELUDE: &[u8] = &[0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; +static PNG_CHUNK_END: &[u8; 4] = b"IEND"; + +#[derive(Debug)] +/// This is used as part of PNG validation to identify if we've seen the end of the file, and if it suffers from +/// Acropalypyse issues by having trailing data. +enum PngChunkStatus { + SeenEnd { has_trailer: bool }, + MoreChunks, +} + +/// Loop over the PNG file contents to find out if we've got valid chunks +fn png_consume_chunks_until_iend( + buf: &[u8], +) -> Result<(PngChunkStatus, &[u8]), ImageValidationError> { + // length[u8;4] + chunk_type[u8;4] + checksum[u8;4] + minimum size + if buf.len() < 12 { + return Err(ImageValidationError::InvalidImage(format!( + "PNG file is too short to be valid, got {} bytes", + buf.len() + ))); + } else { + #[cfg(any(debug_assertions, test))] + trace!("input buflen: {}", buf.len()); + } + let (length_bytes, buf) = buf.split_at(4); + let (chunk_type, buf) = buf.split_at(4); + + // Infallible: We've definitely consumed 4 bytes + let length = u32::from_be_bytes( + length_bytes + .try_into() + .map_err(|_| ImageValidationError::InvalidImage("PNG corrupt!".to_string()))?, + ); + #[cfg(any(debug_assertions, test))] + trace!( + "length_bytes: {:?} length: {} chunk_type: {:?} buflen: {}", + length_bytes, + &length, + &chunk_type, + &buf.len() + ); + + if buf.len() < (length + 4) as usize { + return Err(ImageValidationError::InvalidImage(format!( + "PNG file is too short to be valid, failed to split at the chunk length {}, had {} bytes", + length, + buf.len(), + ))); + } + let (_, buf) = buf.split_at(length as usize); + #[cfg(any(debug_assertions, test))] + trace!("new buflen: {}", &buf.len()); + + let (_checksum, buf) = buf.split_at(4); + #[cfg(any(debug_assertions, test))] + trace!("post-checksum buflen: {}", &buf.len()); + + if chunk_type == PNG_CHUNK_END { + if buf.is_empty() { + Ok((PngChunkStatus::SeenEnd { has_trailer: false }, buf)) + } else { + Ok((PngChunkStatus::SeenEnd { has_trailer: true }, buf)) + } + } else { + Ok((PngChunkStatus::MoreChunks, buf)) + } +} + +// needs to be pub for bench things +pub fn png_has_trailer(contents: &Vec) -> Result { + let buf = contents.as_slice(); + // let magic = buf.split_off(PNG_PRELUDE.len()); + let (magic, buf) = buf.split_at(PNG_PRELUDE.len()); + + let buf = buf.to_owned(); + let mut buf = buf.as_slice(); + + if magic != PNG_PRELUDE { + return Err(ImageValidationError::InvalidPngPrelude); + } + + loop { + let (status, new_buf) = png_consume_chunks_until_iend(buf)?; + buf = match status { + PngChunkStatus::SeenEnd { has_trailer } => return Ok(has_trailer), + PngChunkStatus::MoreChunks => new_buf, + }; + } +} + +// needs to be pub for bench things +pub fn png_lodepng_validate( + contents: &Vec, + filename: &String, +) -> Result<(), ImageValidationError> { + match lodepng::decode32(contents) { + Ok(val) => { + if val.width > MAX_IMAGE_WIDTH as usize || val.height > MAX_IMAGE_HEIGHT as usize { + admin_debug!( + "PNG validation failed for {} {}", + filename, + ImageValidationError::ExceedsMaxWidth + ); + Err(ImageValidationError::ExceedsMaxWidth) + } else if val.height > MAX_IMAGE_HEIGHT as usize { + admin_debug!( + "PNG validation failed for {} {}", + filename, + ImageValidationError::ExceedsMaxHeight + ); + Err(ImageValidationError::ExceedsMaxHeight) + } else { + Ok(()) + } + } + Err(err) => { + // admin_debug!("PNG validation failed for {} {:?}", self.filename, err); + Err(ImageValidationError::InvalidImage(format!("{:?}", err))) + } + } +} + +#[test] +/// this tests a variety of input options for `png_consume_chunks_until_iend` +fn test_png_consume_chunks_until_iend() { + let mut foo = vec![0, 0, 0, 1]; // the length + + foo.extend(PNG_CHUNK_END); // ... the type of chunk we're looking at! + foo.push(1); // the data + foo.extend([0, 0, 0, 1]); // the 4-byte checksum which we ignore + let expected: [u8; 0] = []; + let foo = foo.as_slice(); + let res = png_consume_chunks_until_iend(&foo); + + // simple, valid image works + match res { + Ok((result, buf)) => { + if let PngChunkStatus::MoreChunks = result { + panic!("Shouldn't have more chunks!"); + } + assert_eq!(buf, &expected); + } + Err(err) => panic!("Error: {:?}", err), + }; + + // let's make sure it works with a bunch of different length inputs + let mut x = 11; + while x > 0 { + let foo = &foo[0..=x]; + let res = png_consume_chunks_until_iend(&foo); + trace!("chunkstatus at size {} {:?}", x, &res); + assert!(res.is_err()); + x = x - 1; + } +} diff --git a/server/lib/src/valueset/image/test_images/ok.gif b/server/lib/src/valueset/image/test_images/ok.gif new file mode 100644 index 000000000..fe2da8ca8 Binary files /dev/null and b/server/lib/src/valueset/image/test_images/ok.gif differ diff --git a/server/lib/src/valueset/image/test_images/ok.jpg b/server/lib/src/valueset/image/test_images/ok.jpg new file mode 100644 index 000000000..82c2bdabf Binary files /dev/null and b/server/lib/src/valueset/image/test_images/ok.jpg differ diff --git a/server/lib/src/valueset/image/test_images/ok.png b/server/lib/src/valueset/image/test_images/ok.png new file mode 100644 index 000000000..aeee5ee1c Binary files /dev/null and b/server/lib/src/valueset/image/test_images/ok.png differ diff --git a/server/lib/src/valueset/image/test_images/ok.svg b/server/lib/src/valueset/image/test_images/ok.svg new file mode 100644 index 000000000..57e8f6da4 --- /dev/null +++ b/server/lib/src/valueset/image/test_images/ok.svg @@ -0,0 +1,834 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/server/lib/src/valueset/image/test_images/ok.webp b/server/lib/src/valueset/image/test_images/ok.webp new file mode 100644 index 000000000..d32bef57a Binary files /dev/null and b/server/lib/src/valueset/image/test_images/ok.webp differ diff --git a/server/lib/src/valueset/image/test_images/oversize_dimensions.gif b/server/lib/src/valueset/image/test_images/oversize_dimensions.gif new file mode 100644 index 000000000..fb9350df9 Binary files /dev/null and b/server/lib/src/valueset/image/test_images/oversize_dimensions.gif differ diff --git a/server/lib/src/valueset/image/test_images/oversize_dimensions.jpg b/server/lib/src/valueset/image/test_images/oversize_dimensions.jpg new file mode 100644 index 000000000..43c798ce6 Binary files /dev/null and b/server/lib/src/valueset/image/test_images/oversize_dimensions.jpg differ diff --git a/server/lib/src/valueset/image/test_images/oversize_dimensions.png b/server/lib/src/valueset/image/test_images/oversize_dimensions.png new file mode 100644 index 000000000..8961361f6 Binary files /dev/null and b/server/lib/src/valueset/image/test_images/oversize_dimensions.png differ diff --git a/server/lib/src/valueset/image/test_images/oversize_dimensions.webp b/server/lib/src/valueset/image/test_images/oversize_dimensions.webp new file mode 100644 index 000000000..5241bbcfc Binary files /dev/null and b/server/lib/src/valueset/image/test_images/oversize_dimensions.webp differ diff --git a/server/lib/src/valueset/image/test_images/windows11_3_cropped.jpg b/server/lib/src/valueset/image/test_images/windows11_3_cropped.jpg new file mode 100644 index 000000000..76951cac3 Binary files /dev/null and b/server/lib/src/valueset/image/test_images/windows11_3_cropped.jpg differ diff --git a/server/lib/src/valueset/mod.rs b/server/lib/src/valueset/mod.rs index 07db86282..08275393c 100644 --- a/server/lib/src/valueset/mod.rs +++ b/server/lib/src/valueset/mod.rs @@ -3,6 +3,7 @@ use std::collections::{BTreeMap, BTreeSet}; use compact_jwt::JwsSigner; use dyn_clone::DynClone; use hashbrown::HashSet; +use kanidm_proto::internal::ImageValue; use openssl::ec::EcKey; use openssl::pkey::Private; use openssl::pkey::Public; @@ -30,6 +31,7 @@ pub use self::cid::ValueSetCid; pub use self::cred::{ValueSetCredential, ValueSetDeviceKey, ValueSetIntentToken, ValueSetPasskey}; pub use self::datetime::ValueSetDateTime; pub use self::eckey::ValueSetEcKeyPrivate; +use self::image::ValueSetImage; pub use self::iname::ValueSetIname; pub use self::index::ValueSetIndex; pub use self::iutf8::ValueSetIutf8; @@ -58,6 +60,7 @@ mod cid; mod cred; mod datetime; pub mod eckey; +pub mod image; mod iname; mod index; mod iutf8; @@ -83,6 +86,10 @@ pub type ValueSet = Box; dyn_clone::clone_trait_object!(ValueSetT); pub trait ValueSetT: std::fmt::Debug + DynClone { + /// Returns whether the value was newly inserted. That is: + /// * If the set did not previously contain an equal value, true is returned. + /// * If the set already contained an equal value, false is returned, and the entry is not updated. + /// fn insert_checked(&mut self, value: Value) -> Result; fn clear(&mut self); @@ -562,6 +569,11 @@ pub trait ValueSetT: std::fmt::Debug + DynClone { None } + fn as_imageset(&self) -> Option<&HashSet> { + debug_assert!(false); + None + } + fn repl_merge_valueset( &self, _older: &ValueSet, @@ -636,6 +648,7 @@ pub fn from_result_value_iter( Value::UiHint(u) => ValueSetUiHint::new(u), Value::AuditLogString(c, s) => ValueSetAuditLogString::new((c, s)), Value::EcKeyPrivate(k) => ValueSetEcKeyPrivate::new(&k), + Value::Image(imagevalue) => image::ValueSetImage::new(imagevalue), Value::PhoneNumber(_, _) | Value::Passkey(_, _, _) | Value::DeviceKey(_, _, _) @@ -702,6 +715,8 @@ pub fn from_value_iter(mut iter: impl Iterator) -> Result ValueSetTotpSecret::new(l, t), Value::AuditLogString(c, s) => ValueSetAuditLogString::new((c, s)), Value::EcKeyPrivate(k) => ValueSetEcKeyPrivate::new(&k), + + Value::Image(imagevalue) => image::ValueSetImage::new(imagevalue), Value::PhoneNumber(_, _) => { debug_assert!(false); return Err(OperationError::InvalidValueState); @@ -757,6 +772,7 @@ pub fn from_db_valueset_v2(dbvs: DbValueSetV2) -> Result ValueSetImage::from_dbvs2(&set), } } @@ -801,5 +817,6 @@ pub fn from_repl_v1(rv1: &ReplAttrV1) -> Result { ReplAttrV1::TotpSecret { set } => ValueSetTotpSecret::from_repl_v1(set), ReplAttrV1::AuditLogString { map } => ValueSetAuditLogString::from_repl_v1(map), ReplAttrV1::EcKeyPrivate { key } => ValueSetEcKeyPrivate::from_repl_v1(key), + ReplAttrV1::Image { set } => ValueSetImage::from_repl_v1(set), } } diff --git a/server/testkit/src/lib.rs b/server/testkit/src/lib.rs index 699145840..e51d22462 100644 --- a/server/testkit/src/lib.rs +++ b/server/testkit/src/lib.rs @@ -33,6 +33,7 @@ pub const NOT_ADMIN_TEST_PASSWORD: &str = "eicieY7ahchaoCh0eeTa"; pub static PORT_ALLOC: AtomicU16 = AtomicU16::new(18080); pub use testkit_macros::test; +use tracing::trace; pub fn is_free_port(port: u16) -> bool { TcpStream::connect(("0.0.0.0", port)).is_err() @@ -325,7 +326,7 @@ pub async fn test_read_attrs( let e = rset.first().expect("Failed to get first user from set"); for attr in attrs.iter() { - println!("Reading {}", attr); + trace!("Reading {}", attr); #[allow(clippy::unwrap_used)] let is_ok = match *attr { Attribute::RadiusSecret => rsclient @@ -335,7 +336,7 @@ pub async fn test_read_attrs( .is_some(), _ => e.attrs.get(attr.as_ref()).is_some(), }; - dbg!(is_ok, is_readable); + trace!("is_ok: {}, is_readable: {}", is_ok, is_readable); assert!(is_ok == is_readable) } } diff --git a/server/testkit/tests/proto_v1_test.rs b/server/testkit/tests/proto_v1_test.rs index 446084630..c402199fc 100644 --- a/server/testkit/tests/proto_v1_test.rs +++ b/server/testkit/tests/proto_v1_test.rs @@ -1,6 +1,8 @@ #![deny(warnings)] +use std::path::Path; use std::time::SystemTime; +use kanidm_proto::internal::ImageValue; use kanidm_proto::v1::{ ApiToken, AuthCredential, AuthIssueSession, AuthMech, AuthRequest, AuthResponse, AuthState, AuthStep, CURegState, CredentialDetailType, Entry, Filter, Modify, ModifyList, UatPurpose, @@ -11,7 +13,7 @@ use kanidmd_lib::prelude::{ Attribute, BUILTIN_GROUP_IDM_ADMINS_V1, BUILTIN_GROUP_SYSTEM_ADMINS_V1, IDM_PEOPLE_ACCOUNT_PASSWORD_IMPORT_PRIV_V1, }; -use tracing::debug; +use tracing::{debug, trace}; use std::str::FromStr; @@ -958,6 +960,74 @@ async fn test_server_rest_oauth2_basic_lifecycle(rsclient: KanidmClient) { assert!(oauth2_config_updated2 != oauth2_config_updated3); + // Check we can upload an image + let image_path = Path::new("../../server/lib/src/valueset/image/test_images/ok.png"); + assert!(image_path.exists()); + let image_contents = std::fs::read(image_path).unwrap(); + let image = ImageValue::new( + "test".to_string(), + kanidm_proto::internal::ImageType::Png, + image_contents, + ); + + let res = rsclient + .idm_oauth2_rs_update_image("test_integration", image) + .await; + trace!("update image result: {:?}", &res); + assert!(res.is_ok()); + + //test getting the image + let client = reqwest::Client::new(); + + let response = client + .get(rsclient.make_url("/ui/images/oauth2/test_integration")) + .bearer_auth(rsclient.get_token().await.unwrap()); + + let response = response + .send() + .await + .map_err(|err| rsclient.handle_response_error(err)) + .unwrap(); + + assert!(response.status().is_success()); + + // check we can upload a *replacement* image + + let image_path = Path::new("../../server/lib/src/valueset/image/test_images/ok.jpg"); + trace!("image path {:?}", &image_path.canonicalize()); + assert!(image_path.exists()); + let jpg_file_contents = std::fs::read(image_path).unwrap(); + let image = ImageValue::new( + "test".to_string(), + kanidm_proto::internal::ImageType::Jpg, + jpg_file_contents.clone(), + ); + let res = rsclient + .idm_oauth2_rs_update_image("test_integration", image) + .await; + trace!("idm_oauth2_rs_update_image result: {:?}", &res); + assert!(res.is_ok()); + + // check it fails when we upload a jpg and say it's a webp + let image = ImageValue::new( + "test".to_string(), + kanidm_proto::internal::ImageType::Webp, + jpg_file_contents, + ); + let res = rsclient + .idm_oauth2_rs_update_image("test_integration", image) + .await; + trace!("idm_oauth2_rs_update_image result: {:?}", &res); + assert!(res.is_err()); + + // check we can remove an image + + let res = rsclient + .idm_oauth2_rs_delete_image("test_integration") + .await; + trace!("idm_oauth2_rs_delete_image result: {:?}", &res); + assert!(res.is_ok()); + // Check we can delete a scope map. rsclient diff --git a/tools/cli/src/opt/kanidm.rs b/tools/cli/src/opt/kanidm.rs index 6419f88af..8810a399b 100644 --- a/tools/cli/src/opt/kanidm.rs +++ b/tools/cli/src/opt/kanidm.rs @@ -9,7 +9,7 @@ pub struct Named { #[derive(Debug, Args)] pub struct DebugOpt { - /// Enable debbuging of the kanidm tool + /// Enable debugging of the kanidm tool #[clap(short, long, env = "KANIDM_DEBUG")] pub debug: bool, } @@ -34,7 +34,7 @@ impl std::str::FromStr for OutputMode { #[derive(Debug, Args, Clone)] pub struct CommonOpt { - /// Enable debbuging of the kanidm tool + /// Enable debugging of the kanidm tool #[clap(short, long, env = "KANIDM_DEBUG")] pub debug: bool, /// The URL of the kanidm instance diff --git a/tools/iam_migrations/freeipa/src/opt.rs b/tools/iam_migrations/freeipa/src/opt.rs index 7449e4f94..174ee35bf 100644 --- a/tools/iam_migrations/freeipa/src/opt.rs +++ b/tools/iam_migrations/freeipa/src/opt.rs @@ -1,13 +1,10 @@ - - use kanidm_proto::constants::DEFAULT_CLIENT_CONFIG_PATH; pub const DEFAULT_IPA_CONFIG_PATH: &str = "/etc/kanidm/ipa-sync"; - #[derive(Debug, clap::Parser)] #[clap(about = "Kanidm FreeIPA Sync Driver")] pub struct Opt { - /// Enable debbuging of the sync driver + /// Enable debugging of the sync driver #[clap(short, long, env = "KANIDM_DEBUG")] pub debug: bool, /// Path to the client config file. diff --git a/tools/iam_migrations/ldap/src/opt.rs b/tools/iam_migrations/ldap/src/opt.rs index e4d907216..5b838baa0 100644 --- a/tools/iam_migrations/ldap/src/opt.rs +++ b/tools/iam_migrations/ldap/src/opt.rs @@ -1,12 +1,10 @@ - use kanidm_proto::constants::DEFAULT_CLIENT_CONFIG_PATH; pub const DEFAULT_LDAP_CONFIG_PATH: &str = "/etc/kanidm/ldap-sync"; - #[derive(Debug, clap::Parser)] #[clap(about = "Kanidm LDAP Sync Driver")] pub struct Opt { - /// Enable debbuging of the sync driver + /// Enable debugging of the sync driver #[clap(short, long, env = "KANIDM_DEBUG")] pub debug: bool, /// Path to the client config file.