Add Google OAuth authentication system #4
24
config/auth.js
Normal file
24
config/auth.js
Normal file
@ -0,0 +1,24 @@
|
||||
// Google OAuth and JWT configuration
|
||||
module.exports = {
|
||||
google: {
|
||||
clientID: process.env.GOOGLE_CLIENT_ID,
|
||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
|
||||
callbackURL: process.env.GOOGLE_CALLBACK_URL,
|
||||
scope: ['profile', 'email']
|
||||
},
|
||||
jwt: {
|
||||
secret: process.env.JWT_SECRET,
|
||||
expiresIn: '7d',
|
||||
refreshThreshold: 24 * 60 * 60 // Refresh if token is older than 1 day (in seconds)
|
||||
},
|
||||
cookie: {
|
||||
name: 'auth_token',
|
||||
options: {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
|
||||
domain: process.env.NODE_ENV === 'production' ? '.maverickapplications.com' : undefined
|
||||
}
|
||||
}
|
||||
};
|
||||
111
middleware/auth.js
Normal file
111
middleware/auth.js
Normal file
@ -0,0 +1,111 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const authConfig = require('../config/auth');
|
||||
const { findById } = require('../models/user');
|
||||
|
||||
/**
|
||||
* Check if a token should be refreshed based on its age
|
||||
* @param {Object} decoded - Decoded JWT payload
|
||||
* @returns {boolean} True if token should be refreshed
|
||||
*/
|
||||
const shouldRefreshToken = (decoded) => {
|
||||
const tokenAge = Math.floor(Date.now() / 1000) - decoded.iat;
|
||||
return tokenAge > authConfig.jwt.refreshThreshold;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate a new JWT token for a user
|
||||
* @param {string|ObjectId} userId - User's MongoDB _id
|
||||
* @returns {string} JWT token
|
||||
*/
|
||||
const generateToken = (userId) => {
|
||||
return jwt.sign(
|
||||
{ userId: userId.toString() },
|
||||
authConfig.jwt.secret,
|
||||
{ expiresIn: authConfig.jwt.expiresIn }
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Authentication middleware
|
||||
* Validates JWT token from cookie and attaches user to request
|
||||
* Implements sliding window token refresh
|
||||
*
|
||||
* @param {Request} req - Express request object
|
||||
* @param {Response} res - Express response object
|
||||
* @param {Function} next - Express next function
|
||||
*/
|
||||
const requireAuth = async (req, res, next) => {
|
||||
const token = req.cookies[authConfig.cookie.name];
|
||||
|
||||
// No token present
|
||||
if (!token) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
try {
|
||||
// Verify the token
|
||||
const decoded = jwt.verify(token, authConfig.jwt.secret);
|
||||
|
||||
// Get database instance
|
||||
const db = req.app.locals.db;
|
||||
|
||||
// Fetch user from database
|
||||
const user = await findById(db, decoded.userId);
|
||||
|
||||
// User not found
|
||||
if (!user) {
|
||||
res.clearCookie(authConfig.cookie.name);
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
// User is disabled (silent logout)
|
||||
if (!user.isActive) {
|
||||
res.clearCookie(authConfig.cookie.name);
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
// Attach user to request
|
||||
req.user = user;
|
||||
|
||||
// Sliding window token refresh
|
||||
if (shouldRefreshToken(decoded)) {
|
||||
const newToken = generateToken(user._id);
|
||||
res.cookie(authConfig.cookie.name, newToken, authConfig.cookie.options);
|
||||
}
|
||||
|
||||
next();
|
||||
} catch (err) {
|
||||
// Token verification failed (invalid or expired)
|
||||
res.clearCookie(authConfig.cookie.name);
|
||||
return res.status(401).json({ error: 'Invalid token' });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Admin authorization middleware
|
||||
* Must be used after requireAuth middleware
|
||||
* Checks if authenticated user has admin role
|
||||
*
|
||||
* @param {Request} req - Express request object
|
||||
* @param {Response} res - Express response object
|
||||
* @param {Function} next - Express next function
|
||||
*/
|
||||
const requireAdmin = (req, res, next) => {
|
||||
// Check if user is attached (requireAuth should be called first)
|
||||
if (!req.user) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
// Check if user has admin role
|
||||
if (req.user.role !== 'admin') {
|
||||
return res.status(403).json({ error: 'Admin access required' });
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
requireAuth,
|
||||
requireAdmin,
|
||||
generateToken
|
||||
};
|
||||
29
middleware/passport.js
Normal file
29
middleware/passport.js
Normal file
@ -0,0 +1,29 @@
|
||||
const passport = require('passport');
|
||||
const GoogleStrategy = require('passport-google-oauth20').Strategy;
|
||||
const authConfig = require('../config/auth');
|
||||
const { findOrCreateUser } = require('../models/user');
|
||||
|
||||
const configurePassport = (passport, db) => {
|
||||
passport.use(new GoogleStrategy({
|
||||
clientID: authConfig.google.clientID,
|
||||
clientSecret: authConfig.google.clientSecret,
|
||||
callbackURL: authConfig.google.callbackURL
|
||||
},
|
||||
async (accessToken, refreshToken, profile, done) => {
|
||||
try {
|
||||
const userProfile = {
|
||||
googleId: profile.id,
|
||||
email: profile.emails[0].value,
|
||||
name: profile.displayName,
|
||||
picture: profile.photos?.[0]?.value || null
|
||||
};
|
||||
|
||||
const user = await findOrCreateUser(db, userProfile);
|
||||
done(null, user);
|
||||
} catch (error) {
|
||||
done(error, null);
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
module.exports = { configurePassport };
|
||||
129
models/user.js
Normal file
129
models/user.js
Normal file
@ -0,0 +1,129 @@
|
||||
const { ObjectId } = require('mongodb');
|
||||
|
||||
const USER_COLLECTION = 'users';
|
||||
|
||||
/**
|
||||
* Find or create a user based on Google OAuth profile
|
||||
* Uses upsert pattern to handle both new and returning users
|
||||
*
|
||||
* @param {Db} db - MongoDB database instance
|
||||
* @param {Object} profile - Google OAuth profile
|
||||
* @param {string} profile.id - Google's unique user ID
|
||||
* @param {string} profile.displayName - User's display name
|
||||
* @param {Array} profile.emails - Array of email objects
|
||||
* @param {Array} profile.photos - Array of photo objects
|
||||
* @returns {Promise<Object>} User document
|
||||
*/
|
||||
async function findOrCreateUser(db, profile) {
|
||||
const now = new Date();
|
||||
|
||||
const result = await db.collection(USER_COLLECTION).findOneAndUpdate(
|
||||
{ googleId: profile.id },
|
||||
{
|
||||
$set: {
|
||||
email: profile.emails[0].value,
|
||||
name: profile.displayName,
|
||||
picture: profile.photos?.[0]?.value || null,
|
||||
lastLoginAt: now
|
||||
},
|
||||
$inc: { loginCount: 1 },
|
||||
$setOnInsert: {
|
||||
googleId: profile.id,
|
||||
isActive: true,
|
||||
role: 'user',
|
||||
createdAt: now,
|
||||
loginCount: 0 // Will be incremented to 1 by $inc
|
||||
}
|
||||
},
|
||||
{
|
||||
upsert: true,
|
||||
returnDocument: 'after'
|
||||
}
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a user by Google ID
|
||||
*
|
||||
* @param {Db} db - MongoDB database instance
|
||||
* @param {string} googleId - Google's unique user ID
|
||||
* @returns {Promise<Object|null>} User document or null
|
||||
*/
|
||||
async function findByGoogleId(db, googleId) {
|
||||
return await db.collection(USER_COLLECTION).findOne({ googleId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a user by MongoDB ObjectId
|
||||
*
|
||||
* @param {Db} db - MongoDB database instance
|
||||
* @param {string|ObjectId} id - User's MongoDB _id
|
||||
* @returns {Promise<Object|null>} User document or null
|
||||
*/
|
||||
async function findById(db, id) {
|
||||
// Convert string to ObjectId if needed
|
||||
const objectId = typeof id === 'string' ? new ObjectId(id) : id;
|
||||
return await db.collection(USER_COLLECTION).findOne({ _id: objectId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable or disable a user account
|
||||
*
|
||||
* @param {Db} db - MongoDB database instance
|
||||
* @param {string|ObjectId} id - User's MongoDB _id
|
||||
* @param {boolean} isActive - New active status
|
||||
* @returns {Promise<Object>} Update result
|
||||
*/
|
||||
async function setUserActive(db, id, isActive) {
|
||||
// Convert string to ObjectId if needed
|
||||
const objectId = typeof id === 'string' ? new ObjectId(id) : id;
|
||||
|
||||
const result = await db.collection(USER_COLLECTION).findOneAndUpdate(
|
||||
{ _id: objectId },
|
||||
{ $set: { isActive } },
|
||||
{ returnDocument: 'after' }
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create required indexes for the users collection
|
||||
* Should be called once during application startup
|
||||
*
|
||||
* @param {Db} db - MongoDB database instance
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function createIndexes(db) {
|
||||
const collection = db.collection(USER_COLLECTION);
|
||||
|
||||
// Unique index on googleId for OAuth lookups
|
||||
await collection.createIndex(
|
||||
{ googleId: 1 },
|
||||
{ unique: true }
|
||||
);
|
||||
|
||||
// Unique index on email for user identification
|
||||
await collection.createIndex(
|
||||
{ email: 1 },
|
||||
{ unique: true }
|
||||
);
|
||||
|
||||
// Compound index for querying active users sorted by last login
|
||||
await collection.createIndex(
|
||||
{ isActive: 1, lastLoginAt: -1 }
|
||||
);
|
||||
|
||||
console.log('User collection indexes created successfully');
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
USER_COLLECTION,
|
||||
findOrCreateUser,
|
||||
findByGoogleId,
|
||||
findById,
|
||||
setUserActive,
|
||||
createIndexes
|
||||
};
|
||||
233
package-lock.json
generated
233
package-lock.json
generated
@ -9,10 +9,15 @@
|
||||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.18.2",
|
||||
"express-rate-limit": "^7.1.5",
|
||||
"mongodb": "^6.3.0"
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"mongodb": "^6.3.0",
|
||||
"passport": "^0.7.0",
|
||||
"passport-google-oauth20": "^2.0.0",
|
||||
"uuid": "^13.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.2"
|
||||
@ -85,6 +90,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"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/binary-extensions": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||
@ -155,6 +169,12 @@
|
||||
"node": ">=16.20.1"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-equal-constant-time": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/bytes": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||
@ -255,6 +275,28 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie-parser": {
|
||||
"version": "1.4.7",
|
||||
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
|
||||
"integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie": "0.7.2",
|
||||
"cookie-signature": "1.0.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie-parser/node_modules/cookie": {
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
||||
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie-signature": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
|
||||
@ -316,6 +358,15 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/ecdsa-sig-formatter": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/ee-first": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||
@ -702,6 +753,97 @@
|
||||
"node": ">=0.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jsonwebtoken": {
|
||||
"version": "9.0.3",
|
||||
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
|
||||
"integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jws": "^4.0.1",
|
||||
"lodash.includes": "^4.3.0",
|
||||
"lodash.isboolean": "^3.0.3",
|
||||
"lodash.isinteger": "^4.0.4",
|
||||
"lodash.isnumber": "^3.0.3",
|
||||
"lodash.isplainobject": "^4.0.6",
|
||||
"lodash.isstring": "^4.0.1",
|
||||
"lodash.once": "^4.0.0",
|
||||
"ms": "^2.1.1",
|
||||
"semver": "^7.5.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12",
|
||||
"npm": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/jsonwebtoken/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/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/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/lodash.includes": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
|
||||
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isboolean": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
|
||||
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isinteger": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
|
||||
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isnumber": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
|
||||
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isplainobject": {
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
|
||||
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isstring": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
|
||||
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.once": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
|
||||
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
@ -925,6 +1067,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",
|
||||
@ -967,12 +1115,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-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-to-regexp": {
|
||||
"version": "0.1.12",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
|
||||
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
|
||||
"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/picomatch": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
@ -1097,7 +1308,6 @@
|
||||
"version": "7.7.2",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
|
||||
"integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
@ -1339,6 +1549,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",
|
||||
@ -1364,6 +1580,19 @@
|
||||
"node": ">= 0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "13.0.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
|
||||
"integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist-node/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/vary": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||
|
||||
33
package.json
33
package.json
@ -4,28 +4,33 @@
|
||||
"description": "API backend for Country Club Towers & Gardens apartment dashboard",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"dev": "nodemon server.js",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
"start": "node server.js",
|
||||
"dev": "nodemon server.js",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [
|
||||
"apartments",
|
||||
"api",
|
||||
"real-estate",
|
||||
"monitoring"
|
||||
"apartments",
|
||||
"api",
|
||||
"real-estate",
|
||||
"monitoring"
|
||||
],
|
||||
"author": "Stephen",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"express": "^4.18.2",
|
||||
"mongodb": "^6.3.0",
|
||||
"cors": "^2.8.5",
|
||||
"express-rate-limit": "^7.1.5"
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.18.2",
|
||||
"express-rate-limit": "^7.1.5",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"mongodb": "^6.3.0",
|
||||
"passport": "^0.7.0",
|
||||
"passport-google-oauth20": "^2.0.0",
|
||||
"uuid": "^13.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.2"
|
||||
"nodemon": "^3.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
116
routes/auth.js
Normal file
116
routes/auth.js
Normal file
@ -0,0 +1,116 @@
|
||||
const express = require('express');
|
||||
const passport = require('passport');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const authConfig = require('../config/auth');
|
||||
const { requireAuth, generateToken } = require('../middleware/auth');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
* GET /auth/google
|
||||
* Initiates Google OAuth flow
|
||||
*/
|
||||
router.get('/google', passport.authenticate('google', {
|
||||
scope: authConfig.google.scope,
|
||||
session: false
|
||||
}));
|
||||
|
||||
/**
|
||||
* GET /auth/google/callback
|
||||
* Handles OAuth callback from Google
|
||||
* On success: generates JWT, sets cookie, redirects to frontend
|
||||
* On failure: redirects to login with error
|
||||
*/
|
||||
router.get('/google/callback',
|
||||
passport.authenticate('google', {
|
||||
session: false,
|
||||
failureRedirect: `${process.env.FRONTEND_URL}/login?error=auth_failed`
|
||||
}),
|
||||
async (req, res) => {
|
||||
try {
|
||||
const user = req.user;
|
||||
|
||||
// Check if email is verified (if available in profile)
|
||||
if (req.authInfo && req.authInfo.emails && req.authInfo.emails[0]) {
|
||||
const emailVerified = req.authInfo.emails[0].verified !== false; // Default to true if not present
|
||||
if (!emailVerified) {
|
||||
return res.redirect(`${process.env.FRONTEND_URL}/login?error=unverified`);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if user account is active
|
||||
if (!user.isActive) {
|
||||
return res.redirect(`${process.env.FRONTEND_URL}/login`);
|
||||
}
|
||||
|
||||
// Generate JWT token
|
||||
const token = generateToken(user._id);
|
||||
|
||||
// Set auth cookie
|
||||
res.cookie(authConfig.cookie.name, token, authConfig.cookie.options);
|
||||
|
||||
// Redirect to frontend
|
||||
res.redirect(process.env.FRONTEND_URL);
|
||||
} catch (err) {
|
||||
console.error('OAuth callback error:', err);
|
||||
res.redirect(`${process.env.FRONTEND_URL}/login?error=auth_failed`);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /auth/me
|
||||
* Returns current authenticated user's data
|
||||
* Protected route - requires valid JWT
|
||||
*/
|
||||
router.get('/me', requireAuth, (req, res) => {
|
||||
res.json({
|
||||
user: {
|
||||
id: req.user._id,
|
||||
email: req.user.email,
|
||||
name: req.user.name,
|
||||
picture: req.user.picture,
|
||||
role: req.user.role
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /auth/logout
|
||||
* Clears authentication cookie
|
||||
* Protected route - requires valid JWT
|
||||
*/
|
||||
router.post('/logout', requireAuth, (req, res) => {
|
||||
// Clear the auth cookie
|
||||
res.clearCookie(authConfig.cookie.name, {
|
||||
...authConfig.cookie.options,
|
||||
maxAge: 0
|
||||
});
|
||||
|
||||
res.json({ message: 'Logged out successfully' });
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /auth/status
|
||||
* Returns authentication status without requiring middleware
|
||||
* Used for quick frontend checks
|
||||
*/
|
||||
router.get('/status', (req, res) => {
|
||||
const token = req.cookies[authConfig.cookie.name];
|
||||
|
||||
// No token present
|
||||
if (!token) {
|
||||
return res.json({ authenticated: false });
|
||||
}
|
||||
|
||||
try {
|
||||
// Verify the token
|
||||
jwt.verify(token, authConfig.jwt.secret);
|
||||
return res.json({ authenticated: true });
|
||||
} catch (err) {
|
||||
// Token invalid or expired
|
||||
return res.json({ authenticated: false });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
58
server.js
58
server.js
@ -2,6 +2,12 @@ const express = require('express');
|
||||
const { MongoClient } = require('mongodb');
|
||||
const cors = require('cors');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const passport = require('passport');
|
||||
const { configurePassport } = require('./middleware/passport');
|
||||
const { requireAuth } = require('./middleware/auth');
|
||||
const authRoutes = require('./routes/auth');
|
||||
const { createIndexes } = require('./models/user');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
@ -16,7 +22,17 @@ const DAILY_SUMMARIES_COLLECTION = "daily_summaries";
|
||||
console.log(`🔗 Connecting to MongoDB: ${MONGO_URI.includes('localhost') ? 'Local MongoDB' : 'Atlas MongoDB'}`);
|
||||
|
||||
// Middleware
|
||||
app.use(cors());
|
||||
const FRONTEND_URL = process.env.FRONTEND_URL || 'http://localhost:5173';
|
||||
|
||||
const corsOptions = {
|
||||
origin: FRONTEND_URL,
|
||||
credentials: true,
|
||||
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
||||
allowedHeaders: ['Content-Type', 'Authorization'],
|
||||
exposedHeaders: ['set-cookie']
|
||||
};
|
||||
app.use(cors(corsOptions));
|
||||
app.use(cookieParser());
|
||||
app.use(express.json());
|
||||
|
||||
// Rate limiting
|
||||
@ -26,6 +42,9 @@ const limiter = rateLimit({
|
||||
});
|
||||
app.use(limiter);
|
||||
|
||||
// Initialize Passport
|
||||
app.use(passport.initialize());
|
||||
|
||||
// MongoDB connection
|
||||
let db;
|
||||
let client;
|
||||
@ -35,7 +54,13 @@ async function connectToMongoDB() {
|
||||
client = new MongoClient(MONGO_URI);
|
||||
await client.connect();
|
||||
db = client.db(DB_NAME);
|
||||
app.locals.db = db;
|
||||
console.log('✅ Successfully connected to MongoDB');
|
||||
|
||||
// Configure Passport and create indexes after DB connection
|
||||
configurePassport(passport, db);
|
||||
await createIndexes(db);
|
||||
console.log('✅ Passport configured and indexes created');
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to connect to MongoDB:', error);
|
||||
process.exit(1);
|
||||
@ -52,6 +77,9 @@ const getYesterdayDate = () => {
|
||||
return yesterday.toISOString().split('T')[0];
|
||||
};
|
||||
|
||||
// Mount auth routes
|
||||
app.use('/auth', authRoutes);
|
||||
|
||||
// Health check endpoint
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({
|
||||
@ -62,7 +90,7 @@ app.get('/health', (req, res) => {
|
||||
});
|
||||
|
||||
// Get last scrape time
|
||||
app.get('/last-scrape', async (req, res) => {
|
||||
app.get('/last-scrape', requireAuth, async (req, res) => {
|
||||
try {
|
||||
// Get the most recent price record using _id (ObjectId contains timestamp)
|
||||
const lastRecord = await db.collection(PRICES_COLLECTION)
|
||||
@ -94,7 +122,7 @@ app.get('/last-scrape', async (req, res) => {
|
||||
});
|
||||
|
||||
// Get daily summary (matches your daily_summaries collection)
|
||||
app.get('/daily-summary', async (req, res) => {
|
||||
app.get('/daily-summary', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const today = getTodayDate();
|
||||
|
||||
@ -133,7 +161,7 @@ app.get('/daily-summary', async (req, res) => {
|
||||
});
|
||||
|
||||
// Get price history (last 15 days of average prices)
|
||||
app.get('/price-history', async (req, res) => {
|
||||
app.get('/price-history', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const days = parseInt(req.query.days) || 15;
|
||||
|
||||
@ -188,7 +216,7 @@ app.get('/price-history', async (req, res) => {
|
||||
});
|
||||
|
||||
// Get available units with current prices
|
||||
app.get('/available-units', async (req, res) => {
|
||||
app.get('/available-units', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const today = getTodayDate();
|
||||
|
||||
@ -336,7 +364,7 @@ app.get('/available-units', async (req, res) => {
|
||||
});
|
||||
|
||||
// Get full price history for a specific unit
|
||||
app.get('/unit/:unitCode/price-history', async (req, res) => {
|
||||
app.get('/unit/:unitCode/price-history', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const { unitCode } = req.params;
|
||||
|
||||
@ -363,7 +391,7 @@ app.get('/unit/:unitCode/price-history', async (req, res) => {
|
||||
});
|
||||
|
||||
// Get comprehensive analytics data
|
||||
app.get('/analytics', async (req, res) => {
|
||||
app.get('/analytics', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const today = getTodayDate();
|
||||
|
||||
@ -605,7 +633,7 @@ app.get('/analytics', async (req, res) => {
|
||||
});
|
||||
|
||||
// Get recent activity (new units, rented units, price changes)
|
||||
app.get('/recent-activity', async (req, res) => {
|
||||
app.get('/recent-activity', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const today = getTodayDate();
|
||||
const yesterday = getYesterdayDate();
|
||||
@ -734,7 +762,7 @@ app.get('/recent-activity', async (req, res) => {
|
||||
});
|
||||
|
||||
// Get plan statistics
|
||||
app.get('/plan-stats', async (req, res) => {
|
||||
app.get('/plan-stats', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const today = getTodayDate();
|
||||
|
||||
@ -797,7 +825,7 @@ app.get('/plan-stats', async (req, res) => {
|
||||
|
||||
// Expanded api
|
||||
// Get best deals (units priced below their historical average)
|
||||
app.get('/best-deals', async (req, res) => {
|
||||
app.get('/best-deals', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const today = getTodayDate();
|
||||
const limit = parseInt(req.query.limit) || 5;
|
||||
@ -922,7 +950,7 @@ app.get('/best-deals', async (req, res) => {
|
||||
});
|
||||
|
||||
// Get price drops (units with recent price decreases)
|
||||
app.get('/price-drops', async (req, res) => {
|
||||
app.get('/price-drops', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const today = getTodayDate();
|
||||
const daysBack = parseInt(req.query.days) || 7;
|
||||
@ -1019,7 +1047,7 @@ app.get('/price-drops', async (req, res) => {
|
||||
});
|
||||
|
||||
// Get stale inventory (units on market for a long time)
|
||||
app.get('/stale-inventory', async (req, res) => {
|
||||
app.get('/stale-inventory', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const today = getTodayDate();
|
||||
const minDays = parseInt(req.query.minDays) || 10;
|
||||
@ -1145,7 +1173,7 @@ app.get('/stale-inventory', async (req, res) => {
|
||||
});
|
||||
|
||||
// Get market insights and predictions
|
||||
app.get('/market-insights', async (req, res) => {
|
||||
app.get('/market-insights', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const today = getTodayDate();
|
||||
|
||||
@ -1355,7 +1383,7 @@ app.get('/market-insights', async (req, res) => {
|
||||
});
|
||||
|
||||
// Enhanced available units with more details
|
||||
app.get('/available-units-enhanced', async (req, res) => {
|
||||
app.get('/available-units-enhanced', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const today = getTodayDate();
|
||||
const yesterday = getYesterdayDate();
|
||||
@ -1541,7 +1569,7 @@ app.get('/available-units-enhanced', async (req, res) => {
|
||||
});
|
||||
|
||||
// Get unit details by unit code
|
||||
app.get('/unit/:unitCode', async (req, res) => {
|
||||
app.get('/unit/:unitCode', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const { unitCode } = req.params;
|
||||
const today = getTodayDate();
|
||||
|
||||
Reference in New Issue
Block a user