From 8a086c82405bc8e2c8cb2fbbcceb10418f231d1b Mon Sep 17 00:00:00 2001 From: TKilFree Date: Fri, 23 Jun 2023 10:47:12 +0100 Subject: [PATCH] feat: re-added captcha checks (#3249) --- Cargo.lock | 2 + crates/api/Cargo.toml | 1 + crates/api/src/lib.rs | 16 ++ crates/api/src/local_user/get_captcha.rs | 53 ++++++ crates/api/src/local_user/mod.rs | 1 + crates/api_crud/Cargo.toml | 1 + crates/api_crud/src/user/create.rs | 18 ++ crates/db_schema/src/diesel_ltree.patch | 26 +-- crates/db_schema/src/impls/captcha_answer.rs | 164 ++++++++++++++++++ crates/db_schema/src/impls/mod.rs | 1 + crates/db_schema/src/schema.rs | 9 + crates/db_schema/src/source/captcha_answer.rs | 14 ++ crates/db_schema/src/source/mod.rs | 1 + .../2023-06-21-153242_add_captcha/down.sql | 1 + .../2023-06-21-153242_add_captcha/up.sql | 5 + src/api_routes_http.rs | 7 + 16 files changed, 300 insertions(+), 20 deletions(-) create mode 100644 crates/api/src/local_user/get_captcha.rs create mode 100644 crates/db_schema/src/impls/captcha_answer.rs create mode 100644 crates/db_schema/src/source/captcha_answer.rs create mode 100644 migrations/2023-06-21-153242_add_captcha/down.sql create mode 100644 migrations/2023-06-21-153242_add_captcha/up.sql diff --git a/Cargo.lock b/Cargo.lock index cee02f798..08437ecf7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2526,6 +2526,7 @@ dependencies = [ "base64 0.13.1", "bcrypt", "captcha", + "chrono", "lemmy_api_common", "lemmy_db_schema", "lemmy_db_views", @@ -2576,6 +2577,7 @@ dependencies = [ "actix-web", "async-trait", "bcrypt", + "chrono", "lemmy_api_common", "lemmy_db_schema", "lemmy_db_views", diff --git a/crates/api/Cargo.toml b/crates/api/Cargo.toml index 2488f2c2c..ca792809b 100644 --- a/crates/api/Cargo.toml +++ b/crates/api/Cargo.toml @@ -29,6 +29,7 @@ async-trait = { workspace = true } captcha = { workspace = true } anyhow = { workspace = true } tracing = { workspace = true } +chrono = { workspace = true } [dev-dependencies] serial_test = { workspace = true } diff --git a/crates/api/src/lib.rs b/crates/api/src/lib.rs index 9ff1677d0..615a8a314 100644 --- a/crates/api/src/lib.rs +++ b/crates/api/src/lib.rs @@ -1,4 +1,5 @@ use actix_web::web::Data; +use captcha::Captcha; use lemmy_api_common::{context::LemmyContext, utils::local_site_to_slur_regex}; use lemmy_db_schema::source::local_site::LocalSite; use lemmy_utils::{error::LemmyError, utils::slurs::check_slurs}; @@ -20,6 +21,21 @@ pub trait Perform { async fn perform(&self, context: &Data) -> Result; } +/// Converts the captcha to a base64 encoded wav audio file +pub(crate) fn captcha_as_wav_base64(captcha: &Captcha) -> String { + let letters = captcha.as_wav(); + + let mut concat_letters: Vec = Vec::new(); + + for letter in letters { + let bytes = letter.unwrap_or_default(); + concat_letters.extend(bytes); + } + + // Convert to base64 + base64::encode(concat_letters) +} + /// Check size of report and remove whitespace pub(crate) fn check_report_reason(reason: &str, local_site: &LocalSite) -> Result<(), LemmyError> { let slur_regex = &local_site_to_slur_regex(local_site); diff --git a/crates/api/src/local_user/get_captcha.rs b/crates/api/src/local_user/get_captcha.rs new file mode 100644 index 000000000..6dbc34823 --- /dev/null +++ b/crates/api/src/local_user/get_captcha.rs @@ -0,0 +1,53 @@ +use crate::{captcha_as_wav_base64, Perform}; +use actix_web::web::Data; +use captcha::{gen, Difficulty}; +use chrono::Duration; +use lemmy_api_common::{ + context::LemmyContext, + person::{CaptchaResponse, GetCaptcha, GetCaptchaResponse}, +}; +use lemmy_db_schema::{ + source::{captcha_answer::CaptchaAnswer, local_site::LocalSite}, + utils::naive_now, +}; +use lemmy_utils::error::LemmyError; + +#[async_trait::async_trait(?Send)] +impl Perform for GetCaptcha { + type Response = GetCaptchaResponse; + + #[tracing::instrument(skip(context))] + async fn perform(&self, context: &Data) -> Result { + let local_site = LocalSite::read(context.pool()).await?; + + if !local_site.captcha_enabled { + return Ok(GetCaptchaResponse { ok: None }); + } + + let captcha = gen(match local_site.captcha_difficulty.as_str() { + "easy" => Difficulty::Easy, + "hard" => Difficulty::Hard, + _ => Difficulty::Medium, + }); + + let answer = captcha.chars_as_string(); + + let png = captcha.as_base64().expect("failed to generate captcha"); + + let uuid = uuid::Uuid::new_v4().to_string(); + + let wav = captcha_as_wav_base64(&captcha); + + let captcha: CaptchaAnswer = CaptchaAnswer { + answer, + uuid: uuid.clone(), + expires: naive_now() + Duration::minutes(10), // expires in 10 minutes + }; + // Stores the captcha item in the db + CaptchaAnswer::insert(context.pool(), &captcha).await?; + + Ok(GetCaptchaResponse { + ok: Some(CaptchaResponse { png, wav, uuid }), + }) + } +} diff --git a/crates/api/src/local_user/mod.rs b/crates/api/src/local_user/mod.rs index 9244f825d..3a92beda5 100644 --- a/crates/api/src/local_user/mod.rs +++ b/crates/api/src/local_user/mod.rs @@ -3,6 +3,7 @@ mod ban_person; mod block; mod change_password; mod change_password_after_reset; +mod get_captcha; mod list_banned; mod login; mod notifications; diff --git a/crates/api_crud/Cargo.toml b/crates/api_crud/Cargo.toml index 1fb1e5a66..21320a3c3 100644 --- a/crates/api_crud/Cargo.toml +++ b/crates/api_crud/Cargo.toml @@ -22,3 +22,4 @@ tracing = { workspace = true } url = { workspace = true } async-trait = { workspace = true } webmention = "0.4.0" +chrono = { worspace = true } diff --git a/crates/api_crud/src/user/create.rs b/crates/api_crud/src/user/create.rs index f5a26f756..871a05d6f 100644 --- a/crates/api_crud/src/user/create.rs +++ b/crates/api_crud/src/user/create.rs @@ -1,6 +1,7 @@ use crate::PerformCrud; use activitypub_federation::http_signatures::generate_actor_keypair; use actix_web::web::Data; +use chrono::NaiveDateTime; use lemmy_api_common::{ context::LemmyContext, person::{LoginResponse, Register}, @@ -19,6 +20,7 @@ use lemmy_api_common::{ use lemmy_db_schema::{ aggregates::structs::PersonAggregates, source::{ + captcha_answer::CaptchaAnswer, local_user::{LocalUser, LocalUserInsertForm}, person::{Person, PersonInsertForm}, registration_application::{RegistrationApplication, RegistrationApplicationInsertForm}, @@ -71,6 +73,22 @@ impl PerformCrud for Register { return Err(LemmyError::from_message("passwords_dont_match")); } + if local_site.site_setup && local_site.captcha_enabled { + let check = CaptchaAnswer::check_captcha( + context.pool(), + CaptchaAnswer { + uuid: data.captcha_uuid.clone().unwrap_or_default(), + answer: data.captcha_answer.clone().unwrap_or_default(), + // not used when checking + expires: NaiveDateTime::MIN, + }, + ) + .await?; + if !check { + return Err(LemmyError::from_message("captcha_incorrect")); + } + } + let slur_regex = local_site_to_slur_regex(&local_site); check_slurs(&data.username, &slur_regex)?; check_slurs_opt(&data.answer, &slur_regex)?; diff --git a/crates/db_schema/src/diesel_ltree.patch b/crates/db_schema/src/diesel_ltree.patch index d7d49f03e..2607eb68b 100644 --- a/crates/db_schema/src/diesel_ltree.patch +++ b/crates/db_schema/src/diesel_ltree.patch @@ -1,28 +1,17 @@ -diff --git a/crates/db_schema/src/schema.rs b/crates/db_schema/src/schema.rs -index 255c6422..f2ccf5e2 100644 ---- a/crates/db_schema/src/schema.rs -+++ b/crates/db_schema/src/schema.rs -@@ -2,16 +2,12 @@ - - pub mod sql_types { - #[derive(diesel::sql_types::SqlType)] - #[diesel(postgres_type(name = "listing_type_enum"))] +--- schema.rs 2023-06-21 22:25:50.252384233 +0100 ++++ "schema copy.rs" 2023-06-21 22:26:50.452378651 +0100 +@@ -6,10 +6,6 @@ pub struct ListingTypeEnum; -- #[derive(diesel::sql_types::SqlType)] + #[derive(diesel::sql_types::SqlType)] - #[diesel(postgres_type(name = "ltree"))] - pub struct Ltree; - - #[derive(diesel::sql_types::SqlType)] +- #[derive(diesel::sql_types::SqlType)] #[diesel(postgres_type(name = "registration_mode_enum"))] pub struct RegistrationModeEnum; - #[derive(diesel::sql_types::SqlType)] - #[diesel(postgres_type(name = "sort_type_enum"))] -@@ -67,13 +63,13 @@ diesel::table! { - when_ -> Timestamp, - } - } +@@ -78,7 +74,7 @@ diesel::table! { use diesel::sql_types::*; @@ -31,6 +20,3 @@ index 255c6422..f2ccf5e2 100644 comment (id) { id -> Int4, - creator_id -> Int4, - post_id -> Int4, - content -> Text, diff --git a/crates/db_schema/src/impls/captcha_answer.rs b/crates/db_schema/src/impls/captcha_answer.rs new file mode 100644 index 000000000..afd18181c --- /dev/null +++ b/crates/db_schema/src/impls/captcha_answer.rs @@ -0,0 +1,164 @@ +use crate::{ + schema::captcha_answer, + source::captcha_answer::CaptchaAnswer, + utils::{functions::lower, get_conn, naive_now, DbPool}, +}; +use diesel::{ + delete, + dsl::exists, + insert_into, + result::Error, + select, + ExpressionMethods, + QueryDsl, +}; +use diesel_async::RunQueryDsl; + +impl CaptchaAnswer { + pub async fn insert(pool: &DbPool, captcha: &CaptchaAnswer) -> Result { + let conn = &mut get_conn(pool).await?; + + insert_into(captcha_answer::table) + .values(captcha) + .get_result::(conn) + .await + } + + pub async fn check_captcha(pool: &DbPool, to_check: CaptchaAnswer) -> Result { + let conn = &mut get_conn(pool).await?; + + // delete any expired captchas + delete(captcha_answer::table.filter(captcha_answer::expires.lt(&naive_now()))) + .execute(conn) + .await?; + + // fetch requested captcha + let captcha_exists = select(exists( + captcha_answer::dsl::captcha_answer + .filter((captcha_answer::dsl::uuid).eq(to_check.uuid.clone())) + .filter(lower(captcha_answer::dsl::answer).eq(to_check.answer.to_lowercase().clone())), + )) + .get_result::(conn) + .await?; + + // delete checked captcha + delete(captcha_answer::table.filter(captcha_answer::uuid.eq(to_check.uuid.clone()))) + .execute(conn) + .await?; + + Ok(captcha_exists) + } +} + +#[cfg(test)] +mod tests { + use crate::{ + source::captcha_answer::CaptchaAnswer, + utils::{build_db_pool_for_tests, naive_now}, + }; + use chrono::Duration; + use serial_test::serial; + + #[tokio::test] + #[serial] + async fn test_captcha_happy_path() { + let pool = &build_db_pool_for_tests().await; + + let captcha_a_id = "a".to_string(); + + let _ = CaptchaAnswer::insert( + pool, + &CaptchaAnswer { + uuid: captcha_a_id.clone(), + answer: "XYZ".to_string(), + expires: naive_now() + Duration::minutes(10), + }, + ) + .await; + + let result = CaptchaAnswer::check_captcha( + pool, + CaptchaAnswer { + uuid: captcha_a_id.clone(), + answer: "xyz".to_string(), + expires: chrono::NaiveDateTime::MIN, + }, + ) + .await; + + assert!(result.is_ok()); + assert!(result.unwrap()); + } + + #[tokio::test] + #[serial] + async fn test_captcha_repeat_answer_fails() { + let pool = &build_db_pool_for_tests().await; + + let captcha_a_id = "a".to_string(); + + let _ = CaptchaAnswer::insert( + pool, + &CaptchaAnswer { + uuid: captcha_a_id.clone(), + answer: "XYZ".to_string(), + expires: naive_now() + Duration::minutes(10), + }, + ) + .await; + + let result = CaptchaAnswer::check_captcha( + pool, + CaptchaAnswer { + uuid: captcha_a_id.clone(), + answer: "xyz".to_string(), + expires: chrono::NaiveDateTime::MIN, + }, + ) + .await; + + let result_repeat = CaptchaAnswer::check_captcha( + pool, + CaptchaAnswer { + uuid: captcha_a_id.clone(), + answer: "xyz".to_string(), + expires: chrono::NaiveDateTime::MIN, + }, + ) + .await; + + assert!(result_repeat.is_ok()); + assert!(!result_repeat.unwrap()); + } + + #[tokio::test] + #[serial] + async fn test_captcha_expired_fails() { + let pool = &build_db_pool_for_tests().await; + + let expired_id = "already_expired".to_string(); + + let _ = CaptchaAnswer::insert( + pool, + &CaptchaAnswer { + uuid: expired_id.clone(), + answer: "xyz".to_string(), + expires: naive_now() - Duration::seconds(1), + }, + ) + .await; + + let expired_result = CaptchaAnswer::check_captcha( + pool, + CaptchaAnswer { + uuid: expired_id.clone(), + answer: "xyz".to_string(), + expires: chrono::NaiveDateTime::MIN, + }, + ) + .await; + + assert!(expired_result.is_ok()); + assert!(!expired_result.unwrap()); + } +} diff --git a/crates/db_schema/src/impls/mod.rs b/crates/db_schema/src/impls/mod.rs index 915d1c8e2..f13004d01 100644 --- a/crates/db_schema/src/impls/mod.rs +++ b/crates/db_schema/src/impls/mod.rs @@ -1,5 +1,6 @@ pub mod activity; pub mod actor_language; +pub mod captcha_answer; pub mod comment; pub mod comment_reply; pub mod comment_report; diff --git a/crates/db_schema/src/schema.rs b/crates/db_schema/src/schema.rs index ac4ddc47a..f244ae664 100644 --- a/crates/db_schema/src/schema.rs +++ b/crates/db_schema/src/schema.rs @@ -64,6 +64,14 @@ diesel::table! { } } +diesel::table! { + captcha_answer (uuid) { + uuid -> Text, + answer -> Text, + expires -> Timestamp, + } +} + diesel::table! { use diesel::sql_types::{Bool, Int4, Nullable, Text, Timestamp, Varchar}; use diesel_ltree::sql_types::Ltree; @@ -916,6 +924,7 @@ diesel::allow_tables_to_appear_in_same_query!( admin_purge_community, admin_purge_person, admin_purge_post, + captcha_answer, comment, comment_aggregates, comment_like, diff --git a/crates/db_schema/src/source/captcha_answer.rs b/crates/db_schema/src/source/captcha_answer.rs new file mode 100644 index 000000000..113b7c96a --- /dev/null +++ b/crates/db_schema/src/source/captcha_answer.rs @@ -0,0 +1,14 @@ +#[cfg(feature = "full")] +use crate::schema::captcha_answer; +use serde::{Deserialize, Serialize}; +use serde_with::skip_serializing_none; + +#[skip_serializing_none] +#[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "full", derive(Queryable, Insertable, AsChangeset))] +#[cfg_attr(feature = "full", diesel(table_name = captcha_answer))] +pub struct CaptchaAnswer { + pub uuid: String, + pub answer: String, + pub expires: chrono::NaiveDateTime, +} diff --git a/crates/db_schema/src/source/mod.rs b/crates/db_schema/src/source/mod.rs index 9aab4b90b..926e23e73 100644 --- a/crates/db_schema/src/source/mod.rs +++ b/crates/db_schema/src/source/mod.rs @@ -1,6 +1,7 @@ #[cfg(feature = "full")] pub mod activity; pub mod actor_language; +pub mod captcha_answer; pub mod comment; pub mod comment_reply; pub mod comment_report; diff --git a/migrations/2023-06-21-153242_add_captcha/down.sql b/migrations/2023-06-21-153242_add_captcha/down.sql new file mode 100644 index 000000000..4e5b83042 --- /dev/null +++ b/migrations/2023-06-21-153242_add_captcha/down.sql @@ -0,0 +1 @@ +drop table captcha_answer; \ No newline at end of file diff --git a/migrations/2023-06-21-153242_add_captcha/up.sql b/migrations/2023-06-21-153242_add_captcha/up.sql new file mode 100644 index 000000000..71467be61 --- /dev/null +++ b/migrations/2023-06-21-153242_add_captcha/up.sql @@ -0,0 +1,5 @@ +create table captcha_answer ( + uuid text not null primary key, + answer text not null, + expires timestamp not null +); diff --git a/src/api_routes_http.rs b/src/api_routes_http.rs index a2abfa690..375630a92 100644 --- a/src/api_routes_http.rs +++ b/src/api_routes_http.rs @@ -38,6 +38,7 @@ use lemmy_api_common::{ ChangePassword, DeleteAccount, GetBannedPersons, + GetCaptcha, GetPersonDetails, GetPersonMentions, GetReplies, @@ -272,6 +273,12 @@ pub fn config(cfg: &mut web::ServiceConfig, rate_limit: &RateLimitCell) { .wrap(rate_limit.register()) .route(web::post().to(route_post_crud::)), ) + .service( + // Handle captcha separately + web::resource("/user/get_captcha") + .wrap(rate_limit.post()) + .route(web::get().to(route_get::)), + ) // User actions .service( web::scope("/user")