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 = () => {
+