Add Google OAuth authentication system (#4)
Some checks failed
Deploy Apartment API / deploy (push) Failing after 1s

Co-authored-by: Stephen Minakian <stephenminakian@gmail.com>
Co-committed-by: Stephen Minakian <stephenminakian@gmail.com>
This commit is contained in:
2026-01-21 19:15:22 -07:00
committed by stephen
parent 04a78a21cc
commit ef4ddc0de0
13 changed files with 1247 additions and 59 deletions

179
routes/activity.js Normal file
View File

@ -0,0 +1,179 @@
const express = require('express');
const { ObjectId } = require('mongodb');
const { requireAuth, requireAdmin } = require('../middleware/auth');
const { logActivity, ACTIONS, ACTIVITY_COLLECTION } = require('../services/activityLogger');
const router = express.Router();
/**
* POST /log - Log frontend activity
* Protected with requireAuth
*/
router.post('/log', requireAuth, async (req, res) => {
try {
const { action, metadata } = req.body;
// Validate action is in ACTIONS object
if (!action || !Object.values(ACTIONS).includes(action)) {
return res.status(400).json({ error: 'Invalid action type' });
}
const db = req.app.locals.db;
// Log the activity with merged metadata
await logActivity(
db,
req.user._id,
action,
{ ...metadata, page: metadata?.page },
req
);
res.json({ success: true });
} catch (error) {
console.error('Error logging activity:', error);
res.status(500).json({ error: 'Failed to log activity' });
}
});
/**
* GET /user/:userId - Get activity for specific user
* Protected with requireAuth and requireAdmin
*/
router.get('/user/:userId', requireAuth, requireAdmin, async (req, res) => {
try {
const { userId } = req.params;
const limit = parseInt(req.query.limit) || 50;
const skip = parseInt(req.query.skip) || 0;
const db = req.app.locals.db;
// Convert userId to ObjectId
let userObjectId;
try {
userObjectId = new ObjectId(userId);
} catch (error) {
return res.status(400).json({ error: 'Invalid user ID format' });
}
// Find activities for the user
const activities = await db
.collection(ACTIVITY_COLLECTION)
.find({ userId: userObjectId })
.sort({ timestamp: -1 })
.skip(skip)
.limit(limit)
.toArray();
// Get total count
const total = await db
.collection(ACTIVITY_COLLECTION)
.countDocuments({ userId: userObjectId });
res.json({ activities, total });
} catch (error) {
console.error('Error fetching user activity:', error);
res.status(500).json({ error: 'Failed to fetch user activity' });
}
});
/**
* GET /recent - Get recent activity across all users
* Protected with requireAuth and requireAdmin
*/
router.get('/recent', requireAuth, requireAdmin, async (req, res) => {
try {
const limit = parseInt(req.query.limit) || 50;
const skip = parseInt(req.query.skip) || 0;
const db = req.app.locals.db;
// Find all activities sorted by timestamp
const activities = await db
.collection(ACTIVITY_COLLECTION)
.find({})
.sort({ timestamp: -1 })
.skip(skip)
.limit(limit)
.toArray();
// Get total count
const total = await db.collection(ACTIVITY_COLLECTION).countDocuments({});
res.json({ activities, total });
} catch (error) {
console.error('Error fetching recent activity:', error);
res.status(500).json({ error: 'Failed to fetch recent activity' });
}
});
/**
* GET /stats - Get activity statistics
* Protected with requireAuth and requireAdmin
*/
router.get('/stats', requireAuth, requireAdmin, async (req, res) => {
try {
const db = req.app.locals.db;
// Calculate date 7 days ago
const sevenDaysAgo = new Date();
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
// Aggregate activity counts by action type in last 7 days
const actionCountsResult = await db
.collection(ACTIVITY_COLLECTION)
.aggregate([
{
$match: {
timestamp: { $gte: sevenDaysAgo }
}
},
{
$group: {
_id: '$action',
count: { $sum: 1 }
}
}
])
.toArray();
// Convert array to object
const actionCounts = {};
actionCountsResult.forEach(item => {
actionCounts[item._id] = item.count;
});
// Count unique active users in last 7 days
const activeUsersResult = await db
.collection(ACTIVITY_COLLECTION)
.aggregate([
{
$match: {
timestamp: { $gte: sevenDaysAgo }
}
},
{
$group: {
_id: '$userId'
}
},
{
$count: 'total'
}
])
.toArray();
const activeUsers = activeUsersResult.length > 0 ? activeUsersResult[0].total : 0;
res.json({
actionCounts,
activeUsers,
period: '7d'
});
} catch (error) {
console.error('Error fetching activity stats:', error);
res.status(500).json({ error: 'Failed to fetch activity statistics' });
}
});
module.exports = router;

154
routes/auth.js Normal file
View File

@ -0,0 +1,154 @@
const express = require('express');
const passport = require('passport');
const jwt = require('jsonwebtoken');
const crypto = require('crypto');
const authConfig = require('../config/auth');
const { requireAuth, generateToken } = require('../middleware/auth');
const { logActivity, ACTIONS } = require('../services/activityLogger');
const router = express.Router();
/**
* GET /auth/google
* Initiates Google OAuth flow with state parameter for CSRF protection
*/
router.get('/google', (req, res, next) => {
// Generate cryptographically secure state parameter
const state = crypto.randomBytes(32).toString('hex');
// Store state in a short-lived cookie for validation
res.cookie('oauth_state', state, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 5 * 60 * 1000 // 5 minutes
});
passport.authenticate('google', {
scope: authConfig.google.scope,
session: false,
state: state
})(req, res, next);
});
/**
* GET /auth/google/callback
* Handles OAuth callback from Google
* Validates state parameter for CSRF protection
* On success: generates JWT, sets cookie, redirects to frontend
* On failure: redirects to login with error
*/
router.get('/google/callback', (req, res, next) => {
// Validate state parameter to prevent CSRF
const stateFromCookie = req.cookies.oauth_state;
const stateFromQuery = req.query.state;
// Clear the state cookie immediately
res.clearCookie('oauth_state');
if (!stateFromCookie || !stateFromQuery || stateFromCookie !== stateFromQuery) {
console.warn('OAuth state mismatch - potential CSRF attack');
return res.redirect(`${process.env.FRONTEND_URL}/login?error=invalid_state`);
}
// State is valid, proceed with authentication
passport.authenticate('google', {
session: false,
failureRedirect: `${process.env.FRONTEND_URL}/login?error=auth_failed`
})(req, res, next);
}, 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);
// Log login activity
const db = req.app.locals.db;
await logActivity(db, user._id, ACTIONS.LOGIN, { method: 'google' }, req);
// 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, async (req, res) => {
// Log logout activity before clearing cookie
const db = req.app.locals.db;
await logActivity(db, req.user._id, ACTIONS.LOGOUT, {}, req);
// 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;