diff --git a/package-lock.json b/package-lock.json index 46473e5..a0b5e0a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "crypto": "^1.0.1", "dotenv": "^16.4.7", "express": "^4.21.2", + "joi": "^17.13.3", "jsonwebtoken": "^9.0.2", "nodemailer": "^6.10.0" }, @@ -1051,6 +1052,21 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@hapi/hoek": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", + "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/topo": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz", + "integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^9.0.0" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1200,6 +1216,27 @@ "@prisma/debug": "6.5.0" } }, + "node_modules/@sideway/address": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz", + "integrity": "sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^9.0.0" + } + }, + "node_modules/@sideway/formula": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.1.tgz", + "integrity": "sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==", + "license": "BSD-3-Clause" + }, + "node_modules/@sideway/pinpoint": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz", + "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==", + "license": "BSD-3-Clause" + }, "node_modules/@supabase/auth-js": { "version": "2.68.0", "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.68.0.tgz", @@ -3117,6 +3154,19 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/joi": { + "version": "17.13.3", + "resolved": "https://registry.npmjs.org/joi/-/joi-17.13.3.tgz", + "integrity": "sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^9.3.0", + "@hapi/topo": "^5.1.0", + "@sideway/address": "^4.1.5", + "@sideway/formula": "^3.0.1", + "@sideway/pinpoint": "^2.0.0" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", diff --git a/package.json b/package.json index 7aeccc8..74212b2 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "crypto": "^1.0.1", "dotenv": "^16.4.7", "express": "^4.21.2", + "joi": "^17.13.3", "jsonwebtoken": "^9.0.2", "nodemailer": "^6.10.0" }, diff --git a/prisma/migrations/20250327110352_fix_auth/migration.sql b/prisma/migrations/20250327110352_fix_auth/migration.sql new file mode 100644 index 0000000..32f7b62 --- /dev/null +++ b/prisma/migrations/20250327110352_fix_auth/migration.sql @@ -0,0 +1,12 @@ +/* + Warnings: + + - You are about to drop the column `name` on the `users` table. All the data in the column will be lost. + - Added the required column `firstName` to the `users` table without a default value. This is not possible if the table is not empty. + - Added the required column `lastName` to the `users` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "users" DROP COLUMN "name", +ADD COLUMN "firstName" VARCHAR(100) NOT NULL, +ADD COLUMN "lastName" VARCHAR(100) NOT NULL; diff --git a/prisma/schema/models/user.model.prisma b/prisma/schema/models/user.model.prisma index 1ed9c1a..35dc9f4 100644 --- a/prisma/schema/models/user.model.prisma +++ b/prisma/schema/models/user.model.prisma @@ -3,7 +3,8 @@ model User { email String @unique @db.VarChar(255) username String @unique @db.VarChar(50) password String @db.VarChar(255) - name String @db.VarChar(100) + firstName String @db.VarChar(100) + lastName String @db.VarChar(100) role UserRole profilePic String? departmentId String? @db.Uuid diff --git a/src/controllers/auth.controller.js b/src/controllers/auth.controller.js new file mode 100644 index 0000000..c75998d --- /dev/null +++ b/src/controllers/auth.controller.js @@ -0,0 +1,73 @@ +import * as dotenv from 'dotenv'; +dotenv.config(); + +import prisma from '../config/prismaClient.js'; +import { hashPassword } from '../utils/password.utils.js'; +import { generateOTP } from '../utils/otp.utils.js'; +import { sendEmail } from '../utils/email.utils.js'; +import { signupValidation } from '../validations/auth.validations.js'; + +/* eslint no-undef:off */ +// Authentication Controllers +export const signup = async (req, res) => { + try { + const { error } = signupValidation(req.body); + if (error) { + return res.status(400).json({ error: error.details[0].message }); + } + + const { email, password, firstName, lastName, username } = req.body; + + // Check if user already exists + const existingUser = await prisma.user.findFirst({ + where: { + email, + username, + }, + }); + + if (existingUser) { + return res.status(400).json({ + message: 'User with this email or username already exists', + }); + } + + // Hash password + const hashedPassword = await hashPassword(password); + + // Generate email verification OTP + const verificationOTP = generateOTP(); + // TODO: hash otp when you save it in the db + const otpExpiry = new Date(Date.now() + 10 * 60 * 1000); + + // Create user + const user = await prisma.user.create({ + data: { + email, + username, + firstName, + lastName, + password: hashedPassword, + role: 'MEMBER', // Default role + isActive: false, // Require email verification + emailVerificationToken: verificationOTP, + emailVerificationExpires: otpExpiry, + }, + }); + + // Send verification email + await sendEmail({ + to: email, + subject: 'Verify Your Email', + text: `Your verification code is: ${verificationOTP}`, + }); + + return res.status(201).json({ + message: 'User created. Please verify your email.', + userId: user.id, + user: user, + }); + } catch (error) { + return res.status(500).json({ message: `Signup failed: ${error.message}` }); + } +}; diff --git a/src/index.js b/src/index.js index 20cdf48..c942341 100644 --- a/src/index.js +++ b/src/index.js @@ -1,11 +1,17 @@ import express from 'express'; import * as dotenv from 'dotenv'; dotenv.config(); +import authRouter from './routes/auth.routes.js'; /* eslint no-undef: off */ const PORT = process.env.PORT; const app = express(); +app.use(express.json()); // for parsing application/json +app.use(express.urlencoded({ extended: true })); + +app.use(authRouter); + app.listen(PORT, () => { /* eslint no-console:off */ console.log( diff --git a/src/routes/auth.routes.js b/src/routes/auth.routes.js new file mode 100644 index 0000000..1c06722 --- /dev/null +++ b/src/routes/auth.routes.js @@ -0,0 +1,8 @@ +import { Router } from 'express'; +import { signup } from '../controllers/auth.controller.js'; + +const router = Router(); + +router.post('/api/auth/signup', signup); + +export default router; diff --git a/src/utils/email.utils.js b/src/utils/email.utils.js index a651672..b626ec7 100644 --- a/src/utils/email.utils.js +++ b/src/utils/email.utils.js @@ -6,12 +6,16 @@ const transporter = nodemailer.createTransport({ user: process.env.EMAIL_USER, pass: process.env.EMAIL_PASS, }, + pool: true, + maxConnections: 1, + rateDelta: 20000, // 20 seconds + rateLimit: 5, // max 5 emails per rateDelta }); /* eslint no-undef: off */ export const sendOTPEmail = async (email, otp) => { await transporter.sendMail({ - from: process.env.EMAIL_FROM, + from: process.env.EMAIL_USER, to: email, subject: 'Verify your Email', text: `Your OTP for email verification is: ${otp}. It will expire in 10 minutes.`, diff --git a/src/utils/token.utils.js b/src/utils/token.utils.js index 48e4203..9fdb35f 100644 --- a/src/utils/token.utils.js +++ b/src/utils/token.utils.js @@ -8,8 +8,8 @@ export const generateAccessToken = (user) => { email: user.email, role: user.role, }, - process.env.JWT_SECRET, - { expiresIn: '7d' }, + process.env.JWT_ACCESS_SECRET, + { expiresIn: '1h' }, ); }; @@ -18,7 +18,7 @@ export const generateRefreshToken = (user) => { { id: user.id, }, - process.env.JWT_SECRET, - { expiresIn: '30d' }, + process.env.JWT_REFRESH_SECRET, + { expiresIn: '7d' }, ); }; diff --git a/src/validations/auth.validations.js b/src/validations/auth.validations.js new file mode 100644 index 0000000..d67f9e0 --- /dev/null +++ b/src/validations/auth.validations.js @@ -0,0 +1,33 @@ +import Joi from 'joi'; + +export const signupValidation = (obj) => { + const schema = Joi.object({ + firstName: Joi.string().required().trim().min(3).max(30).messages({ + 'string.empty': 'First name is required', + 'string.min': 'First name must be at least 3 characters long.', + 'string.max': 'First name must be at most 30 characters long.', + }), + lastName: Joi.string().required().trim().messages({ + 'string.empty': 'Last name is required', + 'string.min': 'Last name must be at least 3 characters long.', + 'string.max': 'Last name must be at most 30 characters long.', + }), + username: Joi.string().trim().required().messages({ + 'string.alphanum': 'Username must contain only alphanumeric characters', + 'string.min': 'Username must be at least 3 characters long', + 'string.max': 'Username cannot be longer than 30 characters', + 'any.required': 'Username is required', + }), + email: Joi.string().email().required().trim().messages({ + 'string.empty': 'Email is required.', + 'string.email': 'Please enter a valid email address.', + }), + password: Joi.string().required().trim().min(8).max(32).messages({ + 'string.empty': 'Password is required.', + 'string.min': 'Password must be at least 8 characters long.', + 'string.max': 'Password must be at most 32 characters long.', + }), + }); + + return schema.validate(obj); +};