diff --git a/backend/.env.example b/backend/.env.example index e6f3472..4e379d7 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -7,6 +7,10 @@ CLOUDINARY_CLOUD_NAME=your_cloudinary_cloud_name CLOUDINARY_API_KEY=your_cloudinary_api_key CLOUDINARY_API_SECRET=your_cloudinary_api_secret +GOOGLE_CLIENT_ID=your_google_client_id +GOOGLE_CLIENT_SECRET=your_google_client_secret_key +GITHUB_CLIENT_ID=your_github_client_id +GITHUB_CLIENT_SECRET=your_github_client_secret_key # Google recaptcha secret key RECAPTCHA_SECRET_KEY=your_recaptcha_secret_key diff --git a/backend/lib/passport.js b/backend/lib/passport.js new file mode 100644 index 0000000..13e2989 --- /dev/null +++ b/backend/lib/passport.js @@ -0,0 +1,90 @@ +import passport from 'passport' +import { Strategy as GoogleStrategy } from 'passport-google-oauth20' +import { Strategy as GitHubStrategy } from 'passport-github2' +import User from '../models/User.js' + +const googleConfigured = process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET +const githubConfigured = process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET + +if (googleConfigured) { + passport.use(new GoogleStrategy( + { + clientID: process.env.GOOGLE_CLIENT_ID, + clientSecret: process.env.GOOGLE_CLIENT_SECRET, + callbackURL: `${process.env.BACKEND_URL}/api/v1/auth/google/callback`, + }, + async (_accessToken, _refreshToken, profile, done) => { + try { + let user = await User.findOne({ googleId: profile.id }) + + if (!user) { + const email = profile.emails?.[0]?.value + user = email ? await User.findOne({ email }) : null + + if (user) { + user.googleId = profile.id + if (!user.avatarUrl) user.avatarUrl = profile.photos?.[0]?.value + await user.save() + } else { + user = await User.create({ + googleId: profile.id, + email: profile.emails?.[0]?.value, + name: profile.displayName || profile.emails?.[0]?.value?.split('@')[0] || 'User', + avatarUrl: profile.photos?.[0]?.value, + password: null, + }) + } + } + + done(null, user) + } catch (err) { + done(err, null) + } + } + )) +} else { + console.warn(' Google OAuth not configured. Skipping Google strategy.') +} + +if (githubConfigured) { + passport.use(new GitHubStrategy( + { + clientID: process.env.GITHUB_CLIENT_ID, + clientSecret: process.env.GITHUB_CLIENT_SECRET, + callbackURL: `${process.env.BACKEND_URL}/api/v1/auth/github/callback`, + scope: ['user:email'], + }, + async (_accessToken, _refreshToken, profile, done) => { + try { + let user = await User.findOne({ githubId: profile.id }) + + if (!user) { + const email = profile.emails?.[0]?.value + user = email ? await User.findOne({ email }) : null + + if (user) { + user.githubId = profile.id + if (!user.avatarUrl) user.avatarUrl = profile.photos?.[0]?.value + await user.save() + } else { + user = await User.create({ + githubId: profile.id, + email: profile.emails?.[0]?.value, + name: profile.displayName || profile.username || profile.emails?.[0]?.value?.split('@')[0] || 'User', + avatarUrl: profile.photos?.[0]?.value, + password: null, + }) + } + } + + done(null, user) + } catch (err) { + done(err, null) + } + } + )) +} else { + console.warn(' GitHub OAuth not configured. Skipping GitHub strategy.') +} + +export default passport \ No newline at end of file diff --git a/backend/models/User.js b/backend/models/User.js index 4d5eb5e..87ab66f 100644 --- a/backend/models/User.js +++ b/backend/models/User.js @@ -11,8 +11,9 @@ const userSchema = new mongoose.Schema( password: { type: String, - required: true, + required: false, minlength: 6, + default: null, }, name: { @@ -55,6 +56,16 @@ const userSchema = new mongoose.Schema( type: Date, default: null, }, + googleId: { + type: String, + default: null, + index: true, + }, + githubId: { + type: String, + default: null, + index: true, + }, }, { timestamps: true }, ) diff --git a/backend/package-lock.json b/backend/package-lock.json index 3b230d0..5257d62 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -21,6 +21,9 @@ "mongoose": "^8.16.1", "nodemailer": "^8.0.7", "nodemon": "^3.1.10", + "passport": "^0.7.0", + "passport-github2": "^0.1.12", + "passport-google-oauth20": "^2.0.0", "socket.io": "^4.8.1", "swagger-jsdoc": "^6.3.0", "swagger-ui-express": "^5.0.1", @@ -1040,6 +1043,15 @@ "node": "^4.5.0 || >= 5.9" } }, + "node_modules/base64url": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz", + "integrity": "sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/bcryptjs": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", @@ -3306,6 +3318,12 @@ "node": ">=0.10.0" } }, + "node_modules/oauth": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.10.2.tgz", + "integrity": "sha512-JtFnB+8nxDEXgNyniwz573xxbKSOu3R8D40xQKqcjwJ2CDkYqUDI53o6IuzDJBx60Z8VKCm271+t8iFjakrl8Q==", + "license": "MIT" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -3421,6 +3439,75 @@ "node": ">= 0.8" } }, + "node_modules/passport": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz", + "integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==", + "license": "MIT", + "dependencies": { + "passport-strategy": "1.x.x", + "pause": "0.0.1", + "utils-merge": "^1.0.1" + }, + "engines": { + "node": ">= 0.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jaredhanson" + } + }, + "node_modules/passport-github2": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/passport-github2/-/passport-github2-0.1.12.tgz", + "integrity": "sha512-3nPUCc7ttF/3HSP/k9sAXjz3SkGv5Nki84I05kSQPo01Jqq1NzJACgMblCK0fGcv9pKCG/KXU3AJRDGLqHLoIw==", + "dependencies": { + "passport-oauth2": "1.x.x" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/passport-google-oauth20": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/passport-google-oauth20/-/passport-google-oauth20-2.0.0.tgz", + "integrity": "sha512-KSk6IJ15RoxuGq7D1UKK/8qKhNfzbLeLrG3gkLZ7p4A6DBCcv7xpyQwuXtWdpyR0+E0mwkpjY1VfPOhxQrKzdQ==", + "license": "MIT", + "dependencies": { + "passport-oauth2": "1.x.x" + }, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/passport-oauth2": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.8.0.tgz", + "integrity": "sha512-cjsQbOrXIDE4P8nNb3FQRCCmJJ/utnFKEz2NX209f7KOHPoX18gF7gBzBbLLsj2/je4KrgiwLLGjf0lm9rtTBA==", + "license": "MIT", + "dependencies": { + "base64url": "3.x.x", + "oauth": "0.10.x", + "passport-strategy": "1.x.x", + "uid2": "0.0.x", + "utils-merge": "1.x.x" + }, + "engines": { + "node": ">= 0.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jaredhanson" + } + }, + "node_modules/passport-strategy": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", + "integrity": "sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -3469,6 +3556,11 @@ "dev": true, "license": "MIT" }, + "node_modules/pause": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", + "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" + }, "node_modules/pend": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", @@ -4362,6 +4454,12 @@ "node": ">= 0.6" } }, + "node_modules/uid2": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/uid2/-/uid2-0.0.4.tgz", + "integrity": "sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==", + "license": "MIT" + }, "node_modules/undefsafe": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", diff --git a/backend/package.json b/backend/package.json index 7d09804..598c091 100644 --- a/backend/package.json +++ b/backend/package.json @@ -25,6 +25,9 @@ "mongoose": "^8.16.1", "nodemailer": "^8.0.7", "nodemon": "^3.1.10", + "passport": "^0.7.0", + "passport-github2": "^0.1.12", + "passport-google-oauth20": "^2.0.0", "socket.io": "^4.8.1", "swagger-jsdoc": "^6.3.0", "swagger-ui-express": "^5.0.1", diff --git a/backend/routes/auth.oauth.routes.js b/backend/routes/auth.oauth.routes.js new file mode 100644 index 0000000..08ffa47 --- /dev/null +++ b/backend/routes/auth.oauth.routes.js @@ -0,0 +1,45 @@ +import express from 'express' +import passport from '../lib/passport.js' +import { generateToken } from '../lib/utils.js' + +const oauthRouter = express.Router() + +// Google + +oauthRouter.get( + '/google', + passport.authenticate('google', { scope: ['profile', 'email'], session: false }) +) + +oauthRouter.get( + '/google/callback', + passport.authenticate('google', { + session: false, + failureRedirect: `${process.env.CLIENT_URL}/login?error=oauth_failed`, + }), + (req, res) => { + const token = generateToken(req.user._id) + // Redirect to frontend callback page with token in query param + res.redirect(`${process.env.CLIENT_URL}/oauth-callback?token=${token}`) + } +) + +// GitHub +oauthRouter.get( + '/github', + passport.authenticate('github', { scope: ['user:email'], session: false }) +) + +oauthRouter.get( + '/github/callback', + passport.authenticate('github', { + session: false, + failureRedirect: `${process.env.CLIENT_URL}/login?error=oauth_failed`, + }), + (req, res) => { + const token = generateToken(req.user._id) + res.redirect(`${process.env.CLIENT_URL}/oauth-callback?token=${token}`) + } +) + +export default oauthRouter \ No newline at end of file diff --git a/backend/server.js b/backend/server.js index 37c12d6..8d4f5f8 100644 --- a/backend/server.js +++ b/backend/server.js @@ -1,5 +1,9 @@ -import express from "express" import "dotenv/config" + +import passport from './lib/passport.js' +import oauthRouter from './routes/auth.oauth.routes.js' +import express from "express" + import cors from "cors" import http from "http" import helmet from "helmet" @@ -198,7 +202,7 @@ io.on("connection", async (socket) => { }) app.use(express.json({ limit: "4mb" })) - +app.use(passport.initialize()) // 4. Routes with Rate Limiting applied app.use("/api/status", (req, res) => { res.status(200).json({ status: "ok" }) @@ -220,8 +224,8 @@ app.use("/api/health", (req, res) => { // Apply strict limiter to auth routes, and standard limiter to message routes app.use("/api/v1/auth", authLimiter, userRouter) +app.use("/api/v1/auth", authLimiter, oauthRouter) app.use("/api/v1/messages", standardLimiter, messageRouter) - app.use("/", (req, res) => { res.send("NPMChat API is running") }) diff --git a/frontend/src/app/login/page.tsx b/frontend/src/app/login/page.tsx index 34d380e..d72ab7e 100644 --- a/frontend/src/app/login/page.tsx +++ b/frontend/src/app/login/page.tsx @@ -1,4 +1,5 @@ "use client" +import OAuthButtons from '@/components/OAuthButtons' import React, { useState } from "react" import Link from "next/link" import { useRouter } from "next/navigation" @@ -139,7 +140,7 @@ function LoginPageContent() { className="mt-2 border-2 border-black bg-[#b39ddb] text-black font-extrabold text-lg py-2 rounded-none transition-all cursor-[url('/custom-cursor-click.svg'),_pointer] hover:bg-[#39ff14] hover:text-white focus:outline-none" style={{ boxShadow: `4px 4px 0 0 ${accent}` }} > - {loading ? "Logging In..." : "Login 2"} + {loading ? "Logging In..." : "Login"}
+
+ {/* Floating accent shape bottom left */}
diff --git a/frontend/src/app/oauth-callback/page.tsx b/frontend/src/app/oauth-callback/page.tsx new file mode 100644 index 0000000..352a1bb --- /dev/null +++ b/frontend/src/app/oauth-callback/page.tsx @@ -0,0 +1,34 @@ +'use client' +import { useEffect } from 'react' +import { useRouter, useSearchParams } from 'next/navigation' +import { useAuth } from '../AuthContext' + +export default function OAuthCallback() { + const router = useRouter() + const params = useSearchParams() + const { checkAuth, setToken } = useAuth() + + useEffect(() => { + const token = params.get('token') + const error = params.get('error') + + if (error) { + router.replace('/login?error=oauth_failed') + return + } + + if (token) { + localStorage.setItem('token', token) + checkAuth().then(() => router.replace('/chat')) + } + }, []) + + return ( +
+
+ Signing you in... +
+
+ ) +} \ No newline at end of file diff --git a/frontend/src/app/signup/page.tsx b/frontend/src/app/signup/page.tsx index c24e28c..de04892 100644 --- a/frontend/src/app/signup/page.tsx +++ b/frontend/src/app/signup/page.tsx @@ -6,6 +6,7 @@ import { useAuth } from "../AuthContext" import ProtectedRoute from "../../components/ProtectedRoute" import zxcvbn from "zxcvbn" import { toast } from "sonner" +import OAuthButtons from '@/components/OAuthButtons' import { Eye, EyeOff, ArrowLeft } from "lucide-react" import ReCAPTCHA from "react-google-recaptcha" import { api } from "../fetcher" @@ -257,7 +258,7 @@ function SignupPageContent() { /> )} -