Merge remote-tracking branch 'origin/main' into 10-run-diesel-database-migrations-automatically

This commit is contained in:
2024-02-13 16:58:17 -05:00
7 changed files with 486 additions and 26 deletions

71
src/auth.rs Normal file
View File

@ -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<bool, ServerFnError> {
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<Identity>| async move {
if let Some(user) = user {
user.logout();
}
}).await?;
Ok(())
}

View File

@ -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! {

View File

@ -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)?

View File

@ -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<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,
#[cfg_attr(feature = "ssr", diesel(deserialize_as = String))]
pub password: Option<String>,
/// The time the user was created
#[cfg_attr(feature = "ssr", diesel(deserialize_as = SystemTime))]
pub created_at: Option<SystemTime>,
}

104
src/users.rs Normal file
View File

@ -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<Option<User>, 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::<User>(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<Option<User>, 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<Option<User>, 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)
}