diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e078f9b..e0c5f52 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,6 +15,9 @@ env: CLOUDNARY_API_KEY: ${{secrets.CLOUDNARY_API_KEY}} CLOUDINARY_CLOUD_NAME: ${{secrets.CLOUDINARY_CLOUD_NAME}} CLOUDINARY_API_SECRET: ${{secrets.CLOUDINARY_API_SECRET}} + GOOGLE_CLIENT_ID: ${{secrets.GOOGLE_CLIENT_ID}} + GOOGLE_CLIENT_SECRET: ${{secrets.GOOGLE_CLIENT_SECRET}} + jobs: build-lint-test-coverage: diff --git a/package.json b/package.json index 9851474..06e7e40 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "cross-env": "^7.0.3", "dotenv": "^16.4.5", "express": "^4.19.2", + "express-session": "^1.18.0", "express-winston": "^4.2.0", "highlight.js": "^11.9.0", "joi": "^17.13.1", @@ -43,6 +44,8 @@ "multer": "^1.4.5-lts.1", "nodemailer": "^6.9.13", "nodemon": "^3.1.0", + "passport": "^0.7.0", + "passport-google-oauth20": "^2.0.0", "pg": "^8.11.5", "reflect-metadata": "^0.2.2", "source-map-support": "^0.5.21", @@ -66,12 +69,14 @@ "@types/eslint": "^8.56.10", "@types/eslint__js": "^8.42.3", "@types/express": "^4.17.21", + "@types/express-session": "^1.18.0", "@types/jest": "^29.5.12", "@types/jsend": "^1.0.32", "@types/jsonwebtoken": "^9.0.6", "@types/morgan": "^1.9.9", "@types/node": "^20.12.7", "@types/nodemailer": "^6.4.15", + "@types/passport-google-oauth20": "^2.0.16", "@types/reflect-metadata": "^0.1.0", "@types/supertest": "^6.0.2", "@types/uuid": "^9.0.8", diff --git a/src/__test__/oauth.test.ts b/src/__test__/oauth.test.ts new file mode 100644 index 0000000..7723cb4 --- /dev/null +++ b/src/__test__/oauth.test.ts @@ -0,0 +1,37 @@ +import request from 'supertest'; +import { app, server } from '../index'; +import { createConnection, getConnection, getConnectionOptions, getRepository } from 'typeorm'; +import { User } from '../entities/User'; + +beforeAll(async () => { + // Connect to the test database + const connectionOptions = await getConnectionOptions(); + + await createConnection({ ...connectionOptions, name: 'testConnection' }); +}); + +afterAll(async () => { + const connection = getConnection('testConnection'); + const userRepository = connection.getRepository(User); + + // Delete all records from the User + await userRepository.delete({}); + + // Close the connection to the test database + await connection.close(); + + server.close(); +}); +describe('authentication routes test',() => { + it('should redirect to the google authentication page',async() => { + const response = await request(app) + .get('/user/google-auth'); + expect(response.statusCode).toBe(302) + }) + it('should redirect after google authentication', async() => { + const response = await request(app) + .get('/user/auth/google/callback'); + expect(response.statusCode).toBe(302) + }) +}); + diff --git a/src/index.ts b/src/index.ts index fb60cd4..07efd39 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,8 @@ import router from './routes'; import { addDocumentation } from './startups/docs'; import 'reflect-metadata'; import cookieParser from 'cookie-parser'; +import session from "express-session"; +import passport from 'passport'; import { CustomError, errorHandler } from './middlewares/errorHandler'; import morgan from 'morgan'; @@ -13,6 +15,11 @@ dotenv.config(); export const app = express(); const port = process.env.PORT || 8000; +app.use(session({ + secret: 'keyboard cat' +})) +app.use(passport.initialize()) +app.use(passport.session()) app.use(express.json()); app.use(cookieParser()); app.use(cors({ origin: '*' })); diff --git a/src/routes/ProductRoutes.ts b/src/routes/ProductRoutes.ts index 98c0a50..10e5be7 100644 --- a/src/routes/ProductRoutes.ts +++ b/src/routes/ProductRoutes.ts @@ -1,4 +1,4 @@ -import { Router } from 'express'; +import { RequestHandler, Router } from 'express'; import { productStatus } from '../controllers/index'; import { hasRole } from '../middlewares/roleCheck'; @@ -17,18 +17,16 @@ import { singleProduct, } from '../controllers'; const router = Router(); - router.get('/all', listAllProducts); -router.get('/recommended', authMiddleware, hasRole('BUYER'), getRecommendedProducts); -router.get('/collection', authMiddleware, hasRole('VENDOR'), readProducts); -router.get('/', authMiddleware, hasRole('BUYER'), readProducts); -router.get('/:id', singleProduct) -router.get('/collection/:id', authMiddleware, hasRole('VENDOR'), readProduct); -router.post('/', authMiddleware, hasRole('VENDOR'), upload.array('images', 10), createProduct); -router.put('/:id', authMiddleware, hasRole('VENDOR'), upload.array('images', 10), updateProduct); -router.delete('/images/:id', authMiddleware, hasRole('VENDOR'), removeProductImage); -router.delete('/:id', authMiddleware, hasRole('VENDOR'), deleteProduct); -router.put('/availability/:id', authMiddleware, hasRole('VENDOR'), productStatus); - +router.get('/recommended', authMiddleware as RequestHandler, hasRole('BUYER'), getRecommendedProducts); +router.get('/collection', authMiddleware as RequestHandler, hasRole('VENDOR'), readProducts); +router.get('/', authMiddleware as RequestHandler, hasRole('BUYER'), readProducts); +router.get('/:id', singleProduct); +router.get('/collection/:id', authMiddleware as RequestHandler, hasRole('VENDOR'), readProduct); +router.post('/', authMiddleware as RequestHandler, hasRole('VENDOR'), upload.array('images', 10), createProduct); +router.put('/:id', authMiddleware as RequestHandler, hasRole('VENDOR'), upload.array('images', 10), updateProduct); +router.delete('/images/:id', authMiddleware as RequestHandler, hasRole('VENDOR'), removeProductImage); +router.delete('/:id', authMiddleware as RequestHandler, hasRole('VENDOR'), deleteProduct); +router.put('/availability/:id', authMiddleware as RequestHandler, hasRole('VENDOR'), productStatus); export default router; diff --git a/src/routes/UserRoutes.ts b/src/routes/UserRoutes.ts index 2175d84..50bb4ca 100644 --- a/src/routes/UserRoutes.ts +++ b/src/routes/UserRoutes.ts @@ -1,4 +1,7 @@ import { Router } from 'express'; +import { responseError } from '../utils/response.utils'; +import { UserInterface } from '../entities/User'; +import jwt from 'jsonwebtoken' import { disable2FA, enable2FA, @@ -15,7 +18,8 @@ import { import { activateUser, disactivateUser, userProfileUpdate } from '../controllers/index'; import { hasRole } from '../middlewares/roleCheck'; import { isTokenValide } from '../middlewares/isValid'; - +import passport from 'passport'; +import "../utils/auth"; const router = Router(); router.post('/register', userRegistration); @@ -32,4 +36,37 @@ router.post('/password/reset', userPasswordReset); router.post('/password/reset/link', sendPasswordResetLink); router.put('/update', userProfileUpdate); +router.get('/google-auth', passport.authenticate('google', { scope: ['profile', 'email'] })); +router.get("/auth/google/callback", + passport.authenticate("google", { + successRedirect: "/user/login/success", + failureRedirect: "/user/login/failed" + }) +); +router.get("/login/success", async (req, res) => { + const user = req.user as UserInterface; + if(!user){ + responseError(res, 404, 'user not found') + } + const payload = { + id: user?.id, + email: user?.email, + role: user?.role + } + const token = jwt.sign(payload, process.env.JWT_SECRET as string,{expiresIn: '24h'}) + res.status(200).json({ + status: 'success', + data:{ + token: token, + message: "Login success" + } + }) +}); +router.get("/login/failed", async (req, res) => { + res.status(401).json({ + status: false, + message: "Login failed" + }); +}); + export default router; diff --git a/src/routes/wishListRoute.ts b/src/routes/wishListRoute.ts index ae92553..d5ac6fb 100644 --- a/src/routes/wishListRoute.ts +++ b/src/routes/wishListRoute.ts @@ -1,4 +1,4 @@ -import { Router } from 'express'; +import { RequestHandler, Router } from 'express'; import { authMiddleware } from '../middlewares/verifyToken'; import { hasRole } from '../middlewares'; import { checkUserStatus } from '../middlewares/isAllowed'; @@ -6,9 +6,9 @@ import { wishlistAddProduct,wishlistRemoveProduct,wishlistGetProducts,wishlistCl const router = Router(); -router.post('/add/:id', authMiddleware, checkUserStatus, hasRole('BUYER'), wishlistAddProduct); -router.get('/',authMiddleware, checkUserStatus, hasRole('BUYER'),wishlistGetProducts); -router.delete('/delete/:id',authMiddleware, checkUserStatus, hasRole('BUYER'),wishlistRemoveProduct); -router.delete('/clearAll',authMiddleware, checkUserStatus, hasRole('BUYER'),wishlistClearAllProducts); +router.post('/add/:id', authMiddleware as RequestHandler, checkUserStatus, hasRole('BUYER'), wishlistAddProduct); +router.get('/',authMiddleware as RequestHandler, checkUserStatus, hasRole('BUYER'),wishlistGetProducts); +router.delete('/delete/:id',authMiddleware as RequestHandler, checkUserStatus, hasRole('BUYER'),wishlistRemoveProduct); +router.delete('/clearAll',authMiddleware as RequestHandler, checkUserStatus, hasRole('BUYER'),wishlistClearAllProducts); export default router; \ No newline at end of file diff --git a/src/utils/auth.ts b/src/utils/auth.ts new file mode 100644 index 0000000..91874e3 --- /dev/null +++ b/src/utils/auth.ts @@ -0,0 +1,72 @@ +/* eslint-disable camelcase */ +import passport from 'passport'; +import { Strategy } from "passport-google-oauth20"; +import { User } from '../entities/User'; +import { getRepository } from 'typeorm'; +import bcrypt from 'bcrypt'; +import "../utils/auth"; +passport.use( + new Strategy( + { + clientID: process.env.GOOGLE_CLIENT_ID as string, + clientSecret: process.env.GOOGLE_CLIENT_SECRET as string, + callbackURL: 'http://localhost:6890/user/auth/google/callback/', + scope: ['email', 'profile'], + }, + async (accessToken: any, refreshToken: any, profile: any, cb: any) => { + const userRepository = getRepository(User); + const { family_name, + name, + picture, + email, + email_verified + + } = profile._json; + const { familyName, givenName } = profile.name; + + if (email || givenName || family_name || picture) { + try { + // Check for existing user + const existingUser = await userRepository.findOneBy({ email }); + + if (existingUser) { + return await cb(null, existingUser); + } + const saltRounds = 10; + const hashedPassword = await bcrypt.hash("password", saltRounds); + const newUser = new User(); + newUser.firstName = givenName; + newUser.lastName = family_name ?? familyName ?? "undefined"; + newUser.email = email; + newUser.userType = 'Buyer'; + newUser.photoUrl = picture; + newUser.gender = "Not specified"; + newUser.phoneNumber = "Not specified"; + newUser.password = hashedPassword; + newUser.verified = email_verified; + + await userRepository.save(newUser); + return await cb(null, newUser); + } catch (error) { + console.error(error); + return await cb(error, null); + } + } + return await cb(null, profile, { message: 'Missing required profile information' }); + } + ) +); + +passport.serializeUser((user: any, cb) => { + cb(null, user.id); +}); + +passport.deserializeUser(async (id: any, cb) => { + const userRepository = getRepository(User); + try { + const user = await userRepository.findOneBy({id}); + cb(null, user); + } catch (error) { + cb(error); + } +});