From b7a806f19048ba07e9a1117f2fca8cb066fc0be1 Mon Sep 17 00:00:00 2001 From: can kurttekin Date: Tue, 12 Nov 2024 12:57:56 +0300 Subject: [PATCH] add turnstile to login and register --- backend/docker-compose.yml | 1 + .../service/TurnstileVerificationService.java | 20 +++++++++++++++ .../presentation/rest/AuthController.java | 23 ++++++++++++++--- .../src/main/resources/application.properties | 2 ++ .../presentation/rest/AuthControllerTest.java | 21 +++++++++++++--- frontend/package-lock.json | 11 ++++++++ frontend/package.json | 1 + frontend/src/components/Login.js | 14 ++++++++++- frontend/src/components/Register.js | 14 +++++++++-- frontend/src/components/TurnstileWidget.js | 25 +++++++++++++++++++ frontend/src/config.js | 3 ++- frontend/src/contexts/AuthContext.js | 4 +-- frontend/src/services/authService.js | 8 +++--- 13 files changed, 130 insertions(+), 17 deletions(-) create mode 100644 backend/src/main/java/com/kurttekin/can/job_track/application/service/TurnstileVerificationService.java create mode 100644 frontend/src/components/TurnstileWidget.js diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml index 3777e535..f1ac59d8 100644 --- a/backend/docker-compose.yml +++ b/backend/docker-compose.yml @@ -21,6 +21,7 @@ services: MAIL_PORT: ${MAIL_PORT} MAIL_USER: ${MAIL_USER} MAIL_PASS: ${MAIL_PASS} + TURNSTILE_SECRET: ${TURNSTILE_SECRET} depends_on: - db diff --git a/backend/src/main/java/com/kurttekin/can/job_track/application/service/TurnstileVerificationService.java b/backend/src/main/java/com/kurttekin/can/job_track/application/service/TurnstileVerificationService.java new file mode 100644 index 00000000..63b47da9 --- /dev/null +++ b/backend/src/main/java/com/kurttekin/can/job_track/application/service/TurnstileVerificationService.java @@ -0,0 +1,20 @@ +package com.kurttekin.can.job_track.application.service; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; +import java.util.HashMap; +import java.util.Map; +@Service +public class TurnstileVerificationService { + @Value("${turnstile.secret-key}") + private String turnstileSecretKey; + private static final String VERIFY_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify"; + public boolean verifyToken(String turnstileToken) { + RestTemplate restTemplate = new RestTemplate(); + Map requestBody = new HashMap<>(); + requestBody.put("secret", turnstileSecretKey); + requestBody.put("response", turnstileToken); + Map response = restTemplate.postForObject(VERIFY_URL, requestBody, Map.class); + return response != null && Boolean.TRUE.equals(response.get("success")); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/kurttekin/can/job_track/presentation/rest/AuthController.java b/backend/src/main/java/com/kurttekin/can/job_track/presentation/rest/AuthController.java index 75874e79..10451d63 100644 --- a/backend/src/main/java/com/kurttekin/can/job_track/presentation/rest/AuthController.java +++ b/backend/src/main/java/com/kurttekin/can/job_track/presentation/rest/AuthController.java @@ -2,6 +2,7 @@ import com.kurttekin.can.job_track.application.dto.ErrorResponse; import com.kurttekin.can.job_track.application.dto.UserRegistrationRequest; +import com.kurttekin.can.job_track.application.service.TurnstileVerificationService; import com.kurttekin.can.job_track.domain.model.user.User; import com.kurttekin.can.job_track.domain.service.UserService; import com.kurttekin.can.job_track.domain.service.VerificationService; @@ -34,9 +35,19 @@ public class AuthController { @Autowired private VerificationService verificationService; + @Autowired + private TurnstileVerificationService turnstileVerificationService; + @PostMapping("/login") - public ResponseEntity login(@RequestBody LoginRequest loginRequest) { + public ResponseEntity login(@RequestBody LoginRequest loginRequest, + @RequestParam String turnstileToken) { try { + // Verify Turnstile token + boolean isTokenValid = turnstileVerificationService.verifyToken(turnstileToken); + if (!isTokenValid) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(new ErrorResponse("CAPTCHA failed.")); + } + User user = userService.findUserByUsername(loginRequest.getUsername()) .orElseThrow(() -> new BadCredentialsException("Invalid credentials")); @@ -64,8 +75,14 @@ public ResponseEntity login(@RequestBody LoginRequest loginRequest) { } @PostMapping("/register") - public ResponseEntity registerUser(@RequestBody UserRegistrationRequest userRequest) { + public ResponseEntity registerUser(@RequestBody UserRegistrationRequest userRequest, + @RequestParam String turnstileToken) { try { + // Verify Turnstile token + boolean isTokenValid = turnstileVerificationService.verifyToken(turnstileToken); + if (!isTokenValid) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).body("CAPTCHA failed."); + } userService.registerUser(userRequest); return ResponseEntity.ok("User registered successfully! Please verify your email before logging in."); } catch (Exception e) { @@ -78,4 +95,4 @@ public String verifyEmail(@RequestParam("token") String token) { boolean isValid = verificationService.verifyUser(token); return isValid ? "Email verified successfully. You can now log in." : "Invalid or expired verification token."; } -} +} \ No newline at end of file diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index 31213537..ec2d4d60 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -16,3 +16,5 @@ server.port=${PORT:8080} #server.ssl.key-store-password=${KEY_STORE_PASS} #server.ssl.keyStoreType=PKCS12 #server.ssl.key-alias=${KEY_STORE_ALIAS} + +turnstile.secret-key=${TURNSTILE_SECRET:"0x0000"} \ No newline at end of file diff --git a/backend/src/test/java/com/kurttekin/can/job_track/presentation/rest/AuthControllerTest.java b/backend/src/test/java/com/kurttekin/can/job_track/presentation/rest/AuthControllerTest.java index 408e7a85..92fb5762 100644 --- a/backend/src/test/java/com/kurttekin/can/job_track/presentation/rest/AuthControllerTest.java +++ b/backend/src/test/java/com/kurttekin/can/job_track/presentation/rest/AuthControllerTest.java @@ -4,6 +4,7 @@ import com.kurttekin.can.job_track.application.dto.LoginRequest; import com.kurttekin.can.job_track.application.dto.UserRegistrationRequest; import com.kurttekin.can.job_track.application.service.EmailService; +import com.kurttekin.can.job_track.application.service.TurnstileVerificationService; import com.kurttekin.can.job_track.domain.service.UserService; import com.kurttekin.can.job_track.infrastructure.security.jwt.JwtProvider; import org.junit.jupiter.api.BeforeEach; @@ -48,10 +49,13 @@ class AuthControllerTest { @Mock private EmailService emailService; + @Mock + private TurnstileVerificationService turnstileVerificationService; + private LoginRequest loginRequest; private UserRegistrationRequest userRegistrationRequest; private String token; - + private String turnstileToken; @BeforeEach public void setUp() { @@ -60,14 +64,19 @@ public void setUp() { userRegistrationRequest = new UserRegistrationRequest("testuser", "testuser@test.com", "testpassword"); token = "test.jwt.token"; + turnstileToken= "test.jwt.turnstile"; } @Test public void testLogin_InvalidCredentials() { + // Mock Turnstile verification logic + //when(turnstileVerificationService.verifyToken(anyString())).thenReturn(true); + when(turnstileVerificationService.verifyToken(turnstileToken)).thenReturn(true); + when(authenticationManager.authenticate(any(UsernamePasswordAuthenticationToken.class))) .thenThrow(new BadCredentialsException("Invalid credentials")); - ResponseEntity response = authController.login(loginRequest); + ResponseEntity response = authController.login(loginRequest, turnstileToken); assertEquals(HttpStatus.UNAUTHORIZED, response.getStatusCode()); assertEquals("Invalid credentials", response.getBody()); @@ -82,8 +91,10 @@ public void testRegisterUser_Success() { doNothing().when(userService).registerUser(any(UserRegistrationRequest.class)); doNothing().when(emailService).sendVerificationEmail(anyString(),anyString(), anyString()); // Mock email sending + when(turnstileVerificationService.verifyToken(turnstileToken)).thenReturn(true); + // Call the registerUser method in the controller - ResponseEntity response = authController.registerUser(userRegistrationRequest); + ResponseEntity response = authController.registerUser(userRegistrationRequest, turnstileToken); // Check the status and response body assertEquals(HttpStatus.OK, response.getStatusCode()); @@ -94,7 +105,9 @@ public void testRegisterUser_Success() { public void testRegisterUser_Failure() { doThrow(new RuntimeException("Registration failed")).when(userService).registerUser(any(UserRegistrationRequest.class)); - ResponseEntity response = authController.registerUser(userRegistrationRequest); + when(turnstileVerificationService.verifyToken(turnstileToken)).thenReturn(true); + + ResponseEntity response = authController.registerUser(userRegistrationRequest, turnstileToken); assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); assertEquals("Registration failed", response.getBody()); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 131a7839..45470788 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -21,6 +21,7 @@ "react-router-dom": "^6.27.0", "react-scripts": "^5.0.1", "react-toastify": "^10.0.5", + "react-turnstile": "^1.1.4", "recharts": "^2.13.0", "styled-components": "^6.1.13", "web-vitals": "^2.1.4" @@ -16169,6 +16170,16 @@ "react-dom": ">=16.6.0" } }, + "node_modules/react-turnstile": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/react-turnstile/-/react-turnstile-1.1.4.tgz", + "integrity": "sha512-oluyRWADdsufCt5eMqacW4gfw8/csr6Tk+fmuaMx0PWMKP1SX1iCviLvD2D5w92eAzIYDHi/krUWGHhlfzxTpQ==", + "license": "MIT", + "peerDependencies": { + "react": ">= 16.13.1", + "react-dom": ">= 16.13.1" + } + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 36c89342..9976b9bb 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,6 +17,7 @@ "react-router-dom": "^6.27.0", "react-scripts": "^5.0.1", "react-toastify": "^10.0.5", + "react-turnstile": "^1.1.4", "recharts": "^2.13.0", "styled-components": "^6.1.13", "web-vitals": "^2.1.4" diff --git a/frontend/src/components/Login.js b/frontend/src/components/Login.js index 4b9bd5ee..a3175d57 100644 --- a/frontend/src/components/Login.js +++ b/frontend/src/components/Login.js @@ -2,6 +2,7 @@ import React, { useContext, useState } from 'react'; import styled from 'styled-components'; import { useNavigate } from 'react-router-dom'; import { AuthContext } from '../contexts/AuthContext'; // Import the context +import TurnstileWidget from './TurnstileWidget'; const Container = styled.div` display: flex; @@ -50,6 +51,11 @@ const Login = () => { const [password, setPassword] = useState(""); const [error, setError] = useState(""); // To store and display error messages const { login } = useContext(AuthContext); + const [turnstileToken, setTurnstileToken] = useState(null); + + const handleTurnstileChange = (token) => { + setTurnstileToken(token); + }; const handleSubmit = async (e) => { e.preventDefault(); @@ -61,8 +67,13 @@ const Login = () => { return; } + if (!turnstileToken) { + setError("Please complete the CAPTCHA."); + return; + } + try { - await login(username, password); // Call the login function + await login(username, password, turnstileToken); // Call the login function navigate('/job-applications'); // Redirect after successful login } catch (error) { setError(error.message); // Set the error message @@ -87,6 +98,7 @@ const Login = () => { value={password} onChange={e => setPassword(e.target.value)} /> + {error && {error}} {/* Display error message */} diff --git a/frontend/src/components/Register.js b/frontend/src/components/Register.js index 3ccb4094..4daab2ac 100644 --- a/frontend/src/components/Register.js +++ b/frontend/src/components/Register.js @@ -2,6 +2,7 @@ import React, { useState } from 'react'; import { useNavigate } from 'react-router-dom'; import styled from 'styled-components'; import { register as registerService } from '../services/authService'; // Import the register function from authService +import TurnstileWidget from './TurnstileWidget'; // Styled components for layout and styling const Container = styled.div` @@ -75,7 +76,7 @@ const Register = () => { specialChar: false, }); const [isPasswordFocused, setIsPasswordFocused] = useState(false); // Track if the password field is focused - + const [turnstileToken, setTurnstileToken] = useState(null); const navigate = useNavigate(); // Password validation function @@ -107,6 +108,10 @@ const Register = () => { setIsPasswordFocused(false); }; + const handleTurnstileChange = (token) => { + setTurnstileToken(token); + }; + // Handle form submit const handleSubmit = async (e) => { e.preventDefault(); @@ -124,8 +129,13 @@ const Register = () => { return; } + if (!turnstileToken) { + setError("Please complete the CAPTCHA."); + return; + } + try { - await registerService(username, email, password); // Call the register function + await registerService(username, email, password, turnstileToken); // Call the register function setError('Registration successful. Please verify your email before logging in.'); setTimeout(() => navigate('/login'), 1500); // Redirect after 1.5 seconds } catch (err) { diff --git a/frontend/src/components/TurnstileWidget.js b/frontend/src/components/TurnstileWidget.js new file mode 100644 index 00000000..7f88f489 --- /dev/null +++ b/frontend/src/components/TurnstileWidget.js @@ -0,0 +1,25 @@ +import React, { useState } from 'react'; +import Turnstile from 'react-turnstile'; +import {REACT_APP_TURNSTILE_SITE_KEY} from "../config"; + +const TurnstileWidget = ({ onChange }) => { + const [token, setToken] = useState(null); + + const handleTurnstileChange = (value) => { + setToken(value); + if (onChange) { + onChange(value); // Pass token back to parent component + } + }; + + return ( +
+ +
+ ); +}; + +export default TurnstileWidget; diff --git a/frontend/src/config.js b/frontend/src/config.js index 8222ed45..63d92873 100644 --- a/frontend/src/config.js +++ b/frontend/src/config.js @@ -1 +1,2 @@ -export const REACT_APP_BACKEND_URL = process.env.REACT_APP_BACKEND_URL+'/api'; \ No newline at end of file +export const REACT_APP_BACKEND_URL = process.env.REACT_APP_BACKEND_URL+'/api'; +export const REACT_APP_TURNSTILE_SITE_KEY = process.env.REACT_APP_TURNSTILE_SITE_KEY; \ No newline at end of file diff --git a/frontend/src/contexts/AuthContext.js b/frontend/src/contexts/AuthContext.js index 275e7182..92fb1f71 100644 --- a/frontend/src/contexts/AuthContext.js +++ b/frontend/src/contexts/AuthContext.js @@ -18,9 +18,9 @@ export const AuthProvider = ({ children }) => { } }, []); - const login = async (username, password) => { + const login = async (username, password, turnstileToken) => { try { - const response = await loginService(username, password); + const response = await loginService(username, password, turnstileToken); const { token } = response; if (token) { diff --git a/frontend/src/services/authService.js b/frontend/src/services/authService.js index f829e40c..93bf11c1 100644 --- a/frontend/src/services/authService.js +++ b/frontend/src/services/authService.js @@ -1,11 +1,11 @@ import axios from 'axios'; import { REACT_APP_BACKEND_URL } from '../config'; -export const login = async (username, password) => { +export const login = async (username, password, turnstileToken) => { try { const response = await axios.post( `${REACT_APP_BACKEND_URL}/auth/login`, - { username, password }, + { username, password, turnstileToken }, { headers: { 'Content-Type': 'application/json', @@ -33,8 +33,8 @@ export const login = async (username, password) => { } }; -export const register = async (username, email, password) => { - return axios.post(`${REACT_APP_BACKEND_URL}/auth/register`, { username, email, password }); +export const register = async (username, email, password, turnstileToken) => { + return axios.post(`${REACT_APP_BACKEND_URL}/auth/register`, { username, email, password, turnstileToken }); }; export const logout = () => {