diff --git a/backend/.env.sample b/backend/.env.sample index ee9fc7e0..5457d313 100644 --- a/backend/.env.sample +++ b/backend/.env.sample @@ -2,9 +2,12 @@ PORT=8080 MONGODB_URI="mongodb://127.0.0.1/wanderlust" REDIS_URL="redis://127.0.0.1:6379" FRONTEND_URL=http://localhost:5173 +BACKEND_URL=http://localhost:8080 ACCESS_COOKIE_MAXAGE=120000 ACCESS_TOKEN_EXPIRES_IN='120s' REFRESH_COOKIE_MAXAGE=120000 REFRESH_TOKEN_EXPIRES_IN='120s' JWT_SECRET=70dd8b38486eee723ce2505f6db06f1ee503fde5eb06fc04687191a0ed665f3f98776902d2c89f6b993b1c579a87fedaf584c693a106f7cbf16e8b4e67e9d6df -NODE_ENV=Development \ No newline at end of file +NODE_ENV=Development +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= \ No newline at end of file diff --git a/backend/app.js b/backend/app.js index f198513e..38e4e7a6 100644 --- a/backend/app.js +++ b/backend/app.js @@ -7,6 +7,8 @@ import authRouter from './routes/auth.js'; import postsRouter from './routes/posts.js'; import userRouter from './routes/user.js'; import errorMiddleware from './middlewares/error-middleware.js'; +import passport from './config/passport.js'; +import session from 'express-session'; const app = express(); @@ -21,6 +23,9 @@ app.use(express.json()); app.use(express.urlencoded({ extended: true })); app.use(cookieParser()); app.use(compression()); +app.use(session({ secret: 'secret', resave: false, saveUninitialized: false })); +app.use(passport.initialize()); +app.use(passport.session()); // API route app.use('/api/posts', postsRouter); diff --git a/backend/config/passport.js b/backend/config/passport.js new file mode 100644 index 00000000..728b377e --- /dev/null +++ b/backend/config/passport.js @@ -0,0 +1,54 @@ +import passport from 'passport'; +import { Strategy as GoogleStrategy } from 'passport-google-oauth20'; +import User from '../models/user.js'; + +passport.use( + new GoogleStrategy( + { + clientID: process.env.GOOGLE_CLIENT_ID, + clientSecret: process.env.GOOGLE_CLIENT_SECRET, + callbackURL: `${process.env.BACKEND_URL}/api/auth/google/callback`, + }, + async (accessToken, refreshToken, profile, done) => { + try { + let user = await User.findOne({ googleId: profile.id }); + + if (!user) { + const email = profile.emails && profile.emails[0] ? profile.emails[0].value : ''; + let fullName = profile.displayName || ''; + if (fullName.length > 15) { + fullName = fullName.slice(0, 15); // Ensure fullName is less than 15 characters + } + const userName = email.split('@')[0] || fullName.replace(/\s+/g, '').toLowerCase(); + + user = new User({ + googleId: profile.id, + email, + fullName, + userName, + avatar: profile.photos && profile.photos[0] ? profile.photos[0].value : '', + }); + + await user.save(); + } + + done(null, user); + } catch (err) { + done(err, null); + } + } + ) +); + +passport.serializeUser((user, done) => done(null, user.id)); + +passport.deserializeUser(async (id, done) => { + try { + const user = await User.findById(id); + done(null, user); + } catch (err) { + done(err, null); + } +}); + +export default passport; diff --git a/backend/controllers/auth-controller.js b/backend/controllers/auth-controller.js index bdeb3fea..e5dd9300 100644 --- a/backend/controllers/auth-controller.js +++ b/backend/controllers/auth-controller.js @@ -115,7 +115,7 @@ export const signInWithEmailOrUsername = asyncHandler(async (req, res) => { }); //Sign Out -export const signOutUser = asyncHandler(async (req, res) => { +export const signOutUser = asyncHandler(async (req, res, next) => { await User.findByIdAndUpdate( req.user?._id, { @@ -128,19 +128,31 @@ export const signOutUser = asyncHandler(async (req, res) => { } ); - res - .status(HTTP_STATUS.OK) - .clearCookie('access_token', cookieOptions) - .clearCookie('refresh_token', cookieOptions) - .json(new ApiResponse(HTTP_STATUS.OK, '', RESPONSE_MESSAGES.USERS.SIGNED_OUT)); -}); + // Passport.js logout + req.logout((err) => { + if (err) { + return next(new ApiError(HTTP_STATUS.INTERNAL_SERVER_ERROR, 'Logout failed')); + } + res + .status(HTTP_STATUS.OK) + .clearCookie('access_token', cookieOptions) + .clearCookie('refresh_token', cookieOptions) + .clearCookie('jwt', cookieOptions) + .json(new ApiResponse(HTTP_STATUS.OK, '', RESPONSE_MESSAGES.USERS.SIGNED_OUT)); + }); +}); // check user export const isLoggedIn = asyncHandler(async (req, res) => { let access_token = req.cookies?.access_token; let refresh_token = req.cookies?.refresh_token; const { _id } = req.params; + if (!_id) { + return res + .status(HTTP_STATUS.BAD_REQUEST) + .json(new ApiResponse(HTTP_STATUS.BAD_REQUEST, '', 'User ID is required')); + } if (access_token) { try { await jwt.verify(access_token, JWT_SECRET); @@ -148,22 +160,32 @@ export const isLoggedIn = asyncHandler(async (req, res) => { .status(HTTP_STATUS.OK) .json(new ApiResponse(HTTP_STATUS.OK, access_token, RESPONSE_MESSAGES.USERS.VALID_TOKEN)); } catch (error) { - // Access token invalid, proceed to check refresh token - console.log(error); + console.log('Access token verification error:', error.message); } - } else if (refresh_token) { + } + // If access token is not valid, check the refresh token + if (refresh_token) { try { await jwt.verify(refresh_token, JWT_SECRET); + const user = await User.findById(_id); + if (!user) { + return res + .status(HTTP_STATUS.NOT_FOUND) + .json( + new ApiResponse(HTTP_STATUS.NOT_FOUND, '', RESPONSE_MESSAGES.USERS.USER_NOT_EXISTS) + ); + } access_token = await user.generateAccessToken(); return res .status(HTTP_STATUS.OK) .cookie('access_token', access_token, cookieOptions) .json(new ApiResponse(HTTP_STATUS.OK, access_token, RESPONSE_MESSAGES.USERS.VALID_TOKEN)); } catch (error) { - // Access token invalid, proceed to check refresh token that is in db - console.log(error); + console.log('Refresh token verification error:', error.message); } } + + // If neither token is valid, handle accordingly const user = await User.findById(_id); if (!user) { return res diff --git a/backend/middlewares/auth-middleware.js b/backend/middlewares/auth-middleware.js index 6e091962..363645f3 100644 --- a/backend/middlewares/auth-middleware.js +++ b/backend/middlewares/auth-middleware.js @@ -3,6 +3,7 @@ import { ApiError } from '../utils/api-error.js'; import { HTTP_STATUS, RESPONSE_MESSAGES } from '../utils/constants.js'; import jwt from 'jsonwebtoken'; import { Role } from '../types/role-type.js'; +import User from '../models/user.js'; export const authMiddleware = async (req, res, next) => { const token = req.cookies?.access_token; @@ -10,14 +11,13 @@ export const authMiddleware = async (req, res, next) => { return next(new ApiError(HTTP_STATUS.BAD_REQUEST, RESPONSE_MESSAGES.USERS.RE_LOGIN)); } - if (token) { - await jwt.verify(token, JWT_SECRET, (error, payload) => { - if (error) { - return new ApiError(HTTP_STATUS.FORBIDDEN, RESPONSE_MESSAGES.USERS.INVALID_TOKEN); - } - req.user = payload; - next(); - }); + try { + const payload = jwt.verify(token, JWT_SECRET); + req.user = await User.findById(payload.id); + next(); + } catch (error) { + console.log('Token verification error:', error.message); + return next(new ApiError(HTTP_STATUS.FORBIDDEN, RESPONSE_MESSAGES.USERS.INVALID_TOKEN)); } }; diff --git a/backend/models/user.js b/backend/models/user.js index 344289f6..c564fba4 100644 --- a/backend/models/user.js +++ b/backend/models/user.js @@ -34,7 +34,7 @@ const userSchema = new Schema( }, password: { type: String, - required: [true, 'Password is required'], + required: false, minLength: [8, 'Password must be at least 8 character '], match: [ /^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9])(?=.*?[#?!@$%^&*-]).{8,}$/, @@ -60,7 +60,13 @@ const userSchema = new Schema( refreshToken: String, forgotPasswordToken: String, forgotPasswordExpiry: Date, + googleId: { + type: String, + unique: true, + required: false, + }, }, + { timestamps: true } ); diff --git a/backend/package.json b/backend/package.json index 7f3f03be..9d666138 100644 --- a/backend/package.json +++ b/backend/package.json @@ -8,9 +8,12 @@ "cors": "^2.8.5", "dotenv": "^16.3.1", "express": "^4.18.2", + "express-session": "^1.18.0", "jsonwebtoken": "^9.0.2", "mongoose": "^8.0.0", "nodemon": "^3.0.1", + "passport": "^0.7.0", + "passport-google-oauth20": "^2.0.0", "redis": "^4.6.13" }, "lint-staged": { diff --git a/backend/routes/auth.js b/backend/routes/auth.js index c11b3394..85b498b3 100644 --- a/backend/routes/auth.js +++ b/backend/routes/auth.js @@ -1,5 +1,7 @@ import { Router } from 'express'; import { authMiddleware } from '../middlewares/auth-middleware.js'; +import passport from '../config/passport.js'; +import jwt from 'jsonwebtoken'; import { signUpWithEmail, signInWithEmailOrUsername, @@ -13,10 +15,38 @@ const router = Router(); router.post('/email-password/signup', signUpWithEmail); router.post('/email-password/signin', signInWithEmailOrUsername); +// Google-login +router.get('/google', passport.authenticate('google', { scope: ['profile', 'email'] })); + +router.get( + '/google/callback', + passport.authenticate('google', { failureRedirect: '/' }), + (req, res) => { + const token = jwt.sign({ id: req.user._id }, process.env.JWT_SECRET, { expiresIn: '1h' }); + res.cookie('access_token', token, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + }); + res.redirect(`${process.env.FRONTEND_URL}/signup?google-callback=true`); + } +); + +router.get('/check', authMiddleware, (req, res) => { + const token = req.cookies.access_token; + res.json({ + token, + user: { + _id: req.user._id, + role: req.user.role, + }, + }); +}); + //SIGN OUT router.post('/signout', authMiddleware, signOutUser); //CHECK USER STATUS -router.get('/check/:_id', isLoggedIn); +router.get('/check/:_id', authMiddleware, isLoggedIn); export default router; diff --git a/frontend/src/components/require-auth.tsx b/frontend/src/components/require-auth.tsx index 06f6febc..68647121 100644 --- a/frontend/src/components/require-auth.tsx +++ b/frontend/src/components/require-auth.tsx @@ -8,7 +8,7 @@ function RequireAuth({ allowedRole }: { allowedRole: string[] }) { if (loading) { return ( <> - + ); // Render a loading indicator } diff --git a/frontend/src/hooks/useAuthData.ts b/frontend/src/hooks/useAuthData.ts index ded74bf4..852a11ef 100644 --- a/frontend/src/hooks/useAuthData.ts +++ b/frontend/src/hooks/useAuthData.ts @@ -32,6 +32,7 @@ const useAuthData = (): AuthData => { loading: false, }); } catch (error) { + console.error('Error fetching token:', error); setData({ ...data, token: '', diff --git a/frontend/src/pages/signin-page.tsx b/frontend/src/pages/signin-page.tsx index b46d3790..797f873a 100644 --- a/frontend/src/pages/signin-page.tsx +++ b/frontend/src/pages/signin-page.tsx @@ -1,5 +1,5 @@ import { Link, useNavigate } from 'react-router-dom'; -// import AddGoogleIcon from '@/assets/svg/google-color-icon.svg'; +import AddGoogleIcon from '@/assets/svg/google-color-icon.svg'; // import AddGithubIcon from '@/assets/svg/github-icon.svg'; import { useForm } from 'react-hook-form'; import type { FieldValues } from 'react-hook-form'; @@ -11,12 +11,13 @@ import { AxiosError, isAxiosError } from 'axios'; import axiosInstance from '@/helpers/axios-instance'; import userState from '@/utils/user-state'; import ThemeToggle from '@/components/theme-toggle-button'; -import { useState } from 'react'; +import { useState, useEffect, useRef } from 'react'; import EyeIcon from '@/assets/svg/eye.svg'; import EyeOffIcon from '@/assets/svg/eye-off.svg'; function signin() { const navigate = useNavigate(); const [passwordVisible, setPasswordVisible] = useState(false); + const toastShownRef = useRef(false); const { register, handleSubmit, @@ -68,11 +69,48 @@ function signin() { } }; + useEffect(() => { + const handleGoogleCallback = async () => { + const searchParams = new URLSearchParams(location.search); + const isGoogleCallback = searchParams.get('google-callback') === 'true'; + + if (isGoogleCallback && !toastShownRef.current) { + try { + const response = await axiosInstance.get('/api/auth/check'); + const { user } = response.data; + if (user && user._id && user.role) { + userState.setUser({ _id: user._id, role: user.role }); + navigate('/'); + if (!toastShownRef.current) { + toast.success('Successfully logged in with Google'); + toastShownRef.current = true; + } + } else { + console.error('User data is incomplete:', user); + } + window.history.replaceState({}, document.title, window.location.pathname); + } catch (error) { + console.error('Error handling Google login:', error); + if (!toastShownRef.current) { + toast.error('Failed to log in with Google'); + toastShownRef.current = true; + } + } + } + }; + + handleGoogleCallback(); + }, [location, navigate]); + + const handleGoogleLogin = () => { + window.location.href = `${import.meta.env.VITE_API_PATH}/api/auth/google`; + }; + return (
-

+

Sign in to WanderLust

@@ -138,21 +176,21 @@ function signin() { {/* OR */}
- {/* - Continue with Google - + Continue with Google + - Continue with Github - */} + */}
); diff --git a/frontend/src/pages/signup-page.tsx b/frontend/src/pages/signup-page.tsx index 5ce7655c..9d42dffd 100644 --- a/frontend/src/pages/signup-page.tsx +++ b/frontend/src/pages/signup-page.tsx @@ -1,5 +1,5 @@ import { Link, useNavigate } from 'react-router-dom'; -// import AddGoogleIcon from '@/assets/svg/google-color-icon.svg'; +import AddGoogleIcon from '@/assets/svg/google-color-icon.svg'; // import AddGithubIcon from '@/assets/svg/github-icon.svg'; import { useForm } from 'react-hook-form'; import type { FieldValues } from 'react-hook-form'; @@ -11,7 +11,7 @@ import { AxiosError, isAxiosError } from 'axios'; import axiosInstance from '@/helpers/axios-instance'; import userState from '@/utils/user-state'; import ThemeToggle from '@/components/theme-toggle-button'; -import { useState } from 'react'; +import { useState, useEffect, useRef } from 'react'; import EyeIcon from '@/assets/svg/eye.svg'; import EyeOffIcon from '@/assets/svg/eye-off.svg'; function Signup() { @@ -25,6 +25,7 @@ function Signup() { } = useForm({ resolver: zodResolver(signUpSchema) }); const [passwordVisible, setPasswordVisible] = useState(false); + const toastShownRef = useRef(false); const onSubmit = async (data: FieldValues) => { try { const res = axiosInstance.post('/api/auth/email-password/signup', data); @@ -63,11 +64,48 @@ function Signup() { } }; + useEffect(() => { + const handleGoogleCallback = async () => { + const searchParams = new URLSearchParams(location.search); + const isGoogleCallback = searchParams.get('google-callback') === 'true'; + + if (isGoogleCallback && !toastShownRef.current) { + try { + const response = await axiosInstance.get('/api/auth/check'); + const { user } = response.data; + if (user && user._id && user.role) { + userState.setUser({ _id: user._id, role: user.role }); + navigate('/'); + if (!toastShownRef.current) { + toast.success('Successfully logged in with Google'); + toastShownRef.current = true; + } + } else { + console.error('User data is incomplete:', user); + } + window.history.replaceState({}, document.title, window.location.pathname); + } catch (error) { + console.error('Error handling Google login:', error); + if (!toastShownRef.current) { + toast.error('Failed to log in with Google'); + toastShownRef.current = true; + } + } + } + }; + + handleGoogleCallback(); + }, [location, navigate]); + + const handleGoogleLogin = () => { + window.location.href = `${import.meta.env.VITE_API_PATH}/api/auth/google`; + }; + return (
-

+

Sign up to WanderLust

@@ -176,21 +214,22 @@ function Signup() { {/* OR */}
- {/* Continue with Google - + - Continue with Github - */} + */}
); diff --git a/package.json b/package.json index f77dc743..dbc22dd4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,7 @@ { "dependencies": { - "concurrently": "^8.2.2" + "concurrently": "^8.2.2", + "js-cookie": "^3.0.5" }, "scripts": { "start-frontend": "cd frontend && npm run dev",