From 2becba4da7fb1cf16661505fe2fbfedbcd210c17 Mon Sep 17 00:00:00 2001 From: Dessalines Date: Mon, 27 Jul 2020 11:24:40 -0400 Subject: [PATCH] Lots of captcha fixes. - Removed login captchas. - Added settings to disable captchas, and change difficulty. - Captchas can only be checked / used once, front end gives a new one on failure. - Added front end button for regenerating captcha. - Added a disabled / pause button audio playing. --- docs/src/contributing_websocket_http_api.md | 8 +- server/config/defaults.hjson | 4 + server/lemmy_utils/src/settings.rs | 7 ++ server/src/api/site.rs | 4 +- server/src/api/user.rs | 60 +++++------ server/src/websocket/server.rs | 9 +- ui/src/api_tests/api.spec.ts | 6 -- ui/src/components/login.tsx | 111 +++++++++++--------- ui/src/components/symbols.tsx | 6 ++ ui/src/interfaces.ts | 6 +- 10 files changed, 121 insertions(+), 100 deletions(-) diff --git a/docs/src/contributing_websocket_http_api.md b/docs/src/contributing_websocket_http_api.md index 4c8d1bb19..90b9f40f7 100644 --- a/docs/src/contributing_websocket_http_api.md +++ b/docs/src/contributing_websocket_http_api.md @@ -360,8 +360,6 @@ The `jwt` string should be stored and used anywhere `auth` is called for. data: { username_or_email: String, password: String - captcha_uuid: String, - captcha_answer: String, } } ``` @@ -393,8 +391,8 @@ Only the first user will be able to be the admin. password: String, password_verify: String, admin: bool, - captcha_uuid: String, - captcha_answer: String, + captcha_uuid: Option, // Only checked if these are enabled in the server + captcha_answer: Option, } } ``` @@ -429,7 +427,7 @@ These expire after 10 minutes. data: { png: String, // A Base64 encoded png wav: Option, // A Base64 encoded wav audio file - uuid: String, + uuid: String, // will return 'disabled' if server has these disabled } } ``` diff --git a/server/config/defaults.hjson b/server/config/defaults.hjson index 348368f19..5238455a7 100644 --- a/server/config/defaults.hjson +++ b/server/config/defaults.hjson @@ -59,6 +59,10 @@ # comma seperated list of instances with which federation is allowed allowed_instances: "" } + captcha: { + enabled: true + difficulty: medium # Can be easy, medium, or hard + } # # email sending configuration # email: { # # hostname and port of the smtp server diff --git a/server/lemmy_utils/src/settings.rs b/server/lemmy_utils/src/settings.rs index 102914779..b7cc2c45f 100644 --- a/server/lemmy_utils/src/settings.rs +++ b/server/lemmy_utils/src/settings.rs @@ -17,6 +17,7 @@ pub struct Settings { pub rate_limit: RateLimitConfig, pub email: Option, pub federation: Federation, + pub captcha: CaptchaConfig, } #[derive(Debug, Deserialize, Clone)] @@ -46,6 +47,12 @@ pub struct EmailConfig { pub use_tls: bool, } +#[derive(Debug, Deserialize, Clone)] +pub struct CaptchaConfig { + pub enabled: bool, + pub difficulty: String, // easy, medium, or hard +} + #[derive(Debug, Deserialize, Clone)] pub struct Database { pub user: String, diff --git a/server/src/api/site.rs b/server/src/api/site.rs index 15f50db89..428db8d8e 100644 --- a/server/src/api/site.rs +++ b/server/src/api/site.rs @@ -366,8 +366,8 @@ impl Perform for Oper { password_verify: setup.admin_password.to_owned(), admin: true, show_nsfw: true, - captcha_uuid: "".to_string(), - captcha_answer: "".to_string(), + captcha_uuid: None, + captcha_answer: None, }; let login_response = Oper::new(register, self.client.clone()) .perform(pool, websocket_info.clone()) diff --git a/server/src/api/user.rs b/server/src/api/user.rs index e6729a69d..e25b0e90d 100644 --- a/server/src/api/user.rs +++ b/server/src/api/user.rs @@ -59,8 +59,6 @@ use std::str::FromStr; pub struct Login { username_or_email: String, password: String, - captcha_uuid: String, - captcha_answer: String, } #[derive(Serialize, Deserialize)] @@ -71,8 +69,8 @@ pub struct Register { pub password_verify: String, pub admin: bool, pub show_nsfw: bool, - pub captcha_uuid: String, - pub captcha_answer: String, + pub captcha_uuid: Option, + pub captcha_answer: Option, } #[derive(Serialize, Deserialize)] @@ -82,7 +80,7 @@ pub struct GetCaptcha {} pub struct GetCaptchaResponse { png: String, // A Base64 encoded png wav: Option, // A Base64 encoded wav audio - uuid: String, + uuid: String, // will be 'disabled' if captchas are disabled } #[derive(Serialize, Deserialize)] @@ -285,7 +283,7 @@ impl Perform for Oper { async fn perform( &self, pool: &DbPool, - websocket_info: Option, + _websocket_info: Option, ) -> Result { let data: &Login = &self.data; @@ -306,27 +304,6 @@ impl Perform for Oper { return Err(APIError::err("password_incorrect").into()); } - // Verify the captcha - // Do an admin check, so that for your federation tests, - // you don't need to solve the captchas - if !user.admin { - match websocket_info { - Some(ws) => { - let check = ws - .chatserver - .send(CheckCaptcha { - uuid: data.captcha_uuid.to_owned(), - answer: data.captcha_answer.to_owned(), - }) - .await?; - if !check { - return Err(APIError::err("captcha_incorrect").into()); - } - } - None => return Err(APIError::err("captcha_incorrect").into()), - }; - } - // Return the jwt Ok(LoginResponse { jwt: Claims::jwt(user, Settings::get().hostname), @@ -359,14 +336,20 @@ impl Perform for Oper { } // If its not the admin, check the captcha - if !data.admin { + if !data.admin && Settings::get().captcha.enabled { match websocket_info { Some(ws) => { let check = ws .chatserver .send(CheckCaptcha { - uuid: data.captcha_uuid.to_owned(), - answer: data.captcha_answer.to_owned(), + uuid: data + .captcha_uuid + .to_owned() + .unwrap_or_else(|| "".to_string()), + answer: data + .captcha_answer + .to_owned() + .unwrap_or_else(|| "".to_string()), }) .await?; if !check { @@ -505,7 +488,22 @@ impl Perform for Oper { _pool: &DbPool, websocket_info: Option, ) -> Result { - let captcha = gen(Difficulty::Medium); + let captcha_settings = Settings::get().captcha; + + if !captcha_settings.enabled { + return Ok(GetCaptchaResponse { + png: "disabled".to_string(), + uuid: "disabled".to_string(), + wav: None, + }); + } + + let captcha = match captcha_settings.difficulty.as_str() { + "easy" => gen(Difficulty::Easy), + "medium" => gen(Difficulty::Medium), + "hard" => gen(Difficulty::Hard), + _ => gen(Difficulty::Medium), + }; let answer = captcha.chars_as_string(); diff --git a/server/src/websocket/server.rs b/server/src/websocket/server.rs index 07ac9d5e7..86c99b724 100644 --- a/server/src/websocket/server.rs +++ b/server/src/websocket/server.rs @@ -811,9 +811,14 @@ impl Handler for ChatServer { // Remove all the ones that are past the expire time self.captchas.retain(|x| x.expires.gt(&naive_now())); - self + let check = self .captchas .iter() - .any(|r| r.uuid == msg.uuid && r.answer == msg.answer) + .any(|r| r.uuid == msg.uuid && r.answer == msg.answer); + + // Remove this uuid so it can't be re-checked (Checks only work once) + self.captchas.retain(|x| x.uuid != msg.uuid); + + check } } diff --git a/ui/src/api_tests/api.spec.ts b/ui/src/api_tests/api.spec.ts index d42939a3f..9ab9fc2ae 100644 --- a/ui/src/api_tests/api.spec.ts +++ b/ui/src/api_tests/api.spec.ts @@ -51,8 +51,6 @@ describe('main', () => { let form: LoginForm = { username_or_email: 'lemmy_alpha', password: 'lemmy', - captcha_uuid: '', // Admins don't need this - captcha_answer: '', }; let res: LoginResponse = await fetch(`${lemmyAlphaApiUrl}/user/login`, { @@ -69,8 +67,6 @@ describe('main', () => { let formB = { username_or_email: 'lemmy_beta', password: 'lemmy', - captcha_uuid: '', // Admins don't need this - captcha_answer: '', }; let resB: LoginResponse = await fetch(`${lemmyBetaApiUrl}/user/login`, { @@ -87,8 +83,6 @@ describe('main', () => { let formC = { username_or_email: 'lemmy_gamma', password: 'lemmy', - captcha_uuid: '', // Admins don't need this - captcha_answer: '', }; let resG: LoginResponse = await fetch(`${lemmyGammaApiUrl}/user/login`, { diff --git a/ui/src/components/login.tsx b/ui/src/components/login.tsx index cefe92859..162930d78 100644 --- a/ui/src/components/login.tsx +++ b/ui/src/components/login.tsx @@ -22,6 +22,7 @@ interface State { registerLoading: boolean; enable_nsfw: boolean; captcha: GetCaptchaResponse; + captchaPlaying: boolean; } export class Login extends Component { @@ -31,8 +32,6 @@ export class Login extends Component { loginForm: { username_or_email: undefined, password: undefined, - captcha_uuid: undefined, - captcha_answer: undefined, }, registerForm: { username: undefined, @@ -46,11 +45,8 @@ export class Login extends Component { loginLoading: false, registerLoading: false, enable_nsfw: undefined, - captcha: { - png: undefined, - uuid: undefined, - wav: undefined, - }, + captcha: undefined, + captchaPlaying: false, }; constructor(props: any, context: any) { @@ -133,22 +129,6 @@ export class Login extends Component { )} -
- - {this.showCaptcha()} -
- -
-
-
- - {this.showCaptcha()} -
- + + {this.state.captcha && ( +
+ + {this.showCaptcha()} +
+ +
-
+ )} {this.state.enable_nsfw && (
@@ -320,10 +314,17 @@ export class Login extends Component { style="border-top-right-radius: 0; border-top-left-radius: 0;" title={i18n.t('play_captcha_audio')} onClick={linkEvent(this, this.handleCaptchaPlay)} + disabled={this.state.captchaPlaying} > - - - + {!this.state.captchaPlaying ? ( + + + + ) : ( + + + + )} )} @@ -384,16 +385,16 @@ export class Login extends Component { i.setState(i.state); } - handleLoginCaptchaAnswerChange(i: Login, event: any) { - i.state.loginForm.captcha_answer = event.target.value; - i.setState(i.state); - } - handleRegisterCaptchaAnswerChange(i: Login, event: any) { i.state.registerForm.captcha_answer = event.target.value; i.setState(i.state); } + handleRegenCaptcha(_i: Login, _event: any) { + event.preventDefault(); + WebSocketService.Instance.getCaptcha(); + } + handlePasswordReset(i: Login) { event.preventDefault(); let resetForm: PasswordResetForm = { @@ -406,6 +407,13 @@ export class Login extends Component { event.preventDefault(); let snd = new Audio('data:audio/wav;base64,' + i.state.captcha.wav); snd.play(); + i.state.captchaPlaying = true; + i.setState(i.state); + snd.addEventListener('ended', () => { + snd.currentTime = 0; + i.state.captchaPlaying = false; + i.setState(this.state); + }); } captchaPngSrc() { @@ -417,6 +425,8 @@ export class Login extends Component { if (msg.error) { toast(i18n.t(msg.error), 'danger'); this.state = this.emptyState; + // Refetch another captcha + WebSocketService.Instance.getCaptcha(); this.setState(this.state); return; } else { @@ -437,10 +447,11 @@ export class Login extends Component { this.props.history.push('/communities'); } else if (res.op == UserOperation.GetCaptcha) { let data = res.data as GetCaptchaResponse; - this.state.captcha = data; - this.state.loginForm.captcha_uuid = data.uuid; - this.state.registerForm.captcha_uuid = data.uuid; - this.setState(this.state); + if (data.uuid != 'disabled') { + this.state.captcha = data; + this.state.registerForm.captcha_uuid = data.uuid; + this.setState(this.state); + } } else if (res.op == UserOperation.PasswordReset) { toast(i18n.t('reset_password_mail_sent')); } else if (res.op == UserOperation.GetSite) { diff --git a/ui/src/components/symbols.tsx b/ui/src/components/symbols.tsx index eb68182c8..2bddf0b25 100644 --- a/ui/src/components/symbols.tsx +++ b/ui/src/components/symbols.tsx @@ -15,6 +15,12 @@ export class Symbols extends Component { xmlnsXlink="http://www.w3.org/1999/xlink" > + + + + + + diff --git a/ui/src/interfaces.ts b/ui/src/interfaces.ts index 90794e6ff..a5b4bec17 100644 --- a/ui/src/interfaces.ts +++ b/ui/src/interfaces.ts @@ -549,8 +549,6 @@ export interface ModAdd { export interface LoginForm { username_or_email: string; password: string; - captcha_uuid: string; - captcha_answer: string; } export interface RegisterForm { @@ -560,8 +558,8 @@ export interface RegisterForm { password_verify: string; admin: boolean; show_nsfw: boolean; - captcha_uuid: string; - captcha_answer: string; + captcha_uuid?: string; + captcha_answer?: string; } export interface GetCaptchaResponse {