From 4901d5f402e95ed100bacc7c5437ac9f7ac576f0 Mon Sep 17 00:00:00 2001 From: Sebastian Schmidt Date: Wed, 18 Oct 2023 12:16:49 +0300 Subject: [PATCH 01/15] Allow password hash to be nullable in database. --- .../2023-08-24-100631_add_password_column_to_user_table/up.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/rvoc-backend/migrations/2023-08-24-100631_add_password_column_to_user_table/up.sql b/backend/rvoc-backend/migrations/2023-08-24-100631_add_password_column_to_user_table/up.sql index 7452ae6..c64a601 100644 --- a/backend/rvoc-backend/migrations/2023-08-24-100631_add_password_column_to_user_table/up.sql +++ b/backend/rvoc-backend/migrations/2023-08-24-100631_add_password_column_to_user_table/up.sql @@ -1 +1 @@ -ALTER TABLE users ADD COLUMN password_hash TEXT NOT NULL; +ALTER TABLE users ADD COLUMN password_hash TEXT; From f4aa8125fbf5cc4f7b6f029a231a6ca17ac86ce9 Mon Sep 17 00:00:00 2001 From: Sebastian Schmidt Date: Wed, 18 Oct 2023 12:18:18 +0300 Subject: [PATCH 02/15] Update database schema. --- backend/rvoc-backend/src/database/schema.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/rvoc-backend/src/database/schema.rs b/backend/rvoc-backend/src/database/schema.rs index 76f2920..a1c6173 100644 --- a/backend/rvoc-backend/src/database/schema.rs +++ b/backend/rvoc-backend/src/database/schema.rs @@ -86,10 +86,10 @@ diesel::table! { name -> Text, /// The `password_hash` column of the `users` table. /// - /// Its SQL type is `Text`. + /// Its SQL type is `Nullable`. /// /// (Automatically generated by Diesel.) - password_hash -> Text, + password_hash -> Nullable, } } From 7c78e600ad5cbb40fe7140cc84ac3a157372500f Mon Sep 17 00:00:00 2001 From: Sebastian Schmidt Date: Wed, 18 Oct 2023 12:25:44 +0300 Subject: [PATCH 03/15] Prevent login if the password is empty. (Before, this was a compiler error thanks to diesel) --- backend/rvoc-backend/src/web/authentication.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/backend/rvoc-backend/src/web/authentication.rs b/backend/rvoc-backend/src/web/authentication.rs index c80216a..05b655e 100644 --- a/backend/rvoc-backend/src/web/authentication.rs +++ b/backend/rvoc-backend/src/web/authentication.rs @@ -67,8 +67,15 @@ pub async fn login( .await .optional()? { - password_hash + if let Some(password_hash) = password_hash { + password_hash + } else { + // Here the optional() returned a row, but with a null password hash. + info!("User has no password: {:?}", login.name); + return Err(UserError::InvalidUsernamePassword.into()); + } } else { + // Here the optional() returned None, i.e. no row was found. info!("User not found: {:?}", login.name); return Err(UserError::InvalidUsernamePassword.into()); }; From e69f28fbcbf61dd49dc28fdd42902edd9f4a3644 Mon Sep 17 00:00:00 2001 From: Sebastian Schmidt Date: Sun, 22 Oct 2023 10:40:36 +0300 Subject: [PATCH 04/15] Make `PasswordHash` store optional hash internally. --- backend/rvoc-backend/Cargo.toml | 2 +- .../rvoc-backend/src/web/authentication.rs | 2 +- backend/rvoc-backend/src/web/user/model.rs | 3 +- .../src/web/user/password_hash.rs | 55 +++++++++++-------- 4 files changed, 37 insertions(+), 25 deletions(-) diff --git a/backend/rvoc-backend/Cargo.toml b/backend/rvoc-backend/Cargo.toml index 220d39a..6b1c31f 100644 --- a/backend/rvoc-backend/Cargo.toml +++ b/backend/rvoc-backend/Cargo.toml @@ -31,7 +31,7 @@ opentelemetry-otlp = { version = "0.13.0" } thiserror = "1.0.50" # database -diesel = { version = "2.1.0", features = ["postgres", "chrono"] } +diesel = { version = "2.1.3", features = ["postgres", "chrono"] } diesel_migrations = "2.1.0" diesel-async = { version = "0.4.1", features = ["postgres", "deadpool"] } diff --git a/backend/rvoc-backend/src/web/authentication.rs b/backend/rvoc-backend/src/web/authentication.rs index 05b655e..e747a7e 100644 --- a/backend/rvoc-backend/src/web/authentication.rs +++ b/backend/rvoc-backend/src/web/authentication.rs @@ -94,7 +94,7 @@ pub async fn login( if verify_result.modified { let affected_rows = diesel::update(users::table) .filter(users::name.eq(&login.name)) - .set(users::password_hash.eq(String::from(password_hash))) + .set(users::password_hash.eq(Option::::from(password_hash))) .execute(database_connection) .await?; diff --git a/backend/rvoc-backend/src/web/user/model.rs b/backend/rvoc-backend/src/web/user/model.rs index 368effd..3e54a74 100644 --- a/backend/rvoc-backend/src/web/user/model.rs +++ b/backend/rvoc-backend/src/web/user/model.rs @@ -6,10 +6,11 @@ use super::password_hash::PasswordHash; #[diesel(table_name = crate::database::schema::users)] #[diesel(primary_key(name))] #[diesel(check_for_backend(diesel::pg::Pg))] +#[diesel(treat_none_as_default_value = false)] pub struct User { #[diesel(serialize_as = String)] pub name: Username, - #[diesel(serialize_as = String)] + #[diesel(serialize_as = Option)] pub password_hash: PasswordHash, } diff --git a/backend/rvoc-backend/src/web/user/password_hash.rs b/backend/rvoc-backend/src/web/user/password_hash.rs index aa885b8..6d384ef 100644 --- a/backend/rvoc-backend/src/web/user/password_hash.rs +++ b/backend/rvoc-backend/src/web/user/password_hash.rs @@ -15,7 +15,7 @@ static HASH_ALGORITHM_VERSION: argon2::Version = argon2::Version::V0x13; #[derive(Clone, Debug)] pub struct PasswordHash { - argon_hash: SecUtf8, + argon_hash: Option, } #[must_use] @@ -47,13 +47,15 @@ impl PasswordHash { let argon2 = Self::build_argon2(configuration)?; - let argon_hash = argon2 - .hash_password(plaintext_password.unsecure(), &salt) - .map_err(|error| RVocError::PasswordArgon2IdHash { - source: Box::new(error), - })? - .to_string() - .into(); + let argon_hash = Some( + argon2 + .hash_password(plaintext_password.unsecure(), &salt) + .map_err(|error| RVocError::PasswordArgon2IdHash { + source: Box::new(error), + })? + .to_string() + .into(), + ); Ok(Self { argon_hash }) } @@ -63,10 +65,18 @@ impl PasswordHash { plaintext_password: SecBytes, configuration: impl AsRef, ) -> RVocResult { + let Some(argon_hash) = &self.argon_hash else { + return Err(RVocError::PasswordArgon2IdVerify { + source: Box::new(password_hash::Error::Password), + }); + }; + let configuration = configuration.as_ref(); - let parsed_hash = argon2::password_hash::PasswordHash::new(self.argon_hash.unsecure()) - .map_err(|error| RVocError::PasswordArgon2IdVerify { - source: Box::new(error), + let parsed_hash = + argon2::password_hash::PasswordHash::new(argon_hash.unsecure()).map_err(|error| { + RVocError::PasswordArgon2IdVerify { + source: Box::new(error), + } })?; let argon2 = Self::build_argon2_from_parameters( argon2::Params::try_from(&parsed_hash).map_err(|error| { @@ -145,23 +155,25 @@ impl PasswordHash { } } -impl From for String { +impl From for Option { fn from(value: PasswordHash) -> Self { - value.argon_hash.into_unsecure() + value.argon_hash.map(SecUtf8::into_unsecure) } } -impl From for PasswordHash { - fn from(value: String) -> Self { +impl From> for PasswordHash { + fn from(value: Option) -> Self { Self { - argon_hash: value.into(), + argon_hash: value.map(Into::into), } } } -impl AsRef for PasswordHash { - fn as_ref(&self) -> &str { - self.argon_hash.unsecure() +impl From for PasswordHash { + fn from(value: String) -> Self { + Self { + argon_hash: Some(value.into()), + } } } @@ -188,7 +200,6 @@ mod tests { let password = SecBytes::from("mypassword"); let mut password_hash = PasswordHash::new(password.clone(), &configuration).unwrap(); - println!("hash string: {}", password_hash.as_ref()); let verify_password_result = password_hash.verify(password.clone(), &configuration); assert!( @@ -204,8 +215,8 @@ mod tests { ); // convert to string and back - let password_hash_string = String::from(password_hash); - let mut password_hash = PasswordHash::from(password_hash_string); + let password_hash_string = Option::::from(password_hash).unwrap(); + let mut password_hash = PasswordHash::from(Some(password_hash_string)); let verify_password_result = password_hash.verify(password.clone(), &configuration); assert!( verify_password_result.is_ok(), From 70f56e812bdc9a082531f4b7968fd4fabd83f856 Mon Sep 17 00:00:00 2001 From: Sebastian Schmidt Date: Sun, 22 Oct 2023 10:45:38 +0300 Subject: [PATCH 05/15] Test that `None` password hash does not match. --- .../src/web/user/password_hash.rs | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/backend/rvoc-backend/src/web/user/password_hash.rs b/backend/rvoc-backend/src/web/user/password_hash.rs index 6d384ef..74b533f 100644 --- a/backend/rvoc-backend/src/web/user/password_hash.rs +++ b/backend/rvoc-backend/src/web/user/password_hash.rs @@ -230,4 +230,47 @@ mod tests { } ); } + + #[test] + fn test_empty_password_hash() { + let configuration = Configuration::test_configuration(); + + println!("Hash algo: {}", HASH_ALGORITHM.ident()); + println!("Hash algo version: {}", u32::from(HASH_ALGORITHM_VERSION)); + println!( + "Hash algo parameters: {:?}", + configuration.build_argon2_parameters().unwrap() + ); + + let password = SecBytes::from("mypassword"); + let mut password_hash = PasswordHash::new(password.clone(), &configuration).unwrap(); + + let verify_password_result = password_hash.verify(password.clone(), &configuration); + assert!( + verify_password_result.is_ok(), + "password hash result: {verify_password_result:?}" + ); + assert_eq!( + verify_password_result.unwrap(), + VerifyPasswordResult { + matches: true, + modified: false, + } + ); + + // set to none + password_hash.argon_hash = None; + let verify_password_result = password_hash.verify(password.clone(), &configuration); + assert!( + verify_password_result.is_ok(), + "password hash result: {verify_password_result:?}" + ); + assert_eq!( + verify_password_result.unwrap(), + VerifyPasswordResult { + matches: false, + modified: false, + } + ); + } } From 2e7d70bdc1206fc0dc5f1e7cc685f820217f2e2e Mon Sep 17 00:00:00 2001 From: Sebastian Schmidt Date: Sun, 22 Oct 2023 11:09:55 +0300 Subject: [PATCH 06/15] Refactor CLI commands into own module. --- backend/rvoc-backend/src/cli/mod.rs | 109 ++++++++++++++++++++++++++++ backend/rvoc-backend/src/main.rs | 90 +---------------------- 2 files changed, 113 insertions(+), 86 deletions(-) create mode 100644 backend/rvoc-backend/src/cli/mod.rs diff --git a/backend/rvoc-backend/src/cli/mod.rs b/backend/rvoc-backend/src/cli/mod.rs new file mode 100644 index 0000000..cb1643a --- /dev/null +++ b/backend/rvoc-backend/src/cli/mod.rs @@ -0,0 +1,109 @@ +use std::sync::{atomic, Arc}; + +use clap::Parser; +use tracing::{debug, info, instrument}; + +use crate::{ + configuration::Configuration, + database::{ + create_async_database_connection_pool, + migrations::{has_missing_migrations, run_migrations}, + }, + error::RVocError, + error::RVocResult, + job_queue::{jobs::update_witkionary::run_update_wiktionary, spawn_job_queue_runner}, + web::run_web_api, +}; + +/// Decide how to run the application. +/// This should only be used internally for code that does not support async, +/// and hence should be run as subprocess. +#[derive(Parser, Debug)] +enum Cli { + /// Run the web API, this is the only variant that should be called by the user. + Web, + /// Update the wiktionary data. + UpdateWiktionary, + /// Apply pending database migrations. + ApplyMigrations, + /// Expire the passwords of all users. + ExpireAllPasswords, +} + +#[instrument(skip(configuration))] +pub async fn run_cli_command(configuration: &Configuration) -> RVocResult<()> { + let cli_command = Cli::parse(); + debug!("Cli arguments: {cli_command:#?}"); + + match cli_command { + Cli::Web => run_rvoc_backend(configuration).await?, + Cli::UpdateWiktionary => { + run_update_wiktionary( + &create_async_database_connection_pool(configuration).await?, + configuration, + ) + .await? + } + Cli::ApplyMigrations => apply_pending_database_migrations(configuration).await?, + Cli::ExpireAllPasswords => expire_all_passwords(configuration).await?, + } + + Ok(()) +} + +#[instrument(err, skip(configuration))] +async fn run_rvoc_backend(configuration: &Configuration) -> RVocResult<()> { + debug!("Running rvoc backend with configuration: {configuration:#?}"); + + // Connect to database. + // (This does not actually connect to the database, connections are created lazily.) + let database_connection_pool = create_async_database_connection_pool(configuration).await?; + + // Create shutdown flag. + let do_shutdown = Arc::new(atomic::AtomicBool::new(false)); + + // Start job queue + let job_queue_join_handle: tokio::task::JoinHandle> = + spawn_job_queue_runner( + database_connection_pool.clone(), + do_shutdown.clone(), + configuration.clone(), + ) + .await?; + + // Start web API + run_web_api(database_connection_pool, configuration).await?; + + // Shutdown + info!("Shutting down..."); + do_shutdown.store(true, atomic::Ordering::Relaxed); + + info!("Waiting for asynchronous tasks to finish..."); + job_queue_join_handle + .await + .map_err(|error| RVocError::TokioTaskJoin { + source: Box::new(error), + })??; + + Ok(()) +} + +#[instrument(err, skip(configuration))] +async fn apply_pending_database_migrations(configuration: &Configuration) -> RVocResult<()> { + if has_missing_migrations(configuration)? { + info!("Executing missing database migrations"); + run_migrations(configuration)?; + info!("Success!"); + } else { + info!("No missing migrations"); + } + + Ok(()) +} + +#[instrument(err, skip(configuration))] +async fn expire_all_passwords(configuration: &Configuration) -> RVocResult<()> { + todo!() + + //Ok(()) +} diff --git a/backend/rvoc-backend/src/main.rs b/backend/rvoc-backend/src/main.rs index b4af121..22494ce 100644 --- a/backend/rvoc-backend/src/main.rs +++ b/backend/rvoc-backend/src/main.rs @@ -1,19 +1,12 @@ -use std::sync::{atomic, Arc}; - -use crate::database::migrations::run_migrations; use crate::error::RVocResult; -use crate::job_queue::jobs::update_witkionary::run_update_wiktionary; -use crate::job_queue::spawn_job_queue_runner; -use crate::web::run_web_api; use crate::{configuration::Configuration, error::RVocError}; -use clap::Parser; -use database::create_async_database_connection_pool; -use database::migrations::has_missing_migrations; +use cli::run_cli_command; use secstr::SecVec; -use tracing::{debug, info, instrument, Level}; +use tracing::{info, instrument, Level}; use tracing_subscriber::filter::FilterFn; use tracing_subscriber::Layer; +mod cli; mod configuration; mod database; mod error; @@ -22,19 +15,6 @@ mod web; type SecBytes = SecVec; -/// Decide how to run the application. -/// This should only be used internally for code that does not support async, -/// and hence should be run as subprocess. -#[derive(Parser, Debug)] -enum Cli { - /// Run the web API, this is the only variant that should be called by the user. - Web, - /// Update the wiktionary data. - UpdateWiktionary, - /// Apply pending database migrations. - ApplyMigrations, -} - #[instrument(err, skip(configuration))] fn setup_tracing_subscriber(configuration: &Configuration) -> RVocResult<()> { use opentelemetry::sdk::Resource; @@ -102,72 +82,10 @@ fn setup_tracing_subscriber(configuration: &Configuration) -> RVocResult<()> { async fn main() -> RVocResult<()> { // Load configuration & CLI let configuration = Configuration::from_environment()?; - let cli = Cli::parse(); - debug!("Cli arguments: {cli:#?}"); setup_tracing_subscriber(&configuration)?; - match cli { - Cli::Web => run_rvoc_backend(&configuration).await?, - Cli::UpdateWiktionary => { - run_update_wiktionary( - &create_async_database_connection_pool(&configuration).await?, - &configuration, - ) - .await? - } - Cli::ApplyMigrations => apply_pending_database_migrations(&configuration).await?, - } - - Ok(()) -} - -#[instrument(err, skip(configuration))] -async fn run_rvoc_backend(configuration: &Configuration) -> RVocResult<()> { - debug!("Running rvoc backend with configuration: {configuration:#?}"); - - // Connect to database. - // (This does not actually connect to the database, connections are created lazily.) - let database_connection_pool = create_async_database_connection_pool(configuration).await?; - - // Create shutdown flag. - let do_shutdown = Arc::new(atomic::AtomicBool::new(false)); - - // Start job queue - let job_queue_join_handle: tokio::task::JoinHandle> = - spawn_job_queue_runner( - database_connection_pool.clone(), - do_shutdown.clone(), - configuration.clone(), - ) - .await?; - - // Start web API - run_web_api(database_connection_pool, configuration).await?; - - // Shutdown - info!("Shutting down..."); - do_shutdown.store(true, atomic::Ordering::Relaxed); - - info!("Waiting for asynchronous tasks to finish..."); - job_queue_join_handle - .await - .map_err(|error| RVocError::TokioTaskJoin { - source: Box::new(error), - })??; - - Ok(()) -} - -#[instrument(err, skip(configuration))] -async fn apply_pending_database_migrations(configuration: &Configuration) -> RVocResult<()> { - if has_missing_migrations(configuration)? { - info!("Executing missing database migrations"); - run_migrations(configuration)?; - info!("Success!"); - } else { - info!("No missing migrations"); - } + run_cli_command(&configuration).await?; Ok(()) } From d9d1153f5176ead4b3439c60292b03680e846842 Mon Sep 17 00:00:00 2001 From: Sebastian Schmidt Date: Sun, 22 Oct 2023 11:12:32 +0300 Subject: [PATCH 07/15] Improve the documentation of the cli. --- backend/rvoc-backend/src/cli/mod.rs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/backend/rvoc-backend/src/cli/mod.rs b/backend/rvoc-backend/src/cli/mod.rs index cb1643a..1912bb3 100644 --- a/backend/rvoc-backend/src/cli/mod.rs +++ b/backend/rvoc-backend/src/cli/mod.rs @@ -16,16 +16,22 @@ use crate::{ }; /// Decide how to run the application. -/// This should only be used internally for code that does not support async, -/// and hence should be run as subprocess. -#[derive(Parser, Debug)] +#[derive(Parser, Debug, Default)] enum Cli { - /// Run the web API, this is the only variant that should be called by the user. + /// Run the web API (default). + #[default] Web, + /// Update the wiktionary data. + /// This is done automatically while the web application runs. + /// But if required, it can be run manually with this command. + /// + /// **WARNING:** The web API should not run while running this command, since otherwise a simultaneous update is possible, with unforseeable results. UpdateWiktionary, + /// Apply pending database migrations. ApplyMigrations, + /// Expire the passwords of all users. ExpireAllPasswords, } From 649c374f055586dd1e5945cf4adaf55494d9d71e Mon Sep 17 00:00:00 2001 From: Sebastian Schmidt Date: Sun, 22 Oct 2023 11:14:59 +0300 Subject: [PATCH 08/15] Set `rvoc-backend` as default-run binary. --- backend/rvoc-backend/Cargo.toml | 2 ++ backend/rvoc-backend/src/cli/mod.rs | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/backend/rvoc-backend/Cargo.toml b/backend/rvoc-backend/Cargo.toml index 6b1c31f..91887f7 100644 --- a/backend/rvoc-backend/Cargo.toml +++ b/backend/rvoc-backend/Cargo.toml @@ -5,6 +5,8 @@ edition.workspace = true rust-version.workspace = true publish = false +default-run = "rvoc-backend" + # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [[bin]] diff --git a/backend/rvoc-backend/src/cli/mod.rs b/backend/rvoc-backend/src/cli/mod.rs index 1912bb3..96dab2a 100644 --- a/backend/rvoc-backend/src/cli/mod.rs +++ b/backend/rvoc-backend/src/cli/mod.rs @@ -31,7 +31,7 @@ enum Cli { /// Apply pending database migrations. ApplyMigrations, - + /// Expire the passwords of all users. ExpireAllPasswords, } From 4118aa745a552b1a9a7a9f7b520b52aa22a3ff23 Mon Sep 17 00:00:00 2001 From: Sebastian Schmidt Date: Sun, 22 Oct 2023 11:17:40 +0300 Subject: [PATCH 09/15] Fix CLI help message. --- backend/rvoc-backend/src/cli/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/rvoc-backend/src/cli/mod.rs b/backend/rvoc-backend/src/cli/mod.rs index 96dab2a..7dcc76d 100644 --- a/backend/rvoc-backend/src/cli/mod.rs +++ b/backend/rvoc-backend/src/cli/mod.rs @@ -15,7 +15,7 @@ use crate::{ web::run_web_api, }; -/// Decide how to run the application. +/// CLI of the vocabulary learning application. #[derive(Parser, Debug, Default)] enum Cli { /// Run the web API (default). From 86088a99ba541c30a0d3b67080d822037c3ed985 Mon Sep 17 00:00:00 2001 From: Sebastian Schmidt Date: Sun, 22 Oct 2023 11:33:39 +0300 Subject: [PATCH 10/15] Implement command to expire all passwords. --- backend/rvoc-backend/src/cli/mod.rs | 30 ++++++++++++++++++++++++++- backend/rvoc-backend/src/error/mod.rs | 3 +++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/backend/rvoc-backend/src/cli/mod.rs b/backend/rvoc-backend/src/cli/mod.rs index 7dcc76d..655eee8 100644 --- a/backend/rvoc-backend/src/cli/mod.rs +++ b/backend/rvoc-backend/src/cli/mod.rs @@ -1,6 +1,7 @@ use std::sync::{atomic, Arc}; use clap::Parser; +use diesel_async::RunQueryDsl; use tracing::{debug, info, instrument}; use crate::{ @@ -61,7 +62,7 @@ pub async fn run_cli_command(configuration: &Configuration) -> RVocResult<()> { async fn run_rvoc_backend(configuration: &Configuration) -> RVocResult<()> { debug!("Running rvoc backend with configuration: {configuration:#?}"); - // Connect to database. + // Create database connection pool. // (This does not actually connect to the database, connections are created lazily.) let database_connection_pool = create_async_database_connection_pool(configuration).await?; @@ -109,6 +110,33 @@ async fn apply_pending_database_migrations(configuration: &Configuration) -> RVo #[instrument(err, skip(configuration))] async fn expire_all_passwords(configuration: &Configuration) -> RVocResult<()> { + // Create database connection pool. + // (This does not actually connect to the database, connections are created lazily.) + let database_connection_pool = create_async_database_connection_pool(configuration).await?; + + database_connection_pool + .execute_read_committed_transaction( + |database_connection| { + Box::pin(async { + use crate::database::schema::users::dsl::*; + use diesel::ExpressionMethods; + + diesel::update(users) + .set(password_hash.eq(Option::::None)) + .execute(database_connection) + .await + .map_err(|error| { + RVocError::ExpireAllPasswords { + source: Box::new(error), + } + .into() + }) + }) + }, + configuration.maximum_transaction_retry_count, + ) + .await?; + todo!() //Ok(()) diff --git a/backend/rvoc-backend/src/error/mod.rs b/backend/rvoc-backend/src/error/mod.rs index 1972b99..19fa8ea 100644 --- a/backend/rvoc-backend/src/error/mod.rs +++ b/backend/rvoc-backend/src/error/mod.rs @@ -87,6 +87,9 @@ pub enum RVocError { #[error("error deleting user: {source}")] DeleteUser { source: BoxDynError }, + #[error("error expiring all passwords: {source}")] + ExpireAllPasswords { source: BoxDynError }, + #[error("error deleting all user sessions: {source}")] DeleteAllUserSessions { source: BoxDynError }, From 2c9fe2e5905eaa66bea753b83e4e24ea22a2ba6a Mon Sep 17 00:00:00 2001 From: Sebastian Schmidt Date: Sun, 22 Oct 2023 11:46:50 +0300 Subject: [PATCH 11/15] Produce no error when verifying empty password hash. The API of the verify function is to return `Ok` with a `false` value for the verification result if the password does not match. --- backend/rvoc-backend/src/web/user/password_hash.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/backend/rvoc-backend/src/web/user/password_hash.rs b/backend/rvoc-backend/src/web/user/password_hash.rs index 74b533f..bbc9d20 100644 --- a/backend/rvoc-backend/src/web/user/password_hash.rs +++ b/backend/rvoc-backend/src/web/user/password_hash.rs @@ -66,8 +66,9 @@ impl PasswordHash { configuration: impl AsRef, ) -> RVocResult { let Some(argon_hash) = &self.argon_hash else { - return Err(RVocError::PasswordArgon2IdVerify { - source: Box::new(password_hash::Error::Password), + return Ok(VerifyPasswordResult { + matches: false, + modified: false, }); }; From 604a4441b57fb861f3aae9b32575ee5929170871 Mon Sep 17 00:00:00 2001 From: Sebastian Schmidt Date: Wed, 25 Oct 2023 10:16:05 +0300 Subject: [PATCH 12/15] Refactor user types into new mod `model`. --- backend/rvoc-backend/src/main.rs | 1 + backend/rvoc-backend/src/model/mod.rs | 1 + .../src/{web/user/model.rs => model/user/mod.rs} | 4 +++- .../src/{web => model}/user/password_hash.rs | 9 ++------- backend/rvoc-backend/src/web/authentication.rs | 6 ++---- backend/rvoc-backend/src/web/session.rs | 3 +-- .../rvoc-backend/src/web/{user/mod.rs => user.rs} | 14 ++++---------- 7 files changed, 14 insertions(+), 24 deletions(-) create mode 100644 backend/rvoc-backend/src/model/mod.rs rename backend/rvoc-backend/src/{web/user/model.rs => model/user/mod.rs} (92%) rename backend/rvoc-backend/src/{web => model}/user/password_hash.rs (97%) rename backend/rvoc-backend/src/web/{user/mod.rs => user.rs} (96%) diff --git a/backend/rvoc-backend/src/main.rs b/backend/rvoc-backend/src/main.rs index 22494ce..84c680d 100644 --- a/backend/rvoc-backend/src/main.rs +++ b/backend/rvoc-backend/src/main.rs @@ -11,6 +11,7 @@ mod configuration; mod database; mod error; mod job_queue; +mod model; mod web; type SecBytes = SecVec; diff --git a/backend/rvoc-backend/src/model/mod.rs b/backend/rvoc-backend/src/model/mod.rs new file mode 100644 index 0000000..22d12a3 --- /dev/null +++ b/backend/rvoc-backend/src/model/mod.rs @@ -0,0 +1 @@ +pub mod user; diff --git a/backend/rvoc-backend/src/web/user/model.rs b/backend/rvoc-backend/src/model/user/mod.rs similarity index 92% rename from backend/rvoc-backend/src/web/user/model.rs rename to backend/rvoc-backend/src/model/user/mod.rs index 3e54a74..fb0772e 100644 --- a/backend/rvoc-backend/src/web/user/model.rs +++ b/backend/rvoc-backend/src/model/user/mod.rs @@ -1,6 +1,8 @@ use diesel::prelude::Insertable; -use super::password_hash::PasswordHash; +use self::password_hash::PasswordHash; + +pub mod password_hash; #[derive(Insertable, Clone, Debug)] #[diesel(table_name = crate::database::schema::users)] diff --git a/backend/rvoc-backend/src/web/user/password_hash.rs b/backend/rvoc-backend/src/model/user/password_hash.rs similarity index 97% rename from backend/rvoc-backend/src/web/user/password_hash.rs rename to backend/rvoc-backend/src/model/user/password_hash.rs index bbc9d20..34eb29b 100644 --- a/backend/rvoc-backend/src/web/user/password_hash.rs +++ b/backend/rvoc-backend/src/model/user/password_hash.rs @@ -180,13 +180,8 @@ impl From for PasswordHash { #[cfg(test)] mod tests { - use crate::{ - configuration::Configuration, - web::user::password_hash::{VerifyPasswordResult, HASH_ALGORITHM, HASH_ALGORITHM_VERSION}, - SecBytes, - }; - - use super::PasswordHash; + use super::{PasswordHash, VerifyPasswordResult, HASH_ALGORITHM, HASH_ALGORITHM_VERSION}; + use crate::{configuration::Configuration, SecBytes}; #[test] fn test_password_check() { diff --git a/backend/rvoc-backend/src/web/authentication.rs b/backend/rvoc-backend/src/web/authentication.rs index e747a7e..61494d0 100644 --- a/backend/rvoc-backend/src/web/authentication.rs +++ b/backend/rvoc-backend/src/web/authentication.rs @@ -11,12 +11,10 @@ use typed_session_axum::{SessionHandle, WritableSession}; use crate::{ error::{RVocError, RVocResult, UserError}, - web::user::password_hash::PasswordHash, + model::user::{password_hash::PasswordHash, Username}, }; -use super::{ - session::RVocSessionData, user::model::Username, WebConfiguration, WebDatabaseConnectionPool, -}; +use super::{session::RVocSessionData, WebConfiguration, WebDatabaseConnectionPool}; pub async fn ensure_logged_in(mut request: Request, next: Next) -> Response { let session: &SessionHandle = request.extensions().get().unwrap(); diff --git a/backend/rvoc-backend/src/web/session.rs b/backend/rvoc-backend/src/web/session.rs index 5aef87c..49684a8 100644 --- a/backend/rvoc-backend/src/web/session.rs +++ b/backend/rvoc-backend/src/web/session.rs @@ -15,10 +15,9 @@ use crate::{ RVocAsyncDatabaseConnectionPool, }, error::{BoxDynError, RVocError}, + model::user::Username, }; -use super::user::model::Username; - #[derive(Clone)] pub struct RVocSessionStoreConnector { database_connection_pool: RVocAsyncDatabaseConnectionPool, diff --git a/backend/rvoc-backend/src/web/user/mod.rs b/backend/rvoc-backend/src/web/user.rs similarity index 96% rename from backend/rvoc-backend/src/web/user/mod.rs rename to backend/rvoc-backend/src/web/user.rs index 679a99d..571693e 100644 --- a/backend/rvoc-backend/src/web/user/mod.rs +++ b/backend/rvoc-backend/src/web/user.rs @@ -1,23 +1,17 @@ +use crate::{ + error::{RVocError, RVocResult, UserError}, + model::user::{password_hash::PasswordHash, User, Username}, +}; use api_commands::CreateAccount; use axum::{http::StatusCode, Extension, Json}; use tracing::instrument; use typed_session_axum::WritableSession; -use crate::error::{RVocError, RVocResult, UserError}; - -use self::{ - model::{User, Username}, - password_hash::PasswordHash, -}; - use super::{ authentication::LoggedInUser, session::RVocSessionData, WebConfiguration, WebDatabaseConnectionPool, }; -pub mod model; -pub mod password_hash; - #[instrument(err, skip(database_connection_pool, configuration))] pub async fn create_account( Extension(database_connection_pool): WebDatabaseConnectionPool, From 3e6f32185e1b373654ddb66d28473d91a4df5a3f Mon Sep 17 00:00:00 2001 From: Sebastian Schmidt Date: Wed, 25 Oct 2023 10:44:49 +0300 Subject: [PATCH 13/15] Move verification of username and password length to model. --- backend/api_commands/src/lib.rs | 6 +-- backend/integration-tests/src/main.rs | 41 ++++++++++--------- backend/rvoc-backend/src/model/user/mod.rs | 8 +++- .../src/model/user/password_hash.rs | 11 ++--- .../rvoc-backend/src/web/authentication.rs | 18 ++++---- backend/rvoc-backend/src/web/mod.rs | 6 ++- backend/rvoc-backend/src/web/session.rs | 27 ++++++------ backend/rvoc-backend/src/web/user.rs | 8 ++-- 8 files changed, 64 insertions(+), 61 deletions(-) diff --git a/backend/api_commands/src/lib.rs b/backend/api_commands/src/lib.rs index 7ea814e..19e9d50 100644 --- a/backend/api_commands/src/lib.rs +++ b/backend/api_commands/src/lib.rs @@ -6,13 +6,13 @@ use serde::{Deserialize, Serialize}; #[derive(Serialize, Deserialize, Debug, Eq, PartialEq)] pub struct CreateAccount { - pub name: String, + pub username: String, pub password: SecBytes, } #[derive(Serialize, Deserialize, Debug, Eq, PartialEq)] pub struct Login { - pub name: String, + pub username: String, pub password: SecBytes, } @@ -23,7 +23,7 @@ mod tests { #[test] fn test_serde_create_account() { let create_account = CreateAccount { - name: "anne".to_owned(), + username: "anne".to_owned(), password: "frank".to_owned().into(), }; diff --git a/backend/integration-tests/src/main.rs b/backend/integration-tests/src/main.rs index 4e65f01..f3fe257 100644 --- a/backend/integration-tests/src/main.rs +++ b/backend/integration-tests/src/main.rs @@ -78,7 +78,7 @@ async fn test_user_account_creation() -> anyhow::Result<()> { .post( "/accounts/create", CreateAccount { - name: "anne".to_owned(), + username: "anne".to_owned(), password: "frank😀😀😀".to_owned().into(), }, ) @@ -93,7 +93,7 @@ async fn test_duplicate_user_account_creation() -> anyhow::Result<()> { .post( "/accounts/create", CreateAccount { - name: "rosa".to_owned(), + username: "rosa".to_owned(), password: "luxemburg".to_owned().into(), }, ) @@ -105,7 +105,7 @@ async fn test_duplicate_user_account_creation() -> anyhow::Result<()> { .post( "/accounts/create", CreateAccount { - name: "rosa".to_owned(), + username: "rosa".to_owned(), password: "luxemburg".to_owned().into(), }, ) @@ -120,7 +120,7 @@ async fn test_user_account_deletion() -> anyhow::Result<()> { .post( "/accounts/create", CreateAccount { - name: "claus".to_owned(), + username: "claus".to_owned(), password: "von stauffenberg".to_owned().into(), }, ) @@ -136,7 +136,7 @@ async fn test_user_account_deletion() -> anyhow::Result<()> { .post( "/accounts/login", Login { - name: "claus".to_owned(), + username: "claus".to_owned(), password: "von stauffenberg".to_owned().into(), }, ) @@ -157,7 +157,7 @@ async fn test_login_logout() -> anyhow::Result<()> { .post( "/accounts/create", CreateAccount { - name: "orli".to_owned(), + username: "orli".to_owned(), password: password.clone(), }, ) @@ -173,7 +173,7 @@ async fn test_login_logout() -> anyhow::Result<()> { .post( "/accounts/login", Login { - name: "orli".to_owned(), + username: "orli".to_owned(), password: password.clone(), }, ) @@ -196,7 +196,7 @@ async fn test_wrong_password() -> anyhow::Result<()> { .post( "/accounts/create", CreateAccount { - name: "lothar".to_owned(), + username: "lothar".to_owned(), password: "kreyssig".to_owned().into(), }, ) @@ -209,7 +209,7 @@ async fn test_wrong_password() -> anyhow::Result<()> { .post( "/accounts/login", Login { - name: "lothar".to_owned(), + username: "lothar".to_owned(), password: "anders++".to_owned().into(), }, ) @@ -222,7 +222,7 @@ async fn test_wrong_password() -> anyhow::Result<()> { .post( "/accounts/login", Login { - name: "lothar".to_owned(), + username: "lothar".to_owned(), password: "kreyssig".to_owned().into(), }, ) @@ -235,7 +235,7 @@ async fn test_wrong_password() -> anyhow::Result<()> { .post( "/accounts/login", Login { - name: "lothar".to_owned(), + username: "lothar".to_owned(), password: "anders".to_owned().into(), }, ) @@ -252,7 +252,7 @@ async fn test_wrong_password() -> anyhow::Result<()> { .post( "/accounts/login", Login { - name: "lothar".to_owned(), + username: "lothar".to_owned(), password: "kreyssig".to_owned().into(), }, ) @@ -275,8 +275,9 @@ async fn test_too_long_username() -> anyhow::Result<()> { .post( "/accounts/create", CreateAccount { - name: "else-else-else-else-else-else-else-else-else-else-else-else-else-else-else" - .to_owned(), + username: + "else-else-else-else-else-else-else-else-else-else-else-else-else-else-else" + .to_owned(), password: "hirsch😀😀".to_owned().into(), }, ) @@ -291,7 +292,7 @@ async fn test_too_long_password() -> anyhow::Result<()> { .post( "/accounts/create", CreateAccount { - name: "josef" + username: "josef" .to_owned(), password: "höhn-höhn-höhn-höhn-höhn-höhn-höhn-höhn-höhn-höhn-höhn-höhn-höhn-höhn-höhn-höhn-höhn-höhn-höhn-höhn-höhn-höhn-höhn-höhn-höhn-höhn-höhn-höhn-höhn-höhn-höhn".to_owned().into(), }, @@ -307,7 +308,7 @@ async fn test_too_short_username() -> anyhow::Result<()> { .post( "/accounts/create", CreateAccount { - name: "K.".to_owned(), + username: "K.".to_owned(), password: "ibach😀😀😀".to_owned().into(), }, ) @@ -322,7 +323,7 @@ async fn test_too_short_password() -> anyhow::Result<()> { .post( "/accounts/create", CreateAccount { - name: "hans".to_owned(), + username: "hans".to_owned(), password: "ils".to_owned().into(), }, ) @@ -337,7 +338,7 @@ async fn test_wrong_username_login() -> anyhow::Result<()> { .post( "/accounts/login", Login { - name: "peter".to_owned(), + username: "peter".to_owned(), password: "hüppeler".to_owned().into(), }, ) @@ -352,7 +353,7 @@ async fn test_too_long_password_login() -> anyhow::Result<()> { .post( "/accounts/create", CreateAccount { - name: "alois".to_owned(), + username: "alois".to_owned(), password: "hundhammer".to_owned().into(), }, ) @@ -364,7 +365,7 @@ async fn test_too_long_password_login() -> anyhow::Result<()> { .post( "/accounts/login", Login { - name: "alois".to_owned(), + username: "alois".to_owned(), password: "hundhammer-hundhammer-hundhammer-hundhammer-hundhammer-hundhammer-hundhammer-hundhammer-hundhammer-hundhammer-hundhammer-hundhammer".to_owned().into(), }, ) diff --git a/backend/rvoc-backend/src/model/user/mod.rs b/backend/rvoc-backend/src/model/user/mod.rs index fb0772e..8b4baff 100644 --- a/backend/rvoc-backend/src/model/user/mod.rs +++ b/backend/rvoc-backend/src/model/user/mod.rs @@ -1,5 +1,7 @@ use diesel::prelude::Insertable; +use crate::{configuration::Configuration, error::RVocResult}; + use self::password_hash::PasswordHash; pub mod password_hash; @@ -22,8 +24,10 @@ pub struct Username { } impl Username { - pub fn new(name: String) -> Self { - Self { name } + pub fn new(name: String, configuration: impl AsRef) -> RVocResult { + configuration.as_ref().verify_username_length(&name)?; + + Ok(Self { name }) } } diff --git a/backend/rvoc-backend/src/model/user/password_hash.rs b/backend/rvoc-backend/src/model/user/password_hash.rs index 34eb29b..c569b16 100644 --- a/backend/rvoc-backend/src/model/user/password_hash.rs +++ b/backend/rvoc-backend/src/model/user/password_hash.rs @@ -34,14 +34,7 @@ impl PasswordHash { configuration: impl AsRef, ) -> RVocResult { let configuration = configuration.as_ref(); - - // the password length should be checked at the point where we have the password as string. - let plaintext_password_length = plaintext_password.unsecure().len(); - assert!( - plaintext_password_length >= configuration.minimum_password_length - // times 4 because this is the length in bytes, and not in unicode code points - && plaintext_password_length <= configuration.maximum_password_length * 4 - ); + configuration.verify_password_length(&plaintext_password)?; let salt = SaltString::generate(&mut OsRng); @@ -73,6 +66,8 @@ impl PasswordHash { }; let configuration = configuration.as_ref(); + configuration.verify_password_length(&plaintext_password)?; + let parsed_hash = argon2::password_hash::PasswordHash::new(argon_hash.unsecure()).map_err(|error| { RVocError::PasswordArgon2IdVerify { diff --git a/backend/rvoc-backend/src/web/authentication.rs b/backend/rvoc-backend/src/web/authentication.rs index 61494d0..52898c1 100644 --- a/backend/rvoc-backend/src/web/authentication.rs +++ b/backend/rvoc-backend/src/web/authentication.rs @@ -43,8 +43,8 @@ pub async fn login( // any failed login attempt should cause a logout *session.data_mut() = RVocSessionData::Anonymous; - configuration.verify_username_length(&login.name)?; - configuration.verify_password_length(&login.password)?; + let Login { username, password } = login; + let username = Username::new(username, &configuration)?; database_connection_pool .execute_transaction::<_, RVocError>( @@ -60,7 +60,7 @@ pub async fn login( // get password hash let password_hash: String = if let Some(password_hash) = users::table .select(users::password_hash) - .filter(users::name.eq(&login.name)) + .filter(users::name.eq(username.as_ref())) .first(database_connection) .await .optional()? @@ -69,29 +69,29 @@ pub async fn login( password_hash } else { // Here the optional() returned a row, but with a null password hash. - info!("User has no password: {:?}", login.name); + info!("User has no password: {:?}", username); return Err(UserError::InvalidUsernamePassword.into()); } } else { // Here the optional() returned None, i.e. no row was found. - info!("User not found: {:?}", login.name); + info!("User not found: {:?}", username); return Err(UserError::InvalidUsernamePassword.into()); }; // verify password hash let mut password_hash = PasswordHash::from(password_hash); let verify_result = - password_hash.verify(login.password.clone(), configuration)?; + password_hash.verify(password.clone(), configuration)?; if !verify_result.matches { - info!("Wrong password for user: {:?}", login.name); + info!("Wrong password for user: {:?}", username); return Err(UserError::InvalidUsernamePassword.into()); } // update password hash if modified if verify_result.modified { let affected_rows = diesel::update(users::table) - .filter(users::name.eq(&login.name)) + .filter(users::name.eq(username.as_ref())) .set(users::password_hash.eq(Option::::from(password_hash))) .execute(database_connection) .await?; @@ -116,7 +116,7 @@ pub async fn login( }, })?; - *session.data_mut() = RVocSessionData::LoggedIn(Username::new(login.name.clone())); + *session.data_mut() = RVocSessionData::LoggedIn(username); Ok(StatusCode::NO_CONTENT) } diff --git a/backend/rvoc-backend/src/web/mod.rs b/backend/rvoc-backend/src/web/mod.rs index 0666850..7b313a6 100644 --- a/backend/rvoc-backend/src/web/mod.rs +++ b/backend/rvoc-backend/src/web/mod.rs @@ -44,6 +44,8 @@ pub async fn run_web_api( StatusCode::INTERNAL_SERVER_ERROR } + let configuration = Arc::new(configuration.clone()); + let router = Router::new() .route("/accounts/delete", delete(delete_account)) .route("/accounts/logout", post(logout)) @@ -59,10 +61,10 @@ pub async fn run_web_api( ) .layer(Extension(RVocSessionStoreConnector::new( database_connection_pool.clone(), - configuration, + configuration.clone(), ))) .layer(Extension(database_connection_pool)) - .layer(Extension(Arc::new(configuration.clone()))); + .layer(Extension(configuration.clone())); debug!( "Listening for API requests on {}", diff --git a/backend/rvoc-backend/src/web/session.rs b/backend/rvoc-backend/src/web/session.rs index 49684a8..bce869b 100644 --- a/backend/rvoc-backend/src/web/session.rs +++ b/backend/rvoc-backend/src/web/session.rs @@ -1,3 +1,5 @@ +use std::sync::Arc; + use async_trait::async_trait; use chrono::{DateTime, Utc}; use diesel::{Insertable, Queryable, Selectable}; @@ -21,8 +23,7 @@ use crate::{ #[derive(Clone)] pub struct RVocSessionStoreConnector { database_connection_pool: RVocAsyncDatabaseConnectionPool, - maximum_retries_on_id_collision: u32, - maximum_transaction_retry_count: u64, + configuration: Arc, } #[derive(Default, Debug)] @@ -35,13 +36,11 @@ pub enum RVocSessionData { impl RVocSessionStoreConnector { pub fn new( database_connection_pool: RVocAsyncDatabaseConnectionPool, - configuration: &Configuration, + configuration: Arc, ) -> Self { Self { database_connection_pool, - maximum_retries_on_id_collision: configuration - .maximum_session_id_generation_retry_count, - maximum_transaction_retry_count: configuration.maximum_transaction_retry_count, + configuration, } } } @@ -51,7 +50,7 @@ impl SessionStoreConnector for RVocSessionStoreConnector { type Error = RVocError; fn maximum_retries_on_id_collision(&self) -> Option { - Some(self.maximum_retries_on_id_collision) + Some(self.configuration.maximum_session_id_generation_retry_count) } async fn create_session( @@ -85,7 +84,7 @@ impl SessionStoreConnector for RVocSessionStoreConnector { Ok(()) }) }, - self.maximum_transaction_retry_count, + self.configuration.maximum_transaction_retry_count, ) .await { @@ -128,7 +127,7 @@ impl SessionStoreConnector for RVocSessionStoreConnector { .map_err(TransactionError::from) }) }, - self.maximum_transaction_retry_count, + self.configuration.maximum_transaction_retry_count, ) .await .map_err(|error| { @@ -143,7 +142,9 @@ impl SessionStoreConnector for RVocSessionStoreConnector { SessionExpiry::DateTime(queryable.expiry) }; let data = match queryable.username { - Some(name) => RVocSessionData::LoggedIn(Username::new(name)), + Some(username) => { + RVocSessionData::LoggedIn(Username::new(username, &self.configuration)?) + } None => RVocSessionData::Anonymous, }; @@ -200,7 +201,7 @@ impl SessionStoreConnector for RVocSessionStoreConnector { Ok(()) }) }, - self.maximum_transaction_retry_count, + self.configuration.maximum_transaction_retry_count, ) .await { @@ -245,7 +246,7 @@ impl SessionStoreConnector for RVocSessionStoreConnector { Ok(()) }) - }, self.maximum_transaction_retry_count) + }, self.configuration.maximum_transaction_retry_count) .await .map_err(|error|typed_session::Error::SessionStoreConnector(RVocError::DeleteSession {source: Box::new(error)})) } @@ -264,7 +265,7 @@ impl SessionStoreConnector for RVocSessionStoreConnector { .map_err(Into::into) }) }, - self.maximum_transaction_retry_count, + self.configuration.maximum_transaction_retry_count, ) .await .map(|_| ()) diff --git a/backend/rvoc-backend/src/web/user.rs b/backend/rvoc-backend/src/web/user.rs index 571693e..1f0645a 100644 --- a/backend/rvoc-backend/src/web/user.rs +++ b/backend/rvoc-backend/src/web/user.rs @@ -18,12 +18,12 @@ pub async fn create_account( Extension(configuration): WebConfiguration, Json(create_account): Json, ) -> RVocResult { - configuration.verify_username_length(&create_account.name)?; - configuration.verify_password_length(&create_account.password)?; + let CreateAccount { username, password } = create_account; + let username = Username::new(username, &configuration)?; let user = User { - name: Username::new(create_account.name), - password_hash: PasswordHash::new(create_account.password, &configuration)?, + name: username, + password_hash: PasswordHash::new(password, &configuration)?, }; database_connection_pool From 8c5780796cf2c153ba6827356dbd056f56f79ef1 Mon Sep 17 00:00:00 2001 From: Sebastian Schmidt Date: Wed, 25 Oct 2023 11:14:44 +0300 Subject: [PATCH 14/15] Add CLI command to set user password. --- backend/rvoc-backend/src/cli/mod.rs | 72 +++++++++++++++++-- backend/rvoc-backend/src/database/mod.rs | 1 + backend/rvoc-backend/src/error/mod.rs | 3 + backend/rvoc-backend/src/model/user/mod.rs | 30 +------- .../src/model/user/password_hash.rs | 12 ++++ .../rvoc-backend/src/model/user/username.rs | 26 +++++++ .../rvoc-backend/src/web/authentication.rs | 2 +- backend/rvoc-backend/src/web/session.rs | 2 +- backend/rvoc-backend/src/web/user.rs | 2 +- 9 files changed, 113 insertions(+), 37 deletions(-) create mode 100644 backend/rvoc-backend/src/model/user/username.rs diff --git a/backend/rvoc-backend/src/cli/mod.rs b/backend/rvoc-backend/src/cli/mod.rs index 655eee8..607a644 100644 --- a/backend/rvoc-backend/src/cli/mod.rs +++ b/backend/rvoc-backend/src/cli/mod.rs @@ -1,7 +1,10 @@ use std::sync::{atomic, Arc}; +use api_commands::SecBytes; use clap::Parser; use diesel_async::RunQueryDsl; +use secstr::SecUtf8; +use tokio::io::{stdin, AsyncReadExt}; use tracing::{debug, info, instrument}; use crate::{ @@ -13,6 +16,7 @@ use crate::{ error::RVocError, error::RVocResult, job_queue::{jobs::update_witkionary::run_update_wiktionary, spawn_job_queue_runner}, + model::user::password_hash::PasswordHash, web::run_web_api, }; @@ -35,6 +39,18 @@ enum Cli { /// Expire the passwords of all users. ExpireAllPasswords, + + /// Set the password of a user. + /// If no password is given, then it is read from stdin. + SetPassword { + /// The name of the user. + #[arg(short, long)] + username: String, + /// The new password. + /// If not given, then it is read from stdin. + #[arg(short, long)] + password: Option, + }, } #[instrument(skip(configuration))] @@ -53,6 +69,9 @@ pub async fn run_cli_command(configuration: &Configuration) -> RVocResult<()> { } Cli::ApplyMigrations => apply_pending_database_migrations(configuration).await?, Cli::ExpireAllPasswords => expire_all_passwords(configuration).await?, + Cli::SetPassword { username, password } => { + set_password(username, password, configuration).await? + } } Ok(()) @@ -62,8 +81,6 @@ pub async fn run_cli_command(configuration: &Configuration) -> RVocResult<()> { async fn run_rvoc_backend(configuration: &Configuration) -> RVocResult<()> { debug!("Running rvoc backend with configuration: {configuration:#?}"); - // Create database connection pool. - // (This does not actually connect to the database, connections are created lazily.) let database_connection_pool = create_async_database_connection_pool(configuration).await?; // Create shutdown flag. @@ -110,8 +127,6 @@ async fn apply_pending_database_migrations(configuration: &Configuration) -> RVo #[instrument(err, skip(configuration))] async fn expire_all_passwords(configuration: &Configuration) -> RVocResult<()> { - // Create database connection pool. - // (This does not actually connect to the database, connections are created lazily.) let database_connection_pool = create_async_database_connection_pool(configuration).await?; database_connection_pool @@ -137,7 +152,52 @@ async fn expire_all_passwords(configuration: &Configuration) -> RVocResult<()> { ) .await?; - todo!() + Ok(()) +} + +#[instrument(err, skip(configuration))] +async fn set_password( + username: String, + password: Option, + configuration: &Configuration, +) -> RVocResult<()> { + let password = if let Some(password) = password { + password + } else { + let mut password = Vec::new(); + stdin().read_to_end(&mut password).await.map_err(|error| { + RVocError::ReadPasswordFromStdin { + source: Box::new(error), + } + })?; + SecBytes::from(password) + }; + + let password_hash = PasswordHash::new(password, configuration)?; + let password_hash = Option::::from(password_hash).expect( + "creating a password hash from a password should never return an empty password hash", + ); + + let database_connection_pool = create_async_database_connection_pool(configuration).await?; + + database_connection_pool + .execute_transaction::<_, RVocError>( + |database_connection| { + Box::pin(async { + use crate::database::schema::users; + use diesel::ExpressionMethods; + + diesel::update(users::table) + .filter(users::name.eq(&username)) + .set(users::password_hash.eq(password_hash.unsecure())) + .execute(database_connection) + .await + .map_err(Into::into) + }) + }, + configuration.maximum_transaction_retry_count, + ) + .await?; - //Ok(()) + Ok(()) } diff --git a/backend/rvoc-backend/src/database/mod.rs b/backend/rvoc-backend/src/database/mod.rs index e535d34..08d1a95 100644 --- a/backend/rvoc-backend/src/database/mod.rs +++ b/backend/rvoc-backend/src/database/mod.rs @@ -17,6 +17,7 @@ mod sync_connection; pub mod transactions; /// Create an async connection pool to the database. +/// Note that this does not actually open any connections to the database, the connections are opened lazily. /// /// If there are pending database migrations, this method returns an error. pub async fn create_async_database_connection_pool( diff --git a/backend/rvoc-backend/src/error/mod.rs b/backend/rvoc-backend/src/error/mod.rs index 19fa8ea..10de632 100644 --- a/backend/rvoc-backend/src/error/mod.rs +++ b/backend/rvoc-backend/src/error/mod.rs @@ -90,6 +90,9 @@ pub enum RVocError { #[error("error expiring all passwords: {source}")] ExpireAllPasswords { source: BoxDynError }, + #[error("error reading password from stdin: {source}")] + ReadPasswordFromStdin { source: BoxDynError }, + #[error("error deleting all user sessions: {source}")] DeleteAllUserSessions { source: BoxDynError }, diff --git a/backend/rvoc-backend/src/model/user/mod.rs b/backend/rvoc-backend/src/model/user/mod.rs index 8b4baff..7237bee 100644 --- a/backend/rvoc-backend/src/model/user/mod.rs +++ b/backend/rvoc-backend/src/model/user/mod.rs @@ -1,10 +1,9 @@ use diesel::prelude::Insertable; -use crate::{configuration::Configuration, error::RVocResult}; - -use self::password_hash::PasswordHash; +use self::{password_hash::PasswordHash, username::Username}; pub mod password_hash; +pub mod username; #[derive(Insertable, Clone, Debug)] #[diesel(table_name = crate::database::schema::users)] @@ -17,28 +16,3 @@ pub struct User { #[diesel(serialize_as = Option)] pub password_hash: PasswordHash, } - -#[derive(Debug, Clone)] -pub struct Username { - name: String, -} - -impl Username { - pub fn new(name: String, configuration: impl AsRef) -> RVocResult { - configuration.as_ref().verify_username_length(&name)?; - - Ok(Self { name }) - } -} - -impl AsRef for Username { - fn as_ref(&self) -> &str { - &self.name - } -} - -impl From for String { - fn from(value: Username) -> Self { - value.name - } -} diff --git a/backend/rvoc-backend/src/model/user/password_hash.rs b/backend/rvoc-backend/src/model/user/password_hash.rs index c569b16..a236767 100644 --- a/backend/rvoc-backend/src/model/user/password_hash.rs +++ b/backend/rvoc-backend/src/model/user/password_hash.rs @@ -157,6 +157,12 @@ impl From for Option { } } +impl From for Option { + fn from(value: PasswordHash) -> Self { + value.argon_hash + } +} + impl From> for PasswordHash { fn from(value: Option) -> Self { Self { @@ -165,6 +171,12 @@ impl From> for PasswordHash { } } +impl From> for PasswordHash { + fn from(value: Option) -> Self { + Self { argon_hash: value } + } +} + impl From for PasswordHash { fn from(value: String) -> Self { Self { diff --git a/backend/rvoc-backend/src/model/user/username.rs b/backend/rvoc-backend/src/model/user/username.rs new file mode 100644 index 0000000..dabfbd4 --- /dev/null +++ b/backend/rvoc-backend/src/model/user/username.rs @@ -0,0 +1,26 @@ +use crate::{configuration::Configuration, error::RVocResult}; + +#[derive(Debug, Clone)] +pub struct Username { + name: String, +} + +impl Username { + pub fn new(name: String, configuration: impl AsRef) -> RVocResult { + configuration.as_ref().verify_username_length(&name)?; + + Ok(Self { name }) + } +} + +impl AsRef for Username { + fn as_ref(&self) -> &str { + &self.name + } +} + +impl From for String { + fn from(value: Username) -> Self { + value.name + } +} diff --git a/backend/rvoc-backend/src/web/authentication.rs b/backend/rvoc-backend/src/web/authentication.rs index 52898c1..7c87d2d 100644 --- a/backend/rvoc-backend/src/web/authentication.rs +++ b/backend/rvoc-backend/src/web/authentication.rs @@ -11,7 +11,7 @@ use typed_session_axum::{SessionHandle, WritableSession}; use crate::{ error::{RVocError, RVocResult, UserError}, - model::user::{password_hash::PasswordHash, Username}, + model::user::{password_hash::PasswordHash, username::Username}, }; use super::{session::RVocSessionData, WebConfiguration, WebDatabaseConnectionPool}; diff --git a/backend/rvoc-backend/src/web/session.rs b/backend/rvoc-backend/src/web/session.rs index bce869b..c78c2d8 100644 --- a/backend/rvoc-backend/src/web/session.rs +++ b/backend/rvoc-backend/src/web/session.rs @@ -17,7 +17,7 @@ use crate::{ RVocAsyncDatabaseConnectionPool, }, error::{BoxDynError, RVocError}, - model::user::Username, + model::user::username::Username, }; #[derive(Clone)] diff --git a/backend/rvoc-backend/src/web/user.rs b/backend/rvoc-backend/src/web/user.rs index 1f0645a..5aaa4a7 100644 --- a/backend/rvoc-backend/src/web/user.rs +++ b/backend/rvoc-backend/src/web/user.rs @@ -1,6 +1,6 @@ use crate::{ error::{RVocError, RVocResult, UserError}, - model::user::{password_hash::PasswordHash, User, Username}, + model::user::{password_hash::PasswordHash, username::Username, User}, }; use api_commands::CreateAccount; use axum::{http::StatusCode, Extension, Json}; From bf2be51029a10549c22c2c576e3d5fbdacecaa3c Mon Sep 17 00:00:00 2001 From: Sebastian Schmidt Date: Wed, 25 Oct 2023 11:18:14 +0300 Subject: [PATCH 15/15] Document what happens if passwords are updated while being expired. --- backend/rvoc-backend/src/cli/mod.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backend/rvoc-backend/src/cli/mod.rs b/backend/rvoc-backend/src/cli/mod.rs index 607a644..bff6e73 100644 --- a/backend/rvoc-backend/src/cli/mod.rs +++ b/backend/rvoc-backend/src/cli/mod.rs @@ -38,6 +38,8 @@ enum Cli { ApplyMigrations, /// Expire the passwords of all users. + /// This should always succeed, and users who update their passwords simultaneously should receive an error. + /// Note that this does not expire all sessions. ExpireAllPasswords, /// Set the password of a user.