]> Untitled Git - lemmy.git/commitdiff
feat: re-added captcha checks (#3289)
authorTKilFree <tristanfreeman1@gmail.com>
Tue, 27 Jun 2023 10:38:53 +0000 (11:38 +0100)
committerGitHub <noreply@github.com>
Tue, 27 Jun 2023 10:38:53 +0000 (06:38 -0400)
18 files changed:
Cargo.lock
crates/api/Cargo.toml
crates/api/src/lib.rs
crates/api/src/local_user/get_captcha.rs [new file with mode: 0644]
crates/api/src/local_user/mod.rs
crates/api_crud/Cargo.toml
crates/api_crud/src/user/create.rs
crates/db_schema/Cargo.toml
crates/db_schema/src/diesel_ltree.patch
crates/db_schema/src/impls/captcha_answer.rs [new file with mode: 0644]
crates/db_schema/src/impls/mod.rs
crates/db_schema/src/schema.rs
crates/db_schema/src/source/captcha_answer.rs [new file with mode: 0644]
crates/db_schema/src/source/mod.rs
migrations/2023-06-21-153242_add_captcha/down.sql [new file with mode: 0644]
migrations/2023-06-21-153242_add_captcha/up.sql [new file with mode: 0644]
src/api_routes_http.rs
src/scheduled_tasks.rs

index b01dd41c64d997d157785bd3f91ee7ff96e60405..39ef0d807c0569685b763fc2448ec952a4c6f88f 100644 (file)
@@ -1370,6 +1370,7 @@ dependencies = [
  "itoa",
  "pq-sys",
  "serde_json",
+ "uuid",
 ]
 
 [[package]]
@@ -2577,6 +2578,7 @@ dependencies = [
  "base64 0.13.1",
  "bcrypt",
  "captcha",
+ "chrono",
  "lemmy_api_common",
  "lemmy_db_schema",
  "lemmy_db_views",
@@ -2627,6 +2629,7 @@ dependencies = [
  "actix-web",
  "async-trait",
  "bcrypt",
+ "chrono",
  "lemmy_api_common",
  "lemmy_db_schema",
  "lemmy_db_views",
@@ -2635,6 +2638,7 @@ dependencies = [
  "serde",
  "tracing",
  "url",
+ "uuid",
  "webmention",
 ]
 
@@ -2710,6 +2714,7 @@ dependencies = [
  "ts-rs",
  "typed-builder",
  "url",
+ "uuid",
 ]
 
 [[package]]
index 2488f2c2c93caf69f195387a1d66787a8cb1086f..ca792809bed869ec39a4bbc28e960bfb4245e9c7 100644 (file)
@@ -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 }
index 9ff1677d0ed4950e5e28f81473b10661a9b892eb..615a8a3144fdea49e8d7bca4388a79c8c05acd23 100644 (file)
@@ -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<LemmyContext>) -> Result<Self::Response, LemmyError>;
 }
 
+/// 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<u8> = 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 (file)
index 0000000..1330442
--- /dev/null
@@ -0,0 +1,50 @@
+use crate::{captcha_as_wav_base64, Perform};
+use actix_web::web::Data;
+use captcha::{gen, Difficulty};
+use lemmy_api_common::{
+  context::LemmyContext,
+  person::{CaptchaResponse, GetCaptcha, GetCaptchaResponse},
+};
+use lemmy_db_schema::source::{
+  captcha_answer::{CaptchaAnswer, CaptchaAnswerForm},
+  local_site::LocalSite,
+};
+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<LemmyContext>) -> Result<Self::Response, LemmyError> {
+    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 wav = captcha_as_wav_base64(&captcha);
+
+    let captcha_form: CaptchaAnswerForm = CaptchaAnswerForm { answer };
+    // Stores the captcha item in the db
+    let captcha = CaptchaAnswer::insert(context.pool(), &captcha_form).await?;
+
+    Ok(GetCaptchaResponse {
+      ok: Some(CaptchaResponse {
+        png,
+        wav,
+        uuid: captcha.uuid.to_string(),
+      }),
+    })
+  }
+}
index 9244f825dd64941ffd0908a79bdd194064a55ffd..3a92beda57e21d7f5cfb928da9d47e684b0fd6f0 100644 (file)
@@ -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;
index 1fb1e5a664d2085379f47d9556a3d27148ae2b2d..abe747b154e2d93c1fcb51910dc7a9429d810ebc 100644 (file)
@@ -22,3 +22,5 @@ tracing = { workspace = true }
 url = { workspace = true }
 async-trait = { workspace = true }
 webmention = "0.4.0"
+chrono = { worspace = true }
+uuid = { workspace = true }
\ No newline at end of file
index f5a26f75634eee2b8f2270c92da1a929738b056b..302e2f98e62543265bd8415af69f0ae2ad94d507 100644 (file)
@@ -19,6 +19,7 @@ use lemmy_api_common::{
 use lemmy_db_schema::{
   aggregates::structs::PersonAggregates,
   source::{
+    captcha_answer::{CaptchaAnswer, CheckCaptchaAnswer},
     local_user::{LocalUser, LocalUserInsertForm},
     person::{Person, PersonInsertForm},
     registration_application::{RegistrationApplication, RegistrationApplicationInsertForm},
@@ -71,6 +72,25 @@ impl PerformCrud for Register {
       return Err(LemmyError::from_message("passwords_dont_match"));
     }
 
+    if local_site.site_setup && local_site.captcha_enabled {
+      if let Some(captcha_uuid) = &data.captcha_uuid {
+        let uuid = uuid::Uuid::parse_str(captcha_uuid)?;
+        let check = CaptchaAnswer::check_captcha(
+          context.pool(),
+          CheckCaptchaAnswer {
+            uuid,
+            answer: data.captcha_answer.clone().unwrap_or_default(),
+          },
+        )
+        .await?;
+        if !check {
+          return Err(LemmyError::from_message("captcha_incorrect"));
+        }
+      } else {
+        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)?;
index e99f3cd1cba82eb85fdb0e569d670f10369e6e9d..69affde883a7ca6d7fedb667c78df97dd5947288 100644 (file)
@@ -29,7 +29,7 @@ serde_json = { workspace = true, optional = true }
 activitypub_federation = { workspace = true, optional = true }
 lemmy_utils = { workspace = true, optional = true }
 bcrypt = { workspace = true, optional = true }
-diesel = { workspace = true, features = ["postgres","chrono", "serde_json"], optional = true }
+diesel = { workspace = true, features = ["postgres","chrono", "serde_json", "uuid"], optional = true }
 diesel-derive-newtype = { workspace = true, optional = true }
 diesel-derive-enum = { workspace = true, optional = true }
 diesel_migrations = { workspace = true, optional = true }
@@ -48,6 +48,7 @@ rustls = { workspace = true }
 futures-util = { workspace = true }
 tokio-postgres = { workspace = true }
 tokio-postgres-rustls = { workspace = true }
+uuid = { features = ["v4"] }
 
 [dev-dependencies]
 serial_test = { workspace = true }
index d7d49f03e93f9f3b73c3dd9563e2847456ef0d5b..ecbeb219320253e3ac8600eadd9b72bd15a3290f 100644 (file)
@@ -19,8 +19,8 @@ index 255c6422..f2ccf5e2 100644
  
      #[derive(diesel::sql_types::SqlType)]
      #[diesel(postgres_type(name = "sort_type_enum"))]
-@@ -67,13 +63,13 @@ diesel::table! {
-         when_ -> Timestamp,
+@@ -76,13 +76,13 @@ diesel::table! {
+         published -> Timestamp,
      }
  }
  
diff --git a/crates/db_schema/src/impls/captcha_answer.rs b/crates/db_schema/src/impls/captcha_answer.rs
new file mode 100644 (file)
index 0000000..de5fac6
--- /dev/null
@@ -0,0 +1,118 @@
+use crate::{
+  schema::captcha_answer::dsl::{answer, captcha_answer, uuid},
+  source::captcha_answer::{CaptchaAnswer, CaptchaAnswerForm, CheckCaptchaAnswer},
+  utils::{functions::lower, get_conn, 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: &CaptchaAnswerForm) -> Result<Self, Error> {
+    let conn = &mut get_conn(pool).await?;
+
+    insert_into(captcha_answer)
+      .values(captcha)
+      .get_result::<Self>(conn)
+      .await
+  }
+
+  pub async fn check_captcha(pool: &DbPool, to_check: CheckCaptchaAnswer) -> Result<bool, Error> {
+    let conn = &mut get_conn(pool).await?;
+
+    // fetch requested captcha
+    let captcha_exists = select(exists(
+      captcha_answer
+        .filter((uuid).eq(to_check.uuid))
+        .filter(lower(answer).eq(to_check.answer.to_lowercase().clone())),
+    ))
+    .get_result::<bool>(conn)
+    .await?;
+
+    // delete checked captcha
+    delete(captcha_answer.filter(uuid.eq(to_check.uuid)))
+      .execute(conn)
+      .await?;
+
+    Ok(captcha_exists)
+  }
+}
+
+#[cfg(test)]
+mod tests {
+  use crate::{
+    source::captcha_answer::{CaptchaAnswer, CaptchaAnswerForm, CheckCaptchaAnswer},
+    utils::build_db_pool_for_tests,
+  };
+  use serial_test::serial;
+
+  #[tokio::test]
+  #[serial]
+  async fn test_captcha_happy_path() {
+    let pool = &build_db_pool_for_tests().await;
+
+    let inserted = CaptchaAnswer::insert(
+      pool,
+      &CaptchaAnswerForm {
+        answer: "XYZ".to_string(),
+      },
+    )
+    .await
+    .expect("should not fail to insert captcha");
+
+    let result = CaptchaAnswer::check_captcha(
+      pool,
+      CheckCaptchaAnswer {
+        uuid: inserted.uuid,
+        answer: "xyz".to_string(),
+      },
+    )
+    .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 inserted = CaptchaAnswer::insert(
+      pool,
+      &CaptchaAnswerForm {
+        answer: "XYZ".to_string(),
+      },
+    )
+    .await
+    .expect("should not fail to insert captcha");
+
+    let _result = CaptchaAnswer::check_captcha(
+      pool,
+      CheckCaptchaAnswer {
+        uuid: inserted.uuid,
+        answer: "xyz".to_string(),
+      },
+    )
+    .await;
+
+    let result_repeat = CaptchaAnswer::check_captcha(
+      pool,
+      CheckCaptchaAnswer {
+        uuid: inserted.uuid,
+        answer: "xyz".to_string(),
+      },
+    )
+    .await;
+
+    assert!(result_repeat.is_ok());
+    assert!(!result_repeat.unwrap());
+  }
+}
index 915d1c8e2c99c9774a20620b29460f344ba7c3da..f13004d015deeffa5ace03a96ab9c003f9d5f582 100644 (file)
@@ -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;
index abd3ca22abd6e68847da30aa8062832f1bc729db..42946d69911a1a7553488e41706d678cfdc679c9 100644 (file)
@@ -64,6 +64,15 @@ diesel::table! {
     }
 }
 
+diesel::table! {
+    captcha_answer (id) {
+        id -> Int4,
+        uuid -> Uuid,
+        answer -> Text,
+        published -> Timestamp,
+    }
+}
+
 diesel::table! {
     use diesel::sql_types::*;
     use diesel_ltree::sql_types::Ltree;
@@ -914,6 +923,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 (file)
index 0000000..e3e64c4
--- /dev/null
@@ -0,0 +1,33 @@
+#[cfg(feature = "full")]
+use crate::schema::captcha_answer;
+use serde::{Deserialize, Serialize};
+use serde_with::skip_serializing_none;
+use uuid::Uuid;
+
+#[skip_serializing_none]
+#[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)]
+#[cfg_attr(feature = "full", derive(Queryable))]
+#[cfg_attr(feature = "full", diesel(table_name = captcha_answer))]
+pub struct CaptchaAnswer {
+  pub id: i32,
+  pub uuid: Uuid,
+  pub answer: String,
+  pub published: chrono::NaiveDateTime,
+}
+
+#[skip_serializing_none]
+#[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)]
+#[cfg_attr(feature = "full", derive(Queryable))]
+#[cfg_attr(feature = "full", diesel(table_name = captcha_answer))]
+pub struct CheckCaptchaAnswer {
+  pub uuid: Uuid,
+  pub answer: String,
+}
+
+#[skip_serializing_none]
+#[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)]
+#[cfg_attr(feature = "full", derive(Insertable, AsChangeset))]
+#[cfg_attr(feature = "full", diesel(table_name = captcha_answer))]
+pub struct CaptchaAnswerForm {
+  pub answer: String,
+}
index 9aab4b90b35753023f5142c22b74e2687521c5bf..926e23e73d314160f99ee87820fc309bf4fec600 100644 (file)
@@ -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 (file)
index 0000000..4e5b830
--- /dev/null
@@ -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 (file)
index 0000000..5c566bc
--- /dev/null
@@ -0,0 +1,6 @@
+create table captcha_answer (
+    id serial primary key,
+    uuid uuid not null unique default gen_random_uuid(),
+    answer text not null,
+    published timestamp not null default now()
+);
index a2abfa690f9b9d9b990a276528f11a175142b76e..375630a9243fc529c7227740e600746d3e7c2d39 100644 (file)
@@ -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::<Register>)),
       )
+      .service(
+        // Handle captcha separately
+        web::resource("/user/get_captcha")
+          .wrap(rate_limit.post())
+          .route(web::get().to(route_get::<GetCaptcha>)),
+      )
       // User actions
       .service(
         web::scope("/user")
index c67dac0a41f187b977b337eca83c7fc966cb0a6c..4d3c936e88b4cd49eac90c4473645be350c4ac9d 100644 (file)
@@ -13,7 +13,7 @@ use diesel::{
 use diesel::{sql_query, PgConnection, RunQueryDsl};
 use lemmy_api_common::context::LemmyContext;
 use lemmy_db_schema::{
-  schema::{activity, comment, community_person_ban, instance, person, post},
+  schema::{activity, captcha_answer, comment, community_person_ban, instance, person, post},
   source::instance::{Instance, InstanceForm},
   utils::{naive_now, DELETED_REPLACEMENT_TEXT},
 };
@@ -49,6 +49,13 @@ pub fn setup(
     update_hot_ranks(&mut conn, true);
   });
 
+  // Delete any captcha answers older than ten minutes, every ten minutes
+  let url = db_url.clone();
+  scheduler.every(CTimeUnits::minutes(10)).run(move || {
+    let mut conn = PgConnection::establish(&url).expect("could not establish connection");
+    delete_expired_captcha_answers(&mut conn);
+  });
+
   // Clear old activities every week
   let url = db_url.clone();
   scheduler.every(CTimeUnits::weeks(1)).run(move || {
@@ -181,6 +188,21 @@ fn process_hot_ranks_in_batches(
   );
 }
 
+fn delete_expired_captcha_answers(conn: &mut PgConnection) {
+  match diesel::delete(
+    captcha_answer::table.filter(captcha_answer::published.lt(now - IntervalDsl::minutes(10))),
+  )
+  .execute(conn)
+  {
+    Ok(_) => {
+      info!("Done.");
+    }
+    Err(e) => {
+      error!("Failed to clear old captcha answers: {}", e)
+    }
+  }
+}
+
 /// Clear old activities (this table gets very large)
 fn clear_old_activities(conn: &mut PgConnection) {
   info!("Clearing old activities...");