YeboLearn Middleware
Authentication, authorization, validation, and utility middleware.
Authentication Middleware
Location: backend/src/middleware/auth.middleware.ts
authenticate
Verifies JWT and attaches user to request.
export const authenticate = async (
req: AuthRequest,
res: Response,
next: NextFunction
) => {
try {
// Extract token from Authorization header
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
throw new UnauthorizedError('No token provided');
}
const token = authHeader.substring(7);
const payload = JwtUtil.verify(token);
// Verify token version (for invalidation)
const user = await database.query(
'SELECT * FROM users WHERE id = $1',
[payload.userId]
);
if (!user.rows[0]) {
throw new UnauthorizedError('User not found');
}
if (payload.tokenVersion !== user.rows[0].token_version) {
throw new UnauthorizedError('Token has been invalidated');
}
if (!user.rows[0].is_active) {
throw new UnauthorizedError('Account is inactive');
}
// Attach to request
req.user = {
id: user.rows[0].id,
schoolId: user.rows[0].school_id,
role: user.rows[0].role,
email: user.rows[0].email,
};
next();
} catch (error) {
next(error);
}
};Usage:
router.get('/students', authenticate, listStudents);Authorization Middleware
Location: backend/src/middleware/authorize.middleware.ts
authorize(...roles)
Role-based access control.
export const authorize = (...allowedRoles: UserRole[]) => {
return (req: AuthRequest, res: Response, next: NextFunction) => {
if (!req.user) {
throw new UnauthorizedError('Authentication required');
}
if (!allowedRoles.includes(req.user.role)) {
throw new ForbiddenError('Insufficient permissions');
}
next();
};
};Usage:
router.post(
'/students',
authenticate,
authorize('school_admin', 'teacher'),
createStudent
);schoolScope
Ensures user can only access their school's data.
export const schoolScope = (req: AuthRequest, res: Response, next: NextFunction) => {
// Super admins can access any school
if (req.user.role === 'super_admin') {
return next();
}
// Others scoped to their school
const requestedSchoolId = req.params.schoolId || req.body.school_id || req.query.school_id;
if (requestedSchoolId && requestedSchoolId !== req.user.schoolId) {
throw new ForbiddenError('Cannot access other school data');
}
next();
};Validation Middleware
Location: backend/src/middleware/validation.middleware.ts
validate(schema)
Request validation using Joi.
import Joi from 'joi';
export const validate = (schema: Joi.Schema, source: 'body' | 'query' | 'params' = 'body') => {
return (req: Request, res: Response, next: NextFunction) => {
const { error, value } = schema.validate(req[source], {
abortEarly: false,
stripUnknown: true,
});
if (error) {
const errors = error.details.map(d => ({
field: d.path.join('.'),
message: d.message,
}));
throw new ValidationError('Validation failed', errors);
}
req[source] = value; // Replace with sanitized values
next();
};
};Usage:
const createStudentSchema = Joi.object({
first_name: Joi.string().required().max(100),
last_name: Joi.string().required().max(100),
date_of_birth: Joi.date().optional(),
grade_level: Joi.string().required(),
});
router.post(
'/students',
authenticate,
validate(createStudentSchema),
createStudent
);Rate Limiting Middleware
Location: backend/src/middleware/rateLimit.middleware.ts
rateLimiter
Prevents abuse with configurable limits.
import rateLimit from 'express-rate-limit';
import RedisStore from 'rate-limit-redis';
import redisClient from '@utils/redis';
export const createRateLimiter = (options: {
windowMs: number;
max: number;
message?: string;
}) => {
return rateLimit({
store: new RedisStore({
sendCommand: (...args) => redisClient.sendCommand(args),
}),
windowMs: options.windowMs,
max: options.max,
message: {
success: false,
error: options.message || 'Too many requests',
},
standardHeaders: true,
legacyHeaders: false,
});
};
// Presets
export const authLimiter = createRateLimiter({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 10,
message: 'Too many auth attempts',
});
export const apiLimiter = createRateLimiter({
windowMs: 60 * 1000, // 1 minute
max: 100,
});
export const aiLimiter = createRateLimiter({
windowMs: 60 * 1000,
max: 10, // AI calls are expensive
message: 'AI rate limit exceeded',
});Error Handling Middleware
Location: backend/src/middleware/error.middleware.ts
errorHandler
Global error handling.
export const errorHandler = (
err: Error,
req: Request,
res: Response,
next: NextFunction
) => {
// Log error
logger.error({
message: err.message,
stack: err.stack,
path: req.path,
method: req.method,
userId: (req as AuthRequest).user?.id,
});
// Known error types
if (err instanceof AppError) {
return res.status(err.statusCode).json({
success: false,
error: {
code: err.code,
message: err.message,
details: err.details,
},
});
}
// Database errors
if (err.code === '23505') { // Unique violation
return res.status(409).json({
success: false,
error: {
code: 'DUPLICATE_ENTRY',
message: 'Resource already exists',
},
});
}
// Default server error
res.status(500).json({
success: false,
error: {
code: 'INTERNAL_ERROR',
message: config.nodeEnv === 'production'
? 'Internal server error'
: err.message,
},
});
};Request Logging Middleware
Location: backend/src/middleware/logging.middleware.ts
requestLogger
Logs all incoming requests.
import morgan from 'morgan';
// Custom format
morgan.token('user-id', (req: AuthRequest) => req.user?.id || 'anonymous');
morgan.token('school-id', (req: AuthRequest) => req.user?.schoolId || 'none');
export const requestLogger = morgan(
':method :url :status :response-time ms - user::user-id school::school-id',
{
stream: {
write: (message) => logger.http(message.trim()),
},
skip: (req) => req.path === '/api/health',
}
);File Upload Middleware
Location: backend/src/middleware/upload.middleware.ts
uploadMiddleware
Handles multipart file uploads.
import multer from 'multer';
import { S3Client } from '@aws-sdk/client-s3';
import multerS3 from 'multer-s3';
const s3 = new S3Client({
region: 'auto',
endpoint: config.r2.endpoint,
credentials: {
accessKeyId: config.r2.accessKeyId,
secretAccessKey: config.r2.secretAccessKey,
},
});
export const uploadMiddleware = multer({
storage: multerS3({
s3,
bucket: config.r2.bucket,
key: (req, file, cb) => {
const userId = (req as AuthRequest).user?.id;
const ext = path.extname(file.originalname);
const key = `uploads/${userId}/${Date.now()}${ext}`;
cb(null, key);
},
contentType: multerS3.AUTO_CONTENT_TYPE,
}),
limits: {
fileSize: 10 * 1024 * 1024, // 10MB
},
fileFilter: (req, file, cb) => {
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'application/pdf'];
if (allowedTypes.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new ValidationError('Invalid file type'));
}
},
});
// Usage:
// router.post('/upload', authenticate, uploadMiddleware.single('file'), uploadController);CORS Middleware
Location: backend/src/middleware/cors.middleware.ts
import cors from 'cors';
const allowedOrigins = [
'https://student.yebolearn.com',
'https://teacher.yebolearn.com',
'https://parent.yebolearn.com',
'https://admin.yebolearn.com',
'https://kids.yebolearn.com',
];
export const corsMiddleware = cors({
origin: (origin, callback) => {
if (!origin || allowedOrigins.includes(origin) || config.nodeEnv === 'development') {
callback(null, true);
} else {
callback(new Error('Not allowed by CORS'));
}
},
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'],
allowedHeaders: ['Content-Type', 'Authorization'],
});Middleware Stack Order
// app.ts
app.use(helmet()); // Security headers
app.use(corsMiddleware); // CORS
app.use(express.json()); // Body parser
app.use(requestLogger); // Request logging
// Rate limiting on sensitive routes
app.use('/api/auth', authLimiter);
app.use('/api/ai', authenticate, aiLimiter);
// API routes
app.use('/api', routes);
// Error handler (must be last)
app.use(errorHandler);