diff --git a/Cargo.lock b/Cargo.lock index 15d3947..23c93d8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -53,7 +53,7 @@ dependencies = [ "actix-service", "actix-utils", "ahash 0.8.6", - "base64", + "base64 0.21.5", "bitflags 2.4.1", "brotli", "bytes", @@ -81,6 +81,22 @@ dependencies = [ "zstd", ] +[[package]] +name = "actix-identity" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1478456bca71c7b04411da1afb0c506e16dec6863815207693b791247511027f" +dependencies = [ + "actix-service", + "actix-session", + "actix-utils", + "actix-web", + "derive_more", + "futures-core", + "serde", + "tracing", +] + [[package]] name = "actix-macros" version = "0.2.4" @@ -142,6 +158,24 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "actix-session" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b671404ec72194d8af58c2bdaf51e3c477a0595056bd5010148405870dda8df2" +dependencies = [ + "actix-service", + "actix-utils", + "actix-web", + "anyhow", + "derive_more", + "rand", + "redis", + "serde", + "serde_json", + "tracing", +] + [[package]] name = "actix-utils" version = "3.0.1" @@ -219,6 +253,41 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac1f845298e95f983ff1944b728ae08b8cebab80d684f0a832ed0fc74dfa27e2" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "ahash" version = "0.7.7" @@ -279,6 +348,12 @@ version = "1.0.77" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c9d19de80eff169429ac1e9f48fffb163916b448a44e8e046186232046d9e1f9" +[[package]] +name = "arc-swap" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bddcadddf5e9015d310179a59bb28c4d4b9920ad0f11e8e14dbadf654890c9a6" + [[package]] name = "askama_escape" version = "0.10.3" @@ -356,12 +431,24 @@ dependencies = [ "rustc-demangle", ] +[[package]] +name = "base64" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ea22880d78093b0cbe17c89f64a7d457941e65759157ec6cb31a31d652b05e5" + [[package]] name = "base64" version = "0.21.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35636a1494ede3b646cc98f74f8e62c773a38a659ebc777a2cf26b9b74171df9" +[[package]] +name = "base64ct" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" + [[package]] name = "bitflags" version = "1.3.2" @@ -547,12 +634,36 @@ dependencies = [ "half", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "collection_literals" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "186dce98367766de751c42c4f03970fc60fc012296e706ccbb9d5df9b6c1e271" +[[package]] +name = "combine" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35ed6e9d84f0b51a7f52daf1c7d71dd136fd7a3f41a8462b8cdb8c78d920fad4" +dependencies = [ + "bytes", + "futures-core", + "memchr", + "pin-project-lite", + "tokio", + "tokio-util", +] + [[package]] name = "config" version = "0.13.4" @@ -618,7 +729,14 @@ version = "0.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e859cd57d0710d9e06c381b550c06e76992472a8c6d527aecd2fc673dcc231fb" dependencies = [ + "aes-gcm", + "base64 0.20.0", + "hkdf", + "hmac", "percent-encoding", + "rand", + "sha2", + "subtle", "time", "version_check", ] @@ -664,9 +782,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" dependencies = [ "generic-array", + "rand_core", "typenum", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + [[package]] name = "darling" version = "0.14.4" @@ -789,6 +917,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] [[package]] @@ -982,6 +1111,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "ghash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d930750de5717d2dd0b8c0d42c076c0e884c81a73e6cab859bbd2339c71e3e40" +dependencies = [ + "opaque-debug", + "polyval", +] + [[package]] name = "gimli" version = "0.28.1" @@ -1071,6 +1210,24 @@ dependencies = [ "allocator-api2", ] +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "html-escape" version = "0.2.13" @@ -1195,6 +1352,15 @@ dependencies = [ "hashbrown 0.14.3", ] +[[package]] +name = "inout" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0c10553d664a4d0bcff9f4215d0aac67a639cc68ef660840afe309b807bc9f5" +dependencies = [ + "generic-array", +] + [[package]] name = "instant" version = "0.1.12" @@ -1445,7 +1611,7 @@ version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22207568e096ac153ba8da68635e3136c1ec614ea9012736fa861c05bfb2eeff" dependencies = [ - "base64", + "base64 0.21.5", "cfg-if", "futures", "indexmap", @@ -1525,6 +1691,8 @@ name = "libretunes" version = "0.1.0" dependencies = [ "actix-files", + "actix-identity", + "actix-session", "actix-web", "cfg-if", "console_error_panic_hook", @@ -1539,6 +1707,7 @@ dependencies = [ "leptos_meta", "leptos_router", "openssl", + "pbkdf2", "serde", "wasm-bindgen", ] @@ -1713,6 +1882,12 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" +[[package]] +name = "opaque-debug" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5" + [[package]] name = "openssl" version = "0.10.63" @@ -1780,6 +1955,17 @@ dependencies = [ "windows-targets", ] +[[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.14" @@ -1792,6 +1978,18 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8835116a5c179084a830efb3adc117ab007512b535bc1a21c991d3b32a6b44dd" +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", + "password-hash", + "sha2", +] + [[package]] name = "percent-encoding" version = "2.3.1" @@ -1836,6 +2034,18 @@ version = "0.3.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69d3587f8a9e599cc7ec2c00e331f71c4e69a5f9a4b8a6efd5b07466b9736f9a" +[[package]] +name = "polyval" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d52cff9d1d4dee5fe6d03729099f4a310a41179e0a10dbf542039873f2e826fb" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + [[package]] name = "powerfmt" version = "0.2.0" @@ -2022,6 +2232,28 @@ dependencies = [ "getrandom", ] +[[package]] +name = "redis" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c580d9cbbe1d1b479e8d67cf9daf6a62c957e6846048408b80b43ac3f6af84cd" +dependencies = [ + "arc-swap", + "async-trait", + "bytes", + "combine", + "futures", + "futures-util", + "itoa", + "percent-encoding", + "pin-project-lite", + "ryu", + "tokio", + "tokio-retry", + "tokio-util", + "url", +] + [[package]] name = "redox_syscall" version = "0.4.1" @@ -2075,7 +2307,7 @@ version = "0.11.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37b1ae8d9ac08420c66222fb9096fc5de435c3c48542bc5336c51892cffafb41" dependencies = [ - "base64", + "base64 0.21.5", "bytes", "encoding_rs", "futures-core", @@ -2360,6 +2592,17 @@ dependencies = [ "digest", ] +[[package]] +name = "sha2" +version = "0.10.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "signal-hook-registry" version = "1.4.1" @@ -2416,6 +2659,12 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" +[[package]] +name = "subtle" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81cdd64d312baedb58e21336b31bc043b77e01cc99033ce76ef539f78e965ebc" + [[package]] name = "syn" version = "1.0.109" @@ -2558,6 +2807,17 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "tokio-retry" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f57eb36ecbe0fc510036adff84824dd3c24bb781e21bfa67b69d556aa85214f" +dependencies = [ + "pin-project", + "rand", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.10" @@ -2727,6 +2987,16 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f962df74c8c05a667b5ee8bcf162993134c104e96440b663c8daa176dc772d8c" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + [[package]] name = "url" version = "2.5.0" diff --git a/Cargo.toml b/Cargo.toml index 3ae4a72..8d1b2a6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,6 +30,9 @@ lazy_static = { version = "1.4.0", optional = true } serde = { versions = "1.0.195", features = ["derive"] } openssl = { version = "0.10.63", optional = true } diesel_migrations = { version = "2.1.0", optional = true } +actix-identity = { version = "0.7.0", optional = true } +actix-session = { version = "0.9.0", features = ["redis-rs-session"], optional = true } +pbkdf2 = { version = "0.12.2", features = ["simple"], optional = true } [features] csr = ["leptos/csr", "leptos_meta/csr", "leptos_router/csr"] @@ -46,6 +49,9 @@ ssr = [ "lazy_static", "openssl", "diesel_migrations", + "actix-identity", + "actix-session", + "pbkdf2", ] # Defines a size-optimized profile for the WASM bundle in release mode diff --git a/src/auth.rs b/src/auth.rs new file mode 100644 index 0000000..1995deb --- /dev/null +++ b/src/auth.rs @@ -0,0 +1,71 @@ +use leptos::*; +use crate::models::User; + +/// Create a new user and log them in +/// Takes in a NewUser struct, with the password in plaintext +/// Returns a Result with the error message if the user could not be created +#[server(endpoint = "signup")] +pub async fn signup(new_user: User) -> Result<(), ServerFnError> { + use crate::users::create_user; + + use leptos_actix::extract; + use actix_web::{HttpMessage, HttpRequest}; + use actix_identity::Identity; + + // Ensure the user has no id + let new_user = User { + id: None, + ..new_user + }; + + create_user(&new_user).await + .map_err(|e| ServerFnError::ServerError(format!("Error creating user: {}", e)))?; + + extract(|request: HttpRequest| async move { + Identity::login(&request.extensions(), new_user.username.clone()) + }).await??; + + Ok(()) +} + +/// Log a user in +/// Takes in a username or email and a password in plaintext +/// Returns a Result with a boolean indicating if the login was successful +#[server(endpoint = "login")] +pub async fn login(username_or_email: String, password: String) -> Result { + use crate::users::validate_user; + use actix_web::{HttpMessage, HttpRequest}; + use actix_identity::Identity; + use leptos_actix::extract; + + let possible_user = validate_user(username_or_email, password).await + .map_err(|e| ServerFnError::ServerError(format!("Error validating user: {}", e)))?; + + let user = match possible_user { + Some(user) => user, + None => return Ok(false) + }; + + extract(|request: HttpRequest| async move { + Identity::login(&request.extensions(), user.username.clone()) + }).await??; + + Ok(true) +} + +/// Log a user out +/// Returns a Result with the error message if the user could not be logged out +#[server(endpoint = "logout")] +pub async fn logout() -> Result<(), ServerFnError> { + use leptos_actix::extract; + use actix_identity::Identity; + + extract(|user: Option| async move { + if let Some(user) = user { + user.logout(); + } + }).await?; + + Ok(()) +} + diff --git a/src/lib.rs b/src/lib.rs index 28db59f..5eaf272 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,9 +1,11 @@ pub mod app; +pub mod auth; pub mod songdata; pub mod playstatus; pub mod playbar; pub mod database; pub mod models; +pub mod users; use cfg_if::cfg_if; cfg_if! { diff --git a/src/main.rs b/src/main.rs index 7c066dd..f6a448f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -14,11 +14,25 @@ extern crate diesel_migrations; #[cfg(feature = "ssr")] #[actix_web::main] async fn main() -> std::io::Result<()> { + use actix_identity::IdentityMiddleware; + use actix_session::storage::RedisSessionStore; + use actix_session::SessionMiddleware; + use actix_web::cookie::Key; + use dotenv::dotenv; dotenv().ok(); // Bring the database up to date libretunes::database::migrate(); + + let session_secret_key = if let Ok(key) = std::env::var("SESSION_SECRET_KEY") { + Key::from(key.as_bytes()) + } else { + Key::generate() + }; + + let redis_url = std::env::var("REDIS_URL").expect("REDIS_URL must be set"); + let redis_store = RedisSessionStore::new(redis_url).await.unwrap(); use actix_files::Files; use actix_web::*; @@ -46,6 +60,8 @@ async fn main() -> std::io::Result<()> { .service(favicon) .leptos_routes(leptos_options.to_owned(), routes.to_owned(), App) .app_data(web::Data::new(leptos_options.to_owned())) + .wrap(IdentityMiddleware::default()) + .wrap(SessionMiddleware::new(redis_store.clone(), session_secret_key.clone())) //.wrap(middleware::Compress::default()) }) .bind(&addr)? diff --git a/src/models.rs b/src/models.rs index 2daf3ae..e6ee017 100644 --- a/src/models.rs +++ b/src/models.rs @@ -11,35 +11,26 @@ use diesel::prelude::*; // diesel-specific attributes to the models when compiling for the server /// Model for a "User", used for querying the database -#[cfg_attr(feature = "ssr", derive(Queryable, Selectable))] -#[cfg_attr(feature = "ssr", diesel(table_name = crate::schema::users))] -#[cfg_attr(feature = "ssr", diesel(check_for_backend(diesel::pg::Pg)))] -#[derive(Serialize, Deserialize)] -pub struct User { - /// A unique id for the user - pub id: i32, - /// The user's username - pub username: String, - /// The user's email - pub email: String, - /// The user's password, stored as a hash - pub password: String, - /// The time the user was created - pub created_at: SystemTime, -} - -/// Model for a "New User", used for inserting into the database -/// Note that this model does not have an id or created_at field, as those are automatically -/// generated by the database and we don't want to deal with them ourselves -#[cfg_attr(feature = "ssr", derive(Insertable))] +/// Various fields are wrapped in Options, because they are not always wanted for inserts/retrieval +/// Using deserialize_as makes Diesel use the specified type when deserializing from the database, +/// and then call .into() to convert it into the Option +#[cfg_attr(feature = "ssr", derive(Queryable, Selectable, Insertable))] #[cfg_attr(feature = "ssr", diesel(table_name = crate::schema::users))] #[cfg_attr(feature = "ssr", diesel(check_for_backend(diesel::pg::Pg)))] #[derive(Serialize, Deserialize, Clone, Debug)] -pub struct NewUser { +pub struct User { + /// A unique id for the user + #[cfg_attr(feature = "ssr", diesel(deserialize_as = i32))] + // #[cfg_attr(feature = "ssr", diesel(skip_insertion))] // This feature is not yet released + pub id: Option, /// The user's username pub username: String, /// The user's email pub email: String, /// The user's password, stored as a hash - pub password: String, + #[cfg_attr(feature = "ssr", diesel(deserialize_as = String))] + pub password: Option, + /// The time the user was created + #[cfg_attr(feature = "ssr", diesel(deserialize_as = SystemTime))] + pub created_at: Option, } diff --git a/src/users.rs b/src/users.rs new file mode 100644 index 0000000..7d81b31 --- /dev/null +++ b/src/users.rs @@ -0,0 +1,104 @@ +cfg_if::cfg_if! { + if #[cfg(feature = "ssr")] { + use diesel::prelude::*; + use crate::database::get_db_conn; + + use pbkdf2::{ + password_hash::{ + rand_core::OsRng, + PasswordHasher, PasswordHash, SaltString, PasswordVerifier, Error + }, + Pbkdf2 + }; + } +} + +use leptos::*; +use crate::models::User; + +/// Get a user from the database by username or email +/// Returns a Result with the user if found, None if not found, or an error if there was a problem +#[cfg(feature = "ssr")] +pub async fn find_user(username_or_email: String) -> Result, ServerFnError> { + use crate::schema::users::dsl::*; + + // Look for either a username or email that matches the input, and return an option with None if no user is found + let db_con = &mut get_db_conn(); + let user = users.filter(username.eq(username_or_email.clone())).or_filter(email.eq(username_or_email)) + .first::(db_con).optional() + .map_err(|e| ServerFnError::ServerError(format!("Error getting user from database: {}", e)))?; + + Ok(user) +} + +/// Create a new user in the database +/// Returns an empty Result if successful, or an error if there was a problem +#[cfg(feature = "ssr")] +pub async fn create_user(new_user: &User) -> Result<(), ServerFnError> { + use crate::schema::users::dsl::*; + + let new_password = new_user.password.clone() + .ok_or(ServerFnError::ServerError(format!("No password provided for user {}", new_user.username)))?; + + let salt = SaltString::generate(&mut OsRng); + let password_hash = Pbkdf2.hash_password(new_password.as_bytes(), &salt) + .map_err(|_| ServerFnError::ServerError("Error hashing password".to_string()))?.to_string(); + + let new_user = User { + password: Some(password_hash), + ..new_user.clone() + }; + + let db_con = &mut get_db_conn(); + + diesel::insert_into(users).values(&new_user).execute(db_con) + .map_err(|e| ServerFnError::ServerError(format!("Error creating user: {}", e)))?; + + Ok(()) +} + +/// Validate a user's credentials +/// Returns a Result with the user if the credentials are valid, None if not valid, or an error if there was a problem +#[cfg(feature = "ssr")] +pub async fn validate_user(username_or_email: String, password: String) -> Result, ServerFnError> { + let db_user = find_user(username_or_email.clone()).await + .map_err(|e| ServerFnError::ServerError(format!("Error getting user from database: {}", e)))?; + + // If the user is not found, return None + let db_user = match db_user { + Some(user) => user, + None => return Ok(None) + }; + + let db_password = db_user.password.clone() + .ok_or(ServerFnError::ServerError(format!("No password found for user {}", db_user.username)))?; + + let password_hash = PasswordHash::new(&db_password) + .map_err(|e| ServerFnError::ServerError(format!("Error hashing supplied password: {}", e)))?; + + match Pbkdf2.verify_password(password.as_bytes(), &password_hash) { + Ok(()) => {}, + Err(Error::Password) => { + return Ok(None); + }, + Err(e) => { + return Err(ServerFnError::ServerError(format!("Error verifying password: {}", e))); + } + } + + Ok(Some(db_user)) +} + +/// Get a user from the database by username or email +/// Returns a Result with the user if found, None if not found, or an error if there was a problem +#[server(endpoint = "get_user")] +pub async fn get_user(username_or_email: String) -> Result, ServerFnError> { + let mut user = find_user(username_or_email).await?; + + // Remove the password hash before returning the user + if let Some(user) = user.as_mut() { + user.password = None; + } + + Ok(user) +}