feat: re-added captcha checks (#3289)
This commit is contained in:
parent
76a4513774
commit
2aef6a5a33
18 changed files with 299 additions and 4 deletions
5
Cargo.lock
generated
5
Cargo.lock
generated
|
@ -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]]
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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);
|
||||
|
|
50
crates/api/src/local_user/get_captcha.rs
Normal file
50
crates/api/src/local_user/get_captcha.rs
Normal 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(),
|
||||
}),
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -22,3 +22,5 @@ tracing = { workspace = true }
|
|||
url = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
webmention = "0.4.0"
|
||||
chrono = { worspace = true }
|
||||
uuid = { workspace = true }
|
|
@ -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)?;
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
118
crates/db_schema/src/impls/captcha_answer.rs
Normal file
118
crates/db_schema/src/impls/captcha_answer.rs
Normal 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());
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
|
|
33
crates/db_schema/src/source/captcha_answer.rs
Normal file
33
crates/db_schema/src/source/captcha_answer.rs
Normal 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,
|
||||
}
|
|
@ -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;
|
||||
|
|
1
migrations/2023-06-21-153242_add_captcha/down.sql
Normal file
1
migrations/2023-06-21-153242_add_captcha/down.sql
Normal file
|
@ -0,0 +1 @@
|
|||
drop table captcha_answer;
|
6
migrations/2023-06-21-153242_add_captcha/up.sql
Normal file
6
migrations/2023-06-21-153242_add_captcha/up.sql
Normal 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()
|
||||
);
|
|
@ -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")
|
||||
|
|
|
@ -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...");
|
||||
|
|
Loading…
Reference in a new issue