diff --git a/.env.sample b/.env.sample index 81e198c..2feb727 100644 --- a/.env.sample +++ b/.env.sample @@ -1,11 +1,22 @@ -PORT=3000 +PORT=80 DOMAIN="http://localhost:3000" DATABASE_HOST="127.0.0.1" DATABASE_NAME=hsp_gdh DATABASE_USER=hsp_gdh DATABASE_PASSWORD="" JWT_SECRET="" -SMTP= -MAIL= -MAIL_PASS= +MAIL_SERVER= +MAIL_ADDRESS= +MAIL_PASSWORD= +BEHIND_PROXY=false DISABLE_EMAIL_VERIFICATION=true +DISABLE_2_FACTOR_AUTHENTICATION=true +DISABLE_ANTI_DOS=true +DISABLE_ANTI_SPAM=true +RATE_LIMIT_REQUESTS=100 +RATE_LIMIT_LOGIN_ATTEMPTS=5 +RATE_LIMIT_VERIFICATION_REQUESTS=5 +ENABLE_HTTPS=false +HTTPS_PORT=443 +HTTPS_KEY=domain.key +HTTPS_CERT=domain.crt diff --git a/.gitignore b/.gitignore index 6769c0e..123b98f 100644 --- a/.gitignore +++ b/.gitignore @@ -136,4 +136,7 @@ logs/ *.log # token -tokens/ \ No newline at end of file +tokens/ + +# certs +certs/ \ No newline at end of file diff --git a/README.md b/README.md index e69de29..d8b8a90 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,38 @@ +# HSP-GDH (API) + +Short description of your project. + +## Table of Contents + +- [HSP-GDH (API)](#hsp-gdh-api) + - [Table of Contents](#table-of-contents) + - [Installation](#installation) + - [Usage](#usage) + +## Installation + +1. Install [bun](https://bun.sh/). +2. Clone the repository. +3. Install the dependencies by running the following command: + ```bash + bun install + ``` +4. Copy .env.sample as .env + ```bash + cp .env.sample .env + ``` +5. (Optional) Setup SSL/TLS certs + 1. Create Certificate + ```bash + openssl req -newkey rsa:2048 -nodes -keyout certs/domain.key -x509 -days 365 -out certs/domain.crt -subj "/C=FR/L=Paris/O=HSP-GDH/CN=localhost" + ``` + 2. Enable HTTPS in .env + + +## Usage + +1. Start the application by running the following command: + ```bash + bun index.js + ``` +2. Open your browser and navigate to `http(s)://localhost:{port}`. \ No newline at end of file diff --git a/bun.lockb b/bun.lockb index 0a0b0fd..6d0949c 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/database.sql b/database.sql index 117e1e4..e6ea7d2 100644 --- a/database.sql +++ b/database.sql @@ -67,7 +67,7 @@ CREATE TABLE user_roles ( CREATE TABLE verification_codes ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, user_id INT UNSIGNED NOT NULL, - verification_code VARCHAR(255), + verification_code VARCHAR(255) NOT NULL, type VARCHAR(32) NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), diff --git a/index.js b/index.js index f7faff3..b1ed281 100644 --- a/index.js +++ b/index.js @@ -1,12 +1,15 @@ import fs from 'fs'; import path from 'path'; import cors from 'cors'; +import https from 'https'; +import helmet from 'helmet'; import logger from 'morgan'; import express from 'express'; import cookieParser from 'cookie-parser'; -import { log } from './modules/logManager'; -import { speedLimiter, checkSystemLoad } from './modules/requestHandler'; +import { fileExist } from './modules/fileManager'; +import { log, error } from './modules/logManager'; +import { speedLimiter, requestLimiter, checkSystemLoad, respondWithStatus } from './modules/requestHandler'; import testRouter from './routes/test'; import usersRouter from './routes/users'; @@ -21,17 +24,23 @@ const logsDir = path.join(import.meta.dir, 'logs'); if (!fs.existsSync(logsDir)) fs.mkdirSync(logsDir); const app = express(); -app.set('trust proxy', 1); - +if (process.env.BEHIND_PROXY == 'true') app.set('trust proxy', ['loopback', 'linklocal', 'uniquelocal']); +app.use(cors({ origin: '*' })); app.use(express.json()); app.use(cookieParser()); -app.use(speedLimiter); + +// Security +app.use(helmet()); +app.disable('x-powered-by'); + +// Rate limiting and anti DoS +if (!process.env.DISABLE_ANTI_SPAM == 'true') app.use(speedLimiter); +if (!process.env.DISABLE_ANTI_DOS == 'true') app.use(requestLimiter); app.use(checkSystemLoad); + +// Logging app.use(logger('dev')); app.use(logger('combined', { stream: fs.createWriteStream(path.join(import.meta.dir, 'logs/access.log'), { flags: 'a' }) })); -app.use(cors({ - origin: '*', -})); app.use(express.static('public')); @@ -44,11 +53,43 @@ app.use('/api/patients', patientsRouter); app.use('/api/companies', companiesRouter); app.use('/api/hospitals', hospitalsRouter); -// run the API -app.listen(process.env.PORT, async () => { - log(`running at port ${process.env.PORT}`); + +app.use((req, res) => { + return respondWithStatus(res, 404, 'Nothing\'s here!'); }); -// test -// import { post } from './modules/fetcher'; -// post('http://127.0.0.1:1109/users/login', { 'usernameOrEmail':'foo', 'password':'bar' }).then(res => console.log(res)); \ No newline at end of file +app.use((err, req, res) => { + error(err.stack); + return respondWithStatus(res, 500, 'Internal server error'); +}); + +// run the API server +if (process.env.ENABLE_HTTPS == 'true') { + const certsDir = path.join(import.meta.dir, 'certs'); + if (!fs.existsSync(certsDir)) fs.mkdirSync(certsDir); + + if (!fileExist(path.join(certsDir, process.env.HTTPS_KEY)) || !fileExist(path.join(certsDir, process.env.HTTPS_CERT))) { + error('Missing HTTPS key or certificate'); + process.exit(1); + } + const options = { + key: fs.readFileSync(path.join(certsDir, process.env.HTTPS_KEY)), + cert: fs.readFileSync(path.join(certsDir, process.env.HTTPS_CERT)), + maxVersion: 'TLSv1.3', + minVersion: 'TLSv1.2', + ciphers: 'TLS_AES_256_GCM_SHA384:TLS_AES_128_GCM_SHA256', + ecdhCurve: 'P-521:P-384', + sigalgs: 'ecdsa_secp384r1_sha384', + honorCipherOrder: true, + }; + https.createServer(options, app).listen(process.env.HTTPS_PORT, async () => { + log('running in HTTPS mode'); + log(`running at port ${process.env.HTTPS_PORT}`); + }); +} +else { + app.listen(process.env.PORT, async () => { + log('running in HTTP mode'); + log(`running at port ${process.env.PORT}`); + }); +} \ No newline at end of file diff --git a/modules/mailHandler.js b/modules/mailHandler.js index 160455d..ddc1354 100644 --- a/modules/mailHandler.js +++ b/modules/mailHandler.js @@ -3,12 +3,12 @@ import { random } from './random'; import { log } from './logManager'; const transporter = nodemailer.createTransport({ - host: process.env.SMTP, + host: process.env.MAIL_SERVER, port: 465, secure: true, auth: { - user: `${process.env.MAIL}`, - pass: `${process.env.PASS}`, + user: `${process.env.MAIL_ADDRESS}`, + pass: `${process.env.PASSWORD}`, }, tls: { rejectUnauthorized: false }, }); diff --git a/modules/requestHandler.js b/modules/requestHandler.js index bdf33a3..17ae10b 100644 --- a/modules/requestHandler.js +++ b/modules/requestHandler.js @@ -6,7 +6,7 @@ import { log } from './logManager'; const requestLimiter = rateLimit({ windowMs: 60 * 1000, - max: 5, + max: process.env.RATE_LIMIT_REQUESTS || 100, standardHeaders: true, legacyHeaders: false, message: 'Too many requests from this IP, please try again later', @@ -18,6 +18,22 @@ const speedLimiter = slowDown({ delayMs: (hits) => hits * 100, }); +const antiBruteForce = rateLimit({ + windowMs: 60 * 60 * 1000, + max: process.env.RATE_LIMIT_LOGIN_ATTEMPTS || 5, + standardHeaders: true, + legacyHeaders: false, + message: 'Too many login attempts, please try again later', +}); + +const antiVerificationSpam = rateLimit({ + windowMs: 60 * 1000, + max: process.env.RATE_LIMIT_VERIFICATION_REQUESTS || 5, + standardHeaders: true, + legacyHeaders: false, + message: 'Too many verification requests, please try again later', +}); + function checkSystemLoad(req, res, next) { const load = os.loadavg()[0]; const cores = os.cpus().length; @@ -49,6 +65,8 @@ function respondWithStatusJSON(res, statusCode, JSON) { export { requestLimiter, + antiBruteForce, + antiVerificationSpam, speedLimiter, checkSystemLoad, respondWithStatus, diff --git a/package.json b/package.json index e6af035..61cbaea 100644 --- a/package.json +++ b/package.json @@ -17,9 +17,11 @@ "dependencies": { "cookie-parser": "^1.4.6", "cors": "^2.8.5", - "express": "^4.18.2", + "express": "^4.19.2", "express-rate-limit": "^7.1.4", "express-slow-down": "^2.0.1", + "helmet": "^7.1.0", + "https": "^1.0.0", "jsonwebtoken": "^9.0.2", "level": "^8.0.1", "morgan": "^1.10.0", diff --git a/routes/users.js b/routes/users.js index c0a2dc1..40e4eab 100644 --- a/routes/users.js +++ b/routes/users.js @@ -4,12 +4,12 @@ import { pool } from '../modules/databaseManager'; import { sendVerification } from '../modules/mailHandler'; import { verifyToken, generateToken } from '../modules/tokenManager'; import { isEmailDomainValid, isValidEmail, isPhoneNumber } from '../modules/formatManager'; -import { requestLimiter, respondWithStatus, respondWithStatusJSON } from '../modules/requestHandler'; +import { antiBruteForce, antiVerificationSpam, respondWithStatus, respondWithStatusJSON } from '../modules/requestHandler'; import { checkBanned, checkPermissions, userExists, isBanned, verifyPermissions } from '../modules/permissionManager'; const router = express.Router(); -router.post('/register', requestLimiter, async (req, res) => { +router.post('/register', antiBruteForce, async (req, res) => { const { username, email, password, first_name, last_name, phone = null } = req.body; if ([ username, email, password, first_name, last_name ].every(Boolean)) { try { @@ -30,7 +30,7 @@ router.post('/register', requestLimiter, async (req, res) => { ); if (result.affectedRows === 0) return await respondWithStatus(res, 500, 'Error storing user'); - if (process.env.DISABLE_EMAIL_VERIFICATION) return await respondWithStatus(res, 200, 'Successfully registered'); + if (process.env.DISABLE_EMAIL_VERIFICATION == 'true') return await respondWithStatus(res, 200, 'Successfully registered'); const [rows] = await pool.execute('SELECT id FROM users WHERE email = ? LIMIT 1', [email]); const code = sendVerification(email, rows[0].id, 'email'); @@ -51,7 +51,7 @@ router.post('/register', requestLimiter, async (req, res) => { } }); -router.post('/login', requestLimiter, async (req, res) => { +router.post('/login', antiBruteForce, async (req, res) => { const { usernameOrEmail, password } = req.body; if ([usernameOrEmail, password].every(Boolean)) { try { @@ -76,7 +76,7 @@ router.post('/login', requestLimiter, async (req, res) => { email: user.email, first_name: user.first_name, last_name: user.last_name, - verified_status: process.env.DISABLE_EMAIL_VERIFICATION ? true : user.email_verified, + verified_status: process.env.DISABLE_EMAIL_VERIFICATION == 'true' ? true : user.email_verified, }, }); } @@ -124,7 +124,7 @@ router.post('/', verifyToken, checkBanned, checkPermissions('user', 2), async (r }); // Email verification endpoints -router.get('/email/request', verifyToken, checkBanned, async (req, res) => { +router.get('/email/request', antiVerificationSpam, verifyToken, checkBanned, async (req, res) => { const userId = req.userId; try { const [rows] = await pool.execute('SELECT * FROM users WHERE id = ? LIMIT 1', [userId]); @@ -141,7 +141,7 @@ router.get('/email/request', verifyToken, checkBanned, async (req, res) => { } }); -router.get('/email/verify', verifyToken, checkBanned, async (req, res) => { +router.get('/email/verify', antiVerificationSpam, verifyToken, checkBanned, async (req, res) => { const { code } = req.query; const userId = req.userId; if (code) { @@ -173,7 +173,7 @@ router.get('/email/verify', verifyToken, checkBanned, async (req, res) => { // PATCH /phone/verify // Password reset endpoints -router.post('/password/request', async (req, res) => { +router.post('/password/request', antiVerificationSpam, async (req, res) => { const { usernameOrEmail } = req.body; if (usernameOrEmail) { try { @@ -204,7 +204,7 @@ router.post('/password/request', async (req, res) => { } }); -router.patch('/password/verify', async (req, res) => { +router.patch('/password/verify', antiVerificationSpam, async (req, res) => { const { usernameOrEmail, password, code } = req.body; if ([usernameOrEmail, password, code].every(Boolean)) { try {