feat: authentication system

This commit is contained in:
Ninjdai 2025-08-03 01:50:18 +02:00
parent d8c29e1ec8
commit 37153c6e36
15 changed files with 852 additions and 18 deletions

422
Cargo.lock generated
View file

@ -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]]

View file

@ -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"

1
scripts/generate_secret.sh Executable file
View file

@ -0,0 +1 @@
tr -dc A-Za-z0-9 </dev/urandom | head -c 256; echo

View file

@ -3,4 +3,5 @@ pub mod prelude;
pub mod book;
pub mod book_instance;
pub mod owner;
pub mod user;

View file

@ -2,3 +2,4 @@
pub use super::book::Entity as Book;
pub use super::book_instance::Entity as BookInstance;
pub use super::owner::Entity as Owner;
pub use super::user::Entity as User;

26
src/entities/user.rs Normal file
View file

@ -0,0 +1,26 @@
use argon2::{Argon2, PasswordHash, PasswordVerifier};
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
#[sea_orm(table_name = "User")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = true)]
pub id: u32,
#[sea_orm(unique)]
pub username: String,
pub hashed_password: String,
}
impl Model {
pub fn verify_password(&self, password: String) -> 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 {}

View file

@ -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<PathBuf>,
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
Run,
User
}
pub struct AppState {
app_name: String,
db_conn: Arc<DatabaseConnection>,
@ -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<Keys> = 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<DatabaseConnection> = 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<DatabaseConnection> = 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<DatabaseConnection>) {
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::<SocketAddr>()
).await.unwrap();
}

144
src/routes/auth.rs Normal file
View file

@ -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<Response, StatusCode> {
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<Arc<AppState>>, Json(payload): Json<AuthPayload>) -> Result<Json<AuthBody>, 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<S> FromRequestParts<S> for Claims
where
S: Send + Sync,
{
type Rejection = AuthError;
async fn from_request_parts(parts: &mut Parts, _s: &S) -> Result<Self, Self::Rejection> {
let TypedHeader(Authorization(bearer)) = parts
.extract::<TypedHeader<Authorization<Bearer>>>()
.await
.map_err(|_| AuthError::InvalidToken)?;
let token_data = decode::<Claims>(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,
}

View file

@ -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"})))

View file

@ -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<BookInstanceCreateParams>,
security(("jwt" = [])),
responses(
(status = OK, description = "Successfully created book instances"),
),

View file

@ -1,3 +1,4 @@
pub mod auth;
pub mod book;
pub mod book_instance;
pub mod owner;

View file

@ -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<owner::Model>, description = "List of owners"),
),

View file

@ -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"),
),

147
src/utils/cli.rs Normal file
View file

@ -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<DatabaseConnection>) {
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<DatabaseConnection>) -> Option<user::Model> {
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
}
}

View file

@ -1,4 +1,5 @@
pub mod cli;
pub mod events;
pub mod open_library;
pub mod serde;
pub mod events;