diff --git a/.github/workflows/test.yml-template b/.github/workflows/test.yml-template new file mode 100644 index 00000000..bb13dfc4 --- /dev/null +++ b/.github/workflows/test.yml-template @@ -0,0 +1,23 @@ +name: Test + +on: + pull_request: + branches: [ master ] + +jobs: + build: + + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [20.x] + + steps: + - uses: actions/checkout@v2 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v1 + with: + node-version: ${{ matrix.node-version }} + - run: npm install + - run: npm test diff --git a/prisma.config.ts b/prisma.config.ts new file mode 100644 index 00000000..831a20fa --- /dev/null +++ b/prisma.config.ts @@ -0,0 +1,14 @@ +// This file was generated by Prisma, and assumes you have installed the following: +// npm install --save-dev prisma dotenv +import "dotenv/config"; +import { defineConfig } from "prisma/config"; + +export default defineConfig({ + schema: "prisma/schema.prisma", + migrations: { + path: "prisma/migrations", + }, + datasource: { + url: process.env["DATABASE_URL"], + }, +}); diff --git a/prisma/migrations/20260206215804_init/migration.sql b/prisma/migrations/20260206215804_init/migration.sql new file mode 100644 index 00000000..f552f2a2 --- /dev/null +++ b/prisma/migrations/20260206215804_init/migration.sql @@ -0,0 +1,38 @@ +-- CreateTable +CREATE TABLE "users_auth" ( + "id" SERIAL NOT NULL, + "name" VARCHAR(255) NOT NULL, + "email" VARCHAR(255) NOT NULL, + "password" VARCHAR(255) NOT NULL, + "activationToken" VARCHAR(255), + "createdAt" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMPTZ(6) NOT NULL, + + CONSTRAINT "users_auth_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "tokens_auth" ( + "id" SERIAL NOT NULL, + "refreshToken" VARCHAR(255) NOT NULL, + "createdAt" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMPTZ(6) NOT NULL, + "userId" INTEGER NOT NULL, + + CONSTRAINT "tokens_auth_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "users_auth_email_key" ON "users_auth"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "users_auth_activationToken_key" ON "users_auth"("activationToken"); + +-- CreateIndex +CREATE UNIQUE INDEX "tokens_auth_refreshToken_key" ON "tokens_auth"("refreshToken"); + +-- CreateIndex +CREATE UNIQUE INDEX "tokens_auth_userId_key" ON "tokens_auth"("userId"); + +-- AddForeignKey +ALTER TABLE "tokens_auth" ADD CONSTRAINT "tokens_auth_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users_auth"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/migrations/20260209120310_add_user_reset_token/migration.sql b/prisma/migrations/20260209120310_add_user_reset_token/migration.sql new file mode 100644 index 00000000..72735481 --- /dev/null +++ b/prisma/migrations/20260209120310_add_user_reset_token/migration.sql @@ -0,0 +1,11 @@ +/* + Warnings: + + - A unique constraint covering the columns `[resetToken]` on the table `users_auth` will be added. If there are existing duplicate values, this will fail. + +*/ +-- AlterTable +ALTER TABLE "users_auth" ADD COLUMN "resetToken" VARCHAR(255); + +-- CreateIndex +CREATE UNIQUE INDEX "users_auth_resetToken_key" ON "users_auth"("resetToken"); diff --git a/prisma/migrations/20260210160621_add_pending_email/migration.sql b/prisma/migrations/20260210160621_add_pending_email/migration.sql new file mode 100644 index 00000000..7420a1ff --- /dev/null +++ b/prisma/migrations/20260210160621_add_pending_email/migration.sql @@ -0,0 +1,11 @@ +/* + Warnings: + + - A unique constraint covering the columns `[pendingEmail]` on the table `users_auth` will be added. If there are existing duplicate values, this will fail. + +*/ +-- AlterTable +ALTER TABLE "users_auth" ADD COLUMN "pendingEmail" VARCHAR(255); + +-- CreateIndex +CREATE UNIQUE INDEX "users_auth_pendingEmail_key" ON "users_auth"("pendingEmail"); diff --git a/prisma/migrations/20260211144328_add_pending_email_token/migration.sql b/prisma/migrations/20260211144328_add_pending_email_token/migration.sql new file mode 100644 index 00000000..a52b0c64 --- /dev/null +++ b/prisma/migrations/20260211144328_add_pending_email_token/migration.sql @@ -0,0 +1,21 @@ +/* + Warnings: + + - You are about to drop the column `resetToken` on the `users_auth` table. All the data in the column will be lost. + - A unique constraint covering the columns `[passwordResetToken]` on the table `users_auth` will be added. If there are existing duplicate values, this will fail. + - A unique constraint covering the columns `[pendingEmailToken]` on the table `users_auth` will be added. If there are existing duplicate values, this will fail. + +*/ +-- DropIndex +DROP INDEX "users_auth_resetToken_key"; + +-- AlterTable +ALTER TABLE "users_auth" DROP COLUMN "resetToken", +ADD COLUMN "passwordResetToken" VARCHAR(255), +ADD COLUMN "pendingEmailToken" VARCHAR(255); + +-- CreateIndex +CREATE UNIQUE INDEX "users_auth_passwordResetToken_key" ON "users_auth"("passwordResetToken"); + +-- CreateIndex +CREATE UNIQUE INDEX "users_auth_pendingEmailToken_key" ON "users_auth"("pendingEmailToken"); diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 00000000..044d57cd --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "postgresql" diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 00000000..0190f31d --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,34 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" +} + +model User { + id Int @id @default(autoincrement()) + name String @db.VarChar(255) + email String @unique @db.VarChar(255) + password String @db.VarChar(255) + activationToken String? @unique @db.VarChar(255) + createdAt DateTime @default(now()) @db.Timestamptz(6) + updatedAt DateTime @updatedAt @db.Timestamptz(6) + passwordResetToken String? @unique @db.VarChar(255) + pendingEmail String? @unique @db.VarChar(255) + pendingEmailToken String? @unique @db.VarChar(255) + tokens Token[] + + @@map("users_auth") +} + +model Token { + id Int @id @default(autoincrement()) + refreshToken String @unique @db.VarChar(255) + createdAt DateTime @default(now()) @db.Timestamptz(6) + updatedAt DateTime @updatedAt @db.Timestamptz(6) + userId Int @unique + users User @relation(fields: [userId], references: [id]) + + @@map("tokens_auth") +} \ No newline at end of file diff --git a/src/controllers/auth.controller.js b/src/controllers/auth.controller.js new file mode 100644 index 00000000..5d37dd7a --- /dev/null +++ b/src/controllers/auth.controller.js @@ -0,0 +1,100 @@ +import { userService } from '../services/user.service.js'; +import { authService } from '../services/auth.service.js'; + +const register = async (req, res) => { + const { name, email, password } = req.body; + + await authService.register(name, email, password); + + res.send({ message: 'Registration successful' }); +}; + +const activate = async (req, res) => { + const { activationToken } = req.params; + + const activatedUser = await authService.activate(activationToken); + + res.send(userService.normalize(activatedUser)); +}; + +const sendAuthentication = async (res, user) => { + const { + user: normalizedUser, + accessToken, + refreshToken, + } = await authService.authenticate(user); + + res.cookie('refreshToken', refreshToken, { + maxAge: 30 * 24 * 60 * 60 * 1000, + httpOnly: true, + }); + res.send({ user: normalizedUser, accessToken }); +}; + +const login = async (req, res) => { + const { email, password } = req.body; + + const user = await authService.login(email, password); + + await sendAuthentication(res, user); +}; + +const refresh = async (req, res) => { + const { refreshToken } = req.cookies; + + const user = await authService.refresh(refreshToken); + + await sendAuthentication(res, user); +}; + +const logout = async (req, res) => { + const { refreshToken } = req.cookies; + + await authService.logout(refreshToken); + + res.clearCookie('refreshToken'); + res.send({ message: 'Logout successful' }); +}; + +const resetPasswordNotification = async (req, res) => { + const { email } = req.body; + + await authService.resetPasswordNotification(email); + + res.clearCookie('refreshToken'); + res.send({ message: 'Password reset email sent' }); +}; + +const confirmPasswordReset = async (req, res) => { + const { passwordResetToken } = req.params; + const { password, confirmation } = req.body; + + await authService.confirmPasswordReset( + passwordResetToken, + password, + confirmation, + ); + + res.clearCookie('refreshToken'); + res.send({ message: 'Password reset successful' }); +}; + +const confirmEmailChange = async (req, res) => { + const { pendingEmailToken } = req.params; + + await authService.confirmEmailChange(pendingEmailToken); + + res.clearCookie('refreshToken'); + res.send({ message: 'Email updated successfully' }); +}; + +export const authController = { + register, + activate, + login, + refresh, + logout, + resetPasswordNotification, + confirmPasswordReset, + confirmEmailChange, +}; diff --git a/src/controllers/user.controller.js b/src/controllers/user.controller.js new file mode 100644 index 00000000..0c31b285 --- /dev/null +++ b/src/controllers/user.controller.js @@ -0,0 +1,55 @@ +import { authService } from '../services/auth.service.js'; +import { userService } from '../services/user.service.js'; + +const getProfile = async (req, res) => { + const { refreshToken } = req.cookies; + const profile = await userService.getProfile(req.userId, refreshToken); + + res.send(profile); +}; + +const updateName = async (req, res) => { + const { name } = req.body; + const { refreshToken } = req.cookies; + + await authService.updateName(req.userId, name, refreshToken); + + res.send({ message: 'Name changed successfully' }); +}; + +const updatePassword = async (req, res) => { + const { password, newPassword, confirmation } = req.body; + const { refreshToken } = req.cookies; + + await authService.updatePassword( + refreshToken, + req.userId, + password, + newPassword, + confirmation, + ); + + res.clearCookie('refreshToken'); + res.send({ message: 'Password changed successfully' }); +}; + +const updateEmailNotification = async (req, res) => { + const { newEmail, password } = req.body; + const { refreshToken } = req.cookies; + + await authService.updateEmailNotification( + refreshToken, + req.userId, + newEmail, + password, + ); + + res.send({ message: 'Change email notification sent' }); +}; + +export const userController = { + getProfile, + updateName, + updatePassword, + updateEmailNotification, +}; diff --git a/src/index.js b/src/index.js index ad9a93a7..092d853f 100644 --- a/src/index.js +++ b/src/index.js @@ -1 +1,21 @@ 'use strict'; +import cors from 'cors'; +import express from 'express'; +import cookieParser from 'cookie-parser'; +import { authRouter } from './routes/auth.route.js'; +import { userRouter } from './routes/user.route.js'; +import { authMiddleware } from './middlewares/auth.middleware.js'; +import { errorMiddleware } from './middlewares/error.middleware.js'; + +const app = express(); +const PORT = process.env.PORT || 3005; + +app.use(cors()); + +app.use(express.json()); +app.use(cookieParser()); +app.use(authRouter); +app.use('/profile', authMiddleware, userRouter); +app.use(errorMiddleware); + +app.listen(PORT); diff --git a/src/middlewares/auth.middleware.js b/src/middlewares/auth.middleware.js new file mode 100644 index 00000000..240b9f53 --- /dev/null +++ b/src/middlewares/auth.middleware.js @@ -0,0 +1,31 @@ +import { jwtService } from '../services/jwt.service.js'; +import { userService } from '../services/user.service.js'; +import { ApiError } from '../utils/api.error.js'; +import { logger } from '../utils/logger.js'; + +export const authMiddleware = async (req, res, next) => { + const authHeader = req.headers.authorization ?? ''; + + if (!authHeader.startsWith('Bearer ')) { + logger.error('Authorization header missing or malformed'); + throw ApiError.unauthorized(); + } + + const token = authHeader.split(' ')[1]; + const userData = jwtService.verify(token); + + if (!userData) { + logger.error('Invalid or expired token'); + throw ApiError.unauthorized(); + } + + const user = await userService.getById(userData.id); + + if (!user) { + logger.error('User not found for token', { userId: userData.id }); + throw ApiError.notFound(); + } + + req.userId = user.id; + next(); +}; diff --git a/src/middlewares/catch-error.middleware.js b/src/middlewares/catch-error.middleware.js new file mode 100644 index 00000000..37ba73be --- /dev/null +++ b/src/middlewares/catch-error.middleware.js @@ -0,0 +1,9 @@ +export const catchError = (action) => { + return async (req, res, next) => { + try { + await action(req, res, next); + } catch (error) { + next(error); + } + }; +}; diff --git a/src/middlewares/check-login.middleware.js b/src/middlewares/check-login.middleware.js new file mode 100644 index 00000000..79052018 --- /dev/null +++ b/src/middlewares/check-login.middleware.js @@ -0,0 +1,14 @@ +import { ApiError } from '../utils/api.error.js'; +import { logger } from '../utils/logger.js'; + +export const checkNotLoggedIn = (req, res, next) => { + const { refreshToken } = req.cookies; + + if (refreshToken) { + logger.info('Already logged in user attempt', { + userId: req.userId, + }); + throw ApiError.forbidden(); + } + next(); +}; diff --git a/src/middlewares/error.middleware.js b/src/middlewares/error.middleware.js new file mode 100644 index 00000000..7d24e006 --- /dev/null +++ b/src/middlewares/error.middleware.js @@ -0,0 +1,13 @@ +import { ApiError } from '../utils/api.error.js'; + +export const errorMiddleware = (error, req, res, next) => { + if (error instanceof ApiError) { + return res + .status(error.status) + .send({ message: error.message, errors: error.errors }); + } + + res.status(500).send({ message: error.message || 'Internal Server Error' }); + + next(); +}; diff --git a/src/routes/auth.route.js b/src/routes/auth.route.js new file mode 100644 index 00000000..fb271950 --- /dev/null +++ b/src/routes/auth.route.js @@ -0,0 +1,39 @@ +import express from 'express'; +import { authController } from '../controllers/auth.controller.js'; +import { catchError } from '../middlewares/catch-error.middleware.js'; +import { authMiddleware } from '../middlewares/auth.middleware.js'; +import { checkNotLoggedIn } from '../middlewares/check-login.middleware.js'; + +export const authRouter = express.Router(); + +authRouter.post( + '/registration', + checkNotLoggedIn, + catchError(authController.register), +); + +authRouter.get( + '/activate/:activationToken', + checkNotLoggedIn, + catchError(authController.activate), +); +authRouter.post('/login', checkNotLoggedIn, catchError(authController.login)); +authRouter.get('/refresh', catchError(authController.refresh)); +authRouter.post('/logout', authMiddleware, catchError(authController.logout)); + +authRouter.post( + '/reset-password', + checkNotLoggedIn, + catchError(authController.resetPasswordNotification), +); + +authRouter.post( + '/reset-password/:passwordResetToken', + checkNotLoggedIn, + catchError(authController.confirmPasswordReset), +); + +authRouter.get( + '/activate-new-email/:pendingEmailToken', + catchError(authController.confirmEmailChange), +); diff --git a/src/routes/user.route.js b/src/routes/user.route.js new file mode 100644 index 00000000..df19908c --- /dev/null +++ b/src/routes/user.route.js @@ -0,0 +1,14 @@ +import express from 'express'; +import { userController } from '../controllers/user.controller.js'; +import { catchError } from '../middlewares/catch-error.middleware.js'; + +export const userRouter = express.Router(); + +userRouter.get('/', catchError(userController.getProfile)); +userRouter.patch('/', catchError(userController.updateName)); +userRouter.patch('/password', catchError(userController.updatePassword)); + +userRouter.post( + '/change-email', + catchError(userController.updateEmailNotification), +); diff --git a/src/services/auth.service.js b/src/services/auth.service.js new file mode 100644 index 00000000..8439ba07 --- /dev/null +++ b/src/services/auth.service.js @@ -0,0 +1,355 @@ +import { v4 as uuidv4 } from 'uuid'; +import bcrypt from 'bcrypt'; +import { jwtService } from './jwt.service.js'; +import { userService } from './user.service.js'; +import { emailService } from './email.service.js'; +import { tokenService } from './token.service.js'; +import { authValidation } from '../validations/auth.validation.js'; +import { ApiError } from '../utils/api.error.js'; +import { logger } from '../utils/logger.js'; + +const register = async (name, email, password) => { + const errors = { + name: authValidation.validateName(name), + email: authValidation.validateEmail(email), + password: authValidation.validatePassword(password), + }; + + if (Object.values(errors).some((error) => error)) { + throw ApiError.badRequest('Validation error', errors); + } + + const existingUser = await userService.getByEmail(email); + + if (existingUser) { + throw ApiError.badRequest('User with this email already exists', { + email: 'User with this email already exists', + }); + } + + const hashedPassword = await bcrypt.hash(password, 10); + const activationToken = uuidv4(); + + await userService.register(name, email, hashedPassword, activationToken); + await emailService.sendActivationEmail(name, email, activationToken); +}; + +const authenticate = async (user) => { + const normalizedUser = userService.normalize(user); + + const accessToken = jwtService.sign(normalizedUser); + const refreshToken = jwtService.signRefresh(normalizedUser); + + await tokenService.create(normalizedUser.id, refreshToken); + + return { + user: normalizedUser, + accessToken, + refreshToken, + }; +}; + +const activate = async (activationToken) => { + const user = await userService.getByActivationToken(activationToken); + + if (!user || user.activationToken !== activationToken) { + logger.warn('Invalid activation token attempt'); + throw ApiError.badRequest(); + } + + return await userService.activate(activationToken); +}; + +const login = async (email, password) => { + const user = await userService.getByEmail(email); + + if (!user) { + throw ApiError.badRequest('No such user'); + } + + const isPasswordValid = await bcrypt.compare(password, user.password); + + if (!isPasswordValid) { + throw ApiError.badRequest('Incorrect password'); + } + + if (user.activationToken) { + throw ApiError.forbidden('Account is not activated', { + activation: 'Account is not activated', + }); + } + + return user; +}; + +const logout = async (refreshToken) => { + if (!refreshToken) { + logger.warn('Logout attempt without refresh token'); + throw ApiError.unauthorized(); + } + + const userData = jwtService.verifyRefresh(refreshToken); + + if (!userData) { + throw ApiError.unauthorized(); + } + + await tokenService.removeByToken(refreshToken); +}; + +const refresh = async (refreshToken) => { + if (!refreshToken) { + logger.warn('Refresh attempt without refresh token'); + throw ApiError.unauthorized(); + } + + const userData = jwtService.verifyRefresh(refreshToken); + const user = await userService.getByEmail(userData?.email ?? ''); + const token = await tokenService.getByToken(refreshToken); + + if (!userData || !user || !token || token.userId !== user.id) { + logger.warn('Invalid refresh token attempt'); + throw ApiError.unauthorized(); + } + + return user; +}; + +const resetPasswordNotification = async (email) => { + const user = await userService.getByEmail(email); + + if (!user) { + logger.warn('Password reset email attempt for non-existent user', { + email, + }); + throw ApiError.badRequest(); + } + + if (user.passwordResetToken) { + logger.warn( + 'Password reset email attempt when there is already a pending reset', + { + email, + }, + ); + throw ApiError.badRequest( + 'There is already a pending password reset for this email', + ); + } + + const passwordResetToken = uuidv4(); + + await userService.setPasswordResetToken(user.id, passwordResetToken); + + await emailService.sendResetPasswordEmail( + user.name, + email, + passwordResetToken, + ); +}; + +const confirmPasswordReset = async ( + passwordResetToken, + password, + confirmation, +) => { + const user = await userService.getByPasswordResetToken(passwordResetToken); + + if (!user || user.passwordResetToken !== passwordResetToken) { + logger.warn('Invalid password reset token attempt'); + throw ApiError.badRequest(); + } + + if (!password || !confirmation) { + throw ApiError.badRequest('Password and confirmation fields are required'); + } + + if (password !== confirmation) { + throw ApiError.badRequest('Confirmation and password do not match'); + } + + const isNewEqualsOld = await bcrypt.compare(password, user.password); + + if (isNewEqualsOld) { + throw ApiError.badRequest('New password is the same as the old password'); + } + + const validationError = authValidation.validatePassword(password); + + if (validationError) { + throw ApiError.badRequest('Validation error', { + password: validationError, + }); + } + + const hashedPassword = await bcrypt.hash(password, 10); + + await userService.updatePassword(user.id, hashedPassword); + await userService.clearPasswordResetToken(user.id); + await tokenService.invalidateSessions(user.id); +}; + +const updateName = async (id, name, refreshToken) => { + if (!refreshToken) { + logger.warn('Update name attempt without refresh token', { userId: id }); + throw ApiError.unauthorized(); + } + + const user = await userService.getById(id); + + if (!user) { + throw ApiError.notFound('User not found'); + } + + const validationError = authValidation.validateName(name); + + if (validationError) { + throw ApiError.badRequest('Validation error', { + name: validationError, + }); + } + + if (user.name === name) { + throw ApiError.badRequest( + 'New name must be different from the current name', + ); + } + + await userService.updateName(id, name); +}; + +const updateEmailNotification = async ( + refreshToken, + userId, + newEmail, + password, +) => { + if (!refreshToken) { + logger.warn('Update email attempt without refresh token', { userId }); + throw ApiError.unauthorized(); + } + + const validationError = authValidation.validateEmail(newEmail); + + if (validationError) { + throw ApiError.badRequest('Validation error', { + newEmail: validationError, + }); + } + + const existingUser = await userService.getByEmail(newEmail); + + if (existingUser) { + throw ApiError.badRequest('Email already in use'); + } + + const user = await userService.getById(userId); + + if (!user) { + throw ApiError.notFound('User not found'); + } + + const isPasswordValid = await bcrypt.compare(password, user.password); + + if (!isPasswordValid) { + throw ApiError.badRequest('Incorrect password'); + } + + if (user.pendingEmail) { + throw ApiError.badRequest('There is already a pending email change'); + } + + const hasPendingEmail = await userService.getByPendingEmail(newEmail); + + if (hasPendingEmail) { + throw ApiError.badRequest('This email is already pending for another user'); + } + + const pendingEmailToken = uuidv4(); + + await userService.setPendingEmail(userId, newEmail, pendingEmailToken); + + await emailService.sendActivationNewEmail( + user.name, + newEmail, + pendingEmailToken, + ); +}; + +const confirmEmailChange = async (pendingEmailToken) => { + const user = await userService.getByPendingEmailToken(pendingEmailToken); + + if (!user || user.pendingEmailToken !== pendingEmailToken) { + logger.warn('Invalid email change activation attempt'); + throw ApiError.badRequest(); + } + + await userService.updateEmail(user.id, user.pendingEmail); + await emailService.sendChangeEmailNotification(user.name, user.email); + await tokenService.invalidateSessions(user.id); +}; + +const updatePassword = async ( + refreshToken, + userId, + password, + newPassword, + confirmation, +) => { + if (!refreshToken) { + logger.warn('Update password attempt without refresh token', { userId }); + throw ApiError.unauthorized(); + } + + const user = await userService.getById(userId); + + if (!password || !newPassword || !confirmation) { + throw ApiError.badRequest( + 'Old password, new password, and confirmation fields are required', + ); + } + + const isOldPasswordValid = await bcrypt.compare(password, user.password); + + if (!isOldPasswordValid) { + throw ApiError.badRequest('Incorrect old password'); + } + + const validationError = authValidation.validatePassword(newPassword); + + if (validationError) { + throw ApiError.badRequest('Validation error', { + newPassword: validationError, + }); + } + + if (password === newPassword) { + throw ApiError.badRequest( + 'New password must be different from the old password', + ); + } + + if (newPassword !== confirmation) { + throw ApiError.badRequest('Confirmation and password do not match'); + } + + const hashedPassword = await bcrypt.hash(newPassword, 10); + + await userService.updatePassword(userId, hashedPassword); + await tokenService.invalidateSessions(userId); +}; + +export const authService = { + register, + authenticate, + activate, + login, + logout, + refresh, + resetPasswordNotification, + confirmPasswordReset, + updateName, + updateEmailNotification, + confirmEmailChange, + updatePassword, +}; diff --git a/src/services/email.service.js b/src/services/email.service.js new file mode 100644 index 00000000..540e28c8 --- /dev/null +++ b/src/services/email.service.js @@ -0,0 +1,73 @@ +import nodemailer from 'nodemailer'; +import 'dotenv/config'; + +const transporter = nodemailer.createTransport({ + host: process.env.SMTP_HOST, + port: process.env.SMTP_PORT, + auth: { + user: process.env.SMTP_USER, + pass: process.env.SMTP_PASSWORD, + }, +}); + +const send = ({ email, subject, html }) => { + return transporter.sendMail({ + to: email, + subject, + html, + }); +}; + +const sendActivationEmail = (name, email, token) => { + const activationLink = `${process.env.CLIENT_HOST}/activate/${token}`; + const subject = 'Account Activation'; + const html = ` +
Please click the link below to activate your account:
+ ${activationLink} + `; + + return send({ email, subject, html }); +}; + +const sendResetPasswordEmail = (name, email, token) => { + const resetLink = `${process.env.CLIENT_HOST}/reset-password/${token}`; + const subject = 'Password Reset'; + const html = ` +Please click the link below to reset your password:
+ ${resetLink} + `; + + return send({ email, subject, html }); +}; + +const sendChangeEmailNotification = (name, email) => { + const subject = 'Email Change Notification'; + const html = ` +Your email has been successfully changed.
+ `; + + return send({ email, subject, html }); +}; + +const sendActivationNewEmail = (name, email, token) => { + const activationLink = `${process.env.CLIENT_HOST}/activate-new-email/${token}`; + const subject = 'New Email Activation'; + const html = ` +Please click the link below to activate your new email:
+ ${activationLink} + `; + + return send({ email, subject, html }); +}; + +export const emailService = { + send, + sendActivationEmail, + sendResetPasswordEmail, + sendChangeEmailNotification, + sendActivationNewEmail, +}; diff --git a/src/services/jwt.service.js b/src/services/jwt.service.js new file mode 100644 index 00000000..9db77aab --- /dev/null +++ b/src/services/jwt.service.js @@ -0,0 +1,32 @@ +import jwt from 'jsonwebtoken'; + +const sign = (user) => { + return jwt.sign(user, process.env.JWT_SECRET, { expiresIn: '10m' }); +}; + +const verify = (token) => { + try { + return jwt.verify(token, process.env.JWT_SECRET); + } catch (err) { + return null; + } +}; + +const signRefresh = (user) => { + return jwt.sign(user, process.env.JWT_REFRESH_SECRET, { expiresIn: '7d' }); +}; + +const verifyRefresh = (token) => { + try { + return jwt.verify(token, process.env.JWT_REFRESH_SECRET); + } catch (err) { + return null; + } +}; + +export const jwtService = { + sign, + verify, + signRefresh, + verifyRefresh, +}; diff --git a/src/services/token.service.js b/src/services/token.service.js new file mode 100644 index 00000000..08cd54a3 --- /dev/null +++ b/src/services/token.service.js @@ -0,0 +1,32 @@ +import { db } from '../utils/db.js'; + +const create = async (userId, newToken) => { + await db.token.upsert({ + where: { userId }, + update: { refreshToken: newToken }, + create: { userId, refreshToken: newToken }, + }); +}; + +const getByToken = async (refreshToken) => { + return db.token.findUnique({ + where: { refreshToken }, + }); +}; + +const removeByToken = async (refreshToken) => { + await db.token.delete({ + where: { refreshToken }, + }); +}; + +const invalidateSessions = async (userId) => { + return db.token.deleteMany({ where: { userId } }); +}; + +export const tokenService = { + create, + getByToken, + removeByToken, + invalidateSessions, +}; diff --git a/src/services/user.service.js b/src/services/user.service.js new file mode 100644 index 00000000..24d73b5b --- /dev/null +++ b/src/services/user.service.js @@ -0,0 +1,141 @@ +import { ApiError } from '../utils/api.error.js'; +import { db } from '../utils/db.js'; +import { logger } from '../utils/logger.js'; + +const normalize = ({ id, name, email }) => { + return { + id, + name, + email, + }; +}; + +const register = async (name, email, password, activationToken) => { + await db.user.create({ + data: { + name, + email, + password, + activationToken, + }, + }); +}; + +const activate = async (activationToken) => { + return await db.user.update({ + data: { activationToken: null }, + where: { activationToken }, + }); +}; + +const getById = async (id) => { + return db.user.findUnique({ where: { id } }); +}; + +const getByEmail = async (email) => { + return db.user.findUnique({ where: { email } }); +}; + +const getProfile = async (userId, refreshToken) => { + if (!refreshToken) { + logger.warn('Get profile attempt without refresh token', { userId }); + throw ApiError.unauthorized(); + } + + const user = await getById(userId); + + if (!user) { + throw ApiError.notFound('User not found'); + } + + return normalize(user); +}; + +const getByActivationToken = async (activationToken) => { + return db.user.findUnique({ + where: { activationToken }, + }); +}; + +const getByPasswordResetToken = async (passwordResetToken) => { + return db.user.findUnique({ + where: { passwordResetToken }, + }); +}; + +const updatePassword = async (id, newPassword) => { + await db.user.update({ + data: { password: newPassword }, + where: { id }, + }); +}; + +const setPasswordResetToken = async (id, passwordResetToken) => { + await db.user.update({ + data: { passwordResetToken }, + where: { id }, + }); +}; + +const clearPasswordResetToken = async (id) => { + await db.user.update({ + data: { passwordResetToken: null }, + where: { id }, + }); +}; + +const updateName = async (id, name) => { + await db.user.update({ + data: { name }, + where: { id }, + }); +}; + +const setPendingEmail = async (id, newEmail, pendingEmailToken) => { + await db.user.update({ + data: { pendingEmail: newEmail, pendingEmailToken }, + where: { id }, + }); +}; + +const updateEmail = async (id, pendingEmail) => { + await db.user.update({ + data: { + email: pendingEmail, + pendingEmail: null, + pendingEmailToken: null, + }, + where: { id }, + }); +}; + +const getByPendingEmailToken = async (pendingEmailToken) => { + return db.user.findUnique({ + where: { pendingEmailToken }, + }); +}; + +const getByPendingEmail = async (pendingEmail) => { + return db.user.findUnique({ + where: { pendingEmail }, + }); +}; + +export const userService = { + normalize, + register, + activate, + getById, + getByEmail, + getProfile, + getByActivationToken, + getByPasswordResetToken, + updatePassword, + setPasswordResetToken, + clearPasswordResetToken, + setPendingEmail, + updateName, + updateEmail, + getByPendingEmailToken, + getByPendingEmail, +}; diff --git a/src/utils/api.error.js b/src/utils/api.error.js new file mode 100644 index 00000000..02535b1c --- /dev/null +++ b/src/utils/api.error.js @@ -0,0 +1,27 @@ +export class ApiError extends Error { + constructor({ message, status, errors = {} }) { + super(message); + this.status = status; + this.errors = errors; + } + + static badRequest(message = 'Bad Request', errors = {}) { + return new ApiError({ message, status: 400, errors }); + } + + static unauthorized() { + return new ApiError({ message: 'Unauthorized', status: 401 }); + } + + static forbidden() { + return new ApiError({ message: 'Forbidden', status: 403 }); + } + + static notFound() { + return new ApiError({ message: 'Not Found', status: 404 }); + } + + static internal() { + return new ApiError({ message: 'Internal Server Error', status: 500 }); + } +} diff --git a/src/utils/db.js b/src/utils/db.js new file mode 100644 index 00000000..5fcad9a0 --- /dev/null +++ b/src/utils/db.js @@ -0,0 +1,9 @@ +import 'dotenv/config'; +import { PrismaPg } from '@prisma/adapter-pg'; +import { PrismaClient } from '@prisma/client'; + +const connectionString = `${process.env.DATABASE_URL}`; + +const adapter = new PrismaPg({ connectionString }); + +export const db = new PrismaClient({ adapter }); diff --git a/src/utils/logger.js b/src/utils/logger.js new file mode 100644 index 00000000..abda7ec3 --- /dev/null +++ b/src/utils/logger.js @@ -0,0 +1,8 @@ +export const logger = { + info: (msg, meta = {}) => + console.log(JSON.stringify({ level: 'info', msg, ...meta })), + warn: (msg, meta = {}) => + console.warn(JSON.stringify({ level: 'warn', msg, ...meta })), + error: (msg, meta = {}) => + console.error(JSON.stringify({ level: 'error', msg, ...meta })), +}; diff --git a/src/validations/auth.validation.js b/src/validations/auth.validation.js new file mode 100644 index 00000000..5029cf54 --- /dev/null +++ b/src/validations/auth.validation.js @@ -0,0 +1,33 @@ +const validateName = (value) => { + if (!value) { + return 'Name is required'; + } +}; + +const validateEmail = (value) => { + const EMAIL_PATTERN = /^[\w.+-]+@([\w-]+\.){1,3}[\w-]{2,}$/; + + if (!value) { + return 'Email is required'; + } + + if (!EMAIL_PATTERN.test(value)) { + return 'Email is not valid'; + } +}; + +const validatePassword = (value) => { + if (!value) { + return 'Password is required'; + } + + if (value.length < 6) { + return 'At least 6 characters'; + } +}; + +export const authValidation = { + validateName, + validateEmail, + validatePassword, +};