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.
This commit is contained in:
Dessalines 2020-07-27 11:24:40 -04:00
parent 1e1e87c9b2
commit 2becba4da7
10 changed files with 121 additions and 100 deletions

View file

@ -360,8 +360,6 @@ The `jwt` string should be stored and used anywhere `auth` is called for.
data: { data: {
username_or_email: String, username_or_email: String,
password: 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: String,
password_verify: String, password_verify: String,
admin: bool, admin: bool,
captcha_uuid: String, captcha_uuid: Option<String>, // Only checked if these are enabled in the server
captcha_answer: String, captcha_answer: Option<String>,
} }
} }
``` ```
@ -429,7 +427,7 @@ These expire after 10 minutes.
data: { data: {
png: String, // A Base64 encoded png png: String, // A Base64 encoded png
wav: Option<String>, // A Base64 encoded wav audio file wav: Option<String>, // A Base64 encoded wav audio file
uuid: String, uuid: String, // will return 'disabled' if server has these disabled
} }
} }
``` ```

View file

@ -59,6 +59,10 @@
# comma seperated list of instances with which federation is allowed # comma seperated list of instances with which federation is allowed
allowed_instances: "" allowed_instances: ""
} }
captcha: {
enabled: true
difficulty: medium # Can be easy, medium, or hard
}
# # email sending configuration # # email sending configuration
# email: { # email: {
# # hostname and port of the smtp server # # hostname and port of the smtp server

View file

@ -17,6 +17,7 @@ pub struct Settings {
pub rate_limit: RateLimitConfig, pub rate_limit: RateLimitConfig,
pub email: Option<EmailConfig>, pub email: Option<EmailConfig>,
pub federation: Federation, pub federation: Federation,
pub captcha: CaptchaConfig,
} }
#[derive(Debug, Deserialize, Clone)] #[derive(Debug, Deserialize, Clone)]
@ -46,6 +47,12 @@ pub struct EmailConfig {
pub use_tls: bool, 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)] #[derive(Debug, Deserialize, Clone)]
pub struct Database { pub struct Database {
pub user: String, pub user: String,

View file

@ -366,8 +366,8 @@ impl Perform for Oper<GetSite> {
password_verify: setup.admin_password.to_owned(), password_verify: setup.admin_password.to_owned(),
admin: true, admin: true,
show_nsfw: true, show_nsfw: true,
captcha_uuid: "".to_string(), captcha_uuid: None,
captcha_answer: "".to_string(), captcha_answer: None,
}; };
let login_response = Oper::new(register, self.client.clone()) let login_response = Oper::new(register, self.client.clone())
.perform(pool, websocket_info.clone()) .perform(pool, websocket_info.clone())

View file

@ -59,8 +59,6 @@ use std::str::FromStr;
pub struct Login { pub struct Login {
username_or_email: String, username_or_email: String,
password: String, password: String,
captcha_uuid: String,
captcha_answer: String,
} }
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
@ -71,8 +69,8 @@ pub struct Register {
pub password_verify: String, pub password_verify: String,
pub admin: bool, pub admin: bool,
pub show_nsfw: bool, pub show_nsfw: bool,
pub captcha_uuid: String, pub captcha_uuid: Option<String>,
pub captcha_answer: String, pub captcha_answer: Option<String>,
} }
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
@ -82,7 +80,7 @@ pub struct GetCaptcha {}
pub struct GetCaptchaResponse { pub struct GetCaptchaResponse {
png: String, // A Base64 encoded png png: String, // A Base64 encoded png
wav: Option<String>, // A Base64 encoded wav audio wav: Option<String>, // A Base64 encoded wav audio
uuid: String, uuid: String, // will be 'disabled' if captchas are disabled
} }
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
@ -285,7 +283,7 @@ impl Perform for Oper<Login> {
async fn perform( async fn perform(
&self, &self,
pool: &DbPool, pool: &DbPool,
websocket_info: Option<WebsocketInfo>, _websocket_info: Option<WebsocketInfo>,
) -> Result<LoginResponse, LemmyError> { ) -> Result<LoginResponse, LemmyError> {
let data: &Login = &self.data; let data: &Login = &self.data;
@ -306,27 +304,6 @@ impl Perform for Oper<Login> {
return Err(APIError::err("password_incorrect").into()); 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 // Return the jwt
Ok(LoginResponse { Ok(LoginResponse {
jwt: Claims::jwt(user, Settings::get().hostname), jwt: Claims::jwt(user, Settings::get().hostname),
@ -359,14 +336,20 @@ impl Perform for Oper<Register> {
} }
// If its not the admin, check the captcha // If its not the admin, check the captcha
if !data.admin { if !data.admin && Settings::get().captcha.enabled {
match websocket_info { match websocket_info {
Some(ws) => { Some(ws) => {
let check = ws let check = ws
.chatserver .chatserver
.send(CheckCaptcha { .send(CheckCaptcha {
uuid: data.captcha_uuid.to_owned(), uuid: data
answer: data.captcha_answer.to_owned(), .captcha_uuid
.to_owned()
.unwrap_or_else(|| "".to_string()),
answer: data
.captcha_answer
.to_owned()
.unwrap_or_else(|| "".to_string()),
}) })
.await?; .await?;
if !check { if !check {
@ -505,7 +488,22 @@ impl Perform for Oper<GetCaptcha> {
_pool: &DbPool, _pool: &DbPool,
websocket_info: Option<WebsocketInfo>, websocket_info: Option<WebsocketInfo>,
) -> Result<Self::Response, LemmyError> { ) -> Result<Self::Response, LemmyError> {
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(); let answer = captcha.chars_as_string();

View file

@ -811,9 +811,14 @@ impl Handler<CheckCaptcha> for ChatServer {
// Remove all the ones that are past the expire time // Remove all the ones that are past the expire time
self.captchas.retain(|x| x.expires.gt(&naive_now())); self.captchas.retain(|x| x.expires.gt(&naive_now()));
self let check = self
.captchas .captchas
.iter() .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
} }
} }

View file

@ -51,8 +51,6 @@ describe('main', () => {
let form: LoginForm = { let form: LoginForm = {
username_or_email: 'lemmy_alpha', username_or_email: 'lemmy_alpha',
password: 'lemmy', password: 'lemmy',
captcha_uuid: '', // Admins don't need this
captcha_answer: '',
}; };
let res: LoginResponse = await fetch(`${lemmyAlphaApiUrl}/user/login`, { let res: LoginResponse = await fetch(`${lemmyAlphaApiUrl}/user/login`, {
@ -69,8 +67,6 @@ describe('main', () => {
let formB = { let formB = {
username_or_email: 'lemmy_beta', username_or_email: 'lemmy_beta',
password: 'lemmy', password: 'lemmy',
captcha_uuid: '', // Admins don't need this
captcha_answer: '',
}; };
let resB: LoginResponse = await fetch(`${lemmyBetaApiUrl}/user/login`, { let resB: LoginResponse = await fetch(`${lemmyBetaApiUrl}/user/login`, {
@ -87,8 +83,6 @@ describe('main', () => {
let formC = { let formC = {
username_or_email: 'lemmy_gamma', username_or_email: 'lemmy_gamma',
password: 'lemmy', password: 'lemmy',
captcha_uuid: '', // Admins don't need this
captcha_answer: '',
}; };
let resG: LoginResponse = await fetch(`${lemmyGammaApiUrl}/user/login`, { let resG: LoginResponse = await fetch(`${lemmyGammaApiUrl}/user/login`, {

View file

@ -22,6 +22,7 @@ interface State {
registerLoading: boolean; registerLoading: boolean;
enable_nsfw: boolean; enable_nsfw: boolean;
captcha: GetCaptchaResponse; captcha: GetCaptchaResponse;
captchaPlaying: boolean;
} }
export class Login extends Component<any, State> { export class Login extends Component<any, State> {
@ -31,8 +32,6 @@ export class Login extends Component<any, State> {
loginForm: { loginForm: {
username_or_email: undefined, username_or_email: undefined,
password: undefined, password: undefined,
captcha_uuid: undefined,
captcha_answer: undefined,
}, },
registerForm: { registerForm: {
username: undefined, username: undefined,
@ -46,11 +45,8 @@ export class Login extends Component<any, State> {
loginLoading: false, loginLoading: false,
registerLoading: false, registerLoading: false,
enable_nsfw: undefined, enable_nsfw: undefined,
captcha: { captcha: undefined,
png: undefined, captchaPlaying: false,
uuid: undefined,
wav: undefined,
},
}; };
constructor(props: any, context: any) { constructor(props: any, context: any) {
@ -133,22 +129,6 @@ export class Login extends Component<any, State> {
)} )}
</div> </div>
</div> </div>
<div class="form-group row">
<label class="col-sm-2" htmlFor="login-captcha">
{i18n.t('enter_code')}
</label>
{this.showCaptcha()}
<div class="col-sm-6">
<input
type="text"
class="form-control"
id="login-captcha"
value={this.state.loginForm.captcha_answer}
onInput={linkEvent(this, this.handleLoginCaptchaAnswerChange)}
required
/>
</div>
</div>
<div class="form-group row"> <div class="form-group row">
<div class="col-sm-10"> <div class="col-sm-10">
<button type="submit" class="btn btn-secondary"> <button type="submit" class="btn btn-secondary">
@ -253,9 +233,19 @@ export class Login extends Component<any, State> {
/> />
</div> </div>
</div> </div>
{this.state.captcha && (
<div class="form-group row"> <div class="form-group row">
<label class="col-sm-2" htmlFor="register-captcha"> <label class="col-sm-2" htmlFor="register-captcha">
{i18n.t('enter_code')} <span class="mr-2">{i18n.t('enter_code')}</span>
<button
class="btn btn-secondary"
onClick={linkEvent(this, this.handleRegenCaptcha)}
>
<svg class="icon icon-refresh-cw">
<use xlinkHref="#icon-refresh-cw"></use>
</svg>
</button>
</label> </label>
{this.showCaptcha()} {this.showCaptcha()}
<div class="col-sm-6"> <div class="col-sm-6">
@ -264,11 +254,15 @@ export class Login extends Component<any, State> {
class="form-control" class="form-control"
id="register-captcha" id="register-captcha"
value={this.state.registerForm.captcha_answer} value={this.state.registerForm.captcha_answer}
onInput={linkEvent(this, this.handleRegisterCaptchaAnswerChange)} onInput={linkEvent(
this,
this.handleRegisterCaptchaAnswerChange
)}
required required
/> />
</div> </div>
</div> </div>
)}
{this.state.enable_nsfw && ( {this.state.enable_nsfw && (
<div class="form-group row"> <div class="form-group row">
<div class="col-sm-10"> <div class="col-sm-10">
@ -320,10 +314,17 @@ export class Login extends Component<any, State> {
style="border-top-right-radius: 0; border-top-left-radius: 0;" style="border-top-right-radius: 0; border-top-left-radius: 0;"
title={i18n.t('play_captcha_audio')} title={i18n.t('play_captcha_audio')}
onClick={linkEvent(this, this.handleCaptchaPlay)} onClick={linkEvent(this, this.handleCaptchaPlay)}
disabled={this.state.captchaPlaying}
> >
{!this.state.captchaPlaying ? (
<svg class="icon icon-play"> <svg class="icon icon-play">
<use xlinkHref="#icon-play"></use> <use xlinkHref="#icon-play"></use>
</svg> </svg>
) : (
<svg class="icon icon-pause">
<use xlinkHref="#icon-pause"></use>
</svg>
)}
</button> </button>
)} )}
</> </>
@ -384,16 +385,16 @@ export class Login extends Component<any, State> {
i.setState(i.state); 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) { handleRegisterCaptchaAnswerChange(i: Login, event: any) {
i.state.registerForm.captcha_answer = event.target.value; i.state.registerForm.captcha_answer = event.target.value;
i.setState(i.state); i.setState(i.state);
} }
handleRegenCaptcha(_i: Login, _event: any) {
event.preventDefault();
WebSocketService.Instance.getCaptcha();
}
handlePasswordReset(i: Login) { handlePasswordReset(i: Login) {
event.preventDefault(); event.preventDefault();
let resetForm: PasswordResetForm = { let resetForm: PasswordResetForm = {
@ -406,6 +407,13 @@ export class Login extends Component<any, State> {
event.preventDefault(); event.preventDefault();
let snd = new Audio('data:audio/wav;base64,' + i.state.captcha.wav); let snd = new Audio('data:audio/wav;base64,' + i.state.captcha.wav);
snd.play(); 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() { captchaPngSrc() {
@ -417,6 +425,8 @@ export class Login extends Component<any, State> {
if (msg.error) { if (msg.error) {
toast(i18n.t(msg.error), 'danger'); toast(i18n.t(msg.error), 'danger');
this.state = this.emptyState; this.state = this.emptyState;
// Refetch another captcha
WebSocketService.Instance.getCaptcha();
this.setState(this.state); this.setState(this.state);
return; return;
} else { } else {
@ -437,10 +447,11 @@ export class Login extends Component<any, State> {
this.props.history.push('/communities'); this.props.history.push('/communities');
} else if (res.op == UserOperation.GetCaptcha) { } else if (res.op == UserOperation.GetCaptcha) {
let data = res.data as GetCaptchaResponse; let data = res.data as GetCaptchaResponse;
if (data.uuid != 'disabled') {
this.state.captcha = data; this.state.captcha = data;
this.state.loginForm.captcha_uuid = data.uuid;
this.state.registerForm.captcha_uuid = data.uuid; this.state.registerForm.captcha_uuid = data.uuid;
this.setState(this.state); this.setState(this.state);
}
} else if (res.op == UserOperation.PasswordReset) { } else if (res.op == UserOperation.PasswordReset) {
toast(i18n.t('reset_password_mail_sent')); toast(i18n.t('reset_password_mail_sent'));
} else if (res.op == UserOperation.GetSite) { } else if (res.op == UserOperation.GetSite) {

View file

@ -15,6 +15,12 @@ export class Symbols extends Component<any, any> {
xmlnsXlink="http://www.w3.org/1999/xlink" xmlnsXlink="http://www.w3.org/1999/xlink"
> >
<defs> <defs>
<symbol id="icon-refresh-cw" viewBox="0 0 24 24">
<path d="M4.453 9.334c0.737-2.083 2.247-3.669 4.096-4.552s4.032-1.059 6.114-0.322c1.186 0.42 2.206 1.088 2.983 1.88l2.83 2.66h-3.476c-0.552 0-1 0.448-1 1s0.448 1 1 1h5.997c0.005 0 0.009 0 0.014 0 0.137-0.001 0.268-0.031 0.386-0.082 0.119-0.051 0.229-0.126 0.324-0.225 0.012-0.013 0.024-0.026 0.036-0.039 0.075-0.087 0.133-0.183 0.173-0.285s0.064-0.211 0.069-0.326c0.001-0.015 0.001-0.029 0.001-0.043v-6c0-0.552-0.448-1-1-1s-1 0.448-1 1v3.689l-2.926-2.749c-0.992-1.010-2.271-1.843-3.743-2.364-2.603-0.921-5.335-0.699-7.643 0.402s-4.199 3.086-5.12 5.689c-0.185 0.52 0.088 1.091 0.608 1.276s1.092-0.088 1.276-0.609zM2 16.312l2.955 2.777c1.929 1.931 4.49 2.908 7.048 2.909s5.119-0.975 7.072-2.927c1.104-1.104 1.901-2.407 2.361-3.745 0.18-0.522-0.098-1.091-0.621-1.271s-1.091 0.098-1.271 0.621c-0.361 1.050-0.993 2.091-1.883 2.981-1.563 1.562-3.609 2.342-5.657 2.342s-4.094-0.782-5.679-2.366l-2.8-2.633h3.475c0.552 0 1-0.448 1-1s-0.448-1-1-1h-5.997c-0.005 0-0.009 0-0.014 0-0.137 0.001-0.268 0.031-0.386 0.082-0.119 0.051-0.229 0.126-0.324 0.225-0.012 0.013-0.024 0.026-0.036 0.039-0.075 0.087-0.133 0.183-0.173 0.285s-0.064 0.211-0.069 0.326c-0.001 0.015-0.001 0.029-0.001 0.043v6c0 0.552 0.448 1 1 1s1-0.448 1-1z"></path>
</symbol>
<symbol id="icon-pause" viewBox="0 0 24 24">
<path d="M6 3c-0.552 0-1 0.448-1 1v16c0 0.552 0.448 1 1 1h4c0.552 0 1-0.448 1-1v-16c0-0.552-0.448-1-1-1zM7 5h2v14h-2zM14 3c-0.552 0-1 0.448-1 1v16c0 0.552 0.448 1 1 1h4c0.552 0 1-0.448 1-1v-16c0-0.552-0.448-1-1-1zM15 5h2v14h-2z"></path>
</symbol>
<symbol id="icon-play" viewBox="0 0 24 24"> <symbol id="icon-play" viewBox="0 0 24 24">
<path d="M5.541 2.159c-0.153-0.1-0.34-0.159-0.541-0.159-0.552 0-1 0.448-1 1v18c-0.001 0.182 0.050 0.372 0.159 0.541 0.299 0.465 0.917 0.599 1.382 0.3l14-9c0.114-0.072 0.219-0.174 0.3-0.3 0.299-0.465 0.164-1.083-0.3-1.382zM6 4.832l11.151 7.168-11.151 7.168z"></path> <path d="M5.541 2.159c-0.153-0.1-0.34-0.159-0.541-0.159-0.552 0-1 0.448-1 1v18c-0.001 0.182 0.050 0.372 0.159 0.541 0.299 0.465 0.917 0.599 1.382 0.3l14-9c0.114-0.072 0.219-0.174 0.3-0.3 0.299-0.465 0.164-1.083-0.3-1.382zM6 4.832l11.151 7.168-11.151 7.168z"></path>
</symbol> </symbol>

View file

@ -549,8 +549,6 @@ export interface ModAdd {
export interface LoginForm { export interface LoginForm {
username_or_email: string; username_or_email: string;
password: string; password: string;
captcha_uuid: string;
captcha_answer: string;
} }
export interface RegisterForm { export interface RegisterForm {
@ -560,8 +558,8 @@ export interface RegisterForm {
password_verify: string; password_verify: string;
admin: boolean; admin: boolean;
show_nsfw: boolean; show_nsfw: boolean;
captcha_uuid: string; captcha_uuid?: string;
captcha_answer: string; captcha_answer?: string;
} }
export interface GetCaptchaResponse { export interface GetCaptchaResponse {