diff --git a/.github/workflows/tests_be.yml b/.github/workflows/tests_be.yml index 4e3a662b9..d0cca4c2c 100644 --- a/.github/workflows/tests_be.yml +++ b/.github/workflows/tests_be.yml @@ -18,6 +18,8 @@ env: CORS_ORIGIN_WHITELIST: '' ALLOWED_ENV_HOST: '' REDIS_URL: ${{ vars.REDIS_URL }} + RECAPTCHA_V2_PRIVATE_KEY: '' + RECAPTCHA_URL: ${{ vars.RECAPTCHA_URL }} jobs: diff --git a/BackEnd/authentication/serializers.py b/BackEnd/authentication/serializers.py index 3aa6814be..121bdb0cb 100644 --- a/BackEnd/authentication/serializers.py +++ b/BackEnd/authentication/serializers.py @@ -20,6 +20,7 @@ validate_password_include_symbols, ) from validation.validate_profile import validate_profile +from validation.validate_recaptcha import verify_recaptcha User = get_user_model() @@ -42,13 +43,17 @@ class UserRegistrationSerializer(UserCreatePasswordRetypeSerializer): password = serializers.CharField( style={"input_type": "password"}, write_only=True ) + captcha = serializers.CharField( + write_only=True, allow_blank=True, allow_null=True + ) class Meta(UserCreatePasswordRetypeSerializer.Meta): model = User - fields = ("email", "password", "name", "surname", "company") + fields = ("email", "password", "name", "surname", "company", "captcha") def validate(self, value): custom_errors = defaultdict(list) + captcha_token = value.get("captcha") self.fields.pop("re_password", None) re_password = value.pop("re_password") email = value.get("email").lower() @@ -74,11 +79,16 @@ def validate(self, value): custom_errors["password"].append(error.message) if value["password"] != re_password: custom_errors["password"].append("Passwords don't match.") + if captcha_token and not verify_recaptcha(captcha_token): + custom_errors["captcha"].append( + "Invalid reCAPTCHA. Please try again." + ) if custom_errors: raise serializers.ValidationError(custom_errors) return value def create(self, validated_data): + validated_data.pop("captcha", None) company_data = validated_data.pop("company") user = User.objects.create(**validated_data) user.set_password(validated_data["password"]) @@ -105,7 +115,13 @@ class Meta(UserSerializer.Meta): class CustomTokenCreateSerializer(TokenCreateSerializer): + captcha = serializers.CharField( + write_only=True, allow_blank=True, allow_null=True + ) + def validate(self, attrs): + captcha_token = attrs.get("captcha") + try: validate_profile(attrs.get("email")) except ValidationError as error: @@ -116,6 +132,11 @@ def validate(self, attrs): except RateLimitException: self.fail("inactive_account") + if captcha_token and not verify_recaptcha(captcha_token): + raise serializers.ValidationError( + "Invalid reCAPTCHA. Please try again." + ) + @RateLimitDecorator( calls=django_settings.ATTEMPTS_FOR_LOGIN, period=django_settings.DELAY_FOR_LOGIN, diff --git a/BackEnd/authentication/tests/test_user_autologout.py b/BackEnd/authentication/tests/test_user_autologout.py index edf84e3bf..fe2b75c28 100644 --- a/BackEnd/authentication/tests/test_user_autologout.py +++ b/BackEnd/authentication/tests/test_user_autologout.py @@ -1,4 +1,5 @@ from datetime import timedelta +from unittest.mock import patch from rest_framework import status from rest_framework.authtoken.models import Token @@ -12,6 +13,12 @@ class UserLogoutAPITests(APITestCase): def setUp(self): + patcher = patch( + "authentication.serializers.verify_recaptcha", return_value=True + ) + self.mock_verify_recaptcha = patcher.start() + self.addCleanup(patcher.stop) + self.user = UserFactory( email="test@test.com", name="Test", surname="Test" ) @@ -29,6 +36,7 @@ def test_user_autologout_after_14_days(self): data={ "email": "test@test.com", "password": "Test1234", + "captcha": "dummy_captcha", }, ).data["auth_token"] token = Token.objects.get(key=self.test_user_token) @@ -53,6 +61,7 @@ def test_user_autologout_after_10_days(self): data={ "email": "test@test.com", "password": "Test1234", + "captcha": "dummy_captcha", }, ).data["auth_token"] token = Token.objects.get(key=self.test_user_token) diff --git a/BackEnd/authentication/tests/test_user_login.py b/BackEnd/authentication/tests/test_user_login.py index d20a70404..8e7c9b0db 100644 --- a/BackEnd/authentication/tests/test_user_login.py +++ b/BackEnd/authentication/tests/test_user_login.py @@ -1,3 +1,5 @@ +from unittest.mock import patch + from rest_framework import status from rest_framework.test import APITestCase from time import sleep @@ -9,6 +11,12 @@ class UserLoginAPITests(APITestCase): def setUp(self): + patcher = patch( + "authentication.serializers.verify_recaptcha", return_value=True + ) + self.mock_verify_recaptcha = patcher.start() + self.addCleanup(patcher.stop) + self.user = UserFactory(email="test@test.com") def test_login_successful(self): @@ -20,6 +28,7 @@ def test_login_successful(self): data={ "email": "test@test.com", "password": "Test1234", + "captcha": "dummy_captcha", }, ) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -35,6 +44,7 @@ def test_login_email_incorrect(self): data={ "email": "tost@test.com", "password": "Test1234", + "captcha": "dummy_captcha", }, ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) @@ -56,6 +66,7 @@ def test_login_password_incorrect(self): data={ "email": "test@test.com", "password": "Test5678", + "captcha": "dummy_captcha", }, ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) @@ -77,6 +88,7 @@ def test_login_after_allowed_number_attempts(self): data={ "email": "test@test.com", "password": "Test1234", + "captcha": "dummy_captcha", }, ) self.client.post( @@ -84,6 +96,7 @@ def test_login_after_allowed_number_attempts(self): data={ "email": "test@test.com", "password": "Test1234", + "captcha": "dummy_captcha", }, ) @@ -92,6 +105,7 @@ def test_login_after_allowed_number_attempts(self): data={ "email": "test@test.com", "password": "Test1234", + "captcha": "dummy_captcha", }, ) sleep(6) @@ -110,6 +124,7 @@ def test_login_after_allowed_delay_time(self): data={ "email": "test@test.com", "password": "Test1234", + "captcha": "dummy_captcha", }, ) @@ -118,6 +133,7 @@ def test_login_after_allowed_delay_time(self): data={ "email": "test@test.com", "password": "Test1234", + "captcha": "dummy_captcha", }, ) self.client.post( @@ -125,6 +141,7 @@ def test_login_after_allowed_delay_time(self): data={ "email": "test@test.com", "password": "Test1234", + "captcha": "dummy_captcha", }, ) sleep(6) @@ -133,6 +150,7 @@ def test_login_after_allowed_delay_time(self): data={ "email": "test@test.com", "password": "Test1234", + "captcha": "dummy_captcha", }, ) diff --git a/BackEnd/authentication/tests/test_user_logout.py b/BackEnd/authentication/tests/test_user_logout.py index ca6cd66d2..29c7f279c 100644 --- a/BackEnd/authentication/tests/test_user_logout.py +++ b/BackEnd/authentication/tests/test_user_logout.py @@ -1,3 +1,5 @@ +from unittest.mock import patch + from rest_framework import status from rest_framework.test import APITestCase @@ -8,6 +10,12 @@ class UserLogoutAPITests(APITestCase): def setUp(self): + patcher = patch( + "authentication.serializers.verify_recaptcha", return_value=True + ) + self.mock_verify_recaptcha = patcher.start() + self.addCleanup(patcher.stop) + self.user = UserFactory(email="test@test.com") def test_logout_successful(self): @@ -19,6 +27,7 @@ def test_logout_successful(self): data={ "email": "test@test.com", "password": "Test1234", + "captcha": "dummy_captcha", }, ).data["auth_token"] self.client.credentials( diff --git a/BackEnd/authentication/tests/test_user_registration.py b/BackEnd/authentication/tests/test_user_registration.py index 444b87c0d..f9ec1e4e6 100644 --- a/BackEnd/authentication/tests/test_user_registration.py +++ b/BackEnd/authentication/tests/test_user_registration.py @@ -1,3 +1,5 @@ +from unittest.mock import patch + from rest_framework import status from rest_framework.test import APITestCase @@ -8,6 +10,12 @@ class UserRegistrationAPITests(APITestCase): def setUp(self): + patcher = patch( + "authentication.serializers.verify_recaptcha", return_value=True + ) + self.mock_verify_recaptcha = patcher.start() + self.addCleanup(patcher.stop) + self.user = UserFactory(email="test@test.com") def test_register_user_yurosoba_successful(self): @@ -19,6 +27,7 @@ def test_register_user_yurosoba_successful(self): "re_password": "Test1234", "name": "Jane", "surname": "Smith", + "captcha": "dummy_captcha", "company": { "name": "My Company", "is_registered": True, @@ -49,6 +58,7 @@ def test_register_user_fop_successful(self): "re_password": "Test1234", "name": "Jane", "surname": "Smith", + "captcha": "dummy_captcha", "company": { "name": "My Company", "is_registered": True, @@ -79,6 +89,7 @@ def test_register_user_email_incorrect(self): "re_password": "Test1234", "name": "Jane", "surname": "Smith", + "captcha": "dummy_captcha", "company": { "name": "My Company", "is_registered": True, @@ -103,6 +114,7 @@ def test_register_user_email_exists(self): "re_password": "Test1234", "name": "Test", "surname": "Test", + "captcha": "dummy_captcha", "company": { "name": "Test Company", "is_registered": True, @@ -127,6 +139,7 @@ def test_register_user_password_incorrect(self): "re_password": "tess", "name": "Jane", "surname": "Smith", + "captcha": "dummy_captcha", "company": { "name": "My Company", "is_registered": True, @@ -157,6 +170,7 @@ def test_register_user_who_represent_empty_fields(self): "re_password": "Test1234", "name": "Jane", "surname": "Smith", + "captcha": "dummy_captcha", "company": { "name": "My Company", "is_registered": False, @@ -181,6 +195,7 @@ def test_register_user_who_represent_both_chosen(self): "re_password": "Test1234", "name": "Jane", "surname": "Smith", + "captcha": "dummy_captcha", "company": { "name": "My Company", "is_registered": True, diff --git a/BackEnd/forum/settings.py b/BackEnd/forum/settings.py index deb84dfac..a9c668afe 100644 --- a/BackEnd/forum/settings.py +++ b/BackEnd/forum/settings.py @@ -267,6 +267,10 @@ def show_toolbar(request): }, } +# ReCaptcha V2 Invisible +RECAPTCHA_V2_PRIVATE_KEY = config("RECAPTCHA_V2_PRIVATE_KEY") +RECAPTCHA_URL = config("RECAPTCHA_URL") + CONTACTS_INFO = { "email": "craft.forum0@gmail.com", "phone": "+38 050 234 23 23", diff --git a/BackEnd/profiles/tests/test_reject_moderation_request.py b/BackEnd/profiles/tests/test_reject_moderation_request.py index bd820fbbd..edae46969 100644 --- a/BackEnd/profiles/tests/test_reject_moderation_request.py +++ b/BackEnd/profiles/tests/test_reject_moderation_request.py @@ -429,6 +429,7 @@ def test_login_blocked_user_due_to_rejected_request( data={ "email": "test@test.com", "password": "Test1234", + "captcha": "dummy_captcha", }, ) self.assertEqual(status.HTTP_400_BAD_REQUEST, response.status_code) @@ -439,8 +440,9 @@ def test_login_blocked_user_due_to_rejected_request( mock_schedule.assert_called_once() mock_revoke.assert_called_once() + @patch("authentication.serializers.verify_recaptcha", return_value=True) def test_register_blocked_user_due_to_rejected_request( - self, mock_revoke, mock_schedule + self, mock_revoke, mock_schedule, mock_verify_recaptcha ): # user updates both banner and logo self.user_client.patch( @@ -475,6 +477,7 @@ def test_register_blocked_user_due_to_rejected_request( "re_password": "Test1234", "name": "Test", "surname": "Test", + "captcha": "dummy_captcha", "company": { "name": "Test Company", "is_registered": True, diff --git a/BackEnd/validation/validate_recaptcha.py b/BackEnd/validation/validate_recaptcha.py new file mode 100644 index 000000000..a7ed82229 --- /dev/null +++ b/BackEnd/validation/validate_recaptcha.py @@ -0,0 +1,16 @@ +from django.conf import settings +import requests + + +def verify_recaptcha(token): + """ + Validates the reCAPTCHA token with Google's API. + """ + recaptcha_url = settings.RECAPTCHA_URL + recaptcha_data = { + "secret": settings.RECAPTCHA_V2_PRIVATE_KEY, + "response": token, + } + response = requests.post(recaptcha_url, data=recaptcha_data) + result = response.json() + return result.get("success", False) diff --git a/FrontEnd/package-lock.json b/FrontEnd/package-lock.json index 394833959..2c270b451 100644 --- a/FrontEnd/package-lock.json +++ b/FrontEnd/package-lock.json @@ -22,6 +22,7 @@ "react": "^18.2.0", "react-cookie": "^6.1.0", "react-dom": "^18.2.0", + "react-google-recaptcha": "^3.1.0", "react-hook-form": "^7.45.4", "react-router-dom": "^6.22.3", "react-router-hash-link": "^2.4.3", @@ -17039,6 +17040,18 @@ "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==" }, + "node_modules/react-async-script": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/react-async-script/-/react-async-script-1.2.0.tgz", + "integrity": "sha512-bCpkbm9JiAuMGhkqoAiC0lLkb40DJ0HOEJIku+9JDjxX3Rcs+ztEOG13wbrOskt3n2DTrjshhaQ/iay+SnGg5Q==", + "dependencies": { + "hoist-non-react-statics": "^3.3.0", + "prop-types": "^15.5.0" + }, + "peerDependencies": { + "react": ">=16.4.1" + } + }, "node_modules/react-cookie": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/react-cookie/-/react-cookie-6.1.0.tgz", @@ -17111,6 +17124,18 @@ "resolved": "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-6.0.11.tgz", "integrity": "sha512-/6UZ2qgEyH2aqzYZgQPxEnz33NJ2gNsnHA2o5+o4wW9bLM/JYQitNP9xPhsXwC08hMMovfGe/8retsdDsczPRg==" }, + "node_modules/react-google-recaptcha": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/react-google-recaptcha/-/react-google-recaptcha-3.1.0.tgz", + "integrity": "sha512-cYW2/DWas8nEKZGD7SCu9BSuVz8iOcOLHChHyi7upUuVhkpkhYG/6N3KDiTQ3XAiZ2UAZkfvYKMfAHOzBOcGEg==", + "dependencies": { + "prop-types": "^15.5.0", + "react-async-script": "^1.2.0" + }, + "peerDependencies": { + "react": ">=16.4.1" + } + }, "node_modules/react-hook-form": { "version": "7.46.0", "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.46.0.tgz", diff --git a/FrontEnd/package.json b/FrontEnd/package.json index 4f0c3d281..1a0483456 100644 --- a/FrontEnd/package.json +++ b/FrontEnd/package.json @@ -29,6 +29,7 @@ "react-scripts": "^5.0.1", "react-timer-hook": "^3.0.7", "react-toastify": "^9.1.3", + "react-google-recaptcha": "^3.1.0", "swr": "^2.2.2", "validator": "^13.11.0", "web-vitals": "^2.1.4" diff --git a/FrontEnd/sample.env b/FrontEnd/sample.env index 47e459c4d..a92153139 100644 --- a/FrontEnd/sample.env +++ b/FrontEnd/sample.env @@ -1,2 +1,4 @@ REACT_APP_BASE_API_URL= -REACT_APP_PUBLIC_URL= \ No newline at end of file +REACT_APP_PUBLIC_URL= + +REACT_APP_RECAPTCHA_V2_SITE_KEY= \ No newline at end of file diff --git a/FrontEnd/src/pages/Authorization/LoginPage.jsx b/FrontEnd/src/pages/Authorization/LoginPage.jsx index ea2c5bec2..ca91560b6 100644 --- a/FrontEnd/src/pages/Authorization/LoginPage.jsx +++ b/FrontEnd/src/pages/Authorization/LoginPage.jsx @@ -1,5 +1,5 @@ import { useForm } from 'react-hook-form'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { useNavigate, Link } from 'react-router-dom'; import axios from 'axios'; import { useStopwatch } from 'react-timer-hook'; @@ -11,11 +11,13 @@ import EyeInvisible from './EyeInvisible'; import classes from './LoginPage.module.css'; import { useAuth } from '../../hooks'; import checkIfStaff from '../AdminPage/checkIfStaff'; +import ReCAPTCHA from 'react-google-recaptcha'; const LoginContent = () => { const { login } = useAuth(); const navigate = useNavigate(); const [showPassword, setShowPassword] = useState(false); + const reCaptchaRef = useRef(); const togglePassword = () => { setShowPassword(!showPassword); @@ -82,12 +84,20 @@ const LoginContent = () => { const disabled = !isValid || (isRunning && minutes < 10); const onSubmit = async (value) => { + let recaptcha_token = null; + try { + recaptcha_token = await reCaptchaRef.current.executeAsync(); + } catch (error) { + console.warn('reCAPTCHA failed or quota expired:', error); + } + try { const response = await axios.post( `${process.env.REACT_APP_BASE_API_URL}/api/auth/token/login/`, { email: value.email, password: value.password, + captcha: recaptcha_token, } ); const authToken = response.data.auth_token; @@ -121,6 +131,8 @@ const LoginContent = () => { }); } } + } finally { + reCaptchaRef.current?.reset(); } }; @@ -227,6 +239,11 @@ const LoginContent = () => { + ); diff --git a/FrontEnd/src/pages/ProfileList/ProfileCard.jsx b/FrontEnd/src/pages/ProfileList/ProfileCard.jsx index 9f25d2db6..3525e9ef4 100644 --- a/FrontEnd/src/pages/ProfileList/ProfileCard.jsx +++ b/FrontEnd/src/pages/ProfileList/ProfileCard.jsx @@ -108,7 +108,7 @@ export default function ProfileCard({ isAuthorized, data, savedIsUpdated, onClea
import('./RulesModal')); export function SignUpFormContentComponent(props) { const [showPassword, setShowPassword] = useState(false); const [showConfirmPassword, setShowConfirmPassword] = useState(false); + const reCaptchaRef = useRef(); + const onReCaptchaChange = () => reCaptchaRef.current = !reCaptchaRef.current; const toggleConfirmPassword = () => { setShowConfirmPassword(!showConfirmPassword); }; @@ -121,13 +124,21 @@ export function SignUpFormContentComponent(props) { } }, [watch('confirmPassword'), watch('password')]); - const onSubmit = () => { + const onSubmit = async () => { + let recaptcha_token = null; + try { + recaptcha_token = await reCaptchaRef.current.executeAsync(); + } catch (error) { + console.warn('reCAPTCHA failed or quota expired:', error); + } + const dataToSend = { email: getValues('email'), password: getValues('password'), re_password: getValues('confirmPassword'), name: getValues('name'), surname: getValues('surname'), + captcha: recaptcha_token, company: { name: getValues('companyName'), is_registered: getValues('representative').indexOf('company') > -1, @@ -543,6 +554,12 @@ export function SignUpFormContentComponent(props) { + Loading...}> {isModalOpen && } diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index ca1d78f7d..fa14300df 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -32,7 +32,9 @@ services: - ALLOWED_ENV_HOST=${ALLOWED_ENV_HOST} - CELERY_BROKER_URL=${REDIS_URL} - CELERY_RESULT_BACKEND=${REDIS_URL} - - REDIS_URL=${REDIS_URL} + - REDIS_URL=${REDIS_URL} + - RECAPTCHA_V2_PRIVATE_KEY=${RECAPTCHA_V2_PRIVATE_KEY} + - RECAPTCHA_URL=${RECAPTCHA_URL} networks: - forum_network frontend: @@ -42,6 +44,7 @@ services: environment: - REACT_APP_BASE_API_URL=${REACT_APP_BASE_API_URL} - REACT_APP_PUBLIC_URL=${REACT_APP_PUBLIC_URL} + - REACT_APP_RECAPTCHA_V2_SITE_KEY=${REACT_APP_RECAPTCHA_V2_SITE_KEY} ports: - 3000:80 build: @@ -77,6 +80,8 @@ services: - CELERY_BROKER_URL=${REDIS_URL} - CELERY_RESULT_BACKEND=${REDIS_URL} - REDIS_URL=${REDIS_URL} + - RECAPTCHA_V2_PRIVATE_KEY=${RECAPTCHA_V2_PRIVATE_KEY} + - RECAPTCHA_URL=${RECAPTCHA_URL} networks: - forum_network celery_beat: @@ -107,6 +112,8 @@ services: - CELERY_BROKER_URL=${REDIS_URL} - CELERY_RESULT_BACKEND=${REDIS_URL} - REDIS_URL=${REDIS_URL} + - RECAPTCHA_V2_PRIVATE_KEY=${RECAPTCHA_V2_PRIVATE_KEY} + - RECAPTCHA_URL=${RECAPTCHA_URL} networks: - forum_network redis: diff --git a/sample.env b/sample.env index 386cec112..f02fe8a4e 100644 --- a/sample.env +++ b/sample.env @@ -28,3 +28,6 @@ POSTGRES_DB= EMAIL_USE_TLS= REDIS_URL= redis://localhost:6379/0 + +RECAPTCHA_V2_PRIVATE_KEY = "6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe" # this is test key +RECAPTCHA_URL="https://www.google.com/recaptcha/api/siteverify"