From 37153c6e36cd7da0165b0b456752a629bcb534f3 Mon Sep 17 00:00:00 2001 From: Ninjdai Date: Sun, 3 Aug 2025 01:50:18 +0200 Subject: [PATCH] feat: authentication system --- Cargo.lock | 422 +++++++++++++++++++++++++++++++++++- Cargo.toml | 7 + scripts/generate_secret.sh | 1 + src/entities/mod.rs | 1 + src/entities/prelude.rs | 1 + src/entities/user.rs | 26 +++ src/main.rs | 105 ++++++++- src/routes/auth.rs | 144 ++++++++++++ src/routes/book.rs | 2 + src/routes/book_instance.rs | 5 + src/routes/mod.rs | 1 + src/routes/owner.rs | 4 + src/routes/websocket.rs | 1 + src/utils/cli.rs | 147 +++++++++++++ src/utils/mod.rs | 3 +- 15 files changed, 852 insertions(+), 18 deletions(-) create mode 100755 scripts/generate_secret.sh create mode 100644 src/entities/user.rs create mode 100644 src/routes/auth.rs create mode 100644 src/utils/cli.rs diff --git a/Cargo.lock b/Cargo.lock index 3962284..b024ff8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -41,11 +41,18 @@ dependencies = [ name = "alexandria" version = "0.1.0" dependencies = [ + "argon2", "axum", + "axum-extra", + "clap", "dotenvy", "futures-util", + "inquire", + "jsonwebtoken", "log", + "password-hash", "pretty_env_logger", + "rand_core 0.9.3", "reqwest", "sea-orm", "serde", @@ -84,6 +91,56 @@ dependencies = [ "libc", ] +[[package]] +name = "anstream" +version = "0.6.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "301af1932e46185686725e0fad2f8f2aa7da69dd70bf6ecc44d6b703844a3933" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c8bdeb6047d8983be085bab0ba1472e6dc604e7041dbf6fcd5e71523014fae9" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "403f75924867bb1033c59fbf0797484329750cfbe3c4325cd33127941fabc882" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.59.0", +] + [[package]] name = "arbitrary" version = "1.4.1" @@ -93,6 +150,18 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures", + "password-hash", +] + [[package]] name = "arrayvec" version = "0.7.6" @@ -211,6 +280,29 @@ dependencies = [ "tracing", ] +[[package]] +name = "axum-extra" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45bf463831f5131b7d3c756525b305d40f1185b688565648a92e1392ca35713d" +dependencies = [ + "axum", + "axum-core", + "bytes", + "futures-util", + "headers", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "serde", + "tower", + "tower-layer", + "tower-service", +] + [[package]] name = "axum-macros" version = "0.5.0" @@ -263,6 +355,12 @@ dependencies = [ "serde", ] +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.9.1" @@ -284,6 +382,15 @@ dependencies = [ "wyz", ] +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -390,6 +497,52 @@ dependencies = [ "windows-link", ] +[[package]] +name = "clap" +version = "4.5.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed87a9d530bb41a67537289bafcac159cb3ee28460e0a4571123d2a778a6a882" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64f4f3f3c77c94aff3c7e9aac9a2ca1974a5adf392a8bb751e827d6d127ab966" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef4f52386a59ca4c860f7393bcf8abd8dfd91ecccc0f774635ff68e92eeef491" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.104", +] + +[[package]] +name = "clap_lex" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -469,6 +622,31 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crossterm" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e64e6c0fbe2c17357405f7c758c1ef960fce08bdfb2c03d88d2a18d7e09c4b67" +dependencies = [ + "bitflags 1.3.2", + "crossterm_winapi", + "libc", + "mio 0.8.11", + "parking_lot", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crypto-common" version = "0.1.6" @@ -546,6 +724,12 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "either" version = "1.15.0" @@ -769,6 +953,24 @@ dependencies = [ "slab", ] +[[package]] +name = "fuzzy-matcher" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94" +dependencies = [ + "thread_local", +] + +[[package]] +name = "fxhash" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +dependencies = [ + "byteorder", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -860,6 +1062,30 @@ dependencies = [ "hashbrown 0.15.4", ] +[[package]] +name = "headers" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb" +dependencies = [ + "base64", + "bytes", + "headers-core", + "http", + "httpdate", + "mime", + "sha1", +] + +[[package]] +name = "headers-core" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" +dependencies = [ + "http", +] + [[package]] name = "heck" version = "0.4.1" @@ -1196,13 +1422,30 @@ dependencies = [ "syn 2.0.104", ] +[[package]] +name = "inquire" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fddf93031af70e75410a2511ec04d49e758ed2f26dad3404a934e0fb45cc12a" +dependencies = [ + "bitflags 2.9.1", + "crossterm", + "dyn-clone", + "fuzzy-matcher", + "fxhash", + "newline-converter", + "once_cell", + "unicode-segmentation", + "unicode-width", +] + [[package]] name = "io-uring" version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d93587f37623a1a17d94ef2bc9ada592f5465fe7732084ab7beefabe5c77c0c4" dependencies = [ - "bitflags", + "bitflags 2.9.1", "cfg-if", "libc", ] @@ -1234,6 +1477,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" + [[package]] name = "itoa" version = "1.0.15" @@ -1250,6 +1499,21 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonwebtoken" +version = "9.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +dependencies = [ + "base64", + "js-sys", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1372,6 +1636,18 @@ dependencies = [ "adler2", ] +[[package]] +name = "mio" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +dependencies = [ + "libc", + "log", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.48.0", +] + [[package]] name = "mio" version = "1.0.4" @@ -1400,6 +1676,15 @@ dependencies = [ "tempfile", ] +[[package]] +name = "newline-converter" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b6b097ecb1cbfed438542d16e84fd7ad9b0c76c8a65b7f9039212a3d14dc7f" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "num-bigint" version = "0.4.6" @@ -1478,13 +1763,19 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "once_cell_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" + [[package]] name = "openssl" version = "0.10.73" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8" dependencies = [ - "bitflags", + "bitflags 2.9.1", "cfg-if", "foreign-types", "libc", @@ -1584,12 +1875,33 @@ dependencies = [ "windows-targets 0.52.6", ] +[[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" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pem" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38af38e8470ac9dee3ce1bae1af9c1671fffc44ddfd8bd1d0a3445bf349a8ef3" +dependencies = [ + "base64", + "serde", +] + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -1901,7 +2213,7 @@ version = "0.5.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5407465600fb0548f1442edf71dd20683c6ed326200ace4b1ef0763521bb3b77" dependencies = [ - "bitflags", + "bitflags 2.9.1", ] [[package]] @@ -2119,7 +2431,7 @@ version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8" dependencies = [ - "bitflags", + "bitflags 2.9.1", "errno", "libc", "linux-raw-sys", @@ -2297,7 +2609,7 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags", + "bitflags 2.9.1", "core-foundation", "core-foundation-sys", "libc", @@ -2396,6 +2708,27 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34db1a06d485c9142248b7a054f034b349b212551f3dfd19c94d45a754a217cd" +dependencies = [ + "libc", + "mio 0.8.11", + "signal-hook", +] + [[package]] name = "signal-hook-registry" version = "1.4.5" @@ -2427,6 +2760,18 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +[[package]] +name = "simple_asn1" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror", + "time", +] + [[package]] name = "slab" version = "0.4.10" @@ -2582,7 +2927,7 @@ dependencies = [ "atoi", "base64", "bigdecimal", - "bitflags", + "bitflags 2.9.1", "byteorder", "bytes", "chrono", @@ -2629,7 +2974,7 @@ dependencies = [ "atoi", "base64", "bigdecimal", - "bitflags", + "bitflags 2.9.1", "byteorder", "chrono", "crc", @@ -2713,6 +3058,12 @@ dependencies = [ "unicode-properties", ] +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "strum" version = "0.26.3" @@ -2773,7 +3124,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" dependencies = [ - "bitflags", + "bitflags 2.9.1", "core-foundation", "system-configuration-sys", ] @@ -2836,6 +3187,15 @@ dependencies = [ "syn 2.0.104", ] +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + [[package]] name = "time" version = "0.3.41" @@ -2902,7 +3262,7 @@ dependencies = [ "bytes", "io-uring", "libc", - "mio", + "mio 1.0.4", "parking_lot", "pin-project-lite", "signal-hook-registry", @@ -3018,7 +3378,7 @@ version = "0.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" dependencies = [ - "bitflags", + "bitflags 2.9.1", "bytes", "futures-util", "http", @@ -3136,6 +3496,18 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e70f2a8b45122e719eb623c01822704c4e0907e7e426a05927e1a1cfff5b75d0" +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + [[package]] name = "untrusted" version = "0.9.0" @@ -3165,6 +3537,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "utoipa" version = "5.4.0" @@ -3414,6 +3792,22 @@ dependencies = [ "wasite", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + [[package]] name = "winapi-util" version = "0.1.9" @@ -3423,6 +3817,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-core" version = "0.61.2" @@ -3730,7 +4130,7 @@ version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" dependencies = [ - "bitflags", + "bitflags 2.9.1", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 0ac3985..2eee7ce 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ edition = "2024" [dependencies] axum = { version = "0.8.4", features = [ "macros", "ws", "tokio" ] } +axum-extra = { version = "0.10.1", features = ["typed-header"] } dotenvy = "0.15.7" reqwest = "0.12.22" sea-orm = { version = "1.1.13", features = [ "sqlx-sqlite", "runtime-tokio-rustls", "macros" ] } @@ -18,4 +19,10 @@ utoipa-redoc = { version = "6", features = ["axum"] } futures-util = "0.3.31" log = "0.4.27" pretty_env_logger = "0.5.0" +argon2 = { version = "0.5.3", features = ["password-hash", "alloc", "rand"] } +clap = { version = "4.5.42", features = ["derive"] } +inquire = "0.7.5" +rand_core = { version = "0.9.3", features = ["os_rng"] } +password-hash = { version = "0.5.0", features = ["getrandom"] } +jsonwebtoken = "9.3.1" diff --git a/scripts/generate_secret.sh b/scripts/generate_secret.sh new file mode 100755 index 0000000..e368fd8 --- /dev/null +++ b/scripts/generate_secret.sh @@ -0,0 +1 @@ +tr -dc A-Za-z0-9 bool { + let parsed_hash = PasswordHash::new(&self.hashed_password).unwrap(); + Argon2::default().verify_password(password.as_bytes(), &parsed_hash).is_ok() + } +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src/main.rs b/src/main.rs index 70d7976..f927c1f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,21 +1,40 @@ -use std::{net::SocketAddr, sync::Arc}; +use std::{net::SocketAddr, path::PathBuf, sync::{Arc, LazyLock}}; -use axum::{extract::State, http::HeaderMap, routing::get}; +use axum::{extract::State, http::HeaderMap, middleware, routing::get}; +use clap::{Parser, Subcommand}; use reqwest::{header::USER_AGENT}; use sea_orm::{ConnectionTrait, Database, DatabaseConnection, EntityTrait, PaginatorTrait, Schema}; use tokio::{sync::broadcast::{self, Sender}}; -use utoipa::openapi::{ContactBuilder, InfoBuilder, LicenseBuilder}; +use utoipa::{openapi::{security::{HttpAuthScheme, HttpBuilder, SecurityScheme}, ContactBuilder, InfoBuilder, LicenseBuilder}, Modify, OpenApi}; use utoipa_axum::router::OpenApiRouter; use utoipa_redoc::{Redoc, Servable}; use utoipa_swagger_ui::{Config, SwaggerUi}; use utoipa_axum::routes; -use crate::{entities::prelude::BookInstance, utils::events::Event}; +use crate::{entities::prelude::BookInstance, routes::auth::Keys, utils::events::Event}; pub mod entities; pub mod utils; pub mod routes; +#[derive(Parser)] +#[command(name = "Alexandria")] +#[command(version = "1.0")] +#[command(about = "BAL management server", long_about = None)] +struct Cli { + #[arg(long, short, value_name = "FILE")] + database: Option, + + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand)] +enum Commands { + Run, + User +} + pub struct AppState { app_name: String, db_conn: Arc, @@ -36,11 +55,40 @@ async fn index( format!("Hello from {app_name}! Database is {status}. We currently have {book_count} books in stock !") } +static KEYS: LazyLock = LazyLock::new(|| { + let secret = std::env::var("JWT_SECRET").expect("JWT_SECRET must be set"); + Keys::new(secret.as_bytes()) +}); + #[tokio::main] async fn main() { pretty_env_logger::init(); - let db: Arc = Arc::new(Database::connect(String::from("sqlite://./alexandria.db?mode=rwc")).await.unwrap()); + let cli = Cli::parse(); + + let db_path = match cli.database { + Some(path) => { + if path.is_dir() { + log::error!("{path:?} is a directory"); + return; + } + if let None = path.parent() { + log::error!("Invalid path: {path:?}"); + return; + } + path.to_string_lossy().into_owned() + }, + None => "./alexandria.db".to_owned() + }; + + let db: Arc = Arc::new( + match Database::connect(format!("sqlite://{db_path}?mode=rwc")).await { + Ok(c) => c, + Err(e) => { + log::error!(target: "database", "Error while opening database: {}", e.to_string()); + return; + } + }); let builder = db.get_database_backend(); let schema = Schema::new(builder); @@ -56,9 +104,25 @@ async fn main() { log::error!(target: "database", "Error while creating owner table: {err:?}"); return; } + if let Err(err) = db.execute(builder.build(schema.create_table_from_entity(crate::entities::prelude::User).if_not_exists())).await { + log::error!(target: "database", "Error while creating user table: {err:?}"); + return; + } + match cli.command { + Commands::Run => run_server(db).await, + Commands::User => utils::cli::manage_users(db).await + } +} + +async fn run_server(db: Arc) { let (event_bus, _) = broadcast::channel(16); + if std::env::var("JWT_SECRET").is_err() { + log::error!("JWT_SECRET is not set"); + return; + } + let mut default_headers = HeaderMap::new(); default_headers.append(USER_AGENT, "Alexandria/1.0 (unionetudianteauvergne@gmail.com)".parse().unwrap()); let shared_state = Arc::new(AppState { @@ -68,6 +132,30 @@ async fn main() { web_client: reqwest::Client::builder().default_headers(default_headers).build().expect("creating the reqwest client failed") }); + + #[derive(OpenApi)] + #[openapi( + tags( + (name = "book-api", description = "Book management endpoints.") + ), + modifiers(&SecurityAddon) + )] + struct ApiDoc; + + struct SecurityAddon; + + impl Modify for SecurityAddon { + fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) { + let components = openapi.components.as_mut().unwrap(); + components.add_security_scheme( + "jwt", + SecurityScheme::Http( + HttpBuilder::new().scheme(HttpAuthScheme::Bearer).bearer_format("JWT").build() + ) + ) + } + } + let (router, mut api) = OpenApiRouter::new() // Book API .routes(routes!(routes::book::get_book_by_ean)) @@ -85,6 +173,10 @@ async fn main() { .routes(routes!(routes::owner::get_owners)) // Misc .routes(routes!(routes::websocket::ws_handler)) + // Authentication + .route_layer(middleware::from_fn_with_state(shared_state.clone(), routes::auth::auth_middleware)) + .routes(routes!(routes::auth::auth)) + .route("/", get(index)) .with_state(shared_state) .split_for_parts(); @@ -101,6 +193,8 @@ async fn main() { .version("1.0.0") .build(); + api.merge(ApiDoc::openapi()); + let redoc = Redoc::with_url("/docs/", api.clone()); let swagger = SwaggerUi::new("/docs2/") .url("/docs2/openapi.json", api) @@ -118,4 +212,3 @@ async fn main() { router.into_make_service_with_connect_info::() ).await.unwrap(); } - diff --git a/src/routes/auth.rs b/src/routes/auth.rs new file mode 100644 index 0000000..9bf3841 --- /dev/null +++ b/src/routes/auth.rs @@ -0,0 +1,144 @@ +use std::sync::{Arc, LazyLock}; + +use argon2::PasswordHash; +use axum::{ + extract::{FromRequestParts, Request, State}, http::{request::Parts, HeaderMap, StatusCode}, middleware::Next, response::{IntoResponse, Response}, routing::{get, post}, Json, RequestPartsExt, Router +}; +use axum_extra::{ + headers::{authorization::Bearer, Authorization}, + TypedHeader, +}; +use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation}; +use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; +use serde::{Deserialize, Serialize}; +use serde_json::json; + +use crate::{entities::user, AppState, KEYS}; + +pub async fn auth_middleware( + _claims: Claims, + request: Request, + next: Next, +) -> Result { + let response = next.run(request).await; + Ok(response) +} + +#[axum::debug_handler] +#[utoipa::path( + post, + path = "/auth", + request_body = AuthPayload, + responses( + (status = OK, body = AuthBody, description = "Successfully authenticated"), + (status = UNAUTHORIZED, description = "Wrong credentials"), + (status = BAD_REQUEST, description = "Missing credentials"), + (status = INTERNAL_SERVER_ERROR, description = "Token creation error"), + (status = BAD_REQUEST, description = "Invalid token") + ), + summary = "Authenticate to access the API", + tag = "auth-api", +)] +pub async fn auth(State(state): State>, Json(payload): Json) -> Result, AuthError> { + log::debug!("Payload: {payload:?}"); + if payload.username.is_empty() || payload.password.is_empty() { + return Err(AuthError::MissingCredentials); + } + + match user::Entity::find().filter(user::Column::Username.eq(payload.username)).one(state.db_conn.as_ref()).await { + Err(_) | Ok(None) => return Err(AuthError::WrongCredentials), + Ok(Some(user)) => { + user.verify_password(payload.password); + + let claims = Claims { + sub: user.username, + exp: 2000000000, + }; + let token = encode(&Header::default(), &claims, &KEYS.encoding) + .map_err(|_| AuthError::TokenCreation)?; + + Ok(Json(AuthBody::new(token))) + } + } +} + +impl AuthBody { + fn new(access_token: String) -> Self { + Self { + access_token, + token_type: "Bearer".to_string(), + } + } +} + +impl FromRequestParts for Claims +where + S: Send + Sync, +{ + type Rejection = AuthError; + + async fn from_request_parts(parts: &mut Parts, _s: &S) -> Result { + let TypedHeader(Authorization(bearer)) = parts + .extract::>>() + .await + .map_err(|_| AuthError::InvalidToken)?; + let token_data = decode::(bearer.token(), &KEYS.decoding, &Validation::default()) + .map_err(|_| AuthError::InvalidToken)?; + + Ok(token_data.claims) + } +} + +impl IntoResponse for AuthError { + fn into_response(self) -> Response { + let (status, error_message) = match self { + AuthError::WrongCredentials => (StatusCode::UNAUTHORIZED, "Wrong credentials"), + AuthError::MissingCredentials => (StatusCode::BAD_REQUEST, "Missing credentials"), + AuthError::TokenCreation => (StatusCode::INTERNAL_SERVER_ERROR, "Token creation error"), + AuthError::InvalidToken => (StatusCode::BAD_REQUEST, "Invalid token"), + }; + let body = Json(json!({ + "error": error_message, + })); + (status, body).into_response() + } +} +pub struct Keys { + encoding: EncodingKey, + decoding: DecodingKey, +} + +impl Keys { + pub fn new(secret: &[u8]) -> Self { + Self { + encoding: EncodingKey::from_secret(secret), + decoding: DecodingKey::from_secret(secret), + } + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Claims { + sub: String, + exp: usize, +} + +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct AuthBody { + access_token: String, + token_type: String, +} + +#[derive(Debug, Deserialize, utoipa::ToSchema)] +pub struct AuthPayload { + username: String, + password: String, +} + +#[derive(Debug)] +pub enum AuthError { + WrongCredentials, + MissingCredentials, + TokenCreation, + InvalidToken, +} diff --git a/src/routes/book.rs b/src/routes/book.rs index 2314e0e..6b350c6 100644 --- a/src/routes/book.rs +++ b/src/routes/book.rs @@ -20,6 +20,7 @@ struct BookByIdParams(u32); get, path = "/book/id/{id}", params(BookByIdParams), + security(("jwt" = [])), responses( (status = OK, body = book::Model, description = "Found book with corresponding ID in the database", examples( ("Found regular book" = (value = json!({"author": "Pierre Bottero", "ean": "9782700234015", "id": 5642, "title": "Ellana l'envol"}))), @@ -52,6 +53,7 @@ struct BookByEanParams(String); get, path = "/book/ean/{ean}", params(BookByEanParams), + security(("jwt" = [])), responses( (status = OK, body = book::Model, description = "Found book with corresponding EAN", examples( ("Found regular book" = (value = json!({"author": "Pierre Bottero", "ean": "9782700234015", "id": 5642, "title": "Ellana l'envol"}))) diff --git a/src/routes/book_instance.rs b/src/routes/book_instance.rs index 9eb8bea..8cc2ab3 100644 --- a/src/routes/book_instance.rs +++ b/src/routes/book_instance.rs @@ -19,6 +19,7 @@ struct BookInstanceByIdParams(u32); get, path = "/book_instance/{id}", params(BookInstanceByIdParams), + security(("jwt" = [])), responses( (status = OK, body = book_instance::Model, description = "Found book instance with corresponding ID in the database"), (status = NOT_FOUND, description = "No book instance with this id exists in the database") @@ -50,6 +51,7 @@ pub struct BookInstanceCreateParams { post, path = "/book_instance", request_body = BookInstanceCreateParams, + security(("jwt" = [])), responses( (status = OK, body = book_instance::Model, description = "Successfully created book instance"), ), @@ -93,6 +95,7 @@ pub struct BookInstanceUpdateParams { path = "/book_instance/{id}", params(BookInstanceByIdParams), request_body = BookInstanceUpdateParams, + security(("jwt" = [])), responses( (status = OK, body = book_instance::Model, description = "Successfully updated book instance"), (status = NOT_FOUND, description = "No book instance with specified id was found"), @@ -148,6 +151,7 @@ pub struct BookInstanceSaleParams { path = "/book_instance/{id}/sell", params(BookInstanceByIdParams), request_body = BookInstanceSaleParams, + security(("jwt" = [])), responses( (status = OK, body = book_instance::Model, description = "Successfully sold book instance"), (status = NOT_FOUND, description = "No book instance with specified id was found"), @@ -186,6 +190,7 @@ pub async fn sell_book_instance( post, path = "/book_instance/bulk", request_body = Vec, + security(("jwt" = [])), responses( (status = OK, description = "Successfully created book instances"), ), diff --git a/src/routes/mod.rs b/src/routes/mod.rs index d7e6167..34b9290 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -1,3 +1,4 @@ +pub mod auth; pub mod book; pub mod book_instance; pub mod owner; diff --git a/src/routes/owner.rs b/src/routes/owner.rs index 0ba5c6c..b067746 100644 --- a/src/routes/owner.rs +++ b/src/routes/owner.rs @@ -19,6 +19,7 @@ struct OwnerByIdParams(u32); get, path = "/owner/{id}", params(OwnerByIdParams), + security(("jwt" = [])), responses( (status = OK, body = owner::Model, description = "Found owner with corresponding ID in the database"), (status = NOT_FOUND, description = "No owner with this id exists in the database") @@ -50,6 +51,7 @@ pub struct OwnerCreateParams { post, path = "/owner", request_body = OwnerCreateParams, + security(("jwt" = [])), responses( (status = OK, body = owner::Model, description = "Successfully created owner"), ), @@ -96,6 +98,7 @@ pub struct OwnerUpdateParams { path = "/owner/{id}", params(OwnerByIdParams), request_body = OwnerUpdateParams, + security(("jwt" = [])), responses( (status = OK, body = owner::Model, description = "Successfully updated owner"), (status = NOT_FOUND, description = "No owner with this id exists in the database") @@ -145,6 +148,7 @@ pub async fn update_owner( #[utoipa::path( get, path = "/owners", + security(("jwt" = [])), responses( (status = OK, body = Vec, description = "List of owners"), ), diff --git a/src/routes/websocket.rs b/src/routes/websocket.rs index 264697a..ee14c99 100644 --- a/src/routes/websocket.rs +++ b/src/routes/websocket.rs @@ -16,6 +16,7 @@ use futures_util::{sink::SinkExt, stream::StreamExt}; #[utoipa::path( get, path = "/ws", + security(("jwt" = [])), responses( (status = SWITCHING_PROTOCOLS, description = "Succesfully reached the websocket, now upgrade to establish the connection"), ), diff --git a/src/utils/cli.rs b/src/utils/cli.rs new file mode 100644 index 0000000..a61101d --- /dev/null +++ b/src/utils/cli.rs @@ -0,0 +1,147 @@ +use std::{fmt::Display, sync::Arc}; + +use argon2::{password_hash::{SaltString}, Argon2, PasswordHasher}; +use inquire::{min_length, prompt_text, Confirm, Password, Select, Text}; +use password_hash::rand_core::OsRng; +use sea_orm::{ActiveModelTrait, ActiveValue::{NotSet, Set}, ColumnTrait, DatabaseConnection, EntityTrait, ModelTrait, QueryFilter}; + +use crate::entities::{prelude::User, user::{self, ActiveModel}}; + +#[derive(Debug, Copy, Clone)] +enum Action { + Add, + Delete, + Update +} + +impl Action { + const VARIANTS: &'static [Action] = &[ + Self::Add, + Self::Delete, + Self::Update, + ]; +} + +impl Display for Action { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{self:?}") + } +} + +#[derive(Debug, Copy, Clone)] +enum Update { + Username, + Password +} + +impl Update { + const VARIANTS: &'static [Update] = &[ + Self::Username, + Self::Password, + ]; +} + +impl Display for Update { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{self:?}") + } +} + +pub async fn manage_users(db: Arc) { + loop { + match Select::new("User Manager (ESC to quit)", Action::VARIANTS.to_vec()).prompt_skippable() { + Ok(Some(action)) => { + match action { + Action::Add => { + let username = Text::new("Username").with_validator(min_length!(3)).prompt().unwrap(); + if User::find().filter(user::Column::Username.eq(username.clone())).one(db.as_ref()).await.is_ok_and(|r| r.is_some()) { + println!("Username already in use !"); + } else { + let password = Password::new("Password") + .with_validator(min_length!(10)) + .with_display_mode(inquire::PasswordDisplayMode::Masked) + .prompt().unwrap(); + let new_user = user::ActiveModel { + id: NotSet, + username: Set(username), + hashed_password: Set(hash_password(password)) + }; + let _ = new_user.insert(db.as_ref()).await; + } + }, + Action::Delete => { + match select_user(db.clone()).await { + Some(user) => { + let username = user.username.clone(); + + match Confirm::new(format!("Delete {username} ?").as_ref()) + .with_default(false) + .with_help_message("The user and all associated data will *permanently* be deleted") + .prompt() { + Ok(true) => { + let _ = user.delete(db.as_ref()).await; + println!("{username} has been permanently deleted") + }, + Ok(false) | Err(_) => println!("Cancelled deletion of {username}"), + } + }, + None => println!("Could not find user") + } + }, + Action::Update => { + match select_user(db.clone()).await { + Some(user) => { + match Select::new(format!("Editing {}", user.username).as_ref(), Update::VARIANTS.to_vec()).prompt() { + Ok(v) => { + let mut updated_user = ActiveModel::from(user); + match v { + Update::Username => { + updated_user.username = Set(Text::new("New username") + .with_initial_value(updated_user.username.unwrap().as_ref()) + .prompt().unwrap() + ) + }, + Update::Password => { + match Password::new("Password") + .with_validator(min_length!(10)) + .with_display_mode(inquire::PasswordDisplayMode::Masked) + .prompt() { + Ok(new_password) => updated_user.hashed_password = Set(hash_password(new_password)), + Err(_) => println!("Cancelled user update") + } + + } + } + let _ = updated_user.save(db.as_ref()).await; + }, + Err(_) => {} + } + }, + None => println!("Could not find user") + } + } + } + }, + Ok(None) | Err(_) => { + break; + } + } + } +} + +fn hash_password(password: String) -> String { + let salt = SaltString::generate(&mut OsRng); + Argon2::default().hash_password(&password.clone().into_bytes(), &salt).unwrap().to_string() +} + +async fn select_user(db: Arc) -> Option { + let users = User::find().all(db.as_ref()).await.unwrap(); + if users.is_empty() { + return None; + } + match Select::new("Select a user", users.iter().map(|u| u.username.clone()).collect()).prompt() { + Ok(selection) => User::find().filter(user::Column::Username.eq(selection)).one(db.as_ref()).await.unwrap(), + Err(_) => None + } +} + diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 82c29d2..3733d86 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -1,4 +1,5 @@ +pub mod cli; +pub mod events; pub mod open_library; pub mod serde; -pub mod events;