men-boilerplate 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +65 -0
- package/bin/bin.code-workspace +13 -0
- package/bin/cli.js +53 -0
- package/package.json +36 -0
- package/template/.env.example +7 -0
- package/template/_package.json +23 -0
- package/template/index.js +39 -0
- package/template/server.js +19 -0
- package/template/src/auth/authMiddleware.js +22 -0
- package/template/src/auth/jwtService.js +1 -0
- package/template/src/config/config.js +41 -0
- package/template/src/config/db.js +19 -0
- package/template/src/controllers/authControllers.js +151 -0
- package/template/src/crud/factory.js +79 -0
- package/template/src/crud/queryBuilder.js +79 -0
- package/template/src/middleware/errorHandler.js +60 -0
- package/template/src/middleware/logger.js +1 -0
- package/template/src/models/BaseUser.js +37 -0
- package/template/src/models/Session.js +36 -0
- package/template/src/routes/authRoutes.js +23 -0
- package/template/src/utils/ApiResponse.js +46 -0
- package/template/src/utils/asyncHandler.js +5 -0
package/README.md
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# MEN Auth Boilerplate (MongoDB, Express, Node.js)
|
|
2
|
+
|
|
3
|
+
A production-grade, ESM-ready authentication and session management boilerplate. Scaffolds a complete auth system in seconds.
|
|
4
|
+
|
|
5
|
+
## 🚀 Features
|
|
6
|
+
|
|
7
|
+
* **100% ES Modules (ESM)**: Modern, future-proof JavaScript.
|
|
8
|
+
* **JWT Authentication**: Secure Access & Refresh token rotation.
|
|
9
|
+
* **Session Management**: Track and manage active sessions (device, IP mapping).
|
|
10
|
+
* **Persistent Sessions**: Multi-device support with remote revocation (force logout).
|
|
11
|
+
* **Standard CRUD Factory**: Pre-built logic for MongoDB models.
|
|
12
|
+
* **Clean Architecture**: Separation of concerns across controllers, routes, models, and middleware.
|
|
13
|
+
|
|
14
|
+
## 🛠 Installation
|
|
15
|
+
|
|
16
|
+
Run the following command to scaffold a new project:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npx men-setup my-auth-app
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
*Note: Replace `men-boilerplate-setup` with the actual package name after publication.*
|
|
24
|
+
|
|
25
|
+
## 📂 Project Structure
|
|
26
|
+
|
|
27
|
+
```text
|
|
28
|
+
src/
|
|
29
|
+
├── auth/ # Middleware and JWT logic
|
|
30
|
+
├── config/ # DB and environment configuration
|
|
31
|
+
├── controllers/ # Request handlers (Auth, Sessions)
|
|
32
|
+
├── crud/ # Reusable CRUD factory
|
|
33
|
+
├── middleware/ # Global error and log handlers
|
|
34
|
+
├── models/ # Mongoose schemas (User, Session)
|
|
35
|
+
├── routes/ # Express routing
|
|
36
|
+
└── utils/ # API Response helpers
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## 🔐 Session Management Endpoints
|
|
40
|
+
|
|
41
|
+
| Method | Endpoint | Description |
|
|
42
|
+
| :--- | :--- | :--- |
|
|
43
|
+
| `POST` | `/api/v1/auth/sign-up` | Create a new user |
|
|
44
|
+
| `POST` | `/api/v1/auth/login` | Login and start a new session |
|
|
45
|
+
| `GET` | `/api/v1/auth/me` | Get current user profile (Protected) |
|
|
46
|
+
| `POST` | `/api/v1/auth/refresh` | Rotate tokens using active session |
|
|
47
|
+
| `GET` | `/api/v1/auth/sessions` | List all active sessions/devices |
|
|
48
|
+
| `DELETE` | `/api/v1/auth/sessions/:id` | Revoke a session (Remote logout) |
|
|
49
|
+
| `POST` | `/api/v1/auth/logout` | Clear current session and cookies |
|
|
50
|
+
|
|
51
|
+
## ⚙️ Environment Variables
|
|
52
|
+
|
|
53
|
+
Create a `.env` file in the root:
|
|
54
|
+
|
|
55
|
+
```env
|
|
56
|
+
PORT=5000
|
|
57
|
+
MONGO_URI=mongodb://localhost:27017/auth-db
|
|
58
|
+
JWT_SECRET=your_jwt_secret
|
|
59
|
+
REFRESH_SECRET=your_refresh_secret
|
|
60
|
+
NODE_ENV=development
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## 📝 License
|
|
64
|
+
|
|
65
|
+
ISC
|
package/bin/cli.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import { fileURLToPath } from 'url';
|
|
6
|
+
import { execSync } from 'child_process';
|
|
7
|
+
|
|
8
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
9
|
+
const __dirname = path.dirname(__filename);
|
|
10
|
+
|
|
11
|
+
// Ensure we are pointing to the RIGHT template folder inside YOUR package
|
|
12
|
+
const templateDir = path.resolve(__dirname, '../template');
|
|
13
|
+
const targetDir = process.cwd();
|
|
14
|
+
|
|
15
|
+
async function init() {
|
|
16
|
+
console.log('🛠️ Starting Scaffolding...');
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
// Check if template exists before starting
|
|
20
|
+
if (!fs.existsSync(templateDir)) {
|
|
21
|
+
throw new Error(`Template directory not found at: ${templateDir}`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// 1. Copy Files
|
|
25
|
+
console.log(`📂 Copying from ${templateDir} to ${targetDir}`);
|
|
26
|
+
fs.cpSync(templateDir, targetDir, { recursive: true });
|
|
27
|
+
|
|
28
|
+
// 2. Rename _package.json
|
|
29
|
+
const oldPkg = path.join(targetDir, '_package.json');
|
|
30
|
+
const newPkg = path.join(targetDir, 'package.json');
|
|
31
|
+
|
|
32
|
+
if (fs.existsSync(oldPkg)) {
|
|
33
|
+
fs.renameSync(oldPkg, newPkg);
|
|
34
|
+
console.log('✅ Created package.json');
|
|
35
|
+
} else {
|
|
36
|
+
console.warn('⚠️ Warning: _package.json missing from template!');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// 3. Install Dependencies
|
|
40
|
+
console.log('📦 Running npm install...');
|
|
41
|
+
// We use { stdio: 'inherit' } to see the actual NPM output in the console
|
|
42
|
+
execSync('npm install', { stdio: 'inherit', cwd: targetDir });
|
|
43
|
+
|
|
44
|
+
console.log('\n🚀 ALL DONE! Happy coding.');
|
|
45
|
+
|
|
46
|
+
} catch (err) {
|
|
47
|
+
console.error('\n❌ CRITICAL ERROR:');
|
|
48
|
+
console.error(err.message);
|
|
49
|
+
process.exit(1); // Force the process to show it failed
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
init();
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "men-boilerplate",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"main": "index.js",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"men-setup": "bin/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin",
|
|
11
|
+
"template"
|
|
12
|
+
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"test": "echo \"Error: no test specified\" && exit 1"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [],
|
|
17
|
+
"author": "",
|
|
18
|
+
"license": "ISC",
|
|
19
|
+
"description": "",
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"bcryptjs": "^3.0.3",
|
|
22
|
+
"cookie-parser": "^1.4.7",
|
|
23
|
+
"cors": "^2.8.6",
|
|
24
|
+
"dotenv": "^17.4.0",
|
|
25
|
+
"express": "^5.2.1",
|
|
26
|
+
"jsonwebtoken": "^9.0.3",
|
|
27
|
+
"mongoose": "^9.4.1",
|
|
28
|
+
"morgan": "^1.10.1",
|
|
29
|
+
"winston": "^3.19.0"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"jest": "^30.3.0",
|
|
33
|
+
"nodemon": "^3.1.14",
|
|
34
|
+
"supertest": "^7.2.2"
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "my-new-auth-app",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "server.js",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"start": "node server.js",
|
|
8
|
+
"dev": "nodemon server.js"
|
|
9
|
+
},
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"bcryptjs": "^2.4.3",
|
|
12
|
+
"cookie-parser": "^1.4.6",
|
|
13
|
+
"cors": "^2.8.5",
|
|
14
|
+
"dotenv": "^16.0.3",
|
|
15
|
+
"express": "^4.18.2",
|
|
16
|
+
"jsonwebtoken": "^9.0.1",
|
|
17
|
+
"mongoose": "^7.5.0",
|
|
18
|
+
"morgan": "^1.10.0"
|
|
19
|
+
},
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"nodemon": "^3.0.1"
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import cors from 'cors';
|
|
3
|
+
import morgan from 'morgan';
|
|
4
|
+
import cookieParser from 'cookie-parser';
|
|
5
|
+
|
|
6
|
+
import config from './src/config/config.js';
|
|
7
|
+
import connectDB from './src/config/db.js';
|
|
8
|
+
import authRoutes from './src/routes/authRoutes.js';
|
|
9
|
+
import globalErrorHandler from './src/middleware/errorHandler.js';
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
export const bootstrap = async (app, options = {}) => {
|
|
13
|
+
|
|
14
|
+
await connectDB();
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
app.use(express.json({ limit: '10kb' }));
|
|
18
|
+
app.use(cookieParser());
|
|
19
|
+
app.use(morgan(options.morganFormat || config.morganFormat));
|
|
20
|
+
|
|
21
|
+
app.use(cors({
|
|
22
|
+
origin: options.corsOrigin || config.corsOrigin,
|
|
23
|
+
credentials: true
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
app.use('/api/v1/auth', authRoutes);
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
app.use(globalErrorHandler);
|
|
32
|
+
|
|
33
|
+
console.log('🛡️ Auth Engine: System Bootstrapped Successfully.');
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
export { default as factory } from './src/crud/factory.js';
|
|
38
|
+
export { default as ApiResponse } from './src/utils/ApiResponse.js';
|
|
39
|
+
export { default as protect } from './src/auth/authMiddleware.js';
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import { bootstrap } from './index.js';
|
|
3
|
+
|
|
4
|
+
const app = express();
|
|
5
|
+
const port = process.env.PORT || 5000;
|
|
6
|
+
|
|
7
|
+
const startServer = async () => {
|
|
8
|
+
try {
|
|
9
|
+
await bootstrap(app);
|
|
10
|
+
app.listen(port, () => {
|
|
11
|
+
console.log(`🚀 Server running on http://localhost:${port}`);
|
|
12
|
+
});
|
|
13
|
+
} catch (error) {
|
|
14
|
+
console.error('❌ Failed to start server:', error.message);
|
|
15
|
+
process.exit(1);
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
startServer();
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import jwt from 'jsonwebtoken';
|
|
2
|
+
import ApiResponse from '../utils/ApiResponse.js';
|
|
3
|
+
import asyncHandler from '../utils/asyncHandler.js';
|
|
4
|
+
import User from '../models/BaseUser.js';
|
|
5
|
+
|
|
6
|
+
const protect = asyncHandler(async (req, res, next) => {
|
|
7
|
+
let token = req.cookies.accessToken;
|
|
8
|
+
|
|
9
|
+
if (!token) {
|
|
10
|
+
return ApiResponse.error(res, 'Not authorized, token missing', 401);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
try {
|
|
14
|
+
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
|
15
|
+
req.user = await User.findById(decoded.id);
|
|
16
|
+
next();
|
|
17
|
+
} catch (error) {
|
|
18
|
+
return ApiResponse.error(res, 'Token expired or invalid', 401);
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
export default protect;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
// JWT signing and Refresh Token logic
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import dotenv from 'dotenv';
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
dotenv.config();
|
|
5
|
+
|
|
6
|
+
const config = {
|
|
7
|
+
|
|
8
|
+
port: process.env.PORT || 5000,
|
|
9
|
+
nodeEnv: process.env.NODE_ENV || 'development',
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
mongoUri: process.env.MONGO_URI,
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
jwtSecret: process.env.JWT_SECRET || 'super-secret-key-change-me',
|
|
16
|
+
jwtExpire: process.env.JWT_EXPIRE || '15m',
|
|
17
|
+
|
|
18
|
+
refreshSecret: process.env.REFRESH_SECRET || 'even-more-secret-key-change-me',
|
|
19
|
+
refreshExpire: process.env.REFRESH_EXPIRE || '7d',
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
cookieExpire: process.env.COOKIE_EXPIRE || 7,
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
corsOrigin: process.env.CORS_ORIGIN || '*',
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
morganFormat: process.env.MORGAN_FORMAT || 'dev'
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
if (config.nodeEnv === 'production') {
|
|
33
|
+
if (!process.env.JWT_SECRET || !process.env.REFRESH_SECRET) {
|
|
34
|
+
console.warn('⚠️ WARNING: JWT_SECRET or REFRESH_SECRET is missing in production!');
|
|
35
|
+
}
|
|
36
|
+
if (!process.env.MONGO_URI) {
|
|
37
|
+
console.error('❌ ERROR: MONGO_URI is required in production!');
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export default config;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import mongoose from 'mongoose';
|
|
2
|
+
import config from './config.js';
|
|
3
|
+
|
|
4
|
+
const connectDB = async () => {
|
|
5
|
+
try {
|
|
6
|
+
const conn = await mongoose.connect(config.mongoUri);
|
|
7
|
+
|
|
8
|
+
console.log(`🍀 MongoDB Connected: ${conn.connection.host}`);
|
|
9
|
+
mongoose.connection.on('error', (err) => {
|
|
10
|
+
console.error(`❌ MongoDB Runtime Error: ${err}`);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
} catch (error) {
|
|
14
|
+
console.error(`❌ Error connecting to MongoDB: ${error.message}`);
|
|
15
|
+
process.exit(1);
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export default connectDB;
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import jwt from 'jsonwebtoken';
|
|
2
|
+
const { sign, verify } = jwt;
|
|
3
|
+
|
|
4
|
+
import User, { create, findOne, findById } from '../models/BaseUser.js';
|
|
5
|
+
import Session from '../models/Session.js';
|
|
6
|
+
import { success, error } from '../utils/ApiResponse.js';
|
|
7
|
+
import asyncHandler from '../utils/asyncHandler.js';
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
const sendTokenResponse = async (user, statusCode, res, req = {}) => {
|
|
13
|
+
const accessToken = sign({ id: user._id }, process.env.JWT_SECRET, {
|
|
14
|
+
expiresIn: process.env.JWT_EXPIRE || '15m'
|
|
15
|
+
});
|
|
16
|
+
const refreshToken = sign({ id: user._id }, process.env.REFRESH_SECRET, {
|
|
17
|
+
expiresIn: process.env.REFRESH_EXPIRE || '7d'
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
// Calculate expiration date for session
|
|
21
|
+
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days matching REFRESH_EXPIRE
|
|
22
|
+
|
|
23
|
+
// Create a Session record
|
|
24
|
+
await Session.create({
|
|
25
|
+
userId: user._id,
|
|
26
|
+
refreshToken,
|
|
27
|
+
device: req.get ? req.get('User-Agent') : 'Unknown',
|
|
28
|
+
ip: req.ip || 'Unknown',
|
|
29
|
+
expiresAt
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const cookieOptions = {
|
|
33
|
+
httpOnly: true,
|
|
34
|
+
secure: process.env.NODE_ENV === 'production',
|
|
35
|
+
sameSite: 'Lax',
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
res.cookie('accessToken', accessToken, {
|
|
39
|
+
...cookieOptions,
|
|
40
|
+
expires: new Date(Date.now() + 15 * 60 * 1000)
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
res.cookie('refreshToken', refreshToken, {
|
|
44
|
+
...cookieOptions,
|
|
45
|
+
expires: expiresAt
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
return success(res, "Authentication successful", {
|
|
49
|
+
id: user._id,
|
|
50
|
+
email: user.email
|
|
51
|
+
}, statusCode);
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
export const signUp = asyncHandler(async (req, res, next) => {
|
|
56
|
+
const user = await create(req.body);
|
|
57
|
+
await sendTokenResponse(user, 201, res, req);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
export const login = asyncHandler(async (req, res, next) => {
|
|
62
|
+
const { email, password } = req.body;
|
|
63
|
+
|
|
64
|
+
if (!email || !password) {
|
|
65
|
+
return error(res, "Please provide email and password", 400);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const user = await findOne({ email }).select('+password');
|
|
69
|
+
|
|
70
|
+
if (!user || !(await user.comparePassword(password, user.password))) {
|
|
71
|
+
return error(res, "Invalid credentials", 401);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
await sendTokenResponse(user, 200, res, req);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
export const refresh = asyncHandler(async (req, res, next) => {
|
|
79
|
+
const { refreshToken } = req.cookies;
|
|
80
|
+
|
|
81
|
+
if (!refreshToken) {
|
|
82
|
+
return error(res, "No refresh token provided", 401);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Find session record
|
|
86
|
+
const session = await Session.findOne({ refreshToken, isValid: true });
|
|
87
|
+
if (!session) {
|
|
88
|
+
return error(res, "Invalid or expired session", 401);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const decoded = verify(refreshToken, process.env.REFRESH_SECRET);
|
|
92
|
+
const user = await findById(decoded.id);
|
|
93
|
+
|
|
94
|
+
if (!user) {
|
|
95
|
+
return error(res, "User no longer exists", 401);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Delete old session and create new one
|
|
99
|
+
await session.deleteOne();
|
|
100
|
+
await sendTokenResponse(user, 200, res, req);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
export const getMe = asyncHandler(async (req, res, next) => {
|
|
105
|
+
|
|
106
|
+
return success(res, "User profile retrieved", req.user);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
export const logout = asyncHandler(async (req, res, next) => {
|
|
110
|
+
const { refreshToken } = req.cookies;
|
|
111
|
+
|
|
112
|
+
if (refreshToken) {
|
|
113
|
+
await Session.deleteOne({ refreshToken });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
res.cookie('accessToken', 'none', { expires: new Date(Date.now() + 10 * 1000), httpOnly: true });
|
|
117
|
+
res.cookie('refreshToken', 'none', { expires: new Date(Date.now() + 10 * 1000), httpOnly: true });
|
|
118
|
+
|
|
119
|
+
return success(res, "Logged out successfully");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
export const getSessions = asyncHandler(async (req, res, next) => {
|
|
124
|
+
const sessions = await Session.find({ userId: req.user._id, isValid: true })
|
|
125
|
+
.select('-refreshToken') // Don't expose token in listing
|
|
126
|
+
.sort('-createdAt');
|
|
127
|
+
|
|
128
|
+
return success(res, "Active sessions retrieved", sessions);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
export const revokeSession = asyncHandler(async (req, res, next) => {
|
|
132
|
+
const sessionId = req.params.id;
|
|
133
|
+
|
|
134
|
+
const session = await Session.findOne({ _id: sessionId, userId: req.user._id });
|
|
135
|
+
if (!session) {
|
|
136
|
+
return error(res, "Session not found", 404);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
await session.deleteOne();
|
|
140
|
+
return success(res, "Session revoked successfully");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
export function healthCheck(req, res) {
|
|
145
|
+
res.status(200).json({
|
|
146
|
+
status: "success",
|
|
147
|
+
message: "Server is healthy",
|
|
148
|
+
uptime: process.uptime(),
|
|
149
|
+
timestamp: new Date().toISOString()
|
|
150
|
+
});
|
|
151
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import APIFeatures from './queryBuilder.js';
|
|
2
|
+
import ApiResponse from '../utils/ApiResponse.js';
|
|
3
|
+
import asyncHandler from '../utils/asyncHandler.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Factory functions to handle standard CRUD operations
|
|
7
|
+
* @param {import('mongoose').Model} Model - The Mongoose model to use
|
|
8
|
+
*/
|
|
9
|
+
const factory = (Model) => ({
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
getAll: asyncHandler(async (req, res, next) => {
|
|
13
|
+
const features = new APIFeatures(Model.find(), req.query)
|
|
14
|
+
.filter()
|
|
15
|
+
.sort()
|
|
16
|
+
.limitFields()
|
|
17
|
+
.paginate();
|
|
18
|
+
|
|
19
|
+
const docs = await features.query;
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
const filterObj = new APIFeatures(Model.find(), req.query).filter().query.getFilter();
|
|
23
|
+
const total = await Model.countDocuments(filterObj);
|
|
24
|
+
|
|
25
|
+
const page = Number(req.query.page) || 1;
|
|
26
|
+
const limit = Number(req.query.limit) || 10;
|
|
27
|
+
|
|
28
|
+
return ApiResponse.success(res, "Resources retrieved successfully", docs, 200, {
|
|
29
|
+
total,
|
|
30
|
+
page,
|
|
31
|
+
limit,
|
|
32
|
+
totalPages: Math.ceil(total / limit)
|
|
33
|
+
});
|
|
34
|
+
}),
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
getOne: asyncHandler(async (req, res, next) => {
|
|
38
|
+
const doc = await Model.findById(req.params.id);
|
|
39
|
+
|
|
40
|
+
if (!doc) {
|
|
41
|
+
return ApiResponse.error(res, "No document found with that ID", 404);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return ApiResponse.success(res, "Resource retrieved successfully", doc);
|
|
45
|
+
}),
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
createOne: asyncHandler(async (req, res, next) => {
|
|
49
|
+
const doc = await Model.create(req.body);
|
|
50
|
+
return ApiResponse.success(res, "Resource created successfully", doc, 201);
|
|
51
|
+
}),
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
updateOne: asyncHandler(async (req, res, next) => {
|
|
55
|
+
const doc = await Model.findByIdAndUpdate(req.params.id, req.body, {
|
|
56
|
+
new: true,
|
|
57
|
+
runValidators: true
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
if (!doc) {
|
|
61
|
+
return ApiResponse.error(res, "No document found with that ID", 404);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return ApiResponse.success(res, "Resource updated successfully", doc);
|
|
65
|
+
}),
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
deleteOne: asyncHandler(async (req, res, next) => {
|
|
69
|
+
const doc = await Model.findByIdAndDelete(req.params.id);
|
|
70
|
+
|
|
71
|
+
if (!doc) {
|
|
72
|
+
return ApiResponse.error(res, "No document found with that ID", 404);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return ApiResponse.success(res, "Resource deleted successfully", null, 204);
|
|
76
|
+
})
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
export default factory;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
class QueryBuillder {
|
|
2
|
+
constructor(query, queryString) {
|
|
3
|
+
this.query = query
|
|
4
|
+
this.queryString = queryString
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
filter() {
|
|
8
|
+
const queryObj = { ...this.queryString }
|
|
9
|
+
|
|
10
|
+
const excludedFields = ["page", "sort", "limit", "fields", "search"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
let queryStr = queryObj.JSON.stringfy(queryObj)
|
|
14
|
+
excludedFields.forEach(el => delete queryObj[el])
|
|
15
|
+
|
|
16
|
+
queryStr.replace(/\b(gte|gt|lte|lt)\b/g,(match)=> `$${match}`)
|
|
17
|
+
|
|
18
|
+
this.query = this.query.find(JSON.parse(queryStr))
|
|
19
|
+
|
|
20
|
+
return this
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
search(fields = []) {
|
|
27
|
+
if (this.queryString.search) {
|
|
28
|
+
const keyWords = this.queryString.search
|
|
29
|
+
|
|
30
|
+
const searchQuery = {
|
|
31
|
+
$or: fields.map(field => ({[field]:{$regex:keyWords}}))
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
this.query = this.query.find(searchQuery)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return this
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
sort() {
|
|
43
|
+
if (this.queryString.sort) {
|
|
44
|
+
const sortQuery = this.queryString.sort.split(",").join(" ")
|
|
45
|
+
this.query = this.query.sort(sortQuery)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
else {
|
|
49
|
+
this.query = this.query.sort('-createdAt')
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return this
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
limitFields() {
|
|
56
|
+
if (this.queryString.fields) {
|
|
57
|
+
const fields = this.queryString.fields.split(",").join(" ");
|
|
58
|
+
this.query = this.query.select(fields);
|
|
59
|
+
} else {
|
|
60
|
+
this.query = this.query.select("-__v");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return this;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
paginate() {
|
|
67
|
+
const page = parseInt(this.queryString.page) || 1;
|
|
68
|
+
const limit = parseInt(this.queryString.limit) || 10;
|
|
69
|
+
const skip = (page - 1) * limit;
|
|
70
|
+
|
|
71
|
+
this.query = this.query.skip(skip).limit(limit);
|
|
72
|
+
return this;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export default QueryBuillder
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import ApiResponse from '../utils/ApiResponse.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Global Error Handling Middleware
|
|
5
|
+
* Catch-all for every error passed via next(err)
|
|
6
|
+
*/
|
|
7
|
+
const globalErrorHandler = (err, req, res, next) => {
|
|
8
|
+
|
|
9
|
+
err.statusCode = err.statusCode || 500;
|
|
10
|
+
err.status = err.status || 'error';
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
if (err.name === 'CastError') {
|
|
15
|
+
const message = `Invalid ${err.path}: ${err.value}.`;
|
|
16
|
+
return ApiResponse.error(res, message, 400);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
if (err.code === 11000) {
|
|
22
|
+
const field = Object.keys(err.keyValue)[0];
|
|
23
|
+
const value = Object.values(err.keyValue)[0];
|
|
24
|
+
const message = `Duplicate field value: "${value}". This ${field} is already in use.`;
|
|
25
|
+
return ApiResponse.error(res, message, 400);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
if (err.name === 'ValidationError') {
|
|
31
|
+
const errors = Object.values(err.errors).map(el => el.message);
|
|
32
|
+
const message = `Invalid input data: ${errors.join('. ')}`;
|
|
33
|
+
return ApiResponse.error(res, message, 400);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
if (err.name === 'JsonWebTokenError') {
|
|
38
|
+
return ApiResponse.error(res, 'Invalid token. Please log in again.', 401);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (err.name === 'TokenExpiredError') {
|
|
42
|
+
return ApiResponse.error(res, 'Your session has expired. Please refresh your token.', 401);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
const message = err.message || 'An unexpected error occurred on the server.';
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
const errorDetails = process.env.NODE_ENV === 'development' ? { stack: err.stack } : null;
|
|
51
|
+
|
|
52
|
+
return ApiResponse.error(
|
|
53
|
+
res,
|
|
54
|
+
message,
|
|
55
|
+
err.statusCode,
|
|
56
|
+
errorDetails
|
|
57
|
+
);
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export default globalErrorHandler;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
// Morgan and Winston configuration
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import mongoose from 'mongoose';
|
|
2
|
+
import bcrypt from 'bcryptjs';
|
|
3
|
+
|
|
4
|
+
const BaseUserSchema = new mongoose.Schema({
|
|
5
|
+
email: {
|
|
6
|
+
type: String,
|
|
7
|
+
required: [true, 'Email is required'],
|
|
8
|
+
unique: true,
|
|
9
|
+
lowercase: true,
|
|
10
|
+
},
|
|
11
|
+
password: {
|
|
12
|
+
type: String,
|
|
13
|
+
required: [true, 'Password is required'],
|
|
14
|
+
select: false,
|
|
15
|
+
}
|
|
16
|
+
}, { timestamps: true });
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
BaseUserSchema.pre('save', async function() {
|
|
21
|
+
if (!this.isModified('password')) return;
|
|
22
|
+
this.password = await bcrypt.hash(this.password, 12);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
BaseUserSchema.methods.comparePassword = async function(candidatePassword, userPassword) {
|
|
28
|
+
return await bcrypt.compare(candidatePassword, userPassword);
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const User = mongoose.model('User', BaseUserSchema);
|
|
32
|
+
|
|
33
|
+
export const create = (...args) => User.create(...args);
|
|
34
|
+
export const findOne = (...args) => User.findOne(...args);
|
|
35
|
+
export const findById = (...args) => User.findById(...args);
|
|
36
|
+
|
|
37
|
+
export default User;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import mongoose from 'mongoose';
|
|
2
|
+
|
|
3
|
+
const SessionSchema = new mongoose.Schema({
|
|
4
|
+
userId: {
|
|
5
|
+
type: mongoose.Schema.Types.ObjectId,
|
|
6
|
+
ref: 'User',
|
|
7
|
+
required: true,
|
|
8
|
+
},
|
|
9
|
+
refreshToken: {
|
|
10
|
+
type: String,
|
|
11
|
+
required: true,
|
|
12
|
+
unique: true,
|
|
13
|
+
},
|
|
14
|
+
device: {
|
|
15
|
+
type: String,
|
|
16
|
+
default: 'Unknown Device',
|
|
17
|
+
},
|
|
18
|
+
ip: {
|
|
19
|
+
type: String,
|
|
20
|
+
},
|
|
21
|
+
isValid: {
|
|
22
|
+
type: Boolean,
|
|
23
|
+
default: true,
|
|
24
|
+
},
|
|
25
|
+
expiresAt: {
|
|
26
|
+
type: Date,
|
|
27
|
+
required: true,
|
|
28
|
+
}
|
|
29
|
+
}, { timestamps: true });
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
SessionSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 });
|
|
33
|
+
|
|
34
|
+
const Session = mongoose.model('Session', SessionSchema);
|
|
35
|
+
|
|
36
|
+
export default Session;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import * as authController from '../controllers/authControllers.js';
|
|
3
|
+
import protect from '../auth/authMiddleware.js';
|
|
4
|
+
|
|
5
|
+
const router = express.Router();
|
|
6
|
+
|
|
7
|
+
// Public Routes
|
|
8
|
+
router.post('/sign-up', authController.signUp);
|
|
9
|
+
router.post('/login', authController.login);
|
|
10
|
+
router.post('/refresh', authController.refresh);
|
|
11
|
+
router.get('/health', authController.healthCheck);
|
|
12
|
+
|
|
13
|
+
// Protected Routes
|
|
14
|
+
router.use(protect); // Applies protection to all routes below this line
|
|
15
|
+
router.get('/me', authController.getMe);
|
|
16
|
+
router.post('/logout', authController.logout);
|
|
17
|
+
|
|
18
|
+
// Session Management
|
|
19
|
+
router.get('/sessions', authController.getSessions);
|
|
20
|
+
router.delete('/sessions/:id', authController.revokeSession);
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
export default router;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
class ApiResponse {
|
|
2
|
+
constructor({statusCode = 200, success = true, data = null, message = ""}) {
|
|
3
|
+
this.statusCode = statusCode,
|
|
4
|
+
this.success=success
|
|
5
|
+
this.data = data
|
|
6
|
+
this.message = message
|
|
7
|
+
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
send(res) {
|
|
11
|
+
return res.status(this.statusCode).json(
|
|
12
|
+
{
|
|
13
|
+
success: this.success,
|
|
14
|
+
message:this.message,
|
|
15
|
+
data:this.data,
|
|
16
|
+
}
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
static success(res, message = "Success", data = null, statusCode = 200, meta = null) {
|
|
22
|
+
const response = {
|
|
23
|
+
success: true,
|
|
24
|
+
message,
|
|
25
|
+
data,
|
|
26
|
+
...(meta && { meta })
|
|
27
|
+
};
|
|
28
|
+
return res.status(statusCode).json(response);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
static error(res, message = "Error", statusCode = 500, data = null) {
|
|
32
|
+
const response = {
|
|
33
|
+
success: false,
|
|
34
|
+
message,
|
|
35
|
+
...(data && { data })
|
|
36
|
+
};
|
|
37
|
+
return res.status(statusCode).json(response);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export default ApiResponse;
|
|
43
|
+
|
|
44
|
+
// Named exports for convenience
|
|
45
|
+
export const success = ApiResponse.success;
|
|
46
|
+
export const error = ApiResponse.error;
|