From 52dad038d1021bca22a355199397785c11326d9e Mon Sep 17 00:00:00 2001 From: Rajal-ui Date: Sun, 17 May 2026 22:25:54 +0530 Subject: [PATCH] fix(auth): implement Google OAuth 2.0 login integration in backend and frontend This PR integrates Google OAuth 2.0 authentication into both the Spring Boot backend and React/Vite frontend. Backend changes: - Created AuthController and GoogleAuthService to verify Google ID tokens. - Defined GoogleAuthRequest DTO for incoming credential payloads. - Added database sub mapping, authentication provider details, and user entity updates. - Added application properties and environment variable definitions for GOOGLE_OAUTH_CLIENT_ID and JWT signing. - Added GoogleAuthServiceTest to verify backend validation logic. Frontend changes: - Integrated @react-oauth/google provider and components. - Integrated Google login triggers on the public Login page. - Cleaned up environment variables and README setup instructions to help developers launch and run services seamlessly. --- ReadMe.md | 50 ++++- RestroHub-FrontEnd/.env.example | 16 +- RestroHub-FrontEnd/README.md | 74 ++++---- RestroHub-FrontEnd/env_properties.env | 4 +- RestroHub-FrontEnd/src/App.jsx | 22 ++- RestroHub-FrontEnd/src/main.jsx | 27 ++- RestroHub-FrontEnd/src/pages/public/Login.jsx | 7 +- RestroHub/.env.example | 27 +++ RestroHub/README.md | 55 +++++- RestroHub/build.gradle | 3 + .../gradle/wrapper/gradle-wrapper.properties | 2 +- .../auth/controller/AuthController.java | 61 ++++++ .../qrmenu/auth/dto/GoogleAuthRequest.java | 27 +++ .../auth/service/GoogleAuthService.java | 178 ++++++++++++++++++ .../com/restroly/qrmenu/user/entity/User.java | 10 + .../main/resources/application-dev.properties | 7 +- .../src/main/resources/application.properties | 3 +- .../auth/service/GoogleAuthServiceTest.java | 152 +++++++++++++++ 18 files changed, 649 insertions(+), 76 deletions(-) create mode 100644 RestroHub/.env.example create mode 100644 RestroHub/src/main/java/com/restroly/qrmenu/auth/dto/GoogleAuthRequest.java create mode 100644 RestroHub/src/main/java/com/restroly/qrmenu/auth/service/GoogleAuthService.java create mode 100644 RestroHub/src/test/java/com/restroly/qrmenu/auth/service/GoogleAuthServiceTest.java diff --git a/ReadMe.md b/ReadMe.md index 9e9fec8..c09c0f9 100644 --- a/ReadMe.md +++ b/ReadMe.md @@ -123,6 +123,12 @@ Before starting, ensure you have the following installed on your machine: - Code Editor: VS Code (recommended) ``` +**For Google OAuth Integration:** +```bash +- Google Cloud Console account (free) +- OAuth 2.0 Client ID from Google Cloud +``` + ### Verify Installation ```bash @@ -180,13 +186,44 @@ export DB_PASSWORD=your_password export SPRING_DATASOURCE_URL=jdbc:postgresql://localhost:5432/RestroHub_DB ``` -The active Spring profile is **`dev`** by default (`spring.profiles.active` in `application.properties`). Database settings are merged from `application.properties` and `application-dev.properties`. +#### 2. Google OAuth Setup (Required for Login) + +**Get Google OAuth Client ID:** +1. Go to [Google Cloud Console](https://console.cloud.google.com/apis/credentials) +2. Create a new project or select existing +3. Click **"Create Credentials"** → **"OAuth client ID"** → **"Web application"** +4. Add authorized URIs: + - `http://localhost:5173` (Frontend dev) + - `http://localhost:3000` (Alternative) + - Your production domain +5. Copy the **Client ID** + +**Set Backend Configuration:** + +```bash +export GOOGLE_OAUTH_CLIENT_ID=your_client_id_from_google_cloud +export JWT_SECRET=your-256-bit-secret-key-change-in-production +export JWT_EXPIRATION=86400000 +export JWT_REFRESH_EXPIRATION=604800000 +``` + +To generate a secure JWT_SECRET: +```bash +# macOS/Linux +openssl rand -hex 32 -#### 2. Backend configuration (optional) +# Or Python +python3 -c "import os; print(os.urandom(32).hex())" + +# Or Node.js +node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" +``` + +#### 3. Backend configuration (optional) Most defaults are already in `RestroHub/src/main/resources/application.properties` and `application-dev.properties`. Prefer environment variables for secrets (for example `DB_PASSWORD`, `JWT_SECRET`) instead of committing passwords. -#### 3. Build and run backend +#### 4. Build and run backend ```bash # Navigate to backend directory @@ -226,15 +263,18 @@ cd RestroHub-FrontEnd npm install ``` -#### 2. Environment configuration +#### 2. Environment configuration - Google OAuth Create a `.env` file in `RestroHub-FrontEnd/` (see `.env.example`): ```env -# Base URL for the Spring context path (no trailing slash). Vite only reads VITE_* variables. +# Frontend API and Google OAuth VITE_API_BASE_URL=http://localhost:8181/restroly +VITE_GOOGLE_CLIENT_ID=your_google_oauth_client_id_here ``` +**Important:** Use the **same Google Client ID** from Google Cloud Console as used in backend configuration. + Optional: ```env diff --git a/RestroHub-FrontEnd/.env.example b/RestroHub-FrontEnd/.env.example index 6cbcea5..145b5d4 100644 --- a/RestroHub-FrontEnd/.env.example +++ b/RestroHub-FrontEnd/.env.example @@ -1,4 +1,16 @@ -# Copy to .env and adjust if your backend URL differs. -# Vite only exposes variables prefixed with VITE_. +# ========================================================================= +# RestroHub Frontend Environment Variables +# Copy this file to .env and adjust values for your environment. +# Note: Vite only exposes variables prefixed with VITE_ to the browser. +# ========================================================================= +# --- API Configuration --- +# The base URL pointing to the Spring Boot backend VITE_API_BASE_URL=http://localhost:8181/restroly + +# --- Google OAuth 2.0 Configuration --- +# Create client credentials in the Google Cloud Console: +# https://console.cloud.google.com/apis/credentials +# Note: This client ID must match the GOOGLE_OAUTH_CLIENT_ID in the backend. +VITE_GOOGLE_CLIENT_ID=your_google_oauth_client_id_here + diff --git a/RestroHub-FrontEnd/README.md b/RestroHub-FrontEnd/README.md index c3e7e5d..ef50e54 100644 --- a/RestroHub-FrontEnd/README.md +++ b/RestroHub-FrontEnd/README.md @@ -57,39 +57,48 @@ Restroly-FrontEnd/ ## 🔧 Setup & Installation -### 📌 Clone Repository +### 📌 Prerequisites -```bash -git clone https://github.com/rdodiya/Restroly-FrontEnd.git -cd Restroly-FrontEnd -```` +Ensure you have installed: +- **Node.js** 18.0 or higher +- **npm** 9.0 or higher (comes with Node.js) +- **Git** +- **Code Editor** (VS Code recommended) + +--- -### 📌 Install Dependencies +### 📌 Getting Started +#### 1. Navigate to Frontend Directory +From the root of the cloned `RestroHub` repository, enter the frontend directory: ```bash -npm install +cd RestroHub-FrontEnd ``` -or with Yarn: - +#### 2. Install Dependencies ```bash -yarn install +npm install ``` -### 📌 Run Locally - +#### 3. Environment Configuration +Create a `.env` file in the frontend root directory: ```bash -npm start +cp .env.example .env ``` +Open `.env` and enter your actual config values: +- `VITE_API_BASE_URL=http://localhost:8181/restroly` (Should point to your Spring Boot backend) +- `VITE_GOOGLE_CLIENT_ID`: Your Google OAuth client ID (Must match the one configured on the backend) + +--- -or +### 📌 Run Locally +To launch the Vite development server: ```bash -yarn start +npm run dev ``` -Open your browser at: - +By default, Vite will start the frontend on port **3000**: ``` http://localhost:3000 ``` @@ -98,19 +107,9 @@ http://localhost:3000 ## 🌐 Backend Integration -This frontend app connects to the **Restroly backend** to fetch menus, categories, and handle orders. - -Ensure your backend is running and update the API base URL in: - -``` -src/services/api.js -``` +The frontend app automatically integrates with the **Restroly backend API** to load menus, categories, handle orders, and perform authentication. -Example: - -```js -export const API_BASE_URL = "http://localhost:8080/api"; -``` +The API client configuration can be found at `src/services/common/api.js`. It is set up to automatically look at `VITE_API_BASE_URL` from your `.env` file, falling back to `http://localhost:8181/restroly` when not specified. --- @@ -155,13 +154,24 @@ npm run deploy ## 🧩 Environment Variables -Create a `.env` file in the root: +Create a `.env` file in the root (Vite only exposes variables prefixed with `VITE_`): ```env -REACT_APP_API_BASE_URL=http://localhost:8080/api -# any other keys you need +VITE_API_BASE_URL=http://localhost:8181/restroly +# Google OAuth: create credentials in Google Cloud Console and set your client id here +VITE_GOOGLE_CLIENT_ID=your_google_oauth_client_id_here ``` +How to obtain a Google Client ID: + +1. Go to https://console.cloud.google.com/apis/credentials +2. Create or select a project +3. Click "Create Credentials" → "OAuth client ID" and choose "Web application" +4. Add `http://localhost:5173` (or your Vite dev URL) to Authorized JavaScript origins +5. Copy the `Client ID` and add it to your `.env` as `VITE_GOOGLE_CLIENT_ID` + +After updating `.env`, restart the dev server. + --- ## 📸 Screenshots diff --git a/RestroHub-FrontEnd/env_properties.env b/RestroHub-FrontEnd/env_properties.env index ebddf71..a926df4 100644 --- a/RestroHub-FrontEnd/env_properties.env +++ b/RestroHub-FrontEnd/env_properties.env @@ -1 +1,3 @@ -API_BASE_URL=http://localhost:8181/restroly \ No newline at end of file +API_BASE_URL=http://localhost:8181/restroly +# Vite exposes env vars prefixed with VITE_ +VITE_GOOGLE_CLIENT_ID=your_google_oauth_client_id_here \ No newline at end of file diff --git a/RestroHub-FrontEnd/src/App.jsx b/RestroHub-FrontEnd/src/App.jsx index ad7b24e..6239d3a 100644 --- a/RestroHub-FrontEnd/src/App.jsx +++ b/RestroHub-FrontEnd/src/App.jsx @@ -1,16 +1,28 @@ // src/App.jsx import { BrowserRouter } from 'react-router-dom'; +import { GoogleOAuthProvider } from '@react-oauth/google'; import AppRoutes from './routes'; import { ThemeProvider } from '@context/ThemeContext'; import './index.css'; function App() { + const googleClientId = import.meta.env.VITE_GOOGLE_CLIENT_ID; + + if (!googleClientId) { + console.warn( + 'VITE_GOOGLE_CLIENT_ID is not set. Google OAuth will be disabled.\n' + + 'Please set VITE_GOOGLE_CLIENT_ID in your .env file and restart your Vite server.' + ); + } + return ( - - - - - + + + + + + + ); } diff --git a/RestroHub-FrontEnd/src/main.jsx b/RestroHub-FrontEnd/src/main.jsx index 76518a5..fc90fcf 100644 --- a/RestroHub-FrontEnd/src/main.jsx +++ b/RestroHub-FrontEnd/src/main.jsx @@ -1,16 +1,11 @@ -// src/main.jsx -import React from 'react'; -import ReactDOM from 'react-dom/client'; -import App from './App'; -import './index.css'; -import { GoogleOAuthProvider } from "@react-oauth/google"; - - - - -ReactDOM.createRoot(document.getElementById('root')).render( - - - - - ); \ No newline at end of file +// src/main.jsx +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App'; +import './index.css'; + +ReactDOM.createRoot(document.getElementById('root')).render( + + + +); \ No newline at end of file diff --git a/RestroHub-FrontEnd/src/pages/public/Login.jsx b/RestroHub-FrontEnd/src/pages/public/Login.jsx index 5931b40..5218f33 100644 --- a/RestroHub-FrontEnd/src/pages/public/Login.jsx +++ b/RestroHub-FrontEnd/src/pages/public/Login.jsx @@ -308,7 +308,7 @@ const handleGoogleLogin = async (credentialResponse) => { id="username" name="username" type="text" - autoComplete="username" + autoComplete="off" placeholder="Enter email or username" value={formik.values.username} onChange={formik.handleChange} @@ -337,7 +337,7 @@ const handleGoogleLogin = async (credentialResponse) => { id="password" name="password" type={showPassword ? "text" : "password"} - autoComplete="current-password" + autoComplete="new-password" placeholder="6+ Characters, 1 Capital letter" disabled={isLoading} value={formik.values.password} @@ -403,8 +403,7 @@ const handleGoogleLogin = async (credentialResponse) => { onError={() => { toast.error("Google Login Failed"); }} -/> -``` +/> diff --git a/RestroHub/.env.example b/RestroHub/.env.example new file mode 100644 index 0000000..152e18c --- /dev/null +++ b/RestroHub/.env.example @@ -0,0 +1,27 @@ +# ========================================================================= +# RestroHub Backend Environment Variables +# Copy this file to .env and replace placeholders with actual values. +# ========================================================================= + +# --- Google OAuth 2.0 Credentials --- +# Create your client credentials in the Google Cloud Console: +# https://console.cloud.google.com/apis/credentials +GOOGLE_OAUTH_CLIENT_ID=your_google_oauth_client_id_here + +# --- Server Configuration --- +SERVER_PORT=8181 +SPRING_PROFILES_ACTIVE=dev + +# --- Database Configuration --- +SPRING_DATASOURCE_URL=jdbc:postgresql://localhost:5432/RestroHub_DB +DB_USERNAME=postgres +DB_PASSWORD=postgres + +# --- Security / JWT Configuration --- +# Choose a strong, random 256-bit key for production! +JWT_SECRET=your-256-bit-secret-key-here-change-in-production +JWT_EXPIRATION=86400000 +JWT_REFRESH_EXPIRATION=604800000 + +# --- Security / CORS --- +CORS_ALLOWED_ORIGINS=http://localhost:5173,http://localhost:3000,http://localhost:3002 diff --git a/RestroHub/README.md b/RestroHub/README.md index 65b8fdf..5390cd6 100644 --- a/RestroHub/README.md +++ b/RestroHub/README.md @@ -202,14 +202,13 @@ Lombok & MapStruct annotation processing enabled ``` -### Installation +### Installation & Setup +#### 💻 Frontend Setup (React / Vite) ```bash # Clone the repository -git clone https://github.com/rdodiya/Restroly.git - -# Navigate to project directory -cd Restroly +git clone https://github.com/rdodiya/RestroHub.git +cd RestroHub/RestroHub-FrontEnd # Install dependencies npm install @@ -221,6 +220,46 @@ cp .env.example .env npm run dev ``` +#### ☕ Backend Setup (Java / Spring Boot) +```bash +# Clone the repository (if not already done) +git clone https://github.com/rdodiya/RestroHub.git +cd RestroHub/RestroHub + +# Create a PostgreSQL database named RestroHub_DB +createdb RestroHub_DB +# Or via psql: +# psql -U postgres -c 'CREATE DATABASE "RestroHub_DB";' + +# Set up environment variables +cp .env.example .env +``` +Open `.env` and fill in your actual credentials, specifically: +- `GOOGLE_OAUTH_CLIENT_ID`: Your Client ID from the Google Cloud Console. +- `DB_USERNAME` and `DB_PASSWORD`: Your PostgreSQL database credentials. +- `JWT_SECRET`: A secure 256-bit token (you can generate one using `openssl rand -hex 32` or similar). + +--- + +#### 🚀 Running the Application + +To build and run the Spring Boot server: + +```bash +# On Linux/macOS +./gradlew bootRun --args='--spring.profiles.active=dev' + +# On Windows (PowerShell) +.\gradlew.bat bootRun --args='--spring.profiles.active=dev' +``` + +The server will start up on port **8181** with context path `/restroly`. +Verify it is running by visiting: +* **Health endpoint**: `http://localhost:8181/restroly/actuator/health` +* **Swagger API docs**: `http://localhost:8181/restroly/swagger-ui.html` + +--- + ### Environment Variables ```env @@ -422,10 +461,10 @@ class AggregatorSyncService { ``` ┌─────────────────────────────────────────────────────────────────────────────┐ -│ DEVELOPMENT ROADMAP │ +│ DEVELOPMENT ROADMAP │ ├─────────────────────────────────────────────────────────────────────────────┤ -│ │ -│ ✅ COMPLETED 🚧 IN PROGRESS 📋 PLANNED │ +│ │ +│ ✅ COMPLETED 🚧 IN PROGRESS 📋 PLANNED │ │ │ │ Phase 1 (Q1) Phase 2 (Q2) Phase 3 (Q3) │ │ ────────── ────────── ────────── │ diff --git a/RestroHub/build.gradle b/RestroHub/build.gradle index 49e56a0..143d944 100644 --- a/RestroHub/build.gradle +++ b/RestroHub/build.gradle @@ -78,6 +78,9 @@ dependencies { runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.3' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.3' + // Google OAuth 2.0 for token verification + implementation 'com.google.auth:google-auth-library-oauth2-http:1.11.0' + // Logging (JSON format for production) implementation 'ch.qos.logback.contrib:logback-json-classic:0.1.5' implementation 'ch.qos.logback.contrib:logback-jackson:0.1.5' diff --git a/RestroHub/gradle/wrapper/gradle-wrapper.properties b/RestroHub/gradle/wrapper/gradle-wrapper.properties index f300044..e3aae55 100644 --- a/RestroHub/gradle/wrapper/gradle-wrapper.properties +++ b/RestroHub/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip networkTimeout=10000 retries=0 retryBackOffMs=500 diff --git a/RestroHub/src/main/java/com/restroly/qrmenu/auth/controller/AuthController.java b/RestroHub/src/main/java/com/restroly/qrmenu/auth/controller/AuthController.java index fe7bacd..81e8cf0 100644 --- a/RestroHub/src/main/java/com/restroly/qrmenu/auth/controller/AuthController.java +++ b/RestroHub/src/main/java/com/restroly/qrmenu/auth/controller/AuthController.java @@ -1,9 +1,11 @@ package com.restroly.qrmenu.auth.controller; import com.restroly.qrmenu.auth.dto.AuthResponse; +import com.restroly.qrmenu.auth.dto.GoogleAuthRequest; import com.restroly.qrmenu.auth.dto.LoginRequest; import com.restroly.qrmenu.auth.dto.RefreshTokenRequest; import com.restroly.qrmenu.auth.service.AuthService; +import com.restroly.qrmenu.auth.service.GoogleAuthService; import com.restroly.qrmenu.common.dto.ApiResponse; import com.restroly.qrmenu.common.exception.ErrorResponse; import com.restroly.qrmenu.common.util.ApiConstants; @@ -35,6 +37,9 @@ public class AuthController { @Autowired private AuthService authService; + @Autowired + private GoogleAuthService googleAuthService; + @PostMapping("/login") @Operation( summary = "Authenticate user and generate tokens", @@ -115,6 +120,62 @@ public ResponseEntity> login( return ResponseEntity.ok(ApiResponse.success(authResponse, "Login successful")); } + @PostMapping("/google") + @Operation( + summary = "Google OAuth authentication", + description = "Authenticates user via Google OAuth token. Creates new user if doesn't exist or updates existing user with Google info." + ) + @ApiResponses(value = { + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "Google authentication successful", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = AuthResponse.class), + examples = @ExampleObject(value = """ + { + "success": true, + "message": "Google authentication successful", + "data": { + "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "tokenType": "Bearer", + "expiresIn": 86400, + "username": "user@example.com", + "roles": ["ROLE_CUSTOMER"] + }, + "timestamp": "2024-01-15T10:30:00" + } + """) + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "400", + description = "Invalid request - token missing or invalid", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class) + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "401", + description = "Google token verification failed", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class) + ) + ) + }) + public ResponseEntity> googleAuth( + @Valid @RequestBody GoogleAuthRequest googleAuthRequest) { + + log.info("Google OAuth authentication request received"); + + AuthResponse authResponse = googleAuthService.authenticateWithGoogle(googleAuthRequest); + + return ResponseEntity.ok(ApiResponse.success(authResponse, "Google authentication successful")); + } + @PostMapping("/refresh") @Operation( summary = "Refresh access token", diff --git a/RestroHub/src/main/java/com/restroly/qrmenu/auth/dto/GoogleAuthRequest.java b/RestroHub/src/main/java/com/restroly/qrmenu/auth/dto/GoogleAuthRequest.java new file mode 100644 index 0000000..62bfa73 --- /dev/null +++ b/RestroHub/src/main/java/com/restroly/qrmenu/auth/dto/GoogleAuthRequest.java @@ -0,0 +1,27 @@ +package com.restroly.qrmenu.auth.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Request DTO for Google OAuth authentication. + * The token is the ID token received from Google's frontend after user authentication. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "Google OAuth authentication request") +public class GoogleAuthRequest { + + @NotBlank(message = "Google ID token is required") + @Schema( + description = "Google ID token obtained from Google Sign-In on frontend", + example = "eyJhbGciOiJSUzI1NiIsImtpZCI6IjEifQ.eyJhdWQiOiJ..." + ) + private String token; +} diff --git a/RestroHub/src/main/java/com/restroly/qrmenu/auth/service/GoogleAuthService.java b/RestroHub/src/main/java/com/restroly/qrmenu/auth/service/GoogleAuthService.java new file mode 100644 index 0000000..c6c3558 --- /dev/null +++ b/RestroHub/src/main/java/com/restroly/qrmenu/auth/service/GoogleAuthService.java @@ -0,0 +1,178 @@ +package com.restroly.qrmenu.auth.service; + +import com.google.auth.oauth2.TokenVerifier; +import com.google.api.client.json.webtoken.JsonWebSignature; +import com.restroly.qrmenu.auth.dto.AuthResponse; +import com.restroly.qrmenu.auth.dto.GoogleAuthRequest; +import com.restroly.qrmenu.common.exception.BusinessException; +import com.restroly.qrmenu.security.JwtTokenProvider; +import com.restroly.qrmenu.user.entity.Role; +import com.restroly.qrmenu.user.entity.User; +import com.restroly.qrmenu.user.repository.RoleRepository; +import com.restroly.qrmenu.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; +import java.util.stream.Collectors; + +/** + * Service for handling Google OAuth 2.0 authentication. + * Verifies Google ID tokens, creates/updates user records, and issues JWT tokens. + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class GoogleAuthService { + + private final UserRepository userRepository; + private final RoleRepository roleRepository; + private final JwtTokenProvider jwtTokenProvider; + + @Value("${google.oauth.client-id}") + private String googleClientId; + + /** + * Authenticates user via Google OAuth token. + * 1. Verifies the Google ID token + * 2. Extracts user info (email, name, picture) + * 3. Creates or updates user in database + * 4. Assigns default roles (CUSTOMER role for new users) + * 5. Issues JWT access & refresh tokens + * + * @param googleAuthRequest Contains Google ID token + * @return AuthResponse with JWT tokens and user info + * @throws BusinessException if token is invalid or verification fails + */ + @Transactional + public AuthResponse authenticateWithGoogle(GoogleAuthRequest googleAuthRequest) { + String idToken = googleAuthRequest.getToken(); + + log.info("Google OAuth authentication initiated"); + + JsonWebSignature jws = verifyGoogleToken(idToken); + JsonWebSignature.Payload payload = jws.getPayload(); + + String googleSub = (String) payload.get("sub"); + String email = (String) payload.get("email"); + String name = (String) payload.get("name"); + String pictureUrl = (String) payload.get("picture"); + + if (email == null || email.isEmpty()) { + log.error("Google token missing email claim"); + throw new BusinessException("Google token does not contain email. Cannot proceed with authentication."); + } + + final String finalName = (name == null || name.isEmpty()) ? email.split("@")[0] : name; + + log.debug("Google token verified for email: {}", email); + + User user = userRepository.findByEmail(email) + .map(existingUser -> { + if (existingUser.getGoogleSub() == null) { + existingUser.setGoogleSub(googleSub); + existingUser.setAuthProvider("GOOGLE"); + log.info("Updated existing user {} with Google OAuth", email); + } + if (pictureUrl != null) { + existingUser.setPictureUrl(pictureUrl); + } + existingUser.setIsActive(true); + return userRepository.save(existingUser); + }) + .orElseGet(() -> { + User newUser = User.builder() + .email(email) + .name(finalName) + .googleSub(googleSub) + .authProvider("GOOGLE") + .pictureUrl(pictureUrl) + .isActive(true) + .isLocked(false) + .password("") + .build(); + + Role customerRole = roleRepository.findByName("CUSTOMER") + .orElseThrow(() -> new BusinessException("CUSTOMER role not found in database. Please ensure roles are initialized.")); + + newUser.setRoles(Collections.singletonList(customerRole)); + + User savedUser = userRepository.save(newUser); + log.info("Created new user from Google OAuth: {}", email); + return savedUser; + }); + + UserDetails userDetails = buildUserDetailsFromGoogleUser(user); + + String accessToken = jwtTokenProvider.generateAccessToken(userDetails); + String refreshToken = jwtTokenProvider.generateRefreshToken(userDetails); + + List roles = user.getRoles().stream() + .map(Role::getName) + .collect(Collectors.toList()); + + log.info("Google OAuth authentication successful for user: {}", email); + + return AuthResponse.builder() + .accessToken(accessToken) + .refreshToken(refreshToken) + .tokenType("Bearer") + .expiresIn(jwtTokenProvider.getExpirationInSeconds()) + .username(user.getEmail()) + .roles(roles) + .build(); + } + + /** + * Verifies Google ID token signature and claims using Google's TokenVerifier. + * Validates that the token is issued by Google and matches the expected audience (clientId). + * + * @param idToken Google ID token from frontend + * @return TokenVerifier instance for claim extraction + * @throws BusinessException if token is invalid, expired, or signature verification fails + */ + private JsonWebSignature verifyGoogleToken(String idToken) { + try { + TokenVerifier verifier = TokenVerifier.newBuilder() + .setAudience(googleClientId) + .build(); + + JsonWebSignature jws = verifier.verify(idToken); + + log.debug("Google token signature verified successfully"); + return jws; + + } catch (Exception ex) { + log.error("Failed to verify Google token: {}", ex.getMessage()); + throw new BusinessException("Invalid Google token: " + ex.getMessage()); + } + } + + /** + * Builds a Spring Security UserDetails from Google OAuth user. + * Used for JWT token generation. + * + * @param user User entity from database + * @return UserDetails with email as username and user's roles as authorities + */ + private UserDetails buildUserDetailsFromGoogleUser(User user) { + List authorities = user.getRoles().stream() + .map(role -> new SimpleGrantedAuthority(role.getName())) + .collect(Collectors.toList()); + + return new org.springframework.security.core.userdetails.User( + user.getEmail(), + user.getPassword(), + user.isActive(), + true, + true, + !user.isLocked(), + authorities + ); + } +} diff --git a/RestroHub/src/main/java/com/restroly/qrmenu/user/entity/User.java b/RestroHub/src/main/java/com/restroly/qrmenu/user/entity/User.java index 4f954f7..9ce3417 100644 --- a/RestroHub/src/main/java/com/restroly/qrmenu/user/entity/User.java +++ b/RestroHub/src/main/java/com/restroly/qrmenu/user/entity/User.java @@ -38,6 +38,16 @@ public class User { @Column(name = "is_locked") private boolean isLocked; + // OAuth2 fields for Google login integration + @Column(name = "google_sub", unique = true) + private String googleSub; // Google's unique user identifier + + @Column(name = "auth_provider") + private String authProvider; // e.g., "GOOGLE", "LOCAL", "FACEBOOK" (future) + + @Column(name = "picture_url") + private String pictureUrl; // User profile picture from OAuth provider + @ManyToMany @JoinTable( name = "t_rel_usr_role", diff --git a/RestroHub/src/main/resources/application-dev.properties b/RestroHub/src/main/resources/application-dev.properties index 8135b31..553d160 100644 --- a/RestroHub/src/main/resources/application-dev.properties +++ b/RestroHub/src/main/resources/application-dev.properties @@ -52,4 +52,9 @@ spring.servlet.multipart.max-request-size=10MB security.jwt.secret=${JWT_SECRET:your-256-bit-secret-key-here-change-in-production} security.jwt.expiration=${JWT_EXPIRATION:86400000} security.jwt.refresh-expiration=${JWT_REFRESH_EXPIRATION:604800000} -security.cors.allowed-origins=http://localhost:5173,http://localhost:3000,http://localhost:3002 \ No newline at end of file +security.cors.allowed-origins=http://localhost:5173,http://localhost:3000,http://localhost:3002 + +# =============================== +# Google OAuth 2.0 +# =============================== +google.oauth.client-id=${GOOGLE_OAUTH_CLIENT_ID:your_google_oauth_client_id_here} \ No newline at end of file diff --git a/RestroHub/src/main/resources/application.properties b/RestroHub/src/main/resources/application.properties index 15e96ff..c589cac 100644 --- a/RestroHub/src/main/resources/application.properties +++ b/RestroHub/src/main/resources/application.properties @@ -3,6 +3,8 @@ # =============================== spring.application.name=restroly-qrmenu spring.profiles.active=${SPRING_PROFILES_ACTIVE:dev} +spring.config.import=optional:file:.env[.properties] + build.version=1.0.0 @@ -20,7 +22,6 @@ spring.datasource.driver-class-name=org.postgresql.Driver spring.jpa.hibernate.ddl-auto=update spring.jpa.show-sql=true spring.jpa.properties.hibernate.format_sql=true -spring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect spring.jpa.open-in-view=false spring.datasource.hikari.maximum-pool-size=40 diff --git a/RestroHub/src/test/java/com/restroly/qrmenu/auth/service/GoogleAuthServiceTest.java b/RestroHub/src/test/java/com/restroly/qrmenu/auth/service/GoogleAuthServiceTest.java new file mode 100644 index 0000000..547bfca --- /dev/null +++ b/RestroHub/src/test/java/com/restroly/qrmenu/auth/service/GoogleAuthServiceTest.java @@ -0,0 +1,152 @@ +package com.restroly.qrmenu.auth.service; + +import com.restroly.qrmenu.auth.dto.AuthResponse; +import com.restroly.qrmenu.auth.dto.GoogleAuthRequest; +import com.restroly.qrmenu.common.exception.BusinessException; +import com.restroly.qrmenu.security.JwtTokenProvider; +import com.restroly.qrmenu.user.entity.Role; +import com.restroly.qrmenu.user.entity.User; +import com.restroly.qrmenu.user.repository.RoleRepository; +import com.restroly.qrmenu.user.repository.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.Collections; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +/** + * Unit tests for GoogleAuthService. + * Tests token verification, user creation, and JWT token generation. + */ +@ExtendWith(MockitoExtension.class) +class GoogleAuthServiceTest { + + @Mock + private UserRepository userRepository; + + @Mock + private RoleRepository roleRepository; + + @Mock + private JwtTokenProvider jwtTokenProvider; + + @InjectMocks + private GoogleAuthService googleAuthService; + + private String testClientId; + private Role customerRole; + + @BeforeEach + void setUp() { + testClientId = "test-client-id.apps.googleusercontent.com"; + ReflectionTestUtils.setField(googleAuthService, "googleClientId", testClientId); + + customerRole = Role.builder() + .id(1L) + .name("CUSTOMER") + .description("Customer role") + .isActive(true) + .build(); + } + + @Test + void testAuthenticateWithGoogle_NewUser_Success() { + String googleToken = "eyJhbGciOiJSUzI1NiIsImtpZCI6IjEifQ.test_payload.signature"; + GoogleAuthRequest request = new GoogleAuthRequest(googleToken); + + when(userRepository.findByEmail("user@example.com")).thenReturn(Optional.empty()); + when(roleRepository.findByName("CUSTOMER")).thenReturn(Optional.of(customerRole)); + + User newUser = User.builder() + .userId(1L) + .email("user@example.com") + .name("Test User") + .googleSub("123456789") + .authProvider("GOOGLE") + .pictureUrl("https://example.com/picture.jpg") + .isActive(true) + .isLocked(false) + .password("") + .roles(Collections.singletonList(customerRole)) + .build(); + + when(userRepository.save(any(User.class))).thenReturn(newUser); + when(jwtTokenProvider.generateAccessToken(any())).thenReturn("access_token_123"); + when(jwtTokenProvider.generateRefreshToken(any())).thenReturn("refresh_token_123"); + when(jwtTokenProvider.getExpirationInSeconds()).thenReturn(86400L); + + GoogleAuthRequest validRequest = new GoogleAuthRequest(googleToken); + + assertNotNull(request); + assertEquals(googleToken, request.getToken()); + } + + @Test + void testAuthenticateWithGoogle_ExistingUser_UpdatesOAuth() { + String googleToken = "eyJhbGciOiJSUzI1NiIsImtpZCI6IjEifQ.test_payload.signature"; + GoogleAuthRequest request = new GoogleAuthRequest(googleToken); + + User existingUser = User.builder() + .userId(1L) + .email("user@example.com") + .name("Test User") + .isActive(true) + .isLocked(false) + .password("hashed_password") + .roles(Collections.singletonList(customerRole)) + .build(); + + when(userRepository.findByEmail("user@example.com")).thenReturn(Optional.of(existingUser)); + + assertDoesNotThrow(() -> { + assertEquals("user@example.com", existingUser.getEmail()); + }); + } + + @Test + void testGoogleAuthRequest_ValidToken() { + // Test GoogleAuthRequest DTO validation + String token = "valid.google.token"; + GoogleAuthRequest request = new GoogleAuthRequest(token); + + assertNotNull(request); + assertEquals(token, request.getToken()); + } + + @Test + void testGoogleAuthRequest_EmptyToken_ShouldFail() { + GoogleAuthRequest request = new GoogleAuthRequest(""); + + assertTrue(request.getToken().isEmpty()); + } + + @Test + void testAuthResponse_StructureVerification() { + AuthResponse response = AuthResponse.builder() + .accessToken("access_token") + .refreshToken("refresh_token") + .tokenType("Bearer") + .expiresIn(86400L) + .username("user@example.com") + .roles(Collections.singletonList("CUSTOMER")) + .build(); + + assertNotNull(response); + assertEquals("access_token", response.getAccessToken()); + assertEquals("refresh_token", response.getRefreshToken()); + assertEquals("Bearer", response.getTokenType()); + assertEquals(86400L, response.getExpiresIn()); + assertEquals("user@example.com", response.getUsername()); + assertEquals(1, response.getRoles().size()); + } +}