From ece5acc9a92dfe0fcde8ee966135b73e7099a36e Mon Sep 17 00:00:00 2001 From: Nicolas Ngomai Date: Fri, 3 Nov 2023 11:10:45 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Add=20recaptcha=20challenge=20on=20?= =?UTF-8?q?hasura=20auth?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/errors.ts | 8 ++++++++ src/middleware/auth.ts | 25 +++++++++++++++++++++++ src/routes/signin/email-password.ts | 1 + src/routes/signin/index.ts | 6 +++++- src/routes/signin/passwordless/email.ts | 2 ++ src/routes/signin/passwordless/sms/sms.ts | 2 ++ src/routes/signup/email-password.ts | 1 + src/routes/signup/index.ts | 3 +++ src/routes/user/index.ts | 9 ++++++-- src/routes/user/password-reset.ts | 1 + src/utils/env.ts | 12 +++++++++++ src/utils/recaptcha.ts | 17 +++++++++++++++ 12 files changed, 84 insertions(+), 3 deletions(-) create mode 100644 src/utils/recaptcha.ts diff --git a/src/errors.ts b/src/errors.ts index fa9d1da39..418b42dea 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -147,6 +147,14 @@ export const ERRORS = asErrors({ status: StatusCodes.INTERNAL_SERVER_ERROR, message: 'Invalid OAuth configuration', }, + 'missing-or-invalid-captcha': { + status: StatusCodes.UNAUTHORIZED, + message: 'Missing or Invalid recaptcha challenge', + }, + 'missing-captcha-site-key': { + status: StatusCodes.INTERNAL_SERVER_ERROR, + message: 'Missing catpcha site key', + }, }); export const sendError = ( diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts index bb765862d..255f23237 100644 --- a/src/middleware/auth.ts +++ b/src/middleware/auth.ts @@ -1,6 +1,7 @@ import { RequestHandler } from 'express'; import { getPermissionVariables } from '@/utils'; import { sendError } from '@/errors'; +import { checkRecaptchaChallenge } from '@/utils/recaptcha'; export const authMiddleware: RequestHandler = async (req, _, next) => { try { @@ -25,3 +26,27 @@ export const authenticationGate: RequestHandler = (req, res, next) => { next(); } }; + +export const verifyCaptcha: RequestHandler = async (req, res, next) => { + const { body } = req; + const { recaptchaChallenge } = body; + + try { + const isValidChallenge = await checkRecaptchaChallenge(recaptchaChallenge); + + if (!isValidChallenge) { + return sendError(res, 'missing-or-invalid-captcha'); + } else { + next(); + } + } catch (e: any) { + sendError( + res, + e.message === 'missing-captcha-site-key' + ? 'missing-captcha-site-key' + : 'internal-error' + ); + } +}; + +export const alwaysAllow: RequestHandler = (_, __, next) => next(); diff --git a/src/routes/signin/email-password.ts b/src/routes/signin/email-password.ts index 75bfe0cf8..354aa4c54 100644 --- a/src/routes/signin/email-password.ts +++ b/src/routes/signin/email-password.ts @@ -9,6 +9,7 @@ import { Joi, email, password } from '@/validation'; export const signInEmailPasswordSchema = Joi.object({ email: email.required(), password: password.required(), + recaptchaChallenge: Joi.string().allow('').optional(), }).meta({ className: 'SignInEmailPasswordSchema' }); export const signInEmailPasswordHandler: RequestHandler< diff --git a/src/routes/signin/index.ts b/src/routes/signin/index.ts index b0ec27baa..ffe373781 100644 --- a/src/routes/signin/index.ts +++ b/src/routes/signin/index.ts @@ -1,6 +1,6 @@ import { Router } from 'express'; -import { asyncWrapper as aw } from '@/utils'; +import { ENV, asyncWrapper as aw } from '@/utils'; import { bodyValidator } from '@/validation'; import { signInAnonymousHandler, signInAnonymousSchema } from './anonymous'; @@ -23,6 +23,7 @@ import { signInWebauthnHandler, signInWebauthnSchema, } from './webauthn'; +import { alwaysAllow, verifyCaptcha } from '@/middleware/auth'; const router = Router(); @@ -39,6 +40,7 @@ const router = Router(); router.post( '/signin/email-password', bodyValidator(signInEmailPasswordSchema), + ENV.AUTH_SIGNIN_RECAPTCHA_CHALLENGE ? verifyCaptcha : alwaysAllow, aw(signInEmailPasswordHandler) ); @@ -55,6 +57,7 @@ router.post( router.post( '/signin/passwordless/email', bodyValidator(signInPasswordlessEmailSchema), + ENV.AUTH_SIGNIN_RECAPTCHA_CHALLENGE ? verifyCaptcha : alwaysAllow, aw(signInPasswordlessEmailHandler) ); @@ -70,6 +73,7 @@ router.post( router.post( '/signin/passwordless/sms', bodyValidator(signInPasswordlessSmsSchema), + ENV.AUTH_SIGNIN_RECAPTCHA_CHALLENGE ? verifyCaptcha : alwaysAllow, aw(signInPasswordlessSmsHandler) ); diff --git a/src/routes/signin/passwordless/email.ts b/src/routes/signin/passwordless/email.ts index da50b8951..4f76efc5d 100644 --- a/src/routes/signin/passwordless/email.ts +++ b/src/routes/signin/passwordless/email.ts @@ -19,12 +19,14 @@ import { Joi, email, registrationOptions } from '@/validation'; export type PasswordLessEmailRequestBody = { email: string; options: UserRegistrationOptionsWithRedirect; + recaptchaChallenge?: string; }; export const signInPasswordlessEmailSchema = Joi.object({ email: email.required(), options: registrationOptions, + recaptchaChallenge: Joi.string().allow('').optional(), }).meta({ className: 'SignInPasswordlessEmailSchema' }); export const signInPasswordlessEmailHandler: RequestHandler< diff --git a/src/routes/signin/passwordless/sms/sms.ts b/src/routes/signin/passwordless/sms/sms.ts index 58f52dfe6..e1a2eba2e 100644 --- a/src/routes/signin/passwordless/sms/sms.ts +++ b/src/routes/signin/passwordless/sms/sms.ts @@ -19,12 +19,14 @@ import { renderTemplate } from '@/templates'; export type PasswordLessSmsRequestBody = { phoneNumber: string; options: UserRegistrationOptions; + recaptchaChallenge?: string; }; export const signInPasswordlessSmsSchema = Joi.object({ phoneNumber, options: registrationOptions, + recaptchaChallenge: Joi.string().allow('').optional(), }).meta({ className: 'SignInPasswordlessSmsSchema' }); export const signInPasswordlessSmsHandler: RequestHandler< diff --git a/src/routes/signup/email-password.ts b/src/routes/signup/email-password.ts index aa4d46cc5..64b7a0f61 100644 --- a/src/routes/signup/email-password.ts +++ b/src/routes/signup/email-password.ts @@ -10,6 +10,7 @@ export const signUpEmailPasswordSchema = Joi.object({ email: email.required(), password: passwordInsert.required(), options: registrationOptions, + recaptchaChallenge: Joi.string().allow('').optional(), }).meta({ className: 'SignUpEmailPasswordSchema' }); export const signUpEmailPasswordHandler: RequestHandler< diff --git a/src/routes/signup/index.ts b/src/routes/signup/index.ts index 36f9b7399..6b50907a6 100644 --- a/src/routes/signup/index.ts +++ b/src/routes/signup/index.ts @@ -12,6 +12,8 @@ import { signUpWebauthnHandler, signUpWebauthnSchema, } from './webauthn'; +import { alwaysAllow, verifyCaptcha } from '@/middleware/auth'; +import { ENV } from '@/utils/env'; const router = Router(); @@ -27,6 +29,7 @@ const router = Router(); router.post( '/signup/email-password', bodyValidator(signUpEmailPasswordSchema), + ENV.AUTH_SIGNUP_RECAPTCHA_CHALLENGE ? verifyCaptcha : alwaysAllow, aw(signUpEmailPasswordHandler) ); diff --git a/src/routes/user/index.ts b/src/routes/user/index.ts index 248165089..329fff645 100644 --- a/src/routes/user/index.ts +++ b/src/routes/user/index.ts @@ -1,8 +1,12 @@ import { Router } from 'express'; -import { asyncWrapper as aw } from '@/utils'; +import { ENV, asyncWrapper as aw } from '@/utils'; import { bodyValidator } from '@/validation'; -import { authenticationGate } from '@/middleware/auth'; +import { + alwaysAllow, + authenticationGate, + verifyCaptcha, +} from '@/middleware/auth'; import { userMFAHandler, userMfaSchema } from './mfa'; import { userHandler } from './user'; @@ -51,6 +55,7 @@ router.get('/user', authenticationGate, aw(userHandler)); router.post( '/user/password/reset', bodyValidator(userPasswordResetSchema), + ENV.AUTH_SIGNIN_RECAPTCHA_CHALLENGE ? verifyCaptcha : alwaysAllow, aw(userPasswordResetHandler) ); diff --git a/src/routes/user/password-reset.ts b/src/routes/user/password-reset.ts index b222f9287..157f358f3 100644 --- a/src/routes/user/password-reset.ts +++ b/src/routes/user/password-reset.ts @@ -19,6 +19,7 @@ export const userPasswordResetSchema = Joi.object({ options: Joi.object({ redirectTo, }).default(), + recaptchaChallenge: Joi.string().allow('').optional(), }).meta({ className: 'UserPasswordResetSchema' }); export const userPasswordResetHandler: RequestHandler< diff --git a/src/utils/env.ts b/src/utils/env.ts index b3650677a..61f212e2f 100644 --- a/src/utils/env.ts +++ b/src/utils/env.ts @@ -116,6 +116,11 @@ export const ENV = { return castIntEnv('AUTH_WEBAUTHN_ATTESTATION_TIMEOUT', 60000); }, + // RECAPTCHA + get AUTH_RECAPTCHA_SITE_KEY() { + return castStringEnv('AUTH_RECAPTCHA_SITE_KEY', ''); + }, + // SIGN UP get AUTH_ANONYMOUS_USERS_ENABLED() { return castBooleanEnv('AUTH_ANONYMOUS_USERS_ENABLED', false); @@ -164,6 +169,9 @@ export const ENV = { // remove duplicates return [...new Set(locales)]; }, + get AUTH_SIGNUP_RECAPTCHA_CHALLENGE() { + return castBooleanEnv('AUTH_SIGNUP_RECAPTCHA_CHALLENGE', false); + }, // SIGN IN get AUTH_EMAIL_PASSWORDLESS_ENABLED() { @@ -175,6 +183,10 @@ export const ENV = { get AUTH_EMAIL_SIGNIN_EMAIL_VERIFIED_REQUIRED() { return castBooleanEnv('AUTH_EMAIL_SIGNIN_EMAIL_VERIFIED_REQUIRED', true); }, + get AUTH_SIGNIN_RECAPTCHA_CHALLENGE() { + return castBooleanEnv('AUTH_SIGNIN_RECAPTCHA_CHALLENGE', false); + }, + // get AUTH_SIGNIN_PHONE_NUMBER_VERIFIED_REQUIRED() { // return castBooleanEnv('AUTH_SIGNIN_PHONE_NUMBER_VERIFIED_REQUIRED', true); // }, diff --git a/src/utils/recaptcha.ts b/src/utils/recaptcha.ts new file mode 100644 index 000000000..b6aaf8691 --- /dev/null +++ b/src/utils/recaptcha.ts @@ -0,0 +1,17 @@ +import axios from 'axios'; +import { ENV } from '@/utils/env'; + +export const RECAPTCHA_BASE_URL = + 'https://www.google.com/recaptcha/api/siteverify'; + +export const checkRecaptchaChallenge = async (recaptchaChallenge: string) => { + if (!ENV.AUTH_RECAPTCHA_SITE_KEY) { + throw new Error('missing-captcha-site-key'); + } + + const { data } = await axios.post( + `${RECAPTCHA_BASE_URL}?secret=${ENV.AUTH_RECAPTCHA_SITE_KEY}&response=${recaptchaChallenge}` + ); + + return data.success; +};