feat: re-added captcha checks (#3289)

This commit is contained in:
TKilFree 2023-06-27 11:38:53 +01:00 committed by GitHub
parent 76a4513774
commit 2aef6a5a33
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 299 additions and 4 deletions

5
Cargo.lock generated
View file

@ -1370,6 +1370,7 @@ dependencies = [
"itoa", "itoa",
"pq-sys", "pq-sys",
"serde_json", "serde_json",
"uuid",
] ]
[[package]] [[package]]
@ -2577,6 +2578,7 @@ dependencies = [
"base64 0.13.1", "base64 0.13.1",
"bcrypt", "bcrypt",
"captcha", "captcha",
"chrono",
"lemmy_api_common", "lemmy_api_common",
"lemmy_db_schema", "lemmy_db_schema",
"lemmy_db_views", "lemmy_db_views",
@ -2627,6 +2629,7 @@ dependencies = [
"actix-web", "actix-web",
"async-trait", "async-trait",
"bcrypt", "bcrypt",
"chrono",
"lemmy_api_common", "lemmy_api_common",
"lemmy_db_schema", "lemmy_db_schema",
"lemmy_db_views", "lemmy_db_views",
@ -2635,6 +2638,7 @@ dependencies = [
"serde", "serde",
"tracing", "tracing",
"url", "url",
"uuid",
"webmention", "webmention",
] ]
@ -2710,6 +2714,7 @@ dependencies = [
"ts-rs", "ts-rs",
"typed-builder", "typed-builder",
"url", "url",
"uuid",
] ]
[[package]] [[package]]

View file

@ -29,6 +29,7 @@ async-trait = { workspace = true }
captcha = { workspace = true } captcha = { workspace = true }
anyhow = { workspace = true } anyhow = { workspace = true }
tracing = { workspace = true } tracing = { workspace = true }
chrono = { workspace = true }
[dev-dependencies] [dev-dependencies]
serial_test = { workspace = true } serial_test = { workspace = true }

View file

@ -1,4 +1,5 @@
use actix_web::web::Data; use actix_web::web::Data;
use captcha::Captcha;
use lemmy_api_common::{context::LemmyContext, utils::local_site_to_slur_regex}; use lemmy_api_common::{context::LemmyContext, utils::local_site_to_slur_regex};
use lemmy_db_schema::source::local_site::LocalSite; use lemmy_db_schema::source::local_site::LocalSite;
use lemmy_utils::{error::LemmyError, utils::slurs::check_slurs}; 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>; 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 /// Check size of report and remove whitespace
pub(crate) fn check_report_reason(reason: &str, local_site: &LocalSite) -> Result<(), LemmyError> { pub(crate) fn check_report_reason(reason: &str, local_site: &LocalSite) -> Result<(), LemmyError> {
let slur_regex = &local_site_to_slur_regex(local_site); let slur_regex = &local_site_to_slur_regex(local_site);

View file

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

View file

@ -3,6 +3,7 @@ mod ban_person;
mod block; mod block;
mod change_password; mod change_password;
mod change_password_after_reset; mod change_password_after_reset;
mod get_captcha;
mod list_banned; mod list_banned;
mod login; mod login;
mod notifications; mod notifications;

View file

@ -22,3 +22,5 @@ tracing = { workspace = true }
url = { workspace = true } url = { workspace = true }
async-trait = { workspace = true } async-trait = { workspace = true }
webmention = "0.4.0" webmention = "0.4.0"
chrono = { worspace = true }
uuid = { workspace = true }

View file

@ -19,6 +19,7 @@ use lemmy_api_common::{
use lemmy_db_schema::{ use lemmy_db_schema::{
aggregates::structs::PersonAggregates, aggregates::structs::PersonAggregates,
source::{ source::{
captcha_answer::{CaptchaAnswer, CheckCaptchaAnswer},
local_user::{LocalUser, LocalUserInsertForm}, local_user::{LocalUser, LocalUserInsertForm},
person::{Person, PersonInsertForm}, person::{Person, PersonInsertForm},
registration_application::{RegistrationApplication, RegistrationApplicationInsertForm}, registration_application::{RegistrationApplication, RegistrationApplicationInsertForm},
@ -71,6 +72,25 @@ impl PerformCrud for Register {
return Err(LemmyError::from_message("passwords_dont_match")); 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); let slur_regex = local_site_to_slur_regex(&local_site);
check_slurs(&data.username, &slur_regex)?; check_slurs(&data.username, &slur_regex)?;
check_slurs_opt(&data.answer, &slur_regex)?; check_slurs_opt(&data.answer, &slur_regex)?;

View file

@ -29,7 +29,7 @@ serde_json = { workspace = true, optional = true }
activitypub_federation = { workspace = true, optional = true } activitypub_federation = { workspace = true, optional = true }
lemmy_utils = { workspace = true, optional = true } lemmy_utils = { workspace = true, optional = true }
bcrypt = { 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-newtype = { workspace = true, optional = true }
diesel-derive-enum = { workspace = true, optional = true } diesel-derive-enum = { workspace = true, optional = true }
diesel_migrations = { workspace = true, optional = true } diesel_migrations = { workspace = true, optional = true }
@ -48,6 +48,7 @@ rustls = { workspace = true }
futures-util = { workspace = true } futures-util = { workspace = true }
tokio-postgres = { workspace = true } tokio-postgres = { workspace = true }
tokio-postgres-rustls = { workspace = true } tokio-postgres-rustls = { workspace = true }
uuid = { features = ["v4"] }
[dev-dependencies] [dev-dependencies]
serial_test = { workspace = true } serial_test = { workspace = true }

View file

@ -19,8 +19,8 @@ index 255c6422..f2ccf5e2 100644
#[derive(diesel::sql_types::SqlType)] #[derive(diesel::sql_types::SqlType)]
#[diesel(postgres_type(name = "sort_type_enum"))] #[diesel(postgres_type(name = "sort_type_enum"))]
@@ -67,13 +63,13 @@ diesel::table! { @@ -76,13 +76,13 @@ diesel::table! {
when_ -> Timestamp, published -> Timestamp,
} }
} }

View file

@ -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());
}
}

View file

@ -1,5 +1,6 @@
pub mod activity; pub mod activity;
pub mod actor_language; pub mod actor_language;
pub mod captcha_answer;
pub mod comment; pub mod comment;
pub mod comment_reply; pub mod comment_reply;
pub mod comment_report; pub mod comment_report;

View file

@ -64,6 +64,15 @@ diesel::table! {
} }
} }
diesel::table! {
captcha_answer (id) {
id -> Int4,
uuid -> Uuid,
answer -> Text,
published -> Timestamp,
}
}
diesel::table! { diesel::table! {
use diesel::sql_types::*; use diesel::sql_types::*;
use diesel_ltree::sql_types::Ltree; use diesel_ltree::sql_types::Ltree;
@ -914,6 +923,7 @@ diesel::allow_tables_to_appear_in_same_query!(
admin_purge_community, admin_purge_community,
admin_purge_person, admin_purge_person,
admin_purge_post, admin_purge_post,
captcha_answer,
comment, comment,
comment_aggregates, comment_aggregates,
comment_like, comment_like,

View file

@ -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,
}

View file

@ -1,6 +1,7 @@
#[cfg(feature = "full")] #[cfg(feature = "full")]
pub mod activity; pub mod activity;
pub mod actor_language; pub mod actor_language;
pub mod captcha_answer;
pub mod comment; pub mod comment;
pub mod comment_reply; pub mod comment_reply;
pub mod comment_report; pub mod comment_report;

View file

@ -0,0 +1 @@
drop table captcha_answer;

View file

@ -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()
);

View file

@ -38,6 +38,7 @@ use lemmy_api_common::{
ChangePassword, ChangePassword,
DeleteAccount, DeleteAccount,
GetBannedPersons, GetBannedPersons,
GetCaptcha,
GetPersonDetails, GetPersonDetails,
GetPersonMentions, GetPersonMentions,
GetReplies, GetReplies,
@ -272,6 +273,12 @@ pub fn config(cfg: &mut web::ServiceConfig, rate_limit: &RateLimitCell) {
.wrap(rate_limit.register()) .wrap(rate_limit.register())
.route(web::post().to(route_post_crud::<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 // User actions
.service( .service(
web::scope("/user") web::scope("/user")

View file

@ -13,7 +13,7 @@ use diesel::{
use diesel::{sql_query, PgConnection, RunQueryDsl}; use diesel::{sql_query, PgConnection, RunQueryDsl};
use lemmy_api_common::context::LemmyContext; use lemmy_api_common::context::LemmyContext;
use lemmy_db_schema::{ 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}, source::instance::{Instance, InstanceForm},
utils::{naive_now, DELETED_REPLACEMENT_TEXT}, utils::{naive_now, DELETED_REPLACEMENT_TEXT},
}; };
@ -49,6 +49,13 @@ pub fn setup(
update_hot_ranks(&mut conn, true); 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 // Clear old activities every week
let url = db_url.clone(); let url = db_url.clone();
scheduler.every(CTimeUnits::weeks(1)).run(move || { 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) /// Clear old activities (this table gets very large)
fn clear_old_activities(conn: &mut PgConnection) { fn clear_old_activities(conn: &mut PgConnection) {
info!("Clearing old activities..."); info!("Clearing old activities...");