From 5d64a3137aebda6eaf20b7dfbcad175c80c05cfa Mon Sep 17 00:00:00 2001 From: Butter Date: Mon, 20 Apr 2026 09:37:09 -0400 Subject: [PATCH] add auth, FLCL reference but I don't like sour drinks --- .env | 1 + Cargo.lock | 421 ++++++++++++++++++++++++++++++++++- Cargo.toml | 8 +- src/handlers/auth.rs | 172 ++++++++++++++ src/handlers/mod.rs | 1 + src/main.rs | 52 ++++- src/models/mod.rs | 1 + src/models/user.rs | 70 ++++++ static/css/style.css | 49 +++- templates/auth/login.html | 31 +++ templates/auth/register.html | 35 +++ templates/base.html | 2 +- 12 files changed, 832 insertions(+), 11 deletions(-) create mode 100644 src/handlers/auth.rs create mode 100644 src/handlers/mod.rs create mode 100644 src/models/mod.rs create mode 100644 src/models/user.rs create mode 100644 templates/auth/login.html create mode 100644 templates/auth/register.html diff --git a/.env b/.env index 3ea5410..d530d62 100644 --- a/.env +++ b/.env @@ -1 +1,2 @@ DATABASE_URL=sqlite:./sarmentine.db +SESSION_SECRET=gzC0+m1ol9sPHaZpEU/AwIQVA9STkmxrWK6HhCI7ovlNdVzQqR0sg8VHQCRxURQFmVNEiQvJ8cx7G7nNd3b4jw diff --git a/Cargo.lock b/Cargo.lock index 95a677a..007d520 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -21,12 +21,33 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anyhow" version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[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 = "askama" version = "0.12.1" @@ -175,6 +196,12 @@ version = "0.21.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "base64ct" version = "1.8.3" @@ -199,6 +226,15 @@ dependencies = [ "serde_core", ] +[[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" @@ -208,6 +244,12 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + [[package]] name = "byteorder" version = "1.5.0" @@ -236,12 +278,48 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + [[package]] name = "const-oid" version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "base64 0.22.1", + "hmac", + "percent-encoding", + "rand", + "sha2", + "subtle", + "time", + "version_check", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -302,6 +380,16 @@ dependencies = [ "zeroize", ] +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", + "serde_core", +] + [[package]] name = "digest" version = "0.10.7" @@ -411,6 +499,20 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.32" @@ -455,6 +557,17 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "futures-sink" version = "0.3.32" @@ -475,6 +588,7 @@ checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-core", "futures-io", + "futures-macro", "futures-sink", "futures-task", "memchr", @@ -705,6 +819,30 @@ dependencies = [ "tower-service", ] +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "2.2.0" @@ -832,6 +970,16 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "js-sys" +version = "0.3.94" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -901,6 +1049,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" dependencies = [ "scopeguard", + "serde", ] [[package]] @@ -990,6 +1139,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "num-conv" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" + [[package]] name = "num-integer" version = "0.1.46" @@ -1049,6 +1204,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core", + "subtle", +] + [[package]] name = "paste" version = "1.0.15" @@ -1118,6 +1284,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1215,6 +1387,25 @@ dependencies = [ "bitflags", ] +[[package]] +name = "rmp" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "rmp-serde" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" +dependencies = [ + "rmp", + "serde", +] + [[package]] name = "rsa" version = "0.9.10" @@ -1265,13 +1456,19 @@ name = "sarmentine" version = "0.1.0" dependencies = [ "anyhow", + "argon2", "askama", "askama_axum", "axum", + "chrono", "dotenvy", + "rand_core", + "serde", "sqlx", "tokio", "tower-http", + "tower-sessions", + "tower-sessions-sqlx-store", ] [[package]] @@ -1474,6 +1671,7 @@ dependencies = [ "atoi", "byteorder", "bytes", + "chrono", "crc", "crossbeam-queue", "either", @@ -1497,6 +1695,7 @@ dependencies = [ "smallvec", "sqlformat", "thiserror", + "time", "tokio", "tokio-stream", "tracing", @@ -1534,6 +1733,7 @@ dependencies = [ "sha2", "sqlx-core", "sqlx-mysql", + "sqlx-postgres", "sqlx-sqlite", "syn 1.0.109", "tempfile", @@ -1548,10 +1748,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ed31390216d20e538e447a7a9b959e06ed9fc51c37b514b46eb758016ecd418" dependencies = [ "atoi", - "base64", + "base64 0.21.7", "bitflags", "byteorder", "bytes", + "chrono", "crc", "digest", "dotenvy", @@ -1579,6 +1780,7 @@ dependencies = [ "sqlx-core", "stringprep", "thiserror", + "time", "tracing", "whoami", ] @@ -1590,9 +1792,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c824eb80b894f926f89a0b9da0c7f435d27cdd35b8c655b114e58223918577e" dependencies = [ "atoi", - "base64", + "base64 0.21.7", "bitflags", "byteorder", + "chrono", "crc", "dotenvy", "etcetera", @@ -1617,6 +1820,7 @@ dependencies = [ "sqlx-core", "stringprep", "thiserror", + "time", "tracing", "whoami", ] @@ -1628,6 +1832,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b244ef0a8414da0bed4bb1910426e890b19e5e9bccc27ada6b797d05c55ae0aa" dependencies = [ "atoi", + "chrono", "flume", "futures-channel", "futures-core", @@ -1639,6 +1844,7 @@ dependencies = [ "percent-encoding", "serde", "sqlx-core", + "time", "tracing", "url", "urlencoding", @@ -1739,6 +1945,37 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -1832,6 +2069,23 @@ dependencies = [ "tracing", ] +[[package]] +name = "tower-cookies" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fd0118512cf0b3768f7fcccf0bef1ae41d68f2b45edc1e77432b36c97c56c6d" +dependencies = [ + "async-trait", + "axum-core", + "cookie", + "futures-util", + "http", + "parking_lot", + "pin-project-lite", + "tower-layer", + "tower-service", +] + [[package]] name = "tower-http" version = "0.5.2" @@ -1869,6 +2123,71 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" +[[package]] +name = "tower-sessions" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50571505955aaa8b73f2f40489953d92b4d7ff9eb9b2a8b4e11fee0dcdb2760e" +dependencies = [ + "async-trait", + "http", + "time", + "tokio", + "tower-cookies", + "tower-layer", + "tower-service", + "tower-sessions-core", + "tower-sessions-memory-store", + "tracing", +] + +[[package]] +name = "tower-sessions-core" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6293bf33f1977d5ef422c2e02f909eb2c3d7bf921d93557c40d4f1b130b84aa4" +dependencies = [ + "async-trait", + "axum-core", + "base64 0.22.1", + "futures", + "http", + "parking_lot", + "rand", + "serde", + "serde_json", + "thiserror", + "time", + "tokio", + "tracing", +] + +[[package]] +name = "tower-sessions-memory-store" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cec5f88eeef0f036e6900217034efbce733cbdf0528a85204eaaed90bc34c354" +dependencies = [ + "async-trait", + "time", + "tokio", + "tower-sessions-core", +] + +[[package]] +name = "tower-sessions-sqlx-store" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9442116d8ec67af57e2213f5b4007b6bb55d74c19eae429cd6525b7527844807" +dependencies = [ + "async-trait", + "rmp-serde", + "sqlx", + "thiserror", + "time", + "tower-sessions-core", +] + [[package]] name = "tracing" version = "0.1.44" @@ -2023,6 +2342,51 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" +[[package]] +name = "wasm-bindgen" +version = "0.2.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b" +dependencies = [ + "unicode-ident", +] + [[package]] name = "wasm-encoder" version = "0.244.0" @@ -2067,12 +2431,65 @@ dependencies = [ "wasite", ] +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.48.0" diff --git a/Cargo.toml b/Cargo.toml index 21b64ab..5a988b6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,13 @@ tokio = { version = "1", features = ["full"] } askama = { version = "0.12", features = ["with-axum"] } askama_axum = "0.4" tower-http = { version = "0.5", features = ["fs"] } -sqlx = { version = "0.7", features = ["runtime-tokio", "sqlite", "migrate"] } +sqlx = { version = "0.7", features = ["runtime-tokio","chrono", "sqlite", "migrate"] } dotenvy = "0.15" +argon2 = "0.5" +rand_core = { version = "0.6", features = ["std"] } +tower-sessions = { version = "0.12", features = ["signed"] } +tower-sessions-sqlx-store = { version = "0.12", features = ["sqlite"] } +serde = { version = "1", features = ["derive"] } +chrono = { version = "0.4", features = ["serde"] } # Error handling anyhow = "1" diff --git a/src/handlers/auth.rs b/src/handlers/auth.rs new file mode 100644 index 0000000..2dca072 --- /dev/null +++ b/src/handlers/auth.rs @@ -0,0 +1,172 @@ +use argon2::{ + password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, + Argon2, +}; +use askama::Template; +use axum::{ + extract::{Form, State}, + response::{Html, IntoResponse, Redirect, Response}, +}; +use serde::Deserialize; +use sqlx::SqlitePool; +use tower_sessions::Session; + +use crate::models::user::User; + +const SESSION_USER_ID_KEY: &str = "user_id"; + +// ~~ Templates + +#[derive(Template)] +#[template(path = "auth/register.html")] +pub struct RegisterTemplate { + pub title: String, + pub current_user: Option, + pub error: Option, +} + +#[derive(Template)] +#[template(path = "auth/login.html")] +pub struct LoginTemplate { + pub title: String, + pub current_user: Option, + pub error: Option, +} + +fn render(t: T) -> Response { + match t.render() { + Ok(html) => Html(html).into_response(), + Err(_) => (axum::http::StatusCode::INTERNAL_SERVER_ERROR, "render error").into_response(), + } +} + +// ~~ Form types + +#[derive(Deserialize)] +pub struct RegisterForm { + pub username: String, + pub email: String, + pub password: String, +} + +#[derive(Deserialize)] +pub struct LoginForm { + pub username: String, + pub password: String, +} + +// ~~ Handlers + +pub async fn register_page() -> Response { + render(RegisterTemplate { + title: "register".into(), + current_user: None, + error: None, + }) +} + +pub async fn register_submit( + State(pool): State, + session: Session, + Form(form): Form, +) -> Response { + macro_rules! register_err { + ($msg:expr) => { + return render(RegisterTemplate { + title: "register".into(), + current_user: None, + error: Some($msg.into()), + }) + }; + } + + if form.username.len() < 2 || form.username.len() > 32 { + register_err!("username must be between 2 and 32 characters"); + } + if form.password.len() < 8 { + register_err!("password must be at least 8 characters"); + } + + let existing_user: Option = User::find_by_username(&pool, &form.username) + .await + .unwrap_or(None); + if existing_user.is_some() { + register_err!("that username is already taken"); + } + + let existing_email: Option = User::find_by_email(&pool, &form.email) + .await + .unwrap_or(None); + if existing_email.is_some() { + register_err!("an account with that email already exists"); + } + + let salt = SaltString::generate(&mut OsRng); + let hash = Argon2::default() + .hash_password(form.password.as_bytes(), &salt) + .expect("password hashing failed") + .to_string(); + + let user_id: i64 = User::insert(&pool, &form.username, &form.email, &hash) + .await + .expect("failed to insert user"); + + session.insert(SESSION_USER_ID_KEY, user_id).await.unwrap(); + + Redirect::to("/").into_response() +} + +pub async fn login_page() -> Response { + render(LoginTemplate { + title: "login".into(), + current_user: None, + error: None, + }) +} + +pub async fn login_submit( + State(pool): State, + session: Session, + Form(form): Form, +) -> Response { + let fail = || { + render(LoginTemplate { + title: "login".into(), + current_user: None, + error: Some("incorrect username or password".into()), + }) + }; + + let user: Option = User::find_by_username(&pool, &form.username) + .await + .unwrap_or(None); + + let user = match user { + Some(u) => u, + None => return fail(), + }; + + let parsed_hash = PasswordHash::new(&user.password).expect("invalid stored hash"); + if Argon2::default() + .verify_password(form.password.as_bytes(), &parsed_hash) + .is_err() + { + return fail(); + } + + session.insert(SESSION_USER_ID_KEY, user.id).await.unwrap(); + + Redirect::to("/").into_response() +} + +pub async fn logout(session: Session) -> Response { + session.flush().await.unwrap(); + Redirect::to("/auth/login").into_response() +} + +// ~~ Session helper + +pub async fn get_current_user(session: &Session, pool: &SqlitePool) -> Option { + let user_id: i64 = session.get(SESSION_USER_ID_KEY).await.ok()??; + User::find_by_id(pool, user_id).await.unwrap_or(None) +} diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs new file mode 100644 index 0000000..0e4a05d --- /dev/null +++ b/src/handlers/mod.rs @@ -0,0 +1 @@ +pub mod auth; diff --git a/src/main.rs b/src/main.rs index 555043d..d86b8c8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,10 +1,22 @@ mod db; +mod handlers; +mod models; use askama::Template; -use askama_axum::IntoResponse; -use axum::{routing::get, Router}; +use axum::{ + extract::State, + response::{Html, IntoResponse, Response}, + routing::{get, post}, + Router, +}; use sqlx::SqlitePool; use tower_http::services::ServeDir; +use tower_sessions::{cookie::SameSite, SessionManagerLayer}; +use tower_sessions_sqlx_store::SqliteStore; + +use handlers::auth::{ + get_current_user, login_page, login_submit, logout, register_page, register_submit, +}; pub struct CurrentUser { pub username: String, @@ -17,10 +29,21 @@ struct IndexTemplate { current_user: Option, } -async fn index() -> impl IntoResponse { - IndexTemplate { +async fn index( + State(pool): State, + session: tower_sessions::Session, +) -> Response { + let user: Option = get_current_user(&session, &pool).await; + let current_user = user.map(|u| CurrentUser { username: u.username }); + + let tmpl = IndexTemplate { title: "home".into(), - current_user: None, + current_user, + }; + + match tmpl.render() { + Ok(html) => Html(html).into_response(), + Err(_) => (axum::http::StatusCode::INTERNAL_SERVER_ERROR, "render error").into_response(), } } @@ -33,9 +56,28 @@ async fn main() { let pool: SqlitePool = db::connect(&database_url).await; + let session_store = SqliteStore::new(pool.clone()); + session_store.migrate().await.expect("session store migration failed"); + + let session_secret = std::env::var("SESSION_SECRET") + .unwrap_or_else(|_| "change-me-in-production-use-a-long-random-string!!".into()); + + let session_layer = SessionManagerLayer::new(session_store) + .with_secure(false) + .with_same_site(SameSite::Lax) + .with_signed(tower_sessions::cookie::Key::from( + session_secret.as_bytes(), + )); + let app = Router::new() .route("/", get(index)) + .route("/auth/register", get(register_page)) + .route("/auth/register", post(register_submit)) + .route("/auth/login", get(login_page)) + .route("/auth/login", post(login_submit)) + .route("/auth/logout", post(logout)) .nest_service("/static", ServeDir::new("static")) + .layer(session_layer) .with_state(pool); let listener = tokio::net::TcpListener::bind("127.0.0.1:3000") diff --git a/src/models/mod.rs b/src/models/mod.rs new file mode 100644 index 0000000..22d12a3 --- /dev/null +++ b/src/models/mod.rs @@ -0,0 +1 @@ +pub mod user; diff --git a/src/models/user.rs b/src/models/user.rs new file mode 100644 index 0000000..53e4dbf --- /dev/null +++ b/src/models/user.rs @@ -0,0 +1,70 @@ +use chrono::NaiveDateTime; +use serde::{Deserialize, Serialize}; +use sqlx::{FromRow, SqlitePool}; + +#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] +pub struct User { + pub id: i64, + pub username: String, + pub email: String, + #[serde(skip_serializing)] + pub password: String, + pub bio: Option, + pub avatar_url: Option, + pub role: String, + pub created_at: NaiveDateTime, +} + +impl User { + pub async fn find_by_id(pool: &SqlitePool, id: i64) -> sqlx::Result> { + sqlx::query_as( + "SELECT id, username, email, password, bio, avatar_url, role, created_at + FROM users WHERE id = ?" + ) + .bind(id) + .fetch_optional(pool) + .await + } + + pub async fn find_by_username(pool: &SqlitePool, username: &str) -> sqlx::Result> { + sqlx::query_as( + "SELECT id, username, email, password, bio, avatar_url, role, created_at + FROM users WHERE username = ?" + ) + .bind(username) + .fetch_optional(pool) + .await + } + + pub async fn find_by_email(pool: &SqlitePool, email: &str) -> sqlx::Result> { + sqlx::query_as( + "SELECT id, username, email, password, bio, avatar_url, role, created_at + FROM users WHERE email = ?" + ) + .bind(email) + .fetch_optional(pool) + .await + } + + pub async fn insert( + pool: &SqlitePool, + username: &str, + email: &str, + password_hash: &str, + ) -> sqlx::Result { + let result = sqlx::query( + "INSERT INTO users (username, email, password) VALUES (?, ?, ?)" + ) + .bind(username) + .bind(email) + .bind(password_hash) + .execute(pool) + .await?; + + Ok(result.last_insert_rowid()) + } + + pub fn is_admin(&self) -> bool { + self.role == "admin" + } +} diff --git a/static/css/style.css b/static/css/style.css index ecc851d..3e7f394 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -254,7 +254,7 @@ pre { height: calc(31px * 2); } -/* nav right (login/logout) */ +/* ── nav right (login/logout) ───────────────────────────── */ .nav { justify-content: flex-start; } @@ -285,7 +285,7 @@ pre { background: #d4879c; } -/* layout: no sidebar */ +/* ── layout: no sidebar ─────────────────────────────────── */ .layout { display: block; } @@ -293,3 +293,48 @@ pre { .main { max-width: 100%; } + +/* ── forms ──────────────────────────────────────────────── */ +.form-field { + margin-bottom: 10px; +} + +.form-label { + display: block; + font-family: 'Silkscreen', monospace; + font-size: 10px; + color: #b48ead; + text-transform: uppercase; + margin-bottom: 4px; +} + +.form-input { + width: 100%; + background: #fdf6e3; + border: 2px solid #b48ead; + color: #4a4060; + font-family: 'VT323', monospace; + font-size: 18px; + padding: 4px 8px; + outline: none; +} + +.form-input:focus { + border-color: #d4879c; +} + +.form-submit { + background: #4a4060; + color: #fdf6e3; + border: 2px solid #4a4060; + font-family: 'Silkscreen', monospace; + font-size: 11px; + padding: 6px 14px; + cursor: pointer; + margin-top: 6px; +} + +.form-submit:hover { + background: #d4879c; + border-color: #d4879c; +} diff --git a/templates/auth/login.html b/templates/auth/login.html new file mode 100644 index 0000000..e4c256e --- /dev/null +++ b/templates/auth/login.html @@ -0,0 +1,31 @@ +{% extends "base.html" %} + +{% block content %} +
login
+
:: i'm a computer ::
+ +
+ +{% if let Some(err) = error %} +
+
⚠ {{ err }}
+
+{% endif %} + +
+
+
+ + +
+
+ + +
+ +
+ +
+{% endblock %} diff --git a/templates/auth/register.html b/templates/auth/register.html new file mode 100644 index 0000000..aba543f --- /dev/null +++ b/templates/auth/register.html @@ -0,0 +1,35 @@ +{% extends "base.html" %} + +{% block content %} +
register
+
:: no more free gabagool ::
+ +
+ +{% if let Some(err) = error %} +
+
⚠ {{ err }}
+
+{% endif %} + +
+
+
+ + +
+
+ + +
+
+ + +
+ +
+ +
+{% endblock %} diff --git a/templates/base.html b/templates/base.html index 62d6752..e779d97 100644 --- a/templates/base.html +++ b/templates/base.html @@ -35,7 +35,7 @@
- rhizomatic queer ascendance + but i don't like sour drinks toasterdragon.com