init Backend
This commit is contained in:
parent
08a4e0d8fa
commit
924000b084
29 changed files with 10080 additions and 13 deletions
109
Backend/src/app.js
Normal file
109
Backend/src/app.js
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
const express = require('express');
|
||||
const helmet = require('helmet');
|
||||
const cors = require('cors');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const swaggerUi = require('swagger-ui-express');
|
||||
const logger = require('./config/logger');
|
||||
const { errorHandler, notFoundHandler } = require('./middleware/errorHandler');
|
||||
const swaggerSpec = require('./config/swagger');
|
||||
|
||||
// Import routes
|
||||
const authRoutes = require('./routes/auth.routes');
|
||||
|
||||
const app = express();
|
||||
|
||||
// ============================================
|
||||
// Middleware
|
||||
// ============================================
|
||||
|
||||
// Security headers
|
||||
app.use(helmet());
|
||||
|
||||
// CORS
|
||||
const corsOptions = {
|
||||
origin: process.env.CORS_ORIGIN?.split(',') || ['http://localhost:3000'],
|
||||
credentials: true,
|
||||
};
|
||||
app.use(cors(corsOptions));
|
||||
|
||||
// Body parser
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
|
||||
|
||||
// Rate limiting
|
||||
const limiter = rateLimit({
|
||||
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS) || 15 * 60 * 1000,
|
||||
max: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS) || 100,
|
||||
message: {
|
||||
error: {
|
||||
code: 'RATE_LIMIT_EXCEEDED',
|
||||
message: 'Too many requests, please try again later',
|
||||
},
|
||||
},
|
||||
});
|
||||
app.use('/api/', limiter);
|
||||
|
||||
// Request logging
|
||||
app.use((req, res, next) => {
|
||||
logger.http(`${req.method} ${req.url}`);
|
||||
next();
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// Routes
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /health:
|
||||
* get:
|
||||
* summary: Health check endpoint
|
||||
* tags: [Health]
|
||||
* description: Returns the health status of the API server
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Server is healthy
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* status:
|
||||
* type: string
|
||||
* example: ok
|
||||
* timestamp:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* uptime:
|
||||
* type: number
|
||||
* example: 123.456
|
||||
* environment:
|
||||
* type: string
|
||||
* example: development
|
||||
*/
|
||||
app.get('/health', (req, res) => {
|
||||
res.status(200).json({
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: process.uptime(),
|
||||
environment: process.env.NODE_ENV,
|
||||
});
|
||||
});
|
||||
|
||||
// API Documentation
|
||||
const swaggerOptions = {
|
||||
customCss: '.swagger-ui .topbar { display: none }',
|
||||
customSiteTitle: 'E-Learning API Documentation',
|
||||
};
|
||||
app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec, swaggerOptions));
|
||||
|
||||
// API routes
|
||||
app.use('/api/auth', authRoutes);
|
||||
|
||||
// 404 handler
|
||||
app.use(notFoundHandler);
|
||||
|
||||
// Error handler (must be last)
|
||||
app.use(errorHandler);
|
||||
|
||||
module.exports = app;
|
||||
51
Backend/src/config/database.js
Normal file
51
Backend/src/config/database.js
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
const { PrismaClient } = require('@prisma/client');
|
||||
const logger = require('./logger');
|
||||
|
||||
const prisma = new PrismaClient({
|
||||
log: [
|
||||
{
|
||||
emit: 'event',
|
||||
level: 'query',
|
||||
},
|
||||
{
|
||||
emit: 'event',
|
||||
level: 'error',
|
||||
},
|
||||
{
|
||||
emit: 'event',
|
||||
level: 'info',
|
||||
},
|
||||
{
|
||||
emit: 'event',
|
||||
level: 'warn',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Log Prisma queries in development
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
prisma.$on('query', (e) => {
|
||||
logger.debug(`Query: ${e.query}`);
|
||||
logger.debug(`Duration: ${e.duration}ms`);
|
||||
});
|
||||
}
|
||||
|
||||
prisma.$on('error', (e) => {
|
||||
logger.error(`Prisma Error: ${e.message}`);
|
||||
});
|
||||
|
||||
prisma.$on('warn', (e) => {
|
||||
logger.warn(`Prisma Warning: ${e.message}`);
|
||||
});
|
||||
|
||||
// Test connection
|
||||
prisma.$connect()
|
||||
.then(() => {
|
||||
logger.info('✅ Database connected successfully');
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error('❌ Database connection failed:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
module.exports = prisma;
|
||||
45
Backend/src/config/logger.js
Normal file
45
Backend/src/config/logger.js
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
const winston = require('winston');
|
||||
|
||||
const logLevels = {
|
||||
error: 0,
|
||||
warn: 1,
|
||||
info: 2,
|
||||
http: 3,
|
||||
debug: 4,
|
||||
};
|
||||
|
||||
const logColors = {
|
||||
error: 'red',
|
||||
warn: 'yellow',
|
||||
info: 'green',
|
||||
http: 'magenta',
|
||||
debug: 'blue',
|
||||
};
|
||||
|
||||
winston.addColors(logColors);
|
||||
|
||||
const format = winston.format.combine(
|
||||
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
|
||||
winston.format.colorize({ all: true }),
|
||||
winston.format.printf(
|
||||
(info) => `${info.timestamp} ${info.level}: ${info.message}`
|
||||
)
|
||||
);
|
||||
|
||||
const transports = [
|
||||
new winston.transports.Console(),
|
||||
new winston.transports.File({
|
||||
filename: 'logs/error.log',
|
||||
level: 'error',
|
||||
}),
|
||||
new winston.transports.File({ filename: 'logs/all.log' }),
|
||||
];
|
||||
|
||||
const logger = winston.createLogger({
|
||||
level: process.env.LOG_LEVEL || 'info',
|
||||
levels: logLevels,
|
||||
format,
|
||||
transports,
|
||||
});
|
||||
|
||||
module.exports = logger;
|
||||
36
Backend/src/config/redis.js
Normal file
36
Backend/src/config/redis.js
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
const redis = require('redis');
|
||||
const logger = require('./logger');
|
||||
|
||||
let redisClient = null;
|
||||
|
||||
async function connectRedis() {
|
||||
try {
|
||||
redisClient = redis.createClient({
|
||||
url: process.env.REDIS_URL || 'redis://localhost:6379',
|
||||
});
|
||||
|
||||
redisClient.on('error', (err) => {
|
||||
logger.error('Redis Client Error:', err);
|
||||
});
|
||||
|
||||
redisClient.on('connect', () => {
|
||||
logger.info('✅ Redis connected successfully');
|
||||
});
|
||||
|
||||
await redisClient.connect();
|
||||
return redisClient;
|
||||
} catch (error) {
|
||||
logger.error('❌ Redis connection failed:', error);
|
||||
// Don't exit, allow app to run without Redis
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getRedisClient() {
|
||||
return redisClient;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
connectRedis,
|
||||
getRedisClient,
|
||||
};
|
||||
216
Backend/src/config/swagger.js
Normal file
216
Backend/src/config/swagger.js
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
const swaggerJsdoc = require('swagger-jsdoc');
|
||||
|
||||
const options = {
|
||||
definition: {
|
||||
openapi: '3.0.0',
|
||||
info: {
|
||||
title: 'E-Learning Platform API',
|
||||
version: '1.0.0',
|
||||
description: 'API documentation for E-Learning Platform Backend',
|
||||
contact: {
|
||||
name: 'API Support',
|
||||
email: 'support@elearning.local',
|
||||
},
|
||||
license: {
|
||||
name: 'MIT',
|
||||
url: 'https://opensource.org/licenses/MIT',
|
||||
},
|
||||
},
|
||||
servers: [
|
||||
{
|
||||
url: process.env.APP_URL || 'http://localhost:4000',
|
||||
description: 'Development server',
|
||||
},
|
||||
],
|
||||
components: {
|
||||
securitySchemes: {
|
||||
bearerAuth: {
|
||||
type: 'http',
|
||||
scheme: 'bearer',
|
||||
bearerFormat: 'JWT',
|
||||
description: 'Enter your JWT token',
|
||||
},
|
||||
},
|
||||
schemas: {
|
||||
Error: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
error: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
code: {
|
||||
type: 'string',
|
||||
example: 'VALIDATION_ERROR',
|
||||
},
|
||||
message: {
|
||||
type: 'string',
|
||||
example: 'Invalid input data',
|
||||
},
|
||||
details: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
field: {
|
||||
type: 'string',
|
||||
},
|
||||
message: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
User: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'integer',
|
||||
example: 1,
|
||||
},
|
||||
username: {
|
||||
type: 'string',
|
||||
example: 'john_doe',
|
||||
},
|
||||
email: {
|
||||
type: 'string',
|
||||
format: 'email',
|
||||
example: 'john@example.com',
|
||||
},
|
||||
role_id: {
|
||||
type: 'integer',
|
||||
example: 3,
|
||||
},
|
||||
is_active: {
|
||||
type: 'boolean',
|
||||
example: true,
|
||||
},
|
||||
created_at: {
|
||||
type: 'string',
|
||||
format: 'date-time',
|
||||
},
|
||||
updated_at: {
|
||||
type: 'string',
|
||||
format: 'date-time',
|
||||
},
|
||||
role: {
|
||||
$ref: '#/components/schemas/Role',
|
||||
},
|
||||
profile: {
|
||||
$ref: '#/components/schemas/Profile',
|
||||
},
|
||||
},
|
||||
},
|
||||
Role: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'integer',
|
||||
example: 3,
|
||||
},
|
||||
code: {
|
||||
type: 'string',
|
||||
example: 'STUDENT',
|
||||
},
|
||||
name: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
th: {
|
||||
type: 'string',
|
||||
example: 'นักเรียน',
|
||||
},
|
||||
en: {
|
||||
type: 'string',
|
||||
example: 'Student',
|
||||
},
|
||||
},
|
||||
},
|
||||
description: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
th: {
|
||||
type: 'string',
|
||||
},
|
||||
en: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Profile: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'integer',
|
||||
},
|
||||
user_id: {
|
||||
type: 'integer',
|
||||
},
|
||||
first_name: {
|
||||
type: 'string',
|
||||
example: 'John',
|
||||
},
|
||||
last_name: {
|
||||
type: 'string',
|
||||
example: 'Doe',
|
||||
},
|
||||
phone: {
|
||||
type: 'string',
|
||||
example: '+66812345678',
|
||||
},
|
||||
avatar_url: {
|
||||
type: 'string',
|
||||
format: 'uri',
|
||||
},
|
||||
bio: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
th: {
|
||||
type: 'string',
|
||||
},
|
||||
en: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
AuthResponse: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
user: {
|
||||
$ref: '#/components/schemas/User',
|
||||
},
|
||||
accessToken: {
|
||||
type: 'string',
|
||||
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
|
||||
},
|
||||
refreshToken: {
|
||||
type: 'string',
|
||||
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: [
|
||||
{
|
||||
name: 'Authentication',
|
||||
description: 'User authentication and authorization endpoints',
|
||||
},
|
||||
{
|
||||
name: 'Health',
|
||||
description: 'System health check endpoints',
|
||||
},
|
||||
],
|
||||
},
|
||||
apis: ['./src/routes/*.js', './src/app.js'],
|
||||
};
|
||||
|
||||
const swaggerSpec = swaggerJsdoc(options);
|
||||
|
||||
module.exports = swaggerSpec;
|
||||
82
Backend/src/controllers/auth.controller.js
Normal file
82
Backend/src/controllers/auth.controller.js
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
const authService = require('../services/auth.service');
|
||||
const logger = require('../config/logger');
|
||||
|
||||
class AuthController {
|
||||
/**
|
||||
* Register a new user
|
||||
* POST /api/auth/register
|
||||
*/
|
||||
async register(req, res, next) {
|
||||
try {
|
||||
const { username, email, password } = req.body;
|
||||
|
||||
const result = await authService.register({
|
||||
username,
|
||||
email,
|
||||
password,
|
||||
});
|
||||
|
||||
logger.info(`User registered: ${username}`);
|
||||
|
||||
res.status(201).json(result);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Login user
|
||||
* POST /api/auth/login
|
||||
*/
|
||||
async login(req, res, next) {
|
||||
try {
|
||||
const { username, email, password } = req.body;
|
||||
|
||||
const result = await authService.login({
|
||||
username,
|
||||
email,
|
||||
password,
|
||||
});
|
||||
|
||||
logger.info(`User logged in: ${result.user.username}`);
|
||||
|
||||
res.status(200).json(result);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current user profile
|
||||
* GET /api/auth/me
|
||||
*/
|
||||
async getProfile(req, res, next) {
|
||||
try {
|
||||
const user = await authService.getProfile(req.user.id);
|
||||
|
||||
res.status(200).json(user);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout user
|
||||
* POST /api/auth/logout
|
||||
*/
|
||||
async logout(req, res, next) {
|
||||
try {
|
||||
// In a real implementation, you would invalidate the token
|
||||
// For now, just return success
|
||||
logger.info(`User logged out: ${req.user.username}`);
|
||||
|
||||
res.status(200).json({
|
||||
message: 'Logged out successfully',
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new AuthController();
|
||||
91
Backend/src/middleware/auth.js
Normal file
91
Backend/src/middleware/auth.js
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
const { verifyToken, extractToken } = require('../utils/jwt');
|
||||
const prisma = require('../config/database');
|
||||
const logger = require('../config/logger');
|
||||
|
||||
/**
|
||||
* Authentication middleware
|
||||
* Verifies JWT token and attaches user to request
|
||||
*/
|
||||
async function authenticate(req, res, next) {
|
||||
try {
|
||||
const token = extractToken(req.headers.authorization);
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).json({
|
||||
error: {
|
||||
code: 'UNAUTHORIZED',
|
||||
message: 'Authentication required',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const decoded = verifyToken(token);
|
||||
|
||||
// Fetch user from database
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: decoded.userId },
|
||||
include: { role: true },
|
||||
});
|
||||
|
||||
if (!user || !user.is_active) {
|
||||
return res.status(401).json({
|
||||
error: {
|
||||
code: 'UNAUTHORIZED',
|
||||
message: 'Invalid or inactive user',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Attach user to request
|
||||
req.user = {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
};
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
logger.error('Authentication error:', error.message);
|
||||
return res.status(401).json({
|
||||
error: {
|
||||
code: 'UNAUTHORIZED',
|
||||
message: 'Invalid or expired token',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Authorization middleware
|
||||
* Checks if user has required role
|
||||
* @param {string[]} allowedRoles - Array of allowed role codes
|
||||
*/
|
||||
function authorize(...allowedRoles) {
|
||||
return (req, res, next) => {
|
||||
if (!req.user) {
|
||||
return res.status(401).json({
|
||||
error: {
|
||||
code: 'UNAUTHORIZED',
|
||||
message: 'Authentication required',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (!allowedRoles.includes(req.user.role.code)) {
|
||||
return res.status(403).json({
|
||||
error: {
|
||||
code: 'FORBIDDEN',
|
||||
message: 'Insufficient permissions',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
authenticate,
|
||||
authorize,
|
||||
};
|
||||
85
Backend/src/middleware/errorHandler.js
Normal file
85
Backend/src/middleware/errorHandler.js
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
const logger = require('../config/logger');
|
||||
|
||||
/**
|
||||
* Global error handler middleware
|
||||
*/
|
||||
function errorHandler(err, req, res, next) {
|
||||
logger.error('Error:', {
|
||||
message: err.message,
|
||||
stack: err.stack,
|
||||
url: req.url,
|
||||
method: req.method,
|
||||
});
|
||||
|
||||
// Prisma errors
|
||||
if (err.code && err.code.startsWith('P')) {
|
||||
if (err.code === 'P2002') {
|
||||
return res.status(409).json({
|
||||
error: {
|
||||
code: 'DUPLICATE_ENTRY',
|
||||
message: 'A record with this value already exists',
|
||||
field: err.meta?.target?.[0],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (err.code === 'P2025') {
|
||||
return res.status(404).json({
|
||||
error: {
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Record not found',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Validation errors
|
||||
if (err.isJoi || err.name === 'ValidationError') {
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
code: 'VALIDATION_ERROR',
|
||||
message: err.message,
|
||||
details: err.details?.map((d) => ({
|
||||
field: d.path.join('.'),
|
||||
message: d.message,
|
||||
})),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// JWT errors
|
||||
if (err.name === 'JsonWebTokenError' || err.name === 'TokenExpiredError') {
|
||||
return res.status(401).json({
|
||||
error: {
|
||||
code: 'UNAUTHORIZED',
|
||||
message: 'Invalid or expired token',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Default error
|
||||
const statusCode = err.statusCode || 500;
|
||||
res.status(statusCode).json({
|
||||
error: {
|
||||
code: err.code || 'INTERNAL_ERROR',
|
||||
message: err.message || 'An unexpected error occurred',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 404 Not Found handler
|
||||
*/
|
||||
function notFoundHandler(req, res) {
|
||||
res.status(404).json({
|
||||
error: {
|
||||
code: 'NOT_FOUND',
|
||||
message: `Route ${req.method} ${req.url} not found`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
errorHandler,
|
||||
notFoundHandler,
|
||||
};
|
||||
200
Backend/src/routes/auth.routes.js
Normal file
200
Backend/src/routes/auth.routes.js
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
const express = require('express');
|
||||
const authController = require('../controllers/auth.controller');
|
||||
const { authenticate } = require('../middleware/auth');
|
||||
const Joi = require('joi');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Validation schemas
|
||||
const registerSchema = Joi.object({
|
||||
username: Joi.string().min(3).max(50).required(),
|
||||
email: Joi.string().email().required(),
|
||||
password: Joi.string().min(6).required(),
|
||||
});
|
||||
|
||||
const loginSchema = Joi.object({
|
||||
username: Joi.string().optional(),
|
||||
email: Joi.string().email().optional(),
|
||||
password: Joi.string().required(),
|
||||
}).or('username', 'email');
|
||||
|
||||
// Validation middleware
|
||||
function validate(schema) {
|
||||
return (req, res, next) => {
|
||||
const { error, value } = schema.validate(req.body);
|
||||
if (error) {
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
code: 'VALIDATION_ERROR',
|
||||
message: error.details[0].message,
|
||||
details: error.details.map((d) => ({
|
||||
field: d.path.join('.'),
|
||||
message: d.message,
|
||||
})),
|
||||
},
|
||||
});
|
||||
}
|
||||
req.body = value;
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/auth/register:
|
||||
* post:
|
||||
* summary: Register a new user
|
||||
* tags: [Authentication]
|
||||
* description: Create a new user account
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - username
|
||||
* - email
|
||||
* - password
|
||||
* properties:
|
||||
* username:
|
||||
* type: string
|
||||
* minLength: 3
|
||||
* maxLength: 50
|
||||
* example: john_doe
|
||||
* email:
|
||||
* type: string
|
||||
* format: email
|
||||
* example: john@example.com
|
||||
* password:
|
||||
* type: string
|
||||
* minLength: 6
|
||||
* example: password123
|
||||
* responses:
|
||||
* 201:
|
||||
* description: User registered successfully
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/AuthResponse'
|
||||
* 400:
|
||||
* description: Validation error
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 409:
|
||||
* description: Username or email already exists
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
*/
|
||||
router.post('/register', validate(registerSchema), authController.register);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/auth/login:
|
||||
* post:
|
||||
* summary: Login user
|
||||
* tags: [Authentication]
|
||||
* description: Authenticate user and return JWT tokens
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - password
|
||||
* properties:
|
||||
* username:
|
||||
* type: string
|
||||
* example: admin
|
||||
* email:
|
||||
* type: string
|
||||
* format: email
|
||||
* example: admin@elearning.local
|
||||
* password:
|
||||
* type: string
|
||||
* example: admin123
|
||||
* oneOf:
|
||||
* - required: [username]
|
||||
* - required: [email]
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Login successful
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/AuthResponse'
|
||||
* 401:
|
||||
* description: Invalid credentials
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 403:
|
||||
* description: Account is inactive
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
*/
|
||||
router.post('/login', validate(loginSchema), authController.login);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/auth/me:
|
||||
* get:
|
||||
* summary: Get current user profile
|
||||
* tags: [Authentication]
|
||||
* description: Retrieve the authenticated user's profile information
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* responses:
|
||||
* 200:
|
||||
* description: User profile retrieved successfully
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/User'
|
||||
* 401:
|
||||
* description: Unauthorized - Invalid or missing token
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
*/
|
||||
router.get('/me', authenticate, authController.getProfile);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/auth/logout:
|
||||
* post:
|
||||
* summary: Logout user
|
||||
* tags: [Authentication]
|
||||
* description: Logout the authenticated user
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Logout successful
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* message:
|
||||
* type: string
|
||||
* example: Logged out successfully
|
||||
* 401:
|
||||
* description: Unauthorized - Invalid or missing token
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
*/
|
||||
router.post('/logout', authenticate, authController.logout);
|
||||
|
||||
module.exports = router;
|
||||
65
Backend/src/server.js
Normal file
65
Backend/src/server.js
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
require('dotenv').config();
|
||||
const app = require('./app');
|
||||
const logger = require('./config/logger');
|
||||
const { connectRedis } = require('./config/redis');
|
||||
|
||||
const PORT = process.env.PORT || 4000;
|
||||
|
||||
// Validate required environment variables
|
||||
const requiredEnvVars = ['DATABASE_URL', 'JWT_SECRET'];
|
||||
requiredEnvVars.forEach((key) => {
|
||||
if (!process.env[key]) {
|
||||
logger.error(`Missing required environment variable: ${key}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Start server
|
||||
async function startServer() {
|
||||
try {
|
||||
// Connect to Redis (optional)
|
||||
await connectRedis();
|
||||
|
||||
// Create logs directory
|
||||
const fs = require('fs');
|
||||
if (!fs.existsSync('logs')) {
|
||||
fs.mkdirSync('logs');
|
||||
}
|
||||
|
||||
// Start listening
|
||||
app.listen(PORT, () => {
|
||||
logger.info(`🚀 Server running on port ${PORT}`);
|
||||
logger.info(`📝 Environment: ${process.env.NODE_ENV || 'development'}`);
|
||||
logger.info(`🔗 Health check: http://localhost:${PORT}/health`);
|
||||
logger.info(`🔐 API endpoint: http://localhost:${PORT}/api`);
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to start server:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle uncaught exceptions
|
||||
process.on('uncaughtException', (error) => {
|
||||
logger.error('Uncaught Exception:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
// Handle unhandled promise rejections
|
||||
process.on('unhandledRejection', (reason, promise) => {
|
||||
logger.error('Unhandled Rejection at:', promise, 'reason:', reason);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
// Graceful shutdown
|
||||
process.on('SIGTERM', () => {
|
||||
logger.info('SIGTERM received, shutting down gracefully');
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
logger.info('SIGINT received, shutting down gracefully');
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
startServer();
|
||||
152
Backend/src/services/auth.service.js
Normal file
152
Backend/src/services/auth.service.js
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
const bcrypt = require('bcrypt');
|
||||
const prisma = require('../config/database');
|
||||
const { generateAccessToken, generateRefreshToken } = require('../utils/jwt');
|
||||
|
||||
class AuthService {
|
||||
/**
|
||||
* Register a new user
|
||||
* @param {Object} data - User registration data
|
||||
* @returns {Promise<Object>} User and tokens
|
||||
*/
|
||||
async register({ username, email, password, role_id = 3 }) {
|
||||
// Check if user exists
|
||||
const existingUser = await prisma.user.findFirst({
|
||||
where: {
|
||||
OR: [{ username }, { email }],
|
||||
},
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
const field = existingUser.username === username ? 'username' : 'email';
|
||||
throw Object.assign(new Error(`This ${field} is already taken`), {
|
||||
statusCode: 409,
|
||||
code: 'DUPLICATE_ENTRY',
|
||||
});
|
||||
}
|
||||
|
||||
// Hash password
|
||||
const hashedPassword = await bcrypt.hash(password, 10);
|
||||
|
||||
// Create user
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
username,
|
||||
email,
|
||||
password: hashedPassword,
|
||||
role_id,
|
||||
},
|
||||
include: {
|
||||
role: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Generate tokens
|
||||
const accessToken = generateAccessToken({
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
role: user.role.code,
|
||||
});
|
||||
|
||||
const refreshToken = generateRefreshToken({
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
// Remove password from response
|
||||
delete user.password;
|
||||
|
||||
return {
|
||||
user,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Login user
|
||||
* @param {Object} credentials - Login credentials
|
||||
* @returns {Promise<Object>} User and tokens
|
||||
*/
|
||||
async login({ username, email, password }) {
|
||||
// Find user
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
OR: [{ username }, { email }],
|
||||
},
|
||||
include: {
|
||||
role: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw Object.assign(new Error('Invalid credentials'), {
|
||||
statusCode: 401,
|
||||
code: 'INVALID_CREDENTIALS',
|
||||
});
|
||||
}
|
||||
|
||||
// Check if user is active
|
||||
if (!user.is_active) {
|
||||
throw Object.assign(new Error('Account is inactive'), {
|
||||
statusCode: 403,
|
||||
code: 'ACCOUNT_INACTIVE',
|
||||
});
|
||||
}
|
||||
|
||||
// Verify password
|
||||
const isPasswordValid = await bcrypt.compare(password, user.password);
|
||||
|
||||
if (!isPasswordValid) {
|
||||
throw Object.assign(new Error('Invalid credentials'), {
|
||||
statusCode: 401,
|
||||
code: 'INVALID_CREDENTIALS',
|
||||
});
|
||||
}
|
||||
|
||||
// Generate tokens
|
||||
const accessToken = generateAccessToken({
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
role: user.role.code,
|
||||
});
|
||||
|
||||
const refreshToken = generateRefreshToken({
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
// Remove password from response
|
||||
delete user.password;
|
||||
|
||||
return {
|
||||
user,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user profile
|
||||
* @param {number} userId - User ID
|
||||
* @returns {Promise<Object>} User profile
|
||||
*/
|
||||
async getProfile(userId) {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
include: {
|
||||
role: true,
|
||||
profile: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw Object.assign(new Error('User not found'), {
|
||||
statusCode: 404,
|
||||
code: 'NOT_FOUND',
|
||||
});
|
||||
}
|
||||
|
||||
delete user.password;
|
||||
return user;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new AuthService();
|
||||
57
Backend/src/utils/jwt.js
Normal file
57
Backend/src/utils/jwt.js
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
const jwt = require('jsonwebtoken');
|
||||
const logger = require('../config/logger');
|
||||
|
||||
/**
|
||||
* Generate JWT access token
|
||||
* @param {Object} payload - Token payload
|
||||
* @returns {string} JWT token
|
||||
*/
|
||||
function generateAccessToken(payload) {
|
||||
return jwt.sign(payload, process.env.JWT_SECRET, {
|
||||
expiresIn: process.env.JWT_EXPIRES_IN || '24h',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate JWT refresh token
|
||||
* @param {Object} payload - Token payload
|
||||
* @returns {string} JWT refresh token
|
||||
*/
|
||||
function generateRefreshToken(payload) {
|
||||
return jwt.sign(payload, process.env.JWT_SECRET, {
|
||||
expiresIn: process.env.JWT_REFRESH_EXPIRES_IN || '7d',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify JWT token
|
||||
* @param {string} token - JWT token
|
||||
* @returns {Object} Decoded token payload
|
||||
*/
|
||||
function verifyToken(token) {
|
||||
try {
|
||||
return jwt.verify(token, process.env.JWT_SECRET);
|
||||
} catch (error) {
|
||||
logger.error('Token verification failed:', error.message);
|
||||
throw new Error('Invalid token');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract token from Authorization header
|
||||
* @param {string} authHeader - Authorization header value
|
||||
* @returns {string|null} Token or null
|
||||
*/
|
||||
function extractToken(authHeader) {
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return null;
|
||||
}
|
||||
return authHeader.substring(7);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
generateAccessToken,
|
||||
generateRefreshToken,
|
||||
verifyToken,
|
||||
extractToken,
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue