Add Google OAuth authentication system #4
179
routes/activity.js
Normal file
179
routes/activity.js
Normal 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;
|
||||||
@ -3,6 +3,7 @@ const passport = require('passport');
|
|||||||
const jwt = require('jsonwebtoken');
|
const jwt = require('jsonwebtoken');
|
||||||
const authConfig = require('../config/auth');
|
const authConfig = require('../config/auth');
|
||||||
const { requireAuth, generateToken } = require('../middleware/auth');
|
const { requireAuth, generateToken } = require('../middleware/auth');
|
||||||
|
const { logActivity, ACTIONS } = require('../services/activityLogger');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@ -49,6 +50,10 @@ router.get('/google/callback',
|
|||||||
// Set auth cookie
|
// Set auth cookie
|
||||||
res.cookie(authConfig.cookie.name, token, authConfig.cookie.options);
|
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
|
// Redirect to frontend
|
||||||
res.redirect(process.env.FRONTEND_URL);
|
res.redirect(process.env.FRONTEND_URL);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -80,7 +85,11 @@ router.get('/me', requireAuth, (req, res) => {
|
|||||||
* Clears authentication cookie
|
* Clears authentication cookie
|
||||||
* Protected route - requires valid JWT
|
* Protected route - requires valid JWT
|
||||||
*/
|
*/
|
||||||
router.post('/logout', requireAuth, (req, res) => {
|
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
|
// Clear the auth cookie
|
||||||
res.clearCookie(authConfig.cookie.name, {
|
res.clearCookie(authConfig.cookie.name, {
|
||||||
...authConfig.cookie.options,
|
...authConfig.cookie.options,
|
||||||
|
|||||||
@ -7,7 +7,9 @@ const passport = require('passport');
|
|||||||
const { configurePassport } = require('./middleware/passport');
|
const { configurePassport } = require('./middleware/passport');
|
||||||
const { requireAuth } = require('./middleware/auth');
|
const { requireAuth } = require('./middleware/auth');
|
||||||
const authRoutes = require('./routes/auth');
|
const authRoutes = require('./routes/auth');
|
||||||
|
const activityRoutes = require('./routes/activity');
|
||||||
const { createIndexes } = require('./models/user');
|
const { createIndexes } = require('./models/user');
|
||||||
|
const { createActivityIndexes } = require('./services/activityLogger');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3000;
|
const PORT = process.env.PORT || 3000;
|
||||||
@ -60,6 +62,7 @@ async function connectToMongoDB() {
|
|||||||
// Configure Passport and create indexes after DB connection
|
// Configure Passport and create indexes after DB connection
|
||||||
configurePassport(passport, db);
|
configurePassport(passport, db);
|
||||||
await createIndexes(db);
|
await createIndexes(db);
|
||||||
|
await createActivityIndexes(db);
|
||||||
console.log('✅ Passport configured and indexes created');
|
console.log('✅ Passport configured and indexes created');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Failed to connect to MongoDB:', error);
|
console.error('❌ Failed to connect to MongoDB:', error);
|
||||||
@ -80,6 +83,9 @@ const getYesterdayDate = () => {
|
|||||||
// Mount auth routes
|
// Mount auth routes
|
||||||
app.use('/auth', authRoutes);
|
app.use('/auth', authRoutes);
|
||||||
|
|
||||||
|
// Mount activity routes
|
||||||
|
app.use('/activity', activityRoutes);
|
||||||
|
|
||||||
// Health check endpoint
|
// Health check endpoint
|
||||||
app.get('/health', (req, res) => {
|
app.get('/health', (req, res) => {
|
||||||
res.json({
|
res.json({
|
||||||
|
|||||||
161
services/activityLogger.js
Normal file
161
services/activityLogger.js
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
const { ObjectId } = require('mongodb');
|
||||||
|
|
||||||
|
// Collection name
|
||||||
|
const ACTIVITY_COLLECTION = 'user_activity';
|
||||||
|
|
||||||
|
// Activity action constants
|
||||||
|
const ACTIONS = {
|
||||||
|
LOGIN: 'login',
|
||||||
|
LOGOUT: 'logout',
|
||||||
|
PAGE_VIEW: 'page_view',
|
||||||
|
NAVIGATION: 'navigation',
|
||||||
|
UNIT_VIEW: 'unit_view',
|
||||||
|
UNIT_WATCH: 'unit_watch',
|
||||||
|
FILTER_CHANGE: 'filter_change',
|
||||||
|
SORT_CHANGE: 'sort_change',
|
||||||
|
SEARCH: 'search',
|
||||||
|
EXPORT: 'export',
|
||||||
|
VIEW_TOGGLE: 'view_toggle'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Log levels define which actions should be logged
|
||||||
|
const LOG_LEVELS = {
|
||||||
|
all: [
|
||||||
|
'login',
|
||||||
|
'logout',
|
||||||
|
'page_view',
|
||||||
|
'navigation',
|
||||||
|
'filter_change',
|
||||||
|
'sort_change',
|
||||||
|
'search',
|
||||||
|
'unit_view',
|
||||||
|
'unit_watch',
|
||||||
|
'export',
|
||||||
|
'view_toggle'
|
||||||
|
],
|
||||||
|
navigation: [
|
||||||
|
'login',
|
||||||
|
'logout',
|
||||||
|
'page_view',
|
||||||
|
'navigation'
|
||||||
|
],
|
||||||
|
none: []
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current activity log level from environment variable
|
||||||
|
* @returns {string} Current log level ('all', 'navigation', or 'none')
|
||||||
|
*/
|
||||||
|
function getActivityLevel() {
|
||||||
|
const level = process.env.ACTIVITY_LOG_LEVEL || 'all';
|
||||||
|
|
||||||
|
// Validate level exists, default to 'all' if invalid
|
||||||
|
if (!LOG_LEVELS[level]) {
|
||||||
|
console.warn(`Invalid ACTIVITY_LOG_LEVEL: ${level}, defaulting to 'all'`);
|
||||||
|
return 'all';
|
||||||
|
}
|
||||||
|
|
||||||
|
return level;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an action should be logged based on current log level
|
||||||
|
* @param {string} action - Action to check
|
||||||
|
* @returns {boolean} True if action should be logged
|
||||||
|
*/
|
||||||
|
function shouldLog(action) {
|
||||||
|
const level = getActivityLevel();
|
||||||
|
const allowedActions = LOG_LEVELS[level];
|
||||||
|
return allowedActions.includes(action);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log a user activity to the database
|
||||||
|
* @param {Db} db - MongoDB database instance
|
||||||
|
* @param {string} userId - User ID (will be converted to ObjectId)
|
||||||
|
* @param {string} action - Action type (use ACTIONS constants)
|
||||||
|
* @param {Object} metadata - Additional action-specific data
|
||||||
|
* @param {Object} req - Express request object (for IP and user agent)
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async function logActivity(db, userId, action, metadata = {}, req = null) {
|
||||||
|
// Only log if this action type is enabled at current log level
|
||||||
|
if (!shouldLog(action)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Extract IP address (handle proxy forwarding)
|
||||||
|
let ip = null;
|
||||||
|
if (req) {
|
||||||
|
ip = req.ip || req.headers?.['x-forwarded-for']?.split(',')[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract user agent
|
||||||
|
const userAgent = req?.headers?.['user-agent'] || null;
|
||||||
|
|
||||||
|
// Create activity document
|
||||||
|
const activityDoc = {
|
||||||
|
userId: new ObjectId(userId),
|
||||||
|
action: action,
|
||||||
|
metadata: metadata || {},
|
||||||
|
page: metadata?.page || null,
|
||||||
|
timestamp: new Date(),
|
||||||
|
userAgent: userAgent,
|
||||||
|
ip: ip
|
||||||
|
};
|
||||||
|
|
||||||
|
// Insert into user_activity collection
|
||||||
|
await db.collection(ACTIVITY_COLLECTION).insertOne(activityDoc);
|
||||||
|
} catch (error) {
|
||||||
|
// Log error but don't throw - activity logging should not break the main flow
|
||||||
|
console.error('Error logging activity:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create required indexes for the user_activity collection
|
||||||
|
* @param {Db} db - MongoDB database instance
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async function createActivityIndexes(db) {
|
||||||
|
try {
|
||||||
|
const collection = db.collection(ACTIVITY_COLLECTION);
|
||||||
|
|
||||||
|
// Index for user activity queries (get all activities for a user, sorted by time)
|
||||||
|
await collection.createIndex(
|
||||||
|
{ userId: 1, timestamp: -1 },
|
||||||
|
{ name: 'userId_timestamp' }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Index for action type queries (get all activities of a certain type)
|
||||||
|
await collection.createIndex(
|
||||||
|
{ action: 1, timestamp: -1 },
|
||||||
|
{ name: 'action_timestamp' }
|
||||||
|
);
|
||||||
|
|
||||||
|
// TTL index to automatically delete activities older than 90 days
|
||||||
|
await collection.createIndex(
|
||||||
|
{ timestamp: 1 },
|
||||||
|
{
|
||||||
|
name: 'timestamp_ttl',
|
||||||
|
expireAfterSeconds: 7776000 // 90 days = 90 * 24 * 60 * 60
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('Activity collection indexes created successfully');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating activity indexes:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
ACTIVITY_COLLECTION,
|
||||||
|
ACTIONS,
|
||||||
|
LOG_LEVELS,
|
||||||
|
getActivityLevel,
|
||||||
|
shouldLog,
|
||||||
|
logActivity,
|
||||||
|
createActivityIndexes
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user