diff --git a/.env.example b/.env.example index 1e218ec..50fcd0c 100644 --- a/.env.example +++ b/.env.example @@ -7,3 +7,7 @@ REDIS_URL="redis://localhost:6379" EMAIL_USER=test@gmail.com EMAIL_PASS=app-specific-password EMAIL_HOST="smtp.gmail.com" + +# GOOGLE OAUTH (Step 3+) +# Chrome Extension OAuth client ID from Google Cloud Console +GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com diff --git a/package-lock.json b/package-lock.json index b3ed008..00be29b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "bcrypt": "^6.0.0", "cors": "^2.8.5", "express": "^4.18.2", + "google-auth-library": "^10.7.0", "ioredis": "^5.6.1", "jsonwebtoken": "^9.0.2", "nodemailer": "^7.0.9", @@ -2093,6 +2094,15 @@ "node": ">=0.4.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", @@ -2127,6 +2137,26 @@ "dev": true, "license": "MIT" }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/bcrypt": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz", @@ -2141,6 +2171,15 @@ "node": ">= 18" } }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -2378,6 +2417,15 @@ "dev": true, "license": "MIT" }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -2595,6 +2643,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, "node_modules/fast-xml-parser": { "version": "5.2.5", "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.2.5.tgz", @@ -2613,6 +2667,29 @@ "fxparser": "src/cli/cli.js" } }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -2644,6 +2721,18 @@ "node": ">= 0.8" } }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -2686,6 +2775,34 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gaxios": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.5.tgz", + "integrity": "sha512-5FZy72Rh8LhtjmvDrKkI+lVhrsQrVKVsItxMoDm5mNQE+xR0WVIIs+jzPSJgBvKVsLi24fZhXJIsNI0bihDzFg==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -2749,6 +2866,53 @@ "node": ">= 6" } }, + "node_modules/google-auth-library": { + "version": "10.7.0", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.7.0.tgz", + "integrity": "sha512-QpTAbNJ36TliZLx3TTtahR8HG0hN9RllL1e3FymOvQSIKK8JmgV58H924ub2wa2DsS3ANjjP1Aw1N+Ramc8hqQ==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.1.4", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-auth-library/node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/google-auth-library/node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -2811,6 +2975,42 @@ "node": ">= 0.8" } }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/https-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -2948,6 +3148,15 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, "node_modules/jsonwebtoken": { "version": "9.0.2", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", @@ -3164,6 +3373,44 @@ "node": "^18 || ^20 || >= 21" } }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, "node_modules/node-gyp-build": { "version": "4.8.4", "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", @@ -3879,6 +4126,15 @@ "node": ">= 0.8" } }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, "node_modules/ws": { "version": "8.18.2", "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.2.tgz", diff --git a/package.json b/package.json index 151a517..3edf7f6 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "bcrypt": "^6.0.0", "cors": "^2.8.5", "express": "^4.18.2", + "google-auth-library": "^10.7.0", "ioredis": "^5.6.1", "jsonwebtoken": "^9.0.2", "nodemailer": "^7.0.9", diff --git a/prisma/migrations/20250620120000_add_google_auth_fields/migration.sql b/prisma/migrations/20250620120000_add_google_auth_fields/migration.sql new file mode 100644 index 0000000..eb6c324 --- /dev/null +++ b/prisma/migrations/20250620120000_add_google_auth_fields/migration.sql @@ -0,0 +1,8 @@ +-- AlterTable +ALTER TABLE `users` MODIFY `password` VARCHAR(191) NULL, + ADD COLUMN `googleId` VARCHAR(191) NULL, + ADD COLUMN `authProvider` ENUM('LOCAL', 'GOOGLE') NOT NULL DEFAULT 'LOCAL', + ADD COLUMN `emailVerified` BOOLEAN NOT NULL DEFAULT false; + +-- CreateIndex +CREATE UNIQUE INDEX `users_googleId_key` ON `users`(`googleId`); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b84dc05..a354658 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -50,10 +50,15 @@ enum NotificationPriority { HIGH } +enum AuthProvider { + LOCAL + GOOGLE +} + model User { id String @id @default(uuid()) username String @unique - password String + password String? email String @unique displayName String? bio String? @db.Text @@ -67,6 +72,11 @@ model User { // Session management sessionId String? + // Authentication (Google OAuth — Step 2) + googleId String? @unique + authProvider AuthProvider @default(LOCAL) + emailVerified Boolean @default(false) + // Privacy settings emailPrivacy PrivacyLevel @default(friends_only) dobPrivacy PrivacyLevel @default(private) diff --git a/src/controllers/forgotPasswordController.ts b/src/controllers/forgotPasswordController.ts index a862391..54fd4df 100644 --- a/src/controllers/forgotPasswordController.ts +++ b/src/controllers/forgotPasswordController.ts @@ -17,6 +17,8 @@ import { } from '../utils/redis'; import { sendOTPEmail, sendPasswordResetOTPEmail } from '../services/emailService'; import prisma from '../utils/prisma'; +import { normalizeEmail, isEmailIdentifier } from '../utils/loginIdentifier'; +import { isGoogleOnlyUser } from '../utils/authHelpers'; export const requestForgotPasswordOTP = async (req: Request, res: Response) => { try { @@ -30,22 +32,33 @@ export const requestForgotPasswordOTP = async (req: Request, res: Response) => { }); } - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - const isEmail = emailRegex.test(identifier); + const isEmail = isEmailIdentifier(identifier); let user; let email; if (isEmail) { + email = normalizeEmail(identifier); user = await prisma.user.findUnique({ - where: { email: identifier }, - select: { id: true, email: true, username: true } + where: { email }, + select: { + id: true, + email: true, + username: true, + authProvider: true, + password: true + } }); - email = identifier; } else { user = await prisma.user.findUnique({ where: { username: identifier }, - select: { id: true, email: true, username: true } + select: { + id: true, + email: true, + username: true, + authProvider: true, + password: true + } }); email = user?.email; } @@ -58,6 +71,15 @@ export const requestForgotPasswordOTP = async (req: Request, res: Response) => { }); } + if (isGoogleOnlyUser(user)) { + return res.status(400).json({ + success: false, + message: 'Password reset not available', + error: 'This account uses Google sign-in. Please sign in with Google instead.', + useGoogleLogin: true + }); + } + const attempts = await checkOTPAttempts(email); if (attempts >= 3) { return res.status(429).json({ @@ -114,16 +136,20 @@ export const verifyForgotPasswordOTP = async (req: Request, res: Response) => { }); } - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - const isEmail = emailRegex.test(identifier); + const isEmail = isEmailIdentifier(identifier); let email: string; + let user; if (isEmail) { - email = identifier; + email = normalizeEmail(identifier); + user = await prisma.user.findUnique({ + where: { email }, + select: { email: true, authProvider: true, password: true } + }); } else { - const user = await prisma.user.findUnique({ + user = await prisma.user.findUnique({ where: { username: identifier }, - select: { email: true } + select: { email: true, authProvider: true, password: true } }); if (!user) { @@ -137,6 +163,23 @@ export const verifyForgotPasswordOTP = async (req: Request, res: Response) => { email = user.email; } + if (!user) { + return res.status(404).json({ + success: false, + message: 'Account not found', + error: 'No account found with this identifier' + }); + } + + if (isGoogleOnlyUser(user)) { + return res.status(400).json({ + success: false, + message: 'Password reset not available', + error: 'This account uses Google sign-in. Please sign in with Google instead.', + useGoogleLogin: true + }); + } + const verificationAttempts = await checkOTPVerificationAttempts(email); if (verificationAttempts >= 5) { await deleteOTP(email); @@ -213,21 +256,20 @@ export const resetPassword = async (req: Request, res: Response) => { }); } - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - const isEmail = emailRegex.test(identifier); + const isEmail = isEmailIdentifier(identifier); let email: string; let user; if (isEmail) { - email = identifier; + email = normalizeEmail(identifier); user = await prisma.user.findUnique({ where: { email }, - select: { id: true, email: true } + select: { id: true, email: true, authProvider: true, password: true } }); } else { user = await prisma.user.findUnique({ where: { username: identifier }, - select: { id: true, email: true } + select: { id: true, email: true, authProvider: true, password: true } }); email = user?.email || ''; } @@ -240,6 +282,15 @@ export const resetPassword = async (req: Request, res: Response) => { }); } + if (isGoogleOnlyUser(user)) { + return res.status(400).json({ + success: false, + message: 'Password reset not available', + error: 'This account uses Google sign-in. Please sign in with Google instead.', + useGoogleLogin: true + }); + } + const verified = await isEmailVerified(email); if (!verified) { return res.status(403).json({ diff --git a/src/controllers/googleAuthController.ts b/src/controllers/googleAuthController.ts new file mode 100644 index 0000000..2764865 --- /dev/null +++ b/src/controllers/googleAuthController.ts @@ -0,0 +1,367 @@ +import { Request, Response } from 'express'; +import { AuthProvider } from '@prisma/client'; +import prisma from '../utils/prisma'; +import { verifyGoogleIdToken } from '../services/googleAuthService'; +import { hashIdentifierForLog, normalizeEmail, isEmailIdentifier } from '../utils/loginIdentifier'; +import { generateToken, generateSessionId } from '../utils/jwt'; +import { ApiResponse } from '../types'; +import bcrypt from 'bcrypt'; +import { googleAccountLinked, hasPasswordLogin } from '../utils/authHelpers'; + +const USERNAME_REGEX = /^[a-zA-Z][a-zA-Z0-9_-]{3,15}$/; + +const generateUniqueUsername = async (email: string): Promise => { + const localPart = email.split('@')[0] ?? 'user'; + let base = localPart + .replace(/[^a-zA-Z0-9_-]/g, '_') + .replace(/[_-]{2,}/g, '_') + .replace(/^[_-]+|[_-]+$/g, ''); + + if (!base || !/^[a-zA-Z]/.test(base)) { + base = 'user'; + } + + base = base.slice(0, 8); + + for (let attempt = 0; attempt < 10; attempt++) { + const suffix = Math.floor(1000 + Math.random() * 9000); + const candidate = `${base}${suffix}`.slice(0, 15); + + if (!USERNAME_REGEX.test(candidate)) { + continue; + } + + const existing = await prisma.user.findUnique({ where: { username: candidate } }); + if (!existing) { + return candidate; + } + } + + throw new Error('Failed to generate unique username'); +}; + +const createSessionResponse = async (user: { id: string; username: string }) => { + const sessionId = generateSessionId(); + + const updatedUser = await prisma.user.update({ + where: { id: user.id }, + data: { + sessionId, + lastOnlineAt: new Date(), + }, + }); + + const token = generateToken({ + id: updatedUser.id, + username: updatedUser.username, + sessionId, + }); + + const { password: _, sessionId: _sessionId, ...userWithoutPassword } = updatedUser; + + return { + ...userWithoutPassword, + token, + }; +}; + +export const verifyGoogleToken = async (req: Request, res: Response) => { + try { + const { idToken } = req.body; + + if (!idToken || typeof idToken !== 'string') { + return res.status(400).json({ + success: false, + message: 'ID token is required', + error: 'Missing or invalid idToken in request body', + }); + } + + const profile = await verifyGoogleIdToken(idToken); + + console.log( + `[Google Auth] Token verified: googleId=${hashIdentifierForLog(profile.googleId)} email=${hashIdentifierForLog(profile.email)}` + ); + + return res.status(200).json({ + success: true, + message: 'Google ID token verified', + data: profile, + }); + } catch (error) { + console.error('[Google Auth] Token verification failed:', error); + + return res.status(401).json({ + success: false, + message: 'Invalid Google ID token', + error: error instanceof Error ? error.message : 'Verification failed', + }); + } +}; + +export const googleLogin = async (req: Request, res: Response) => { + try { + const { idToken } = req.body; + + if (!idToken || typeof idToken !== 'string') { + return res.status(400).json({ + success: false, + message: 'ID token is required', + error: 'Missing or invalid idToken in request body', + }); + } + + const profile = await verifyGoogleIdToken(idToken); + + if (!profile.emailVerified) { + return res.status(403).json({ + success: false, + message: 'Email not verified', + error: 'Your Google account email must be verified', + }); + } + + let user = await prisma.user.findUnique({ + where: { googleId: profile.googleId }, + }); + + if (!user) { + const userByEmail = await prisma.user.findUnique({ + where: { email: profile.email }, + }); + + if (userByEmail) { + if ( + hasPasswordLogin(userByEmail) && + !googleAccountLinked(userByEmail) + ) { + return res.status(409).json({ + success: false, + message: 'Account already exists', + error: + 'An account with this email already exists. Please log in with your password, then link your Google account.', + usePasswordLogin: true, + canLink: true, + }); + } + + if ( + googleAccountLinked(userByEmail) && + userByEmail.googleId !== profile.googleId + ) { + return res.status(409).json({ + success: false, + message: 'Account conflict', + error: 'This email is linked to a different Google account.', + }); + } + + user = await prisma.user.update({ + where: { id: userByEmail.id }, + data: { + googleId: profile.googleId, + authProvider: hasPasswordLogin(userByEmail) + ? AuthProvider.LOCAL + : AuthProvider.GOOGLE, + emailVerified: true, + displayName: userByEmail.displayName ?? profile.name, + avatarUrl: userByEmail.avatarUrl ?? profile.picture, + }, + }); + + console.log( + `[Google Auth] Linked Google account: googleId=${hashIdentifierForLog(profile.googleId)}` + ); + } else { + const username = await generateUniqueUsername(profile.email); + + user = await prisma.user.create({ + data: { + username, + email: profile.email, + googleId: profile.googleId, + authProvider: AuthProvider.GOOGLE, + emailVerified: true, + displayName: profile.name ?? username, + avatarUrl: profile.picture, + }, + }); + + console.log( + `[Google Auth] New user created: googleId=${hashIdentifierForLog(profile.googleId)} username=${username}` + ); + } + } else { + user = await prisma.user.update({ + where: { id: user.id }, + data: { + displayName: user.displayName ?? profile.name, + avatarUrl: profile.picture ?? user.avatarUrl, + emailVerified: true, + }, + }); + } + + const sessionData = await createSessionResponse(user); + + const response: ApiResponse = { + success: true, + message: 'Login successful', + data: sessionData, + }; + + return res.status(200).json(response); + } catch (error) { + console.error('[Google Auth] Login failed:', error); + + return res.status(500).json({ + success: false, + message: 'Internal server error', + error: 'Failed to authenticate with Google', + }); + } +}; + +export const linkGoogleAccount = async (req: Request, res: Response) => { + try { + const { idToken, identifier, password } = req.body; + + if (!idToken || typeof idToken !== 'string') { + return res.status(400).json({ + success: false, + message: 'ID token is required', + error: 'Missing or invalid idToken in request body', + }); + } + + if (!identifier || typeof identifier !== 'string') { + return res.status(400).json({ + success: false, + message: 'Identifier is required', + error: 'Username or email must be provided', + }); + } + + if (!password || typeof password !== 'string') { + return res.status(400).json({ + success: false, + message: 'Password is required', + error: 'Password must be provided to link a Google account', + }); + } + + const profile = await verifyGoogleIdToken(idToken); + + if (!profile.emailVerified) { + return res.status(403).json({ + success: false, + message: 'Email not verified', + error: 'Your Google account email must be verified', + }); + } + + const normalizedIdentifier = isEmailIdentifier(identifier) + ? normalizeEmail(identifier) + : identifier; + + const user = await prisma.user.findUnique({ + where: isEmailIdentifier(identifier) + ? { email: normalizedIdentifier } + : { username: normalizedIdentifier }, + }); + + if (!user) { + return res.status(401).json({ + success: false, + message: 'Authentication failed', + error: 'Invalid credentials', + }); + } + + if (!hasPasswordLogin(user)) { + return res.status(400).json({ + success: false, + message: 'Linking not available', + error: 'This account does not use password login.', + useGoogleLogin: true, + }); + } + + const isPasswordValid = await bcrypt.compare(password, user.password!); + if (!isPasswordValid) { + return res.status(401).json({ + success: false, + message: 'Authentication failed', + error: 'Invalid credentials', + }); + } + + if (normalizeEmail(user.email) !== profile.email) { + return res.status(403).json({ + success: false, + message: 'Email mismatch', + error: 'Google account email does not match this BrowsePing account.', + }); + } + + if (googleAccountLinked(user)) { + if (user.googleId === profile.googleId) { + return res.status(200).json({ + success: true, + message: 'Google account already linked', + data: { linked: true }, + }); + } + + return res.status(409).json({ + success: false, + message: 'Account already linked', + error: 'This account is already linked to a different Google account.', + }); + } + + const existingGoogleUser = await prisma.user.findUnique({ + where: { googleId: profile.googleId }, + }); + + if (existingGoogleUser && existingGoogleUser.id !== user.id) { + return res.status(409).json({ + success: false, + message: 'Google account in use', + error: 'This Google account is already linked to another BrowsePing user.', + }); + } + + await prisma.user.update({ + where: { id: user.id }, + data: { + googleId: profile.googleId, + authProvider: AuthProvider.LOCAL, + emailVerified: true, + avatarUrl: user.avatarUrl ?? profile.picture, + displayName: user.displayName ?? profile.name, + }, + }); + + console.log( + `[Google Auth] Account linked: googleId=${hashIdentifierForLog(profile.googleId)} userId=${hashIdentifierForLog(user.id)}` + ); + + return res.status(200).json({ + success: true, + message: 'Google account linked successfully', + data: { + linked: true, + email: user.email, + }, + }); + } catch (error) { + console.error('[Google Auth] Account linking failed:', error); + + return res.status(500).json({ + success: false, + message: 'Internal server error', + error: 'Failed to link Google account', + }); + } +}; diff --git a/src/controllers/userController.ts b/src/controllers/userController.ts index e13972f..842bf58 100644 --- a/src/controllers/userController.ts +++ b/src/controllers/userController.ts @@ -4,6 +4,8 @@ import { SignupRequest, LoginRequest, ApiResponse } from '../types'; import bcrypt from 'bcrypt'; import { generateToken, generateSessionId } from '../utils/jwt'; import { isEmailVerified, clearEmailVerification } from '../utils/redis'; +import { normalizeEmail, isEmailIdentifier } from '../utils/loginIdentifier'; +import { isGoogleOnlyUser } from '../utils/authHelpers'; export const signup = async (req: Request, res: Response) => { try { @@ -83,12 +85,14 @@ export const login = async (req: Request, res: Response) => { }); } - const isEmail = identifier.includes('@'); - + const normalizedIdentifier = isEmailIdentifier(identifier) + ? normalizeEmail(identifier) + : identifier; + const user = await prisma.user.findUnique({ - where: isEmail - ? { email: identifier } - : { username: identifier } + where: isEmailIdentifier(identifier) + ? { email: normalizedIdentifier } + : { username: normalizedIdentifier } }); if (!user) { @@ -99,7 +103,19 @@ export const login = async (req: Request, res: Response) => { }); } - const isPasswordValid = await bcrypt.compare(password, user.password); + if (isGoogleOnlyUser(user)) { + return res.status(401).json({ + success: false, + message: 'Authentication failed', + error: 'This account uses Google sign-in. Please continue with Google.', + useGoogleLogin: true + }); + } + + const isPasswordValid = user.password + ? await bcrypt.compare(password, user.password) + : false; + if (!isPasswordValid) { return res.status(401).json({ diff --git a/src/index.ts b/src/index.ts index 47d4cf1..f50856e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,7 +8,8 @@ import { userRoutes, conversationRoutes, notificationRoutes, otpRoutes, - forgotPasswordRoutes } from './routes'; + forgotPasswordRoutes, + authRoutes } from './routes'; import http from 'http'; import { initializeWebSocketServer } from './websocket/server'; import { startAnalyticsFlushWorker } from './workers/analyticsFlushWorker'; @@ -30,6 +31,7 @@ app.use('/api/conversation', conversationRoutes); app.use('/api/notifications', notificationRoutes); app.use('/api/otp', otpRoutes); app.use('/api/forgot-password', forgotPasswordRoutes); +app.use('/api/auth', authRoutes); // Health check route app.get('/health', (req, res) => { diff --git a/src/routes/authRoutes.ts b/src/routes/authRoutes.ts new file mode 100644 index 0000000..7097e00 --- /dev/null +++ b/src/routes/authRoutes.ts @@ -0,0 +1,10 @@ +import { Router } from 'express'; +import { verifyGoogleToken, googleLogin, linkGoogleAccount } from '../controllers/googleAuthController'; + +const router = Router(); + +router.post('/google/verify', verifyGoogleToken); +router.post('/google', googleLogin); +router.post('/google/link', linkGoogleAccount); + +export default router; diff --git a/src/routes/index.ts b/src/routes/index.ts index 310fe32..6e69c82 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -7,6 +7,7 @@ import conversationRoutes from './conversationRoutes'; import notificationRoutes from './notificationRoutes'; import otpRoutes from './otpRoutes'; import forgotPasswordRoutes from './forgotPasswordRoutes'; +import authRoutes from './authRoutes'; export { userRoutes, @@ -17,5 +18,6 @@ export { conversationRoutes, notificationRoutes, otpRoutes, - forgotPasswordRoutes + forgotPasswordRoutes, + authRoutes }; \ No newline at end of file diff --git a/src/services/googleAuthService.ts b/src/services/googleAuthService.ts new file mode 100644 index 0000000..7be3213 --- /dev/null +++ b/src/services/googleAuthService.ts @@ -0,0 +1,52 @@ +import { OAuth2Client } from 'google-auth-library'; +import { normalizeEmail } from '../utils/loginIdentifier'; + +export interface GoogleUserProfile { + googleId: string; + email: string; + name: string | null; + picture: string | null; + emailVerified: boolean; +} + +let oauthClient: OAuth2Client | null = null; + +const getOAuthClient = (): OAuth2Client => { + if (!oauthClient) { + const clientId = process.env.GOOGLE_CLIENT_ID; + if (!clientId) { + throw new Error('GOOGLE_CLIENT_ID is not configured'); + } + oauthClient = new OAuth2Client(clientId); + } + return oauthClient; +}; + +export const verifyGoogleIdToken = async (idToken: string): Promise => { + const clientId = process.env.GOOGLE_CLIENT_ID; + if (!clientId) { + throw new Error('GOOGLE_CLIENT_ID is not configured'); + } + + const ticket = await getOAuthClient().verifyIdToken({ + idToken, + audience: clientId, + }); + + const payload = ticket.getPayload(); + if (!payload?.sub) { + throw new Error('Invalid Google ID token'); + } + + if (!payload.email) { + throw new Error('Google account email is not available'); + } + + return { + googleId: payload.sub, + email: normalizeEmail(payload.email), + name: payload.name ?? null, + picture: payload.picture ?? null, + emailVerified: payload.email_verified === true, + }; +}; diff --git a/src/utils/authHelpers.ts b/src/utils/authHelpers.ts new file mode 100644 index 0000000..9daf600 --- /dev/null +++ b/src/utils/authHelpers.ts @@ -0,0 +1,14 @@ +import { AuthProvider } from '@prisma/client'; + +type AuthFields = { + authProvider: AuthProvider; + password: string | null; + googleId?: string | null; +}; + +export const isGoogleOnlyUser = (user: AuthFields): boolean => + user.authProvider === AuthProvider.GOOGLE && !user.password; + +export const hasPasswordLogin = (user: AuthFields): boolean => Boolean(user.password); + +export const googleAccountLinked = (user: AuthFields): boolean => Boolean(user.googleId); diff --git a/src/utils/loginIdentifier.ts b/src/utils/loginIdentifier.ts new file mode 100644 index 0000000..e4d5d95 --- /dev/null +++ b/src/utils/loginIdentifier.ts @@ -0,0 +1,15 @@ +import crypto from 'crypto'; + +const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +export function normalizeEmail(email: string): string { + return email.trim().toLowerCase(); +} + +export function isEmailIdentifier(value: string): boolean { + return EMAIL_REGEX.test(value); +} + +export function hashIdentifierForLog(identifier: string): string { + return crypto.createHash('sha256').update(identifier).digest('hex').slice(0, 8); +}