diff --git a/Cargo.lock b/Cargo.lock index fcfd7a0..8b6e95d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -169,6 +169,18 @@ dependencies = [ "rustversion", ] +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash", +] + [[package]] name = "arrayvec" version = "0.7.8" @@ -389,11 +401,11 @@ dependencies = [ "bytes", "form_urlencoded", "hex", - "hmac", + "hmac 0.13.0", "http 0.2.12", "http 1.4.2", "percent-encoding", - "sha2", + "sha2 0.11.0", "time", "tracing", ] @@ -651,6 +663,28 @@ dependencies = [ "tracing", ] +[[package]] +name = "axum-extra" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be44683b41ccb9ab2d23a5230015c9c3c55be97a25e4428366de8873103f7970" +dependencies = [ + "axum", + "axum-core", + "bytes", + "cookie", + "futures-core", + "futures-util", + "http 1.4.2", + "http-body 1.1.0", + "http-body-util", + "mime", + "pin-project-lite", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "axum-server" version = "0.8.0" @@ -719,6 +753,18 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base32" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "022dfe9eb35f19ebbcb51e0b40a5ab759f46ad60cadf7297e0bd085afb50e076" + [[package]] name = "base64" version = "0.22.1" @@ -735,6 +781,12 @@ dependencies = [ "vsimd", ] +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bit-vec" version = "0.9.1" @@ -1031,12 +1083,35 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "const-oid" version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" +[[package]] +name = "constant_time_eq" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + [[package]] name = "core-error" version = "0.0.0" @@ -1114,6 +1189,18 @@ version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -1163,6 +1250,33 @@ dependencies = [ "cmov", ] +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest 0.10.7", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "daemonize" version = "0.5.0" @@ -1187,8 +1301,18 @@ version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core 0.21.3", + "darling_macro 0.21.3", ] [[package]] @@ -1205,13 +1329,38 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.118", +] + [[package]] name = "darling_macro" version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ - "darling_core", + "darling_core 0.20.11", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core 0.21.3", "quote", "syn 2.0.118", ] @@ -1222,6 +1371,17 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "pem-rfc7468", + "zeroize", +] + [[package]] name = "der-parser" version = "9.0.0" @@ -1282,7 +1442,7 @@ version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" dependencies = [ - "darling", + "darling 0.20.11", "proc-macro2", "quote", "syn 2.0.118", @@ -1298,6 +1458,65 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "diesel" +version = "2.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e54d1f576cd3a3460f212a4615fd12ce1b6303c095b79a44449ffbe627753dc1" +dependencies = [ + "diesel_derives", + "downcast-rs", + "libsqlite3-sys", + "sqlite-wasm-rs", + "time", +] + +[[package]] +name = "diesel-async" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd39af30158d444884f166fe4c58f35dc40ad71ad017bb59408a3448526ff4bd" +dependencies = [ + "diesel", + "futures-core", + "futures-util", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "diesel_derives" +version = "2.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1817b7f4279b947fc4cafddec12b0e5f8727141706561ce3ac94a60bddd1cf5" +dependencies = [ + "diesel_table_macro_syntax", + "dsl_auto_type", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "diesel_migrations" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d0f4a98124ba6d4ca75da535f65984badec16a003b6e2f94a01e31a79490b8" +dependencies = [ + "diesel", + "migrations_internals", + "migrations_macros", +] + +[[package]] +name = "diesel_table_macro_syntax" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe2444076b48641147115697648dc743c2c00b61adade0f01ce67133c7babe8c" +dependencies = [ + "syn 2.0.118", +] + [[package]] name = "digest" version = "0.10.7" @@ -1305,6 +1524,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", + "const-oid 0.9.6", "crypto-common 0.1.7", "subtle", ] @@ -1316,7 +1536,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ "block-buffer 0.12.1", - "const-oid", + "const-oid 0.10.2", "crypto-common 0.2.2", "ctutils", ] @@ -1344,18 +1564,97 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "downcast-rs" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc" + +[[package]] +name = "dsl_auto_type" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd122633e4bef06db27737f21d3738fb89c8f6d5360d6d9d7635dda142a7757e" +dependencies = [ + "darling 0.21.3", + "either", + "heck", + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "dunce" version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest 0.10.7", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2 0.10.9", + "subtle", + "zeroize", +] + [[package]] name = "either" version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "hkdf", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "encoding_rs" version = "0.8.35" @@ -1420,6 +1719,22 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "filedescriptor" version = "0.8.3" @@ -1649,6 +1964,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -1730,6 +2046,17 @@ version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "h2" version = "0.3.27" @@ -1819,6 +2146,24 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac 0.12.1", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + [[package]] name = "hmac" version = "0.13.0" @@ -2361,6 +2706,30 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonwebtoken" +version = "11.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "881733cbc631fc9e472e24447ce32a64bedf2da498d6d8570b08edc87de71f65" +dependencies = [ + "base64", + "ed25519-dalek", + "getrandom 0.2.17", + "hmac 0.12.1", + "js-sys", + "p256", + "p384", + "pem", + "rand 0.8.7", + "rsa", + "serde", + "serde_json", + "sha2 0.10.9", + "signature", + "simple_asn1", + "zeroize", +] + [[package]] name = "kqueue" version = "1.2.0" @@ -2386,6 +2755,9 @@ name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] [[package]] name = "libc" @@ -2403,6 +2775,23 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "libz-ng-sys" version = "1.1.29" @@ -2522,6 +2911,27 @@ dependencies = [ "autocfg", ] +[[package]] +name = "migrations_internals" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36c791ecdf977c99f45f23280405d7723727470f6689a5e6dbf513ac547ae10d" +dependencies = [ + "serde", + "toml 0.9.12+spec-1.1.0", +] + +[[package]] +name = "migrations_macros" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36fc5ac76be324cfd2d3f2cf0fdf5d5d3c4f14ed8aaebadb09e304ba42282703" +dependencies = [ + "migrations_internals", + "proc-macro2", + "quote", +] + [[package]] name = "mime" version = "0.3.17" @@ -2887,18 +3297,25 @@ version = "0.1.0" dependencies = [ "aes-gcm", "anyhow", + "argon2", "async-trait", "aws-credential-types", "aws-sdk-route53", "axum", + "axum-extra", "axum-server", "base64", "chrono", "ctrlc", + "diesel", + "diesel-async", + "diesel_migrations", "futures", "gag", "instant-acme", "ipnetwork", + "jsonwebtoken", + "libsqlite3-sys", "mime_guess", "notify", "nullnet-grpc-lib", @@ -2912,11 +3329,14 @@ dependencies = [ "rust-embed", "serde", "serde_json", + "sha2 0.11.0", + "time", "tokio", "tokio-stream", - "toml", + "toml 1.1.2+spec-1.1.0", "tonic", "tonic-prost", + "totp-rs", "uuid", "x509-parser 0.16.0", ] @@ -2931,6 +3351,22 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.7", + "smallvec", + "zeroize", +] + [[package]] name = "num-conv" version = "0.2.2" @@ -2946,6 +3382,16 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2953,6 +3399,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -3092,6 +3539,30 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + [[package]] name = "parking" version = "2.2.1" @@ -3121,6 +3592,17 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "paste" version = "1.0.15" @@ -3137,6 +3619,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -3417,6 +3908,27 @@ dependencies = [ "futures-io", ] +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.33" @@ -3469,6 +3981,15 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -3884,6 +4405,16 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac 0.12.1", + "subtle", +] + [[package]] name = "ring" version = "0.17.14" @@ -3931,6 +4462,36 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid 0.9.6", + "digest 0.10.7", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rsqlite-vfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" +dependencies = [ + "hashbrown 0.16.1", + "thiserror 2.0.18", +] + [[package]] name = "rtnetlink" version = "0.20.0" @@ -3981,7 +4542,7 @@ version = "8.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42ffa149f6aa81b58a5b3011d01a857c4ed12c7a732d2c51947a4c7c692185f0" dependencies = [ - "sha2", + "sha2 0.11.0", "walkdir", ] @@ -4182,6 +4743,20 @@ dependencies = [ "untrusted 0.9.0", ] +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -4310,6 +4885,28 @@ dependencies = [ "rust_decimal", ] +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + [[package]] name = "sha2" version = "0.11.0" @@ -4337,6 +4934,16 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest 0.10.7", + "rand_core 0.6.4", +] + [[package]] name = "simd-adler32" version = "0.3.9" @@ -4359,6 +4966,18 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.18", + "time", +] + [[package]] name = "slab" version = "0.4.12" @@ -4400,6 +5019,28 @@ dependencies = [ "lock_api", ] +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlite-wasm-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" +dependencies = [ + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -4734,6 +5375,19 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "serde_core", + "serde_spanned", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "winnow 0.7.15", +] + [[package]] name = "toml" version = "1.1.2+spec-1.1.0" @@ -4743,10 +5397,19 @@ dependencies = [ "indexmap 2.14.0", "serde_core", "serde_spanned", - "toml_datetime", + "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "toml_writer", - "winnow", + "winnow 1.0.4", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", ] [[package]] @@ -4764,7 +5427,7 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "winnow", + "winnow 1.0.4", ] [[package]] @@ -4843,6 +5506,22 @@ dependencies = [ "tonic-build", ] +[[package]] +name = "totp-rs" +version = "5.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e69a15e21b2ff22c415446983978bded3244195f17d59cb113551c1e806f91" +dependencies = [ + "base32", + "constant_time_eq", + "hmac 0.12.1", + "rand 0.9.5", + "sha1", + "sha2 0.10.9", + "url", + "urlencoding", +] + [[package]] name = "tower" version = "0.5.3" @@ -5032,6 +5711,12 @@ dependencies = [ "serde", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -5603,6 +6288,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + [[package]] name = "winnow" version = "1.0.4" @@ -5760,6 +6451,20 @@ name = "zeroize" version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] [[package]] name = "zerotrie" diff --git a/Cargo.toml b/Cargo.toml index 167d88a..d8d0b4f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,10 @@ async-trait = "0.1.89" tonic = "0.14.5" prost = "0.14.3" tonic-prost = "0.14.5" +diesel = "2.3.11" +diesel-async = "0.9.2" +diesel_migrations = "2.3.2" +libsqlite3-sys = "0.30" [profile.release] opt-level = 3 diff --git a/README.md b/README.md index e992ef7..cab3bf1 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,11 @@ The repository should be cloned under `/root` so the provided `setup-*.sh` scrip ``` NET_TYPE=VXLAN CERT_ENCRYPTION_KEY=<32 raw bytes or 64 hex chars> + JWT_SIGNING_KEY=<32 raw bytes or 64 hex chars> + MFA_ENCRYPTION_KEY=<32 raw bytes or 64 hex chars> + DATABASE_URL=/var/nullnet/data/nullnet.db # SQLite db path; default shown + ADMIN_BOOTSTRAP_USERNAME=admin # only used the first time the users table is empty + ADMIN_BOOTSTRAP_PASSWORD= # ditto — defaults to admin/admin if either is unset PROXY_IP=192.168.1.100 ENCRYPTION_ENABLED=true INGRESS_ALLOW_TCP_PORTS=22,8080 # inbound TCP listeners every node accepts @@ -50,6 +55,21 @@ The repository should be cloned under `/root` so the provided `setup-*.sh` scrip keep it stable, since rotating it makes existing encrypted data undecryptable. Generate one with `openssl rand -hex 32`. + `JWT_SIGNING_KEY` and `MFA_ENCRYPTION_KEY` are also **required** — the server refuses to start + without them. `JWT_SIGNING_KEY` signs the admin UI's session access tokens; `MFA_ENCRYPTION_KEY` + encrypts TOTP secrets at rest. Each is deliberately a separate key from `CERT_ENCRYPTION_KEY` (no + shared blast radius between secret classes) — generate each independently with `openssl rand -hex 32` + and keep them stable, for the same reason as `CERT_ENCRYPTION_KEY`. + + `DATABASE_URL` is the path to the server's SQLite database (users, sessions, service config, etc.), + created on first start along with any parent directories. Defaults to `/var/nullnet/data/nullnet.db` + if unset. + + `ADMIN_BOOTSTRAP_USERNAME`/`ADMIN_BOOTSTRAP_PASSWORD` create the first admin account the one time + the `users` table is empty — irrelevant on every subsequent start. If either is unset, the server + falls back to `admin`/`admin` and prints a loud warning; change that password immediately after + first login if the admin UI is reachable by anyone else. + `PROXY_IP` is the IP of the host running `nullnet-proxy` (the egress gateway). It is **required to enable egress brokering**: when a registered service reaches out to the internet the server builds a per-initiator egress edge to this host. If unset, egress is disabled (the trigger is rejected @@ -68,6 +88,12 @@ The repository should be cloned under `/root` so the provided `setup-*.sh` scrip IP equals `PROXY_IP`) is switched to gateway posture automatically — all outbound allowed and tracked — so no per-node flag is needed; inbound there still obeys these lists, so include `80,443`. +- the admin web UI now requires login (JWT-based, cookie sessions). Roles are `admin` (all + permissions) and `user` (explicit per-resource scopes, assigned from the *Users* page). MFA + (TOTP) is optional per-account — an account without it configured sees a persistent banner + prompting setup (QR code + confirm step), until it's done. See `ADMIN_BOOTSTRAP_USERNAME` above + for how the first admin account is created. + - TLS certificates are issued from Let's Encrypt via a DNS-01 challenge (UI: *Certificates* page). Each cert stores its DNS-provider credentials encrypted at rest and is **renewed automatically** before expiry. The renewal scan is tunable via optional env vars (defaults shown): diff --git a/members/nullnet-server/Cargo.toml b/members/nullnet-server/Cargo.toml index 63e9472..54658ea 100644 --- a/members/nullnet-server/Cargo.toml +++ b/members/nullnet-server/Cargo.toml @@ -41,6 +41,17 @@ openssl = "0.10" quick-xml = "0.39" aws-sdk-route53 = "1" aws-credential-types = { version = "1", features = ["hardcoded-credentials"] } +diesel = { workspace = true, features = ["sqlite", "returning_clauses_for_sqlite_3_35"] } +diesel-async = { workspace = true, features = ["sync-connection-wrapper", "tokio"] } +diesel_migrations = { workspace = true, features = ["sqlite"] } +libsqlite3-sys = { workspace = true, features = ["bundled"] } +# auth: JWT + password hashing + TOTP MFA +jsonwebtoken = { version = "11", default-features = false, features = ["rust_crypto", "use_pem"] } +argon2 = "0.5" +totp-rs = { version = "5", features = ["gen_secret", "otpauth"] } +sha2 = "0.11" +axum-extra = { version = "0.12", features = ["cookie"] } +time = { version = "0.3", default-features = false } [build-dependencies] diff --git a/members/nullnet-server/build.rs b/members/nullnet-server/build.rs index db0044e..9e8b9f3 100644 --- a/members/nullnet-server/build.rs +++ b/members/nullnet-server/build.rs @@ -1,12 +1,23 @@ +use std::path::Path; use std::process::Command; fn main() { println!("cargo:rerun-if-changed=ui/src"); println!("cargo:rerun-if-changed=ui/index.html"); println!("cargo:rerun-if-changed=ui/package.json"); + println!("cargo:rerun-if-changed=ui/package-lock.json"); - let ui_dir = std::path::Path::new("ui"); - if !ui_dir.join("node_modules").exists() { + let ui_dir = Path::new("ui"); + let node_modules = ui_dir.join("node_modules"); + // npm rewrites this to mirror package-lock.json on every successful + // install; if package-lock.json is newer, node_modules is stale (e.g. a + // dependency was added since the last install) and must be reinstalled — + // not just "does node_modules exist at all". + let installed_lock = node_modules.join(".package-lock.json"); + let needs_install = + !node_modules.exists() || is_older(&installed_lock, &ui_dir.join("package-lock.json")); + + if needs_install { let status = Command::new("npm") .args(["install"]) .current_dir(ui_dir) @@ -22,3 +33,15 @@ fn main() { .expect("failed to run npm run build"); assert!(status.success(), "npm run build failed"); } + +/// `true` if `path` doesn't exist, or its mtime predates `reference`'s — in +/// either case treat it as stale rather than trusting it. +fn is_older(path: &Path, reference: &Path) -> bool { + let (Ok(path_meta), Ok(ref_meta)) = (path.metadata(), reference.metadata()) else { + return true; + }; + let (Ok(path_mtime), Ok(ref_mtime)) = (path_meta.modified(), ref_meta.modified()) else { + return true; + }; + path_mtime < ref_mtime +} diff --git a/members/nullnet-server/src/auth/bootstrap.rs b/members/nullnet-server/src/auth/bootstrap.rs new file mode 100644 index 0000000..692f3b7 --- /dev/null +++ b/members/nullnet-server/src/auth/bootstrap.rs @@ -0,0 +1,100 @@ +use crate::auth::{Role, password}; +use crate::db::Db; +use nullnet_liberror::Error; +use uuid::Uuid; + +/// If any user already exists, this is a no-op. Otherwise creates the first +/// admin account from `bootstrap_username`/`bootstrap_password` if both are +/// non-empty; if either is missing, warns loudly and continues rather than +/// failing startup (mirrors the `INGRESS_ALLOW_TCP_PORTS.is_empty()` +/// warn-not-crash pattern in `main.rs`) — the server starts with zero users, +/// and login rejects everyone until an operator sets +/// `ADMIN_BOOTSTRAP_USERNAME`/`ADMIN_BOOTSTRAP_PASSWORD` and restarts. +/// Idempotent: safe to leave those env vars set permanently, since this only +/// ever runs while the `users` table is empty. +/// +/// Takes the candidate username/password as plain params (rather than +/// reading the env vars itself) so callers — `main.rs` in production, tests +/// here — control them directly instead of mutating shared process env state. +pub(crate) async fn ensure_admin_exists( + db: &Db, + bootstrap_username: Option<&str>, + bootstrap_password: Option<&str>, +) -> Result<(), Error> { + let users = db.users(); + if users.count().await? > 0 { + return Ok(()); + } + + let username = bootstrap_username.filter(|s| !s.trim().is_empty()); + let plain_password = bootstrap_password.filter(|s| !s.is_empty()); + let (Some(username), Some(plain_password)) = (username, plain_password) else { + println!( + "WARNING: no users exist and ADMIN_BOOTSTRAP_USERNAME/ADMIN_BOOTSTRAP_PASSWORD are \ + not both set — the admin UI will be inaccessible until an initial admin is created \ + (set these env vars and restart)." + ); + return Ok(()); + }; + + let id = Uuid::new_v4().to_string(); + let password_hash = password::hash(plain_password)?; + users + .create(&id, username, &password_hash, Role::Admin.as_str()) + .await?; + println!("Bootstrapped initial admin account '{username}'."); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::ensure_admin_exists; + use crate::db::Db; + use std::sync::atomic::{AtomicUsize, Ordering}; + + async fn test_db() -> Db { + static COUNTER: AtomicUsize = AtomicUsize::new(0); + let n = COUNTER.fetch_add(1, Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!( + "nullnet-server-bootstrap-test-{}-{n}", + std::process::id() + )); + std::fs::create_dir_all(&dir).unwrap(); + Db::open(dir.join("test.db").to_str().unwrap()) + .await + .unwrap() + } + + #[tokio::test] + async fn does_nothing_if_bootstrap_args_absent() { + let db = test_db().await; + ensure_admin_exists(&db, None, None).await.unwrap(); + assert_eq!(db.users().count().await.unwrap(), 0); + } + + #[tokio::test] + async fn creates_admin_when_bootstrap_args_set() { + let db = test_db().await; + ensure_admin_exists(&db, Some("root"), Some("hunter22222")) + .await + .unwrap(); + + let user = db.users().by_username("root").await.unwrap().unwrap(); + assert_eq!(user.role, "admin"); + assert_ne!(user.password_hash, "hunter22222", "must be hashed"); + } + + #[tokio::test] + async fn is_idempotent_once_a_user_exists() { + let db = test_db().await; + db.users() + .create("id-1", "someone", "hash", "user") + .await + .unwrap(); + + ensure_admin_exists(&db, Some("root"), Some("hunter22222")) + .await + .unwrap(); + assert!(db.users().by_username("root").await.unwrap().is_none()); + } +} diff --git a/members/nullnet-server/src/auth/jwt.rs b/members/nullnet-server/src/auth/jwt.rs new file mode 100644 index 0000000..732f5a6 --- /dev/null +++ b/members/nullnet-server/src/auth/jwt.rs @@ -0,0 +1,158 @@ +use crate::auth::{Role, now}; +use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode}; +use nullnet_liberror::{Error, ErrorHandler, Location, location}; +use serde::{Deserialize, Serialize}; +use std::sync::OnceLock; + +static KEYS: OnceLock<(EncodingKey, DecodingKey)> = OnceLock::new(); + +/// Normal session access token lifetime. Also used by `http_server::auth` to +/// set the matching cookie `Max-Age`. +pub(crate) const ACCESS_TOKEN_TTL_SECS: i64 = 15 * 60; +/// Short-lived token handed back between login step 1 (password) and the +/// TOTP-verify step, for accounts with MFA enabled. +const MFA_PENDING_TTL_SECS: i64 = 2 * 60; + +const PURPOSE_ACCESS: &str = "access"; +const PURPOSE_MFA_PENDING: &str = "mfa_pending"; + +#[derive(Debug, Serialize, Deserialize)] +pub(crate) struct Claims { + /// User id. + pub(crate) sub: String, + pub(crate) role: String, + #[serde(default)] + pub(crate) scopes: Vec, + /// `"access"` for a normal session token, `"mfa_pending"` for the + /// step-1-to-step-2 token — kept distinct so an mfa_pending token can + /// never be accepted by the auth middleware as a real session. + pub(crate) purpose: String, + pub(crate) iat: i64, + pub(crate) exp: i64, +} + +/// Initialize the JWT signing/verification key from `JWT_SIGNING_KEY` (32 raw +/// bytes or 64 hex chars, same format as `CERT_ENCRYPTION_KEY`). Call once at +/// startup; fails fast if the key is missing/invalid. +pub(crate) fn init_from_env() -> Result<(), Error> { + let key = crate::crypto::parse_key_from_env("JWT_SIGNING_KEY")?; + let _ = KEYS.set(( + EncodingKey::from_secret(&key), + DecodingKey::from_secret(&key), + )); + Ok(()) +} + +fn keys() -> &'static (EncodingKey, DecodingKey) { + KEYS.get().expect("JWT keys not initialized") +} + +pub(crate) fn issue_access_token( + user_id: &str, + role: Role, + scopes: &[String], +) -> Result { + let iat = now(); + let claims = Claims { + sub: user_id.to_string(), + role: role.as_str().to_string(), + scopes: scopes.to_vec(), + purpose: PURPOSE_ACCESS.to_string(), + iat, + exp: iat + ACCESS_TOKEN_TTL_SECS, + }; + encode(&Header::new(Algorithm::HS256), &claims, &keys().0).handle_err(location!()) +} + +/// Verify an access token, rejecting anything that isn't `purpose: "access"` +/// (e.g. a stray `mfa_pending` token can never authenticate a request). +pub(crate) fn verify_access_token(token: &str) -> Result { + let claims = decode_any(token)?; + if claims.purpose != PURPOSE_ACCESS { + return Err::("token is not an access token").handle_err(location!()); + } + Ok(claims) +} + +pub(crate) fn issue_mfa_pending_token(user_id: &str) -> Result { + let iat = now(); + let claims = Claims { + sub: user_id.to_string(), + role: String::new(), + scopes: Vec::new(), + purpose: PURPOSE_MFA_PENDING.to_string(), + iat, + exp: iat + MFA_PENDING_TTL_SECS, + }; + encode(&Header::new(Algorithm::HS256), &claims, &keys().0).handle_err(location!()) +} + +/// Verify an mfa-pending token, returning the user id it was issued for. +pub(crate) fn verify_mfa_pending_token(token: &str) -> Result { + let claims = decode_any(token)?; + if claims.purpose != PURPOSE_MFA_PENDING { + return Err::("token is not an mfa-pending token").handle_err(location!()); + } + Ok(claims.sub) +} + +fn decode_any(token: &str) -> Result { + decode::(token, &keys().1, &Validation::new(Algorithm::HS256)) + .map(|data| data.claims) + .handle_err(location!()) +} + +#[cfg(test)] +mod tests { + use super::{ + issue_access_token, issue_mfa_pending_token, verify_access_token, verify_mfa_pending_token, + }; + use crate::auth::Role; + use std::sync::Once; + + fn ensure_keys() { + static INIT: Once = Once::new(); + INIT.call_once(|| { + // SAFETY: runs once, before any other test thread reads the env var. + unsafe { std::env::set_var("JWT_SIGNING_KEY", "b".repeat(32)) }; + super::init_from_env().unwrap(); + }); + } + + #[test] + fn access_token_round_trip() { + ensure_keys(); + let token = issue_access_token("user-1", Role::User, &["events:read".to_string()]).unwrap(); + let claims = verify_access_token(&token).unwrap(); + assert_eq!(claims.sub, "user-1"); + assert_eq!(claims.role, "user"); + assert_eq!(claims.scopes, vec!["events:read"]); + } + + #[test] + fn mfa_pending_token_round_trip() { + ensure_keys(); + let token = issue_mfa_pending_token("user-2").unwrap(); + assert_eq!(verify_mfa_pending_token(&token).unwrap(), "user-2"); + } + + #[test] + fn mfa_pending_token_rejected_as_access_token() { + ensure_keys(); + let token = issue_mfa_pending_token("user-2").unwrap(); + assert!(verify_access_token(&token).is_err()); + } + + #[test] + fn access_token_rejected_as_mfa_pending_token() { + ensure_keys(); + let token = issue_access_token("user-1", Role::Admin, &[]).unwrap(); + assert!(verify_mfa_pending_token(&token).is_err()); + } + + #[test] + fn garbage_token_is_rejected() { + ensure_keys(); + assert!(verify_access_token("not-a-jwt").is_err()); + } +} diff --git a/members/nullnet-server/src/auth/mfa_crypto.rs b/members/nullnet-server/src/auth/mfa_crypto.rs new file mode 100644 index 0000000..0980a10 --- /dev/null +++ b/members/nullnet-server/src/auth/mfa_crypto.rs @@ -0,0 +1,35 @@ +use crate::crypto::Encryptor; +use nullnet_liberror::Error; +use std::sync::OnceLock; + +/// Process-wide cipher used to encrypt TOTP secrets at rest. Deliberately a +/// *separate* key/instance from `crypto::CIPHER` (which handles cert private +/// keys) — no shared blast radius between the two secret classes. +static CIPHER: OnceLock = OnceLock::new(); + +/// Initialize the global MFA-secret cipher from `MFA_ENCRYPTION_KEY` (32 raw +/// bytes or 64 hex chars). Call once at startup; fails fast if missing/invalid. +pub(crate) fn init_from_env() -> Result<(), Error> { + let key = crate::crypto::parse_key_from_env("MFA_ENCRYPTION_KEY")?; + let _ = CIPHER.set(Encryptor::new(&key)); + Ok(()) +} + +/// The global MFA-secret cipher. Panics if [`init_from_env`] wasn't called first. +pub(crate) fn cipher() -> &'static Encryptor { + CIPHER.get().expect("MFA cipher not initialized") +} + +#[cfg(test)] +mod tests { + use crate::crypto::Encryptor; + + #[test] + fn round_trip() { + let enc = Encryptor::new(&[9u8; 32]); + let secret = "JBSWY3DPEHPK3PXP"; + let ct = enc.encrypt(secret).unwrap(); + assert_ne!(ct, secret); + assert_eq!(enc.decrypt(&ct).unwrap(), secret); + } +} diff --git a/members/nullnet-server/src/auth/mod.rs b/members/nullnet-server/src/auth/mod.rs new file mode 100644 index 0000000..ed14ee9 --- /dev/null +++ b/members/nullnet-server/src/auth/mod.rs @@ -0,0 +1,114 @@ +//! Core authentication/authorization logic: roles, scopes, password hashing, +//! TOTP MFA, JWT issuing/verification, and refresh-token/bootstrap helpers. +//! No Axum here — the HTTP layer lives in `http_server::auth`. + +pub(crate) mod bootstrap; +pub(crate) mod jwt; +pub(crate) mod mfa_crypto; +pub(crate) mod password; +pub(crate) mod session; +pub(crate) mod totp; + +use nullnet_liberror::{Error, ErrorHandler, Location, location}; + +/// Unix seconds. Mirrors `db::now()` — kept separate since `auth` doesn't +/// otherwise depend on `db`. +pub(crate) fn now() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Role { + Admin, + User, +} + +impl Role { + pub(crate) fn as_str(self) -> &'static str { + match self { + Role::Admin => "admin", + Role::User => "user", + } + } +} + +impl std::str::FromStr for Role { + type Err = Error; + + fn from_str(s: &str) -> Result { + match s { + "admin" => Ok(Role::Admin), + "user" => Ok(Role::User), + other => Err::(format!("unknown role '{other}'")).handle_err(location!()), + } + } +} + +/// A single resource-level permission. `admin`-role users implicitly have +/// every scope; `user`-role accounts get an explicit subset assigned by an +/// admin (persisted in the `user_scopes` table). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Scope { + CertificatesRead, + CertificatesWrite, + ConfigRead, + ConfigWrite, + SessionsRead, + SessionsWrite, + NodesRead, + EventsRead, +} + +impl Scope { + pub(crate) const ALL: [Scope; 8] = [ + Scope::CertificatesRead, + Scope::CertificatesWrite, + Scope::ConfigRead, + Scope::ConfigWrite, + Scope::SessionsRead, + Scope::SessionsWrite, + Scope::NodesRead, + Scope::EventsRead, + ]; + + pub(crate) fn as_str(self) -> &'static str { + match self { + Scope::CertificatesRead => "certificates:read", + Scope::CertificatesWrite => "certificates:write", + Scope::ConfigRead => "config:read", + Scope::ConfigWrite => "config:write", + Scope::SessionsRead => "sessions:read", + Scope::SessionsWrite => "sessions:write", + Scope::NodesRead => "nodes:read", + Scope::EventsRead => "events:read", + } + } + + pub(crate) fn from_str_opt(s: &str) -> Option { + Scope::ALL.into_iter().find(|scope| scope.as_str() == s) + } +} + +#[cfg(test)] +mod tests { + use super::{Role, Scope}; + use std::str::FromStr; + + #[test] + fn role_round_trips() { + assert_eq!(Role::from_str("admin").unwrap().as_str(), "admin"); + assert_eq!(Role::from_str("user").unwrap().as_str(), "user"); + assert!(Role::from_str("nope").is_err()); + } + + #[test] + fn scope_round_trips() { + for scope in Scope::ALL { + assert_eq!(Scope::from_str_opt(scope.as_str()), Some(scope)); + } + assert_eq!(Scope::from_str_opt("bogus:scope"), None); + } +} diff --git a/members/nullnet-server/src/auth/password.rs b/members/nullnet-server/src/auth/password.rs new file mode 100644 index 0000000..803314b --- /dev/null +++ b/members/nullnet-server/src/auth/password.rs @@ -0,0 +1,41 @@ +use argon2::password_hash::rand_core::OsRng; +use argon2::password_hash::{PasswordHash, SaltString}; +use argon2::{Argon2, PasswordHasher, PasswordVerifier}; +use nullnet_liberror::{Error, ErrorHandler, Location, location}; + +/// Hash `plain` with argon2id, returning the standard encoded hash string +/// (algorithm + params + salt + hash, all in one — nothing else needs storing). +pub(crate) fn hash(plain: &str) -> Result { + let salt = SaltString::generate(&mut OsRng); + Ok(Argon2::default() + .hash_password(plain.as_bytes(), &salt) + .handle_err(location!())? + .to_string()) +} + +/// Verify `plain` against a previously encoded hash from [`hash`]. +pub(crate) fn verify(plain: &str, encoded_hash: &str) -> Result { + let parsed = PasswordHash::new(encoded_hash).handle_err(location!())?; + Ok(Argon2::default() + .verify_password(plain.as_bytes(), &parsed) + .is_ok()) +} + +#[cfg(test)] +mod tests { + use super::{hash, verify}; + + #[test] + fn round_trip() { + let encoded = hash("correct horse battery staple").unwrap(); + assert_ne!(encoded, "correct horse battery staple"); + assert!(verify("correct horse battery staple", &encoded).unwrap()); + assert!(!verify("wrong password", &encoded).unwrap()); + } + + #[test] + fn same_password_hashes_differently() { + // random salt per call + assert_ne!(hash("same").unwrap(), hash("same").unwrap()); + } +} diff --git a/members/nullnet-server/src/auth/session.rs b/members/nullnet-server/src/auth/session.rs new file mode 100644 index 0000000..c2562c0 --- /dev/null +++ b/members/nullnet-server/src/auth/session.rs @@ -0,0 +1,53 @@ +use aes_gcm::aead::OsRng; +use aes_gcm::aead::rand_core::RngCore; +use base64::Engine; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use sha2::{Digest, Sha256}; + +/// Refresh token lifetime. +pub(crate) const REFRESH_TOKEN_TTL_SECS: i64 = 30 * 24 * 60 * 60; + +/// A fresh opaque refresh token: 32 random bytes, base64url-encoded (this is +/// the raw value that goes in the cookie — only its hash is ever stored). +/// Reuses the same CSPRNG source `crypto.rs` already relies on for nonces. +pub(crate) fn generate_raw_token() -> String { + let mut bytes = [0u8; 32]; + OsRng.fill_bytes(&mut bytes); + URL_SAFE_NO_PAD.encode(bytes) +} + +/// SHA-256 hex digest of a raw token — what actually gets stored/looked up. +pub(crate) fn hash_token(raw: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(raw.as_bytes()); + hasher + .finalize() + .iter() + .map(|b| format!("{b:02x}")) + .collect() +} + +#[cfg(test)] +mod tests { + use super::{generate_raw_token, hash_token}; + + #[test] + fn tokens_are_unique() { + assert_ne!(generate_raw_token(), generate_raw_token()); + } + + #[test] + fn hash_is_deterministic_and_not_the_raw_value() { + let raw = generate_raw_token(); + assert_eq!(hash_token(&raw), hash_token(&raw)); + assert_ne!(hash_token(&raw), raw); + } + + #[test] + fn different_tokens_hash_differently() { + assert_ne!( + hash_token(&generate_raw_token()), + hash_token(&generate_raw_token()) + ); + } +} diff --git a/members/nullnet-server/src/auth/totp.rs b/members/nullnet-server/src/auth/totp.rs new file mode 100644 index 0000000..9ba7c90 --- /dev/null +++ b/members/nullnet-server/src/auth/totp.rs @@ -0,0 +1,92 @@ +use nullnet_liberror::{Error, ErrorHandler, Location, location}; +use totp_rs::{Algorithm, Secret, TOTP}; + +/// Shown in the authenticator app next to the account name. +const ISSUER: &str = "nullnet"; +/// RFC 6238 defaults: 6-digit codes, 30s steps, ±1 step (30s) clock skew. +const DIGITS: usize = 6; +const SKEW: u8 = 1; +const STEP_SECS: u64 = 30; + +/// Generate a fresh random base32 TOTP secret (160 bits, RFC 4226's +/// recommended size). +pub(crate) fn generate_secret() -> String { + match Secret::generate_secret().to_encoded() { + Secret::Encoded(s) => s, + Secret::Raw(_) => unreachable!("Secret::to_encoded() always returns Secret::Encoded"), + } +} + +fn totp_for(secret_base32: &str, username: &str) -> Result { + let secret_bytes = Secret::Encoded(secret_base32.to_string()) + .to_bytes() + .handle_err(location!())?; + TOTP::new( + Algorithm::SHA1, + DIGITS, + SKEW, + STEP_SECS, + secret_bytes, + Some(ISSUER.to_string()), + username.to_string(), + ) + .handle_err(location!()) +} + +/// The `otpauth://` provisioning URI for a QR code / manual entry. +pub(crate) fn provisioning_uri(secret_base32: &str, username: &str) -> Result { + Ok(totp_for(secret_base32, username)?.get_url()) +} + +/// Verify a user-supplied code against `secret_base32` at the current time +/// (within the configured clock-skew window). +pub(crate) fn verify_code(secret_base32: &str, code: &str) -> Result { + totp_for(secret_base32, "")? + .check_current(code) + .handle_err(location!()) +} + +#[cfg(test)] +mod tests { + use super::{generate_secret, provisioning_uri, totp_for, verify_code}; + + #[test] + fn generated_secret_is_usable() { + let secret = generate_secret(); + assert!(!secret.is_empty()); + // round-trips through TOTP::new without error + totp_for(&secret, "alice").unwrap(); + } + + #[test] + fn provisioning_uri_contains_issuer_and_account() { + let secret = generate_secret(); + let uri = provisioning_uri(&secret, "alice").unwrap(); + assert!(uri.starts_with("otpauth://totp/")); + assert!(uri.contains("nullnet")); + assert!(uri.contains("alice")); + } + + #[test] + fn current_code_verifies() { + let secret = generate_secret(); + let totp = totp_for(&secret, "alice").unwrap(); + let code = totp.generate_current().unwrap(); + assert!(verify_code(&secret, &code).unwrap()); + } + + #[test] + fn wrong_code_is_rejected() { + let secret = generate_secret(); + let totp = totp_for(&secret, "alice").unwrap(); + let real_code = totp.generate_current().unwrap(); + // flip the first digit so it's guaranteed to differ from the real code + let mut wrong_code = real_code.clone(); + let first = wrong_code.remove(0); + let flipped = if first == '0' { '1' } else { '0' }; + wrong_code.insert(0, flipped); + + assert!(verify_code(&secret, &real_code).unwrap()); + assert!(!verify_code(&secret, &wrong_code).unwrap()); + } +} diff --git a/members/nullnet-server/src/crypto.rs b/members/nullnet-server/src/crypto.rs index 84afc02..25a6f2d 100644 --- a/members/nullnet-server/src/crypto.rs +++ b/members/nullnet-server/src/crypto.rs @@ -14,7 +14,7 @@ pub(crate) struct Encryptor { } impl Encryptor { - fn new(key: &[u8; 32]) -> Self { + pub(crate) fn new(key: &[u8; 32]) -> Self { Self { cipher: Aes256Gcm::new(Key::::from_slice(key)), } @@ -50,30 +50,37 @@ impl Encryptor { /// Initialize the global cipher from `CERT_ENCRYPTION_KEY` (32 raw bytes or 64 /// hex chars). Call once at startup; fails fast if the key is missing/invalid. pub(crate) fn init_from_env() -> Result<(), Error> { - let raw = std::env::var("CERT_ENCRYPTION_KEY") - .map_err(|_| "CERT_ENCRYPTION_KEY not set") + let key = parse_key_from_env("CERT_ENCRYPTION_KEY")?; + let _ = CIPHER.set(Encryptor::new(&key)); + Ok(()) +} + +/// Read a 32-byte key from the env var `name` (32 raw bytes or 64 hex chars). +/// Shared by every secret-key env var this process loads at startup (cert +/// encryption, MFA-secret encryption, JWT signing) so they all get the same +/// fail-fast validation and accepted formats. +pub(crate) fn parse_key_from_env(name: &str) -> Result<[u8; 32], Error> { + let raw = std::env::var(name) + .map_err(|_| format!("{name} not set")) .handle_err(location!())?; - let key: [u8; 32] = if raw.len() == 64 { + if raw.len() == 64 { decode_hex(&raw)? .try_into() - .map_err(|_| "CERT_ENCRYPTION_KEY: hex must decode to exactly 32 bytes") - .handle_err(location!())? + .map_err(|_| format!("{name}: hex must decode to exactly 32 bytes")) + .handle_err(location!()) } else if raw.len() == 32 { raw.as_bytes() .try_into() - .map_err(|_| "CERT_ENCRYPTION_KEY: failed to read as 32-byte key") - .handle_err(location!())? + .map_err(|_| format!("{name}: failed to read as 32-byte key")) + .handle_err(location!()) } else { - return Err::<(), _>(format!( - "CERT_ENCRYPTION_KEY: expected 32 raw chars or 64 hex chars, got {}", + Err::<[u8; 32], _>(format!( + "{name}: expected 32 raw chars or 64 hex chars, got {}", raw.len() )) - .handle_err(location!()); - }; - - let _ = CIPHER.set(Encryptor::new(&key)); - Ok(()) + .handle_err(location!()) + } } /// The global cipher. Panics if [`init_from_env`] was not called first. diff --git a/members/nullnet-server/src/db/certs.rs b/members/nullnet-server/src/db/certs.rs new file mode 100644 index 0000000..82839a0 --- /dev/null +++ b/members/nullnet-server/src/db/certs.rs @@ -0,0 +1,267 @@ +use crate::crypto; +use crate::db::AsyncSqlite; +use crate::db::models::{Certificate, NewCertificate, NewDnsCredential}; +use crate::db::schema::{certificates, dns_credentials}; +use diesel::prelude::*; +use diesel_async::RunQueryDsl; +use nullnet_liberror::{Error, ErrorHandler, Location, location}; +use std::sync::Arc; +use tokio::sync::Mutex; + +/// A certificate as handed back to callers: PEM chain plus the *decrypted* +/// private key (the encrypted-at-rest representation never leaves this module). +pub(crate) struct CertRecord { + pub(crate) domain: String, + pub(crate) fullchain_pem: String, + pub(crate) key_pem: String, + pub(crate) not_after: i64, +} + +impl CertRecord { + fn from_row(row: Certificate) -> Result { + Ok(Self { + key_pem: crypto::cipher().decrypt(&row.key_pem_enc)?, + domain: row.domain, + fullchain_pem: row.fullchain_pem, + not_after: row.not_after, + }) + } +} + +/// Typed access to the `certificates`/`dns_credentials` tables. Handles +/// encryption at rest transparently, mirroring `certs.rs`'s current +/// file-based call surface so it can be swapped in as a drop-in replacement. +pub(crate) struct CertRepository { + conn: Arc>, +} + +impl CertRepository { + pub(super) fn new(conn: Arc>) -> Self { + Self { conn } + } + + /// Insert or replace the cert for `domain`. Returns whether a row for + /// this domain already existed (i.e. this was a renewal/replacement). + pub(crate) async fn put_cert( + &self, + domain: &str, + fullchain_pem: &str, + key_pem: &str, + ) -> Result { + let key_pem_enc = crypto::cipher().encrypt(key_pem)?; + let not_after = parse_not_after(fullchain_pem) + .ok_or("failed to parse notAfter from fullchain_pem") + .handle_err(location!())?; + let updated_at = super::now(); + + let new_cert = NewCertificate { + domain, + fullchain_pem, + key_pem_enc: &key_pem_enc, + not_after, + updated_at, + }; + + let mut conn = self.conn.lock().await; + let existed = certificates::table + .find(domain) + .count() + .get_result::(&mut *conn) + .await + .handle_err(location!())? + > 0; + diesel::insert_into(certificates::table) + .values(&new_cert) + .on_conflict(certificates::domain) + .do_update() + .set(&new_cert) + .execute(&mut *conn) + .await + .handle_err(location!())?; + Ok(existed) + } + + pub(crate) async fn get(&self, domain: &str) -> Result, Error> { + let mut conn = self.conn.lock().await; + let row = certificates::table + .find(domain) + .first::(&mut *conn) + .await + .optional() + .handle_err(location!())?; + row.map(CertRecord::from_row).transpose() + } + + pub(crate) async fn list(&self) -> Result, Error> { + let mut conn = self.conn.lock().await; + let rows = certificates::table + .load::(&mut *conn) + .await + .handle_err(location!())?; + rows.into_iter().map(CertRecord::from_row).collect() + } + + pub(crate) async fn delete(&self, domain: &str) -> Result<(), Error> { + let mut conn = self.conn.lock().await; + diesel::delete(certificates::table.find(domain)) + .execute(&mut *conn) + .await + .handle_err(location!())?; + Ok(()) + } + + pub(crate) async fn domains(&self) -> Result, Error> { + let mut conn = self.conn.lock().await; + certificates::table + .select(certificates::domain) + .load(&mut *conn) + .await + .handle_err(location!()) + } + + pub(crate) async fn expiry(&self, domain: &str) -> Result, Error> { + let mut conn = self.conn.lock().await; + certificates::table + .find(domain) + .select(certificates::not_after) + .first(&mut *conn) + .await + .optional() + .handle_err(location!()) + } + + /// Store DNS-provider credentials (`creds_json`) encrypted at rest so the + /// cert can be auto-renewed without re-supplying the token. + pub(crate) async fn put_dns_credentials( + &self, + domain: &str, + creds_json: &str, + ) -> Result<(), Error> { + let creds_json_enc = crypto::cipher().encrypt(creds_json)?; + let new_creds = NewDnsCredential { + domain, + creds_json_enc: &creds_json_enc, + }; + let mut conn = self.conn.lock().await; + diesel::insert_into(dns_credentials::table) + .values(&new_creds) + .on_conflict(dns_credentials::domain) + .do_update() + .set(&new_creds) + .execute(&mut *conn) + .await + .handle_err(location!())?; + Ok(()) + } + + /// Load + decrypt the stored DNS-provider credentials JSON for `domain`, if any. + pub(crate) async fn dns_credentials(&self, domain: &str) -> Result, Error> { + let mut conn = self.conn.lock().await; + let enc: Option = dns_credentials::table + .find(domain) + .select(dns_credentials::creds_json_enc) + .first(&mut *conn) + .await + .optional() + .handle_err(location!())?; + enc.map(|enc| crypto::cipher().decrypt(&enc)).transpose() + } +} + +/// Best-effort leaf `notAfter` (unix seconds) parsed out of a `fullchain_pem`. +fn parse_not_after(fullchain_pem: &str) -> Option { + let (_, pem) = x509_parser::pem::parse_x509_pem(fullchain_pem.as_bytes()).ok()?; + let cert = pem.parse_x509().ok()?; + Some(cert.validity().not_after.timestamp()) +} + +#[cfg(test)] +mod tests { + use crate::db::Db; + + fn ensure_crypto() { + static INIT: std::sync::Once = std::sync::Once::new(); + INIT.call_once(|| { + // SAFETY: runs once, before any other test thread reads the env var. + unsafe { std::env::set_var("CERT_ENCRYPTION_KEY", "a".repeat(32)) }; + let _ = crate::crypto::init_from_env(); + }); + } + + async fn test_db() -> Db { + ensure_crypto(); + static COUNTER: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!( + "nullnet-server-certs-test-{}-{n}", + std::process::id() + )); + std::fs::create_dir_all(&dir).unwrap(); + Db::open(dir.join("test.db").to_str().unwrap()) + .await + .unwrap() + } + + fn self_signed_pem() -> (String, String) { + let cert = rcgen::generate_simple_self_signed(vec!["example.com".to_string()]).unwrap(); + (cert.cert.pem(), cert.signing_key.serialize_pem()) + } + + #[tokio::test] + async fn put_get_list_delete_round_trip() { + let db = test_db().await; + let repo = db.certs(); + let (fullchain_pem, key_pem) = self_signed_pem(); + + let existed = repo + .put_cert("example.com", &fullchain_pem, &key_pem) + .await + .unwrap(); + assert!(!existed, "first insert should not report an existing row"); + + let existed_again = repo + .put_cert("example.com", &fullchain_pem, &key_pem) + .await + .unwrap(); + assert!(existed_again, "second insert is a replacement"); + + let fetched = repo.get("example.com").await.unwrap().unwrap(); + assert_eq!(fetched.fullchain_pem, fullchain_pem); + assert_eq!(fetched.key_pem, key_pem); + assert!(fetched.not_after > 0); + + assert_eq!(repo.domains().await.unwrap(), vec!["example.com"]); + assert_eq!(repo.list().await.unwrap().len(), 1); + assert_eq!( + repo.expiry("example.com").await.unwrap(), + Some(fetched.not_after) + ); + + repo.delete("example.com").await.unwrap(); + assert!(repo.get("example.com").await.unwrap().is_none()); + assert!(repo.domains().await.unwrap().is_empty()); + } + + #[tokio::test] + async fn dns_credentials_round_trip() { + let db = test_db().await; + let repo = db.certs(); + let (fullchain_pem, key_pem) = self_signed_pem(); + repo.put_cert("example.com", &fullchain_pem, &key_pem) + .await + .unwrap(); + + assert!(repo.dns_credentials("example.com").await.unwrap().is_none()); + + repo.put_dns_credentials("example.com", r#"{"token":"secret"}"#) + .await + .unwrap(); + assert_eq!( + repo.dns_credentials("example.com") + .await + .unwrap() + .as_deref(), + Some(r#"{"token":"secret"}"#) + ); + } +} diff --git a/members/nullnet-server/src/db/login_attempts.rs b/members/nullnet-server/src/db/login_attempts.rs new file mode 100644 index 0000000..88b466f --- /dev/null +++ b/members/nullnet-server/src/db/login_attempts.rs @@ -0,0 +1,133 @@ +use crate::db::AsyncSqlite; +use crate::db::models::{LoginAttempt, NewLoginAttempt}; +use crate::db::schema::login_attempts; +use diesel::prelude::*; +use diesel_async::RunQueryDsl; +use nullnet_liberror::{Error, ErrorHandler, Location, location}; +use std::sync::Arc; +use tokio::sync::Mutex; + +/// After this many consecutive failures, the account is locked out. +const MAX_FAILED_ATTEMPTS: i32 = 5; +/// Lockout duration once the threshold is hit. +const LOCKOUT_SECS: i64 = 15 * 60; + +/// Typed access to the `login_attempts` table — a simple per-username +/// failed-login counter/lockout, keyed by username (not IP) since this is a +/// small internal admin tool rather than a public-facing service. +pub(crate) struct LoginAttemptRepository { + conn: Arc>, +} + +impl LoginAttemptRepository { + pub(super) fn new(conn: Arc>) -> Self { + Self { conn } + } + + /// `true` if `username` is currently locked out. + pub(crate) async fn is_locked(&self, username: &str) -> Result { + let mut conn = self.conn.lock().await; + let row = login_attempts::table + .find(username) + .first::(&mut *conn) + .await + .optional() + .handle_err(location!())?; + Ok(row + .and_then(|r| r.locked_until) + .is_some_and(|until| until > super::now())) + } + + /// Record a failed attempt; locks the account once the threshold is hit. + pub(crate) async fn record_failure(&self, username: &str) -> Result<(), Error> { + let mut conn = self.conn.lock().await; + let existing = login_attempts::table + .find(username) + .first::(&mut *conn) + .await + .optional() + .handle_err(location!())?; + + let now = super::now(); + let failed_count = existing.map_or(0, |r| r.failed_count) + 1; + let locked_until = if failed_count >= MAX_FAILED_ATTEMPTS { + Some(now + LOCKOUT_SECS) + } else { + None + }; + + let new_row = NewLoginAttempt { + username, + failed_count, + locked_until, + updated_at: now, + }; + diesel::insert_into(login_attempts::table) + .values(&new_row) + .on_conflict(login_attempts::username) + .do_update() + .set(&new_row) + .execute(&mut *conn) + .await + .handle_err(location!())?; + Ok(()) + } + + /// Reset the counter on a successful login. + pub(crate) async fn clear(&self, username: &str) -> Result<(), Error> { + let mut conn = self.conn.lock().await; + diesel::delete(login_attempts::table.find(username)) + .execute(&mut *conn) + .await + .handle_err(location!())?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use crate::db::Db; + + async fn test_db() -> Db { + static COUNTER: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!( + "nullnet-server-login-attempts-test-{}-{n}", + std::process::id() + )); + std::fs::create_dir_all(&dir).unwrap(); + Db::open(dir.join("test.db").to_str().unwrap()) + .await + .unwrap() + } + + #[tokio::test] + async fn locks_out_after_threshold() { + let db = test_db().await; + let repo = db.login_attempts(); + + assert!(!repo.is_locked("alice").await.unwrap()); + for _ in 0..4 { + repo.record_failure("alice").await.unwrap(); + assert!(!repo.is_locked("alice").await.unwrap()); + } + repo.record_failure("alice").await.unwrap(); + assert!(repo.is_locked("alice").await.unwrap()); + } + + #[tokio::test] + async fn clear_resets_counter() { + let db = test_db().await; + let repo = db.login_attempts(); + for _ in 0..5 { + repo.record_failure("alice").await.unwrap(); + } + assert!(repo.is_locked("alice").await.unwrap()); + + repo.clear("alice").await.unwrap(); + assert!(!repo.is_locked("alice").await.unwrap()); + // failure count restarts from zero + repo.record_failure("alice").await.unwrap(); + assert!(!repo.is_locked("alice").await.unwrap()); + } +} diff --git a/members/nullnet-server/src/db/migrations/2026-07-24-000001_create_certificates/down.sql b/members/nullnet-server/src/db/migrations/2026-07-24-000001_create_certificates/down.sql new file mode 100644 index 0000000..749bd02 --- /dev/null +++ b/members/nullnet-server/src/db/migrations/2026-07-24-000001_create_certificates/down.sql @@ -0,0 +1 @@ +DROP TABLE certificates; diff --git a/members/nullnet-server/src/db/migrations/2026-07-24-000001_create_certificates/up.sql b/members/nullnet-server/src/db/migrations/2026-07-24-000001_create_certificates/up.sql new file mode 100644 index 0000000..6e36387 --- /dev/null +++ b/members/nullnet-server/src/db/migrations/2026-07-24-000001_create_certificates/up.sql @@ -0,0 +1,7 @@ +CREATE TABLE certificates ( + domain TEXT NOT NULL PRIMARY KEY, + fullchain_pem TEXT NOT NULL, + key_pem_enc TEXT NOT NULL, + not_after BIGINT NOT NULL, + updated_at BIGINT NOT NULL +); diff --git a/members/nullnet-server/src/db/migrations/2026-07-24-000002_create_dns_credentials/down.sql b/members/nullnet-server/src/db/migrations/2026-07-24-000002_create_dns_credentials/down.sql new file mode 100644 index 0000000..214edca --- /dev/null +++ b/members/nullnet-server/src/db/migrations/2026-07-24-000002_create_dns_credentials/down.sql @@ -0,0 +1 @@ +DROP TABLE dns_credentials; diff --git a/members/nullnet-server/src/db/migrations/2026-07-24-000002_create_dns_credentials/up.sql b/members/nullnet-server/src/db/migrations/2026-07-24-000002_create_dns_credentials/up.sql new file mode 100644 index 0000000..f537c21 --- /dev/null +++ b/members/nullnet-server/src/db/migrations/2026-07-24-000002_create_dns_credentials/up.sql @@ -0,0 +1,4 @@ +CREATE TABLE dns_credentials ( + domain TEXT NOT NULL PRIMARY KEY REFERENCES certificates(domain) ON DELETE CASCADE, + creds_json_enc TEXT NOT NULL +); diff --git a/members/nullnet-server/src/db/migrations/2026-07-24-000003_create_services/down.sql b/members/nullnet-server/src/db/migrations/2026-07-24-000003_create_services/down.sql new file mode 100644 index 0000000..4dd8073 --- /dev/null +++ b/members/nullnet-server/src/db/migrations/2026-07-24-000003_create_services/down.sql @@ -0,0 +1 @@ +DROP TABLE services; diff --git a/members/nullnet-server/src/db/migrations/2026-07-24-000003_create_services/up.sql b/members/nullnet-server/src/db/migrations/2026-07-24-000003_create_services/up.sql new file mode 100644 index 0000000..a505792 --- /dev/null +++ b/members/nullnet-server/src/db/migrations/2026-07-24-000003_create_services/up.sql @@ -0,0 +1,5 @@ +CREATE TABLE services ( + stack TEXT NOT NULL PRIMARY KEY, + service_json TEXT NOT NULL, + updated_at BIGINT NOT NULL +); diff --git a/members/nullnet-server/src/db/migrations/2026-07-24-000004_create_users/down.sql b/members/nullnet-server/src/db/migrations/2026-07-24-000004_create_users/down.sql new file mode 100644 index 0000000..cc1f647 --- /dev/null +++ b/members/nullnet-server/src/db/migrations/2026-07-24-000004_create_users/down.sql @@ -0,0 +1 @@ +DROP TABLE users; diff --git a/members/nullnet-server/src/db/migrations/2026-07-24-000004_create_users/up.sql b/members/nullnet-server/src/db/migrations/2026-07-24-000004_create_users/up.sql new file mode 100644 index 0000000..f546406 --- /dev/null +++ b/members/nullnet-server/src/db/migrations/2026-07-24-000004_create_users/up.sql @@ -0,0 +1,10 @@ +CREATE TABLE users ( + id TEXT NOT NULL PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + role TEXT NOT NULL, + mfa_secret_enc TEXT, + mfa_confirmed_at BIGINT, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL +); diff --git a/members/nullnet-server/src/db/migrations/2026-07-24-000005_create_user_scopes/down.sql b/members/nullnet-server/src/db/migrations/2026-07-24-000005_create_user_scopes/down.sql new file mode 100644 index 0000000..9aa9c41 --- /dev/null +++ b/members/nullnet-server/src/db/migrations/2026-07-24-000005_create_user_scopes/down.sql @@ -0,0 +1 @@ +DROP TABLE user_scopes; diff --git a/members/nullnet-server/src/db/migrations/2026-07-24-000005_create_user_scopes/up.sql b/members/nullnet-server/src/db/migrations/2026-07-24-000005_create_user_scopes/up.sql new file mode 100644 index 0000000..90d1214 --- /dev/null +++ b/members/nullnet-server/src/db/migrations/2026-07-24-000005_create_user_scopes/up.sql @@ -0,0 +1,5 @@ +CREATE TABLE user_scopes ( + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + scope TEXT NOT NULL, + PRIMARY KEY (user_id, scope) +); diff --git a/members/nullnet-server/src/db/migrations/2026-07-24-000006_create_refresh_tokens/down.sql b/members/nullnet-server/src/db/migrations/2026-07-24-000006_create_refresh_tokens/down.sql new file mode 100644 index 0000000..7f0336a --- /dev/null +++ b/members/nullnet-server/src/db/migrations/2026-07-24-000006_create_refresh_tokens/down.sql @@ -0,0 +1 @@ +DROP TABLE refresh_tokens; diff --git a/members/nullnet-server/src/db/migrations/2026-07-24-000006_create_refresh_tokens/up.sql b/members/nullnet-server/src/db/migrations/2026-07-24-000006_create_refresh_tokens/up.sql new file mode 100644 index 0000000..d0a0572 --- /dev/null +++ b/members/nullnet-server/src/db/migrations/2026-07-24-000006_create_refresh_tokens/up.sql @@ -0,0 +1,7 @@ +CREATE TABLE refresh_tokens ( + token_hash TEXT NOT NULL PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + expires_at BIGINT NOT NULL, + created_at BIGINT NOT NULL, + revoked_at BIGINT +); diff --git a/members/nullnet-server/src/db/migrations/2026-07-24-000007_create_login_attempts/down.sql b/members/nullnet-server/src/db/migrations/2026-07-24-000007_create_login_attempts/down.sql new file mode 100644 index 0000000..e3ad9fa --- /dev/null +++ b/members/nullnet-server/src/db/migrations/2026-07-24-000007_create_login_attempts/down.sql @@ -0,0 +1 @@ +DROP TABLE login_attempts; diff --git a/members/nullnet-server/src/db/migrations/2026-07-24-000007_create_login_attempts/up.sql b/members/nullnet-server/src/db/migrations/2026-07-24-000007_create_login_attempts/up.sql new file mode 100644 index 0000000..ab1be2e --- /dev/null +++ b/members/nullnet-server/src/db/migrations/2026-07-24-000007_create_login_attempts/up.sql @@ -0,0 +1,6 @@ +CREATE TABLE login_attempts ( + username TEXT NOT NULL PRIMARY KEY, + failed_count INTEGER NOT NULL, + locked_until BIGINT, + updated_at BIGINT NOT NULL +); diff --git a/members/nullnet-server/src/db/mod.rs b/members/nullnet-server/src/db/mod.rs new file mode 100644 index 0000000..cf63338 --- /dev/null +++ b/members/nullnet-server/src/db/mod.rs @@ -0,0 +1,148 @@ +//! SQLite-backed storage for the server. All database access (schema, +//! migrations, repositories) is contained in this module; callers only see +//! [`Db`] and the repository types it hands out. +//! +//! `certs.rs`/`services/input.rs` still own the on-disk file storage they +//! always have; nothing calls into those two repositories yet, so that part +//! of this module's API surface is unused until that migration happens. The +//! auth repositories (`users`/`user_scopes`/`refresh_tokens`/`login_attempts`) +//! back the server's JWT auth system and are fully wired up. +#![allow(dead_code)] + +mod certs; +mod login_attempts; +mod models; +mod refresh_tokens; +mod schema; +mod services; +mod user_scopes; +mod users; + +pub(crate) use certs::CertRepository; +pub(crate) use login_attempts::LoginAttemptRepository; +pub(crate) use refresh_tokens::RefreshTokenRepository; +pub(crate) use services::ServiceRepository; +pub(crate) use user_scopes::ScopeRepository; +pub(crate) use users::UserRepository; + +use diesel::Connection; +use diesel::sqlite::SqliteConnection; +use diesel_async::sync_connection_wrapper::SyncConnectionWrapper; +use diesel_async::{AsyncConnection, RunQueryDsl}; +use diesel_migrations::{EmbeddedMigrations, MigrationHarness, embed_migrations}; +use nullnet_liberror::{Error, ErrorHandler, Location, location}; +use std::sync::Arc; +use tokio::sync::Mutex; + +const MIGRATIONS: EmbeddedMigrations = embed_migrations!("src/db/migrations"); + +type AsyncSqlite = SyncConnectionWrapper; + +/// Shared handle to the server's SQLite database. Cloning is cheap (an +/// `Arc` around a single mutex-guarded connection) and hands out +/// [`CertRepository`]/[`ServiceRepository`] instances for typed access. +#[derive(Clone)] +pub(crate) struct Db { + conn: Arc>, +} + +impl Db { + /// Open (creating if absent) the SQLite database at `database_url`, + /// running any pending migrations before the connection is handed out. + pub(crate) async fn open(database_url: &str) -> Result { + if let Some(parent) = std::path::Path::new(database_url).parent() + && !parent.as_os_str().is_empty() + { + tokio::fs::create_dir_all(parent) + .await + .handle_err(location!())?; + } + + // Migrations run through a plain synchronous connection: diesel_migrations' + // `MigrationHarness` is only implemented for synchronous `diesel::Connection`s. + let url = database_url.to_owned(); + tokio::task::spawn_blocking(move || { + let mut conn = SqliteConnection::establish(&url).handle_err(location!())?; + conn.run_pending_migrations(MIGRATIONS) + .handle_err(location!())?; + Ok::<(), Error>(()) + }) + .await + .handle_err(location!())??; + + let mut conn = AsyncSqlite::establish(database_url) + .await + .handle_err(location!())?; + // one writer at a time (serialized by `conn`'s mutex) + concurrent readers + diesel::sql_query("PRAGMA journal_mode = WAL;") + .execute(&mut conn) + .await + .handle_err(location!())?; + // SQLite ignores FK constraints per-connection unless told otherwise; + // the auth tables' ON DELETE CASCADE (and dns_credentials' existing + // one) depend on this being set. + diesel::sql_query("PRAGMA foreign_keys = ON;") + .execute(&mut conn) + .await + .handle_err(location!())?; + + Ok(Self { + conn: Arc::new(Mutex::new(conn)), + }) + } + + pub(crate) fn certs(&self) -> CertRepository { + CertRepository::new(self.conn.clone()) + } + + pub(crate) fn services(&self) -> ServiceRepository { + ServiceRepository::new(self.conn.clone()) + } + + pub(crate) fn users(&self) -> UserRepository { + UserRepository::new(self.conn.clone()) + } + + pub(crate) fn scopes(&self) -> ScopeRepository { + ScopeRepository::new(self.conn.clone()) + } + + pub(crate) fn refresh_tokens(&self) -> RefreshTokenRepository { + RefreshTokenRepository::new(self.conn.clone()) + } + + pub(crate) fn login_attempts(&self) -> LoginAttemptRepository { + LoginAttemptRepository::new(self.conn.clone()) + } +} + +/// Unix seconds, for `updated_at` columns. +fn now() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::Db; + + #[tokio::test] + async fn open_runs_migrations_and_is_reusable() { + let dir = tempfile_dir(); + let path = dir.join("test.db"); + let db = Db::open(path.to_str().unwrap()).await.unwrap(); + // reopening the same file should be a no-op migration-wise + drop(db); + let db = Db::open(path.to_str().unwrap()).await.unwrap(); + assert!(db.certs().domains().await.unwrap().is_empty()); + } + + fn tempfile_dir() -> std::path::PathBuf { + let dir = + std::env::temp_dir().join(format!("nullnet-server-db-test-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + dir + } +} diff --git a/members/nullnet-server/src/db/models.rs b/members/nullnet-server/src/db/models.rs new file mode 100644 index 0000000..61ae46f --- /dev/null +++ b/members/nullnet-server/src/db/models.rs @@ -0,0 +1,165 @@ +use crate::db::schema::{ + certificates, dns_credentials, login_attempts, refresh_tokens, services, user_scopes, users, +}; +use diesel::prelude::*; + +#[derive(Queryable, Selectable, Identifiable, Debug, Clone)] +#[diesel(table_name = certificates)] +#[diesel(primary_key(domain))] +#[diesel(check_for_backend(diesel::sqlite::Sqlite))] +pub(crate) struct Certificate { + pub(crate) domain: String, + pub(crate) fullchain_pem: String, + pub(crate) key_pem_enc: String, + pub(crate) not_after: i64, + pub(crate) updated_at: i64, +} + +#[derive(Insertable, AsChangeset, Debug, Clone)] +#[diesel(table_name = certificates)] +#[diesel(check_for_backend(diesel::sqlite::Sqlite))] +pub(crate) struct NewCertificate<'a> { + pub(crate) domain: &'a str, + pub(crate) fullchain_pem: &'a str, + pub(crate) key_pem_enc: &'a str, + pub(crate) not_after: i64, + pub(crate) updated_at: i64, +} + +#[derive(Queryable, Selectable, Identifiable, Debug, Clone)] +#[diesel(table_name = dns_credentials)] +#[diesel(primary_key(domain))] +#[diesel(check_for_backend(diesel::sqlite::Sqlite))] +pub(crate) struct DnsCredential { + pub(crate) domain: String, + pub(crate) creds_json_enc: String, +} + +#[derive(Insertable, AsChangeset, Debug, Clone)] +#[diesel(table_name = dns_credentials)] +#[diesel(check_for_backend(diesel::sqlite::Sqlite))] +pub(crate) struct NewDnsCredential<'a> { + pub(crate) domain: &'a str, + pub(crate) creds_json_enc: &'a str, +} + +#[derive(Queryable, Selectable, Identifiable, Debug, Clone)] +#[diesel(table_name = services)] +#[diesel(primary_key(stack))] +#[diesel(check_for_backend(diesel::sqlite::Sqlite))] +pub(crate) struct Service { + pub(crate) stack: String, + pub(crate) service_json: String, + pub(crate) updated_at: i64, +} + +#[derive(Insertable, AsChangeset, Debug, Clone)] +#[diesel(table_name = services)] +#[diesel(check_for_backend(diesel::sqlite::Sqlite))] +pub(crate) struct NewService<'a> { + pub(crate) stack: &'a str, + pub(crate) service_json: &'a str, + pub(crate) updated_at: i64, +} + +#[derive(Queryable, Selectable, Identifiable, Debug, Clone)] +#[diesel(table_name = users)] +#[diesel(primary_key(id))] +#[diesel(check_for_backend(diesel::sqlite::Sqlite))] +pub(crate) struct User { + pub(crate) id: String, + pub(crate) username: String, + pub(crate) password_hash: String, + pub(crate) role: String, + pub(crate) mfa_secret_enc: Option, + pub(crate) mfa_confirmed_at: Option, + pub(crate) created_at: i64, + pub(crate) updated_at: i64, +} + +#[derive(Insertable, AsChangeset, Debug, Clone)] +#[diesel(table_name = users)] +#[diesel(check_for_backend(diesel::sqlite::Sqlite))] +pub(crate) struct NewUser<'a> { + pub(crate) id: &'a str, + pub(crate) username: &'a str, + pub(crate) password_hash: &'a str, + pub(crate) role: &'a str, + pub(crate) mfa_secret_enc: Option<&'a str>, + pub(crate) mfa_confirmed_at: Option, + pub(crate) created_at: i64, + pub(crate) updated_at: i64, +} + +/// Partial update: every field is `Option`, and diesel's `AsChangeset` skips +/// setting any column whose field is `None` — so callers only pass what +/// they're actually changing. `updated_at` is always `Some(..)` in practice. +#[derive(AsChangeset, Debug, Clone, Default)] +#[diesel(table_name = users)] +#[diesel(check_for_backend(diesel::sqlite::Sqlite))] +pub(crate) struct UserUpdate<'a> { + pub(crate) username: Option<&'a str>, + pub(crate) role: Option<&'a str>, + pub(crate) password_hash: Option<&'a str>, + pub(crate) updated_at: Option, +} + +#[derive(Queryable, Selectable, Identifiable, Debug, Clone)] +#[diesel(table_name = user_scopes)] +#[diesel(primary_key(user_id, scope))] +#[diesel(check_for_backend(diesel::sqlite::Sqlite))] +pub(crate) struct UserScope { + pub(crate) user_id: String, + pub(crate) scope: String, +} + +#[derive(Insertable, Debug, Clone)] +#[diesel(table_name = user_scopes)] +#[diesel(check_for_backend(diesel::sqlite::Sqlite))] +pub(crate) struct NewUserScope<'a> { + pub(crate) user_id: &'a str, + pub(crate) scope: &'a str, +} + +#[derive(Queryable, Selectable, Identifiable, Debug, Clone)] +#[diesel(table_name = refresh_tokens)] +#[diesel(primary_key(token_hash))] +#[diesel(check_for_backend(diesel::sqlite::Sqlite))] +pub(crate) struct RefreshToken { + pub(crate) token_hash: String, + pub(crate) user_id: String, + pub(crate) expires_at: i64, + pub(crate) created_at: i64, + pub(crate) revoked_at: Option, +} + +#[derive(Insertable, Debug, Clone)] +#[diesel(table_name = refresh_tokens)] +#[diesel(check_for_backend(diesel::sqlite::Sqlite))] +pub(crate) struct NewRefreshToken<'a> { + pub(crate) token_hash: &'a str, + pub(crate) user_id: &'a str, + pub(crate) expires_at: i64, + pub(crate) created_at: i64, +} + +#[derive(Queryable, Selectable, Identifiable, Debug, Clone)] +#[diesel(table_name = login_attempts)] +#[diesel(primary_key(username))] +#[diesel(check_for_backend(diesel::sqlite::Sqlite))] +pub(crate) struct LoginAttempt { + pub(crate) username: String, + pub(crate) failed_count: i32, + pub(crate) locked_until: Option, + pub(crate) updated_at: i64, +} + +#[derive(Insertable, AsChangeset, Debug, Clone)] +#[diesel(table_name = login_attempts)] +#[diesel(check_for_backend(diesel::sqlite::Sqlite))] +pub(crate) struct NewLoginAttempt<'a> { + pub(crate) username: &'a str, + pub(crate) failed_count: i32, + pub(crate) locked_until: Option, + pub(crate) updated_at: i64, +} diff --git a/members/nullnet-server/src/db/refresh_tokens.rs b/members/nullnet-server/src/db/refresh_tokens.rs new file mode 100644 index 0000000..613275b --- /dev/null +++ b/members/nullnet-server/src/db/refresh_tokens.rs @@ -0,0 +1,201 @@ +use crate::db::AsyncSqlite; +use crate::db::models::{NewRefreshToken, RefreshToken}; +use crate::db::schema::refresh_tokens; +use diesel::prelude::*; +use diesel_async::RunQueryDsl; +use nullnet_liberror::{Error, ErrorHandler, Location, location}; +use std::sync::Arc; +use tokio::sync::Mutex; + +/// Typed access to the `refresh_tokens` table. Callers pass/receive only the +/// SHA-256 hash of the raw opaque token — the raw value itself never touches +/// storage, only the cookie the browser holds. +pub(crate) struct RefreshTokenRepository { + conn: Arc>, +} + +impl RefreshTokenRepository { + pub(super) fn new(conn: Arc>) -> Self { + Self { conn } + } + + pub(crate) async fn insert( + &self, + token_hash: &str, + user_id: &str, + expires_at: i64, + ) -> Result<(), Error> { + let new_token = NewRefreshToken { + token_hash, + user_id, + expires_at, + created_at: super::now(), + }; + let mut conn = self.conn.lock().await; + diesel::insert_into(refresh_tokens::table) + .values(&new_token) + .execute(&mut *conn) + .await + .handle_err(location!())?; + Ok(()) + } + + /// The token row for `token_hash`, only if it's neither revoked nor expired. + pub(crate) async fn find_active( + &self, + token_hash: &str, + ) -> Result, Error> { + let now = super::now(); + let mut conn = self.conn.lock().await; + refresh_tokens::table + .find(token_hash) + .filter(refresh_tokens::revoked_at.is_null()) + .filter(refresh_tokens::expires_at.gt(now)) + .first::(&mut *conn) + .await + .optional() + .handle_err(location!()) + } + + pub(crate) async fn revoke(&self, token_hash: &str) -> Result<(), Error> { + let mut conn = self.conn.lock().await; + diesel::update(refresh_tokens::table.find(token_hash)) + .set(refresh_tokens::revoked_at.eq(Some(super::now()))) + .execute(&mut *conn) + .await + .handle_err(location!())?; + Ok(()) + } + + /// Revoke every active token for `user_id` — used when an admin deletes + /// or demotes a user, or a password changes, to kill existing sessions. + pub(crate) async fn revoke_all_for_user(&self, user_id: &str) -> Result<(), Error> { + let mut conn = self.conn.lock().await; + diesel::update( + refresh_tokens::table + .filter(refresh_tokens::user_id.eq(user_id)) + .filter(refresh_tokens::revoked_at.is_null()), + ) + .set(refresh_tokens::revoked_at.eq(Some(super::now()))) + .execute(&mut *conn) + .await + .handle_err(location!())?; + Ok(()) + } + + /// Rotate-on-use: revoke `old_hash` and insert a fresh row for `new_hash` + /// in one critical section (the shared connection is already serialized + /// by its mutex, so no explicit transaction is needed for atomicity here). + pub(crate) async fn rotate( + &self, + old_hash: &str, + new_hash: &str, + user_id: &str, + new_expires_at: i64, + ) -> Result<(), Error> { + let new_token = NewRefreshToken { + token_hash: new_hash, + user_id, + expires_at: new_expires_at, + created_at: super::now(), + }; + let mut conn = self.conn.lock().await; + diesel::update(refresh_tokens::table.find(old_hash)) + .set(refresh_tokens::revoked_at.eq(Some(super::now()))) + .execute(&mut *conn) + .await + .handle_err(location!())?; + diesel::insert_into(refresh_tokens::table) + .values(&new_token) + .execute(&mut *conn) + .await + .handle_err(location!())?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use crate::db::Db; + + async fn test_db() -> Db { + static COUNTER: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!( + "nullnet-server-refresh-tokens-test-{}-{n}", + std::process::id() + )); + std::fs::create_dir_all(&dir).unwrap(); + let db = Db::open(dir.join("test.db").to_str().unwrap()) + .await + .unwrap(); + db.users() + .create("id-1", "alice", "hash", "user") + .await + .unwrap(); + db + } + + #[tokio::test] + async fn insert_find_revoke() { + let db = test_db().await; + let repo = db.refresh_tokens(); + let future = super::super::now() + 3600; + + repo.insert("hash-a", "id-1", future).await.unwrap(); + assert!(repo.find_active("hash-a").await.unwrap().is_some()); + + repo.revoke("hash-a").await.unwrap(); + assert!(repo.find_active("hash-a").await.unwrap().is_none()); + } + + #[tokio::test] + async fn expired_token_is_not_active() { + let db = test_db().await; + let repo = db.refresh_tokens(); + let past = super::super::now() - 10; + + repo.insert("hash-a", "id-1", past).await.unwrap(); + assert!(repo.find_active("hash-a").await.unwrap().is_none()); + } + + #[tokio::test] + async fn rotate_revokes_old_and_creates_new() { + let db = test_db().await; + let repo = db.refresh_tokens(); + let future = super::super::now() + 3600; + + repo.insert("hash-old", "id-1", future).await.unwrap(); + repo.rotate("hash-old", "hash-new", "id-1", future) + .await + .unwrap(); + + assert!(repo.find_active("hash-old").await.unwrap().is_none()); + assert!(repo.find_active("hash-new").await.unwrap().is_some()); + } + + #[tokio::test] + async fn revoke_all_for_user() { + let db = test_db().await; + let repo = db.refresh_tokens(); + let future = super::super::now() + 3600; + + repo.insert("hash-a", "id-1", future).await.unwrap(); + repo.insert("hash-b", "id-1", future).await.unwrap(); + repo.revoke_all_for_user("id-1").await.unwrap(); + + assert!(repo.find_active("hash-a").await.unwrap().is_none()); + assert!(repo.find_active("hash-b").await.unwrap().is_none()); + } + + #[tokio::test] + async fn deleting_user_cascades_tokens() { + let db = test_db().await; + let repo = db.refresh_tokens(); + let future = super::super::now() + 3600; + repo.insert("hash-a", "id-1", future).await.unwrap(); + + db.users().delete("id-1").await.unwrap(); + assert!(repo.find_active("hash-a").await.unwrap().is_none()); + } +} diff --git a/members/nullnet-server/src/db/schema.rs b/members/nullnet-server/src/db/schema.rs new file mode 100644 index 0000000..80a3fd0 --- /dev/null +++ b/members/nullnet-server/src/db/schema.rs @@ -0,0 +1,78 @@ +// Mirrors src/db/migrations — regenerate by hand (or `diesel print-schema`) +// whenever a migration changes these tables. + +diesel::table! { + certificates (domain) { + domain -> Text, + fullchain_pem -> Text, + key_pem_enc -> Text, + not_after -> BigInt, + updated_at -> BigInt, + } +} + +diesel::table! { + dns_credentials (domain) { + domain -> Text, + creds_json_enc -> Text, + } +} + +diesel::table! { + services (stack) { + stack -> Text, + service_json -> Text, + updated_at -> BigInt, + } +} + +diesel::table! { + users (id) { + id -> Text, + username -> Text, + password_hash -> Text, + role -> Text, + mfa_secret_enc -> Nullable, + mfa_confirmed_at -> Nullable, + created_at -> BigInt, + updated_at -> BigInt, + } +} + +diesel::table! { + user_scopes (user_id, scope) { + user_id -> Text, + scope -> Text, + } +} + +diesel::table! { + refresh_tokens (token_hash) { + token_hash -> Text, + user_id -> Text, + expires_at -> BigInt, + created_at -> BigInt, + revoked_at -> Nullable, + } +} + +diesel::table! { + login_attempts (username) { + username -> Text, + failed_count -> Integer, + locked_until -> Nullable, + updated_at -> BigInt, + } +} + +diesel::joinable!(dns_credentials -> certificates (domain)); +diesel::joinable!(user_scopes -> users (user_id)); +diesel::joinable!(refresh_tokens -> users (user_id)); +diesel::allow_tables_to_appear_in_same_query!( + certificates, + dns_credentials, + users, + user_scopes, + refresh_tokens, + login_attempts, +); diff --git a/members/nullnet-server/src/db/services.rs b/members/nullnet-server/src/db/services.rs new file mode 100644 index 0000000..eaf7a63 --- /dev/null +++ b/members/nullnet-server/src/db/services.rs @@ -0,0 +1,127 @@ +use crate::db::AsyncSqlite; +use crate::db::models::{NewService, Service}; +use crate::db::schema::services; +use diesel::prelude::*; +use diesel_async::RunQueryDsl; +use nullnet_liberror::{Error, ErrorHandler, Location, location}; +use std::sync::Arc; +use tokio::sync::Mutex; + +/// A service-stack record: `stack` is the name, `service_json` the serialized +/// `Vec` (kept as opaque JSON for now to minimize migration risk). +pub(crate) struct ServiceRecord { + pub(crate) stack: String, + pub(crate) service_json: String, +} + +impl From for ServiceRecord { + fn from(row: Service) -> Self { + Self { + stack: row.stack, + service_json: row.service_json, + } + } +} + +/// Typed access to the `services` table, mirroring `services/input.rs`'s +/// current per-stack-TOML-file storage. +pub(crate) struct ServiceRepository { + conn: Arc>, +} + +impl ServiceRepository { + pub(super) fn new(conn: Arc>) -> Self { + Self { conn } + } + + pub(crate) async fn put(&self, stack: &str, service_json: &str) -> Result<(), Error> { + let new_service = NewService { + stack, + service_json, + updated_at: super::now(), + }; + let mut conn = self.conn.lock().await; + diesel::insert_into(services::table) + .values(&new_service) + .on_conflict(services::stack) + .do_update() + .set(&new_service) + .execute(&mut *conn) + .await + .handle_err(location!())?; + Ok(()) + } + + pub(crate) async fn get(&self, stack: &str) -> Result, Error> { + let mut conn = self.conn.lock().await; + let row = services::table + .find(stack) + .first::(&mut *conn) + .await + .optional() + .handle_err(location!())?; + Ok(row.map(ServiceRecord::from)) + } + + pub(crate) async fn list(&self) -> Result, Error> { + let mut conn = self.conn.lock().await; + let rows = services::table + .load::(&mut *conn) + .await + .handle_err(location!())?; + Ok(rows.into_iter().map(ServiceRecord::from).collect()) + } + + pub(crate) async fn delete(&self, stack: &str) -> Result<(), Error> { + let mut conn = self.conn.lock().await; + diesel::delete(services::table.find(stack)) + .execute(&mut *conn) + .await + .handle_err(location!())?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use crate::db::Db; + + async fn test_db() -> Db { + static COUNTER: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!( + "nullnet-server-services-test-{}-{n}", + std::process::id() + )); + std::fs::create_dir_all(&dir).unwrap(); + Db::open(dir.join("test.db").to_str().unwrap()) + .await + .unwrap() + } + + #[tokio::test] + async fn put_get_list_delete_round_trip() { + let db = test_db().await; + let repo = db.services(); + + assert!(repo.get("stack-a").await.unwrap().is_none()); + + repo.put("stack-a", r#"[{"name":"web"}]"#).await.unwrap(); + let fetched = repo.get("stack-a").await.unwrap().unwrap(); + assert_eq!(fetched.stack, "stack-a"); + assert_eq!(fetched.service_json, r#"[{"name":"web"}]"#); + + repo.put("stack-a", r#"[{"name":"web2"}]"#).await.unwrap(); + assert_eq!( + repo.get("stack-a").await.unwrap().unwrap().service_json, + r#"[{"name":"web2"}]"# + ); + + repo.put("stack-b", r#"[]"#).await.unwrap(); + assert_eq!(repo.list().await.unwrap().len(), 2); + + repo.delete("stack-a").await.unwrap(); + assert!(repo.get("stack-a").await.unwrap().is_none()); + assert_eq!(repo.list().await.unwrap().len(), 1); + } +} diff --git a/members/nullnet-server/src/db/user_scopes.rs b/members/nullnet-server/src/db/user_scopes.rs new file mode 100644 index 0000000..0092e87 --- /dev/null +++ b/members/nullnet-server/src/db/user_scopes.rs @@ -0,0 +1,116 @@ +use crate::db::AsyncSqlite; +use crate::db::models::NewUserScope; +use crate::db::schema::user_scopes; +use diesel::prelude::*; +use diesel_async::RunQueryDsl; +use nullnet_liberror::{Error, ErrorHandler, Location, location}; +use std::sync::Arc; +use tokio::sync::Mutex; + +/// Typed access to the `user_scopes` table — the explicit per-resource +/// read/write grants for `user`-role accounts. `admin`-role accounts never +/// need rows here; callers should treat admin as implicitly having every +/// scope rather than querying this table for them. +pub(crate) struct ScopeRepository { + conn: Arc>, +} + +impl ScopeRepository { + pub(super) fn new(conn: Arc>) -> Self { + Self { conn } + } + + pub(crate) async fn for_user(&self, user_id: &str) -> Result, Error> { + let mut conn = self.conn.lock().await; + user_scopes::table + .filter(user_scopes::user_id.eq(user_id)) + .select(user_scopes::scope) + .load(&mut *conn) + .await + .handle_err(location!()) + } + + /// Replace `user_id`'s full scope set with exactly `scopes` (delete-all, + /// then insert) — simplest correct semantics for "an admin edited this + /// user's scope checkboxes and saved." + pub(crate) async fn set_for_user(&self, user_id: &str, scopes: &[String]) -> Result<(), Error> { + let mut conn = self.conn.lock().await; + diesel::delete(user_scopes::table.filter(user_scopes::user_id.eq(user_id))) + .execute(&mut *conn) + .await + .handle_err(location!())?; + // SQLite (via diesel-async's SyncConnectionWrapper) doesn't support a + // single multi-row VALUES(...),(...) insert, so insert one row at a + // time — the scope count per user is tiny (at most 8), so this is fine. + for scope in scopes { + diesel::insert_into(user_scopes::table) + .values(&NewUserScope { user_id, scope }) + .execute(&mut *conn) + .await + .handle_err(location!())?; + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use crate::db::Db; + + async fn test_db() -> Db { + static COUNTER: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!( + "nullnet-server-scopes-test-{}-{n}", + std::process::id() + )); + std::fs::create_dir_all(&dir).unwrap(); + let db = Db::open(dir.join("test.db").to_str().unwrap()) + .await + .unwrap(); + db.users() + .create("id-1", "alice", "hash", "user") + .await + .unwrap(); + db + } + + #[tokio::test] + async fn set_and_read_scopes() { + let db = test_db().await; + let repo = db.scopes(); + + assert!(repo.for_user("id-1").await.unwrap().is_empty()); + + repo.set_for_user( + "id-1", + &["certificates:read".to_string(), "events:read".to_string()], + ) + .await + .unwrap(); + let mut scopes = repo.for_user("id-1").await.unwrap(); + scopes.sort(); + assert_eq!(scopes, vec!["certificates:read", "events:read"]); + + // replace, not append + repo.set_for_user("id-1", &["config:write".to_string()]) + .await + .unwrap(); + assert_eq!(repo.for_user("id-1").await.unwrap(), vec!["config:write"]); + + // empty replace clears everything + repo.set_for_user("id-1", &[]).await.unwrap(); + assert!(repo.for_user("id-1").await.unwrap().is_empty()); + } + + #[tokio::test] + async fn deleting_user_cascades_scopes() { + let db = test_db().await; + db.scopes() + .set_for_user("id-1", &["nodes:read".to_string()]) + .await + .unwrap(); + db.users().delete("id-1").await.unwrap(); + assert!(db.scopes().for_user("id-1").await.unwrap().is_empty()); + } +} diff --git a/members/nullnet-server/src/db/users.rs b/members/nullnet-server/src/db/users.rs new file mode 100644 index 0000000..da822de --- /dev/null +++ b/members/nullnet-server/src/db/users.rs @@ -0,0 +1,264 @@ +use crate::db::AsyncSqlite; +use crate::db::models::{NewUser, User, UserUpdate}; +use crate::db::schema::users; +use diesel::prelude::*; +use diesel_async::RunQueryDsl; +use nullnet_liberror::{Error, ErrorHandler, Location, location}; +use std::sync::Arc; +use tokio::sync::Mutex; + +/// Typed access to the `users` table. Callers outside `db` never see the +/// internal `NewUser`/`User` Diesel models — only plain scalar args and the +/// returned `User` row (also `pub(crate)`, but callers should treat it as +/// data, not construct one themselves). +pub(crate) struct UserRepository { + conn: Arc>, +} + +impl UserRepository { + pub(super) fn new(conn: Arc>) -> Self { + Self { conn } + } + + /// Create a new user with no MFA configured yet. `id` is the caller's + /// choice (a uuid v4 in practice) so it can be returned immediately + /// without a round-trip read-back. + pub(crate) async fn create( + &self, + id: &str, + username: &str, + password_hash: &str, + role: &str, + ) -> Result<(), Error> { + let now = super::now(); + let new_user = NewUser { + id, + username, + password_hash, + role, + mfa_secret_enc: None, + mfa_confirmed_at: None, + created_at: now, + updated_at: now, + }; + let mut conn = self.conn.lock().await; + diesel::insert_into(users::table) + .values(&new_user) + .execute(&mut *conn) + .await + .handle_err(location!())?; + Ok(()) + } + + pub(crate) async fn by_username(&self, username: &str) -> Result, Error> { + let mut conn = self.conn.lock().await; + users::table + .filter(users::username.eq(username)) + .first::(&mut *conn) + .await + .optional() + .handle_err(location!()) + } + + pub(crate) async fn by_id(&self, id: &str) -> Result, Error> { + let mut conn = self.conn.lock().await; + users::table + .find(id) + .first::(&mut *conn) + .await + .optional() + .handle_err(location!()) + } + + pub(crate) async fn list(&self) -> Result, Error> { + let mut conn = self.conn.lock().await; + users::table + .load::(&mut *conn) + .await + .handle_err(location!()) + } + + /// Total user count — used to decide whether bootstrap needs to run. + pub(crate) async fn count(&self) -> Result { + let mut conn = self.conn.lock().await; + users::table + .count() + .get_result(&mut *conn) + .await + .handle_err(location!()) + } + + /// Number of `admin`-role users — used to guard against deleting the last one. + pub(crate) async fn count_admins(&self) -> Result { + let mut conn = self.conn.lock().await; + users::table + .filter(users::role.eq("admin")) + .count() + .get_result(&mut *conn) + .await + .handle_err(location!()) + } + + /// Partial update: only fields passed as `Some` are changed; `updated_at` is + /// always bumped. Plain scalar args (rather than a `db::models` type) so + /// callers outside this module — the Users admin API — don't need to + /// name an internal Diesel model type. + pub(crate) async fn update( + &self, + id: &str, + username: Option<&str>, + role: Option<&str>, + password_hash: Option<&str>, + ) -> Result<(), Error> { + let changes = UserUpdate { + username, + role, + password_hash, + updated_at: Some(super::now()), + }; + let mut conn = self.conn.lock().await; + diesel::update(users::table.find(id)) + .set(&changes) + .execute(&mut *conn) + .await + .handle_err(location!())?; + Ok(()) + } + + pub(crate) async fn delete(&self, id: &str) -> Result<(), Error> { + let mut conn = self.conn.lock().await; + diesel::delete(users::table.find(id)) + .execute(&mut *conn) + .await + .handle_err(location!())?; + Ok(()) + } + + /// Store a freshly generated (unconfirmed) TOTP secret, encrypted at rest. + pub(crate) async fn set_mfa_pending(&self, id: &str, secret_enc: &str) -> Result<(), Error> { + let mut conn = self.conn.lock().await; + diesel::update(users::table.find(id)) + .set(( + users::mfa_secret_enc.eq(Some(secret_enc)), + users::mfa_confirmed_at.eq(None::), + users::updated_at.eq(super::now()), + )) + .execute(&mut *conn) + .await + .handle_err(location!())?; + Ok(()) + } + + /// Mark the pending secret as confirmed — MFA is now enabled. + pub(crate) async fn confirm_mfa(&self, id: &str) -> Result<(), Error> { + let mut conn = self.conn.lock().await; + diesel::update(users::table.find(id)) + .set(( + users::mfa_confirmed_at.eq(Some(super::now())), + users::updated_at.eq(super::now()), + )) + .execute(&mut *conn) + .await + .handle_err(location!())?; + Ok(()) + } + + /// Disable/reset MFA entirely (self-service disable, or an admin reset). + pub(crate) async fn clear_mfa(&self, id: &str) -> Result<(), Error> { + let mut conn = self.conn.lock().await; + diesel::update(users::table.find(id)) + .set(( + users::mfa_secret_enc.eq(None::), + users::mfa_confirmed_at.eq(None::), + users::updated_at.eq(super::now()), + )) + .execute(&mut *conn) + .await + .handle_err(location!())?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use crate::db::Db; + + async fn test_db() -> Db { + static COUNTER: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!( + "nullnet-server-users-test-{}-{n}", + std::process::id() + )); + std::fs::create_dir_all(&dir).unwrap(); + Db::open(dir.join("test.db").to_str().unwrap()) + .await + .unwrap() + } + + #[tokio::test] + async fn create_get_list_delete_round_trip() { + let db = test_db().await; + let repo = db.users(); + + assert_eq!(repo.count().await.unwrap(), 0); + repo.create("id-1", "alice", "hash", "admin").await.unwrap(); + + let fetched = repo.by_username("alice").await.unwrap().unwrap(); + assert_eq!(fetched.id, "id-1"); + assert_eq!(repo.by_id("id-1").await.unwrap().unwrap().username, "alice"); + assert_eq!(repo.count().await.unwrap(), 1); + assert_eq!(repo.count_admins().await.unwrap(), 1); + assert_eq!(repo.list().await.unwrap().len(), 1); + + repo.delete("id-1").await.unwrap(); + assert!(repo.by_id("id-1").await.unwrap().is_none()); + assert_eq!(repo.count().await.unwrap(), 0); + } + + #[tokio::test] + async fn partial_update_only_touches_given_fields() { + let db = test_db().await; + let repo = db.users(); + repo.create("id-1", "alice", "hash", "admin").await.unwrap(); + + repo.update("id-1", Some("alice2"), None, None) + .await + .unwrap(); + let fetched = repo.by_id("id-1").await.unwrap().unwrap(); + assert_eq!(fetched.username, "alice2"); + assert_eq!(fetched.role, "admin", "role should be untouched"); + } + + #[tokio::test] + async fn mfa_lifecycle() { + let db = test_db().await; + let repo = db.users(); + repo.create("id-1", "alice", "hash", "admin").await.unwrap(); + + let fetched = repo.by_id("id-1").await.unwrap().unwrap(); + assert!(fetched.mfa_confirmed_at.is_none()); + + repo.set_mfa_pending("id-1", "encrypted-secret") + .await + .unwrap(); + let fetched = repo.by_id("id-1").await.unwrap().unwrap(); + assert_eq!(fetched.mfa_secret_enc.as_deref(), Some("encrypted-secret")); + assert!(fetched.mfa_confirmed_at.is_none(), "not yet confirmed"); + + repo.confirm_mfa("id-1").await.unwrap(); + assert!( + repo.by_id("id-1") + .await + .unwrap() + .unwrap() + .mfa_confirmed_at + .is_some() + ); + + repo.clear_mfa("id-1").await.unwrap(); + let fetched = repo.by_id("id-1").await.unwrap().unwrap(); + assert!(fetched.mfa_secret_enc.is_none()); + assert!(fetched.mfa_confirmed_at.is_none()); + } +} diff --git a/members/nullnet-server/src/http_server/auth/login.rs b/members/nullnet-server/src/http_server/auth/login.rs new file mode 100644 index 0000000..0b5fc35 --- /dev/null +++ b/members/nullnet-server/src/http_server/auth/login.rs @@ -0,0 +1,173 @@ +use super::{ + attach_session_cookies, internal_error, issue_session, rejected, role_and_scopes, unauthorized, +}; +use crate::auth::{jwt, mfa_crypto, password, totp}; +use crate::http_server::AppState; +use axum::extract::State; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use axum_extra::extract::cookie::CookieJar; +use serde::{Deserialize, Serialize}; + +#[derive(Deserialize)] +pub(crate) struct LoginReq { + username: String, + password: String, +} + +#[derive(Serialize)] +struct LoginResp { + mfa_required: bool, + #[serde(skip_serializing_if = "Option::is_none")] + mfa_token: Option, +} + +/// Step 1: verify username/password. If the account has MFA confirmed, +/// returns a short-lived `mfa_token` instead of session cookies — the client +/// must then call [`mfa_verify_handler`] with a TOTP code. Locks the account +/// out after repeated failures (`login_attempts`). +pub(crate) async fn login_handler( + State(state): State, + jar: CookieJar, + axum::Json(req): axum::Json, +) -> (CookieJar, Response) { + let attempts = state.db.login_attempts(); + match attempts.is_locked(&req.username).await { + Ok(true) => { + return ( + jar, + rejected( + StatusCode::TOO_MANY_REQUESTS, + "too many failed attempts — try again later", + ), + ); + } + Ok(false) => {} + Err(_) => return (jar, internal_error("lockout check failed")), + } + + let user = match state.db.users().by_username(&req.username).await { + Ok(Some(u)) => u, + Ok(None) => { + let _ = attempts.record_failure(&req.username).await; + return (jar, unauthorized("invalid username or password")); + } + Err(_) => return (jar, internal_error("user lookup failed")), + }; + + let password_ok = password::verify(&req.password, &user.password_hash).unwrap_or(false); + if !password_ok { + let _ = attempts.record_failure(&req.username).await; + return (jar, unauthorized("invalid username or password")); + } + let _ = attempts.clear(&req.username).await; + + if user.mfa_confirmed_at.is_some() { + return match jwt::issue_mfa_pending_token(&user.id) { + Ok(mfa_token) => ( + jar, + axum::Json(LoginResp { + mfa_required: true, + mfa_token: Some(mfa_token), + }) + .into_response(), + ), + Err(_) => (jar, internal_error("failed to issue mfa challenge")), + }; + } + + let (role, scopes) = match role_and_scopes(&state.db, &user.id, &user.role).await { + Ok(v) => v, + Err(resp) => return (jar, resp), + }; + match issue_session(&state.db, &user.id, role, scopes).await { + Ok(tokens) => { + let jar = attach_session_cookies(jar, &tokens); + ( + jar, + axum::Json(LoginResp { + mfa_required: false, + mfa_token: None, + }) + .into_response(), + ) + } + Err(_) => (jar, internal_error("failed to establish session")), + } +} + +#[derive(Deserialize)] +pub(crate) struct MfaVerifyReq { + mfa_token: String, + code: String, +} + +/// Step 2 (only reached when step 1 reported `mfa_required`): verify the +/// TOTP code against the user's confirmed secret, then issue a real session. +/// Shares the same per-username lockout as the password step — otherwise the +/// password check's brute-force protection would be moot, since MFA codes +/// are only 6 digits (1 in a million per guess, easily brute-forceable +/// without a lockout). +pub(crate) async fn mfa_verify_handler( + State(state): State, + jar: CookieJar, + axum::Json(req): axum::Json, +) -> (CookieJar, Response) { + let user_id = match jwt::verify_mfa_pending_token(&req.mfa_token) { + Ok(id) => id, + Err(_) => return (jar, unauthorized("invalid or expired mfa challenge")), + }; + let user = match state.db.users().by_id(&user_id).await { + Ok(Some(u)) => u, + Ok(None) => return (jar, unauthorized("user not found")), + Err(_) => return (jar, internal_error("user lookup failed")), + }; + + let attempts = state.db.login_attempts(); + match attempts.is_locked(&user.username).await { + Ok(true) => { + return ( + jar, + rejected( + StatusCode::TOO_MANY_REQUESTS, + "too many failed attempts — try again later", + ), + ); + } + Ok(false) => {} + Err(_) => return (jar, internal_error("lockout check failed")), + } + + let Some(secret_enc) = user.mfa_secret_enc.as_deref() else { + return (jar, internal_error("mfa not configured")); + }; + let secret = match mfa_crypto::cipher().decrypt(secret_enc) { + Ok(s) => s, + Err(_) => return (jar, internal_error("failed to decrypt mfa secret")), + }; + let code_ok = totp::verify_code(&secret, &req.code).unwrap_or(false); + if !code_ok { + let _ = attempts.record_failure(&user.username).await; + return (jar, unauthorized("invalid code")); + } + let _ = attempts.clear(&user.username).await; + + let (role, scopes) = match role_and_scopes(&state.db, &user.id, &user.role).await { + Ok(v) => v, + Err(resp) => return (jar, resp), + }; + match issue_session(&state.db, &user.id, role, scopes).await { + Ok(tokens) => { + let jar = attach_session_cookies(jar, &tokens); + ( + jar, + axum::Json(LoginResp { + mfa_required: false, + mfa_token: None, + }) + .into_response(), + ) + } + Err(_) => (jar, internal_error("failed to establish session")), + } +} diff --git a/members/nullnet-server/src/http_server/auth/me.rs b/members/nullnet-server/src/http_server/auth/me.rs new file mode 100644 index 0000000..380f696 --- /dev/null +++ b/members/nullnet-server/src/http_server/auth/me.rs @@ -0,0 +1,43 @@ +use super::{AuthContext, internal_error, rejected}; +use crate::auth::{Role, Scope}; +use crate::http_server::AppState; +use axum::extract::{Extension, State}; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use serde::Serialize; + +#[derive(Serialize)] +struct MeResp { + id: String, + username: String, + role: String, + scopes: Vec, + mfa_enabled: bool, +} + +/// What the frontend polls once on load to determine "am I logged in, and +/// as whom" — cookies are httpOnly, so this is the only way JS learns the +/// current identity. +pub(crate) async fn me_handler( + State(state): State, + Extension(ctx): Extension, +) -> Response { + let user = match state.db.users().by_id(&ctx.user_id).await { + Ok(Some(u)) => u, + Ok(None) => return rejected(StatusCode::NOT_FOUND, "user not found"), + Err(_) => return internal_error("user lookup failed"), + }; + let scopes = if ctx.role == Role::Admin { + Scope::ALL.iter().map(|s| s.as_str().to_string()).collect() + } else { + ctx.scopes.clone() + }; + axum::Json(MeResp { + id: user.id, + username: user.username, + role: ctx.role.as_str().to_string(), + scopes, + mfa_enabled: user.mfa_confirmed_at.is_some(), + }) + .into_response() +} diff --git a/members/nullnet-server/src/http_server/auth/mfa.rs b/members/nullnet-server/src/http_server/auth/mfa.rs new file mode 100644 index 0000000..59e6cd5 --- /dev/null +++ b/members/nullnet-server/src/http_server/auth/mfa.rs @@ -0,0 +1,147 @@ +use super::{AuthContext, internal_error, rejected}; +use crate::auth::{mfa_crypto, totp}; +use crate::http_server::AppState; +use axum::extract::{Extension, State}; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize)] +struct SetupResp { + secret: String, + otpauth_uri: String, +} + +#[derive(Deserialize, Default)] +pub(crate) struct SetupReq { + /// Required (and checked against the *current* secret) only when + /// re-enrolling an already-confirmed account; ignored for a first-time + /// setup, where there's no existing secret to prove possession of. + #[serde(default)] + code: Option, +} + +/// Generate a fresh (unconfirmed) TOTP secret for the current user and store +/// it encrypted. Calling this again before the pending secret is confirmed +/// just replaces it, so the UI always shows a fresh QR code on retry — but +/// re-enrolling an *already-confirmed* account requires the current TOTP +/// code first, the same proof-of-possession `disable_handler` requires. +/// Without this, a hijacked session (stolen cookie, unattended device, etc.) +/// could silently strip a confirmed account's MFA with no code at all, +/// turning a transient session compromise into a durable one. +pub(crate) async fn setup_handler( + State(state): State, + Extension(ctx): Extension, + axum::Json(req): axum::Json, +) -> Response { + let user = match state.db.users().by_id(&ctx.user_id).await { + Ok(Some(u)) => u, + Ok(None) => return rejected(StatusCode::NOT_FOUND, "user not found"), + Err(_) => return internal_error("user lookup failed"), + }; + + if user.mfa_confirmed_at.is_some() { + let Some(secret_enc) = user.mfa_secret_enc.as_deref() else { + return internal_error("mfa marked confirmed but no secret stored"); + }; + let current_secret = match mfa_crypto::cipher().decrypt(secret_enc) { + Ok(s) => s, + Err(_) => return internal_error("failed to decrypt mfa secret"), + }; + let code_ok = req + .code + .as_deref() + .is_some_and(|c| totp::verify_code(¤t_secret, c).unwrap_or(false)); + if !code_ok { + return rejected( + StatusCode::UNAUTHORIZED, + "mfa is already enabled — enter your current code to re-enroll", + ); + } + } + + let secret = totp::generate_secret(); + let otpauth_uri = match totp::provisioning_uri(&secret, &user.username) { + Ok(uri) => uri, + Err(_) => return internal_error("failed to build provisioning uri"), + }; + let secret_enc = match mfa_crypto::cipher().encrypt(&secret) { + Ok(enc) => enc, + Err(_) => return internal_error("failed to encrypt mfa secret"), + }; + if state + .db + .users() + .set_mfa_pending(&ctx.user_id, &secret_enc) + .await + .is_err() + { + return internal_error("failed to store mfa secret"); + } + axum::Json(SetupResp { + secret, + otpauth_uri, + }) + .into_response() +} + +#[derive(Deserialize)] +pub(crate) struct CodeReq { + code: String, +} + +/// Confirm the pending secret from `setup_handler` — MFA is enabled from +/// this point on. +pub(crate) async fn confirm_handler( + State(state): State, + Extension(ctx): Extension, + axum::Json(req): axum::Json, +) -> Response { + let Some(secret) = decrypt_pending_secret(&state, &ctx.user_id).await else { + return rejected( + StatusCode::BAD_REQUEST, + "no pending mfa setup — call setup first", + ); + }; + let Ok(secret) = secret else { + return internal_error("failed to decrypt mfa secret"); + }; + if !totp::verify_code(&secret, &req.code).unwrap_or(false) { + return rejected(StatusCode::BAD_REQUEST, "invalid code"); + } + if state.db.users().confirm_mfa(&ctx.user_id).await.is_err() { + return internal_error("failed to confirm mfa"); + } + StatusCode::NO_CONTENT.into_response() +} + +/// Self-service disable: requires a current TOTP code as proof of +/// possession. An admin can also force-reset a user's MFA from the Users +/// page without needing a code (see `http_server::auth::users`). +pub(crate) async fn disable_handler( + State(state): State, + Extension(ctx): Extension, + axum::Json(req): axum::Json, +) -> Response { + let Some(secret) = decrypt_pending_secret(&state, &ctx.user_id).await else { + return rejected(StatusCode::BAD_REQUEST, "mfa is not enabled"); + }; + let Ok(secret) = secret else { + return internal_error("failed to decrypt mfa secret"); + }; + if !totp::verify_code(&secret, &req.code).unwrap_or(false) { + return rejected(StatusCode::BAD_REQUEST, "invalid code"); + } + if state.db.users().clear_mfa(&ctx.user_id).await.is_err() { + return internal_error("failed to disable mfa"); + } + StatusCode::NO_CONTENT.into_response() +} + +/// `None` if the user has no secret stored at all (setup never called, or +/// already cleared); `Some(Err(_))` if stored but decryption failed. +async fn decrypt_pending_secret(state: &AppState, user_id: &str) -> Option> { + let user = state.db.users().by_id(user_id).await.ok()??; + let secret_enc = user.mfa_secret_enc?; + Some(mfa_crypto::cipher().decrypt(&secret_enc).map_err(|_| ())) +} diff --git a/members/nullnet-server/src/http_server/auth/middleware.rs b/members/nullnet-server/src/http_server/auth/middleware.rs new file mode 100644 index 0000000..6d26aea --- /dev/null +++ b/members/nullnet-server/src/http_server/auth/middleware.rs @@ -0,0 +1,29 @@ +use super::{ACCESS_COOKIE, AuthContext, unauthorized}; +use crate::auth::jwt; +use axum::extract::Request; +use axum::middleware::Next; +use axum::response::Response; +use axum_extra::extract::cookie::CookieJar; + +/// Guards every route in the `protected` router: verifies the `nn_access` +/// cookie's JWT and, on success, inserts an [`AuthContext`] into the +/// request's extensions for handlers (and scope checks) to read back out. +/// Stateless — access-token verification never touches the database. +pub(crate) async fn require_auth(jar: CookieJar, mut req: Request, next: Next) -> Response { + let Some(cookie) = jar.get(ACCESS_COOKIE) else { + return unauthorized("missing access token"); + }; + let claims = match jwt::verify_access_token(cookie.value()) { + Ok(c) => c, + Err(_) => return unauthorized("invalid or expired access token"), + }; + let Ok(role) = claims.role.parse() else { + return unauthorized("invalid token"); + }; + req.extensions_mut().insert(AuthContext { + user_id: claims.sub, + role, + scopes: claims.scopes, + }); + next.run(req).await +} diff --git a/members/nullnet-server/src/http_server/auth/mod.rs b/members/nullnet-server/src/http_server/auth/mod.rs new file mode 100644 index 0000000..a74ae60 --- /dev/null +++ b/members/nullnet-server/src/http_server/auth/mod.rs @@ -0,0 +1,171 @@ +//! JWT auth for the admin HTTP API: login (+ optional TOTP MFA step), +//! silent refresh, logout, "who am I", and MFA setup/confirm/disable. +//! Core crypto/token logic lives in `crate::auth`; this submodule is just +//! the Axum wiring on top of it. + +mod login; +mod me; +mod mfa; +mod middleware; +mod refresh; +mod users; + +pub(super) use login::{login_handler, mfa_verify_handler}; +pub(super) use me::me_handler; +pub(super) use mfa::{confirm_handler, disable_handler, setup_handler}; +pub(super) use middleware::require_auth; +pub(super) use refresh::{logout_handler, refresh_handler}; +pub(super) use users::{create_handler, delete_handler, list_handler, update_handler}; + +use crate::auth::{Role, jwt, session}; +use crate::db::Db; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite}; +use serde::Serialize; +use time::Duration as CookieDuration; + +/// The access-token cookie: short-lived, sent with every request. +const ACCESS_COOKIE: &str = "nn_access"; +/// The refresh-token cookie: long-lived, scoped to `/api/auth` (covers +/// `refresh` and `logout`, which both need to read it to revoke it) so it's +/// never sent on ordinary API calls. +const REFRESH_COOKIE: &str = "nn_refresh"; + +/// The authenticated identity for the current request, inserted into request +/// extensions by [`require_auth`] and read back out by handlers (both the +/// ones in this submodule and, for scope checks, every other protected +/// handler in `http_server`). +#[derive(Clone)] +pub(super) struct AuthContext { + pub(super) user_id: String, + pub(super) role: Role, + pub(super) scopes: Vec, +} + +#[derive(Serialize)] +struct ErrorJson { + error: String, +} + +fn rejected(status: StatusCode, error: impl Into) -> Response { + ( + status, + axum::Json(ErrorJson { + error: error.into(), + }), + ) + .into_response() +} + +fn unauthorized(error: impl Into) -> Response { + rejected(StatusCode::UNAUTHORIZED, error) +} + +fn forbidden(error: impl Into) -> Response { + rejected(StatusCode::FORBIDDEN, error) +} + +fn internal_error(error: impl Into) -> Response { + rejected(StatusCode::INTERNAL_SERVER_ERROR, error) +} + +/// Called as the first line of every other protected handler in +/// `http_server`: `admin` role always passes; `user` role must have `needed` +/// in its JWT-embedded scope list. +#[allow(clippy::result_large_err)] +pub(super) fn require_scope(ctx: &AuthContext, needed: crate::auth::Scope) -> Result<(), Response> { + if ctx.role == Role::Admin || ctx.scopes.iter().any(|s| s == needed.as_str()) { + Ok(()) + } else { + Err(forbidden(format!( + "missing required scope '{}'", + needed.as_str() + ))) + } +} + +/// Used by the Users admin API — role is coarser-grained than the 8 resource +/// scopes, so it doesn't need its own extractor abstraction. +#[allow(clippy::result_large_err)] +pub(super) fn require_admin(ctx: &AuthContext) -> Result<(), Response> { + if ctx.role == Role::Admin { + Ok(()) + } else { + Err(forbidden("admin role required")) + } +} + +/// `role` parsed, plus the effective scope list: admins implicitly have +/// every scope (no DB row needed); `user`-role accounts get whatever's been +/// explicitly granted in `user_scopes`. +async fn role_and_scopes( + db: &Db, + user_id: &str, + role_str: &str, +) -> Result<(Role, Vec), Response> { + let Ok(role) = role_str.parse::() else { + return Err(internal_error("corrupt user role")); + }; + let scopes = if role == Role::Admin { + Vec::new() + } else { + db.scopes().for_user(user_id).await.unwrap_or_default() + }; + Ok((role, scopes)) +} + +struct SessionTokens { + access_token: String, + refresh_token: String, +} + +/// Mint a fresh access token + a fresh (persisted) refresh token for `user_id`. +async fn issue_session( + db: &Db, + user_id: &str, + role: Role, + scopes: Vec, +) -> Result { + let access_token = jwt::issue_access_token(user_id, role, &scopes)?; + let refresh_token = session::generate_raw_token(); + let refresh_hash = session::hash_token(&refresh_token); + let expires_at = crate::auth::now() + session::REFRESH_TOKEN_TTL_SECS; + db.refresh_tokens() + .insert(&refresh_hash, user_id, expires_at) + .await?; + Ok(SessionTokens { + access_token, + refresh_token, + }) +} + +/// Set both session cookies on `jar` from freshly issued `tokens`. +fn attach_session_cookies(jar: CookieJar, tokens: &SessionTokens) -> CookieJar { + let access_cookie = Cookie::build((ACCESS_COOKIE, tokens.access_token.clone())) + .http_only(true) + .secure(true) + .same_site(SameSite::Lax) + .path("/") + .max_age(CookieDuration::seconds(jwt::ACCESS_TOKEN_TTL_SECS)) + .build(); + let refresh_cookie = Cookie::build((REFRESH_COOKIE, tokens.refresh_token.clone())) + .http_only(true) + .secure(true) + .same_site(SameSite::Lax) + .path("/api/auth") + .max_age(CookieDuration::seconds(session::REFRESH_TOKEN_TTL_SECS)) + .build(); + jar.add(access_cookie).add(refresh_cookie) +} + +/// Clear both session cookies (logout, or a refresh that turned out invalid). +/// Path must match what the cookie was originally set with, or the browser +/// won't overwrite it. +fn clear_session_cookies(jar: CookieJar) -> CookieJar { + let clear_access = Cookie::build((ACCESS_COOKIE, "")).path("/").build(); + let clear_refresh = Cookie::build((REFRESH_COOKIE, "")) + .path("/api/auth") + .build(); + jar.remove(clear_access).remove(clear_refresh) +} diff --git a/members/nullnet-server/src/http_server/auth/refresh.rs b/members/nullnet-server/src/http_server/auth/refresh.rs new file mode 100644 index 0000000..b2e7c4f --- /dev/null +++ b/members/nullnet-server/src/http_server/auth/refresh.rs @@ -0,0 +1,99 @@ +use super::{ + REFRESH_COOKIE, SessionTokens, attach_session_cookies, clear_session_cookies, internal_error, + role_and_scopes, unauthorized, +}; +use crate::auth::{jwt, session}; +use crate::http_server::AppState; +use axum::extract::State; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use axum_extra::extract::cookie::CookieJar; + +/// Rotate-on-use: the presented refresh token is revoked and a brand new one +/// issued (along with a fresh access token reflecting the user's *current* +/// role/scopes — this is how a scope edit an admin made converges without a +/// full re-login). A reused/expired/unknown token is just rejected — no +/// theft-detection chain-revocation in this version. +pub(crate) async fn refresh_handler( + State(state): State, + jar: CookieJar, +) -> (CookieJar, Response) { + let Some(raw) = jar.get(REFRESH_COOKIE).map(|c| c.value().to_string()) else { + return ( + clear_session_cookies(jar), + unauthorized("missing refresh token"), + ); + }; + let hash = session::hash_token(&raw); + + let active = match state.db.refresh_tokens().find_active(&hash).await { + Ok(Some(row)) => row, + Ok(None) => { + return ( + clear_session_cookies(jar), + unauthorized("refresh token invalid or expired"), + ); + } + Err(_) => return (jar, internal_error("refresh lookup failed")), + }; + + let user = match state.db.users().by_id(&active.user_id).await { + Ok(Some(u)) => u, + Ok(None) => { + return ( + clear_session_cookies(jar), + unauthorized("user no longer exists"), + ); + } + Err(_) => return (jar, internal_error("user lookup failed")), + }; + let (role, scopes) = match role_and_scopes(&state.db, &user.id, &user.role).await { + Ok(v) => v, + Err(resp) => return (jar, resp), + }; + + let access_token = match jwt::issue_access_token(&user.id, role, &scopes) { + Ok(t) => t, + Err(_) => return (jar, internal_error("failed to issue access token")), + }; + let new_raw_refresh = session::generate_raw_token(); + let new_hash = session::hash_token(&new_raw_refresh); + let new_expires_at = crate::auth::now() + session::REFRESH_TOKEN_TTL_SECS; + if state + .db + .refresh_tokens() + .rotate(&hash, &new_hash, &user.id, new_expires_at) + .await + .is_err() + { + return (jar, internal_error("failed to rotate refresh token")); + } + + let jar = attach_session_cookies( + jar, + &SessionTokens { + access_token, + refresh_token: new_raw_refresh, + }, + ); + (jar, StatusCode::NO_CONTENT.into_response()) +} + +/// Revoke the presented refresh token (idempotent if it's already gone/invalid) +/// and clear both cookies. +pub(crate) async fn logout_handler( + State(state): State, + jar: CookieJar, +) -> (CookieJar, Response) { + if let Some(raw) = jar.get(REFRESH_COOKIE).map(|c| c.value().to_string()) { + let _ = state + .db + .refresh_tokens() + .revoke(&session::hash_token(&raw)) + .await; + } + ( + clear_session_cookies(jar), + StatusCode::NO_CONTENT.into_response(), + ) +} diff --git a/members/nullnet-server/src/http_server/auth/users.rs b/members/nullnet-server/src/http_server/auth/users.rs new file mode 100644 index 0000000..fa764f9 --- /dev/null +++ b/members/nullnet-server/src/http_server/auth/users.rs @@ -0,0 +1,345 @@ +use super::{AuthContext, internal_error, rejected, require_admin, role_and_scopes}; +use crate::auth::{Role, Scope, mfa_crypto, password, totp}; +use crate::http_server::AppState; +use axum::extract::{Extension, Path, State}; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Serialize)] +struct UserJson { + id: String, + username: String, + role: String, + scopes: Vec, + mfa_enabled: bool, +} + +/// Parse+validate a list of scope strings, rejecting the whole request if +/// any entry isn't a recognized scope. +#[allow(clippy::result_large_err)] +fn parse_scopes(raw: &[String]) -> Result, Response> { + let mut parsed = Vec::with_capacity(raw.len()); + for s in raw { + let Some(scope) = Scope::from_str_opt(s) else { + return Err(rejected( + StatusCode::BAD_REQUEST, + format!("unknown scope '{s}'"), + )); + }; + parsed.push(scope.as_str().to_string()); + } + Ok(parsed) +} + +const MIN_PASSWORD_LEN: usize = 8; + +/// Trim and reject an empty username — shared by create and update so a +/// username can never be blanked out through either path. +#[allow(clippy::result_large_err)] +fn validate_username(raw: &str) -> Result<&str, Response> { + let username = raw.trim(); + if username.is_empty() { + return Err(rejected(StatusCode::BAD_REQUEST, "username is required")); + } + Ok(username) +} + +/// Reject a new/changed password shorter than [`MIN_PASSWORD_LEN`]. Only +/// applied when a password is actually being set — login verifies an +/// existing hash and never re-checks length. +#[allow(clippy::result_large_err)] +fn validate_password(password: &str) -> Result<(), Response> { + if password.chars().count() < MIN_PASSWORD_LEN { + return Err(rejected( + StatusCode::BAD_REQUEST, + format!("password must be at least {MIN_PASSWORD_LEN} characters"), + )); + } + Ok(()) +} + +pub(crate) async fn list_handler( + State(state): State, + Extension(ctx): Extension, +) -> Response { + if let Err(resp) = require_admin(&ctx) { + return resp; + } + let users = match state.db.users().list().await { + Ok(u) => u, + Err(_) => return internal_error("failed to list users"), + }; + let mut out = Vec::with_capacity(users.len()); + for user in users { + let (role, scopes) = match role_and_scopes(&state.db, &user.id, &user.role).await { + Ok(v) => v, + Err(resp) => return resp, + }; + out.push(UserJson { + id: user.id, + username: user.username, + role: role.as_str().to_string(), + scopes, + mfa_enabled: user.mfa_confirmed_at.is_some(), + }); + } + axum::Json(out).into_response() +} + +#[derive(Deserialize)] +pub(crate) struct CreateUserReq { + username: String, + password: String, + role: String, + #[serde(default)] + scopes: Vec, +} + +#[derive(Serialize)] +struct CreatedResp { + id: String, +} + +/// Create a user. `scopes` is ignored (but still validated) for `admin` role +/// accounts, since admin implicitly has every scope. +pub(crate) async fn create_handler( + State(state): State, + Extension(ctx): Extension, + axum::Json(req): axum::Json, +) -> Response { + if let Err(resp) = require_admin(&ctx) { + return resp; + } + let username = match validate_username(&req.username) { + Ok(u) => u, + Err(resp) => return resp, + }; + if let Err(resp) = validate_password(&req.password) { + return resp; + } + let Ok(role) = req.role.parse::() else { + return rejected(StatusCode::BAD_REQUEST, "invalid role"); + }; + let scopes = match parse_scopes(&req.scopes) { + Ok(s) => s, + Err(resp) => return resp, + }; + + match state.db.users().by_username(username).await { + Ok(Some(_)) => return rejected(StatusCode::CONFLICT, "username already taken"), + Ok(None) => {} + Err(_) => return internal_error("failed to check existing users"), + } + + let password_hash = match password::hash(&req.password) { + Ok(h) => h, + Err(_) => return internal_error("failed to hash password"), + }; + let id = Uuid::new_v4().to_string(); + if state + .db + .users() + .create(&id, username, &password_hash, role.as_str()) + .await + .is_err() + { + return internal_error("failed to create user"); + } + if role != Role::Admin + && !scopes.is_empty() + && state.db.scopes().set_for_user(&id, &scopes).await.is_err() + { + return internal_error("user created, but failed to set scopes"); + } + (StatusCode::CREATED, axum::Json(CreatedResp { id })).into_response() +} + +#[derive(Deserialize)] +pub(crate) struct UpdateUserReq { + username: Option, + password: Option, + /// Required (and checked against the account's own current hash) only + /// when the caller is changing *their own* password — see `is_self` + /// below. An admin resetting someone else's password needs no proof, + /// same as `reset_mfa` for someone else. + current_password: Option, + role: Option, + scopes: Option>, + #[serde(default)] + reset_mfa: bool, + /// Required (and checked against the account's own current secret) only + /// when the caller is resetting their *own* MFA — see `is_self` below. + /// An admin resetting someone else's MFA needs no proof. + mfa_code: Option, +} + +/// Partial update. Changing `role`/`password` revokes the user's existing +/// sessions so the change takes effect immediately rather than waiting for +/// their access token to expire. +pub(crate) async fn update_handler( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, + axum::Json(req): axum::Json, +) -> Response { + if let Err(resp) = require_admin(&ctx) { + return resp; + } + let target_user = match state.db.users().by_id(&id).await { + Ok(Some(u)) => u, + Ok(None) => return rejected(StatusCode::NOT_FOUND, "user not found"), + Err(_) => return internal_error("user lookup failed"), + }; + // An admin acting on someone else's account needs no extra proof — that's + // the legitimate "admin reset" path. Acting on their *own* account via + // this same admin-only endpoint must not be weaker than the dedicated + // self-service endpoints (e.g. `disable_handler`), or a hijacked admin + // session could durably take over the account with no proof at all. + let is_self = id == ctx.user_id; + + // A blank password means "keep the current one" (the edit form's + // placeholder says as much); a *provided* username, though, must be a + // real, non-empty value — same rule `create_handler` enforces. + let username = match req.username.as_deref() { + Some(raw) => match validate_username(raw) { + Ok(u) => Some(u), + Err(resp) => return resp, + }, + None => None, + }; + let role = match req.role.as_deref().map(str::parse::) { + Some(Ok(role)) => Some(role), + Some(Err(_)) => return rejected(StatusCode::BAD_REQUEST, "invalid role"), + None => None, + }; + // Mirrors `delete_handler`'s guard: a role change that demotes the last + // remaining admin would lock the deployment out just as surely as + // deleting that account would. + if target_user.role == Role::Admin.as_str() && role.is_some_and(|r| r != Role::Admin) { + match state.db.users().count_admins().await { + Ok(n) if n <= 1 => { + return rejected( + StatusCode::BAD_REQUEST, + "cannot demote the last remaining admin", + ); + } + Err(_) => return internal_error("failed to check admin count"), + _ => {} + } + } + let password_hash = match req.password.as_deref().filter(|p| !p.is_empty()) { + Some(p) => { + if let Err(resp) = validate_password(p) { + return resp; + } + if is_self { + let current_ok = req.current_password.as_deref().is_some_and(|cur| { + password::verify(cur, &target_user.password_hash).unwrap_or(false) + }); + if !current_ok { + return rejected( + StatusCode::UNAUTHORIZED, + "current password required to change your own password", + ); + } + } + match password::hash(p) { + Ok(h) => Some(h), + Err(_) => return internal_error("failed to hash password"), + } + } + None => None, + }; + + if state + .db + .users() + .update( + &id, + username, + role.map(Role::as_str), + password_hash.as_deref(), + ) + .await + .is_err() + { + return internal_error("failed to update user"); + } + + if let Some(raw_scopes) = &req.scopes { + let scopes = match parse_scopes(raw_scopes) { + Ok(s) => s, + Err(resp) => return resp, + }; + if state.db.scopes().set_for_user(&id, &scopes).await.is_err() { + return internal_error("user updated, but failed to update scopes"); + } + } + + if req.reset_mfa { + if is_self { + let Some(secret_enc) = target_user.mfa_secret_enc.as_deref() else { + return rejected(StatusCode::BAD_REQUEST, "mfa is not enabled"); + }; + let current_secret = match mfa_crypto::cipher().decrypt(secret_enc) { + Ok(s) => s, + Err(_) => return internal_error("failed to decrypt mfa secret"), + }; + let code_ok = req + .mfa_code + .as_deref() + .is_some_and(|c| totp::verify_code(¤t_secret, c).unwrap_or(false)); + if !code_ok { + return rejected( + StatusCode::UNAUTHORIZED, + "current mfa code required to reset your own mfa", + ); + } + } + if state.db.users().clear_mfa(&id).await.is_err() { + return internal_error("user updated, but failed to reset mfa"); + } + } + + if role.is_some() || password_hash.is_some() { + let _ = state.db.refresh_tokens().revoke_all_for_user(&id).await; + } + + StatusCode::NO_CONTENT.into_response() +} + +/// Delete a user (also revokes any active sessions). Refuses to delete the +/// last remaining admin, so the deployment can never lock itself out. +pub(crate) async fn delete_handler( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, +) -> Response { + if let Err(resp) = require_admin(&ctx) { + return resp; + } + let user = match state.db.users().by_id(&id).await { + Ok(Some(u)) => u, + Ok(None) => return rejected(StatusCode::NOT_FOUND, "user not found"), + Err(_) => return internal_error("user lookup failed"), + }; + if user.role == Role::Admin.as_str() { + match state.db.users().count_admins().await { + Ok(n) if n <= 1 => { + return rejected( + StatusCode::BAD_REQUEST, + "cannot delete the last remaining admin", + ); + } + Err(_) => return internal_error("failed to check admin count"), + _ => {} + } + } + if state.db.users().delete(&id).await.is_err() { + return internal_error("failed to delete user"); + } + let _ = state.db.refresh_tokens().revoke_all_for_user(&id).await; + StatusCode::NO_CONTENT.into_response() +} diff --git a/members/nullnet-server/src/http_server/certificates.rs b/members/nullnet-server/src/http_server/certificates.rs index e84b83c..3096287 100644 --- a/members/nullnet-server/src/http_server/certificates.rs +++ b/members/nullnet-server/src/http_server/certificates.rs @@ -1,10 +1,12 @@ use super::AppState; +use super::auth::{AuthContext, require_scope}; +use crate::auth::Scope; use crate::cert::{self, CertificateAuthority, DnsProviderCredentials}; use crate::certs::CERTS_DIR; use crate::events::Event; -use axum::extract::{Path as AxumPath, State}; +use axum::extract::{Extension, Path as AxumPath, State}; use axum::http::StatusCode; -use axum::response::IntoResponse; +use axum::response::{IntoResponse, Response}; use serde::{Deserialize, Serialize}; use std::path::PathBuf; @@ -32,14 +34,17 @@ fn bad_request(error: &str) -> axum::response::Response { /// List installed certs (domain + best-effort expiry). Never returns keys or /// credentials. -pub(super) async fn list_handler() -> impl IntoResponse { +pub(super) async fn list_handler(Extension(ctx): Extension) -> Response { + if let Err(resp) = require_scope(&ctx, Scope::CertificatesRead) { + return resp; + } let mut certs: Vec = Vec::new(); for domain in crate::certs::cert_domains().await { let expires_at = crate::certs::read_expiry(&domain).await; certs.push(CertJson { domain, expires_at }); } certs.sort_by(|a, b| a.domain.cmp(&b.domain)); - axum::Json(certs) + axum::Json(certs).into_response() } /// Write `fullchain.pem` + the encrypted key into `./certs//` and emit an @@ -89,8 +94,12 @@ pub(super) struct RequestReq { /// watcher pushes the new cert to the proxies. pub(super) async fn request_handler( State(state): State, + Extension(ctx): Extension, axum::Json(req): axum::Json, ) -> impl IntoResponse { + if let Err(resp) = require_scope(&ctx, Scope::CertificatesWrite) { + return resp; + } let Some(domain) = sanitize_domain(&req.domain) else { return bad_request("invalid domain"); }; @@ -149,18 +158,22 @@ pub(super) async fn request_handler( /// other; removing the last cert clears the set everywhere. pub(super) async fn delete_handler( State(state): State, + Extension(ctx): Extension, AxumPath(domain): AxumPath, -) -> impl IntoResponse { +) -> Response { + if let Err(resp) = require_scope(&ctx, Scope::CertificatesWrite) { + return resp; + } let Some(domain) = sanitize_domain(&domain) else { - return StatusCode::BAD_REQUEST; + return StatusCode::BAD_REQUEST.into_response(); }; let dir = PathBuf::from(CERTS_DIR).join(&domain); match tokio::fs::remove_dir_all(&dir).await { Ok(()) => { state.events.emit(Event::certificate_removed(domain)).await; - StatusCode::NO_CONTENT + StatusCode::NO_CONTENT.into_response() } - Err(_) => StatusCode::NOT_FOUND, + Err(_) => StatusCode::NOT_FOUND.into_response(), } } diff --git a/members/nullnet-server/src/http_server/chains.rs b/members/nullnet-server/src/http_server/chains.rs index 68a3637..fdee7ff 100644 --- a/members/nullnet-server/src/http_server/chains.rs +++ b/members/nullnet-server/src/http_server/chains.rs @@ -1,7 +1,9 @@ use super::AppState; +use super::auth::{AuthContext, require_scope}; +use crate::auth::Scope; use crate::services::clients::Client; use crate::services::service_info::ServiceInfo; -use axum::extract::{Path, State}; +use axum::extract::{Extension, Path, State}; use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; use serde::Serialize; @@ -15,9 +17,13 @@ struct ChainJson { } pub(super) async fn chains_handler( + Extension(ctx): Extension, Path(stack): Path, State(state): State, ) -> Response { + if let Err(resp) = require_scope(&ctx, Scope::NodesRead) { + return resp; + } let services = state.services.read().await; let Some(stack_map) = services.get(&stack) else { return StatusCode::NOT_FOUND.into_response(); diff --git a/members/nullnet-server/src/http_server/config.rs b/members/nullnet-server/src/http_server/config.rs index c1354fd..90346ff 100644 --- a/members/nullnet-server/src/http_server/config.rs +++ b/members/nullnet-server/src/http_server/config.rs @@ -1,6 +1,8 @@ use super::AppState; +use super::auth::{AuthContext, require_scope}; +use crate::auth::Scope; use crate::services::input::{detect_port_conflicts, validate_stack_toml}; -use axum::extract::{Path, State}; +use axum::extract::{Extension, Path, State}; use axum::http::{StatusCode, header}; use axum::response::{IntoResponse, Response}; use serde::Serialize; @@ -18,7 +20,13 @@ fn valid_stack_name(stack: &str) -> bool { } /// GET the raw TOML of a stack's service configuration. -pub(super) async fn config_handler(Path(stack): Path) -> Response { +pub(super) async fn config_handler( + Extension(ctx): Extension, + Path(stack): Path, +) -> Response { + if let Err(resp) = require_scope(&ctx, Scope::ConfigRead) { + return resp; + } if !valid_stack_name(&stack) { return Response::builder() .status(StatusCode::BAD_REQUEST) @@ -63,10 +71,14 @@ fn rejected(status: StatusCode, error: impl Into) -> Response { /// it live; on failure nothing is written, so the last valid config keeps running /// and the response carries the parse error for the UI's status indicator. pub(super) async fn save_handler( + Extension(ctx): Extension, Path(stack): Path, State(state): State, body: String, ) -> Response { + if let Err(resp) = require_scope(&ctx, Scope::ConfigWrite) { + return resp; + } if !valid_stack_name(&stack) { return rejected(StatusCode::BAD_REQUEST, "invalid stack name"); } @@ -117,7 +129,13 @@ pub(super) async fn save_handler( /// DELETE a stack's config file. The `./services` watcher sees the removal and /// tears the stack's services down (`apply_config_update`). Creating a stack is /// just a `save_handler` POST to a name that has no file yet. -pub(super) async fn delete_handler(Path(stack): Path) -> Response { +pub(super) async fn delete_handler( + Extension(ctx): Extension, + Path(stack): Path, +) -> Response { + if let Err(resp) = require_scope(&ctx, Scope::ConfigWrite) { + return resp; + } if !valid_stack_name(&stack) { return rejected(StatusCode::BAD_REQUEST, "invalid stack name"); } diff --git a/members/nullnet-server/src/http_server/events.rs b/members/nullnet-server/src/http_server/events.rs index 2f2208d..85ab1fa 100644 --- a/members/nullnet-server/src/http_server/events.rs +++ b/members/nullnet-server/src/http_server/events.rs @@ -1,7 +1,9 @@ +use super::auth::{AuthContext, require_scope}; +use crate::auth::Scope; use crate::events::{EventEnvelope, Severity}; use crate::http_server::AppState; -use axum::Json; -use axum::extract::{Query, State}; +use axum::extract::{Extension, Query, State}; +use axum::response::{IntoResponse, Response}; use serde::Deserialize; #[derive(Deserialize)] @@ -12,9 +14,13 @@ pub(crate) struct EventsQuery { } pub(crate) async fn events_handler( + Extension(ctx): Extension, State(state): State, Query(params): Query, -) -> Json { +) -> Response { + if let Err(resp) = require_scope(&ctx, Scope::EventsRead) { + return resp; + } let events = state .events .snapshot(params.limit, params.kind.as_deref(), params.severity) @@ -26,5 +32,6 @@ pub(crate) async fn events_handler( event: e, }) .collect(); - Json(serde_json::to_value(envelopes).unwrap_or(serde_json::Value::Array(vec![]))) + axum::Json(serde_json::to_value(envelopes).unwrap_or(serde_json::Value::Array(vec![]))) + .into_response() } diff --git a/members/nullnet-server/src/http_server/events_stream.rs b/members/nullnet-server/src/http_server/events_stream.rs index 6708587..d458e67 100644 --- a/members/nullnet-server/src/http_server/events_stream.rs +++ b/members/nullnet-server/src/http_server/events_stream.rs @@ -1,14 +1,21 @@ +use super::auth::{AuthContext, require_scope}; +use crate::auth::Scope; use crate::events::EventEnvelope; use crate::http_server::AppState; -use axum::extract::State; +use axum::extract::{Extension, State}; use axum::response::sse::{Event as SseEvent, KeepAlive, Sse}; +use axum::response::{IntoResponse, Response}; use futures::stream::{self, StreamExt}; use std::convert::Infallible; use tokio_stream::wrappers::BroadcastStream; pub(crate) async fn events_stream_handler( + Extension(ctx): Extension, State(state): State, -) -> Sse>> { +) -> Response { + if let Err(resp) = require_scope(&ctx, Scope::EventsRead) { + return resp; + } let backfill = state.events.snapshot(None, None, None).await; let rx = state.events.subscribe(); @@ -34,5 +41,7 @@ pub(crate) async fn events_stream_handler( }) }); - Sse::new(backfill_stream.chain(live_stream)).keep_alive(KeepAlive::default()) + Sse::new(backfill_stream.chain(live_stream)) + .keep_alive(KeepAlive::default()) + .into_response() } diff --git a/members/nullnet-server/src/http_server/graph.rs b/members/nullnet-server/src/http_server/graph.rs index cf49586..ba4c89d 100644 --- a/members/nullnet-server/src/http_server/graph.rs +++ b/members/nullnet-server/src/http_server/graph.rs @@ -1,13 +1,19 @@ use super::AppState; +use super::auth::{AuthContext, require_scope}; +use crate::auth::Scope; use crate::graphviz::render_graph_json; -use axum::extract::{Path, State}; +use axum::extract::{Extension, Path, State}; use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; pub(super) async fn graph_handler( + Extension(ctx): Extension, Path(stack): Path, State(state): State, ) -> Response { + if let Err(resp) = require_scope(&ctx, Scope::NodesRead) { + return resp; + } let services = state.services.read().await; let Some(stack_map) = services.get(&stack) else { return StatusCode::NOT_FOUND.into_response(); diff --git a/members/nullnet-server/src/http_server/mod.rs b/members/nullnet-server/src/http_server/mod.rs index d0770c7..cf5e63c 100644 --- a/members/nullnet-server/src/http_server/mod.rs +++ b/members/nullnet-server/src/http_server/mod.rs @@ -1,12 +1,14 @@ +use crate::db::Db; use crate::events::EventStore; use crate::orchestrator::Orchestrator; use crate::services::input::StackMap; use axum::Router; -use axum::routing::{delete, get, post}; +use axum::routing::{delete, get, patch, post}; use std::net::{IpAddr, Ipv4Addr, SocketAddr}; use std::sync::Arc; use tokio::sync::RwLock; +mod auth; mod certificates; mod chains; mod config; @@ -27,11 +29,14 @@ pub(crate) struct AppState { pub(crate) services: Arc>, pub(crate) orchestrator: Orchestrator, pub(crate) events: EventStore, + pub(crate) db: Db, } pub async fn serve(state: AppState) { - let app = Router::new() - .route("/api/health", get(health::health)) + // Guarded by `auth::require_auth`: every route here needs a valid + // access-token cookie. Individual handlers additionally check their own + // required scope (see `http_server::auth::require_scope`). + let protected = Router::new() .route("/api/stacks", get(stacks::stacks_handler)) .route("/api/services/{stack}", get(services::services_handler)) .route("/api/nodes/{stack}", get(nodes::nodes_handler)) @@ -62,8 +67,32 @@ pub async fn serve(state: AppState) { "/api/events/stream", get(events_stream::events_stream_handler), ) - .fallback(get(static_files::static_handler)) - .with_state(state); + .route("/api/auth/logout", post(auth::logout_handler)) + .route("/api/auth/me", get(auth::me_handler)) + .route("/api/auth/mfa/setup", post(auth::setup_handler)) + .route("/api/auth/mfa/confirm", post(auth::confirm_handler)) + .route("/api/auth/mfa/disable", post(auth::disable_handler)) + .route( + "/api/auth/users", + get(auth::list_handler).post(auth::create_handler), + ) + .route( + "/api/auth/users/{id}", + patch(auth::update_handler).delete(auth::delete_handler), + ) + .route_layer(axum::middleware::from_fn(auth::require_auth)); + + // No auth required — login/refresh have no session yet by definition, + // health is a plain liveness check, and the SPA fallback must stay open + // so the browser can load the login page itself. + let public = Router::new() + .route("/api/health", get(health::health)) + .route("/api/auth/login", post(auth::login_handler)) + .route("/api/auth/mfa/verify", post(auth::mfa_verify_handler)) + .route("/api/auth/refresh", post(auth::refresh_handler)) + .fallback(get(static_files::static_handler)); + + let app = protected.merge(public).with_state(state); // Self-signed cert, regenerated each start. The admin UI is single-origin, so // relative /api calls inherit HTTPS; browsers prompt to trust the cert once. diff --git a/members/nullnet-server/src/http_server/nodes.rs b/members/nullnet-server/src/http_server/nodes.rs index 53600ff..f2959e1 100644 --- a/members/nullnet-server/src/http_server/nodes.rs +++ b/members/nullnet-server/src/http_server/nodes.rs @@ -1,7 +1,9 @@ use super::AppState; +use super::auth::{AuthContext, require_scope}; +use crate::auth::Scope; use crate::services::service_info::ServiceInfo; -use axum::extract::{Path, State}; -use axum::response::IntoResponse; +use axum::extract::{Extension, Path, State}; +use axum::response::{IntoResponse, Response}; use serde::Serialize; use std::collections::HashMap; use std::net::IpAddr; @@ -19,9 +21,13 @@ struct NodeJson { } pub(super) async fn nodes_handler( + Extension(ctx): Extension, Path(stack): Path, State(state): State, -) -> impl IntoResponse { +) -> Response { + if let Err(resp) = require_scope(&ctx, Scope::NodesRead) { + return resp; + } let connected_ips = state.orchestrator.connected_node_ips().await; let services = state.services.read().await; @@ -55,5 +61,5 @@ pub(super) async fn nodes_handler( .collect(); nodes.sort_by(|a, b| a.ip.cmp(&b.ip)); - axum::Json(nodes) + axum::Json(nodes).into_response() } diff --git a/members/nullnet-server/src/http_server/services.rs b/members/nullnet-server/src/http_server/services.rs index 3b9aca8..f058545 100644 --- a/members/nullnet-server/src/http_server/services.rs +++ b/members/nullnet-server/src/http_server/services.rs @@ -1,6 +1,8 @@ use super::AppState; +use super::auth::{AuthContext, require_scope}; +use crate::auth::Scope; use crate::services::service_info::ServiceInfo; -use axum::extract::{Path, State}; +use axum::extract::{Extension, Path, State}; use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; use serde::Serialize; @@ -30,9 +32,13 @@ struct ServiceJson { } pub(super) async fn services_handler( + Extension(ctx): Extension, Path(stack): Path, State(state): State, ) -> Response { + if let Err(resp) = require_scope(&ctx, Scope::ConfigRead) { + return resp; + } let services = state.services.read().await; let Some(stack_map) = services.get(&stack) else { return StatusCode::NOT_FOUND.into_response(); diff --git a/members/nullnet-server/src/http_server/sessions.rs b/members/nullnet-server/src/http_server/sessions.rs index fb81557..a7a14a2 100644 --- a/members/nullnet-server/src/http_server/sessions.rs +++ b/members/nullnet-server/src/http_server/sessions.rs @@ -1,9 +1,11 @@ use super::AppState; +use super::auth::{AuthContext, require_scope}; +use crate::auth::Scope; use crate::services::changes::{ServiceChange, apply_changes}; use crate::services::service_info::ServiceInfo; -use axum::extract::{Path, State}; +use axum::extract::{Extension, Path, State}; use axum::http::StatusCode; -use axum::response::IntoResponse; +use axum::response::{IntoResponse, Response}; use serde::Serialize; use std::net::Ipv4Addr; use std::time::UNIX_EPOCH; @@ -33,9 +35,13 @@ struct ErrorJson { } pub(super) async fn list_handler( + Extension(ctx): Extension, Path(stack): Path, State(state): State, -) -> impl IntoResponse { +) -> Response { + if let Err(resp) = require_scope(&ctx, Scope::SessionsRead) { + return resp; + } let services = state.services.read().await; let Some(stack_map) = services.get(&stack) else { return axum::Json(Vec::::new()).into_response(); @@ -87,9 +93,13 @@ pub(super) async fn list_handler( } pub(super) async fn teardown_handler( + Extension(ctx): Extension, State(state): State, Path((stack, id)): Path<(String, u32)>, -) -> impl IntoResponse { +) -> Response { + if let Err(resp) = require_scope(&ctx, Scope::SessionsWrite) { + return resp; + } let mut services = state.services.write().await; let found = { diff --git a/members/nullnet-server/src/http_server/stacks.rs b/members/nullnet-server/src/http_server/stacks.rs index ff17b18..4d87768 100644 --- a/members/nullnet-server/src/http_server/stacks.rs +++ b/members/nullnet-server/src/http_server/stacks.rs @@ -1,10 +1,18 @@ use super::AppState; -use axum::extract::State; -use axum::response::IntoResponse; +use super::auth::{AuthContext, require_scope}; +use crate::auth::Scope; +use axum::extract::{Extension, State}; +use axum::response::{IntoResponse, Response}; -pub(super) async fn stacks_handler(State(state): State) -> impl IntoResponse { +pub(super) async fn stacks_handler( + Extension(ctx): Extension, + State(state): State, +) -> Response { + if let Err(resp) = require_scope(&ctx, Scope::ConfigRead) { + return resp; + } let services = state.services.read().await; let mut stacks: Vec = services.keys().cloned().collect(); stacks.sort(); - axum::Json(stacks) + axum::Json(stacks).into_response() } diff --git a/members/nullnet-server/src/main.rs b/members/nullnet-server/src/main.rs index 2a2fabb..f0ba243 100644 --- a/members/nullnet-server/src/main.rs +++ b/members/nullnet-server/src/main.rs @@ -1,7 +1,9 @@ +mod auth; mod cert; mod cert_renewal; mod certs; mod crypto; +mod db; mod env; mod events; mod geo; @@ -24,6 +26,8 @@ use std::{panic, process}; use tonic::transport::Server; const PORT: u16 = 50051; +/// Default path for the SQLite database; override with `DATABASE_URL`. +const DEFAULT_DATABASE_URL: &str = "/var/nullnet/data/nullnet.db"; #[tokio::main] async fn main() -> Result<(), Error> { @@ -40,6 +44,38 @@ async fn main() -> Result<(), Error> { // cert private keys are encrypted at rest with this key; fail fast if absent crypto::init_from_env()?; + // JWT signing key + MFA-secret encryption key: same fail-fast pattern, + // distinct keys/env vars so neither shares blast radius with the other + // or with CERT_ENCRYPTION_KEY. + auth::jwt::init_from_env()?; + auth::mfa_crypto::init_from_env()?; + + // SQLite-backed storage for server data (certs/services, going forward): + // pending schema migrations run automatically before anything else starts. + let database_url = + std::env::var("DATABASE_URL").unwrap_or_else(|_| DEFAULT_DATABASE_URL.to_string()); + let db = db::Db::open(&database_url).await?; + // Create the first admin account if none exists yet. Defaults to + // 'admin'/'admin' when the bootstrap env vars aren't set, so a fresh + // deployment is never locked out of its own admin UI — but that's a + // well-known credential pair, so warn loudly every time it's used. + let bootstrap_username_env = std::env::var("ADMIN_BOOTSTRAP_USERNAME") + .ok() + .filter(|s| !s.trim().is_empty()); + let bootstrap_password_env = std::env::var("ADMIN_BOOTSTRAP_PASSWORD") + .ok() + .filter(|s| !s.is_empty()); + if bootstrap_username_env.is_none() || bootstrap_password_env.is_none() { + println!( + "WARNING: ADMIN_BOOTSTRAP_USERNAME/ADMIN_BOOTSTRAP_PASSWORD not set — defaulting the \ + initial admin account to 'admin'/'admin'. Change this password immediately after \ + first login (this only affects a brand-new deployment with no existing users)." + ); + } + let bootstrap_username = bootstrap_username_env.unwrap_or_else(|| "admin".to_string()); + let bootstrap_password = bootstrap_password_env.unwrap_or_else(|| "admin".to_string()); + auth::bootstrap::ensure_admin_exists(&db, Some(&bootstrap_username), Some(&bootstrap_password)) + .await?; // The firewall allowlist is now global (single point of decision). An empty // ingress-TCP list means every client's host firewall drops ALL inbound TCP — @@ -63,6 +99,7 @@ async fn main() -> Result<(), Error> { services: nullnet.services().clone(), events: nullnet.orchestrator().events.clone(), orchestrator: nullnet.orchestrator().clone(), + db, }; // auto-renew ACME certs nearing expiry (those with stored DNS credentials) diff --git a/members/nullnet-server/ui/package-lock.json b/members/nullnet-server/ui/package-lock.json index 9c3cedb..47b11a9 100644 --- a/members/nullnet-server/ui/package-lock.json +++ b/members/nullnet-server/ui/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.0", "dependencies": { "@types/react-router-dom": "^5.3.3", + "qrcode.react": "^4.2.0", "react": "^19.2.6", "react-dom": "^19.2.6", "react-router-dom": "^7.15.1" @@ -2383,6 +2384,14 @@ "node": ">=6" } }, + "node_modules/qrcode.react": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", + "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/react": { "version": "19.2.6", "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", diff --git a/members/nullnet-server/ui/package.json b/members/nullnet-server/ui/package.json index 93cb0c4..a415a3b 100644 --- a/members/nullnet-server/ui/package.json +++ b/members/nullnet-server/ui/package.json @@ -11,6 +11,7 @@ }, "dependencies": { "@types/react-router-dom": "^5.3.3", + "qrcode.react": "^4.2.0", "react": "^19.2.6", "react-dom": "^19.2.6", "react-router-dom": "^7.15.1" diff --git a/members/nullnet-server/ui/src/App.tsx b/members/nullnet-server/ui/src/App.tsx index d24ea9c..7431f4a 100644 --- a/members/nullnet-server/ui/src/App.tsx +++ b/members/nullnet-server/ui/src/App.tsx @@ -1,5 +1,8 @@ import { BrowserRouter, Routes, Route } from 'react-router-dom'; import { StackProvider } from './StackContext'; +import { AuthProvider } from './AuthContext'; +import RequireAuth from './components/RequireAuth'; +import Login from './pages/Login'; import Dashboard from './pages/Dashboard'; import Services from './pages/Services'; import Nodes from './pages/Nodes'; @@ -8,22 +11,27 @@ import Config from './pages/Config'; import Events from './pages/Events'; import Certificates from './pages/Certificates'; import Topology from './pages/Topology'; +import Users from './pages/Users'; export default function App() { return ( - - - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - - + + + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + ); } diff --git a/members/nullnet-server/ui/src/AuthContext.tsx b/members/nullnet-server/ui/src/AuthContext.tsx new file mode 100644 index 0000000..a51f77e --- /dev/null +++ b/members/nullnet-server/ui/src/AuthContext.tsx @@ -0,0 +1,66 @@ +import { createContext, useCallback, useContext, useEffect, useState } from 'react'; + +export interface AuthUser { + id: string; + username: string; + role: 'admin' | 'user'; + scopes: string[]; + mfaEnabled: boolean; +} + +interface AuthContextValue { + user: AuthUser | null; + loading: boolean; + refetchMe: () => Promise; +} + +const AuthContext = createContext({ + user: null, + loading: true, + refetchMe: async () => {}, +}); + +export function AuthProvider({ children }: { children: React.ReactNode }) { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + + const load = useCallback(async (signal?: AbortSignal) => { + try { + // Plain fetch, not apiFetch: checking "am I logged in" legitimately 401s + // for an anonymous visitor — that's not a session-expired event, so it + // shouldn't trigger apiFetch's refresh-then-redirect side effect. + const res = await fetch('/api/auth/me', { credentials: 'include', signal }); + if (!res.ok) { + setUser(null); + return; + } + const data = await res.json(); + setUser({ + id: data.id, + username: data.username, + role: data.role, + scopes: data.scopes, + mfaEnabled: data.mfa_enabled, + }); + } catch (e) { + if ((e as Error).name === 'AbortError') return; + setUser(null); + } finally { + if (!signal?.aborted) setLoading(false); + } + }, []); + + useEffect(() => { + const controller = new AbortController(); + (async () => { + await load(controller.signal); + })(); + return () => controller.abort(); + }, [load]); + + return {children}; +} + +export function useAuth() { + return useContext(AuthContext); +} diff --git a/members/nullnet-server/ui/src/components/Layout.tsx b/members/nullnet-server/ui/src/components/Layout.tsx index 57aba69..52359c8 100644 --- a/members/nullnet-server/ui/src/components/Layout.tsx +++ b/members/nullnet-server/ui/src/components/Layout.tsx @@ -1,10 +1,13 @@ import { NavLink } from 'react-router-dom'; import { useStack } from '../StackContext'; +import { useAuth } from '../AuthContext'; import { useApi } from '../hooks/useApi'; +import { apiFetch } from '../lib/apiFetch'; +import MfaSetupDialog from './MfaSetupDialog'; import type { SessionJson } from '../types'; import { useRef, useState, useEffect } from 'react'; -type Page = 'dashboard' | 'topology' | 'services' | 'nodes' | 'sessions' | 'config' | 'certificates' | 'events'; +type Page = 'dashboard' | 'topology' | 'services' | 'nodes' | 'sessions' | 'config' | 'certificates' | 'events' | 'users'; interface Props { page: Page; @@ -34,16 +37,26 @@ const NAV = [ { id: 'events', icon: '≡', label: 'Events', to: '/events' }, { id: 'certificates', icon: '⛨', label: 'Certificates', to: '/certificates' }, { id: 'config', icon: '⚙', label: 'Config', to: '/config' }, + { id: 'users', icon: '⚉', label: 'Users', to: '/users', adminOnly: true }, ], }, ]; export default function Layout({ page, topbarRight, children }: Props) { const { stack, setStack, editing, setEditing } = useStack(); + const { user } = useAuth(); const { data: sessions } = useApi(`/api/sessions/${stack}`, 5000); const { data: availableStacks } = useApi('/api/stacks', 10000); const [dropdownOpen, setDropdownOpen] = useState(false); const dropdownRef = useRef(null); + const [mfaDialogOpen, setMfaDialogOpen] = useState(false); + + async function logout() { + await apiFetch('/api/auth/logout', { method: 'POST' }); + // Full reload (not a SPA navigate): guarantees AuthContext re-checks + // /api/auth/me from scratch rather than needing its own invalidation path. + window.location.href = '/login'; + } useEffect(() => { if (!dropdownOpen) return; @@ -71,6 +84,9 @@ export default function Layout({ page, topbarRight, children }: Props) {
{group.group}
{group.items.map(item => { + if ('adminOnly' in item && item.adminOnly && user?.role !== 'admin') { + return null; + } if (!item.to) { return ( @@ -99,6 +115,18 @@ export default function Layout({ page, topbarRight, children }: Props) {
+ {user && ( +
+ {user.username} + + logout + +
+ )}
HTTP · 8080
stack: @@ -189,8 +217,20 @@ export default function Layout({ page, topbarRight, children }: Props) { {stack}
+ {user && !user.mfaEnabled && ( +
+ + MFA not set up + Your account has no multi-factor authentication configured. + + +
+ )} {children} + setMfaDialogOpen(false)} /> ); } diff --git a/members/nullnet-server/ui/src/components/MfaSetupDialog.tsx b/members/nullnet-server/ui/src/components/MfaSetupDialog.tsx new file mode 100644 index 0000000..0a7b024 --- /dev/null +++ b/members/nullnet-server/ui/src/components/MfaSetupDialog.tsx @@ -0,0 +1,111 @@ +import { useEffect, useState } from 'react'; +import { QRCodeSVG } from 'qrcode.react'; +import Modal from './Modal'; +import { apiFetch } from '../lib/apiFetch'; +import { useAuth } from '../AuthContext'; + +interface Props { + open: boolean; + onClose: () => void; +} + +type Step = + | { kind: 'loading' } + | { kind: 'ready'; secret: string; otpauthUri: string } + | { kind: 'error'; message: string }; + +export default function MfaSetupDialog({ open, onClose }: Props) { + const { refetchMe } = useAuth(); + const [step, setStep] = useState({ kind: 'loading' }); + const [code, setCode] = useState(''); + const [busy, setBusy] = useState(false); + const [confirmError, setConfirmError] = useState(null); + + useEffect(() => { + if (!open) return; + (async () => { + setStep({ kind: 'loading' }); + setCode(''); + setConfirmError(null); + try { + // The dialog is only ever opened when MFA isn't enabled yet (see the + // Layout banner), so no current code is needed here — an empty body + // is still required since the server now expects valid JSON. + const res = await apiFetch('/api/auth/mfa/setup', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + setStep({ kind: 'error', message: data.error ?? `HTTP ${res.status}` }); + return; + } + setStep({ kind: 'ready', secret: data.secret, otpauthUri: data.otpauth_uri }); + } catch (e) { + setStep({ kind: 'error', message: String(e) }); + } + })(); + }, [open]); + + async function confirm(e: React.FormEvent) { + e.preventDefault(); + setBusy(true); + setConfirmError(null); + try { + const res = await apiFetch('/api/auth/mfa/confirm', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ code }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + setConfirmError(data.error ?? `HTTP ${res.status}`); + return; + } + await refetchMe(); + onClose(); + } catch (e) { + setConfirmError(String(e)); + } finally { + setBusy(false); + } + } + + return ( + + {step.kind === 'loading' &&
Generating secret…
} + {step.kind === 'error' &&
{step.message}
} + {step.kind === 'ready' && ( +
+
+ Scan with your authenticator app (Google Authenticator, Aegis, 1Password, etc.), or enter the secret manually. +
+
+ +
+ + + {confirmError &&
{confirmError}
} +
+ +
+
+ )} +
+ ); +} diff --git a/members/nullnet-server/ui/src/components/Modal.tsx b/members/nullnet-server/ui/src/components/Modal.tsx new file mode 100644 index 0000000..5b045fe --- /dev/null +++ b/members/nullnet-server/ui/src/components/Modal.tsx @@ -0,0 +1,40 @@ +import { useEffect } from 'react'; + +interface Props { + open: boolean; + onClose: () => void; + title: string; + children: React.ReactNode; +} + +export default function Modal({ open, onClose, title, children }: Props) { + useEffect(() => { + if (!open) return; + function onKey(e: KeyboardEvent) { + if (e.key === 'Escape') onClose(); + } + document.addEventListener('keydown', onKey); + return () => document.removeEventListener('keydown', onKey); + }, [open, onClose]); + + if (!open) return null; + + return ( +
{ + if (e.target === e.currentTarget) onClose(); + }} + > +
+
+ {title} + +
+
{children}
+
+
+ ); +} diff --git a/members/nullnet-server/ui/src/components/RequireAuth.tsx b/members/nullnet-server/ui/src/components/RequireAuth.tsx new file mode 100644 index 0000000..e91f977 --- /dev/null +++ b/members/nullnet-server/ui/src/components/RequireAuth.tsx @@ -0,0 +1,9 @@ +import { Navigate } from 'react-router-dom'; +import { useAuth } from '../AuthContext'; + +export default function RequireAuth({ children }: { children: React.ReactNode }) { + const { user, loading } = useAuth(); + if (loading) return null; + if (!user) return ; + return <>{children}; +} diff --git a/members/nullnet-server/ui/src/hooks/useApi.ts b/members/nullnet-server/ui/src/hooks/useApi.ts index 47ef1fb..96ac1bf 100644 --- a/members/nullnet-server/ui/src/hooks/useApi.ts +++ b/members/nullnet-server/ui/src/hooks/useApi.ts @@ -1,4 +1,5 @@ import { useState, useEffect, useCallback } from 'react'; +import { apiFetch } from '../lib/apiFetch'; interface ApiState { data: T | null; @@ -11,7 +12,7 @@ export function useApi(url: string, refreshMs?: number): ApiState & { refe const load = useCallback(async () => { try { - const res = await fetch(url); + const res = await apiFetch(url); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data: T = await res.json(); setState({ data, loading: false, error: null }); @@ -35,7 +36,7 @@ export function useApiText(url: string, refreshMs?: number): { text: string | nu const load = useCallback(async () => { try { - const res = await fetch(url); + const res = await apiFetch(url); if (!res.ok) throw new Error(`HTTP ${res.status}`); const text = await res.text(); setState({ text, loading: false, error: null }); diff --git a/members/nullnet-server/ui/src/index.css b/members/nullnet-server/ui/src/index.css index b328db4..ac845c3 100644 --- a/members/nullnet-server/ui/src/index.css +++ b/members/nullnet-server/ui/src/index.css @@ -282,6 +282,39 @@ body { .cfg-name { font-family: 'JetBrains Mono', monospace; font-size: 12px; color: var(--t1); background: rgba(0,0,0,.3); border: 1px solid var(--gb); border-radius: 8px; padding: 6px 12px; outline: none; transition: border-color .12s; } .cfg-name:focus { border-color: rgba(91,156,246,.5); } +/* ── MFA banner ── */ +.mfa-banner { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin: 16px 24px 0; padding: 10px 16px; background: var(--amber-g); border: 1px solid rgba(251,191,36,.25); border-radius: 10px; font-size: 12px; color: var(--t1); } +.mfa-banner > span:first-child { display: flex; align-items: center; gap: 10px; } + +/* ── Modal ── */ +.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.6); display: flex; align-items: center; justify-content: center; z-index: 1000; padding: 20px; } +.modal-panel { width: 100%; max-width: 440px; max-height: 85vh; overflow-y: auto; border-radius: 14px; } +.modal-head { display: flex; align-items: center; justify-content: space-between; padding: 14px 18px; border-bottom: 1px solid var(--t3); position: sticky; top: 0; background: rgba(10,12,16,.9); backdrop-filter: blur(8px); } +.modal-close { background: none; border: none; color: var(--t2); font-size: 18px; cursor: pointer; line-height: 1; padding: 2px 6px; } +.modal-close:hover { color: var(--t0); } +.modal-body { padding: 18px; display: flex; flex-direction: column; gap: 12px; } +.modal-field { display: flex; flex-direction: column; gap: 4px; } +.modal-field span { font-size: 12px; color: var(--t2); } +.modal-field input, .modal-field select { font-family: 'JetBrains Mono', monospace; padding: 6px 8px; background: rgba(0,0,0,.3); border: 1px solid var(--gb); border-radius: 8px; color: var(--t0); outline: none; } +.modal-field input:focus, .modal-field select:focus { border-color: rgba(91,156,246,.5); } +.scope-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 6px 12px; } +.scope-check { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--t1); } +.modal-actions { display: flex; align-items: center; gap: 10px; margin-top: 4px; } +.modal-err { color: var(--red); font-size: 12px; } + +/* ── Login ── */ +.login-page { width: 100%; min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 24px; } +.login-card { width: 100%; max-width: 320px; border-radius: 16px; padding: 28px 26px; } +.login-logo { text-align: center; margin-bottom: 22px; } +.login-form { display: flex; flex-direction: column; gap: 6px; } +.login-form label { font-size: 10px; color: var(--t1); letter-spacing: .06em; font-weight: 600; text-transform: uppercase; margin-top: 10px; } +.login-form label:first-of-type { margin-top: 0; } +.login-form input { font-family: 'JetBrains Mono', monospace; font-size: 13px; color: var(--t0); background: rgba(0,0,0,.3); border: 1px solid var(--gb); border-radius: 8px; padding: 8px 12px; outline: none; transition: border-color .12s; } +.login-form input:focus { border-color: rgba(91,156,246,.5); } +.login-sub { font-size: 11px; color: var(--t2); margin-bottom: 4px; } +.login-error { font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--red); margin-top: 10px; } +.login-submit { margin-top: 18px; padding: 9px 16px; font-size: 13px; width: 100%; } + /* ── Animations ── */ @keyframes gp { 0%, 100% { opacity: 1; } 50% { opacity: .3; } } @keyframes flash { from { background: rgba(52,211,153,.08); } to { background: transparent; } } diff --git a/members/nullnet-server/ui/src/lib/apiFetch.ts b/members/nullnet-server/ui/src/lib/apiFetch.ts new file mode 100644 index 0000000..9572a42 --- /dev/null +++ b/members/nullnet-server/ui/src/lib/apiFetch.ts @@ -0,0 +1,35 @@ +// Wraps fetch with cookie-based auth: always sends credentials, and on a 401 +// (expired/invalid access token) transparently refreshes the session once +// and retries — only falling through to a hard redirect to /login if the +// refresh itself fails (refresh token missing/expired/revoked). + +let refreshPromise: Promise | null = null; + +function refreshSession(): Promise { + if (!refreshPromise) { + refreshPromise = fetch('/api/auth/refresh', { method: 'POST', credentials: 'include' }) + .then(res => res.ok) + .catch(() => false) + .finally(() => { + refreshPromise = null; + }); + } + return refreshPromise; +} + +export async function apiFetch(input: string, init?: RequestInit): Promise { + const res = await fetch(input, { ...init, credentials: 'include' }); + if (res.status !== 401) return res; + + // Auth endpoints 401ing is either an expected "not logged in" state (login + // itself) or the refresh call failing on its own merits — never chase our + // own tail trying to refresh those. + if (input.startsWith('/api/auth/')) return res; + + const refreshed = await refreshSession(); + if (!refreshed) { + window.location.href = '/login'; + return res; + } + return fetch(input, { ...init, credentials: 'include' }); +} diff --git a/members/nullnet-server/ui/src/pages/Certificates.tsx b/members/nullnet-server/ui/src/pages/Certificates.tsx index bc8c5e1..d2df6ab 100644 --- a/members/nullnet-server/ui/src/pages/Certificates.tsx +++ b/members/nullnet-server/ui/src/pages/Certificates.tsx @@ -1,6 +1,7 @@ import { useState } from 'react'; import Layout from '../components/Layout'; import { useApi } from '../hooks/useApi'; +import { apiFetch } from '../lib/apiFetch'; import type { CertJson } from '../types'; type CredField = { key: string; label: string; optional?: boolean; textarea?: boolean }; @@ -91,7 +92,7 @@ export default function Certificates() { const secs = parseInt(propagation, 10); if (!Number.isNaN(secs)) body.dns_propagation_secs = secs; - const res = await fetch('/api/certificates/request', { + const res = await apiFetch('/api/certificates/request', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), @@ -113,7 +114,7 @@ export default function Certificates() { if (!confirm(`Delete certificate for ${d}?`)) return; setDeleting(prev => new Set(prev).add(d)); try { - await fetch(`/api/certificates/${encodeURIComponent(d)}`, { method: 'DELETE' }); + await apiFetch(`/api/certificates/${encodeURIComponent(d)}`, { method: 'DELETE' }); refetch(); } finally { setDeleting(prev => { const next = new Set(prev); next.delete(d); return next; }); diff --git a/members/nullnet-server/ui/src/pages/Config.tsx b/members/nullnet-server/ui/src/pages/Config.tsx index 3130347..5316319 100644 --- a/members/nullnet-server/ui/src/pages/Config.tsx +++ b/members/nullnet-server/ui/src/pages/Config.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react'; import Layout from '../components/Layout'; import { useApi, useApiText } from '../hooks/useApi'; +import { apiFetch } from '../lib/apiFetch'; import { useStack } from '../StackContext'; type Status = @@ -62,7 +63,7 @@ export default function Config() { async function postConfig(name: string, body: string): Promise<{ ok: boolean; error?: string }> { try { - const res = await fetch(`/api/config/${name}`, { + const res = await apiFetch(`/api/config/${name}`, { method: 'POST', headers: { 'Content-Type': 'text/plain' }, body, @@ -101,7 +102,7 @@ export default function Config() { async function removeStack() { if (!confirm(`Delete stack "${stack}"? Its services are torn down immediately.`)) return; - const res = await fetch(`/api/config/${stack}`, { method: 'DELETE' }); + const res = await apiFetch(`/api/config/${stack}`, { method: 'DELETE' }); if (res.ok) { const others = (stacks ?? []).filter(s => s !== stack); refetchStacks(); diff --git a/members/nullnet-server/ui/src/pages/Login.tsx b/members/nullnet-server/ui/src/pages/Login.tsx new file mode 100644 index 0000000..89790fc --- /dev/null +++ b/members/nullnet-server/ui/src/pages/Login.tsx @@ -0,0 +1,125 @@ +import { useState } from 'react'; +import type { FormEvent } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useAuth } from '../AuthContext'; + +type Step = { kind: 'credentials' } | { kind: 'mfa'; mfaToken: string }; + +export default function Login() { + const navigate = useNavigate(); + const { refetchMe } = useAuth(); + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [code, setCode] = useState(''); + const [step, setStep] = useState({ kind: 'credentials' }); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + async function submitCredentials(e: FormEvent) { + e.preventDefault(); + setBusy(true); + setError(null); + try { + const res = await fetch('/api/auth/login', { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + setError(data.error ?? `HTTP ${res.status}`); + return; + } + if (data.mfa_required) { + setStep({ kind: 'mfa', mfaToken: data.mfa_token }); + return; + } + await refetchMe(); + navigate('/', { replace: true }); + } catch (e) { + setError(String(e)); + } finally { + setBusy(false); + } + } + + async function submitCode(e: FormEvent) { + e.preventDefault(); + if (step.kind !== 'mfa') return; + setBusy(true); + setError(null); + try { + const res = await fetch('/api/auth/mfa/verify', { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ mfa_token: step.mfaToken, code }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + setError(data.error ?? `HTTP ${res.status}`); + return; + } + await refetchMe(); + navigate('/', { replace: true }); + } catch (e) { + setError(String(e)); + } finally { + setBusy(false); + } + } + + return ( +
+
+
+
Nullnet
+
Control Plane
+
+ + {step.kind === 'credentials' ? ( +
+ + setUsername(e.target.value)} + autoFocus + autoComplete="username" + /> + + setPassword(e.target.value)} + autoComplete="current-password" + /> + {error &&
{error}
} + +
+ ) : ( +
+
Enter the 6-digit code from your authenticator app.
+ + setCode(e.target.value.replace(/\D/g, '').slice(0, 6))} + autoFocus + inputMode="numeric" + maxLength={6} + /> + {error &&
{error}
} + +
+ )} +
+
+ ); +} diff --git a/members/nullnet-server/ui/src/pages/Sessions.tsx b/members/nullnet-server/ui/src/pages/Sessions.tsx index 7ed82ad..2847841 100644 --- a/members/nullnet-server/ui/src/pages/Sessions.tsx +++ b/members/nullnet-server/ui/src/pages/Sessions.tsx @@ -1,6 +1,7 @@ import { useState } from 'react'; import Layout from '../components/Layout'; import { useApi } from '../hooks/useApi'; +import { apiFetch } from '../lib/apiFetch'; import { useStack } from '../StackContext'; import type { SessionJson } from '../types'; import { flagEmoji, countryName } from '../geo'; @@ -14,7 +15,7 @@ export default function Sessions() { if (!confirm(`Force teardown session ${id}?`)) return; setTearing(prev => new Set(prev).add(id)); try { - await fetch(`/api/sessions/${stack}/${id}`, { method: 'DELETE' }); + await apiFetch(`/api/sessions/${stack}/${id}`, { method: 'DELETE' }); refetch(); } finally { setTearing(prev => { const next = new Set(prev); next.delete(id); return next; }); diff --git a/members/nullnet-server/ui/src/pages/Users.tsx b/members/nullnet-server/ui/src/pages/Users.tsx new file mode 100644 index 0000000..a4b9a5f --- /dev/null +++ b/members/nullnet-server/ui/src/pages/Users.tsx @@ -0,0 +1,471 @@ +import { useState } from 'react'; +import Layout from '../components/Layout'; +import Modal from '../components/Modal'; +import { useApi } from '../hooks/useApi'; +import { apiFetch } from '../lib/apiFetch'; +import { useAuth } from '../AuthContext'; +import { ALL_SCOPES } from '../types'; +import type { Scope, UserJson } from '../types'; + +// Matches the server's minimum in http_server/auth/users.rs — kept in sync by hand. +const MIN_PASSWORD_LEN = 8; + +interface IdentityFieldsProps { + username: string; + role: 'admin' | 'user'; + scopes: Scope[]; + onUsernameChange: (v: string) => void; + onRoleChange: (v: 'admin' | 'user') => void; + onToggleScope: (s: Scope) => void; +} + +/// Username/role/scopes — the fields shared by the create and edit dialogs. +/// Password and MFA reset live in their own single-purpose dialogs instead of +/// being crammed into this same form, so each dialog stays a single task. +function IdentityFields({ username, role, scopes, onUsernameChange, onRoleChange, onToggleScope }: IdentityFieldsProps) { + return ( + <> + + + {role === 'user' ? ( +
+ Scopes +
+ {ALL_SCOPES.map(s => ( + + ))} +
+
+ ) : ( +
Admins implicitly have every scope.
+ )} + + ); +} + +interface CreateFormState { + username: string; + password: string; + role: 'admin' | 'user'; + scopes: Scope[]; +} + +const EMPTY_CREATE_FORM: CreateFormState = { username: '', password: '', role: 'user', scopes: [] }; + +interface EditFormState { + username: string; + role: 'admin' | 'user'; + scopes: Scope[]; +} + +export default function Users() { + const { user: currentUser } = useAuth(); + const { data: users, loading, refetch } = useApi('/api/auth/users', 10000); + const [deleting, setDeleting] = useState>(new Set()); + + const [createOpen, setCreateOpen] = useState(false); + const [createForm, setCreateForm] = useState(EMPTY_CREATE_FORM); + const [createBusy, setCreateBusy] = useState(false); + const [createError, setCreateError] = useState(null); + + const [editing, setEditing] = useState(null); + const [editForm, setEditForm] = useState({ username: '', role: 'user', scopes: [] }); + const [editBusy, setEditBusy] = useState(false); + const [editError, setEditError] = useState(null); + + const [pwUser, setPwUser] = useState(null); + const [newPassword, setNewPassword] = useState(''); + const [currentPassword, setCurrentPassword] = useState(''); + const [pwBusy, setPwBusy] = useState(false); + const [pwError, setPwError] = useState(null); + + const [mfaUser, setMfaUser] = useState(null); + const [mfaCode, setMfaCode] = useState(''); + const [mfaBusy, setMfaBusy] = useState(false); + const [mfaError, setMfaError] = useState(null); + + const list = users ?? []; + + function openCreate() { + setCreateForm(EMPTY_CREATE_FORM); + setCreateError(null); + setCreateOpen(true); + } + + function openEdit(u: UserJson) { + setEditing(u); + setEditForm({ username: u.username, role: u.role, scopes: u.scopes as Scope[] }); + setEditError(null); + } + + function openPasswordDialog(u: UserJson) { + setPwUser(u); + setNewPassword(''); + setCurrentPassword(''); + setPwError(null); + } + + function openMfaReset(u: UserJson) { + setMfaUser(u); + setMfaCode(''); + setMfaError(null); + } + + async function submitCreate(e: React.FormEvent) { + e.preventDefault(); + setCreateBusy(true); + setCreateError(null); + try { + const res = await apiFetch('/api/auth/users', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(createForm), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + setCreateError(data.error ?? `HTTP ${res.status}`); + return; + } + setCreateOpen(false); + refetch(); + } catch (e) { + setCreateError(String(e)); + } finally { + setCreateBusy(false); + } + } + + async function submitEdit(e: React.FormEvent) { + e.preventDefault(); + if (!editing) return; + setEditBusy(true); + setEditError(null); + try { + const res = await apiFetch(`/api/auth/users/${editing.id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(editForm), + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + setEditError(data.error ?? `HTTP ${res.status}`); + return; + } + setEditing(null); + refetch(); + } catch (e) { + setEditError(String(e)); + } finally { + setEditBusy(false); + } + } + + async function submitPasswordChange(e: React.FormEvent) { + e.preventDefault(); + if (!pwUser) return; + setPwBusy(true); + setPwError(null); + try { + const body: Record = { password: newPassword }; + if (pwUser.id === currentUser?.id) body.current_password = currentPassword; + const res = await apiFetch(`/api/auth/users/${pwUser.id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + setPwError(data.error ?? `HTTP ${res.status}`); + return; + } + setPwUser(null); + refetch(); + } catch (e) { + setPwError(String(e)); + } finally { + setPwBusy(false); + } + } + + async function confirmMfaReset() { + if (!mfaUser) return; + setMfaBusy(true); + setMfaError(null); + try { + const isSelf = mfaUser.id === currentUser?.id; + const body: Record = { reset_mfa: true }; + if (isSelf) body.mfa_code = mfaCode; + const res = await apiFetch(`/api/auth/users/${mfaUser.id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + setMfaError(data.error ?? `HTTP ${res.status}`); + return; + } + setMfaUser(null); + refetch(); + } finally { + setMfaBusy(false); + } + } + + async function remove(u: UserJson) { + if (!confirm(`Delete user "${u.username}"?`)) return; + setDeleting(prev => new Set(prev).add(u.id)); + try { + const res = await apiFetch(`/api/auth/users/${u.id}`, { method: 'DELETE' }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + alert(data.error ?? `HTTP ${res.status}`); + return; + } + refetch(); + } finally { + setDeleting(prev => { + const next = new Set(prev); + next.delete(u.id); + return next; + }); + } + } + + const createValid = createForm.username.trim() !== '' && createForm.password.length >= MIN_PASSWORD_LEN; + const editValid = editForm.username.trim() !== ''; + + const pwIsSelf = pwUser !== null && pwUser.id === currentUser?.id; + const pwValid = newPassword.length >= MIN_PASSWORD_LEN && (!pwIsSelf || currentPassword !== ''); + + const mfaIsSelf = mfaUser !== null && mfaUser.id === currentUser?.id; + const mfaValid = !mfaIsSelf || mfaCode.length === 6; + + return ( + +
+
+ {list.length} + user accounts +
+ +
+
+ Users + +
+ + + + + + + + + + + + {loading && ( + + + + )} + {list.map(u => ( + + + + + + + + ))} + {!loading && list.length === 0 && ( + + + + )} + +
UsernameRoleScopesMFA
+ Loading… +
{u.username} + {u.role} + + {u.role === 'admin' ? 'all' : u.scopes.length > 0 ? u.scopes.join(', ') : '—'} + + + {u.mfa_enabled ? 'enabled' : 'off'} + + + + +
+ No users +
+
+
+ + setCreateOpen(false)} title="Add user"> +
+ setCreateForm(f => ({ ...f, username: v }))} + onRoleChange={v => setCreateForm(f => ({ ...f, role: v }))} + onToggleScope={s => + setCreateForm(f => ({ + ...f, + scopes: f.scopes.includes(s) ? f.scopes.filter(x => x !== s) : [...f.scopes, s], + })) + } + /> + + {createError &&
{createError}
} +
+ +
+ +
+ + setEditing(null)} title={`Edit ${editing?.username ?? ''}`}> +
+ setEditForm(f => ({ ...f, username: v }))} + onRoleChange={v => setEditForm(f => ({ ...f, role: v }))} + onToggleScope={s => + setEditForm(f => ({ + ...f, + scopes: f.scopes.includes(s) ? f.scopes.filter(x => x !== s) : [...f.scopes, s], + })) + } + /> + {editError &&
{editError}
} +
+ + + {editing?.mfa_enabled && ( + + )} +
+ +
+ + setPwUser(null)} title={`Change password — ${pwUser?.username ?? ''}`}> +
+ + {pwIsSelf && ( + + )} + {pwError &&
{pwError}
} +
+ +
+
+
+ + setMfaUser(null)} title={`Reset MFA — ${mfaUser?.username ?? ''}`}> +
+
+ This will disable MFA for this account. They'll need to set it up again from scratch. +
+ {mfaIsSelf && ( + + )} + {mfaError &&
{mfaError}
} +
+ +
+
+
+
+ ); +} diff --git a/members/nullnet-server/ui/src/types.ts b/members/nullnet-server/ui/src/types.ts index 6dc3a83..8ba310d 100644 --- a/members/nullnet-server/ui/src/types.ts +++ b/members/nullnet-server/ui/src/types.ts @@ -49,6 +49,27 @@ export interface CertJson { expires_at: number | null; } +export const ALL_SCOPES = [ + 'certificates:read', + 'certificates:write', + 'config:read', + 'config:write', + 'sessions:read', + 'sessions:write', + 'nodes:read', + 'events:read', +] as const; + +export type Scope = (typeof ALL_SCOPES)[number]; + +export interface UserJson { + id: string; + username: string; + role: 'admin' | 'user'; + scopes: string[]; + mfa_enabled: boolean; +} + export type Severity = 'info' | 'warning' | 'error'; type WithSeverity = { severity: Severity; timestamp: number };