make captcha more general and add captcha to signup form

This commit is contained in:
HF 2022-01-11 10:14:08 +01:00
parent 2bc1aa9591
commit c29578dfaf
9 changed files with 234 additions and 119 deletions

View File

@ -251,10 +251,12 @@ export function requestLogin(nameoremail, password) {
); );
} }
export function requestRegistration(name, email, password) { export function requestRegistration(name, email, password, captcha, captchaid) {
return makeAPIPOSTRequest( return makeAPIPOSTRequest(
'api/auth/register', 'api/auth/register',
{ name, email, password }, {
name, email, password, captcha, captchaid,
},
); );
} }

View File

@ -6,7 +6,7 @@
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { useSelector, useDispatch } from 'react-redux'; import { useSelector, useDispatch } from 'react-redux';
import Captcha from './Captcha'; import GlobalCaptcha from './GlobalCaptcha';
import { closeAlert } from '../actions'; import { closeAlert } from '../actions';
const Alert = () => { const Alert = () => {
@ -55,7 +55,7 @@ const Alert = () => {
</p> </p>
<div> <div>
{(alertType === 'captcha') {(alertType === 'captcha')
? <Captcha close={close} /> ? <GlobalCaptcha close={close} />
: ( : (
<button <button
type="button" type="button"

View File

@ -1,9 +1,7 @@
/* /*
* Form to ask for captcha. * Form to ask for captcha.
* If callback is provided, it sets the captcha text to it. * Offers input for captchas, parent needs to provide a form and
* If callback is not provided, it provides a button to send the * get "captcha" and "captchaid" values
* captcha itself
* @flow
*/ */
/* eslint-disable jsx-a11y/no-autofocus */ /* eslint-disable jsx-a11y/no-autofocus */
@ -12,7 +10,6 @@ import React, { useState, useEffect } from 'react';
import { t } from 'ttag'; import { t } from 'ttag';
import { IoReloadCircleSharp } from 'react-icons/io5'; import { IoReloadCircleSharp } from 'react-icons/io5';
import { requestSolveCaptcha } from '../actions/fetch';
async function getUrlAndId() { async function getUrlAndId() {
const url = window.ssv.captchaurl; const url = window.ssv.captchaurl;
@ -27,40 +24,38 @@ async function getUrlAndId() {
return null; return null;
} }
const Captcha = ({ callback, close }) => { /*
* autoload: Load captcha immediately and autofocus input textbox
* width: width of the captcha image
*/
const Captcha = ({ autoload, width }) => {
const [captchaData, setCaptchaData] = useState({}); const [captchaData, setCaptchaData] = useState({});
const [text, setText] = useState('');
const [errors, setErrors] = useState([]); const [errors, setErrors] = useState([]);
const [imgLoaded, setImgLoaded] = useState(false); const [imgLoaded, setImgLoaded] = useState(false);
useEffect(async () => { const reloadCaptcha = async () => {
const [svgUrl, captchaid] = await getUrlAndId(); if (imgLoaded) {
setImgLoaded(false);
}
const captchaResponse = await getUrlAndId();
if (!captchaResponse) {
setErrors([t`Could not load captcha`]);
return;
}
const [svgUrl, captchaid] = captchaResponse;
setCaptchaData({ url: svgUrl, id: captchaid }); setCaptchaData({ url: svgUrl, id: captchaid });
};
useEffect(async () => {
if (autoload) {
reloadCaptcha();
}
}, []); }, []);
const contWidth = width || 100;
return ( return (
<form <>
onSubmit={async (e) => {
e.preventDefault();
const { errors: resErrors } = await requestSolveCaptcha(
text,
captchaData.id,
);
if (resErrors) {
const [svgUrl, captchaid] = await getUrlAndId();
setCaptchaData({ url: svgUrl, id: captchaid });
setText('');
setErrors(resErrors);
} else {
close();
}
}}
>
{errors.map((error) => (
<p key={error} className="errormessage">
<span>{t`Error`}</span>:&nbsp;{error}
</p>
))}
<p className="modaltext"> <p className="modaltext">
{t`Type the characters from the following image:`} {t`Type the characters from the following image:`}
&nbsp; &nbsp;
@ -69,30 +64,61 @@ const Captcha = ({ callback, close }) => {
</span> </span>
</p> </p>
<br /> <br />
{errors.map((error) => (
<p key={error} className="errormessage">
<span>{t`Error`}</span>:&nbsp;{error}
</p>
))}
<div <div
style={{ style={{
width: '100%', width: `${contWidth}%`,
paddingTop: '60%', paddingTop: `${Math.floor(contWidth * 0.6)}%`,
position: 'relative', position: 'relative',
display: 'inline-block',
backgroundColor: '#e0e0e0',
}} }}
> >
{(captchaData.url) && ( <div
<img style={{
style={{ width: '100%',
width: '100%', position: 'absolute',
position: 'absolute', top: '50%',
top: '50%', left: '50%',
left: '50%', transform: 'translate(-50%,-50%)',
opacity: (imgLoaded) ? 1 : 0, }}
transform: 'translate(-50%,-50%)', >
transition: '100ms', {(captchaData.url)
}} ? (
src={captchaData.url} <img
alt="CAPTCHA" style={{
onLoad={() => { setImgLoaded(true); }} width: '100%',
onError={() => setErrors([t`Could not load captcha`])} opacity: (imgLoaded) ? 1 : 0,
/> transition: '100ms',
)} }}
src={captchaData.url}
alt="CAPTCHA"
onLoad={() => {
setErrors([]);
setImgLoaded(true);
}}
onError={() => {
setErrors([t`Could not load captcha`]);
}}
/>
)
: (
<span
role="button"
tabIndex={0}
title={t`Load Captcha`}
className="modallink"
onClick={reloadCaptcha}
onKeyPress={reloadCaptcha}
>
{t`Click to Load Captcha`}
</span>
)}
</div>
</div> </div>
<p className="modaltext"> <p className="modaltext">
{t`Can't read? Reload:`}&nbsp; {t`Can't read? Reload:`}&nbsp;
@ -102,54 +128,29 @@ const Captcha = ({ callback, close }) => {
title={t`Reload`} title={t`Reload`}
className="modallink" className="modallink"
style={{ fontSize: 28 }} style={{ fontSize: 28 }}
onClick={async () => { onClick={reloadCaptcha}
setImgLoaded(false);
const [svgUrl, captchaid] = await getUrlAndId();
setCaptchaData({ url: svgUrl, id: captchaid });
}}
> >
<IoReloadCircleSharp /> <IoReloadCircleSharp />
</span> </span>
</p> </p>
<input <input
name="captcha"
placeholder={t`Enter Characters`} placeholder={t`Enter Characters`}
type="text" type="text"
value={text}
autoComplete="off" autoComplete="off"
autoCorrect="off" autoCorrect="off"
autoCapitalize="off" autoCapitalize="off"
spellCheck="false" spellCheck="false"
autoFocus autoFocus={autoload}
style={{ style={{
width: '6em', width: '6em',
fontSize: 21, fontSize: 21,
margin: 5, margin: 5,
}} }}
onChange={(evt) => {
const txt = evt.target.value;
setText(txt);
if (callback) callback(txt);
}}
/> />
{(!callback) && ( <input type="hidden" name="captchaid" value={captchaData.id || '0'} />
<div> <br />
<button </>
type="button"
onClick={close}
style={{ fontSize: 16 }}
>
{t`Cancel`}
</button>
&nbsp;
<button
type="submit"
style={{ fontSize: 16 }}
>
{t`Send`}
</button>
</div>
)}
</form>
); );
}; };

View File

@ -0,0 +1,62 @@
/*
* Global Captcha that is valid sitewide
* via api/captcha
* Displayed in an Alert
*/
import React, { useState } from 'react';
import { t } from 'ttag';
import Captcha from './Captcha';
import { requestSolveCaptcha } from '../actions/fetch';
const GlobalCaptcha = ({ close }) => {
const [errors, setErrors] = useState([]);
// used to be able to force Captcha rerender on error
const [captKey, setCaptKey] = useState(Date.now());
return (
<form
onSubmit={async (e) => {
e.preventDefault();
const text = e.target.captcha.value;
const captchaid = e.target.captchaid.value;
const { errors: resErrors } = await requestSolveCaptcha(
text,
captchaid,
);
if (resErrors) {
setCaptKey(Date.now());
setErrors(resErrors);
} else {
close();
}
}}
>
{errors.map((error) => (
<p key={error} className="errormessage">
<span>{t`Error`}</span>:&nbsp;{error}
</p>
))}
<Captcha autoload key={captKey} />
<p>
<button
type="button"
onClick={close}
style={{ fontSize: 16 }}
>
{t`Cancel`}
</button>
&nbsp;
<button
type="submit"
style={{ fontSize: 16 }}
>
{t`Send`}
</button>
</p>
</form>
);
};
export default React.memo(GlobalCaptcha);

View File

@ -6,6 +6,7 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useDispatch } from 'react-redux'; import { useDispatch } from 'react-redux';
import { t } from 'ttag'; import { t } from 'ttag';
import Captcha from '../Captcha';
import { import {
validateEMail, validateName, validatePassword, validateEMail, validateName, validatePassword,
} from '../../utils/validation'; } from '../../utils/validation';
@ -29,27 +30,27 @@ function validate(name, email, password, confirmPassword) {
return errors; return errors;
} }
const inputStyles = {
display: 'inline-block',
width: '100%',
maxWidth: '35em',
};
const Register = ({ windowId }) => { const Register = ({ windowId }) => {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [submitting, setSubmitting] = useState(''); const [submitting, setSubmitting] = useState('');
const [errors, setErrors] = useState([]); const [errors, setErrors] = useState([]);
// used to be able to force Captcha rerender on error
const [captKey, setCaptKey] = useState(Date.now());
const dispatch = useDispatch(); const dispatch = useDispatch();
const handleSubmit = async () => { const handleSubmit = async (evt) => {
evt.preventDefault();
if (submitting) { if (submitting) {
return; return;
} }
const name = evt.target.name.value;
const email = evt.target.email.value;
const password = evt.target.password.value;
const confirmPassword = evt.target.confirmpassword.value;
const captcha = evt.target.captcha.value;
const captchaid = evt.target.captchaid.value;
const valErrors = validate(name, email, password, confirmPassword); const valErrors = validate(name, email, password, confirmPassword);
if (valErrors.length > 0) { if (valErrors.length > 0) {
setErrors(valErrors); setErrors(valErrors);
@ -61,9 +62,12 @@ const Register = ({ windowId }) => {
name, name,
email, email,
password, password,
captcha,
captchaid,
); );
setSubmitting(false); setSubmitting(false);
if (respErrors) { if (respErrors) {
setCaptKey(Date.now());
setErrors(respErrors); setErrors(respErrors);
return; return;
} }
@ -83,38 +87,40 @@ const Register = ({ windowId }) => {
<p key={error} className="errormessage"><span>{t`Error`}</span> <p key={error} className="errormessage"><span>{t`Error`}</span>
:&nbsp;{error}</p> :&nbsp;{error}</p>
))} ))}
<p className="modaltitle">{t`Name`}:</p>
<input <input
style={inputStyles} name="name"
value={name} className="reginput"
autoComplete="username" autoComplete="username"
onChange={(evt) => setName(evt.target.value)}
type="text" type="text"
placeholder={t`Name`} placeholder={t`Name`}
/><br /> />
<p className="modaltitle">{t`Email`}:</p>
<input <input
style={inputStyles} name="email"
value={email} className="reginput"
autoComplete="email" autoComplete="email"
onChange={(evt) => setEmail(evt.target.value)}
type="text" type="text"
placeholder={t`Email`} placeholder={t`Email`}
/><br /> />
<p className="modaltitle">{t`Password`}:</p>
<input <input
style={inputStyles} name="password"
value={password} className="reginput"
autoComplete="new-password" autoComplete="new-password"
onChange={(evt) => setPassword(evt.target.value)}
type="password" type="password"
placeholder={t`Password`} placeholder={t`Password`}
/><br /> />
<p className="modaltitle">{t`Confirm Password`}:</p>
<input <input
style={inputStyles} name="confirmpassword"
value={confirmPassword} className="reginput"
autoComplete="new-password" autoComplete="new-password"
onChange={(evt) => setConfirmPassword(evt.target.value)}
type="password" type="password"
placeholder={t`Confirm Password`} placeholder={t`Confirm Password`}
/><br /> />
<p className="modaltitle">{t`Captcha`}:</p>
<Captcha autoload={false} width={60} key={captKey} />
<button type="submit"> <button type="submit">
{(submitting) ? '...' : t`Submit`} {(submitting) ? '...' : t`Submit`}
</button> </button>

View File

@ -17,8 +17,11 @@ import {
validateName, validateName,
validatePassword, validatePassword,
} from '../../../utils/validation'; } from '../../../utils/validation';
import {
checkCaptchaSolution,
} from '../../../utils/captcha';
async function validate(email, name, password, t, gettext) { async function validate(email, name, password, captcha, captchaid, t, gettext) {
const errors = []; const errors = [];
const emailerror = gettext(validateEMail(email)); const emailerror = gettext(validateEMail(email));
if (emailerror) errors.push(emailerror); if (emailerror) errors.push(emailerror);
@ -27,6 +30,8 @@ async function validate(email, name, password, t, gettext) {
const passworderror = gettext(validatePassword(password)); const passworderror = gettext(validatePassword(password));
if (passworderror) errors.push(passworderror); if (passworderror) errors.push(passworderror);
if (!captcha || !captchaid) errors.push(t`No Captcha given`);
let reguser = await RegUser.findOne({ where: { email } }); let reguser = await RegUser.findOne({ where: { email } });
if (reguser) errors.push(t`E-Mail already in use.`); if (reguser) errors.push(t`E-Mail already in use.`);
reguser = await RegUser.findOne({ where: { name } }); reguser = await RegUser.findOne({ where: { name } });
@ -36,9 +41,34 @@ async function validate(email, name, password, t, gettext) {
} }
export default async (req: Request, res: Response) => { export default async (req: Request, res: Response) => {
const { email, name, password } = req.body; const {
email, name, password, captcha, captchaid,
} = req.body;
const { t, gettext } = req.ttag; const { t, gettext } = req.ttag;
const errors = await validate(email, name, password, t, gettext); const errors = await validate(
email, name, password, captcha, captchaid, t, gettext,
);
const ip = getIPFromRequest(req);
if (!errors.length) {
const captchaPass = await checkCaptchaSolution(
captcha, ip, true, captchaid,
);
switch (captchaPass) {
case 0:
break;
case 1:
errors.push(t`You took too long, try again.`);
break;
case 2:
errors.push(t`You failed your captcha`);
break;
default:
errors.push(t`Unknown Captcha Error`);
break;
}
}
if (errors.length > 0) { if (errors.length > 0) {
res.status(400); res.status(400);
res.json({ res.json({
@ -63,7 +93,6 @@ export default async (req: Request, res: Response) => {
return; return;
} }
const ip = getIPFromRequest(req);
logger.info(`Created new user ${name} ${email} ${ip}`); logger.info(`Created new user ${name} ${email} ${ip}`);
const { user, lang } = req; const { user, lang } = req;

View File

@ -28,7 +28,7 @@ export default async (req: Request, res: Response) => {
return; return;
} }
const ret = await checkCaptchaSolution(text, ip, id); const ret = await checkCaptchaSolution(text, ip, false, id);
switch (ret) { switch (ret) {
case 0: case 0:

View File

@ -805,6 +805,16 @@ tr:nth-child(even) {
background-color: #ffa9a9cc; background-color: #ffa9a9cc;
} }
.reginput {
display: inline-block;
width: 100%;
max-width: 35em;
}
.errormessage {
color: #b73c3c;
}
.cooldownbox { .cooldownbox {
top: 16px; top: 16px;
width: 48px; width: 48px;

View File

@ -90,6 +90,8 @@ export function setCaptchaSolution(
* *
* @param text Solution of captcha * @param text Solution of captcha
* @param ip * @param ip
* @param onetime If the captcha is just one time or should be remembered
* for this ip
* @return 0 if solution right * @return 0 if solution right
* 1 if timed out * 1 if timed out
* 2 if wrong * 2 if wrong
@ -97,6 +99,7 @@ export function setCaptchaSolution(
export async function checkCaptchaSolution( export async function checkCaptchaSolution(
text, text,
ip, ip,
onetime = false,
captchaid = null, captchaid = null,
) { ) {
const ipn = getIPv6Subnet(ip); const ipn = getIPv6Subnet(ip);
@ -107,8 +110,10 @@ export async function checkCaptchaSolution(
const solution = await redis.getAsync(key); const solution = await redis.getAsync(key);
if (solution) { if (solution) {
if (evaluateResult(solution.toString('utf8'), text)) { if (evaluateResult(solution.toString('utf8'), text)) {
const solvkey = `human:${ipn}`; if (!onetime) {
await redis.setAsync(solvkey, '', 'EX', TTL_CACHE); const solvkey = `human:${ipn}`;
await redis.setAsync(solvkey, '', 'EX', TTL_CACHE);
}
logger.info(`CAPTCHA ${ip} successfully solved captcha`); logger.info(`CAPTCHA ${ip} successfully solved captcha`);
return 0; return 0;
} }